Chat persistant pour la partie lore et la partie campagne pour chaque page / scène.....
All checks were successful
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m23s
Build & Push Images / build (web) (push) Successful in 1m26s

Correction du carroussel
Passage en v0.4.0
Correction du docker compose pour tout le temps utiliser le bon port que ce soit prod ou dev
This commit is contained in:
2026-04-21 23:35:43 +02:00
parent b0fe8de708
commit 49a82d05f7
45 changed files with 2153 additions and 202 deletions

View File

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

View File

@@ -9,6 +9,7 @@ from typing import Annotated, AsyncIterator, Literal
import hmac
import httpx
import tiktoken
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
@@ -37,10 +38,27 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.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["<arch>.context_length"]`
(ex: `gemma3.context_length: 131072`). L'arch varie selon le modele, on
scanne donc tous les champs finissant par `.context_length`.
"""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/show"
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.post(url, json={"model": name})
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return OllamaModelInfoDTO(context_length=0)
model_info = data.get("model_info") or {}
for key, value in model_info.items():
if key.endswith(".context_length") and isinstance(value, int):
return OllamaModelInfoDTO(context_length=value)
return OllamaModelInfoDTO(context_length=0)
@app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,11 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minioadmin}
volumes:
- minio-data:/data
# Mapping bind sur loopback pour autoriser un core/web lance en local (mode dev)
# a atteindre MinIO. Invisible sur le LAN donc non-exploitable depuis l'exterieur.
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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