Angular RAG Chat Interface
From Standalone Components to Fully-Featured Admin Dashboards
The frontend is where RAG comes alive for end users. A backend that's technically perfect but slow to use, or confusing to navigate, might as well not exist. This article is about building an intuitive, responsive Angular chat interface that doesn't sacrifice power for simplicity.
Modern Angular—standalone components, no NgModules, reactive forms, Material Design 3—enables a lean, maintainable codebase. Add internationalization (i18n) support, role-based feature visibility, and streaming responses, and you have a frontend that whispers, "This system was built by people who cared."
The Angular Version: Modern, Not Legacy
The RAG system uses Angular 20 with:
- Standalone components (no NgModule declarations)
- Reactive architecture (RxJS observables, not promises)
- Angular Material 3 (modern styling and components)
- Transloco (i18n for multi-language support)
- TypeScript strict mode (no
anytypes in critical paths)
This means:
- Faster bundle sizes (tree-shaking works better)
- Clearer dependency injection (explicit imports)
- Better performance (change detection optimization)
- Easier testing (minimal mocking required)
Component Architecture: The Composition Pattern
Instead of one monolithic dashboard component, the chat interface is built from small, focused, reusable pieces.
┌─────────────────────────────────────────────┐
│ DashboardComponent │
│ (orchestrator, state holder, layout) │
├─────────────┬─────────────────┬─────────────┤
│ │ │ │
▼ ▼ ▼ ▼
ChatHistory ChatInput ChatMessage ChatActions
(messages) (textarea) (rendered) (buttons)
│
└─ SourceBadge
(reference links)
DashboardComponent: The Orchestrator
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
ChatHistoryComponent,
ChatInputComponent,
ChatMessageComponent,
ChatActionsComponent
],
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'],
encapsulation: ViewEncapsulation.None // Material theming
})
export class DashboardComponent implements OnInit, OnDestroy, AfterViewChecked {
private destroy$ = new Subject<void>();
private shouldScrollToBottom = false;
// State
messages: ChatMessage[] = [];
isLoading = false;
currentUser: IUser | null = null;
// RAG Config
selectedModelId: number | null = null;
selectedContextId: number | null = null;
topK = 5;
rerank = false;
formattingMode: 'default' | 'structured' = 'default';
// UI State
@ViewChild('messagesContainer')
private messagesContainer!: ElementRef;
constructor(
private ragService: RagService,
private authService: AuthService,
private chatService: ChatService
) {}
ngOnInit() {
this.authService.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe(user => {
this.currentUser = user;
this.loadContexts();
this.loadModels();
});
}
async sendMessage(question: string) {
if (!question.trim()) return;
// Add user message to history
this.messages.push({
question,
timestamp: new Date(),
response: undefined
});
this.isLoading = true;
try {
const result = await this.ragService.query({
question,
contextId: this.selectedContextId,
modelId: this.selectedModelId,
topK: this.topK,
rerank: this.rerank,
format: this.formattingMode
}).toPromise();
// Add AI response
this.messages.push({
question: '',
response: result.answer,
references: this.parseReferences(result),
timestamp: new Date()
});
this.shouldScrollToBottom = true;
} catch (error) {
// Error handling shown in UI
this.showErrorNotification('Query failed');
} finally {
this.isLoading = false;
}
}
ngAfterViewChecked() {
if (this.shouldScrollToBottom) {
this.messagesContainer.nativeElement.scrollTop =
this.messagesContainer.nativeElement.scrollHeight;
this.shouldScrollToBottom = false;
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
ChatHistoryComponent: Displaying Messages
@Component({
selector: 'app-chat-history',
standalone: true,
imports: [CommonModule, ChatMessageComponent],
template: `
<div class="chat-history">
<div *ngFor="let msg of messages" class="message-item">
<app-chat-message
[message]="msg">
</app-chat-message>
</div>
</div>
`,
styles: [`
.chat-history {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.message-item {
margin-bottom: 12px;
}
`]
})
export class ChatHistoryComponent {
@Input() messages: ChatMessage[] = [];
}
ChatInputComponent: Question Entry
@Component({
selector: 'app-chat-input',
standalone: true,
imports: [CommonModule, FormsModule, MatButtonModule, MatIconModule],
template: `
<form (ngSubmit)="onSubmit()" class="chat-input">
<textarea
[(ngModel)]="question"
name="question"
placeholder="Ask a question..."
[disabled]="isLoading"
(keydown.enter)="handleEnter($event)">
</textarea>
<button
type="submit"
[disabled]="!question.trim() || isLoading"
mat-raised-button
color="primary">
<mat-icon>send</mat-icon>
{{ 'chat.send' | transloco }}
</button>
</form>
`,
styles: [`
.chat-input {
display: flex;
gap: 8px;
padding: 16px;
border-top: 1px solid var(--mat-divider-color);
}
textarea {
flex: 1;
min-height: 60px;
padding: 8px;
border: 1px solid var(--mat-outline-label-text-color);
border-radius: 4px;
resize: vertical;
}
`]
})
export class ChatInputComponent {
@Output() submit = new EventEmitter<string>();
@Input() isLoading = false;
question = '';
onSubmit() {
if (this.question.trim()) {
this.submit.emit(this.question);
this.question = '';
}
}
handleEnter(event: KeyboardEvent) {
if (event.ctrlKey || event.metaKey) {
this.onSubmit();
event.preventDefault();
}
}
}
ChatMessageComponent: Rendering with Citations
@Component({
selector: 'app-chat-message',
standalone: true,
imports: [CommonModule, MatButtonModule, MatIconModule],
template: `
<div [class.message]="true" [class.assistant]="!isUser">
<div class="content">
<p *ngIf="message.question" class="user-question">
{{ message.question }}
</p>
<p *ngIf="message.response" class="assistant-response"
[innerHTML]="formatResponse(message.response)">
</p>
</div>
<div *ngIf="message.references?.length" class="sources">
<span class="label">{{ 'chat.sources' | transloco }}:</span>
<div class="badges">
<button
*ngFor="let ref of message.references; let i = index"
class="source-badge"
(click)="onSourceClick(ref)">
[{{ i + 1 }}] {{ ref.title }}
</button>
</div>
</div>
</div>
`,
styles: [`
.message {
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
}
.message.assistant {
background-color: var(--mdc-theme-surface-variant);
}
.user-question {
font-weight: 500;
margin: 0 0 8px 0;
}
.assistant-response {
margin: 0;
line-height: 1.6;
}
.sources {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid var(--mat-divider-color);
}
.source-badge {
margin: 4px 4px 0 0;
padding: 4px 8px;
background-color: var(--mdc-theme-primary);
color: var(--mdc-theme-on-primary);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
`]
})
export class ChatMessageComponent {
@Input() message!: ChatMessage;
@Output() sourceClick = new EventEmitter<SourceReference>();
get isUser(): boolean {
return !!this.message.question && !this.message.response;
}
formatResponse(response: string): string {
// Parse citations [REF-1] and convert to clickable badges
return response.replace(/\[REF-(\d+)\]/g,
'<span class="citation">[REF-$1]</span>');
}
onSourceClick(ref: SourceReference) {
this.sourceClick.emit(ref);
}
}
State Management: Store Service Pattern
Instead of NgRx (which can be overkill for this use case), the RAG system uses a simpler Store Service pattern:
@Injectable({ providedIn: 'root' })
export class ChatStateService {
private messagesSubject = new BehaviorSubject<ChatMessage[]>([]);
private modelsSubject = new BehaviorSubject<IRagModel[]>([]);
private contextsSubject = new BehaviorSubject<IContext[]>([]);
messages$ = this.messagesSubject.asObservable();
models$ = this.modelsSubject.asObservable();
contexts$ = this.contextsSubject.asObservable();
addMessage(message: ChatMessage) {
const current = this.messagesSubject.value;
this.messagesSubject.next([...current, message]);
}
loadContexts(contexts: IContext[]) {
this.contextsSubject.next(contexts);
}
clearMessages() {
this.messagesSubject.next([]);
}
}
Why not NgRx?
- Extra boilerplate for simple state
- Learning curve for new team members
- Debugging complexity with action/reducer tracing
- Store Service is 80/20: 80% of NgRx benefits, 20% of complexity
For complex, multi-feature applications with shared state across many components, NgRx makes sense. For a concentrated chat interface, Store Service is pragmatic.
Real-Time Updates & Streaming Responses
When querying the backend, responses should feel instantaneous even if the LLM takes 5+ seconds.
Streaming Response Pattern
async streamQuery(question: string): Promise<void> {
// Start with immediate acknowledgment
const tempMessage: ChatMessage = {
question,
response: '⏳ Processing...',
timestamp: new Date()
};
this.messages.push(tempMessage);
try {
// Backend streams chunked responses
const response = await this.ragService.queryStream({
question,
contextId: this.selectedContextId
}).toPromise();
// Update the message with rendered response
const messageIndex = this.messages.length - 1;
this.messages[messageIndex].response = response.answer;
this.messages[messageIndex].references = response.references;
// Trigger scroll-to-bottom
this.shouldScrollToBottom = true;
} catch (error) {
tempMessage.response = '❌ Query failed. Please try again.';
}
}
Alternatively, with SSE (Server-Sent Events):
streamQueryWithSSE(question: string) {
const eventSource = new EventSource(
`/api/v1/chat/stream?question=${question}`
);
let accumulatedResponse = '';
const messageIndex = this.messages.length - 1;
eventSource.onmessage = (event) => {
const chunk = JSON.parse(event.data);
accumulatedResponse += chunk.text;
this.messages[messageIndex].response = accumulatedResponse;
};
eventSource.onerror = () => {
eventSource.close();
this.messages[messageIndex].response = accumulatedResponse;
};
}
Context Switching: Multi-Domain Knowledge Bases
Users can switch between different knowledge domains without leaving the app.
export interface IContext {
id: number;
name: string;
description?: string;
icon?: string;
isPublic: boolean;
createdBy: number;
createdAt: Date;
}
UI for context selection:
<div class="context-selector">
<mat-form-field>
<mat-label>{{ 'dashboard.selectContext' | transloco }}</mat-label>
<mat-select [(ngModel)]="selectedContextId">
<mat-option
*ngFor="let ctx of contexts$ | async"
[value]="ctx.id">
<mat-icon *ngIf="ctx.icon">{{ ctx.icon }}</mat-icon>
{{ ctx.name }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
When context changes:
- Clear chat history (or tag messages by context)
- Update
selectedContextId - Fetch context-specific models and settings
- Reset RAG parameters to context defaults
selectContext(contextId: number) {
this.selectedContextId = contextId;
this.messages = []; // Clear history when switching contexts
this.loadContextSettings(contextId);
}
async loadContextSettings(contextId: number) {
const settings = await this.contextsService
.getSettings(contextId).toPromise();
this.topK = settings.defaultTopK || 5;
this.rerank = settings.defaultRerank || false;
this.formattingMode = settings.defaultFormat || 'default';
}
Admin Features: Document & Context Management
The same frontend serves two audiences: regular users (chat) and admins (management).
Admin Dashboard Tab Structure
<mat-tab-group class="admin-tabs" *ngIf="currentUser?.role === 'ADMIN'">
<mat-tab label="{{ 'admin.documents' | transloco }}">
<app-document-management></app-document-management>
</mat-tab>
<mat-tab label="{{ 'admin.contexts' | transloco }}">
<app-context-management></app-context-management>
</mat-tab>
<mat-tab label="{{ 'admin.users' | transloco }}">
<app-user-management></app-user-management>
</mat-tab>
<mat-tab label="{{ 'admin.models' | transloco }}">
<app-model-management></app-model-management>
</mat-tab>
</mat-tab-group>
Document Management Component
@Component({
selector: 'app-document-management',
standalone: true,
imports: [CommonModule, MatTableModule, MatButtonModule, MatDialogModule],
template: `
<div class="document-management">
<button mat-raised-button (click)="onUploadClick()">
<mat-icon>upload</mat-icon>
{{ 'admin.uploadDocument' | transloco }}
</button>
<table mat-table [dataSource]="documents$ | async" class="documents-table">
<!-- Title Column -->
<ng-container matColumnDef="title">
<th mat-header-cell>{{ 'admin.title' | transloco }}</th>
<td mat-cell>{{ element.title }}</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell>{{ 'admin.status' | transloco }}</th>
<td mat-cell>
<span [class]="'status-' + element.status">
{{ 'admin.status.' + element.status | transloco }}
</span>
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell>{{ 'admin.actions' | transloco }}</th>
<td mat-cell>
<button mat-icon-button (click)="onDelete(element.id)">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
`
})
export class DocumentManagementComponent implements OnInit {
@Input() selectedContextId!: number | null;
documents$!: Observable<IDocument[]>;
displayedColumns = ['title', 'status', 'actions'];
constructor(private documentsService: DocumentsService) {}
ngOnInit() {
this.documents$ = this.selectedContextId
? this.documentsService.getByContext(this.selectedContextId)
: of([]);
}
onUploadClick() {
// Open file upload dialog
}
async onDelete(documentId: number) {
const confirmed = await this.confirmDialog.open('Delete document?');
if (confirmed) {
await this.documentsService.delete(documentId).toPromise();
// Refresh list
}
}
}
Context Management Component
@Component({
selector: 'app-context-management',
standalone: true,
imports: [CommonModule, MatFormFieldModule, MatInputModule, MatButtonModule],
template: `
<div class="context-management">
<form (ngSubmit)="onCreateContext(form)" #form="ngForm">
<mat-form-field>
<mat-label>{{ 'admin.contextName' | transloco }}</mat-label>
<input matInput name="name" ngModel required>
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'admin.contextDescription' | transloco }}</mat-label>
<textarea matInput name="description" ngModel></textarea>
</mat-form-field>
<mat-slide-toggle name="isPublic" ngModel>
{{ 'admin.isPublic' | transloco }}
</mat-slide-toggle>
<button mat-raised-button type="submit" color="primary">
{{ 'admin.createContext' | transloco }}
</button>
</form>
<mat-list>
<mat-list-item *ngFor="let ctx of contexts$ | async">
<strong>{{ ctx.name }}</strong>
<button mat-icon-button (click)="onDeleteContext(ctx.id)">
<mat-icon>delete</mat-icon>
</button>
</mat-list-item>
</mat-list>
</div>
`
})
export class ContextManagementComponent implements OnInit {
contexts$!: Observable<IContext[]>;
constructor(private contextsService: ContextsService) {}
ngOnInit() {
this.contexts$ = this.contextsService.getAll();
}
async onCreateContext(form: NgForm) {
if (form.valid) {
await this.contextsService.create(form.value).toPromise();
this.contexts$ = this.contextsService.getAll();
form.reset();
}
}
onDeleteContext(contextId: number) {
this.contextsService.delete(contextId).subscribe(() => {
this.contexts$ = this.contextsService.getAll();
});
}
}
Internationalization: Transloco for Multi-Language
The chat interface supports multiple languages without rebuilding the app.
i18n Setup
Translation files (i18n/en.json):
{
"chat": {
"send": "Send",
"sources": "Sources",
"typing": "Assistant is typing..."
},
"admin": {
"documents": "Documents",
"uploadDocument": "Upload Document",
"deleteDocument": "Delete"
},
"dashboard": {
"selectContext": "Select Knowledge Domain",
"topk": {
"fast": "Fast (3 results)",
"balanced": "Balanced (5 results)",
"precise": "Thorough (8 results)"
}
}
}
In components:
<button>{{ 'chat.send' | transloco }}</button>
Switching languages:
switchLanguage(lang: string) {
this.translocoService.setActiveLang(lang);
localStorage.setItem('language', lang);
}
Role-Based UI Rendering
Not all features are for all users. Admins see management tabs, users don't.
// In component
get isAdmin(): boolean {
return this.currentUser?.role === 'ADMIN' ||
this.currentUser?.role === 'SUPERUSER';
}
get canManageContexts(): boolean {
return this.currentUser?.role === 'SUPERUSER' ||
(this.currentUser?.role === 'ADMIN' &&
this.hasContextPermission('MANAGE'));
}
<!-- Template -->
<div class="admin-section" *ngIf="isAdmin">
<mat-tab-group>
<mat-tab label="Documents">
<app-document-management></app-document-management>
</mat-tab>
</mat-tab-group>
</div>
Error Handling & User Feedback
Errors should be informative without being technical.
private handleError(error: any) {
let message = 'An error occurred';
if (error.status === 401) {
message = 'Your session expired. Please log in again.';
this.redirectToLogin();
} else if (error.status === 403) {
message = 'You don't have permission to perform this action.';
} else if (error.status === 404) {
message = 'The requested resource was not found.';
} else if (error.status >= 500) {
message = 'Server error. Please try again later.';
}
this.notificationService.error(message);
}
Snackbar notifications:
this.notificationService.success('Document uploaded successfully');
this.notificationService.error('Failed to process document');
this.notificationService.info('Document is being indexed...');
Performance Optimizations
OnPush Change Detection
@Component({
...
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChatMessageComponent {
@Input() message!: ChatMessage;
// Only updates when @Input changes
}
Virtual Scrolling (for large chat histories)
<cdk-virtual-scroll-viewport class="chat-history">
<app-chat-message
*cdkVirtualFor="let message of messages"
[message]="message">
</app-chat-message>
</cdk-virtual-scroll-viewport>
Lazy-Loaded Tabs
Load admin panels only when first accessed:
<mat-tab-group>
<mat-tab label="Documents">
<ng-template matTabLabel>
<mat-icon>description</mat-icon>
Documents
</ng-template>
<app-document-management *ngIf="activeTabIndex === 0"></app-document-management>
</mat-tab>
</mat-tab-group>
Material Design 3 & Theming
The UI adapts to system theme (light/dark) using Material 3.
Theme configuration (styles.scss):
@import '@angular/material/prebuilt-themes/indigo-pink.css';
// Custom theme (material.io theme builder)
.dark-theme {
@include angular-material-theme($dark-theme);
}
.light-theme {
@include angular-material-theme($light-theme);
}
Toggle theme:
toggleTheme() {
const isDark = localStorage.getItem('theme') === 'dark';
const newTheme = isDark ? 'light' : 'dark';
document.body.className = `${newTheme}-theme`;
localStorage.setItem('theme', newTheme);
}
Looking Ahead
The frontend brings the RAG system to users. In the next article, we'll explore LLM Provider Switching—how a single configuration change lets you swap from cloud APIs to local engines without touching code.
---
Key Takeaways:
✅ Standalone components = Cleaner, leaner bundles
✅ Composition pattern = Small, testable, reusable pieces
✅ Store Service = Simple state management for this scale
✅ Material Design 3 = Modern, accessible, responsive UI
✅ Transloco = i18n without complexity
✅ Role-based rendering = Different features for different users
✅ OnPush + Virtual scrolling = Blazing-fast even with large histories
The frontend is a thin, beautiful layer on top of a solid backend. Users shouldn't need to think about architecture—they should just ask questions and get answers.
---
GitHub:
- RAD System (open-source): Github source code
- RAG System (source not published): Github RAG System Overview