Compare commits

...

3 Commits

Author SHA1 Message Date
6c7dbff6a0 passage version v0.11.3-beta
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-08 17:01:05 +02:00
833280c784 Amélioration de l'IA pour la partie atelier PDF
Mise en place d'un outil permettant de faire des tableau d'objets pour des boutiques par exemple
2026-06-08 17:00:22 +02:00
70ec1f2fb9 Modification du timeout pour permettre d'analyser de plus gros livres
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-08 14:49:00 +02:00
42 changed files with 1735 additions and 27 deletions

View File

@@ -41,11 +41,17 @@ QUESTION : {question}
Informations pertinentes (ou « {no_match} ») :"""
_REDUCE_SYSTEM = """Tu réponds à la question d'un MJ à partir de NOTES extraites de
l'ENSEMBLE d'un document source (donc tu as une vue COMPLÈTE, pas un simple extrait).
Synthétise ces notes en une réponse claire et structurée, cite les pages (« p. X »),
et n'invente rien qui n'y figure pas. Si une CAMPAGNE est fournie ci-dessous, relie ta
réponse à sa structure / ses PNJ pour des adaptations cohérentes.
_REDUCE_SYSTEM = """Tu es l'assistant-MJ d'un jeu de rôle. Tu réponds à la demande du MJ en
t'appuyant sur TROIS sources : (1) des NOTES extraites de l'ENSEMBLE du document source (vue
complète — mais POSSIBLEMENT VIDE si rien d'utile n'y figure), (2) le contexte de sa CAMPAGNE,
(3) la conversation ci-dessous.
- Si les notes contiennent des éléments utiles : exploite-les et CITE les pages (« p. X »).
- Si les notes sont VIDES ou pauvres (cas fréquent d'une demande CRÉATIVE portant sur des
éléments INVENTÉS par le MJ) : ne te bloque surtout PAS. Aide-le quand même en t'appuyant
sur sa CAMPAGNE, la CONVERSATION et ta connaissance du genre — propose des adaptations
concrètes (arcs, chapitres, scènes, PNJ), structurées et jouables.
- Sois concret et utile. N'affirme rien de FAUX sur le contenu du document.
{context_block}
--- NOTES EXTRAITES DE TOUT LE DOCUMENT ---
@@ -113,8 +119,21 @@ class NotebookDeepUseCase:
# dernier message est bien la question courante.
reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)]
llm_chat: LLMChatProvider = self._llm # type: ignore[assignment]
produced = False
async for token in llm_chat.stream_chat(reduce_messages, system_prompt=system_prompt):
yield {"type": "token", "token": token}
if token:
produced = True
yield {"type": "token", "token": token}
if not produced:
# Jamais de bulle vide : message de repli + orientation vers le mode rapide,
# mieux adapté aux demandes créatives (et qui propose des cartes d'action).
yield {"type": "token", "token": (
"Je n'ai pas trouvé d'éléments pertinents dans le document pour cette demande "
"(elle porte sans doute sur des éléments que tu as inventés). Pour une "
"**adaptation créative** — proposer des arcs, chapitres, scènes ou PNJ — "
"utilise plutôt le bouton **« Envoyer »** (mode rapide) : il est conversationnel, "
"voit ta campagne, et te propose des cartes « Créer dans la campagne »."
)}
yield {"type": "done"}
def _group(self, chunks: list[dict]) -> list[list[dict]]:

View File

@@ -66,7 +66,7 @@ from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.11.1-beta",
version="0.11.3-beta",
)
logger = logging.getLogger(__name__)
@@ -1015,6 +1015,80 @@ async def improvise_table_roll(
return ImproviseRollResponseDTO(narration=raw.strip())
# --- Catalogues d'objets (boutiques) : génération IA -------------------------
class GenerateCatalogRequestDTO(BaseModel):
description: str
context: str = Field(default="")
class GeneratedCatalogItemDTO(BaseModel):
name: str
price: str = ""
category: str = ""
description: str = ""
class GenerateCatalogResponseDTO(BaseModel):
name: str
description: str = ""
items: list[GeneratedCatalogItemDTO]
@app.post("/generate/item-catalog", response_model=GenerateCatalogResponseDTO)
async def generate_item_catalog(
body: GenerateCatalogRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> GenerateCatalogResponseDTO:
"""Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description."""
context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else ""
prompt = (
"Tu es un assistant de jeu de rôle. Génère un CATALOGUE D'OBJETS (boutique, butin, trésor…).\n"
f"Sujet : {body.description.strip()}\n"
f"{context_block}\n"
"Règles IMPÉRATIVES :\n"
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
'- Format : {"name": "...", "description": "...", "items": '
'[{"name": "Objet", "price": "ex. 50 po", "category": "ex. Armes", "description": "effet/détails"}]}\n'
"- Des objets variés et cohérents avec le sujet (et le contexte s'il est fourni).\n"
"- 'price' = prix court dans la monnaie du jeu ; 'category' = regroupement (Armes, Potions…) ; "
"'description' = effet/détails en une phrase. En français.\n"
"Renvoie maintenant le JSON."
)
try:
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
parsed, _ = load_json_object(raw)
if not isinstance(parsed, dict):
raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de catalogue exploitable.")
items: list[GeneratedCatalogItemDTO] = []
for it in parsed.get("items", []) or []:
if not isinstance(it, dict):
continue
name = str(it.get("name") or "").strip()
if not name:
continue
items.append(GeneratedCatalogItemDTO(
name=name[:200],
price=str(it.get("price") or "").strip(),
category=str(it.get("category") or "").strip(),
description=str(it.get("description") or "").strip(),
))
if not items:
raise HTTPException(status_code=502, detail="Aucun objet généré — réessaie ou reformule.")
name = str(parsed.get("name") or body.description).strip()[:120] or "Catalogue généré"
return GenerateCatalogResponseDTO(
name=name,
description=str(parsed.get("description") or "").strip(),
items=items,
)
# --- Notebooks (atelier RAG) : indexation des sources + chat ancré ----------

View File

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

View File

@@ -0,0 +1,122 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.CatalogItem;
import com.loremind.domain.campaigncontext.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator;
import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Service d'application des catalogues d'objets (campagne) : CRUD + génération IA.
*/
@Service
public class ItemCatalogService {
private final ItemCatalogRepository repository;
private final ItemCatalogGenerator generator;
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
public ItemCatalogService(
ItemCatalogRepository repository,
ItemCatalogGenerator generator,
CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository) {
this.repository = repository;
this.generator = generator;
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
}
public record CatalogData(
String name,
String description,
String icon,
List<CatalogItem> items,
String campaignId,
Integer order
) {}
public ItemCatalog createCatalog(CatalogData data) {
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
ItemCatalog catalog = ItemCatalog.builder()
.name(data.name())
.description(data.description())
.icon(data.icon())
.items(copyItems(data.items()))
.campaignId(data.campaignId())
.order(order)
.build();
return repository.save(catalog);
}
public Optional<ItemCatalog> getCatalogById(String id) {
return repository.findById(id);
}
public List<ItemCatalog> getCatalogsByCampaignId(String campaignId) {
return repository.findByCampaignId(campaignId);
}
public ItemCatalog updateCatalog(String id, CatalogData data) {
ItemCatalog existing = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Catalogue d'objets introuvable: " + id));
existing.setName(data.name());
existing.setDescription(data.description());
existing.setIcon(data.icon());
existing.setItems(copyItems(data.items()));
if (data.order() != null) {
existing.setOrder(data.order());
}
return repository.save(existing);
}
public void deleteCatalog(String id) {
repository.deleteById(id);
}
/** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
public ItemCatalog generateProposal(String campaignId, String description) {
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId));
return ItemCatalog.builder()
.name(g.name())
.description(g.description())
.campaignId(campaignId)
.items(g.items() != null ? new ArrayList<>(g.items()) : new ArrayList<>())
.build();
}
private static List<CatalogItem> copyItems(List<CatalogItem> items) {
return items != null ? new ArrayList<>(items) : new ArrayList<>();
}
private String buildContext(String campaignId) {
if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return "";
StringBuilder sb = new StringBuilder();
sb.append("Campagne : ").append(campaign.getName());
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
sb.append("").append(campaign.getDescription().trim());
}
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
gameSystemRepository.findById(campaign.getGameSystemId())
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
}
return sb.toString();
}
private int nextOrderFor(String campaignId) {
return repository.findByCampaignId(campaignId).stream()
.mapToInt(ItemCatalog::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
/**
* Un objet d'un {@link ItemCatalog} (boutique, butin, trésor…).
* Value object possédé par le catalogue : remplacé en bloc à chaque mise à jour.
*/
@Data
@Builder
public class CatalogItem {
private String name;
/** Prix libre (ex. « 50 po », « 2 pa »). Nullable. */
private String price;
/** Catégorie de regroupement (ex. « Armes », « Potions »). Nullable. */
private String category;
/** Description / effet (markdown). Nullable. */
private String description;
}

View File

@@ -0,0 +1,44 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Catalogue d'objets prédéfinis d'une campagne : une boutique, un butin, un trésor…
* Le MJ le remplit à la main ou via l'IA, et le consulte (notamment en session) quand
* les joueurs visitent une échoppe. Scope campagne (cross-aggregate via ID).
*/
@Data
@Builder
public class ItemCatalog {
private String id;
private String name;
/** Description libre (à quoi sert ce catalogue / cette boutique). Nullable. */
private String description;
/** Clé d'icône (lucide) pour la sidebar/fiche. Nullable. */
private String icon;
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/** Ordre d'affichage dans la liste des catalogues de la campagne. */
private int order;
/** Objets ordonnés du catalogue. Jamais null après construction. */
private List<CatalogItem> items;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public List<CatalogItem> getItems() {
if (items == null) items = new ArrayList<>();
return items;
}
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.campaigncontext.ports;
/**
* Échec de génération IA d'un catalogue d'objets (Brain injoignable, erreur du
* modèle, réponse inexploitable…). Mappée en HTTP 502 par le contrôleur.
*/
public class ItemCatalogGenerationException extends RuntimeException {
public ItemCatalogGenerationException(String message) {
super(message);
}
public ItemCatalogGenerationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,21 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.CatalogItem;
import java.util.List;
/**
* Port de sortie : génération IA d'un catalogue d'objets. Implémenté par un client
* du Brain (service IA Python).
*/
public interface ItemCatalogGenerator {
/** Catalogue proposé (non persisté) à partir d'une description. */
record GeneratedCatalog(String name, String description, List<CatalogItem> items) {}
/**
* Génère une proposition de catalogue (objets) sur le sujet donné, en s'appuyant
* sur le contexte (campagne, système…) s'il est fourni.
*/
GeneratedCatalog generate(String description, String context);
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.ItemCatalog;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des {@link ItemCatalog}.
*/
public interface ItemCatalogRepository {
ItemCatalog save(ItemCatalog catalog);
Optional<ItemCatalog> findById(String id);
List<ItemCatalog> findByCampaignId(String campaignId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -0,0 +1,92 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.campaigncontext.CatalogItem;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerationException;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Adapter de sortie : génère un catalogue d'objets via le Brain (POST one-shot).
*/
@Component
public class BrainItemCatalogClient implements ItemCatalogGenerator {
private static final String GENERATE_PATH = "/generate/item-catalog";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainItemCatalogClient(
RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
@SuppressWarnings("unchecked")
public GeneratedCatalog generate(String description, String context) {
Map<String, Object> req = new LinkedHashMap<>();
req.put("description", description == null ? "" : description);
req.put("context", context == null ? "" : context);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers);
Map<String, Object> resp;
try {
resp = restTemplate.postForObject(baseUrl + GENERATE_PATH, entity, Map.class);
} catch (ResourceAccessException e) {
throw new ItemCatalogGenerationException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) {
throw new ItemCatalogGenerationException(
"Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e);
} catch (Exception e) {
throw new ItemCatalogGenerationException("Erreur inattendue lors de l'appel au Brain.", e);
}
if (resp == null) {
throw new ItemCatalogGenerationException("Le Brain a renvoyé une réponse vide.");
}
List<CatalogItem> items = new ArrayList<>();
Object rawItems = resp.get("items");
if (rawItems instanceof List<?> list) {
for (Object item : list) {
if (!(item instanceof Map<?, ?> m)) continue;
String name = asString(m.get("name"));
if (name == null || name.isBlank()) continue;
items.add(CatalogItem.builder()
.name(name)
.price(asString(m.get("price")))
.category(asString(m.get("category")))
.description(asString(m.get("description")))
.build());
}
}
if (items.isEmpty()) {
throw new ItemCatalogGenerationException("Aucun objet généré — réessaie ou reformule.");
}
String name = asString(resp.get("name"));
return new GeneratedCatalog(
name != null && !name.isBlank() ? name : description,
asString(resp.get("description")),
items);
}
private static String asString(Object o) {
return o != null ? o.toString() : null;
}
}

View File

@@ -0,0 +1,49 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* Entité JPA d'un objet de catalogue (enfant de {@link ItemCatalogJpaEntity}).
* Ordonné par {@code position}. Référence parente exclue de toString/equals.
*/
@Entity
@Table(name = "catalog_items", indexes = {
@Index(name = "idx_catalog_items_catalog_id", columnList = "catalog_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CatalogItemJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(length = 64)
private String price;
@Column(length = 128)
private String category;
@Column(columnDefinition = "TEXT")
private String description;
@Column(nullable = false)
private int position;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "catalog_id", nullable = false)
@ToString.Exclude
@EqualsAndHashCode.Exclude
private ItemCatalogJpaEntity catalog;
}

View File

@@ -0,0 +1,71 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entité JPA d'un catalogue d'objets (parent) avec ses objets ordonnés (enfants).
*/
@Entity
@Table(name = "item_catalogs", indexes = {
@Index(name = "idx_item_catalogs_campaign_id", columnList = "campaign_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ItemCatalogJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(length = 64)
private String icon;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "\"order\"", nullable = false)
private int order;
@OneToMany(
mappedBy = "catalog",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
@OrderBy("position ASC")
@Builder.Default
private List<CatalogItemJpaEntity> items = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,13 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.ItemCatalogJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ItemCatalogJpaRepository extends JpaRepository<ItemCatalogJpaEntity, Long> {
List<ItemCatalogJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
}

View File

@@ -0,0 +1,101 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.CatalogItem;
import com.loremind.domain.campaigncontext.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository;
import com.loremind.infrastructure.persistence.entity.CatalogItemJpaEntity;
import com.loremind.infrastructure.persistence.entity.ItemCatalogJpaEntity;
import com.loremind.infrastructure.persistence.jpa.ItemCatalogJpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresItemCatalogRepository implements ItemCatalogRepository {
private final ItemCatalogJpaRepository jpaRepository;
public PostgresItemCatalogRepository(ItemCatalogJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
@Transactional
public ItemCatalog save(ItemCatalog catalog) {
ItemCatalogJpaEntity entity = (catalog.getId() != null)
? jpaRepository.findById(Long.parseLong(catalog.getId())).orElseGet(ItemCatalogJpaEntity::new)
: new ItemCatalogJpaEntity();
entity.setName(catalog.getName());
entity.setDescription(catalog.getDescription());
entity.setIcon(catalog.getIcon());
entity.setCampaignId(Long.parseLong(catalog.getCampaignId()));
entity.setOrder(catalog.getOrder());
// Remplacement en bloc des objets (orphanRemoval supprime les anciens).
entity.getItems().clear();
int position = 0;
for (CatalogItem it : catalog.getItems()) {
entity.getItems().add(CatalogItemJpaEntity.builder()
.name(it.getName())
.price(it.getPrice())
.category(it.getCategory())
.description(it.getDescription())
.position(position++)
.catalog(entity)
.build());
}
return toDomainEntity(jpaRepository.save(entity));
}
@Override
@Transactional(readOnly = true)
public Optional<ItemCatalog> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
@Transactional(readOnly = true)
public List<ItemCatalog> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) {
List<CatalogItem> items = e.getItems().stream()
.map(c -> CatalogItem.builder()
.name(c.getName())
.price(c.getPrice())
.category(c.getCategory())
.description(c.getDescription())
.build())
.collect(Collectors.toCollection(ArrayList::new));
return ItemCatalog.builder()
.id(e.getId().toString())
.name(e.getName())
.description(e.getDescription())
.icon(e.getIcon())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.items(items)
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
}

View File

@@ -0,0 +1,84 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.ItemCatalogService;
import com.loremind.domain.campaigncontext.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerationException;
import com.loremind.infrastructure.web.dto.campaigncontext.ItemCatalogDTO;
import com.loremind.infrastructure.web.mapper.ItemCatalogMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/item-catalogs")
public class ItemCatalogController {
private final ItemCatalogService service;
private final ItemCatalogMapper mapper;
public ItemCatalogController(ItemCatalogService service, ItemCatalogMapper mapper) {
this.service = service;
this.mapper = mapper;
}
@PostMapping
public ResponseEntity<ItemCatalogDTO> create(@RequestBody ItemCatalogDTO dto) {
ItemCatalog created = service.createCatalog(toData(dto, null));
return ResponseEntity.ok(mapper.toDTO(created));
}
@GetMapping("/{id}")
public ResponseEntity<ItemCatalogDTO> getById(@PathVariable String id) {
return service.getCatalogById(id)
.map(c -> ResponseEntity.ok(mapper.toDTO(c)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<ItemCatalogDTO>> getByCampaign(@PathVariable String campaignId) {
List<ItemCatalogDTO> dtos = service.getCatalogsByCampaignId(campaignId).stream()
.map(mapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}")
public ResponseEntity<ItemCatalogDTO> update(@PathVariable String id, @RequestBody ItemCatalogDTO dto) {
ItemCatalog updated = service.updateCatalog(id, toData(dto, dto.getOrder()));
return ResponseEntity.ok(mapper.toDTO(updated));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.deleteCatalog(id);
return ResponseEntity.noContent().build();
}
/** Génère une PROPOSITION de catalogue via l'IA (non persistée) — l'UI préremplit le formulaire. */
@PostMapping("/generate")
public ResponseEntity<ItemCatalogDTO> generate(@RequestBody GenerateRequest req) {
try {
ItemCatalog proposal = service.generateProposal(req.campaignId(), req.description());
return ResponseEntity.ok(mapper.toDTO(proposal));
} catch (ItemCatalogGenerationException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e);
}
}
private ItemCatalogService.CatalogData toData(ItemCatalogDTO dto, Integer order) {
return new ItemCatalogService.CatalogData(
dto.getName(),
dto.getDescription(),
dto.getIcon(),
mapper.toDomainItems(dto.getItems()),
dto.getCampaignId(),
order
);
}
public record GenerateRequest(String campaignId, String description) {}
}

View File

@@ -28,7 +28,10 @@ import java.util.Map;
@RequestMapping("/api/notebooks")
public class NotebookController {
private static final long SSE_TIMEOUT_MS = 10 * 60 * 1000L;
// L'analyse approfondie (map-reduce sur tout le doc) peut être longue sur un gros
// livre / un modèle lent → 30 min. Pour aller plus vite : modèle gros-contexte
// (moins de lots). Au-delà, l'expiration est gérée proprement (pas de crash).
private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L;
private final NotebookService service;
private final NotebookChatStreamer chatStreamer;
@@ -143,13 +146,18 @@ public class NotebookController {
return emitter;
}
// --- Helpers SSE (mêmes conventions que AiChatController) ---
// --- Helpers SSE ---
// IMPORTANT : on attrape AUSSI IllegalStateException. Si le flux a déjà été fermé
// (timeout async, client déconnecté), `emitter.send/complete` la lève — et comme
// ces helpers tournent dans un thread d'exécuteur, une exception non gérée y
// remontait jusqu'au pool ("Exception in thread task-1: ResponseBodyEmitter has
// already completed"). On l'ignore silencieusement : il n'y a plus rien à envoyer.
private void sendToken(SseEmitter emitter, String token) {
try {
emitter.send(SseEmitter.event().name("token").data("{\"token\":" + jsonEscape(token) + "}"));
} catch (IOException e) {
emitter.completeWithError(e);
} catch (IOException | IllegalStateException e) {
// flux fermé/expiré : on cesse d'écrire
}
}
@@ -157,8 +165,8 @@ public class NotebookController {
try {
emitter.send(SseEmitter.event().name("progress")
.data("{\"current\":" + p.current() + ",\"total\":" + p.total() + "}"));
} catch (IOException e) {
emitter.completeWithError(e);
} catch (IOException | IllegalStateException e) {
// flux fermé/expiré : on cesse d'écrire
}
}
@@ -166,8 +174,8 @@ public class NotebookController {
try {
emitter.send(SseEmitter.event().name("done").data("{}"));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
} catch (IOException | IllegalStateException e) {
// flux déjà fermé/expiré : rien à compléter
}
}
@@ -176,8 +184,8 @@ public class NotebookController {
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
emitter.send(SseEmitter.event().name("error").data("{\"message\":" + jsonEscape(message) + "}"));
emitter.complete();
} catch (IOException ioe) {
emitter.completeWithError(ioe);
} catch (IOException | IllegalStateException e) {
// flux déjà fermé/expiré : rien à envoyer
}
}

View File

@@ -0,0 +1,14 @@
package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data;
/**
* DTO d'un objet de catalogue.
*/
@Data
public class CatalogItemDTO {
private String name;
private String price;
private String category;
private String description;
}

View File

@@ -0,0 +1,20 @@
package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* DTO d'un catalogue d'objets (avec ses objets).
*/
@Data
public class ItemCatalogDTO {
private String id;
private String name;
private String description;
private String icon;
private String campaignId;
private int order;
private List<CatalogItemDTO> items = new ArrayList<>();
}

View File

@@ -0,0 +1,50 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.campaigncontext.CatalogItem;
import com.loremind.domain.campaigncontext.ItemCatalog;
import com.loremind.infrastructure.web.dto.campaigncontext.CatalogItemDTO;
import com.loremind.infrastructure.web.dto.campaigncontext.ItemCatalogDTO;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class ItemCatalogMapper {
public ItemCatalogDTO toDTO(ItemCatalog c) {
if (c == null) return null;
ItemCatalogDTO dto = new ItemCatalogDTO();
dto.setId(c.getId());
dto.setName(c.getName());
dto.setDescription(c.getDescription());
dto.setIcon(c.getIcon());
dto.setCampaignId(c.getCampaignId());
dto.setOrder(c.getOrder());
dto.setItems(c.getItems().stream().map(this::toItemDTO).collect(Collectors.toList()));
return dto;
}
public List<CatalogItem> toDomainItems(List<CatalogItemDTO> dtos) {
if (dtos == null) return List.of();
return dtos.stream().map(this::toDomainItem).collect(Collectors.toList());
}
private CatalogItemDTO toItemDTO(CatalogItem i) {
CatalogItemDTO dto = new CatalogItemDTO();
dto.setName(i.getName());
dto.setPrice(i.getPrice());
dto.setCategory(i.getCategory());
dto.setDescription(i.getDescription());
return dto;
}
private CatalogItem toDomainItem(CatalogItemDTO dto) {
return CatalogItem.builder()
.name(dto.getName())
.price(dto.getPrice())
.category(dto.getCategory())
.description(dto.getDescription())
.build();
}
}

4
web/package-lock.json generated
View File

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

View File

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

View File

@@ -27,6 +27,10 @@ export const routes: Routes = [
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
{ path: 'campaigns/:campaignId/notebooks', loadComponent: () => import('./campaigns/notebook/notebook-list/notebook-list.component').then(m => m.NotebookListComponent) },
{ path: 'campaigns/:campaignId/notebooks/:notebookId', loadComponent: () => import('./campaigns/notebook/notebook-detail/notebook-detail.component').then(m => m.NotebookDetailComponent) },
{ path: 'campaigns/:campaignId/item-catalogs', loadComponent: () => import('./campaigns/item-catalog/item-catalog-list/item-catalog-list.component').then(m => m.ItemCatalogListComponent) },
{ path: 'campaigns/:campaignId/item-catalogs/create', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) },
{ path: 'campaigns/:campaignId/item-catalogs/:catalogId/edit', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) },
{ path: 'campaigns/:campaignId/item-catalogs/:catalogId', loadComponent: () => import('./campaigns/item-catalog/item-catalog-view/item-catalog-view.component').then(m => m.ItemCatalogViewComponent) },
{ path: 'campaigns/:campaignId/random-tables/create', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
{ path: 'campaigns/:campaignId/random-tables/:tableId/edit', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
{ path: 'campaigns/:campaignId/random-tables/:tableId', loadComponent: () => import('./campaigns/random-table/random-table-view/random-table-view.component').then(m => m.RandomTableViewComponent) },

View File

@@ -225,6 +225,14 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
route: `/campaigns/${campaignId}/notebooks`
};
// Catalogues d'objets (boutiques, butins…) → page de liste (outil).
const catalogsNode: TreeItem = {
id: 'item-catalogs-root',
label: 'Catalogues d\'objets',
iconKey: 'package',
route: `/campaigns/${campaignId}/item-catalogs`
};
// Importer un PDF de campagne → arborescence (outil, comme tables & ateliers).
const importNode: TreeItem = {
id: 'import-pdf-root',
@@ -233,7 +241,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
route: `/campaigns/${campaignId}/import`
};
return [...arcNodes, npcsNode, tablesNode, notebooksNode, importNode];
return [...arcNodes, npcsNode, tablesNode, notebooksNode, catalogsNode, importNode];
}
/**

View File

@@ -0,0 +1,68 @@
<div class="ice-page">
<div class="ice-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
</button>
<span class="spacer"></span>
<button class="btn-save" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="14"></lucide-icon>
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
</button>
</div>
<h1>{{ catalogId ? 'Éditer le catalogue' : 'Nouveau catalogue d\'objets' }}</h1>
<div class="error" *ngIf="errorMessage">{{ errorMessage }}</div>
<div class="form-row">
<label for="ic-name">Nom *</label>
<input id="ic-name" type="text" [(ngModel)]="name" placeholder="Ex: Échoppe de l'armurier nain">
</div>
<div class="form-row">
<label for="ic-desc">Description</label>
<textarea id="ic-desc" rows="2" [(ngModel)]="description" placeholder="À quoi sert ce catalogue / cette boutique ?"></textarea>
</div>
<!-- Génération IA -->
<div class="ai-box">
<div class="ai-title"><lucide-icon [img]="Sparkles" [size]="15"></lucide-icon> Générer avec l'IA</div>
<p class="ai-hint">Décris la boutique/le butin ; l'IA propose les objets (revois-les avant d'enregistrer). Contextualisé avec ta campagne.</p>
<textarea rows="2" [(ngModel)]="aiPrompt"
placeholder="Ex: boutique d'alchimiste dans une cité portuaire, objets exotiques"></textarea>
<div class="ai-actions">
<button class="btn-ai" (click)="generateWithAI()" [disabled]="generating">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
{{ generating ? 'Génération…' : 'Générer' }}
</button>
<span class="ai-error" *ngIf="aiError">{{ aiError }}</span>
</div>
</div>
<div class="items-head">
<h2>Objets</h2>
<button class="btn-mini" (click)="addItem()">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon> Ajouter
</button>
</div>
<div class="item-row head">
<span class="c-name">Nom</span>
<span class="c-price">Prix</span>
<span class="c-cat">Catégorie</span>
<span class="c-desc">Description</span>
<span class="c-del"></span>
</div>
<div class="item-row" *ngFor="let it of items; let i = index">
<input class="c-name" type="text" [(ngModel)]="it.name" placeholder="Objet">
<input class="c-price" type="text" [(ngModel)]="it.price" placeholder="50 po">
<input class="c-cat" type="text" [(ngModel)]="it.category" placeholder="Armes">
<input class="c-desc" type="text" [(ngModel)]="it.description" placeholder="Effet / détails">
<button class="c-del btn-del" (click)="removeItem(i)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
<p class="empty-hint" *ngIf="items.length === 0">Aucun objet — clique « Ajouter ».</p>
</div>

View File

@@ -0,0 +1,87 @@
.ice-page { max-width: 980px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
.ice-toolbar {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
.spacer { flex: 1; }
button {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.4rem 0.8rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.85rem;
&:hover { background: rgba(255,255,255,0.09); }
&:disabled { opacity: 0.5; cursor: default; }
}
.btn-save { background: #667eea; border-color: #667eea; color: #fff; }
.btn-save:hover:not(:disabled) { background: #5568d3; }
}
h1 { font-size: 1.4rem; margin: 0 0 1rem; }
h2 { font-size: 1rem; margin: 0; }
.error {
padding: 0.6rem 0.9rem; border-radius: 6px; margin-bottom: 1rem;
background: rgba(224,90,90,0.12); border: 1px solid rgba(224,90,90,0.4); color: #e88;
}
.form-row {
display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 1rem;
label { font-size: 0.85rem; color: var(--color-text-muted, #9aa0aa); }
input, textarea {
padding: 0.5rem 0.7rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04);
color: inherit; font: inherit;
}
}
.ai-box {
margin: 1rem 0; padding: 0.85rem 1rem; border-radius: 10px;
background: rgba(168,130,255,0.08); border: 1px solid rgba(168,130,255,0.28);
.ai-title { display: flex; align-items: center; gap: 0.4rem; font-weight: 600; color: #c4a8ff; }
.ai-hint { margin: 0.3rem 0 0.5rem; font-size: 0.8rem; color: var(--color-text-muted, #9aa0aa); }
textarea {
width: 100%; padding: 0.5rem 0.7rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); color: inherit; font: inherit;
}
.ai-actions { display: flex; align-items: center; gap: 0.7rem; margin-top: 0.5rem; }
.btn-ai {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.45rem 0.9rem; border: none; border-radius: 7px;
background: #8a6dff; color: #fff; font-weight: 600; cursor: pointer;
&:hover:not(:disabled) { background: #7a5cf0; }
&:disabled { opacity: 0.55; cursor: default; }
}
.ai-error { color: #e88; font-size: 0.82rem; }
}
.items-head {
display: flex; align-items: center; justify-content: space-between; margin: 1.25rem 0 0.5rem;
.btn-mini {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.3rem 0.6rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.8rem;
&:hover { background: rgba(255,255,255,0.09); }
}
}
.item-row {
display: grid; grid-template-columns: 1.4fr 80px 1fr 1.8fr 36px;
gap: 0.5rem; align-items: center; margin-bottom: 0.4rem;
&.head {
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--color-text-muted, #9aa0aa); margin-bottom: 0.3rem;
}
input {
padding: 0.4rem 0.55rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04);
color: inherit; font: inherit; width: 100%;
}
.btn-del {
display: inline-flex; align-items: center; justify-content: center; padding: 0.35rem;
border-radius: 6px; border: 1px solid rgba(224,90,90,0.3);
background: rgba(224,90,90,0.08); color: #e88; cursor: pointer;
&:hover { background: rgba(224,90,90,0.18); }
}
}
.empty-hint { color: var(--color-text-muted, #9aa0aa); font-style: italic; }

View File

@@ -0,0 +1,152 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Sparkles } from 'lucide-angular';
import { ItemCatalogService } from '../../../services/item-catalog.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { ItemCatalog, CatalogItem, ItemCatalogCreate } from '../../../services/item-catalog.model';
/**
* Création/édition d'un catalogue d'objets (boutique, butin…).
* Routes : /campaigns/:campaignId/item-catalogs/create
* /campaigns/:campaignId/item-catalogs/:catalogId/edit
*/
@Component({
selector: 'app-item-catalog-edit',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './item-catalog-edit.component.html',
styleUrls: ['./item-catalog-edit.component.scss']
})
export class ItemCatalogEditComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Save = Save;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly Sparkles = Sparkles;
campaignId: string | null = null;
catalogId: string | null = null;
name = '';
description = '';
items: CatalogItem[] = [];
saving = false;
errorMessage = '';
aiPrompt = '';
generating = false;
aiError = '';
constructor(
private route: ActivatedRoute,
private router: Router,
private service: ItemCatalogService,
private campaignSidebar: CampaignSidebarService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.catalogId = params.get('catalogId');
if (this.campaignId) this.campaignSidebar.show(this.campaignId);
if (this.catalogId) {
this.service.getById(this.catalogId).subscribe({
next: (c: ItemCatalog) => {
this.name = c.name;
this.description = c.description ?? '';
this.items = c.items.map(i => ({ ...i }));
},
error: () => this.back()
});
} else {
this.addItem();
}
}
addItem(): void {
this.items.push({ name: '', price: '', category: '', description: '' });
}
removeItem(i: number): void {
this.items.splice(i, 1);
}
generateWithAI(): void {
if (!this.campaignId) return;
if (!this.aiPrompt.trim()) { this.aiError = 'Décris le catalogue à générer.'; return; }
this.generating = true;
this.aiError = '';
this.service.generate(this.campaignId, this.aiPrompt.trim()).subscribe({
next: (c) => {
this.generating = false;
if (c.name && !this.name.trim()) this.name = c.name;
if (c.description) this.description = c.description;
this.items = (c.items ?? []).map(i => ({ ...i }));
},
error: (err) => {
this.generating = false;
this.aiError = err?.error?.message || 'Échec de la génération IA. Réessaie ou reformule.';
}
});
}
save(): void {
if (!this.campaignId) return;
if (!this.name.trim()) { this.errorMessage = 'Le nom est requis.'; return; }
this.saving = true;
this.errorMessage = '';
const cleanItems = this.items
.filter(i => i.name.trim())
.map(i => ({
name: i.name.trim(),
price: i.price?.trim() || undefined,
category: i.category?.trim() || undefined,
description: i.description?.trim() || undefined
}));
if (this.catalogId) {
const payload: ItemCatalog = {
id: this.catalogId,
name: this.name.trim(),
description: this.description.trim() || undefined,
campaignId: this.campaignId,
items: cleanItems
};
this.service.update(this.catalogId, payload).subscribe({
next: () => this.goToView(this.catalogId!),
error: (e) => this.fail(e)
});
} else {
const payload: ItemCatalogCreate = {
name: this.name.trim(),
description: this.description.trim() || undefined,
campaignId: this.campaignId,
items: cleanItems
};
this.service.create(payload).subscribe({
next: (c) => this.goToView(c.id!),
error: (e) => this.fail(e)
});
}
}
private goToView(id: string): void {
this.saving = false;
this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', id]);
}
private fail(err: unknown): void {
this.saving = false;
this.errorMessage = 'Échec de l\'enregistrement.';
console.error('ItemCatalog save failed', err);
}
back(): void {
this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']);
}
}

View File

@@ -0,0 +1,31 @@
<div class="icl-page">
<div class="icl-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
</button>
<span class="spacer"></span>
<button class="btn-create" (click)="create()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Nouveau catalogue
</button>
</div>
<header class="icl-header">
<h1><lucide-icon [img]="Package" [size]="22"></lucide-icon> Catalogues d'objets</h1>
<p class="icl-hint">
Boutiques, butins, trésors… à remplir à la main ou via l'IA. Consultables en session
quand les joueurs visitent une échoppe.
</p>
</header>
<div class="icl-list">
<p class="empty" *ngIf="catalogs.length === 0">Aucun catalogue pour l'instant.</p>
<button class="icl-item" *ngFor="let c of catalogs" (click)="open(c)">
<lucide-icon [img]="Package" [size]="16"></lucide-icon>
<span class="icl-item-name">{{ c.name }}</span>
<span class="icl-item-count">{{ c.items.length }} objet(s)</span>
<span class="icl-del" (click)="remove(c, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,36 @@
.icl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
.icl-toolbar {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
.spacer { flex: 1; }
button {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.4rem 0.8rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.85rem;
&:hover { background: rgba(255,255,255,0.09); }
}
.btn-create { background: #667eea; border-color: #667eea; color: #fff; }
.btn-create:hover { background: #5568d3; }
}
.icl-header {
margin-bottom: 1.25rem;
h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; }
.icl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; }
}
.icl-list { display: flex; flex-direction: column; gap: 0.4rem; }
.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
.icl-item {
display: flex; align-items: center; gap: 0.55rem;
padding: 0.7rem 0.85rem; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
color: inherit; cursor: pointer; text-align: left;
&:hover { background: rgba(255,255,255,0.07); }
.icl-item-name { flex: 1; font-weight: 500; }
.icl-item-count { font-size: 0.78rem; color: var(--color-text-muted, #9aa0aa); }
.icl-del { display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88;
&:hover { background: rgba(224,90,90,0.15); } }
}

View File

@@ -0,0 +1,77 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Package } from 'lucide-angular';
import { ItemCatalogService } from '../../../services/item-catalog.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { ItemCatalog } from '../../../services/item-catalog.model';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Liste des catalogues d'objets d'une campagne + création.
* Route : /campaigns/:campaignId/item-catalogs
*/
@Component({
selector: 'app-item-catalog-list',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './item-catalog-list.component.html',
styleUrls: ['./item-catalog-list.component.scss']
})
export class ItemCatalogListComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly Package = Package;
campaignId = '';
catalogs: ItemCatalog[] = [];
constructor(
private route: ActivatedRoute,
private router: Router,
private service: ItemCatalogService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? '';
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.load();
}
}
load(): void {
this.service.getByCampaign(this.campaignId).subscribe({
next: (list) => this.catalogs = list,
error: () => this.catalogs = []
});
}
create(): void {
this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', 'create']);
}
open(c: ItemCatalog): void {
this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', c.id]);
}
remove(c: ItemCatalog, ev: Event): void {
ev.stopPropagation();
this.confirmDialog.confirm({
title: 'Supprimer le catalogue',
message: `Supprimer « ${c.name} » ?`,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.service.delete(c.id!).subscribe(() => this.load());
});
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
}

View File

@@ -0,0 +1,33 @@
<div class="ic-page" *ngIf="catalog">
<div class="ic-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
</button>
<span class="spacer"></span>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon> Éditer
</button>
</div>
<header class="ic-header">
<h1><lucide-icon [img]="Package" [size]="22"></lucide-icon> {{ catalog.name }}</h1>
<p class="ic-desc" *ngIf="catalog.description">{{ catalog.description }}</p>
</header>
<p class="ic-empty" *ngIf="catalog.items.length === 0">
Aucun objet — édite le catalogue pour en ajouter.
</p>
<section class="ic-group" *ngFor="let g of groups">
<h2 *ngIf="g.category !== '—'">{{ g.category }}</h2>
<table>
<tbody>
<tr *ngFor="let it of g.items">
<td class="col-name">{{ it.name }}</td>
<td class="col-price">{{ it.price }}</td>
<td class="col-desc">{{ it.description }}</td>
</tr>
</tbody>
</table>
</section>
</div>

View File

@@ -0,0 +1,40 @@
.ic-page { max-width: 880px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
.ic-toolbar {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
.spacer { flex: 1; }
button {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.4rem 0.7rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.85rem;
&:hover { background: rgba(255,255,255,0.09); }
}
}
.ic-header {
margin-bottom: 1.25rem;
h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; }
.ic-desc { margin: 0; color: var(--color-text-muted, #9aa0aa); }
}
.ic-empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
.ic-group {
margin-bottom: 1.25rem;
h2 {
font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em;
color: #c4a8ff; margin: 0 0 0.4rem; padding-bottom: 0.25rem;
border-bottom: 1px solid rgba(168,130,255,0.2);
}
table { width: 100%; border-collapse: collapse; }
td {
padding: 0.45rem 0.6rem; border-bottom: 1px solid rgba(255,255,255,0.06); vertical-align: top;
}
.col-name { font-weight: 500; white-space: nowrap; }
.col-price {
white-space: nowrap; color: #e0c074; font-variant-numeric: tabular-nums;
text-align: right; width: 90px;
}
.col-desc { color: var(--color-text-muted, #cfd3da); font-size: 0.9rem; }
}

View File

@@ -0,0 +1,73 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Package } from 'lucide-angular';
import { ItemCatalogService } from '../../../services/item-catalog.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { ItemCatalog, CatalogItem } from '../../../services/item-catalog.model';
interface ItemGroup { category: string; items: CatalogItem[]; }
/**
* Vue d'un catalogue d'objets : liste (groupée par catégorie).
* Route : /campaigns/:campaignId/item-catalogs/:catalogId
*/
@Component({
selector: 'app-item-catalog-view',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './item-catalog-view.component.html',
styleUrls: ['./item-catalog-view.component.scss']
})
export class ItemCatalogViewComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
readonly Package = Package;
campaignId: string | null = null;
catalogId: string | null = null;
catalog: ItemCatalog | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: ItemCatalogService,
private campaignSidebar: CampaignSidebarService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.catalogId = params.get('catalogId');
if (this.catalogId) {
this.service.getById(this.catalogId).subscribe({
next: c => this.catalog = c,
error: () => this.back()
});
}
if (this.campaignId) this.campaignSidebar.show(this.campaignId);
}
/** Objets groupés par catégorie (non catégorisés en dernier). */
get groups(): ItemGroup[] {
const map = new Map<string, CatalogItem[]>();
for (const it of this.catalog?.items ?? []) {
const cat = (it.category ?? '').trim() || '—';
if (!map.has(cat)) map.set(cat, []);
map.get(cat)!.push(it);
}
return [...map.entries()]
.sort(([a], [b]) => (a === '—' ? 1 : b === '—' ? -1 : a.localeCompare(b, 'fr')))
.map(([category, items]) => ({ category, items }));
}
edit(): void {
if (this.campaignId && this.catalogId) {
this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', this.catalogId, 'edit']);
}
}
back(): void {
this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']);
}
}

View File

@@ -2,7 +2,7 @@ import {
Folder,
Users, Swords, MapPin, Shield, Crown, Skull, Gem,
BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain,
Ship, Flame, Star, Moon, Key, Globe, Compass, Dices, FileUp, LucideIconData
Ship, Flame, Star, Moon, Key, Globe, Compass, Dices, FileUp, Package, LucideIconData
} from 'lucide-angular';
import { CAMPAIGN_ICON_OPTIONS } from '../campaigns/campaign-icons';
@@ -44,6 +44,7 @@ export const LORE_ICON_OPTIONS: IconOption[] = [
{ key: 'compass', icon: Compass },
{ key: 'dice', icon: Dices },
{ key: 'file-up', icon: FileUp },
{ key: 'package', icon: Package },
];
/** Icône par défaut pour un dossier sans icône. */

View File

@@ -0,0 +1,26 @@
/** Reflet de l'ItemCatalogDTO côté Core. Un catalogue d'objets (boutique, butin…). */
export interface CatalogItem {
name: string;
price?: string;
category?: string;
description?: string;
}
export interface ItemCatalog {
id?: string;
name: string;
description?: string;
icon?: string;
campaignId: string;
order?: number;
items: CatalogItem[];
}
export interface ItemCatalogCreate {
name: string;
description?: string;
icon?: string;
campaignId: string;
items: CatalogItem[];
}

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ItemCatalog, ItemCatalogCreate } from './item-catalog.model';
/**
* CRUD des catalogues d'objets (campagne) + génération IA. Mirroir de RandomTableService.
*/
@Injectable({ providedIn: 'root' })
export class ItemCatalogService {
private apiUrl = '/api/item-catalogs';
constructor(private http: HttpClient) {}
getByCampaign(campaignId: string): Observable<ItemCatalog[]> {
return this.http.get<ItemCatalog[]>(`${this.apiUrl}/campaign/${campaignId}`);
}
getById(id: string): Observable<ItemCatalog> {
return this.http.get<ItemCatalog>(`${this.apiUrl}/${id}`);
}
create(payload: ItemCatalogCreate): Observable<ItemCatalog> {
return this.http.post<ItemCatalog>(this.apiUrl, payload);
}
update(id: string, payload: ItemCatalog): Observable<ItemCatalog> {
return this.http.put<ItemCatalog>(`${this.apiUrl}/${id}`, payload);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
/** Génère une PROPOSITION de catalogue via l'IA (non persistée) à préremplir. */
generate(campaignId: string, description: string): Observable<ItemCatalog> {
return this.http.post<ItemCatalog>(`${this.apiUrl}/generate`, { campaignId, description });
}
}

View File

@@ -180,7 +180,8 @@
[sessionId]="session.id"
[canAddToJournal]="session.active"
(rolled)="onDiceRolled($event)"
(aiReplyToJournal)="onAiReplyToJournal($event)">
(aiReplyToJournal)="onAiReplyToJournal($event)"
(noteToJournal)="onItemNoteToJournal($event)">
</app-session-reference-panel>
</aside>

View File

@@ -265,6 +265,22 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
});
}
/**
* Réception d'un objet de catalogue à consigner dans le journal.
* Le panneau fournit déjà une chaîne formatée (🛒 …) : on la sauvegarde
* telle quelle comme entrée NOTE.
*/
onItemNoteToJournal(content: string): void {
if (!this.session || !this.session.active) return;
const trimmed = content.trim();
if (!trimmed) return;
const input: SessionEntryInput = { type: 'NOTE', content: trimmed };
this.entryService.createEntry(this.session.id, input).subscribe({
next: created => this.entries = [created, ...this.entries],
error: () => console.error('Erreur lors de l\'ajout de l\'objet au journal')
});
}
deleteEntry(entry: SessionEntry): void {
if (!this.session) return;
const session = this.session;

View File

@@ -0,0 +1,38 @@
<div class="sic-panel">
<p class="loading-hint" *ngIf="loading">Chargement…</p>
<!-- Liste des catalogues -->
<div *ngIf="!loading && !selected" class="sic-list">
<p class="empty-hint" *ngIf="catalogs.length === 0">
Aucun catalogue d'objets dans cette campagne.
</p>
<button *ngFor="let c of catalogs" type="button" class="sic-item" (click)="select(c)">
<lucide-icon [img]="Package" [size]="13"></lucide-icon>
<span class="sic-item-name">{{ c.name }}</span>
<span class="sic-item-count">{{ c.items.length }}</span>
</button>
</div>
<!-- Catalogue sélectionné -->
<div *ngIf="selected" class="sic-detail">
<button type="button" class="sic-back" (click)="backToList()">
<lucide-icon [img]="ChevronLeft" [size]="13"></lucide-icon> Catalogues
</button>
<h4>{{ selected.name }}</h4>
<div class="sic-group" *ngFor="let g of groups">
<div class="sic-group-title" *ngIf="g.category !== '—'">{{ g.category }}</div>
<div class="sic-obj" *ngFor="let it of g.items">
<div class="sic-obj-main">
<span class="sic-obj-name">{{ it.name }}</span>
<span class="sic-obj-price" *ngIf="it.price">{{ it.price }}</span>
</div>
<div class="sic-obj-desc" *ngIf="it.description">{{ it.description }}</div>
<button type="button" class="sic-note" (click)="note(it)" [disabled]="!canAddToJournal"
title="Consigner au journal (ex. achat)">
<lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon> Journal
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,48 @@
.sic-panel { display: flex; flex-direction: column; gap: 0.5rem; padding: 0.25rem; }
.loading-hint, .empty-hint {
color: var(--color-text-muted, #9aa0aa); font-size: 0.85rem; font-style: italic; padding: 0.5rem 0;
}
.sic-list { display: flex; flex-direction: column; gap: 0.3rem; }
.sic-item {
display: flex; align-items: center; gap: 0.45rem; width: 100%;
padding: 0.45rem 0.6rem; border-radius: 7px;
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
color: inherit; cursor: pointer; text-align: left;
&:hover { background: rgba(255,255,255,0.08); }
.sic-item-name { flex: 1; }
.sic-item-count {
font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa);
background: rgba(255,255,255,0.06); padding: 0.05rem 0.35rem; border-radius: 4px;
}
}
.sic-detail { display: flex; flex-direction: column; gap: 0.5rem; }
.sic-back {
display: inline-flex; align-items: center; gap: 0.25rem; align-self: flex-start;
padding: 0.2rem 0.4rem; border: none; background: none;
color: var(--color-text-muted, #9aa0aa); cursor: pointer; font-size: 0.8rem;
&:hover { color: inherit; }
}
h4 { margin: 0; font-size: 0.95rem; }
.sic-group-title {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
color: #c4a8ff; margin: 0.5rem 0 0.2rem;
}
.sic-obj {
padding: 0.4rem 0.5rem; border-radius: 7px; background: rgba(255,255,255,0.03); margin-bottom: 0.3rem;
.sic-obj-main { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; }
.sic-obj-name { font-weight: 500; }
.sic-obj-price { color: #e0c074; font-size: 0.82rem; white-space: nowrap; }
.sic-obj-desc { font-size: 0.82rem; color: var(--color-text-muted, #cfd3da); margin-top: 0.15rem; }
.sic-note {
display: inline-flex; align-items: center; gap: 0.25rem; margin-top: 0.35rem;
padding: 0.2rem 0.45rem; border-radius: 5px; font-size: 0.74rem;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.05); color: inherit; cursor: pointer;
&:hover:not(:disabled) { background: rgba(255,255,255,0.1); }
&:disabled { opacity: 0.5; cursor: default; }
}
}

View File

@@ -0,0 +1,64 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, Package, BookmarkPlus, ChevronLeft } from 'lucide-angular';
import { catchError, of } from 'rxjs';
import { ItemCatalogService } from '../../services/item-catalog.service';
import { ItemCatalog, CatalogItem } from '../../services/item-catalog.model';
interface ItemGroup { category: string; items: CatalogItem[]; }
/**
* Panneau « Objets » du mode jeu : consulter les catalogues (boutiques) de la
* campagne et consigner un objet au journal (ex. « le joueur achète X »).
*/
@Component({
selector: 'app-session-item-catalogs-panel',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './session-item-catalogs-panel.component.html',
styleUrls: ['./session-item-catalogs-panel.component.scss']
})
export class SessionItemCatalogsPanelComponent implements OnInit {
readonly Package = Package;
readonly BookmarkPlus = BookmarkPlus;
readonly ChevronLeft = ChevronLeft;
@Input() campaignId!: string;
@Input() canAddToJournal = true;
/** Émis pour consigner un objet au journal (entrée NOTE). */
@Output() noteToJournal = new EventEmitter<string>();
catalogs: ItemCatalog[] = [];
loading = false;
selected: ItemCatalog | null = null;
constructor(private service: ItemCatalogService) {}
ngOnInit(): void {
if (!this.campaignId) return;
this.loading = true;
this.service.getByCampaign(this.campaignId).pipe(catchError(() => of([] as ItemCatalog[])))
.subscribe(list => { this.catalogs = list; this.loading = false; });
}
select(c: ItemCatalog): void { this.selected = c; }
backToList(): void { this.selected = null; }
get groups(): ItemGroup[] {
const map = new Map<string, CatalogItem[]>();
for (const it of this.selected?.items ?? []) {
const cat = (it.category ?? '').trim() || '—';
if (!map.has(cat)) map.set(cat, []);
map.get(cat)!.push(it);
}
return [...map.entries()]
.sort(([a], [b]) => (a === '—' ? 1 : b === '—' ? -1 : a.localeCompare(b, 'fr')))
.map(([category, items]) => ({ category, items }));
}
note(it: CatalogItem): void {
if (!this.canAddToJournal || !this.selected) return;
const price = it.price ? ` (${it.price})` : '';
this.noteToJournal.emit(`🛒 ${this.selected.name}${it.name}${price}`);
}
}

View File

@@ -22,6 +22,13 @@
<lucide-icon [img]="Table2" [size]="14"></lucide-icon>
Tables
</button>
<button type="button"
class="ref-tab"
[class.ref-tab--active]="activeTab === 'objects'"
(click)="selectTab('objects')">
<lucide-icon [img]="Package" [size]="14"></lucide-icon>
Objets
</button>
<button type="button"
class="ref-tab"
[class.ref-tab--active]="activeTab === 'characters'"
@@ -64,6 +71,14 @@
(aiReplyToJournal)="onAiSaveToJournal($event)">
</app-session-random-tables-panel>
<!-- ====== Catalogues d'objets ====== -->
<app-session-item-catalogs-panel
*ngIf="activeTab === 'objects'"
[campaignId]="campaignId"
[canAddToJournal]="canAddToJournal"
(noteToJournal)="onItemNote($event)">
</app-session-item-catalogs-panel>
<!-- ====== Personnages (PJ + PNJ) ====== -->
<div *ngIf="activeTab === 'characters'" class="ref-list">
<p class="loading-hint" *ngIf="loadingChars">Chargement…</p>

View File

@@ -1,6 +1,6 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2 } from 'lucide-angular';
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package } from 'lucide-angular';
import { catchError, of } from 'rxjs';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
@@ -14,8 +14,9 @@ import {
} from '../session-dice-panel/session-dice-panel.component';
import { SessionAiChatPanelComponent } from '../session-ai-chat-panel/session-ai-chat-panel.component';
import { SessionRandomTablesPanelComponent } from '../session-random-tables-panel/session-random-tables-panel.component';
import { SessionItemCatalogsPanelComponent } from '../session-item-catalogs-panel/session-item-catalogs-panel.component';
type TabId = 'dice' | 'tables' | 'characters' | 'scenes' | 'ai';
type TabId = 'dice' | 'tables' | 'objects' | 'characters' | 'scenes' | 'ai';
/**
* Panneau latéral du mode jeu : référence rapide en lecture seule.
@@ -30,7 +31,7 @@ type TabId = 'dice' | 'tables' | 'characters' | 'scenes' | 'ai';
@Component({
selector: 'app-session-reference-panel',
standalone: true,
imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent],
imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent, SessionItemCatalogsPanelComponent],
templateUrl: './session-reference-panel.component.html',
styleUrls: ['./session-reference-panel.component.scss']
})
@@ -42,6 +43,7 @@ export class SessionReferencePanelComponent implements OnChanges {
readonly ExternalLink = ExternalLink;
readonly Sparkles = Sparkles;
readonly Table2 = Table2;
readonly Package = Package;
@Input() campaignId!: string;
/** Partie active — nécessaire pour charger les PJ (refonte Playthrough). */
@@ -51,6 +53,8 @@ export class SessionReferencePanelComponent implements OnChanges {
@Output() rolled = new EventEmitter<DiceRollResult>();
/** Émis quand l'IA répond et que le MJ veut sauvegarder la réponse comme entrée. */
@Output() aiReplyToJournal = new EventEmitter<string>();
/** Émis pour consigner un objet de catalogue au journal (entrée NOTE). */
@Output() noteToJournal = new EventEmitter<string>();
activeTab: TabId = 'dice';
@@ -143,4 +147,8 @@ export class SessionReferencePanelComponent implements OnChanges {
onAiSaveToJournal(content: string): void {
this.aiReplyToJournal.emit(content);
}
onItemNote(content: string): void {
this.noteToJournal.emit(content);
}
}