Integrez Hashbrown dans Angular pour creer des chatbots IA avec tool calling, composants dynamiques generes par LLM et UI conversationnelles modernes.
Generative UI : la prochaine génération d'interfaces
Le terme Generative UI désigne une approche où l'interface utilisateur n'est plus entièrement écrite à l'avance par les développeurs, mais générée à la volée par un LLM en fonction de l'intention exprimée par l'utilisateur. Au lieu de remplir un formulaire avec dix champs, l'utilisateur tape « Crée-moi un dashboard de ventes par région pour T2 2026 » et l'IA assemble les composants Angular adéquats.
Cette mutation, popularisée fin 2025 par Vercel et la communauté React, arrive enfin dans l'écosystème Angular avec Hashbrown. La promesse est forte : conserver la sécurité et la robustesse d'un framework typé tout en offrant la flexibilité d'une interface conversationnelle pilotée par GPT-4o, Claude ou Gemini.
Pourquoi Hashbrown plutôt qu'une intégration manuelle ?
Avant Hashbrown, intégrer un LLM dans Angular impliquait d'écrire soi-même :
- La couche HTTP vers OpenAI/Anthropic avec gestion des erreurs réseau
- Le parsing du streaming Server-Sent Events
- L'orchestration du tool calling et du « think → act → observe » loop
- La validation des sorties JSON face aux hallucinations
- L'intégration des Signals pour mettre à jour la vue progressivement
Hashbrown encapsule tout cela dans une API signal-first qui ressemble à n'importe quel service Angular moderne. Une seule injection, et votre composant devient capable de parler à n'importe quel LLM.
Hashbrown : présentation et architecture
Hashbrown est une bibliothèque TypeScript open source créée par l'équipe Liveloveapp (les auteurs de plusieurs cours Angular reconnus). Elle se positionne comme « l'outillage manquant entre Angular et les LLM ». Elle est distribuée sous forme de packages @hashbrownai/* sur npm.
| Package | Rôle | Quand l'installer |
|---|---|---|
@hashbrownai/core |
API Chat, tools, streaming, signals | Toujours |
@hashbrownai/angular |
Helpers Angular : provideHashbrown(), inject(Chat) | Toujours dans un projet Angular |
@hashbrownai/openai |
Adapter OpenAI GPT-4o / GPT-4.1 | Si vous utilisez OpenAI |
@hashbrownai/anthropic |
Adapter Claude Sonnet 4.6 / Opus 4.7 | Si vous utilisez Anthropic |
@hashbrownai/google |
Adapter Gemini 2.5 | Si vous utilisez Google |
Architecture en 3 couches
Hashbrown sépare clairement trois responsabilités :
- Couche modèle (LLM) : l'adapter fournit une API uniforme — peu importe le provider
- Couche outils (tools) : vos services Angular, exposés via
createTool() - Couche UI (composants) : votre catalogue de composants, exposés via
exposeComponent()
Le LLM ne voit jamais directement votre code. Il reçoit un schéma JSON décrivant les tools et composants disponibles, choisit lesquels invoquer, et Hashbrown se charge d'exécuter les appels côté client.
Installation et premier chat IA
Partons d'un projet Angular 19+ standalone (Hashbrown nécessite les standalone components et les Signals). Voici comment ajouter Hashbrown en 2 minutes.
1. Installer les packages
// Installation pour un projet utilisant OpenAI
npm install @hashbrownai/core @hashbrownai/angular @hashbrownai/openai
// Ou pour Anthropic Claude
npm install @hashbrownai/core @hashbrownai/angular @hashbrownai/anthropic
// Dépendance peer requise (déjà installée dans Angular 19+)
// @angular/core ^19.0.0
// rxjs ^7.8.0
2. Configurer le provider Hashbrown
// app.config.ts — Angular 19+ standalone bootstrap
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideHashbrown } from '@hashbrownai/angular';
import { openAi } from '@hashbrownai/openai';
import { environment } from '../environments/environment';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
// Provider principal Hashbrown : on choisit ici l'adapter LLM
provideHashbrown({
// L'adapter OpenAI prend la clé API et le modèle par défaut
adapter: openAi({
apiKey: environment.openAiApiKey,
model: 'gpt-4o', // ou 'gpt-4.1', 'gpt-4o-mini'
temperature: 0.7, // créativité 0 → 1
}),
// System prompt global injecté dans chaque conversation
systemPrompt: 'Tu es un assistant pour une application e-commerce. Réponds en français, sois concis et propose des actions concrètes.',
}),
],
};
environment.ts en production. Utilisez un endpoint backend qui proxie les requêtes — exemple détaillé section « Sécurité ».
3. Premier composant chatbot
// chat.component.ts — chatbot minimal en 30 lignes
import { Component, signal, inject } from '@angular/core';
import { Chat } from '@hashbrownai/angular';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-chat',
standalone: true,
imports: [FormsModule],
template: `
<div class="chat">
@for (msg of messages(); track msg.id) {
<div [class]="'msg ' + msg.role">
<strong>{{ msg.role }}</strong> : {{ msg.content }}
</div>
}
<input [(ngModel)]="prompt" (keydown.enter)="send()" placeholder="Posez votre question...">
</div>
`,
})
export class ChatComponent {
// Injection signal-based — pas de constructeur nécessaire
private chat = inject(Chat);
prompt = '';
// Hashbrown expose messages() comme Signal<Message[]> — réactif natif
messages = this.chat.messages;
async send() {
if (!this.prompt.trim()) return;
await this.chat.send({ content: this.prompt });
this.prompt = '';
}
}
Ce composant minimal suffit à obtenir un chatbot fonctionnel avec streaming. La réponse du LLM apparaît token par token dans messages() grâce aux Signals.
Tool calling : laisser le LLM exécuter vos services
C'est ici que Hashbrown devient réellement puissant. Plutôt que de répondre par du texte, le LLM peut appeler vos services Angular pour effectuer une action ou récupérer des données. C'est ce qu'on appelle le tool calling, popularisé par OpenAI puis adopté par Anthropic et Google.
Pattern : exposer un service comme outil
// products.service.ts — service Angular standard
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class ProductsService {
private http = inject(HttpClient);
// Méthode appelée à la fois par votre UI et par le LLM
async search(query: string, maxResults = 5): Promise<Product[]> {
return await this.http
.get<Product[]>('/api/products', { params: { q: query, limit: maxResults } })
.toPromise() ?? [];
}
}
// chat.config.ts — exposer la méthode search() au LLM via createTool()
import { createTool } from '@hashbrownai/core';
import { inject } from '@angular/core';
import { z } from 'zod'; // validation runtime (recommandée)
import { ProductsService } from './products.service';
export const searchProductsTool = createTool({
name: 'searchProducts',
description: 'Recherche des produits par mot-clé. À utiliser quand l\'utilisateur cherche un article du catalogue.',
// Schéma Zod = contrat typé garanti envoyé au LLM
schema: z.object({
query: z.string().describe('Le mot-clé saisi par l\'utilisateur'),
maxResults: z.number().int().min(1).max(20).default(5),
}),
// Handler exécuté côté client quand le LLM choisit ce tool
handler: async ({ query, maxResults }) => {
const service = inject(ProductsService);
const products = await service.search(query, maxResults);
// La valeur retournée est renvoyée au LLM pour formuler sa réponse
return { found: products.length, products };
},
});
Activer le tool dans le provider
// app.config.ts — déclarer le tool au provider Hashbrown
provideHashbrown({
adapter: openAi({ apiKey: environment.openAiApiKey, model: 'gpt-4o' }),
tools: [searchProductsTool], // tableau de tools disponibles
systemPrompt: `
Tu peux appeler searchProducts() pour interroger le catalogue.
Ne réponds JAMAIS avec un produit fictif — utilise toujours le tool.
`,
}),
À partir de ce moment, si l'utilisateur tape « Tu as des sneakers Nike en taille 42 ? », le LLM ne va PAS halluciner une réponse. Il va appeler searchProducts({ query: 'sneakers nike 42' }), recevoir la liste réelle, et formuler une réponse basée sur vos données.
Multi-tools : enchaîner plusieurs appels
// Le LLM peut chaîner plusieurs tools dans une même conversation
// Exemple : commande complète "Ajoute 2 baskets noires au panier et calcule le total avec promo"
export const addToCartTool = createTool({
name: 'addToCart',
description: 'Ajoute un produit au panier',
schema: z.object({
productId: z.string(),
quantity: z.number().int().min(1).max(10),
}),
handler: async ({ productId, quantity }) => {
const cart = inject(CartService);
return await cart.add(productId, quantity);
},
});
export const computeTotalTool = createTool({
name: 'computeTotal',
description: 'Calcule le total du panier avec promotions actives',
schema: z.object({ promoCode: z.string().optional() }),
handler: async ({ promoCode }) => {
const cart = inject(CartService);
return await cart.computeTotal(promoCode);
},
});
// Le LLM choisira automatiquement la séquence : searchProducts → addToCart → computeTotal
Composants dynamiques générés par l'IA
L'autre cas d'usage emblématique de Hashbrown est la Generative UI proprement dite : laisser le LLM choisir et composer vos composants Angular pour répondre à un besoin utilisateur.
Exposer un composant au LLM
// product-card.component.ts — composant Angular standard
import { Component, input } from '@angular/core';
@Component({
selector: 'app-product-card',
standalone: true,
template: `
<div class="card">
<img [src]="image()" [alt]="name()">
<h3>{{ name() }}</h3>
<p class="price">{{ price() | currency:'EUR' }}</p>
</div>
`,
})
export class ProductCardComponent {
name = input.required<string>();
price = input.required<number>();
image = input.required<string>();
}
// expose-components.ts — déclarer les composants au LLM
import { exposeComponent } from '@hashbrownai/angular';
import { z } from 'zod';
import { ProductCardComponent } from './product-card.component';
import { ChartComponent } from './chart.component';
export const productCardExposed = exposeComponent(ProductCardComponent, {
description: 'Affiche un produit individuel avec image, nom et prix.',
// Le LLM doit fournir ces inputs pour instancier le composant
inputs: {
name: z.string(),
price: z.number().positive(),
image: z.string().url(),
},
});
export const chartExposed = exposeComponent(ChartComponent, {
description: 'Affiche un graphique en barres pour comparer plusieurs valeurs numériques.',
inputs: {
title: z.string(),
labels: z.array(z.string()),
values: z.array(z.number()),
},
});
Render dynamique côté template
// generative-view.component.ts — afficher la réponse UI du LLM
import { Component, inject } from '@angular/core';
import { Chat, HashbrownRenderer } from '@hashbrownai/angular';
@Component({
selector: 'app-generative-view',
standalone: true,
imports: [HashbrownRenderer],
template: `
<input [(ngModel)]="prompt" placeholder="Décris ce que tu veux voir...">
<button (click)="generate()">Générer</button>
<!-- Le renderer instancie automatiquement les composants choisis par le LLM -->
<hb-renderer [tree]="tree()"></hb-renderer>
`,
})
export class GenerativeViewComponent {
private chat = inject(Chat);
prompt = '';
tree = this.chat.uiTree; // Signal<ComponentTree | null>
async generate() {
// generateUi() demande explicitement une réponse UI au LLM
await this.chat.generateUi({ prompt: this.prompt });
}
}
Imaginons que l'utilisateur tape « Montre-moi les 3 produits les plus vendus en mai sous forme de cards puis un graphique de leurs ventes ». Le LLM choisit :
// Sortie générée par le LLM (JSON validé par Hashbrown)
{
"tree": [
{ "component": "app-product-card", "inputs": { "name": "Sneakers Air Max", "price": 129, "image": "..." } },
{ "component": "app-product-card", "inputs": { "name": "T-shirt Coton Bio", "price": 29, "image": "..." } },
{ "component": "app-product-card", "inputs": { "name": "Casquette Logo", "price": 19, "image": "..." } },
{ "component": "app-chart", "inputs": {
"title": "Ventes mai 2026",
"labels": ["Sneakers", "T-shirt", "Casquette"],
"values": [340, 280, 195]
}}
]
}
searchProducts, getSalesStats), pas du LLM seul. Sans cela, vous risquez des hallucinations. Couplez TOUJOURS Generative UI et tool calling.
Streaming et Signals : UX en temps réel
Hashbrown est conçu pour exploiter pleinement les Angular Signals. Tout est réactif et streamé par défaut : pas besoin d'écrire votre propre EventSource ou WebSocket.
Signals exposés par le service Chat
// chat.component.ts — exploiter les Signals natifs
import { Component, computed, inject } from '@angular/core';
import { Chat } from '@hashbrownai/angular';
@Component({ /* ... */ })
export class ChatComponent {
private chat = inject(Chat);
messages = this.chat.messages; // Signal<Message[]>
isThinking = this.chat.isThinking; // Signal<boolean>
isStreaming= this.chat.isStreaming; // Signal<boolean>
error = this.chat.error; // Signal<Error | null>
usage = this.chat.usage; // Signal<TokenUsage> — coûts
// Signal dérivé : afficher un indicateur visuel selon l'état
statusLabel = computed(() => {
if (this.isThinking()) return '🤔 Le modèle réfléchit...';
if (this.isStreaming()) return '✍️ Réponse en cours...';
if (this.error()) return `❌ Erreur : ${this.error()?.message}`;
return '✅ Prêt';
});
// Signal dérivé : coût estimé de la conversation en USD
costEstimate = computed(() => {
const u = this.usage();
// GPT-4o pricing : $5 / 1M input + $15 / 1M output (mai 2026)
return ((u.inputTokens * 5 + u.outputTokens * 15) / 1_000_000).toFixed(4);
});
}
Annuler une requête en cours
// Bouton "stop" pour interrompre la génération (UX moderne)
@Component({
template: `
<div *ngIf="isStreaming()">
<button (click)="cancel()">⏹ Arrêter la génération</button>
</div>
`,
})
export class StopButtonComponent {
private chat = inject(Chat);
isStreaming = this.chat.isStreaming;
cancel() {
// Annule le streaming et préserve le contenu déjà reçu
this.chat.abort();
}
}
- Toujours afficher un indicateur visuel pendant
isThinking() - Permettre d'interrompre via
chat.abort()(latence cognitive perçue divisée par 2) - Désactiver le bouton « Envoyer » tant que
isStreaming() === true - Auto-scroller vers le bas avec un
effect()surmessages()
Sécurité, validation et fallback
Intégrer un LLM en production demande des précautions spécifiques. Hashbrown ne suffit pas à lui seul : voici les patterns critiques à implémenter.
1. Ne jamais exposer la clé API côté client
// ❌ MAUVAIS — clé API visible dans le bundle JS production
provideHashbrown({
adapter: openAi({
apiKey: 'sk-proj-xxxx...', // n'importe qui peut la voler !
model: 'gpt-4o',
}),
}),
// ✅ BON — Backend proxy (NestJS, Express, Cloud Function)
provideHashbrown({
adapter: openAi({
// L'adapter pointe vers VOTRE backend, pas vers openai.com directement
baseURL: '/api/llm', // votre proxy authentifié
apiKey: 'unused', // ignoré, la vraie clé est côté serveur
model: 'gpt-4o',
}),
}),
// llm-proxy.controller.ts — exemple NestJS
import { Controller, Post, Req, Res, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from './auth/jwt.guard';
import { RateLimitGuard } from './rate-limit.guard';
@Controller('api/llm')
@UseGuards(JwtAuthGuard, RateLimitGuard) // auth + rate limit obligatoires
export class LlmProxyController {
@Post('chat/completions')
async proxy(@Req() req, @Res() res) {
const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, // 🔒 server-only
'Content-Type': 'application/json',
},
body: JSON.stringify(req.body),
});
// Streaming SSE pass-through
upstream.body?.pipeTo(res);
}
}
2. Valider les outputs avec Zod
// Toujours valider les sorties JSON du LLM — il peut halluciner ou tronquer
import { z } from 'zod';
const RecommendationSchema = z.object({
productId: z.string().uuid(), // format strict UUID
confidence: z.number().min(0).max(1),
reason: z.string().min(10).max(500),
});
const recommendTool = createTool({
name: 'recommendProduct',
schema: z.object({ userId: z.string().uuid() }),
output: RecommendationSchema, // Hashbrown rejette une sortie invalide
handler: async ({ userId }) => {
const reco = await fetch(`/api/reco/${userId}`).then(r => r.json());
return reco; // validé contre RecommendationSchema
},
});
3. Fallback en cas d'échec LLM
// fallback.component.ts — toujours prévoir un mode dégradé
@Component({
template: `
@if (chat.error()) {
<div class="alert alert-warning">
L'assistant IA est temporairement indisponible.
<a routerLink="/search">Utilisez la recherche classique</a>
</div>
} @else {
<app-chat></app-chat>
}
`,
})
export class GenerativeViewComponent {
chat = inject(Chat);
}
Cas d'usage réels en production
Cas 1 : Assistant de pré-remplissage de formulaire
Plutôt que d'imposer un formulaire de 20 champs, l'utilisateur dicte sa demande en langage naturel et le LLM remplit les champs Signal Forms appropriés.
// form-assistant.component.ts
import { signal, inject } from '@angular/core';
import { Chat } from '@hashbrownai/angular';
const fillFormTool = createTool({
name: 'fillBookingForm',
description: 'Remplit un formulaire de réservation à partir d\'une demande en langage naturel',
schema: z.object({
destination: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
passengers: z.number().int().min(1).max(9),
budget: z.number().positive().optional(),
}),
handler: async (data, ctx) => {
// ctx.signal expose les signaux globaux du chat
bookingForm.set(data); // remplit le formulaire Signal Forms
return { ok: true };
},
});
// L'utilisateur tape : "Vol Paris-Marrakech du 12 au 19 juin, 2 passagers, budget 800€"
// → 4 champs remplis automatiquement, prêt à valider
Cas 2 : Dashboard configurable par prompt
L'utilisateur décrit le rapport souhaité (« chiffre d'affaires par région T2 + comparaison N-1 ») et l'IA assemble les composants chart, table et KPI cards de votre catalogue.
// dashboard-builder.component.ts
@Component({
template: `
<input #q (keydown.enter)="build(q.value)" placeholder="Décrivez votre dashboard...">
<hb-renderer [tree]="tree()"></hb-renderer>
`,
})
export class DashboardBuilderComponent {
private chat = inject(Chat);
tree = this.chat.uiTree;
async build(prompt: string) {
await this.chat.generateUi({
prompt,
allowedComponents: ['app-kpi', 'app-chart', 'app-table'],
// L'IA peut UNIQUEMENT utiliser ces 3 composants — sécurité d'usage
});
}
}
Cas 3 : Aide contextuelle pour utilisateur perdu
L'assistant connaît la route actuelle et l'état applicatif. Quand l'utilisateur demande de l'aide, le LLM voit où il est et propose les bonnes actions.
// help-assistant.component.ts
import { effect, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({ /* ... */ })
export class HelpAssistantComponent {
private chat = inject(Chat);
private router = inject(Router);
constructor() {
// À chaque changement de route, on met à jour le contexte du LLM
effect(() => {
const url = this.router.url;
this.chat.setSystemContext(
`L'utilisateur est sur la page ${url}. ` +
`Aide-le à accomplir l'action principale de cette page.`,
);
});
}
}
Conclusion et perspectives
Hashbrown apporte à Angular ce que Vercel AI SDK a apporté à Next.js : un outillage standardisé, typé et signal-first pour intégrer les LLM sans réinventer la roue. La courbe d'apprentissage est douce — quelques heures suffisent pour passer d'un chatbot basique à un assistant orchestrant 10 services métier.
Les points clés à retenir :
- Generative UI ≠ HTML aléatoire : le LLM choisit dans VOTRE catalogue de composants typés
- Tool calling est le mécanisme central — toujours coupler avec validation Zod
- Sécurité : backend proxy obligatoire, rate limiting, validation outputs
- UX : streaming + abort + indicateurs visuels d'état
- Coûts : monitorer les tokens via le Signal
usage()dès le départ