How to Create Modern AI Chatbot Experiences in Angular

Date
Updated

Demo

The user sends a message. A pulsing dot shows while the AI thinks, and then the response streams in as Markdown.

Angular Generative AI Demo

Prefer video? I gave this as a talk at ng-conf:

Setup

Clone the demo repo and install its dependencies:

git clone https://github.com/c-o-l-i-n/ng-generative-ai-demo.git
cd ng-generative-ai-demo
npm install

Get an API key from Google AI Studio and put it in a .env file at the project root:

# .env
GOOGLE_AI_STUDIO_API_KEY=paste-api-key-here

Backend

We’ll use Google’s Gemini API because it has a free tier, as long as you’re willing to sell your soul and your data to Google. The whole backend is one Hono app:

// src/server.ts

import { GoogleGenAI } from '@google/genai';
import { serve } from '@hono/node-server';
import dotenv from 'dotenv';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { stream } from 'hono/streaming';

dotenv.config();

const { GOOGLE_AI_STUDIO_API_KEY } = process.env;

if (!GOOGLE_AI_STUDIO_API_KEY) {
  throw new Error('Provide GOOGLE_AI_STUDIO_API_KEY in a .env file');
}

const ai = new GoogleGenAI({ apiKey: GOOGLE_AI_STUDIO_API_KEY });
const chat = ai.chats.create({ model: 'gemini-3.1-flash-lite' });

const app = new Hono();

app.use(cors());

app.post('/message', async (c) => {
  const prompt = await c.req.text();

  console.log('Received prompt:', prompt);

  if (!prompt) {
    return c.body(null, 400);
  }

  return stream(c, async (s) => {
    try {
      console.log('Generating response:');
      const response = await chat.sendMessageStream({
        message: prompt,
      });

      for await (const chunk of response) {
        const chunkText = chunk.text;
        console.log(chunkText);
        await s.write(chunkText ?? '');
      }
    } catch (err) {
      console.error('Error generating response:', err);
    }
  });
});

const port = 3000;
serve({ fetch: app.fetch, port }, () => {
  console.log('Server is running on port', port);
});

The /message endpoint sends the prompt to Gemini and writes each chunk to the response as it arrives. Hono’s stream() helper closes the response when the callback finishes. cors() lets the Angular dev server on port 4200 call the backend on port 3000.

ai.chats.create() opens one chat session that remembers the conversation in memory, which is fine for a demo.

App config

The app’s only provider is ngx-markdown, which renders the AI’s Markdown:

// src/app/app.config.ts

import { ApplicationConfig } from '@angular/core';
import { provideMarkdown } from 'ngx-markdown';

export const appConfig: ApplicationConfig = {
  providers: [
    // add markdown support to handle llm output
    provideMarkdown(),
  ],
};

State

MessageService holds the chat state and streams the responses.

// src/app/message.service.ts

export interface Message {
  id: string;
  text: string;
  fromUser: boolean;
  generating?: boolean;
}

@Service()
export class MessageService {
  private readonly http = inject(HttpClient);

  private readonly completeMessages = signal<Message[]>([]);
  private readonly incomingMessage = signal<Message | null>(null);

  readonly messages = computed(() => {
    const incomingMessage = this.incomingMessage();
    return incomingMessage
      ? [...this.completeMessages(), incomingMessage]
      : this.completeMessages();
  });

  readonly generatingInProgress = computed(
    () => this.incomingMessage() !== null,
  );

Two private signals hold the state, completeMessages and incomingMessage. messages and generatingInProgress are computed from them, so they can’t drift out of sync.

Streaming the response

// src/app/message.service.ts

private getChatResponseStream(prompt: string): Observable<Message> {
  const id = window.crypto.randomUUID();

  return this.http
    .post('http://localhost:3000/message', prompt, {
      responseType: 'text',
      observe: 'events',
      reportProgress: true,
    })
    .pipe(
      filter(
        (event: HttpEvent<string>): boolean =>
          event.type === HttpEventType.DownloadProgress ||
          event.type === HttpEventType.Response,
      ),
      map((event: HttpEvent<string>): Message =>
        event.type === HttpEventType.DownloadProgress
          ? {
              id,
              text: (event as HttpDownloadProgressEvent).partialText!,
              fromUser: false,
              generating: true,
            }
          : {
              id,
              text: (event as HttpResponse<string>).body!,
              fromUser: false,
              generating: false,
            },
      ),
      startWith<Message>({
        id,
        text: '',
        fromUser: false,
        generating: true,
      }),
    );
}
  • responseType: 'text', observe: 'events', and reportProgress: true make HttpClient emit a DownloadProgress event for each chunk.
  • filter keeps the progress events and the final Response.
  • map turns each event into a Message. partialText holds all the text received so far, so each message replaces the last one.
  • startWith emits an empty message right away, which shows the pulsing dot.

Sending a message

// src/app/message.service.ts

sendMessage(prompt: string): void {
  this.completeMessages.update((msgs) => [
    ...msgs,
    { id: window.crypto.randomUUID(), text: prompt, fromUser: true },
  ]);

  this.getChatResponseStream(prompt).subscribe({
    next: (message) => this.incomingMessage.set(message),

    complete: () => {
      const completedMessage = this.incomingMessage();
      if (completedMessage) {
        this.completeMessages.update((messages) => [
          ...messages,
          { ...completedMessage, generating: false },
        ]);
      }
      this.incomingMessage.set(null);
    },

    error: () => this.incomingMessage.set(null),
  });
}

sendMessage adds the user’s message, then sets incomingMessage on every stream update. When the stream completes, the AI message moves into completeMessages. On error, incomingMessage resets so the send button doesn’t stay disabled.

UI

<!-- src/app/app.component.ts -->

<!-- Message List -->
@for (message of messages(); track message.id) {
  @if (message.fromUser) {
    <div class="flex flex-row-reverse">
      <pre
        class="max-w-[80%] rounded-2xl rounded-br-sm bg-blue-600 px-4 py-2 font-sans leading-relaxed whitespace-pre-wrap text-white"
        >{{ message.text }}</pre>
    </div>
  } @else {
    @if (message.generating && !message.text) {
      <div class="px-1 py-2">
        <div class="size-4 animate-pulse rounded-full bg-white"></div>
      </div>
    } @else {
      <markdown
        [data]="message.text"
        class="prose prose-invert w-full max-w-none px-1 py-2 leading-relaxed text-slate-200"
      />
    }
  }
}

User messages get a blue bubble. An AI message with no text yet shows a pulsing dot. Once text arrives, ngx-markdown renders it and Tailwind’s prose classes style it. The rest of the component reads the two computed signals and passes form submissions to MessageService.

Run it

Start the backend and the Angular dev server in two terminals:

# terminal 1
npm run server

# terminal 2
npm start

Then open http://localhost:4200.

Takeaways

  • Stream the LLM response from the backend with Hono’s stream() helper
  • Read it in Angular with HttpClient progress events
  • Keep state in signals and derive the rest with computed

Happy coding!