Adding an AI Assistant UI (fix for #43)

Merge request reports

Loading
+20 −0
Changes for src/app/p_chat/assistant/services/assistant.service.ts: 20 added lines, 0 removed lines.
Original line number Diff line number Diff line
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<any> {
    const payload = { question: message };
    return this.http.post('/ask', payload);
  }
}
+58 −0
Changes for src/app/p_chat/assistant/assistant.component.html: 58 added lines, 0 removed lines.
Original line number Diff line number Diff line
<div class="container py-3">
    <div class="row">
        <div class="col-12">

            <mat-card class="chat-container mat-elevation-z8">
                <mat-card-header class="chat-header large-header">
                    <div class="header-wrapper">
                        <div class="brand-logo-container">
                            <img src="assets/images/osl_ai_assistant.png" alt="OpenSlice Logo" class="brand-logo">
                        </div>
                        <div class="title-group">
                            <mat-card-title class="gradient-title">OpenSlice AI Assistant</mat-card-title>
                            <mat-card-subtitle class="status-subtitle">
                                <span class="status-dot"></span> Powered by MCP Backend & LLM
                            </mat-card-subtitle>
                        </div>
                    </div>
                </mat-card-header>

                <mat-card-content #chatHistory class="chat-history">
                    <div *ngFor="let message of messages"
                        [ngClass]="{'user-row': message.sender === 'user', 'ai-row': message.sender === 'ai'}">
                        <div class="message-bubble">

                            <markdown [data]="message.text"></markdown>
                            <span class="timestamp">{{ message.timestamp | date:'shortTime' }}</span>
                        </div>
                    </div>

                    <div *ngIf="isLoading" class="ai-row">
                        <div class="message-bubble ai-loading-bubble">
                            <div class="typing-indicator">
                                <span></span>
                                <span></span>
                                <span></span>
                            </div>
                            <span class="loading-text">OpenSlice AI is thinking...</span>
                        </div>
                    </div>
                </mat-card-content>

                <mat-card-actions class="chat-input-area">
                    <mat-form-field appearance="outline" class="full-width">
                        <mat-label>Type your message</mat-label>
                        <textarea matInput cdkTextareaAutosize #autosize="cdkTextareaAutosize" cdkAutosizeMinRows="1"
                            cdkAutosizeMaxRows="5" [(ngModel)]="userInput" (keydown.enter)="handleEnter($event)"
                            [disabled]="isLoading" placeholder="Ask me anything...">
        </textarea>
                        <button mat-icon-button matSuffix color="primary" (click)="sendMessage()"
                            [disabled]="!userInput.trim() || isLoading">
                            <mat-icon>send</mat-icon>
                        </button>
                    </mat-form-field>
                </mat-card-actions>
            </mat-card>
        </div>
    </div>
</div>
 No newline at end of file
+293 −0
Changes for src/app/p_chat/assistant/assistant.component.scss: 293 added lines, 0 removed lines.
Original line number Diff line number Diff line
.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
+23 −0
Changes for src/app/p_chat/assistant/assistant.component.spec.ts: 23 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { AssistantComponent } from './assistant.component';

describe('AssistantComponent', () => {
  let component: AssistantComponent;
  let fixture: ComponentFixture<AssistantComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ AssistantComponent ]
    })
    .compileComponents();

    fixture = TestBed.createComponent(AssistantComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
+110 −0
Changes for src/app/p_chat/assistant/assistant.component.ts: 110 added lines, 0 removed lines.
Original line number Diff line number Diff line
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<HTMLDivElement>;
  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
Loading
Loading