Ajout de la partie IA
This commit is contained in:
@@ -52,15 +52,37 @@
|
||||
|
||||
<!-- Aide contextuelle -->
|
||||
<div class="info-box">
|
||||
💡 Une fois créée, vous pourrez remplir les champs du template et utiliser l'Assistant IA pour développer le contenu.
|
||||
💡 Option 1 : <strong>Créer la page</strong> vide, puis remplir les champs manuellement.<br>
|
||||
💡 Option 2 : <strong>Créer avec l'IA</strong> pour dialoguer avec un assistant qui pré-remplira les champs.
|
||||
</div>
|
||||
|
||||
<!-- Erreur wizard (parsing <values> ou échec HTTP) -->
|
||||
<div class="wizard-error" *ngIf="wizardError" role="alert">{{ wizardError }}</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions-row">
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-ai" (click)="openWizard()" [disabled]="!canSubmit"
|
||||
title="Ouvrir l'assistant IA pour pré-remplir les champs">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Créer avec l'IA
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" [disabled]="!canSubmit">Créer la page</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Drawer chat IA en mode wizard -->
|
||||
<app-ai-chat-drawer
|
||||
[loreId]="loreId"
|
||||
[isOpen]="chatOpen"
|
||||
[welcomeMessage]="wizardWelcome"
|
||||
[systemPromptAddon]="wizardSystemPrompt"
|
||||
[quickSuggestions]="wizardSuggestions"
|
||||
[primaryAction]="wizardPrimaryAction"
|
||||
(close)="closeWizard()"
|
||||
(assistantReply)="onWizardReply($event)"
|
||||
(primaryActionClick)="applyWizardAndCreate()">
|
||||
</app-ai-chat-drawer>
|
||||
|
||||
@@ -157,3 +157,30 @@
|
||||
|
||||
&:hover { background: #363650; }
|
||||
}
|
||||
|
||||
.btn-ai {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.65rem 1.1rem;
|
||||
background: transparent;
|
||||
color: #a5b4fc;
|
||||
border: 1px solid #6c63ff;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
|
||||
&:hover:not(:disabled) { background: #1f1d3a; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; border-color: #2a2a3d; color: #6b7280; }
|
||||
}
|
||||
|
||||
.wizard-error {
|
||||
background: #3f1f1f;
|
||||
color: #fca5a5;
|
||||
border: 1px solid #7f1d1d;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { LucideAngularModule, FileText } from 'lucide-angular';
|
||||
import { LucideAngularModule, FileText, Sparkles } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
@@ -11,6 +11,7 @@ import { PageTitleService } from '../../services/page-title.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { Template } from '../../services/template.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
|
||||
/**
|
||||
* Écran de création d'une Page.
|
||||
@@ -26,12 +27,13 @@ import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.hel
|
||||
@Component({
|
||||
selector: 'app-page-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterModule, LucideAngularModule],
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterModule, LucideAngularModule, AiChatDrawerComponent],
|
||||
templateUrl: './page-create.component.html',
|
||||
styleUrls: ['./page-create.component.scss']
|
||||
})
|
||||
export class PageCreateComponent implements OnInit, OnDestroy {
|
||||
readonly FileText = FileText;
|
||||
readonly Sparkles = Sparkles;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
@@ -42,6 +44,23 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
||||
/** Template actuellement sélectionné dans la grille. */
|
||||
selectedTemplateId: string | null = null;
|
||||
|
||||
// --- Mode wizard IA (étape b6) -----------------------------------------
|
||||
|
||||
/** Drawer chat ouvert ? */
|
||||
chatOpen = false;
|
||||
/** Dernière réponse complète de l'assistant — on y cherchera le bloc <values>. */
|
||||
private lastWizardReply: string | null = null;
|
||||
/** Erreur de parsing du bloc <values> — affichée sous le drawer. */
|
||||
wizardError: string | null = null;
|
||||
/** Action primaire du wizard : applique les valeurs extraites et crée la page. */
|
||||
readonly wizardPrimaryAction: ChatPrimaryAction = { label: 'Appliquer et créer la page' };
|
||||
/** Suggestions rapides orientées "affiner le résultat" (mode wizard). */
|
||||
readonly wizardSuggestions: string[] = [
|
||||
'Rends la description plus courte',
|
||||
'Ajoute un trait distinctif marquant',
|
||||
'Donne un ton plus sombre'
|
||||
];
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
@@ -88,6 +107,10 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
||||
return this.form.valid && !!this.selectedTemplateId;
|
||||
}
|
||||
|
||||
get selectedTemplate(): Template | null {
|
||||
return this.templates.find(t => t.id === this.selectedTemplateId) ?? null;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) return;
|
||||
const raw = this.form.value;
|
||||
@@ -106,6 +129,119 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
// --- Mode wizard IA (étape b6) -----------------------------------------
|
||||
|
||||
openWizard(): void {
|
||||
if (!this.canSubmit) return;
|
||||
this.wizardError = null;
|
||||
this.lastWizardReply = null;
|
||||
this.chatOpen = true;
|
||||
}
|
||||
|
||||
closeWizard(): void {
|
||||
this.chatOpen = false;
|
||||
}
|
||||
|
||||
/** Mémorise la dernière réponse de l'assistant — on y cherchera le bloc <values>. */
|
||||
onWizardReply(reply: string): void {
|
||||
this.lastWizardReply = reply;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clic sur "Appliquer et créer la page" :
|
||||
* 1. Extraire le bloc JSON <values>...</values> de la dernière réponse.
|
||||
* 2. Créer la page avec titre + template + nodeId + values.
|
||||
* 3. Naviguer vers l'édition pour que l'utilisateur finalise.
|
||||
*/
|
||||
applyWizardAndCreate(): void {
|
||||
if (!this.canSubmit || !this.lastWizardReply) {
|
||||
this.wizardError = "L'assistant n'a pas encore répondu. Décrivez d'abord votre idée.";
|
||||
return;
|
||||
}
|
||||
const values = this.extractValuesBlock(this.lastWizardReply);
|
||||
if (!values) {
|
||||
this.wizardError = "Impossible d'extraire les valeurs. Demandez à l'assistant de proposer à nouveau.";
|
||||
return;
|
||||
}
|
||||
this.wizardError = null;
|
||||
const raw = this.form.value;
|
||||
// Le backend POST /api/pages ne prend pas `values` — on crée d'abord la
|
||||
// coquille, puis on PUT immédiatement avec les valeurs extraites.
|
||||
// 2 roundtrips, mais zéro modification backend nécessaire.
|
||||
this.pageService.create({
|
||||
loreId: this.loreId,
|
||||
nodeId: raw.nodeId,
|
||||
templateId: this.selectedTemplateId!,
|
||||
title: raw.title
|
||||
}).subscribe({
|
||||
next: (created) => {
|
||||
const updated = { ...created, values };
|
||||
this.pageService.update(created.id!, updated).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId, 'pages', created.id]),
|
||||
error: () => this.wizardError = 'Page créée, mais impossible d\'appliquer les valeurs.'
|
||||
});
|
||||
},
|
||||
error: () => this.wizardError = 'Erreur lors de la création de la page.'
|
||||
});
|
||||
}
|
||||
|
||||
/** Prompt système injecté dans le backend pour le mode wizard. */
|
||||
get wizardSystemPrompt(): string | null {
|
||||
const tpl = this.selectedTemplate;
|
||||
if (!tpl || !this.canSubmit) return null;
|
||||
const title = this.form.value.title as string;
|
||||
const fieldsList = tpl.fields.length ? tpl.fields.map(f => `"${f}"`).join(', ') : '(aucun champ)';
|
||||
const exampleJson = tpl.fields.length
|
||||
? '{\n ' + tpl.fields.map(f => `"${f}": "valeur proposée"`).join(',\n ') + '\n}'
|
||||
: '{}';
|
||||
|
||||
return `MODE WIZARD — CRÉATION DE PAGE
|
||||
|
||||
L'utilisateur crée une nouvelle page intitulée "${title}" à partir du template "${tpl.name}".
|
||||
Les champs à proposer sont : ${fieldsList}.
|
||||
|
||||
Règles de cohérence :
|
||||
- Tu PEUX inventer des éléments originaux (personnages, lieux, objets, intrigues) — c'est ton rôle.
|
||||
- Tu ne peux PAS faire référence à un élément comme s'il existait déjà dans l'univers, sauf s'il apparaît EXACTEMENT dans la carte du Lore fournie plus haut.
|
||||
- Si l'utilisateur évoque un élément absent de la carte, suggère de le créer plutôt que d'inventer des détails fictifs à son sujet.
|
||||
|
||||
Format de réponse OBLIGATOIRE :
|
||||
Après avoir dialogué (1-3 phrases), termine CHAQUE réponse par un bloc JSON entre balises <values>, sans rien ajouter après :
|
||||
|
||||
<values>
|
||||
${exampleJson}
|
||||
</values>
|
||||
|
||||
Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués. Laisse "" si tu manques d'info pour un champ.`;
|
||||
}
|
||||
|
||||
/** Welcome message contextualisé au template choisi. */
|
||||
get wizardWelcome(): string {
|
||||
const tpl = this.selectedTemplate;
|
||||
if (!tpl) return 'Décrivez ce que vous souhaitez créer.';
|
||||
return `Super, on va créer une page "${tpl.name}" ! Décrivez-la-moi en quelques mots — contexte, rôle, traits marquants — et je proposerai des valeurs pour chaque champ.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le bloc <values>{...}</values> de la réponse assistant et parse en objet.
|
||||
* Retourne null si absent ou JSON invalide.
|
||||
*/
|
||||
private extractValuesBlock(reply: string): Record<string, string> | null {
|
||||
const match = reply.match(/<values>\s*([\s\S]*?)\s*<\/values>/i);
|
||||
if (!match) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]) as Record<string, unknown>;
|
||||
// On coerce toute valeur non-string en string (l'IA peut parfois produire des nombres).
|
||||
const result: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
result[k] = v == null ? '' : String(v);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
|
||||
@@ -8,9 +8,13 @@
|
||||
<p class="subtitle">{{ template?.name || 'Page' }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-ai" disabled title="Bientôt disponible">
|
||||
<button type="button" class="btn-ai"
|
||||
(click)="toggleChat()"
|
||||
[disabled]="aiLoading"
|
||||
[class.active]="chatOpen"
|
||||
title="Ouvrir l'Assistant IA (chat ou remplissage automatique)">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Assistant IA
|
||||
{{ aiLoading ? 'Génération…' : 'Assistant IA' }}
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
|
||||
<button type="button" class="btn-primary" (click)="save()" [disabled]="!title.trim()">
|
||||
@@ -19,6 +23,11 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div *ngIf="aiError" class="ai-error-banner" role="alert">
|
||||
<span>{{ aiError }}</span>
|
||||
<button type="button" class="ai-error-dismiss" (click)="aiError = null" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
|
||||
<form class="edit-form">
|
||||
|
||||
<!-- Identité ----------------------------------------------------- -->
|
||||
@@ -88,3 +97,14 @@
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Drawer chat IA (hors du .page pour pouvoir couvrir le viewport côté droit) -->
|
||||
<app-ai-chat-drawer
|
||||
[loreId]="loreId"
|
||||
[pageId]="pageId"
|
||||
[isOpen]="chatOpen"
|
||||
[quickSuggestions]="chatQuickSuggestions"
|
||||
[primaryAction]="chatPrimaryAction"
|
||||
(close)="chatOpen = false"
|
||||
(primaryActionClick)="onChatFillRequested()">
|
||||
</app-ai-chat-drawer>
|
||||
|
||||
@@ -119,4 +119,35 @@
|
||||
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
&:hover:not(:disabled) { background: #1f2937; }
|
||||
&.active {
|
||||
background: #1f2937;
|
||||
border-color: #6c63ff;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
background: #3f1f1f;
|
||||
color: #fca5a5;
|
||||
border: 1px solid #7f1d1d;
|
||||
border-radius: 6px;
|
||||
padding: 0.7rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.88rem;
|
||||
|
||||
.ai-error-dismiss {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fca5a5;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 0.25rem;
|
||||
|
||||
&:hover { color: white; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.hel
|
||||
import { ChipsInputComponent } from '../../shared/chips-input/chips-input.component';
|
||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
||||
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { Lore } from '../../services/lore.model';
|
||||
|
||||
/**
|
||||
@@ -34,7 +35,7 @@ import { Lore } from '../../services/lore.model';
|
||||
@Component({
|
||||
selector: 'app-page-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent],
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent],
|
||||
templateUrl: './page-edit.component.html',
|
||||
styleUrls: ['./page-edit.component.scss']
|
||||
})
|
||||
@@ -61,6 +62,21 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
/** IDs des pages liées (Phase 5B). */
|
||||
relatedPageIds: string[] = [];
|
||||
|
||||
/** Phase 5D — état de l'Assistant IA (one-shot). */
|
||||
aiLoading = false;
|
||||
aiError: string | null = null;
|
||||
|
||||
/** Phase b5 — drawer chat IA (conversationnel). */
|
||||
chatOpen = false;
|
||||
/** Action primaire dans le chat : déclenche le one-shot b4 (remplissage automatique). */
|
||||
readonly chatPrimaryAction: ChatPrimaryAction = { label: 'Remplir automatiquement tous les champs' };
|
||||
/** Suggestions rapides hardcodées (MVP). */
|
||||
readonly chatQuickSuggestions: string[] = [
|
||||
"Étoffe l'histoire de cette page",
|
||||
'Suggère des liens avec d\'autres pages du Lore',
|
||||
'Propose une intrigue secondaire'
|
||||
];
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
@@ -169,6 +185,59 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Chat IA conversationnel (Phase b5) --------------------------------
|
||||
|
||||
toggleChat(): void {
|
||||
this.chatOpen = !this.chatOpen;
|
||||
}
|
||||
|
||||
/** Appelé depuis le drawer quand l'utilisateur clique sur l'action primaire. */
|
||||
onChatFillRequested(): void {
|
||||
this.chatOpen = false; // on ferme le drawer : le résultat apparaîtra dans les textareas
|
||||
this.runAssistantAI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assistant IA (Phase 5D) — demande au Brain des suggestions de valeurs
|
||||
* pour les champs dynamiques du template.
|
||||
*
|
||||
* Merge soft : on n'écrase pas une valeur déjà saisie par l'utilisateur
|
||||
* si la suggestion est vide. L'utilisateur garde le contrôle final avant
|
||||
* de cliquer "Sauvegarder".
|
||||
*/
|
||||
runAssistantAI(): void {
|
||||
if (this.aiLoading || !this.template?.fields?.length) return;
|
||||
this.aiLoading = true;
|
||||
this.aiError = null;
|
||||
this.pageService.generateValues(this.pageId).subscribe({
|
||||
next: (suggestions) => {
|
||||
this.mergeSuggestions(suggestions);
|
||||
this.aiLoading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.aiLoading = false;
|
||||
this.aiError = err?.status === 502
|
||||
? "L'assistant IA est injoignable. V\u00e9rifiez que le service Brain tourne."
|
||||
: "\u00c9chec de la g\u00e9n\u00e9ration IA. R\u00e9essayez dans un instant.";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusionne les suggestions dans les valeurs courantes.
|
||||
* Merge soft :
|
||||
* - Suggestion non-vide → on applique (l'utilisateur a demandé la génération).
|
||||
* - Suggestion vide → on NE touche PAS à la valeur courante (l'IA n'a rien à proposer pour ce champ).
|
||||
*/
|
||||
private mergeSuggestions(suggestions: Record<string, string>): void {
|
||||
for (const field of this.template?.fields ?? []) {
|
||||
const suggestion = suggestions[field];
|
||||
if (suggestion && suggestion.trim()) {
|
||||
this.values[field] = suggestion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!this.page) return;
|
||||
if (!confirm(`Supprimer la page "${this.page.title}" ?`)) return;
|
||||
|
||||
171
web/src/app/services/ai-chat.service.ts
Normal file
171
web/src/app/services/ai-chat.service.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Un message d'une conversation IA (vue front).
|
||||
* Aligné sur le DTO ChatMessageDTO côté Java.
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Événements émis par le flux SSE durant un chat streamé.
|
||||
* - token : un fragment de texte vient d'arriver (à concaténer dans la bulle).
|
||||
* - 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).
|
||||
*/
|
||||
export type ChatStreamEvent =
|
||||
| { type: 'token'; value: string }
|
||||
| { type: 'done' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
/**
|
||||
* Service qui encapsule l'appel SSE au backend Java (POST /api/ai/chat/stream).
|
||||
*
|
||||
* On n'utilise pas EventSource (API navigateur natif) car elle ne supporte
|
||||
* que GET sans body. On fait donc un fetch() avec un ReadableStream qu'on
|
||||
* décode ligne par ligne pour extraire les événements SSE.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AiChatService {
|
||||
private readonly endpoint = 'http://localhost:8080/api/ai/chat/stream';
|
||||
|
||||
/**
|
||||
* Streame la réponse de l'IA pour un historique de messages donné.
|
||||
* L'Observable :
|
||||
* - émet `{type: 'token', value}` à chaque fragment reçu ;
|
||||
* - se complete quand `event: done` arrive ;
|
||||
* - erreur-complete (via `throwError`) quand `event: error` arrive ou qu'une erreur réseau survient.
|
||||
*
|
||||
* Annuler la subscription annule proprement le fetch (AbortController).
|
||||
*/
|
||||
streamChat(
|
||||
loreId: string,
|
||||
messages: ChatMessage[],
|
||||
pageId?: string | null
|
||||
): Observable<ChatStreamEvent> {
|
||||
return new Observable<ChatStreamEvent>((subscriber) => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// Payload : pageId inclus uniquement s'il est fourni et non vide, pour
|
||||
// garder le comportement "chat générique au Lore" par défaut.
|
||||
const body: Record<string, unknown> = { loreId, messages };
|
||||
if (pageId) body['pageId'] = pageId;
|
||||
|
||||
fetch(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream'
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok || !response.body) {
|
||||
subscriber.error(new Error(`HTTP ${response.status}`));
|
||||
return;
|
||||
}
|
||||
await this.consumeSseStream(response.body, subscriber);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return; // annulation volontaire, silencieuse
|
||||
subscriber.error(err);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consomme un ReadableStream SSE ligne par ligne.
|
||||
* Format attendu (un événement = un bloc séparé par `\n\n`) :
|
||||
* event: done (optionnel, défaut = 'message')
|
||||
* data: {...} (une ou plusieurs lignes, concaténées avec '\n')
|
||||
* <ligne vide> (séparateur d'événements)
|
||||
*/
|
||||
private async consumeSseStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
subscriber: { next: (e: ChatStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
|
||||
): Promise<void> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
|
||||
// Événement SSE en cours de construction (accumulé entre lignes vides).
|
||||
let currentEvent: string | null = null;
|
||||
let currentData = '';
|
||||
|
||||
const dispatchCurrentEvent = () => {
|
||||
const eventName = currentEvent ?? 'message';
|
||||
if (eventName === 'error') {
|
||||
const message = this.safeParseMessage(currentData);
|
||||
subscriber.error(new Error(message));
|
||||
} else if (eventName === 'done') {
|
||||
subscriber.next({ type: 'done' });
|
||||
subscriber.complete();
|
||||
} else {
|
||||
// Événement 'message' (défaut) : JSON {"token": "..."}
|
||||
const token = this.safeParseToken(currentData);
|
||||
if (token) subscriber.next({ type: 'token', value: token });
|
||||
}
|
||||
currentEvent = null;
|
||||
currentData = '';
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// On découpe par lignes ; la dernière (potentiellement incomplète) reste dans buffer.
|
||||
let newlineIdx: number;
|
||||
while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, newlineIdx).replace(/\r$/, '');
|
||||
buffer = buffer.slice(newlineIdx + 1);
|
||||
|
||||
if (line === '') {
|
||||
// Ligne vide = fin d'un événement SSE : on dispatch ce qu'on a accumulé.
|
||||
if (currentEvent !== null || currentData !== '') {
|
||||
dispatchCurrentEvent();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const chunk = line.slice(5).replace(/^ /, '');
|
||||
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
|
||||
}
|
||||
// Autres champs SSE (id:, retry:) ignorés pour le MVP.
|
||||
}
|
||||
}
|
||||
// Fin de stream côté réseau sans event: done explicite → on complete quand même.
|
||||
if (currentEvent !== null || currentData !== '') dispatchCurrentEvent();
|
||||
subscriber.complete();
|
||||
} catch (err) {
|
||||
subscriber.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
private safeParseToken(json: string): string | null {
|
||||
try {
|
||||
const obj = JSON.parse(json) as { token?: string };
|
||||
return typeof obj.token === 'string' ? obj.token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private safeParseMessage(json: string): string {
|
||||
try {
|
||||
const obj = JSON.parse(json) as { message?: string };
|
||||
return obj.message ?? 'Erreur inconnue côté serveur.';
|
||||
} catch {
|
||||
return json || 'Erreur inconnue côté serveur.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Page, PageCreate } from './page.model';
|
||||
|
||||
/**
|
||||
@@ -45,4 +46,17 @@ export class PageService {
|
||||
const params = new HttpParams().set('q', q);
|
||||
return this.http.get<Page[]>(`${this.apiUrl}/search`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Demande à l'IA (Brain Python, via le Core Java) des suggestions de valeurs
|
||||
* pour les champs dynamiques de la page. Ne modifie PAS la page en base —
|
||||
* l'appelant est responsable de fusionner les valeurs et de sauvegarder.
|
||||
*
|
||||
* Peut prendre plusieurs dizaines de secondes selon le modèle LLM.
|
||||
*/
|
||||
generateValues(pageId: string): Observable<Record<string, string>> {
|
||||
return this.http
|
||||
.post<{ values: Record<string, string> }>(`${this.apiUrl}/${pageId}/generate`, {})
|
||||
.pipe(map(res => res.values ?? {}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<aside class="drawer" [class.drawer-open]="isOpen" 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 }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
</aside>
|
||||
261
web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.scss
Normal file
261
web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.scss
Normal file
@@ -0,0 +1,261 @@
|
||||
:host {
|
||||
// Le drawer lui-même gère son positionnement fixed. Rien à prévoir côté host.
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 380px;
|
||||
height: 100vh;
|
||||
background: #0f0f1a;
|
||||
border-left: 1px solid #1e1e3a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s ease;
|
||||
z-index: 1000;
|
||||
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.drawer-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
// --- Header --------------------------------------------------------------
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #1e1e3a;
|
||||
flex-shrink: 0;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: #1e1e3a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Zone messages -------------------------------------------------------
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.msg {
|
||||
max-width: 85%;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.msg-assistant {
|
||||
align-self: flex-start;
|
||||
background: #1a1a2e;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid #2a2a3d;
|
||||
}
|
||||
|
||||
.msg-user {
|
||||
align-self: flex-end;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.msg-streaming {
|
||||
// Le caret clignotant à la fin donne la sensation de "l'IA est en train d'écrire"
|
||||
.caret {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 1em;
|
||||
background: #a5b4fc;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-error {
|
||||
align-self: stretch;
|
||||
background: #3f1f1f;
|
||||
color: #fca5a5;
|
||||
border: 1px solid #7f1d1d;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
// --- Indicateur "typing" -------------------------------------------------
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
align-self: flex-start;
|
||||
|
||||
span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #6c63ff;
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.2s infinite ease-in-out both;
|
||||
}
|
||||
span:nth-child(2) { animation-delay: 0.15s; }
|
||||
span:nth-child(3) { animation-delay: 0.3s; }
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
// --- Action primaire -----------------------------------------------------
|
||||
|
||||
.primary-action {
|
||||
padding: 0.75rem 1.25rem 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
.primary-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.9rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
|
||||
&:hover:not(:disabled) { background: #5a52e0; }
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Suggestions rapides -------------------------------------------------
|
||||
|
||||
.quick-suggestions {
|
||||
padding: 0.75rem 1.25rem 0;
|
||||
border-top: 1px solid #1e1e3a;
|
||||
flex-shrink: 0;
|
||||
|
||||
.quick-label {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.quick-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.quick-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 4px;
|
||||
color: #d1d5db;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #2a2a3d;
|
||||
color: white;
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Zone de saisie ------------------------------------------------------
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem 1rem;
|
||||
flex-shrink: 0;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
width: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
|
||||
&:hover:not(:disabled) { background: #5a52e0; }
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
182
web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts
Normal file
182
web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, X, Send, Sparkles, Lightbulb, Wand2 } from 'lucide-angular';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { AiChatService, ChatMessage } from '../../services/ai-chat.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).
|
||||
*/
|
||||
export interface ChatPrimaryAction {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drawer de chat IA réutilisable — panneau fixe à droite de l'écran.
|
||||
*
|
||||
* 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é).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-ai-chat-drawer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './ai-chat-drawer.component.html',
|
||||
styleUrls: ['./ai-chat-drawer.component.scss']
|
||||
})
|
||||
export class AiChatDrawerComponent implements OnDestroy {
|
||||
readonly X = X;
|
||||
readonly Send = Send;
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly Lightbulb = Lightbulb;
|
||||
readonly Wand2 = Wand2;
|
||||
|
||||
@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;
|
||||
@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() 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;
|
||||
|
||||
@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;
|
||||
|
||||
private streamSub: Subscription | null = null;
|
||||
|
||||
constructor(private readonly chatService: AiChatService) {}
|
||||
|
||||
// --- Handlers UI --------------------------------------------------------
|
||||
|
||||
onClose(): void {
|
||||
this.abortStream();
|
||||
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;
|
||||
this.sendUserMessage(text);
|
||||
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 -----------------------------------------
|
||||
|
||||
private sendUserMessage(text: string): void {
|
||||
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;
|
||||
|
||||
this.streamSub = this.chatService.streamChat(this.loreId, payload, this.pageId).subscribe({
|
||||
next: (event) => {
|
||||
if (event.type === 'token') {
|
||||
this.currentAssistantText += event.value;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
// 'done' : l'Observable va compléter → géré par complete()
|
||||
},
|
||||
error: (err) => {
|
||||
this.isStreaming = false;
|
||||
this.errorMessage = err?.message ?? 'Erreur inconnue.';
|
||||
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 });
|
||||
this.assistantReply.emit(reply);
|
||||
}
|
||||
this.currentAssistantText = '';
|
||||
this.isStreaming = false;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private abortStream(): void {
|
||||
this.streamSub?.unsubscribe();
|
||||
this.streamSub = null;
|
||||
this.isStreaming = false;
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user