Construisez un assistant IA local dans Angular 17+ : service standalone, streaming token-by-token depuis Ollama, Signals, sélecteur de modèle et proxy CORS dev.
Pourquoi Ollama + Angular : privacy, offline, RGPD
Intégrer un LLM cloud (OpenAI, Claude, Mistral) reste la solution par défaut quand la qualité prime. Mais quatre cas d'usage font basculer le choix vers Ollama en local :
- Privacy stricte : aucune donnée ne quitte le poste. Critique pour les outils internes manipulant des PII, du juridique, du médical.
- Offline-first : applications Electron/Tauri ou PWA qui doivent fonctionner sans connexion (déplacements, missions terrain, sites sécurisés).
- Coût zéro par requête : démos, outils internes massivement utilisés, environnements de formation où chaque utilisateur génère 1000+ prompts/jour.
- Souveraineté : zéro dépendance à un fournisseur tiers, modèles open weight (Llama, Mistral, Qwen) auditables.
La contrepartie est honnête : Llama 3.2 3B en local reste 10-15 % en dessous de GPT-4o-mini sur les benchmarks de raisonnement, et l'utilisateur doit installer Ollama (1 commande). Pour les outils internes d'entreprise, le compromis est largement acceptable.
Installer Ollama et choisir un modèle
Ollama s'installe en une commande sur macOS, Linux et Windows. Il télécharge les modèles à la demande, les met en cache, et expose une API REST sur le port 11434.
# Installation (macOS / Linux)
curl -fsSL https://ollama.com/install.sh | sh
# Windows : telecharger l'installeur depuis ollama.com/download
# Demarrer le service (souvent automatique apres install)
ollama serve &
# Telecharger un modele - 2 a 8 GB selon la taille
ollama pull llama3.2 # 3B - rapide, leger
ollama pull mistral # 7B - bon equilibre
ollama pull qwen2.5:14b # 14B - meilleur raisonnement
# Test rapide
ollama run llama3.2 "Bonjour, presente-toi en une phrase."
# Lister les modeles installes
ollama list
| Modèle | Taille | RAM requise | Usage recommandé |
|---|---|---|---|
| llama3.2 | 3B / 2 GB | 8 GB | Chat rapide, classification |
| mistral | 7B / 4 GB | 16 GB | Polyvalent, multilingue |
| qwen2.5 | 14B / 8 GB | 32 GB | Raisonnement, code |
| nomic-embed-text | 137M / 250 MB | 2 GB | Embeddings RAG |
| llama3.2-vision | 11B / 8 GB | 16 GB | OCR, image captioning |
# Premier appel API direct - format Ollama natif
curl http://localhost:11434/api/chat -d '{
"model": "llama3.2",
"messages": [
{ "role": "user", "content": "Explique Angular Signals en une phrase." }
],
"stream": false
}'
# Reponse JSON
# {
# "model": "llama3.2",
# "message": { "role": "assistant", "content": "Angular Signals..." },
# "done": true,
# "total_duration": 2384562000,
# "eval_count": 42
# }
Proxy CORS pour Angular en développement
Ollama écoute sur localhost:11434, Angular sert sur localhost:4200. Sans proxy, le navigateur bloque l'appel pour cause de CORS. Deux options : ouvrir Ollama au front via OLLAMA_ORIGINS, ou utiliser le proxy intégré d'Angular CLI (recommandé).
// proxy.conf.json - a la racine du projet Angular
{
"/api/ollama": {
"target": "http://localhost:11434",
"secure": false,
"changeOrigin": true,
"pathRewrite": { "^/api/ollama": "" },
"logLevel": "debug"
}
}
// angular.json - branchement du proxy sur ng serve
{
"projects": {
"mon-app": {
"architect": {
"serve": {
"options": {
"proxyConfig": "proxy.conf.json"
}
}
}
}
}
}
Maintenant, /api/ollama/api/chat côté Angular est redirigé vers http://localhost:11434/api/chat côté serveur. Aucun problème CORS, et le code de production utilisera la même URL relative (avec un nginx qui réécrira vers le bon Ollama distant).
OLLAMA_ORIGINS=http://localhost:4200 ollama serve. Plus simple mais moins propre — vous devrez changer cette config pour la prod, alors que le proxy reste le même.
Service Angular standalone avec HttpClient
On crée un service injectable Angular qui encapsule les appels à Ollama. Pour les requêtes sans streaming, HttpClient suffit. Pour le streaming token-by-token, on bascule sur fetch natif qui expose un ReadableStream.
// ollama.types.ts - Contrats partages
export interface OllamaMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
}
export interface OllamaChatRequest {
model: string;
messages: OllamaMessage[];
stream?: boolean;
options?: {
temperature?: number;
num_predict?: number; // equivalent max_tokens
top_p?: number;
seed?: number;
};
tools?: unknown[]; // pour le function calling
}
export interface OllamaChatChunk {
model: string;
message: { role: string; content: string };
done: boolean;
done_reason?: string;
total_duration?: number;
eval_count?: number;
}
export interface OllamaModel {
name: string;
size: number;
modified_at: string;
}
// ollama.service.ts - Service standalone
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { OllamaChatRequest, OllamaChatChunk, OllamaModel } from './ollama.types';
@Injectable({ providedIn: 'root' })
export class OllamaService {
private http = inject(HttpClient);
private readonly base = '/api/ollama';
// 1. Liste des modeles installes localement
async listModels(): Promise<OllamaModel[]> {
const response = await firstValueFrom(
this.http.get<{ models: OllamaModel[] }>(`${this.base}/api/tags`)
);
return response.models;
}
// 2. Chat sans streaming - reponse complete en une fois
async chat(req: OllamaChatRequest): Promise<OllamaChatChunk> {
return firstValueFrom(
this.http.post<OllamaChatChunk>(`${this.base}/api/chat`, {
...req,
stream: false
})
);
}
// 3. Embeddings pour le RAG local
async embed(model: string, input: string): Promise<number[]> {
const response = await firstValueFrom(
this.http.post<{ embedding: number[] }>(`${this.base}/api/embeddings`, {
model,
prompt: input
})
);
return response.embedding;
}
// 4. Streaming - traite separement (voir section suivante)
async *streamChat(req: OllamaChatRequest, signal?: AbortSignal):
AsyncGenerator<OllamaChatChunk, void, unknown> {
const response = await fetch(`${this.base}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...req, stream: true }),
signal
});
if (!response.body) throw new Error('Pas de stream disponible');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Ollama emet du NDJSON - un objet JSON par ligne
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // garde le fragment incomplet
for (const line of lines) {
if (!line.trim()) continue;
try {
yield JSON.parse(line) as OllamaChatChunk;
} catch {
// ligne incomplete - on attendra plus de donnees
}
}
}
} finally {
reader.releaseLock();
}
}
}
State management avec Signals
Pour un chat, le state est simple : liste de messages, message en cours de streaming, modèle actif, état de chargement. Les Signals évitent le passage par RxJS pour ce cas, et la réactivité est instantanée dans le template.
// chat.store.ts - State manager dedie au chat Ollama
import { Injectable, computed, inject, signal } from '@angular/core';
import { OllamaService } from './ollama.service';
import type { OllamaMessage } from './ollama.types';
@Injectable({ providedIn: 'root' })
export class ChatStore {
private ollama = inject(OllamaService);
// === STATE SIGNALS ===
readonly messages = signal<OllamaMessage[]>([
{ role: 'system', content: 'Tu es un assistant developpeur Angular concis.' }
]);
readonly streamingMessage = signal<string>('');
readonly loading = signal<boolean>(false);
readonly model = signal<string>('llama3.2');
readonly error = signal<string | null>(null);
private abortController: AbortController | null = null;
// === COMPUTED ===
// Liste pour affichage - exclut le system prompt et inclut le message en cours
readonly displayedMessages = computed<OllamaMessage[]>(() => {
const base = this.messages().filter(m => m.role !== 'system');
const streaming = this.streamingMessage();
return streaming
? [...base, { role: 'assistant', content: streaming }]
: base;
});
// Combien de tokens approximatifs dans le contexte (4 chars ~ 1 token)
readonly contextSize = computed(() => {
const total = this.messages().reduce((acc, m) => acc + m.content.length, 0);
return Math.round(total / 4);
});
// === ACTIONS ===
async send(userMessage: string): Promise<void> {
if (!userMessage.trim() || this.loading()) return;
this.error.set(null);
this.loading.set(true);
this.streamingMessage.set('');
// Ajoute le message utilisateur dans l'historique
this.messages.update(msgs => [...msgs, { role: 'user', content: userMessage }]);
// Permet d'annuler la generation en cours
this.abortController = new AbortController();
try {
const stream = this.ollama.streamChat(
{ model: this.model(), messages: this.messages() },
this.abortController.signal
);
// Iteration sur les chunks - chaque token met a jour le signal
for await (const chunk of stream) {
if (chunk.message?.content) {
this.streamingMessage.update(s => s + chunk.message.content);
}
if (chunk.done) break;
}
// Commit du message complet dans l'historique
const finalContent = this.streamingMessage();
if (finalContent) {
this.messages.update(msgs => [
...msgs,
{ role: 'assistant', content: finalContent }
]);
}
} catch (err: any) {
if (err.name !== 'AbortError') {
this.error.set(err.message ?? 'Erreur Ollama');
}
} finally {
this.streamingMessage.set('');
this.loading.set(false);
this.abortController = null;
}
}
// Stop bouton - annule le streaming en cours
stop(): void {
this.abortController?.abort();
}
reset(): void {
this.stop();
this.messages.set([this.messages()[0]]); // garde le system prompt
this.streamingMessage.set('');
this.error.set(null);
}
changeModel(model: string): void {
this.model.set(model);
}
}
Streaming token-by-token via fetch
Le détail technique du streaming mérite un focus. Ollama émet du NDJSON (newline-delimited JSON) sur le endpoint /api/chat avec stream: true — un objet JSON par ligne, terminé par un objet avec done: true. Le travail Angular consiste à découper le buffer, parser chaque ligne, et mettre à jour le Signal.
// Mecanisme detaille - explique pas a pas
async function streamFromOllama(model: string, messages: OllamaMessage[]) {
const response = await fetch('/api/ollama/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages, stream: true })
});
// response.body est un ReadableStream
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
let fullText = '';
while (true) {
// 1. Lecture d'un chunk binaire (peut contenir 0, 1 ou N lignes JSON)
const { done, value } = await reader.read();
if (done) break;
// 2. Decode UTF-8 - le flag stream:true gere les caracteres multi-byte coupes
buffer += decoder.decode(value, { stream: true });
// 3. Decoupage par lignes - la derniere ligne peut etre incomplete
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
// 4. Parse chaque ligne complete
for (const line of lines) {
if (!line.trim()) continue;
try {
const chunk = JSON.parse(line);
if (chunk.message?.content) {
fullText += chunk.message.content;
// 5. Notification (Signal.update, EventEmitter, etc.)
console.log('Token recu :', chunk.message.content);
}
} catch (e) {
console.warn('Ligne non parsable :', line);
}
}
}
return fullText;
}
{ stream: true } dans TextDecoder.decode() casse les caractères UTF-8 multi-octets (é, è, à, accents arabes). Oublier de garder la dernière ligne incomplète dans le buffer fait sauter des tokens. Toujours tester avec un message qui contient des accents.
Sélecteur de modèle dynamique
L'utilisateur doit pouvoir basculer entre Llama 3.2 (rapide), Mistral (équilibré) et Qwen 2.5 (raisonnement). Le composant interroge /api/tags au démarrage et propose la liste des modèles installés localement.
// model-selector.component.ts - Composant standalone
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { OllamaService } from './ollama.service';
import { ChatStore } from './chat.store';
import type { OllamaModel } from './ollama.types';
@Component({
selector: 'app-model-selector',
standalone: true,
imports: [CommonModule],
changeDetection: 0, // ChangeDetectionStrategy.OnPush
template: `
@if (loading()) {
<p class="text-muted small mb-0">Chargement des modeles...</p>
} @else if (models().length === 0) {
<p class="text-warning small mb-0">
Aucun modele detecte. Lance <code>ollama pull llama3.2</code>.
</p>
} @else {
<label class="form-label small fw-bold mb-1">Modele</label>
<select
class="form-select form-select-sm"
[value]="store.model()"
(change)="onChange($event)"
[disabled]="store.loading()">
@for (m of models(); track m.name) {
<option [value]="m.name">
{{ m.name }} ({{ formatSize(m.size) }})
</option>
}
</select>
}
`
})
export class ModelSelectorComponent implements OnInit {
private ollama = inject(OllamaService);
protected store = inject(ChatStore);
readonly models = signal<OllamaModel[]>([]);
readonly loading = signal<boolean>(true);
async ngOnInit() {
try {
const list = await this.ollama.listModels();
this.models.set(list);
// Selectionne le premier modele si aucun n'est defini
if (list.length && !list.some(m => m.name === this.store.model())) {
this.store.changeModel(list[0].name);
}
} finally {
this.loading.set(false);
}
}
onChange(event: Event) {
const value = (event.target as HTMLSelectElement).value;
this.store.changeModel(value);
}
formatSize(bytes: number): string {
return (bytes / 1e9).toFixed(1) + ' GB';
}
}
Composant chat UI complet
On assemble le sélecteur de modèle, la liste des messages, l'input et les boutons. Tout est standalone, OnPush, et la réactivité Signals supprime le besoin d'async pipes ou de Subject.
// chat.component.ts - Composant chat principal
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ChatStore } from './chat.store';
import { ModelSelectorComponent } from './model-selector.component';
@Component({
selector: 'app-chat',
standalone: true,
imports: [CommonModule, FormsModule, ModelSelectorComponent],
changeDetection: 0, // OnPush
template: `
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-bold">Assistant local</span>
<app-model-selector />
</div>
<div #scroll class="card-body chat-scroll" style="height: 480px; overflow-y: auto;">
@for (msg of store.displayedMessages(); track $index) {
<div [class.text-end]="msg.role === 'user'" class="mb-3">
<span class="badge"
[class.bg-primary]="msg.role === 'user'"
[class.bg-secondary]="msg.role === 'assistant'">
{{ msg.role === 'user' ? 'Moi' : 'Assistant' }}
</span>
<p class="mb-0 mt-1" style="white-space: pre-wrap;">{{ msg.content }}</p>
</div>
}
@if (store.error()) {
<div class="alert alert-danger small">{{ store.error() }}</div>
}
</div>
<div class="card-footer">
<form (submit)="onSubmit($event)" class="d-flex gap-2">
<input
type="text"
class="form-control"
placeholder="Pose ta question..."
[(ngModel)]="input"
name="prompt"
[disabled]="store.loading()" />
@if (store.loading()) {
<button type="button" class="btn btn-danger" (click)="store.stop()">
Stop
</button>
} @else {
<button type="submit" class="btn btn-primary" [disabled]="!input.trim()">
Envoyer
</button>
}
<button type="button" class="btn btn-outline-secondary" (click)="store.reset()">
Reset
</button>
</form>
<p class="text-muted small mb-0 mt-2">
Contexte : ~{{ store.contextSize() }} tokens
</p>
</div>
</div>
`
})
export class ChatComponent {
protected store = inject(ChatStore);
protected input = '';
private scrollEl = viewChild<ElementRef<HTMLDivElement>>('scroll');
constructor() {
// Auto-scroll a chaque nouveau token
effect(() => {
// Lit les signals pour reactiver l'effect a chaque change
this.store.displayedMessages();
this.store.streamingMessage();
queueMicrotask(() => {
const el = this.scrollEl()?.nativeElement;
if (el) el.scrollTop = el.scrollHeight;
});
});
}
onSubmit(event: Event) {
event.preventDefault();
const text = this.input.trim();
if (!text) return;
this.input = '';
this.store.send(text);
}
}
Utilisation dans n'importe quelle page Angular : <app-chat />. Le composant est entièrement self-contained — il injecte son store, gère son streaming, son auto-scroll et son sélecteur de modèle.
Production : self-hosted et offline-first
En développement, Ollama tourne sur la machine du dev. En production, trois architectures émergent selon le cas d'usage.
| Scénario | Architecture | Trade-off |
|---|---|---|
| Outil interne entreprise | Ollama sur serveur dédié + nginx + auth | 1 GPU pour 20-50 utilisateurs |
| App desktop Electron/Tauri | Ollama embarqué localement | Installation lourde, mais 100 % offline |
| PWA bureautique sensible | Ollama Docker sur poste + service worker | Offline-first total, RGPD parfait |
# Configuration nginx pour exposer Ollama avec auth basique
server {
listen 443 ssl;
server_name ollama.intranet.acme.io;
ssl_certificate /etc/ssl/acme/cert.pem;
ssl_certificate_key /etc/ssl/acme/key.pem;
# Auth basique - en interne uniquement, derriere VPN
auth_basic "Acme AI Assistant";
auth_basic_user_file /etc/nginx/.htpasswd;
location / {
proxy_pass http://127.0.0.1:11434;
proxy_buffering off; # CRITIQUE pour le streaming
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_http_version 1.1;
# CORS pour les apps SPA depuis le domaine intranet
add_header 'Access-Control-Allow-Origin' 'https://app.intranet.acme.io' always;
}
}
// environment.prod.ts - Bascule de l'URL selon l'environnement
export const environment = {
production: true,
ollamaBase: 'https://ollama.intranet.acme.io', // production
// En dev : '/api/ollama' via le proxy Angular
};
// Adapter le service Ollama pour utiliser l'env
// private readonly base = environment.ollamaBase;
- Pas de buffering nginx sur les endpoints stream — sinon le client attend la fin de la réponse complète au lieu de recevoir les tokens un par un.
- Timeouts longs :
proxy_read_timeout 300s; une génération longue peut prendre plusieurs minutes sur 70B. - Auth obligatoire : même en intranet, jamais d'Ollama exposé sans auth (un endpoint
/api/generateouvert = serveur miné en 24h). - GPU monitoring :
nvidia-smi+ Prometheus exporter — un seul utilisateur sur Qwen 14B sature un A40. - Queue applicative au-delà de 10 utilisateurs simultanés : Ollama ne fait pas de batching automatique, faites passer les requêtes par BullMQ ou similaire.
- Service worker côté Angular pour mettre en cache la coquille de l'app et permettre l'usage hors-ligne — voir notre article Ollama dédié.
- Fallback gracieux : si Ollama est down, afficher un message clair plutôt qu'une erreur réseau brute.
Conclusion
Connecter Angular à Ollama transforme un LLM local en assistant intégré à votre app, sans dépendance cloud, sans coût par requête et sans data leak. Le combo Signals + standalone components + fetch streaming offre une UX comparable à ChatGPT, avec une architecture parfaitement maîtrisable. Pour les outils internes d'entreprise, les apps desktop et les contextes RGPD stricts, c'est aujourd'hui la stack de référence.
Pour démarrer : installez Ollama et tirez llama3.2, créez le proxy Angular, écrivez le OllamaService avec streamChat(), branchez un ChatStore Signals et le composant chat. Une fois ce socle stable, ajoutez le function calling (compatible Ollama 0.3+), les embeddings nomic-embed-text pour un RAG local, et un service worker pour l'offline-first. La même base servira pour une PWA, une app Electron ou un outil intranet.