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 { Subscription } 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 | |
], | |
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> | |
</mat-card-header> | |
<mat-divider></mat-divider> | |
<mat-card-content> | |
<!-- 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" | |
[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"> | |
<mat-icon>volume_up</mat-icon> | |
</button> | |
</div> | |
</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"> | |
<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> | |
</mat-card-actions> | |
</mat-card> | |
`, | |
styles: [` | |
.realtime-chat-container { | |
max-width: 800px; | |
margin: 20px auto; | |
height: 80vh; | |
display: flex; | |
flex-direction: column; | |
} | |
.transcription-area { | |
background: #f5f5f5; | |
padding: 16px; | |
border-radius: 8px; | |
margin-bottom: 16px; | |
min-height: 60px; | |
} | |
.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; | |
} | |
.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); | |
} | |
.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; | |
} | |
.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; | |
justify-content: space-between; | |
} | |
`] | |
}) | |
export class RealtimeChatComponent implements OnInit, OnDestroy, AfterViewChecked { | |
'scrollContainer') private scrollContainer!: ElementRef; | (|
'audioVisualizer') private audioVisualizer!: ElementRef<HTMLCanvasElement>; | (|
sessionId: string | null = null; | |
isConversationActive = false; | |
isRecording = false; | |
currentState: ConversationState = 'idle'; | |
currentTranscription = ''; | |
messages: ConversationMessage[] = []; | |
conversationStates: ConversationState[] = [ | |
'idle', 'listening', 'processing_stt', 'processing_llm', 'processing_tts', 'playing_audio' | |
]; | |
private subscriptions = new Subscription(); | |
private shouldScrollToBottom = false; | |
private animationId: number | null = null; | |
constructor( | |
private conversationManager: ConversationManagerService, | |
private audioService: AudioStreamService | |
) {} | |
ngOnInit(): void { | |
// Get session ID from parent component or service | |
this.sessionId = localStorage.getItem('current_session_id'); | |
// Subscribe to conversation state | |
this.subscriptions.add( | |
this.conversationManager.currentState$.subscribe(state => { | |
this.currentState = state; | |
this.updateRecordingState(state); | |
}) | |
); | |
// Subscribe to messages | |
this.subscriptions.add( | |
this.conversationManager.messages$.subscribe(messages => { | |
this.messages = messages; | |
this.shouldScrollToBottom = true; | |
}) | |
); | |
// Subscribe to transcription | |
this.subscriptions.add( | |
this.conversationManager.transcription$.subscribe(text => { | |
this.currentTranscription = text; | |
}) | |
); | |
// Check browser support | |
if (!AudioStreamService.checkBrowserSupport()) { | |
alert('Tarayıcınız ses kaydını desteklemiyor. Lütfen modern bir tarayıcı kullanın.'); | |
} | |
} | |
ngAfterViewChecked(): void { | |
if (this.shouldScrollToBottom) { | |
this.scrollToBottom(); | |
this.shouldScrollToBottom = false; | |
} | |
} | |
ngOnDestroy(): void { | |
this.subscriptions.unsubscribe(); | |
this.stopVisualization(); | |
if (this.isConversationActive) { | |
this.conversationManager.stopConversation(); | |
} | |
} | |
async toggleConversation(): Promise<void> { | |
if (!this.sessionId) return; | |
if (this.isConversationActive) { | |
this.conversationManager.stopConversation(); | |
this.isConversationActive = false; | |
this.stopVisualization(); | |
} else { | |
try { | |
await this.conversationManager.startConversation(this.sessionId); | |
this.isConversationActive = true; | |
this.startVisualization(); | |
} catch (error) { | |
console.error('Failed to start conversation:', error); | |
alert('Konuşma başlatılamadı. Lütfen tekrar deneyin.'); | |
} | |
} | |
} | |
clearChat(): void { | |
this.conversationManager.clearMessages(); | |
this.currentTranscription = ''; | |
} | |
playAudio(audioUrl: string): void { | |
const audio = new Audio(audioUrl); | |
audio.play(); | |
} | |
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' | |
}; | |
return labels[state] || state; | |
} | |
private updateRecordingState(state: ConversationState): void { | |
this.isRecording = state === 'listening'; | |
} | |
private scrollToBottom(): void { | |
try { | |
this.scrollContainer.nativeElement.scrollTop = | |
this.scrollContainer.nativeElement.scrollHeight; | |
} catch(err) {} | |
} | |
private startVisualization(): void { | |
if (!this.audioVisualizer) return; | |
const canvas = this.audioVisualizer.nativeElement; | |
const ctx = canvas.getContext('2d'); | |
if (!ctx) return; | |
// Simple visualization animation | |
const draw = () => { | |
ctx.fillStyle = '#333'; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
// Draw random bars for demo | |
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); | |
} | |
if (this.isRecording) { | |
this.animationId = requestAnimationFrame(draw); | |
} | |
}; | |
draw(); | |
} | |
private stopVisualization(): void { | |
if (this.animationId) { | |
cancelAnimationFrame(this.animationId); | |
this.animationId = null; | |
} | |
// Clear canvas | |
if (this.audioVisualizer) { | |
const canvas = this.audioVisualizer.nativeElement; | |
const ctx = canvas.getContext('2d'); | |
if (ctx) { | |
ctx.fillStyle = '#333'; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
} | |
} | |
} | |
} |