| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221 |
- import {
- AfterViewChecked,
- Component,
- ElementRef,
- inject,
- OnDestroy,
- OnInit,
- signal,
- ViewChild,
- } from '@angular/core';
- import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
- import { Subject, takeUntil } from 'rxjs';
- import { MatButtonModule } from '@angular/material/button';
- import { MatFormFieldModule } from '@angular/material/form-field';
- import { MatIconModule } from '@angular/material/icon';
- import { MatInputModule } from '@angular/material/input';
- import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
- import { MatTooltipModule } from '@angular/material/tooltip';
- import { DpService } from 'dp-ui/dp.service';
- import { FisAppMessage, MessageHeader, AppMessageType } from 'dp-ui/fisappmessage/apprequestmessagetype';
- interface ChatMessage {
- id: string;
- sender: 'user' | 'assistant';
- content: string;
- timestamp: number;
- }
- function makeWelcome(): ChatMessage {
- return {
- id: 'system-welcome',
- sender: 'assistant',
- content:
- 'Welcome to the Industrial Intelligence Portal. Ask me about batch yield summaries, ripeness distributions, ABW trends, or anomaly flags from your production data.',
- timestamp: Date.now(),
- };
- }
- @Component({
- selector: 'app-chatbot',
- standalone: true,
- imports: [
- ReactiveFormsModule,
- MatButtonModule,
- MatFormFieldModule,
- MatIconModule,
- MatInputModule,
- MatProgressSpinnerModule,
- MatTooltipModule,
- ],
- styleUrl: './chatbot.component.scss',
- template: `
- <div class="chat-shell">
- <!-- Header -->
- <div class="chat-header">
- <mat-icon class="header-icon">psychology</mat-icon>
- <span class="header-title">Intelligence Portal</span>
- <span class="session-tag" [title]="'Session: ' + sessionId()">
- SID·{{ sessionId().slice(-6) }}
- </span>
- <button mat-icon-button
- class="reset-btn"
- type="button"
- (click)="resetSession()"
- matTooltip="Reset session context"
- [disabled]="loading()">
- <mat-icon>refresh</mat-icon>
- </button>
- </div>
- <!-- Message feed -->
- <div class="chat-feed" #messagesEl>
- @for (msg of messages(); track msg.id) {
- <div class="chat-msg" [class.chat-msg--user]="msg.sender === 'user'">
- <span class="chat-msg__label">
- {{ msg.sender === 'user' ? 'You' : 'Intelligence' }}
- </span>
- <div class="chat-msg__bubble">{{ msg.content }}</div>
- </div>
- }
- @if (loading()) {
- <div class="chat-thinking">
- <mat-spinner diameter="14"></mat-spinner>
- <span>Processing…</span>
- </div>
- }
- </div>
- <!-- Composer -->
- <form class="chat-composer" (submit)="send($event)">
- <mat-form-field appearance="outline" subscriptSizing="dynamic" class="chat-input">
- <mat-label>Ask the intelligence layer…</mat-label>
- <input matInput [formControl]="inputControl" autocomplete="off" />
- </mat-form-field>
- <button mat-icon-button
- type="submit"
- class="send-btn"
- [disabled]="loading() || inputControl.invalid">
- <mat-icon>send</mat-icon>
- </button>
- </form>
- </div>
- `,
- })
- export class ChatbotComponent implements OnInit, AfterViewChecked, OnDestroy {
- private readonly dpService = inject(DpService);
- @ViewChild('messagesEl') messagesEl!: ElementRef<HTMLDivElement>;
- inputControl = new FormControl('', {
- nonNullable: true,
- validators: [Validators.required, Validators.minLength(1)],
- });
- messages = signal<ChatMessage[]>([]);
- loading = signal(false);
- sessionId = signal(crypto.randomUUID());
- private readonly destroy$ = new Subject<void>();
- private pendingScroll = false;
- ngOnInit(): void {
- this.messages.set([makeWelcome()]);
- }
- ngAfterViewChecked(): void {
- if (this.pendingScroll) {
- const el = this.messagesEl?.nativeElement;
- if (el) el.scrollTop = el.scrollHeight;
- this.pendingScroll = false;
- }
- }
- ngOnDestroy(): void {
- this.destroy$.next();
- this.destroy$.complete();
- }
- resetSession(): void {
- this.fisStream('Chat', 'clear', {}).pipe(takeUntil(this.destroy$)).subscribe();
- this.sessionId.set(crypto.randomUUID());
- this.messages.set([makeWelcome()]);
- this.inputControl.reset();
- }
- send(event: Event): void {
- event.preventDefault();
- const text = this.inputControl.value.trim();
- if (!text || this.loading()) return;
- this.inputControl.reset();
- this.messages.update(m => [
- ...m,
- {
- id: crypto.randomUUID(),
- sender: 'user',
- content: text,
- timestamp: Date.now(),
- },
- ]);
- this.loading.set(true);
- this.pendingScroll = true;
- this.fisStream('Chat', 'send', { message: text, sessionId: this.sessionId() })
- .pipe(takeUntil(this.destroy$))
- .subscribe({
- next: (res: any) => {
- const content: string =
- typeof res === 'string'
- ? res
- : Array.isArray(res)
- ? (res[0]?.output ?? res[0]?.text ?? res[0]?.message ?? JSON.stringify(res))
- : (res?.output ?? res?.text ?? res?.message ?? JSON.stringify(res));
- this.messages.update(m => [
- ...m,
- {
- id: crypto.randomUUID(),
- sender: 'assistant',
- content,
- timestamp: Date.now(),
- },
- ]);
- this.loading.set(false);
- this.pendingScroll = true;
- },
- error: () => this.appendError(),
- });
- }
- private fisStream(serviceId: string, operation: string, payload: unknown) {
- const messageID = crypto.randomUUID();
- const message: FisAppMessage = {
- header: {
- messageID,
- serviceId,
- messageName: operation,
- messageType: AppMessageType.Command,
- } as unknown as MessageHeader,
- data: payload,
- };
- return this.dpService.stream(message);
- }
- private appendError(): void {
- this.messages.update(m => [
- ...m,
- {
- id: crypto.randomUUID(),
- sender: 'assistant',
- content:
- 'Unable to reach the intelligence layer. Verify the WebSocket connection is active and the backend is running.',
- timestamp: Date.now(),
- },
- ]);
- this.loading.set(false);
- this.pendingScroll = true;
- }
- }
|