Construisez un chatbot IA Angular avec Signals, streaming SSE, AbortController, historique persistant et backend proxy securise pret pour la production.
Pourquoi intégrer un chatbot IA dans Angular ?
L'arrivée des LLM (OpenAI GPT-4o, Anthropic Claude, Groq LLaMA, Mistral) a transformé le rôle d'un chatbot dans une application web. On ne déroule plus un arbre de décisions scripté ; on délègue la compréhension du langage à un modèle, et l'application Angular se concentre sur l'orchestration : envoyer le contexte, afficher la réponse en streaming, gérer l'historique, et brancher d'éventuels outils (recherche dans la base, appel API métier, fonctions personnalisées).
Dans une app Angular 17+, on a tout ce qu'il faut pour une expérience temps réel propre : Signals pour l'état réactif, fetch + ReadableStream pour le streaming token par token, @for/@if pour un template compact, et HttpResource pour les requêtes one-shot. Ce guide construit un chatbot complet, type-safe, prêt pour la production, sans aucune librairie tierce de chat.
Cas d'usage typiques
- Assistant de documentation — répond aux questions des utilisateurs sur votre produit en piochant dans vos articles (pattern RAG).
- Support client niveau 1 — gère les FAQ, escalade vers un humain quand l'intent est ambigu.
- Copilote interne — outil métier qui consulte votre base, rédige des emails, génère des rapports.
- Onboarding produit — accompagne le premier utilisateur, propose des actions contextuelles.
Architecture 3-tiers et règle d'or de sécurité
Un chatbot IA Angular repose toujours sur la même architecture en trois couches. La connaître évite 90 % des erreurs vues en revue de code.
Les trois couches
- 1. Composant Angular (UI) — affiche les messages, capte la saisie utilisateur, déclenche le scroll, montre l'indicateur de frappe. Aucune logique IA ici.
- 2. Service Angular (orchestration client) — détient le
Signald'historique, persiste enlocalStorage, ouvre le fluxfetchvers le backend, parse les chunks SSE, gère l'AbortController. - 3. Backend proxy (Node, NestJS, PHP) — détient la clé API LLM, valide l'utilisateur, applique du rate limiting, appelle le modèle en mode stream et relaie les tokens.
Pourquoi un backend proxy est non négociable
Tout ce qui est compilé dans votre bundle Angular est public — y compris une variable d'environnement, un fichier environment.prod.ts ou une constante API_KEY. En appelant https://api.openai.com directement depuis le navigateur, votre clé apparaît dans l'onglet Network, et n'importe quel utilisateur peut la copier et facturer votre compte à hauteur de plusieurs milliers d'euros en quelques heures.
Le backend proxy joue cinq rôles, qu'aucune solution « tout-frontend » ne peut couvrir : conservation du secret, authentification par utilisateur, rate limiting (par IP et par compte), modération du prompt, et journalisation des coûts en base.
Schéma du flux complet
// Flux d'un message utilisateur
[Composant] --userInput--> [ChatService]
|
|--> signal: messages.update(...) // optimistic UI
|
|--> fetch('/api/chat', { method: POST, body: { messages, model } })
|
v
[Backend Express + Auth JWT + Rate Limit]
|
|--> openai.chat.completions.create({ stream: true })
| |
| v
| [OpenAI / Claude / Groq]
| |
| |--> tokens streamés
| |
| res.write(token) // SSE / chunked
|
ReadableStream <-- chaque chunk décodé
|
signal: messages.update(...) // concat dans le dernier message
|
Template Angular met à jour l'UI automatiquement
Cette séparation permet aussi de changer de LLM sans toucher au frontend — c'est le backend qui décide d'utiliser gpt-4o en prod et llama-3.1-8b-instant sur Groq en dev.
ChatService — Signals, typage et état réactif
Le service centralise toute la logique du chatbot. Il expose trois Signals lus directement par les composants : messages, isLoading, error. Le typage strict d'un message évite les classes de bugs liées à un rôle inattendu.
Types et structure
// chat.types.ts
// Le rôle suit la convention OpenAI/Anthropic — 'system' configure
// la personnalité, 'user' est l'humain, 'assistant' est le modèle.
export type Role = 'system' | 'user' | 'assistant';
export interface Message {
id: string; // uuid local pour le trackBy de @for
role: Role;
content: string; // peut être Markdown
createdAt: number; // Date.now() — sert au tri et à l'UI "envoyé il y a X"
status?: 'sending' | 'streaming' | 'done' | 'error';
}
export interface ChatError {
code: 'network' | 'rate_limit' | 'server' | 'aborted';
message: string;
}
Service avec Signals (Angular 19)
// chat.service.ts
import { Injectable, signal, computed, inject } from '@angular/core';
import { Message, ChatError } from './chat.types';
@Injectable({ providedIn: 'root' })
export class ChatService {
// URL du backend proxy — JAMAIS l'URL OpenAI directe
private readonly apiUrl = '/api/chat';
// État réactif exposé aux composants
readonly messages = signal<Message[]>([]);
readonly isLoading = signal(false);
readonly error = signal<ChatError | null>(null);
// Computed : seulement les messages affichables (on cache le system prompt)
readonly visibleMessages = computed(() =>
this.messages().filter(m => m.role !== 'system')
);
// Référence à l'AbortController courant pour le bouton "Stop"
private currentAbort: AbortController | null = null;
/**
* Envoie un message utilisateur et stream la réponse de l'assistant.
* Le placeholder "streaming" est ajouté immédiatement pour un effet UI fluide.
*/
async send(userInput: string): Promise<void> {
if (!userInput.trim() || this.isLoading()) return;
this.error.set(null);
// 1. Optimistic UI — message utilisateur visible avant l'appel réseau
this.messages.update(m => [
...m,
this.makeMessage('user', userInput, 'done'),
this.makeMessage('assistant', '', 'streaming'), // placeholder
]);
this.isLoading.set(true);
this.currentAbort = new AbortController();
try {
await this.streamResponse(this.currentAbort.signal);
this.updateLastAssistant({ status: 'done' });
} catch (e: unknown) {
this.handleError(e);
} finally {
this.isLoading.set(false);
this.currentAbort = null;
}
}
abort(): void {
this.currentAbort?.abort();
}
clear(): void {
this.messages.set([]);
this.error.set(null);
}
private makeMessage(role: Message['role'], content: string, status: Message['status']): Message {
return { id: crypto.randomUUID(), role, content, createdAt: Date.now(), status };
}
private updateLastAssistant(patch: Partial<Message>): void {
this.messages.update(m => {
const last = m[m.length - 1];
if (!last || last.role !== 'assistant') return m;
return [...m.slice(0, -1), { ...last, ...patch }];
});
}
// streamResponse() — implémentée en section 6
private async streamResponse(signal: AbortSignal): Promise<void> { /* ... */ }
private handleError(e: unknown): void { /* ... */ }
}
id uuid distinct. C'est essentiel pour @for (msg of messages(); track msg.id) — Angular saute le re-rendu des bulles inchangées et n'anime que celle qui streame.
Composant Chat — template moderne avec @for et @if
Le composant reste mince : il lit l'état du service, déclenche les actions, et délègue tout le reste. Le template utilise la nouvelle syntaxe de control flow d'Angular 17+ et la classe utilitaire .af-alert pour les erreurs (jamais alert() natif).
// chat.component.ts
import { Component, inject, ElementRef, viewChild, effect, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ChatService } from './chat.service';
@Component({
selector: 'app-chat',
standalone: true,
imports: [FormsModule],
templateUrl: './chat.component.html',
styleUrl: './chat.component.css',
})
export class ChatComponent {
protected readonly chat = inject(ChatService);
// Référence templates via la nouvelle API viewChild() en signal
private readonly messagesEl = viewChild<ElementRef<HTMLDivElement>>('messagesContainer');
readonly userInput = signal('');
constructor() {
// Auto-scroll réactif — se déclenche dès que messages() change
effect(() => {
this.chat.messages(); // dépendance explicite
queueMicrotask(() => {
const el = this.messagesEl()?.nativeElement;
if (el) el.scrollTop = el.scrollHeight;
});
});
}
onSubmit(): void {
const input = this.userInput().trim();
if (!input) return;
this.userInput.set('');
this.chat.send(input);
}
}
<!-- chat.component.html -->
<div class="chat-container">
<header class="chat-header">
<h3>Assistant</h3>
<button type="button" (click)="chat.clear()" aria-label="Nouvelle conversation">
Nouvelle conversation
</button>
</header>
<div class="messages" #messagesContainer role="log" aria-live="polite">
@for (msg of chat.visibleMessages(); track msg.id) {
<div class="message" [class]="msg.role">
<span class="bubble">{{ msg.content }}</span>
@if (msg.status === 'streaming' && !msg.content) {
<span class="typing-indicator" aria-label="L'assistant rédige">
<span></span><span></span><span></span>
</span>
}
</div>
} @empty {
<p class="chat-empty">Posez votre première question pour démarrer la conversation.</p>
}
</div>
@if (chat.error(); as err) {
<div class="af-alert af-alert--danger" role="alert">
<span class="af-alert__icon">⚠️</span>
<span>{{ err.message }}</span>
</div>
}
<form class="chat-input" (ngSubmit)="onSubmit()">
<input
type="text"
name="message"
[ngModel]="userInput()"
(ngModelChange)="userInput.set($event)"
placeholder="Votre question…"
aria-label="Saisissez votre message"
[disabled]="chat.isLoading()">
@if (chat.isLoading()) {
<button type="button" (click)="chat.abort()">Arrêter</button>
} @else {
<button type="submit" [disabled]="!userInput().trim()">Envoyer</button>
}
</form>
</div>
Le role="log" et aria-live="polite" sur le conteneur des messages permettent aux lecteurs d'écran d'annoncer les nouvelles réponses sans interrompre l'utilisateur. C'est une exigence WCAG 2.1 souvent oubliée sur les chatbots.
Backend Node/Express — streaming SSE avec OpenAI
Le backend est court mais critique. Il authentifie, applique un rate limit, valide l'entrée, et stream les tokens du LLM vers le client. Ici en Node 20 + Express + OpenAI SDK officiel, mais l'équivalent en NestJS ou Fastify est trivial.
// server.ts
import express from 'express';
import OpenAI from 'openai';
import rateLimit from 'express-rate-limit';
import { verifyJwt } from './auth';
const app = express();
app.use(express.json({ limit: '100kb' })); // un message ne dépasse jamais 100ko
// Clé API uniquement côté serveur — variable d'environnement
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
// Rate limit anti-abus : 20 requêtes/min par IP
const chatLimiter = rateLimit({
windowMs: 60_000,
max: 20,
message: { error: 'rate_limit' },
});
// Configuration du modèle — modifiable sans toucher au frontend
const SYSTEM_PROMPT = `Tu es l'assistant d'AngularForAll. Réponds en français,
concis, oriente vers les articles du site quand pertinent. Refuse poliment
les sujets hors développement web.`;
app.post('/api/chat', chatLimiter, verifyJwt, async (req, res) => {
const { messages } = req.body as { messages: { role: string; content: string }[] };
// Validation minimale — refuse les payloads trop gros ou mal formés
if (!Array.isArray(messages) || messages.length === 0 || messages.length > 50) {
return res.status(400).json({ error: 'bad_request' });
}
// Headers SSE — désactive le buffer Nginx via X-Accel-Buffering
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked');
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('Cache-Control', 'no-cache, no-transform');
try {
const stream = await openai.chat.completions.create({
model: 'gpt-4o-mini',
stream: true,
temperature: 0.7,
max_tokens: 1024,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
...messages,
],
});
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content ?? '';
if (delta) res.write(delta);
// Important : flush pour pousser le token tout de suite vers le client
if (typeof (res as any).flush === 'function') (res as any).flush();
}
res.end();
} catch (err) {
// Si la connexion est encore ouverte, on signale l'erreur en queue de flux
if (!res.writableEnded) {
res.write(`\n[ERROR] ${(err as Error).message}`);
res.end();
}
}
});
app.listen(3000, () => console.log('Chat API on :3000'));
X-Accel-Buffering: no. Sur Vercel/Netlify, vérifiez que la route est en Edge Function, pas Serverless (les serverless bufferisent souvent la réponse complète).
Streaming côté Angular — fetch et ReadableStream
C'est la pièce la plus délicate. HttpClient ne convient pas car il attend la fin de la réponse avant de la livrer. On utilise donc directement fetch() qui expose un ReadableStream dans response.body. Chaque chunk est décodé et concaténé dans le dernier message assistant.
// chat.service.ts (suite)
private async streamResponse(signal: AbortSignal): Promise<void> {
// On envoie l'historique SANS le placeholder vide qu'on vient d'ajouter
const payload = this.messages()
.slice(0, -1)
.map(({ role, content }) => ({ role, content }));
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: payload }),
signal, // permet l'annulation
});
if (!response.ok) {
// 429 = rate limit, 401 = pas authentifié, 500 = serveur HS
const code = response.status === 429 ? 'rate_limit' : 'server';
throw { code, message: `Erreur ${response.status}` } as ChatError;
}
const reader = response.body!.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// stream: true permet de gérer les caractères multi-bytes coupés
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
// Concatène dans le dernier message assistant (le placeholder)
this.messages.update(m => {
const last = m[m.length - 1];
if (!last || last.role !== 'assistant') return m;
const updated = { ...last, content: last.content + chunk };
return [...m.slice(0, -1), updated];
});
}
}
private handleError(e: unknown): void {
// AbortError = utilisateur a cliqué "Arrêter" — pas une erreur visible
if (e instanceof DOMException && e.name === 'AbortError') {
this.updateLastAssistant({ status: 'done' });
return;
}
const err = (e as ChatError)?.code
? (e as ChatError)
: { code: 'network' as const, message: 'Connexion impossible. Réessayez.' };
this.error.set(err);
this.updateLastAssistant({ status: 'error' });
}
Pourquoi pas HttpClient ?
Le client HTTP d'Angular est conçu pour des requêtes type RPC : il accumule la réponse complète avant de l'émettre dans l'Observable. Avec fetch + ReadableStream, chaque token apparaît dès qu'il arrive. C'est ce qui donne l'effet « machine à écrire » qu'attendent les utilisateurs depuis ChatGPT.
Variante format SSE strict (data: ...)
Si votre backend formate ses tokens selon le protocole SSE officiel (data: {...}\n\n), parsez ligne par ligne :
// Parser SSE complet
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Le protocole SSE sépare les événements par une ligne vide
const events = buffer.split('\n\n');
buffer = events.pop() ?? ''; // dernier morceau peut être incomplet
for (const evt of events) {
const line = evt.trim();
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6); // retire "data: "
if (payload === '[DONE]') return; // convention OpenAI
const json = JSON.parse(payload);
const token = json.choices?.[0]?.delta?.content ?? '';
if (token) this.appendToken(token);
}
}
Historique persistant et fenêtre de contexte
Sans persistance, l'utilisateur perd sa conversation à chaque rechargement. Sans troncature, vous explosez le quota de tokens du modèle et le coût. La bonne stratégie combine les deux.
Persistance en localStorage avec effect()
// chat.service.ts (extrait — initialisation et persistance)
import { Injectable, signal, effect } from '@angular/core';
const STORAGE_KEY = 'chat-history-v1';
const MAX_PERSISTED = 100; // évite de remplir localStorage indéfiniment
@Injectable({ providedIn: 'root' })
export class ChatService {
readonly messages = signal<Message[]>(this.load());
constructor() {
// Sauvegarde automatique à chaque changement — Angular gère le debounce naturel
effect(() => {
const snapshot = this.messages().slice(-MAX_PERSISTED);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
// QuotaExceeded — on tronque davantage et on retente
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot.slice(-20)));
}
});
}
private load(): Message[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as Message[];
// Réinitialise les statuts "streaming" orphelins (page rechargée en plein stream)
return parsed.map(m => m.status === 'streaming' ? { ...m, status: 'done' } : m);
} catch {
return [];
}
}
}
Troncature intelligente du contexte envoyé
Le modèle a une fenêtre limitée (8k, 32k, 128k tokens selon le modèle). Plus l'historique est long, plus la requête coûte cher et plus elle ralentit. On envoie donc une fenêtre glissante : les N derniers messages, en gardant systématiquement le premier system message s'il existe.
// Sélectionne les messages pertinents pour le contexte LLM
private buildContext(): Message[] {
const KEEP_LAST = 16;
const all = this.messages();
const system = all.filter(m => m.role === 'system');
const others = all.filter(m => m.role !== 'system');
const window = others.slice(-KEEP_LAST);
// On garantit que la fenêtre commence par un message 'user' pour
// ne pas envoyer une suite assistant-assistant qui perturbe le modèle.
while (window.length && window[0].role === 'assistant') window.shift();
return [...system, ...window];
}
Pour une troncature au nombre de tokens (plus précise et plus économe), utilisez tiktoken côté backend — le frontend ne sait pas compter les tokens de façon fiable.
Rendu Markdown et code colorisé sécurisé
Un LLM répond systématiquement en Markdown : titres, listes, blocs de code, gras. L'afficher en texte brut casse l'expérience. La bonne approche : ngx-markdown + prismjs avec sanitization activée pour bloquer toute injection de HTML malveillant.
Installation et import
# installation
npm install ngx-markdown marked prismjs
# Optionnel : prismjs themes
# Importer le CSS Prism dans angular.json ou styles.css
// chat.component.ts (import ngx-markdown)
import { MarkdownModule, MarkdownService, SECURITY_CONTEXT } from 'ngx-markdown';
import { SecurityContext } from '@angular/core';
@Component({
selector: 'app-chat',
standalone: true,
imports: [FormsModule, MarkdownModule],
providers: [
// Active le DomSanitizer d'Angular sur le HTML rendu
{ provide: SECURITY_CONTEXT, useValue: SecurityContext.HTML },
],
templateUrl: './chat.component.html',
})
export class ChatComponent { /* ... */ }
Template — bulle assistant en Markdown
<!-- bulle assistant : Markdown ; bulle user : texte brut -->
@for (msg of chat.visibleMessages(); track msg.id) {
<div class="message" [class]="msg.role">
@if (msg.role === 'assistant') {
<markdown
class="bubble"
[data]="msg.content"
[lineHighlight]="false"
clipboard>
</markdown>
} @else {
<span class="bubble">{{ msg.content }}</span>
}
</div>
}
[innerHTML] directement avec le contenu d'un LLM. Un attaquant peut amener le modèle à renvoyer <img src=x onerror=...> via une question piégée (prompt injection). Le sanitizer d'Angular et l'option SecurityContext.HTML sont obligatoires.
UX avancée — auto-scroll, abort, retry et indicators
Les détails d'UX font la différence entre un démo cool et un produit utilisable. Voici les patterns à connaître.
Auto-scroll intelligent (ne scrolle pas si l'utilisateur a remonté)
// chat.component.ts — auto-scroll respectueux
private readonly stickToBottom = signal(true);
onMessagesScroll(ev: Event): void {
const el = ev.target as HTMLDivElement;
// Tolérance de 40px — si on est proche du bas, on recolle au bas
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
this.stickToBottom.set(atBottom);
}
constructor() {
effect(() => {
this.chat.messages();
if (!this.stickToBottom()) return; // utilisateur en train de relire — on ne touche pas
queueMicrotask(() => {
const el = this.messagesEl()?.nativeElement;
if (el) el.scrollTop = el.scrollHeight;
});
});
}
Bouton « Retry » sur la dernière réponse
// chat.service.ts — relancer la dernière question
async retry(): Promise<void> {
const all = this.messages();
// On retire la dernière paire user/assistant et on relance avec la question
const lastUserIdx = [...all].reverse().findIndex(m => m.role === 'user');
if (lastUserIdx === -1) return;
const realIdx = all.length - 1 - lastUserIdx;
const userMsg = all[realIdx];
this.messages.set(all.slice(0, realIdx));
await this.send(userMsg.content);
}
Toast non bloquant via .af-alert (jamais alert() natif)
<!-- Toast d'erreur conforme au design system -->
@if (chat.error(); as err) {
<div class="af-alert af-alert--danger af-alert--toast"
role="alert"
aria-live="polite">
<span class="af-alert__icon">⚠️</span>
<span>{{ err.message }}</span>
<button class="af-alert__close" (click)="chat.dismissError()" aria-label="Fermer">×</button>
</div>
}
Indicateur de frappe en CSS pur
/* chat.component.css */
.typing-indicator { display: inline-flex; gap: 4px; padding: 0 6px; }
.typing-indicator span {
width: 6px; height: 6px;
background: var(--af-text-muted, #888);
border-radius: 50%;
animation: typing 1.2s infinite ease-in-out;
}
.typing-indicator span:nth-child(2) { animation-delay: .2s; }
.typing-indicator span:nth-child(3) { animation-delay: .4s; }
@keyframes typing {
0%, 80%, 100% { transform: scale(.6); opacity: .4; }
40% { transform: scale(1); opacity: 1; }
}
Mise en production — sécurité, coûts et modération
Mettre un chatbot IA en ligne, c'est ouvrir un robinet sur l'API d'un fournisseur facturé au token. Sans garde-fous, un script kiddie peut générer plusieurs milliers d'euros de coûts en une nuit. Voici la checklist minimale.
- Auth obligatoire — JWT, session, ou clé API utilisateur. Pas d'accès anonyme.
- Rate limiting double — par IP (anti-bot) et par utilisateur (anti-abus du même compte).
express-rate-limit+ Redis. - Quota par compte — N messages par jour, N par mois. Stocké en base.
- Modération du prompt — appel à l'endpoint
/moderationsd'OpenAI avant l'inférence pour bloquer haine/violence/contenu illégal. - Budget plafond — script qui coupe l'accès si le coût mensuel dépasse un seuil.
- Journalisation — userId, modèle, tokens in/out, coût estimé. Table
chat_usage+ dashboard Grafana. - Modèles séparés dev/prod — Groq
llama-3.1-8b-instant(gratuit) en local,gpt-4o-minien prod. - Timeout côté backend — 60 s max sinon on coupe le stream, sinon une requête vicieuse peut squatter une connexion indéfiniment.
- CSP headers stricts — interdire
script-src 'unsafe-inline'pour bloquer l'XSS en cas de prompt injection. - Sanitization Markdown — voir section précédente, jamais d'
innerHTMLdirect.
Modération avant inférence
// server.ts — pré-modération obligatoire avant gros LLM
const lastUser = messages.findLast(m => m.role === 'user');
const moderation = await openai.moderations.create({
model: 'omni-moderation-latest',
input: lastUser?.content ?? '',
});
if (moderation.results[0]?.flagged) {
return res.status(403).json({ error: 'content_policy' });
}
// Seulement après on déclenche le modèle payant
const stream = await openai.chat.completions.create({ /* ... */ });
Journalisation des coûts
// Après la fin du stream — compter les tokens et logger
const usage = stream.usage; // disponible sur le dernier chunk avec stream_options
await db.chatUsage.create({
userId: req.user.id,
model: 'gpt-4o-mini',
promptTokens: usage?.prompt_tokens ?? 0,
completionTokens: usage?.completion_tokens ?? 0,
costUsd: estimateCost(usage),
createdAt: new Date(),
});
Bascule de modèle sans toucher au frontend
Toutes les API LLM majeures (Groq, Together, Anthropic via leur SDK compatible) acceptent le format OpenAI. Une simple variable d'environnement bascule le frontend de GPT-4o à Llama 3.1 sans déploiement Angular. C'est l'avantage final du backend proxy : votre code Angular n'est jamais lié à un fournisseur.
Conclusion
Un chatbot IA de production dans Angular tient en trois pièces : un ChatService à base de Signals pour l'état réactif, un composant mince exploitant @for/@if et fetch + ReadableStream pour le streaming, et un backend proxy non négociable pour la clé API, le rate limiting et la modération. Le reste — historique persistant, Markdown sécurisé, abort, retry, indicators — n'est que du polish UX qui se branche par-dessus.
Le vrai changement d'Angular 17+, c'est que tout l'état du chat (messages, statuts, indicateurs de frappe, erreurs) tient dans des Signals lus directement par le template. Pas de Subject, pas de async pipe, pas de OnPush à configurer. Combiné à effect() pour la persistance et l'auto-scroll, on obtient un composant ~150 lignes pour une UX comparable à ChatGPT.
- Toujours un backend proxy — la clé API ne quitte jamais le serveur
- Utiliser
fetch + ReadableStream, pasHttpClient, pour le streaming token par token - Stocker l'historique dans un
Signal<Message[]>aveceffect()de persistance localStorage - Tronquer la fenêtre de contexte envoyée au LLM (16 derniers messages ou budget tokens)
- Rendre le Markdown via
ngx-markdownavecSecurityContext.HTMLactivé - Implémenter
AbortControllerpour le bouton « Arrêter » — UX attendue par défaut - Auto-scroll uniquement quand l'utilisateur est déjà en bas (signal
stickToBottom) - Pré-modérer le dernier message
uservia/moderationsavant tout appel au gros modèle - Logger tokens et coût par utilisateur en base — sinon facture inexplicable garantie
- Jamais
alert()natif pour les erreurs — utiliser.af-alertdu design system