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
5998f62 verified
raw
history blame
17.9 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, 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
],
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 {
@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 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 {
// 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;
}
}
}