Spaces:
Paused
Paused
| 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, takeUntil } from 'rxjs'; | |
| import { ConversationManagerService, ConversationState, ConversationMessage } from '../../services/conversation-manager.service'; | |
| import { AudioStreamService } from '../../services/audio-stream.service'; | |
| ({ | |
| selector: 'app-realtime-chat', | |
| standalone: true, | |
| imports: [ | |
| CommonModule, | |
| MatCardModule, | |
| MatButtonModule, | |
| MatIconModule, | |
| MatProgressSpinnerModule, | |
| MatDividerModule, | |
| MatChipsModule, | |
| MatSnackBarModule | |
| ], | |
| template: ` | |
| <mat-card class="realtime-chat-container"> | |
| <mat-card-header> | |
| <mat-icon mat-card-avatar>voice_chat</mat-icon> | |
| <mat-card-title>Real-time Conversation</mat-card-title> | |
| <mat-card-subtitle> | |
| <mat-chip-listbox> | |
| <mat-chip [class.active]="currentState === state" | |
| *ngFor="let state of conversationStates"> | |
| {{ getStateLabel(state) }} | |
| </mat-chip> | |
| </mat-chip-listbox> | |
| </mat-card-subtitle> | |
| <button mat-icon-button class="close-button" (click)="closeDialog()"> | |
| <mat-icon>close</mat-icon> | |
| </button> | |
| </mat-card-header> | |
| <mat-divider></mat-divider> | |
| <mat-card-content> | |
| <!-- Error State --> | |
| <div class="error-banner" *ngIf="error"> | |
| <mat-icon>error_outline</mat-icon> | |
| <span>{{ error }}</span> | |
| <button mat-icon-button (click)="retryConnection()"> | |
| <mat-icon>refresh</mat-icon> | |
| </button> | |
| </div> | |
| <!-- Transcription Display --> | |
| <div class="transcription-area" *ngIf="currentTranscription"> | |
| <div class="transcription-label">Dinleniyor...</div> | |
| <div class="transcription-text">{{ currentTranscription }}</div> | |
| </div> | |
| <!-- Chat Messages --> | |
| <div class="chat-messages" #scrollContainer> | |
| <div *ngFor="let msg of messages; trackBy: trackByIndex" | |
| [class]="'message ' + msg.role"> | |
| <mat-icon class="message-icon"> | |
| {{ msg.role === 'user' ? 'person' : 'smart_toy' }} | |
| </mat-icon> | |
| <div class="message-content"> | |
| <div class="message-text">{{ msg.text }}</div> | |
| <div class="message-time">{{ msg.timestamp | date:'HH:mm:ss' }}</div> | |
| <button *ngIf="msg.audioUrl" | |
| mat-icon-button | |
| (click)="playAudio(msg.audioUrl)" | |
| class="audio-button" | |
| [disabled]="isPlayingAudio"> | |
| <mat-icon>{{ isPlayingAudio ? 'stop' : 'volume_up' }}</mat-icon> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Empty State --> | |
| <div class="empty-state" *ngIf="messages.length === 0 && !isConversationActive"> | |
| <mat-icon>mic_off</mat-icon> | |
| <p>Konuşmaya başlamak için aşağıdaki butona tıklayın</p> | |
| </div> | |
| </div> | |
| <!-- Audio Visualizer --> | |
| <canvas #audioVisualizer | |
| class="audio-visualizer" | |
| width="600" | |
| height="100" | |
| [class.active]="isRecording"> | |
| </canvas> | |
| </mat-card-content> | |
| <mat-card-actions> | |
| <button mat-raised-button | |
| color="primary" | |
| (click)="toggleConversation()" | |
| [disabled]="!sessionId || loading"> | |
| @if (loading) { | |
| <mat-spinner diameter="20"></mat-spinner> | |
| } @else { | |
| <mat-icon>{{ isConversationActive ? 'stop' : 'mic' }}</mat-icon> | |
| {{ isConversationActive ? 'Konuşmayı Bitir' : 'Konuşmaya Başla' }} | |
| } | |
| </button> | |
| <button mat-button | |
| (click)="clearChat()" | |
| [disabled]="messages.length === 0"> | |
| <mat-icon>clear</mat-icon> | |
| Temizle | |
| </button> | |
| <button mat-button | |
| (click)="performBargeIn()" | |
| [disabled]="!isConversationActive || currentState === 'idle' || currentState === 'listening'"> | |
| <mat-icon>pan_tool</mat-icon> | |
| Kesme (Barge-in) | |
| </button> | |
| </mat-card-actions> | |
| </mat-card> | |
| `, | |
| styles: [` | |
| .realtime-chat-container { | |
| max-width: 800px; | |
| margin: 20px auto; | |
| height: 80vh; | |
| display: flex; | |
| flex-direction: column; | |
| position: relative; | |
| } | |
| mat-card-header { | |
| position: relative; | |
| .close-button { | |
| position: absolute; | |
| top: 8px; | |
| right: 8px; | |
| } | |
| } | |
| .error-banner { | |
| background-color: #ffebee; | |
| color: #c62828; | |
| padding: 12px; | |
| border-radius: 4px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 16px; | |
| mat-icon { | |
| font-size: 20px; | |
| width: 20px; | |
| height: 20px; | |
| } | |
| span { | |
| flex: 1; | |
| } | |
| } | |
| .transcription-area { | |
| background: #f5f5f5; | |
| padding: 16px; | |
| border-radius: 8px; | |
| margin-bottom: 16px; | |
| min-height: 60px; | |
| animation: pulse 2s infinite; | |
| } | |
| @keyframes pulse { | |
| 0% { opacity: 1; } | |
| 50% { opacity: 0.7; } | |
| 100% { opacity: 1; } | |
| } | |
| .transcription-label { | |
| font-size: 12px; | |
| color: #666; | |
| margin-bottom: 4px; | |
| } | |
| .transcription-text { | |
| font-size: 16px; | |
| color: #333; | |
| min-height: 24px; | |
| } | |
| .chat-messages { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 16px; | |
| background: #fafafa; | |
| border-radius: 8px; | |
| min-height: 200px; | |
| max-height: 400px; | |
| } | |
| .message { | |
| display: flex; | |
| align-items: flex-start; | |
| margin-bottom: 16px; | |
| animation: slideIn 0.3s ease-out; | |
| } | |
| @keyframes slideIn { | |
| from { | |
| opacity: 0; | |
| transform: translateY(10px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| .message.user { | |
| flex-direction: row-reverse; | |
| } | |
| .message-icon { | |
| margin: 0 8px; | |
| color: #666; | |
| } | |
| .message-content { | |
| max-width: 70%; | |
| background: white; | |
| padding: 12px 16px; | |
| border-radius: 12px; | |
| box-shadow: 0 1px 2px rgba(0,0,0,0.1); | |
| position: relative; | |
| } | |
| .message.user .message-content { | |
| background: #3f51b5; | |
| color: white; | |
| } | |
| .message-text { | |
| margin-bottom: 4px; | |
| } | |
| .message-time { | |
| font-size: 11px; | |
| opacity: 0.7; | |
| } | |
| .audio-button { | |
| margin-top: 8px; | |
| } | |
| .empty-state { | |
| text-align: center; | |
| padding: 60px 20px; | |
| color: #999; | |
| mat-icon { | |
| font-size: 48px; | |
| width: 48px; | |
| height: 48px; | |
| margin-bottom: 16px; | |
| } | |
| } | |
| .audio-visualizer { | |
| width: 100%; | |
| height: 100px; | |
| background: #333; | |
| border-radius: 8px; | |
| margin-top: 16px; | |
| opacity: 0.3; | |
| transition: opacity 0.3s; | |
| } | |
| .audio-visualizer.active { | |
| opacity: 1; | |
| } | |
| mat-chip { | |
| font-size: 12px; | |
| } | |
| mat-chip.active { | |
| background-color: #3f51b5 !important; | |
| color: white !important; | |
| } | |
| mat-card-actions { | |
| padding: 16px; | |
| display: flex; | |
| gap: 16px; | |
| justify-content: flex-start; | |
| mat-spinner { | |
| display: inline-block; | |
| margin-right: 8px; | |
| } | |
| } | |
| `] | |
| }) | |
| export class RealtimeChatComponent implements OnInit, OnDestroy, AfterViewChecked { | |
| ('scrollContainer') private scrollContainer!: ElementRef; | |
| ('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 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>, | |
| (MAT_DIALOG_DATA) public data: { sessionId: string; projectName?: string } | |
| ) { | |
| this.sessionId = data.sessionId; | |
| this.projectName = data.projectName || null; | |
| } | |
| ngOnInit(): void { | |
| // 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 => { | |
| this.currentState = state; | |
| this.updateRecordingState(state); | |
| }); | |
| // Subscribe to messages | |
| this.conversationManager.messages$.pipe( | |
| takeUntil(this.destroyed$) | |
| ).subscribe(messages => { | |
| this.messages = messages; | |
| this.shouldScrollToBottom = true; | |
| }); | |
| // Subscribe to transcription | |
| this.conversationManager.transcription$.pipe( | |
| takeUntil(this.destroyed$) | |
| ).subscribe(text => { | |
| this.currentTranscription = text; | |
| }); | |
| } | |
| ngAfterViewChecked(): void { | |
| if (this.shouldScrollToBottom) { | |
| this.scrollToBottom(); | |
| this.shouldScrollToBottom = false; | |
| } | |
| } | |
| ngOnDestroy(): void { | |
| this.destroyed$.next(); | |
| this.destroyed$.complete(); | |
| 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(); | |
| } | |
| } | |
| private async startConversation(): Promise<void> { | |
| try { | |
| this.loading = true; | |
| this.error = ''; | |
| await this.conversationManager.startConversation(this.sessionId!); | |
| this.isConversationActive = true; | |
| 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 { | |
| this.isRecording = state === 'listening'; | |
| } | |
| 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; | |
| // Get volume level and visualize | |
| const draw = async () => { | |
| if (!this.isConversationActive) { | |
| this.clearVisualization(); | |
| return; | |
| } | |
| ctx.fillStyle = '#333'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| if (this.isRecording) { | |
| // Get actual volume level | |
| try { | |
| const volume = await this.audioService.getVolumeLevel(); | |
| // Draw volume bars | |
| ctx.fillStyle = '#4caf50'; | |
| const barCount = 50; | |
| const barWidth = canvas.width / barCount; | |
| for (let i = 0; i < barCount; i++) { | |
| const barHeight = Math.random() * volume * canvas.height; | |
| const x = i * barWidth; | |
| const y = canvas.height - barHeight; | |
| ctx.fillRect(x, y, barWidth - 2, barHeight); | |
| } | |
| } catch (error) { | |
| // Fallback to random visualization | |
| ctx.fillStyle = '#4caf50'; | |
| const barCount = 50; | |
| const barWidth = canvas.width / barCount; | |
| for (let i = 0; i < barCount; i++) { | |
| const barHeight = Math.random() * canvas.height * 0.8; | |
| const x = i * barWidth; | |
| const y = canvas.height - barHeight; | |
| ctx.fillRect(x, y, barWidth - 2, barHeight); | |
| } | |
| } | |
| } | |
| this.animationId = requestAnimationFrame(draw); | |
| }; | |
| draw(); | |
| } | |
| 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; | |
| } | |
| } | |
| } |