From a9a5c0af4bb3fd25d8d2eb0b50836450ecb57984 Mon Sep 17 00:00:00 2001 From: Kostis Trantzas Date: Wed, 21 Jan 2026 22:15:10 +0200 Subject: [PATCH 1/3] Adding an AI Assistant UI (fix for #43) --- .gitignore | 1 + src/app/app-routing.module.ts | 3 + src/app/app.module.ts | 2 + .../p_chat/assistant/assistant.component.html | 58 ++++ .../p_chat/assistant/assistant.component.scss | 293 ++++++++++++++++++ .../assistant/assistant.component.spec.ts | 23 ++ .../p_chat/assistant/assistant.component.ts | 110 +++++++ .../assistant/services/assistant.service.ts | 20 ++ src/app/shared.module.ts | 30 +- .../components/redirect/redirect.component.ts | 5 + src/app/shared/models/app-config.model.ts | 1 + src/app/shared/navbar/navbar.component.html | 36 +++ src/app/shared/navbar/navbar.component.scss | 68 ++++ src/app/shared/services/app.service.ts | 10 +- src/app/shared/services/auth-guard.service.ts | 4 +- src/assets/config/config.prod.default.json | 1 + src/assets/images/osl_ai_assistant.png | Bin 0 -> 121020 bytes 17 files changed, 654 insertions(+), 11 deletions(-) create mode 100644 src/app/p_chat/assistant/assistant.component.html create mode 100644 src/app/p_chat/assistant/assistant.component.scss create mode 100644 src/app/p_chat/assistant/assistant.component.spec.ts create mode 100644 src/app/p_chat/assistant/assistant.component.ts create mode 100644 src/app/p_chat/assistant/services/assistant.service.ts create mode 100644 src/assets/images/osl_ai_assistant.png diff --git a/.gitignore b/.gitignore index 1f2405b..c80fec9 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ src/assets/config/config.theming.json /src/assets/config/theming.scss /.angular /.tmp/ +/proxy.conf.json diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 0620c61..4b0cc60 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -5,6 +5,7 @@ import { LandingComponent } from './landing/landing.component'; import { PortalsComponent } from './landing/portals/portals.component'; import { PageNotFoundComponent } from './shared/components/page-not-found/page-not-found.component'; import { RedirectComponent } from './shared/components/redirect/redirect.component'; +import { AssistantComponent } from './p_chat/assistant/assistant.component'; const routes: Routes = [ @@ -25,6 +26,8 @@ const routes: Routes = [ { path: 'products', component: PortalsComponent}, { path: 'products', loadChildren: () => import('./app-products.module').then(m => m.AppProductsModule)}, + { path: 'assistant', loadChildren: () => import('./shared.module').then(m => m.SharedModule)}, + { path: '**', redirectTo: '404'}, { path: '404', component: PageNotFoundComponent} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index f30581d..a8f6e14 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -119,6 +119,8 @@ export function initializeAppTheme(bootstrap: BootstrapService) { MatInputModule, MatDialogModule, MatCheckboxModule, + MatIconModule, + MatTooltipModule, OAuthModule.forRoot(), ToastrModule.forRoot({ progressBar: true, preventDuplicates: true }) ], diff --git a/src/app/p_chat/assistant/assistant.component.html b/src/app/p_chat/assistant/assistant.component.html new file mode 100644 index 0000000..9a4d044 --- /dev/null +++ b/src/app/p_chat/assistant/assistant.component.html @@ -0,0 +1,58 @@ +
+
+
+ + + +
+
+ +
+
+ OpenSlice AI Assistant + + Powered by MCP Backend & LLM + +
+
+
+ + +
+
+ + + {{ message.timestamp | date:'shortTime' }} +
+
+ +
+
+
+ + + +
+ OpenSlice AI is thinking... +
+
+
+ + + + Type your message + + + + +
+
+
+
\ No newline at end of file diff --git a/src/app/p_chat/assistant/assistant.component.scss b/src/app/p_chat/assistant/assistant.component.scss new file mode 100644 index 0000000..97904a5 --- /dev/null +++ b/src/app/p_chat/assistant/assistant.component.scss @@ -0,0 +1,293 @@ +.large-header { + padding: 16px 20px !important; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + + .header-wrapper { + display: flex; + align-items: center; + gap: 16px; + } + + .brand-logo-container { + height: 52px; // This matches the combined height of Title (1.5rem) + Subtitle + Gap + width: 52px; + display: flex; + align-items: center; + justify-content: center; + // background: #f8f9fa; + border-radius: 12px; + overflow: hidden; + flex-shrink: 0; + + .brand-logo { + height: 100%; + width: 100%; + object-fit: contain; // Ensures logo doesn't distort + // padding: 4px; // Tiny padding so logo doesn't touch the container edges + } + } + + .title-group { + display: flex; + flex-direction: column; + justify-content: center; + line-height: 1.2; + + .gradient-title { + font-weight: 800 !important; + letter-spacing: -0.5px; + font-size: 1.25rem !important; + // background: linear-gradient(90deg, #3f51b5, #7b1fa2); + background: linear-gradient(90deg, #00AFBB, #C8D400); + + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + margin: 0; + } + + .status-subtitle { + display: flex; + align-items: center; + margin-top: 4px; + font-size: 0.75rem; + color: #666; + + .status-dot { + height: 7px; + width: 7px; + background-color: #4caf50; + border-radius: 50%; + margin-right: 6px; + animation: pulse 2s infinite; + } + } + } +} + +::ng-deep .large-header .mat-card-header-text { + margin: 0 !important; + display: flex; + flex-direction: column; + justify-content: center; +} + +@keyframes pulse { + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7); + } + + 70% { + transform: scale(1); + box-shadow: 0 0 0 6px rgba(76, 175, 80, 0); + } + + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); + } +} + + +.chat-container { + // max-width: 1200px; + margin: 20px auto; + height: 75vh; // Use viewport height for better responsiveness + min-height: 400px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.chat-history { + flex-grow: 1; + overflow-y: auto; + padding: 16px; + background-color: #f8f9fa; + display: flex; + flex-direction: column; + gap: 12px; + scroll-behavior: smooth; + + /* Custom Scrollbar for a cleaner look */ + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 10px; + } +} + +.user-row { + display: flex; + justify-content: flex-end; + + .message-bubble { + background-color: #3f51b5; // Material Primary + color: white; + border-radius: 15px 15px 0 15px; + } +} + +.ai-row { + display: flex; + justify-content: flex-start; + + .message-bubble { + background-color: #e0e0e0; + color: rgba(0, 0, 0, 0.87); + border-radius: 15px 15px 15px 0; + } +} + +.ai-loading-bubble { + display: flex; + align-items: center; + gap: 12px; + background-color: #f0f2f5 !important; + border: 1px solid rgba(0, 0, 0, 0.05); + padding: 10px 16px !important; + + .loading-text { + font-size: 0.85rem; + font-style: italic; + color: #5c6bc0; + font-weight: 500; + } +} + +.typing-indicator { + display: flex; + align-items: center; + gap: 4px; + + span { + height: 6px; + width: 6px; + background: linear-gradient(135deg, #3f51b5 0%, #7b1fa2 100%); + border-radius: 50%; + display: block; + opacity: 0.4; + animation: typing 1s infinite; + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + } +} + +@keyframes typing { + + 0%, + 100% { + transform: translateY(0); + opacity: 0.4; + } + + 50% { + transform: translateY(-5px); + opacity: 1; + } +} + +.message-bubble { + padding: 12px 16px; + max-width: 85%; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); + animation: fadeIn 0.3s ease-in-out; + + // white-space: pre-wrap; + word-wrap: break-word; // Standard support + overflow-wrap: break-word; // Modern support + + // Target elements generated by the markdown parser + ::ng-deep markdown { + p { + margin-bottom: 8px; + white-space: initial; + } + + p:last-child { + margin-bottom: 0 !important; + } + + code { + background-color: rgba(0, 0, 0, 0.1); + padding: 2px 4px; + border-radius: 4px; + font-family: monospace; + } + + ul, + ol { + padding-left: 20px; + margin: 8px 0; + } + + blockquote { + border-left: 3px solid #ccc; + margin: 8px 0; + padding-left: 10px; + font-style: italic; + } + + pre { + background-color: #2d2d2d; + color: #ccc; + padding: 12px; + border-radius: 8px; + overflow-x: auto; + margin: 10px 0; + } + + img { + max-width: 100%; + border-radius: 8px; + } + } + +} + +.timestamp { + display: block; + font-size: 0.7rem; + margin-top: 4px; + opacity: 0.7; + text-align: right; +} + +// Ensure the user bubble markdown text stays white +.user-row .message-bubble ::ng-deep markdown { + color: white; + + code { + background-color: rgba(255, 255, 255, 0.2); + } +} + + +.chat-input-area { + padding: 8px 16px !important; + + .full-width { + width: 100%; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/src/app/p_chat/assistant/assistant.component.spec.ts b/src/app/p_chat/assistant/assistant.component.spec.ts new file mode 100644 index 0000000..f047388 --- /dev/null +++ b/src/app/p_chat/assistant/assistant.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AssistantComponent } from './assistant.component'; + +describe('AssistantComponent', () => { + let component: AssistantComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ AssistantComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AssistantComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/p_chat/assistant/assistant.component.ts b/src/app/p_chat/assistant/assistant.component.ts new file mode 100644 index 0000000..7e28f9a --- /dev/null +++ b/src/app/p_chat/assistant/assistant.component.ts @@ -0,0 +1,110 @@ +import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AssistantService } from './services/assistant.service'; +import { Subscription } from 'rxjs'; +import { FormsModule } from '@angular/forms'; +import { AuthService } from 'src/app/shared/services/auth.service'; + +@Component({ + selector: 'app-assistant', + templateUrl: './assistant.component.html', + styleUrls: ['./assistant.component.scss'] +}) +export class AssistantComponent implements OnInit { + + + @ViewChild('chatHistory') private chatHistoryContainer!: ElementRef; + messages: ChatMessage[] = []; + userInput: string = ''; + isLoading: boolean = false; + + private chatSubscription?: Subscription; + + constructor( + private assistantService: AssistantService, + private authService: AuthService + ) { } + + ngOnInit(): void { + this.messages = [ + { text: `Hello, ${this.authService.portalUserJWT.preferred_username}! How can I assist you today?`, sender: 'ai', timestamp: new Date() } + ]; + } + + sendMessage(): void { + const currentInput = this.userInput.trim(); + if (!currentInput) return; + + this.messages.push({ + text: currentInput, + sender: 'user', + timestamp: new Date() + }); + + this.userInput = ''; // Clear input + this.isLoading = true; // Show loading indicator + + // Trigger scroll after DOM updates from user message + this.scheduleScroll(); + + // Send to backend and get AI response + this.chatSubscription = this.assistantService.sendMessage(currentInput).subscribe({ + next: (response) => { + const cleanMessage = response.answer.trim() + + this.messages.push({ + text: cleanMessage, + sender: 'ai', + timestamp: new Date() + }); + this.isLoading = false; + this.scheduleScroll(); + }, + error: (err) => { + console.error('Error getting AI response:', err); + this.messages.push({ + text: 'Oops! Something went wrong. Please try again.', + sender: 'ai', + timestamp: new Date() + }); + this.isLoading = false; + this.scheduleScroll(); + } + }); + } + + handleEnter(event: any): void { + // If user presses Enter WITHOUT Shift, send the message + if (!event.shiftKey) { + event.preventDefault(); // Prevents adding a new line to the textarea + this.sendMessage(); + } +} + + private scheduleScroll(): void { + // Replaces the 'effect' logic by manually scheduling a scroll check + setTimeout(() => this.scrollToBottom(), 0); + } + + private scrollToBottom(): void { + if (this.chatHistoryContainer) { + const element = this.chatHistoryContainer.nativeElement; + element.scrollTop = element.scrollHeight; + } + } + + ngOnDestroy(): void { + // Clean up subscription when component is destroyed + if (this.chatSubscription) { + this.chatSubscription.unsubscribe(); + } + } + +} + + +export interface ChatMessage { + text: string; + sender: 'user' | 'ai'; // To differentiate who sent the message + timestamp: Date; +} \ No newline at end of file diff --git a/src/app/p_chat/assistant/services/assistant.service.ts b/src/app/p_chat/assistant/services/assistant.service.ts new file mode 100644 index 0000000..be2fe45 --- /dev/null +++ b/src/app/p_chat/assistant/services/assistant.service.ts @@ -0,0 +1,20 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { BootstrapService } from 'src/app/bootstrap/bootstrap.service'; + +@Injectable({ + providedIn: 'root' +}) +export class AssistantService { + private assistantURL = this.bootstrapService.getConfig().ASSISTANT_URL; + + constructor(private http:HttpClient + , private bootstrapService:BootstrapService + ) {console.log("AssistantService assistantURL: ", this.assistantURL); } + + sendMessage(message: string): Observable { + const payload = { question: message }; + return this.http.post('/ask', payload); + } +} diff --git a/src/app/shared.module.ts b/src/app/shared.module.ts index 471910c..75e8f53 100644 --- a/src/app/shared.module.ts +++ b/src/app/shared.module.ts @@ -1,4 +1,4 @@ -import { NgModule } from '@angular/core'; +import { NgModule, SecurityContext } from '@angular/core'; import { CommonModule } from '@angular/common'; import { NgProgressModule } from 'ngx-progressbar'; @@ -14,7 +14,7 @@ import { OAuthModule } from 'angular-oauth2-oidc'; import { FileUploadModule } from '@iplab/ngx-file-upload' -import { MarkdownModule } from 'ngx-markdown' +import { MarkdownModule, MarkedOptions } from 'ngx-markdown' import { BootstrapComponent } from './bootstrap/bootstrap.component'; @@ -38,7 +38,7 @@ import { MatNativeDateModule } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDialogModule } from '@angular/material/dialog'; import { MatExpansionModule } from '@angular/material/expansion'; -import { MatIconModule } from '@angular/material/icon'; +import { MatIcon, MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatListModule } from '@angular/material/list'; import { MatPaginatorModule } from '@angular/material/paginator'; @@ -52,13 +52,19 @@ import { MatTabsModule } from '@angular/material/tabs'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTreeModule } from '@angular/material/tree'; +import { MatCardModule } from '@angular/material/card'; import {DragDropModule} from '@angular/cdk/drag-drop'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { RouterModule } from '@angular/router'; +import { RouterModule, Routes } from '@angular/router'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { NgJsonEditorModule } from 'ang-jsoneditor'; +import { AssistantComponent } from './p_chat/assistant/assistant.component'; +import { AuthGuardService } from './shared/services/auth-guard.service'; +const routes: Routes = [ + { path: '', component: AssistantComponent, canActivate: [AuthGuardService] } +]; @NgModule({ declarations: [ @@ -71,10 +77,11 @@ import { NgJsonEditorModule } from 'ang-jsoneditor'; DeleteOrganizationComponent, ListIndividualsComponent, EditIndividualsComponent, - DeleteIndividualComponent + DeleteIndividualComponent, + AssistantComponent ], imports: [ - RouterModule, + RouterModule.forChild(routes), FormsModule, ReactiveFormsModule, CommonModule, @@ -99,6 +106,7 @@ import { NgJsonEditorModule } from 'ang-jsoneditor'; MatRadioModule, MatProgressSpinnerModule, MatListModule, + MatCardModule, DragDropModule, MatNativeDateModule, MatDatepickerModule, @@ -109,7 +117,15 @@ import { NgJsonEditorModule } from 'ang-jsoneditor'; // ToastrModule.forRoot({progressBar: true, preventDuplicates: true}), // OAuthModule.forRoot(), FileUploadModule, - MarkdownModule.forRoot(), + MarkdownModule.forRoot({ + markedOptions: { + provide: MarkedOptions, + useValue: { + gfm: true, + breaks: true, // This converts single \n into
+ }, + }, + }), NgJsonEditorModule ], exports: [ diff --git a/src/app/shared/components/redirect/redirect.component.ts b/src/app/shared/components/redirect/redirect.component.ts index 9dd049f..e34ac11 100644 --- a/src/app/shared/components/redirect/redirect.component.ts +++ b/src/app/shared/components/redirect/redirect.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { AppService } from '../../services/app.service'; +import { el } from '@fullcalendar/core/internal-common'; @Component({ selector: 'app-redirect', @@ -31,6 +32,10 @@ export class RedirectComponent implements OnInit { this.router.navigate(['products', 'marketplace']) } + else if (activePortal === 'assistant') { + this.router.navigate(['assistant']) + } + else { this.router.navigate(['/']) } diff --git a/src/app/shared/models/app-config.model.ts b/src/app/shared/models/app-config.model.ts index 7d639d6..8ee673c 100644 --- a/src/app/shared/models/app-config.model.ts +++ b/src/app/shared/models/app-config.model.ts @@ -7,6 +7,7 @@ export interface IAppConfig { "BUGZILLA": string, "STATUS": string, "WEBURL": string, + "ASSISTANT_URL": string, "PORTAL_REPO_APIURL": string, "ASSURANCE_SERVICE_MGMT_APIURL": string, "APITMFURL": string, diff --git a/src/app/shared/navbar/navbar.component.html b/src/app/shared/navbar/navbar.component.html index 5e69a56..f8f9b93 100644 --- a/src/app/shared/navbar/navbar.component.html +++ b/src/app/shared/navbar/navbar.component.html @@ -42,6 +42,22 @@ Products + + @@ -257,6 +273,12 @@ + + + + + @@ -268,6 +290,20 @@ Order List + + + + @@ -257,6 +273,12 @@ + + + + + @@ -268,6 +290,20 @@ Order List + +