flare / flare-ui /src /app /components /chat /realtime-chat.component.ts
ciyidogan's picture
Update flare-ui/src/app/components/chat/realtime-chat.component.ts
1bffd96 verified
raw
history blame
12.7 kB
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';
currentTranscription = '';
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;
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;
this.updateRecordingState(state);
// Visualization'ı state'e göre güncelle
if (state === 'listening') {
this.isRecording = true;
} else {
this.isRecording = false;
}
});
// Subscribe to transcription
this.conversationManager.transcription$.pipe(
takeUntil(this.destroyed$)
).subscribe(text => {
if (text) {
console.log('🎙️ Transcription:', text);
}
this.currentTranscription = text;
});
// 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 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;
// 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.stopVisualization();
this.snackBar.open('Konuşma sonlandırıldı', 'Close', {
duration: 2000
});
}
async retryConnection(): Promise<void> {
this.error = '';
if (!this.isConversationActive && this.sessionId) {
await this.startConversation();
}
}
clearChat(): void {
this.conversationManager.clearMessages();
this.currentTranscription = '';
this.error = '';
}
performBargeIn(): void {
this.conversationManager.performBargeIn();
this.snackBar.open('Kesme yapıldı', 'Close', {
duration: 1000
});
}
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 updateRecordingState(state: ConversationState): void {
// State'e göre recording durumunu güncelle
this.isRecording = state === 'listening';
// Visualizer'ı güncelle
if (this.isRecording && this.isConversationActive) {
// Eğer visualizer çalışmıyorsa başlat
if (!this.animationId) {
this.startVisualization();
}
}
}
private scrollToBottom(): void {
try {
if (this.scrollContainer?.nativeElement) {
const element = this.scrollContainer.nativeElement;
element.scrollTop = element.scrollHeight;
}
} catch(err) {
console.error('Scroll error:', err);
}
}
private startVisualization(): void {
if (!this.audioVisualizer) return;
const canvas = this.audioVisualizer.nativeElement;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let lastVolume = 0;
// Subscribe to volume changes
this.subscriptions.add(
this.audioService.volumeLevel$.subscribe(volume => {
lastVolume = volume;
})
);
// Animation loop
const draw = () => {
if (!this.isConversationActive) {
this.clearVisualization();
return;
}
// Clear canvas
ctx.fillStyle = '#333';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (this.isRecording && lastVolume > 0) {
// Draw volume visualization
ctx.fillStyle = '#4caf50';
const barCount = 50;
const barWidth = canvas.width / barCount;
for (let i = 0; i < barCount; i++) {
const barHeight = (Math.random() * 0.5 + 0.5) * lastVolume * canvas.height;
const x = i * barWidth;
const y = canvas.height - barHeight;
ctx.fillRect(x, y, barWidth - 2, barHeight);
}
}
this.animationId = requestAnimationFrame(draw);
};
draw();
}
private drawVolumeLevel(ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, volume: number): void {
ctx.fillStyle = '#333';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#4caf50';
const barCount = 50;
const barWidth = canvas.width / barCount;
for (let i = 0; i < barCount; i++) {
const barHeight = (Math.random() * 0.5 + 0.5) * volume * canvas.height;
const x = i * barWidth;
const y = canvas.height - barHeight;
ctx.fillRect(x, y, barWidth - 2, barHeight);
}
}
private stopVisualization(): void {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
this.clearVisualization();
}
private clearVisualization(): void {
if (!this.audioVisualizer) return;
const canvas = this.audioVisualizer.nativeElement;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#333';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}
private cleanupAudio(): void {
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio = null;
this.isPlayingAudio = false;
}
}
}