Intelligence Artificielle angularforall.com

- Angular + IA : intégrer Claude dans Angular 17+

Angular Claude-Ai Claude-Api Signals Streaming-Sse Anthropic Tool-Use Angular-17 Standalone-Components Prompt-Caching Ia-Generative Typescript Rxjs Assistant-Ia
Angular + IA : intégrer Claude dans Angular 17+

Construisez un assistant IA dans Angular 17+ : service standalone, streaming SSE, Signals, tool use et prompt caching avec l'API Claude d'Anthropic.

Pourquoi Claude dans Angular 19+ ?

Angular 19 et 20 marquent un tournant : standalone par défaut (le flag standalone: true disparaît), signal-based APIs stables (input(), output(), model(), viewChild()), nouveau mode zoneless (provideZonelessChangeDetection), linkedSignal() pour les états dérivés mutables, afterRenderEffect() pour le DOM, et la Resource API (resource(), httpResource()) qui transforme la gestion des requêtes async.

Cette base réactive est parfaitement taillée pour intégrer Claude d'Anthropic : chaque token reçu en streaming SSE déclenche un re-render fin sans subscription RxJS, sans async pipe, sans Zone.js. On construit ici un assistant IA prêt pour la production : service injectable + streaming SSE via fetch, composant chat zoneless piloté 100% par Signals, proxy backend pour ne jamais exposer la clé API, plus tool use, prompt caching, retry, abort et hydration incrémentale en SSR.

Stack moderne Angular 19+ × Claude

Tout ce qu'on va utiliser dans cet article
  • signal(), computed(), linkedSignal() — sources, dérivés, dérivés mutables
  • input.required(), output(), model() — signal inputs/outputs (plus de décorateurs)
  • viewChild(), contentChild() — références DOM en Signals
  • afterRenderEffect(), afterNextRender() — effets DOM safe SSR
  • resource(), httpResource() — chargement async réactif (Angular 20)
  • provideZonelessChangeDetection() — bye Zone.js
  • @if, @for, @let — control flow et bindings réactifs
  • DestroyRef + takeUntilDestroyed() — cleanup automatique

Modèle Claude utilisé et coûts

Modèle Latence (1er token) Prix entrée / 1M tokens Prix sortie / 1M tokens Cas d'usage Angular
claude-haiku-4-5 ~400 ms $1 $5 Chatbot temps réel, autocomplétion, classification
claude-sonnet-4-6 ~700 ms $3 $15 Assistant produit, génération de contenu structuré
claude-opus-4-7 ~1.2 s $15 $75 Analyse de code, raisonnement complexe, agents
Recommandation par défaut : commencez avec claude-haiku-4-5 pour un chatbot Angular. Sa latence rend l'effet typing ultra fluide via SSE, et le coût reste maîtrisé. Bascule sur claude-sonnet-4-6 uniquement quand la qualité l'exige (tool use complexes, raisonnement multi-étapes).

Bootstrap zoneless de l'application

// src/main.ts — Angular 19+ : zoneless + standalone par défaut
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';

bootstrapApplication(AppComponent, {
  providers: [
    // Mode zoneless : Angular ne dépend plus de Zone.js
    // Le change detection se déclenche uniquement sur changement de Signal
    provideZonelessChangeDetection(),

    // HttpClient en mode fetch (compatible httpResource, SSR, edge runtimes)
    provideHttpClient(withFetch()),

    provideRouter(routes),
  ],
});
Polyfills : en zoneless, retirez zone.js de la section polyfills de angular.json. Le bundle initial perd 30 à 50 KB et l'app est compatible avec les runtimes Edge (Cloudflare Workers, Vercel Edge) sans shim.

Architecture sécurisée : proxy backend

Règle absolue : ne jamais embarquer une clé Anthropic dans une app Angular. Tout bundle JavaScript livré au navigateur est public — DevTools, mobile, n'importe quel curl. Une clé fuitée se traduit en factures à 5 chiffres en quelques heures.

Le pattern correct : un proxy backend léger qui détient la clé via une variable d'environnement et expose un endpoint relayant la requête vers Anthropic. L'app Angular ne connaît que l'URL de ce proxy.

Proxy Node.js (Express) — minimal

// server/proxy.js — proxy minimal Express
import express from 'express';
import Anthropic from '@anthropic-ai/sdk';

const app = express();
app.use(express.json());

// La clé reste côté serveur (env var) — JAMAIS dans Angular
const client = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

// Endpoint que l'app Angular appellera
app.post('/api/chat', async (req, res) => {
  // On force les paramètres autorisés (defense-in-depth)
  const { messages, system } = req.body;

  // Validation : pas plus de 50 messages, pas de payload géant
  if (!Array.isArray(messages) || messages.length > 50) {
    return res.status(400).json({ error: 'Bad messages' });
  }

  // On configure les headers pour du streaming SSE vers Angular
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  try {
    // Appel streaming chez Anthropic
    const stream = await client.messages.stream({
      model: 'claude-haiku-4-5',
      max_tokens: 1024,
      system: system ?? 'Tu es un assistant pour AngularForAll.',
      messages,
    });

    // Forward chaque event SSE vers le client
    for await (const event of stream) {
      res.write(`data: ${JSON.stringify(event)}\n\n`);
    }
    res.end();
  } catch (err) {
    res.write(`data: ${JSON.stringify({ type: 'error', error: err.message })}\n\n`);
    res.end();
  }
});

app.listen(3001, () => console.log('Proxy on :3001'));
En prod : ajoutez authentification (JWT, session), rate limiting (express-rate-limit), CORS strict (cors({ origin: 'https://votreapp.com' })), et logs structurés pour pouvoir auditer l'usage de la clé.

Service ClaudeService avec Signals modernes

Côté Angular 19+, on encapsule la communication dans un service injectable racine. On utilise fetch natif pour le streaming SSE (qui requiert un ReadableStream brut), et httpResource() — nouveauté Angular 20 — pour les requêtes non streamées (chargement de l'historique persisté, liste des conversations). On combine signal(), computed() et linkedSignal() pour un état réactif complet.

Squelette du service avec linkedSignal et DestroyRef

// src/app/core/claude.service.ts — Angular 19+
import {
  Injectable,
  inject,
  signal,
  computed,
  linkedSignal,
  DestroyRef,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

// Type minimal pour un message dans l'historique
export interface ChatMessage {
  role: 'user' | 'assistant';
  content: string;
  // Métadonnées Anthropic (tokens, modèle) pour audit
  meta?: { inputTokens?: number; outputTokens?: number; model?: string };
}

@Injectable({ providedIn: 'root' })
export class ClaudeService {
  // DestroyRef : cleanup automatique des subscriptions/listeners
  private readonly destroyRef = inject(DestroyRef);

  // URL de notre proxy — JAMAIS l'URL d'Anthropic directement
  private readonly endpoint = '/api/chat';

  // Signal-source : historique complet du chat
  readonly messages = signal<ChatMessage[]>([]);

  // Signal-source : état de streaming en cours
  readonly streaming = signal<boolean>(false);

  // Signal-source : draft assistant en cours d'écriture (token par token)
  readonly draft = signal<string>('');

  // linkedSignal() : dérivé MUTABLE qui se réinitialise quand la source change
  // Ici : compteur de tokens local qui se reset à chaque nouvelle conversation
  readonly tokenBudget = linkedSignal({
    source: this.messages,
    computation: (msgs, prev) => {
      // Si on repart de zéro (clear), on remet à 0
      if (msgs.length === 0) return 0;
      // Sinon on garde la valeur courante (mutable via .set/.update)
      return prev?.value ?? 0;
    },
  });

  // computed() : longueur de la conversation, recalcul auto
  readonly count = computed(() => this.messages().length);

  // computed() : dernier message utilisateur (analytics)
  readonly lastUser = computed(() =>
    [...this.messages()].reverse().find(m => m.role === 'user')?.content ?? null
  );

  // computed() : booléen "peut envoyer" pour binding direct dans le template
  readonly canSend = computed(() => !this.streaming());

  // Méthode principale : envoyer un message et streamer la réponse
  async send(userInput: string): Promise<void> {
    if (this.streaming()) return;

    // 1. Ajout du message user (Signal réactif — le template re-render seul)
    this.messages.update(prev => [...prev, { role: 'user', content: userInput }]);

    // 2. Bascule en mode streaming
    this.streaming.set(true);
    this.draft.set('');

    try {
      await this.streamFromProxy();
    } catch (err) {
      console.error('Claude error:', err);
      this.draft.set('[Erreur de communication avec l\'assistant]');
    } finally {
      // 3. Commit du draft dans l'historique
      this.messages.update(prev => [
        ...prev,
        { role: 'assistant', content: this.draft() },
      ]);
      this.draft.set('');
      this.streaming.set(false);
    }
  }

  // Reset complet (nouveau chat)
  reset(): void {
    this.messages.set([]);
    this.draft.set('');
    this.streaming.set(false);
    // tokenBudget se reset automatiquement via linkedSignal
  }

  // Voir section suivante pour l'implémentation streaming
  private async streamFromProxy(): Promise<void> { /* ... */ }
}

Bonus : précharger l'historique avec httpResource() (Angular 20)

// src/app/core/conversation.resource.ts — Resource API
import { Injectable, inject, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';

@Injectable({ providedIn: 'root' })
export class ConversationStore {
  // Signal qui drive la requête : changer l'ID = nouveau fetch automatique
  readonly conversationId = signal<string | null>(null);

  // httpResource() : ressource async réactive
  // Re-fetch automatique quand conversationId() change
  // Expose .value(), .status(), .error(), .isLoading() en Signals
  readonly history = httpResource<ChatMessage[]>(() => {
    const id = this.conversationId();
    return id
      ? { url: `/api/conversations/${id}`, method: 'GET' }
      : undefined; // undefined = ne pas déclencher de requête
  });

  // Le composant lit directement : history.value(), history.isLoading()
  // Plus besoin d'AsyncPipe, d'observable, de subscribe/unsubscribe
}
Signals vs BehaviorSubject : avec un Signal, le template lit {{ draft() }} et Angular détecte le changement sans async pipe ni OnPush manuel. En mode zoneless, le re-render se limite aux nœuds qui dépendent du Signal — exactement ce qu'on veut pour un effet typing à 60 fps.
Quand utiliser linkedSignal() ? Quand un état dérivé doit rester modifiable mais se réinitialiser sur changement de la source. Exemple : un index de message sélectionné qui revient à 0 quand l'historique est vidé, mais que l'utilisateur peut changer librement.

Streaming SSE token par token

Le streaming Server-Sent Events permet d'afficher la réponse de Claude au fur et à mesure qu'elle est générée, plutôt que d'attendre 3-8 secondes la réponse complète. C'est la différence entre une UX "je tape" et un spinner figé.

Lire le ReadableStream de fetch

// src/app/core/claude.service.ts (suite)
private async streamFromProxy(): Promise<void> {
  // Préparer le payload pour le proxy
  const payload = {
    messages: this.messages().map(m => ({ role: m.role, content: m.content })),
  };

  // fetch() retourne un ReadableStream sur response.body
  const response = await fetch(this.endpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });

  if (!response.ok || !response.body) {
    throw new Error(`HTTP ${response.status}`);
  }

  // Reader sur le flux d'octets
  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  let buffer = '';

  // Boucle de lecture jusqu'à fin de stream
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    // Accumulation : un event SSE peut être coupé en plusieurs chunks
    buffer += decoder.decode(value, { stream: true });

    // Découpe par double-newline (séparateur SSE)
    const events = buffer.split('\n\n');
    buffer = events.pop() ?? ''; // dernier morceau peut être incomplet

    for (const raw of events) {
      // Format SSE : chaque ligne commence par "data: "
      const line = raw.replace(/^data: /, '').trim();
      if (!line) continue;

      const event = JSON.parse(line);

      // Anthropic émet plusieurs types d'events
      // On ne s'intéresse qu'aux deltas de texte
      if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
        // Append au draft Signal — déclenche re-render du template
        this.draft.update(prev => prev + event.delta.text);
      }

      if (event.type === 'message_stop') {
        return; // fin propre
      }
    }
  }
}

Format des events Anthropic

// Exemples d'events reçus en streaming SSE
{ "type": "message_start", "message": { "id": "msg_01...", "model": "claude-haiku-4-5" } }
{ "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" } }
{ "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Bonjour" } }
{ "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": " ! Je" } }
{ "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": " suis" } }
{ "type": "message_delta", "delta": { "stop_reason": "end_turn" }, "usage": { "output_tokens": 42 } }
{ "type": "message_stop" }
À surveiller : les chunks fetch peuvent contenir plusieurs events ou un event coupé en deux. Le buffer + split sur \n\n est crucial. Sans ça, vous verrez des erreurs JSON.parse aléatoires en charge.

Composant chat zoneless (signal inputs, viewChild, afterRenderEffect)

On exploite ici tout l'arsenal moderne d'Angular 19+ : standalone par défaut (plus de standalone: true), signal inputs/outputs/model pour le contrat composant, viewChild() en Signal pour la référence DOM, et afterRenderEffect() pour l'auto-scroll — safe en SSR car il ne s'exécute que côté navigateur après chaque render.

Composant ChatComponent — Angular 19+

// src/app/chat/chat.component.ts
import {
  Component,
  inject,
  signal,
  computed,
  input,
  output,
  model,
  viewChild,
  afterRenderEffect,
  ElementRef,
  ChangeDetectionStrategy,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ClaudeService } from '../core/claude.service';

@Component({
  selector: 'app-chat',
  // PAS de "standalone: true" : c'est le défaut depuis Angular 19
  imports: [FormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="chat-shell">
      <!-- @let : binding réactif local au template (Angular 18+) -->
      @let history = claude.messages();
      @let isStreaming = claude.streaming();

      <header class="chat-header">
        <h2>{{ title() }}</h2>
        <button type="button" (click)="onClear()" [disabled]="isStreaming">
          Nouveau chat
        </button>
      </header>

      <!-- viewChild() en Signal : on récupère l'élément DOM réactivement -->
      <ul #log class="chat-log">
        @for (msg of history; track $index) {
          <li [class]="msg.role">
            <strong>{{ msg.role === 'user' ? 'Vous' : 'Claude' }} :</strong>
            {{ msg.content }}
          </li>
        } @empty {
          <li class="empty">Démarrez la conversation avec Claude.</li>
        }

        <!-- Draft en cours d'écriture (token par token) -->
        @if (isStreaming) {
          <li class="assistant streaming">
            <strong>Claude :</strong> {{ claude.draft() }}<span class="cursor">▋</span>
          </li>
        }
      </ul>

      <p class="chat-meta">{{ statusLabel() }}</p>

      <form (ngSubmit)="submit()">
        <!-- model() : two-way binding signal-based -->
        <input
          type="text"
          [(ngModel)]="draftInput"
          name="msg"
          [disabled]="isStreaming"
          [placeholder]="placeholder()"
        />
        <button type="submit" [disabled]="!canSubmit()">
          Envoyer
        </button>
      </form>
    </div>
  `,
})
export class ChatComponent {
  // Injection moderne (sans constructor)
  protected readonly claude = inject(ClaudeService);

  // === SIGNAL INPUTS (remplacent @Input) ===
  // input.required() : input obligatoire, garanti non-undefined
  readonly title = input.required<string>();
  // input() : input optionnel avec valeur par défaut
  readonly placeholder = input<string>('Posez votre question à Claude…');

  // === SIGNAL OUTPUT (remplace @Output + EventEmitter) ===
  readonly cleared = output<void>();

  // === MODEL : two-way binding signal-based ===
  // Permet [(draftInput)]="..." côté parent
  readonly draftInput = model<string>('');

  // === viewChild() Signal : référence DOM réactive ===
  // Remplace @ViewChild + ngAfterViewInit
  private readonly logRef = viewChild<ElementRef<HTMLUListElement>>('log');

  // === COMPUTED : dérivés synchrones ===
  protected readonly statusLabel = computed(() => {
    if (this.claude.streaming()) return 'Claude réfléchit…';
    const n = this.claude.count();
    return n === 0 ? 'Démarrez la conversation' : `${n} messages échangés`;
  });

  protected readonly canSubmit = computed(() =>
    this.draftInput().trim().length > 0 && !this.claude.streaming()
  );

  constructor() {
    // afterRenderEffect() : effet de rendu DOM, ne s'exécute QUE côté navigateur
    // Safe en SSR (skip côté serveur), s'exécute après chaque render touchant le DOM
    afterRenderEffect(() => {
      // Lecture des Signals : Angular trace les dépendances automatiquement
      this.claude.count();   // re-fire à chaque nouveau message
      this.claude.draft();   // re-fire à chaque token streamé
      const el = this.logRef()?.nativeElement;
      if (el) el.scrollTop = el.scrollHeight;
    });
  }

  submit(): void {
    const text = this.draftInput().trim();
    if (!text) return;
    this.draftInput.set('');
    this.claude.send(text);
  }

  onClear(): void {
    this.claude.reset();
    this.cleared.emit(); // signal output
  }
}

Utilisation parent — self-closing tags + binding propre

<!-- src/app/app.component.html — tags auto-fermants Angular 17+ -->
<app-chat
  title="Assistant AngularForAll"
  placeholder="Une question sur Angular ?"
  [(draftInput)]="userDraft"
  (cleared)="onChatCleared()"
/>
Zoneless × Signals = perf garantie : en mode provideZonelessChangeDetection() + Signals partout, Angular re-render uniquement les nœuds dont une dépendance Signal a changé. Le coût d'un chat avec 1000 messages est constant — pas de re-render de toute la liste à chaque token, et zéro overhead Zone.js.
Pourquoi afterRenderEffect() et pas effect() ? effect() peut s'exécuter avant que le DOM soit à jour (et plante en SSR si on touche au document). afterRenderEffect() garantit que le DOM est synchronisé et ne s'exécute jamais côté serveur — le bon outil pour scroll, focus, mesures de layout.
Migration @Inputinput() : exécutez ng generate @angular/core:signals pour migrer automatiquement vos décorateurs vers les signal-based APIs. Le schematic gère @Input, @Output, @ViewChild, @ContentChild dans toute la codebase.

Tool use : connecter Claude à vos APIs

Tool use permet à Claude d'appeler des fonctions que vous définissez. Côté Angular, vous ne voyez rien de cette mécanique — elle se passe entièrement côté backend. Le composant chat reçoit simplement la réponse finale, déjà enrichie des données récupérées.

Déclarer un tool côté proxy

// server/proxy.js — extension avec tool use
const tools = [
  {
    name: 'get_user_orders',
    description: 'Retourne les commandes récentes d\'un utilisateur connecté.',
    input_schema: {
      type: 'object',
      properties: {
        userId: { type: 'string', description: 'Identifiant interne utilisateur' },
        limit:  { type: 'number', description: 'Nombre max de commandes (défaut 5)' },
      },
      required: ['userId'],
    },
  },
];

// Implémentation locale — appel à votre vraie base de données
async function executeTool(name, args, currentUser) {
  if (name === 'get_user_orders') {
    // Sécurité : on ignore args.userId fourni par Claude, on prend celui authentifié
    const userId = currentUser.id;
    const limit  = Math.min(args.limit ?? 5, 20);
    return await db.query('SELECT * FROM orders WHERE user_id=$1 LIMIT $2', [userId, limit]);
  }
  throw new Error(`Tool inconnu : ${name}`);
}

Boucle agentique côté backend

// server/proxy.js — gestion du cycle tool_use → tool_result
async function runChat(messages, currentUser) {
  let response = await client.messages.create({
    model: 'claude-sonnet-4-6', // tool use marche mieux avec Sonnet
    max_tokens: 1024,
    tools,
    messages,
  });

  // Tant que Claude demande un tool, on l'exécute et on relance
  while (response.stop_reason === 'tool_use') {
    const toolBlock = response.content.find(c => c.type === 'tool_use');

    // Exécution locale de la fonction
    const result = await executeTool(toolBlock.name, toolBlock.input, currentUser);

    // On enrichit l'historique avec la réponse de l'assistant + le résultat
    messages.push({ role: 'assistant', content: response.content });
    messages.push({
      role: 'user',
      content: [{
        type: 'tool_result',
        tool_use_id: toolBlock.id,
        content: JSON.stringify(result),
      }],
    });

    // Nouvelle passe — Claude peut soit appeler un autre tool, soit conclure
    response = await client.messages.create({
      model: 'claude-sonnet-4-6',
      max_tokens: 1024,
      tools,
      messages,
    });
  }

  // stop_reason === "end_turn" → on a la réponse finale en texte
  return response.content.find(c => c.type === 'text').text;
}
Sécurité tool use : ne faites JAMAIS confiance aux input envoyés par Claude — il peut "halluciner" des paramètres. Toujours valider, autoriser, et substituer les valeurs sensibles (userId authentifié, scope) avec celles de la session utilisateur réelle.

Prompt caching pour réduire les coûts

Une app Angular avec un assistant qui répète à chaque requête le même system prompt (instructions produit, FAQ, exemples) gaspille des tokens. Le prompt caching d'Anthropic permet de mettre en cache les portions stables du prompt — les coûts de lecture sur cache tombent à 10% du tarif normal.

Activer le cache sur le system prompt

// server/proxy.js — system prompt mis en cache
const SYSTEM_PROMPT = `Tu es l'assistant de support d'AngularForAll.
Tu réponds toujours en français, de façon technique et concise.
Voici la documentation produit complète :
${productDocsMarkdown} // 50 000 tokens de doc statique
`;

const response = await client.messages.create({
  model: 'claude-sonnet-4-6',
  max_tokens: 1024,
  system: [
    {
      type: 'text',
      text: SYSTEM_PROMPT,
      cache_control: { type: 'ephemeral' }, // ← active le cache (TTL 5 min)
    },
  ],
  messages,
});

// La 1re requête écrit le cache (coût normal + 25%)
// Toutes les suivantes dans les 5 min : lecture cache à 10% du prix
console.log('Cache hit:', response.usage.cache_read_input_tokens);
console.log('Cache write:', response.usage.cache_creation_input_tokens);

Économies réalistes pour un chatbot Angular

Scénario Sans cache Avec cache (5 min TTL) Économie
50k tokens system, 100 requêtes / 5 min $15.00 $1.95 87 %
10k tokens system, 1000 req / jour $30.00 $4.20 86 %
Critère de cache : le bloc mis en cache doit faire au minimum 1024 tokens (Sonnet) ou 2048 tokens (Haiku). Sous ce seuil, cache_control est silencieusement ignoré — vérifiez usage.cache_read_input_tokens dans la réponse.

Gestion d'erreurs, retry, coûts, SSR

En production, trois types d'erreurs reviennent : rate limits (429), timeouts réseau, et coupures de connexion utilisateur (mobile). Chacune mérite un traitement distinct.

Retry exponentiel sur 429 / 529

// src/app/core/claude.service.ts — retry helper
private async withRetry<T>(
  fn: () => Promise<T>,
  maxAttempts = 3,
): Promise<T> {
  let lastErr: unknown;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err: any) {
      lastErr = err;
      const status = err?.status ?? err?.response?.status;

      // 429 (rate limit) ou 529 (overloaded) : on retente
      // 4xx (autre) : erreur cliente, pas de retry
      if (status !== 429 && status !== 529 && status !== 503) {
        throw err;
      }

      // Backoff exponentiel : 500ms, 1s, 2s
      const delay = 500 * Math.pow(2, attempt - 1);
      await new Promise(r => setTimeout(r, delay));
    }
  }
  throw lastErr;
}

Annuler un streaming (utilisateur ferme l'onglet)

// src/app/core/claude.service.ts — AbortController
private currentController: AbortController | null = null;

async send(userInput: string): Promise<void> {
  // Annuler un streaming précédent si présent
  this.currentController?.abort();
  this.currentController = new AbortController();

  const response = await fetch(this.endpoint, {
    method: 'POST',
    body: JSON.stringify({ messages: this.messages() }),
    signal: this.currentController.signal, // ← lien avec l'AbortController
  });
  // ... suite du streaming
}

cancel(): void {
  this.currentController?.abort();
  this.streaming.set(false);
  this.draft.set('');
}

Tracker les coûts en temps réel

// On expose un Signal compteur de tokens
readonly tokensUsed = signal<{ input: number; output: number }>({ input: 0, output: 0 });

// computed() : coût estimé en USD
readonly estimatedCost = computed(() => {
  const { input, output } = this.tokensUsed();
  // Tarif Haiku 4.5 : $1 / 1M input, $5 / 1M output
  const cost = (input / 1_000_000) * 1 + (output / 1_000_000) * 5;
  return cost.toFixed(4);
});

// Mise à jour à la fin de chaque streaming (event message_delta)
private updateUsage(usage: { input_tokens: number; output_tokens: number }): void {
  this.tokensUsed.update(prev => ({
    input:  prev.input + usage.input_tokens,
    output: prev.output + usage.output_tokens,
  }));
}

SSR + hydration incrémentale (Angular 19+)

Pour un assistant IA inséré dans une page SSR (page produit, blog), l'hydration incrémentale d'Angular 19 permet de différer l'hydratation du composant chat jusqu'à ce que l'utilisateur interagisse — économie majeure de JS au premier paint.

// src/main.server.ts — activation SSR + hydration incrémentale
import { bootstrapApplication } from '@angular/platform-browser';
import {
  provideClientHydration,
  withIncrementalHydration, // ← Angular 19+
} from '@angular/platform-browser';

bootstrapApplication(AppComponent, {
  providers: [
    provideClientHydration(
      withIncrementalHydration(), // hydratation différée par triggers
    ),
  ],
});
<!-- Le composant chat ne s'hydrate qu'au survol ou au clic -->
<!-- Le SSR rend du HTML statique, le JS du chat ne charge qu'à l'interaction -->
@defer (hydrate on interaction) {
  <app-chat title="Assistant" />
} @placeholder {
  <button class="chat-cta">Discuter avec l'IA</button>
}
Gain réel : sur une page produit avec un widget chat IA, l'hydration incrémentale économise 80 à 150 KB de JS au premier paint. Le LCP s'améliore de 200 à 500 ms sur mobile 3G/4G — tout en gardant le SEO du contenu rendu côté serveur.
Checklist mise en production Angular 19+
  • Clé API jamais dans Angular — proxy backend obligatoire
  • Authentification utilisateur sur le proxy (JWT / session)
  • Rate limiting par IP et par utilisateur (10 req/min)
  • Validation stricte du payload (taille messages, count)
  • Retry exponentiel sur 429 / 529 / 503
  • AbortController pour annuler un streaming
  • Prompt caching activé sur le system prompt > 1k tokens
  • Logs structurés des tokens consommés par utilisateur
  • Alertes coût quotidien dans la console Anthropic
  • Tests E2E (Playwright) sur le chat avec mock du proxy
  • provideZonelessChangeDetection() activé (perf + bundle)
  • Migration @Input/@Output/@ViewChild → signal APIs (ng g signals)
  • Hydration incrémentale (@defer hydrate on interaction) si SSR
  • Bundle vérifié sans zone.js (économie 30-50 KB)

Conclusion

Intégrer Claude dans Angular 19+ tire pleinement parti des nouveautés du framework : standalone par défaut, signal inputs/outputs/model, viewChild() en Signal, afterRenderEffect() safe SSR, linkedSignal() pour les états dérivés mutables, et httpResource() (Angular 20) pour les requêtes async réactives. Le streaming SSE devient un branchement naturel sur le graphe réactif — sans subscription manuelle, sans async pipe, sans Zone.js si vous activez le mode zoneless.

Quatre piliers pour la production : proxy backend obligatoire pour la clé API, tool use côté serveur pour les opérations sensibles, prompt caching pour la facture, et hydration incrémentale (@defer hydrate on interaction) pour économiser 80 à 150 KB de JS au premier paint. Le pattern tient pour des assistants de support, des copilotes intégrés à votre app, ou des agents qui dialoguent avec votre backend métier.

Pour aller plus loin : explorez rxResource() pour bridger des Observables existants vers le monde Signals, persistez l'historique via IndexedDB ou un endpoint exposé en httpResource(), expérimentez le mode extended thinking de Claude Opus 4 pour les cas de raisonnement long, ou structurez les workflows multi-tools en architecture agentique. La base Angular 19+ posée ici reste la même — c'est tout son intérêt.

Partager