Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Intelligence Service MVP Chat Interface #169

Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
723d3d3
Add mocked chat interface
milesha Nov 19, 2024
a767111
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 19, 2024
aee2967
Change header design
milesha Nov 19, 2024
4da1bec
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 19, 2024
ae650a7
Add session history
milesha Nov 21, 2024
c7d9c01
Add autoscrolling
milesha Nov 21, 2024
404fa8b
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 21, 2024
6992fad
Add session card
milesha Nov 21, 2024
8e6518e
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 21, 2024
e3d7af9
Add infromation fetching
milesha Nov 21, 2024
fc41a8a
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 21, 2024
8ada388
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 22, 2024
0c18b83
Update creation prop
milesha Nov 22, 2024
ee20325
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 22, 2024
9cead8f
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 23, 2024
b73becf
Update icon
milesha Nov 23, 2024
8e561bb
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 23, 2024
0a3d017
Update AI mentor button
milesha Nov 23, 2024
20f555c
Add dynamic session list
milesha Nov 23, 2024
e186137
Add data fetching
milesha Nov 23, 2024
d8d04ba
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 23, 2024
9eca357
Add backend connection
milesha Nov 23, 2024
82ac250
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 23, 2024
9b8c3f0
Improve layout
milesha Nov 24, 2024
338843f
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 24, 2024
b3a661a
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 24, 2024
46d1f0c
Fix prettier issues
milesha Nov 24, 2024
7daf291
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 24, 2024
30b64a9
Change access roles
milesha Nov 24, 2024
a1470a7
Add storybook
milesha Nov 25, 2024
20d1eb0
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 25, 2024
e4af9f6
Imrove bot usability
milesha Nov 25, 2024
ae370be
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 25, 2024
b959388
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 26, 2024
91007bc
Update prettier version
milesha Nov 26, 2024
7f3e20b
Delete angular analytics setting
milesha Nov 26, 2024
7aefd9d
Delete console log
milesha Nov 26, 2024
6b602fa
Improve code quality
milesha Nov 26, 2024
67d66c4
change bot icon
milesha Nov 27, 2024
c419390
Reverse session order and update selected session on success
milesha Nov 27, 2024
219090f
Add enter key functionality to send message in chat input
milesha Nov 27, 2024
ce1f95b
Clear message input after sending with a slight delay
milesha Nov 27, 2024
c7ba180
improve layout
milesha Nov 27, 2024
7ef9a41
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 27, 2024
f7c41d5
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 27, 2024
3ef7b80
update dark mode color
milesha Nov 27, 2024
3acd54c
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Nov 28, 2024
ee21f79
update naming
milesha Nov 28, 2024
74a72fc
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Dec 1, 2024
5dfe8bc
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Dec 1, 2024
2faceb2
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
milesha Dec 1, 2024
f2b5d4c
Fix formating
milesha Dec 1, 2024
f150136
Merge branch 'feature/java-chat-mvp-intelligence-service' into featur…
FelixTJDietrich Dec 4, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions webapp/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,5 +145,8 @@
}
}
}
},
"cli": {
"analytics": false
milesha marked this conversation as resolved.
Show resolved Hide resolved
}
}
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",
milesha marked this conversation as resolved.
Show resolved Hide resolved
"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()) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other components we try to go for Skeletons instead of Spinners since they are much easier on the eye in terms of layout shifts.

Try to integrate Skeletons in here too. You can pass the "loading"-state down to the individual components, which can then handle it themselves.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GODrums
Sure, was thinking of it too - the only problem in this case is that i am not sure what I need to create a Skeleton for in this case: we have one view which is shown if there are no sessions yet and a completely different one for the actual chat interface. What should I then make a Skeleton for when in is "undecided", which view to show (the number of user sessions is loading)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, I see your point here. I was a little confused about how the whole "fetching"-logic is fully contained in the ChatComponent.
I would have thought of it this way (inspired by the way https://chatgpt.com/ works):

  • until the sessions are loaded, a spinner should be fine
  • if existing sessions are found, they are displayed in the sidebar and the "chat-window" displays a message similar to "Please select a session or create a new one", basically a placeholder
  • the user is now able to click on a session, replacing the placeholder with Skeletons of sample messages until the real messages are loaded

Maybe I'm overengineering this a little though, I think your approach is fine too!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GODrums good idea! I would make an extra issue for this frontend improvement and come back to it in another PR then 👍🏼

<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>
}
132 changes: 132 additions & 0 deletions webapp/src/app/chat/chat/chat.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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);
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: () => {
this.query_sessions.refetch();
}
}));

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,11 @@
<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">
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>

Check failure on line 10 in webapp/src/app/chat/first-session-card/first-session-card.component.html

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Replace `>Start·First·Session</a` with `⏎····>Start·First·Session</a⏎··`
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Component, Output, EventEmitter } 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;

@Output() createSession = new EventEmitter<void>();
milesha marked this conversation as resolved.
Show resolved Hide resolved

handleCreateSession(): void {
this.createSession.emit();
}
}
20 changes: 20 additions & 0 deletions webapp/src/app/chat/input/input.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<div class="flex items-start space-x-3">
<textarea
[(ngModel)]="messageText"
placeholder="Message AI Mentor"
style="resize: none"
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>
28 changes: 28 additions & 0 deletions webapp/src/app/chat/input/input.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Component, Output, EventEmitter } 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;

@Output() messageSent = new EventEmitter<string>();
milesha marked this conversation as resolved.
Show resolved Hide resolved

messageText: string = '';
milesha marked this conversation as resolved.
Show resolved Hide resolved

onSend(): void {
if (this.messageText.trim() !== '') {
milesha marked this conversation as resolved.
Show resolved Hide resolved
this.messageSent.emit(this.messageText);
this.messageText = '';
}
}
}
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 === 'SYSTEM' }">
<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 === 'SYSTEM') {
<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]="Hammer" class="size-6 text-cyan-500"></lucide-angular>
</div>
</div>
}

<div class="flex flex-col space-y-2">
<div
[ngClass]="{
'bg-cyan-500 text-white': message.sender === 'USER',
'bg-gray-200 text-black': message.sender === 'SYSTEM'
}"
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>
41 changes: 41 additions & 0 deletions webapp/src/app/chat/messages/messages.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Component, inject, Input, OnInit, AfterViewChecked, ElementRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, Hammer } 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 Hammer = Hammer;

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

@ViewChild('chatMessagesContainer') private chatMessagesContainer!: ElementRef;
@Input() messageHistory = [] as Message[];
milesha marked this conversation as resolved.
Show resolved Hide resolved

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('Error scrolling to bottom', err);
}
}
}
Loading
Loading