Compare commits
6 Commits
v0.12.3-be
...
c77c0bc994
| Author | SHA1 | Date | |
|---|---|---|---|
| c77c0bc994 | |||
| 6035df262d | |||
| 809e00ce49 | |||
| bc0cbb0f7b | |||
| 6740ed2177 | |||
| 8cc90bd24d |
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
39
brain/app/application/import_status.py
Normal file
39
brain/app/application/import_status.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -34,23 +34,59 @@ Règles :
|
|||||||
|
|
||||||
PROPOSITIONS D'INTÉGRATION (IMPORTANT) :
|
PROPOSITIONS D'INTÉGRATION (IMPORTANT) :
|
||||||
Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ,
|
Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ,
|
||||||
une scène, un chapitre, un arc, une table aléatoire), termine ta réponse par un ou
|
une scène, un chapitre, une quête, un arc, une table aléatoire), termine ta réponse par
|
||||||
plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture ```loremind-action.
|
un ou plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture
|
||||||
L'interface les transformera en boutons « Créer dans la campagne ». N'en mets que si
|
```loremind-action. L'interface les transformera en boutons « Créer dans la campagne ».
|
||||||
c'est pertinent et explicitement souhaité. Formats acceptés :
|
Si l'utilisateur demande PLUSIEURS éléments (« propose-moi 3 quêtes »), produis UN bloc
|
||||||
|
par élément. N'en mets pas si l'utilisateur pose une simple question.
|
||||||
|
|
||||||
|
VOCABULAIRE DE LA CAMPAGNE : une « quête » n'est PAS un type à part — c'est un CHAPITRE
|
||||||
|
rangé dans un arc de type HUB (quêtes parallèles, sans ordre imposé), tandis qu'un arc
|
||||||
|
LINEAR contient des chapitres joués en séquence. Donc :
|
||||||
|
- demande de QUÊTE → action "chapter" (l'utilisateur la placera dans son arc HUB) ;
|
||||||
|
s'il n'a aucun arc HUB dans sa campagne, propose AUSSI une action "arc" avec
|
||||||
|
"arcType": "HUB" pour les accueillir.
|
||||||
|
- demande de CHAPITRE → action "chapter" (destinée plutôt à un arc LINEAR).
|
||||||
|
|
||||||
|
RÈGLE CLÉ : remplis TOUS les champs pour lesquels tu as de la matière — pas seulement
|
||||||
|
le résumé ou les notes MJ. Chaque champ rempli atterrit au bon endroit de la fiche ;
|
||||||
|
un champ laissé vide est une fiche que l'utilisateur devra compléter à la main. Vise
|
||||||
|
2 à 5 phrases concrètes par champ narratif, tirées de la source et de la campagne.
|
||||||
|
Omets simplement un champ si tu n'as rien de précis à y mettre. Formats acceptés :
|
||||||
|
|
||||||
```loremind-action
|
```loremind-action
|
||||||
{{"type": "npc", "name": "Nom", "description": "Fiche en quelques phrases."}}
|
{{"type": "npc", "name": "Nom",
|
||||||
|
"description": "Résumé du PNJ (rôle, apparence, motivation).",
|
||||||
|
"values": {{"<champ de la fiche PNJ>": "contenu", "<autre champ>": "contenu"}}}}
|
||||||
|
```
|
||||||
|
(`values` : utilise comme clés les CHAMPS DE LA FICHE PNJ listés dans le contexte
|
||||||
|
campagne s'ils y figurent — ex. "Histoire", "Apparence" — sinon omets `values`.)
|
||||||
|
|
||||||
|
```loremind-action
|
||||||
|
{{"type": "scene", "name": "Nom",
|
||||||
|
"description": "Résumé court de la scène.",
|
||||||
|
"location": "Lieu précis", "timing": "Quand elle survient",
|
||||||
|
"atmosphere": "Ambiance sensorielle (sons, odeurs, lumière…)",
|
||||||
|
"playerNarration": "Texte d'ambiance À LIRE AUX JOUEURS, immersif, à la 2e personne.",
|
||||||
|
"gmSecretNotes": "Secrets, vérités cachées, notes pour le MJ uniquement.",
|
||||||
|
"choicesConsequences": "Choix offerts aux joueurs et leurs conséquences.",
|
||||||
|
"combatDifficulty": "Difficulté du combat éventuel", "enemies": "Ennemis présents (effectifs, tactiques)"}}
|
||||||
```
|
```
|
||||||
```loremind-action
|
```loremind-action
|
||||||
{{"type": "scene", "name": "Nom", "description": "Résumé", "content": "Déroulé détaillé."}}
|
{{"type": "chapter", "name": "Nom",
|
||||||
|
"description": "Résumé du chapitre (ou de la quête).",
|
||||||
|
"playerObjectives": "Objectifs tels que les joueurs les perçoivent.",
|
||||||
|
"narrativeStakes": "Enjeux narratifs (ce qui se joue vraiment).",
|
||||||
|
"gmNotes": "Notes MJ : fils à tirer, points d'attention."}}
|
||||||
```
|
```
|
||||||
```loremind-action
|
```loremind-action
|
||||||
{{"type": "chapter", "name": "Nom", "description": "Résumé du chapitre."}}
|
{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR",
|
||||||
```
|
"themes": "Thèmes de l'arc", "stakes": "Enjeux",
|
||||||
```loremind-action
|
"rewards": "Récompenses attendues", "resolution": "Issues possibles",
|
||||||
{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR"}}
|
"gmNotes": "Notes MJ."}}
|
||||||
```
|
```
|
||||||
|
(`arcType` : "LINEAR" pour des chapitres en séquence, "HUB" pour un recueil de
|
||||||
|
quêtes parallèles.)
|
||||||
```loremind-action
|
```loremind-action
|
||||||
{{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}}
|
{{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 "")
|
||||||
|
|||||||
@@ -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.3-beta",
|
version="0.13.0-beta",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.12.3-beta</version>
|
<version>0.13.0-beta</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -42,15 +42,17 @@ public class CampaignBriefBuilder {
|
|||||||
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
|
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
|
||||||
|
|
||||||
sb.append("\n## Structure (arcs → chapitres → scènes)\n");
|
sb.append("\n## Structure (arcs → chapitres → scènes)\n");
|
||||||
|
sb.append("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ")
|
||||||
|
.append("un arc LINEAR contient des chapitres en séquence._\n");
|
||||||
if (cc.arcs().isEmpty()) {
|
if (cc.arcs().isEmpty()) {
|
||||||
sb.append("_(aucun arc pour le moment)_\n");
|
sb.append("_(aucun arc pour le moment)_\n");
|
||||||
}
|
}
|
||||||
for (ArcSummary arc : cc.arcs()) {
|
for (ArcSummary arc : cc.arcs()) {
|
||||||
sb.append("### Arc : ").append(arc.name());
|
sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name());
|
||||||
if (notBlank(arc.description())) sb.append(" — ").append(arc.description());
|
if (notBlank(arc.description())) sb.append(" — ").append(arc.description());
|
||||||
sb.append("\n");
|
sb.append("\n");
|
||||||
for (ChapterSummary ch : arc.chapters()) {
|
for (ChapterSummary ch : arc.chapters()) {
|
||||||
sb.append("- Chapitre : ").append(ch.name());
|
sb.append(arc.hub() ? "- Quête : " : "- Chapitre : ").append(ch.name());
|
||||||
if (notBlank(ch.description())) sb.append(" — ").append(ch.description());
|
if (notBlank(ch.description())) sb.append(" — ").append(ch.description());
|
||||||
sb.append("\n");
|
sb.append("\n");
|
||||||
for (SceneSummary sc : ch.scenes()) {
|
for (SceneSummary sc : ch.scenes()) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ public class CharacterService {
|
|||||||
characterRepository.deleteById(id);
|
characterRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Character> searchCharacters(String query) {
|
||||||
|
if (query == null || query.isBlank()) return List.of();
|
||||||
|
return characterRepository.searchByName(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
private int nextOrderFor(String playthroughId) {
|
private int nextOrderFor(String playthroughId) {
|
||||||
return characterRepository.findByPlaythroughId(playthroughId).stream()
|
return characterRepository.findByPlaythroughId(playthroughId).stream()
|
||||||
.mapToInt(Character::getOrder)
|
.mapToInt(Character::getOrder)
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Enemy;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service d'application pour les fiches d'ennemis (bestiaire de campagne).
|
||||||
|
* Miroir de {@link NpcService} : fiche pilotée par le template ENNEMI du GameSystem.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class EnemyService {
|
||||||
|
|
||||||
|
private final EnemyRepository enemyRepository;
|
||||||
|
|
||||||
|
public EnemyService(EnemyRepository enemyRepository) {
|
||||||
|
this.enemyRepository = enemyRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record EnemyData(
|
||||||
|
String name,
|
||||||
|
String level,
|
||||||
|
String folder,
|
||||||
|
String portraitImageId,
|
||||||
|
String headerImageId,
|
||||||
|
Map<String, String> values,
|
||||||
|
Map<String, List<String>> imageValues,
|
||||||
|
Map<String, Map<String, String>> keyValueValues,
|
||||||
|
String campaignId,
|
||||||
|
Integer order
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public Enemy createEnemy(EnemyData data) {
|
||||||
|
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
|
||||||
|
Enemy enemy = Enemy.builder()
|
||||||
|
.name(data.name())
|
||||||
|
.level(normalize(data.level()))
|
||||||
|
.folder(normalize(data.folder()))
|
||||||
|
.portraitImageId(data.portraitImageId())
|
||||||
|
.headerImageId(data.headerImageId())
|
||||||
|
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||||
|
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
||||||
|
.campaignId(data.campaignId())
|
||||||
|
.order(order)
|
||||||
|
.build();
|
||||||
|
return enemyRepository.save(enemy);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Enemy> getEnemyById(String id) {
|
||||||
|
return enemyRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Enemy> getEnemiesByCampaignId(String campaignId) {
|
||||||
|
return enemyRepository.findByCampaignId(campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Enemy updateEnemy(String id, EnemyData data) {
|
||||||
|
Enemy existing = enemyRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
|
||||||
|
existing.setName(data.name());
|
||||||
|
existing.setLevel(normalize(data.level()));
|
||||||
|
existing.setFolder(normalize(data.folder()));
|
||||||
|
existing.setPortraitImageId(data.portraitImageId());
|
||||||
|
existing.setHeaderImageId(data.headerImageId());
|
||||||
|
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||||
|
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||||
|
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
||||||
|
if (data.order() != null) {
|
||||||
|
existing.setOrder(data.order());
|
||||||
|
}
|
||||||
|
return enemyRepository.save(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteEnemy(String id) {
|
||||||
|
enemyRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Enemy> searchEnemies(String query) {
|
||||||
|
if (query == null || query.isBlank()) return List.of();
|
||||||
|
return enemyRepository.searchByName(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trim ; chaîne vide → null (= non renseigné / non classé). */
|
||||||
|
private static String normalize(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int nextOrderFor(String campaignId) {
|
||||||
|
return enemyRepository.findByCampaignId(campaignId).stream()
|
||||||
|
.mapToInt(Enemy::getOrder)
|
||||||
|
.max()
|
||||||
|
.orElse(-1) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,11 @@ public class ItemCatalogService {
|
|||||||
repository.deleteById(id);
|
repository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<ItemCatalog> searchCatalogs(String query) {
|
||||||
|
if (query == null || query.isBlank()) return List.of();
|
||||||
|
return repository.searchByName(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
/** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
|
/** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
|
||||||
public ItemCatalog generateProposal(String campaignId, String description) {
|
public ItemCatalog generateProposal(String campaignId, String description) {
|
||||||
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId));
|
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId));
|
||||||
|
|||||||
@@ -22,16 +22,19 @@ public class NotebookService {
|
|||||||
private final NotebookIndexer indexer;
|
private final NotebookIndexer indexer;
|
||||||
private final CampaignRepository campaignRepository;
|
private final CampaignRepository campaignRepository;
|
||||||
private final CampaignBriefBuilder briefBuilder;
|
private final CampaignBriefBuilder briefBuilder;
|
||||||
|
private final com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository;
|
||||||
|
|
||||||
public NotebookService(
|
public NotebookService(
|
||||||
NotebookRepository repository,
|
NotebookRepository repository,
|
||||||
NotebookIndexer indexer,
|
NotebookIndexer indexer,
|
||||||
CampaignRepository campaignRepository,
|
CampaignRepository campaignRepository,
|
||||||
CampaignBriefBuilder briefBuilder) {
|
CampaignBriefBuilder briefBuilder,
|
||||||
|
com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.indexer = indexer;
|
this.indexer = indexer;
|
||||||
this.campaignRepository = campaignRepository;
|
this.campaignRepository = campaignRepository;
|
||||||
this.briefBuilder = briefBuilder;
|
this.briefBuilder = briefBuilder;
|
||||||
|
this.gameSystemRepository = gameSystemRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Notebooks ---
|
// --- Notebooks ---
|
||||||
@@ -119,6 +122,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) :
|
||||||
@@ -127,6 +182,25 @@ public class NotebookService {
|
|||||||
if (campaignId == null) return "";
|
if (campaignId == null) return "";
|
||||||
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
|
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
|
||||||
if (campaign == null) return "";
|
if (campaign == null) return "";
|
||||||
return briefBuilder.build(campaign);
|
String brief = briefBuilder.build(campaign);
|
||||||
|
// Champs TEXT de la fiche PNJ du système de jeu : permet à l'IA de remplir
|
||||||
|
// `values` des actions "npc" avec les BONS noms de champs (Histoire,
|
||||||
|
// Apparence…) au lieu de tout entasser dans une description générique.
|
||||||
|
String npcFields = npcSheetFields(campaign.getGameSystemId());
|
||||||
|
return npcFields.isEmpty() ? brief : brief + "\n\n" + npcFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String npcSheetFields(String gameSystemId) {
|
||||||
|
if (gameSystemId == null || gameSystemId.isBlank()) return "";
|
||||||
|
var gameSystem = gameSystemRepository.findById(gameSystemId).orElse(null);
|
||||||
|
if (gameSystem == null || gameSystem.getNpcTemplate() == null) return "";
|
||||||
|
var names = gameSystem.getNpcTemplate().stream()
|
||||||
|
.filter(f -> f.getType() == com.loremind.domain.shared.template.FieldType.TEXT)
|
||||||
|
.map(com.loremind.domain.shared.template.TemplateField::getName)
|
||||||
|
.filter(n -> n != null && !n.isBlank())
|
||||||
|
.toList();
|
||||||
|
if (names.isEmpty()) return "";
|
||||||
|
return "FICHE PNJ — champs texte disponibles (clés à utiliser dans `values` "
|
||||||
|
+ "d'une action npc) : " + String.join(", ", names);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ public class NpcService {
|
|||||||
npcRepository.deleteById(id);
|
npcRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Npc> searchNpcs(String query) {
|
||||||
|
if (query == null || query.isBlank()) return List.of();
|
||||||
|
return npcRepository.searchByName(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
/** Trim le dossier ; chaîne vide → null (= non classé). */
|
/** Trim le dossier ; chaîne vide → null (= non classé). */
|
||||||
private static String normalizeFolder(String folder) {
|
private static String normalizeFolder(String folder) {
|
||||||
if (folder == null) return null;
|
if (folder == null) return null;
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ public class RandomTableService {
|
|||||||
repository.deleteById(id);
|
repository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<RandomTable> searchTables(String query) {
|
||||||
|
if (query == null || query.isBlank()) return List.of();
|
||||||
|
return repository.searchByName(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
/** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */
|
/** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */
|
||||||
public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
|
public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
|
||||||
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;
|
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +57,7 @@ public class GameSystemService {
|
|||||||
String rulesMarkdown,
|
String rulesMarkdown,
|
||||||
List<TemplateField> characterTemplate,
|
List<TemplateField> characterTemplate,
|
||||||
List<TemplateField> npcTemplate,
|
List<TemplateField> npcTemplate,
|
||||||
|
List<TemplateField> enemyTemplate,
|
||||||
String author,
|
String author,
|
||||||
boolean isPublic
|
boolean isPublic
|
||||||
) {}
|
) {}
|
||||||
@@ -70,6 +72,7 @@ public class GameSystemService {
|
|||||||
.build();
|
.build();
|
||||||
gameSystem.replaceCharacterTemplate(data.characterTemplate());
|
gameSystem.replaceCharacterTemplate(data.characterTemplate());
|
||||||
gameSystem.replaceNpcTemplate(data.npcTemplate());
|
gameSystem.replaceNpcTemplate(data.npcTemplate());
|
||||||
|
gameSystem.replaceEnemyTemplate(data.enemyTemplate());
|
||||||
return gameSystemRepository.save(gameSystem);
|
return gameSystemRepository.save(gameSystem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +92,7 @@ public class GameSystemService {
|
|||||||
existing.setRulesMarkdown(data.rulesMarkdown());
|
existing.setRulesMarkdown(data.rulesMarkdown());
|
||||||
existing.replaceCharacterTemplate(data.characterTemplate());
|
existing.replaceCharacterTemplate(data.characterTemplate());
|
||||||
existing.replaceNpcTemplate(data.npcTemplate());
|
existing.replaceNpcTemplate(data.npcTemplate());
|
||||||
|
existing.replaceEnemyTemplate(data.enemyTemplate());
|
||||||
existing.setAuthor(normalize(data.author()));
|
existing.setAuthor(normalize(data.author()));
|
||||||
existing.setPublic(data.isPublic());
|
existing.setPublic(data.isPublic());
|
||||||
return gameSystemRepository.save(existing);
|
return gameSystemRepository.save(existing);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.loremind.application.generationcontext;
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
|
import com.loremind.domain.campaigncontext.ArcType;
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.Character;
|
import com.loremind.domain.campaigncontext.Character;
|
||||||
@@ -10,6 +11,7 @@ import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
|||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||||
@@ -48,6 +50,7 @@ public class CampaignStructuralContextBuilder {
|
|||||||
private final SceneRepository sceneRepository;
|
private final SceneRepository sceneRepository;
|
||||||
private final CharacterRepository characterRepository;
|
private final CharacterRepository characterRepository;
|
||||||
private final NpcRepository npcRepository;
|
private final NpcRepository npcRepository;
|
||||||
|
private final EnemyRepository enemyRepository;
|
||||||
|
|
||||||
public CampaignStructuralContextBuilder(
|
public CampaignStructuralContextBuilder(
|
||||||
CampaignRepository campaignRepository,
|
CampaignRepository campaignRepository,
|
||||||
@@ -55,13 +58,15 @@ public class CampaignStructuralContextBuilder {
|
|||||||
ChapterRepository chapterRepository,
|
ChapterRepository chapterRepository,
|
||||||
SceneRepository sceneRepository,
|
SceneRepository sceneRepository,
|
||||||
CharacterRepository characterRepository,
|
CharacterRepository characterRepository,
|
||||||
NpcRepository npcRepository) {
|
NpcRepository npcRepository,
|
||||||
|
EnemyRepository enemyRepository) {
|
||||||
this.campaignRepository = campaignRepository;
|
this.campaignRepository = campaignRepository;
|
||||||
this.arcRepository = arcRepository;
|
this.arcRepository = arcRepository;
|
||||||
this.chapterRepository = chapterRepository;
|
this.chapterRepository = chapterRepository;
|
||||||
this.sceneRepository = sceneRepository;
|
this.sceneRepository = sceneRepository;
|
||||||
this.characterRepository = characterRepository;
|
this.characterRepository = characterRepository;
|
||||||
this.npcRepository = npcRepository;
|
this.npcRepository = npcRepository;
|
||||||
|
this.enemyRepository = enemyRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */
|
/** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */
|
||||||
@@ -84,9 +89,17 @@ public class CampaignStructuralContextBuilder {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException(
|
.orElseThrow(() -> new IllegalArgumentException(
|
||||||
"Campagne non trouvée avec l'ID: " + campaignId));
|
"Campagne non trouvée avec l'ID: " + campaignId));
|
||||||
|
|
||||||
|
// Libellés du bestiaire (« Nom (niveau) ») chargés UNE fois pour résoudre
|
||||||
|
// les enemyIds des pièces sans N+1 sur le repo.
|
||||||
|
Map<String, String> enemyLabelById = enemyRepository.findByCampaignId(campaignId).stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
com.loremind.domain.campaigncontext.Enemy::getId,
|
||||||
|
CampaignStructuralContextBuilder::enemyLabel,
|
||||||
|
(a, b) -> a));
|
||||||
|
|
||||||
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
|
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
|
||||||
.sorted(Comparator.comparingInt(Arc::getOrder))
|
.sorted(Comparator.comparingInt(Arc::getOrder))
|
||||||
.map(this::toArcSummary)
|
.map(arc -> toArcSummary(arc, enemyLabelById))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
|
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
|
||||||
@@ -143,19 +156,20 @@ public class CampaignStructuralContextBuilder {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private ArcSummary toArcSummary(Arc arc) {
|
private ArcSummary toArcSummary(Arc arc, Map<String, String> enemyLabelById) {
|
||||||
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
|
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
|
||||||
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
||||||
.map(this::toChapterSummary)
|
.map(chapter -> toChapterSummary(chapter, enemyLabelById))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
return new ArcSummary(
|
return new ArcSummary(
|
||||||
arc.getName(),
|
arc.getName(),
|
||||||
arc.getDescription(),
|
arc.getDescription(),
|
||||||
|
arc.getType() == ArcType.HUB,
|
||||||
countImages(arc.getIllustrationImageIds()),
|
countImages(arc.getIllustrationImageIds()),
|
||||||
chapters);
|
chapters);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ChapterSummary toChapterSummary(Chapter chapter) {
|
private ChapterSummary toChapterSummary(Chapter chapter, Map<String, String> enemyLabelById) {
|
||||||
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()).stream()
|
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()).stream()
|
||||||
.sorted(Comparator.comparingInt(Scene::getOrder))
|
.sorted(Comparator.comparingInt(Scene::getOrder))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -166,7 +180,7 @@ public class CampaignStructuralContextBuilder {
|
|||||||
.collect(Collectors.toMap(Scene::getId, Scene::getName));
|
.collect(Collectors.toMap(Scene::getId, Scene::getName));
|
||||||
|
|
||||||
List<SceneSummary> summaries = scenes.stream()
|
List<SceneSummary> summaries = scenes.stream()
|
||||||
.map(s -> toSceneSummary(s, nameById))
|
.map(s -> toSceneSummary(s, nameById, enemyLabelById))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
return new ChapterSummary(
|
return new ChapterSummary(
|
||||||
@@ -176,7 +190,8 @@ public class CampaignStructuralContextBuilder {
|
|||||||
summaries);
|
summaries);
|
||||||
}
|
}
|
||||||
|
|
||||||
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
|
private SceneSummary toSceneSummary(
|
||||||
|
Scene scene, Map<String, String> nameById, Map<String, String> enemyLabelById) {
|
||||||
List<BranchHint> hints = scene.getBranches() == null
|
List<BranchHint> hints = scene.getBranches() == null
|
||||||
? List.of()
|
? List.of()
|
||||||
: scene.getBranches().stream()
|
: scene.getBranches().stream()
|
||||||
@@ -186,7 +201,7 @@ public class CampaignStructuralContextBuilder {
|
|||||||
b.condition()))
|
b.condition()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
List<RoomSummary> rooms = toRoomSummaries(scene);
|
List<RoomSummary> rooms = toRoomSummaries(scene, enemyLabelById);
|
||||||
|
|
||||||
return new SceneSummary(
|
return new SceneSummary(
|
||||||
scene.getName(),
|
scene.getName(),
|
||||||
@@ -202,7 +217,7 @@ public class CampaignStructuralContextBuilder {
|
|||||||
* connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est
|
* connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est
|
||||||
* suffisant pour proposer de la narration ou anticiper les choix.
|
* suffisant pour proposer de la narration ou anticiper les choix.
|
||||||
*/
|
*/
|
||||||
private List<RoomSummary> toRoomSummaries(Scene scene) {
|
private List<RoomSummary> toRoomSummaries(Scene scene, Map<String, String> enemyLabelById) {
|
||||||
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
|
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
|
||||||
Map<String, String> nameById = scene.getRooms().stream()
|
Map<String, String> nameById = scene.getRooms().stream()
|
||||||
.collect(Collectors.toMap(
|
.collect(Collectors.toMap(
|
||||||
@@ -219,11 +234,36 @@ public class CampaignStructuralContextBuilder {
|
|||||||
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
|
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
|
||||||
b.condition()))
|
b.condition()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
return new RoomSummary(r.getName(), r.getFloor(), r.getDescription(), r.getEnemies(), hints);
|
return new RoomSummary(
|
||||||
|
r.getName(), r.getFloor(), r.getDescription(),
|
||||||
|
roomEnemiesText(r, enemyLabelById), hints);
|
||||||
})
|
})
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texte « ennemis » d'une pièce pour le prompt : fiches du bestiaire
|
||||||
|
* référencées (libellés résolus, IDs orphelins ignorés) suivies du texte
|
||||||
|
* libre. L'un ou l'autre peut être vide.
|
||||||
|
*/
|
||||||
|
private static String roomEnemiesText(
|
||||||
|
com.loremind.domain.campaigncontext.Room room, Map<String, String> enemyLabelById) {
|
||||||
|
String linked = room.getEnemyIds() == null ? "" : room.getEnemyIds().stream()
|
||||||
|
.map(enemyLabelById::get)
|
||||||
|
.filter(l -> l != null && !l.isBlank())
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
String freeText = room.getEnemies() == null ? "" : room.getEnemies().strip();
|
||||||
|
if (linked.isEmpty()) return freeText;
|
||||||
|
if (freeText.isEmpty()) return linked;
|
||||||
|
return linked + " — " + freeText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Libellé court d'une fiche du bestiaire : « Nom (niveau) » ou « Nom ». */
|
||||||
|
private static String enemyLabel(com.loremind.domain.campaigncontext.Enemy enemy) {
|
||||||
|
String level = enemy.getLevel() == null ? "" : enemy.getLevel().strip();
|
||||||
|
return level.isEmpty() ? enemy.getName() : enemy.getName() + " (" + level + ")";
|
||||||
|
}
|
||||||
|
|
||||||
/** Helper defensif : compte les illustrations attachees (null-safe). */
|
/** Helper defensif : compte les illustrations attachees (null-safe). */
|
||||||
private static int countImages(List<String> ids) {
|
private static int countImages(List<String> ids) {
|
||||||
return ids == null ? 0 : ids.size();
|
return ids == null ? 0 : ids.size();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.loremind.domain.campaigncontext.Scene;
|
|||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
@@ -32,18 +33,21 @@ public class NarrativeEntityContextBuilder {
|
|||||||
private final SceneRepository sceneRepository;
|
private final SceneRepository sceneRepository;
|
||||||
private final CharacterRepository characterRepository;
|
private final CharacterRepository characterRepository;
|
||||||
private final NpcRepository npcRepository;
|
private final NpcRepository npcRepository;
|
||||||
|
private final EnemyRepository enemyRepository;
|
||||||
|
|
||||||
public NarrativeEntityContextBuilder(
|
public NarrativeEntityContextBuilder(
|
||||||
ArcRepository arcRepository,
|
ArcRepository arcRepository,
|
||||||
ChapterRepository chapterRepository,
|
ChapterRepository chapterRepository,
|
||||||
SceneRepository sceneRepository,
|
SceneRepository sceneRepository,
|
||||||
CharacterRepository characterRepository,
|
CharacterRepository characterRepository,
|
||||||
NpcRepository npcRepository) {
|
NpcRepository npcRepository,
|
||||||
|
EnemyRepository enemyRepository) {
|
||||||
this.arcRepository = arcRepository;
|
this.arcRepository = arcRepository;
|
||||||
this.chapterRepository = chapterRepository;
|
this.chapterRepository = chapterRepository;
|
||||||
this.sceneRepository = sceneRepository;
|
this.sceneRepository = sceneRepository;
|
||||||
this.characterRepository = characterRepository;
|
this.characterRepository = characterRepository;
|
||||||
this.npcRepository = npcRepository;
|
this.npcRepository = npcRepository;
|
||||||
|
this.enemyRepository = enemyRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,10 +128,41 @@ public class NarrativeEntityContextBuilder {
|
|||||||
putField(fields, "choicesConsequences", s.getChoicesConsequences());
|
putField(fields, "choicesConsequences", s.getChoicesConsequences());
|
||||||
putField(fields, "combatDifficulty", s.getCombatDifficulty());
|
putField(fields, "combatDifficulty", s.getCombatDifficulty());
|
||||||
putField(fields, "enemies", s.getEnemies());
|
putField(fields, "enemies", s.getEnemies());
|
||||||
|
putField(fields, "linkedEnemies", resolveLinkedEnemies(s));
|
||||||
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
|
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
|
||||||
return new NarrativeEntityContext("scene", s.getName(), fields);
|
return new NarrativeEntityContext("scene", s.getName(), fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout les fiches du bestiaire référencées par la scène en une ligne par
|
||||||
|
* ennemi : « Nom (niveau) — champ: valeur ; … ». Valeurs tronquées : le
|
||||||
|
* contexte focus doit camper la rencontre, pas embarquer la fiche complète.
|
||||||
|
* Les IDs orphelins (fiche supprimée) sont ignorés silencieusement.
|
||||||
|
*/
|
||||||
|
private String resolveLinkedEnemies(Scene s) {
|
||||||
|
if (s.getEnemyIds() == null || s.getEnemyIds().isEmpty()) return "";
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (String enemyId : s.getEnemyIds()) {
|
||||||
|
enemyRepository.findById(enemyId).ifPresent(e -> {
|
||||||
|
if (sb.length() > 0) sb.append("\n");
|
||||||
|
sb.append("- ").append(e.getName());
|
||||||
|
if (e.getLevel() != null && !e.getLevel().isBlank()) {
|
||||||
|
sb.append(" (").append(e.getLevel().trim()).append(")");
|
||||||
|
}
|
||||||
|
String stats = e.getValues().entrySet().stream()
|
||||||
|
.filter(en -> en.getValue() != null && !en.getValue().isBlank())
|
||||||
|
.map(en -> en.getKey() + ": " + truncate(en.getValue().trim(), 100))
|
||||||
|
.collect(java.util.stream.Collectors.joining(" ; "));
|
||||||
|
if (!stats.isEmpty()) sb.append(" — ").append(stats);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String value, int maxLen) {
|
||||||
|
return value.length() <= maxLen ? value : value.substring(0, maxLen - 1).stripTrailing() + "…";
|
||||||
|
}
|
||||||
|
|
||||||
private NarrativeEntityContext fromCharacter(Character c) {
|
private NarrativeEntityContext fromCharacter(Character c) {
|
||||||
Map<String, String> fields = new LinkedHashMap<>();
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
if (c.getValues() != null) {
|
if (c.getValues() != null) {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.loremind.domain.campaigncontext;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fiche d'ennemi (monstre/créature) d'une campagne — le bestiaire du MJ.
|
||||||
|
* <p>
|
||||||
|
* Même principe de templating que {@link Npc} : champs universels hard-codés
|
||||||
|
* (nom, niveau, dossier, portrait, bandeau) + champs pilotés par le template
|
||||||
|
* ENNEMI du GameSystem ({@code GameSystem.enemyTemplate} : CA, PV, attaques…).
|
||||||
|
* Classement libre par dossier (« Démons », « Humanoïdes »…).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class Enemy {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** Niveau / FP / dangerosité — texte libre (« 5 », « FP 8 », « Boss »). Nullable. */
|
||||||
|
private String level;
|
||||||
|
|
||||||
|
/** Dossier de classement (texte libre). Null = non classé. */
|
||||||
|
private String folder;
|
||||||
|
|
||||||
|
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
|
||||||
|
private String portraitImageId;
|
||||||
|
|
||||||
|
/** ID de l'image header/bannière (champ universel hard-codé). Nullable. */
|
||||||
|
private String headerImageId;
|
||||||
|
|
||||||
|
/** Valeurs TEXT/NUMBER du template ennemi. Jamais null après construction. */
|
||||||
|
private Map<String, String> values;
|
||||||
|
|
||||||
|
/** Valeurs IMAGE du template ennemi (listes d'IDs ordonnées par champ). Jamais null. */
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
/** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
|
||||||
|
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||||
|
private String campaignId;
|
||||||
|
|
||||||
|
/** Ordre d'affichage dans la liste. */
|
||||||
|
private int order;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public Map<String, String> getValues() {
|
||||||
|
if (values == null) values = new HashMap<>();
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, List<String>> getImageValues() {
|
||||||
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
return imageValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Map<String, String>> getKeyValueValues() {
|
||||||
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
|
return keyValueValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ public class Room {
|
|||||||
/** Énemis, créatures, boss éventuels (markdown libre). */
|
/** Énemis, créatures, boss éventuels (markdown libre). */
|
||||||
private String enemies;
|
private String enemies;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDs des fiches du bestiaire ({@link Enemy}) présentes dans la pièce
|
||||||
|
* (weak refs). Complète le texte libre {@code enemies}, comme sur Scene.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> enemyIds = new ArrayList<>();
|
||||||
|
|
||||||
/** Loot / récompenses présentes dans la pièce. */
|
/** Loot / récompenses présentes dans la pièce. */
|
||||||
private String loot;
|
private String loot;
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,15 @@ public class Scene {
|
|||||||
|
|
||||||
// === Combat ou rencontre ===
|
// === Combat ou rencontre ===
|
||||||
private String combatDifficulty; // Difficulté estimée
|
private String combatDifficulty; // Difficulté estimée
|
||||||
private String enemies; // Liste des ennemis et créatures
|
private String enemies; // Liste des ennemis et créatures (texte libre)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDs des fiches du bestiaire ({@link Enemy}) engagées dans cette rencontre
|
||||||
|
* (weak cross-aggregate references). Complète le texte libre `enemies` :
|
||||||
|
* l'utilisateur peut référencer ses fiches, ou tout écrire à la main, ou les deux.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> enemyIds = new ArrayList<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IDs des pages du Lore associées à cette scène (weak cross-context references).
|
* IDs des pages du Lore associées à cette scène (weak cross-context references).
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,7 @@ public interface CharacterRepository {
|
|||||||
void deleteById(String id);
|
void deleteById(String id);
|
||||||
|
|
||||||
boolean existsById(String id);
|
boolean existsById(String id);
|
||||||
|
|
||||||
|
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
|
||||||
|
List<Character> searchByName(String query);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Enemy;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie pour la persistance des fiches d'ennemis (bestiaire de campagne).
|
||||||
|
*/
|
||||||
|
public interface EnemyRepository {
|
||||||
|
|
||||||
|
Enemy save(Enemy enemy);
|
||||||
|
|
||||||
|
Optional<Enemy> findById(String id);
|
||||||
|
|
||||||
|
List<Enemy> findByCampaignId(String campaignId);
|
||||||
|
|
||||||
|
void deleteById(String id);
|
||||||
|
|
||||||
|
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
|
||||||
|
List<Enemy> searchByName(String query);
|
||||||
|
}
|
||||||
@@ -19,4 +19,7 @@ public interface ItemCatalogRepository {
|
|||||||
void deleteById(String id);
|
void deleteById(String id);
|
||||||
|
|
||||||
boolean existsById(String id);
|
boolean existsById(String id);
|
||||||
|
|
||||||
|
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
|
||||||
|
List<ItemCatalog> searchByName(String query);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,7 @@ public interface NpcRepository {
|
|||||||
void deleteById(String id);
|
void deleteById(String id);
|
||||||
|
|
||||||
boolean existsById(String id);
|
boolean existsById(String id);
|
||||||
|
|
||||||
|
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
|
||||||
|
List<Npc> searchByName(String query);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,7 @@ public interface RandomTableRepository {
|
|||||||
void deleteById(String id);
|
void deleteById(String id);
|
||||||
|
|
||||||
boolean existsById(String id);
|
boolean existsById(String id);
|
||||||
|
|
||||||
|
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
|
||||||
|
List<RandomTable> searchByName(String query);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ public class GameSystem {
|
|||||||
*/
|
*/
|
||||||
private List<TemplateField> npcTemplate;
|
private List<TemplateField> npcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template de fiche ENNEMI (monstres/créatures du bestiaire de campagne).
|
||||||
|
* Mêmes règles que {@link #characterTemplate} — distinct du template PNJ :
|
||||||
|
* un ennemi porte des stats de combat (CA, PV, attaques…), pas une
|
||||||
|
* caractérisation narrative.
|
||||||
|
*/
|
||||||
|
private List<TemplateField> enemyTemplate;
|
||||||
|
|
||||||
/** Auteur déclaré — futur marketplace. Nullable. */
|
/** Auteur déclaré — futur marketplace. Nullable. */
|
||||||
private String author;
|
private String author;
|
||||||
|
|
||||||
@@ -98,6 +106,10 @@ public class GameSystem {
|
|||||||
npcTemplate = validateAndCopy(fields);
|
npcTemplate = validateAndCopy(fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void replaceEnemyTemplate(List<TemplateField> fields) {
|
||||||
|
enemyTemplate = validateAndCopy(fields);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helpers privés ----------------------------------------------------
|
// --- Helpers privés ----------------------------------------------------
|
||||||
|
|
||||||
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {
|
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,11 +52,15 @@ public record CampaignStructuralContext(
|
|||||||
/**
|
/**
|
||||||
* Résumé d'un arc : nom + description courte + ses chapitres.
|
* Résumé d'un arc : nom + description courte + ses chapitres.
|
||||||
*
|
*
|
||||||
|
* @param hub true si l'arc est de type HUB : ses chapitres sont des
|
||||||
|
* « quêtes » parallèles (vocabulaire UI). L'IA doit le savoir
|
||||||
|
* pour parler de quêtes et cibler le bon arc.
|
||||||
* @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
|
* @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
|
||||||
*/
|
*/
|
||||||
public record ArcSummary(
|
public record ArcSummary(
|
||||||
String name,
|
String name,
|
||||||
String description,
|
String description,
|
||||||
|
boolean hub,
|
||||||
int illustrationCount,
|
int illustrationCount,
|
||||||
List<ChapterSummary> chapters) {
|
List<ChapterSummary> chapters) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -252,9 +252,15 @@ public class BrainChatPayloadBuilder {
|
|||||||
ArcSummary::name,
|
ArcSummary::name,
|
||||||
ArcSummary::description,
|
ArcSummary::description,
|
||||||
ArcSummary::illustrationCount,
|
ArcSummary::illustrationCount,
|
||||||
(map, arc) -> map.put("chapters", arc.chapters().stream()
|
(map, arc) -> {
|
||||||
.map(this::chapterSummaryToMap)
|
// Vocabulaire UI : les chapitres d'un arc HUB sont des « quêtes ».
|
||||||
.collect(Collectors.toList())));
|
if (arc.hub()) {
|
||||||
|
map.put("arc_type", "HUB");
|
||||||
|
}
|
||||||
|
map.put("chapters", arc.chapters().stream()
|
||||||
|
.map(this::chapterSummaryToMap)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entité JPA des fiches d'ennemis (bestiaire). Mêmes règles que NpcJpaEntity.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "enemies", indexes = {
|
||||||
|
@Index(name = "idx_enemies_campaign_id", columnList = "campaign_id")
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class EnemyJpaEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** Niveau / FP — texte libre. Nullable. */
|
||||||
|
@Column(name = "level")
|
||||||
|
private String level;
|
||||||
|
|
||||||
|
/** Dossier de classement (« Démons », « Humanoïdes »…). Nullable = non classé. */
|
||||||
|
@Column(name = "folder")
|
||||||
|
private String folder;
|
||||||
|
|
||||||
|
@Column(name = "portrait_image_id")
|
||||||
|
private String portraitImageId;
|
||||||
|
|
||||||
|
@Column(name = "header_image_id")
|
||||||
|
private String headerImageId;
|
||||||
|
|
||||||
|
@Convert(converter = StringMapJsonConverter.class)
|
||||||
|
@Column(name = "field_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, String> values;
|
||||||
|
|
||||||
|
@Convert(converter = StringListMapJsonConverter.class)
|
||||||
|
@Column(name = "image_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
@Convert(converter = StringMapMapJsonConverter.class)
|
||||||
|
@Column(name = "key_value_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
|
||||||
|
@Column(name = "campaign_id", nullable = false)
|
||||||
|
private Long campaignId;
|
||||||
|
|
||||||
|
@Column(name = "\"order\"", nullable = false)
|
||||||
|
private int order;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
if (values == null) values = new HashMap<>();
|
||||||
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,11 @@ public class GameSystemJpaEntity {
|
|||||||
@Column(name = "npc_template", columnDefinition = "TEXT")
|
@Column(name = "npc_template", columnDefinition = "TEXT")
|
||||||
private List<TemplateField> npcTemplate;
|
private List<TemplateField> npcTemplate;
|
||||||
|
|
||||||
|
/** Template ENNEMI (bestiaire) serialise en JSON. */
|
||||||
|
@Convert(converter = TemplateFieldListJsonConverter.class)
|
||||||
|
@Column(name = "enemy_template", columnDefinition = "TEXT")
|
||||||
|
private List<TemplateField> enemyTemplate;
|
||||||
|
|
||||||
@Column
|
@Column
|
||||||
private String author;
|
private String author;
|
||||||
|
|
||||||
@@ -64,6 +69,7 @@ public class GameSystemJpaEntity {
|
|||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
if (characterTemplate == null) characterTemplate = new ArrayList<>();
|
if (characterTemplate == null) characterTemplate = new ArrayList<>();
|
||||||
if (npcTemplate == null) npcTemplate = new ArrayList<>();
|
if (npcTemplate == null) npcTemplate = new ArrayList<>();
|
||||||
|
if (enemyTemplate == null) enemyTemplate = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ public class SceneJpaEntity {
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String enemies;
|
private String enemies;
|
||||||
|
|
||||||
|
/** IDs des fiches du bestiaire liées à la rencontre (JSON, weak refs). */
|
||||||
|
@Column(name = "enemy_ids", columnDefinition = "TEXT")
|
||||||
|
@Convert(converter = StringListJsonConverter.class)
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> enemyIds = new ArrayList<>();
|
||||||
|
|
||||||
@Column(name = "related_page_ids", columnDefinition = "TEXT")
|
@Column(name = "related_page_ids", columnDefinition = "TEXT")
|
||||||
@Convert(converter = StringListJsonConverter.class)
|
@Convert(converter = StringListJsonConverter.class)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
|
|||||||
@@ -10,4 +10,7 @@ import java.util.List;
|
|||||||
public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> {
|
public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> {
|
||||||
|
|
||||||
List<CharacterJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId);
|
List<CharacterJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId);
|
||||||
|
|
||||||
|
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
|
||||||
|
List<CharacterJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.jpa;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface EnemyJpaRepository extends JpaRepository<EnemyJpaEntity, Long> {
|
||||||
|
|
||||||
|
List<EnemyJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||||
|
|
||||||
|
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
|
||||||
|
List<EnemyJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
|
||||||
|
}
|
||||||
@@ -10,4 +10,7 @@ import java.util.List;
|
|||||||
public interface ItemCatalogJpaRepository extends JpaRepository<ItemCatalogJpaEntity, Long> {
|
public interface ItemCatalogJpaRepository extends JpaRepository<ItemCatalogJpaEntity, Long> {
|
||||||
|
|
||||||
List<ItemCatalogJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
List<ItemCatalogJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||||
|
|
||||||
|
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
|
||||||
|
List<ItemCatalogJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,7 @@ import java.util.List;
|
|||||||
public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> {
|
public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> {
|
||||||
|
|
||||||
List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||||
|
|
||||||
|
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
|
||||||
|
List<NpcJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,7 @@ import java.util.List;
|
|||||||
public interface RandomTableJpaRepository extends JpaRepository<RandomTableJpaEntity, Long> {
|
public interface RandomTableJpaRepository extends JpaRepository<RandomTableJpaEntity, Long> {
|
||||||
|
|
||||||
List<RandomTableJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
List<RandomTableJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||||
|
|
||||||
|
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
|
||||||
|
List<RandomTableJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
|||||||
return jpaRepository.existsById(Long.parseLong(id));
|
return jpaRepository.existsById(Long.parseLong(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Character> searchByName(String query) {
|
||||||
|
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||||
|
.map(this::toDomainEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
private Character toDomainEntity(CharacterJpaEntity e) {
|
private Character toDomainEntity(CharacterJpaEntity e) {
|
||||||
return Character.builder()
|
return Character.builder()
|
||||||
.id(e.getId().toString())
|
.id(e.getId().toString())
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.postgres;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Enemy;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
|
||||||
|
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
|
||||||
|
import com.loremind.infrastructure.persistence.jpa.EnemyJpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class PostgresEnemyRepository implements EnemyRepository {
|
||||||
|
|
||||||
|
private final EnemyJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
public PostgresEnemyRepository(EnemyJpaRepository jpaRepository) {
|
||||||
|
this.jpaRepository = jpaRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Enemy save(Enemy enemy) {
|
||||||
|
return toDomainEntity(jpaRepository.save(toJpaEntity(enemy)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Enemy> findById(String id) {
|
||||||
|
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Enemy> findByCampaignId(String campaignId) {
|
||||||
|
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
||||||
|
.map(this::toDomainEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(String id) {
|
||||||
|
jpaRepository.deleteById(Long.parseLong(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Enemy> searchByName(String query) {
|
||||||
|
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||||
|
.map(this::toDomainEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Enemy toDomainEntity(EnemyJpaEntity e) {
|
||||||
|
return Enemy.builder()
|
||||||
|
.id(e.getId().toString())
|
||||||
|
.name(e.getName())
|
||||||
|
.level(e.getLevel())
|
||||||
|
.folder(e.getFolder())
|
||||||
|
.portraitImageId(e.getPortraitImageId())
|
||||||
|
.headerImageId(e.getHeaderImageId())
|
||||||
|
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
||||||
|
.campaignId(e.getCampaignId().toString())
|
||||||
|
.order(e.getOrder())
|
||||||
|
.createdAt(e.getCreatedAt())
|
||||||
|
.updatedAt(e.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private EnemyJpaEntity toJpaEntity(Enemy n) {
|
||||||
|
return EnemyJpaEntity.builder()
|
||||||
|
.id(n.getId() != null ? Long.parseLong(n.getId()) : null)
|
||||||
|
.name(n.getName())
|
||||||
|
.level(n.getLevel())
|
||||||
|
.folder(n.getFolder())
|
||||||
|
.portraitImageId(n.getPortraitImageId())
|
||||||
|
.headerImageId(n.getHeaderImageId())
|
||||||
|
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
|
||||||
|
.campaignId(Long.parseLong(n.getCampaignId()))
|
||||||
|
.order(n.getOrder())
|
||||||
|
.createdAt(n.getCreatedAt())
|
||||||
|
.updatedAt(n.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
|||||||
.npcTemplate(e.getNpcTemplate() != null
|
.npcTemplate(e.getNpcTemplate() != null
|
||||||
? new java.util.ArrayList<>(e.getNpcTemplate())
|
? new java.util.ArrayList<>(e.getNpcTemplate())
|
||||||
: new java.util.ArrayList<>())
|
: new java.util.ArrayList<>())
|
||||||
|
.enemyTemplate(e.getEnemyTemplate() != null
|
||||||
|
? new java.util.ArrayList<>(e.getEnemyTemplate())
|
||||||
|
: new java.util.ArrayList<>())
|
||||||
.author(e.getAuthor())
|
.author(e.getAuthor())
|
||||||
.isPublic(e.isPublic())
|
.isPublic(e.isPublic())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
@@ -87,6 +90,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
|||||||
.npcTemplate(g.getNpcTemplate() != null
|
.npcTemplate(g.getNpcTemplate() != null
|
||||||
? new java.util.ArrayList<>(g.getNpcTemplate())
|
? new java.util.ArrayList<>(g.getNpcTemplate())
|
||||||
: new java.util.ArrayList<>())
|
: new java.util.ArrayList<>())
|
||||||
|
.enemyTemplate(g.getEnemyTemplate() != null
|
||||||
|
? new java.util.ArrayList<>(g.getEnemyTemplate())
|
||||||
|
: new java.util.ArrayList<>())
|
||||||
.author(g.getAuthor())
|
.author(g.getAuthor())
|
||||||
.isPublic(g.isPublic())
|
.isPublic(g.isPublic())
|
||||||
.createdAt(g.getCreatedAt())
|
.createdAt(g.getCreatedAt())
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository {
|
|||||||
return jpaRepository.existsById(Long.parseLong(id));
|
return jpaRepository.existsById(Long.parseLong(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ItemCatalog> searchByName(String query) {
|
||||||
|
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||||
|
.map(this::toDomainEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) {
|
private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) {
|
||||||
List<CatalogItem> items = e.getItems().stream()
|
List<CatalogItem> items = e.getItems().stream()
|
||||||
.map(c -> CatalogItem.builder()
|
.map(c -> CatalogItem.builder()
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
return jpaRepository.existsById(Long.parseLong(id));
|
return jpaRepository.existsById(Long.parseLong(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Npc> searchByName(String query) {
|
||||||
|
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||||
|
.map(this::toDomainEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
private Npc toDomainEntity(NpcJpaEntity e) {
|
private Npc toDomainEntity(NpcJpaEntity e) {
|
||||||
return Npc.builder()
|
return Npc.builder()
|
||||||
.id(e.getId().toString())
|
.id(e.getId().toString())
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ public class PostgresRandomTableRepository implements RandomTableRepository {
|
|||||||
return jpaRepository.existsById(Long.parseLong(id));
|
return jpaRepository.existsById(Long.parseLong(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<RandomTable> searchByName(String query) {
|
||||||
|
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||||
|
.map(this::toDomainEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
private RandomTable toDomainEntity(RandomTableJpaEntity e) {
|
private RandomTable toDomainEntity(RandomTableJpaEntity e) {
|
||||||
List<RandomTableEntry> entries = e.getEntries().stream()
|
List<RandomTableEntry> entries = e.getEntries().stream()
|
||||||
.map(c -> RandomTableEntry.builder()
|
.map(c -> RandomTableEntry.builder()
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ public class PostgresSceneRepository implements SceneRepository {
|
|||||||
.choicesConsequences(jpaEntity.getChoicesConsequences())
|
.choicesConsequences(jpaEntity.getChoicesConsequences())
|
||||||
.combatDifficulty(jpaEntity.getCombatDifficulty())
|
.combatDifficulty(jpaEntity.getCombatDifficulty())
|
||||||
.enemies(jpaEntity.getEnemies())
|
.enemies(jpaEntity.getEnemies())
|
||||||
|
.enemyIds(jpaEntity.getEnemyIds() != null
|
||||||
|
? new ArrayList<>(jpaEntity.getEnemyIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
|
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(jpaEntity.getRelatedPageIds())
|
? new ArrayList<>(jpaEntity.getRelatedPageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
@@ -117,6 +120,9 @@ public class PostgresSceneRepository implements SceneRepository {
|
|||||||
.choicesConsequences(scene.getChoicesConsequences())
|
.choicesConsequences(scene.getChoicesConsequences())
|
||||||
.combatDifficulty(scene.getCombatDifficulty())
|
.combatDifficulty(scene.getCombatDifficulty())
|
||||||
.enemies(scene.getEnemies())
|
.enemies(scene.getEnemies())
|
||||||
|
.enemyIds(scene.getEnemyIds() != null
|
||||||
|
? new ArrayList<>(scene.getEnemyIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.relatedPageIds(scene.getRelatedPageIds() != null
|
.relatedPageIds(scene.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(scene.getRelatedPageIds())
|
? new ArrayList<>(scene.getRelatedPageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ public class CharacterController {
|
|||||||
|
|
||||||
private final CharacterService characterService;
|
private final CharacterService characterService;
|
||||||
private final CharacterMapper characterMapper;
|
private final CharacterMapper characterMapper;
|
||||||
|
private final com.loremind.domain.playcontext.ports.PlaythroughRepository playthroughRepository;
|
||||||
|
|
||||||
public CharacterController(CharacterService characterService, CharacterMapper characterMapper) {
|
public CharacterController(CharacterService characterService, CharacterMapper characterMapper,
|
||||||
|
com.loremind.domain.playcontext.ports.PlaythroughRepository playthroughRepository) {
|
||||||
this.characterService = characterService;
|
this.characterService = characterService;
|
||||||
this.characterMapper = characterMapper;
|
this.characterMapper = characterMapper;
|
||||||
|
this.playthroughRepository = playthroughRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -43,6 +46,31 @@ public class CharacterController {
|
|||||||
return ResponseEntity.ok(dtos);
|
return ResponseEntity.ok(dtos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche par nom — alimente la recherche globale (Ctrl+K). Le résultat est
|
||||||
|
* enrichi du campaignId (résolu via le Playthrough) pour que le front puisse
|
||||||
|
* construire la route /campaigns/{c}/playthroughs/{p}/characters/{id}.
|
||||||
|
*/
|
||||||
|
@GetMapping("/search")
|
||||||
|
public ResponseEntity<List<CharacterSearchDTO>> search(@RequestParam("q") String query) {
|
||||||
|
List<CharacterSearchDTO> out = characterService.searchCharacters(query).stream()
|
||||||
|
.map(c -> new CharacterSearchDTO(
|
||||||
|
c.getId(),
|
||||||
|
c.getName(),
|
||||||
|
c.getPlaythroughId(),
|
||||||
|
c.getPlaythroughId() != null
|
||||||
|
? playthroughRepository.findById(c.getPlaythroughId())
|
||||||
|
.map(com.loremind.domain.playcontext.Playthrough::getCampaignId)
|
||||||
|
.orElse(null)
|
||||||
|
: null))
|
||||||
|
.filter(r -> r.campaignId() != null) // PJ orphelin (legacy) : non navigable → exclu
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return ResponseEntity.ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résultat de recherche d'un PJ, enrichi pour la navigation. */
|
||||||
|
public record CharacterSearchDTO(String id, String name, String playthroughId, String campaignId) {}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
|
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
|
||||||
Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder()));
|
Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder()));
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.application.campaigncontext.EnemyService;
|
||||||
|
import com.loremind.domain.campaigncontext.Enemy;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST Controller des fiches d'ennemis (bestiaire de campagne).
|
||||||
|
* Réponses = domaine {@link Enemy} sérialisé tel quel (Lombok @Data) ;
|
||||||
|
* requêtes = record dédié (le domaine n'a pas de constructeur no-args).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/enemies")
|
||||||
|
public class EnemyController {
|
||||||
|
|
||||||
|
private final EnemyService enemyService;
|
||||||
|
|
||||||
|
public EnemyController(EnemyService enemyService) {
|
||||||
|
this.enemyService = enemyService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<Enemy> create(@RequestBody EnemyRequest req) {
|
||||||
|
return ResponseEntity.ok(enemyService.createEnemy(toData(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<Enemy> getById(@PathVariable String id) {
|
||||||
|
return enemyService.getEnemyById(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/campaign/{campaignId}")
|
||||||
|
public ResponseEntity<List<Enemy>> getByCampaign(@PathVariable String campaignId) {
|
||||||
|
return ResponseEntity.ok(enemyService.getEnemiesByCampaignId(campaignId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||||
|
@GetMapping("/search")
|
||||||
|
public ResponseEntity<List<Enemy>> search(@RequestParam("q") String query) {
|
||||||
|
return ResponseEntity.ok(enemyService.searchEnemies(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<Enemy> update(@PathVariable String id, @RequestBody EnemyRequest req) {
|
||||||
|
return ResponseEntity.ok(enemyService.updateEnemy(id, toData(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> delete(@PathVariable String id) {
|
||||||
|
enemyService.deleteEnemy(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private EnemyService.EnemyData toData(EnemyRequest req) {
|
||||||
|
return new EnemyService.EnemyData(
|
||||||
|
req.name(), req.level(), req.folder(),
|
||||||
|
req.portraitImageId(), req.headerImageId(),
|
||||||
|
req.values(), req.imageValues(), req.keyValueValues(),
|
||||||
|
req.campaignId(), req.order());
|
||||||
|
}
|
||||||
|
|
||||||
|
public record EnemyRequest(
|
||||||
|
String name,
|
||||||
|
String level,
|
||||||
|
String folder,
|
||||||
|
String portraitImageId,
|
||||||
|
String headerImageId,
|
||||||
|
Map<String, String> values,
|
||||||
|
Map<String, List<String>> imageValues,
|
||||||
|
Map<String, Map<String, String>> keyValueValues,
|
||||||
|
String campaignId,
|
||||||
|
Integer order) {}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -261,6 +263,7 @@ public class GameSystemController {
|
|||||||
dto.getRulesMarkdown(),
|
dto.getRulesMarkdown(),
|
||||||
toDomainFields(dto.getCharacterTemplate()),
|
toDomainFields(dto.getCharacterTemplate()),
|
||||||
toDomainFields(dto.getNpcTemplate()),
|
toDomainFields(dto.getNpcTemplate()),
|
||||||
|
toDomainFields(dto.getEnemyTemplate()),
|
||||||
dto.getAuthor(),
|
dto.getAuthor(),
|
||||||
dto.isPublic()
|
dto.isPublic()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ public class ItemCatalogController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||||
|
@GetMapping("/search")
|
||||||
|
public ResponseEntity<List<ItemCatalogDTO>> search(@RequestParam("q") String query) {
|
||||||
|
return ResponseEntity.ok(service.searchCatalogs(query).stream()
|
||||||
|
.map(mapper::toDTO)
|
||||||
|
.collect(java.util.stream.Collectors.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
/** Génère une PROPOSITION de catalogue via l'IA (non persistée) — l'UI préremplit le formulaire. */
|
/** Génère une PROPOSITION de catalogue via l'IA (non persistée) — l'UI préremplit le formulaire. */
|
||||||
@PostMapping("/generate")
|
@PostMapping("/generate")
|
||||||
public ResponseEntity<ItemCatalogDTO> generate(@RequestBody GenerateRequest req) {
|
public ResponseEntity<ItemCatalogDTO> generate(@RequestBody GenerateRequest req) {
|
||||||
|
|||||||
@@ -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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ public class NpcController {
|
|||||||
return ResponseEntity.ok(dtos);
|
return ResponseEntity.ok(dtos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||||
|
@GetMapping("/search")
|
||||||
|
public ResponseEntity<List<NpcDTO>> search(@RequestParam("q") String query) {
|
||||||
|
List<NpcDTO> dtos = npcService.searchNpcs(query).stream()
|
||||||
|
.map(npcMapper::toDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return ResponseEntity.ok(dtos);
|
||||||
|
}
|
||||||
|
|
||||||
/** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */
|
/** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */
|
||||||
@GetMapping("/lore/{loreId}")
|
@GetMapping("/lore/{loreId}")
|
||||||
public ResponseEntity<List<NpcDTO>> getNpcsByLore(@PathVariable String loreId) {
|
public ResponseEntity<List<NpcDTO>> getNpcsByLore(@PathVariable String loreId) {
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ public class RandomTableController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||||
|
@GetMapping("/search")
|
||||||
|
public ResponseEntity<List<RandomTableDTO>> search(@RequestParam("q") String query) {
|
||||||
|
return ResponseEntity.ok(service.searchTables(query).stream()
|
||||||
|
.map(mapper::toDTO)
|
||||||
|
.collect(java.util.stream.Collectors.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
/** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */
|
/** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */
|
||||||
@PostMapping("/generate")
|
@PostMapping("/generate")
|
||||||
public ResponseEntity<RandomTableDTO> generate(@RequestBody GenerateRequest req) {
|
public ResponseEntity<RandomTableDTO> generate(@RequestBody GenerateRequest req) {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ public class RoomDTO {
|
|||||||
private String name;
|
private String name;
|
||||||
private String description;
|
private String description;
|
||||||
private String enemies;
|
private String enemies;
|
||||||
|
/** IDs des fiches du bestiaire présentes dans la pièce (weak refs). */
|
||||||
|
private List<String> enemyIds = new ArrayList<>();
|
||||||
private String loot;
|
private String loot;
|
||||||
private String traps;
|
private String traps;
|
||||||
private String gmNotes;
|
private String gmNotes;
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ public class SceneDTO {
|
|||||||
private String combatDifficulty;
|
private String combatDifficulty;
|
||||||
private String enemies;
|
private String enemies;
|
||||||
|
|
||||||
|
/** IDs des fiches du bestiaire engagées dans la rencontre (weak refs). */
|
||||||
|
private List<String> enemyIds = new ArrayList<>();
|
||||||
|
|
||||||
/** IDs des pages du Lore liées (weak cross-context references). */
|
/** IDs des pages du Lore liées (weak cross-context references). */
|
||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class GameSystemDTO {
|
|||||||
private String rulesMarkdown;
|
private String rulesMarkdown;
|
||||||
private List<TemplateFieldDTO> characterTemplate = new ArrayList<>();
|
private List<TemplateFieldDTO> characterTemplate = new ArrayList<>();
|
||||||
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
|
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
|
||||||
|
private List<TemplateFieldDTO> enemyTemplate = new ArrayList<>();
|
||||||
private String author;
|
private String author;
|
||||||
private boolean isPublic;
|
private boolean isPublic;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class GameSystemMapper {
|
|||||||
dto.setRulesMarkdown(g.getRulesMarkdown());
|
dto.setRulesMarkdown(g.getRulesMarkdown());
|
||||||
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
|
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
|
||||||
dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
|
dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
|
||||||
|
dto.setEnemyTemplate(toDTOList(g.getEnemyTemplate()));
|
||||||
dto.setAuthor(g.getAuthor());
|
dto.setAuthor(g.getAuthor());
|
||||||
dto.setPublic(g.isPublic());
|
dto.setPublic(g.isPublic());
|
||||||
return dto;
|
return dto;
|
||||||
@@ -41,6 +42,7 @@ public class GameSystemMapper {
|
|||||||
.rulesMarkdown(dto.getRulesMarkdown())
|
.rulesMarkdown(dto.getRulesMarkdown())
|
||||||
.characterTemplate(toDomainList(dto.getCharacterTemplate()))
|
.characterTemplate(toDomainList(dto.getCharacterTemplate()))
|
||||||
.npcTemplate(toDomainList(dto.getNpcTemplate()))
|
.npcTemplate(toDomainList(dto.getNpcTemplate()))
|
||||||
|
.enemyTemplate(toDomainList(dto.getEnemyTemplate()))
|
||||||
.author(dto.getAuthor())
|
.author(dto.getAuthor())
|
||||||
.isPublic(dto.isPublic())
|
.isPublic(dto.isPublic())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ public class SceneMapper {
|
|||||||
dto.setChoicesConsequences(scene.getChoicesConsequences());
|
dto.setChoicesConsequences(scene.getChoicesConsequences());
|
||||||
dto.setCombatDifficulty(scene.getCombatDifficulty());
|
dto.setCombatDifficulty(scene.getCombatDifficulty());
|
||||||
dto.setEnemies(scene.getEnemies());
|
dto.setEnemies(scene.getEnemies());
|
||||||
|
dto.setEnemyIds(scene.getEnemyIds() != null
|
||||||
|
? new ArrayList<>(scene.getEnemyIds())
|
||||||
|
: new ArrayList<>());
|
||||||
dto.setRelatedPageIds(scene.getRelatedPageIds() != null
|
dto.setRelatedPageIds(scene.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(scene.getRelatedPageIds())
|
? new ArrayList<>(scene.getRelatedPageIds())
|
||||||
: new ArrayList<>());
|
: new ArrayList<>());
|
||||||
@@ -74,6 +77,9 @@ public class SceneMapper {
|
|||||||
.choicesConsequences(dto.getChoicesConsequences())
|
.choicesConsequences(dto.getChoicesConsequences())
|
||||||
.combatDifficulty(dto.getCombatDifficulty())
|
.combatDifficulty(dto.getCombatDifficulty())
|
||||||
.enemies(dto.getEnemies())
|
.enemies(dto.getEnemies())
|
||||||
|
.enemyIds(dto.getEnemyIds() != null
|
||||||
|
? new ArrayList<>(dto.getEnemyIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.relatedPageIds(dto.getRelatedPageIds() != null
|
.relatedPageIds(dto.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(dto.getRelatedPageIds())
|
? new ArrayList<>(dto.getRelatedPageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
@@ -117,6 +123,9 @@ public class SceneMapper {
|
|||||||
dto.setName(r.getName());
|
dto.setName(r.getName());
|
||||||
dto.setDescription(r.getDescription());
|
dto.setDescription(r.getDescription());
|
||||||
dto.setEnemies(r.getEnemies());
|
dto.setEnemies(r.getEnemies());
|
||||||
|
dto.setEnemyIds(r.getEnemyIds() != null
|
||||||
|
? new ArrayList<>(r.getEnemyIds())
|
||||||
|
: new ArrayList<>());
|
||||||
dto.setLoot(r.getLoot());
|
dto.setLoot(r.getLoot());
|
||||||
dto.setTraps(r.getTraps());
|
dto.setTraps(r.getTraps());
|
||||||
dto.setGmNotes(r.getGmNotes());
|
dto.setGmNotes(r.getGmNotes());
|
||||||
@@ -145,6 +154,9 @@ public class SceneMapper {
|
|||||||
.name(d.getName())
|
.name(d.getName())
|
||||||
.description(d.getDescription())
|
.description(d.getDescription())
|
||||||
.enemies(d.getEnemies())
|
.enemies(d.getEnemies())
|
||||||
|
.enemyIds(d.getEnemyIds() != null
|
||||||
|
? new ArrayList<>(d.getEnemyIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.loot(d.getLoot())
|
.loot(d.getLoot())
|
||||||
.traps(d.getTraps())
|
.traps(d.getTraps())
|
||||||
.gmNotes(d.getGmNotes())
|
.gmNotes(d.getGmNotes())
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class CampaignStructuralContextTest {
|
|||||||
ArcSummary arc = new ArcSummary(
|
ArcSummary arc = new ArcSummary(
|
||||||
"Acte I",
|
"Acte I",
|
||||||
"Mise en place",
|
"Mise en place",
|
||||||
|
false,
|
||||||
1,
|
1,
|
||||||
List.of(chapter));
|
List.of(chapter));
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ class CampaignStructuralContextTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void illustrationCount_defaultsToZero_onAllSummaryTypes() {
|
void illustrationCount_defaultsToZero_onAllSummaryTypes() {
|
||||||
ArcSummary arc = new ArcSummary("X", null, 0, List.of());
|
ArcSummary arc = new ArcSummary("X", null, false, 0, List.of());
|
||||||
ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
|
ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
|
||||||
SceneSummary scene = new SceneSummary("X", null, 0, List.of(), List.of());
|
SceneSummary scene = new SceneSummary("X", null, 0, List.of(), List.of());
|
||||||
|
|
||||||
@@ -92,6 +93,7 @@ class CampaignStructuralContextTest {
|
|||||||
ArcSummary arc = new ArcSummary(
|
ArcSummary arc = new ArcSummary(
|
||||||
"Acte I",
|
"Acte I",
|
||||||
null,
|
null,
|
||||||
|
false,
|
||||||
0,
|
0,
|
||||||
List.of(
|
List.of(
|
||||||
new ChapterSummary("Ch1", null, 0, List.of()),
|
new ChapterSummary("Ch1", null, 0, List.of()),
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ class BrainChatPayloadBuilderTest {
|
|||||||
BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
|
BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
|
||||||
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch), List.of());
|
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch), List.of());
|
||||||
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
|
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
|
||||||
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
|
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", false, 1, List.of(chapter));
|
||||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
"Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of());
|
"Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
@@ -198,7 +198,7 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_arcSummary_omitsIllustrationCount_whenZero() {
|
void build_arcSummary_omitsIllustrationCount_whenZero() {
|
||||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of());
|
ArcSummary arc = new ArcSummary("A", "", false, 0, List.of());
|
||||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
"X", "", List.of(arc), List.of(), List.of());
|
"X", "", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
@@ -215,7 +215,7 @@ class BrainChatPayloadBuilderTest {
|
|||||||
void build_sceneSummary_omitsBranches_whenEmpty() {
|
void build_sceneSummary_omitsBranches_whenEmpty() {
|
||||||
SceneSummary scene = new SceneSummary("S", "", 0, List.of(), List.of());
|
SceneSummary scene = new SceneSummary("S", "", 0, List.of(), List.of());
|
||||||
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter));
|
||||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
"X", "", List.of(arc), List.of(), List.of());
|
"X", "", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
@@ -234,7 +234,7 @@ class BrainChatPayloadBuilderTest {
|
|||||||
BranchHint branch = new BranchHint("X", "Y", " ");
|
BranchHint branch = new BranchHint("X", "Y", " ");
|
||||||
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch), List.of());
|
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch), List.of());
|
||||||
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter));
|
||||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
"X", "", List.of(arc), List.of(), List.of());
|
"X", "", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.3-beta",
|
"version": "0.13.0-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.3-beta",
|
"version": "0.13.0-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^21.2.16",
|
"@angular/animations": "^21.2.16",
|
||||||
"@angular/common": "^21.2.16",
|
"@angular/common": "^21.2.16",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.3-beta",
|
"version": "0.13.0-beta",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const routes: Routes = [
|
|||||||
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },
|
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },
|
||||||
|
{ path: 'campaigns/:campaignId/npcs', loadComponent: () => import('./campaigns/npc/npc-list/npc-list.component').then(m => m.NpcListComponent) },
|
||||||
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
|
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
|
||||||
@@ -32,6 +33,10 @@ export const routes: Routes = [
|
|||||||
{ path: 'campaigns/:campaignId/item-catalogs/create', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) },
|
{ path: 'campaigns/:campaignId/item-catalogs/create', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/item-catalogs/:catalogId/edit', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) },
|
{ path: 'campaigns/:campaignId/item-catalogs/:catalogId/edit', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/item-catalogs/:catalogId', loadComponent: () => import('./campaigns/item-catalog/item-catalog-view/item-catalog-view.component').then(m => m.ItemCatalogViewComponent) },
|
{ path: 'campaigns/:campaignId/item-catalogs/:catalogId', loadComponent: () => import('./campaigns/item-catalog/item-catalog-view/item-catalog-view.component').then(m => m.ItemCatalogViewComponent) },
|
||||||
|
{ path: 'campaigns/:campaignId/enemies', loadComponent: () => import('./campaigns/enemy/enemy-list/enemy-list.component').then(m => m.EnemyListComponent) },
|
||||||
|
{ path: 'campaigns/:campaignId/enemies/create', loadComponent: () => import('./campaigns/enemy/enemy-edit/enemy-edit.component').then(m => m.EnemyEditComponent) },
|
||||||
|
{ path: 'campaigns/:campaignId/enemies/:enemyId/edit', loadComponent: () => import('./campaigns/enemy/enemy-edit/enemy-edit.component').then(m => m.EnemyEditComponent) },
|
||||||
|
{ path: 'campaigns/:campaignId/enemies/:enemyId', loadComponent: () => import('./campaigns/enemy/enemy-view/enemy-view.component').then(m => m.EnemyViewComponent) },
|
||||||
{ path: 'campaigns/:campaignId/random-tables/create', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
|
{ path: 'campaigns/:campaignId/random-tables/create', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/random-tables/:tableId/edit', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
|
{ path: 'campaigns/:campaignId/random-tables/:tableId/edit', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/random-tables/:tableId', loadComponent: () => import('./campaigns/random-table/random-table-view/random-table-view.component').then(m => m.RandomTableViewComponent) },
|
{ path: 'campaigns/:campaignId/random-tables/:tableId', loadComponent: () => import('./campaigns/random-table/random-table-view/random-table-view.component').then(m => m.RandomTableViewComponent) },
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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 { RandomTableService } from '../../../services/random-table.service';
|
import { RandomTableService } from '../../../services/random-table.service';
|
||||||
|
import { EnemyService } from '../../../services/enemy.service';
|
||||||
import { LayoutService } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
@@ -41,6 +42,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private randomTableService: RandomTableService,
|
private randomTableService: RandomTableService,
|
||||||
|
private enemyService: EnemyService,
|
||||||
private layoutService: LayoutService
|
private layoutService: LayoutService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
@@ -60,7 +62,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
forkJoin({
|
forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
this.existingArcCount = treeData.arcs.length;
|
this.existingArcCount = treeData.arcs.length;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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 { RandomTableService } from '../../../services/random-table.service';
|
import { RandomTableService } from '../../../services/random-table.service';
|
||||||
|
import { EnemyService } from '../../../services/enemy.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';
|
||||||
@@ -78,6 +79,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private randomTableService: RandomTableService,
|
private randomTableService: RandomTableService,
|
||||||
|
private enemyService: EnemyService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService,
|
private pageTitleService: PageTitleService,
|
||||||
@@ -117,7 +119,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
arc: this.campaignService.getArcById(this.arcId),
|
arc: this.campaignService.getArcById(this.arcId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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 { RandomTableService } from '../../../services/random-table.service';
|
import { RandomTableService } from '../../../services/random-table.service';
|
||||||
|
import { EnemyService } from '../../../services/enemy.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';
|
||||||
@@ -60,6 +61,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private randomTableService: RandomTableService,
|
private randomTableService: RandomTableService,
|
||||||
|
private enemyService: EnemyService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService,
|
private pageTitleService: PageTitleService,
|
||||||
@@ -83,7 +85,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
arc: this.campaignService.getArcById(this.arcId),
|
arc: this.campaignService.getArcById(this.arcId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ 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 { RandomTableService } from '../services/random-table.service';
|
import { RandomTableService } from '../services/random-table.service';
|
||||||
|
import { EnemyService } from '../services/enemy.service';
|
||||||
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
|
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
|
||||||
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
|
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
|
||||||
import { Character } from '../services/character.model';
|
import { Character } from '../services/character.model';
|
||||||
import { Npc } from '../services/npc.model';
|
import { Npc } from '../services/npc.model';
|
||||||
import { RandomTable } from '../services/random-table.model';
|
import { RandomTable } from '../services/random-table.model';
|
||||||
|
import { Enemy } from '../services/enemy.model';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +28,7 @@ export interface CampaignTreeData {
|
|||||||
characters: Character[];
|
characters: Character[];
|
||||||
npcs: Npc[];
|
npcs: Npc[];
|
||||||
randomTables: RandomTable[];
|
randomTables: RandomTable[];
|
||||||
|
enemies: Enemy[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadCampaignTreeData(
|
export function loadCampaignTreeData(
|
||||||
@@ -35,7 +38,10 @@ export function loadCampaignTreeData(
|
|||||||
npcService: NpcService,
|
npcService: NpcService,
|
||||||
// Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les
|
// Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les
|
||||||
// tables aléatoires sont chargées et apparaissent dans la sidebar.
|
// tables aléatoires sont chargées et apparaissent dans la sidebar.
|
||||||
randomTableService?: RandomTableService
|
randomTableService?: RandomTableService,
|
||||||
|
// Optionnel (même principe) : si fourni, les ennemis sont chargés et le nœud
|
||||||
|
// « Ennemis » devient dépliable (dossiers → fiches) en plus du lien.
|
||||||
|
enemyService?: EnemyService
|
||||||
): Observable<CampaignTreeData> {
|
): Observable<CampaignTreeData> {
|
||||||
// Note refonte Playthrough : les PJ appartiennent désormais à une Partie,
|
// Note refonte Playthrough : les PJ appartiennent désormais à une Partie,
|
||||||
// pas à la campagne — on ne les charge plus ici (les vues qui les affichent
|
// pas à la campagne — on ne les charge plus ici (les vues qui les affichent
|
||||||
@@ -46,11 +52,14 @@ export function loadCampaignTreeData(
|
|||||||
npcs: npcService.getByCampaign(campaignId),
|
npcs: npcService.getByCampaign(campaignId),
|
||||||
randomTables: randomTableService
|
randomTables: randomTableService
|
||||||
? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[])))
|
? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[])))
|
||||||
: of([] as RandomTable[])
|
: of([] as RandomTable[]),
|
||||||
|
enemies: enemyService
|
||||||
|
? enemyService.getByCampaign(campaignId).pipe(catchError(() => of([] as Enemy[])))
|
||||||
|
: of([] as Enemy[])
|
||||||
}).pipe(
|
}).pipe(
|
||||||
switchMap(({ arcs, characters, npcs, randomTables }) => {
|
switchMap(({ arcs, characters, npcs, randomTables, enemies }) => {
|
||||||
if (arcs.length === 0) {
|
if (arcs.length === 0) {
|
||||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables });
|
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables, enemies });
|
||||||
}
|
}
|
||||||
const chapterCalls = arcs.map(a =>
|
const chapterCalls = arcs.map(a =>
|
||||||
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
|
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
|
||||||
@@ -65,7 +74,7 @@ export function loadCampaignTreeData(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (allChapters.length === 0) {
|
if (allChapters.length === 0) {
|
||||||
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables });
|
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables, enemies });
|
||||||
}
|
}
|
||||||
const sceneCalls = allChapters.map(c =>
|
const sceneCalls = allChapters.map(c =>
|
||||||
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
|
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
|
||||||
@@ -74,7 +83,7 @@ export function loadCampaignTreeData(
|
|||||||
map(sceneResults => {
|
map(sceneResults => {
|
||||||
const scenesByChapter: Record<string, Scene[]> = {};
|
const scenesByChapter: Record<string, Scene[]> = {};
|
||||||
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
|
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
|
||||||
return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables };
|
return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables, enemies };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -135,6 +144,9 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
iconKey: 'c-drama',
|
iconKey: 'c-drama',
|
||||||
children: npcChildren,
|
children: npcChildren,
|
||||||
meta: sortedNpcs.length ? String(sortedNpcs.length) : undefined,
|
meta: sortedNpcs.length ? String(sortedNpcs.length) : undefined,
|
||||||
|
// Cliquer le LIBELLÉ ouvre la page de liste (vue d'ensemble par dossiers) ;
|
||||||
|
// cliquer le CHEVRON déplie l'arbre dans la sidebar — les deux coexistent.
|
||||||
|
route: `/campaigns/${campaignId}/npcs`,
|
||||||
// Porte le header de section "Personnages" (les PJ ayant migré vers la Partie).
|
// Porte le header de section "Personnages" (les PJ ayant migré vers la Partie).
|
||||||
// Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar.
|
// Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar.
|
||||||
sectionHeaderBefore: 'Personnages',
|
sectionHeaderBefore: 'Personnages',
|
||||||
@@ -146,6 +158,40 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Ennemis (bestiaire) : même structure que les PNJ — dossiers dépliables
|
||||||
|
// dans la sidebar + libellé cliquable vers la page de liste.
|
||||||
|
const sortedEnemies = [...(data.enemies ?? [])].sort(byName);
|
||||||
|
const enemyItem = (e: Enemy): TreeItem => ({
|
||||||
|
id: `enemy-${e.id}`,
|
||||||
|
label: e.name,
|
||||||
|
route: `/campaigns/${campaignId}/enemies/${e.id}`,
|
||||||
|
meta: e.level ? `Niv. ${e.level}` : undefined
|
||||||
|
});
|
||||||
|
const enemiesByFolder = new Map<string, Enemy[]>();
|
||||||
|
const ungroupedEnemies: Enemy[] = [];
|
||||||
|
for (const e of sortedEnemies) {
|
||||||
|
const f = (e.folder ?? '').trim();
|
||||||
|
if (f) {
|
||||||
|
if (!enemiesByFolder.has(f)) enemiesByFolder.set(f, []);
|
||||||
|
enemiesByFolder.get(f)!.push(e);
|
||||||
|
} else {
|
||||||
|
ungroupedEnemies.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const enemyFolderNodes: TreeItem[] = [...enemiesByFolder.keys()]
|
||||||
|
.sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' }))
|
||||||
|
.map(folder => {
|
||||||
|
const items = enemiesByFolder.get(folder)!.map(enemyItem);
|
||||||
|
return {
|
||||||
|
id: `enemy-folder-${folder}`,
|
||||||
|
label: folder,
|
||||||
|
iconKey: 'folder',
|
||||||
|
children: items,
|
||||||
|
meta: String(items.length)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const enemyChildren: TreeItem[] = [...enemyFolderNodes, ...ungroupedEnemies.map(enemyItem)];
|
||||||
|
|
||||||
const sortedArcs = [...data.arcs].sort(byName);
|
const sortedArcs = [...data.arcs].sort(byName);
|
||||||
|
|
||||||
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
|
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
|
||||||
@@ -233,6 +279,24 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
route: `/campaigns/${campaignId}/item-catalogs`
|
route: `/campaigns/${campaignId}/item-catalogs`
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ennemis (bestiaire, fiches pilotées par le template Ennemi du GameSystem,
|
||||||
|
// classées par dossier) — rangé avec les PERSONNAGES, comme les PNJ.
|
||||||
|
// Libellé → page de liste ; chevron → arbre dépliable (dossiers → fiches).
|
||||||
|
const enemiesNode: TreeItem = {
|
||||||
|
id: 'enemies-root',
|
||||||
|
label: 'Ennemis',
|
||||||
|
iconKey: 'skull',
|
||||||
|
children: enemyChildren,
|
||||||
|
meta: sortedEnemies.length ? String(sortedEnemies.length) : undefined,
|
||||||
|
route: `/campaigns/${campaignId}/enemies`,
|
||||||
|
createActions: [{
|
||||||
|
id: 'new-enemy',
|
||||||
|
label: 'Nouvel ennemi',
|
||||||
|
route: `/campaigns/${campaignId}/enemies/create`,
|
||||||
|
actionIcon: 'plus'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
// Importer un PDF de campagne → arborescence (outil, comme tables & ateliers).
|
// Importer un PDF de campagne → arborescence (outil, comme tables & ateliers).
|
||||||
const importNode: TreeItem = {
|
const importNode: TreeItem = {
|
||||||
id: 'import-pdf-root',
|
id: 'import-pdf-root',
|
||||||
@@ -241,7 +305,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
route: `/campaigns/${campaignId}/import`
|
route: `/campaigns/${campaignId}/import`
|
||||||
};
|
};
|
||||||
|
|
||||||
return [...arcNodes, npcsNode, tablesNode, notebooksNode, catalogsNode, importNode];
|
return [...arcNodes, npcsNode, enemiesNode, tablesNode, notebooksNode, catalogsNode, importNode];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { GameSystem } from '../../../services/game-system.model';
|
|||||||
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 { RandomTableService } from '../../../services/random-table.service';
|
import { RandomTableService } from '../../../services/random-table.service';
|
||||||
|
import { EnemyService } from '../../../services/enemy.service';
|
||||||
import { SessionService } from '../../../services/session.service';
|
import { SessionService } from '../../../services/session.service';
|
||||||
import { PlaythroughService } from '../../../services/playthrough.service';
|
import { PlaythroughService } from '../../../services/playthrough.service';
|
||||||
import { Playthrough } from '../../../services/campaign.model';
|
import { Playthrough } from '../../../services/campaign.model';
|
||||||
@@ -95,6 +96,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private randomTableService: RandomTableService,
|
private randomTableService: RandomTableService,
|
||||||
|
private enemyService: EnemyService,
|
||||||
private sessionService: SessionService,
|
private sessionService: SessionService,
|
||||||
private playthroughService: PlaythroughService,
|
private playthroughService: PlaythroughService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
@@ -112,8 +114,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
switchMap(id => forkJoin({
|
switchMap(id => forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(id),
|
campaign: this.campaignService.getCampaignById(id),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe(
|
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe(
|
||||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
|
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||||
),
|
),
|
||||||
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
|
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
|
||||||
}))
|
}))
|
||||||
@@ -149,8 +151,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
forkJoin({
|
forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(id),
|
campaign: this.campaignService.getCampaignById(id),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe(
|
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe(
|
||||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
|
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||||
),
|
),
|
||||||
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
|
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
|
||||||
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
|
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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 { RandomTableService } from '../../../services/random-table.service';
|
import { RandomTableService } from '../../../services/random-table.service';
|
||||||
|
import { EnemyService } from '../../../services/enemy.service';
|
||||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { ArcKind, ArcProposal, ChapterProposal, NpcProposal, SceneProposal } from '../../../services/campaign-import.model';
|
import { ArcKind, ArcProposal, ChapterProposal, NpcProposal, SceneProposal } from '../../../services/campaign-import.model';
|
||||||
@@ -74,6 +75,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). */
|
||||||
@@ -100,6 +107,7 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private randomTableService: RandomTableService,
|
private randomTableService: RandomTableService,
|
||||||
|
private enemyService: EnemyService,
|
||||||
private campaignSidebar: CampaignSidebarService,
|
private campaignSidebar: CampaignSidebarService,
|
||||||
private pageTitle: PageTitleService
|
private pageTitle: PageTitleService
|
||||||
) {}
|
) {}
|
||||||
@@ -111,7 +119,7 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
|
|
||||||
// Pré-chargement de l'arborescence existante (pour fusionner à la revue).
|
// Pré-chargement de l'arborescence existante (pour fusionner à la revue).
|
||||||
// En cas d'échec on dégrade : tout sera considéré comme nouveau.
|
// En cas d'échec on dégrade : tout sera considéré comme nouveau.
|
||||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
|
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||||
.pipe(catchError(() => of(null)))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe(data => this.existingData = data);
|
.subscribe(data => this.existingData = data);
|
||||||
}
|
}
|
||||||
@@ -132,12 +140,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 +160,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;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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 { RandomTableService } from '../../../services/random-table.service';
|
import { RandomTableService } from '../../../services/random-table.service';
|
||||||
|
import { EnemyService } from '../../../services/enemy.service';
|
||||||
import { LayoutService } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
@@ -43,6 +44,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
|||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private randomTableService: RandomTableService,
|
private randomTableService: RandomTableService,
|
||||||
|
private enemyService: EnemyService,
|
||||||
private layoutService: LayoutService
|
private layoutService: LayoutService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
@@ -61,7 +63,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
|||||||
forkJoin({
|
forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
|
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
|
||||||
this.arcName = currentArc?.name ?? '';
|
this.arcName = currentArc?.name ?? '';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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 { RandomTableService } from '../../../services/random-table.service';
|
import { RandomTableService } from '../../../services/random-table.service';
|
||||||
|
import { EnemyService } from '../../../services/enemy.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';
|
||||||
@@ -92,6 +93,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private randomTableService: RandomTableService,
|
private randomTableService: RandomTableService,
|
||||||
|
private enemyService: EnemyService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService,
|
private pageTitleService: PageTitleService,
|
||||||
@@ -128,7 +130,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 { RandomTableService } from '../../../services/random-table.service';
|
import { RandomTableService } from '../../../services/random-table.service';
|
||||||
|
import { EnemyService } from '../../../services/enemy.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Chapter, Scene } from '../../../services/campaign.model';
|
import { Campaign, Chapter, Scene } from '../../../services/campaign.model';
|
||||||
@@ -71,6 +72,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private randomTableService: RandomTableService,
|
private randomTableService: RandomTableService,
|
||||||
|
private enemyService: EnemyService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
) {}
|
) {}
|
||||||
@@ -90,7 +92,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||||
scenes: this.campaignService.getScenes(this.chapterId),
|
scenes: this.campaignService.getScenes(this.chapterId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||||
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
|
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
|
||||||
this.chapter = chapter;
|
this.chapter = chapter;
|
||||||
this.scenes = scenes;
|
this.scenes = scenes;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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 { RandomTableService } from '../../../services/random-table.service';
|
import { RandomTableService } from '../../../services/random-table.service';
|
||||||
|
import { EnemyService } from '../../../services/enemy.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';
|
||||||
@@ -52,6 +53,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private randomTableService: RandomTableService,
|
private randomTableService: RandomTableService,
|
||||||
|
private enemyService: EnemyService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService,
|
private pageTitleService: PageTitleService,
|
||||||
@@ -79,7 +81,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
|
|||||||
111
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html
Normal file
111
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<div class="ne-page">
|
||||||
|
|
||||||
|
<div class="ne-header">
|
||||||
|
<button class="btn-back" (click)="back()">
|
||||||
|
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||||
|
Retour aux ennemis
|
||||||
|
</button>
|
||||||
|
<div class="header-row">
|
||||||
|
<h1>
|
||||||
|
<lucide-icon [img]="Skull" [size]="22"></lucide-icon>
|
||||||
|
{{ enemyId ? 'Éditer l\'ennemi' : 'Nouvel ennemi' }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ne-form">
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="enemy-name">Nom de l'ennemi *</label>
|
||||||
|
<input
|
||||||
|
id="enemy-name"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="name"
|
||||||
|
name="name"
|
||||||
|
placeholder="Ex: Balor, Gobelin chef de guerre, Liche…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row image-row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="enemy-level">Niveau / FP</label>
|
||||||
|
<input
|
||||||
|
id="enemy-level"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="level"
|
||||||
|
name="level"
|
||||||
|
placeholder="Ex: 5, FP 8, Boss…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="enemy-folder">Dossier</label>
|
||||||
|
<input
|
||||||
|
id="enemy-folder"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="folder"
|
||||||
|
name="folder"
|
||||||
|
list="enemy-folders"
|
||||||
|
placeholder="Ex: Démons, Humanoïdes… (vide = non classé)"
|
||||||
|
/>
|
||||||
|
<datalist id="enemy-folders">
|
||||||
|
@for (f of existingFolders; track f) {
|
||||||
|
<option [value]="f"></option>
|
||||||
|
}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row image-row">
|
||||||
|
<div class="field portrait-field">
|
||||||
|
<label>Portrait</label>
|
||||||
|
<app-single-image-picker
|
||||||
|
[imageId]="portraitImageId"
|
||||||
|
aspectRatio="1 / 1"
|
||||||
|
hint="Carre conseille (400×400)."
|
||||||
|
(imageIdChange)="portraitImageId = $event">
|
||||||
|
</app-single-image-picker>
|
||||||
|
</div>
|
||||||
|
<div class="field header-field">
|
||||||
|
<label>Bandeau / Header</label>
|
||||||
|
<app-single-image-picker
|
||||||
|
[imageId]="headerImageId"
|
||||||
|
aspectRatio="3 / 1"
|
||||||
|
hint="Format paysage conseille (1200×400)."
|
||||||
|
(imageIdChange)="headerImageId = $event">
|
||||||
|
</app-single-image-picker>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="template-fields">
|
||||||
|
<app-dynamic-fields-form
|
||||||
|
[fields]="templateFields"
|
||||||
|
[values]="values"
|
||||||
|
[imageValues]="imageValues"
|
||||||
|
[keyValueValues]="keyValueValues"
|
||||||
|
(valuesChange)="values = $event"
|
||||||
|
(imageValuesChange)="imageValues = $event"
|
||||||
|
(keyValueValuesChange)="keyValueValues = $event">
|
||||||
|
</app-dynamic-fields-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
||||||
|
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
||||||
|
{{ enemyId ? 'Enregistrer' : 'Créer' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
@if (enemyId) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
(click)="deleteEnemy()">
|
||||||
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
157
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.scss
Normal file
157
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.scss
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
.ne-page {
|
||||||
|
padding: 2rem 3rem;
|
||||||
|
color: #e5e7eb;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ne-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: white;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ai {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: rgba(167, 139, 250, 0.08);
|
||||||
|
border: 1px solid rgba(167, 139, 250, 0.4);
|
||||||
|
color: #a78bfa;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover { background: rgba(167, 139, 250, 0.15); border-color: #a78bfa; }
|
||||||
|
&.active { background: #a78bfa; color: #0b1220; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
&:hover { color: #e5e7eb; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ne-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0.4rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"], textarea {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #e5e7eb;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #a78bfa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-field textarea {
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #a78bfa;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
|
||||||
|
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
&:hover:not(:disabled) { background: #c4b5fd; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { border-color: #374151; color: #e5e7eb; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||||
|
color: #f87171;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #f87171;
|
||||||
|
background: rgba(248, 113, 113, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts
Normal file
167
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { LucideAngularModule, Save, ArrowLeft, Skull, Trash2 } from 'lucide-angular';
|
||||||
|
import { EnemyService } from '../../../services/enemy.service';
|
||||||
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
|
import { TemplateField } from '../../../services/template.model';
|
||||||
|
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||||
|
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editeur plein écran d'une fiche d'ennemi (bestiaire). Même principe que
|
||||||
|
* NpcEditComponent : formulaire dynamique piloté par le template ENNEMI du
|
||||||
|
* GameSystem associé à la campagne, + champs universels niveau/dossier.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-enemy-edit',
|
||||||
|
imports: [FormsModule, LucideAngularModule, DynamicFieldsFormComponent, SingleImagePickerComponent],
|
||||||
|
templateUrl: './enemy-edit.component.html',
|
||||||
|
styleUrls: ['./enemy-edit.component.scss']
|
||||||
|
})
|
||||||
|
export class EnemyEditComponent implements OnInit {
|
||||||
|
readonly Save = Save;
|
||||||
|
readonly ArrowLeft = ArrowLeft;
|
||||||
|
readonly Skull = Skull;
|
||||||
|
readonly Trash2 = Trash2;
|
||||||
|
|
||||||
|
campaignId: string | null = null;
|
||||||
|
enemyId: string | null = null;
|
||||||
|
|
||||||
|
name = '';
|
||||||
|
level = '';
|
||||||
|
folder = '';
|
||||||
|
/** Dossiers déjà utilisés dans la campagne (datalist d'auto-complétion). */
|
||||||
|
existingFolders: string[] = [];
|
||||||
|
portraitImageId: string | null = null;
|
||||||
|
headerImageId: string | null = null;
|
||||||
|
values: Record<string, string> = {};
|
||||||
|
imageValues: Record<string, string[]> = {};
|
||||||
|
keyValueValues: Record<string, Record<string, string>> = {};
|
||||||
|
templateFields: TemplateField[] = [];
|
||||||
|
private order = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private service: EnemyService,
|
||||||
|
private campaignService: CampaignService,
|
||||||
|
private gameSystemService: GameSystemService,
|
||||||
|
private campaignSidebar: CampaignSidebarService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const params = this.route.snapshot.paramMap;
|
||||||
|
this.campaignId = params.get('campaignId');
|
||||||
|
this.enemyId = params.get('enemyId');
|
||||||
|
|
||||||
|
if (this.campaignId) {
|
||||||
|
this.loadTemplateForCampaign(this.campaignId);
|
||||||
|
this.campaignSidebar.show(this.campaignId);
|
||||||
|
this.loadExistingFolders(this.campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.enemyId) {
|
||||||
|
this.service.getById(this.enemyId).subscribe({
|
||||||
|
next: (e) => {
|
||||||
|
this.name = e.name;
|
||||||
|
this.level = e.level ?? '';
|
||||||
|
this.folder = e.folder ?? '';
|
||||||
|
this.portraitImageId = e.portraitImageId ?? null;
|
||||||
|
this.headerImageId = e.headerImageId ?? null;
|
||||||
|
this.values = e.values ?? {};
|
||||||
|
this.imageValues = e.imageValues ?? {};
|
||||||
|
this.keyValueValues = e.keyValueValues ?? {};
|
||||||
|
this.order = e.order ?? 0;
|
||||||
|
},
|
||||||
|
error: () => this.back()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadExistingFolders(campaignId: string): void {
|
||||||
|
this.service.getByCampaign(campaignId).subscribe({
|
||||||
|
next: (list) => {
|
||||||
|
this.existingFolders = [...new Set(
|
||||||
|
list.map(e => (e.folder ?? '').trim()).filter(f => f.length > 0)
|
||||||
|
)].sort((a, b) => a.localeCompare(b, 'fr'));
|
||||||
|
},
|
||||||
|
error: () => { this.existingFolders = []; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadTemplateForCampaign(campaignId: string): void {
|
||||||
|
this.campaignService.getCampaignById(campaignId).subscribe({
|
||||||
|
next: (campaign) => {
|
||||||
|
if (!campaign.gameSystemId) {
|
||||||
|
this.templateFields = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.gameSystemService.getById(campaign.gameSystemId).subscribe({
|
||||||
|
next: (gs) => { this.templateFields = gs.enemyTemplate ?? []; },
|
||||||
|
error: () => { this.templateFields = []; }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => { this.templateFields = []; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (!this.name.trim() || !this.campaignId) return;
|
||||||
|
const payload = {
|
||||||
|
name: this.name.trim(),
|
||||||
|
level: this.level.trim() || null,
|
||||||
|
folder: this.folder.trim() || null,
|
||||||
|
portraitImageId: this.portraitImageId,
|
||||||
|
headerImageId: this.headerImageId,
|
||||||
|
values: this.values,
|
||||||
|
imageValues: this.imageValues,
|
||||||
|
keyValueValues: this.keyValueValues,
|
||||||
|
campaignId: this.campaignId
|
||||||
|
};
|
||||||
|
const isCreation = !this.enemyId;
|
||||||
|
const req = this.enemyId
|
||||||
|
? this.service.update(this.enemyId, { ...payload, id: this.enemyId, order: this.order })
|
||||||
|
: this.service.create(payload);
|
||||||
|
req.subscribe({
|
||||||
|
next: (saved) => {
|
||||||
|
if (isCreation && saved.id) {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId, 'enemies', saved.id]);
|
||||||
|
} else {
|
||||||
|
this.back();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => console.error('Erreur sauvegarde Enemy')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEnemy(): void {
|
||||||
|
if (!this.enemyId) return;
|
||||||
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer la fiche ?',
|
||||||
|
message: `Supprimer la fiche de "${this.name}" ?`,
|
||||||
|
details: ['Cette action est irreversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok || !this.enemyId) return;
|
||||||
|
this.service.delete(this.enemyId).subscribe({
|
||||||
|
next: () => this.back(),
|
||||||
|
error: () => console.error('Erreur suppression Enemy')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
back(): void {
|
||||||
|
if (this.campaignId) {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId, 'enemies']);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/campaigns']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<div class="sbl-page">
|
||||||
|
<div class="sbl-toolbar">
|
||||||
|
<button class="btn-back" (click)="back()">
|
||||||
|
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
|
||||||
|
</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn-create" (click)="create()">
|
||||||
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Nouvel ennemi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header class="sbl-header">
|
||||||
|
<h1><lucide-icon [img]="Skull" [size]="22"></lucide-icon> Ennemis</h1>
|
||||||
|
<p class="sbl-hint">
|
||||||
|
Le bestiaire de la campagne : monstres et créatures avec leur fiche (pilotée par le
|
||||||
|
template Ennemi du système de jeu), classés par dossier (Démons, Humanoïdes…).
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="sbl-list">
|
||||||
|
@if (total === 0) {
|
||||||
|
<p class="empty">Aucun ennemi pour l'instant.</p>
|
||||||
|
}
|
||||||
|
@for (g of groups; track g.folder) {
|
||||||
|
@if (g.folder) {
|
||||||
|
<h2 class="sbl-folder">
|
||||||
|
<lucide-icon [img]="Folder" [size]="14"></lucide-icon>
|
||||||
|
{{ g.folder }}
|
||||||
|
<span class="sbl-folder-count">{{ g.enemies.length }}</span>
|
||||||
|
</h2>
|
||||||
|
} @else if (groups.length > 1) {
|
||||||
|
<h2 class="sbl-folder sbl-folder--none">Non classés</h2>
|
||||||
|
}
|
||||||
|
@for (e of g.enemies; track e.id) {
|
||||||
|
<button class="sbl-item" (click)="open(e)">
|
||||||
|
<lucide-icon [img]="Skull" [size]="16"></lucide-icon>
|
||||||
|
<span class="sbl-item-name">{{ e.name }}</span>
|
||||||
|
@if (e.level) {
|
||||||
|
<span class="sbl-item-level">Niv. {{ e.level }}</span>
|
||||||
|
}
|
||||||
|
<span class="sbl-del" (click)="remove(e, $event)" title="Supprimer">
|
||||||
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
.sbl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
|
||||||
|
|
||||||
|
.sbl-toolbar {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
button {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||||
|
padding: 0.4rem 0.8rem; border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
|
||||||
|
color: inherit; cursor: pointer; font-size: 0.85rem;
|
||||||
|
&:hover { background: rgba(255,255,255,0.09); }
|
||||||
|
}
|
||||||
|
.btn-create { background: #667eea; border-color: #667eea; color: #fff; }
|
||||||
|
.btn-create:hover { background: #5568d3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbl-header {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; }
|
||||||
|
.sbl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbl-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
|
||||||
|
|
||||||
|
// En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes.
|
||||||
|
.sbl-folder {
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
color: #a5b4fc;
|
||||||
|
|
||||||
|
.sbl-folder-count {
|
||||||
|
font-weight: 400; color: var(--color-text-muted, #9aa0aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sbl-folder--none { color: var(--color-text-muted, #9aa0aa); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbl-item {
|
||||||
|
display: flex; align-items: center; gap: 0.55rem;
|
||||||
|
padding: 0.7rem 0.85rem; border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
|
||||||
|
color: inherit; cursor: pointer; text-align: left;
|
||||||
|
&:hover { background: rgba(255,255,255,0.07); }
|
||||||
|
.sbl-item-name { flex: 1; font-weight: 500; }
|
||||||
|
.sbl-item-level {
|
||||||
|
font-size: 0.74rem; padding: 0.12rem 0.5rem; border-radius: 999px;
|
||||||
|
background: rgba(224,90,90,0.12); border: 1px solid rgba(224,90,90,0.35); color: #fca5a5;
|
||||||
|
}
|
||||||
|
.sbl-del { display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88;
|
||||||
|
&:hover { background: rgba(224,90,90,0.15); } }
|
||||||
|
}
|
||||||
107
web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts
Normal file
107
web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Skull, Folder } from 'lucide-angular';
|
||||||
|
import { EnemyService } from '../../../services/enemy.service';
|
||||||
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
|
import { Enemy } from '../../../services/enemy.model';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
|
/** Groupe d'affichage : un dossier (« Démons »…) et ses ennemis. */
|
||||||
|
interface FolderGroup {
|
||||||
|
folder: string;
|
||||||
|
enemies: Enemy[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste des ennemis d'une campagne, groupés par dossier (comme les PNJ).
|
||||||
|
* Route : /campaigns/:campaignId/enemies
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-enemy-list',
|
||||||
|
imports: [LucideAngularModule],
|
||||||
|
templateUrl: './enemy-list.component.html',
|
||||||
|
styleUrls: ['./enemy-list.component.scss']
|
||||||
|
})
|
||||||
|
export class EnemyListComponent implements OnInit {
|
||||||
|
readonly ArrowLeft = ArrowLeft;
|
||||||
|
readonly Plus = Plus;
|
||||||
|
readonly Trash2 = Trash2;
|
||||||
|
readonly Skull = Skull;
|
||||||
|
readonly Folder = Folder;
|
||||||
|
|
||||||
|
campaignId = '';
|
||||||
|
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
|
||||||
|
groups: FolderGroup[] = [];
|
||||||
|
total = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private service: EnemyService,
|
||||||
|
private campaignSidebar: CampaignSidebarService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? '';
|
||||||
|
if (this.campaignId) {
|
||||||
|
this.campaignSidebar.show(this.campaignId);
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.service.getByCampaign(this.campaignId).subscribe({
|
||||||
|
next: (list) => this.groups = this.groupByFolder(list),
|
||||||
|
error: () => this.groups = []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Même logique de groupement que les PNJ de la sidebar : dossiers triés, non-classés à la fin. */
|
||||||
|
private groupByFolder(enemies: Enemy[]): FolderGroup[] {
|
||||||
|
this.total = enemies.length;
|
||||||
|
const sorted = [...enemies].sort((a, b) => a.name.localeCompare(b.name, 'fr'));
|
||||||
|
const byFolder = new Map<string, Enemy[]>();
|
||||||
|
const ungrouped: Enemy[] = [];
|
||||||
|
for (const e of sorted) {
|
||||||
|
const f = (e.folder ?? '').trim();
|
||||||
|
if (f) {
|
||||||
|
if (!byFolder.has(f)) byFolder.set(f, []);
|
||||||
|
byFolder.get(f)!.push(e);
|
||||||
|
} else {
|
||||||
|
ungrouped.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const groups: FolderGroup[] = [...byFolder.keys()]
|
||||||
|
.sort((a, b) => a.localeCompare(b, 'fr'))
|
||||||
|
.map(folder => ({ folder, enemies: byFolder.get(folder)! }));
|
||||||
|
if (ungrouped.length) groups.push({ folder: '', enemies: ungrouped });
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId, 'enemies', 'create']);
|
||||||
|
}
|
||||||
|
|
||||||
|
open(e: Enemy): void {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId, 'enemies', e.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(e: Enemy, ev: Event): void {
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer l\'ennemi',
|
||||||
|
message: `Supprimer « ${e.name} » ?`,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
|
this.service.delete(e.id!).subscribe(() => this.load());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
back(): void {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<div class="nv-page">
|
||||||
|
<div class="nv-toolbar">
|
||||||
|
<button class="btn-back" (click)="back()">
|
||||||
|
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn-edit" (click)="edit()">
|
||||||
|
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
|
||||||
|
Editer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-persona-view [persona]="enemy" [templateFields]="templateFields" [subtitle]="subtitle"></app-persona-view>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
.nv-page {
|
||||||
|
padding: 16px 0 48px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 32px 16px;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back,
|
||||||
|
.btn-edit,
|
||||||
|
.btn-ai {
|
||||||
|
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;
|
||||||
|
transition: all 120ms;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit {
|
||||||
|
border-color: rgba(209, 168, 120, 0.4);
|
||||||
|
color: #d1a878;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(209, 168, 120, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ai.active {
|
||||||
|
background: rgba(168, 85, 247, 0.2);
|
||||||
|
border-color: rgba(168, 85, 247, 0.5);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { LucideAngularModule, ArrowLeft, Edit3 } from 'lucide-angular';
|
||||||
|
import { EnemyService } from '../../../services/enemy.service';
|
||||||
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
|
import { TemplateField } from '../../../services/template.model';
|
||||||
|
import { Enemy } from '../../../services/enemy.model';
|
||||||
|
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue lecture seule d'une fiche d'ennemi (même rendu « persona » que les PNJ).
|
||||||
|
* Route : /campaigns/:campaignId/enemies/:enemyId
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-enemy-view',
|
||||||
|
imports: [LucideAngularModule, PersonaViewComponent],
|
||||||
|
templateUrl: './enemy-view.component.html',
|
||||||
|
styleUrls: ['./enemy-view.component.scss']
|
||||||
|
})
|
||||||
|
export class EnemyViewComponent implements OnInit, OnDestroy {
|
||||||
|
readonly ArrowLeft = ArrowLeft;
|
||||||
|
readonly Edit3 = Edit3;
|
||||||
|
|
||||||
|
campaignId: string | null = null;
|
||||||
|
enemyId: string | null = null;
|
||||||
|
|
||||||
|
enemy: Enemy | null = null;
|
||||||
|
templateFields: TemplateField[] = [];
|
||||||
|
|
||||||
|
private paramsSub?: Subscription;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private service: EnemyService,
|
||||||
|
private campaignService: CampaignService,
|
||||||
|
private gameSystemService: GameSystemService,
|
||||||
|
private campaignSidebar: CampaignSidebarService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// paramMap (pas snapshot) : Angular réutilise le composant entre 2 ennemis.
|
||||||
|
this.paramsSub = this.route.paramMap.subscribe(params => {
|
||||||
|
const newCampaignId = params.get('campaignId');
|
||||||
|
this.enemyId = params.get('enemyId');
|
||||||
|
|
||||||
|
if (this.enemyId) {
|
||||||
|
this.service.getById(this.enemyId).subscribe({
|
||||||
|
next: e => { this.enemy = e; },
|
||||||
|
error: () => this.back()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newCampaignId && newCampaignId !== this.campaignId) {
|
||||||
|
this.campaignId = newCampaignId;
|
||||||
|
this.campaignSidebar.show(this.campaignId);
|
||||||
|
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
||||||
|
if (camp.gameSystemId) {
|
||||||
|
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
|
||||||
|
this.templateFields = gs.enemyTemplate ?? [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (newCampaignId) {
|
||||||
|
this.campaignId = newCampaignId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.paramsSub?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sous-titre de la fiche : niveau + dossier (« Niveau 8 · Démons »). */
|
||||||
|
get subtitle(): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (this.enemy?.level) parts.push(`Niveau ${this.enemy.level}`);
|
||||||
|
if (this.enemy?.folder) parts.push(this.enemy.folder);
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(): void {
|
||||||
|
if (this.campaignId && this.enemyId) {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId, 'enemies', this.enemyId, 'edit']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
back(): void {
|
||||||
|
if (this.campaignId) {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId, 'enemies']);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/campaigns']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,10 +103,19 @@ export class NotebookActionCardComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createNpc(): void {
|
private createNpc(): void {
|
||||||
|
// Valeurs de fiche proposées par l'IA (clés = champs du template PNJ) ;
|
||||||
|
// la description sert de repli si le modèle n'a pas détaillé par champ.
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
for (const [key, val] of Object.entries(this.action.values ?? {})) {
|
||||||
|
if (typeof val === 'string' && val.trim()) values[key] = val;
|
||||||
|
}
|
||||||
|
if (Object.keys(values).length === 0 && this.action.description) {
|
||||||
|
values['Description'] = this.action.description;
|
||||||
|
}
|
||||||
this.npcService.create({
|
this.npcService.create({
|
||||||
name: this.action.name,
|
name: this.action.name,
|
||||||
campaignId: this.campaignId,
|
campaignId: this.campaignId,
|
||||||
values: this.action.description ? { Description: this.action.description } : {}
|
values
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (n) => this.done(['/campaigns', this.campaignId, 'npcs', n.id!]),
|
next: (n) => this.done(['/campaigns', this.campaignId, 'npcs', n.id!]),
|
||||||
error: (e) => this.fail(e)
|
error: (e) => this.fail(e)
|
||||||
@@ -119,7 +128,12 @@ export class NotebookActionCardComponent implements OnInit {
|
|||||||
description: this.action.description,
|
description: this.action.description,
|
||||||
campaignId: this.campaignId,
|
campaignId: this.campaignId,
|
||||||
order: this.arcs.length,
|
order: this.arcs.length,
|
||||||
type: this.action.arcType === 'HUB' ? 'HUB' : 'LINEAR'
|
type: this.action.arcType === 'HUB' ? 'HUB' : 'LINEAR',
|
||||||
|
themes: this.action.themes,
|
||||||
|
stakes: this.action.stakes,
|
||||||
|
rewards: this.action.rewards,
|
||||||
|
resolution: this.action.resolution,
|
||||||
|
gmNotes: this.action.gmNotes
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (a) => this.done(['/campaigns', this.campaignId, 'arcs', a.id!]),
|
next: (a) => this.done(['/campaigns', this.campaignId, 'arcs', a.id!]),
|
||||||
error: (e) => this.fail(e)
|
error: (e) => this.fail(e)
|
||||||
@@ -132,7 +146,10 @@ export class NotebookActionCardComponent implements OnInit {
|
|||||||
name: this.action.name,
|
name: this.action.name,
|
||||||
description: this.action.description,
|
description: this.action.description,
|
||||||
arcId: this.selectedArcId,
|
arcId: this.selectedArcId,
|
||||||
order
|
order,
|
||||||
|
playerObjectives: this.action.playerObjectives,
|
||||||
|
narrativeStakes: this.action.narrativeStakes,
|
||||||
|
gmNotes: this.action.gmNotes
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (c) => this.done(['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', c.id!]),
|
next: (c) => this.done(['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', c.id!]),
|
||||||
error: (e) => this.fail(e)
|
error: (e) => this.fail(e)
|
||||||
@@ -143,9 +160,17 @@ export class NotebookActionCardComponent implements OnInit {
|
|||||||
this.campaignService.createScene({
|
this.campaignService.createScene({
|
||||||
name: this.action.name,
|
name: this.action.name,
|
||||||
description: this.action.description,
|
description: this.action.description,
|
||||||
playerNarration: this.action.content,
|
// `content` = ancien protocole (messages archivés d'avant l'enrichissement).
|
||||||
|
playerNarration: this.action.playerNarration ?? this.action.content,
|
||||||
chapterId: this.selectedChapterId,
|
chapterId: this.selectedChapterId,
|
||||||
order: 0
|
order: 0,
|
||||||
|
location: this.action.location,
|
||||||
|
timing: this.action.timing,
|
||||||
|
atmosphere: this.action.atmosphere,
|
||||||
|
gmSecretNotes: this.action.gmSecretNotes,
|
||||||
|
choicesConsequences: this.action.choicesConsequences,
|
||||||
|
combatDifficulty: this.action.combatDifficulty,
|
||||||
|
enemies: this.action.enemies
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (s) => this.done(
|
next: (s) => this.done(
|
||||||
['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', this.selectedChapterId, 'scenes', s.id!]),
|
['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', this.selectedChapterId, 'scenes', s.id!]),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,20 @@ 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 { EnemyService } from '../../../services/enemy.service';
|
||||||
|
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 +23,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 +38,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 +66,9 @@ 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 enemyService: EnemyService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -74,7 +82,7 @@ export class NotebookDetailComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private loadTree(): void {
|
private loadTree(): void {
|
||||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, undefined, this.enemyService)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; },
|
next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; },
|
||||||
error: () => { /* cibles indisponibles : les cartes le signaleront */ }
|
error: () => { /* cibles indisponibles : les cartes le signaleront */ }
|
||||||
@@ -137,13 +145,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 +218,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 +305,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;
|
||||||
|
|||||||
45
web/src/app/campaigns/npc/npc-list/npc-list.component.html
Normal file
45
web/src/app/campaigns/npc/npc-list/npc-list.component.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<div class="sbl-page">
|
||||||
|
<div class="sbl-toolbar">
|
||||||
|
<button class="btn-back" (click)="back()">
|
||||||
|
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
|
||||||
|
</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn-create" (click)="create()">
|
||||||
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Nouveau PNJ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header class="sbl-header">
|
||||||
|
<h1><lucide-icon [img]="Drama" [size]="22"></lucide-icon> PNJ</h1>
|
||||||
|
<p class="sbl-hint">
|
||||||
|
Tous les personnages non-joueurs de la campagne, classés par dossier.
|
||||||
|
Le champ « Dossier » de chaque fiche pilote le classement.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="sbl-list">
|
||||||
|
@if (total === 0) {
|
||||||
|
<p class="empty">Aucun PNJ pour l'instant.</p>
|
||||||
|
}
|
||||||
|
@for (g of groups; track g.folder) {
|
||||||
|
@if (g.folder) {
|
||||||
|
<h2 class="sbl-folder">
|
||||||
|
<lucide-icon [img]="Folder" [size]="14"></lucide-icon>
|
||||||
|
{{ g.folder }}
|
||||||
|
<span class="sbl-folder-count">{{ g.npcs.length }}</span>
|
||||||
|
</h2>
|
||||||
|
} @else if (groups.length > 1) {
|
||||||
|
<h2 class="sbl-folder sbl-folder--none">Non classés</h2>
|
||||||
|
}
|
||||||
|
@for (n of g.npcs; track n.id) {
|
||||||
|
<button class="sbl-item" (click)="open(n)">
|
||||||
|
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
|
||||||
|
<span class="sbl-item-name">{{ n.name }}</span>
|
||||||
|
<span class="sbl-del" (click)="remove(n, $event)" title="Supprimer">
|
||||||
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
53
web/src/app/campaigns/npc/npc-list/npc-list.component.scss
Normal file
53
web/src/app/campaigns/npc/npc-list/npc-list.component.scss
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
.sbl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
|
||||||
|
|
||||||
|
.sbl-toolbar {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
button {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||||
|
padding: 0.4rem 0.8rem; border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
|
||||||
|
color: inherit; cursor: pointer; font-size: 0.85rem;
|
||||||
|
&:hover { background: rgba(255,255,255,0.09); }
|
||||||
|
}
|
||||||
|
.btn-create { background: #667eea; border-color: #667eea; color: #fff; }
|
||||||
|
.btn-create:hover { background: #5568d3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbl-header {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; }
|
||||||
|
.sbl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbl-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
|
||||||
|
|
||||||
|
// En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes.
|
||||||
|
.sbl-folder {
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
color: #a5b4fc;
|
||||||
|
|
||||||
|
.sbl-folder-count {
|
||||||
|
font-weight: 400; color: var(--color-text-muted, #9aa0aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sbl-folder--none { color: var(--color-text-muted, #9aa0aa); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbl-item {
|
||||||
|
display: flex; align-items: center; gap: 0.55rem;
|
||||||
|
padding: 0.7rem 0.85rem; border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
|
||||||
|
color: inherit; cursor: pointer; text-align: left;
|
||||||
|
&:hover { background: rgba(255,255,255,0.07); }
|
||||||
|
.sbl-item-name { flex: 1; font-weight: 500; }
|
||||||
|
.sbl-item-level {
|
||||||
|
font-size: 0.74rem; padding: 0.12rem 0.5rem; border-radius: 999px;
|
||||||
|
background: rgba(224,90,90,0.12); border: 1px solid rgba(224,90,90,0.35); color: #fca5a5;
|
||||||
|
}
|
||||||
|
.sbl-del { display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88;
|
||||||
|
&:hover { background: rgba(224,90,90,0.15); } }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user