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')) { +