diff --git a/webapp/package-lock.json b/webapp/package-lock.json
index 19f24322..5c96f336 100644
--- a/webapp/package-lock.json
+++ b/webapp/package-lock.json
@@ -91,6 +91,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"
},
@@ -19136,6 +19137,7 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
+ "license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
diff --git a/webapp/package.json b/webapp/package.json
index 1aa1854f..b2832667 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -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"
}
diff --git a/webapp/src/app/app.routes.ts b/webapp/src/app/app.routes.ts
index a7397fad..45036f67 100644
--- a/webapp/src/app/app.routes.ts
+++ b/webapp/src/app/app.routes.ts
@@ -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';
@@ -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: '',
diff --git a/webapp/src/app/chat/chat/chat.component.html b/webapp/src/app/chat/chat/chat.component.html
new file mode 100644
index 00000000..9a022492
--- /dev/null
+++ b/webapp/src/app/chat/chat/chat.component.html
@@ -0,0 +1,28 @@
+@if (isLoading()) {
+
+
+
+} @else {
+
+ @if (sessions().length === 0) {
+
+ } @else {
+
+
+ @if (selectedSession()) {
+
+ }
+ }
+
+}
diff --git a/webapp/src/app/chat/chat/chat.component.ts b/webapp/src/app/chat/chat/chat.component.ts
new file mode 100644
index 00000000..8ef3be95
--- /dev/null
+++ b/webapp/src/app/chat/chat/chat.component.ts
@@ -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([]);
+ selectedSession = signal(null);
+ sessions = signal([]);
+ 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 });
+ }
+ }
+}
diff --git a/webapp/src/app/chat/first-session-card/first-session-card.component.html b/webapp/src/app/chat/first-session-card/first-session-card.component.html
new file mode 100644
index 00000000..a48804f4
--- /dev/null
+++ b/webapp/src/app/chat/first-session-card/first-session-card.component.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Meet Your Personal AI Mentor – designed to help you grow faster through focused and reflective learning sessions. Click below to begin!
+
+
+
Start First Session
+
diff --git a/webapp/src/app/chat/first-session-card/first-session-card.component.ts b/webapp/src/app/chat/first-session-card/first-session-card.component.ts
new file mode 100644
index 00000000..01b6cfe0
--- /dev/null
+++ b/webapp/src/app/chat/first-session-card/first-session-card.component.ts
@@ -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();
+
+ handleCreateSession(): void {
+ this.createSession.emit();
+ }
+}
diff --git a/webapp/src/app/chat/input/input.component.html b/webapp/src/app/chat/input/input.component.html
new file mode 100644
index 00000000..f2fc332e
--- /dev/null
+++ b/webapp/src/app/chat/input/input.component.html
@@ -0,0 +1,21 @@
+
diff --git a/webapp/src/app/chat/input/input.component.ts b/webapp/src/app/chat/input/input.component.ts
new file mode 100644
index 00000000..7bfa72eb
--- /dev/null
+++ b/webapp/src/app/chat/input/input.component.ts
@@ -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();
+ messageText = '';
+
+ onSend() {
+ if (this.messageText.trim() !== '') {
+ this.messageSent.emit(this.messageText);
+ setTimeout(() => {
+ this.messageText = '';
+ }, 0);
+ }
+ }
+}
diff --git a/webapp/src/app/chat/input/input.stories.ts b/webapp/src/app/chat/input/input.stories.ts
new file mode 100644
index 00000000..5ab5cb5b
--- /dev/null
+++ b/webapp/src/app/chat/input/input.stories.ts
@@ -0,0 +1,20 @@
+import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
+import { InputComponent } from './input.component';
+
+const meta: Meta = {
+ component: InputComponent,
+ tags: ['autodocs'],
+ args: {
+ messageText: ''
+ }
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => ({
+ props: args,
+ template: ``
+ })
+};
diff --git a/webapp/src/app/chat/messages/messages.component.html b/webapp/src/app/chat/messages/messages.component.html
new file mode 100644
index 00000000..8823e1b3
--- /dev/null
+++ b/webapp/src/app/chat/messages/messages.component.html
@@ -0,0 +1,40 @@
+
+ @for (message of messageHistory(); track message.id) {
+
+
+ @if (message.sender === 'USER') {
+
+
+
+
+ {{ user()?.name?.slice(0, 2)?.toUpperCase() ?? '?' }}
+
+
+
+ }
+
+ @if (message.sender === 'LLM') {
+
+ }
+
+
+
+
{{ message.content }}
+
+
{{ message.sender === 'USER' ? 'You' : 'AI Mentor' }} · {{ message.sentAt | date: 'shortTime' }}
+
+
+
+ }
+
diff --git a/webapp/src/app/chat/messages/messages.component.ts b/webapp/src/app/chat/messages/messages.component.ts
new file mode 100644
index 00000000..30470395
--- /dev/null
+++ b/webapp/src/app/chat/messages/messages.component.ts
@@ -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([]);
+
+ @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);
+ }
+ }
+}
diff --git a/webapp/src/app/chat/sessions-card/sessions-card.component.html b/webapp/src/app/chat/sessions-card/sessions-card.component.html
new file mode 100644
index 00000000..43d7f69b
--- /dev/null
+++ b/webapp/src/app/chat/sessions-card/sessions-card.component.html
@@ -0,0 +1,36 @@
+
+
+
+
+ New Session
+
+
+
Past Sessions
+
+ @for (session of sessions(); track session.id) {
+ -
+ {{ session.createdAt | date: 'short' }}
+
+ }
+
+
+
+
diff --git a/webapp/src/app/chat/sessions-card/sessions-card.component.ts b/webapp/src/app/chat/sessions-card/sessions-card.component.ts
new file mode 100644
index 00000000..5f975663
--- /dev/null
+++ b/webapp/src/app/chat/sessions-card/sessions-card.component.ts
@@ -0,0 +1,31 @@
+import { Component, input, output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { LucideAngularModule, Plus } from 'lucide-angular';
+import { HlmButtonModule } from '@spartan-ng/ui-button-helm';
+import { Session } from '@app/core/modules/openapi';
+
+@Component({
+ standalone: true,
+ selector: 'app-sessions-card',
+ templateUrl: './sessions-card.component.html',
+ imports: [CommonModule, LucideAngularModule, HlmButtonModule]
+})
+export class SessionsCardComponent {
+ protected Plus = Plus;
+
+ sessions = input();
+ activeSessionId = input();
+
+ sessionSelected = output();
+ createSession = output();
+
+ handleSelectSession(sessionId: number): void {
+ if (this.activeSessionId() && this.activeSessionId() !== sessionId) {
+ this.sessionSelected.emit(sessionId);
+ }
+ }
+
+ handleCreateSession(): void {
+ this.createSession.emit();
+ }
+}
diff --git a/webapp/src/app/core/header/ai-mentor/ai-mentor.component.html b/webapp/src/app/core/header/ai-mentor/ai-mentor.component.html
new file mode 100644
index 00000000..0c7d94c7
--- /dev/null
+++ b/webapp/src/app/core/header/ai-mentor/ai-mentor.component.html
@@ -0,0 +1,21 @@
+
+
+
+ @if (!iconOnly()) {
+ AI Mentor
+ }
+
+ @if (iconOnly()) {
+ AI Mentor
+ }
+
diff --git a/webapp/src/app/core/header/ai-mentor/ai-mentor.component.ts b/webapp/src/app/core/header/ai-mentor/ai-mentor.component.ts
new file mode 100644
index 00000000..c56d1274
--- /dev/null
+++ b/webapp/src/app/core/header/ai-mentor/ai-mentor.component.ts
@@ -0,0 +1,18 @@
+import { booleanAttribute, Component, input } from '@angular/core';
+import { RouterModule } from '@angular/router';
+import { HlmButtonModule } from '@spartan-ng/ui-button-helm';
+import { BrnTooltipContentDirective } from '@spartan-ng/ui-tooltip-brain';
+import { HlmTooltipComponent, HlmTooltipTriggerDirective } from '@spartan-ng/ui-tooltip-helm';
+import { LucideAngularModule, BotMessageSquare } from 'lucide-angular';
+
+@Component({
+ selector: 'app-ai-mentor',
+ standalone: true,
+ imports: [LucideAngularModule, HlmButtonModule, HlmTooltipComponent, HlmTooltipTriggerDirective, BrnTooltipContentDirective, RouterModule],
+ templateUrl: './ai-mentor.component.html'
+})
+export class AiMentorComponent {
+ protected BotMessageSquare = BotMessageSquare;
+
+ iconOnly = input(false, { transform: booleanAttribute });
+}
diff --git a/webapp/src/app/core/header/ai-mentor/ai-mentor.stories.ts b/webapp/src/app/core/header/ai-mentor/ai-mentor.stories.ts
new file mode 100644
index 00000000..0513139a
--- /dev/null
+++ b/webapp/src/app/core/header/ai-mentor/ai-mentor.stories.ts
@@ -0,0 +1,30 @@
+import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
+import { AiMentorComponent } from './ai-mentor.component';
+
+const meta: Meta = {
+ component: AiMentorComponent,
+ tags: ['autodocs'],
+ args: {
+ iconOnly: false
+ }
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => ({
+ props: args,
+ template: ``
+ })
+};
+
+export const Icon: Story = {
+ args: {
+ iconOnly: true
+ },
+ render: (args) => ({
+ props: args,
+ template: ``
+ })
+};
diff --git a/webapp/src/app/core/header/header.component.html b/webapp/src/app/core/header/header.component.html
index 428344dd..ee2360df 100644
--- a/webapp/src/app/core/header/header.component.html
+++ b/webapp/src/app/core/header/header.component.html
@@ -8,6 +8,10 @@
Workspace
}
+ @if (user()?.roles?.includes('ai-maintainer')) {
+
+
+ }
@@ -20,7 +24,6 @@
-
{{ user()!.name }}
diff --git a/webapp/src/app/core/header/header.component.ts b/webapp/src/app/core/header/header.component.ts
index d48ce032..59a6dfb7 100644
--- a/webapp/src/app/core/header/header.component.ts
+++ b/webapp/src/app/core/header/header.component.ts
@@ -12,6 +12,7 @@ import { RequestFeatureComponent } from './request-feature/request-feature.compo
import { environment } from 'environments/environment';
import { lucideUser, lucideLogOut, lucideSettings } from '@ng-icons/lucide';
import { provideIcons } from '@ng-icons/core';
+import { AiMentorComponent } from './ai-mentor/ai-mentor.component';
@Component({
selector: 'app-header',
@@ -27,7 +28,8 @@ import { provideIcons } from '@ng-icons/core';
HlmAvatarModule,
HlmMenuModule,
BrnMenuTriggerDirective,
- HlmIconComponent
+ HlmIconComponent,
+ AiMentorComponent
],
providers: [
provideIcons({