chatbot.component.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import {
  2. AfterViewChecked,
  3. Component,
  4. ElementRef,
  5. inject,
  6. OnDestroy,
  7. OnInit,
  8. signal,
  9. ViewChild,
  10. } from '@angular/core';
  11. import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
  12. import { Subject, takeUntil } from 'rxjs';
  13. import { MatButtonModule } from '@angular/material/button';
  14. import { MatFormFieldModule } from '@angular/material/form-field';
  15. import { MatIconModule } from '@angular/material/icon';
  16. import { MatInputModule } from '@angular/material/input';
  17. import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
  18. import { MatTooltipModule } from '@angular/material/tooltip';
  19. import { DpService } from 'dp-ui/dp.service';
  20. import { FisAppMessage, MessageHeader, AppMessageType } from 'dp-ui/fisappmessage/apprequestmessagetype';
  21. interface ChatMessage {
  22. id: string;
  23. sender: 'user' | 'assistant';
  24. content: string;
  25. timestamp: number;
  26. }
  27. function makeWelcome(): ChatMessage {
  28. return {
  29. id: 'system-welcome',
  30. sender: 'assistant',
  31. content:
  32. 'Welcome to the Industrial Intelligence Portal. Ask me about batch yield summaries, ripeness distributions, ABW trends, or anomaly flags from your production data.',
  33. timestamp: Date.now(),
  34. };
  35. }
  36. @Component({
  37. selector: 'app-chatbot',
  38. standalone: true,
  39. imports: [
  40. ReactiveFormsModule,
  41. MatButtonModule,
  42. MatFormFieldModule,
  43. MatIconModule,
  44. MatInputModule,
  45. MatProgressSpinnerModule,
  46. MatTooltipModule,
  47. ],
  48. styleUrl: './chatbot.component.scss',
  49. template: `
  50. <div class="chat-shell">
  51. <!-- Header -->
  52. <div class="chat-header">
  53. <mat-icon class="header-icon">psychology</mat-icon>
  54. <span class="header-title">Intelligence Portal</span>
  55. <span class="session-tag" [title]="'Session: ' + sessionId()">
  56. SID·{{ sessionId().slice(-6) }}
  57. </span>
  58. <button mat-icon-button
  59. class="reset-btn"
  60. type="button"
  61. (click)="resetSession()"
  62. matTooltip="Reset session context"
  63. [disabled]="loading()">
  64. <mat-icon>refresh</mat-icon>
  65. </button>
  66. </div>
  67. <!-- Message feed -->
  68. <div class="chat-feed" #messagesEl>
  69. @for (msg of messages(); track msg.id) {
  70. <div class="chat-msg" [class.chat-msg--user]="msg.sender === 'user'">
  71. <span class="chat-msg__label">
  72. {{ msg.sender === 'user' ? 'You' : 'Intelligence' }}
  73. </span>
  74. <div class="chat-msg__bubble">{{ msg.content }}</div>
  75. </div>
  76. }
  77. @if (loading()) {
  78. <div class="chat-thinking">
  79. <mat-spinner diameter="14"></mat-spinner>
  80. <span>Processing…</span>
  81. </div>
  82. }
  83. </div>
  84. <!-- Composer -->
  85. <form class="chat-composer" (submit)="send($event)">
  86. <mat-form-field appearance="outline" subscriptSizing="dynamic" class="chat-input">
  87. <mat-label>Ask the intelligence layer…</mat-label>
  88. <input matInput [formControl]="inputControl" autocomplete="off" />
  89. </mat-form-field>
  90. <button mat-icon-button
  91. type="submit"
  92. class="send-btn"
  93. [disabled]="loading() || inputControl.invalid">
  94. <mat-icon>send</mat-icon>
  95. </button>
  96. </form>
  97. </div>
  98. `,
  99. })
  100. export class ChatbotComponent implements OnInit, AfterViewChecked, OnDestroy {
  101. private readonly dpService = inject(DpService);
  102. @ViewChild('messagesEl') messagesEl!: ElementRef<HTMLDivElement>;
  103. inputControl = new FormControl('', {
  104. nonNullable: true,
  105. validators: [Validators.required, Validators.minLength(1)],
  106. });
  107. messages = signal<ChatMessage[]>([]);
  108. loading = signal(false);
  109. sessionId = signal(crypto.randomUUID());
  110. private readonly destroy$ = new Subject<void>();
  111. private pendingScroll = false;
  112. ngOnInit(): void {
  113. this.messages.set([makeWelcome()]);
  114. }
  115. ngAfterViewChecked(): void {
  116. if (this.pendingScroll) {
  117. const el = this.messagesEl?.nativeElement;
  118. if (el) el.scrollTop = el.scrollHeight;
  119. this.pendingScroll = false;
  120. }
  121. }
  122. ngOnDestroy(): void {
  123. this.destroy$.next();
  124. this.destroy$.complete();
  125. }
  126. resetSession(): void {
  127. this.fisStream('Chat', 'clear', {}).pipe(takeUntil(this.destroy$)).subscribe();
  128. this.sessionId.set(crypto.randomUUID());
  129. this.messages.set([makeWelcome()]);
  130. this.inputControl.reset();
  131. }
  132. send(event: Event): void {
  133. event.preventDefault();
  134. const text = this.inputControl.value.trim();
  135. if (!text || this.loading()) return;
  136. this.inputControl.reset();
  137. this.messages.update(m => [
  138. ...m,
  139. {
  140. id: crypto.randomUUID(),
  141. sender: 'user',
  142. content: text,
  143. timestamp: Date.now(),
  144. },
  145. ]);
  146. this.loading.set(true);
  147. this.pendingScroll = true;
  148. this.fisStream('Chat', 'send', { message: text, sessionId: this.sessionId() })
  149. .pipe(takeUntil(this.destroy$))
  150. .subscribe({
  151. next: (res: any) => {
  152. const content: string =
  153. typeof res === 'string'
  154. ? res
  155. : Array.isArray(res)
  156. ? (res[0]?.output ?? res[0]?.text ?? res[0]?.message ?? JSON.stringify(res))
  157. : (res?.output ?? res?.text ?? res?.message ?? JSON.stringify(res));
  158. this.messages.update(m => [
  159. ...m,
  160. {
  161. id: crypto.randomUUID(),
  162. sender: 'assistant',
  163. content,
  164. timestamp: Date.now(),
  165. },
  166. ]);
  167. this.loading.set(false);
  168. this.pendingScroll = true;
  169. },
  170. error: () => this.appendError(),
  171. });
  172. }
  173. private fisStream(serviceId: string, operation: string, payload: unknown) {
  174. const messageID = crypto.randomUUID();
  175. const message: FisAppMessage = {
  176. header: {
  177. messageID,
  178. serviceId,
  179. messageName: operation,
  180. messageType: AppMessageType.Command,
  181. } as unknown as MessageHeader,
  182. data: payload,
  183. };
  184. return this.dpService.stream(message);
  185. }
  186. private appendError(): void {
  187. this.messages.update(m => [
  188. ...m,
  189. {
  190. id: crypto.randomUUID(),
  191. sender: 'assistant',
  192. content:
  193. 'Unable to reach the intelligence layer. Verify the WebSocket connection is active and the backend is running.',
  194. timestamp: Date.now(),
  195. },
  196. ]);
  197. this.loading.set(false);
  198. this.pendingScroll = true;
  199. }
  200. }