Spaces:
Running
Running
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>, | |
public data: { sessionId: string; projectName?: string } (MAT_DIALOG_DATA) | |
) { | |
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; | |
// Check if we have initial welcome message | |
if (messages.length === 1 && messages[0].role === 'assistant' && !this.isConversationActive) { | |
// Show welcome message | |
console.log('Welcome message received:', messages[0].text); | |
} | |
}); | |
// Subscribe to transcription | |
this.conversationManager.transcription$.pipe( | |
takeUntil(this.destroyed$) | |
).subscribe(text => { | |
this.currentTranscription = text; | |
}); | |
// Load initial messages from session if available | |
const initialMessages = this.conversationManager.getMessages(); | |
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.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; | |
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; | |
} | |
} | |
} |