Spaces:
Building
Building
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.'; | |
} | |
} |