Spaces:
Paused
Paused
| 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; | |
| } | |
| ({ | |
| 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 { | |
| ('scrollMe') private myScrollContainer!: ElementRef; | |
| ('audioPlayer') private audioPlayer!: ElementRef<HTMLAudioElement>; | |
| ('waveformCanvas') private waveformCanvas!: ElementRef<HTMLCanvasElement>; | |
| 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<void>(); | |
| 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<void> { | |
| 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<void> { | |
| 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.'; | |
| } | |
| } |