flare / flare-ui /src /app /components /chat /realtime-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 { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDividerModule } from '@angular/material/divider';
import { MatChipsModule } from '@angular/material/chips';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { Inject } from '@angular/core';
import { Subject, Subscription, takeUntil } from 'rxjs';
import { ConversationManagerService, ConversationState, ConversationMessage } from '../../services/conversation-manager.service';
import { AudioStreamService } from '../../services/audio-stream.service';
@Component({
selector: 'app-realtime-chat',
standalone: true,
imports: [
CommonModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatDividerModule,
MatChipsModule,
MatSnackBarModule
],
templateUrl: './realtime-chat.component.html',
styleUrls: ['./realtime-chat.component.scss']
})
export class RealtimeChatComponent implements OnInit, OnDestroy, AfterViewChecked {
@ViewChild('scrollContainer') private scrollContainer!: ElementRef;
@ViewChild('audioVisualizer') private audioVisualizer!: ElementRef<HTMLCanvasElement>;
sessionId: string | null = null;
projectName: string | null = null;
isConversationActive = false;
isRecording = false;
isPlayingAudio = false;
currentState: ConversationState = 'idle';
messages: ConversationMessage[] = [];
error = '';
loading = false;
conversationStates: ConversationState[] = [
'idle', 'listening', 'processing_stt', 'processing_llm', 'processing_tts', 'playing_audio'
];
private destroyed$ = new Subject<void>();
private subscriptions = new Subscription();
private shouldScrollToBottom = false;
private animationId: number | null = null;
private currentAudio: HTMLAudioElement | null = null;
private volumeUpdateSubscription?: Subscription;
constructor(
private conversationManager: ConversationManagerService,
private audioService: AudioStreamService,
private snackBar: MatSnackBar,
public dialogRef: MatDialogRef<RealtimeChatComponent>,
@Inject(MAT_DIALOG_DATA) public data: { sessionId: string; projectName?: string }
) {
this.sessionId = data.sessionId;
this.projectName = data.projectName || null;
}
ngOnInit(): void {
console.log('🎤 RealtimeChat component initialized');
console.log('Session ID:', this.sessionId);
console.log('Project Name:', this.projectName);
// Subscribe to messages FIRST - before any connection
this.conversationManager.messages$.pipe(
takeUntil(this.destroyed$)
).subscribe(messages => {
console.log('💬 Messages updated:', messages.length, 'messages');
this.messages = messages;
this.shouldScrollToBottom = true;
// Check if we have initial welcome message
if (messages.length > 0) {
const lastMessage = messages[messages.length - 1];
console.log('📝 Last message:', lastMessage.role, lastMessage.text?.substring(0, 50) + '...');
}
});
// Check browser support
if (!AudioStreamService.checkBrowserSupport()) {
this.error = 'Tarayıcınız ses kaydını desteklemiyor. Lütfen modern bir tarayıcı kullanın.';
this.snackBar.open(this.error, 'Close', {
duration: 5000,
panelClass: 'error-snackbar'
});
return;
}
// Check microphone permission
this.checkMicrophonePermission();
// Subscribe to conversation state
this.conversationManager.currentState$.pipe(
takeUntil(this.destroyed$)
).subscribe(state => {
console.log('📊 Conversation state:', state);
this.currentState = state;
// Recording state'i conversation active olduğu sürece true tut
// Sadece error state'inde false yap
this.isRecording = this.isConversationActive && state !== 'error';
});
// Subscribe to errors
this.conversationManager.error$.pipe(
takeUntil(this.destroyed$)
).subscribe(error => {
console.error('Conversation error:', error);
this.error = error.message;
});
// Load initial messages from session if available
const initialMessages = this.conversationManager.getMessages();
console.log('📋 Initial messages:', initialMessages.length);
if (initialMessages.length > 0) {
this.messages = initialMessages;
this.shouldScrollToBottom = true;
}
}
ngAfterViewChecked(): void {
if (this.shouldScrollToBottom) {
this.scrollToBottom();
this.shouldScrollToBottom = false;
}
}
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
this.subscriptions.unsubscribe();
this.stopVisualization();
this.cleanupAudio();
if (this.isConversationActive) {
this.conversationManager.stopConversation();
}
}
async toggleConversation(): Promise<void> {
if (!this.sessionId) return;
if (this.isConversationActive) {
this.stopConversation();
} else {
await this.startConversation();
}
}
async retryConnection(): Promise<void> {
this.error = '';
if (!this.isConversationActive && this.sessionId) {
await this.startConversation();
}
}
clearChat(): void {
this.conversationManager.clearMessages();
this.error = '';
}
performBargeIn(): void {
// Barge-in özelliği devre dışı
this.snackBar.open('Barge-in özelliği şu anda devre dışı', 'Tamam', {
duration: 2000
});
}
playAudio(audioUrl?: string): void {
if (!audioUrl) return;
// Stop current audio if playing
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio = null;
this.isPlayingAudio = false;
return;
}
this.currentAudio = new Audio(audioUrl);
this.isPlayingAudio = true;
this.currentAudio.play().catch(error => {
console.error('Audio playback error:', error);
this.isPlayingAudio = false;
this.currentAudio = null;
});
this.currentAudio.onended = () => {
this.isPlayingAudio = false;
this.currentAudio = null;
};
this.currentAudio.onerror = () => {
this.isPlayingAudio = false;
this.currentAudio = null;
this.snackBar.open('Ses çalınamadı', 'Close', {
duration: 2000,
panelClass: 'error-snackbar'
});
};
}
getStateLabel(state: ConversationState): string {
const labels: Record<ConversationState, string> = {
'idle': 'Bekliyor',
'listening': 'Dinliyor',
'processing_stt': 'Metin Dönüştürme',
'processing_llm': 'Yanıt Hazırlanıyor',
'processing_tts': 'Ses Oluşturuluyor',
'playing_audio': 'Konuşuyor',
'error': 'Hata'
};
return labels[state] || state;
}
closeDialog(): void {
const result = this.isConversationActive ? 'session_active' : 'closed';
this.dialogRef.close(result);
}
trackByIndex(index: number): number {
return index;
}
private async checkMicrophonePermission(): Promise<void> {
try {
const permission = await this.audioService.checkMicrophonePermission();
if (permission === 'denied') {
this.error = 'Mikrofon erişimi reddedildi. Lütfen tarayıcı ayarlarından izin verin.';
this.snackBar.open(this.error, 'Close', {
duration: 5000,
panelClass: 'error-snackbar'
});
}
} catch (error) {
console.error('Failed to check microphone permission:', error);
}
}
private scrollToBottom(): void {
try {
if (this.scrollContainer?.nativeElement) {
const element = this.scrollContainer.nativeElement;
element.scrollTop = element.scrollHeight;
}
} catch(err) {
console.error('Scroll error:', err);
}
}
async startConversation(): Promise<void> {
try {
this.loading = true;
this.error = '';
// Clear existing messages - welcome will come via WebSocket
this.conversationManager.clearMessages();
await this.conversationManager.startConversation(this.sessionId!);
this.isConversationActive = true;
this.isRecording = true; // Konuşma başladığında recording'i aktif et
// Visualization'ı başlat
this.startVisualization();
this.snackBar.open('Konuşma başlatıldı', 'Close', {
duration: 2000
});
} catch (error: any) {
console.error('Failed to start conversation:', error);
this.error = 'Konuşma başlatılamadı. Lütfen tekrar deneyin.';
this.snackBar.open(this.error, 'Close', {
duration: 5000,
panelClass: 'error-snackbar'
});
} finally {
this.loading = false;
}
}
private stopConversation(): void {
this.conversationManager.stopConversation();
this.isConversationActive = false;
this.isRecording = false; // Konuşma bittiğinde recording'i kapat
this.stopVisualization();
this.snackBar.open('Konuşma sonlandırıldı', 'Close', {
duration: 2000
});
}
private startVisualization(): void {
// Eğer zaten çalışıyorsa tekrar başlatma
if (!this.audioVisualizer || this.animationId) {
return;
}
const canvas = this.audioVisualizer.nativeElement;
const ctx = canvas.getContext('2d');
if (!ctx) {
console.warn('Could not get canvas context');
return;
}
// Set canvas size
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
// Create gradient for bars
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
gradient.addColorStop(0, '#4caf50');
gradient.addColorStop(0.5, '#66bb6a');
gradient.addColorStop(1, '#4caf50');
let lastVolume = 0;
let targetVolume = 0;
const smoothingFactor = 0.8;
// Subscribe to volume updates
this.volumeUpdateSubscription = this.audioService.volumeLevel$.subscribe(volume => {
targetVolume = volume;
});
// Animation loop
const animate = () => {
// isConversationActive kontrolü ile devam et
if (!this.isConversationActive) {
this.clearVisualization();
return;
}
// Clear canvas
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Smooth volume transition
lastVolume = lastVolume * smoothingFactor + targetVolume * (1 - smoothingFactor);
// Draw frequency bars
const barCount = 32;
const barWidth = canvas.width / barCount;
const barSpacing = 2;
for (let i = 0; i < barCount; i++) {
// Create natural wave effect based on volume
const frequencyFactor = Math.sin((i / barCount) * Math.PI);
const timeFactor = Math.sin(Date.now() * 0.001 + i * 0.2) * 0.2 + 0.8;
const randomFactor = 0.8 + Math.random() * 0.2;
const barHeight = lastVolume * canvas.height * 0.7 * frequencyFactor * timeFactor * randomFactor;
const x = i * barWidth;
const y = (canvas.height - barHeight) / 2;
// Draw bar
ctx.fillStyle = gradient;
ctx.fillRect(x + barSpacing / 2, y, barWidth - barSpacing, barHeight);
// Draw reflection
ctx.fillStyle = 'rgba(76, 175, 80, 0.2)';
ctx.fillRect(x + barSpacing / 2, canvas.height - y, barWidth - barSpacing, -barHeight * 0.3);
}
// Draw center line
ctx.strokeStyle = 'rgba(76, 175, 80, 0.5)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, canvas.height / 2);
ctx.lineTo(canvas.width, canvas.height / 2);
ctx.stroke();
this.animationId = requestAnimationFrame(animate);
};
animate();
}
private stopVisualization(): void {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.volumeUpdateSubscription) {
this.volumeUpdateSubscription.unsubscribe();
this.volumeUpdateSubscription = undefined;
}
this.clearVisualization();
}
private clearVisualization(): void {
if (!this.audioVisualizer) return;
const canvas = this.audioVisualizer.nativeElement;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#212121';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}
private cleanupAudio(): void {
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio = null;
this.isPlayingAudio = false;
}
}
}