Créer un assistant IA local avec Ollama, Hermes et Angular

Nous allons construire une application complète appelée Hermes Local Assistant, avec une interface de discussion semblable à ChatGPT, mais qui fonctionne sur ton ordinateur, sans API payante.

Niveau : Intermédiaire

Cette application nous permettra de :

  • Discuter avec une IA Hermes exécutée localement grâce à Ollama.
  • Envoyer des questions depuis Angular.
  • Recevoir les réponses générées par Hermes.
  • Enregistrer les conversations dans MySQL.
  • Afficher l’historique des conversations.
  • Supprimer une conversation.

N.B. : Nous allons utiliser un modèle de la famille « Nous Hermes » compatible avec Ollama, et non l’agent autonome Hermes Agent, qui est un projet différent.

Préparation de l’environnement de travail

Vérifie les installations sur votre ordinateur :

node -v
npm -v
ng version
nest --version
ollama --version

Si Angular CLI et NestJS CLI ne sont pas installés :

npm install -g @angular/cli
npm install -g @nestjs/cli

Pour Ollama, vous pouvez utiliser le site Officiel : Installer Ollama

Installer Hermes dans Ollama

Ollama est le programme qui exécute le modèle. Hermes est le modèle qui répond aux questions.

Nous allons utiliser nous-hermes2, un modèle plus ancien, mais pratique pour apprendre les bases de l’intégration locale.

Dans ton terminal :

ollama pull nous-hermes2

Cette commande va télécharger le modèle sur ton ordinateur.

Teste ensuite :

ollama run nous-hermes2

Vous pouvez lui poser une question :

Explique-moi simplement ce qu'est Angular.

Si le modèle répond, la première partie fonctionne.

Si vous souhaitez verifier les modèles disponibles :

ollama list

Pour vérifier l’API locale :

curl.exe http://localhost:11434/api/tags

Ollama utilise le port 11434.

Important : nous-hermes2 est ici un choix pédagogique. Pour un modèle Hermes plus récent, vous pourrez remplacer son nom dans la configuration NestJS, à condition qu’il soit disponible dans votre installation Ollama.

Création de la base de données MySQL

Nous allons laisser TypeORM créer automatiquement les tables pour ce premier exercice.

Notre structure sera :

hermes_local
│
├── conversations
│   ├── id
│   ├── titre
│   └── createdAt
│
└── messages
    ├── id
    ├── conversationId
    ├── role
    ├── contenu
    └── createdAt

Une conversation contient plusieurs messages. Le champ role permet de savoir si un message vient de l’utilisateur (user) ou de l’IA (assistant).

Création du projet NestJS

mkdir hermes-local
cd hermes-local

nest new backend

J’utilise npm comme gestionnaire de paquet

Ensuite :

cd backend

npm install @nestjs/typeorm typeorm mysql2
npm install @nestjs/config
npm install class-validator class-transformer

Petite explication :

PackageUtilité
@nestjs/typeormRelie NestJS à TypeORM
typeormManipule les tables avec des classes
mysql2Permet la connexion MySQL
@nestjs/configLit le fichier .env
class-validatorVérifie les données reçues
class-transformerTransforme les données pour la validation

Nous allons créer nos modules :

nest g module conversations
nest g controller conversations
nest g service conversations

nest g module ollama
nest g service ollama

Le résultat final :

backend/
├── .env
└── src/
    ├── main.ts
    ├── app.module.ts
    │
    ├── conversations/
    │   ├── conversations.module.ts
    │   ├── conversations.controller.ts
    │   ├── conversations.service.ts
    │   ├── dto/
    │   │   └── send-message.dto.ts
    │   └── entities/
    │       ├── conversation.entity.ts
    │       └── message.entity.ts
    │
    └── ollama/
        ├── ollama.module.ts
        └── ollama.service.ts

La configuration MySQL et Ollama

Dans backend/.env :

# Base de données locale
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=hermes_app
DB_PASSWORD=ChangeMoi_MotDePasseFort_2026!
DB_NAME=hermes_local

# Ollama local
OLLAMA_URL=http://127.0.0.1:11434
OLLAMA_MODEL=nous-hermes2

# Serveur NestJS
PORT=3000

Ne publie jamais ton fichier .env sur GitHub. Vérifie qu’il figure dans .gitignore.

Le fichier src/app.module.ts

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';

import { ConversationsModule } from './conversations/conversations.module';
import { OllamaModule } from './ollama/ollama.module';

@Module({
  imports: [
    // Rend les variables .env accessibles
    ConfigModule.forRoot({
      isGlobal: true,
    }),

    // Connexion à MySQL
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],

      useFactory: (config: ConfigService) => ({
        type: 'mysql',

        host: config.getOrThrow<string>('DB_HOST'),
        port: Number(config.get('DB_PORT', 3306)),

        username: config.getOrThrow<string>('DB_USER'),
        password: config.getOrThrow<string>('DB_PASSWORD'),
        database: config.getOrThrow<string>('DB_NAME'),

        // Détecte automatiquement nos entités
        autoLoadEntities: true,

        // Uniquement pour le développement local
        synchronize: true,
      }),
    }),

    OllamaModule,
    ConversationsModule,
  ],
})
export class AppModule {}

N.B : TypeORM crée et met à jour les tables à partir de nos classes. C’est pratique pour apprendre, mais il faut utiliser des migrations en production, car la synchronisation automatique peut provoquer une perte de données.

Création de l’entités TypeORM

Dans le fichier conversations/entities/conversation.entity.ts

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  OneToMany,
} from 'typeorm';

import { Message } from './message.entity';

@Entity('conversations')
export class Conversation {
  // Identifiant automatique
  @PrimaryGeneratedColumn()
  id: number;

  // Titre de la conversation
  @Column({ type: 'text' })
  titre: string;

  // Date de création
  @CreateDateColumn()
  createdAt: Date;

  // Une conversation possède plusieurs messages
  @OneToMany(
    () => Message,
    (message) => message.conversation,
  )
  messages: Message[];
}

Dans le fichier conversations/entities/message.entity.ts

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  ManyToOne,
  JoinColumn,
} from 'typeorm';

import { Conversation } from './conversation.entity';

@Entity('messages')
export class Message {
  @PrimaryGeneratedColumn()
  id: number;

  // user ou assistant
  @Column({ type: 'varchar', length: 20 })
  role: string;

  // Texte du message
  @Column({ type: 'longtext' })
  contenu: string;

  // Identifiant de la conversation
  @Column()
  conversationId: number;

  // Relation avec la conversation
  @ManyToOne(
    () => Conversation,
    (conversation) => conversation.messages,
    { onDelete: 'CASCADE' },
  )
  @JoinColumn({ name: 'conversationId' })
  conversation: Conversation;

  @CreateDateColumn()
  createdAt: Date;
}

Ici, onDelete: 'CASCADE' signifie que lorsqu’on supprime une conversation, ses messages sont également supprimés.

La connection avec NestJS à Hermes avec Ollama

Nous allons envoyer les messages à l’API locale d’Ollama :

POST http://127.0.0.1:11434/api/chat

Dans le fichier ollama/ollama.service.ts

import {
  Injectable,
  ServiceUnavailableException,
} from '@nestjs/common';

import { ConfigService } from '@nestjs/config';

type ChatMessage = {
  role: 'system' | 'user' | 'assistant';
  content: string;
};

@Injectable()
export class OllamaService {
  constructor(private config: ConfigService) {}

  async demander(messages: ChatMessage[]): Promise<string> {
    // Adresse de notre serveur Ollama
    const url = this.config.get(
      'OLLAMA_URL',
      'http://127.0.0.1:11434',
    );

    // Nom du modèle à utiliser
    const model = this.config.get(
      'OLLAMA_MODEL',
      'nous-hermes2',
    );

    try {
      // Envoi des messages à Ollama
      const response = await fetch(`${url}/api/chat`, {
        method: 'POST',

        headers: {
          'Content-Type': 'application/json',
        },

        body: JSON.stringify({
          model,
          messages,

          // Retourne une seule réponse JSON
          stream: false,
        }),

        // Évite d'attendre indéfiniment
        signal: AbortSignal.timeout(120000),
      });

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

      // Conversion de la réponse en objet
      const data = await response.json() as {
        message?: { content?: string };
      };

      // Récupération du texte de l'IA
      const texte = data.message?.content;

      if (!texte) {
        throw new Error('Réponse Ollama vide');
      }

      return texte;

    } catch (error) {
      console.error('Erreur Ollama :', error);

      throw new ServiceUnavailableException(
        'Impossible de contacter le modèle IA local.',
      );
    }
  }
}

Les explication des instructions importantes :

  • fetch() effectue une requête HTTP vers Ollama.
  • model indique quel modèle doit répondre.
  • messages contient la question et l’historique de discussion.
  • stream: false demande une réponse complète, plutôt que plusieurs fragments successifs.
  • await response.json() transforme la réponse JSON en objet JavaScript.
  • return texte renvoie le texte à notre service NestJS.

Dans le fichier ollama/ollama.module.ts :

import { Module } from '@nestjs/common';
import { OllamaService } from './ollama.service';

@Module({
  providers: [OllamaService],

  // Permet aux autres modules
  // d'utiliser OllamaService
  exports: [OllamaService],
})
export class OllamaModule {}

Le développement de la gestion des conversations

Nous voulons qu’Hermes puisse se souvenir des messages précédents d’une même conversation.

Pour cela, NestJS va lire les messages enregistrés dans MySQL, les transmettre à Ollama et enregistrer la réponse.

Dans le fichier conversations/dto/send-message.dto.ts

import {
  IsInt,
  IsOptional,
  IsString,
  MaxLength,
  Min,
  MinLength,
} from 'class-validator';

export class SendMessageDto {
  // Question obligatoire
  @IsString()
  @MinLength(1)
  @MaxLength(10000)
  message: string;

  // Facultatif : poursuivre une conversation
  @IsOptional()
  @IsInt()
  @Min(1)
  conversationId?: number;
}

Dans le fichier conversations/conversations.service.ts

import {
  Injectable,
  NotFoundException,
} from '@nestjs/common';

import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { Conversation } from './entities/conversation.entity';
import { Message } from './entities/message.entity';
import { OllamaService } from '../ollama/ollama.service';
import { SendMessageDto } from './dto/send-message.dto';

@Injectable()
export class ConversationsService {
  constructor(
    @InjectRepository(Conversation)
    private conversations: Repository<Conversation>,

    @InjectRepository(Message)
    private messages: Repository<Message>,

    private ollama: OllamaService,
  ) {}

  // Afficher toutes les conversations
  async findAll() {
    return this.conversations.find({
      order: { createdAt: 'DESC' },
    });
  }

  // Afficher les messages d'une conversation
  async findOne(id: number) {
    const conversation = await this.conversations.findOne({
      where: { id },
      relations: { messages: true },
    });

    if (!conversation) {
      throw new NotFoundException('Conversation introuvable');
    }

    // Trier les messages dans l'ordre de création
    conversation.messages.sort((a, b) => a.id - b.id);

    return conversation;
  }

  // Envoyer une question à Hermes
  async envoyer(dto: SendMessageDto) {
    let conversation: Conversation;

    if (dto.conversationId) {
      // Continuer une conversation existante
      conversation = await this.findOne(dto.conversationId);
    } else {
      // Créer une nouvelle conversation
      conversation = await this.conversations.save({
        titre: dto.message.slice(0, 60),
      });
    }

    // Récupérer l'historique déjà enregistré
    const historique = await this.messages.find({
      where: { conversationId: conversation.id },
      order: { id: 'ASC' },
    });

    // Construire les messages pour Ollama
    const messagesIA = [
      {
        role: 'system' as const,
        content: 'Tu es Hermes, un assistant utile. Réponds en français avec des explications simples.',
      },

      ...historique.map((message) => ({
        role: message.role as 'user' | 'assistant',
        content: message.contenu,
      })),

      {
        role: 'user' as const,
        content: dto.message,
      },
    ];

    // Interroger le modèle local
    const reponse = await this.ollama.demander(messagesIA);

    // Enregistrer la question et la réponse
    await this.messages.save([
      {
        conversationId: conversation.id,
        role: 'user',
        contenu: dto.message,
      },
      {
        conversationId: conversation.id,
        role: 'assistant',
        contenu: reponse,
      },
    ]);

    return {
      conversationId: conversation.id,
      question: dto.message,
      reponse,
    };
  }

  // Supprimer une conversation
  async supprimer(id: number) {
    await this.findOne(id);

    await this.conversations.delete(id);

    return { message: 'Conversation supprimée' };
  }
}

L »historique complet est transmis à Ollama. Pour de longues conversations, il faudra limiter les messages envoyés afin de respecter la fenêtre de contexte du modèle. Il faudra aussi prévoir un mécanisme empêchant deux demandes simultanées de modifier la même conversation.

Dans le fichier conversations/conversations.controller.ts

import {
  Controller,
  Get,
  Post,
  Delete,
  Body,
  Param,
  ParseIntPipe,
} from '@nestjs/common';

import { ConversationsService } from './conversations.service';
import { SendMessageDto } from './dto/send-message.dto';

@Controller('conversations')
export class ConversationsController {
  constructor(private service: ConversationsService) {}

  // GET /conversations
  @Get()
  findAll() {
    return this.service.findAll();
  }

  // GET /conversations/1
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.service.findOne(id);
  }

  // POST /conversations/chat
  @Post('chat')
  envoyer(@Body() dto: SendMessageDto) {
    return this.service.envoyer(dto);
  }

  // DELETE /conversations/1
  @Delete(':id')
  supprimer(@Param('id', ParseIntPipe) id: number) {
    return this.service.supprimer(id);
  }
}

Dans le fichier conversations/conversations.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

import { Conversation } from './entities/conversation.entity';
import { Message } from './entities/message.entity';

import { ConversationsController } from './conversations.controller';
import { ConversationsService } from './conversations.service';
import { OllamaModule } from '../ollama/ollama.module';

@Module({
  imports: [
    TypeOrmModule.forFeature([
      Conversation,
      Message,
    ]),
    OllamaModule,
  ],

  controllers: [ConversationsController],
  providers: [ConversationsService],
})
export class ConversationsModule {}

Dans le fichier src/main.ts

import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Autoriser uniquement notre application Angular
  app.enableCors({
    origin: 'http://localhost:4200',
  });

  // Vérifier automatiquement les DTO
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
      forbidNonWhitelisted: true,
    }),
  );

  // Écouter uniquement sur l'ordinateur local
  await app.listen(
    Number(process.env.PORT || 3000),
    '127.0.0.1',
  );
}

bootstrap();

Lancer NestJS

npm run start:dev

Le serveur doit démarrer sur :

http://127.0.0.1:3000

Voici les routes :

Méthode	URL	Action
GET	/conversations	Liste des conversations
GET	/conversations/1	Messages de la conversation 1
POST	/conversations/chat	Poser une question
DELETE	/conversations/1	Supprimer la conversation

La création du projet Angular

Maintenant que NestJS fonctionne, nous allons créer l’interface utilisateur avec Angular, en utilisant les Signals pour gérer les données de façon réactive.

Ouvre un deuxième terminal PowerShell :

cd hermes-local

ng new frontend --standalone --routing --style=scss

cd frontend

Génère ensuite les composants et services :

ng g c pages/chat
ng g s services/chat

Voici à quoi va ressembler notre structure front-end :

frontend/
└── src/
    └── app/
        ├── app.ts
        ├── app.html
        ├── app.routes.ts
        │
        ├── models/
        │   └── chat.model.ts
        │
        ├── services/
        │   └── chat.ts
        │
        └── pages/
            └── chat/
                ├── chat.ts
                ├── chat.html
                └── chat.scss

La création des interfaces TypeScript

Dans le fichier src/app/models/chat.model.ts :

// Représente un message enregistré
export interface Message {
  id: number;
  role: 'user' | 'assistant';
  contenu: string;
  conversationId: number;
  createdAt: string;
}

// Représente une conversation
export interface Conversation {
  id: number;
  titre: string;
  createdAt: string;
  messages?: Message[];
}

// Réponse renvoyée par NestJS
export interface ChatResponse {
  conversationId: number;
  question: string;
  reponse: string;
}

Ces interfaces servent à définir la structure des objets utilisés dans Angular.

La creation du service Angular

Dans le fichier src/app/services/chat.ts :

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

import {
  Conversation,
  ChatResponse,
} from '../models/chat.model';

@Injectable({
  providedIn: 'root',
})
export class ChatService {
  private http = inject(HttpClient);

  // Adresse de notre API NestJS
  private api = 'http://127.0.0.1:3000/conversations';

  // Envoyer une question à Hermes
  envoyer(message: string, conversationId?: number) {
    return this.http.post<ChatResponse>(
      `${this.api}/chat`,
      {
        message,
        ...(conversationId ? { conversationId } : {}),
      },
    );
  }

  // Charger toutes les conversations
  liste() {
    return this.http.get<Conversation[]>(this.api);
  }

  // Charger une conversation et ses messages
  detail(id: number) {
    return this.http.get<Conversation>(
      `${this.api}/${id}`,
    );
  }

  // Supprimer une conversation
  supprimer(id: number) {
    return this.http.delete(`${this.api}/${id}`);
  }
}

Le développement du composant Chat avec Signals

Dans le fichier src/app/pages/chat/chat.ts :

import {
  Component,
  inject,
  signal,
} from '@angular/core';

import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';

import { ChatService } from '../../services/chat';
import {
  Conversation,
  Message,
} from '../../models/chat.model';

@Component({
  selector: 'app-chat',
  standalone: true,
  imports: [FormsModule],
  templateUrl: './chat.html',
  styleUrl: './chat.scss',
})
export class Chat {
  private service = inject(ChatService);

  // Liste des conversations
  conversations = signal<Conversation[]>([]);

  // Messages de la conversation sélectionnée
  messages = signal<Message[]>([]);

  // Identifiant de la conversation active
  conversationId = signal<number | null>(null);

  // Question saisie par l'utilisateur
  question = signal('');

  // Indique si Hermes prépare une réponse
  chargement = signal(false);

  // Message d'erreur éventuel
  erreur = signal('');

  constructor() {
    this.chargerConversations();
  }

  // Charger l'historique
  async chargerConversations() {
    try {
      const data = await firstValueFrom(
        this.service.liste(),
      );

      this.conversations.set(data);
    } catch {
      this.erreur.set(
        'Impossible de charger les conversations.',
      );
    }
  }

  // Sélectionner une conversation
  async ouvrir(id: number) {
    if (this.chargement()) return;

    this.erreur.set('');

    try {
      const conversation = await firstValueFrom(
        this.service.detail(id),
      );

      this.conversationId.set(id);
      this.messages.set(conversation.messages ?? []);
    } catch {
      this.erreur.set('Chargement impossible.');
    }
  }

  // Démarrer une nouvelle discussion
  nouvelleConversation() {
    if (this.chargement()) return;

    this.conversationId.set(null);
    this.messages.set([]);
    this.question.set('');
    this.erreur.set('');
  }

  // Envoyer une question
  async envoyer() {
    const texte = this.question().trim();

    // Ne rien faire si le champ est vide
    if (!texte || this.chargement()) return;

    this.chargement.set(true);
    this.erreur.set('');

    try {
      // Envoyer la question à NestJS
      const resultat = await firstValueFrom(
        this.service.envoyer(
          texte,
          this.conversationId() ?? undefined,
        ),
      );

      // Mémoriser l'identifiant de conversation
      this.conversationId.set(resultat.conversationId);

      // Vider le champ de saisie
      this.question.set('');

      // Recharger les messages depuis MySQL
      await this.ouvrirApresEnvoi(resultat.conversationId);

      // Actualiser la liste des conversations
      await this.chargerConversations();

    } catch {
      this.erreur.set(
        'Hermes ne répond pas. Vérifie Ollama et NestJS.',
      );
    } finally {
      this.chargement.set(false);
    }
  }

  private async ouvrirApresEnvoi(id: number) {
    const conversation = await firstValueFrom(
      this.service.detail(id),
    );

    this.messages.set(conversation.messages ?? []);
  }

  // Supprimer une conversation
  async supprimer(id: number) {
    if (this.chargement()) return;

    try {
      await firstValueFrom(this.service.supprimer(id));

      if (this.conversationId() === id) {
        this.nouvelleConversation();
      }

      await this.chargerConversations();
    } catch {
      this.erreur.set('Suppression impossible.');
    }
  }
}

Explication des Signals

Voici les trois instructions importantes :

// Créer une donnée réactive
question = signal('');

// Lire sa valeur
console.log(this.question());

// Modifier sa valeur
this.question.set('Bonjour Hermes');

Quand vous modifiez un Signal avec .set(), Angular actualise automatiquement les parties de l’interface qui utilisent cette donnée.

L’interface HTML

Dans le fichier src/app/pages/chat/chat.html :

<div class="application">

  <!-- Menu latéral -->
  <aside class="sidebar">

    <h2>Hermes Local</h2>

    <button
      (click)="nouvelleConversation()"
      [disabled]="chargement()">
      + Nouvelle conversation
    </button>

    <h3>Historique</h3>

    @for (conversation of conversations(); track conversation.id) {
      <div class="conversation">

        <span (click)="ouvrir(conversation.id)">
          {{ conversation.titre }}
        </span>

        <button
          class="delete"
          (click)="supprimer(conversation.id)"
          [disabled]="chargement()">
          ×
        </button>

      </div>
    }

  </aside>

  <!-- Zone principale -->
  <main class="chat">

    <header>
      <h1>Assistant Hermes</h1>
      <p>IA locale avec Ollama</p>
    </header>

    <section class="messages">

      @if (messages().length === 0) {
        <div class="welcome">
          <h2>Bonjour !</h2>
          <p>Comment puis-je t'aider aujourd'hui ?</p>
        </div>
      }

      @for (message of messages(); track message.id) {
        <div
          class="message"
          [class.user]="message.role === 'user'"
          [class.assistant]="message.role === 'assistant'">

          <strong>
            {{ message.role === 'user' ? 'Vous' : 'Hermes' }}
          </strong>

          <p>{{ message.contenu }}</p>

        </div>
      }

      @if (chargement()) {
        <p class="loading">
          Hermes réfléchit...
        </p>
      }

    </section>

    @if (erreur()) {
      <p class="error">{{ erreur() }}</p>
    }

    <!-- Formulaire d'envoi -->
    <form
      class="formulaire"
      (ngSubmit)="envoyer()">

      <textarea
        name="question"
        [ngModel]="question()"
        (ngModelChange)="question.set($event)"
        placeholder="Pose ta question à Hermes..."
        [disabled]="chargement()">
      </textarea>

      <button
        type="submit"
        [disabled]="chargement() || !question().trim()">
        Envoyer
      </button>

    </form>

  </main>

</div>

On ajouter le style SCSS

Dans le fichier src/app/pages/chat/chat.scss :

:host {
  display: block;
  height: 100vh;
  font-family: Arial, sans-serif;
}

.application {
  display: flex;
  height: 100%;
  background: #f5f6fa;
}

.sidebar {
  width: 260px;
  background: #172033;
  color: white;
  padding: 20px;
  overflow-y: auto;

  button {
    padding: 12px;
    border: none;
    border-radius: 8px;
    cursor: pointer;
  }

  .conversation {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 10px;
    border-bottom: 1px solid #394256;

    span {
      cursor: pointer;
      overflow-wrap: anywhere;
    }

    .delete {
      background: transparent;
      color: white;
      font-size: 20px;
    }
  }
}

.chat {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

header {
  padding: 20px;
  background: white;
  border-bottom: 1px solid #ddd;

  h1 {
    margin: 0;
  }
}

.messages {
  flex: 1;
  overflow-y: auto;
  padding: 25px;
}

.message {
  max-width: 75%;
  margin-bottom: 18px;
  padding: 15px;
  border-radius: 12px;
  overflow-wrap: anywhere;

  p {
    white-space: pre-wrap;
  }
}

.user {
  background: #dbeafe;
  margin-left: auto;
}

.assistant {
  background: white;
  margin-right: auto;
}

.welcome {
  text-align: center;
  margin-top: 60px;
}

.formulaire {
  display: flex;
  gap: 10px;
  padding: 20px;
  background: white;

  textarea {
    flex: 1;
    padding: 12px;
    border: 1px solid #ccc;
    border-radius: 8px;
    resize: vertical;
    min-height: 50px;
  }

  button {
    padding: 12px 20px;
    background: #4f46e5;
    color: white;
    border: none;
    border-radius: 8px;
    cursor: pointer;

    &:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }
  }
}

.loading {
  color: #666;
}

.error {
  color: #b91c1c;
  padding: 10px 20px;
}

@media (max-width: 700px) {
  .sidebar {
    width: 140px;
    padding: 10px;
  }

  .messages {
    padding: 12px;
  }

  .message {
    max-width: 95%;
  }
}

Dans src/styles.scss :

html,
body {
  margin: 0;
  height: 100%;
}

Nous allons configurer la route Angular

Dans src/app/app.routes.ts :

import { Routes } from '@angular/router';
import { Chat } from './pages/chat/chat';

export const routes: Routes = [
  {
    path: '',
    component: Chat,
  },
];

Dans src/app/app.html, nous allons remplacer le contenu généré par :

<router-outlet />

Il fout vérifie que RouterOutlet figure dans les imports du composant racine app.ts.

Démarrer l’application complète

Vous allez avoir trois services locaux en fonctionnement : MySQL, Ollama et NestJS, auxquels nous ajoutons Angular.

Dans un terminal, vérifiez qu’Ollama est actif :

ollama list

Si Ollama n’est pas déjà démarré :

ollama serve

N.B. : Sur Windows, Ollama peut déjà fonctionner en arrière-plan. Dans ce cas, il ne faut pas lancer un deuxième serveur sur le même port.

Dans un deuxième terminal :

cd hermes-local/backend
npm run start:dev

Dans un troisième terminal :

cd hermes-local/frontend
ng serve

N.B. : l’application utilise uniquement des services locaux, mais elle reste pour l’instant un prototype de développement.