3 Commits

Author SHA1 Message Date
49a82d05f7 Chat persistant pour la partie lore et la partie campagne pour chaque page / scène.....
All checks were successful
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m23s
Build & Push Images / build (web) (push) Successful in 1m26s
Correction du carroussel
Passage en v0.4.0
Correction du docker compose pour tout le temps utiliser le bon port que ce soit prod ou dev
2026-04-21 23:35:43 +02:00
b0fe8de708 Passage v 0.3.0
All checks were successful
Build & Push Images / build (brain) (push) Successful in 57s
Build & Push Images / build (core) (push) Successful in 1m37s
Build & Push Images / build (web) (push) Successful in 1m22s
2026-04-21 16:56:57 +02:00
71449bee1b Amélioration de l'UI : meilleur affichage des images que ce soit dans la partie lore ou la partie campagne (partie campagne : visualisation scrapbooking). Possibilité de réordonner les champs dans les templates...
Passage v0.3.0
2026-04-21 16:56:27 +02:00
88 changed files with 3184 additions and 278 deletions

View File

@@ -67,4 +67,9 @@ docker compose up -d --build
## License
[À définir]
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
En pratique :
- Tu peux l'utiliser gratuitement, l'héberger où tu veux, le modifier, le redistribuer.
- Si tu modifies le code et que tu exposes l'application modifiée sur un réseau (même en SaaS privé), tu dois rendre tes modifications publiques sous la même licence.
- Les univers (Lore) et campagnes que tu crées avec LoreMind **t'appartiennent entièrement** — la licence ne couvre que le code de l'application.

View File

@@ -81,6 +81,20 @@ class ChatUseCase:
):
yield token
def build_system_prompt(
self,
lore_context: LoreStructuralContext | None = None,
page_context: PageContext | None = None,
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
) -> str:
"""Version publique — utilisée par le controller HTTP pour compter
les tokens du system prompt avant de streamer (jauge de contexte).
"""
return self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity
)
# --- Construction du system prompt --------------------------------------
def _build_system_prompt(

View File

@@ -9,6 +9,7 @@ from typing import Annotated, AsyncIterator, Literal
import hmac
import httpx
import tiktoken
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
@@ -37,10 +38,27 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.2.0",
version="0.4.0",
)
# Encodeur tiktoken partagé — chargé une fois pour éviter le coût de lookup
# à chaque requête. On utilise cl100k_base (GPT-3.5/4) comme tokenizer
# universel approximatif : ±10% d'écart avec Llama/Gemma mais largement
# suffisant pour une jauge visuelle à l'utilisateur.
_TOKEN_ENCODER: tiktoken.Encoding | None = None
def _count_tokens(text: str | None) -> int:
"""Compte les tokens d'un texte via tiktoken. Null/empty → 0."""
if not text:
return 0
global _TOKEN_ENCODER
if _TOKEN_ENCODER is None:
_TOKEN_ENCODER = tiktoken.get_encoding("cl100k_base")
return len(_TOKEN_ENCODER.encode(text))
# Chemins exemptes d'auth inter-service : healthcheck docker + introspection
# FastAPI (docs uniquement utiles en dev ; en prod docker-compose, le Brain
# n'est pas expose en dehors du reseau interne donc pas un risque).
@@ -335,7 +353,32 @@ async def chat_stream(
campaign_context = _to_campaign_context(body.campaign_context)
narrative_entity = _to_narrative_entity(body.narrative_entity)
# --- Comptage tokens pour la jauge de contexte frontend ---
# On construit le system prompt une fois ici pour le compter — le use case
# le reconstruira à l'identique en interne (coût négligeable : concat de str).
# Cette duplication évite de complexifier le contrat stream() avec un
# paramètre optionnel system_prompt précalculé.
system_prompt_preview = use_case.build_system_prompt(
lore_context=lore_context,
page_context=page_context,
campaign_context=campaign_context,
narrative_entity=narrative_entity,
)
# Dernier message = "current" (souvent user), le reste = historique accumulé.
current_msg = messages[-1] if messages else None
history_msgs = messages[:-1] if messages else []
settings = get_settings()
usage_payload = {
"system": _count_tokens(system_prompt_preview),
"history": sum(_count_tokens(m.content) for m in history_msgs),
"current": _count_tokens(current_msg.content) if current_msg else 0,
"max": settings.llm_num_ctx,
}
async def event_stream() -> AsyncIterator[str]:
# Event 'usage' émis en tout premier : le frontend peut afficher la
# jauge avant même le premier token de réponse.
yield f"event: usage\ndata: {json.dumps(usage_payload, ensure_ascii=False)}\n\n"
try:
async for token in use_case.stream(
messages,
@@ -353,6 +396,60 @@ async def chat_stream(
return StreamingResponse(event_stream(), media_type="text/event-stream")
# --- Auto-titre d'une conversation persistee --------------------------------
class SummarizeTitleMessageDTO(BaseModel):
role: Literal["user", "assistant", "system"]
content: str
class SummarizeTitleRequestDTO(BaseModel):
"""Premiers messages d'une conversation pour auto-generer un titre court."""
messages: list[SummarizeTitleMessageDTO] = Field(default_factory=list)
class SummarizeTitleResponseDTO(BaseModel):
title: str
_TITLE_SYSTEM_PROMPT = (
"Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la "
"conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, "
"sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre "
"en francais et capturer le sujet metier (pas 'Conversation IA')."
)
@app.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO)
async def summarize_conversation_title(
body: SummarizeTitleRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> SummarizeTitleResponseDTO:
"""Genere un titre court a partir des premiers echanges de la conversation.
Appele par le core apres le 1er couple user/assistant, pour remplacer le
titre provisoire "Nouvelle conversation" par quelque chose de parlant.
"""
if not body.messages:
raise HTTPException(status_code=422, detail="Au moins un message requis")
transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6])
prompt = f"{_TITLE_SYSTEM_PROMPT}\n\nConversation :\n{transcript}\n\nTitre :"
try:
raw = await llm.generate(prompt)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
title = raw.strip().splitlines()[0].strip().strip('"').strip("'").rstrip(".")
if len(title) > 80:
title = title[:80].rstrip()
if not title:
title = "Nouvelle conversation"
return SummarizeTitleResponseDTO(title=title)
# --- Mapping DTO → domaine (frontière HTTP) ---------------------------------
@@ -449,6 +546,9 @@ class SettingsDTO(BaseModel):
onemin_model: str
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
onemin_api_key_set: bool
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
# aussi de plafond a la jauge de contexte UI.
llm_num_ctx: int
class SettingsUpdateDTO(BaseModel):
@@ -460,6 +560,7 @@ class SettingsUpdateDTO(BaseModel):
onemin_model: str | None = None
# Chaine vide => on efface la cle. None => pas de changement.
onemin_api_key: str | None = None
llm_num_ctx: int | None = None
def _to_settings_dto(s: Settings) -> SettingsDTO:
@@ -469,6 +570,7 @@ def _to_settings_dto(s: Settings) -> SettingsDTO:
llm_model=s.llm_model,
onemin_model=s.onemin_model,
onemin_api_key_set=bool(s.onemin_api_key),
llm_num_ctx=s.llm_num_ctx,
)
@@ -512,6 +614,50 @@ async def list_ollama_models(
return {"models": sorted(models)}
class OllamaModelInfoDTO(BaseModel):
"""Info utile extraite de /api/show pour un modele Ollama donne.
`context_length` = fenetre de contexte max supportee par le modele
(extraite des metadonnees GGUF). 0 si inconnue. Le frontend s'en sert
pour borner le slider de num_ctx dans les Parametres.
"""
context_length: int = 0
@app.post("/models/ollama/info", response_model=OllamaModelInfoDTO)
async def get_ollama_model_info(
body: dict[str, str],
settings: Annotated[Settings, Depends(get_settings)],
) -> OllamaModelInfoDTO:
"""Retourne les metadonnees d'un modele Ollama via /api/show.
On passe par POST (et pas GET /models/ollama/{name}) parce que les noms
Ollama contiennent souvent un `:` (ex: `gemma3:e2b`) qui se segmente
mal dans une URL — le body JSON evite le probleme d'escaping.
Le champ qui nous interesse est `model_info["<arch>.context_length"]`
(ex: `gemma3.context_length: 131072`). L'arch varie selon le modele, on
scanne donc tous les champs finissant par `.context_length`.
"""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/show"
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.post(url, json={"model": name})
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return OllamaModelInfoDTO(context_length=0)
model_info = data.get("model_info") or {}
for key, value in model_info.items():
if key.endswith(".context_length") and isinstance(value, int):
return OllamaModelInfoDTO(context_length=value)
return OllamaModelInfoDTO(context_length=0)
@app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.

View File

@@ -4,3 +4,9 @@ httpx==0.27.*
pydantic-settings==2.6.*
pydantic
# Comptage de tokens pour la jauge de contexte (UI chat drawer).
# L'encodage cl100k_base (GPT-4/3.5) donne une approximation correcte pour
# la plupart des modeles Llama/Gemma/Mistral (~5-10% d'ecart) — suffisant
# pour une jauge visuelle.
tiktoken==0.8.*

View File

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

View File

@@ -0,0 +1,134 @@
package com.loremind.application.conversationcontext;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.domain.conversationcontext.ports.ConversationRepository;
import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/**
* Service d'application du contexte Conversation.
*
* Regroupe les cas d'usage CRUD + append message + rename. Un seul
* service suffit — le contexte est simple et les operations fortement
* liees (meme aggregat).
*
* Regles metier :
* - exactement un ancrage parent (loreId XOR campaignId) ;
* - entityType et entityId vont ensemble (tous deux null = niveau racine,
* tous deux non-null = niveau entite precise).
*/
@Service
public class ConversationService {
private final ConversationRepository repository;
private final ConversationTitleGenerator titleGenerator;
public ConversationService(ConversationRepository repository,
ConversationTitleGenerator titleGenerator) {
this.repository = repository;
this.titleGenerator = titleGenerator;
}
/** Donnees de creation d'une conversation. Titre optionnel — sera auto-genere si absent. */
public record CreateData(
String title,
String loreId,
String campaignId,
String entityType,
String entityId) {}
public Conversation create(CreateData data) {
validateAnchor(data.loreId(), data.campaignId(), data.entityType(), data.entityId());
String title = (data.title() == null || data.title().isBlank())
? "Nouvelle conversation"
: data.title().trim();
Conversation conv = Conversation.builder()
.title(title)
.loreId(data.loreId())
.campaignId(data.campaignId())
.entityType(data.entityType())
.entityId(data.entityId())
.build();
return repository.save(conv);
}
public Optional<Conversation> getById(String id) {
return repository.findById(id);
}
public List<Conversation> listByContext(String loreId, String campaignId, String entityType, String entityId) {
validateAnchor(loreId, campaignId, entityType, entityId);
return repository.findByContext(loreId, campaignId, entityType, entityId);
}
public void rename(String id, String title) {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("Le titre ne peut pas etre vide");
}
if (repository.findById(id).isEmpty()) {
throw new IllegalArgumentException("Conversation introuvable : " + id);
}
repository.updateTitle(id, title.trim());
}
public void delete(String id) {
repository.deleteById(id);
}
/**
* Auto-genere un titre a partir des premiers messages et le persiste.
* Appele typiquement apres le 1er couple user/assistant pour remplacer
* le titre provisoire. Echec silencieux (fallback dans l'adaptateur) —
* on n'empeche pas la conversation de fonctionner si le Brain est down.
*/
public String autoGenerateTitle(String conversationId) {
Conversation conv = repository.findById(conversationId)
.orElseThrow(() -> new IllegalArgumentException("Conversation introuvable : " + conversationId));
List<ConversationMessage> seeds = conv.getMessages();
if (seeds == null || seeds.isEmpty()) {
return conv.getTitle();
}
String title = titleGenerator.generate(seeds);
repository.updateTitle(conversationId, title);
return title;
}
/**
* Ajoute un message (user ou assistant) a une conversation existante.
* L'horodatage et l'id sont assignes par la couche persistance.
*/
public ConversationMessage appendMessage(String conversationId, String role, String content) {
if (role == null || (!role.equals("user") && !role.equals("assistant") && !role.equals("system"))) {
throw new IllegalArgumentException("Role invalide : " + role);
}
if (content == null || content.isEmpty()) {
throw new IllegalArgumentException("Contenu vide interdit");
}
ConversationMessage msg = ConversationMessage.builder()
.role(role)
.content(content)
.build();
return repository.appendMessage(conversationId, msg);
}
// ---------- Validation ----------
private void validateAnchor(String loreId, String campaignId, String entityType, String entityId) {
boolean hasLore = loreId != null && !loreId.isBlank();
boolean hasCamp = campaignId != null && !campaignId.isBlank();
if (hasLore == hasCamp) {
throw new IllegalArgumentException("Exactement un parent attendu : loreId XOR campaignId");
}
boolean hasType = entityType != null && !entityType.isBlank();
boolean hasEntId = entityId != null && !entityId.isBlank();
if (hasType != hasEntId) {
throw new IllegalArgumentException("entityType et entityId doivent etre tous deux null ou tous deux non-null");
}
}
}

View File

@@ -5,6 +5,7 @@ import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -65,6 +66,7 @@ public class StreamChatForCampaignUseCase {
String entityType,
String entityId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
@@ -84,7 +86,7 @@ public class StreamChatForCampaignUseCase {
.narrativeEntity(narrativeEntity)
.build();
aiChatProvider.streamChat(request, onToken, onComplete, onError);
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
}
/**

View File

@@ -2,6 +2,7 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -60,6 +61,7 @@ public class StreamChatForLoreUseCase {
String loreId,
String pageId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
@@ -75,7 +77,7 @@ public class StreamChatForLoreUseCase {
.pageContext(pageContext)
.build();
aiChatProvider.streamChat(request, onToken, onComplete, onError);
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
}
/**

View File

@@ -38,12 +38,18 @@ public class Arc {
private List<String> relatedPageIds = new ArrayList<>();
/**
* IDs des images (Shared Kernel) servant d'illustrations a cet arc.
* IDs des images (Shared Kernel) servant d'illustrations a cet arc (ambiance).
* Galerie ordonnee : la 1ere image est l'illustration principale.
*/
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/**
* IDs des images utilisees comme cartes / plans (outil de table).
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -34,11 +34,17 @@ public class Chapter {
private List<String> relatedPageIds = new ArrayList<>();
/**
* IDs des images (Shared Kernel) illustrant ce chapitre.
* IDs des images (Shared Kernel) illustrant ce chapitre (ambiance).
*/
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/**
* IDs des images utilisees comme cartes / plans pour ce chapitre (outil de table).
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -48,11 +48,19 @@ public class Scene {
/**
* IDs des images (Shared Kernel) illustrant cette scene.
* Utile pour carte du lieu, portraits des PNJ principaux, ambiance.
* Vocation "ambiance" : portraits, decors, moodboard. Rendu facon editorial.
*/
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/**
* IDs des images utilisees comme cartes / plans.
* Vocation "outil de table" : plan de donjon, carte du lieu, schema tactique.
* Rendu different des illustrations : vignettes plus grandes, ratio natif preserve.
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
/**
* Sorties narratives possibles depuis cette scène (graphe intra-chapitre).
* Chaque branche décrit un choix des joueurs et la scène de destination.

View File

@@ -0,0 +1,47 @@
package com.loremind.domain.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Agregat d'une conversation de chat IA persistee.
*
* Une conversation est ancree sur exactement un niveau de contexte :
* - un Lore (optionnellement une page precise)
* - une Campagne (optionnellement une entite narrative : arc/chapitre/scene)
*
* C'est cet ancrage qui permet au drawer de filtrer les conversations
* a afficher dans la sidebar selon l'ecran en cours.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Conversation {
private String id;
private String title;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** Un seul des deux est non-null. */
private String loreId;
private String campaignId;
/**
* Type d'entite focus, null si la conversation est ancree au niveau
* Lore/Campagne racine (pas sur une page/scene precise).
* Valeurs : "page", "arc", "chapter", "scene".
*/
private String entityType;
private String entityId;
@Builder.Default
private List<ConversationMessage> messages = new ArrayList<>();
}

View File

@@ -0,0 +1,28 @@
package com.loremind.domain.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Un message persiste d'une conversation.
*
* Distinct de {@link com.loremind.domain.generationcontext.ChatMessage}
* qui reste un simple record role+content pour le streaming LLM. Ici
* on ajoute id et horodatage, necessaires pour l'affichage / l'ordre.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConversationMessage {
private String id;
/** "user" | "assistant" | "system". */
private String role;
private String content;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,44 @@
package com.loremind.domain.conversationcontext.ports;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import java.util.List;
import java.util.Optional;
/**
* Port de persistance des conversations de chat IA.
*
* Les methodes de lecture par contexte acceptent des filtres nullables :
* - `loreId` OU `campaignId` doit etre non-null (mais pas les deux)
* - `entityType` + `entityId` : soit tous les deux null (niveau racine),
* soit tous les deux non-null (niveau entite precise).
*/
public interface ConversationRepository {
Conversation save(Conversation conversation);
Optional<Conversation> findById(String id);
/**
* Liste les conversations filtrees par contexte strict, triees par
* updatedAt desc. Les messages ne sont PAS chargees (liste vide) pour
* garder la payload legere — la sidebar n'affiche que les titres.
*/
List<Conversation> findByContext(
String loreId,
String campaignId,
String entityType,
String entityId);
void deleteById(String id);
/**
* Ajoute un message a une conversation existante. Met a jour updatedAt
* de la conversation parent. Renvoie le message persiste (avec id + ts).
*/
ConversationMessage appendMessage(String conversationId, ConversationMessage message);
/** Rename atomique — ne touche pas aux messages. */
void updateTitle(String conversationId, String title);
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.conversationcontext.ports;
import com.loremind.domain.conversationcontext.ConversationMessage;
import java.util.List;
/**
* Port : generation d'un titre court a partir des premiers echanges d'une
* conversation. Implemente via un appel Brain /summarize/conversation-title.
*/
public interface ConversationTitleGenerator {
/** Renvoie un titre en francais (4-7 mots max). Jamais null ni vide. */
String generate(List<ConversationMessage> firstMessages);
}

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.generationcontext;
/**
* Instantané d'occupation de la fenêtre de contexte à l'instant t du chat.
* <p>
* Émis une fois par tour de chat (juste avant le streaming des tokens) pour
* alimenter la jauge de contexte côté frontend. Les unités sont des tokens
* (approximés via tiktoken côté Brain — ±10% vs le tokenizer réel du modèle).
*
* @param system tokens consommés par le system prompt (contextes Lore/campagne injectés)
* @param history tokens consommés par l'historique de la conversation (hors dernier message)
* @param current tokens du dernier message utilisateur en attente de réponse
* @param max taille maximale configurée de la fenêtre de contexte
*/
public record ChatUsage(int system, int history, int current, int max) {
}

View File

@@ -1,6 +1,7 @@
package com.loremind.domain.generationcontext.ports;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import java.util.function.Consumer;
@@ -26,6 +27,10 @@ public interface AiChatProvider {
* HTTP côté controller SSE).
*
* @param request messages + contexte Lore
* @param onUsage invoqué une fois au début du stream avec le bilan
* d'occupation de la fenêtre de contexte (tokens system /
* history / current / max). Peut ne jamais être invoqué
* si le provider ne supporte pas le comptage.
* @param onToken invoqué à chaque token reçu du LLM (peut être appelé
* de nombreuses fois)
* @param onComplete invoqué une fois le stream terminé avec succès
@@ -34,6 +39,7 @@ public interface AiChatProvider {
*/
void streamChat(
ChatRequest request,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.lorecontext;
/**
* Variante de rendu pour un champ de type IMAGE.
* <p>
* - GALLERY : grille de vignettes (defaut, comportement historique)
* - HERO : premiere image en banniere pleine largeur, suivantes en petit
* - MASONRY : mosaique hauteurs variables facon Pinterest
* - CAROUSEL : defilement horizontal
* <p>
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore pour TEXT.
*/
public enum ImageLayout {
GALLERY,
HERO,
MASONRY,
CAROUSEL
}

View File

@@ -12,10 +12,9 @@ import lombok.NoArgsConstructor;
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation).
* <p>
* Evolution de `List<String> fields` vers `List<TemplateField> fields` :
* refactor propre (DDD Value Object polymorphism) permettant d'ajouter
* facilement d'autres types de champs (DATE, NUMBER, RICH_TEXT...) sans
* casser le contrat.
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
* Ignore pour les champs TEXT.
*/
@Data
@Builder
@@ -26,14 +25,26 @@ public class TemplateField {
private String name;
/** Type du champ, pilote le rendu et la generation IA. */
private FieldType type;
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
private ImageLayout layout;
/** Constructeur de retrocompat : type seul, layout=null. */
public TemplateField(String name, FieldType type) {
this(name, type, null);
}
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
public static TemplateField text(String name) {
return new TemplateField(name, FieldType.TEXT);
return new TemplateField(name, FieldType.TEXT, null);
}
/** Raccourci : construit un champ de type IMAGE. */
/** Raccourci : construit un champ de type IMAGE avec layout GALLERY. */
public static TemplateField image(String name) {
return new TemplateField(name, FieldType.IMAGE);
return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY);
}
/** Raccourci : construit un champ IMAGE avec un layout specifique. */
public static TemplateField image(String name, ImageLayout layout) {
return new TemplateField(name, FieldType.IMAGE, layout);
}
}

View File

@@ -7,6 +7,7 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSu
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
@@ -62,6 +63,7 @@ public class BrainAiChatClient implements AiChatProvider {
@Override
public void streamChat(
ChatRequest request,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
@@ -81,7 +83,7 @@ public class BrainAiChatClient implements AiChatProvider {
// au contrat synchrone du port. L'appelant choisit le thread.
flux
.timeout(Duration.ofSeconds(120))
.doOnNext(sse -> handleEvent(sse, onToken, onError))
.doOnNext(sse -> handleEvent(sse, onUsage, onToken, onError))
.blockLast();
onComplete.run();
} catch (Exception e) {
@@ -90,9 +92,10 @@ public class BrainAiChatClient implements AiChatProvider {
}
}
/** Dispatch selon le type d'événement SSE (data par défaut, done, error). */
/** Dispatch selon le type d'événement SSE (data par défaut, done, error, usage). */
private void handleEvent(
ServerSentEvent<String> sse,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Consumer<Throwable> onError) {
String event = sse.event(); // null si pas d'event: xxx -> c'est un data par défaut
@@ -106,6 +109,11 @@ public class BrainAiChatClient implements AiChatProvider {
if ("done".equals(event)) {
return; // la fin est gérée par blockLast + onComplete
}
if ("usage".equals(event)) {
ChatUsage usage = extractUsage(data);
if (usage != null) onUsage.accept(usage);
return;
}
// Défaut : événement data avec JSON {"token":"..."}.
String token = extractToken(data);
if (token != null && !token.isEmpty()) {
@@ -113,6 +121,39 @@ public class BrainAiChatClient implements AiChatProvider {
}
}
/**
* Parse un JSON {"system":N,"history":N,"current":N,"max":N} en ChatUsage.
* Renvoie null si le payload est illisible — dans ce cas on ne propage
* simplement pas d'usage, le stream token continue normalement.
*/
private ChatUsage extractUsage(String json) {
if (json == null) return null;
try {
int system = extractIntField(json, "system");
int history = extractIntField(json, "history");
int current = extractIntField(json, "current");
int max = extractIntField(json, "max");
return new ChatUsage(system, history, current, max);
} catch (Exception e) {
return null;
}
}
/** Parse minimaliste d'un champ entier JSON sans dépendre de Jackson. */
private int extractIntField(String json, String field) {
String needle = "\"" + field + "\"";
int idx = json.indexOf(needle);
if (idx < 0) return 0;
int colon = json.indexOf(':', idx);
if (colon < 0) return 0;
int start = colon + 1;
while (start < json.length() && Character.isWhitespace(json.charAt(start))) start++;
int end = start;
while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) end++;
if (end == start) return 0;
return Integer.parseInt(json.substring(start, end));
}
/**
* Parse minimaliste du JSON {"token":"..."} sans pull Jackson ici.
* Si le format se complexifie, on remplacera par un DTO Jackson.

View File

@@ -0,0 +1,68 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Adaptateur : appelle le Brain POST /summarize/conversation-title pour
* obtenir un titre court a partir des premiers messages.
*
* Fallback volontairement silencieux : si le Brain est indisponible, on
* renvoie un titre par defaut plutot que de casser l'UX chat.
*/
@Component
public class BrainConversationTitleClient implements ConversationTitleGenerator {
private static final String PATH = "/summarize/conversation-title";
private static final String FALLBACK = "Nouvelle conversation";
private final WebClient webClient;
public BrainConversationTitleClient(
WebClient.Builder builder,
@Value("${brain.base-url}") String baseUrl) {
this.webClient = builder.baseUrl(baseUrl).build();
}
@Override
public String generate(List<ConversationMessage> firstMessages) {
if (firstMessages == null || firstMessages.isEmpty()) {
return FALLBACK;
}
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("messages", firstMessages.stream()
.map(m -> Map.<String, Object>of(
"role", m.getRole(),
"content", m.getContent() == null ? "" : m.getContent()))
.collect(Collectors.toList()));
try {
@SuppressWarnings("unchecked")
Map<String, Object> resp = webClient.post()
.uri(PATH)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(payload)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(20))
.block();
if (resp == null) return FALLBACK;
Object title = resp.get("title");
if (title == null) return FALLBACK;
String s = title.toString().trim();
return s.isEmpty() ? FALLBACK : s;
} catch (Exception e) {
return FALLBACK;
}
}
}

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@@ -72,8 +73,20 @@ public class TemplateFieldListJsonConverter
// Type inconnu (ajoute par une version future) : fallback TEXT.
type = FieldType.TEXT;
}
ImageLayout layout = null;
if (type == FieldType.IMAGE) {
String layoutStr = item.path("layout").asText(null);
if (layoutStr != null && !layoutStr.isBlank()) {
try {
layout = ImageLayout.valueOf(layoutStr);
} catch (IllegalArgumentException ex) {
// Layout inconnu : on laisse null → rendu GALLERY par defaut cote UI.
layout = null;
}
}
}
if (name != null && !name.isBlank()) {
result.add(new TemplateField(name, type));
result.add(new TemplateField(name, type, layout));
}
}
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.

View File

@@ -68,6 +68,12 @@ public class ArcJpaEntity {
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images "cartes / plans". */
@Column(name = "map_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -57,6 +57,11 @@ public class ChapterJpaEntity {
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "map_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -0,0 +1,89 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Persistance d'une conversation de chat IA.
*
* Les refs loreId / campaignId / entityId sont des weak references (String,
* pas de FK) — coherent avec la politique inter-contexte du reste du code.
* Indexes compose pour accelerer le listing par contexte dans la sidebar.
*/
@Entity
@Table(name = "conversations", indexes = {
@Index(name = "idx_conv_lore_entity", columnList = "lore_id,entity_type,entity_id,updated_at"),
@Index(name = "idx_conv_campaign_entity", columnList = "campaign_id,entity_type,entity_id,updated_at")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConversationJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(name = "lore_id")
private String loreId;
@Column(name = "campaign_id")
private String campaignId;
@Column(name = "entity_type")
private String entityType;
@Column(name = "entity_id")
private String entityId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* Messages enfants. Charges a la demande (fetch=LAZY) pour ne pas plomber
* le listing sidebar. Cascade ALL + orphanRemoval : la suppression d'une
* conversation efface ses messages.
*/
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("createdAt ASC, id ASC")
@Builder.Default
private List<ConversationMessageJpaEntity> messages = new ArrayList<>();
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,59 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* Persistance d'un message appartenant a une {@link ConversationJpaEntity}.
* Les messages sont ordonnes par createdAt ASC (ordre d'ajout = ordre lu).
*/
@Entity
@Table(name = "conversation_messages")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConversationMessageJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** "user" | "assistant" | "system". */
@Column(nullable = false, length = 16)
private String role;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/**
* Reference vers la conversation parent. ToString exclu pour eviter une
* boucle infinie quand Lombok genere toString() (conv -> messages -> conv...).
*/
@ManyToOne(optional = false)
@JoinColumn(name = "conversation_id", nullable = false)
@ToString.Exclude
private ConversationJpaEntity conversation;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}

View File

@@ -80,6 +80,11 @@ public class SceneJpaEntity {
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "map_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
// Graphe narratif intra-chapitre : sorties possibles vers d'autres scènes.
// Persisté en TEXT JSON via converter (pattern homogène avec les autres listes).
@Column(name = "branches", columnDefinition = "TEXT")

View File

@@ -0,0 +1,64 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.ConversationJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Repository Spring Data JPA pour ConversationJpaEntity.
*
* Les requetes de listing par contexte gerent explicitement les NULL parce
* que JPQL `=` ne matche pas NULL. On combine `IS NULL` / `=` selon si le
* filtre est fourni — plus simple qu'une Specification Criteria API.
*/
@Repository
public interface ConversationJpaRepository extends JpaRepository<ConversationJpaEntity, Long> {
/** Listing Lore racine (entity_type IS NULL). */
@Query("""
SELECT c FROM ConversationJpaEntity c
WHERE c.loreId = :loreId
AND c.entityType IS NULL
ORDER BY c.updatedAt DESC
""")
List<ConversationJpaEntity> findByLoreRoot(@Param("loreId") String loreId);
/** Listing Lore + entite precise. */
@Query("""
SELECT c FROM ConversationJpaEntity c
WHERE c.loreId = :loreId
AND c.entityType = :entityType
AND c.entityId = :entityId
ORDER BY c.updatedAt DESC
""")
List<ConversationJpaEntity> findByLoreAndEntity(
@Param("loreId") String loreId,
@Param("entityType") String entityType,
@Param("entityId") String entityId);
/** Listing Campagne racine. */
@Query("""
SELECT c FROM ConversationJpaEntity c
WHERE c.campaignId = :campaignId
AND c.entityType IS NULL
ORDER BY c.updatedAt DESC
""")
List<ConversationJpaEntity> findByCampaignRoot(@Param("campaignId") String campaignId);
/** Listing Campagne + entite precise. */
@Query("""
SELECT c FROM ConversationJpaEntity c
WHERE c.campaignId = :campaignId
AND c.entityType = :entityType
AND c.entityId = :entityId
ORDER BY c.updatedAt DESC
""")
List<ConversationJpaEntity> findByCampaignAndEntity(
@Param("campaignId") String campaignId,
@Param("entityType") String entityType,
@Param("entityId") String entityId);
}

View File

@@ -83,6 +83,9 @@ public class PostgresArcRepository implements ArcRepository {
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>())
.mapImageIds(jpaEntity.getMapImageIds() != null
? new ArrayList<>(jpaEntity.getMapImageIds())
: new ArrayList<>())
.createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt())
.build();
@@ -107,6 +110,9 @@ public class PostgresArcRepository implements ArcRepository {
.illustrationImageIds(arc.getIllustrationImageIds() != null
? new ArrayList<>(arc.getIllustrationImageIds())
: new ArrayList<>())
.mapImageIds(arc.getMapImageIds() != null
? new ArrayList<>(arc.getMapImageIds())
: new ArrayList<>())
.createdAt(arc.getCreatedAt())
.updatedAt(arc.getUpdatedAt())
.build();

View File

@@ -80,6 +80,9 @@ public class PostgresChapterRepository implements ChapterRepository {
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>())
.mapImageIds(jpaEntity.getMapImageIds() != null
? new ArrayList<>(jpaEntity.getMapImageIds())
: new ArrayList<>())
.createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt())
.build();
@@ -102,6 +105,9 @@ public class PostgresChapterRepository implements ChapterRepository {
.illustrationImageIds(chapter.getIllustrationImageIds() != null
? new ArrayList<>(chapter.getIllustrationImageIds())
: new ArrayList<>())
.mapImageIds(chapter.getMapImageIds() != null
? new ArrayList<>(chapter.getMapImageIds())
: new ArrayList<>())
.createdAt(chapter.getCreatedAt())
.updatedAt(chapter.getUpdatedAt())
.build();

View File

@@ -0,0 +1,148 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.domain.conversationcontext.ports.ConversationRepository;
import com.loremind.infrastructure.persistence.entity.ConversationJpaEntity;
import com.loremind.infrastructure.persistence.entity.ConversationMessageJpaEntity;
import com.loremind.infrastructure.persistence.jpa.ConversationJpaRepository;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Adaptateur Postgres pour ConversationRepository.
*
* Les methodes de listing ne chargent PAS les messages (messages LAZY,
* liste vide renvoyee cote domaine) — la sidebar n'a besoin que des
* meta-donnees. findById charge les messages via fetch explicite de la
* collection dans une transaction.
*/
@Repository
public class PostgresConversationRepository implements ConversationRepository {
private final ConversationJpaRepository jpa;
public PostgresConversationRepository(ConversationJpaRepository jpa) {
this.jpa = jpa;
}
@Override
@Transactional
public Conversation save(Conversation conversation) {
ConversationJpaEntity entity = toJpaEntity(conversation);
ConversationJpaEntity saved = jpa.save(entity);
return toDomain(saved, true);
}
@Override
@Transactional(readOnly = true)
public Optional<Conversation> findById(String id) {
return jpa.findById(Long.parseLong(id))
.map(e -> {
// Force l'initialisation LAZY avant de sortir de la transaction.
e.getMessages().size();
return toDomain(e, true);
});
}
@Override
@Transactional(readOnly = true)
public List<Conversation> findByContext(String loreId, String campaignId, String entityType, String entityId) {
List<ConversationJpaEntity> rows;
if (loreId != null) {
rows = (entityType == null)
? jpa.findByLoreRoot(loreId)
: jpa.findByLoreAndEntity(loreId, entityType, entityId);
} else if (campaignId != null) {
rows = (entityType == null)
? jpa.findByCampaignRoot(campaignId)
: jpa.findByCampaignAndEntity(campaignId, entityType, entityId);
} else {
return Collections.emptyList();
}
return rows.stream().map(e -> toDomain(e, false)).collect(Collectors.toList());
}
@Override
@Transactional
public void deleteById(String id) {
jpa.deleteById(Long.parseLong(id));
}
@Override
@Transactional
public ConversationMessage appendMessage(String conversationId, ConversationMessage message) {
ConversationJpaEntity conv = jpa.findById(Long.parseLong(conversationId))
.orElseThrow(() -> new EntityNotFoundException("Conversation " + conversationId));
ConversationMessageJpaEntity msg = ConversationMessageJpaEntity.builder()
.role(message.getRole())
.content(message.getContent())
.conversation(conv)
.build();
conv.getMessages().add(msg);
// Force updatedAt via @PreUpdate en modifiant la conv (touch).
conv.setUpdatedAt(java.time.LocalDateTime.now());
ConversationJpaEntity saved = jpa.save(conv);
ConversationMessageJpaEntity persisted = saved.getMessages().get(saved.getMessages().size() - 1);
return toDomainMessage(persisted);
}
@Override
@Transactional
public void updateTitle(String conversationId, String title) {
ConversationJpaEntity conv = jpa.findById(Long.parseLong(conversationId))
.orElseThrow(() -> new EntityNotFoundException("Conversation " + conversationId));
conv.setTitle(title);
jpa.save(conv);
}
// ---------- Mapping ----------
private ConversationJpaEntity toJpaEntity(Conversation c) {
Long id = c.getId() != null ? Long.parseLong(c.getId()) : null;
return ConversationJpaEntity.builder()
.id(id)
.title(c.getTitle())
.loreId(c.getLoreId())
.campaignId(c.getCampaignId())
.entityType(c.getEntityType())
.entityId(c.getEntityId())
.createdAt(c.getCreatedAt())
.updatedAt(c.getUpdatedAt())
.build();
}
private Conversation toDomain(ConversationJpaEntity e, boolean withMessages) {
List<ConversationMessage> msgs = withMessages
? e.getMessages().stream().map(this::toDomainMessage).collect(Collectors.toList())
: new java.util.ArrayList<>();
return Conversation.builder()
.id(e.getId().toString())
.title(e.getTitle())
.loreId(e.getLoreId())
.campaignId(e.getCampaignId())
.entityType(e.getEntityType())
.entityId(e.getEntityId())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.messages(msgs)
.build();
}
private ConversationMessage toDomainMessage(ConversationMessageJpaEntity e) {
return ConversationMessage.builder()
.id(e.getId() != null ? e.getId().toString() : null)
.role(e.getRole())
.content(e.getContent())
.createdAt(e.getCreatedAt())
.build();
}
}

View File

@@ -85,6 +85,9 @@ public class PostgresSceneRepository implements SceneRepository {
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>())
.mapImageIds(jpaEntity.getMapImageIds() != null
? new ArrayList<>(jpaEntity.getMapImageIds())
: new ArrayList<>())
.branches(jpaEntity.getBranches() != null
? new ArrayList<>(jpaEntity.getBranches())
: new ArrayList<>())
@@ -115,6 +118,9 @@ public class PostgresSceneRepository implements SceneRepository {
.illustrationImageIds(scene.getIllustrationImageIds() != null
? new ArrayList<>(scene.getIllustrationImageIds())
: new ArrayList<>())
.mapImageIds(scene.getMapImageIds() != null
? new ArrayList<>(scene.getMapImageIds())
: new ArrayList<>())
.branches(scene.getBranches() != null
? new ArrayList<>(scene.getBranches())
: new ArrayList<>())

View File

@@ -3,6 +3,7 @@ package com.loremind.infrastructure.web.controller;
import com.loremind.application.generationcontext.StreamChatForCampaignUseCase;
import com.loremind.application.generationcontext.StreamChatForLoreUseCase;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
@@ -80,6 +81,7 @@ public class AiChatController {
try {
streamChatForLoreUseCase.execute(
loreId, pageId, messages,
usage -> sendUsage(emitter, usage),
token -> sendToken(emitter, token),
() -> complete(emitter),
error -> fail(emitter, error));
@@ -100,6 +102,7 @@ public class AiChatController {
try {
streamChatForCampaignUseCase.execute(
campaignId, entityType, entityId, messages,
usage -> sendUsage(emitter, usage),
token -> sendToken(emitter, token),
() -> complete(emitter),
error -> fail(emitter, error));
@@ -110,6 +113,18 @@ public class AiChatController {
// --- Helpers SSE (un seul point d'écriture par type d'événement) --------
private void sendUsage(SseEmitter emitter, ChatUsage usage) {
try {
String payload = "{\"system\":" + usage.system()
+ ",\"history\":" + usage.history()
+ ",\"current\":" + usage.current()
+ ",\"max\":" + usage.max() + "}";
emitter.send(SseEmitter.event().name("usage").data(payload));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
private void sendToken(SseEmitter emitter, String token) {
try {
emitter.send(SseEmitter.event()

View File

@@ -0,0 +1,100 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.conversationcontext.ConversationService;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.infrastructure.web.dto.conversationcontext.AppendMessageDTO;
import com.loremind.infrastructure.web.dto.conversationcontext.ConversationDTO;
import com.loremind.infrastructure.web.dto.conversationcontext.ConversationMessageDTO;
import com.loremind.infrastructure.web.dto.conversationcontext.CreateConversationDTO;
import com.loremind.infrastructure.web.dto.conversationcontext.RenameConversationDTO;
import com.loremind.infrastructure.web.mapper.ConversationMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* API REST des conversations persistees.
*
* GET /api/conversations?loreId=...&entityType=...&entityId=... (listing filtre)
* GET /api/conversations?campaignId=...&entityType=...&entityId=...
* GET /api/conversations/{id} (detail + messages)
* POST /api/conversations (create)
* PATCH /api/conversations/{id}/title (rename)
* DELETE /api/conversations/{id}
*
* L'ajout de messages est piloje cote chat stream (use case dedie),
* pas par ce controller.
*/
@RestController
@RequestMapping("/api/conversations")
public class ConversationController {
private final ConversationService service;
private final ConversationMapper mapper;
public ConversationController(ConversationService service, ConversationMapper mapper) {
this.service = service;
this.mapper = mapper;
}
@GetMapping
public ResponseEntity<List<ConversationDTO>> list(
@RequestParam(required = false) String loreId,
@RequestParam(required = false) String campaignId,
@RequestParam(required = false) String entityType,
@RequestParam(required = false) String entityId) {
List<Conversation> rows = service.listByContext(loreId, campaignId, entityType, entityId);
return ResponseEntity.ok(rows.stream().map(mapper::toListDTO).collect(Collectors.toList()));
}
@GetMapping("/{id}")
public ResponseEntity<ConversationDTO> getById(@PathVariable String id) {
return service.getById(id)
.map(c -> ResponseEntity.ok(mapper.toDTO(c)))
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<ConversationDTO> create(@RequestBody CreateConversationDTO dto) {
Conversation created = service.create(new ConversationService.CreateData(
dto.getTitle(),
dto.getLoreId(),
dto.getCampaignId(),
dto.getEntityType(),
dto.getEntityId()));
return ResponseEntity.ok(mapper.toDTO(created));
}
@PatchMapping("/{id}/title")
public ResponseEntity<Void> rename(@PathVariable String id, @RequestBody RenameConversationDTO dto) {
service.rename(id, dto.getTitle());
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
@PostMapping("/{id}/messages")
public ResponseEntity<ConversationMessageDTO> appendMessage(
@PathVariable String id,
@RequestBody AppendMessageDTO dto) {
ConversationMessage saved = service.appendMessage(id, dto.getRole(), dto.getContent());
return ResponseEntity.ok(mapper.toMessageDTO(saved));
}
/**
* Auto-genere et persiste un titre base sur les premiers messages.
* Appele par le front apres le 1er couple user/assistant.
*/
@PostMapping("/{id}/auto-title")
public ResponseEntity<RenameConversationDTO> autoTitle(@PathVariable String id) {
String title = service.autoGenerateTitle(id);
return ResponseEntity.ok(RenameConversationDTO.builder().title(title).build());
}
}

View File

@@ -79,6 +79,10 @@ public class ImageController {
.contentType(MediaType.parseMediaType(img.getContentType()))
.contentLength(img.getSizeBytes())
.header(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable")
// Autorise explicitement l'utilisation cross-origin du binaire dans une <img>.
// Sans ce header, Firefox 109+ applique ORB (Opaque Response Blocking) et
// bloque l'image quand le front (localhost:4200) la charge depuis l'API (localhost:8080).
.header("Cross-Origin-Resource-Policy", "cross-origin")
.body(new InputStreamResource(stream));
}

View File

@@ -7,6 +7,7 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -53,6 +54,11 @@ public class SettingsController {
return forward(HttpMethod.GET, "/models/ollama", null);
}
@PostMapping("/models/ollama/info")
public ResponseEntity<Map<String, Object>> getOllamaModelInfo(@RequestBody Map<String, Object> body) {
return forward(HttpMethod.POST, "/models/ollama/info", body);
}
@GetMapping("/models/onemin")
public ResponseEntity<Map<String, Object>> listOneMinModels() {
return forward(HttpMethod.GET, "/models/onemin", null);

View File

@@ -27,6 +27,9 @@ public class ArcDTO {
/** IDs des pages du Lore liées à cet arc (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant cet arc. */
/** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */
private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images utilisees comme cartes / plans. */
private List<String> mapImageIds = new ArrayList<>();
}

View File

@@ -25,6 +25,9 @@ public class ChapterDTO {
/** IDs des pages du Lore liées (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant ce chapitre. */
/** IDs des images (Shared Kernel) illustrant ce chapitre (ambiance). */
private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images utilisees comme cartes / plans. */
private List<String> mapImageIds = new ArrayList<>();
}

View File

@@ -30,9 +30,12 @@ public class SceneDTO {
/** IDs des pages du Lore liées (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant cette scene. */
/** IDs des images (Shared Kernel) illustrant cette scene (ambiance). */
private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images utilisees comme cartes / plans (outil de table). */
private List<String> mapImageIds = new ArrayList<>();
/** Branches narratives : sorties possibles vers d'autres scènes du même chapitre. */
private List<SceneBranchDTO> branches = new ArrayList<>();
}

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.web.dto.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AppendMessageDTO {
/** "user" | "assistant" | "system". */
private String role;
private String content;
}

View File

@@ -0,0 +1,29 @@
package com.loremind.infrastructure.web.dto.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* DTO d'une conversation. Les messages sont inclus uniquement sur GET /{id}
* (null pour les reponses de listing afin d'alleger la sidebar).
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConversationDTO {
private String id;
private String title;
private String loreId;
private String campaignId;
private String entityType;
private String entityId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private List<ConversationMessageDTO> messages;
}

View File

@@ -0,0 +1,19 @@
package com.loremind.infrastructure.web.dto.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConversationMessageDTO {
private String id;
private String role;
private String content;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,23 @@
package com.loremind.infrastructure.web.dto.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Payload de creation. Le client fournit l'ancrage (lore ou campagne, +/-
* entite focus). Le titre est optionnel — sera auto-genere apres le 1er
* echange IA si absent.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateConversationDTO {
private String title;
private String loreId;
private String campaignId;
private String entityType;
private String entityId;
}

View File

@@ -0,0 +1,14 @@
package com.loremind.infrastructure.web.dto.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RenameConversationDTO {
private String title;
}

View File

@@ -9,6 +9,8 @@ import lombok.NoArgsConstructor;
* <p>
* Miroir wire-friendly de {@link com.loremind.domain.lorecontext.TemplateField}.
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
* Le layout (null pour TEXT, ou GALLERY/HERO/MASONRY/CAROUSEL pour IMAGE) pilote
* le rendu visuel des champs image cote front.
*/
@Data
@NoArgsConstructor
@@ -17,4 +19,11 @@ public class TemplateFieldDTO {
private String name;
/** "TEXT" ou "IMAGE" (string pour serialisation JSON transparente). */
private String type;
/** "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", null si type=TEXT. */
private String layout;
/** Retrocompat : constructeur sans layout. */
public TemplateFieldDTO(String name, String type) {
this(name, type, null);
}
}

View File

@@ -31,6 +31,7 @@ public class ArcMapper {
dto.setResolution(arc.getResolution());
dto.setRelatedPageIds(copyList(arc.getRelatedPageIds()));
dto.setIllustrationImageIds(copyList(arc.getIllustrationImageIds()));
dto.setMapImageIds(copyList(arc.getMapImageIds()));
return dto;
}
@@ -52,6 +53,7 @@ public class ArcMapper {
.resolution(dto.getResolution())
.relatedPageIds(copyList(dto.getRelatedPageIds()))
.illustrationImageIds(copyList(dto.getIllustrationImageIds()))
.mapImageIds(copyList(dto.getMapImageIds()))
.build();
}

View File

@@ -29,6 +29,7 @@ public class ChapterMapper {
dto.setNarrativeStakes(chapter.getNarrativeStakes());
dto.setRelatedPageIds(copyList(chapter.getRelatedPageIds()));
dto.setIllustrationImageIds(copyList(chapter.getIllustrationImageIds()));
dto.setMapImageIds(copyList(chapter.getMapImageIds()));
return dto;
}
@@ -48,6 +49,7 @@ public class ChapterMapper {
.narrativeStakes(dto.getNarrativeStakes())
.relatedPageIds(copyList(dto.getRelatedPageIds()))
.illustrationImageIds(copyList(dto.getIllustrationImageIds()))
.mapImageIds(copyList(dto.getMapImageIds()))
.build();
}

View File

@@ -0,0 +1,61 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.infrastructure.web.dto.conversationcontext.ConversationDTO;
import com.loremind.infrastructure.web.dto.conversationcontext.ConversationMessageDTO;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
/**
* Conversion Domaine <-> DTO pour le contexte Conversation.
*
* {@link #toListDTO(Conversation)} omet les messages — utilise pour le
* listing sidebar ou on n'expose que les metadonnees.
*/
@Component
public class ConversationMapper {
public ConversationDTO toDTO(Conversation c) {
List<ConversationMessageDTO> msgs = c.getMessages() == null
? List.of()
: c.getMessages().stream().map(this::toMessageDTO).collect(Collectors.toList());
return ConversationDTO.builder()
.id(c.getId())
.title(c.getTitle())
.loreId(c.getLoreId())
.campaignId(c.getCampaignId())
.entityType(c.getEntityType())
.entityId(c.getEntityId())
.createdAt(c.getCreatedAt())
.updatedAt(c.getUpdatedAt())
.messages(msgs)
.build();
}
/** Variante listing : pas de messages pour alleger la payload. */
public ConversationDTO toListDTO(Conversation c) {
return ConversationDTO.builder()
.id(c.getId())
.title(c.getTitle())
.loreId(c.getLoreId())
.campaignId(c.getCampaignId())
.entityType(c.getEntityType())
.entityId(c.getEntityId())
.createdAt(c.getCreatedAt())
.updatedAt(c.getUpdatedAt())
.messages(null)
.build();
}
public ConversationMessageDTO toMessageDTO(ConversationMessage m) {
return ConversationMessageDTO.builder()
.id(m.getId())
.role(m.getRole())
.content(m.getContent())
.createdAt(m.getCreatedAt())
.build();
}
}

View File

@@ -41,6 +41,9 @@ public class SceneMapper {
dto.setIllustrationImageIds(scene.getIllustrationImageIds() != null
? new ArrayList<>(scene.getIllustrationImageIds())
: new ArrayList<>());
dto.setMapImageIds(scene.getMapImageIds() != null
? new ArrayList<>(scene.getMapImageIds())
: new ArrayList<>());
dto.setBranches(toBranchDTOs(scene.getBranches()));
return dto;
}
@@ -70,6 +73,9 @@ public class SceneMapper {
.illustrationImageIds(dto.getIllustrationImageIds() != null
? new ArrayList<>(dto.getIllustrationImageIds())
: new ArrayList<>())
.mapImageIds(dto.getMapImageIds() != null
? new ArrayList<>(dto.getMapImageIds())
: new ArrayList<>())
.branches(toBranchDomain(dto.getBranches()))
.build();
}

View File

@@ -1,6 +1,7 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
import org.springframework.stereotype.Component;
@@ -11,6 +12,8 @@ import org.springframework.stereotype.Component;
* <p>
* Tolerance : un type inconnu recu du client est interprete comme TEXT
* (plus safe que de rejeter la requete et d'interrompre la sauvegarde).
* Un layout inconnu ou absent sur un champ IMAGE est interprete comme GALLERY.
* Le layout est force a null pour les champs TEXT.
*/
@Component
public class TemplateFieldMapper {
@@ -18,7 +21,12 @@ public class TemplateFieldMapper {
public TemplateFieldDTO toDTO(TemplateField field) {
if (field == null) return null;
String typeStr = field.getType() != null ? field.getType().name() : FieldType.TEXT.name();
return new TemplateFieldDTO(field.getName(), typeStr);
String layoutStr = null;
if (field.getType() == FieldType.IMAGE) {
ImageLayout layout = field.getLayout() != null ? field.getLayout() : ImageLayout.GALLERY;
layoutStr = layout.name();
}
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr);
}
public TemplateField toDomain(TemplateFieldDTO dto) {
@@ -29,6 +37,16 @@ public class TemplateFieldMapper {
} catch (IllegalArgumentException ex) {
type = FieldType.TEXT;
}
return new TemplateField(dto.getName(), type);
ImageLayout layout = null;
if (type == FieldType.IMAGE) {
try {
layout = dto.getLayout() != null
? ImageLayout.valueOf(dto.getLayout())
: ImageLayout.GALLERY;
} catch (IllegalArgumentException ex) {
layout = ImageLayout.GALLERY;
}
}
return new TemplateField(dto.getName(), type, layout);
}
}

View File

@@ -5,6 +5,7 @@ import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -46,6 +47,7 @@ public class StreamChatForCampaignUseCaseTest {
private CampaignStructuralContext campaignCtx;
private List<ChatMessage> messages;
private Consumer<ChatUsage> onUsage;
private Consumer<String> onToken;
private Runnable onComplete;
private Consumer<Throwable> onError;
@@ -57,6 +59,7 @@ public class StreamChatForCampaignUseCaseTest {
.campaignName("X").campaignDescription("d")
.build();
messages = List.of();
onUsage = mock(Consumer.class);
onToken = mock(Consumer.class);
onComplete = mock(Runnable.class);
onError = mock(Consumer.class);
@@ -67,7 +70,7 @@ public class StreamChatForCampaignUseCaseTest {
when(campaignRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> useCase.execute("missing", null, null, messages, onToken, onComplete, onError));
() -> useCase.execute("missing", null, null, messages, onUsage, onToken, onComplete, onError));
verifyNoInteractions(aiChatProvider);
}
@@ -77,10 +80,10 @@ public class StreamChatForCampaignUseCaseTest {
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
useCase.execute("c-1", null, null, messages, onToken, onComplete, onError);
useCase.execute("c-1", null, null, messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onToken), eq(onComplete), eq(onError));
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue();
assertSame(campaignCtx, req.getCampaignContext());
assertNull(req.getLoreContext());
@@ -100,10 +103,10 @@ public class StreamChatForCampaignUseCaseTest {
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
when(loreContextBuilder.buildOptional("lore-1")).thenReturn(Optional.of(loreCtx));
useCase.execute("c-1", null, null, messages, onToken, onComplete, onError);
useCase.execute("c-1", null, null, messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertSame(loreCtx, captor.getValue().getLoreContext());
}
@@ -115,10 +118,10 @@ public class StreamChatForCampaignUseCaseTest {
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
when(loreContextBuilder.buildOptional("lore-ghost")).thenReturn(Optional.empty());
useCase.execute("c-1", null, null, messages, onToken, onComplete, onError);
useCase.execute("c-1", null, null, messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getLoreContext());
// La requete doit tout de meme partir (pas d'exception).
}
@@ -133,10 +136,10 @@ public class StreamChatForCampaignUseCaseTest {
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
when(narrativeEntityContextBuilder.build("scene", "s-1")).thenReturn(entity);
useCase.execute("c-1", "scene", "s-1", messages, onToken, onComplete, onError);
useCase.execute("c-1", "scene", "s-1", messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertSame(entity, captor.getValue().getNarrativeEntity());
}
@@ -146,10 +149,10 @@ public class StreamChatForCampaignUseCaseTest {
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
useCase.execute("c-1", "scene", " ", messages, onToken, onComplete, onError);
useCase.execute("c-1", "scene", " ", messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getNarrativeEntity());
verifyNoInteractions(narrativeEntityContextBuilder);
}

View File

@@ -2,6 +2,7 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.lorecontext.FieldType;
@@ -46,6 +47,7 @@ public class StreamChatForLoreUseCaseTest {
private LoreStructuralContext loreCtx;
private List<ChatMessage> messages;
private Consumer<ChatUsage> onUsage;
private Consumer<String> onToken;
private Runnable onComplete;
private Consumer<Throwable> onError;
@@ -58,6 +60,7 @@ public class StreamChatForLoreUseCaseTest {
.folders(Collections.emptyMap())
.build();
messages = List.of();
onUsage = mock(Consumer.class);
onToken = mock(Consumer.class);
onComplete = mock(Runnable.class);
onError = mock(Consumer.class);
@@ -67,10 +70,10 @@ public class StreamChatForLoreUseCaseTest {
void testExecute_NoPageId_SendsRequestWithoutPageContext() {
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
useCase.execute("lore-1", null, messages, onToken, onComplete, onError);
useCase.execute("lore-1", null, messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onToken), eq(onComplete), eq(onError));
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue();
assertSame(loreCtx, req.getLoreContext());
assertNull(req.getPageContext());
@@ -81,10 +84,10 @@ public class StreamChatForLoreUseCaseTest {
void testExecute_BlankPageId_TreatedAsNoPage() {
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
useCase.execute("lore-1", " ", messages, onToken, onComplete, onError);
useCase.execute("lore-1", " ", messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getPageContext());
verifyNoInteractions(pageRepository);
}
@@ -108,10 +111,10 @@ public class StreamChatForLoreUseCaseTest {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(tpl));
useCase.execute("lore-1", "p-1", messages, onToken, onComplete, onError);
useCase.execute("lore-1", "p-1", messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
ChatRequest req = captor.getValue();
assertNotNull(req.getPageContext());
assertEquals("Alice", req.getPageContext().getTitle());
@@ -130,10 +133,10 @@ public class StreamChatForLoreUseCaseTest {
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
useCase.execute("lore-1", "p-1", messages, onToken, onComplete, onError);
useCase.execute("lore-1", "p-1", messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext();
assertNotNull(pageCtx);
assertEquals("Orphan", pageCtx.getTitle());
@@ -153,10 +156,10 @@ public class StreamChatForLoreUseCaseTest {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-ghost")).thenReturn(Optional.empty());
useCase.execute("lore-1", "p-1", messages, onToken, onComplete, onError);
useCase.execute("lore-1", "p-1", messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext();
assertEquals("?", pageCtx.getTemplateName());
assertTrue(pageCtx.getTemplateFields().isEmpty());
@@ -168,7 +171,7 @@ public class StreamChatForLoreUseCaseTest {
when(pageRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> useCase.execute("lore-1", "missing", messages, onToken, onComplete, onError));
() -> useCase.execute("lore-1", "missing", messages, onUsage, onToken, onComplete, onError));
verifyNoInteractions(aiChatProvider);
}
}

View File

@@ -31,6 +31,11 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minioadmin}
volumes:
- minio-data:/data
# Mapping bind sur loopback pour autoriser un core/web lance en local (mode dev)
# a atteindre MinIO. Invisible sur le LAN donc non-exploitable depuis l'exterieur.
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
@@ -55,7 +60,7 @@ services:
"
core:
image: ${REGISTRY:-gitea.example.com}/ietm64/core:${TAG:-latest}
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/core:${TAG:-latest}
container_name: loremind-core
depends_on:
postgres:
@@ -77,7 +82,7 @@ services:
restart: unless-stopped
brain:
image: ${REGISTRY:-gitea.example.com}/ietm64/brain:${TAG:-latest}
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/brain:${TAG:-latest}
container_name: loremind-brain
environment:
LLM_PROVIDER: ${LLM_PROVIDER:-ollama}
@@ -95,7 +100,7 @@ services:
restart: unless-stopped
web:
image: ${REGISTRY:-gitea.example.com}/ietm64/web:${TAG:-latest}
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/web:${TAG:-latest}
container_name: loremind-web
depends_on:
- core

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.2.0",
"version": "0.4.0",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",

View File

@@ -18,15 +18,28 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Illustrations (galerie editable) -->
<!-- Illustrations (galerie editable, rendu editorial) -->
<div class="field">
<label>Illustrations</label>
<app-image-gallery
[imageIds]="illustrationImageIds"
[editable]="true"
[layout]="'EDITORIAL'"
(imageIdsChange)="illustrationImageIds = $event">
</app-image-gallery>
<small class="field-hint">Glisse-depose ou clique sur "+ Ajouter" pour uploader. JPEG, PNG, WebP ou GIF, 10 Mo max.</small>
<small class="field-hint">Ambiances, portraits, visuels evocateurs de l'arc. JPEG, PNG, WebP ou GIF, 10 Mo max.</small>
</div>
<!-- Cartes & plans -->
<div class="field">
<label>Cartes &amp; plans</label>
<app-image-gallery
[imageIds]="mapImageIds"
[editable]="true"
[layout]="'MAPS'"
(imageIdsChange)="mapImageIds = $event">
</app-image-gallery>
<small class="field-hint">Cartes regionales et plans utiles aux joueurs pour situer l'action.</small>
</div>
<div class="field">

View File

@@ -60,6 +60,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
/** IDs des images illustrant cet arc (bind sur app-image-gallery editable). */
illustrationImageIds: string[] = [];
/** IDs des images utilisees comme cartes / plans (outil de table). */
mapImageIds: string[] = [];
constructor(
private fb: FormBuilder,
@@ -119,6 +121,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])];
this.mapImageIds = [...(arc.mapImageIds ?? [])];
this.pageTitleService.set(arc.name);
this.form.patchValue({
name: arc.name,
@@ -161,7 +164,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
rewards: this.form.value.rewards,
resolution: this.form.value.resolution,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -13,9 +13,15 @@
</div>
</header>
<!-- Illustrations en tete de page (si presentes) -->
<!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(arc.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []"></app-image-gallery>
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
<!-- Cartes & plans -->
<section class="view-section" *ngIf="(arc.mapImageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
<section class="view-section">

View File

@@ -18,15 +18,28 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Illustrations (galerie editable) -->
<!-- Illustrations (galerie editable, rendu editorial) -->
<div class="field">
<label>Illustrations</label>
<app-image-gallery
[imageIds]="illustrationImageIds"
[editable]="true"
[layout]="'EDITORIAL'"
(imageIdsChange)="illustrationImageIds = $event">
</app-image-gallery>
<small class="field-hint">Ajoute des cartes, portraits ou ambiances pour illustrer ce chapitre.</small>
<small class="field-hint">Portraits, ambiances, scenes marquantes du chapitre.</small>
</div>
<!-- Cartes & plans -->
<div class="field">
<label>Cartes &amp; plans</label>
<app-image-gallery
[imageIds]="mapImageIds"
[editable]="true"
[layout]="'MAPS'"
(imageIdsChange)="mapImageIds = $event">
</app-image-gallery>
<small class="field-hint">Cartes regionales, plans de donjon, schemas utiles a la table.</small>
</div>
<div class="field">

View File

@@ -54,6 +54,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
loreId: string | null = null;
relatedPageIds: string[] = [];
illustrationImageIds: string[] = [];
mapImageIds: string[] = [];
constructor(
private fb: FormBuilder,
@@ -111,6 +112,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
this.mapImageIds = [...(chapter.mapImageIds ?? [])];
this.form.patchValue({
name: chapter.name,
description: chapter.description ?? '',
@@ -148,7 +150,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
playerObjectives: this.form.value.playerObjectives,
narrativeStakes: this.form.value.narrativeStakes,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -18,9 +18,15 @@
</div>
</header>
<!-- Illustrations -->
<!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(chapter.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []"></app-image-gallery>
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
<!-- Cartes & plans -->
<section class="view-section" *ngIf="(chapter.mapImageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="chapter.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
<section class="view-section">

View File

@@ -18,15 +18,28 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Illustrations (galerie editable) -->
<!-- Illustrations (galerie editable, rendu editorial) -->
<div class="field">
<label>Illustrations</label>
<app-image-gallery
[imageIds]="illustrationImageIds"
[editable]="true"
[layout]="'EDITORIAL'"
(imageIdsChange)="illustrationImageIds = $event">
</app-image-gallery>
<small class="field-hint">Carte du lieu, portrait des PNJ presents, ambiance visuelle...</small>
<small class="field-hint">Portraits des PNJ, ambiance visuelle, scenes evocatrices...</small>
</div>
<!-- Cartes & plans (galerie editable, rendu maps) -->
<div class="field">
<label>Cartes &amp; plans</label>
<app-image-gallery
[imageIds]="mapImageIds"
[editable]="true"
[layout]="'MAPS'"
(imageIdsChange)="mapImageIds = $event">
</app-image-gallery>
<small class="field-hint">Plans du lieu, cartes tactiques, schemas utilisables a la table.</small>
</div>
<div class="field">

View File

@@ -53,6 +53,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
loreId: string | null = null;
relatedPageIds: string[] = [];
illustrationImageIds: string[] = [];
mapImageIds: string[] = [];
/** Scènes du chapitre courant (hors scène éditée) — alimente le dropdown des cibles. */
siblingScenes: Scene[] = [];
@@ -129,6 +130,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
this.mapImageIds = [...(scene.mapImageIds ?? [])];
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
this.branches = (scene.branches ?? []).map(b => ({ ...b }));
this.form.patchValue({
@@ -179,6 +181,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
enemies: this.form.value.enemies,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds,
branches: this.branches
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),

View File

@@ -13,9 +13,15 @@
</div>
</header>
<!-- Illustrations -->
<!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(scene.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []"></app-image-gallery>
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
<!-- Cartes & plans -->
<section class="view-section" *ngIf="(scene.mapImageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="scene.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
<!-- Description courte -->

View File

@@ -78,6 +78,7 @@
<app-ai-chat-drawer
[loreId]="loreId"
[isOpen]="chatOpen"
[persistent]="false"
[welcomeMessage]="wizardWelcome"
[systemPromptAddon]="wizardSystemPrompt"
[quickSuggestions]="wizardSuggestions"

View File

@@ -65,6 +65,7 @@
<app-image-gallery
[imageIds]="imageValues[field.name] || []"
[editable]="true"
[layout]="field.layout ?? 'GALLERY'"
(imageIdsChange)="imageValues[field.name] = $event">
</app-image-gallery>
</div>

View File

@@ -28,7 +28,10 @@
</section>
<section class="view-section" *ngIf="field.type === 'IMAGE'">
<h2 class="view-section-title">{{ field.name }}</h2>
<app-image-gallery [imageIds]="imageIdsOf(field.name)"></app-image-gallery>
<app-image-gallery
[imageIds]="imageIdsOf(field.name)"
[layout]="field.layout ?? 'GALLERY'">
</app-image-gallery>
</section>
</ng-container>
</ng-container>

View File

@@ -37,7 +37,21 @@
<label class="section-label">Champs du template *</label>
<ul class="fields-list">
<li class="field-row" *ngFor="let f of fields; let i = index">
<li class="field-row" *ngFor="let f of fields; let i = index; let first = first; let last = last">
<div class="reorder-stack">
<button type="button" class="btn-icon btn-reorder"
(click)="moveField(i, -1)"
[disabled]="first"
aria-label="Monter d'un cran" title="Monter">
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon>
</button>
<button type="button" class="btn-icon btn-reorder"
(click)="moveField(i, 1)"
[disabled]="last"
aria-label="Descendre d'un cran" title="Descendre">
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
</button>
</div>
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
{{ f.name }}
@@ -49,6 +63,17 @@
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
</button>
<select *ngIf="f.type === 'IMAGE'"
class="layout-select"
[ngModel]="f.layout ?? 'GALLERY'"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="setFieldLayout(i, $event)"
title="Mise en page des images">
<option value="GALLERY">Grille</option>
<option value="HERO">Heros</option>
<option value="MASONRY">Mosaique</option>
<option value="CAROUSEL">Carrousel</option>
</select>
<button type="button" class="btn-icon" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>

View File

@@ -124,7 +124,8 @@
&:hover { background: #363650; color: white; }
}
.type-select {
.type-select,
.layout-select {
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
@@ -137,6 +138,12 @@
&:focus { outline: none; border-color: #6c63ff; }
}
.layout-select {
height: 28px;
font-size: 0.72rem;
padding: 0 0.45rem;
}
input {
flex: 1;
background: #1a1a2e;
@@ -153,6 +160,35 @@
}
&.add-row { margin-top: 0.5rem; }
.reorder-stack {
display: flex;
flex-direction: column;
gap: 2px;
.btn-reorder {
width: 22px;
height: 16px;
background: transparent;
color: #6b7280;
border: 1px solid #2a2a3d;
border-radius: 3px;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.12s, color 0.12s, border-color 0.12s;
&:hover:not(:disabled) {
background: #2a2a3d;
color: white;
border-color: #6c63ff;
}
&:disabled { opacity: 0.3; cursor: not-allowed; }
}
}
}
.btn-icon {

View File

@@ -2,13 +2,13 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon } from 'lucide-angular';
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { LoreNode } from '../../services/lore.model';
import { FieldType, TemplateField } from '../../services/template.model';
import { FieldType, ImageLayout, TemplateField } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
/**
@@ -29,6 +29,8 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly Type = Type;
readonly ImageIcon = ImageIcon;
readonly ChevronUp = ChevronUp;
readonly ChevronDown = ChevronDown;
form: FormGroup;
loreId = '';
@@ -75,7 +77,10 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
if (!name) return;
// Unicite par nom (on ignore le type pour eviter des collisions d'affichage).
if (this.fields.some(f => f.name === name)) return;
this.fields = [...this.fields, { name, type: this.newFieldType }];
const newField: TemplateField = this.newFieldType === 'IMAGE'
? { name, type: 'IMAGE', layout: 'GALLERY' }
: { name, type: 'TEXT' };
this.fields = [...this.fields, newField];
this.newFieldName = '';
// Le type reste sur la derniere valeur choisie : pratique pour enchainer
// plusieurs champs du meme type.
@@ -85,12 +90,33 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
this.fields = this.fields.filter((_, i) => i !== index);
}
/** Deplace un champ d'un cran vers le haut ou le bas. No-op aux bords. */
moveField(index: number, direction: -1 | 1): void {
const target = index + direction;
if (target < 0 || target >= this.fields.length) return;
const next = [...this.fields];
[next[index], next[target]] = [next[target], next[index]];
this.fields = next;
}
/** Bascule le type d'un champ existant (TEXT <-> IMAGE). */
toggleFieldType(index: number): void {
const field = this.fields[index];
if (!field) return;
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
this.fields = this.fields.map((f, i) => i === index ? { ...f, type: nextType } : f);
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. */
setFieldLayout(index: number, layout: ImageLayout): void {
this.fields = this.fields.map((f, i) =>
i === index && f.type === 'IMAGE' ? { ...f, layout } : f
);
}
submit(): void {

View File

@@ -43,7 +43,21 @@
<label class="section-label">Champs du template</label>
<ul class="fields-list">
<li class="field-row" *ngFor="let f of fields; let i = index">
<li class="field-row" *ngFor="let f of fields; let i = index; let first = first; let last = last">
<div class="reorder-stack">
<button type="button" class="btn-icon-ghost btn-reorder"
(click)="moveField(i, -1)"
[disabled]="first"
aria-label="Monter d'un cran" title="Monter">
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon>
</button>
<button type="button" class="btn-icon-ghost btn-reorder"
(click)="moveField(i, 1)"
[disabled]="last"
aria-label="Descendre d'un cran" title="Descendre">
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
</button>
</div>
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
{{ f.name }}
@@ -54,6 +68,17 @@
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
</button>
<select *ngIf="f.type === 'IMAGE'"
class="layout-select"
[ngModel]="f.layout ?? 'GALLERY'"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="setFieldLayout(i, $event)"
title="Mise en page des images">
<option value="GALLERY">Grille</option>
<option value="HERO">Heros</option>
<option value="MASONRY">Mosaique</option>
<option value="CAROUSEL">Carrousel</option>
</select>
<button type="button" class="btn-icon-ghost" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>

View File

@@ -125,7 +125,8 @@
&:hover { color: #a5b4fc; background: #1f1b3a; }
}
.type-select {
.type-select,
.layout-select {
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
@@ -138,6 +139,12 @@
&:focus { outline: none; border-color: #6c63ff; }
}
.layout-select {
height: 28px;
font-size: 0.72rem;
padding: 0 0.45rem;
}
input {
flex: 1;
background: #1a1a2e;
@@ -167,6 +174,35 @@
&:focus { border: none; }
}
}
.reorder-stack {
display: flex;
flex-direction: column;
gap: 2px;
.btn-reorder {
width: 22px;
height: 16px;
background: transparent;
color: #6b7280;
border: 1px solid #2a2a3d;
border-radius: 3px;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.12s, color 0.12s, border-color 0.12s;
&:hover:not(:disabled) {
background: #2a2a3d;
color: white;
border-color: #6c63ff;
}
&:disabled { opacity: 0.3; cursor: not-allowed; }
}
}
}
.btn-icon-ghost {

View File

@@ -3,14 +3,14 @@ import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, Plus, X, Trash2, Type, Image as ImageIcon } from 'lucide-angular';
import { LucideAngularModule, Plus, X, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { LoreNode } from '../../services/lore.model';
import { FieldType, Template, TemplateField } from '../../services/template.model';
import { FieldType, ImageLayout, Template, TemplateField } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
/**
@@ -30,6 +30,8 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly Type = Type;
readonly ImageIcon = ImageIcon;
readonly ChevronUp = ChevronUp;
readonly ChevronDown = ChevronDown;
form: FormGroup;
loreId = '';
@@ -75,10 +77,12 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
this.template = template;
// Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant,
// utile pour les templates legacy cote frontend meme si le backend le fait aussi).
this.fields = (template.fields ?? []).map(f => ({
name: f.name,
type: f.type === 'IMAGE' ? 'IMAGE' : 'TEXT'
}));
this.fields = (template.fields ?? []).map(f => {
const type: FieldType = f.type === 'IMAGE' ? 'IMAGE' : 'TEXT';
return type === 'IMAGE'
? { name: f.name, type, layout: f.layout ?? 'GALLERY' }
: { name: f.name, type };
});
this.form.patchValue({
name: template.name,
description: template.description,
@@ -91,7 +95,10 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
const name = this.newFieldName.trim();
if (!name) return;
if (this.fields.some(f => f.name === name)) return;
this.fields = [...this.fields, { name, type: this.newFieldType }];
const newField: TemplateField = this.newFieldType === 'IMAGE'
? { name, type: 'IMAGE', layout: 'GALLERY' }
: { name, type: 'TEXT' };
this.fields = [...this.fields, newField];
this.newFieldName = '';
}
@@ -99,12 +106,33 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
this.fields = this.fields.filter((_, i) => i !== index);
}
/** Deplace un champ d'un cran vers le haut ou le bas. No-op aux bords. */
moveField(index: number, direction: -1 | 1): void {
const target = index + direction;
if (target < 0 || target >= this.fields.length) return;
const next = [...this.fields];
[next[index], next[target]] = [next[target], next[index]];
this.fields = next;
}
/** Bascule le type d'un champ (TEXT <-> IMAGE). */
toggleFieldType(index: number): void {
const field = this.fields[index];
if (!field) return;
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
this.fields = this.fields.map((f, i) => i === index ? { ...f, type: nextType } : f);
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. */
setFieldLayout(index: number, layout: ImageLayout): void {
this.fields = this.fields.map((f, i) =>
i === index && f.type === 'IMAGE' ? { ...f, layout } : f
);
}
save(): void {

View File

@@ -16,7 +16,19 @@ export interface ChatMessage {
* - done : le stream s'est terminé proprement (l'observable va compléter).
* - error : une erreur s'est produite côté serveur (l'observable va erreur-compléter).
*/
/**
* Instantané d'occupation de la fenêtre de contexte (émis 1x par tour, avant le streaming).
* Les valeurs sont exprimées en tokens (~cl100k_base, ±10% vs tokenizer natif du modèle).
*/
export interface ChatUsage {
system: number;
history: number;
current: number;
max: number;
}
export type ChatStreamEvent =
| { type: 'usage'; usage: ChatUsage }
| { type: 'token'; value: string }
| { type: 'done' }
| { type: 'error'; message: string };
@@ -128,12 +140,19 @@ export class AiChatService {
const dispatchCurrentEvent = () => {
const eventName = currentEvent ?? 'message';
// DEBUG jauge de contexte — à retirer une fois stabilisé.
if (eventName !== 'message') {
console.log('[AiChatService] SSE event:', eventName, 'data:', currentData);
}
if (eventName === 'error') {
const message = this.safeParseMessage(currentData);
subscriber.error(new Error(message));
} else if (eventName === 'done') {
subscriber.next({ type: 'done' });
subscriber.complete();
} else if (eventName === 'usage') {
const usage = this.safeParseUsage(currentData);
if (usage) subscriber.next({ type: 'usage', usage });
} else {
// Événement 'message' (défaut) : JSON {"token": "..."}
const token = this.safeParseToken(currentData);
@@ -188,6 +207,23 @@ export class AiChatService {
}
}
private safeParseUsage(json: string): ChatUsage | null {
try {
const obj = JSON.parse(json) as Partial<ChatUsage>;
if (
typeof obj.system === 'number' &&
typeof obj.history === 'number' &&
typeof obj.current === 'number' &&
typeof obj.max === 'number'
) {
return { system: obj.system, history: obj.history, current: obj.current, max: obj.max };
}
return null;
} catch {
return null;
}
}
private safeParseMessage(json: string): string {
try {
const obj = JSON.parse(json) as { message?: string };

View File

@@ -36,8 +36,11 @@ export interface Arc {
/** IDs des pages du Lore liées à cet arc (weak cross-context refs). */
relatedPageIds?: string[];
/** IDs des images (Shared Kernel) illustrant cet arc. */
/** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */
illustrationImageIds?: string[];
/** IDs des images utilisees comme cartes / plans (outil de table). */
mapImageIds?: string[];
}
// Payload pour la création d'un Arc (pas d'id)
@@ -55,6 +58,7 @@ export interface ArcCreate {
relatedPageIds?: string[];
illustrationImageIds?: string[];
mapImageIds?: string[];
}
export interface Chapter {
@@ -71,6 +75,7 @@ export interface Chapter {
relatedPageIds?: string[];
illustrationImageIds?: string[];
mapImageIds?: string[];
}
export interface ChapterCreate {
@@ -85,6 +90,7 @@ export interface ChapterCreate {
relatedPageIds?: string[];
illustrationImageIds?: string[];
mapImageIds?: string[];
}
/**
@@ -116,6 +122,7 @@ export interface Scene {
relatedPageIds?: string[];
illustrationImageIds?: string[];
mapImageIds?: string[];
/** Sorties narratives (graphe intra-chapitre). */
branches?: SceneBranch[];
@@ -138,5 +145,6 @@ export interface SceneCreate {
relatedPageIds?: string[];
illustrationImageIds?: string[];
mapImageIds?: string[];
branches?: SceneBranch[];
}

View File

@@ -0,0 +1,35 @@
export type ConversationRole = 'user' | 'assistant' | 'system';
export interface ConversationMessage {
id?: string;
role: ConversationRole;
content: string;
createdAt?: string;
}
export interface Conversation {
id: string;
title: string;
loreId?: string | null;
campaignId?: string | null;
entityType?: string | null;
entityId?: string | null;
createdAt: string;
updatedAt: string;
messages?: ConversationMessage[];
}
/**
* Filtre strict pour le listing sidebar. Fournir soit loreId soit campaignId.
* entityType + entityId vont ensemble — tous deux null = niveau racine.
*/
export interface ConversationContext {
loreId?: string | null;
campaignId?: string | null;
entityType?: 'page' | 'arc' | 'chapter' | 'scene' | null;
entityId?: string | null;
}
export interface CreateConversationPayload extends ConversationContext {
title?: string;
}

View File

@@ -0,0 +1,64 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import {
Conversation,
ConversationContext,
ConversationMessage,
CreateConversationPayload,
} from './conversation.model';
/**
* Service HTTP des conversations persistees.
*
* Le streaming (chat/stream) reste pris en charge par AiChatService. Ce
* service ne gere que la persistance (metadonnees + messages) et
* l'auto-titre declenche apres le 1er echange.
*/
@Injectable({ providedIn: 'root' })
export class ConversationService {
private readonly apiUrl = 'http://localhost:8080/api/conversations';
constructor(private http: HttpClient) {}
list(ctx: ConversationContext): Observable<Conversation[]> {
let params = new HttpParams();
if (ctx.loreId) params = params.set('loreId', ctx.loreId);
if (ctx.campaignId) params = params.set('campaignId', ctx.campaignId);
if (ctx.entityType) params = params.set('entityType', ctx.entityType);
if (ctx.entityId) params = params.set('entityId', ctx.entityId);
return this.http.get<Conversation[]>(this.apiUrl, { params });
}
getById(id: string): Observable<Conversation> {
return this.http.get<Conversation>(`${this.apiUrl}/${id}`);
}
create(payload: CreateConversationPayload): Observable<Conversation> {
return this.http.post<Conversation>(this.apiUrl, payload);
}
rename(id: string, title: string): Observable<void> {
return this.http.patch<void>(`${this.apiUrl}/${id}/title`, { title });
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
appendMessage(
id: string,
role: 'user' | 'assistant' | 'system',
content: string,
): Observable<ConversationMessage> {
return this.http.post<ConversationMessage>(`${this.apiUrl}/${id}/messages`, {
role,
content,
});
}
/** Declenche la generation auto du titre cote Brain. */
autoTitle(id: string): Observable<{ title: string }> {
return this.http.post<{ title: string }>(`${this.apiUrl}/${id}/auto-title`, {});
}
}

View File

@@ -12,6 +12,7 @@ export interface AppSettings {
llm_model: string;
onemin_model: string;
onemin_api_key_set: boolean;
llm_num_ctx: number;
}
/**
@@ -24,6 +25,13 @@ export interface AppSettingsUpdate {
llm_model?: string;
onemin_model?: string;
onemin_api_key?: string;
llm_num_ctx?: number;
}
/** Metadonnees d'un modele Ollama (issues de /api/show). */
export interface OllamaModelInfo {
/** Fenetre de contexte max du modele (en tokens). 0 si inconnue. */
context_length: number;
}
@Injectable({ providedIn: 'root' })
@@ -49,6 +57,11 @@ export class SettingsService {
return this.http.get<{ models: string[] }>(`${this.apiUrl}/models/ollama`, this.authOptions);
}
getOllamaModelInfo(name: string): Observable<OllamaModelInfo> {
return this.http.post<OllamaModelInfo>(
`${this.apiUrl}/models/ollama/info`, { name }, this.authOptions);
}
listOneMinModels(): Observable<{ groups: OneMinModelGroup[] }> {
return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions);
}

View File

@@ -7,6 +7,16 @@
*/
export type FieldType = 'TEXT' | 'IMAGE';
/**
* Variante de rendu pour un champ IMAGE. Miroir de
* com.loremind.domain.lorecontext.ImageLayout. Ignore pour TEXT.
* - 'GALLERY' : grille de vignettes (defaut)
* - 'HERO' : premiere image en banniere, suivantes en petit
* - 'MASONRY' : mosaique hauteurs variables
* - 'CAROUSEL' : defilement horizontal
*/
export type ImageLayout = 'GALLERY' | 'HERO' | 'MASONRY' | 'CAROUSEL' | 'EDITORIAL' | 'MAPS';
/**
* Champ d'un Template : nom + type discriminant.
* Miroir de TemplateFieldDTO (backend).
@@ -14,6 +24,8 @@ export type FieldType = 'TEXT' | 'IMAGE';
export interface TemplateField {
name: string;
type: FieldType;
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
layout?: ImageLayout | null;
}
export interface Template {

View File

@@ -48,7 +48,7 @@
<div class="form-row">
<label for="ollama-model">Modele</label>
<div class="inline-select">
<select id="ollama-model" [(ngModel)]="settings.llm_model">
<select id="ollama-model" [(ngModel)]="settings.llm_model" (ngModelChange)="fetchOllamaModelInfo()">
<option *ngIf="ollamaModels.length === 0" [value]="settings.llm_model">{{ settings.llm_model }}</option>
<option *ngFor="let m of ollamaModels" [value]="m">{{ m }}</option>
</select>
@@ -93,6 +93,54 @@
</div>
</section>
<!-- Bloc Fenetre de contexte -->
<section class="card" *ngIf="settings">
<h2>Fenetre de contexte</h2>
<!-- Ollama : slider borne par le max du modele -->
<div class="form-row" *ngIf="settings.llm_provider === 'ollama'">
<label for="llm-num-ctx">
Tokens alloues au modele
<span class="ctx-value">{{ settings.llm_num_ctx | number }}</span>
<span class="ctx-max" *ngIf="ollamaModelMaxContext > 0">
/ {{ ollamaModelMaxContext | number }} max
</span>
</label>
<input
id="llm-num-ctx"
type="range"
[min]="CTX_MIN"
[max]="effectiveMaxContext"
step="1024"
[(ngModel)]="settings.llm_num_ctx"
class="ctx-slider">
<p class="hint" *ngIf="ollamaModelMaxContext > 0">
Le modele <strong>{{ settings.llm_model }}</strong> accepte jusqu'a
{{ ollamaModelMaxContext | number }} tokens. Plus la valeur est elevee, plus
l'IA peut tenir d'historique et de contexte — au prix de VRAM et de latence.
</p>
<p class="hint" *ngIf="ollamaModelMaxContext === 0">
Impossible de determiner la fenetre max du modele (Ollama injoignable ou modele
inconnu). Slider borne a {{ CTX_FALLBACK_MAX | number }} par securite.
</p>
</div>
<!-- 1min.ai : saisie libre (pas d'introspection possible) -->
<div class="form-row" *ngIf="settings.llm_provider === 'onemin'">
<label for="llm-num-ctx-onemin">Fenetre de contexte (tokens)</label>
<input
id="llm-num-ctx-onemin"
type="number"
min="2048"
step="1024"
[(ngModel)]="settings.llm_num_ctx">
<p class="hint">
A regler selon la capacite du modele 1min.ai choisi (ex: 128 000 pour gpt-4o,
200 000 pour claude-sonnet). Sert de plafond a la jauge de contexte du chat.
</p>
</div>
</section>
<div class="actions" *ngIf="settings">
<button class="btn-primary" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>

View File

@@ -136,3 +136,20 @@
}
.alert-error { background: rgba(220, 80, 80, 0.15); color: #ff9b9b; }
.alert-success { background: rgba(80, 200, 120, 0.15); color: #a2e8b6; }
/* --- Slider fenetre de contexte -------------------------------------- */
.ctx-value {
margin-left: 8px;
font-variant-numeric: tabular-nums;
color: #a5b4fc;
font-weight: 600;
}
.ctx-max {
color: #9ca3af;
font-size: 0.85em;
font-variant-numeric: tabular-nums;
}
.ctx-slider {
width: 100%;
accent-color: #6c63ff;
}

View File

@@ -42,6 +42,18 @@ export class SettingsComponent implements OnInit {
errorMessage = '';
successMessage = '';
/**
* Fenetre de contexte max supportee par le modele Ollama actuellement
* selectionne (extraite des metadonnees GGUF via /api/show). 0 si inconnue
* — dans ce cas on laisse un fallback de 131072 cote UI.
*/
ollamaModelMaxContext = 0;
/** Minimum raisonnable pour num_ctx (defaut Ollama = 2048). */
readonly CTX_MIN = 2048;
/** Fallback si Ollama ne renvoie pas le context_length (modele exotique). */
readonly CTX_FALLBACK_MAX = 131072;
/** Cle 1min.ai saisie — vide = on ne touche pas a la cle persistee. */
oneminApiKeyInput = '';
/** True si l'utilisateur a coche "effacer la cle". */
@@ -61,6 +73,7 @@ export class SettingsComponent implements OnInit {
next: (s) => {
this.settings = { ...s };
this.refreshModels();
this.fetchOllamaModelInfo();
},
error: (err) => this.errorMessage = this.extractError(err, 'Impossible de charger les parametres.')
});
@@ -99,6 +112,31 @@ export class SettingsComponent implements OnInit {
return group ? group.models : [];
}
/**
* Recupere la fenetre max supportee par le modele Ollama selectionne.
* Si la valeur courante de num_ctx depasse ce max, on la clamp.
*/
fetchOllamaModelInfo(): void {
if (!this.settings || this.settings.llm_provider !== 'ollama') return;
const modelName = this.settings.llm_model;
if (!modelName) return;
this.settingsService.getOllamaModelInfo(modelName).subscribe({
next: (info) => {
this.ollamaModelMaxContext = info.context_length;
const max = this.effectiveMaxContext;
if (this.settings && this.settings.llm_num_ctx > max) {
this.settings.llm_num_ctx = max;
}
},
error: () => this.ollamaModelMaxContext = 0
});
}
/** Max effectif a afficher pour le slider (modele Ollama ou fallback). */
get effectiveMaxContext(): number {
return this.ollamaModelMaxContext > 0 ? this.ollamaModelMaxContext : this.CTX_FALLBACK_MAX;
}
/** Quand on change de fournisseur, bascule automatiquement sur son premier modele. */
onProviderChange(): void {
if (!this.settings) return;
@@ -118,7 +156,8 @@ export class SettingsComponent implements OnInit {
llm_provider: this.settings.llm_provider,
ollama_base_url: this.settings.ollama_base_url,
llm_model: this.settings.llm_model,
onemin_model: this.settings.onemin_model
onemin_model: this.settings.onemin_model,
llm_num_ctx: this.settings.llm_num_ctx
};
if (this.clearApiKey) {
patch.onemin_api_key = '';

View File

@@ -1,81 +1,148 @@
<aside class="drawer" [class.drawer-open]="isOpen" aria-label="Assistant IA">
<aside class="drawer" [class.drawer-open]="isOpen" [class.with-sidebar]="persistent && sidebarOpen" aria-label="Assistant IA">
<header class="drawer-header">
<h2>Assistant IA</h2>
<button type="button" class="close-btn" (click)="onClose()" aria-label="Fermer">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</header>
<div #messagesContainer class="messages">
<!-- Message d'accueil (non-stocké dans `messages`, toujours visible tant que la conversation est vide). -->
<div class="msg msg-assistant" *ngIf="messages.length === 0 && !currentAssistantText">
{{ welcomeMessage }}
</div>
<!-- Historique -->
<ng-container *ngFor="let m of messages">
<div class="msg" [class.msg-user]="m.role === 'user'" [class.msg-assistant]="m.role === 'assistant'">
{{ m.content }}
</div>
</ng-container>
<!-- Bulle en cours de streaming -->
<div class="msg msg-assistant msg-streaming" *ngIf="currentAssistantText">
{{ currentAssistantText }}<span class="caret"></span>
</div>
<!-- Indicateur pendant la phase "en train de réfléchir" (avant le premier token) -->
<div class="typing-indicator" *ngIf="isStreaming && !currentAssistantText" aria-live="polite">
<span></span><span></span><span></span>
</div>
<!-- Erreur locale au drawer -->
<div class="msg msg-error" *ngIf="errorMessage" role="alert">
{{ errorMessage }}
</div>
</div>
<!-- Action primaire (optionnelle) : ne passe PAS par le chat -->
<div class="primary-action" *ngIf="primaryAction">
<button
type="button"
class="primary-btn"
(click)="onPrimaryAction()"
[disabled]="isStreaming">
<lucide-icon [img]="Wand2" [size]="14"></lucide-icon>
{{ primaryAction.label }}
</button>
</div>
<!-- Suggestions rapides -->
<div class="quick-suggestions" *ngIf="quickSuggestions.length">
<p class="quick-label">Suggestions rapides :</p>
<div class="quick-list">
<button
type="button"
class="quick-btn"
*ngFor="let s of quickSuggestions"
(click)="useQuickSuggestion(s)"
[disabled]="isStreaming">
<lucide-icon [img]="Lightbulb" [size]="12"></lucide-icon>
{{ s }}
<!-- Sidebar conversations (mode persistent uniquement) -->
<section class="conv-sidebar" *ngIf="persistent && sidebarOpen" aria-label="Conversations">
<div class="conv-sidebar-header">
<span class="conv-sidebar-title">Conversations</span>
<button type="button" class="conv-new-btn" (click)="startNewConversation()" [disabled]="isStreaming" title="Nouvelle conversation">
<lucide-icon [img]="MessageSquarePlus" [size]="16"></lucide-icon>
</button>
</div>
</div>
<ul class="conv-list">
<li *ngIf="conversations.length === 0" class="conv-empty">Aucune conversation</li>
<li
*ngFor="let c of conversations"
class="conv-item"
[class.active]="c.id === currentConversationId"
(click)="selectConversation(c)">
<span class="conv-item-title">{{ c.title }}</span>
<button
type="button"
class="conv-item-del"
(click)="deleteConversation(c, $event)"
[disabled]="isStreaming"
title="Supprimer">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
</li>
</ul>
</section>
<!-- Zone de saisie -->
<form class="input-row" (ngSubmit)="send()">
<input
type="text"
[(ngModel)]="input"
name="chatInput"
placeholder="Posez une question..."
[disabled]="isStreaming"
autocomplete="off" />
<button type="submit" class="send-btn" [disabled]="!input.trim() || isStreaming" aria-label="Envoyer">
<lucide-icon [img]="Send" [size]="16"></lucide-icon>
</button>
</form>
<section class="conv-main">
<header class="drawer-header">
<button
*ngIf="persistent"
type="button"
class="sidebar-toggle"
(click)="toggleSidebar()"
[attr.aria-label]="sidebarOpen ? 'Masquer la liste' : 'Afficher la liste'"
[title]="sidebarOpen ? 'Masquer la liste' : 'Afficher la liste'">
<lucide-icon [img]="sidebarOpen ? PanelLeftClose : PanelLeftOpen" [size]="16"></lucide-icon>
</button>
<div class="header-title-wrap">
<ng-container *ngIf="persistent && currentConversationId; else defaultTitle">
<ng-container *ngIf="!editingTitle; else editingTpl">
<h2 class="header-title" [title]="currentTitle">{{ currentTitle || 'Nouvelle conversation' }}</h2>
<button type="button" class="rename-btn" (click)="startRenameTitle()" title="Renommer" aria-label="Renommer">
<lucide-icon [img]="Pencil" [size]="12"></lucide-icon>
</button>
</ng-container>
<ng-template #editingTpl>
<input
class="rename-input"
type="text"
[(ngModel)]="titleDraft"
(keyup.enter)="submitRenameTitle()"
(keyup.escape)="cancelRenameTitle()"
(blur)="submitRenameTitle()"
autofocus />
</ng-template>
</ng-container>
<ng-template #defaultTitle>
<h2 class="header-title">Assistant IA</h2>
</ng-template>
</div>
<button type="button" class="close-btn" (click)="onClose()" aria-label="Fermer">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</header>
<div class="context-gauge" *ngIf="usage" [attr.data-level]="usageLevel"
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + ' / ' + usage.max + ' tokens'">
<div class="gauge-bar">
<div class="gauge-fill" [style.width.%]="usagePercent"></div>
</div>
<div class="gauge-label">
<span class="gauge-text">Contexte : {{ usageTotal }} / {{ usage.max }} tokens</span>
<span class="gauge-percent">{{ usagePercent }}%</span>
</div>
</div>
<div #messagesContainer class="messages">
<div class="msg msg-assistant" *ngIf="messages.length === 0 && !currentAssistantText">
{{ welcomeMessage }}
</div>
<ng-container *ngFor="let m of messages">
<div class="msg" [class.msg-user]="m.role === 'user'" [class.msg-assistant]="m.role === 'assistant'">
{{ m.content }}
</div>
</ng-container>
<div class="msg msg-assistant msg-streaming" *ngIf="currentAssistantText">
{{ currentAssistantText }}<span class="caret"></span>
</div>
<div class="typing-indicator" *ngIf="isStreaming && !currentAssistantText" aria-live="polite">
<span></span><span></span><span></span>
</div>
<div class="msg msg-error" *ngIf="errorMessage" role="alert">
{{ errorMessage }}
</div>
</div>
<div class="primary-action" *ngIf="primaryAction">
<button
type="button"
class="primary-btn"
(click)="onPrimaryAction()"
[disabled]="isStreaming">
<lucide-icon [img]="Wand2" [size]="14"></lucide-icon>
{{ primaryAction.label }}
</button>
</div>
<div class="quick-suggestions" *ngIf="quickSuggestions.length">
<p class="quick-label">Suggestions rapides :</p>
<div class="quick-list">
<button
type="button"
class="quick-btn"
*ngFor="let s of quickSuggestions"
(click)="useQuickSuggestion(s)"
[disabled]="isStreaming">
<lucide-icon [img]="Lightbulb" [size]="12"></lucide-icon>
{{ s }}
</button>
</div>
</div>
<form class="input-row" (ngSubmit)="send()">
<input
type="text"
[(ngModel)]="input"
name="chatInput"
placeholder="Posez une question..."
[disabled]="isStreaming"
autocomplete="off" />
<button type="submit" class="send-btn" [disabled]="!input.trim() || isStreaming" aria-label="Envoyer">
<lucide-icon [img]="Send" [size]="16"></lucide-icon>
</button>
</form>
</section>
</aside>

View File

@@ -12,13 +12,173 @@
background: #0f0f1a;
border-left: 1px solid #1e1e3a;
display: flex;
flex-direction: column;
flex-direction: row;
transform: translateX(100%);
transition: transform 0.25s ease;
transition: transform 0.25s ease, width 0.25s ease;
z-index: 1000;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.4);
}
.drawer.with-sidebar {
width: 600px;
}
.conv-sidebar {
width: 220px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: #0b0b15;
border-right: 1px solid #1e1e3a;
}
.conv-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid #1e1e3a;
.conv-sidebar-title {
font-size: 0.78rem;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.conv-new-btn {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0.3rem;
border-radius: 4px;
display: flex;
&:hover:not(:disabled) {
background: #1e1e3a;
color: white;
}
&:disabled { opacity: 0.4; cursor: not-allowed; }
}
}
.conv-list {
list-style: none;
margin: 0;
padding: 0.4rem 0;
overflow-y: auto;
flex: 1;
}
.conv-empty {
padding: 1rem 0.9rem;
font-size: 0.78rem;
color: #6b7280;
font-style: italic;
}
.conv-item {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.9rem;
cursor: pointer;
font-size: 0.82rem;
color: #d1d5db;
border-left: 2px solid transparent;
&:hover {
background: #14142a;
}
&.active {
background: #1a1a2e;
border-left-color: #6c63ff;
color: white;
}
.conv-item-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-item-del {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.2rem;
border-radius: 3px;
display: flex;
opacity: 0;
transition: opacity 0.15s;
&:hover:not(:disabled) { color: #f87171; background: #1f0f0f; }
}
&:hover .conv-item-del { opacity: 1; }
&.active .conv-item-del { opacity: 1; }
}
.conv-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.sidebar-toggle {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0.3rem;
border-radius: 4px;
display: flex;
&:hover { background: #1e1e3a; color: white; }
}
.header-title-wrap {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.35rem;
.header-title {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.rename-btn {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.2rem;
border-radius: 3px;
display: flex;
&:hover { color: white; background: #1e1e3a; }
}
.rename-input {
flex: 1;
background: #1a1a2e;
border: 1px solid #6c63ff;
color: white;
padding: 0.3rem 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
font-family: inherit;
outline: none;
}
}
.drawer-open {
transform: translateX(0);
}
@@ -259,3 +419,44 @@
}
}
}
/* --- Jauge de contexte ------------------------------------------------- */
.context-gauge {
padding: 0.5rem 1rem 0.75rem;
border-bottom: 1px solid #1e1e3a;
background: #141428;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.context-gauge .gauge-bar {
height: 6px;
border-radius: 3px;
background: #2a2a45;
overflow: hidden;
}
.context-gauge .gauge-fill {
height: 100%;
transition: width 0.3s ease, background-color 0.3s ease;
border-radius: 3px;
}
.context-gauge[data-level="low"] .gauge-fill { background: #10b981; }
.context-gauge[data-level="mid"] .gauge-fill { background: #f59e0b; }
.context-gauge[data-level="high"] .gauge-fill { background: #ef4444; }
.context-gauge .gauge-label {
display: flex;
justify-content: space-between;
font-size: 0.72rem;
color: #9ca3af;
font-variant-numeric: tabular-nums;
}
.context-gauge[data-level="high"] .gauge-percent {
color: #f87171;
font-weight: 600;
}

View File

@@ -1,105 +1,246 @@
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, X, Send, Sparkles, Lightbulb, Wand2 } from 'lucide-angular';
import { LucideAngularModule, Lightbulb, MessageSquarePlus, PanelLeftClose, PanelLeftOpen, Pencil, Send, Sparkles, Trash2, Wand2, X } from 'lucide-angular';
import { Subscription } from 'rxjs';
import { AiChatService, ChatMessage, NarrativeEntityType } from '../../services/ai-chat.service';
import { AiChatService, ChatMessage, ChatUsage, NarrativeEntityType } from '../../services/ai-chat.service';
import { Conversation, ConversationContext } from '../../services/conversation.model';
import { ConversationService } from '../../services/conversation.service';
/**
* Action primaire optionnelle rendue en gros bouton au-dessus des suggestions.
* Utilisée pour les actions "spéciales" qui NE passent PAS par le chat
* (ex: "Remplir automatiquement tous les champs" → déclenche le one-shot b4).
* Utilisee pour les actions "speciales" qui NE passent PAS par le chat
* (ex: "Remplir automatiquement tous les champs" → declenche le one-shot b4).
*/
export interface ChatPrimaryAction {
label: string;
}
/**
* Drawer de chat IA réutilisable — panneau fixe à droite de l'écran.
* Drawer de chat IA reutilisable — panneau fixe a droite de l'ecran.
*
* Usage minimal :
* <app-ai-chat-drawer
* [loreId]="loreId"
* [isOpen]="chatOpen"
* [quickSuggestions]="['Développe l'histoire', ...]"
* (close)="chatOpen = false">
* </app-ai-chat-drawer>
*
* Contrainte de design : conversation éphémère (on perd tout à la fermeture
* ou à la destruction du composant — choix MVP assumé).
* Deux modes :
* - `persistent = true` (defaut) : sidebar + conversations persistees en base,
* filtrees par contexte (loreId/campaignId + optionnellement entityType+Id).
* Les messages sont persistes en base au fil du chat et un titre automatique
* est genere apres le 1er echange.
* - `persistent = false` : mode ephemere (pour le wizard de generation de page,
* ou la conversation n'a aucune valeur au-dela de l'usage immediat).
*/
@Component({
selector: 'app-ai-chat-drawer',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './ai-chat-drawer.component.html',
styleUrls: ['./ai-chat-drawer.component.scss']
styleUrls: ['./ai-chat-drawer.component.scss'],
})
export class AiChatDrawerComponent implements OnDestroy {
export class AiChatDrawerComponent implements OnChanges, OnDestroy {
readonly X = X;
readonly Send = Send;
readonly Sparkles = Sparkles;
readonly Lightbulb = Lightbulb;
readonly Wand2 = Wand2;
readonly MessageSquarePlus = MessageSquarePlus;
readonly PanelLeftClose = PanelLeftClose;
readonly PanelLeftOpen = PanelLeftOpen;
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
/**
* Mode Lore : fournir `loreId` (et optionnellement `pageId`).
* Mode Campagne : fournir `campaignId` (et optionnellement `entityType`+`entityId`).
* Les deux modes sont exclusifs — si `campaignId` est non-vide, on route
* vers l'endpoint Campagne, sinon vers l'endpoint Lore.
*/
@Input() loreId = '';
/**
* Optionnel : ID d'une page précise en cours d'édition. Si fourni, le
* backend focalise l'IA sur cette page (template, champs, valeurs) via
* un bloc "PAGE EN COURS" dans le system prompt. Sans cet ID, le chat
* reste générique au Lore.
*/
@Input() pageId: string | null = null;
/** ID de la Campagne — active le mode chat Campagne si non-vide. */
@Input() campaignId: string | null = null;
/** Optionnel : "arc"|"chapter"|"scene" — focalise l'IA sur une entité narrative. */
@Input() entityType: NarrativeEntityType | null = null;
/** Optionnel : ID de l'entité narrative en cours d'édition. */
@Input() entityId: string | null = null;
@Input() isOpen = false;
/** Texte accueil affiché au premier ouverture (avant tout échange). */
@Input() welcomeMessage = 'Bonjour ! Je peux vous aider à développer cette page. Que souhaitez-vous créer ?';
/** Suggestions rapides cliquables en bas (hardcodées par le parent, MVP). */
@Input() welcomeMessage = 'Bonjour ! Je peux vous aider a developper cette page. Que souhaitez-vous creer ?';
@Input() quickSuggestions: string[] = [];
/** Action primaire optionnelle (ex: "Remplir automatiquement") — ne passe PAS par le chat. */
@Input() primaryAction: ChatPrimaryAction | null = null;
/**
* Instructions système supplémentaires injectées en tête de la conversation
* envoyée au backend, INVISIBLES côté UI. Usage : mode wizard, où on veut
* contextualiser l'IA (template cible, format JSON attendu) sans polluer
* l'historique visuel.
*/
@Input() systemPromptAddon: string | null = null;
/** Persistance activee ? false = mode wizard ephemere. */
@Input() persistent = true;
@Output() close = new EventEmitter<void>();
/** Émis au clic sur l'action primaire — le parent gère entièrement (one-shot, etc.). */
@Output() primaryActionClick = new EventEmitter<void>();
/** Émis à chaque fin de réponse assistant — utile pour parser côté parent (ex: bloc <values> du wizard). */
@Output() assistantReply = new EventEmitter<string>();
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
/** Conversation en cours (user + assistant). Le welcome n'est pas dedans — rendu séparément. */
messages: ChatMessage[] = [];
/** Texte en cours de streaming (écrit token par token, pas encore poussé dans `messages`). */
currentAssistantText = '';
/** Champ de saisie. */
input = '';
/** Stream en cours ? Désactive le bouton envoyer + les suggestions rapides. */
isStreaming = false;
/** Dernier message d'erreur (affiché dans une bannière locale au drawer). */
errorMessage: string | null = null;
usage: ChatUsage | null = null;
// --- Persistance --------------------------------------------------------
/** Liste visible dans la sidebar pour le contexte courant. */
conversations: Conversation[] = [];
/** Conversation actuellement chargee (null = nouvelle conversation vierge). */
currentConversationId: string | null = null;
/** Titre de la conversation courante (affiche dans le header). */
currentTitle = '';
/** Mode edition inline du titre. */
editingTitle = false;
titleDraft = '';
/** Etat repliable de la sidebar. */
sidebarOpen = true;
private streamSub: Subscription | null = null;
constructor(private readonly chatService: AiChatService) {}
constructor(
private readonly chatService: AiChatService,
private readonly conversationService: ConversationService,
) {}
// --- Jauge de contexte --------------------------------------------------
get usageTotal(): number {
if (!this.usage) return 0;
return this.usage.system + this.usage.history + this.usage.current;
}
get usageRatio(): number {
if (!this.usage || this.usage.max <= 0) return 0;
return Math.min(1, this.usageTotal / this.usage.max);
}
get usagePercent(): number {
return Math.round(this.usageRatio * 100);
}
get usageLevel(): 'low' | 'mid' | 'high' {
const r = this.usageRatio;
if (r > 0.8) return 'high';
if (r >= 0.5) return 'mid';
return 'low';
}
// --- Cycle de vie -------------------------------------------------------
ngOnChanges(changes: SimpleChanges): void {
if (!this.persistent) return;
const contextChanged =
changes['loreId'] || changes['pageId'] || changes['campaignId'] || changes['entityType'] || changes['entityId'];
const openedNow = changes['isOpen'] && this.isOpen;
if (contextChanged || openedNow) {
this.resetConversationState();
this.reloadConversations();
}
}
ngOnDestroy(): void {
this.abortStream();
}
// --- Sidebar : listing / nouveau / select / rename / delete ------------
private buildContext(): ConversationContext {
// Cote Lore : pageId joue le role de focus entite (entityType="page").
// Cote Campagne : entityType + entityId sont deja fournis directement.
if (this.loreId) {
return {
loreId: this.loreId,
campaignId: null,
entityType: this.pageId ? 'page' : null,
entityId: this.pageId ?? null,
};
}
return {
loreId: null,
campaignId: this.campaignId || null,
entityType: this.entityType,
entityId: this.entityId,
};
}
reloadConversations(): void {
if (!this.persistent) return;
const ctx = this.buildContext();
if (!ctx.loreId && !ctx.campaignId) {
this.conversations = [];
return;
}
this.conversationService.list(ctx).subscribe({
next: (rows) => (this.conversations = rows),
error: () => (this.conversations = []),
});
}
startNewConversation(): void {
if (this.isStreaming) return;
this.resetConversationState();
}
private resetConversationState(): void {
this.currentConversationId = null;
this.currentTitle = '';
this.messages = [];
this.currentAssistantText = '';
this.errorMessage = null;
this.usage = null;
this.editingTitle = false;
}
selectConversation(conv: Conversation): void {
if (this.isStreaming) return;
this.conversationService.getById(conv.id).subscribe({
next: (full) => {
this.currentConversationId = full.id;
this.currentTitle = full.title;
this.messages = (full.messages ?? [])
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }));
this.currentAssistantText = '';
this.errorMessage = null;
this.usage = null;
this.scrollToBottom();
},
error: () => (this.errorMessage = 'Impossible de charger la conversation.'),
});
}
deleteConversation(conv: Conversation, event: Event): void {
event.stopPropagation();
if (this.isStreaming) return;
if (!confirm(`Supprimer la conversation "${conv.title}" ?`)) return;
this.conversationService.delete(conv.id).subscribe({
next: () => {
this.conversations = this.conversations.filter((c) => c.id !== conv.id);
if (this.currentConversationId === conv.id) this.resetConversationState();
},
});
}
startRenameTitle(): void {
if (!this.currentConversationId) return;
this.titleDraft = this.currentTitle;
this.editingTitle = true;
}
cancelRenameTitle(): void {
this.editingTitle = false;
this.titleDraft = '';
}
submitRenameTitle(): void {
const t = this.titleDraft.trim();
if (!t || !this.currentConversationId) {
this.cancelRenameTitle();
return;
}
const id = this.currentConversationId;
this.conversationService.rename(id, t).subscribe({
next: () => {
this.currentTitle = t;
this.conversations = this.conversations.map((c) =>
c.id === id ? { ...c, title: t } : c,
);
this.editingTitle = false;
},
});
}
toggleSidebar(): void {
this.sidebarOpen = !this.sidebarOpen;
}
// --- Handlers UI --------------------------------------------------------
@@ -108,7 +249,6 @@ export class AiChatDrawerComponent implements OnDestroy {
this.close.emit();
}
/** Envoi explicite depuis le formulaire (Entrée ou bouton envoyer). */
send(): void {
const text = this.input.trim();
if (!text || this.isStreaming) return;
@@ -116,45 +256,114 @@ export class AiChatDrawerComponent implements OnDestroy {
this.input = '';
}
/** Envoi depuis une suggestion rapide (bouton cliquable en bas). */
useQuickSuggestion(suggestion: string): void {
if (this.isStreaming) return;
this.sendUserMessage(suggestion);
}
/** Clic sur l'action primaire — on délègue entièrement au parent. */
onPrimaryAction(): void {
if (this.isStreaming) return;
this.primaryActionClick.emit();
}
// --- Logique envoi + streaming -----------------------------------------
// --- Envoi + streaming --------------------------------------------------
private sendUserMessage(text: string): void {
if (this.persistent) {
this.ensureConversation().then((convId) => {
if (convId) this.streamAndPersist(text, convId);
});
} else {
this.streamEphemeral(text);
}
}
/**
* Cree la conversation cote serveur si elle n'existe pas encore. Resolu
* avec l'id, ou null sur erreur (auquel cas on n'envoie pas).
*/
private ensureConversation(): Promise<string | null> {
if (this.currentConversationId) return Promise.resolve(this.currentConversationId);
const ctx = this.buildContext();
if (!ctx.loreId && !ctx.campaignId) {
this.errorMessage = 'Contexte manquant pour creer une conversation.';
return Promise.resolve(null);
}
return new Promise((resolve) => {
this.conversationService.create(ctx).subscribe({
next: (conv) => {
this.currentConversationId = conv.id;
this.currentTitle = conv.title;
this.conversations = [conv, ...this.conversations];
resolve(conv.id);
},
error: () => {
this.errorMessage = 'Impossible de creer la conversation.';
resolve(null);
},
});
});
}
private streamAndPersist(text: string, convId: string): void {
const wasEmpty = this.messages.length === 0;
this.errorMessage = null;
this.messages.push({ role: 'user', content: text });
this.currentAssistantText = '';
this.isStreaming = true;
this.scrollToBottom();
// Construit la liste effectivement envoyée au backend : systemPromptAddon
// (si fourni) préfixé, puis l'historique visible. Le system n'est PAS stocké
// dans this.messages → reste invisible côté UI.
const payload = this.systemPromptAddon
? [{ role: 'system' as const, content: this.systemPromptAddon }, ...this.messages]
: this.messages;
// Persiste le message user immediatement — evite toute perte si stream interrompu.
this.conversationService.appendMessage(convId, 'user', text).subscribe({ error: () => {} });
const stream$ = this.campaignId
? this.chatService.streamChatForCampaign(this.campaignId, payload, this.entityType, this.entityId)
: this.chatService.streamChat(this.loreId, payload, this.pageId);
this.streamSub = stream$.subscribe({
this.streamSub = this.buildStream().subscribe({
next: (event) => {
if (event.type === 'token') {
this.currentAssistantText += event.value;
this.scrollToBottom();
} else if (event.type === 'usage') {
this.usage = event.usage;
}
},
error: (err) => {
this.isStreaming = false;
this.errorMessage = err?.message ?? 'Erreur inconnue.';
this.currentAssistantText = '';
},
complete: () => {
const reply = this.currentAssistantText;
if (reply) {
this.messages.push({ role: 'assistant', content: reply });
this.assistantReply.emit(reply);
this.conversationService.appendMessage(convId, 'assistant', reply).subscribe({
next: () => {
if (wasEmpty) this.triggerAutoTitle(convId);
},
error: () => {},
});
}
this.currentAssistantText = '';
this.isStreaming = false;
this.scrollToBottom();
},
});
}
private streamEphemeral(text: string): void {
this.errorMessage = null;
this.messages.push({ role: 'user', content: text });
this.currentAssistantText = '';
this.isStreaming = true;
this.scrollToBottom();
this.streamSub = this.buildStream().subscribe({
next: (event) => {
if (event.type === 'token') {
this.currentAssistantText += event.value;
this.scrollToBottom();
} else if (event.type === 'usage') {
this.usage = event.usage;
}
// 'done' : l'Observable va compléter → géré par complete()
},
error: (err) => {
this.isStreaming = false;
@@ -162,7 +371,6 @@ export class AiChatDrawerComponent implements OnDestroy {
this.currentAssistantText = '';
},
complete: () => {
// On fige le texte streamé en message assistant réel, puis on reset le buffer.
const reply = this.currentAssistantText;
if (reply) {
this.messages.push({ role: 'assistant', content: reply });
@@ -171,7 +379,28 @@ export class AiChatDrawerComponent implements OnDestroy {
this.currentAssistantText = '';
this.isStreaming = false;
this.scrollToBottom();
}
},
});
}
private buildStream() {
const payload = this.systemPromptAddon
? [{ role: 'system' as const, content: this.systemPromptAddon }, ...this.messages]
: this.messages;
return this.campaignId
? this.chatService.streamChatForCampaign(this.campaignId, payload, this.entityType, this.entityId)
: this.chatService.streamChat(this.loreId, payload, this.pageId);
}
private triggerAutoTitle(convId: string): void {
this.conversationService.autoTitle(convId).subscribe({
next: ({ title }) => {
this.currentTitle = title;
this.conversations = this.conversations.map((c) =>
c.id === convId ? { ...c, title } : c,
);
},
error: () => {},
});
}
@@ -182,18 +411,10 @@ export class AiChatDrawerComponent implements OnDestroy {
this.currentAssistantText = '';
}
/**
* Scroll différé au prochain tick : donne à Angular le temps de rendre
* le nouveau contenu avant qu'on mesure/ajuste la position du scroll.
*/
private scrollToBottom(): void {
queueMicrotask(() => {
const el = this.messagesContainer?.nativeElement;
if (el) el.scrollTop = el.scrollHeight;
});
}
ngOnDestroy(): void {
this.abortStream();
}
}

View File

@@ -1,29 +1,183 @@
<!-- Grille de vignettes + uploader si editable. -->
<div class="gallery"
*ngIf="imageIds.length > 0 || editable; else empty">
<!-- Container avec classe dynamique selon le layout choisi. -->
<div [ngSwitch]="effectiveLayout" class="gallery-root">
<div class="gallery-tile"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<!-- =================== HERO =================== -->
<ng-container *ngSwitchCase="'HERO'">
<div class="hero" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="hero-main"
*ngIf="heroId"
(click)="openLightbox(heroId)"
role="button"
tabindex="0">
<img [src]="urlFor(heroId)" [alt]="'Illustration principale'" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(heroId, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="hero-rest" *ngIf="restIds.length > 0 || editable">
<div class="gallery-tile hero-thumb"
*ngFor="let id of restIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<app-image-uploader *ngIf="editable" [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
<!-- Si pas de hero mais editable, on montre au moins l'uploader. -->
<div class="hero-rest" *ngIf="!heroId && editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== MASONRY =================== -->
<ng-container *ngSwitchCase="'MASONRY'">
<div class="masonry" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="masonry-item"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="masonry-item masonry-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== CAROUSEL =================== -->
<ng-container *ngSwitchCase="'CAROUSEL'">
<div class="carousel" *ngIf="imageIds.length > 0 || editable; else empty">
<button type="button"
class="carousel-nav carousel-prev"
*ngIf="imageIds.length > 1"
(click)="scrollCarousel(-1)"
aria-label="Precedent">
<lucide-icon [img]="ChevronLeft" [size]="20"></lucide-icon>
</button>
<div class="carousel-track" #carouselTrack>
<div class="carousel-slide"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="carousel-slide carousel-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
<button type="button"
class="carousel-nav carousel-next"
*ngIf="imageIds.length > 1"
(click)="scrollCarousel(1)"
aria-label="Suivant">
<lucide-icon [img]="ChevronRight" [size]="20"></lucide-icon>
</button>
</div>
</ng-container>
<!-- =================== EDITORIAL =================== -->
<!-- Rendu adaptatif facon magazine : 1 image → hero, 2 → diptyque, 3 → feature + 2 satellites, 4+ → feature + 3 satellites. -->
<ng-container *ngSwitchCase="'EDITORIAL'">
<div class="editorial" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="editorial-item"
*ngFor="let id of imageIds; let i = index"
[class.editorial-feature]="i === 0"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="editorial-item editorial-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== MAPS =================== -->
<!-- Cartes / plans : grandes vignettes, ratio natif preserve (pas de crop). -->
<ng-container *ngSwitchCase="'MAPS'">
<div class="maps" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="map-tile"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Carte ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette carte">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="map-tile map-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== GALLERY (default) =================== -->
<ng-container *ngSwitchDefault>
<div class="gallery" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="gallery-tile"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<app-image-uploader *ngIf="editable" [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</ng-container>
<!-- Bouton + (uploader compact), uniquement en mode edition -->
<app-image-uploader
*ngIf="editable"
[compact]="true"
(uploaded)="onUploaded($event)">
</app-image-uploader>
</div>
<!-- Etat vide (lecture uniquement). -->

View File

@@ -1,33 +1,36 @@
.gallery {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
align-items: flex-start;
}
// =============== Common tile / remove-button ===============
// Partage par tous les layouts : vignette, survol, bouton X.
.gallery-tile {
.gallery-tile,
.masonry-item,
.hero-thumb,
.carousel-slide,
.hero-main,
.map-tile {
position: relative;
width: 120px;
height: 120px;
border-radius: 6px;
border-radius: 8px;
overflow: hidden;
background: #1a1a2e;
border: 1px solid #2a2a3d;
cursor: zoom-in;
transition: border-color 0.15s, transform 0.15s;
transition: border-color 0.15s, transform 0.15s, box-shadow 0.2s;
&:hover {
border-color: #6c63ff;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(108, 99, 255, 0.18);
.gallery-remove { opacity: 1; }
img { transform: scale(1.04); }
}
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
}
@@ -47,6 +50,7 @@
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s;
z-index: 2;
&:hover { background: #7f1d1d; color: white; }
}
@@ -60,7 +64,338 @@
font-style: italic;
}
// Lightbox plein ecran
// =============== Layout: GALLERY (planche de contact) ===============
// Grille stricte de carres identiques, effet "contact sheet" photo.
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.35rem;
padding: 0.35rem;
background: #12121f;
border-radius: 6px;
max-width: 720px; // contient la grille pour ne pas etaler sur tout l'ecran
}
.gallery-tile {
width: auto;
aspect-ratio: 1 / 1;
border-radius: 2px; // carres vifs, presque sans radius
}
// =============== Layout: HERO ===============
.hero {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.hero-main {
width: 100%;
aspect-ratio: 21 / 9;
max-height: 360px;
cursor: zoom-in;
img { object-position: center; }
}
.hero-rest {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.hero-thumb {
width: 90px;
height: 90px;
}
// =============== Layout: MASONRY (Pinterest) ===============
// Colonnes larges, hauteurs naturelles preservees. Effet tres visible si les
// images n'ont pas toutes le meme ratio. Le border-radius genereux et les
// ombres accentuent le cote "tableau d'inspiration".
.masonry {
column-count: 3;
column-gap: 1.2rem;
padding: 0.5rem 0;
@media (max-width: 900px) { column-count: 2; }
@media (max-width: 500px) { column-count: 1; }
}
.masonry-item {
display: inline-block;
width: 100%;
margin-bottom: 1.2rem;
break-inside: avoid;
border-radius: 14px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
// Override de la transition par defaut pour un feel plus doux.
transition: transform 0.25s ease, box-shadow 0.25s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(108, 99, 255, 0.35);
}
img {
height: auto; // ratio natif preserve → hauteur variable entre les tuiles
border-radius: 14px;
}
}
.masonry-uploader {
aspect-ratio: 3 / 4; // slot vertical, bien different d'une tuile simple
border-style: dashed;
border-width: 2px;
cursor: default;
box-shadow: none;
&:hover { transform: none; box-shadow: none; }
}
// =============== Layout: CAROUSEL (cinema) ===============
// Bande horizontale facon affiche de film : grandes slides 16/9, ombres
// marquees, fade sur les bords pour suggerer le defilement infini.
.carousel {
position: relative;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0;
}
.carousel-track {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
padding: 0.5rem 0.25rem;
flex: 1;
min-width: 0;
&::-webkit-scrollbar { height: 6px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb {
background: #2a2a3d;
border-radius: 3px;
}
}
.carousel-slide {
flex: 0 0 auto;
width: 360px;
aspect-ratio: 16 / 9;
height: auto;
scroll-snap-align: start;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
&:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 16px 40px rgba(108, 99, 255, 0.4);
}
}
.carousel-uploader {
width: 220px;
aspect-ratio: 16 / 9;
border-style: dashed;
border-width: 2px;
cursor: default;
box-shadow: none;
&:hover { transform: none; box-shadow: none; }
}
.carousel-nav {
flex: 0 0 auto;
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #2a2a3d;
border-radius: 50%;
background: rgba(26, 26, 46, 0.9);
color: #9ca3af;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
&:hover {
color: white;
border-color: #6c63ff;
background: #1f1b3a;
}
}
// =============== Layout: EDITORIAL (scrapbook polaroid) ===============
// Rendu carnet de campagne : vignettes facon polaroid, legerement inclinees,
// avec bande de papier collant (::before) et ombre portee. Au survol, la photo
// se redresse et se souleve. Pas de grille rigide : flex-wrap laisse respirer.
.editorial {
display: flex;
flex-wrap: wrap;
gap: 2rem 1.25rem;
padding: 1.25rem 0.5rem 0.5rem;
align-items: flex-start;
justify-content: flex-start;
// Fond kraft/parchemin tres discret pour suggerer le carnet.
background:
radial-gradient(ellipse at 20% 20%, rgba(180, 150, 100, 0.05), transparent 60%),
radial-gradient(ellipse at 80% 70%, rgba(160, 120, 80, 0.04), transparent 60%);
border-radius: 8px;
}
.editorial-item {
position: relative;
flex: 0 0 220px;
max-width: 100%;
background: #f5efe0; // papier blanc casse
padding: 10px 10px 34px 10px; // bas = bande blanche facon polaroid
border-radius: 2px;
cursor: zoom-in;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.35),
0 14px 28px rgba(0, 0, 0, 0.45);
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.3, 1),
box-shadow 0.3s ease;
// Rotations pseudo-aleatoires pour casser l'effet grille.
transform: rotate(-2deg);
&:nth-child(2n) { transform: rotate(1.8deg); }
&:nth-child(3n) { transform: rotate(-1.2deg); }
&:nth-child(4n) { transform: rotate(2.5deg); }
&:nth-child(5n) { transform: rotate(-2.8deg); }
&:nth-child(7n) { transform: rotate(0.9deg); }
// Ruban adhesif en haut de la photo.
&::before {
content: '';
position: absolute;
top: -9px;
left: 50%;
width: 68px;
height: 18px;
transform: translateX(-50%) rotate(-4deg);
background: rgba(255, 238, 200, 0.55);
border-left: 1px dashed rgba(0, 0, 0, 0.08);
border-right: 1px dashed rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
pointer-events: none;
}
&:nth-child(2n)::before { transform: translateX(-50%) rotate(3deg); }
&:nth-child(3n)::before { transform: translateX(-50%) rotate(-7deg); left: 58%; }
&:nth-child(4n)::before { transform: translateX(-50%) rotate(5deg); left: 42%; }
&:hover {
transform: rotate(0deg) scale(1.05) translateY(-4px);
z-index: 10;
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.5),
0 24px 48px rgba(0, 0, 0, 0.6);
.gallery-remove { opacity: 1; }
}
img {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
background: #1a1a2e;
border-radius: 0;
}
}
// La premiere image (feature) est plus grande et en ratio 4/3 pour jouer le role d'affiche.
.editorial-feature {
flex: 0 0 420px;
img { aspect-ratio: 4 / 3; }
}
// Bouton X : sur polaroid blanc, on renforce le contraste.
.editorial-item .gallery-remove {
top: 14px;
right: 14px;
background: rgba(17, 17, 30, 0.92);
color: #fecaca;
&:hover { background: #7f1d1d; color: white; }
}
// Uploader : meme cadre polaroid mais en "coller une photo ici" dashed.
.editorial-uploader {
background: rgba(245, 239, 224, 0.06);
border: 2px dashed rgba(108, 99, 255, 0.7);
padding: 0;
cursor: default;
&::before { display: none; } // pas de scotch sur le slot vide
&:hover {
transform: rotate(0deg);
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.35),
0 14px 28px rgba(0, 0, 0, 0.45);
}
// L'uploader interne doit remplir le slot.
app-image-uploader { display: block; width: 100%; height: 100%; min-height: 180px; }
}
// Responsive : on reduit la taille et on supprime les rotations sur mobile.
@media (max-width: 780px) {
.editorial-item { flex: 0 0 calc(50% - 0.75rem); }
.editorial-feature { flex: 0 0 calc(100% - 0.5rem); }
}
@media (max-width: 480px) {
.editorial { gap: 1.25rem 0.75rem; }
.editorial-item,
.editorial-feature {
flex: 0 0 100%;
transform: rotate(0deg) !important;
&::before { display: none; }
}
}
// =============== Layout: MAPS ===============
// Plans et cartes : on ne CROP pas (une carte croppee ne sert a rien).
// Grandes vignettes, ratio natif preserve via object-fit: contain.
.maps {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.map-tile {
aspect-ratio: 4 / 3;
background:
linear-gradient(45deg, #15152440 25%, transparent 25%),
linear-gradient(-45deg, #15152440 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #15152440 75%),
linear-gradient(-45deg, transparent 75%, #15152440 75%),
#1a1a2e;
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
img {
object-fit: contain; // Preserve le ratio natif, ajoute un padding visuel via le fond.
padding: 4px;
}
}
.map-uploader {
border-style: dashed;
cursor: default;
background: #1a1a2e;
&:hover { transform: none; box-shadow: none; }
}
// =============== Lightbox (inchange) ===============
.lightbox-backdrop {
position: fixed;
inset: 0;

View File

@@ -1,8 +1,9 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, X, Image as ImageIcon } from 'lucide-angular';
import { LucideAngularModule, X, Image as ImageIcon, ChevronLeft, ChevronRight } from 'lucide-angular';
import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model';
import { ImageLayout } from '../../services/template.model';
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
/**
@@ -34,6 +35,8 @@ import { ImageUploaderComponent } from '../image-uploader/image-uploader.compone
export class ImageGalleryComponent {
readonly X = X;
readonly ImageIcon = ImageIcon;
readonly ChevronLeft = ChevronLeft;
readonly ChevronRight = ChevronRight;
/** IDs d'images a afficher. */
@Input() imageIds: string[] = [];
@@ -41,14 +44,45 @@ export class ImageGalleryComponent {
/** Mode edition : afficher le bouton d'ajout + les boutons de suppression. */
@Input() editable = false;
/**
* Variante de mise en page. Null/undefined = GALLERY (rendu historique).
* HERO : premiere image en banniere pleine largeur, suivantes en petit dessous.
* MASONRY : mosaique a hauteurs variables.
* CAROUSEL : defilement horizontal avec fleches.
*/
@Input() layout: ImageLayout | null | undefined = 'GALLERY';
/** Emet la nouvelle liste quand l'utilisateur ajoute/retire une image. */
@Output() imageIdsChange = new EventEmitter<string[]>();
/** ID de l'image actuellement ouverte en lightbox (null = ferme). */
lightboxId: string | null = null;
@ViewChild('carouselTrack') carouselTrack?: ElementRef<HTMLDivElement>;
constructor(private imageService: ImageService) {}
/** Layout effectif (null → GALLERY). */
get effectiveLayout(): ImageLayout {
return this.layout ?? 'GALLERY';
}
/** Premiere image (pour le layout HERO). */
get heroId(): string | null {
return this.imageIds[0] ?? null;
}
/** Images restantes apres la hero (pour le layout HERO). */
get restIds(): string[] {
return this.imageIds.slice(1);
}
scrollCarousel(direction: -1 | 1): void {
const el = this.carouselTrack?.nativeElement;
if (!el) return;
el.scrollBy({ left: direction * Math.max(240, el.clientWidth * 0.8), behavior: 'smooth' });
}
/** URL absolue du binaire d'une image. */
urlFor(id: string): string {
return this.imageService.contentUrl(id);

View File

@@ -60,7 +60,7 @@
</div>
<div class="sidebar-footer">
<span class="version">Version 0.2.0</span>
<span class="version">Version 0.4.0</span>
</div>
</aside>