DEV Community

Sujan Lamichhane
Sujan Lamichhane

Posted on

Building a Real-Time Web UI for a Local AI Platform — Angular 22 + Spring Boot 4

From terminal to browser — Phase 7 of the Jarvis AI Platform.


Where We Left Off

After completing six backend-focused phases, Jarvis had evolved into a fully capable local-first AI platform.

Phase 1 → AI Chat + JWT + CLI
Phase 2 → Long-Term Memory + pgvector
Phase 3 → RAG Document Search
Phase 4 → Tool Engine + MCP Server
Phase 5 → Voice Assistant (Whisper + TTS)
Phase 6 → ReACT Agent System
Enter fullscreen mode Exit fullscreen mode

Everything worked.

But everything lived inside a terminal.

Phase 7 changes that.

The goal is a modern browser-based interface without compromising the project's philosophy:

Your AI. Your Data. Your Machine.


Choosing the Frontend Framework

Before writing a single component, we opened a community vote.

The options were:

  • Angular
  • React
  • Vue
  • Next.js

Angular won.

There were three reasons.

1. Reactive Programming Fits Spring WebFlux

Jarvis is built on Spring WebFlux.

The backend already communicates through reactive streams and Server-Sent Events (SSE).

Angular already embraces this model through RxJS, making streaming APIs natural to implement.

Managing subscriptions, cancellation, cleanup, and stream composition is significantly cleaner than manually coordinating React hooks.


2. The Maintainer Already Knows Angular

Open-source projects move at the speed of their maintainers.

Using a framework I already understand deeply means:

  • faster reviews
  • faster bug fixes
  • easier contributor support

Contributors can always learn Angular.

Maintainers shouldn't have to learn an entirely new ecosystem while reviewing every pull request.


3. Angular Material Without Looking Like Angular Material

Angular Material provides an excellent foundation:

  • dialogs
  • icons
  • form controls
  • accessibility

But the overall appearance is custom.

Every layout, spacing rule, typography choice, and color palette is implemented through SCSS.

The result feels like Jarvis—not another stock Material application.


The Stack

Layer Technology
Framework Angular 22
Language TypeScript 6
UI Components Angular Material 22 (partial)
Styling Custom SCSS + CSS Variables
State Angular Signals
HTTP Angular HttpClient
Streaming fetch() + ReadableStream
Routing Angular Router (Lazy Loaded)
Markdown ngx-markdown

The First Surprise: EventSource Doesn't Work

The obvious choice for Server-Sent Events is EventSource.

// ❌ Doesn't work

const source = new EventSource('/api/v1/chat/stream');
Enter fullscreen mode Exit fullscreen mode

Unfortunately it has three major limitations:

  • GET requests only
  • cannot send a request body
  • cannot send Authorization headers

Jarvis requires all three.

Every chat request is:

  • authenticated with JWT
  • sent as POST
  • carries JSON

So EventSource simply isn't an option.


The Solution: fetch() + ReadableStream

Instead, every streaming endpoint uses fetch().

fetch('/api/v1/chat/stream', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${token}`,
    Accept: 'text/event-stream'
  },
  body: JSON.stringify(request)
})
.then(response => response.body!.getReader())
.then(reader => readStream(reader));
Enter fullscreen mode Exit fullscreen mode

This approach supports:

  • JWT authentication
  • POST requests
  • request bodies
  • streaming responses

The exact same implementation powers:

  • Chat
  • Agents
  • Voice

One streaming implementation.

Three features.


The "Done" Event Fired Twice

The backend emits events like this:

event: token
data: {"t":"Hello"}

event: token
data: {"t":" world"}

event: done
data: [DONE]
Enter fullscreen mode Exit fullscreen mode

The frontend correctly handled the done event.

But something strange happened afterward.

When the browser finished reading the stream, reader.read() also returned:

done === true
Enter fullscreen mode Exit fullscreen mode

So onDone() executed twice.

The visible result?

Every AI response ended with an empty assistant message.


The Fix

A single guard solved it.

let finished = false;

const finish = () => {
    if (finished) return;

    finished = true;
    onDone();
};
Enter fullscreen mode Exit fullscreen mode

Both code paths now call finish().

No matter how the stream ends, onDone() executes exactly once.


Designing the Theme System

Dark mode wasn't added later.

It was designed from the beginning.

Every color is a CSS custom property.

:root {
  --bg-primary: #0f1117;
  --bg-secondary: #1a1d27;

  --accent-primary: #6366f1;

  --success: #10b981;
  --error: #ef4444;

  --error-surface: rgba(239,68,68,.12);
}
Enter fullscreen mode Exit fullscreen mode

Light mode simply overrides the variables.

.light-theme {
  --bg-primary: white;
  --bg-secondary: #f8f9fc;
}
Enter fullscreen mode Exit fullscreen mode

Components never hardcode colors.

Instead they use:

background: var(--bg-primary);
color: var(--text-primary);
Enter fullscreen mode Exit fullscreen mode

The benefit is huge.

Switching themes repaints the entire application without modifying a single component.


Angular Signals Everywhere

Every page uses Signals for UI state.

readonly messages = signal<Message[]>([]);
readonly streamingContent = signal('');
readonly isStreaming = signal(false);
Enter fullscreen mode Exit fullscreen mode

Computed state stays simple.

readonly canSend = computed(() =>
    this.input().trim().length > 0 &&
    !this.isStreaming()
);
Enter fullscreen mode Exit fullscreen mode

Our rule became:

Signals
↓
Everything the template displays

Observables
↓
HTTP calls
SSE streams
Background work
Enter fullscreen mode Exit fullscreen mode

That separation keeps components surprisingly small.


The Stop Button Bug

During streaming the Send button becomes a Stop button.

The original implementation reused one button.

<button [disabled]="!canSend()">
Enter fullscreen mode Exit fullscreen mode

The problem?

canSend() becomes false while streaming.

So the Stop button existed...

but was disabled.

It couldn't actually stop anything.

The solution was splitting them into two buttons.

@if (isStreaming()) {
    <button (click)="stopStreaming()">
        stop_circle
    </button>
} @else {
    <button
        [disabled]="!canSend()"
        (click)="sendMessage()">
        send
    </button>
}
Enter fullscreen mode Exit fullscreen mode

Stopping simply aborts the fetch request.

private abortController: AbortController | null = null;

stopStreaming() {
    this.abortController?.abort();
}
Enter fullscreen mode Exit fullscreen mode

CORS Wasn't Actually Fixed

Adding a CorsWebFilter looked correct.

It wasn't.

Spring Security intercepts OPTIONS requests before the filter executes.

The real solution lives inside ServerHttpSecurity.

@Bean
SecurityWebFilterChain security(ServerHttpSecurity http) {

    return http
            .cors(cors -> cors.configurationSource(
                    corsConfigurationSource()))
            .csrf(ServerHttpSecurity.CsrfSpec::disable)
            .build();
}
Enter fullscreen mode Exit fullscreen mode

Without that configuration the browser rejected every authenticated streaming request before it even reached the controller.


Pages Built So Far

✅ Login
    Register/Login tabs
    JWT authentication

✅ App Shell
    Sidebar
    Topbar
    Lazy routing

✅ Chat
    Real-time SSE
    Markdown rendering
    Stop streaming

✅ Settings
    Provider health
    Voice configuration

✅ Memory
    CRUD
    Type badges
    Confirmation dialogs
Enter fullscreen mode Exit fullscreen mode

Still open:

📋 Documents
📋 Agents
📋 Voice
Enter fullscreen mode Exit fullscreen mode

These are active contributor issues.


Accessibility Matters

CodeRabbit caught several accessibility problems before users did.

Alerts

Instead of plain divs:

<div role="status"
     aria-live="polite">
Enter fullscreen mode Exit fullscreen mode

Errors use:

<div role="alert"
     aria-live="assertive">
Enter fullscreen mode Exit fullscreen mode

Screen readers now announce messages automatically.


Hidden Delete Button

Opacity alone isn't enough.

Keyboard users could tab to an invisible button.

The fix:

.memory-card:focus-within .memory-card__delete {
    opacity: 1;
}

.memory-card__delete:focus-visible {
    outline: 2px solid var(--accent-primary);
}
Enter fullscreen mode Exit fullscreen mode

Now keyboard navigation behaves correctly.


Confirmation Dialog

The dialog gained proper semantics.

<div
    role="dialog"
    aria-modal="true"
    aria-labelledby="clear-confirm-title">
Enter fullscreen mode Exit fullscreen mode

Small changes.

Much better accessibility.


Lessons Learned

EventSource Isn't Enough

If your API requires:

  • JWT
  • POST
  • JSON

use fetch() and ReadableStream from the beginning.


Theme Tokens Scale

Hardcoded colors eventually break dark mode.

Every reusable color became a design token.

That decision continues paying dividends as new pages are added.


Signals Simplify Templates

Signals eliminate:

  • manual change detection
  • template subscriptions
  • async pipe chains

Templates become far easier to read.


Automated Reviews Catch Real Bugs

Several production-quality bugs were discovered during automated review:

  • double onDone() execution
  • inaccessible delete button
  • disabled Stop button
  • incorrect CORS configuration

Those bugs never reached users.


Lazy Loading Was Worth It

Every feature loads independently.

{
  path: 'memory',
  loadComponent: () =>
      import('./features/memory/memory')
          .then(m => m.MemoryPage)
}
Enter fullscreen mode Exit fullscreen mode

Contributors can add entirely new pages without increasing the initial bundle size.


What's Next

Three major pages remain:

📄 Documents
    File upload
    Status polling

🤖 Agents
    Live ReACT execution
    Step-by-step streaming

🎙 Voice
    MediaRecorder
    SSE AI responses
Enter fullscreen mode Exit fullscreen mode

After that comes polish:

  • responsive layouts
  • mobile improvements
  • end-to-end testing
  • UI refinement

The backend has already reached six phases.

Phase 7 is about making that functionality accessible through a polished browser experience.


Contributing

Jarvis AI Platform is open source under the Apache 2.0 License.

Current contributor issues:

Documents Page → Good First Issue

Agents Page → Intermediate

Voice Page → Advanced
Enter fullscreen mode Exit fullscreen mode

GitHub:

https://github.com/sujankim/jarvis-ai-platform


Jarvis AI Platform Series


Your AI. Your Data. Your Machine.

Top comments (0)