flare / flare-ui /src /app /components /chat /chat.component.ts
ciyidogan's picture
Upload 118 files
9f79da5 verified
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<HTMLAudioElement>;
@ViewChild('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.';
}
}