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

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