Compare commits
11 Commits
177bf6e781
...
v0.12.5-be
| Author | SHA1 | Date | |
|---|---|---|---|
| bc0cbb0f7b | |||
| 6740ed2177 | |||
| 8cc90bd24d | |||
| 14fc1c28fe | |||
| 7f519588b6 | |||
| 0799c850ec | |||
| 113df6a391 | |||
| a1f3b9b796 | |||
| cff2ceb0b9 | |||
| d1a11823bc | |||
| e1da369cfa |
@@ -5,6 +5,7 @@ port (LLM, embeddings, extracteur PDF), en fonction des Settings — modifiables
|
|||||||
à chaud depuis l'écran Paramètres de l'UI. Les routers ne connaissent que les
|
à chaud depuis l'écran Paramètres de l'UI. Les routers ne connaissent que les
|
||||||
ports et les use cases, jamais Ollama/Mistral/etc.
|
ports et les use cases, jamais Ollama/Mistral/etc.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException
|
from fastapi import Depends, HTTPException
|
||||||
@@ -29,11 +30,39 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
|||||||
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
|
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
|
||||||
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
|
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Extracteur PDF partagé : la détection OCR (version Tesseract) a un coût
|
# Extracteur PDF partagé : la détection OCR (version Tesseract) a un coût
|
||||||
# (subprocess) qu'on ne veut pas payer à chaque requête → singleton module.
|
# (subprocess) qu'on ne veut pas payer à chaque requête → singleton module.
|
||||||
_PDF_EXTRACTOR = PyMuPdfTextExtractor()
|
_PDF_EXTRACTOR = PyMuPdfTextExtractor()
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_import_chunk_tokens(settings: Settings) -> int:
|
||||||
|
"""Taille de morceau réellement utilisable pour l'import.
|
||||||
|
|
||||||
|
Avec Ollama, le morceau (entrée) ET sa réécriture en sections (sortie ≈ même
|
||||||
|
taille) doivent tenir ensemble dans `num_ctx` — sinon Ollama remplit la fenêtre
|
||||||
|
avec le prompt et la génération s'arrête après quelques tokens (JSON coupé net,
|
||||||
|
morceau perdu). Budget : entrée×~1.3 (les morceaux sont mesurés en tokens
|
||||||
|
cl100k, plus compacts que les tokenizers locaux) + consignes + sortie×~1.4
|
||||||
|
≤ num_ctx → morceau ≤ (num_ctx − 800) / 2.7. On plafonne, avec un log pour
|
||||||
|
rester transparent. Les providers cloud (gros contexte) ne sont pas plafonnés.
|
||||||
|
"""
|
||||||
|
requested = settings.import_chunk_tokens
|
||||||
|
if settings.llm_provider != "ollama":
|
||||||
|
return requested
|
||||||
|
cap = max(1000, int((settings.llm_num_ctx - 800) / 2.7))
|
||||||
|
if requested > cap:
|
||||||
|
logger.warning(
|
||||||
|
"Taille de morceau d'import réduite de %s à %s tokens : avec num_ctx=%s, "
|
||||||
|
"un morceau plus gros ne laisserait pas la place à la sortie du modèle "
|
||||||
|
"(génération coupée). Augmentez num_ctx pour utiliser de plus gros morceaux.",
|
||||||
|
requested, cap, settings.llm_num_ctx,
|
||||||
|
)
|
||||||
|
return cap
|
||||||
|
return requested
|
||||||
|
|
||||||
|
|
||||||
def get_llm_provider(
|
def get_llm_provider(
|
||||||
settings: Annotated[Settings, Depends(get_settings)],
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
) -> LLMProvider:
|
) -> LLMProvider:
|
||||||
@@ -82,8 +111,14 @@ def get_import_rules_use_case(
|
|||||||
settings: Annotated[Settings, Depends(get_settings)],
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
) -> ImportRulesUseCase:
|
) -> ImportRulesUseCase:
|
||||||
"""Factory du use case d'import de règles PDF (extraction + structuration)."""
|
"""Factory du use case d'import de règles PDF (extraction + structuration)."""
|
||||||
|
# Modèle LOCAL → mode segmentation : le LLM ne renvoie que les frontières des
|
||||||
|
# sections (~200 tokens) et le texte original est découpé localement. Réécrire
|
||||||
|
# tout le contenu à ~100 tokens/s prendrait des dizaines de minutes par livre.
|
||||||
|
# Les providers cloud (rapides, grand contexte) gardent la réécriture nettoyée.
|
||||||
return ImportRulesUseCase(
|
return ImportRulesUseCase(
|
||||||
llm=llm, extractor=_PDF_EXTRACTOR, chunk_target_tokens=settings.import_chunk_tokens)
|
llm=llm, extractor=_PDF_EXTRACTOR,
|
||||||
|
chunk_target_tokens=_effective_import_chunk_tokens(settings),
|
||||||
|
segment_only=settings.llm_provider == "ollama")
|
||||||
|
|
||||||
|
|
||||||
def get_import_campaign_use_case(
|
def get_import_campaign_use_case(
|
||||||
@@ -94,7 +129,7 @@ def get_import_campaign_use_case(
|
|||||||
return ImportCampaignUseCase(
|
return ImportCampaignUseCase(
|
||||||
llm=llm,
|
llm=llm,
|
||||||
extractor=_PDF_EXTRACTOR,
|
extractor=_PDF_EXTRACTOR,
|
||||||
chunk_target_tokens=settings.import_chunk_tokens,
|
chunk_target_tokens=_effective_import_chunk_tokens(settings),
|
||||||
map_concurrency=settings.llm_map_concurrency,
|
map_concurrency=settings.llm_map_concurrency,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.application.chunking import chunk_text, split_in_half
|
from app.application.chunking import chunk_text, split_in_half
|
||||||
|
from app.application.import_status import (
|
||||||
|
notify_status,
|
||||||
|
reset_status_queue,
|
||||||
|
set_status_queue,
|
||||||
|
)
|
||||||
from app.application.llm_json import load_json_object, looks_like_truncated_json
|
from app.application.llm_json import load_json_object, looks_like_truncated_json
|
||||||
from app.application.llm_retry import generate_with_retry
|
from app.application.llm_retry import generate_with_retry
|
||||||
from app.application.streaming import with_heartbeat
|
from app.application.streaming import with_heartbeat
|
||||||
@@ -29,7 +34,12 @@ from app.domain.models import (
|
|||||||
RoomProposal,
|
RoomProposal,
|
||||||
SceneProposal,
|
SceneProposal,
|
||||||
)
|
)
|
||||||
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
|
from app.domain.ports import (
|
||||||
|
LLMGenerationTimeout,
|
||||||
|
LLMProvider,
|
||||||
|
LLMProviderError,
|
||||||
|
PdfTextExtractor,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -107,6 +117,84 @@ Format de réponse :
|
|||||||
- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait.
|
- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait.
|
||||||
- Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}."""
|
- Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}."""
|
||||||
|
|
||||||
|
# Schéma de l'arbre attendu, passé aux providers à sorties structurées (Ollama
|
||||||
|
# contraint la grammaire : un modèle local ne PEUT plus produire de clés
|
||||||
|
# inventées, d'objets bavards type "thought" ni de texte hors JSON). Les
|
||||||
|
# adapters cloud le traduisent en mode JSON natif. Seuls les "name" sont
|
||||||
|
# requis : le _TreeMerger tolère déjà tous les champs absents.
|
||||||
|
_TREE_SCHEMA: dict = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"arcs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"type": {"type": "string", "enum": ["LINEAR", "HUB"]},
|
||||||
|
"chapters": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"scenes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"player_narration": {"type": "string"},
|
||||||
|
"gm_notes": {"type": "string"},
|
||||||
|
"rooms": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"enemies": {"type": "string"},
|
||||||
|
"loot": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"npcs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["arcs"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
# Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités
|
# Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités
|
||||||
# séparément, c'est CE référentiel commun qui garantit que tous nomment les
|
# séparément, c'est CE référentiel commun qui garantit que tous nomment les
|
||||||
# mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle
|
# mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle
|
||||||
@@ -427,6 +515,11 @@ class ImportCampaignUseCase:
|
|||||||
skipped = 0
|
skipped = 0
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
done_count = 0
|
done_count = 0
|
||||||
|
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
|
||||||
|
# publient des messages destinés à l'UI — cf. import_status.notify_status.
|
||||||
|
status_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
status_token = set_status_queue(status_queue)
|
||||||
|
try:
|
||||||
# PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
|
# PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
|
||||||
# appels simultanés. L'ordre narratif est préservé : la fusion se fait
|
# appels simultanés. L'ordre narratif est préservé : la fusion se fait
|
||||||
# vague par vague, dans l'ordre du livre.
|
# vague par vague, dans l'ordre du livre.
|
||||||
@@ -443,9 +536,12 @@ class ImportCampaignUseCase:
|
|||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
results: list | None = None
|
results: list | None = None
|
||||||
async for kind, payload in with_heartbeat(gathered):
|
async for kind, payload in with_heartbeat(gathered, status_queue=status_queue):
|
||||||
if kind == "heartbeat":
|
if kind == "heartbeat":
|
||||||
yield {"type": "heartbeat", "current": done_count + 1, "total": total}
|
yield {"type": "heartbeat", "current": done_count + 1, "total": total}
|
||||||
|
elif kind == "status":
|
||||||
|
yield {"type": "status", "message": payload,
|
||||||
|
"current": done_count + 1, "total": total}
|
||||||
else:
|
else:
|
||||||
results = payload
|
results = payload
|
||||||
for (i, _), res in zip(wave, results or []):
|
for (i, _), res in zip(wave, results or []):
|
||||||
@@ -480,13 +576,29 @@ class ImportCampaignUseCase:
|
|||||||
f"Dernier message : {last_error or 'inconnu'}"}
|
f"Dernier message : {last_error or 'inconnu'}"}
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if total > 0 and merger.counts()[0] == 0 and not merger.npcs():
|
||||||
|
# Le texte a été extrait mais le modèle n'a produit AUCUNE structure
|
||||||
|
# exploitable : sans ce signal, l'UI reçoit un `done` vide et
|
||||||
|
# l'utilisateur conclut à tort que le PDF est illisible.
|
||||||
|
yield {"type": "error",
|
||||||
|
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
|
||||||
|
"aucune structure exploitable (réponses JSON vides ou coupées). "
|
||||||
|
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
|
||||||
|
"de contexte (num_ctx) ou essayez un autre modèle."}
|
||||||
|
return
|
||||||
|
|
||||||
# Consolidation finale : fusion des quasi-doublons inter-morceaux
|
# Consolidation finale : fusion des quasi-doublons inter-morceaux
|
||||||
# (best-effort, voir _consolidate). Inutile sur un import mono-morceau.
|
# (best-effort, voir _consolidate). Inutile sur un import mono-morceau.
|
||||||
if total > 1:
|
if total > 1:
|
||||||
yield {"type": "consolidating", "total": total}
|
yield {"type": "consolidating", "total": total}
|
||||||
async for kind, _ in with_heartbeat(self._consolidate(merger)):
|
async for kind, payload in with_heartbeat(
|
||||||
|
self._consolidate(merger), status_queue=status_queue
|
||||||
|
):
|
||||||
if kind == "heartbeat":
|
if kind == "heartbeat":
|
||||||
yield {"type": "heartbeat", "current": total, "total": total}
|
yield {"type": "heartbeat", "current": total, "total": total}
|
||||||
|
elif kind == "status":
|
||||||
|
yield {"type": "status", "message": payload,
|
||||||
|
"current": total, "total": total}
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "done",
|
"type": "done",
|
||||||
@@ -496,6 +608,8 @@ class ImportCampaignUseCase:
|
|||||||
"ocr_page_count": doc.ocr_page_count,
|
"ocr_page_count": doc.ocr_page_count,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
}
|
}
|
||||||
|
finally:
|
||||||
|
reset_status_queue(status_token)
|
||||||
|
|
||||||
# --- Consolidation finale (fusion des quasi-doublons) ---------------------
|
# --- Consolidation finale (fusion des quasi-doublons) ---------------------
|
||||||
|
|
||||||
@@ -557,8 +671,29 @@ class ImportCampaignUseCase:
|
|||||||
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
|
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
|
||||||
"Renvoie maintenant le JSON de l'arborescence."
|
"Renvoie maintenant le JSON de l'arborescence."
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
raw = await generate_with_retry(
|
raw = await generate_with_retry(
|
||||||
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
|
self._llm, prompt, output_format=_TREE_SCHEMA, temperature=_TEMPERATURE)
|
||||||
|
except LLMGenerationTimeout:
|
||||||
|
# Génération trop lente pour la taille demandée (fréquent en local /
|
||||||
|
# tier gratuit) : même remède que la troncature, deux moitiés →
|
||||||
|
# sortie 2× plus courte. Re-lever si plus découpable.
|
||||||
|
if depth >= _MAX_SPLIT_DEPTH:
|
||||||
|
raise
|
||||||
|
left, right = split_in_half(text)
|
||||||
|
if not left or not right:
|
||||||
|
raise
|
||||||
|
logger.info(
|
||||||
|
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
|
||||||
|
index, depth + 1)
|
||||||
|
notify_status(
|
||||||
|
f"Le modèle est trop lent sur le morceau {index + 1} : "
|
||||||
|
"re-découpage en 2 moitiés plus digestes…")
|
||||||
|
a = await self._extract_payload(
|
||||||
|
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
||||||
|
b = await self._extract_payload(
|
||||||
|
right, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
||||||
|
return {"arcs": a["arcs"] + b["arcs"], "npcs": a["npcs"] + b["npcs"]}
|
||||||
payload, truncated = self._parse_payload(raw, index=index)
|
payload, truncated = self._parse_payload(raw, index=index)
|
||||||
|
|
||||||
if truncated and depth < _MAX_SPLIT_DEPTH:
|
if truncated and depth < _MAX_SPLIT_DEPTH:
|
||||||
@@ -567,6 +702,9 @@ class ImportCampaignUseCase:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
|
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
|
||||||
index, depth + 1)
|
index, depth + 1)
|
||||||
|
notify_status(
|
||||||
|
f"Réponse du modèle coupée sur le morceau {index + 1} : "
|
||||||
|
"re-découpage en 2 moitiés plus digestes…")
|
||||||
a = await self._extract_payload(
|
a = await self._extract_payload(
|
||||||
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
||||||
b = await self._extract_payload(
|
b = await self._extract_payload(
|
||||||
|
|||||||
@@ -13,8 +13,16 @@ Ne dépend que des abstractions du domaine (ports LLMProvider + PdfTextExtractor
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half
|
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half
|
||||||
|
from app.application.import_status import (
|
||||||
|
notify_status,
|
||||||
|
reset_status_queue,
|
||||||
|
set_status_queue,
|
||||||
|
)
|
||||||
from app.application.llm_json import load_json_object, looks_like_truncated_json
|
from app.application.llm_json import load_json_object, looks_like_truncated_json
|
||||||
from app.application.llm_retry import generate_with_retry
|
from app.application.llm_retry import generate_with_retry
|
||||||
from app.application.streaming import with_heartbeat
|
from app.application.streaming import with_heartbeat
|
||||||
@@ -25,7 +33,12 @@ from app.application.streaming import with_heartbeat
|
|||||||
# 1-2 niveaux suffisent en pratique, le reste est un garde-fou).
|
# 1-2 niveaux suffisent en pratique, le reste est un garde-fou).
|
||||||
_MAX_SPLIT_DEPTH = 3
|
_MAX_SPLIT_DEPTH = 3
|
||||||
from app.domain.models import RulesImportResult
|
from app.domain.models import RulesImportResult
|
||||||
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
|
from app.domain.ports import (
|
||||||
|
LLMGenerationTimeout,
|
||||||
|
LLMProvider,
|
||||||
|
LLMProviderError,
|
||||||
|
PdfTextExtractor,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,6 +47,16 @@ logger = logging.getLogger(__name__)
|
|||||||
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
|
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
|
||||||
_TEMPERATURE = 0.1
|
_TEMPERATURE = 0.1
|
||||||
|
|
||||||
|
# Schéma de la sortie attendue : objet PLAT {titre: markdown}. Passé tel quel à
|
||||||
|
# Ollama (structured outputs : la grammaire interdit physiquement les objets
|
||||||
|
# imbriqués, les clés "thought" à valeur non-string, le bavardage hors JSON…
|
||||||
|
# indispensable pour les petits modèles locaux qui ne suivent pas les consignes).
|
||||||
|
# Les adapters cloud le traduisent en mode JSON natif (json_object).
|
||||||
|
_SECTIONS_SCHEMA: dict = {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {"type": "string"},
|
||||||
|
}
|
||||||
|
|
||||||
# Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre
|
# Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre
|
||||||
# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent).
|
# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent).
|
||||||
# Le modèle reste libre d'en créer d'autres si rien ne correspond.
|
# Le modèle reste libre d'en créer d'autres si rien ne correspond.
|
||||||
@@ -56,9 +79,13 @@ On te donne un EXTRAIT brut d'un PDF de règles (texte parfois mal coupé par la
|
|||||||
|
|
||||||
Ta tâche : répartir le contenu de cet extrait dans des SECTIONS THÉMATIQUES.
|
Ta tâche : répartir le contenu de cet extrait dans des SECTIONS THÉMATIQUES.
|
||||||
|
|
||||||
|
Format EXACT attendu — un objet JSON plat {{titre de section: contenu markdown}} :
|
||||||
|
{{"Combat": "## Initiative\\n\\nChaque participant lance 1d20...", "Magie et sorts": "## Sorts\\n\\n..."}}
|
||||||
|
|
||||||
Règles impératives :
|
Règles impératives :
|
||||||
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
|
- Tu réponds UNIQUEMENT par cet objet JSON, sans texte avant ni après.
|
||||||
- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown.
|
- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown (chaîne de caractères, jamais un objet ou une liste).
|
||||||
|
- INTERDIT : des clés génériques comme "title", "content", "sections", "thought" ou "notes" ; des objets imbriqués ; tout commentaire sur ta démarche ou ton raisonnement.
|
||||||
- Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond :
|
- Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond :
|
||||||
{canonical}
|
{canonical}
|
||||||
- Si un contenu ne rentre dans aucun, crée un titre clair et concis (en français).
|
- Si un contenu ne rentre dans aucun, crée un titre clair et concis (en français).
|
||||||
@@ -67,6 +94,55 @@ Règles impératives :
|
|||||||
- N'INVENTE AUCUNE règle, ne résume pas abusivement : tu réorganises, tu ne réécris pas le fond.
|
- N'INVENTE AUCUNE règle, ne résume pas abusivement : tu réorganises, tu ne réécris pas le fond.
|
||||||
- Ignore les pages de garde, sommaires, crédits, pages vides (renvoie {{}} si l'extrait n'a aucune règle)."""
|
- Ignore les pages de garde, sommaires, crédits, pages vides (renvoie {{}} si l'extrait n'a aucune règle)."""
|
||||||
|
|
||||||
|
# --- Mode SEGMENTATION (modèles locaux) --------------------------------------
|
||||||
|
# Réécrire tout le texte en JSON impose une SORTIE ≈ taille de l'ENTRÉE : à
|
||||||
|
# ~100 tokens/s en local, un livre = des dizaines de minutes et des troncatures
|
||||||
|
# en cascade. Ici le modèle ne renvoie que les FRONTIÈRES des sections (titre +
|
||||||
|
# premiers mots exacts) — ~200 tokens quel que soit le morceau — et c'est NOUS
|
||||||
|
# qui découpons le texte original. ~50× plus rapide, fidélité parfaite du
|
||||||
|
# contenu (texte source intact), plus de troncature possible.
|
||||||
|
|
||||||
|
_SEGMENT_SYSTEM = """Tu analyses un EXTRAIT brut d'un livre de règles de jeu de rôle.
|
||||||
|
Ta tâche : repérer où COMMENCENT les sections thématiques. Tu ne réécris RIEN.
|
||||||
|
|
||||||
|
Format EXACT attendu :
|
||||||
|
{{"sections": [{{"titre": "Combat", "debut": "Le combat se déroule en tours de"}}, ...]}}
|
||||||
|
|
||||||
|
Règles impératives :
|
||||||
|
- "debut" = les 5 à 10 PREMIERS MOTS du passage où la section commence, COPIÉS À L'IDENTIQUE
|
||||||
|
depuis l'extrait (même orthographe, même ponctuation, même langue). JAMAIS un résumé.
|
||||||
|
- La PREMIÈRE entrée commence aux tout premiers mots de l'extrait (même si le contenu
|
||||||
|
poursuit une section entamée avant cet extrait).
|
||||||
|
- Les entrées suivent l'ordre du texte. Vise des sections LARGES (un thème), pas un titre
|
||||||
|
par paragraphe : un extrait contient typiquement 1 à 6 sections.
|
||||||
|
- Titres : EN PRIORITÉ parmi :
|
||||||
|
{canonical}
|
||||||
|
sinon un titre court et clair en français.
|
||||||
|
- Pages de garde, sommaires, crédits : n'en fais pas des sections. Si l'extrait n'est que ça,
|
||||||
|
renvoie {{"sections": []}}."""
|
||||||
|
|
||||||
|
# Schéma passé à Ollama (structured outputs) : un objet {"sections": [...]}.
|
||||||
|
# Racine objet (pas tableau) car l'extraction côté Brain repère le premier {…}.
|
||||||
|
_ANCHORS_SCHEMA: dict = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sections": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"titre": {"type": "string"},
|
||||||
|
"debut": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["titre", "debut"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["sections"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class _SectionMerger:
|
class _SectionMerger:
|
||||||
"""Fusionne les sections issues des différents morceaux, ordre préservé.
|
"""Fusionne les sections issues des différents morceaux, ordre préservé.
|
||||||
@@ -102,6 +178,84 @@ class _SectionMerger:
|
|||||||
return {title: "\n\n".join(parts) for title, parts in self._merged.items()}
|
return {title: "\n\n".join(parts) for title, parts in self._merged.items()}
|
||||||
|
|
||||||
|
|
||||||
|
# Clés "méta" que certains modèles glissent dans le JSON (fuite de raisonnement,
|
||||||
|
# schéma title/content inventé…) : jamais des titres de section voulus.
|
||||||
|
_META_KEYS = frozenset({
|
||||||
|
"thought", "thoughts", "thinking", "reasoning", "raisonnement",
|
||||||
|
"comment", "commentaire", "commentaires", "note", "notes", "explanation",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sections(parsed: dict) -> dict:
|
||||||
|
"""Ramène les formes déviantes courantes au format attendu {titre: contenu}.
|
||||||
|
|
||||||
|
Observé sur les petits modèles locaux (gemma 12b) malgré les consignes :
|
||||||
|
- enveloppe {"sections": {...}} ou {"règles": {...}} autour du vrai contenu ;
|
||||||
|
- schéma inventé {"title": "...", "content": "...", "thought": "..."} →
|
||||||
|
une seule section dont le titre est la valeur de "title" ;
|
||||||
|
- clés méta ("thought", "notes"…) mêlées aux vraies sections → retirées.
|
||||||
|
"""
|
||||||
|
by_lower = {str(k).strip().lower(): k for k in parsed}
|
||||||
|
# Enveloppe : un unique conteneur connu dont la valeur est l'objet attendu.
|
||||||
|
if len(parsed) == 1:
|
||||||
|
only_key, only_val = next(iter(parsed.items()))
|
||||||
|
if (isinstance(only_val, dict)
|
||||||
|
and str(only_key).strip().lower() in {"sections", "règles", "regles", "rules"}):
|
||||||
|
return _normalize_sections(only_val)
|
||||||
|
# Schéma {"title": ..., "content": ...} : le titre est une VALEUR, pas une clé.
|
||||||
|
if "title" in by_lower and "content" in by_lower:
|
||||||
|
title = str(parsed[by_lower["title"]]).strip()
|
||||||
|
content = parsed[by_lower["content"]]
|
||||||
|
if title and not isinstance(content, dict):
|
||||||
|
return {title: content}
|
||||||
|
return {k: v for k, v in parsed.items()
|
||||||
|
if str(k).strip().lower() not in _META_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_markdown(value: object) -> str:
|
||||||
|
"""Convertit une valeur de section renvoyée par le LLM en markdown plat.
|
||||||
|
|
||||||
|
Malgré la consigne « valeurs = markdown », certains modèles nichent des
|
||||||
|
sous-sections ({titre: {sous-titre: contenu}}) ou des listes. Un `str(v)`
|
||||||
|
naïf produirait du repr Python ({'k': 'v'}) ; on aplatit récursivement à la
|
||||||
|
place pour ne perdre aucun contenu.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
parts = []
|
||||||
|
for k, v in value.items():
|
||||||
|
content = _coerce_markdown(v)
|
||||||
|
# Clé = sous-titre (cas normal) ; si la "valeur" est vide, la clé
|
||||||
|
# elle-même porte le contenu (dérive observée sur certains modèles).
|
||||||
|
parts.append(f"{k}\n\n{content}".strip() if content else str(k))
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return "\n\n".join(_coerce_markdown(v) for v in value)
|
||||||
|
return "" if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_anchor(text: str, anchor: str, start: int) -> int | None:
|
||||||
|
"""Position de `anchor` dans `text` à partir de `start`, ou None.
|
||||||
|
|
||||||
|
Le modèle recopie les premiers mots d'un passage, mais le texte extrait du
|
||||||
|
PDF contient des sauts de ligne/espaces multiples au même endroit, et le
|
||||||
|
modèle normalise parfois la casse. Trois passes, de la plus stricte à la
|
||||||
|
plus tolérante : exacte → espaces≈\\s+ → idem insensible à la casse."""
|
||||||
|
pos = text.find(anchor, start)
|
||||||
|
if pos != -1:
|
||||||
|
return pos
|
||||||
|
words = anchor.split()
|
||||||
|
if not words:
|
||||||
|
return None
|
||||||
|
pattern = r"\s+".join(re.escape(w) for w in words)
|
||||||
|
match = re.compile(pattern).search(text, start)
|
||||||
|
if match:
|
||||||
|
return match.start()
|
||||||
|
match = re.compile(pattern, re.IGNORECASE).search(text, start)
|
||||||
|
return match.start() if match else None
|
||||||
|
|
||||||
|
|
||||||
def _combine_sections(a: dict[str, str], b: dict[str, str]) -> dict[str, str]:
|
def _combine_sections(a: dict[str, str], b: dict[str, str]) -> dict[str, str]:
|
||||||
"""Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé).
|
"""Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé).
|
||||||
|
|
||||||
@@ -128,10 +282,16 @@ class ImportRulesUseCase:
|
|||||||
llm: LLMProvider,
|
llm: LLMProvider,
|
||||||
extractor: PdfTextExtractor,
|
extractor: PdfTextExtractor,
|
||||||
chunk_target_tokens: int = CHUNK_TARGET_TOKENS,
|
chunk_target_tokens: int = CHUNK_TARGET_TOKENS,
|
||||||
|
segment_only: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""`segment_only=True` (modèles locaux) : le LLM ne renvoie que les
|
||||||
|
frontières des sections (titre + premiers mots) et le texte original est
|
||||||
|
découpé localement — sortie minuscule, pas de réécriture. False (cloud) :
|
||||||
|
le LLM réécrit le contenu en sections markdown nettoyées."""
|
||||||
self._llm = llm
|
self._llm = llm
|
||||||
self._extractor = extractor
|
self._extractor = extractor
|
||||||
self._chunk_target_tokens = chunk_target_tokens
|
self._chunk_target_tokens = chunk_target_tokens
|
||||||
|
self._segment_only = segment_only
|
||||||
|
|
||||||
async def execute(self, pdf_bytes: bytes) -> RulesImportResult:
|
async def execute(self, pdf_bytes: bytes) -> RulesImportResult:
|
||||||
"""Variante non-streamée : traite tout puis renvoie le résultat complet."""
|
"""Variante non-streamée : traite tout puis renvoie le résultat complet."""
|
||||||
@@ -179,6 +339,11 @@ class ImportRulesUseCase:
|
|||||||
merger = _SectionMerger()
|
merger = _SectionMerger()
|
||||||
skipped = 0
|
skipped = 0
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
|
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
|
||||||
|
# publient des messages destinés à l'UI — cf. import_status.notify_status.
|
||||||
|
status_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
status_token = set_status_queue(status_queue)
|
||||||
|
try:
|
||||||
for i, chunk in enumerate(chunks):
|
for i, chunk in enumerate(chunks):
|
||||||
# RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
|
# RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
|
||||||
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
|
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
|
||||||
@@ -188,10 +353,14 @@ class ImportRulesUseCase:
|
|||||||
try:
|
try:
|
||||||
sections: dict[str, str] | None = None
|
sections: dict[str, str] | None = None
|
||||||
async for kind, payload in with_heartbeat(
|
async for kind, payload in with_heartbeat(
|
||||||
self._map_chunk(chunk, index=i, total=total)
|
self._map_chunk(chunk, index=i, total=total),
|
||||||
|
status_queue=status_queue,
|
||||||
):
|
):
|
||||||
if kind == "heartbeat":
|
if kind == "heartbeat":
|
||||||
yield {"type": "heartbeat", "current": i + 1, "total": total}
|
yield {"type": "heartbeat", "current": i + 1, "total": total}
|
||||||
|
elif kind == "status":
|
||||||
|
yield {"type": "status", "message": payload,
|
||||||
|
"current": i + 1, "total": total}
|
||||||
else:
|
else:
|
||||||
sections = payload
|
sections = payload
|
||||||
new_titles = merger.add(sections or {})
|
new_titles = merger.add(sections or {})
|
||||||
@@ -208,6 +377,8 @@ class ImportRulesUseCase:
|
|||||||
"new_sections": new_titles,
|
"new_sections": new_titles,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
}
|
}
|
||||||
|
finally:
|
||||||
|
reset_status_queue(status_token)
|
||||||
|
|
||||||
if total > 0 and skipped == total:
|
if total > 0 and skipped == total:
|
||||||
yield {"type": "error",
|
yield {"type": "error",
|
||||||
@@ -215,9 +386,21 @@ class ImportRulesUseCase:
|
|||||||
f"Dernier message : {last_error or 'inconnu'}"}
|
f"Dernier message : {last_error or 'inconnu'}"}
|
||||||
return
|
return
|
||||||
|
|
||||||
|
sections = merger.result()
|
||||||
|
if total > 0 and not sections:
|
||||||
|
# Le texte a bien été extrait mais AUCUN morceau n'a produit de JSON
|
||||||
|
# exploitable (sorties coupées/illisibles). Sans ce signal, l'UI reçoit
|
||||||
|
# un `done` vide et l'utilisateur conclut à tort que le PDF est illisible.
|
||||||
|
yield {"type": "error",
|
||||||
|
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
|
||||||
|
"aucune section exploitable (réponses JSON vides ou coupées). "
|
||||||
|
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
|
||||||
|
"de contexte (num_ctx) ou essayez un autre modèle."}
|
||||||
|
return
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "done",
|
"type": "done",
|
||||||
"sections": merger.result(),
|
"sections": sections,
|
||||||
"page_count": doc.page_count,
|
"page_count": doc.page_count,
|
||||||
"ocr_page_count": doc.ocr_page_count,
|
"ocr_page_count": doc.ocr_page_count,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
@@ -234,15 +417,39 @@ class ImportRulesUseCase:
|
|||||||
"""Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le
|
"""Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le
|
||||||
texte en DEUX moitiés (chacune produit une réponse complète) et fusionne —
|
texte en DEUX moitiés (chacune produit une réponse complète) et fusionne —
|
||||||
ainsi aucune section n'est perdue, quel que soit le plafond de sortie."""
|
ainsi aucune section n'est perdue, quel que soit le plafond de sortie."""
|
||||||
|
system = _SEGMENT_SYSTEM if self._segment_only else _MAP_SYSTEM
|
||||||
|
schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA
|
||||||
prompt = (
|
prompt = (
|
||||||
_MAP_SYSTEM.format(
|
system.format(
|
||||||
canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS)
|
canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS)
|
||||||
)
|
)
|
||||||
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
|
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
|
||||||
"Renvoie maintenant le JSON des sections."
|
"Renvoie maintenant le JSON des sections."
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
raw = await generate_with_retry(
|
raw = await generate_with_retry(
|
||||||
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
|
self._llm, prompt, output_format=schema, temperature=_TEMPERATURE)
|
||||||
|
except LLMGenerationTimeout:
|
||||||
|
# Le modèle générait mais trop lentement pour réécrire tout le morceau
|
||||||
|
# dans le temps imparti (fréquent sur tier gratuit + gros morceaux).
|
||||||
|
# Même remède que la troncature : deux moitiés → sortie 2× plus courte.
|
||||||
|
if depth >= _MAX_SPLIT_DEPTH:
|
||||||
|
raise
|
||||||
|
left, right = split_in_half(text)
|
||||||
|
if not left or not right:
|
||||||
|
raise
|
||||||
|
logger.info(
|
||||||
|
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
|
||||||
|
index, depth + 1)
|
||||||
|
notify_status(
|
||||||
|
f"Le modèle est trop lent sur le morceau {index + 1} : "
|
||||||
|
"re-découpage en 2 moitiés plus digestes…")
|
||||||
|
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
|
||||||
|
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
|
||||||
|
return _combine_sections(a, b)
|
||||||
|
if self._segment_only:
|
||||||
|
sections, truncated = self._parse_anchors(raw, text, index=index)
|
||||||
|
else:
|
||||||
sections, truncated = self._parse_sections(raw, index=index)
|
sections, truncated = self._parse_sections(raw, index=index)
|
||||||
|
|
||||||
if truncated and depth < _MAX_SPLIT_DEPTH:
|
if truncated and depth < _MAX_SPLIT_DEPTH:
|
||||||
@@ -251,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)
|
||||||
@@ -259,6 +469,68 @@ class ImportRulesUseCase:
|
|||||||
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
|
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
|
||||||
return sections
|
return sections
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_anchors(raw: str, text: str, *, index: int) -> tuple[dict[str, str], bool]:
|
||||||
|
"""Mode segmentation : réponse {"sections": [{titre, debut}, …]} → on localise
|
||||||
|
chaque `debut` dans le texte ORIGINAL et on découpe entre les ancres.
|
||||||
|
|
||||||
|
Une ancre introuvable est abandonnée (son contenu reste dans la section
|
||||||
|
précédente — aucun texte n'est perdu). Le texte avant la première ancre
|
||||||
|
trouvée est rattaché à la première section (le prompt demande au modèle de
|
||||||
|
faire démarrer la première entrée aux premiers mots de l'extrait)."""
|
||||||
|
parsed, recovered = load_json_object(raw)
|
||||||
|
if parsed is None:
|
||||||
|
truncated = looks_like_truncated_json(raw)
|
||||||
|
if not truncated:
|
||||||
|
logger.warning(
|
||||||
|
"Morceau %s : aucun objet JSON exploitable (segmentation), ignoré. "
|
||||||
|
"Début de la réponse du modèle : %r",
|
||||||
|
index, (raw or "").strip()[:300] or "(réponse VIDE)")
|
||||||
|
return {}, truncated
|
||||||
|
entries = parsed.get("sections") if isinstance(parsed, dict) else None
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
logger.warning("Morceau %s : pas de liste 'sections' exploitable, ignoré.", index)
|
||||||
|
return {}, False
|
||||||
|
|
||||||
|
# Localisation séquentielle : chaque ancre est cherchée APRÈS la précédente
|
||||||
|
# (préserve l'ordre du texte, évite qu'une phrase répétée matche trop tôt).
|
||||||
|
located: list[tuple[str, int]] = []
|
||||||
|
cursor = 0
|
||||||
|
dropped = 0
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
title = str(entry.get("titre") or "").strip()
|
||||||
|
anchor = str(entry.get("debut") or "").strip()
|
||||||
|
if not title or not anchor:
|
||||||
|
continue
|
||||||
|
pos = _find_anchor(text, anchor, cursor)
|
||||||
|
if pos is None:
|
||||||
|
dropped += 1
|
||||||
|
continue
|
||||||
|
located.append((title, pos))
|
||||||
|
cursor = pos + 1
|
||||||
|
if dropped:
|
||||||
|
logger.info(
|
||||||
|
"Morceau %s : %s ancre(s) de section introuvable(s) — contenu rattaché "
|
||||||
|
"à la section précédente.", index, dropped)
|
||||||
|
if not located:
|
||||||
|
return {}, False
|
||||||
|
|
||||||
|
# Découpe entre ancres ; le préambule éventuel rejoint la première section.
|
||||||
|
located[0] = (located[0][0], 0)
|
||||||
|
sections: dict[str, str] = {}
|
||||||
|
for i, (title, start) in enumerate(located):
|
||||||
|
end = located[i + 1][1] if i + 1 < len(located) else len(text)
|
||||||
|
content = text[start:end].strip()
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
if title in sections:
|
||||||
|
sections[title] = f"{sections[title]}\n\n{content}"
|
||||||
|
else:
|
||||||
|
sections[title] = content
|
||||||
|
return sections, recovered
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]:
|
def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]:
|
||||||
"""Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle."""
|
"""Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle."""
|
||||||
@@ -276,4 +548,5 @@ class ImportRulesUseCase:
|
|||||||
if not isinstance(parsed, dict):
|
if not isinstance(parsed, dict):
|
||||||
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
|
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
|
||||||
return {}, False
|
return {}, False
|
||||||
return {str(k): str(v) for k, v in parsed.items()}, recovered
|
normalized = _normalize_sections(parsed)
|
||||||
|
return {str(k): _coerce_markdown(v) for k, v in normalized.items()}, recovered
|
||||||
|
|||||||
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)
|
||||||
@@ -38,13 +38,16 @@ def load_json_object(raw: str) -> tuple[object | None, bool]:
|
|||||||
obj = extract_json_object(raw)
|
obj = extract_json_object(raw)
|
||||||
if obj is not None:
|
if obj is not None:
|
||||||
try:
|
try:
|
||||||
return json.loads(obj), False
|
# strict=False : tolère les caractères de contrôle BRUTS (retours à la
|
||||||
|
# ligne non échappés…) dans les chaînes — erreur fréquente des LLM hors
|
||||||
|
# mode JSON natif, qui invalidait toute la réponse.
|
||||||
|
return json.loads(obj, strict=False), False
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
repaired = repair_truncated_json(raw)
|
repaired = repair_truncated_json(raw)
|
||||||
if repaired is not None:
|
if repaired is not None:
|
||||||
try:
|
try:
|
||||||
return json.loads(repaired), True
|
return json.loads(repaired, strict=False), True
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
return None, False
|
return None, False
|
||||||
@@ -54,12 +57,20 @@ def looks_like_truncated_json(raw: str) -> bool:
|
|||||||
"""La sortie ressemble-t-elle à un JSON COUPÉ (accolades/crochets non refermés)
|
"""La sortie ressemble-t-elle à un JSON COUPÉ (accolades/crochets non refermés)
|
||||||
plutôt qu'à de la prose ? Sert à déclencher un re-découpage même quand RIEN n'a
|
plutôt qu'à de la prose ? Sert à déclencher un re-découpage même quand RIEN n'a
|
||||||
pu être récupéré (cas où le 1er contenu est si long qu'il est coupé avant toute
|
pu être récupéré (cas où le 1er contenu est si long qu'il est coupé avant toute
|
||||||
sous-structure complète). On exige un contenu substantiel pour éviter les
|
sous-structure complète).
|
||||||
faux positifs sur une courte réponse non-JSON."""
|
|
||||||
s = (raw or "").strip()
|
Une réponse qui COMMENCE par `{` est jugée sur le seul équilibre des accolades,
|
||||||
if "{" not in s or len(s) < 100:
|
même très courte : en mode JSON un `{"` de 2 caractères est une génération
|
||||||
|
interrompue net (contexte plein, plafond de sortie), pas de la prose — c'est le
|
||||||
|
signal de re-découpage. Pour le reste (prose contenant des accolades), on exige
|
||||||
|
un contenu substantiel pour éviter les faux positifs."""
|
||||||
|
s = _strip_reasoning(raw or "").strip()
|
||||||
|
if "{" not in s:
|
||||||
return False
|
return False
|
||||||
return s.count("{") > s.count("}") or s.count("[") > s.count("]")
|
unbalanced = s.count("{") > s.count("}") or s.count("[") > s.count("]")
|
||||||
|
if s.startswith("{"):
|
||||||
|
return unbalanced
|
||||||
|
return len(s) >= 100 and unbalanced
|
||||||
|
|
||||||
|
|
||||||
def extract_json_object(raw: str) -> str | None:
|
def extract_json_object(raw: str) -> str | None:
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from app.domain.ports import LLMProvider, LLMProviderError
|
from app.application.import_status import notify_status
|
||||||
|
from app.domain.ports import LLMGenerationTimeout, LLMProvider, LLMProviderError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ async def generate_with_retry(
|
|||||||
llm: LLMProvider,
|
llm: LLMProvider,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
*,
|
*,
|
||||||
output_format: str | None = None,
|
output_format: str | dict | None = None,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff).
|
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff).
|
||||||
@@ -74,6 +75,12 @@ async def generate_with_retry(
|
|||||||
for attempt in range(_ATTEMPTS):
|
for attempt in range(_ATTEMPTS):
|
||||||
try:
|
try:
|
||||||
return await llm.generate(prompt, output_format=output_format, temperature=temperature)
|
return await llm.generate(prompt, output_format=output_format, temperature=temperature)
|
||||||
|
except LLMGenerationTimeout:
|
||||||
|
# Timeout de DÉBIT (génération trop lente pour la sortie demandée) :
|
||||||
|
# rejouer le même prompt re-timeoutera à l'identique — on a déjà perdu
|
||||||
|
# `timeout` secondes. On remonte tout de suite : l'appelant (import)
|
||||||
|
# sait re-découper le morceau en deux pour réduire la sortie.
|
||||||
|
raise
|
||||||
except LLMProviderError as exc:
|
except LLMProviderError as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
# Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite
|
# Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite
|
||||||
@@ -97,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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -24,17 +24,20 @@ class LLMProvider(Protocol):
|
|||||||
self,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
*,
|
*,
|
||||||
output_format: str | None = None,
|
output_format: str | dict | None = None,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Génère une réponse textuelle à partir d'un prompt donné.
|
"""Génère une réponse textuelle à partir d'un prompt donné.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompt: le texte envoyé au modèle.
|
prompt: le texte envoyé au modèle.
|
||||||
output_format: contrainte de format optionnelle. Exemple : "json"
|
output_format: contrainte de format optionnelle. "json" pour forcer
|
||||||
pour forcer le modèle à renvoyer du JSON valide. Les
|
un JSON valide ; un dict = SCHÉMA JSON décrivant la structure
|
||||||
fournisseurs qui ne supportent pas une valeur donnée doivent
|
attendue (les fournisseurs qui supportent les sorties
|
||||||
l'ignorer silencieusement ou la traduire au mieux.
|
structurées — ex. Ollama — contraignent la génération au schéma,
|
||||||
|
les autres retombent sur leur mode JSON natif). Les fournisseurs
|
||||||
|
qui ne supportent pas une valeur donnée doivent l'ignorer
|
||||||
|
silencieusement ou la traduire au mieux.
|
||||||
temperature: créativité du modèle, 0.0 (déterministe/factuel) à
|
temperature: créativité du modèle, 0.0 (déterministe/factuel) à
|
||||||
1.0+ (très créatif, hallucine plus facilement). None =
|
1.0+ (très créatif, hallucine plus facilement). None =
|
||||||
valeur par défaut de l'adapter. Recommandation LoreMind :
|
valeur par défaut de l'adapter. Recommandation LoreMind :
|
||||||
@@ -113,3 +116,14 @@ class LLMProviderError(Exception):
|
|||||||
Définie dans le domaine (pas dans l'infra) pour que les couches
|
Définie dans le domaine (pas dans l'infra) pour que les couches
|
||||||
supérieures puissent l'attraper sans connaître l'adapter concret.
|
supérieures puissent l'attraper sans connaître l'adapter concret.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMGenerationTimeout(LLMProviderError):
|
||||||
|
"""La génération a démarré mais n'a pas FINI dans le temps imparti.
|
||||||
|
|
||||||
|
Cas distinct d'un échec transitoire (file d'attente, 503) : le modèle
|
||||||
|
produisait des tokens mais trop lentement pour la taille de sortie demandée.
|
||||||
|
Réessayer à l'identique est inutile (même entrée → même lenteur) ; la bonne
|
||||||
|
réaction est de RÉDUIRE la sortie demandée (ex. import : re-découper le
|
||||||
|
morceau en deux moitiés).
|
||||||
|
"""
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import httpx
|
|||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.domain.models import ChatMessage
|
from app.domain.models import ChatMessage
|
||||||
from app.domain.ports import LLMProviderError
|
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ class GeminiLLMProvider:
|
|||||||
try:
|
try:
|
||||||
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
||||||
except asyncio.TimeoutError as exc:
|
except asyncio.TimeoutError as exc:
|
||||||
raise LLMProviderError(
|
raise LLMGenerationTimeout(
|
||||||
f"Erreur Gemini : génération non terminée en {self._timeout}s. Réduisez la "
|
f"Erreur Gemini : génération non terminée en {self._timeout}s. Réduisez la "
|
||||||
"taille des morceaux d'import ou augmentez le timeout."
|
"taille des morceaux d'import ou augmentez le timeout."
|
||||||
) from exc
|
) from exc
|
||||||
@@ -130,6 +130,12 @@ class GeminiLLMProvider:
|
|||||||
}
|
}
|
||||||
if temperature is not None:
|
if temperature is not None:
|
||||||
body["temperature"] = temperature
|
body["temperature"] = temperature
|
||||||
|
# Mode JSON natif (supporté par l'endpoint OpenAI-compatible de Gemini) :
|
||||||
|
# supprime fences ```json et JSON invalide, principale cause de morceaux
|
||||||
|
# ignorés. Un SCHÉMA (dict) est traduit en json_object : suffisant, les
|
||||||
|
# grands modèles cloud respectent la structure demandée par le prompt.
|
||||||
|
if output_format is not None:
|
||||||
|
body["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
try:
|
try:
|
||||||
@@ -138,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 "")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import httpx
|
|||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.domain.models import ChatMessage
|
from app.domain.models import ChatMessage
|
||||||
from app.domain.ports import LLMProviderError
|
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ class MistralLLMProvider:
|
|||||||
try:
|
try:
|
||||||
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
||||||
except asyncio.TimeoutError as exc:
|
except asyncio.TimeoutError as exc:
|
||||||
raise LLMProviderError(
|
raise LLMGenerationTimeout(
|
||||||
f"Erreur Mistral : génération non terminée en {self._timeout}s. Réduisez la "
|
f"Erreur Mistral : génération non terminée en {self._timeout}s. Réduisez la "
|
||||||
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
|
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
|
||||||
) from exc
|
) from exc
|
||||||
@@ -136,6 +136,13 @@ class MistralLLMProvider:
|
|||||||
}
|
}
|
||||||
if temperature is not None:
|
if temperature is not None:
|
||||||
body["temperature"] = temperature
|
body["temperature"] = temperature
|
||||||
|
# Mode JSON natif : TOUS les modèles Mistral le supportent → plus de fences
|
||||||
|
# ```json ni de JSON invalide (retours à la ligne bruts dans les chaînes),
|
||||||
|
# principale cause de morceaux d'import ignorés. Un SCHÉMA (dict) est
|
||||||
|
# traduit en json_object : suffisant ici, les grands modèles cloud
|
||||||
|
# respectent la structure demandée par le prompt.
|
||||||
|
if output_format is not None:
|
||||||
|
body["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ Isole le reste de l'application des spécificités du protocole Ollama
|
|||||||
demain, on écrit un nouvel adapter sans toucher au reste du code.
|
demain, on écrit un nouvel adapter sans toucher au reste du code.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from typing import AsyncIterator
|
from typing import AsyncIterator
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.domain.models import ChatMessage
|
from app.domain.models import ChatMessage
|
||||||
from app.domain.ports import LLMProviderError
|
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OllamaLLMProvider:
|
class OllamaLLMProvider:
|
||||||
@@ -45,7 +48,7 @@ class OllamaLLMProvider:
|
|||||||
self,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
*,
|
*,
|
||||||
output_format: str | None = None,
|
output_format: str | dict | None = None,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
url = f"{self._base_url}/api/generate"
|
url = f"{self._base_url}/api/generate"
|
||||||
@@ -55,6 +58,10 @@ class OllamaLLMProvider:
|
|||||||
"stream": False,
|
"stream": False,
|
||||||
"options": self._build_options(temperature),
|
"options": self._build_options(temperature),
|
||||||
}
|
}
|
||||||
|
# "json" (mode JSON simple) ou un SCHÉMA JSON complet (structured outputs) :
|
||||||
|
# Ollama contraint alors la grammaire de génération au schéma — un petit
|
||||||
|
# modèle local ne PEUT physiquement plus produire d'objets imbriqués, de
|
||||||
|
# clés "thought" bavardes ou de texte hors JSON.
|
||||||
if output_format is not None:
|
if output_format is not None:
|
||||||
payload["format"] = output_format
|
payload["format"] = output_format
|
||||||
|
|
||||||
@@ -71,12 +78,45 @@ class OllamaLLMProvider:
|
|||||||
raise LLMProviderError(
|
raise LLMProviderError(
|
||||||
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
|
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
|
||||||
)
|
)
|
||||||
|
except httpx.ConnectTimeout as exc:
|
||||||
|
# Serveur injoignable : erreur d'infrastructure, pas de lenteur.
|
||||||
|
raise LLMProviderError(
|
||||||
|
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||||
|
) from exc
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
# `stream: False` → le read-timeout court jusqu'à la réponse COMPLÈTE,
|
||||||
|
# donc le dépasser = génération trop lente pour la sortie demandée
|
||||||
|
# (fréquent : modèle local modeste + gros morceau d'import à réécrire).
|
||||||
|
# Type dédié → pas de retry à l'identique ; l'import re-découpe le
|
||||||
|
# morceau en deux moitiés (sortie 2× plus courte) à la place.
|
||||||
|
raise LLMGenerationTimeout(
|
||||||
|
f"Erreur Ollama : génération non terminée en {self._timeout}s. Réduisez "
|
||||||
|
"la taille des morceaux d'import, augmentez le timeout, ou utilisez un "
|
||||||
|
"modèle plus rapide."
|
||||||
|
) from exc
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise LLMProviderError(
|
raise LLMProviderError(
|
||||||
f"Erreur lors de l'appel à Ollama : {exc}"
|
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
return response.json()["response"]
|
data = response.json()
|
||||||
|
# Diagnostic crucial pour les imports : `done_reason` != "stop" signifie que
|
||||||
|
# la génération a été INTERROMPUE (fenêtre de contexte pleine, num_predict…)
|
||||||
|
# et non terminée par le modèle. Sans ce log, on ne voit qu'un JSON coupé
|
||||||
|
# en aval, sans la cause. `prompt_eval_count` révèle aussi la VRAIE taille
|
||||||
|
# du prompt en tokens du modèle (les morceaux sont mesurés en tokens
|
||||||
|
# cl100k, ~20-40% plus compacts que les tokenizers locaux).
|
||||||
|
done_reason = data.get("done_reason")
|
||||||
|
if done_reason and done_reason != "stop":
|
||||||
|
logger.warning(
|
||||||
|
"Ollama a interrompu la génération (done_reason=%s) : prompt=%s tokens, "
|
||||||
|
"sortie=%s tokens, num_ctx demandé=%s. Si prompt+sortie ≈ num_ctx, la "
|
||||||
|
"fenêtre de contexte est pleine : réduisez la taille des morceaux "
|
||||||
|
"d'import ou augmentez num_ctx (Paramètres).",
|
||||||
|
done_reason, data.get("prompt_eval_count"),
|
||||||
|
data.get("eval_count"), self._num_ctx,
|
||||||
|
)
|
||||||
|
return data["response"]
|
||||||
|
|
||||||
async def stream_chat(
|
async def stream_chat(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from app.core.config import Settings
|
|||||||
from app.domain.models import ChatMessage
|
from app.domain.models import ChatMessage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
from app.domain.ports import LLMProviderError
|
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||||||
|
|
||||||
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ class OpenRouterLLMProvider:
|
|||||||
try:
|
try:
|
||||||
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
||||||
except asyncio.TimeoutError as exc:
|
except asyncio.TimeoutError as exc:
|
||||||
raise LLMProviderError(
|
raise LLMGenerationTimeout(
|
||||||
f"Erreur {provider} : génération non terminée en {self._timeout}s. Réduisez la "
|
f"Erreur {provider} : génération non terminée en {self._timeout}s. Réduisez la "
|
||||||
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
|
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
|
||||||
) from exc
|
) from exc
|
||||||
|
|||||||
@@ -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.0-beta",
|
version="0.12.5-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.0-beta</version>
|
<version>0.12.5-beta</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -64,9 +64,12 @@ public class CampaignImportService {
|
|||||||
byte[] pdfBytes,
|
byte[] pdfBytes,
|
||||||
String filename,
|
String filename,
|
||||||
Consumer<CampaignImportProgress> onProgress,
|
Consumer<CampaignImportProgress> onProgress,
|
||||||
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<CampaignImportProposal> onDone,
|
Consumer<CampaignImportProposal> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
campaignPdfImporter.importCampaignStreaming(pdfBytes, filename, onProgress, onDone, onError);
|
campaignPdfImporter.importCampaignStreaming(
|
||||||
|
pdfBytes, filename, onProgress, onHeartbeat, onStatus, onDone, onError);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,7 +164,7 @@ public class CampaignImportService {
|
|||||||
isBlank(p.description())
|
isBlank(p.description())
|
||||||
? java.util.Map.of()
|
? java.util.Map.of()
|
||||||
: java.util.Map.of("Description", p.description().trim()),
|
: java.util.Map.of("Description", p.description().trim()),
|
||||||
null, null, campaignId, null, null));
|
null, null, campaignId, null, null, null));
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
return created;
|
return created;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
import com.loremind.domain.campaigncontext.Npc;
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -16,9 +19,11 @@ import java.util.Optional;
|
|||||||
public class NpcService {
|
public class NpcService {
|
||||||
|
|
||||||
private final NpcRepository npcRepository;
|
private final NpcRepository npcRepository;
|
||||||
|
private final CampaignRepository campaignRepository;
|
||||||
|
|
||||||
public NpcService(NpcRepository npcRepository) {
|
public NpcService(NpcRepository npcRepository, CampaignRepository campaignRepository) {
|
||||||
this.npcRepository = npcRepository;
|
this.npcRepository = npcRepository;
|
||||||
|
this.campaignRepository = campaignRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record NpcData(
|
public record NpcData(
|
||||||
@@ -29,6 +34,7 @@ public class NpcService {
|
|||||||
Map<String, List<String>> imageValues,
|
Map<String, List<String>> imageValues,
|
||||||
Map<String, Map<String, String>> keyValueValues,
|
Map<String, Map<String, String>> keyValueValues,
|
||||||
String campaignId,
|
String campaignId,
|
||||||
|
List<String> relatedPageIds,
|
||||||
String folder,
|
String folder,
|
||||||
Integer order
|
Integer order
|
||||||
) {}
|
) {}
|
||||||
@@ -45,6 +51,7 @@ public class NpcService {
|
|||||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||||
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
||||||
.campaignId(data.campaignId())
|
.campaignId(data.campaignId())
|
||||||
|
.relatedPageIds(data.relatedPageIds() != null ? new ArrayList<>(data.relatedPageIds()) : new ArrayList<>())
|
||||||
.folder(normalizeFolder(data.folder()))
|
.folder(normalizeFolder(data.folder()))
|
||||||
.order(order)
|
.order(order)
|
||||||
.build();
|
.build();
|
||||||
@@ -59,6 +66,21 @@ public class NpcService {
|
|||||||
return npcRepository.findByCampaignId(campaignId);
|
return npcRepository.findByCampaignId(campaignId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PNJ de TOUTES les campagnes liées au Lore donné (via {@code campaign.loreId}).
|
||||||
|
* Sert au graphe du Lore : relier les PNJ aux pages qu'ils référencent.
|
||||||
|
* Volume faible (usage mono-utilisateur) → filtrage en mémoire assumé.
|
||||||
|
*/
|
||||||
|
public List<Npc> getNpcsByLoreId(String loreId) {
|
||||||
|
List<Npc> out = new ArrayList<>();
|
||||||
|
for (Campaign campaign : campaignRepository.findAll()) {
|
||||||
|
if (campaign.isLinkedToLore() && campaign.getLoreId().equals(loreId)) {
|
||||||
|
out.addAll(npcRepository.findByCampaignId(campaign.getId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
public Npc updateNpc(String id, NpcData data) {
|
public Npc updateNpc(String id, NpcData data) {
|
||||||
Npc existing = npcRepository.findById(id)
|
Npc existing = npcRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
|
||||||
@@ -68,6 +90,7 @@ public class NpcService {
|
|||||||
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||||
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||||
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
||||||
|
existing.setRelatedPageIds(data.relatedPageIds() != null ? new ArrayList<>(data.relatedPageIds()) : new ArrayList<>());
|
||||||
existing.setFolder(normalizeFolder(data.folder()));
|
existing.setFolder(normalizeFolder(data.folder()));
|
||||||
if (data.order() != null) {
|
if (data.order() != null) {
|
||||||
existing.setOrder(data.order());
|
existing.setOrder(data.order());
|
||||||
|
|||||||
@@ -39,9 +39,12 @@ public class GameSystemService {
|
|||||||
byte[] pdfBytes,
|
byte[] pdfBytes,
|
||||||
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,
|
||||||
|
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(pdfBytes, filename, onProgress, onDone, onError);
|
rulesPdfImporter.importRulesStreaming(
|
||||||
|
pdfBytes, filename, onProgress, onHeartbeat, onStatus, onDone, onError);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ public class PageService {
|
|||||||
existing.setNodeId(changes.getNodeId());
|
existing.setNodeId(changes.getNodeId());
|
||||||
existing.setValues(CollectionUtils.copyMap(changes.getValues()));
|
existing.setValues(CollectionUtils.copyMap(changes.getValues()));
|
||||||
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
|
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
|
||||||
|
existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues()));
|
||||||
|
existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues()));
|
||||||
existing.setNotes(changes.getNotes());
|
existing.setNotes(changes.getNotes());
|
||||||
existing.setTags(CollectionUtils.copyList(changes.getTags()));
|
existing.setTags(CollectionUtils.copyList(changes.getTags()));
|
||||||
existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds()));
|
existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds()));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -46,6 +47,13 @@ public class Npc {
|
|||||||
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDs de Pages de Lore référencées par ce PNJ (sa ville, sa faction, sa
|
||||||
|
* région…). Référence faible cross-context, même principe que sur
|
||||||
|
* Arc/Chapter/Scene — alimente notamment le graphe du Lore.
|
||||||
|
*/
|
||||||
|
private List<String> relatedPageIds;
|
||||||
|
|
||||||
/** Dossier de classement (texte libre, ex. « Bard's Gate »). Nullable = non classé. */
|
/** Dossier de classement (texte libre, ex. « Bard's Gate »). Nullable = non classé. */
|
||||||
private String folder;
|
private String folder;
|
||||||
|
|
||||||
@@ -69,4 +77,9 @@ public class Npc {
|
|||||||
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
return keyValueValues;
|
return keyValueValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<String> getRelatedPageIds() {
|
||||||
|
if (relatedPageIds == null) relatedPageIds = new ArrayList<>();
|
||||||
|
return relatedPageIds;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ public interface CampaignPdfImporter {
|
|||||||
* l'avancement au fil de l'eau, puis la proposition finale.
|
* l'avancement au fil de l'eau, puis la proposition finale.
|
||||||
*
|
*
|
||||||
* @param onProgress invoqué à chaque étape (extraction, puis par morceau).
|
* @param onProgress invoqué à chaque étape (extraction, puis par morceau).
|
||||||
|
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
|
||||||
|
* avancée à afficher, mais le canal SSE vers le navigateur
|
||||||
|
* 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.
|
||||||
*/
|
*/
|
||||||
@@ -23,6 +30,8 @@ public interface CampaignPdfImporter {
|
|||||||
byte[] pdfBytes,
|
byte[] pdfBytes,
|
||||||
String filename,
|
String filename,
|
||||||
Consumer<CampaignImportProgress> onProgress,
|
Consumer<CampaignImportProgress> onProgress,
|
||||||
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<CampaignImportProposal> onDone,
|
Consumer<CampaignImportProposal> onDone,
|
||||||
Consumer<Throwable> onError);
|
Consumer<Throwable> onError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ public interface RulesPdfImporter {
|
|||||||
* d'exécution de l'adapter (synchrone jusqu'à {@code onDone}/{@code onError}).
|
* d'exécution de l'adapter (synchrone jusqu'à {@code onDone}/{@code onError}).
|
||||||
*
|
*
|
||||||
* @param onProgress invoqué à chaque étape (extraction, puis par morceau).
|
* @param onProgress invoqué à chaque étape (extraction, puis par morceau).
|
||||||
|
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
|
||||||
|
* avancée à afficher, mais le canal SSE vers le navigateur
|
||||||
|
* 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.
|
||||||
*/
|
*/
|
||||||
@@ -34,6 +41,8 @@ public interface RulesPdfImporter {
|
|||||||
byte[] pdfBytes,
|
byte[] pdfBytes,
|
||||||
String filename,
|
String filename,
|
||||||
Consumer<RulesImportProgress> onProgress,
|
Consumer<RulesImportProgress> onProgress,
|
||||||
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<RulesImportResult> onDone,
|
Consumer<RulesImportResult> onDone,
|
||||||
Consumer<Throwable> onError);
|
Consumer<Throwable> onError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,20 @@ public class Page {
|
|||||||
*/
|
*/
|
||||||
private Map<String, List<String>> imageValues;
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valeurs des champs KEY_VALUE_LIST (tableau libelle → valeur, comme sur les
|
||||||
|
* fiches de personnage) : fieldName → (label → valeur). Les labels sont
|
||||||
|
* definis par le Template ; seules les valeurs vivent sur la page.
|
||||||
|
*/
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valeurs des champs TABLE (colonnes figees au template, lignes libres) :
|
||||||
|
* fieldName → liste ordonnee de lignes, chaque ligne = colonne → cellule.
|
||||||
|
* Usage type : inventaire de boutique, table d'objets.
|
||||||
|
*/
|
||||||
|
private Map<String, List<Map<String, String>>> tableValues;
|
||||||
|
|
||||||
/** Notes privées du MJ (non exportées vers FoundryVTT). */
|
/** Notes privées du MJ (non exportées vers FoundryVTT). */
|
||||||
private String notes;
|
private String notes;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ package com.loremind.domain.shared.template;
|
|||||||
* - KEY_VALUE_LIST : liste de paires {label, value} avec labels figes au template
|
* - KEY_VALUE_LIST : liste de paires {label, value} avec labels figes au template
|
||||||
* (Map<String, Map<String, String>> : fieldName -> label -> value).
|
* (Map<String, Map<String, String>> : fieldName -> label -> value).
|
||||||
* Usage : stat blocks, listes de competences, traits.
|
* Usage : stat blocks, listes de competences, traits.
|
||||||
|
* - TABLE : tableau a colonnes figees au template (TemplateField.labels =
|
||||||
|
* noms de colonnes) et lignes LIBRES ajoutees au remplissage
|
||||||
|
* (Map<String, List<Map<String, String>>> : fieldName -> lignes,
|
||||||
|
* chaque ligne = colonne -> cellule).
|
||||||
|
* Usage : inventaire de boutique, tables d'objets, listes de prix.
|
||||||
* <p>
|
* <p>
|
||||||
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, REFERENCE...
|
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, REFERENCE...
|
||||||
*/
|
*/
|
||||||
@@ -16,5 +21,6 @@ public enum FieldType {
|
|||||||
TEXT,
|
TEXT,
|
||||||
IMAGE,
|
IMAGE,
|
||||||
NUMBER,
|
NUMBER,
|
||||||
KEY_VALUE_LIST
|
KEY_VALUE_LIST,
|
||||||
|
TABLE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ public class TemplateField {
|
|||||||
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
|
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
|
||||||
private ImageLayout layout;
|
private ImageLayout layout;
|
||||||
/**
|
/**
|
||||||
* Labels predefinis pour les champs KEY_VALUE_LIST (ordre significatif).
|
* Labels predefinis (ordre significatif), selon le type :
|
||||||
* Ex: ["FOR","DEX","CON","INT","SAG","CHA"] pour un champ "Caracteristiques".
|
* - KEY_VALUE_LIST : libelles des lignes. Ex: ["FOR","DEX","CON","INT","SAG","CHA"].
|
||||||
|
* - TABLE : noms des COLONNES. Ex: ["Objet","Prix","Description"].
|
||||||
* Null/vide pour les autres types.
|
* Null/vide pour les autres types.
|
||||||
*/
|
*/
|
||||||
private List<String> labels;
|
private List<String> labels;
|
||||||
@@ -70,4 +71,9 @@ public class TemplateField {
|
|||||||
public static TemplateField keyValueList(String name, List<String> labels) {
|
public static TemplateField keyValueList(String name, List<String> labels) {
|
||||||
return new TemplateField(name, FieldType.KEY_VALUE_LIST, null, labels);
|
return new TemplateField(name, FieldType.KEY_VALUE_LIST, null, labels);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Raccourci : construit un champ TABLE avec ses noms de colonnes. */
|
||||||
|
public static TemplateField table(String name, List<String> columns) {
|
||||||
|
return new TemplateField(name, FieldType.TABLE, null, columns);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
byte[] pdfBytes,
|
byte[] pdfBytes,
|
||||||
String filename,
|
String filename,
|
||||||
Consumer<CampaignImportProgress> onProgress,
|
Consumer<CampaignImportProgress> onProgress,
|
||||||
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<CampaignImportProposal> onDone,
|
Consumer<CampaignImportProposal> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
@@ -83,7 +85,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
flux
|
flux
|
||||||
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
||||||
.doOnNext(sse -> handleEvent(
|
.doOnNext(sse -> handleEvent(
|
||||||
sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError))
|
sse, pageCount, ocrPageCount, terminated,
|
||||||
|
onProgress, onHeartbeat, onStatus, onDone, onError))
|
||||||
.blockLast();
|
.blockLast();
|
||||||
if (!terminated[0]) {
|
if (!terminated[0]) {
|
||||||
onError.accept(new CampaignImportException(
|
onError.accept(new CampaignImportException(
|
||||||
@@ -107,12 +110,36 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
int[] ocrPageCount,
|
int[] ocrPageCount,
|
||||||
boolean[] terminated,
|
boolean[] terminated,
|
||||||
Consumer<CampaignImportProgress> onProgress,
|
Consumer<CampaignImportProgress> onProgress,
|
||||||
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<CampaignImportProposal> onDone,
|
Consumer<CampaignImportProposal> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
String event = sse.event();
|
String event = sse.event();
|
||||||
String data = sse.data() == null ? "" : sse.data();
|
String data = sse.data() == null ? "" : sse.data();
|
||||||
|
|
||||||
|
if ("heartbeat".equals(event)) {
|
||||||
|
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
|
||||||
|
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front.
|
||||||
|
onHeartbeat.run();
|
||||||
|
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(
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
byte[] pdfBytes,
|
byte[] pdfBytes,
|
||||||
String filename,
|
String filename,
|
||||||
Consumer<RulesImportProgress> onProgress,
|
Consumer<RulesImportProgress> onProgress,
|
||||||
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<RulesImportResult> onDone,
|
Consumer<RulesImportResult> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
@@ -139,7 +141,8 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
flux
|
flux
|
||||||
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
||||||
.doOnNext(sse -> handleEvent(
|
.doOnNext(sse -> handleEvent(
|
||||||
sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError))
|
sse, pageCount, ocrPageCount, terminated,
|
||||||
|
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]) {
|
||||||
@@ -165,12 +168,37 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
int[] ocrPageCount,
|
int[] ocrPageCount,
|
||||||
boolean[] terminated,
|
boolean[] terminated,
|
||||||
Consumer<RulesImportProgress> onProgress,
|
Consumer<RulesImportProgress> onProgress,
|
||||||
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<RulesImportResult> onDone,
|
Consumer<RulesImportResult> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
String event = sse.event();
|
String event = sse.event();
|
||||||
String data = sse.data() == null ? "" : sse.data();
|
String data = sse.data() == null ? "" : sse.data();
|
||||||
|
|
||||||
|
if ("heartbeat".equals(event)) {
|
||||||
|
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
|
||||||
|
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front
|
||||||
|
// resté silencieux pendant tout le traitement du morceau.
|
||||||
|
onHeartbeat.run();
|
||||||
|
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,51 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.converter;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.persistence.AttributeConverter;
|
||||||
|
import jakarta.persistence.Converter;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une Map<String, List<Map<String, String>>> en JSON et inversement.
|
||||||
|
* <p>
|
||||||
|
* Utilise pour Page.tableValues : pour chaque champ TABLE du template, stocke
|
||||||
|
* la liste ordonnee des LIGNES du tableau, chaque ligne etant une map
|
||||||
|
* colonne -> cellule. Exemple :
|
||||||
|
* {"Inventaire": [{"Objet":"Potion","Prix":"50 po"}, {"Objet":"Corde","Prix":"1 po"}]}
|
||||||
|
* <p>
|
||||||
|
* Adaptateur technique pur : le domaine ignore ce converter.
|
||||||
|
*/
|
||||||
|
@Converter
|
||||||
|
public class StringRowListMapJsonConverter
|
||||||
|
implements AttributeConverter<Map<String, List<Map<String, String>>>, String> {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final TypeReference<Map<String, List<Map<String, String>>>> TYPE_REF =
|
||||||
|
new TypeReference<>() {};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String convertToDatabaseColumn(Map<String, List<Map<String, String>>> attribute) {
|
||||||
|
if (attribute == null || attribute.isEmpty()) return "{}";
|
||||||
|
try {
|
||||||
|
return MAPPER.writeValueAsString(attribute);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Erreur serialisation Map<String, List<Map<String,String>>> -> JSON", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, List<Map<String, String>>> convertToEntityAttribute(String dbData) {
|
||||||
|
if (dbData == null || dbData.isBlank()) return Collections.emptyMap();
|
||||||
|
try {
|
||||||
|
return MAPPER.readValue(dbData, TYPE_REF);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Erreur deserialisation JSON -> Map<String, List<Map<String,String>>>", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,7 +86,7 @@ public class TemplateFieldListJsonConverter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
List<String> labels = null;
|
List<String> labels = null;
|
||||||
if (type == FieldType.KEY_VALUE_LIST) {
|
if (type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) {
|
||||||
JsonNode labelsNode = item.path("labels");
|
JsonNode labelsNode = item.path("labels");
|
||||||
if (labelsNode.isArray()) {
|
if (labelsNode.isArray()) {
|
||||||
labels = new ArrayList<>();
|
labels = new ArrayList<>();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.loremind.infrastructure.persistence.entity;
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
||||||
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||||
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||||
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
|
||||||
@@ -54,6 +55,11 @@ public class NpcJpaEntity {
|
|||||||
@Column(name = "campaign_id", nullable = false)
|
@Column(name = "campaign_id", nullable = false)
|
||||||
private Long campaignId;
|
private Long campaignId;
|
||||||
|
|
||||||
|
/** IDs de Pages de Lore référencées (référence faible cross-context). JSON TEXT. */
|
||||||
|
@Convert(converter = StringListJsonConverter.class)
|
||||||
|
@Column(name = "related_page_ids", columnDefinition = "TEXT")
|
||||||
|
private List<String> relatedPageIds;
|
||||||
|
|
||||||
@Column(name = "folder")
|
@Column(name = "folder")
|
||||||
private String folder;
|
private String folder;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.loremind.infrastructure.persistence.entity;
|
|||||||
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
||||||
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||||
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringRowListMapJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -52,6 +54,16 @@ public class PageJpaEntity {
|
|||||||
@Convert(converter = StringListMapJsonConverter.class)
|
@Convert(converter = StringListMapJsonConverter.class)
|
||||||
private Map<String, List<String>> imageValues;
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
/** Valeurs des champs KEY_VALUE_LIST : fieldName → (label → valeur). JSON TEXT. */
|
||||||
|
@Column(name = "key_value_values", columnDefinition = "TEXT")
|
||||||
|
@Convert(converter = StringMapMapJsonConverter.class)
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
|
||||||
|
/** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). JSON TEXT. */
|
||||||
|
@Column(name = "table_values", columnDefinition = "TEXT")
|
||||||
|
@Convert(converter = StringRowListMapJsonConverter.class)
|
||||||
|
private Map<String, List<Map<String, String>>> tableValues;
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String notes;
|
private String notes;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
|
|||||||
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
|
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -59,6 +60,7 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(e.getCampaignId().toString())
|
.campaignId(e.getCampaignId().toString())
|
||||||
|
.relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>())
|
||||||
.folder(e.getFolder())
|
.folder(e.getFolder())
|
||||||
.order(e.getOrder())
|
.order(e.getOrder())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
@@ -77,6 +79,7 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
||||||
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
|
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(Long.parseLong(n.getCampaignId()))
|
.campaignId(Long.parseLong(n.getCampaignId()))
|
||||||
|
.relatedPageIds(n.getRelatedPageIds() != null ? new ArrayList<>(n.getRelatedPageIds()) : new ArrayList<>())
|
||||||
.folder(n.getFolder())
|
.folder(n.getFolder())
|
||||||
.order(n.getOrder())
|
.order(n.getOrder())
|
||||||
.createdAt(n.getCreatedAt())
|
.createdAt(n.getCreatedAt())
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ public class PostgresPageRepository implements PageRepository {
|
|||||||
.title(e.getTitle())
|
.title(e.getTitle())
|
||||||
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
||||||
|
.tableValues(e.getTableValues() != null ? new HashMap<>(e.getTableValues()) : new HashMap<>())
|
||||||
.notes(e.getNotes())
|
.notes(e.getNotes())
|
||||||
.tags(e.getTags() != null ? new ArrayList<>(e.getTags()) : new ArrayList<>())
|
.tags(e.getTags() != null ? new ArrayList<>(e.getTags()) : new ArrayList<>())
|
||||||
.relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>())
|
.relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>())
|
||||||
@@ -111,6 +113,8 @@ public class PostgresPageRepository implements PageRepository {
|
|||||||
.title(p.getTitle())
|
.title(p.getTitle())
|
||||||
.values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>())
|
.values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>())
|
||||||
.imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>())
|
.imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(p.getKeyValueValues() != null ? new HashMap<>(p.getKeyValueValues()) : new HashMap<>())
|
||||||
|
.tableValues(p.getTableValues() != null ? new HashMap<>(p.getTableValues()) : new HashMap<>())
|
||||||
.notes(p.getNotes())
|
.notes(p.getNotes())
|
||||||
.tags(p.getTags() != null ? new ArrayList<>(p.getTags()) : new ArrayList<>())
|
.tags(p.getTags() != null ? new ArrayList<>(p.getTags()) : new ArrayList<>())
|
||||||
.relatedPageIds(p.getRelatedPageIds() != null ? new ArrayList<>(p.getRelatedPageIds()) : new ArrayList<>())
|
.relatedPageIds(p.getRelatedPageIds() != null ? new ArrayList<>(p.getRelatedPageIds()) : new ArrayList<>())
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException;
|
|||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -70,6 +71,18 @@ public class GlobalExceptionHandler {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client HTTP parti pendant une reponse asynchrone (SSE) : le navigateur a ferme
|
||||||
|
* la connexion (onglet ferme, proxy coupe...), la reponse n'est plus utilisable.
|
||||||
|
* Ce n'est PAS une erreur serveur -> pas de log ERROR + stack trace (bruit),
|
||||||
|
* et aucune reponse a renvoyer (le canal est mort).
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(AsyncRequestNotUsableException.class)
|
||||||
|
public void handleClientDisconnected(HttpServletRequest request, AsyncRequestNotUsableException ex) {
|
||||||
|
log.debug("Client deconnecte pendant la reponse asynchrone sur {} {} : {}",
|
||||||
|
request.getMethod(), request.getRequestURI(), ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fallback : tout ce qui n'a pas ete catche au-dessus -> 500, mais avec
|
* Fallback : tout ce qui n'a pas ete catche au-dessus -> 500, mais avec
|
||||||
* un log ERROR explicite (path + stack trace) et un body JSON debuggable
|
* un log ERROR explicite (path + stack trace) et un body JSON debuggable
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REST Controller pour l'import d'un PDF de campagne → arbre arc/chapitre/scène.
|
* REST Controller pour l'import d'un PDF de campagne → arbre arc/chapitre/scène.
|
||||||
@@ -30,8 +31,13 @@ public class CampaignImportController {
|
|||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(CampaignImportController.class);
|
private static final Logger log = LoggerFactory.getLogger(CampaignImportController.class);
|
||||||
|
|
||||||
/** Timeout SSE généreux : un import de livre entier peut durer plusieurs minutes. */
|
/**
|
||||||
private static final long IMPORT_SSE_TIMEOUT_MS = 15 * 60 * 1000L;
|
* Timeout SSE = durée TOTALE maximale de l'import (pas un timeout d'inactivité :
|
||||||
|
* les heartbeats ne le réarment pas). Un livre entier sur un modèle local peut
|
||||||
|
* largement dépasser 15 min → 60 min. La déconnexion du client reste détectée
|
||||||
|
* immédiatement par ailleurs (échec d'envoi → interruption de l'import).
|
||||||
|
*/
|
||||||
|
private static final long IMPORT_SSE_TIMEOUT_MS = 60 * 60 * 1000L;
|
||||||
|
|
||||||
private final CampaignImportService campaignImportService;
|
private final CampaignImportService campaignImportService;
|
||||||
private final TaskExecutor taskExecutor;
|
private final TaskExecutor taskExecutor;
|
||||||
@@ -54,33 +60,65 @@ public class CampaignImportController {
|
|||||||
@RequestParam("file") MultipartFile file) throws IOException {
|
@RequestParam("file") MultipartFile file) throws IOException {
|
||||||
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
|
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
|
||||||
if (file == null || file.isEmpty()) {
|
if (file == null || file.isEmpty()) {
|
||||||
sendError(emitter, "Fichier PDF vide.");
|
sendError(emitter, new AtomicBoolean(false), "Fichier PDF vide.");
|
||||||
return emitter;
|
return emitter;
|
||||||
}
|
}
|
||||||
byte[] bytes = file.getBytes();
|
byte[] bytes = file.getBytes();
|
||||||
String filename = file.getOriginalFilename();
|
String filename = file.getOriginalFilename();
|
||||||
|
|
||||||
|
// Suivi de la déconnexion du navigateur : dès qu'un envoi échoue (ou que
|
||||||
|
// l'emitter se termine), on cesse d'envoyer ET on interrompt le streaming
|
||||||
|
// amont (ClientGoneException remonte dans le doOnNext du WebClient →
|
||||||
|
// annule la souscription → le Brain voit la coupure et stoppe le LLM).
|
||||||
|
AtomicBoolean clientGone = new AtomicBoolean(false);
|
||||||
|
emitter.onTimeout(() -> {
|
||||||
|
// Timeout = durée totale dépassée, mais la connexion est encore vivante :
|
||||||
|
// on envoie une vraie erreur au navigateur AVANT de fermer (sinon le flux
|
||||||
|
// se termine en silence et l'UI reste figée sur la barre de progression).
|
||||||
|
sendError(emitter, clientGone,
|
||||||
|
"L'import a dépassé la durée maximale autorisée et a été interrompu. "
|
||||||
|
+ "Réessayez avec un modèle plus rapide ou un PDF plus petit.");
|
||||||
|
clientGone.set(true);
|
||||||
|
});
|
||||||
|
emitter.onError(e -> clientGone.set(true));
|
||||||
|
|
||||||
taskExecutor.execute(() -> {
|
taskExecutor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
campaignImportService.importStructureStreaming(
|
campaignImportService.importStructureStreaming(
|
||||||
bytes, filename,
|
bytes, filename,
|
||||||
progress -> sendEvent(emitter, "progress", progress),
|
progress -> sendEvent(emitter, clientGone, "progress", progress),
|
||||||
|
() -> sendHeartbeat(emitter, clientGone),
|
||||||
|
status -> sendEvent(emitter, clientGone, "status",
|
||||||
|
Map.of("message", status != null ? status : "")),
|
||||||
proposal -> {
|
proposal -> {
|
||||||
sendEvent(emitter, "done", proposal);
|
sendEvent(emitter, clientGone, "done", proposal);
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
},
|
},
|
||||||
error -> {
|
error -> {
|
||||||
|
if (clientGone.get()) {
|
||||||
|
log.info("Import campagne (stream) interrompu : client déconnecté.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
log.warn("Import campagne (stream) échoué : {}", error.getMessage());
|
log.warn("Import campagne (stream) échoué : {}", error.getMessage());
|
||||||
sendError(emitter, error.getMessage());
|
sendError(emitter, clientGone, error.getMessage());
|
||||||
});
|
});
|
||||||
|
} catch (ClientGoneException e) {
|
||||||
|
log.info("Import campagne (stream) interrompu : client déconnecté.");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Import campagne (stream) échoué : {}", e.getMessage());
|
log.warn("Import campagne (stream) échoué : {}", e.getMessage());
|
||||||
sendError(emitter, e.getMessage());
|
sendError(emitter, clientGone, e.getMessage());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return emitter;
|
return emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Signale que le navigateur a fermé le flux SSE : inutile de continuer l'import. */
|
||||||
|
private static final class ClientGoneException extends RuntimeException {
|
||||||
|
ClientGoneException(Throwable cause) {
|
||||||
|
super("Client SSE déconnecté.", cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE)
|
@PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public ResponseEntity<CampaignImportService.ApplyResult> apply(
|
public ResponseEntity<CampaignImportService.ApplyResult> apply(
|
||||||
@PathVariable String campaignId,
|
@PathVariable String campaignId,
|
||||||
@@ -96,23 +134,52 @@ public class CampaignImportController {
|
|||||||
|
|
||||||
// --- Helpers SSE ---------------------------------------------------------
|
// --- Helpers SSE ---------------------------------------------------------
|
||||||
|
|
||||||
private void sendEvent(SseEmitter emitter, String eventName, Object payload) {
|
private void sendEvent(
|
||||||
|
SseEmitter emitter, AtomicBoolean clientGone, String eventName, Object payload) {
|
||||||
|
if (clientGone.get()) {
|
||||||
|
throw new ClientGoneException(null);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
emitter.send(SseEmitter.event().name(eventName).data(
|
emitter.send(SseEmitter.event().name(eventName).data(
|
||||||
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
|
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
|
||||||
} catch (IOException e) {
|
} catch (Exception e) {
|
||||||
|
// IOException OU IllegalStateException (emitter déjà terminé) : le client
|
||||||
|
// est parti — on interrompt le pipeline amont au lieu de rejouer l'échec.
|
||||||
|
clientGone.set(true);
|
||||||
emitter.completeWithError(e);
|
emitter.completeWithError(e);
|
||||||
|
throw new ClientGoneException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendError(SseEmitter emitter, String message) {
|
/**
|
||||||
|
* Keep-alive vers le navigateur pendant un appel LLM long : un commentaire SSE
|
||||||
|
* (ignoré par le front) suffit à réarmer le {@code proxy_read_timeout} de nginx.
|
||||||
|
*/
|
||||||
|
private void sendHeartbeat(SseEmitter emitter, AtomicBoolean clientGone) {
|
||||||
|
if (clientGone.get()) {
|
||||||
|
throw new ClientGoneException(null);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().comment("keepalive"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
clientGone.set(true);
|
||||||
|
emitter.completeWithError(e);
|
||||||
|
throw new ClientGoneException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendError(SseEmitter emitter, AtomicBoolean clientGone, String message) {
|
||||||
|
if (clientGone.get()) {
|
||||||
|
return; // le client n'est plus là pour lire le message d'erreur.
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
emitter.send(SseEmitter.event().name("error").data(
|
emitter.send(SseEmitter.event().name("error").data(
|
||||||
objectMapper.writeValueAsString(Map.of(
|
objectMapper.writeValueAsString(Map.of(
|
||||||
"message", message != null ? message : "Erreur inconnue.")),
|
"message", message != null ? message : "Erreur inconnue.")),
|
||||||
MediaType.APPLICATION_JSON));
|
MediaType.APPLICATION_JSON));
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
} catch (IOException e) {
|
} catch (Exception e) {
|
||||||
|
clientGone.set(true);
|
||||||
emitter.completeWithError(e);
|
emitter.completeWithError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.loremind.infrastructure.web.controller;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.loremind.application.gamesystemcontext.GameSystemService;
|
import com.loremind.application.gamesystemcontext.GameSystemService;
|
||||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||||
import com.loremind.domain.gamesystemcontext.RulesImportProgress;
|
|
||||||
import com.loremind.domain.gamesystemcontext.RulesImportResult;
|
import com.loremind.domain.gamesystemcontext.RulesImportResult;
|
||||||
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
|
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
|
||||||
import com.loremind.domain.shared.template.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
@@ -27,6 +26,7 @@ import java.io.IOException;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -35,8 +35,13 @@ public class GameSystemController {
|
|||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(GameSystemController.class);
|
private static final Logger log = LoggerFactory.getLogger(GameSystemController.class);
|
||||||
|
|
||||||
/** Timeout SSE généreux : un import de livre entier peut durer plusieurs minutes. */
|
/**
|
||||||
private static final long IMPORT_SSE_TIMEOUT_MS = 15 * 60 * 1000L;
|
* Timeout SSE = durée TOTALE maximale de l'import (pas un timeout d'inactivité :
|
||||||
|
* les heartbeats ne le réarment pas). Un livre entier sur un modèle local peut
|
||||||
|
* largement dépasser 15 min → 60 min. La déconnexion du client reste détectée
|
||||||
|
* immédiatement par ailleurs (échec d'envoi → interruption de l'import).
|
||||||
|
*/
|
||||||
|
private static final long IMPORT_SSE_TIMEOUT_MS = 60 * 60 * 1000L;
|
||||||
|
|
||||||
private final GameSystemService gameSystemService;
|
private final GameSystemService gameSystemService;
|
||||||
private final GameSystemMapper gameSystemMapper;
|
private final GameSystemMapper gameSystemMapper;
|
||||||
@@ -135,7 +140,7 @@ public class GameSystemController {
|
|||||||
public SseEmitter importRulesStream(@RequestParam("file") MultipartFile file) throws IOException {
|
public SseEmitter importRulesStream(@RequestParam("file") MultipartFile file) throws IOException {
|
||||||
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
|
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
|
||||||
if (file == null || file.isEmpty()) {
|
if (file == null || file.isEmpty()) {
|
||||||
sendImportError(emitter, "Fichier PDF vide.");
|
sendImportError(emitter, new AtomicBoolean(false), "Fichier PDF vide.");
|
||||||
return emitter;
|
return emitter;
|
||||||
}
|
}
|
||||||
// Les octets sont lus sur le thread servlet (le MultipartFile n'est plus
|
// Les octets sont lus sur le thread servlet (le MultipartFile n'est plus
|
||||||
@@ -143,46 +148,110 @@ public class GameSystemController {
|
|||||||
byte[] bytes = file.getBytes();
|
byte[] bytes = file.getBytes();
|
||||||
String filename = file.getOriginalFilename();
|
String filename = file.getOriginalFilename();
|
||||||
|
|
||||||
|
// Suivi de la déconnexion du navigateur : dès qu'un envoi échoue (ou que
|
||||||
|
// l'emitter se termine), on cesse d'envoyer ET on interrompt le streaming
|
||||||
|
// amont (l'exception ClientGone remonte dans le doOnNext du WebClient →
|
||||||
|
// annule la souscription → le Brain voit la coupure et stoppe le LLM).
|
||||||
|
AtomicBoolean clientGone = new AtomicBoolean(false);
|
||||||
|
emitter.onTimeout(() -> {
|
||||||
|
// Timeout = durée totale dépassée, mais la connexion est encore vivante :
|
||||||
|
// on envoie une vraie erreur au navigateur AVANT de fermer (sinon le flux
|
||||||
|
// se termine en silence et l'UI reste figée sur la barre de progression).
|
||||||
|
sendImportError(emitter, clientGone,
|
||||||
|
"L'import a dépassé la durée maximale autorisée et a été interrompu. "
|
||||||
|
+ "Réessayez avec un modèle plus rapide ou un PDF plus petit.");
|
||||||
|
clientGone.set(true);
|
||||||
|
});
|
||||||
|
emitter.onError(e -> clientGone.set(true));
|
||||||
|
|
||||||
taskExecutor.execute(() -> {
|
taskExecutor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
gameSystemService.importRulesFromPdfStreaming(
|
gameSystemService.importRulesFromPdfStreaming(
|
||||||
bytes, filename,
|
bytes, filename,
|
||||||
progress -> sendImportEvent(emitter, "progress", progress),
|
progress -> sendImportEvent(emitter, clientGone, "progress", progress),
|
||||||
|
() -> sendImportHeartbeat(emitter, clientGone),
|
||||||
|
status -> sendImportEvent(emitter, clientGone, "status",
|
||||||
|
Map.of("message", status != null ? status : "")),
|
||||||
result -> {
|
result -> {
|
||||||
sendImportEvent(emitter, "done", result);
|
sendImportEvent(emitter, clientGone, "done", result);
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
},
|
},
|
||||||
error -> {
|
error -> {
|
||||||
|
if (clientGone.get()) {
|
||||||
|
// La "panne" amont n'est que l'écho de la déconnexion
|
||||||
|
// du navigateur : pas un échec d'import.
|
||||||
|
log.info("Import de règles (stream) interrompu : client déconnecté.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
log.warn("Import de règles (stream) échoué : {}", error.getMessage());
|
log.warn("Import de règles (stream) échoué : {}", error.getMessage());
|
||||||
sendImportError(emitter, error.getMessage());
|
sendImportError(emitter, clientGone, error.getMessage());
|
||||||
});
|
});
|
||||||
|
} catch (ClientGoneException e) {
|
||||||
|
log.info("Import de règles (stream) interrompu : client déconnecté.");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Import de règles (stream) échoué : {}", e.getMessage());
|
log.warn("Import de règles (stream) échoué : {}", e.getMessage());
|
||||||
sendImportError(emitter, e.getMessage());
|
sendImportError(emitter, clientGone, e.getMessage());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return emitter;
|
return emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Signale que le navigateur a fermé le flux SSE : inutile de continuer l'import. */
|
||||||
|
private static final class ClientGoneException extends RuntimeException {
|
||||||
|
ClientGoneException(Throwable cause) {
|
||||||
|
super("Client SSE déconnecté.", cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Sérialise `payload` en JSON et l'envoie comme évènement SSE nommé. */
|
/** Sérialise `payload` en JSON et l'envoie comme évènement SSE nommé. */
|
||||||
private void sendImportEvent(SseEmitter emitter, String eventName, Object payload) {
|
private void sendImportEvent(
|
||||||
|
SseEmitter emitter, AtomicBoolean clientGone, String eventName, Object payload) {
|
||||||
|
if (clientGone.get()) {
|
||||||
|
throw new ClientGoneException(null);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
emitter.send(SseEmitter.event().name(eventName).data(
|
emitter.send(SseEmitter.event().name(eventName).data(
|
||||||
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
|
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
|
||||||
} catch (IOException e) {
|
} catch (Exception e) {
|
||||||
|
// IOException OU IllegalStateException (emitter déjà terminé) : le client
|
||||||
|
// est parti. On marque l'état et on INTERROMPT le pipeline amont — sinon
|
||||||
|
// chaque évènement suivant rejouerait l'échec (bruit de logs + LLM gaspillé).
|
||||||
|
clientGone.set(true);
|
||||||
emitter.completeWithError(e);
|
emitter.completeWithError(e);
|
||||||
|
throw new ClientGoneException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep-alive vers le navigateur pendant un appel LLM long : un commentaire SSE
|
||||||
|
* (ignoré par le front) suffit à réarmer le {@code proxy_read_timeout} de nginx.
|
||||||
|
*/
|
||||||
|
private void sendImportHeartbeat(SseEmitter emitter, AtomicBoolean clientGone) {
|
||||||
|
if (clientGone.get()) {
|
||||||
|
throw new ClientGoneException(null);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().comment("keepalive"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
clientGone.set(true);
|
||||||
|
emitter.completeWithError(e);
|
||||||
|
throw new ClientGoneException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Envoie un évènement `error` {message} puis termine le flux. */
|
/** Envoie un évènement `error` {message} puis termine le flux. */
|
||||||
private void sendImportError(SseEmitter emitter, String message) {
|
private void sendImportError(SseEmitter emitter, AtomicBoolean clientGone, String message) {
|
||||||
|
if (clientGone.get()) {
|
||||||
|
return; // le client n'est plus là pour lire le message d'erreur.
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
emitter.send(SseEmitter.event().name("error").data(
|
emitter.send(SseEmitter.event().name("error").data(
|
||||||
objectMapper.writeValueAsString(Map.of(
|
objectMapper.writeValueAsString(Map.of(
|
||||||
"message", message != null ? message : "Erreur inconnue.")),
|
"message", message != null ? message : "Erreur inconnue.")),
|
||||||
MediaType.APPLICATION_JSON));
|
MediaType.APPLICATION_JSON));
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
} catch (IOException e) {
|
} catch (Exception e) {
|
||||||
|
clientGone.set(true);
|
||||||
emitter.completeWithError(e);
|
emitter.completeWithError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,18 @@ public class NotebookController {
|
|||||||
List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream()
|
List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream()
|
||||||
.map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent()))
|
.map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent()))
|
||||||
.toList();
|
.toList();
|
||||||
List<String> sourceIds = service.readySourceIds(id);
|
// Sélection de l'UI (cases cochées) : on ne garde que les sources qui
|
||||||
|
// appartiennent bien à CE notebook et sont prêtes — un id étranger est
|
||||||
|
// ignoré. Limite le coût (ex. analyse approfondie sur 1 PDF au lieu de 5).
|
||||||
|
// Variable finale : elle est capturée par la lambda du taskExecutor.
|
||||||
|
List<String> readyIds = service.readySourceIds(id);
|
||||||
|
final List<String> sourceIds;
|
||||||
|
if (req.sourceIds() != null) {
|
||||||
|
var wanted = new java.util.HashSet<>(req.sourceIds());
|
||||||
|
sourceIds = readyIds.stream().filter(wanted::contains).toList();
|
||||||
|
} else {
|
||||||
|
sourceIds = readyIds;
|
||||||
|
}
|
||||||
String context = service.buildContext(nb.getCampaignId());
|
String context = service.buildContext(nb.getCampaignId());
|
||||||
|
|
||||||
boolean deep = req.deep() != null && req.deep();
|
boolean deep = req.deep() != null && req.deep();
|
||||||
@@ -220,5 +231,10 @@ public class NotebookController {
|
|||||||
|
|
||||||
public record CreateRequest(String campaignId, String name) {}
|
public record CreateRequest(String campaignId, String name) {}
|
||||||
public record RenameRequest(String name) {}
|
public record RenameRequest(String name) {}
|
||||||
public record ChatRequest(String message, Boolean deep) {}
|
/**
|
||||||
|
* @param sourceIds Optionnel : sous-ensemble de sources à utiliser pour ce tour
|
||||||
|
* (cases cochées dans l'UI). Null = toutes les sources prêtes.
|
||||||
|
* Toujours intersecté avec les sources du notebook (sécurité).
|
||||||
|
*/
|
||||||
|
public record ChatRequest(String message, Boolean deep, List<String> sourceIds) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ public class NpcController {
|
|||||||
return ResponseEntity.ok(dtos);
|
return ResponseEntity.ok(dtos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */
|
||||||
|
@GetMapping("/lore/{loreId}")
|
||||||
|
public ResponseEntity<List<NpcDTO>> getNpcsByLore(@PathVariable String loreId) {
|
||||||
|
List<NpcDTO> dtos = npcService.getNpcsByLoreId(loreId).stream()
|
||||||
|
.map(npcMapper::toDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return ResponseEntity.ok(dtos);
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
|
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
|
||||||
Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));
|
Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));
|
||||||
@@ -64,6 +73,7 @@ public class NpcController {
|
|||||||
dto.getImageValues(),
|
dto.getImageValues(),
|
||||||
dto.getKeyValueValues(),
|
dto.getKeyValueValues(),
|
||||||
dto.getCampaignId(),
|
dto.getCampaignId(),
|
||||||
|
dto.getRelatedPageIds(),
|
||||||
dto.getFolder(),
|
dto.getFolder(),
|
||||||
order
|
order
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -20,6 +21,8 @@ public class NpcDTO {
|
|||||||
private Map<String, List<String>> imageValues = new HashMap<>();
|
private Map<String, List<String>> imageValues = new HashMap<>();
|
||||||
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
|
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
|
/** IDs de Pages de Lore référencées par ce PNJ (référence faible cross-context). */
|
||||||
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
private String folder;
|
private String folder;
|
||||||
private int order;
|
private int order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ public class PageDTO {
|
|||||||
private Map<String, String> values;
|
private Map<String, String> values;
|
||||||
/** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */
|
/** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */
|
||||||
private Map<String, List<String>> imageValues;
|
private Map<String, List<String>> imageValues;
|
||||||
|
/** Pour chaque champ KEY_VALUE_LIST du template : label → valeur. */
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
/** Pour chaque champ TABLE du template : lignes (colonne → cellule). */
|
||||||
|
private Map<String, List<Map<String, String>>> tableValues;
|
||||||
private String notes;
|
private String notes;
|
||||||
private List<String> tags;
|
private List<String> tags;
|
||||||
private List<String> relatedPageIds;
|
private List<String> relatedPageIds;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.loremind.domain.campaigncontext.Npc;
|
|||||||
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
|
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@@ -20,6 +21,7 @@ public class NpcMapper {
|
|||||||
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
|
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
|
||||||
dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
|
dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
|
||||||
dto.setCampaignId(n.getCampaignId());
|
dto.setCampaignId(n.getCampaignId());
|
||||||
|
dto.setRelatedPageIds(n.getRelatedPageIds() != null ? new ArrayList<>(n.getRelatedPageIds()) : new ArrayList<>());
|
||||||
dto.setFolder(n.getFolder());
|
dto.setFolder(n.getFolder());
|
||||||
dto.setOrder(n.getOrder());
|
dto.setOrder(n.getOrder());
|
||||||
return dto;
|
return dto;
|
||||||
@@ -36,6 +38,7 @@ public class NpcMapper {
|
|||||||
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||||
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
|
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(dto.getCampaignId())
|
.campaignId(dto.getCampaignId())
|
||||||
|
.relatedPageIds(dto.getRelatedPageIds() != null ? new ArrayList<>(dto.getRelatedPageIds()) : new ArrayList<>())
|
||||||
.folder(dto.getFolder())
|
.folder(dto.getFolder())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public class PageMapper {
|
|||||||
dto.setTitle(page.getTitle());
|
dto.setTitle(page.getTitle());
|
||||||
dto.setValues(CollectionUtils.copyMap(page.getValues()));
|
dto.setValues(CollectionUtils.copyMap(page.getValues()));
|
||||||
dto.setImageValues(CollectionUtils.copyMap(page.getImageValues()));
|
dto.setImageValues(CollectionUtils.copyMap(page.getImageValues()));
|
||||||
|
dto.setKeyValueValues(CollectionUtils.copyMap(page.getKeyValueValues()));
|
||||||
|
dto.setTableValues(CollectionUtils.copyMap(page.getTableValues()));
|
||||||
dto.setNotes(page.getNotes());
|
dto.setNotes(page.getNotes());
|
||||||
dto.setTags(CollectionUtils.copyList(page.getTags()));
|
dto.setTags(CollectionUtils.copyList(page.getTags()));
|
||||||
dto.setRelatedPageIds(CollectionUtils.copyList(page.getRelatedPageIds()));
|
dto.setRelatedPageIds(CollectionUtils.copyList(page.getRelatedPageIds()));
|
||||||
@@ -41,6 +43,8 @@ public class PageMapper {
|
|||||||
.title(dto.getTitle())
|
.title(dto.getTitle())
|
||||||
.values(CollectionUtils.copyMap(dto.getValues()))
|
.values(CollectionUtils.copyMap(dto.getValues()))
|
||||||
.imageValues(CollectionUtils.copyMap(dto.getImageValues()))
|
.imageValues(CollectionUtils.copyMap(dto.getImageValues()))
|
||||||
|
.keyValueValues(CollectionUtils.copyMap(dto.getKeyValueValues()))
|
||||||
|
.tableValues(CollectionUtils.copyMap(dto.getTableValues()))
|
||||||
.notes(dto.getNotes())
|
.notes(dto.getNotes())
|
||||||
.tags(CollectionUtils.copyList(dto.getTags()))
|
.tags(CollectionUtils.copyList(dto.getTags()))
|
||||||
.relatedPageIds(CollectionUtils.copyList(dto.getRelatedPageIds()))
|
.relatedPageIds(CollectionUtils.copyList(dto.getRelatedPageIds()))
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ public class TemplateFieldMapper {
|
|||||||
layoutStr = layout.name();
|
layoutStr = layout.name();
|
||||||
}
|
}
|
||||||
List<String> labels = null;
|
List<String> labels = null;
|
||||||
if (field.getType() == FieldType.KEY_VALUE_LIST && field.getLabels() != null) {
|
if ((field.getType() == FieldType.KEY_VALUE_LIST || field.getType() == FieldType.TABLE)
|
||||||
|
&& field.getLabels() != null) {
|
||||||
labels = new ArrayList<>(field.getLabels());
|
labels = new ArrayList<>(field.getLabels());
|
||||||
}
|
}
|
||||||
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels);
|
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels);
|
||||||
@@ -54,7 +55,7 @@ public class TemplateFieldMapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
List<String> labels = null;
|
List<String> labels = null;
|
||||||
if (type == FieldType.KEY_VALUE_LIST && dto.getLabels() != null) {
|
if ((type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) && dto.getLabels() != null) {
|
||||||
labels = new ArrayList<>(dto.getLabels());
|
labels = new ArrayList<>(dto.getLabels());
|
||||||
}
|
}
|
||||||
return new TemplateField(dto.getName(), type, layout, labels);
|
return new TemplateField(dto.getName(), type, layout, labels);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Npc;
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -29,6 +30,9 @@ public class NpcServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private NpcRepository npcRepository;
|
private NpcRepository npcRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private CampaignRepository campaignRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private NpcService npcService;
|
private NpcService npcService;
|
||||||
|
|
||||||
@@ -51,7 +55,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
Npc result = npcService.createNpc(
|
Npc result = npcService.createNpc(
|
||||||
new NpcService.NpcData("Borin le forgeron", null, null,
|
new NpcService.NpcData("Borin le forgeron", null, null,
|
||||||
Map.of("Notes", "Borin"), null, null, "camp-1", null,5));
|
Map.of("Notes", "Borin"), null, null, "camp-1", null, null, 5));
|
||||||
|
|
||||||
assertNotNull(result);
|
assertNotNull(result);
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
@@ -67,7 +71,7 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
||||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null,null));
|
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null, null, null));
|
||||||
|
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
verify(npcRepository).save(captor.capture());
|
verify(npcRepository).save(captor.capture());
|
||||||
@@ -79,7 +83,7 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null,null));
|
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null, null, null));
|
||||||
|
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
verify(npcRepository).save(captor.capture());
|
verify(npcRepository).save(captor.capture());
|
||||||
@@ -124,7 +128,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
Npc result = npcService.updateNpc("npc-1",
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
new NpcService.NpcData("Borin renommé", null, null,
|
new NpcService.NpcData("Borin renommé", null, null,
|
||||||
Map.of("Notes", "v2"), null, null, "camp-1", null,7));
|
Map.of("Notes", "v2"), null, null, "camp-1", null, null, 7));
|
||||||
|
|
||||||
assertEquals("Borin renommé", result.getName());
|
assertEquals("Borin renommé", result.getName());
|
||||||
assertEquals("v2", result.getValues().get("Notes"));
|
assertEquals("v2", result.getValues().get("Notes"));
|
||||||
@@ -138,7 +142,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
Npc result = npcService.updateNpc("npc-1",
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
new NpcService.NpcData("Borin", null, null,
|
new NpcService.NpcData("Borin", null, null,
|
||||||
Map.of("Notes", "txt"), null, null, "camp-1", null,null));
|
Map.of("Notes", "txt"), null, null, "camp-1", null, null, null));
|
||||||
|
|
||||||
// testNpc avait order=1 → préservé
|
// testNpc avait order=1 → préservé
|
||||||
assertEquals(1, result.getOrder());
|
assertEquals(1, result.getOrder());
|
||||||
@@ -150,7 +154,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
() -> npcService.updateNpc("missing",
|
() -> npcService.updateNpc("missing",
|
||||||
new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null,null)));
|
new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null, null, null)));
|
||||||
assertTrue(ex.getMessage().contains("missing"));
|
assertTrue(ex.getMessage().contains("missing"));
|
||||||
verify(npcRepository, never()).save(any());
|
verify(npcRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|||||||
739
web/package-lock.json
generated
739
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.0-beta",
|
"version": "0.12.5-beta",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
@@ -34,6 +34,8 @@
|
|||||||
"@angular-devkit/build-angular": "^21.2.14",
|
"@angular-devkit/build-angular": "^21.2.14",
|
||||||
"@angular/cli": "^21.2.14",
|
"@angular/cli": "^21.2.14",
|
||||||
"@angular/compiler-cli": "^21.2.16",
|
"@angular/compiler-cli": "^21.2.16",
|
||||||
|
"@emnapi/core": "^1.11.0",
|
||||||
|
"@emnapi/runtime": "^1.11.0",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
"typescript": "~5.9.3"
|
"typescript": "~5.9.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { hiddenInDemoGuard } from './guards/demo-mode.guard';
|
|||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
|
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
|
||||||
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
|
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
|
||||||
|
{ path: 'lore/:loreId/graph', loadComponent: () => import('./lore/lore-graph/lore-graph.component').then(m => m.LoreGraphComponent) },
|
||||||
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
|
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
|
||||||
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
|
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
|
||||||
{ path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) },
|
{ path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
importing = false;
|
importing = false;
|
||||||
importPhase = '';
|
importPhase = '';
|
||||||
importProgress: { current: number; total: number } | null = null;
|
importProgress: { current: number; total: number } | null = null;
|
||||||
|
/**
|
||||||
|
* Dernier message de statut du flux (fournisseur saturé → retry, morceau
|
||||||
|
* re-découpé/ignoré…). Effacé à chaque progression : il explique l'ATTENTE
|
||||||
|
* en cours, pas l'historique.
|
||||||
|
*/
|
||||||
|
importStatus: string | null = null;
|
||||||
importCounts: { arcs: number; chapters: number; scenes: number; npcs: number } | null = null;
|
importCounts: { arcs: number; chapters: number; scenes: number; npcs: number } | null = null;
|
||||||
importError: string | null = null;
|
importError: string | null = null;
|
||||||
/** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */
|
/** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */
|
||||||
@@ -132,12 +138,15 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
this.applyError = null;
|
this.applyError = null;
|
||||||
this.importPhase = 'Extraction du texte…';
|
this.importPhase = 'Extraction du texte…';
|
||||||
this.importProgress = null;
|
this.importProgress = null;
|
||||||
|
this.importStatus = null;
|
||||||
this.importCounts = null;
|
this.importCounts = null;
|
||||||
this.tree = [];
|
this.tree = [];
|
||||||
|
|
||||||
this.service.importStructureStream(this.campaignId, file).subscribe({
|
this.service.importStructureStream(this.campaignId, file).subscribe({
|
||||||
next: (ev) => {
|
next: (ev) => {
|
||||||
if (ev.type === 'progress') {
|
if (ev.type === 'progress') {
|
||||||
|
// Un morceau vient d'aboutir : le message d'attente est obsolète.
|
||||||
|
this.importStatus = null;
|
||||||
if (ev.total === 0) {
|
if (ev.total === 0) {
|
||||||
this.importPhase = 'Extraction du texte…';
|
this.importPhase = 'Extraction du texte…';
|
||||||
this.importProgress = null;
|
this.importProgress = null;
|
||||||
@@ -149,10 +158,13 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
scenes: ev.sceneCount, npcs: ev.npcCount ?? 0
|
scenes: ev.sceneCount, npcs: ev.npcCount ?? 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
} else if (ev.type === 'status') {
|
||||||
|
this.importStatus = ev.message;
|
||||||
} else if (ev.type === 'done') {
|
} else if (ev.type === 'done') {
|
||||||
this.importing = false;
|
this.importing = false;
|
||||||
this.importPhase = '';
|
this.importPhase = '';
|
||||||
this.importProgress = null;
|
this.importProgress = null;
|
||||||
|
this.importStatus = null;
|
||||||
if ((ev.arcs ?? []).length === 0 && (ev.npcs ?? []).length === 0) {
|
if ((ev.arcs ?? []).length === 0 && (ev.npcs ?? []).length === 0) {
|
||||||
this.importError = "Aucune structure narrative détectée dans ce PDF.";
|
this.importError = "Aucune structure narrative détectée dans ce PDF.";
|
||||||
this.reviewing = false;
|
this.reviewing = false;
|
||||||
|
|||||||
@@ -20,13 +20,30 @@
|
|||||||
@if (uploadError) {
|
@if (uploadError) {
|
||||||
<p class="nbd-upload-error">{{ uploadError }}</p>
|
<p class="nbd-upload-error">{{ uploadError }}</p>
|
||||||
}
|
}
|
||||||
|
@if (readySourceCount > 1) {
|
||||||
|
<p class="nbd-sources-hint"
|
||||||
|
[class.partial]="selectedSourceIds.size < readySourceCount">
|
||||||
|
{{ selectedSourceIds.size }}/{{ readySourceCount }} source(s) utilisée(s) par le chat et l'analyse approfondie
|
||||||
|
</p>
|
||||||
|
}
|
||||||
@for (s of sources; track s) {
|
@for (s of sources; track s) {
|
||||||
<div class="nbd-source">
|
<div class="nbd-source" [class.nbd-source--off]="s.status === 'READY' && !isSelected(s)">
|
||||||
|
@if (s.status === 'READY') {
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="nbd-source-check"
|
||||||
|
[checked]="isSelected(s)"
|
||||||
|
(change)="toggleSource(s)"
|
||||||
|
[title]="isSelected(s)
|
||||||
|
? 'Source utilisée par le chat — décocher pour l\'exclure'
|
||||||
|
: 'Source ignorée par le chat — cocher pour l\'inclure'" />
|
||||||
|
} @else {
|
||||||
<lucide-icon
|
<lucide-icon
|
||||||
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)"
|
[img]="s.status === 'FAILED' ? AlertCircle : Loader"
|
||||||
[size]="14"
|
[size]="14"
|
||||||
[class.ok]="s.status === 'READY'" [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
|
[class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
|
||||||
</lucide-icon>
|
</lucide-icon>
|
||||||
|
}
|
||||||
<div class="nbd-source-info">
|
<div class="nbd-source-info">
|
||||||
<div class="nbd-source-name">{{ s.filename }}</div>
|
<div class="nbd-source-name">{{ s.filename }}</div>
|
||||||
<div class="nbd-source-meta">
|
<div class="nbd-source-meta">
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -137,13 +137,54 @@ export class NotebookDetailComponent implements OnInit {
|
|||||||
this.detail = d;
|
this.detail = d;
|
||||||
this.sources = d.sources ?? [];
|
this.sources = d.sources ?? [];
|
||||||
this.messages = d.messages ?? [];
|
this.messages = d.messages ?? [];
|
||||||
|
this.syncSelection();
|
||||||
},
|
},
|
||||||
error: () => this.back()
|
error: () => this.back()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reloadSources(): void {
|
reloadSources(): void {
|
||||||
this.service.get(this.notebookId).subscribe({ next: (d) => this.sources = d.sources ?? [] });
|
this.service.get(this.notebookId).subscribe({ next: (d) => {
|
||||||
|
this.sources = d.sources ?? [];
|
||||||
|
this.syncSelection();
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sélection des sources utilisées par le chat -------------------------
|
||||||
|
// Par défaut tout est coché ; décocher permet de limiter une question (et
|
||||||
|
// surtout une analyse approfondie, coûteuse en requêtes) à certains PDF.
|
||||||
|
|
||||||
|
/** IDs des sources cochées (sous-ensemble des sources READY). */
|
||||||
|
selectedSourceIds = new Set<string>();
|
||||||
|
/** IDs déjà vus — pour ne cocher par défaut que les NOUVELLES sources. */
|
||||||
|
private knownSourceIds = new Set<string>();
|
||||||
|
|
||||||
|
/** Aligne la sélection sur la liste courante : nouvelles sources cochées par
|
||||||
|
* défaut, sources supprimées retirées, choix de l'utilisateur préservés. */
|
||||||
|
private syncSelection(): void {
|
||||||
|
const readyIds = new Set(this.sources.filter(s => s.status === 'READY').map(s => s.id));
|
||||||
|
for (const id of readyIds) {
|
||||||
|
if (!this.knownSourceIds.has(id)) {
|
||||||
|
this.selectedSourceIds.add(id);
|
||||||
|
this.knownSourceIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of [...this.selectedSourceIds]) {
|
||||||
|
if (!readyIds.has(id)) this.selectedSourceIds.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelected(s: NotebookSource): boolean {
|
||||||
|
return this.selectedSourceIds.has(s.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSource(s: NotebookSource): void {
|
||||||
|
if (this.selectedSourceIds.has(s.id)) this.selectedSourceIds.delete(s.id);
|
||||||
|
else this.selectedSourceIds.add(s.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get readySourceCount(): number {
|
||||||
|
return this.sources.filter(s => s.status === 'READY').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sources ---
|
// --- Sources ---
|
||||||
@@ -181,7 +222,7 @@ export class NotebookDetailComponent implements OnInit {
|
|||||||
this.messages.push(assistant);
|
this.messages.push(assistant);
|
||||||
this.sending = true;
|
this.sending = true;
|
||||||
|
|
||||||
this.service.streamChat(this.notebookId, text, deep).subscribe({
|
this.service.streamChat(this.notebookId, text, deep, [...this.selectedSourceIds]).subscribe({
|
||||||
next: (ev) => {
|
next: (ev) => {
|
||||||
if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; }
|
if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; }
|
||||||
else if (ev.type === 'sources') assistant.sources = ev.sources;
|
else if (ev.type === 'sources') assistant.sources = ev.sources;
|
||||||
|
|||||||
@@ -87,6 +87,20 @@
|
|||||||
</app-dynamic-fields-form>
|
</app-dynamic-fields-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pages de Lore liées (uniquement si la campagne est rattachée à un Lore) -->
|
||||||
|
@if (loreId) {
|
||||||
|
<div class="field">
|
||||||
|
<label>Pages de Lore liées</label>
|
||||||
|
<app-lore-link-picker
|
||||||
|
[value]="relatedPageIds"
|
||||||
|
[availablePages]="lorePages"
|
||||||
|
[loreId]="loreId"
|
||||||
|
(valueChange)="relatedPageIds = $event">
|
||||||
|
</app-lore-link-picker>
|
||||||
|
<p class="hint">Reliez ce PNJ aux pages de l'univers (sa ville, sa faction, sa région…). Ces liens apparaissent dans le graphe du Lore.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
||||||
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'l
|
|||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { GameSystemService } from '../../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { PageService } from '../../../services/page.service';
|
||||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
import { TemplateField } from '../../../services/template.model';
|
import { TemplateField } from '../../../services/template.model';
|
||||||
|
import { Page } from '../../../services/page.model';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||||
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
||||||
|
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,7 +24,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-npc-edit',
|
selector: 'app-npc-edit',
|
||||||
imports: [FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
|
imports: [FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent, LoreLinkPickerComponent],
|
||||||
templateUrl: './npc-edit.component.html',
|
templateUrl: './npc-edit.component.html',
|
||||||
styleUrls: ['./npc-edit.component.scss']
|
styleUrls: ['./npc-edit.component.scss']
|
||||||
})
|
})
|
||||||
@@ -56,12 +59,20 @@ export class NpcEditComponent implements OnInit {
|
|||||||
templateFields: TemplateField[] = [];
|
templateFields: TemplateField[] = [];
|
||||||
private order = 0;
|
private order = 0;
|
||||||
|
|
||||||
|
/** Lore lié à la campagne (null = pas de lore → section liens masquée). */
|
||||||
|
loreId: string | null = null;
|
||||||
|
/** Pages du lore lié — référentiel du picker. */
|
||||||
|
lorePages: Page[] = [];
|
||||||
|
/** IDs des pages de lore référencées par ce PNJ. */
|
||||||
|
relatedPageIds: string[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private service: NpcService,
|
private service: NpcService,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private gameSystemService: GameSystemService,
|
private gameSystemService: GameSystemService,
|
||||||
|
private pageService: PageService,
|
||||||
private campaignSidebar: CampaignSidebarService,
|
private campaignSidebar: CampaignSidebarService,
|
||||||
private confirmDialog: ConfirmDialogService
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
@@ -87,6 +98,7 @@ export class NpcEditComponent implements OnInit {
|
|||||||
this.values = n.values ?? {};
|
this.values = n.values ?? {};
|
||||||
this.imageValues = n.imageValues ?? {};
|
this.imageValues = n.imageValues ?? {};
|
||||||
this.keyValueValues = n.keyValueValues ?? {};
|
this.keyValueValues = n.keyValueValues ?? {};
|
||||||
|
this.relatedPageIds = [...(n.relatedPageIds ?? [])];
|
||||||
this.order = n.order ?? 0;
|
this.order = n.order ?? 0;
|
||||||
},
|
},
|
||||||
error: () => this.back()
|
error: () => this.back()
|
||||||
@@ -108,6 +120,14 @@ export class NpcEditComponent implements OnInit {
|
|||||||
private loadTemplateForCampaign(campaignId: string): void {
|
private loadTemplateForCampaign(campaignId: string): void {
|
||||||
this.campaignService.getCampaignById(campaignId).subscribe({
|
this.campaignService.getCampaignById(campaignId).subscribe({
|
||||||
next: (campaign) => {
|
next: (campaign) => {
|
||||||
|
// Lore lié → charge ses pages pour le picker de références.
|
||||||
|
if (campaign.loreId) {
|
||||||
|
this.loreId = campaign.loreId;
|
||||||
|
this.pageService.getByLoreId(campaign.loreId).subscribe({
|
||||||
|
next: (pages) => { this.lorePages = pages; },
|
||||||
|
error: () => { this.lorePages = []; }
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!campaign.gameSystemId) {
|
if (!campaign.gameSystemId) {
|
||||||
this.templateFields = [];
|
this.templateFields = [];
|
||||||
return;
|
return;
|
||||||
@@ -132,7 +152,8 @@ export class NpcEditComponent implements OnInit {
|
|||||||
values: this.values,
|
values: this.values,
|
||||||
imageValues: this.imageValues,
|
imageValues: this.imageValues,
|
||||||
keyValueValues: this.keyValueValues,
|
keyValueValues: this.keyValueValues,
|
||||||
campaignId: this.campaignId
|
campaignId: this.campaignId,
|
||||||
|
relatedPageIds: this.relatedPageIds
|
||||||
};
|
};
|
||||||
const isCreation = !this.npcId;
|
const isCreation = !this.npcId;
|
||||||
const req = this.npcId
|
const req = this.npcId
|
||||||
|
|||||||
@@ -18,6 +18,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-persona-view [persona]="npc" [templateFields]="templateFields"></app-persona-view>
|
<app-persona-view [persona]="npc" [templateFields]="templateFields"></app-persona-view>
|
||||||
|
|
||||||
|
<!-- Pages de Lore liées à ce PNJ (sa ville, sa faction…) -->
|
||||||
|
@if (loreId && (npc?.relatedPageIds?.length ?? 0) > 0) {
|
||||||
|
<section class="nv-lore-links">
|
||||||
|
<h2 class="nv-lore-links-title">
|
||||||
|
<lucide-icon [img]="Link2" [size]="14"></lucide-icon>
|
||||||
|
Pages de Lore liées
|
||||||
|
</h2>
|
||||||
|
<div class="nv-lore-chips">
|
||||||
|
@for (pageId of npc!.relatedPageIds; track pageId) {
|
||||||
|
<a class="nv-lore-chip" [routerLink]="['/lore', loreId, 'pages', pageId]">
|
||||||
|
{{ titleOfPage(pageId) }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (npcId && campaignId) {
|
@if (npcId && campaignId) {
|
||||||
|
|||||||
@@ -49,3 +49,46 @@
|
|||||||
border-color: rgba(168, 85, 247, 0.5);
|
border-color: rgba(168, 85, 247, 0.5);
|
||||||
color: #d8b4fe;
|
color: #d8b4fe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pages de Lore liées au PNJ — chips cliquables sous la fiche.
|
||||||
|
.nv-lore-links {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 24px auto 0;
|
||||||
|
padding: 0 32px;
|
||||||
|
|
||||||
|
.nv-lore-links-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #a5b4fc;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-lore-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-lore-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid #2a2a3d;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #6c63ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles, Link2 } from 'lucide-angular';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { GameSystemService } from '../../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { PageService } from '../../../services/page.service';
|
||||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
import { TemplateField } from '../../../services/template.model';
|
import { TemplateField } from '../../../services/template.model';
|
||||||
import { Npc } from '../../../services/npc.model';
|
import { Npc } from '../../../services/npc.model';
|
||||||
|
import { Page } from '../../../services/page.model';
|
||||||
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-npc-view',
|
selector: 'app-npc-view',
|
||||||
imports: [LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
|
imports: [LucideAngularModule, RouterLink, PersonaViewComponent, AiChatDrawerComponent],
|
||||||
templateUrl: './npc-view.component.html',
|
templateUrl: './npc-view.component.html',
|
||||||
styleUrls: ['./npc-view.component.scss']
|
styleUrls: ['./npc-view.component.scss']
|
||||||
})
|
})
|
||||||
@@ -26,12 +28,17 @@ export class NpcViewComponent implements OnInit, OnDestroy {
|
|||||||
readonly ArrowLeft = ArrowLeft;
|
readonly ArrowLeft = ArrowLeft;
|
||||||
readonly Edit3 = Edit3;
|
readonly Edit3 = Edit3;
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
|
readonly Link2 = Link2;
|
||||||
|
|
||||||
campaignId: string | null = null;
|
campaignId: string | null = null;
|
||||||
npcId: string | null = null;
|
npcId: string | null = null;
|
||||||
|
|
||||||
npc: Npc | null = null;
|
npc: Npc | null = null;
|
||||||
templateFields: TemplateField[] = [];
|
templateFields: TemplateField[] = [];
|
||||||
|
/** Lore lié à la campagne (résolution des chips de pages liées). */
|
||||||
|
loreId: string | null = null;
|
||||||
|
/** Pages du lore lié, indexées pour résoudre les titres des chips. */
|
||||||
|
private lorePagesById = new Map<string, Page>();
|
||||||
|
|
||||||
chatOpen = false;
|
chatOpen = false;
|
||||||
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||||
@@ -44,6 +51,7 @@ export class NpcViewComponent implements OnInit, OnDestroy {
|
|||||||
private service: NpcService,
|
private service: NpcService,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private gameSystemService: GameSystemService,
|
private gameSystemService: GameSystemService,
|
||||||
|
private pageService: PageService,
|
||||||
private campaignSidebar: CampaignSidebarService
|
private campaignSidebar: CampaignSidebarService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -75,6 +83,13 @@ export class NpcViewComponent implements OnInit, OnDestroy {
|
|||||||
this.templateFields = gs.npcTemplate ?? [];
|
this.templateFields = gs.npcTemplate ?? [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Lore lié → référentiel de pages pour résoudre les chips de liens.
|
||||||
|
if (camp.loreId) {
|
||||||
|
this.loreId = camp.loreId;
|
||||||
|
this.pageService.getByLoreId(camp.loreId).subscribe(pages => {
|
||||||
|
this.lorePagesById = new Map(pages.map(p => [p.id!, p]));
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else if (newCampaignId) {
|
} else if (newCampaignId) {
|
||||||
this.campaignId = newCampaignId;
|
this.campaignId = newCampaignId;
|
||||||
@@ -86,6 +101,11 @@ export class NpcViewComponent implements OnInit, OnDestroy {
|
|||||||
this.paramsSub?.unsubscribe();
|
this.paramsSub?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Titre d'une page de lore liée (pour les chips). */
|
||||||
|
titleOfPage(pageId: string): string {
|
||||||
|
return this.lorePagesById.get(pageId)?.title ?? '(page supprimée)';
|
||||||
|
}
|
||||||
|
|
||||||
edit(): void {
|
edit(): void {
|
||||||
if (this.campaignId && this.npcId) {
|
if (this.campaignId && this.npcId) {
|
||||||
this.router.navigate(['/campaigns', this.campaignId, 'npcs', this.npcId, 'edit']);
|
this.router.navigate(['/campaigns', this.campaignId, 'npcs', this.npcId, 'edit']);
|
||||||
|
|||||||
@@ -60,6 +60,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (importStatus) {
|
||||||
|
<p class="import-status" role="status">{{ importStatus }}</p>
|
||||||
|
}
|
||||||
@if (importFound.length) {
|
@if (importFound.length) {
|
||||||
<p class="import-found">
|
<p class="import-found">
|
||||||
Sections trouvées : {{ importFound.join(' · ') }}
|
Sections trouvées : {{ importFound.join(' · ') }}
|
||||||
|
|||||||
@@ -163,6 +163,16 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Message d'attente live (fournisseur saturé → retry, morceau re-découpé…) :
|
||||||
|
// ambre pour signaler « ça travaille, mais il se passe quelque chose ».
|
||||||
|
.import-status {
|
||||||
|
margin: 0.55rem 0 0;
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.import-note {
|
.import-note {
|
||||||
margin: -0.4rem 0 1rem;
|
margin: -0.4rem 0 1rem;
|
||||||
padding: 0.55rem 0.8rem;
|
padding: 0.55rem 0.8rem;
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ export class GameSystemEditComponent implements OnInit {
|
|||||||
importProgress: { current: number; total: number } | null = null;
|
importProgress: { current: number; total: number } | null = null;
|
||||||
/** Titres de sections trouvés au fil de l'eau (affichage live). */
|
/** Titres de sections trouvés au fil de l'eau (affichage live). */
|
||||||
importFound: string[] = [];
|
importFound: string[] = [];
|
||||||
|
/**
|
||||||
|
* Dernier message de statut du flux (fournisseur saturé → retry, morceau
|
||||||
|
* re-découpé/ignoré…). Effacé à chaque progression : il explique l'ATTENTE
|
||||||
|
* en cours, pas l'historique.
|
||||||
|
*/
|
||||||
|
importStatus: string | null = null;
|
||||||
|
|
||||||
name = '';
|
name = '';
|
||||||
description = '';
|
description = '';
|
||||||
@@ -139,10 +145,13 @@ export class GameSystemEditComponent implements OnInit {
|
|||||||
this.importPhase = 'Extraction du texte…';
|
this.importPhase = 'Extraction du texte…';
|
||||||
this.importProgress = null;
|
this.importProgress = null;
|
||||||
this.importFound = [];
|
this.importFound = [];
|
||||||
|
this.importStatus = null;
|
||||||
|
|
||||||
this.service.importRulesStream(file).subscribe({
|
this.service.importRulesStream(file).subscribe({
|
||||||
next: (ev) => {
|
next: (ev) => {
|
||||||
if (ev.type === 'progress') {
|
if (ev.type === 'progress') {
|
||||||
|
// Un morceau vient d'aboutir : le message d'attente est obsolète.
|
||||||
|
this.importStatus = null;
|
||||||
if (ev.total === 0) {
|
if (ev.total === 0) {
|
||||||
// Phase d'extraction (total encore inconnu).
|
// Phase d'extraction (total encore inconnu).
|
||||||
this.importPhase = 'Extraction du texte…';
|
this.importPhase = 'Extraction du texte…';
|
||||||
@@ -154,6 +163,8 @@ export class GameSystemEditComponent implements OnInit {
|
|||||||
if (!this.importFound.includes(t)) this.importFound.push(t);
|
if (!this.importFound.includes(t)) this.importFound.push(t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (ev.type === 'status') {
|
||||||
|
this.importStatus = ev.message;
|
||||||
} else if (ev.type === 'done') {
|
} else if (ev.type === 'done') {
|
||||||
this.finishImport(ev.sections, ev.pageCount, ev.ocrPageCount);
|
this.finishImport(ev.sections, ev.pageCount, ev.ocrPageCount);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
<p class="description">{{ lore.description }}</p>
|
<p class="description">{{ lore.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button type="button" class="btn-secondary" (click)="openGraph()"
|
||||||
|
title="Visualiser le graphe des pages et de leurs liens (PNJ inclus)">
|
||||||
|
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
|
||||||
|
Graphe
|
||||||
|
</button>
|
||||||
<button type="button" class="btn-secondary" (click)="startEdit()" title="Modifier le Lore">
|
<button type="button" class="btn-secondary" (click)="startEdit()" title="Modifier le Lore">
|
||||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
|||||||
|
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { LucideAngularModule, Folder, Plus, Pencil, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Folder, Plus, Pencil, Trash2, Network } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
@@ -23,6 +23,7 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
|||||||
readonly Plus = Plus;
|
readonly Plus = Plus;
|
||||||
readonly Pencil = Pencil;
|
readonly Pencil = Pencil;
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
|
readonly Network = Network;
|
||||||
|
|
||||||
lore: Lore | null = null;
|
lore: Lore | null = null;
|
||||||
/** Tous les dossiers du Lore (racines + enfants). */
|
/** Tous les dossiers du Lore (racines + enfants). */
|
||||||
@@ -81,6 +82,13 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId]);
|
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ouvre la vue graphe : pages du Lore + PNJ liés, reliés par leurs liens. */
|
||||||
|
openGraph(): void {
|
||||||
|
if (this.lore?.id) {
|
||||||
|
this.router.navigate(['/lore', this.lore.id, 'graph']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────── Édition / suppression du Lore ───────────────
|
// ─────────────── Édition / suppression du Lore ───────────────
|
||||||
|
|
||||||
startEdit(): void {
|
startEdit(): void {
|
||||||
|
|||||||
71
web/src/app/lore/lore-graph/lore-graph.component.html
Normal file
71
web/src/app/lore/lore-graph/lore-graph.component.html
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
@if (lore) {
|
||||||
|
<div class="graph-page">
|
||||||
|
<header class="graph-header">
|
||||||
|
<button type="button" class="btn-back" (click)="back()">
|
||||||
|
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||||
|
Retour au Lore
|
||||||
|
</button>
|
||||||
|
<div class="graph-title">
|
||||||
|
<h1>
|
||||||
|
<lucide-icon [img]="Network" [size]="20"></lucide-icon>
|
||||||
|
{{ lore.name }} — Graphe
|
||||||
|
</h1>
|
||||||
|
<p class="graph-subtitle">
|
||||||
|
{{ nodes.length - npcCount }} page(s) · {{ npcCount }} PNJ · {{ edgeCount }} lien(s).
|
||||||
|
Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="graph-legend">
|
||||||
|
<span class="legend-item"><span class="legend-dot legend-dot--page"></span> Page de Lore</span>
|
||||||
|
<span class="legend-item"><span class="legend-dot legend-dot--npc"></span> PNJ</span>
|
||||||
|
<span class="legend-item"><span class="legend-line legend-line--npc"></span> Lien PNJ → page</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (nodes.length === 0) {
|
||||||
|
<div class="graph-empty">
|
||||||
|
Aucune page dans ce Lore pour l'instant — le graphe se remplira au fur et à mesure.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="graph-scroll">
|
||||||
|
<svg #svgEl
|
||||||
|
[attr.width]="svgWidth"
|
||||||
|
[attr.height]="svgHeight"
|
||||||
|
(pointermove)="onPointerMove($event)"
|
||||||
|
(pointerup)="onPointerUp($event)"
|
||||||
|
(pointerleave)="onPointerUp($event)">
|
||||||
|
|
||||||
|
<!-- Arêtes (sous les nœuds) -->
|
||||||
|
@for (e of edges; track e.key) {
|
||||||
|
<line
|
||||||
|
[attr.x1]="e.x1" [attr.y1]="e.y1"
|
||||||
|
[attr.x2]="e.x2" [attr.y2]="e.y2"
|
||||||
|
[class.edge-page]="e.kind === 'page'"
|
||||||
|
[class.edge-npc]="e.kind === 'npc'" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Nœuds -->
|
||||||
|
@for (n of nodes; track n.id) {
|
||||||
|
<g class="node"
|
||||||
|
[class.node--npc]="n.kind === 'npc'"
|
||||||
|
[class.dragging]="draggingId === n.id"
|
||||||
|
(pointerdown)="onPointerDown($event, n)">
|
||||||
|
<circle [attr.cx]="n.x" [attr.cy]="n.y" [attr.r]="radiusOf(n)" />
|
||||||
|
<text class="node-label"
|
||||||
|
[attr.x]="n.x"
|
||||||
|
[attr.y]="n.y + radiusOf(n) + 14"
|
||||||
|
text-anchor="middle">{{ n.displayLabel }}</text>
|
||||||
|
<title>{{ n.label }}</title>
|
||||||
|
</g>
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
@if (edgeCount === 0) {
|
||||||
|
<p class="graph-hint">
|
||||||
|
Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page)
|
||||||
|
ou rattachez un PNJ à des pages de Lore depuis sa fiche.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
163
web/src/app/lore/lore-graph/lore-graph.component.scss
Normal file
163
web/src/app/lore/lore-graph/lore-graph.component.scss
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
.graph-page {
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 240px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: white;
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-subtitle {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
&.legend-dot--page { background: #4338ca; border: 1px solid #818cf8; }
|
||||||
|
&.legend-dot--npc { background: #92400e; border: 1px solid #fbbf24; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-line {
|
||||||
|
width: 18px;
|
||||||
|
height: 0;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
&.legend-line--npc { border-top: 2px dashed #d97706; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le SVG peut dépasser l'écran sur un gros lore : scroll dans les 2 sens.
|
||||||
|
.graph-scroll {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #1e1e3a;
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.05) 1px, transparent 0) 0 0 / 26px 26px,
|
||||||
|
#0d0d1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
touch-action: none; // requis pour le drag au pointeur sur tactile
|
||||||
|
|
||||||
|
line {
|
||||||
|
&.edge-page {
|
||||||
|
stroke: #4f4f7a;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.edge-npc {
|
||||||
|
stroke: #d97706;
|
||||||
|
stroke-width: 1.4;
|
||||||
|
stroke-dasharray: 5 4;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.node {
|
||||||
|
cursor: grab;
|
||||||
|
|
||||||
|
circle {
|
||||||
|
fill: #1e1b4b;
|
||||||
|
stroke: #6366f1;
|
||||||
|
stroke-width: 2;
|
||||||
|
transition: stroke 0.12s, fill 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label {
|
||||||
|
fill: #c7d2fe;
|
||||||
|
font-size: 11px;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover circle {
|
||||||
|
fill: #312e81;
|
||||||
|
stroke: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dragging { cursor: grabbing; }
|
||||||
|
|
||||||
|
// PNJ : palette ambre, distincte des pages.
|
||||||
|
&.node--npc {
|
||||||
|
circle {
|
||||||
|
fill: #451a03;
|
||||||
|
stroke: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label { fill: #fcd34d; }
|
||||||
|
|
||||||
|
&:hover circle {
|
||||||
|
fill: #78350f;
|
||||||
|
stroke: #fbbf24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-empty {
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
border: 1px dashed #2a2a3d;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-hint {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
350
web/src/app/lore/lore-graph/lore-graph.component.ts
Normal file
350
web/src/app/lore/lore-graph/lore-graph.component.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
|
||||||
|
|
||||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
import { forkJoin } from 'rxjs';
|
||||||
|
import { LucideAngularModule, ArrowLeft, Network } from 'lucide-angular';
|
||||||
|
import { LoreService } from '../../services/lore.service';
|
||||||
|
import { TemplateService } from '../../services/template.service';
|
||||||
|
import { PageService } from '../../services/page.service';
|
||||||
|
import { NpcService } from '../../services/npc.service';
|
||||||
|
import { LayoutService } from '../../services/layout.service';
|
||||||
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
|
import { Lore } from '../../services/lore.model';
|
||||||
|
import { Page } from '../../services/page.model';
|
||||||
|
import { Npc } from '../../services/npc.model';
|
||||||
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
|
|
||||||
|
/** Nœud du graphe : une page de Lore ou un PNJ qui référence des pages. */
|
||||||
|
interface GraphNode {
|
||||||
|
id: string; // 'page:<id>' ou 'npc:<id>' (évite les collisions d'IDs)
|
||||||
|
kind: 'page' | 'npc';
|
||||||
|
label: string;
|
||||||
|
displayLabel: string;
|
||||||
|
route: string[]; // navigation au clic
|
||||||
|
x: number; // centre du nœud (coords SVG)
|
||||||
|
y: number;
|
||||||
|
degree: number; // nombre de liens (taille du nœud)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphEdge {
|
||||||
|
key: string;
|
||||||
|
kind: 'page' | 'npc'; // page↔page ou npc→page (style distinct)
|
||||||
|
x1: number; y1: number; x2: number; y2: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graphe du Lore : vue d'ensemble des pages et de leurs liens.
|
||||||
|
*
|
||||||
|
* Nœuds = toutes les pages du Lore + les PNJ (toutes campagnes liées au Lore)
|
||||||
|
* qui référencent au moins une page. Arêtes = `relatedPageIds` des pages
|
||||||
|
* (liens page↔page) et des PNJ (liens PNJ→page).
|
||||||
|
*
|
||||||
|
* Layout force-directed (Fruchterman-Reingold simplifié) calculé une fois au
|
||||||
|
* chargement, puis nœuds déplaçables à la souris — même approche SVG custom
|
||||||
|
* que chapter-graph, sans dépendance externe.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-lore-graph',
|
||||||
|
imports: [RouterModule, LucideAngularModule],
|
||||||
|
templateUrl: './lore-graph.component.html',
|
||||||
|
styleUrls: ['./lore-graph.component.scss']
|
||||||
|
})
|
||||||
|
export class LoreGraphComponent implements OnInit, OnDestroy {
|
||||||
|
readonly ArrowLeft = ArrowLeft;
|
||||||
|
readonly Network = Network;
|
||||||
|
|
||||||
|
loreId = '';
|
||||||
|
lore: Lore | null = null;
|
||||||
|
|
||||||
|
nodes: GraphNode[] = [];
|
||||||
|
edges: GraphEdge[] = [];
|
||||||
|
npcCount = 0;
|
||||||
|
edgeCount = 0;
|
||||||
|
|
||||||
|
readonly MAX_LABEL_CHARS = 22;
|
||||||
|
private readonly MARGIN = 70;
|
||||||
|
|
||||||
|
svgWidth = 800;
|
||||||
|
svgHeight = 600;
|
||||||
|
|
||||||
|
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
|
||||||
|
|
||||||
|
draggingId: string | null = null;
|
||||||
|
private dragOffsetX = 0;
|
||||||
|
private dragOffsetY = 0;
|
||||||
|
private dragMoved = false;
|
||||||
|
private readonly DRAG_THRESHOLD = 4;
|
||||||
|
|
||||||
|
// Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag.
|
||||||
|
private adjacency: Array<{ key: string; kind: 'page' | 'npc'; a: string; b: string }> = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private loreService: LoreService,
|
||||||
|
private templateService: TemplateService,
|
||||||
|
private pageService: PageService,
|
||||||
|
private npcService: NpcService,
|
||||||
|
private layoutService: LayoutService,
|
||||||
|
private pageTitleService: PageTitleService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||||
|
forkJoin({
|
||||||
|
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
||||||
|
npcs: this.npcService.getByLore(this.loreId)
|
||||||
|
}).subscribe(({ sidebar, npcs }) => {
|
||||||
|
this.lore = sidebar.lore;
|
||||||
|
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||||
|
this.pageTitleService.set(`${sidebar.lore.name} — Graphe`);
|
||||||
|
this.buildGraph(sidebar.pages, npcs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Construction du graphe ----------------------------------------------
|
||||||
|
|
||||||
|
private buildGraph(pages: Page[], npcs: Npc[]): void {
|
||||||
|
const pageIds = new Set(pages.map(p => p.id!));
|
||||||
|
|
||||||
|
// Nœuds pages (toutes, même isolées : la vue d'ensemble inclut les orphelines).
|
||||||
|
const nodes: GraphNode[] = pages.map(p => ({
|
||||||
|
id: `page:${p.id}`,
|
||||||
|
kind: 'page' as const,
|
||||||
|
label: p.title,
|
||||||
|
displayLabel: this.truncate(p.title),
|
||||||
|
route: ['/lore', this.loreId, 'pages', p.id!],
|
||||||
|
x: 0, y: 0, degree: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Nœuds PNJ : seulement ceux qui référencent au moins une page de CE lore
|
||||||
|
// (un PNJ sans lien n'apporte rien à la carte des connexions).
|
||||||
|
const linkedNpcs = npcs.filter(n =>
|
||||||
|
(n.relatedPageIds ?? []).some(pid => pageIds.has(pid)));
|
||||||
|
for (const n of linkedNpcs) {
|
||||||
|
nodes.push({
|
||||||
|
id: `npc:${n.id}`,
|
||||||
|
kind: 'npc',
|
||||||
|
label: n.name,
|
||||||
|
displayLabel: this.truncate(n.name),
|
||||||
|
route: ['/campaigns', n.campaignId, 'npcs', n.id!],
|
||||||
|
x: 0, y: 0, degree: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.npcCount = linkedNpcs.length;
|
||||||
|
|
||||||
|
// Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée
|
||||||
|
// (A→B et B→A = un seul trait).
|
||||||
|
const adjacency: Array<{ key: string; kind: 'page' | 'npc'; a: string; b: string }> = [];
|
||||||
|
const seenPairs = new Set<string>();
|
||||||
|
for (const p of pages) {
|
||||||
|
for (const targetId of p.relatedPageIds ?? []) {
|
||||||
|
if (!pageIds.has(targetId) || targetId === p.id) continue;
|
||||||
|
const pair = [p.id!, targetId].sort().join('|');
|
||||||
|
if (seenPairs.has(pair)) continue;
|
||||||
|
seenPairs.add(pair);
|
||||||
|
adjacency.push({ key: `pp:${pair}`, kind: 'page', a: `page:${p.id}`, b: `page:${targetId}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const n of linkedNpcs) {
|
||||||
|
for (const targetId of new Set(n.relatedPageIds ?? [])) {
|
||||||
|
if (!pageIds.has(targetId)) continue;
|
||||||
|
adjacency.push({ key: `np:${n.id}|${targetId}`, kind: 'npc', a: `npc:${n.id}`, b: `page:${targetId}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.adjacency = adjacency;
|
||||||
|
this.edgeCount = adjacency.length;
|
||||||
|
|
||||||
|
// Degré (pondère la taille des nœuds : une capitale très liée ressort).
|
||||||
|
const degree = new Map<string, number>();
|
||||||
|
for (const e of adjacency) {
|
||||||
|
degree.set(e.a, (degree.get(e.a) ?? 0) + 1);
|
||||||
|
degree.set(e.b, (degree.get(e.b) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
for (const node of nodes) {
|
||||||
|
node.degree = degree.get(node.id) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nodes = nodes;
|
||||||
|
this.runForceLayout();
|
||||||
|
this.recomputeEdges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout force-directed (Fruchterman-Reingold simplifié) :
|
||||||
|
* répulsion entre tous les nœuds, ressorts sur les arêtes, gravité vers le
|
||||||
|
* centre (regroupe les composantes déconnectées), refroidissement progressif.
|
||||||
|
* Positions initiales sur un cercle (déterministe : pas d'aléatoire, cf.
|
||||||
|
* convention projet d'éviter Math.random pour des rendus reproductibles).
|
||||||
|
*/
|
||||||
|
private runForceLayout(): void {
|
||||||
|
const n = this.nodes.length;
|
||||||
|
if (n === 0) {
|
||||||
|
this.svgWidth = 800; this.svgHeight = 400;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const side = Math.max(600, Math.ceil(170 * Math.sqrt(n)));
|
||||||
|
const w = side, h = side;
|
||||||
|
const cx = w / 2, cy = h / 2;
|
||||||
|
|
||||||
|
// Init en spirale : angle d'or → répartition uniforme et déterministe.
|
||||||
|
const golden = Math.PI * (3 - Math.sqrt(5));
|
||||||
|
this.nodes.forEach((node, i) => {
|
||||||
|
const r = (Math.sqrt(i + 0.5) / Math.sqrt(n)) * (side / 2 - this.MARGIN);
|
||||||
|
const a = i * golden;
|
||||||
|
node.x = cx + r * Math.cos(a);
|
||||||
|
node.y = cy + r * Math.sin(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
const index = new Map(this.nodes.map(node => [node.id, node]));
|
||||||
|
const k = 0.9 * Math.sqrt((w * h) / n); // distance "idéale" entre nœuds
|
||||||
|
let temperature = side / 8;
|
||||||
|
|
||||||
|
for (let iter = 0; iter < 300; iter++) {
|
||||||
|
const dx = new Map<string, number>();
|
||||||
|
const dy = new Map<string, number>();
|
||||||
|
for (const node of this.nodes) { dx.set(node.id, 0); dy.set(node.id, 0); }
|
||||||
|
|
||||||
|
// Répulsion entre toutes les paires.
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
for (let j = i + 1; j < n; j++) {
|
||||||
|
const a = this.nodes[i], b = this.nodes[j];
|
||||||
|
let vx = a.x - b.x, vy = a.y - b.y;
|
||||||
|
let d = Math.hypot(vx, vy);
|
||||||
|
if (d < 0.01) { vx = 0.1 * ((i % 3) - 1) || 0.1; vy = 0.1; d = Math.hypot(vx, vy); }
|
||||||
|
const force = (k * k) / d;
|
||||||
|
dx.set(a.id, dx.get(a.id)! + (vx / d) * force);
|
||||||
|
dy.set(a.id, dy.get(a.id)! + (vy / d) * force);
|
||||||
|
dx.set(b.id, dx.get(b.id)! - (vx / d) * force);
|
||||||
|
dy.set(b.id, dy.get(b.id)! - (vy / d) * force);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attraction le long des arêtes.
|
||||||
|
for (const e of this.adjacency) {
|
||||||
|
const a = index.get(e.a)!, b = index.get(e.b)!;
|
||||||
|
const vx = a.x - b.x, vy = a.y - b.y;
|
||||||
|
const d = Math.max(0.01, Math.hypot(vx, vy));
|
||||||
|
const force = (d * d) / k;
|
||||||
|
dx.set(a.id, dx.get(a.id)! - (vx / d) * force);
|
||||||
|
dy.set(a.id, dy.get(a.id)! - (vy / d) * force);
|
||||||
|
dx.set(b.id, dx.get(b.id)! + (vx / d) * force);
|
||||||
|
dy.set(b.id, dy.get(b.id)! + (vy / d) * force);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gravité douce vers le centre (sinon les composantes isolées fuient).
|
||||||
|
for (const node of this.nodes) {
|
||||||
|
dx.set(node.id, dx.get(node.id)! + (cx - node.x) * 0.06);
|
||||||
|
dy.set(node.id, dy.get(node.id)! + (cy - node.y) * 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application bornée par la température, dans le cadre.
|
||||||
|
for (const node of this.nodes) {
|
||||||
|
const ddx = dx.get(node.id)!, ddy = dy.get(node.id)!;
|
||||||
|
const d = Math.max(0.01, Math.hypot(ddx, ddy));
|
||||||
|
const step = Math.min(d, temperature);
|
||||||
|
node.x = Math.min(w - this.MARGIN, Math.max(this.MARGIN, node.x + (ddx / d) * step));
|
||||||
|
node.y = Math.min(h - this.MARGIN, Math.max(this.MARGIN, node.y + (ddy / d) * step));
|
||||||
|
}
|
||||||
|
temperature *= 0.96;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.svgWidth = w;
|
||||||
|
this.svgHeight = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recalcule la géométrie des arêtes depuis les positions courantes des nœuds. */
|
||||||
|
private recomputeEdges(): void {
|
||||||
|
const index = new Map(this.nodes.map(n => [n.id, n]));
|
||||||
|
this.edges = this.adjacency
|
||||||
|
.filter(e => index.has(e.a) && index.has(e.b))
|
||||||
|
.map(e => {
|
||||||
|
const a = index.get(e.a)!, b = index.get(e.b)!;
|
||||||
|
return { key: e.key, kind: e.kind, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rayon d'un nœud : grossit doucement avec son nombre de liens. */
|
||||||
|
radiusOf(node: GraphNode): number {
|
||||||
|
return 14 + Math.min(10, node.degree * 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Interactions (drag pour réarranger, clic pour ouvrir) ----------------
|
||||||
|
|
||||||
|
private toSvgCoords(evt: PointerEvent): { x: number; y: number } {
|
||||||
|
const svg = this.svgEl?.nativeElement;
|
||||||
|
if (!svg) return { x: evt.clientX, y: evt.clientY };
|
||||||
|
const pt = svg.createSVGPoint();
|
||||||
|
pt.x = evt.clientX;
|
||||||
|
pt.y = evt.clientY;
|
||||||
|
const ctm = svg.getScreenCTM();
|
||||||
|
if (!ctm) return { x: evt.clientX, y: evt.clientY };
|
||||||
|
const local = pt.matrixTransform(ctm.inverse());
|
||||||
|
return { x: local.x, y: local.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerDown(evt: PointerEvent, node: GraphNode): void {
|
||||||
|
if (evt.button !== 0) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
const { x, y } = this.toSvgCoords(evt);
|
||||||
|
this.draggingId = node.id;
|
||||||
|
this.dragOffsetX = x - node.x;
|
||||||
|
this.dragOffsetY = y - node.y;
|
||||||
|
this.dragMoved = false;
|
||||||
|
(evt.target as Element).setPointerCapture?.(evt.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerMove(evt: PointerEvent): void {
|
||||||
|
if (!this.draggingId) return;
|
||||||
|
const node = this.nodes.find(n => n.id === this.draggingId);
|
||||||
|
if (!node) return;
|
||||||
|
const { x, y } = this.toSvgCoords(evt);
|
||||||
|
const newX = Math.max(this.MARGIN / 2, x - this.dragOffsetX);
|
||||||
|
const newY = Math.max(this.MARGIN / 2, y - this.dragOffsetY);
|
||||||
|
if (!this.dragMoved) {
|
||||||
|
if (Math.hypot(newX - node.x, newY - node.y) < this.DRAG_THRESHOLD) return;
|
||||||
|
this.dragMoved = true;
|
||||||
|
}
|
||||||
|
node.x = newX;
|
||||||
|
node.y = newY;
|
||||||
|
this.recomputeEdges();
|
||||||
|
this.fitSvgToNodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerUp(evt: PointerEvent): void {
|
||||||
|
if (!this.draggingId) return;
|
||||||
|
const id = this.draggingId;
|
||||||
|
const moved = this.dragMoved;
|
||||||
|
this.draggingId = null;
|
||||||
|
this.dragMoved = false;
|
||||||
|
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
|
||||||
|
if (moved) return;
|
||||||
|
// Clic simple → ouvre la page / la fiche PNJ.
|
||||||
|
const node = this.nodes.find(n => n.id === id);
|
||||||
|
if (node) this.router.navigate(node.route);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agrandit le SVG si un nœud déplacé s'approche du bord (jamais de réduction). */
|
||||||
|
private fitSvgToNodes(): void {
|
||||||
|
for (const n of this.nodes) {
|
||||||
|
if (n.x + this.MARGIN > this.svgWidth) this.svgWidth = n.x + this.MARGIN;
|
||||||
|
if (n.y + this.MARGIN > this.svgHeight) this.svgHeight = n.y + this.MARGIN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private truncate(text: string): string {
|
||||||
|
return text.length > this.MAX_LABEL_CHARS
|
||||||
|
? text.slice(0, this.MAX_LABEL_CHARS - 1) + '…'
|
||||||
|
: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
back(): void {
|
||||||
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,75 @@
|
|||||||
</app-image-gallery>
|
</app-image-gallery>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Champ KEY_VALUE_LIST : liste libellé/valeur (labels figés par le template). -->
|
||||||
|
@if (field.type === 'KEY_VALUE_LIST') {
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ field.name }}</label>
|
||||||
|
<div class="kv-grid">
|
||||||
|
@for (lbl of field.labels ?? []; track $index) {
|
||||||
|
<div class="kv-cell">
|
||||||
|
<span class="kv-label">{{ lbl }}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="keyValueValues[field.name][lbl]"
|
||||||
|
[name]="'kv_' + field.name + '_' + lbl"
|
||||||
|
placeholder="—" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!(field.labels ?? []).length) {
|
||||||
|
<p class="kv-empty">Aucun libellé défini dans le template pour ce champ.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Champ TABLE : colonnes figées par le template, lignes libres. -->
|
||||||
|
@if (field.type === 'TABLE') {
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ field.name }}</label>
|
||||||
|
@if ((field.labels ?? []).length) {
|
||||||
|
<div class="table-edit-wrap">
|
||||||
|
<table class="table-edit">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
@for (col of field.labels; track $index) {
|
||||||
|
<th>{{ col }}</th>
|
||||||
|
}
|
||||||
|
<th class="table-edit-actions-col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of tableValues[field.name] ?? []; track $index; let ri = $index) {
|
||||||
|
<tr>
|
||||||
|
@for (col of field.labels; track $index) {
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="row[col]"
|
||||||
|
[name]="'tbl_' + field.name + '_' + ri + '_' + col"
|
||||||
|
[placeholder]="col" />
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td class="table-edit-actions-col">
|
||||||
|
<button type="button" class="btn-row-delete"
|
||||||
|
(click)="removeTableRow(field.name, ri)"
|
||||||
|
[attr.aria-label]="'Supprimer la ligne ' + (ri + 1)" title="Supprimer la ligne">
|
||||||
|
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button type="button" class="btn-row-add" (click)="addTableRow(field.name, field.labels)">
|
||||||
|
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
|
||||||
|
Ajouter une ligne
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="kv-empty">Aucune colonne définie dans le template pour ce tableau.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<!-- Tags --------------------------------------------------------- -->
|
<!-- Tags --------------------------------------------------------- -->
|
||||||
|
|||||||
@@ -126,6 +126,127 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grille de saisie d'un champ Tableau (KEY_VALUE_LIST) — même esthétique
|
||||||
|
// que la grille des fiches de personnage (dynamic-fields-form).
|
||||||
|
.kv-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.kv-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 8px 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.kv-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: white;
|
||||||
|
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
|
||||||
|
&:focus { outline: none; border-color: #6c63ff; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-empty {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Éditeur d'un champ Tableau (TABLE) : colonnes du template, lignes libres.
|
||||||
|
.table-edit-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-edit {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #99f6e4;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid rgba(20, 184, 166, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 4px 4px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
|
||||||
|
&:focus { outline: none; border-color: #14b8a6; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-edit-actions-col {
|
||||||
|
width: 34px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row-delete {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
&:hover { color: #f87171; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row-add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed rgba(20, 184, 166, 0.5);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #5eead4;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { background: rgba(20, 184, 166, 0.08); }
|
||||||
|
}
|
||||||
|
|
||||||
.ai-error-banner {
|
.ai-error-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import { LucideAngularModule, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, Sparkles, Plus, Trash2 } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
@@ -42,6 +42,8 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
|||||||
})
|
})
|
||||||
export class PageEditComponent implements OnInit, OnDestroy {
|
export class PageEditComponent implements OnInit, OnDestroy {
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
|
readonly Plus = Plus;
|
||||||
|
readonly Trash2 = Trash2;
|
||||||
|
|
||||||
loreId = '';
|
loreId = '';
|
||||||
pageId = '';
|
pageId = '';
|
||||||
@@ -63,6 +65,10 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
* la liste ordonnee des IDs d'images uploadees.
|
* la liste ordonnee des IDs d'images uploadees.
|
||||||
*/
|
*/
|
||||||
imageValues: Record<string, string[]> = {};
|
imageValues: Record<string, string[]> = {};
|
||||||
|
/** Valeurs des champs KEY_VALUE_LIST (liste clé/valeur) : fieldName → (label → valeur). */
|
||||||
|
keyValueValues: Record<string, Record<string, string>> = {};
|
||||||
|
/** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). */
|
||||||
|
tableValues: Record<string, Array<Record<string, string>>> = {};
|
||||||
/** Étiquettes libres (Phase 5B). */
|
/** Étiquettes libres (Phase 5B). */
|
||||||
tags: string[] = [];
|
tags: string[] = [];
|
||||||
/** IDs des pages liées (Phase 5B). */
|
/** IDs des pages liées (Phase 5B). */
|
||||||
@@ -169,16 +175,27 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
// structure `imageValues: Map<String, List<String>>` a l'etape 5).
|
// structure `imageValues: Map<String, List<String>>` a l'etape 5).
|
||||||
const base: Record<string, string> = {};
|
const base: Record<string, string> = {};
|
||||||
const imageBase: Record<string, string[]> = {};
|
const imageBase: Record<string, string[]> = {};
|
||||||
|
const kvBase: Record<string, Record<string, string>> = {};
|
||||||
|
const tableBase: Record<string, Array<Record<string, string>>> = {};
|
||||||
for (const f of this.template?.fields ?? []) {
|
for (const f of this.template?.fields ?? []) {
|
||||||
if (f.type === 'TEXT') {
|
if (f.type === 'TEXT') {
|
||||||
base[f.name] = page.values?.[f.name] ?? '';
|
base[f.name] = page.values?.[f.name] ?? '';
|
||||||
} else if (f.type === 'IMAGE') {
|
} else if (f.type === 'IMAGE') {
|
||||||
// Initialise la galerie d'images pour ce champ (vide si jamais rempli).
|
// Initialise la galerie d'images pour ce champ (vide si jamais rempli).
|
||||||
imageBase[f.name] = [...(page.imageValues?.[f.name] ?? [])];
|
imageBase[f.name] = [...(page.imageValues?.[f.name] ?? [])];
|
||||||
|
} else if (f.type === 'KEY_VALUE_LIST') {
|
||||||
|
// Toujours initialiser l'objet interne : le ngModel du formulaire
|
||||||
|
// bind directement keyValueValues[field.name][label].
|
||||||
|
kvBase[f.name] = { ...(page.keyValueValues?.[f.name] ?? {}) };
|
||||||
|
} else if (f.type === 'TABLE') {
|
||||||
|
// Copie profonde des lignes : chaque ligne est éditée par ngModel.
|
||||||
|
tableBase[f.name] = (page.tableValues?.[f.name] ?? []).map(row => ({ ...row }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.values = base;
|
this.values = base;
|
||||||
this.imageValues = imageBase;
|
this.imageValues = imageBase;
|
||||||
|
this.keyValueValues = kvBase;
|
||||||
|
this.tableValues = tableBase;
|
||||||
this.tags = [...(page.tags ?? [])];
|
this.tags = [...(page.tags ?? [])];
|
||||||
this.relatedPageIds = [...(page.relatedPageIds ?? [])];
|
this.relatedPageIds = [...(page.relatedPageIds ?? [])];
|
||||||
this.pageTitleService.set(page.title);
|
this.pageTitleService.set(page.title);
|
||||||
@@ -193,6 +210,8 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
notes: this.notes,
|
notes: this.notes,
|
||||||
values: this.values,
|
values: this.values,
|
||||||
imageValues: this.imageValues,
|
imageValues: this.imageValues,
|
||||||
|
keyValueValues: this.keyValueValues,
|
||||||
|
tableValues: this.tableValues,
|
||||||
tags: this.tags,
|
tags: this.tags,
|
||||||
relatedPageIds: this.relatedPageIds
|
relatedPageIds: this.relatedPageIds
|
||||||
};
|
};
|
||||||
@@ -202,6 +221,20 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Champs TABLE (lignes libres) ---------------------------------------
|
||||||
|
// Mutation en place des lignes : recréer le tableau à chaque frappe ferait
|
||||||
|
// perdre le focus de la cellule en cours d'édition.
|
||||||
|
|
||||||
|
addTableRow(fieldName: string, columns: string[] | null | undefined): void {
|
||||||
|
const row: Record<string, string> = {};
|
||||||
|
for (const col of columns ?? []) row[col] = '';
|
||||||
|
(this.tableValues[fieldName] ??= []).push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTableRow(fieldName: string, rowIndex: number): void {
|
||||||
|
this.tableValues[fieldName]?.splice(rowIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Chat IA conversationnel (Phase b5) --------------------------------
|
// --- Chat IA conversationnel (Phase b5) --------------------------------
|
||||||
|
|
||||||
toggleChat(): void {
|
toggleChat(): void {
|
||||||
|
|||||||
@@ -40,6 +40,50 @@
|
|||||||
</app-image-gallery>
|
</app-image-gallery>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
@if (field.type === 'KEY_VALUE_LIST') {
|
||||||
|
<section class="view-section">
|
||||||
|
<h2 class="view-section-title">{{ field.name }}</h2>
|
||||||
|
@if (kvHasContent(field.name, field.labels)) {
|
||||||
|
<div class="view-kv-grid">
|
||||||
|
@for (lbl of field.labels ?? []; track $index) {
|
||||||
|
<div class="view-kv-cell">
|
||||||
|
<span class="view-kv-label">{{ lbl }}</span>
|
||||||
|
<span class="view-kv-value">{{ kvValueOf(field.name, lbl) || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="view-section-empty">Non renseigné</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
@if (field.type === 'TABLE') {
|
||||||
|
<section class="view-section">
|
||||||
|
<h2 class="view-section-title">{{ field.name }}</h2>
|
||||||
|
@if (tableRowsOf(field.name).length) {
|
||||||
|
<table class="view-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
@for (col of field.labels ?? []; track $index) {
|
||||||
|
<th>{{ col }}</th>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of tableRowsOf(field.name); track $index) {
|
||||||
|
<tr>
|
||||||
|
@for (col of field.labels ?? []; track $index) {
|
||||||
|
<td>{{ row[col] || '—' }}</td>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
} @else {
|
||||||
|
<p class="view-section-empty">Non renseigné</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
|
|||||||
@@ -114,6 +114,21 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
|||||||
return this.page?.imageValues?.[fieldName] ?? [];
|
return this.page?.imageValues?.[fieldName] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Valeur d'un libellé d'un champ KEY_VALUE_LIST (tableau). */
|
||||||
|
kvValueOf(fieldName: string, label: string): string {
|
||||||
|
return this.page?.keyValueValues?.[fieldName]?.[label] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True si au moins une valeur de la liste clé/valeur est renseignée. */
|
||||||
|
kvHasContent(fieldName: string, labels: string[] | null | undefined): boolean {
|
||||||
|
return (labels ?? []).some(lbl => this.kvValueOf(fieldName, lbl).trim() !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lignes d'un champ TABLE (liste vide si jamais rempli). */
|
||||||
|
tableRowsOf(fieldName: string): Array<Record<string, string>> {
|
||||||
|
return this.page?.tableValues?.[fieldName] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
/** Helper — résout l'ID d'une page liée en son titre (pour affichage dans les chips). */
|
/** Helper — résout l'ID d'une page liée en son titre (pour affichage dans les chips). */
|
||||||
titleOfRelated(pageId: string): string {
|
titleOfRelated(pageId: string): string {
|
||||||
return this.allPages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
|
return this.allPages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
|
||||||
|
|||||||
@@ -66,17 +66,24 @@
|
|||||||
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
|
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
|
<span class="field-chip"
|
||||||
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
[class.field-chip-image]="f.type === 'IMAGE'"
|
||||||
|
[class.field-chip-kv]="f.type === 'KEY_VALUE_LIST'"
|
||||||
|
[class.field-chip-table]="f.type === 'TABLE'">
|
||||||
|
<lucide-icon [img]="iconFor(f.type)" [size]="12"></lucide-icon>
|
||||||
{{ f.name }}
|
{{ f.name }}
|
||||||
</span>
|
</span>
|
||||||
<button type="button"
|
<select
|
||||||
class="btn-icon btn-type-toggle"
|
class="type-select"
|
||||||
(click)="toggleFieldType(i)"
|
[ngModel]="f.type"
|
||||||
[attr.aria-label]="'Basculer vers ' + (f.type === 'TEXT' ? 'Image' : 'Texte')"
|
[ngModelOptions]="{ standalone: true }"
|
||||||
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
|
(ngModelChange)="setFieldType(i, $event)"
|
||||||
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
|
title="Type du champ">
|
||||||
</button>
|
<option value="TEXT">Texte</option>
|
||||||
|
<option value="IMAGE">Image</option>
|
||||||
|
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
|
||||||
|
<option value="TABLE">Tableau</option>
|
||||||
|
</select>
|
||||||
@if (f.type === 'IMAGE') {
|
@if (f.type === 'IMAGE') {
|
||||||
<select
|
<select
|
||||||
class="layout-select"
|
class="layout-select"
|
||||||
@@ -94,6 +101,36 @@
|
|||||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<!-- Libelles (lignes d'une liste clé/valeur, ou colonnes d'un tableau) -->
|
||||||
|
@if (f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE') {
|
||||||
|
<li class="kv-labels-row">
|
||||||
|
@for (lbl of f.labels ?? []; track $index; let li = $index) {
|
||||||
|
<span class="kv-label-chip">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[ngModel]="lbl"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
(ngModelChange)="updateLabel(f, li, $event)"
|
||||||
|
[placeholder]="f.type === 'TABLE' ? 'Colonne' : 'Libellé'"
|
||||||
|
[attr.aria-label]="(f.type === 'TABLE' ? 'Colonne ' : 'Libellé ') + (li + 1)" />
|
||||||
|
<button type="button" class="kv-label-remove" (click)="removeLabel(f, li)" aria-label="Retirer">
|
||||||
|
<lucide-icon [img]="X" [size]="11"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<button type="button" class="btn-kv-add-label" (click)="addLabel(f)">
|
||||||
|
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
|
||||||
|
{{ f.type === 'TABLE' ? 'Colonne' : 'Libellé' }}
|
||||||
|
</button>
|
||||||
|
@if (!(f.labels ?? []).length) {
|
||||||
|
<span class="kv-labels-hint">
|
||||||
|
{{ f.type === 'TABLE'
|
||||||
|
? 'Ajoutez les colonnes du tableau (ex : Objet, Prix, Description…)'
|
||||||
|
: 'Ajoutez les libellés des lignes (ex : FOR, DEX, CON…)' }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -111,13 +148,15 @@
|
|||||||
aria-label="Type du champ">
|
aria-label="Type du champ">
|
||||||
<option value="TEXT">Texte</option>
|
<option value="TEXT">Texte</option>
|
||||||
<option value="IMAGE">Image</option>
|
<option value="IMAGE">Image</option>
|
||||||
|
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
|
||||||
|
<option value="TABLE">Tableau</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
|
<button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
|
||||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="hint">Les champs Texte sont editables librement et utilisables par l'IA. Les champs Image hebergent une galerie d'illustrations.</p>
|
<p class="hint">Texte = libre + utilisable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,70 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sous-ligne des libellés d'un champ Tableau (KEY_VALUE_LIST).
|
||||||
|
.kv-labels-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
// Aligne visuellement sous le chip du champ (décalage = reorder-stack + gap).
|
||||||
|
padding-left: 30px;
|
||||||
|
|
||||||
|
.kv-label-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid #4a3a1f;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 90px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #fbd38d;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.3rem;
|
||||||
|
|
||||||
|
&:focus { outline: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-label-remove {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px;
|
||||||
|
|
||||||
|
&:hover { color: #f87171; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-kv-add-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed #4a3a1f;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #fbd38d;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.3rem 0.55rem;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { background: rgba(251, 211, 141, 0.08); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-labels-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.field-row {
|
.field-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -111,6 +175,18 @@
|
|||||||
background: #312b5c;
|
background: #312b5c;
|
||||||
color: #c7b8ff;
|
color: #c7b8ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Couleur discriminante pour les champs Liste clé/valeur (palette ambre).
|
||||||
|
&.field-chip-kv {
|
||||||
|
background: #4a3a1f;
|
||||||
|
color: #fbd38d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Couleur discriminante pour les champs Tableau (palette sarcelle).
|
||||||
|
&.field-chip-table {
|
||||||
|
background: #134e4a;
|
||||||
|
color: #99f6e4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-type-toggle {
|
.btn-type-toggle {
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
|||||||
|
|
||||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
|
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown, ListOrdered, Table as TableIcon, X } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService } from '../../services/layout.service';
|
import { LayoutService } from '../../services/layout.service';
|
||||||
import { LoreNode } from '../../services/lore.model';
|
import { LoreNode } from '../../services/lore.model';
|
||||||
import { FieldType, ImageLayout, TemplateField } from '../../services/template.model';
|
import { FieldType, ImageLayout, TemplateField, buildLoreTemplateField, cleanFieldLabels } from '../../services/template.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
import { popReturnTo } from '../return-stack.helper';
|
import { popReturnTo } from '../return-stack.helper';
|
||||||
|
|
||||||
@@ -31,6 +31,19 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
readonly ImageIcon = ImageIcon;
|
readonly ImageIcon = ImageIcon;
|
||||||
readonly ChevronUp = ChevronUp;
|
readonly ChevronUp = ChevronUp;
|
||||||
readonly ChevronDown = ChevronDown;
|
readonly ChevronDown = ChevronDown;
|
||||||
|
readonly ListOrdered = ListOrdered;
|
||||||
|
readonly TableIcon = TableIcon;
|
||||||
|
readonly X = X;
|
||||||
|
|
||||||
|
/** Icone du chip selon le type du champ. */
|
||||||
|
iconFor(type: FieldType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'IMAGE': return this.ImageIcon;
|
||||||
|
case 'KEY_VALUE_LIST': return this.ListOrdered;
|
||||||
|
case 'TABLE': return this.TableIcon;
|
||||||
|
default: return this.Type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
loreId = '';
|
loreId = '';
|
||||||
@@ -123,10 +136,7 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
if (!name) return;
|
if (!name) return;
|
||||||
// Unicite par nom (on ignore le type pour eviter des collisions d'affichage).
|
// Unicite par nom (on ignore le type pour eviter des collisions d'affichage).
|
||||||
if (this.fields.some(f => f.name === name)) return;
|
if (this.fields.some(f => f.name === name)) return;
|
||||||
const newField: TemplateField = this.newFieldType === 'IMAGE'
|
this.fields = [...this.fields, buildLoreTemplateField(name, this.newFieldType)];
|
||||||
? { name, type: 'IMAGE', layout: 'GALLERY' }
|
|
||||||
: { name, type: 'TEXT' };
|
|
||||||
this.fields = [...this.fields, newField];
|
|
||||||
this.newFieldName = '';
|
this.newFieldName = '';
|
||||||
// Le type reste sur la derniere valeur choisie : pratique pour enchainer
|
// Le type reste sur la derniere valeur choisie : pratique pour enchainer
|
||||||
// plusieurs champs du meme type.
|
// plusieurs champs du meme type.
|
||||||
@@ -145,17 +155,11 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
this.fields = next;
|
this.fields = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bascule le type d'un champ existant (TEXT <-> IMAGE). */
|
/** Change le type d'un champ existant (TEXT / IMAGE / KEY_VALUE_LIST). */
|
||||||
toggleFieldType(index: number): void {
|
setFieldType(index: number, type: FieldType): void {
|
||||||
const field = this.fields[index];
|
this.fields = this.fields.map((f, i) =>
|
||||||
if (!field) return;
|
i === index ? buildLoreTemplateField(f.name, type, f) : f
|
||||||
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
|
);
|
||||||
this.fields = this.fields.map((f, i) => {
|
|
||||||
if (i !== index) return f;
|
|
||||||
return nextType === 'IMAGE'
|
|
||||||
? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' }
|
|
||||||
: { name: f.name, type: 'TEXT' };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Met a jour le layout d'un champ IMAGE. */
|
/** Met a jour le layout d'un champ IMAGE. */
|
||||||
@@ -165,6 +169,24 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Sous-editeur des libelles (KEY_VALUE_LIST) -------------------------
|
||||||
|
// Mutation en place des labels : recreer le tableau de fields a chaque
|
||||||
|
// frappe ferait perdre le focus de l'input en cours d'edition.
|
||||||
|
|
||||||
|
addLabel(field: TemplateField): void {
|
||||||
|
field.labels = [...(field.labels ?? []), ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLabel(field: TemplateField, labelIndex: number, value: string): void {
|
||||||
|
if (!field.labels) return;
|
||||||
|
field.labels[labelIndex] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeLabel(field: TemplateField, labelIndex: number): void {
|
||||||
|
if (!field.labels) return;
|
||||||
|
field.labels = field.labels.filter((_, i) => i !== labelIndex);
|
||||||
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
if (this.form.invalid) return;
|
if (this.form.invalid) return;
|
||||||
const raw = this.form.value;
|
const raw = this.form.value;
|
||||||
@@ -173,7 +195,7 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
name: raw.name,
|
name: raw.name,
|
||||||
description: raw.description,
|
description: raw.description,
|
||||||
defaultNodeId: raw.defaultNodeId,
|
defaultNodeId: raw.defaultNodeId,
|
||||||
fields: this.fields
|
fields: cleanFieldLabels(this.fields)
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (created) => this.navigateBack(created.id ?? null),
|
next: (created) => this.navigateBack(created.id ?? null),
|
||||||
error: () => console.error('Erreur lors de la création du template')
|
error: () => console.error('Erreur lors de la création du template')
|
||||||
|
|||||||
@@ -54,17 +54,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="field-chip"
|
<span class="field-chip"
|
||||||
[class.field-chip-image]="f.type === 'IMAGE'"
|
[class.field-chip-image]="f.type === 'IMAGE'"
|
||||||
[class.field-chip-existing]="f.type !== 'IMAGE' && isExistingField(f)"
|
[class.field-chip-kv]="f.type === 'KEY_VALUE_LIST'"
|
||||||
[class.field-chip-new]="f.type !== 'IMAGE' && !isExistingField(f)">
|
[class.field-chip-table]="f.type === 'TABLE'"
|
||||||
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
[class.field-chip-existing]="f.type === 'TEXT' && isExistingField(f)"
|
||||||
|
[class.field-chip-new]="f.type === 'TEXT' && !isExistingField(f)">
|
||||||
|
<lucide-icon [img]="iconFor(f.type)" [size]="12"></lucide-icon>
|
||||||
{{ f.name }}
|
{{ f.name }}
|
||||||
</span>
|
</span>
|
||||||
<button type="button"
|
<select
|
||||||
class="btn-icon-ghost btn-type-toggle"
|
class="type-select"
|
||||||
(click)="toggleFieldType(i)"
|
[ngModel]="f.type"
|
||||||
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
|
[ngModelOptions]="{ standalone: true }"
|
||||||
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
|
(ngModelChange)="setFieldType(i, $event)"
|
||||||
</button>
|
title="Type du champ">
|
||||||
|
<option value="TEXT">Texte</option>
|
||||||
|
<option value="IMAGE">Image</option>
|
||||||
|
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
|
||||||
|
<option value="TABLE">Tableau</option>
|
||||||
|
</select>
|
||||||
@if (f.type === 'IMAGE') {
|
@if (f.type === 'IMAGE') {
|
||||||
<select
|
<select
|
||||||
class="layout-select"
|
class="layout-select"
|
||||||
@@ -82,6 +89,36 @@
|
|||||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<!-- Libelles (lignes d'une liste clé/valeur, ou colonnes d'un tableau) -->
|
||||||
|
@if (f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE') {
|
||||||
|
<li class="kv-labels-row">
|
||||||
|
@for (lbl of f.labels ?? []; track $index; let li = $index) {
|
||||||
|
<span class="kv-label-chip">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[ngModel]="lbl"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
(ngModelChange)="updateLabel(f, li, $event)"
|
||||||
|
[placeholder]="f.type === 'TABLE' ? 'Colonne' : 'Libellé'"
|
||||||
|
[attr.aria-label]="(f.type === 'TABLE' ? 'Colonne ' : 'Libellé ') + (li + 1)" />
|
||||||
|
<button type="button" class="kv-label-remove" (click)="removeLabel(f, li)" aria-label="Retirer">
|
||||||
|
<lucide-icon [img]="X" [size]="11"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<button type="button" class="btn-kv-add-label" (click)="addLabel(f)">
|
||||||
|
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
|
||||||
|
{{ f.type === 'TABLE' ? 'Colonne' : 'Libellé' }}
|
||||||
|
</button>
|
||||||
|
@if (!(f.labels ?? []).length) {
|
||||||
|
<span class="kv-labels-hint">
|
||||||
|
{{ f.type === 'TABLE'
|
||||||
|
? 'Ajoutez les colonnes du tableau (ex : Objet, Prix, Description…)'
|
||||||
|
: 'Ajoutez les libellés des lignes (ex : FOR, DEX, CON…)' }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="field-row add-row">
|
<div class="field-row add-row">
|
||||||
@@ -98,12 +135,14 @@
|
|||||||
aria-label="Type du champ">
|
aria-label="Type du champ">
|
||||||
<option value="TEXT">Texte</option>
|
<option value="TEXT">Texte</option>
|
||||||
<option value="IMAGE">Image</option>
|
<option value="IMAGE">Image</option>
|
||||||
|
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
|
||||||
|
<option value="TABLE">Tableau</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
|
<button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
|
||||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Texte = zone editable + generable par l'IA. Image = galerie d'illustrations.</p>
|
<p class="hint">Texte = libre + generable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -90,6 +90,70 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sous-ligne des libellés d'un champ Tableau (KEY_VALUE_LIST).
|
||||||
|
.kv-labels-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
// Aligne visuellement sous le chip du champ (décalage = reorder-stack + gap).
|
||||||
|
padding-left: 30px;
|
||||||
|
|
||||||
|
.kv-label-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid #4a3a1f;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 90px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #fbd38d;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.3rem;
|
||||||
|
|
||||||
|
&:focus { outline: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-label-remove {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px;
|
||||||
|
|
||||||
|
&:hover { color: #f87171; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-kv-add-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed #4a3a1f;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #fbd38d;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.3rem 0.55rem;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { background: rgba(251, 211, 141, 0.08); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-labels-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.field-row {
|
.field-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -127,6 +191,20 @@
|
|||||||
border-color: #3d3566;
|
border-color: #3d3566;
|
||||||
color: #c7b8ff;
|
color: #c7b8ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Champ Liste clé/valeur (palette ambre) — prioritaire sur existing/new.
|
||||||
|
&.field-chip-kv {
|
||||||
|
background: #4a3a1f;
|
||||||
|
border-color: #6b5328;
|
||||||
|
color: #fbd38d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Champ Tableau (palette sarcelle) — prioritaire sur existing/new.
|
||||||
|
&.field-chip-table {
|
||||||
|
background: #134e4a;
|
||||||
|
border-color: #14b8a6;
|
||||||
|
color: #99f6e4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-type-toggle {
|
.btn-type-toggle {
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators }
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { forkJoin, Subject } from 'rxjs';
|
import { forkJoin, Subject } from 'rxjs';
|
||||||
import { switchMap, takeUntil } from 'rxjs/operators';
|
import { switchMap, takeUntil } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
|
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown, ListOrdered, Table as TableIcon, X } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService } from '../../services/layout.service';
|
import { LayoutService } from '../../services/layout.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
import { LoreNode } from '../../services/lore.model';
|
import { LoreNode } from '../../services/lore.model';
|
||||||
import { FieldType, ImageLayout, Template, TemplateField } from '../../services/template.model';
|
import { FieldType, ImageLayout, Template, TemplateField, buildLoreTemplateField, cleanFieldLabels } from '../../services/template.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
@@ -32,6 +32,19 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
readonly ImageIcon = ImageIcon;
|
readonly ImageIcon = ImageIcon;
|
||||||
readonly ChevronUp = ChevronUp;
|
readonly ChevronUp = ChevronUp;
|
||||||
readonly ChevronDown = ChevronDown;
|
readonly ChevronDown = ChevronDown;
|
||||||
|
readonly ListOrdered = ListOrdered;
|
||||||
|
readonly TableIcon = TableIcon;
|
||||||
|
readonly X = X;
|
||||||
|
|
||||||
|
/** Icone du chip selon le type du champ. */
|
||||||
|
iconFor(type: FieldType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'IMAGE': return this.ImageIcon;
|
||||||
|
case 'KEY_VALUE_LIST': return this.ListOrdered;
|
||||||
|
case 'TABLE': return this.TableIcon;
|
||||||
|
default: return this.Type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
loreId = '';
|
loreId = '';
|
||||||
@@ -100,10 +113,9 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
// Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant,
|
// Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant,
|
||||||
// utile pour les templates legacy cote frontend meme si le backend le fait aussi).
|
// utile pour les templates legacy cote frontend meme si le backend le fait aussi).
|
||||||
this.fields = (template.fields ?? []).map(f => {
|
this.fields = (template.fields ?? []).map(f => {
|
||||||
const type: FieldType = f.type === 'IMAGE' ? 'IMAGE' : 'TEXT';
|
const type: FieldType =
|
||||||
return type === 'IMAGE'
|
f.type === 'IMAGE' || f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE' ? f.type : 'TEXT';
|
||||||
? { name: f.name, type, layout: f.layout ?? 'GALLERY' }
|
return buildLoreTemplateField(f.name, type, f);
|
||||||
: { name: f.name, type };
|
|
||||||
});
|
});
|
||||||
this.originalFieldNames = new Set(this.fields.map(f => f.name));
|
this.originalFieldNames = new Set(this.fields.map(f => f.name));
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
@@ -118,10 +130,7 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
const name = this.newFieldName.trim();
|
const name = this.newFieldName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
if (this.fields.some(f => f.name === name)) return;
|
if (this.fields.some(f => f.name === name)) return;
|
||||||
const newField: TemplateField = this.newFieldType === 'IMAGE'
|
this.fields = [...this.fields, buildLoreTemplateField(name, this.newFieldType)];
|
||||||
? { name, type: 'IMAGE', layout: 'GALLERY' }
|
|
||||||
: { name, type: 'TEXT' };
|
|
||||||
this.fields = [...this.fields, newField];
|
|
||||||
this.newFieldName = '';
|
this.newFieldName = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,17 +147,11 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
this.fields = next;
|
this.fields = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bascule le type d'un champ (TEXT <-> IMAGE). */
|
/** Change le type d'un champ existant (TEXT / IMAGE / KEY_VALUE_LIST). */
|
||||||
toggleFieldType(index: number): void {
|
setFieldType(index: number, type: FieldType): void {
|
||||||
const field = this.fields[index];
|
this.fields = this.fields.map((f, i) =>
|
||||||
if (!field) return;
|
i === index ? buildLoreTemplateField(f.name, type, f) : f
|
||||||
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
|
);
|
||||||
this.fields = this.fields.map((f, i) => {
|
|
||||||
if (i !== index) return f;
|
|
||||||
return nextType === 'IMAGE'
|
|
||||||
? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' }
|
|
||||||
: { name: f.name, type: 'TEXT' };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Met a jour le layout d'un champ IMAGE. */
|
/** Met a jour le layout d'un champ IMAGE. */
|
||||||
@@ -158,6 +161,24 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Sous-editeur des libelles (KEY_VALUE_LIST) -------------------------
|
||||||
|
// Mutation en place des labels : recreer le tableau de fields a chaque
|
||||||
|
// frappe ferait perdre le focus de l'input en cours d'edition.
|
||||||
|
|
||||||
|
addLabel(field: TemplateField): void {
|
||||||
|
field.labels = [...(field.labels ?? []), ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLabel(field: TemplateField, labelIndex: number, value: string): void {
|
||||||
|
if (!field.labels) return;
|
||||||
|
field.labels[labelIndex] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeLabel(field: TemplateField, labelIndex: number): void {
|
||||||
|
if (!field.labels) return;
|
||||||
|
field.labels = field.labels.filter((_, i) => i !== labelIndex);
|
||||||
|
}
|
||||||
|
|
||||||
save(): void {
|
save(): void {
|
||||||
if (this.form.invalid || !this.template) return;
|
if (this.form.invalid || !this.template) return;
|
||||||
const raw = this.form.value;
|
const raw = this.form.value;
|
||||||
@@ -166,7 +187,7 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
name: raw.name,
|
name: raw.name,
|
||||||
description: raw.description,
|
description: raw.description,
|
||||||
defaultNodeId: raw.defaultNodeId || null,
|
defaultNodeId: raw.defaultNodeId || null,
|
||||||
fields: this.fields
|
fields: cleanFieldLabels(this.fields)
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||||
error: () => console.error('Erreur lors de la sauvegarde du template')
|
error: () => console.error('Erreur lors de la sauvegarde du template')
|
||||||
|
|||||||
@@ -63,10 +63,13 @@ export interface CampaignImportApplyResult {
|
|||||||
/**
|
/**
|
||||||
* Évènements du flux SSE d'import streamé.
|
* Évènements du flux SSE d'import streamé.
|
||||||
* - progress : avancement (total=0 ⇒ extraction en cours).
|
* - progress : avancement (total=0 ⇒ extraction en cours).
|
||||||
|
* - status : message d'attente lisible (fournisseur saturé → retry, morceau
|
||||||
|
* re-découpé, morceau ignoré…) — feedback live pour l'utilisateur.
|
||||||
* - done : arbre proposé (à réviser).
|
* - done : arbre proposé (à réviser).
|
||||||
* - error : message d'erreur côté serveur.
|
* - error : message d'erreur côté serveur.
|
||||||
*/
|
*/
|
||||||
export type CampaignImportStreamEvent =
|
export type CampaignImportStreamEvent =
|
||||||
|
| { type: 'status'; message: string }
|
||||||
| {
|
| {
|
||||||
type: 'progress';
|
type: 'progress';
|
||||||
current: number;
|
current: number;
|
||||||
|
|||||||
@@ -61,17 +61,30 @@ export class CampaignImportService {
|
|||||||
let buffer = '';
|
let buffer = '';
|
||||||
let currentEvent: string | null = null;
|
let currentEvent: string | null = null;
|
||||||
let currentData = '';
|
let currentData = '';
|
||||||
|
// Le flux s'est-il terminé PROPREMENT (évènement done ou error reçu) ?
|
||||||
|
// Sans ce suivi, une connexion coupée en plein import (timeout serveur,
|
||||||
|
// proxy, Core redémarré) terminait l'Observable en silence : barre de
|
||||||
|
// progression figée et aucun message pour l'utilisateur.
|
||||||
|
let terminated = false;
|
||||||
|
|
||||||
const dispatch = () => {
|
const dispatch = () => {
|
||||||
const name = currentEvent ?? 'message';
|
const name = currentEvent ?? 'message';
|
||||||
if (name === 'error') {
|
if (name === 'error') {
|
||||||
let message = 'Échec de l\'import.';
|
let message = 'Échec de l\'import.';
|
||||||
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
|
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
|
||||||
|
terminated = true;
|
||||||
subscriber.error(new Error(message));
|
subscriber.error(new Error(message));
|
||||||
|
} else if (name === 'status') {
|
||||||
|
// Message d'attente lisible (fournisseur saturé, morceau re-découpé…).
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(currentData) as { message?: string };
|
||||||
|
if (obj.message) subscriber.next({ type: 'status', message: obj.message });
|
||||||
|
} catch { /* bloc malformé ignoré */ }
|
||||||
} else if (name === 'progress' || name === 'done') {
|
} else if (name === 'progress' || name === 'done') {
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(currentData);
|
const obj = JSON.parse(currentData);
|
||||||
if (name === 'done') {
|
if (name === 'done') {
|
||||||
|
terminated = true;
|
||||||
subscriber.next({ type: 'done', arcs: obj.arcs ?? [], npcs: obj.npcs ?? [] });
|
subscriber.next({ type: 'done', arcs: obj.arcs ?? [], npcs: obj.npcs ?? [] });
|
||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
} else {
|
} else {
|
||||||
@@ -105,9 +118,12 @@ export class CampaignImportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentEvent !== null || currentData !== '') dispatch();
|
if (currentEvent !== null || currentData !== '') dispatch();
|
||||||
subscriber.complete();
|
if (!terminated) {
|
||||||
|
subscriber.error(new Error(
|
||||||
|
'L\'import s\'est interrompu avant la fin (connexion coupée ou délai dépassé). Réessayez.'));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
subscriber.error(err);
|
if (!terminated) subscriber.error(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,11 +32,14 @@ export interface RulesImportResponse {
|
|||||||
/**
|
/**
|
||||||
* Évènements du flux SSE d'import streamé.
|
* Évènements du flux SSE d'import streamé.
|
||||||
* - progress : avancement (total=0 ⇒ phase d'extraction en cours).
|
* - progress : avancement (total=0 ⇒ phase d'extraction en cours).
|
||||||
|
* - status : message d'attente lisible (fournisseur saturé → retry, morceau
|
||||||
|
* re-découpé, morceau ignoré…) — feedback live pour l'utilisateur.
|
||||||
* - done : résultat final (sections proposées).
|
* - done : résultat final (sections proposées).
|
||||||
* - error : message d'erreur côté serveur.
|
* - error : message d'erreur côté serveur.
|
||||||
*/
|
*/
|
||||||
export type RulesImportStreamEvent =
|
export type RulesImportStreamEvent =
|
||||||
| { type: 'progress'; current: number; total: number; pageCount: number; ocrPageCount: number; newSectionTitles: string[] }
|
| { type: 'progress'; current: number; total: number; pageCount: number; ocrPageCount: number; newSectionTitles: string[] }
|
||||||
|
| { type: 'status'; message: string }
|
||||||
| { type: 'done'; sections: Record<string, string>; pageCount: number; ocrPageCount: number }
|
| { type: 'done'; sections: Record<string, string>; pageCount: number; ocrPageCount: number }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
|
|||||||
@@ -92,17 +92,30 @@ export class GameSystemService {
|
|||||||
let buffer = '';
|
let buffer = '';
|
||||||
let currentEvent: string | null = null;
|
let currentEvent: string | null = null;
|
||||||
let currentData = '';
|
let currentData = '';
|
||||||
|
// Le flux s'est-il terminé PROPREMENT (évènement done ou error reçu) ?
|
||||||
|
// Sans ce suivi, une connexion coupée en plein import (timeout serveur,
|
||||||
|
// proxy, Core redémarré) terminait l'Observable en silence : barre de
|
||||||
|
// progression figée et aucun message pour l'utilisateur.
|
||||||
|
let terminated = false;
|
||||||
|
|
||||||
const dispatch = () => {
|
const dispatch = () => {
|
||||||
const name = currentEvent ?? 'message';
|
const name = currentEvent ?? 'message';
|
||||||
if (name === 'error') {
|
if (name === 'error') {
|
||||||
let message = 'Échec de l\'import.';
|
let message = 'Échec de l\'import.';
|
||||||
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
|
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
|
||||||
|
terminated = true;
|
||||||
subscriber.error(new Error(message));
|
subscriber.error(new Error(message));
|
||||||
|
} else if (name === 'status') {
|
||||||
|
// Message d'attente lisible (fournisseur saturé, morceau re-découpé…).
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(currentData) as { message?: string };
|
||||||
|
if (obj.message) subscriber.next({ type: 'status', message: obj.message });
|
||||||
|
} catch { /* bloc malformé ignoré */ }
|
||||||
} else if (name === 'progress' || name === 'done') {
|
} else if (name === 'progress' || name === 'done') {
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(currentData);
|
const obj = JSON.parse(currentData);
|
||||||
if (name === 'done') {
|
if (name === 'done') {
|
||||||
|
terminated = true;
|
||||||
subscriber.next({ type: 'done', ...obj });
|
subscriber.next({ type: 'done', ...obj });
|
||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
} else {
|
} else {
|
||||||
@@ -136,9 +149,12 @@ export class GameSystemService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentEvent !== null || currentData !== '') dispatch();
|
if (currentEvent !== null || currentData !== '') dispatch();
|
||||||
subscriber.complete();
|
if (!terminated) {
|
||||||
|
subscriber.error(new Error(
|
||||||
|
'L\'import s\'est interrompu avant la fin (connexion coupée ou délai dépassé). Réessayez.'));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
subscriber.error(err);
|
if (!terminated) subscriber.error(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,11 @@ export class NotebookService {
|
|||||||
/**
|
/**
|
||||||
* Chat ancré streamé. fetch() + ReadableStream (HttpClient bufferise les SSE).
|
* Chat ancré streamé. fetch() + ReadableStream (HttpClient bufferise les SSE).
|
||||||
* Émissions forcées dans la zone Angular pour la détection de changement.
|
* Émissions forcées dans la zone Angular pour la détection de changement.
|
||||||
|
*
|
||||||
|
* `sourceIds` : sous-ensemble de sources à utiliser pour ce tour (cases cochées) ;
|
||||||
|
* undefined = toutes les sources prêtes du notebook.
|
||||||
*/
|
*/
|
||||||
streamChat(notebookId: string, message: string, deep = false): Observable<NotebookChatEvent> {
|
streamChat(notebookId: string, message: string, deep = false, sourceIds?: string[]): Observable<NotebookChatEvent> {
|
||||||
return new Observable<NotebookChatEvent>((subscriber) => {
|
return new Observable<NotebookChatEvent>((subscriber) => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const emit = (ev: NotebookChatEvent) => this.zone.run(() => subscriber.next(ev));
|
const emit = (ev: NotebookChatEvent) => this.zone.run(() => subscriber.next(ev));
|
||||||
@@ -59,7 +62,7 @@ export class NotebookService {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
|
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ message, deep }),
|
body: JSON.stringify({ message, deep, sourceIds: sourceIds ?? null }),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface Npc {
|
|||||||
imageValues?: Record<string, string[]>;
|
imageValues?: Record<string, string[]>;
|
||||||
keyValueValues?: Record<string, Record<string, string>>;
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
|
/** IDs de Pages de Lore référencées (sa ville, sa faction…). */
|
||||||
|
relatedPageIds?: string[];
|
||||||
/** Dossier de classement (ex. « Bard's Gate »). Vide/absent = non classé. */
|
/** Dossier de classement (ex. « Bard's Gate »). Vide/absent = non classé. */
|
||||||
folder?: string | null;
|
folder?: string | null;
|
||||||
order?: number;
|
order?: number;
|
||||||
@@ -24,5 +26,6 @@ export interface NpcCreate {
|
|||||||
imageValues?: Record<string, string[]>;
|
imageValues?: Record<string, string[]>;
|
||||||
keyValueValues?: Record<string, Record<string, string>>;
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
|
relatedPageIds?: string[];
|
||||||
folder?: string | null;
|
folder?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export class NpcService {
|
|||||||
return this.http.get<Npc[]>(`${this.apiUrl}/campaign/${campaignId}`);
|
return this.http.get<Npc[]>(`${this.apiUrl}/campaign/${campaignId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PNJ de toutes les campagnes liées à un Lore — alimente le graphe du Lore. */
|
||||||
|
getByLore(loreId: string): Observable<Npc[]> {
|
||||||
|
return this.http.get<Npc[]>(`${this.apiUrl}/lore/${loreId}`);
|
||||||
|
}
|
||||||
|
|
||||||
getById(id: string): Observable<Npc> {
|
getById(id: string): Observable<Npc> {
|
||||||
return this.http.get<Npc>(`${this.apiUrl}/${id}`);
|
return this.http.get<Npc>(`${this.apiUrl}/${id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ export interface Page {
|
|||||||
* uploadees (Shared Kernel images). Structure separee de `values`.
|
* uploadees (Shared Kernel images). Structure separee de `values`.
|
||||||
*/
|
*/
|
||||||
imageValues?: Record<string, string[]>;
|
imageValues?: Record<string, string[]>;
|
||||||
|
/**
|
||||||
|
* Pour chaque champ KEY_VALUE_LIST (tableau libelle → valeur, comme sur les
|
||||||
|
* fiches de personnage) : fieldName → (label → valeur).
|
||||||
|
*/
|
||||||
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
|
/**
|
||||||
|
* Pour chaque champ TABLE (colonnes figees au template, lignes libres) :
|
||||||
|
* fieldName → lignes ordonnees, chaque ligne = colonne → cellule.
|
||||||
|
*/
|
||||||
|
tableValues?: Record<string, Array<Record<string, string>>>;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
relatedPageIds?: string[];
|
relatedPageIds?: string[];
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
|
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
|
||||||
* - 'NUMBER' : valeur numerique (rendu en input number)
|
* - 'NUMBER' : valeur numerique (rendu en input number)
|
||||||
* - 'KEY_VALUE_LIST' : liste de paires {label, value} avec labels figes au template
|
* - 'KEY_VALUE_LIST' : liste de paires {label, value} avec labels figes au template
|
||||||
|
* - 'TABLE' : tableau a colonnes figees (labels = noms de colonnes) et
|
||||||
|
* lignes libres ajoutees au remplissage (boutique, inventaire…)
|
||||||
*/
|
*/
|
||||||
export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER' | 'KEY_VALUE_LIST';
|
export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER' | 'KEY_VALUE_LIST' | 'TABLE';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Variante de rendu pour un champ IMAGE. Miroir de
|
* Variante de rendu pour un champ IMAGE. Miroir de
|
||||||
@@ -28,10 +30,45 @@ export interface TemplateField {
|
|||||||
type: FieldType;
|
type: FieldType;
|
||||||
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
|
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
|
||||||
layout?: ImageLayout | null;
|
layout?: ImageLayout | null;
|
||||||
/** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */
|
/**
|
||||||
|
* Labels predefinis (ordre significatif) :
|
||||||
|
* KEY_VALUE_LIST = libelles des lignes ; TABLE = noms des colonnes.
|
||||||
|
*/
|
||||||
labels?: string[] | null;
|
labels?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit un TemplateField propre pour un type donne (attributs par defaut,
|
||||||
|
* conservation des attributs compatibles de `previous` lors d'un changement de
|
||||||
|
* type). Partage par les editeurs de template du Lore (create/edit).
|
||||||
|
*/
|
||||||
|
export function buildLoreTemplateField(
|
||||||
|
name: string,
|
||||||
|
type: FieldType,
|
||||||
|
previous?: TemplateField
|
||||||
|
): TemplateField {
|
||||||
|
switch (type) {
|
||||||
|
case 'IMAGE':
|
||||||
|
return { name, type, layout: previous?.layout ?? 'GALLERY' };
|
||||||
|
case 'KEY_VALUE_LIST':
|
||||||
|
case 'TABLE':
|
||||||
|
// Les labels (lignes KV / colonnes TABLE) survivent au changement de type
|
||||||
|
// entre ces deux variantes.
|
||||||
|
return { name, type, labels: previous?.labels ?? [] };
|
||||||
|
default:
|
||||||
|
return { name, type: 'TEXT' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retire les libelles vides (lignes KV / colonnes TABLE) avant sauvegarde. */
|
||||||
|
export function cleanFieldLabels(fields: TemplateField[]): TemplateField[] {
|
||||||
|
return fields.map(f =>
|
||||||
|
f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE'
|
||||||
|
? { ...f, labels: (f.labels ?? []).map(l => l.trim()).filter(l => !!l) }
|
||||||
|
: f
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface Template {
|
export interface Template {
|
||||||
id?: string;
|
id?: string;
|
||||||
loreId: string;
|
loreId: string;
|
||||||
|
|||||||
@@ -142,6 +142,71 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tableau libellé/valeur (champ KEY_VALUE_LIST) en lecture seule.
|
||||||
|
// Même esthétique que la grille de saisie des fiches de personnage.
|
||||||
|
.view-kv-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.view-kv-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.view-kv-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-kv-value {
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tableau à colonnes (champ TABLE) en lecture seule : boutique, inventaire…
|
||||||
|
.view-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #99f6e4;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid rgba(20, 184, 166, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
vertical-align: top;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:nth-child(odd) {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chips (tags et pages liées en lecture seule)
|
// Chips (tags et pages liées en lecture seule)
|
||||||
.view-chips {
|
.view-chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user