Front-end angularforall.com

- Intégrer un chatbot IA dans une app Angular

Angular Chatbot Ia Llm Openai Streaming-Sse Signals Fetch-Api Readablestream Abortcontroller Backend-Proxy Ngx-Markdown
Intégrer un chatbot IA dans une app Angular

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.
À retenir : un bon chatbot IA n'est pas « un wrapper autour d'OpenAI ». C'est un système avec un backend proxy, un état conversationnel propre, du streaming, de la modération et un fallback. Angular fournit la couche UI réactive ; le reste se passe côté serveur.

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 Signal d'historique, persiste en localStorage, ouvre le flux fetch vers 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 { /* ... */ }
}
Note : chaque message porte un 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'));
Pièges classiques : derrière Nginx, le buffer compresse les chunks et casse le streaming — d'où le header 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>
}
Sécurité XSS : ne jamais utiliser [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.

Checklist production :
  • 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 /moderations d'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-mini en 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'innerHTML direct.

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.

Récapitulatif des bonnes pratiques :
  • Toujours un backend proxy — la clé API ne quitte jamais le serveur
  • Utiliser fetch + ReadableStream, pas HttpClient, pour le streaming token par token
  • Stocker l'historique dans un Signal<Message[]> avec effect() de persistance localStorage
  • Tronquer la fenêtre de contexte envoyée au LLM (16 derniers messages ou budget tokens)
  • Rendre le Markdown via ngx-markdown avec SecurityContext.HTML activé
  • Implémenter AbortController pour 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 user via /moderations avant 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-alert du design system

Partager