| |
| import { Component, OnInit, ViewChild, ElementRef, HostListener, AfterViewInit } from '@angular/core'; |
| import { FormBuilder, FormGroup, Validators } from '@angular/forms'; |
| import { ChatService, ChatMessage, SearchResponse, Question } from './staticchat.service'; |
| import { Subject } from 'rxjs'; |
| import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; |
| import { isPlatformBrowser } from '@angular/common'; |
| import { |
| Inject, |
| NgZone, |
| PLATFORM_ID, |
| } from '@angular/core'; |
|
|
|
|
| @Component({ |
| selector: 'app-staticchat', |
| templateUrl: './staticchat.component.html', |
| styleUrls: ['./staticchat.component.css'] |
| }) |
| export class StaticChatComponent implements OnInit, AfterViewInit { |
|
|
| @ViewChild('chatContainer') private chatContainer!: ElementRef; |
| @ViewChild('messageInput') private messageInput!: ElementRef; |
| @ViewChild('videoPlayer') videoRef!: ElementRef<HTMLVideoElement>; |
|
|
| chatForm: FormGroup; |
| messages: (ChatMessage & { suggestions?: string[] })[] = []; |
| isTyping = false; |
| suggestedQuestions: string[] = []; |
| showSuggestions = false; |
| allQuestions: Question[] = []; |
| searchQuery = new Subject<string>(); |
| selectedQuestions: Set<string> = new Set(); |
|
|
| |
| currentPairIndex = 0; |
|
|
| |
| blinkVideoSrc = 'assets/staticchat/blink.mp4'; |
| introVideoSrc = 'assets/staticchat/intro.mp4'; |
|
|
| |
| |
| currentVideoType: 'blink' | 'intro' | 'response' = 'blink'; |
| currentResponseVideoUrl: string | null = null; |
| |
| isVideoPlaying = false; |
|
|
| |
| private audioPlayer: HTMLAudioElement | null = null; |
| hasChatStarted = false; |
| lastResponseVideoUrl: string | null = null; |
|
|
| supported = false; |
| isListening = false; |
| showActions = false; |
|
|
| private isBrowser = false; |
|
|
| private mediaStream: MediaStream | null = null; |
| private recorder: MediaRecorder | null = null; |
| private chunks: BlobPart[] = []; |
|
|
| private uploadInProgress = false; |
| isSpeechProcessing = false; |
|
|
| constructor( |
| private fb: FormBuilder, |
| private chatService: ChatService, |
| @Inject(PLATFORM_ID) platformId: object, private zone: NgZone |
| ) { |
| this.chatForm = this.fb.group({ |
| message: ['', Validators.required] |
| }); |
|
|
| this.searchQuery.pipe( |
| debounceTime(300), |
| distinctUntilChanged() |
| ).subscribe(query => { |
| this.searchQuestions(query); |
| }); |
| this.isBrowser = isPlatformBrowser(platformId); |
| } |
|
|
| ngOnInit() { |
| this.messages.push({ |
| id: 1, |
| text: 'Hello children! Today we will learn tenses in a simple and fun way.', |
| sender: 'bot', |
| timestamp: new Date() |
| }); |
|
|
| if (!this.isBrowser) return; |
|
|
| const hasGetUserMedia = !!navigator.mediaDevices?.getUserMedia; |
| const hasMediaRecorder = typeof (window as any).MediaRecorder !== 'undefined'; |
| this.supported = hasGetUserMedia && hasMediaRecorder; |
|
|
| this.loadAllQuestions(); |
|
|
| |
| setTimeout(() => this.scrollToLastPair(), 0); |
| } |
|
|
| ngAfterViewInit() { |
| this.playBlinkVideo(); |
| } |
|
|
| |
|
|
| private safeVideo(): HTMLVideoElement | null { |
| try { return this.videoRef.nativeElement; } catch { return null; } |
| } |
|
|
| |
| playBlinkVideo() { |
| const video = this.safeVideo(); |
| if (!video) return; |
|
|
| video.onended = null; |
| video.src = this.blinkVideoSrc; |
| video.loop = true; |
| video.muted = true; |
| video.currentTime = 0; |
| video.play().catch(() => { }); |
|
|
| this.currentVideoType = 'blink'; |
| this.currentResponseVideoUrl = null; |
| |
| this.isVideoPlaying = false; |
| } |
|
|
| |
| playIntroVideo() { |
| const video = this.safeVideo(); |
| if (!video) return; |
|
|
| |
| if (this.audioPlayer && !this.audioPlayer.paused) { this.audioPlayer.pause(); } |
|
|
| video.onended = () => { |
| this.playBlinkVideo(); |
| }; |
|
|
| video.src = this.introVideoSrc; |
| video.loop = false; |
| video.muted = false; |
| video.currentTime = 0; |
| video.play().catch(() => { |
| |
| video.muted = true; |
| video.play().catch(() => { }); |
| }); |
|
|
| this.currentVideoType = 'intro'; |
| this.currentResponseVideoUrl = null; |
| this.isVideoPlaying = true; |
| } |
|
|
| |
| |
| playResponseVideo(url?: string) { |
| if (!url) return; |
| const video = this.safeVideo(); |
| if (!video) return; |
|
|
| |
| if (this.audioPlayer && !this.audioPlayer.paused) { this.audioPlayer.pause(); } |
|
|
| video.onended = () => { |
| this.playBlinkVideo(); |
| }; |
|
|
| this.currentResponseVideoUrl = url; |
| this.currentVideoType = 'response'; |
|
|
| video.src = url; |
| video.loop = false; |
| video.muted = false; |
| video.currentTime = 0; |
| video.play().then(() => { |
| this.isVideoPlaying = true; |
| }).catch(() => { |
| |
| video.muted = true; |
| video.play().catch(() => { }); |
| |
| this.isVideoPlaying = !video.paused; |
| }); |
| } |
|
|
| |
| |
| |
| togglePlayPause() { |
| const video = this.safeVideo(); |
| if (!video) return; |
|
|
| if (this.currentVideoType === 'blink') { |
| |
| if (!this.hasChatStarted) { |
| this.playIntroVideo(); |
| return; |
| } |
|
|
| |
| |
| if (this.lastResponseVideoUrl) { |
| this.playResponseVideo(this.lastResponseVideoUrl); |
| } |
| return; |
| } |
|
|
| |
| if (video.paused) { |
| if (this.audioPlayer && !this.audioPlayer.paused) { |
| this.audioPlayer.pause(); |
| } |
| video.play().catch(() => { }); |
| this.isVideoPlaying = true; |
| } else { |
| video.pause(); |
| this.isVideoPlaying = false; |
| } |
| } |
|
|
|
|
| |
|
|
| loadAllQuestions() { |
| this.chatService.getAllQuestions().subscribe({ |
| next: (response) => { |
| if (response.success) { |
| this.allQuestions = response.questions; |
| } |
| }, |
| error: (error) => console.error('Error loading questions:', error) |
| }); |
| } |
|
|
| onInputFocus() { this.showQuestionSuggestions(); } |
| onInputClick() { this.showQuestionSuggestions(); } |
|
|
| showQuestionSuggestions() { |
| if (this.allQuestions.length === 0) { |
| this.loadAllQuestions(); |
| return; |
| } |
|
|
| if (this.messages.length <= 1) { |
| this.suggestedQuestions = this.allQuestions.slice(0, 5).map(q => q.question); |
| this.showSuggestions = true; |
| return; |
| } |
|
|
| const unselected = this.allQuestions.filter(q => !this.selectedQuestions.has(q.question)); |
|
|
| if (unselected.length === 0) { |
| const shuffled = [...this.allQuestions].sort(() => 0.5 - Math.random()); |
| this.suggestedQuestions = shuffled.slice(0, 5).map(q => q.question); |
| } else { |
| this.suggestedQuestions = unselected.slice(0, 5).map(q => q.question); |
| } |
|
|
| this.showSuggestions = true; |
| } |
|
|
| searchQuestions(query: string) { |
| if (query.length > 0) { |
| const filtered = this.allQuestions |
| .filter(q => q.question.toLowerCase().includes(query.toLowerCase())) |
| .slice(0, 5); |
|
|
| this.suggestedQuestions = filtered.map(q => q.question); |
| this.showSuggestions = this.suggestedQuestions.length > 0; |
| } else { |
| this.showQuestionSuggestions(); |
| } |
| } |
|
|
| onInputChange() { |
| const query = this.chatForm.get('message')?.value; |
| query ? this.searchQuery.next(query) : this.showQuestionSuggestions(); |
| } |
|
|
| selectQuestion(question: string) { |
| this.selectedQuestions.add(question); |
| this.chatForm.get('message')?.setValue(question); |
| this.showSuggestions = false; |
| this.suggestedQuestions = this.suggestedQuestions.filter(q => q !== question); |
| this.sendMessage(); |
| } |
|
|
| sendMessage() { |
| const message = this.chatForm.get('message')?.value.trim(); |
| if (!message) return; |
|
|
| this.messages.push({ |
| id: this.messages.length + 1, |
| text: message, |
| sender: 'user', |
| timestamp: new Date() |
| }); |
| this.hasChatStarted = true; |
| this.chatForm.reset(); |
| this.showSuggestions = false; |
| this.isTyping = true; |
|
|
| |
| setTimeout(() => this.scrollToLastPair(), 50); |
|
|
| this.chatService.searchQuestion(message).subscribe({ |
| next: (response: SearchResponse) => { |
| this.isTyping = false; |
|
|
| const botText = response.answer |
| ? response.answer.replace(/\n/g, ' ') |
| : (response.message || 'Sorry, I could not find an answer.'); |
|
|
| this.messages.push({ |
| id: this.messages.length + 1, |
| text: botText, |
| sender: 'bot', |
| timestamp: new Date(), |
| rawData: response |
| }); |
|
|
| |
| setTimeout(() => this.scrollToLastPair(), 50); |
|
|
| |
| if (response.audio_url) { |
| this.playAudio(response.audio_url); |
| } |
| if (response.video_url) { |
| this.lastResponseVideoUrl = response.video_url; |
| this.playResponseVideo(response.video_url); |
| } |
|
|
| }, |
| error: () => { |
| this.isTyping = false; |
| this.messages.push({ |
| id: this.messages.length + 1, |
| text: 'Sorry, I encountered an error. Please try again.', |
| sender: 'bot', |
| timestamp: new Date() |
| }); |
| setTimeout(() => this.scrollToLastPair(), 50); |
| } |
| }); |
| } |
|
|
| |
| playAudio(url?: string) { |
| if (!url) return; |
| try { |
| const video = this.safeVideo(); |
| |
| if (video && this.currentVideoType !== 'blink' && !video.paused) { |
| video.pause(); |
| this.isVideoPlaying = false; |
| } |
|
|
| if (!this.audioPlayer) { |
| this.audioPlayer = new Audio(); |
| } else { |
| this.audioPlayer.pause(); |
| } |
| this.audioPlayer.src = url; |
| this.audioPlayer.currentTime = 0; |
| this.audioPlayer.play().catch(() => { }); |
| } catch (e) { |
| console.error('Audio play failed', e); |
| } |
| } |
|
|
| |
| playVideoFromChat(url?: string) { |
| if (!url) return; |
| this.playResponseVideo(url); |
| } |
|
|
| formatAnswer(response: SearchResponse): string { |
| let html = ''; |
|
|
| const answerText = response.answer?.replace(/\n/g, '<br>') ?? 'No answer available.'; |
| html += `<div class="bot-answer">${answerText}</div>`; |
|
|
| if (response.audio_url || response.video_url) { |
| html += `<div class="media-row">`; |
|
|
| if (response.audio_url) { |
| html += ` |
| <span class="media-icon" |
| onclick="window.dispatchEvent(new CustomEvent('playAudio', { detail: '${response.audio_url}' }))"> |
| 🎧 |
| </span>`; |
| } |
|
|
| if (response.video_url) { |
| html += ` |
| <span class="media-icon" |
| onclick="window.dispatchEvent(new CustomEvent('playVideo', { detail: '${response.video_url}' }))"> |
| 📺 |
| </span>`; |
| } |
|
|
| html += `</div>`; |
| } |
|
|
| return html; |
| } |
|
|
| formatErrorMessage(response: SearchResponse): string { |
| let message = response.message || "I couldn't find an exact match."; |
|
|
| if (response.sample_questions?.length) { |
| message += '<br><br><strong>Try asking:</strong><ul>'; |
| response.sample_questions.forEach(q => message += `<li>${q}</li>`); |
| message += '</ul>'; |
| } |
|
|
| return message; |
| } |
|
|
| |
| get pairedMessages(): Array<{ user?: ChatMessage, bot?: ChatMessage }> { |
| const pairs: Array<{ user?: ChatMessage, bot?: ChatMessage }> = []; |
| const msgs = this.messages || []; |
| let i = 0; |
| while (i < msgs.length) { |
| const current = msgs[i]; |
| if (current.sender === 'user') { |
| const pair: { user?: ChatMessage, bot?: ChatMessage } = { user: current }; |
| const next = msgs[i + 1]; |
| if (next && next.sender === 'bot') { |
| pair.bot = next; |
| i += 2; |
| } else { |
| i += 1; |
| } |
| pairs.push(pair); |
| } else if (current.sender === 'bot') { |
| |
| pairs.push({ bot: current }); |
| i += 1; |
| } else { |
| |
| pairs.push({ bot: current }); |
| i += 1; |
| } |
| } |
| return pairs; |
| } |
|
|
| |
| showNextPair() { |
| const total = this.pairedMessages.length; |
| if (total === 0) return; |
| const next = Math.min(this.currentPairIndex + 1, total - 1); |
| this.scrollToPair(next); |
| } |
|
|
| showPreviousPair() { |
| const total = this.pairedMessages.length; |
| if (total === 0) return; |
| const prev = Math.max(this.currentPairIndex - 1, 0); |
| this.scrollToPair(prev); |
| } |
|
|
| private scrollToPair(index: number) { |
| setTimeout(() => { |
| try { |
| const container = this.chatContainer.nativeElement as HTMLElement; |
| const pairs = container.querySelectorAll('.pair'); |
| if (!pairs || pairs.length === 0) return; |
| if (index < 0) index = 0; |
| if (index >= pairs.length) index = pairs.length - 1; |
| const target = pairs[index] as HTMLElement; |
| if (!target) return; |
| container.scrollTo({ top: target.offsetTop, behavior: 'smooth' }); |
| this.currentPairIndex = index; |
| } catch (e) { |
| |
| try { |
| const container = this.chatContainer.nativeElement as HTMLElement; |
| if (index === 0) container.scrollTop = 0; |
| else container.scrollTop = container.scrollHeight; |
| } catch { } |
| } |
| }, 50); |
| } |
|
|
| private scrollToLastPair(): void { |
| setTimeout(() => { |
| try { |
| const total = this.pairedMessages.length; |
| if (total === 0) return; |
| this.scrollToPair(total - 1); |
| } catch { } |
| }, 50); |
| } |
|
|
| scrollToTop(): void { |
| setTimeout(() => { |
| try { |
| const el = this.chatContainer.nativeElement as HTMLElement; |
| |
| if (typeof el.scrollTo === 'function') { |
| el.scrollTo({ top: 0, behavior: 'smooth' }); |
| } else { |
| el.scrollTop = 0; |
| } |
| } catch { } |
| }, 100); |
| } |
|
|
| clearChat() { |
| this.messages = []; |
| this.selectedQuestions.clear(); |
|
|
| this.hasChatStarted = false; |
| this.lastResponseVideoUrl = null; |
|
|
| this.ngOnInit(); |
| this.playBlinkVideo(); |
| } |
|
|
|
|
| private pickMimeType(): string { |
| const w: any = window; |
|
|
| |
| const types = [ |
| 'audio/webm;codecs=opus', |
| 'audio/webm', |
| 'audio/mp4', |
| 'audio/m4a', |
| ]; |
|
|
| if (!w.MediaRecorder?.isTypeSupported) return ''; |
| for (const t of types) { |
| if (w.MediaRecorder.isTypeSupported(t)) return t; |
| } |
| return ''; |
| } |
|
|
| async toggleMic() { |
| if (!this.supported || this.isListening || this.uploadInProgress) return; |
|
|
| try { |
| this.mediaStream = await navigator.mediaDevices.getUserMedia({ |
| audio: { |
| echoCancellation: true, |
| noiseSuppression: true, |
| }, |
| }); |
|
|
| const mimeType = this.pickMimeType(); |
| this.chunks = []; |
|
|
| this.recorder = mimeType |
| ? new MediaRecorder(this.mediaStream, { mimeType }) |
| : new MediaRecorder(this.mediaStream); |
|
|
| this.recorder.ondataavailable = (e: BlobEvent) => { |
| if (e.data && e.data.size > 0) this.chunks.push(e.data); |
| }; |
|
|
| this.recorder.onerror = () => { |
| this.zone.run(() => { |
| this.handleTranscriptionError('Audio recording error.'); |
| this.cleanupRecorder(); |
| }); |
| }; |
|
|
| this.zone.run(() => { |
| this.isListening = true; |
| this.showActions = true; |
| }); |
|
|
| this.recorder.start(); |
| } catch (e: any) { |
| this.zone.run(() => { |
| this.handleTranscriptionError('Microphone permission denied or not available.'); |
| this.cleanupRecorder(); |
| }); |
| } |
| } |
|
|
| |
| accept() { |
| if (!this.recorder || this.uploadInProgress) return; |
|
|
| this.uploadInProgress = true; |
|
|
| |
| this.recorder.onstop = async () => { |
| try { |
| const mime = this.recorder?.mimeType || 'audio/webm'; |
| const blob = new Blob(this.chunks, { type: mime }); |
|
|
| |
| this.zone.run(() => { |
| this.isSpeechProcessing = true; |
| this.showActions = false; |
| this.isListening = false; |
| this.chatForm.get('message')?.setValue('⏳ Converting speech to text...'); |
| }); |
|
|
| const text = await this.sendToBackendForTranscription(blob); |
|
|
| this.zone.run(() => { |
| this.isSpeechProcessing = false; |
| if (text && text.trim()) { |
| this.handleTranscriptionAccepted(text.trim()); |
| } else { |
| this.chatForm.get('message')?.setValue(''); |
| } |
| }); |
|
|
| } catch (err: any) { |
| this.zone.run(() => { |
| this.handleTranscriptionError( |
| typeof err?.message === 'string' ? err.message : 'Transcription failed.' |
| ); |
| this.showActions = false; |
| this.isListening = false; |
| }); |
| } finally { |
| this.uploadInProgress = false; |
| this.cleanupRecorder(); |
| } |
| }; |
|
|
| try { |
| this.recorder.stop(); |
| } catch { |
| |
| this.uploadInProgress = false; |
| this.cleanupRecorder(); |
| } |
| } |
|
|
| |
| reject() { |
| if (this.uploadInProgress) return; |
|
|
| try { |
| this.recorder?.stop(); |
| } catch { } |
|
|
| this.zone.run(() => { |
| this.handleTranscriptionRejected(); |
| this.showActions = false; |
| this.isListening = false; |
| }); |
|
|
| this.cleanupRecorder(); |
| } |
|
|
| private async sendToBackendForTranscription(blob: Blob): Promise<string> { |
| |
| |
| const url = |
| location.hostname.endsWith('hf.space') |
| ? 'https://pykara-py-learn-backend.hf.space/staticchat/transcribe' |
| : 'http://localhost:5000/staticchat/transcribe'; |
|
|
| const form = new FormData(); |
| |
| form.append('file', blob, 'speech.webm'); |
|
|
| const res = await fetch(url, { |
| method: 'POST', |
| body: form, |
| }); |
|
|
| if (!res.ok) { |
| const msg = await res.text().catch(() => ''); |
| throw new Error(msg || `Transcribe API failed (${res.status}).`); |
| } |
|
|
| const data = await res.json(); |
| |
| return (data?.text || '').toString(); |
| } |
|
|
| private cleanupRecorder() { |
| try { |
| this.recorder?.removeEventListener?.('dataavailable', () => { }); |
| } catch { } |
|
|
| this.recorder = null; |
| this.chunks = []; |
|
|
| if (this.mediaStream) { |
| try { |
| this.mediaStream.getTracks().forEach((t) => t.stop()); |
| } catch { } |
| this.mediaStream = null; |
| } |
| } |
|
|
|
|
| @HostListener('document:click', ['$event']) |
| handleClickOutside(event: Event) { |
| if (this.showSuggestions && this.messageInput) { |
| const clickedInside = this.messageInput.nativeElement.contains(event.target); |
| if (!clickedInside) this.showSuggestions = false; |
| } |
| } |
|
|
| |
|
|
| private handleTranscriptionAccepted(text: string) { |
| try { |
| |
| this.chatForm.get('message')?.setValue(text); |
|
|
| |
| setTimeout(() => { |
| this.messageInput?.nativeElement.focus(); |
| }, 0); |
|
|
| } catch (e) { |
| console.error('handleTranscriptionAccepted error', e); |
| } |
| } |
|
|
|
|
| private handleTranscriptionRejected() { |
| |
| try { |
| this.chatForm.get('message')?.setValue(''); |
| } catch (e) { |
| console.error('handleTranscriptionRejected error', e); |
| } |
| } |
|
|
| private handleTranscriptionError(msg: string) { |
| try { |
| this.isSpeechProcessing = false; |
| this.chatForm.get('message')?.setValue(''); |
|
|
| this.messages.push({ |
| id: this.messages.length + 1, |
| text: `Transcription error: ${msg}`, |
| sender: 'bot', |
| timestamp: new Date() |
| }); |
| setTimeout(() => this.scrollToLastPair(), 50); |
| } catch (e) { |
| console.error('handleTranscriptionError error', e); |
| } |
| } |
|
|
|
|
| } |
|
|