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
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');
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));
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]
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
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();
};
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);
}
Light mode simply overrides the variables.
.light-theme {
--bg-primary: white;
--bg-secondary: #f8f9fc;
}
Components never hardcode colors.
Instead they use:
background: var(--bg-primary);
color: var(--text-primary);
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);
Computed state stays simple.
readonly canSend = computed(() =>
this.input().trim().length > 0 &&
!this.isStreaming()
);
Our rule became:
Signals
↓
Everything the template displays
Observables
↓
HTTP calls
SSE streams
Background work
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()">
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>
}
Stopping simply aborts the fetch request.
private abortController: AbortController | null = null;
stopStreaming() {
this.abortController?.abort();
}
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();
}
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
Still open:
📋 Documents
📋 Agents
📋 Voice
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">
Errors use:
<div role="alert"
aria-live="assertive">
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);
}
Now keyboard navigation behaves correctly.
Confirmation Dialog
The dialog gained proper semantics.
<div
role="dialog"
aria-modal="true"
aria-labelledby="clear-confirm-title">
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)
}
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
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
GitHub:
https://github.com/sujankim/jarvis-ai-platform
Jarvis AI Platform Series
- Part 1 — Building a Local-First AI Assistant with Spring Boot 4
- Part 2 — Long-Term Memory with pgvector
- Part 3 — Semantic Memory Retrieval
- Part 4 — Tool Engine with Spring AI
- Part 5 — Voice Assistant with Whisper & Text-to-Speech
- Part 6 — Building an AI Agent System with the ReACT Pattern
- Part 7 — Building a Real-Time Web UI with Angular 22 (this article)
Your AI. Your Data. Your Machine.
Top comments (0)