Skip to content

Commit

Permalink
Intelligence Service MVP Chat Interface (#169)
Browse files Browse the repository at this point in the history
Co-authored-by: Felix T.J. Dietrich <[email protected]>
  • Loading branch information
milesha and FelixTJDietrich authored Dec 4, 2024
1 parent 3bbb6c0 commit dd53b38
Show file tree
Hide file tree
Showing 19 changed files with 498 additions and 3 deletions.
2 changes: 2 additions & 0 deletions webapp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
"karma-coverage": "2.2.1",
"karma-jasmine": "5.1.0",
"karma-jasmine-html-reporter": "2.1.0",
"prettier": "3.3.3",
"storybook": "8.3.4",
"typescript": "5.5.4"
}
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Routes } from '@angular/router';
import { AboutComponent } from '@app/about/about.component';
import { HomeComponent } from '@app/home/home.component';
import { ChatComponent } from '@app/chat/chat/chat.component';
import { WorkspaceComponent } from '@app/workspace/workspace.component';
import { UserProfileComponent } from '@app/user/user-profile.component';
import { WorkspaceUsersComponent } from './workspace/users/users.component';
Expand Down Expand Up @@ -38,7 +39,7 @@ export const routes: Routes = [
{ path: 'settings', component: SettingsComponent },
{ path: 'imprint', component: ImprintComponent },
{ path: 'privacy', component: PrivacyComponent },

{ path: 'chat', component: ChatComponent },
// Protected routes
{
path: '',
Expand Down
28 changes: 28 additions & 0 deletions webapp/src/app/chat/chat/chat.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
@if (isLoading()) {
<div class="h-[calc(100vh-100px)] self-auto p-4 flex-1 flex items-center justify-center">
<hlm-spinner></hlm-spinner>
</div>
} @else {
<div class="grid grid-cols-1 gap-x-5 md:grid-cols-4 md:h-[calc(100vh-100px)]">
@if (sessions().length === 0) {
<div class="col-span-4 h-[calc(100vh-100px)]">
<app-first-session-card (createSession)="handleCreateSession()"></app-first-session-card>
</div>
} @else {
<app-sessions-card
[sessions]="sessions()"
[activeSessionId]="selectedSession()?.id ?? null"
(sessionSelected)="handleSessionSelect($event)"
(createSession)="handleCreateSession()"
class="h-full overflow-auto"
></app-sessions-card>

@if (selectedSession()) {
<div class="flex flex-col h-[calc(100vh-100px)] md:col-span-3">
<app-messages [messageHistory]="messageHistory()" class="flex-1 overflow-y-auto p-4 space-y-4"></app-messages>
<app-chat-input (messageSent)="handleSendMessage($event)" class="border-t pt-4 px-4"></app-chat-input>
</div>
}
}
</div>
}
137 changes: 137 additions & 0 deletions webapp/src/app/chat/chat/chat.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { lastValueFrom } from 'rxjs';
import { injectMutation, injectQuery } from '@tanstack/angular-query-experimental';
import { SessionsCardComponent } from '../sessions-card/sessions-card.component';
import { MessagesComponent } from '../messages/messages.component';
import { InputComponent } from '../input/input.component';
import { SecurityStore } from '@app/core/security/security-store.service';
import { Message, Session } from '@app/core/modules/openapi';
import { MessageService, SessionService } from '@app/core/modules/openapi';
import { HlmButtonModule } from '@spartan-ng/ui-button-helm';
import { HlmSpinnerComponent } from '@spartan-ng/ui-spinner-helm';
import { FirstSessionCardComponent } from '../first-session-card/first-session-card.component';

@Component({
selector: 'app-chat',
templateUrl: './chat.component.html',
standalone: true,
imports: [CommonModule, FirstSessionCardComponent, HlmSpinnerComponent, SessionsCardComponent, MessagesComponent, InputComponent, HlmButtonModule]
})
export class ChatComponent {
securityStore = inject(SecurityStore);
messageService = inject(MessageService);
sessionService = inject(SessionService);

signedIn = this.securityStore.signedIn;
user = this.securityStore.loadedUser;

messageHistory = signal<Message[]>([]);
selectedSession = signal<Session | null>(null);
sessions = signal<Session[]>([]);
isLoading = signal(true);

latestMessageContent = '';

protected query_sessions = injectQuery(() => ({
enabled: this.signedIn(),
queryKey: ['sessions', { login: this.user()?.username }],
queryFn: async () => {
const username = this.user()?.username;
if (!username) {
throw new Error('User is not logged in or username is undefined.');
}
const sessions = await lastValueFrom(this.sessionService.getSessions(username));
if (sessions.length > 0 && this.selectedSession() == null) {
this.selectedSession.set(sessions.slice(-1)[0]);
}
this.sessions.set(sessions.reverse());
this.isLoading.set(false);
return sessions;
}
}));

handleSessionSelect(sessionId: number): void {
const session = this.sessions().find((s) => s.id === sessionId);
if (session) {
this.selectedSession.set(session);
this.query_sessions.refetch();
}
}

handleCreateSession(): void {
this.createSession.mutate();
}

protected createSession = injectMutation(() => ({
mutationFn: async () => {
const username = this.user()?.username;
if (!username) {
throw new Error('User is not logged in or username is undefined.');
}
await lastValueFrom(this.sessionService.createSession(username));
},
onSuccess: async () => {
await this.query_sessions.refetch();
const sessions = this.sessions();
if (sessions.length > 0) {
const newSession = sessions[0];
this.selectedSession.set(newSession);
}
}
}));

protected query_messages = injectQuery(() => ({
enabled: !!this.selectedSession,
queryKey: ['messages', { sessionId: this.selectedSession()?.id }],
queryFn: async () => {
const selectedSessionId = this.selectedSession()?.id;
if (selectedSessionId == null) {
throw new Error('No session selected!');
}
const loadedMessages = await lastValueFrom(this.messageService.getMessages(selectedSessionId));
this.messageHistory.set(loadedMessages);
return loadedMessages;
}
}));

protected sendMessage = injectMutation(() => ({
queryKey: ['messages', 'create'],
mutationFn: async ({ sessionId }: { sessionId: number }) => {
if (!this.selectedSession) {
throw new Error('No session selected!');
}
await lastValueFrom(this.messageService.createMessage(sessionId, this.latestMessageContent));
},
onSuccess: () => {
this.query_messages.refetch();
}
}));

handleSendMessage(content: string): void {
if (!this.selectedSession) {
console.error('No session selected!');
return;
}

const selectedSessionId = this.selectedSession()?.id;
if (selectedSessionId == null) {
console.error('No session selected!');
return;
} else {
// show the user message directly after sending
const userMessage: Message = {
id: Math.random(), // temporary id until the message is sent
sessionId: selectedSessionId,
sender: 'USER',
content: content,
sentAt: new Date().toISOString()
};

this.messageHistory.set([...this.messageHistory(), userMessage]);

this.latestMessageContent = content;
this.sendMessage.mutate({ sessionId: selectedSessionId });
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<div class="flex flex-col items-center justify-center h-full space-y-6">
<div class="w-20 h-20 bg-transparent border-cyan-500 rounded-full flex items-center justify-center" style="border-width: 3px">
<lucide-angular [img]="BotMessageSquare" class="size-10 text-cyan-500" style="stroke-width: 1.5"></lucide-angular>
</div>

<h2 class="text-center text-xl text-gray-700 font-semibold max-w-3xl dark:text-white">
Meet Your Personal AI Mentor – designed to help you grow faster through focused and reflective learning sessions. Click below to begin!
</h2>

<a hlmBtn aria-describedby="Start First Session" class="bg-cyan-500 text-white p-4 rounded-lg hover:bg-cyan-600 cursor-pointer" (click)="handleCreateSession()"
>Start First Session</a
>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Component, output } from '@angular/core';
import { LucideAngularModule, Plus, BotMessageSquare } from 'lucide-angular';
import { HlmButtonModule } from '@spartan-ng/ui-button-helm';

@Component({
selector: 'app-first-session-card',
standalone: true,
templateUrl: './first-session-card.component.html',
imports: [LucideAngularModule, HlmButtonModule]
})
export class FirstSessionCardComponent {
protected Plus = Plus;
protected BotMessageSquare = BotMessageSquare;

createSession = output<void>();

handleCreateSession(): void {
this.createSession.emit();
}
}
21 changes: 21 additions & 0 deletions webapp/src/app/chat/input/input.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<div class="flex items-start space-x-3">
<textarea
[(ngModel)]="messageText"
placeholder="Message AI Mentor"
style="resize: none"
(keydown.enter)="onSend()"
class="flex-1 bg-transparent px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring focus:ring-cyan-200"
></textarea>
<a
hlmBtn
hlmTooltipTrigger
hlmTooltipContentClass="bg-cyan-500 text-white"
aria-describedby="Send Message"
(click)="onSend()"
class="bg-cyan-500 text-white p-2 rounded-lg hover:bg-cyan-600"
variant="default"
size="icon"
>
<lucide-angular [img]="Send" class="size-6"></lucide-angular>
</a>
</div>
29 changes: 29 additions & 0 deletions webapp/src/app/chat/input/input.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Component, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HlmCardModule } from '@spartan-ng/ui-card-helm';

import { HlmButtonModule } from '@spartan-ng/ui-button-helm';
import { LucideAngularModule, Send } from 'lucide-angular';

@Component({
selector: 'app-chat-input',
templateUrl: './input.component.html',
standalone: true,
imports: [CommonModule, HlmButtonModule, FormsModule, HlmCardModule, LucideAngularModule]
})
export class InputComponent {
protected Send = Send;

messageSent = output<string>();
messageText = '';

onSend() {
if (this.messageText.trim() !== '') {
this.messageSent.emit(this.messageText);
setTimeout(() => {
this.messageText = '';
}, 0);
}
}
}
20 changes: 20 additions & 0 deletions webapp/src/app/chat/input/input.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
import { InputComponent } from './input.component';

const meta: Meta<InputComponent> = {
component: InputComponent,
tags: ['autodocs'],
args: {
messageText: ''
}
};

export default meta;
type Story = StoryObj<InputComponent>;

export const Default: Story = {
render: (args) => ({
props: args,
template: `<app-chat-input ${argsToTemplate(args)} />`
})
};
40 changes: 40 additions & 0 deletions webapp/src/app/chat/messages/messages.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<div #chatMessagesContainer class="messages space-y-4 overflow-y-auto" style="max-height: 100%">
@for (message of messageHistory(); track message.id) {
<div class="flex w-full" [ngClass]="{ 'justify-end': message.sender === 'USER', 'justify-start': message.sender === 'LLM' }">
<div class="flex space-x-2 md:w-3/5" [ngClass]="{ 'flex-row-reverse': message.sender === 'USER' }">
@if (message.sender === 'USER') {
<div class="ml-2 flex flex-col">
<hlm-avatar>
<img [src]="'https://github.com/' + user()!.username + '.png'" [alt]="user()?.name + '\'s avatar'" hlmAvatarImage />
<span hlmAvatarFallback>
{{ user()?.name?.slice(0, 2)?.toUpperCase() ?? '?' }}
</span>
</hlm-avatar>
</div>
}

@if (message.sender === 'LLM') {
<div class="mr-2 flex flex-col">
<div class="w-10 h-10 bg-transparent border-2 border-cyan-500 rounded-full flex items-center justify-center">
<lucide-angular [img]="BotMessageSquare" class="size-6 text-cyan-500"></lucide-angular>
</div>
</div>
}

<div class="flex flex-col space-y-2" [ngClass]="{ 'items-end': message.sender === 'USER', 'items-start': message.sender === 'LLM' }">
<div
[ngClass]="{
'bg-cyan-500 text-white': message.sender === 'USER',
'bg-gray-200 text-black': message.sender === 'LLM'
}"
class="p-3 rounded-lg inline-block"
style="width: fit-content"
>
<p>{{ message.content }}</p>
</div>
<span class="text-xs text-gray-500"> {{ message.sender === 'USER' ? 'You' : 'AI Mentor' }} · {{ message.sentAt | date: 'shortTime' }} </span>
</div>
</div>
</div>
}
</div>
42 changes: 42 additions & 0 deletions webapp/src/app/chat/messages/messages.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Component, inject, input, OnInit, AfterViewChecked, ElementRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, BotMessageSquare } from 'lucide-angular';
import { HlmAvatarModule } from '@spartan-ng/ui-avatar-helm';
import { SecurityStore } from '@app/core/security/security-store.service';
import { Message } from '@app/core/modules/openapi';

@Component({
selector: 'app-messages',
templateUrl: './messages.component.html',
standalone: true,
imports: [CommonModule, LucideAngularModule, HlmAvatarModule]
})
export class MessagesComponent implements OnInit, AfterViewChecked {
protected BotMessageSquare = BotMessageSquare;

securityStore = inject(SecurityStore);
user = this.securityStore.loadedUser;
signedIn = this.securityStore.signedIn;

messageHistory = input<Message[]>([]);

@ViewChild('chatMessagesContainer') private chatMessagesContainer!: ElementRef;

ngOnInit() {
this.scrollToBottom();
}

ngAfterViewChecked() {
this.scrollToBottom();
}

private scrollToBottom(): void {
try {
if (this.chatMessagesContainer) {
this.chatMessagesContainer.nativeElement.scrollTop = this.chatMessagesContainer.nativeElement.scrollHeight;
}
} catch (err) {
console.error(err);
}
}
}
Loading

0 comments on commit dd53b38

Please sign in to comment.