import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormBuilder, ReactiveFormsModule, Validators, FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatCardModule } from '@angular/material/card'; import { MatSelectModule } from '@angular/material/select'; import { MatDividerModule } from '@angular/material/divider'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { Subject, takeUntil } from 'rxjs'; import { ApiService } from '../../services/api.service'; import { EnvironmentService } from '../../services/environment.service'; import { Router } from '@angular/router'; interface ChatMessage { author: 'user' | 'assistant'; text: string; timestamp?: Date; audioUrl?: string; } @Component({ selector: 'app-chat', standalone: true, imports: [ CommonModule, FormsModule, ReactiveFormsModule, MatButtonModule, MatIconModule, MatFormFieldModule, MatInputModule, MatCardModule, MatSelectModule, MatDividerModule, MatTooltipModule, MatProgressSpinnerModule, MatCheckboxModule, MatDialogModule, MatSnackBarModule ], templateUrl: './chat.component.html', styleUrls: ['./chat.component.scss'] }) export class ChatComponent implements OnInit, OnDestroy, AfterViewChecked { @ViewChild('scrollMe') private myScrollContainer!: ElementRef; @ViewChild('audioPlayer') private audioPlayer!: ElementRef; @ViewChild('waveformCanvas') private waveformCanvas!: ElementRef; projects: string[] = []; selectedProject: string | null = null; useTTS = false; ttsAvailable = false; selectedLocale: string = 'tr'; availableLocales: any[] = []; sessionId: string | null = null; messages: ChatMessage[] = []; input = this.fb.control('', Validators.required); loading = false; error = ''; playingAudio = false; useSTT = false; sttAvailable = false; isListening = false; // Audio visualization audioContext?: AudioContext; analyser?: AnalyserNode; animationId?: number; private destroyed$ = new Subject(); private shouldScroll = false; constructor( private fb: FormBuilder, private api: ApiService, private environmentService: EnvironmentService, private dialog: MatDialog, private router: Router, private snackBar: MatSnackBar ) {} ngOnInit(): void { this.loadProjects(); this.loadAvailableLocales(); this.checkTTSAvailability(); this.checkSTTAvailability(); // Initialize Audio Context with error handling try { this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); } catch (error) { console.error('Failed to create AudioContext:', error); } // Watch for STT toggle changes this.watchSTTToggle(); } loadAvailableLocales(): void { this.api.getAvailableLocales().pipe( takeUntil(this.destroyed$) ).subscribe({ next: (response) => { this.availableLocales = response.locales; this.selectedLocale = response.default || 'tr'; }, error: (err) => { console.error('Failed to load locales:', err); // Fallback locales this.availableLocales = [ { code: 'tr', name: 'Türkçe' }, { code: 'en', name: 'English' } ]; } }); } private watchSTTToggle(): void { // When STT is toggled, provide feedback // This could be implemented with form control valueChanges if needed } ngAfterViewChecked() { if (this.shouldScroll) { this.scrollToBottom(); this.shouldScroll = false; } } ngOnDestroy(): void { this.destroyed$.next(); this.destroyed$.complete(); // Cleanup audio resources this.cleanupAudio(); } private cleanupAudio(): void { if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = undefined; } if (this.audioContext && this.audioContext.state !== 'closed') { this.audioContext.close().catch(err => console.error('Failed to close audio context:', err)); } // Clean up audio URLs this.messages.forEach(msg => { if (msg.audioUrl) { URL.revokeObjectURL(msg.audioUrl); } }); } private checkSTTAvailability(): void { this.api.getEnvironment().pipe( takeUntil(this.destroyed$) ).subscribe({ next: (env) => { this.sttAvailable = env.stt_provider?.name !== 'no_stt'; if (!this.sttAvailable) { this.useSTT = false; } }, error: (err) => { console.error('Failed to check STT availability:', err); this.sttAvailable = false; } }); } async startRealtimeChat(): Promise { if (!this.selectedProject) { this.error = 'Please select a project first'; this.snackBar.open(this.error, 'Close', { duration: 3000 }); return; } if (!this.sttAvailable || !this.useSTT) { this.error = 'STT must be enabled for real-time chat'; this.snackBar.open(this.error, 'Close', { duration: 5000 }); return; } this.loading = true; this.error = ''; this.api.startChat(this.selectedProject, true, this.selectedLocale).pipe( takeUntil(this.destroyed$) ).subscribe({ next: res => { // Store session ID for realtime component localStorage.setItem('current_session_id', res.session_id); localStorage.setItem('current_project', this.selectedProject || ''); localStorage.setItem('current_locale', this.selectedLocale); localStorage.setItem('use_tts', this.useTTS.toString()); // Open realtime chat dialog this.openRealtimeDialog(res.session_id); this.loading = false; }, error: (err) => { this.error = this.getErrorMessage(err); this.loading = false; this.snackBar.open(this.error, 'Close', { duration: 5000, panelClass: 'error-snackbar' }); } }); } private async openRealtimeDialog(sessionId: string): Promise { try { const { RealtimeChatComponent } = await import('./realtime-chat.component'); const dialogRef = this.dialog.open(RealtimeChatComponent, { width: '90%', maxWidth: '900px', height: '85vh', maxHeight: '800px', disableClose: false, panelClass: 'realtime-chat-dialog', data: { sessionId: sessionId, projectName: this.selectedProject } }); dialogRef.afterClosed().pipe( takeUntil(this.destroyed$) ).subscribe(result => { // Clean up session data localStorage.removeItem('current_session_id'); localStorage.removeItem('current_project'); localStorage.removeItem('current_locale'); localStorage.removeItem('use_tts'); // If session was active, end it if (result === 'session_active' && sessionId) { this.api.endSession(sessionId).pipe( takeUntil(this.destroyed$) ).subscribe({ next: () => console.log('Session ended'), error: (err: any) => console.error('Failed to end session:', err) }); } }); } catch (error) { console.error('Failed to load realtime chat:', error); this.snackBar.open('Failed to open realtime chat', 'Close', { duration: 3000, panelClass: 'error-snackbar' }); } } loadProjects(): void { this.loading = true; this.error = ''; this.api.getChatProjects().pipe( takeUntil(this.destroyed$) ).subscribe({ next: projects => { this.projects = projects; this.loading = false; if (projects.length === 0) { this.error = 'No enabled projects found. Please enable a project with published version.'; } }, error: (err) => { this.error = 'Failed to load projects'; this.loading = false; this.snackBar.open(this.error, 'Close', { duration: 5000, panelClass: 'error-snackbar' }); } }); } checkTTSAvailability(): void { // Subscribe to environment updates this.environmentService.environment$.pipe( takeUntil(this.destroyed$) ).subscribe(env => { if (env) { this.ttsAvailable = env.tts_provider?.name !== 'no_tts'; if (!this.ttsAvailable) { this.useTTS = false; } } }); // Get current environment this.api.getEnvironment().pipe( takeUntil(this.destroyed$) ).subscribe({ next: (env) => { this.ttsAvailable = env.tts_provider?.name !== 'no_tts'; if (!this.ttsAvailable) { this.useTTS = false; } } }); } startChat(): void { if (!this.selectedProject) { this.snackBar.open('Please select a project', 'Close', { duration: 3000 }); return; } if (this.useSTT) { this.snackBar.open('For voice input, please use Real-time Chat', 'Close', { duration: 3000 }); return; } this.loading = true; this.error = ''; this.api.startChat(this.selectedProject, false, this.selectedLocale).pipe( takeUntil(this.destroyed$) ).subscribe({ next: res => { this.sessionId = res.session_id; const message: ChatMessage = { author: 'assistant', text: res.answer, timestamp: new Date() }; this.messages = [message]; this.loading = false; this.shouldScroll = true; // Generate TTS if enabled if (this.useTTS && this.ttsAvailable) { this.generateTTS(res.answer, this.messages.length - 1); } }, error: (err) => { this.error = this.getErrorMessage(err); this.loading = false; this.snackBar.open(this.error, 'Close', { duration: 5000, panelClass: 'error-snackbar' }); } }); } send(): void { if (!this.sessionId || this.input.invalid || this.loading) return; const text = this.input.value!.trim(); if (!text) return; // Add user message this.messages.push({ author: 'user', text, timestamp: new Date() }); this.input.reset(); this.loading = true; this.shouldScroll = true; // Send to backend this.api.chat(this.sessionId, text).pipe( takeUntil(this.destroyed$) ).subscribe({ next: res => { const message: ChatMessage = { author: 'assistant', text: res.response, timestamp: new Date() }; this.messages.push(message); this.loading = false; this.shouldScroll = true; // Generate TTS if enabled if (this.useTTS && this.ttsAvailable) { this.generateTTS(res.response, this.messages.length - 1); } }, error: (err) => { const errorMsg = this.getErrorMessage(err); this.messages.push({ author: 'assistant', text: '⚠️ ' + errorMsg, timestamp: new Date() }); this.loading = false; this.shouldScroll = true; } }); } generateTTS(text: string, messageIndex: number): void { if (!this.ttsAvailable || messageIndex < 0 || messageIndex >= this.messages.length) return; this.api.generateTTS(text).pipe( takeUntil(this.destroyed$) ).subscribe({ next: (audioBlob) => { const audioUrl = URL.createObjectURL(audioBlob); // Clean up old audio URL if exists if (this.messages[messageIndex].audioUrl) { URL.revokeObjectURL(this.messages[messageIndex].audioUrl!); } this.messages[messageIndex].audioUrl = audioUrl; // Auto-play the latest message if (messageIndex === this.messages.length - 1) { setTimeout(() => this.playAudio(audioUrl), 100); } }, error: (err) => { console.error('TTS generation error:', err); this.snackBar.open('Failed to generate audio', 'Close', { duration: 3000, panelClass: 'error-snackbar' }); } }); } playAudio(audioUrl: string): void { if (!this.audioPlayer || !audioUrl) return; const audio = this.audioPlayer.nativeElement; // Stop current audio if playing if (!audio.paused) { audio.pause(); audio.currentTime = 0; } audio.src = audioUrl; // Set up audio visualization if (this.audioContext && this.audioContext.state !== 'closed') { this.setupAudioVisualization(audio); } audio.play().then(() => { this.playingAudio = true; }).catch(err => { console.error('Audio play error:', err); this.snackBar.open('Failed to play audio', 'Close', { duration: 3000, panelClass: 'error-snackbar' }); }); audio.onended = () => { this.playingAudio = false; if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = undefined; this.clearWaveform(); } }; audio.onerror = () => { this.playingAudio = false; console.error('Audio playback error'); }; } setupAudioVisualization(audio: HTMLAudioElement): void { if (!this.audioContext || !this.waveformCanvas || this.audioContext.state === 'closed') return; try { // Check if source already exists for this audio element if (!(audio as any).audioSource) { const source = this.audioContext.createMediaElementSource(audio); this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 256; // Connect nodes source.connect(this.analyser); this.analyser.connect(this.audioContext.destination); // Store reference to prevent recreation (audio as any).audioSource = source; } // Start visualization this.drawWaveform(); } catch (error) { console.error('Failed to setup audio visualization:', error); } } drawWaveform(): void { if (!this.analyser || !this.waveformCanvas) return; const canvas = this.waveformCanvas.nativeElement; const ctx = canvas.getContext('2d'); if (!ctx) return; const bufferLength = this.analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); const draw = () => { if (!this.playingAudio) { this.clearWaveform(); return; } this.animationId = requestAnimationFrame(draw); this.analyser!.getByteFrequencyData(dataArray); ctx.fillStyle = 'rgb(240, 240, 240)'; ctx.fillRect(0, 0, canvas.width, canvas.height); const barWidth = (canvas.width / bufferLength) * 2.5; let barHeight; let x = 0; for (let i = 0; i < bufferLength; i++) { barHeight = (dataArray[i] / 255) * canvas.height * 0.8; ctx.fillStyle = `rgb(63, 81, 181)`; ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight); x += barWidth + 1; } }; draw(); } clearWaveform(): void { if (!this.waveformCanvas) return; const canvas = this.waveformCanvas.nativeElement; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.fillStyle = 'rgb(240, 240, 240)'; ctx.fillRect(0, 0, canvas.width, canvas.height); } endSession(): void { // Clean up current session if (this.sessionId) { this.api.endSession(this.sessionId).pipe( takeUntil(this.destroyed$) ).subscribe({ error: (err) => console.error('Failed to end session:', err) }); } // Clean up audio URLs this.messages.forEach(msg => { if (msg.audioUrl) { URL.revokeObjectURL(msg.audioUrl); } }); // Reset state this.sessionId = null; this.messages = []; this.selectedProject = null; this.input.reset(); this.error = ''; // Clean up audio if (this.audioPlayer) { this.audioPlayer.nativeElement.pause(); this.audioPlayer.nativeElement.src = ''; } if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = undefined; } this.clearWaveform(); } private scrollToBottom(): void { try { if (this.myScrollContainer?.nativeElement) { const element = this.myScrollContainer.nativeElement; element.scrollTop = element.scrollHeight; } } catch(err) { console.error('Scroll error:', err); } } private getErrorMessage(error: any): string { if (error.status === 0) { return 'Unable to connect to server. Please check your connection.'; } else if (error.status === 401) { return 'Session expired. Please login again.'; } else if (error.status === 403) { return 'You do not have permission to use this feature.'; } else if (error.status === 404) { return 'Project or session not found. Please try again.'; } else if (error.error?.detail) { return error.error.detail; } else if (error.message) { return error.message; } return 'An unexpected error occurred. Please try again.'; } }