From 49a82d05f75853527c4cdb46f987f9012b0a1368 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Tue, 21 Apr 2026 23:35:43 +0200 Subject: [PATCH] =?UTF-8?q?Chat=20persistant=20pour=20la=20partie=20lore?= =?UTF-8?q?=20et=20la=20partie=20campagne=20pour=20chaque=20page=20/=20sc?= =?UTF-8?q?=C3=A8ne.....=20Correction=20du=20carroussel=20Passage=20en=20v?= =?UTF-8?q?0.4.0=20Correction=20du=20docker=20compose=20pour=20tout=20le?= =?UTF-8?q?=20temps=20utiliser=20le=20bon=20port=20que=20ce=20soit=20prod?= =?UTF-8?q?=20ou=20dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- brain/app/application/chat.py | 14 + brain/app/main.py | 148 ++++++- brain/requirements.txt | 8 +- core/pom.xml | 2 +- .../ConversationService.java | 134 +++++++ .../StreamChatForCampaignUseCase.java | 4 +- .../StreamChatForLoreUseCase.java | 4 +- .../conversationcontext/Conversation.java | 47 +++ .../ConversationMessage.java | 28 ++ .../ports/ConversationRepository.java | 44 ++ .../ports/ConversationTitleGenerator.java | 15 + .../domain/generationcontext/ChatUsage.java | 16 + .../ports/AiChatProvider.java | 6 + .../infrastructure/ai/BrainAiChatClient.java | 45 ++- .../ai/BrainConversationTitleClient.java | 68 ++++ .../entity/ConversationJpaEntity.java | 89 +++++ .../entity/ConversationMessageJpaEntity.java | 59 +++ .../jpa/ConversationJpaRepository.java | 64 +++ .../PostgresConversationRepository.java | 148 +++++++ .../web/controller/AiChatController.java | 15 + .../controller/ConversationController.java | 100 +++++ .../web/controller/SettingsController.java | 6 + .../conversationcontext/AppendMessageDTO.java | 16 + .../conversationcontext/ConversationDTO.java | 29 ++ .../ConversationMessageDTO.java | 19 + .../CreateConversationDTO.java | 23 ++ .../RenameConversationDTO.java | 14 + .../web/mapper/ConversationMapper.java | 61 +++ .../StreamChatForCampaignUseCaseTest.java | 25 +- .../StreamChatForLoreUseCaseTest.java | 25 +- docker-compose.yml | 5 + web/package.json | 2 +- .../page-create/page-create.component.html | 1 + web/src/app/services/ai-chat.service.ts | 36 ++ web/src/app/services/conversation.model.ts | 35 ++ web/src/app/services/conversation.service.ts | 64 +++ web/src/app/services/settings.service.ts | 13 + web/src/app/settings/settings.component.html | 50 ++- web/src/app/settings/settings.component.scss | 17 + web/src/app/settings/settings.component.ts | 41 +- .../ai-chat-drawer.component.html | 217 ++++++---- .../ai-chat-drawer.component.scss | 205 +++++++++- .../ai-chat-drawer.component.ts | 377 ++++++++++++++---- .../image-gallery.component.scss | 14 - web/src/app/sidebar/sidebar.component.html | 2 +- 45 files changed, 2153 insertions(+), 202 deletions(-) create mode 100644 core/src/main/java/com/loremind/application/conversationcontext/ConversationService.java create mode 100644 core/src/main/java/com/loremind/domain/conversationcontext/Conversation.java create mode 100644 core/src/main/java/com/loremind/domain/conversationcontext/ConversationMessage.java create mode 100644 core/src/main/java/com/loremind/domain/conversationcontext/ports/ConversationRepository.java create mode 100644 core/src/main/java/com/loremind/domain/conversationcontext/ports/ConversationTitleGenerator.java create mode 100644 core/src/main/java/com/loremind/domain/generationcontext/ChatUsage.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainConversationTitleClient.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationJpaEntity.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationMessageJpaEntity.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/ConversationJpaRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresConversationRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/ConversationController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/AppendMessageDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/ConversationDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/ConversationMessageDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/CreateConversationDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/RenameConversationDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/mapper/ConversationMapper.java create mode 100644 web/src/app/services/conversation.model.ts create mode 100644 web/src/app/services/conversation.service.ts diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py index 4466b3e..0f0c72e 100644 --- a/brain/app/application/chat.py +++ b/brain/app/application/chat.py @@ -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( diff --git a/brain/app/main.py b/brain/app/main.py index 2889fa3..36fc68c 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -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.3.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[".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. diff --git a/brain/requirements.txt b/brain/requirements.txt index 9a74705..20c115d 100644 --- a/brain/requirements.txt +++ b/brain/requirements.txt @@ -3,4 +3,10 @@ uvicorn[standard]==0.32.* httpx==0.27.* pydantic-settings==2.6.* -pydantic \ No newline at end of file +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.* diff --git a/core/pom.xml b/core/pom.xml index cf8a600..40122dd 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.3.0 + 0.4.0 LoreMind Core Backend Core - Architecture Hexagonale diff --git a/core/src/main/java/com/loremind/application/conversationcontext/ConversationService.java b/core/src/main/java/com/loremind/application/conversationcontext/ConversationService.java new file mode 100644 index 0000000..172a49d --- /dev/null +++ b/core/src/main/java/com/loremind/application/conversationcontext/ConversationService.java @@ -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 getById(String id) { + return repository.findById(id); + } + + public List 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 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"); + } + } +} diff --git a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCase.java index 2c2cba9..9618a4b 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCase.java +++ b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCase.java @@ -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 messages, + Consumer onUsage, Consumer onToken, Runnable onComplete, Consumer onError) { @@ -84,7 +86,7 @@ public class StreamChatForCampaignUseCase { .narrativeEntity(narrativeEntity) .build(); - aiChatProvider.streamChat(request, onToken, onComplete, onError); + aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError); } /** diff --git a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java index e3367b2..3668e30 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java +++ b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java @@ -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 messages, + Consumer onUsage, Consumer onToken, Runnable onComplete, Consumer onError) { @@ -75,7 +77,7 @@ public class StreamChatForLoreUseCase { .pageContext(pageContext) .build(); - aiChatProvider.streamChat(request, onToken, onComplete, onError); + aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError); } /** diff --git a/core/src/main/java/com/loremind/domain/conversationcontext/Conversation.java b/core/src/main/java/com/loremind/domain/conversationcontext/Conversation.java new file mode 100644 index 0000000..b8102c9 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/conversationcontext/Conversation.java @@ -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 messages = new ArrayList<>(); +} diff --git a/core/src/main/java/com/loremind/domain/conversationcontext/ConversationMessage.java b/core/src/main/java/com/loremind/domain/conversationcontext/ConversationMessage.java new file mode 100644 index 0000000..6122378 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/conversationcontext/ConversationMessage.java @@ -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; +} diff --git a/core/src/main/java/com/loremind/domain/conversationcontext/ports/ConversationRepository.java b/core/src/main/java/com/loremind/domain/conversationcontext/ports/ConversationRepository.java new file mode 100644 index 0000000..1093c09 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/conversationcontext/ports/ConversationRepository.java @@ -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 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 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); +} diff --git a/core/src/main/java/com/loremind/domain/conversationcontext/ports/ConversationTitleGenerator.java b/core/src/main/java/com/loremind/domain/conversationcontext/ports/ConversationTitleGenerator.java new file mode 100644 index 0000000..8b90bc4 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/conversationcontext/ports/ConversationTitleGenerator.java @@ -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 firstMessages); +} diff --git a/core/src/main/java/com/loremind/domain/generationcontext/ChatUsage.java b/core/src/main/java/com/loremind/domain/generationcontext/ChatUsage.java new file mode 100644 index 0000000..b2bfe96 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/generationcontext/ChatUsage.java @@ -0,0 +1,16 @@ +package com.loremind.domain.generationcontext; + +/** + * Instantané d'occupation de la fenêtre de contexte à l'instant t du chat. + *

+ * É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) { +} diff --git a/core/src/main/java/com/loremind/domain/generationcontext/ports/AiChatProvider.java b/core/src/main/java/com/loremind/domain/generationcontext/ports/AiChatProvider.java index 5efd3c4..3533520 100644 --- a/core/src/main/java/com/loremind/domain/generationcontext/ports/AiChatProvider.java +++ b/core/src/main/java/com/loremind/domain/generationcontext/ports/AiChatProvider.java @@ -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 onUsage, Consumer onToken, Runnable onComplete, Consumer onError diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java index cfeab82..5ea4174 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java @@ -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 onUsage, Consumer onToken, Runnable onComplete, Consumer 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 sse, + Consumer onUsage, Consumer onToken, Consumer 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. diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainConversationTitleClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainConversationTitleClient.java new file mode 100644 index 0000000..41eca5f --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainConversationTitleClient.java @@ -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 firstMessages) { + if (firstMessages == null || firstMessages.isEmpty()) { + return FALLBACK; + } + Map payload = new LinkedHashMap<>(); + payload.put("messages", firstMessages.stream() + .map(m -> Map.of( + "role", m.getRole(), + "content", m.getContent() == null ? "" : m.getContent())) + .collect(Collectors.toList())); + + try { + @SuppressWarnings("unchecked") + Map 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; + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationJpaEntity.java new file mode 100644 index 0000000..ed2e19d --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationJpaEntity.java @@ -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 messages = new ArrayList<>(); + + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationMessageJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationMessageJpaEntity.java new file mode 100644 index 0000000..bbf82f6 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationMessageJpaEntity.java @@ -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(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ConversationJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ConversationJpaRepository.java new file mode 100644 index 0000000..836df90 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ConversationJpaRepository.java @@ -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 { + + /** 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 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 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 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 findByCampaignAndEntity( + @Param("campaignId") String campaignId, + @Param("entityType") String entityType, + @Param("entityId") String entityId); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresConversationRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresConversationRepository.java new file mode 100644 index 0000000..db565ec --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresConversationRepository.java @@ -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 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 findByContext(String loreId, String campaignId, String entityType, String entityId) { + List 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 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(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java index 623e48e..a7f30b8 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java @@ -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() diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ConversationController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ConversationController.java new file mode 100644 index 0000000..db880eb --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ConversationController.java @@ -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( + @RequestParam(required = false) String loreId, + @RequestParam(required = false) String campaignId, + @RequestParam(required = false) String entityType, + @RequestParam(required = false) String entityId) { + List rows = service.listByContext(loreId, campaignId, entityType, entityId); + return ResponseEntity.ok(rows.stream().map(mapper::toListDTO).collect(Collectors.toList())); + } + + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable String id) { + return service.getById(id) + .map(c -> ResponseEntity.ok(mapper.toDTO(c))) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity 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 rename(@PathVariable String id, @RequestBody RenameConversationDTO dto) { + service.rename(id, dto.getTitle()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable String id) { + service.delete(id); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{id}/messages") + public ResponseEntity 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 autoTitle(@PathVariable String id) { + String title = service.autoGenerateTitle(id); + return ResponseEntity.ok(RenameConversationDTO.builder().title(title).build()); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java index 40aab80..b1257aa 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java @@ -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> getOllamaModelInfo(@RequestBody Map body) { + return forward(HttpMethod.POST, "/models/ollama/info", body); + } + @GetMapping("/models/onemin") public ResponseEntity> listOneMinModels() { return forward(HttpMethod.GET, "/models/onemin", null); diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/AppendMessageDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/AppendMessageDTO.java new file mode 100644 index 0000000..3a2aec9 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/AppendMessageDTO.java @@ -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; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/ConversationDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/ConversationDTO.java new file mode 100644 index 0000000..632b4eb --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/ConversationDTO.java @@ -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 messages; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/ConversationMessageDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/ConversationMessageDTO.java new file mode 100644 index 0000000..e5b3f24 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/ConversationMessageDTO.java @@ -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; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/CreateConversationDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/CreateConversationDTO.java new file mode 100644 index 0000000..7070deb --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/CreateConversationDTO.java @@ -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; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/RenameConversationDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/RenameConversationDTO.java new file mode 100644 index 0000000..a18cec6 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/conversationcontext/RenameConversationDTO.java @@ -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; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/ConversationMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/ConversationMapper.java new file mode 100644 index 0000000..63b63a7 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/ConversationMapper.java @@ -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 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(); + } +} diff --git a/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java index c3fc097..a850efa 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCaseTest.java @@ -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 messages; + private Consumer onUsage; private Consumer onToken; private Runnable onComplete; private Consumer 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 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 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 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 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 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); } diff --git a/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java index dfc97cb..b40ef08 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java @@ -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 messages; + private Consumer onUsage; private Consumer onToken; private Runnable onComplete; private Consumer 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 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 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 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 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 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); } } diff --git a/docker-compose.yml b/docker-compose.yml index a2b7a16..901f8ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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"] diff --git a/web/package.json b/web/package.json index 69686a8..948cc60 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.3.0", + "version": "0.4.0", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/lore/page-create/page-create.component.html b/web/src/app/lore/page-create/page-create.component.html index cb3d25a..e213bd8 100644 --- a/web/src/app/lore/page-create/page-create.component.html +++ b/web/src/app/lore/page-create/page-create.component.html @@ -78,6 +78,7 @@ { 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; + 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 }; diff --git a/web/src/app/services/conversation.model.ts b/web/src/app/services/conversation.model.ts new file mode 100644 index 0000000..5b6bb50 --- /dev/null +++ b/web/src/app/services/conversation.model.ts @@ -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; +} diff --git a/web/src/app/services/conversation.service.ts b/web/src/app/services/conversation.service.ts new file mode 100644 index 0000000..45fc234 --- /dev/null +++ b/web/src/app/services/conversation.service.ts @@ -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 { + 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(this.apiUrl, { params }); + } + + getById(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + create(payload: CreateConversationPayload): Observable { + return this.http.post(this.apiUrl, payload); + } + + rename(id: string, title: string): Observable { + return this.http.patch(`${this.apiUrl}/${id}/title`, { title }); + } + + delete(id: string): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } + + appendMessage( + id: string, + role: 'user' | 'assistant' | 'system', + content: string, + ): Observable { + return this.http.post(`${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`, {}); + } +} diff --git a/web/src/app/services/settings.service.ts b/web/src/app/services/settings.service.ts index 9b5215d..2ad4960 100644 --- a/web/src/app/services/settings.service.ts +++ b/web/src/app/services/settings.service.ts @@ -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 { + return this.http.post( + `${this.apiUrl}/models/ollama/info`, { name }, this.authOptions); + } + listOneMinModels(): Observable<{ groups: OneMinModelGroup[] }> { return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions); } diff --git a/web/src/app/settings/settings.component.html b/web/src/app/settings/settings.component.html index 6966e6e..98ba74f 100644 --- a/web/src/app/settings/settings.component.html +++ b/web/src/app/settings/settings.component.html @@ -48,7 +48,7 @@

- @@ -93,6 +93,54 @@
+ +
+

Fenetre de contexte

+ + +
+ + +

+ Le modele {{ settings.llm_model }} 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. +

+

+ Impossible de determiner la fenetre max du modele (Ollama injoignable ou modele + inconnu). Slider borne a {{ CTX_FALLBACK_MAX | number }} par securite. +

+
+ + +
+ + +

+ 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. +

+
+
+
- - -
- -
- {{ welcomeMessage }} -
- - - -
- {{ m.content }} -
-
- - -
- {{ currentAssistantText }} -
- - -
- -
- - - -
- - -
- -
- - -
-

Suggestions rapides :

-
-
-
+
    +
  • Aucune conversation
  • +
  • + {{ c.title }} + +
  • +
+ - -
- - -
+
+
+ + +
+ + +

{{ currentTitle || 'Nouvelle conversation' }}

+ +
+ + + +
+ +

Assistant IA

+
+
+ + +
+ +
+
+
+
+
+ Contexte : {{ usageTotal }} / {{ usage.max }} tokens + {{ usagePercent }}% +
+
+ +
+
+ {{ welcomeMessage }} +
+ + +
+ {{ m.content }} +
+
+ +
+ {{ currentAssistantText }} +
+ +
+ +
+ + +
+ +
+ +
+ +
+

Suggestions rapides :

+
+ +
+
+ +
+ + +
+ +
diff --git a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.scss b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.scss index 0bad2dd..f17cd05 100644 --- a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.scss +++ b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.scss @@ -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; +} diff --git a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts index ac7bde6..6c7190d 100644 --- a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts +++ b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts @@ -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 : - * - * - * - * 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(); - /** Émis au clic sur l'action primaire — le parent gère entièrement (one-shot, etc.). */ @Output() primaryActionClick = new EventEmitter(); - /** Émis à chaque fin de réponse assistant — utile pour parser côté parent (ex: bloc du wizard). */ @Output() assistantReply = new EventEmitter(); @ViewChild('messagesContainer') messagesContainer?: ElementRef; - /** 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 { + 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(); - } } diff --git a/web/src/app/shared/image-gallery/image-gallery.component.scss b/web/src/app/shared/image-gallery/image-gallery.component.scss index 7f3a559..430e390 100644 --- a/web/src/app/shared/image-gallery/image-gallery.component.scss +++ b/web/src/app/shared/image-gallery/image-gallery.component.scss @@ -163,20 +163,6 @@ align-items: center; gap: 0.6rem; padding: 0.5rem 0; - - // Fade gauche/droite pour signaler clairement "ca defile". - &::before, - &::after { - content: ''; - position: absolute; - top: 0; - bottom: 0; - width: 48px; - pointer-events: none; - z-index: 3; - } - &::before { left: 48px; background: linear-gradient(to right, #0f0f1e 0%, transparent 100%); } - &::after { right: 48px; background: linear-gradient(to left, #0f0f1e 0%, transparent 100%); } } .carousel-track { diff --git a/web/src/app/sidebar/sidebar.component.html b/web/src/app/sidebar/sidebar.component.html index 7e9ce70..fe2d771 100644 --- a/web/src/app/sidebar/sidebar.component.html +++ b/web/src/app/sidebar/sidebar.component.html @@ -60,7 +60,7 @@