Intelligence Artificielle angularforall.com

- Angular + Ollama : assistant IA local intégré au front

Angular Ollama Ia-Locale Angular-17 Signals Llm Standalone-Components Streaming-Sse Typescript Assistant-Ia No-Cloud Privacy Llama Mistral Frontend-Ia
Angular + Ollama : assistant IA local intégré au front

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.

Architecture cible : Angular standalone (Signals, ChangeDetection OnPush) ↔ proxy Angular dev / nginx prod ↔ Ollama (localhost ou serveur dédié). Aucun appel sortant vers l'internet public.

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èleTailleRAM requiseUsage recommandé
llama3.23B / 2 GB8 GBChat rapide, classification
mistral7B / 4 GB16 GBPolyvalent, multilingue
qwen2.514B / 8 GB32 GBRaisonnement, code
nomic-embed-text137M / 250 MB2 GBEmbeddings RAG
llama3.2-vision11B / 8 GB16 GBOCR, 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).

Alternative sans proxy : Lancer Ollama avec 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;
}
Pièges classiques : oublier { 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énarioArchitectureTrade-off
Outil interne entrepriseOllama sur serveur dédié + nginx + auth1 GPU pour 20-50 utilisateurs
App desktop Electron/TauriOllama embarqué localementInstallation lourde, mais 100 % offline
PWA bureautique sensibleOllama Docker sur poste + service workerOffline-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/generate ouvert = 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.

Partager