ciyidogan commited on
Commit
fed974c
·
verified ·
1 Parent(s): 881ca77

Update flare-ui/src/app/services/conversation-manager.service.ts

Browse files
flare-ui/src/app/services/conversation-manager.service.ts CHANGED
@@ -1,5 +1,9 @@
 
 
 
1
  import { Injectable, OnDestroy } from '@angular/core';
2
- import { Subject, Subscription, BehaviorSubject } from 'rxjs';
 
3
  import { WebSocketService } from './websocket.service';
4
  import { AudioStreamService } from './audio-stream.service';
5
 
@@ -9,13 +13,30 @@ export type ConversationState =
9
  | 'processing_stt'
10
  | 'processing_llm'
11
  | 'processing_tts'
12
- | 'playing_audio';
 
13
 
14
  export interface ConversationMessage {
15
- role: 'user' | 'assistant';
16
  text: string;
17
  timestamp: Date;
18
  audioUrl?: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  }
20
 
21
  @Injectable({
@@ -25,6 +46,12 @@ export class ConversationManagerService implements OnDestroy {
25
  private subscriptions = new Subscription();
26
  private audioQueue: string[] = [];
27
  private isInterrupting = false;
 
 
 
 
 
 
28
 
29
  // State management
30
  private currentStateSubject = new BehaviorSubject<ConversationState>('idle');
@@ -38,8 +65,13 @@ export class ConversationManagerService implements OnDestroy {
38
  private transcriptionSubject = new BehaviorSubject<string>('');
39
  public transcription$ = this.transcriptionSubject.asObservable();
40
 
 
 
 
 
41
  // Audio player reference
42
  private audioPlayer: HTMLAudioElement | null = null;
 
43
 
44
  constructor(
45
  private wsService: WebSocketService,
@@ -50,50 +82,114 @@ export class ConversationManagerService implements OnDestroy {
50
  this.cleanup();
51
  }
52
 
53
- async startConversation(sessionId: string): Promise<void> {
54
  try {
55
- // Connect WebSocket
56
- await this.wsService.connect(sessionId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
  // Start audio recording
59
- await this.audioService.startRecording();
 
 
 
 
60
 
61
  // Set up subscriptions
62
  this.setupSubscriptions();
63
 
64
- // Send start signal
65
- this.wsService.sendControl('start_session', {
66
- language: 'tr-TR',
67
- stt_engine: 'google',
68
- enable_barge_in: true
69
- });
70
 
71
- console.log('✅ Conversation started');
72
 
73
- } catch (error) {
74
  console.error('Failed to start conversation:', error);
 
 
 
 
 
 
 
 
 
 
 
 
75
  throw error;
76
  }
77
  }
78
 
79
  stopConversation(): void {
80
- this.cleanup();
 
 
 
 
 
 
 
 
 
 
 
 
81
  }
82
 
83
  private setupSubscriptions(): void {
84
  // Audio chunks from microphone
85
  this.subscriptions.add(
86
- this.audioService.audioChunk$.subscribe(chunk => {
87
- if (!this.isInterrupting) {
88
- this.wsService.sendAudioChunk(chunk.data);
 
 
 
 
 
 
 
 
 
 
89
  }
90
  })
91
  );
92
 
 
 
 
 
 
 
 
93
  // WebSocket messages
94
  this.subscriptions.add(
95
- this.wsService.message$.subscribe(message => {
96
- this.handleMessage(message);
 
 
 
 
 
 
97
  })
98
  );
99
 
@@ -114,37 +210,67 @@ export class ConversationManagerService implements OnDestroy {
114
  })
115
  );
116
 
117
- // Errors
118
  this.subscriptions.add(
119
  this.wsService.error$.subscribe(error => {
120
  console.error('WebSocket error:', error);
121
- this.addSystemMessage(`Hata: ${error}`);
 
 
 
 
 
 
 
 
 
122
  })
123
  );
124
  }
125
 
126
  private handleMessage(message: any): void {
127
- switch (message.type) {
128
- case 'transcription':
129
- if (message['is_final']) {
130
- this.addMessage('user', message['text']);
131
- this.transcriptionSubject.next('');
132
- }
133
- break;
134
-
135
- case 'assistant_response':
136
- this.addMessage('assistant', message['text']);
137
- break;
138
-
139
- case 'tts_audio':
140
- this.handleTTSAudio(message);
141
- break;
142
-
143
- case 'control':
144
- if (message['action'] === 'stop_playback') {
145
- this.stopAudioPlayback();
146
- }
147
- break;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  }
149
  }
150
 
@@ -158,28 +284,42 @@ export class ConversationManagerService implements OnDestroy {
158
  } else if (to === 'playing_audio') {
159
  // Start playing accumulated audio
160
  this.playQueuedAudio();
 
 
 
161
  }
162
  }
163
 
164
  private handleTTSAudio(message: any): void {
165
- // Accumulate audio chunks
166
- this.audioQueue.push(message['data']);
167
-
168
- if (message['is_last']) {
169
- // All chunks received, create audio blob
170
- const audioData = this.audioQueue.join('');
171
- const audioBlob = this.base64ToBlob(audioData, 'audio/mpeg');
172
- const audioUrl = URL.createObjectURL(audioBlob);
173
-
174
- // Update last message with audio URL
175
- const messages = this.messagesSubject.value;
176
- if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') {
177
- messages[messages.length - 1].audioUrl = audioUrl;
178
- this.messagesSubject.next([...messages]);
179
  }
180
 
181
- // Clear queue
182
- this.audioQueue = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  }
184
  }
185
 
@@ -192,27 +332,75 @@ export class ConversationManagerService implements OnDestroy {
192
  }
193
  }
194
 
195
- private playAudio(audioUrl: string): void {
196
- if (!this.audioPlayer) {
197
- this.audioPlayer = new Audio();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  }
199
-
200
- this.audioPlayer.src = audioUrl;
201
- this.audioPlayer.play().catch(error => {
202
- console.error('Audio playback error:', error);
203
- });
204
 
205
  this.audioPlayer.onended = () => {
206
  // Notify that audio playback ended
207
- this.wsService.sendControl('audio_ended');
 
 
 
 
 
 
 
 
 
 
 
208
  this.currentStateSubject.next('idle');
209
  };
210
  }
211
 
212
  private stopAudioPlayback(): void {
213
- if (this.audioPlayer) {
214
- this.audioPlayer.pause();
215
- this.audioPlayer.currentTime = 0;
 
 
 
 
 
 
 
 
 
 
 
 
216
  }
217
  }
218
 
@@ -220,17 +408,27 @@ export class ConversationManagerService implements OnDestroy {
220
  performBargeIn(): void {
221
  const currentState = this.currentStateSubject.value;
222
 
223
- if (currentState !== 'idle' && currentState !== 'listening') {
224
  console.log('🛑 Performing barge-in');
225
  this.isInterrupting = true;
226
 
227
  // Stop audio playback
228
  this.stopAudioPlayback();
229
 
 
 
 
230
  // Notify server
231
- this.wsService.sendControl('interrupt', {
232
- at_state: currentState
233
- });
 
 
 
 
 
 
 
234
 
235
  // Reset interruption flag after a delay
236
  setTimeout(() => {
@@ -239,40 +437,161 @@ export class ConversationManagerService implements OnDestroy {
239
  }
240
  }
241
 
242
- private addMessage(role: 'user' | 'assistant', text: string): void {
 
 
 
 
243
  const messages = this.messagesSubject.value;
244
  messages.push({
245
  role,
246
  text,
247
- timestamp: new Date()
 
248
  });
249
  this.messagesSubject.next([...messages]);
250
  }
251
 
252
  private addSystemMessage(text: string): void {
253
  console.log(`📢 System: ${text}`);
 
 
 
 
 
 
 
254
  }
255
 
256
  private base64ToBlob(base64: string, mimeType: string): Blob {
257
- const byteCharacters = atob(base64);
258
- const byteNumbers = new Array(byteCharacters.length);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
- for (let i = 0; i < byteCharacters.length; i++) {
261
- byteNumbers[i] = byteCharacters.charCodeAt(i);
262
  }
263
 
264
- const byteArray = new Uint8Array(byteNumbers);
265
- return new Blob([byteArray], { type: mimeType });
266
  }
267
 
268
  private cleanup(): void {
269
- this.subscriptions.unsubscribe();
270
- this.audioService.stopRecording();
271
- this.wsService.disconnect();
272
- this.stopAudioPlayback();
273
- this.audioQueue = [];
274
- this.currentStateSubject.next('idle');
275
- console.log('🧹 Conversation cleaned up');
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  }
277
 
278
  // Public methods for UI
@@ -286,5 +605,37 @@ export class ConversationManagerService implements OnDestroy {
286
 
287
  clearMessages(): void {
288
  this.messagesSubject.next([]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  }
290
  }
 
1
+ // conversation-manager.service.ts
2
+ // Path: /flare-ui/src/app/services/conversation-manager.service.ts
3
+
4
  import { Injectable, OnDestroy } from '@angular/core';
5
+ import { Subject, Subscription, BehaviorSubject, throwError } from 'rxjs';
6
+ import { catchError, retry } from 'rxjs/operators';
7
  import { WebSocketService } from './websocket.service';
8
  import { AudioStreamService } from './audio-stream.service';
9
 
 
13
  | 'processing_stt'
14
  | 'processing_llm'
15
  | 'processing_tts'
16
+ | 'playing_audio'
17
+ | 'error';
18
 
19
  export interface ConversationMessage {
20
+ role: 'user' | 'assistant' | 'system';
21
  text: string;
22
  timestamp: Date;
23
  audioUrl?: string;
24
+ error?: boolean;
25
+ }
26
+
27
+ export interface ConversationConfig {
28
+ language?: string;
29
+ stt_engine?: string;
30
+ tts_engine?: string;
31
+ enable_barge_in?: boolean;
32
+ max_silence_duration?: number;
33
+ }
34
+
35
+ export interface ConversationError {
36
+ type: 'websocket' | 'audio' | 'permission' | 'network' | 'unknown';
37
+ message: string;
38
+ details?: any;
39
+ timestamp: Date;
40
  }
41
 
42
  @Injectable({
 
46
  private subscriptions = new Subscription();
47
  private audioQueue: string[] = [];
48
  private isInterrupting = false;
49
+ private sessionId: string | null = null;
50
+ private conversationConfig: ConversationConfig = {
51
+ language: 'tr-TR',
52
+ stt_engine: 'google',
53
+ enable_barge_in: true
54
+ };
55
 
56
  // State management
57
  private currentStateSubject = new BehaviorSubject<ConversationState>('idle');
 
65
  private transcriptionSubject = new BehaviorSubject<string>('');
66
  public transcription$ = this.transcriptionSubject.asObservable();
67
 
68
+ // Error handling
69
+ private errorSubject = new Subject<ConversationError>();
70
+ public error$ = this.errorSubject.asObservable();
71
+
72
  // Audio player reference
73
  private audioPlayer: HTMLAudioElement | null = null;
74
+ private audioPlayerPromise: Promise<void> | null = null;
75
 
76
  constructor(
77
  private wsService: WebSocketService,
 
82
  this.cleanup();
83
  }
84
 
85
+ async startConversation(sessionId: string, config?: ConversationConfig): Promise<void> {
86
  try {
87
+ if (!sessionId) {
88
+ throw new Error('Session ID is required');
89
+ }
90
+
91
+ // Update configuration
92
+ if (config) {
93
+ this.conversationConfig = { ...this.conversationConfig, ...config };
94
+ }
95
+
96
+ this.sessionId = sessionId;
97
+
98
+ // Reset state
99
+ this.clearMessages();
100
+ this.currentStateSubject.next('idle');
101
+
102
+ // Connect WebSocket first
103
+ await this.wsService.connect(sessionId).catch(error => {
104
+ throw new Error(`WebSocket connection failed: ${error.message}`);
105
+ });
106
 
107
  // Start audio recording
108
+ await this.audioService.startRecording().catch(error => {
109
+ // Disconnect WebSocket if audio fails
110
+ this.wsService.disconnect();
111
+ throw new Error(`Audio recording failed: ${error.message}`);
112
+ });
113
 
114
  // Set up subscriptions
115
  this.setupSubscriptions();
116
 
117
+ // Send start signal with configuration
118
+ this.wsService.sendControl('start_session', this.conversationConfig);
 
 
 
 
119
 
120
+ console.log('✅ Conversation started with config:', this.conversationConfig);
121
 
122
+ } catch (error: any) {
123
  console.error('Failed to start conversation:', error);
124
+
125
+ const conversationError: ConversationError = {
126
+ type: this.determineErrorType(error),
127
+ message: error.message || 'Failed to start conversation',
128
+ details: error,
129
+ timestamp: new Date()
130
+ };
131
+
132
+ this.errorSubject.next(conversationError);
133
+ this.currentStateSubject.next('error');
134
+ this.cleanup();
135
+
136
  throw error;
137
  }
138
  }
139
 
140
  stopConversation(): void {
141
+ try {
142
+ // Send stop signal if connected
143
+ if (this.wsService.isConnected()) {
144
+ this.wsService.sendControl('stop_session');
145
+ }
146
+
147
+ this.cleanup();
148
+ this.addSystemMessage('Conversation ended');
149
+
150
+ } catch (error) {
151
+ console.error('Error stopping conversation:', error);
152
+ this.cleanup();
153
+ }
154
  }
155
 
156
  private setupSubscriptions(): void {
157
  // Audio chunks from microphone
158
  this.subscriptions.add(
159
+ this.audioService.audioChunk$.subscribe({
160
+ next: (chunk) => {
161
+ if (!this.isInterrupting && this.wsService.isConnected()) {
162
+ try {
163
+ this.wsService.sendAudioChunk(chunk.data);
164
+ } catch (error) {
165
+ console.error('Failed to send audio chunk:', error);
166
+ }
167
+ }
168
+ },
169
+ error: (error) => {
170
+ console.error('Audio stream error:', error);
171
+ this.handleAudioError(error);
172
  }
173
  })
174
  );
175
 
176
+ // Audio stream errors
177
+ this.subscriptions.add(
178
+ this.audioService.error$.subscribe(error => {
179
+ this.handleAudioError(error);
180
+ })
181
+ );
182
+
183
  // WebSocket messages
184
  this.subscriptions.add(
185
+ this.wsService.message$.subscribe({
186
+ next: (message) => {
187
+ this.handleMessage(message);
188
+ },
189
+ error: (error) => {
190
+ console.error('WebSocket message error:', error);
191
+ this.handleWebSocketError(error);
192
+ }
193
  })
194
  );
195
 
 
210
  })
211
  );
212
 
213
+ // WebSocket errors
214
  this.subscriptions.add(
215
  this.wsService.error$.subscribe(error => {
216
  console.error('WebSocket error:', error);
217
+ this.handleWebSocketError({ message: error });
218
+ })
219
+ );
220
+
221
+ // WebSocket connection state
222
+ this.subscriptions.add(
223
+ this.wsService.connection$.subscribe(connected => {
224
+ if (!connected && this.currentStateSubject.value !== 'idle') {
225
+ this.addSystemMessage('Connection lost. Attempting to reconnect...');
226
+ }
227
  })
228
  );
229
  }
230
 
231
  private handleMessage(message: any): void {
232
+ try {
233
+ switch (message.type) {
234
+ case 'transcription':
235
+ if (message['is_final']) {
236
+ this.addMessage('user', message['text']);
237
+ this.transcriptionSubject.next('');
238
+ }
239
+ break;
240
+
241
+ case 'assistant_response':
242
+ this.addMessage('assistant', message['text']);
243
+ break;
244
+
245
+ case 'tts_audio':
246
+ this.handleTTSAudio(message);
247
+ break;
248
+
249
+ case 'control':
250
+ if (message['action'] === 'stop_playback') {
251
+ this.stopAudioPlayback();
252
+ }
253
+ break;
254
+
255
+ case 'error':
256
+ this.handleServerError(message);
257
+ break;
258
+
259
+ case 'session_config':
260
+ // Update configuration from server
261
+ if (message['config']) {
262
+ this.conversationConfig = { ...this.conversationConfig, ...message['config'] };
263
+ }
264
+ break;
265
+ }
266
+ } catch (error) {
267
+ console.error('Error handling message:', error);
268
+ this.errorSubject.next({
269
+ type: 'unknown',
270
+ message: 'Failed to process message',
271
+ details: error,
272
+ timestamp: new Date()
273
+ });
274
  }
275
  }
276
 
 
284
  } else if (to === 'playing_audio') {
285
  // Start playing accumulated audio
286
  this.playQueuedAudio();
287
+ } else if (to === 'error') {
288
+ // Handle error state
289
+ this.addSystemMessage('An error occurred in the conversation flow');
290
  }
291
  }
292
 
293
  private handleTTSAudio(message: any): void {
294
+ try {
295
+ // Validate audio data
296
+ if (!message['data']) {
297
+ console.warn('TTS audio message missing data');
298
+ return;
 
 
 
 
 
 
 
 
 
299
  }
300
 
301
+ // Accumulate audio chunks
302
+ this.audioQueue.push(message['data']);
303
+
304
+ if (message['is_last']) {
305
+ // All chunks received, create audio blob
306
+ const audioData = this.audioQueue.join('');
307
+ const audioBlob = this.base64ToBlob(audioData, message['mime_type'] || 'audio/mpeg');
308
+ const audioUrl = URL.createObjectURL(audioBlob);
309
+
310
+ // Update last message with audio URL
311
+ const messages = this.messagesSubject.value;
312
+ if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') {
313
+ messages[messages.length - 1].audioUrl = audioUrl;
314
+ this.messagesSubject.next([...messages]);
315
+ }
316
+
317
+ // Clear queue
318
+ this.audioQueue = [];
319
+ }
320
+ } catch (error) {
321
+ console.error('Error handling TTS audio:', error);
322
+ this.audioQueue = []; // Clear queue on error
323
  }
324
  }
325
 
 
332
  }
333
  }
334
 
335
+ private async playAudio(audioUrl: string): Promise<void> {
336
+ try {
337
+ if (!this.audioPlayer) {
338
+ this.audioPlayer = new Audio();
339
+ this.setupAudioPlayerHandlers();
340
+ }
341
+
342
+ this.audioPlayer.src = audioUrl;
343
+
344
+ // Store the play promise to handle interruptions properly
345
+ this.audioPlayerPromise = this.audioPlayer.play();
346
+
347
+ await this.audioPlayerPromise;
348
+
349
+ } catch (error: any) {
350
+ // Check if error is due to interruption
351
+ if (error.name === 'AbortError') {
352
+ console.log('Audio playback interrupted');
353
+ } else {
354
+ console.error('Audio playback error:', error);
355
+ this.errorSubject.next({
356
+ type: 'audio',
357
+ message: 'Failed to play audio response',
358
+ details: error,
359
+ timestamp: new Date()
360
+ });
361
+ }
362
+ } finally {
363
+ this.audioPlayerPromise = null;
364
  }
365
+ }
366
+
367
+ private setupAudioPlayerHandlers(): void {
368
+ if (!this.audioPlayer) return;
 
369
 
370
  this.audioPlayer.onended = () => {
371
  // Notify that audio playback ended
372
+ if (this.wsService.isConnected()) {
373
+ try {
374
+ this.wsService.sendControl('audio_ended');
375
+ } catch (error) {
376
+ console.error('Failed to send audio_ended signal:', error);
377
+ }
378
+ }
379
+ this.currentStateSubject.next('idle');
380
+ };
381
+
382
+ this.audioPlayer.onerror = (error) => {
383
+ console.error('Audio player error:', error);
384
  this.currentStateSubject.next('idle');
385
  };
386
  }
387
 
388
  private stopAudioPlayback(): void {
389
+ try {
390
+ if (this.audioPlayer) {
391
+ this.audioPlayer.pause();
392
+ this.audioPlayer.currentTime = 0;
393
+
394
+ // Cancel any pending play promise
395
+ if (this.audioPlayerPromise) {
396
+ this.audioPlayerPromise.catch(() => {
397
+ // Ignore abort errors
398
+ });
399
+ this.audioPlayerPromise = null;
400
+ }
401
+ }
402
+ } catch (error) {
403
+ console.error('Error stopping audio playback:', error);
404
  }
405
  }
406
 
 
408
  performBargeIn(): void {
409
  const currentState = this.currentStateSubject.value;
410
 
411
+ if (currentState !== 'idle' && currentState !== 'listening' && currentState !== 'error') {
412
  console.log('🛑 Performing barge-in');
413
  this.isInterrupting = true;
414
 
415
  // Stop audio playback
416
  this.stopAudioPlayback();
417
 
418
+ // Clear audio queue
419
+ this.audioQueue = [];
420
+
421
  // Notify server
422
+ if (this.wsService.isConnected()) {
423
+ try {
424
+ this.wsService.sendControl('interrupt', {
425
+ at_state: currentState,
426
+ timestamp: Date.now()
427
+ });
428
+ } catch (error) {
429
+ console.error('Failed to send interrupt signal:', error);
430
+ }
431
+ }
432
 
433
  // Reset interruption flag after a delay
434
  setTimeout(() => {
 
437
  }
438
  }
439
 
440
+ private addMessage(role: 'user' | 'assistant', text: string, error: boolean = false): void {
441
+ if (!text || text.trim().length === 0) {
442
+ return;
443
+ }
444
+
445
  const messages = this.messagesSubject.value;
446
  messages.push({
447
  role,
448
  text,
449
+ timestamp: new Date(),
450
+ error
451
  });
452
  this.messagesSubject.next([...messages]);
453
  }
454
 
455
  private addSystemMessage(text: string): void {
456
  console.log(`📢 System: ${text}`);
457
+ const messages = this.messagesSubject.value;
458
+ messages.push({
459
+ role: 'system',
460
+ text,
461
+ timestamp: new Date()
462
+ });
463
+ this.messagesSubject.next([...messages]);
464
  }
465
 
466
  private base64ToBlob(base64: string, mimeType: string): Blob {
467
+ try {
468
+ const byteCharacters = atob(base64);
469
+ const byteNumbers = new Array(byteCharacters.length);
470
+
471
+ for (let i = 0; i < byteCharacters.length; i++) {
472
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
473
+ }
474
+
475
+ const byteArray = new Uint8Array(byteNumbers);
476
+ return new Blob([byteArray], { type: mimeType });
477
+ } catch (error) {
478
+ console.error('Error converting base64 to blob:', error);
479
+ throw new Error('Failed to convert audio data');
480
+ }
481
+ }
482
+
483
+ private handleAudioError(error: any): void {
484
+ const conversationError: ConversationError = {
485
+ type: error.type || 'audio',
486
+ message: error.message || 'Audio error occurred',
487
+ details: error,
488
+ timestamp: new Date()
489
+ };
490
+
491
+ this.errorSubject.next(conversationError);
492
+
493
+ // Add user-friendly message
494
+ if (error.type === 'permission') {
495
+ this.addSystemMessage('Microphone permission denied. Please allow microphone access.');
496
+ } else if (error.type === 'device') {
497
+ this.addSystemMessage('Microphone not found or not accessible.');
498
+ } else {
499
+ this.addSystemMessage('Audio error occurred. Please check your microphone.');
500
+ }
501
+
502
+ // Update state
503
+ this.currentStateSubject.next('error');
504
+ }
505
+
506
+ private handleWebSocketError(error: any): void {
507
+ const conversationError: ConversationError = {
508
+ type: 'websocket',
509
+ message: error.message || 'WebSocket error occurred',
510
+ details: error,
511
+ timestamp: new Date()
512
+ };
513
+
514
+ this.errorSubject.next(conversationError);
515
+ this.addSystemMessage('Connection error. Please check your internet connection.');
516
+
517
+ // Don't set error state for temporary connection issues
518
+ if (this.wsService.getReconnectionInfo().isReconnecting) {
519
+ this.addSystemMessage('Attempting to reconnect...');
520
+ } else {
521
+ this.currentStateSubject.next('error');
522
+ }
523
+ }
524
+
525
+ private handleServerError(message: any): void {
526
+ const errorType = message['error_type'] || 'unknown';
527
+ const errorMessage = message['message'] || 'Server error occurred';
528
+
529
+ const conversationError: ConversationError = {
530
+ type: errorType === 'race_condition' ? 'network' : 'unknown',
531
+ message: errorMessage,
532
+ details: message,
533
+ timestamp: new Date()
534
+ };
535
+
536
+ this.errorSubject.next(conversationError);
537
+
538
+ // Add user-friendly message based on error type
539
+ if (errorType === 'race_condition') {
540
+ this.addSystemMessage('Session conflict detected. Please restart the conversation.');
541
+ } else if (errorType === 'stt_error') {
542
+ this.addSystemMessage('Speech recognition error. Please try speaking again.');
543
+ } else if (errorType === 'tts_error') {
544
+ this.addSystemMessage('Text-to-speech error. Response will be shown as text only.');
545
+ } else {
546
+ this.addSystemMessage(`Error: ${errorMessage}`);
547
+ }
548
+ }
549
+
550
+ private determineErrorType(error: any): ConversationError['type'] {
551
+ if (error.type) {
552
+ return error.type;
553
+ }
554
+
555
+ if (error.message?.includes('WebSocket') || error.message?.includes('connection')) {
556
+ return 'websocket';
557
+ }
558
+
559
+ if (error.message?.includes('microphone') || error.message?.includes('audio')) {
560
+ return 'audio';
561
+ }
562
+
563
+ if (error.message?.includes('permission')) {
564
+ return 'permission';
565
+ }
566
 
567
+ if (error.message?.includes('network') || error.status === 0) {
568
+ return 'network';
569
  }
570
 
571
+ return 'unknown';
 
572
  }
573
 
574
  private cleanup(): void {
575
+ try {
576
+ this.subscriptions.unsubscribe();
577
+ this.subscriptions = new Subscription();
578
+
579
+ this.audioService.stopRecording();
580
+ this.wsService.disconnect();
581
+ this.stopAudioPlayback();
582
+
583
+ if (this.audioPlayer) {
584
+ this.audioPlayer = null;
585
+ }
586
+
587
+ this.audioQueue = [];
588
+ this.isInterrupting = false;
589
+ this.currentStateSubject.next('idle');
590
+
591
+ console.log('🧹 Conversation cleaned up');
592
+ } catch (error) {
593
+ console.error('Error during cleanup:', error);
594
+ }
595
  }
596
 
597
  // Public methods for UI
 
605
 
606
  clearMessages(): void {
607
  this.messagesSubject.next([]);
608
+ this.transcriptionSubject.next('');
609
+ }
610
+
611
+ updateConfig(config: Partial<ConversationConfig>): void {
612
+ this.conversationConfig = { ...this.conversationConfig, ...config };
613
+
614
+ // Send config update if connected
615
+ if (this.wsService.isConnected()) {
616
+ try {
617
+ this.wsService.sendControl('update_config', config);
618
+ } catch (error) {
619
+ console.error('Failed to update config:', error);
620
+ }
621
+ }
622
+ }
623
+
624
+ getConfig(): ConversationConfig {
625
+ return { ...this.conversationConfig };
626
+ }
627
+
628
+ isConnected(): boolean {
629
+ return this.wsService.isConnected();
630
+ }
631
+
632
+ // Retry connection
633
+ async retryConnection(): Promise<void> {
634
+ if (!this.sessionId) {
635
+ throw new Error('No session ID available for retry');
636
+ }
637
+
638
+ this.currentStateSubject.next('idle');
639
+ await this.startConversation(this.sessionId, this.conversationConfig);
640
  }
641
  }