@memori.ai/memori-react 8.0.2 → 8.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/components/Chat/Chat.d.ts +0 -2
  3. package/dist/components/Chat/Chat.js +2 -2
  4. package/dist/components/Chat/Chat.js.map +1 -1
  5. package/dist/components/ChatHistoryDrawer/ChatHistory.css +32 -0
  6. package/dist/components/ChatHistoryDrawer/ChatHistory.js +104 -31
  7. package/dist/components/ChatHistoryDrawer/ChatHistory.js.map +1 -1
  8. package/dist/components/ChatInputs/ChatInputs.d.ts +0 -2
  9. package/dist/components/ChatInputs/ChatInputs.js +3 -4
  10. package/dist/components/ChatInputs/ChatInputs.js.map +1 -1
  11. package/dist/components/MemoriWidget/MemoriWidget.js +103 -329
  12. package/dist/components/MemoriWidget/MemoriWidget.js.map +1 -1
  13. package/dist/helpers/stt/useSTT.d.ts +40 -0
  14. package/dist/helpers/stt/useSTT.js +362 -0
  15. package/dist/helpers/stt/useSTT.js.map +1 -0
  16. package/dist/locales/de.json +11 -0
  17. package/dist/locales/en.json +11 -0
  18. package/dist/locales/es.json +11 -0
  19. package/dist/locales/fr.json +11 -0
  20. package/dist/locales/it.json +11 -0
  21. package/esm/components/Chat/Chat.d.ts +0 -2
  22. package/esm/components/Chat/Chat.js +2 -2
  23. package/esm/components/Chat/Chat.js.map +1 -1
  24. package/esm/components/ChatHistoryDrawer/ChatHistory.css +32 -0
  25. package/esm/components/ChatHistoryDrawer/ChatHistory.js +104 -31
  26. package/esm/components/ChatHistoryDrawer/ChatHistory.js.map +1 -1
  27. package/esm/components/ChatInputs/ChatInputs.d.ts +0 -2
  28. package/esm/components/ChatInputs/ChatInputs.js +3 -4
  29. package/esm/components/ChatInputs/ChatInputs.js.map +1 -1
  30. package/esm/components/MemoriWidget/MemoriWidget.js +103 -329
  31. package/esm/components/MemoriWidget/MemoriWidget.js.map +1 -1
  32. package/esm/helpers/stt/useSTT.d.ts +40 -0
  33. package/esm/helpers/stt/useSTT.js +358 -0
  34. package/esm/helpers/stt/useSTT.js.map +1 -0
  35. package/esm/locales/de.json +11 -0
  36. package/esm/locales/en.json +11 -0
  37. package/esm/locales/es.json +11 -0
  38. package/esm/locales/fr.json +11 -0
  39. package/esm/locales/it.json +11 -0
  40. package/package.json +2 -3
  41. package/src/components/Chat/Chat.test.tsx +0 -9
  42. package/src/components/Chat/Chat.tsx +0 -6
  43. package/src/components/ChatHistoryDrawer/ChatHistory.css +32 -0
  44. package/src/components/ChatHistoryDrawer/ChatHistory.tsx +114 -57
  45. package/src/components/ChatInputs/ChatInputs.test.tsx +0 -6
  46. package/src/components/ChatInputs/ChatInputs.tsx +2 -7
  47. package/src/components/MemoriWidget/MemoriWidget.tsx +152 -476
  48. package/src/helpers/stt/useSTT.ts +551 -0
  49. package/src/locales/de.json +11 -0
  50. package/src/locales/en.json +11 -0
  51. package/src/locales/es.json +11 -0
  52. package/src/locales/fr.json +11 -0
  53. package/src/locales/it.json +11 -0
@@ -0,0 +1,551 @@
1
+ // hooks/useSTT.ts
2
+ // Audio format compatibility:
3
+ // - MediaRecorder supports: webm, mp4, ogg formats
4
+ // - Azure STT supported formats:
5
+ // * webm-16khz-16bit-mono-opus (recommended for this config)
6
+ // * webm-24khz-16bit-24kbps-mono-opus
7
+ // * webm-24khz-16bit-mono-opus
8
+ // - OpenAI: Supports multiple formats including webm, mp4, ogg
9
+ import { useState, useCallback, useRef, useEffect } from 'react';
10
+ import { getLocalConfig } from '../configuration';
11
+
12
+ /**
13
+ * Configuration for STT
14
+ */
15
+ export interface STTConfig {
16
+ provider: 'azure' | 'openai';
17
+ language?: string;
18
+ model?: string;
19
+ region?: string; // required for Azure
20
+ tenant?: string; // Tenant identifier for multi-tenant applications
21
+ }
22
+
23
+ /**
24
+ * Result from STT transcription
25
+ */
26
+ export interface STTResult {
27
+ text: string;
28
+ confidence?: number;
29
+ language?: string;
30
+ duration?: number;
31
+ }
32
+
33
+ /**
34
+ * Options for the useSTT hook
35
+ */
36
+ export interface UseSTTOptions {
37
+ apiUrl?: string;
38
+ onTranscriptionComplete?: (result: STTResult) => void;
39
+ onError?: (error: Error) => void;
40
+ continuousRecording?: boolean;
41
+ autoStart?: boolean;
42
+ processSpeechAndSendMessage?: (text: string) => void;
43
+ silenceTimeout?: number; // Timeout in milliseconds for silence detection
44
+ }
45
+
46
+ /**
47
+ * Recording states
48
+ */
49
+ export type RecordingState = 'idle' | 'recording' | 'processing' | 'error';
50
+
51
+ /**
52
+ * Unified hook for handling Speech-to-Text functionality
53
+ */
54
+ export function useSTT(
55
+ config: STTConfig,
56
+ processSpeechAndSendMessage: (text: string) => void,
57
+ options: UseSTTOptions = {},
58
+ defaultEnableAudio: boolean = true
59
+ ) {
60
+ // Local state
61
+ const [recordingState, setRecordingState] = useState<RecordingState>('idle');
62
+ const [microphoneMuted, setMicrophoneMuted] = useState(
63
+ getLocalConfig('muteMicrophone', !defaultEnableAudio)
64
+ );
65
+ const [hasUserActivatedRecord, setHasUserActivatedRecord] = useState(false);
66
+ const [error, setError] = useState<Error | null>(null);
67
+ const [lastTranscription, setLastTranscription] = useState<STTResult | null>(
68
+ null
69
+ );
70
+ const [isListening, setIsListening] = useState(false);
71
+
72
+ // References
73
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
74
+ const audioStreamRef = useRef<MediaStream | null>(null);
75
+ const chunksRef = useRef<Blob[]>([]);
76
+ const isRecordingRef = useRef<boolean>(false);
77
+ const isMountedRef = useRef<boolean>(true);
78
+ const silenceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
79
+ const audioContextRef = useRef<AudioContext | null>(null);
80
+ const analyserRef = useRef<AnalyserNode | null>(null);
81
+ const dataArrayRef = useRef<Uint8Array | null>(null);
82
+ const apiUrl = options.apiUrl || '/api/stt';
83
+ const silenceTimeout = options.silenceTimeout || 2; // Default 300ms
84
+
85
+ // Replace the initializeRecording function in your useSTT.ts with this:
86
+
87
+ const initializeRecording = useCallback(async (): Promise<boolean> => {
88
+ try {
89
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
90
+ throw new Error('Media recording is not supported in this browser');
91
+ }
92
+
93
+ const stream = await navigator.mediaDevices.getUserMedia({
94
+ audio: {
95
+ echoCancellation: true,
96
+ noiseSuppression: true,
97
+ autoGainControl: true,
98
+ sampleRate: 16000, // Optimal for speech recognition
99
+ },
100
+ });
101
+
102
+ audioStreamRef.current = stream;
103
+
104
+ // Initialize audio context for silence detection if continuous recording is enabled
105
+ if (options.continuousRecording) {
106
+ try {
107
+ audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
108
+ analyserRef.current = audioContextRef.current.createAnalyser();
109
+ analyserRef.current.fftSize = 256;
110
+ const bufferLength = analyserRef.current.frequencyBinCount;
111
+ dataArrayRef.current = new Uint8Array(bufferLength);
112
+
113
+ const source = audioContextRef.current.createMediaStreamSource(stream);
114
+ source.connect(analyserRef.current);
115
+ } catch (err) {
116
+ // Silence detection initialization failed but we can continue
117
+ }
118
+ }
119
+
120
+ // Format selection based on provider and browser support
121
+ let mimeType = '';
122
+
123
+ if (config.provider === 'azure') {
124
+ if (MediaRecorder.isTypeSupported('audio/mp4')) {
125
+ mimeType = 'audio/mp4';
126
+ } else if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) {
127
+ mimeType = 'audio/ogg;codecs=opus';
128
+ } else if (MediaRecorder.isTypeSupported('audio/ogg')) {
129
+ mimeType = 'audio/ogg';
130
+ } else if (MediaRecorder.isTypeSupported('audio/webm')) {
131
+ mimeType = 'audio/webm';
132
+ }
133
+ } else {
134
+ if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
135
+ mimeType = 'audio/webm;codecs=opus';
136
+ } else if (MediaRecorder.isTypeSupported('audio/webm')) {
137
+ mimeType = 'audio/webm';
138
+ } else if (MediaRecorder.isTypeSupported('audio/mp4')) {
139
+ mimeType = 'audio/mp4';
140
+ } else if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) {
141
+ mimeType = 'audio/ogg;codecs=opus';
142
+ }
143
+ }
144
+
145
+ const mediaRecorder = new MediaRecorder(
146
+ stream,
147
+ mimeType ? { mimeType } : {}
148
+ );
149
+
150
+ mediaRecorder.ondataavailable = (event: BlobEvent) => {
151
+ if (event.data.size > 0) {
152
+ chunksRef.current.push(event.data);
153
+ }
154
+ };
155
+
156
+ mediaRecorder.onstop = async () => {
157
+ if (!isRecordingRef.current || !isMountedRef.current) {
158
+ return;
159
+ }
160
+
161
+ setRecordingState('processing');
162
+
163
+ try {
164
+ if (chunksRef.current.length === 0) {
165
+ throw new Error('No audio data recorded');
166
+ }
167
+
168
+ const audioBlob = new Blob(chunksRef.current, {
169
+ type: mediaRecorder.mimeType,
170
+ });
171
+
172
+ if (audioBlob.size === 0) {
173
+ throw new Error('Recorded audio is empty');
174
+ }
175
+
176
+ const result = await transcribeAudio(audioBlob);
177
+
178
+ if (processSpeechAndSendMessage) {
179
+ processSpeechAndSendMessage(result.text);
180
+ }
181
+
182
+ setLastTranscription(result);
183
+
184
+ if (options.onTranscriptionComplete) {
185
+ options.onTranscriptionComplete(result);
186
+ }
187
+
188
+ setRecordingState('idle');
189
+ } catch (err) {
190
+ const errorMsg = err instanceof Error ? err : new Error(String(err));
191
+ setError(errorMsg);
192
+ setRecordingState('error');
193
+
194
+ if (options.onError) {
195
+ options.onError(errorMsg);
196
+ }
197
+ } finally {
198
+ chunksRef.current = [];
199
+ isRecordingRef.current = false;
200
+ }
201
+ };
202
+
203
+ mediaRecorder.onerror = () => {
204
+ const errorMsg = new Error('Recording failed');
205
+ setError(errorMsg);
206
+ setRecordingState('error');
207
+ isRecordingRef.current = false;
208
+
209
+ if (options.onError) {
210
+ options.onError(errorMsg);
211
+ }
212
+ };
213
+
214
+ mediaRecorderRef.current = mediaRecorder;
215
+ return true;
216
+ } catch (err) {
217
+ const errorMsg =
218
+ err instanceof Error ? err : new Error('Failed to access microphone');
219
+ setError(errorMsg);
220
+ setRecordingState('error');
221
+
222
+ if (options.onError) {
223
+ options.onError(errorMsg);
224
+ }
225
+
226
+ return false;
227
+ }
228
+ }, [config.provider, options, silenceTimeout]); // Add silenceTimeout to dependencies
229
+
230
+ /**
231
+ * Detect if there's audio activity (not silence)
232
+ */
233
+ const detectAudioActivity = useCallback((): boolean => {
234
+ if (!analyserRef.current || !dataArrayRef.current) {
235
+ return false;
236
+ }
237
+
238
+ analyserRef.current.getByteFrequencyData(dataArrayRef.current);
239
+
240
+ // Calculate average volume level
241
+ const average = dataArrayRef.current.reduce((sum, value) => sum + value, 0) / dataArrayRef.current.length;
242
+
243
+ // Consider audio active if average volume is above threshold
244
+ // Adjust this threshold based on your needs
245
+ const threshold = 10; // Adjust this value as needed
246
+ return average > threshold;
247
+ }, []);
248
+
249
+ /**
250
+ * Start silence detection monitoring
251
+ */
252
+ const startSilenceDetection = useCallback(() => {
253
+ if (!options.continuousRecording || !analyserRef.current) {
254
+ return;
255
+ }
256
+
257
+ const checkAudioActivity = () => {
258
+ if (!isRecordingRef.current || !isMountedRef.current) {
259
+ return;
260
+ }
261
+
262
+ const hasActivity = detectAudioActivity();
263
+
264
+ if (hasActivity) {
265
+ // Reset silence timeout when audio activity is detected
266
+ if (silenceTimeoutRef.current) {
267
+ clearTimeout(silenceTimeoutRef.current);
268
+ }
269
+
270
+ // Set new timeout for when user stops speaking
271
+ silenceTimeoutRef.current = setTimeout(() => {
272
+ if (isRecordingRef.current && isMountedRef.current) {
273
+ stopRecording();
274
+ }
275
+ }, silenceTimeout * 1000);
276
+ }
277
+ };
278
+
279
+ // Check audio activity every 50ms for responsive detection
280
+ const intervalId = setInterval(checkAudioActivity, 50);
281
+
282
+ // Store interval ID for cleanup
283
+ (window as any).memoriSilenceDetectionInterval = intervalId;
284
+ }, [options.continuousRecording, detectAudioActivity, silenceTimeout]);
285
+
286
+ /**
287
+ * Stop silence detection monitoring
288
+ */
289
+ const stopSilenceDetection = useCallback(() => {
290
+ if (silenceTimeoutRef.current) {
291
+ clearTimeout(silenceTimeoutRef.current);
292
+ silenceTimeoutRef.current = null;
293
+ }
294
+
295
+ if ((window as any).memoriSilenceDetectionInterval) {
296
+ clearInterval((window as any).memoriSilenceDetectionInterval);
297
+ (window as any).memoriSilenceDetectionInterval = null;
298
+ }
299
+ }, []);
300
+
301
+ /**
302
+ * Transcribe audio blob using the API
303
+ */
304
+ const transcribeAudio = useCallback(
305
+ async (audioBlob: Blob): Promise<STTResult> => {
306
+ const formData = new FormData();
307
+ let fileExtension = 'webm'; // default fallback
308
+
309
+ if (mediaRecorderRef.current?.mimeType) {
310
+ if (mediaRecorderRef.current.mimeType.includes('webm')) {
311
+ fileExtension = 'webm';
312
+ } else if (mediaRecorderRef.current.mimeType.includes('mp4')) {
313
+ fileExtension = 'mp4';
314
+ } else if (mediaRecorderRef.current.mimeType.includes('ogg')) {
315
+ fileExtension = 'ogg';
316
+ }
317
+ }
318
+
319
+ formData.append('audio', audioBlob, `recording.${fileExtension}`);
320
+ formData.append('provider', config.provider);
321
+ formData.append('tenant', config.tenant || 'www.aisuru.com');
322
+
323
+ if (config.language) {
324
+ formData.append('language', config.language);
325
+ }
326
+
327
+ if (config.model) {
328
+ formData.append('model', config.model);
329
+ }
330
+
331
+ if (config.region) {
332
+ formData.append('region', config.region);
333
+ }
334
+
335
+ const response = await fetch(apiUrl, {
336
+ method: 'POST',
337
+ body: formData,
338
+ });
339
+
340
+ if (!response.ok) {
341
+ const errorData = await response.json().catch(() => ({}));
342
+ throw new Error(errorData.error || `API error: ${response.status}`);
343
+ }
344
+
345
+ const data = await response.json();
346
+
347
+ if (!data.success || !data.result) {
348
+ throw new Error('Invalid response from transcription service');
349
+ }
350
+
351
+ return data.result;
352
+ },
353
+ [config, apiUrl]
354
+ );
355
+
356
+ /**
357
+ * Start recording audio
358
+ */
359
+ const startRecording = useCallback(async (): Promise<void> => {
360
+ if (
361
+ !isMountedRef.current ||
362
+ microphoneMuted ||
363
+ recordingState === 'recording'
364
+ ) {
365
+ return;
366
+ }
367
+
368
+ if (!hasUserActivatedRecord) {
369
+ setHasUserActivatedRecord(true);
370
+ }
371
+
372
+ try {
373
+ setError(null);
374
+ setRecordingState('recording');
375
+
376
+ // Initialize recording if needed
377
+ if (!mediaRecorderRef.current) {
378
+ const initialized = await initializeRecording();
379
+ if (!initialized) {
380
+ return;
381
+ }
382
+ }
383
+
384
+ // Reset chunks and start recording
385
+ chunksRef.current = [];
386
+ isRecordingRef.current = true;
387
+
388
+ if (
389
+ mediaRecorderRef.current &&
390
+ mediaRecorderRef.current.state === 'inactive'
391
+ ) {
392
+ mediaRecorderRef.current.start(100); // Collect data every 100ms
393
+ setIsListening(true);
394
+
395
+ // Start silence detection if continuous recording is enabled
396
+ if (options.continuousRecording) {
397
+ startSilenceDetection();
398
+ }
399
+ }
400
+ } catch (err) {
401
+ const errorMsg =
402
+ err instanceof Error ? err : new Error('Failed to start recording');
403
+ setError(errorMsg);
404
+ setRecordingState('error');
405
+ isRecordingRef.current = false;
406
+
407
+ if (options.onError) {
408
+ options.onError(errorMsg);
409
+ }
410
+ }
411
+ }, [
412
+ microphoneMuted,
413
+ recordingState,
414
+ hasUserActivatedRecord,
415
+ initializeRecording,
416
+ options,
417
+ startSilenceDetection,
418
+ ]);
419
+
420
+ /**
421
+ * Stop recording audio
422
+ */
423
+ const stopRecording = useCallback((): void => {
424
+ if (!isRecordingRef.current) {
425
+ return;
426
+ }
427
+
428
+ try {
429
+ setIsListening(false);
430
+
431
+ // Stop silence detection
432
+ stopSilenceDetection();
433
+
434
+ if (
435
+ mediaRecorderRef.current &&
436
+ mediaRecorderRef.current.state === 'recording'
437
+ ) {
438
+ mediaRecorderRef.current.stop();
439
+ }
440
+ } catch (err) {
441
+ const errorMsg =
442
+ err instanceof Error ? err : new Error('Failed to stop recording');
443
+ setError(errorMsg);
444
+ setRecordingState('error');
445
+ isRecordingRef.current = false;
446
+
447
+ if (options.onError) {
448
+ options.onError(errorMsg);
449
+ }
450
+ }
451
+ }, [recordingState, options, stopSilenceDetection]);
452
+
453
+ /**
454
+ * Toggle recording state
455
+ */
456
+ const toggleRecording = useCallback(async (): Promise<void> => {
457
+ if (recordingState === 'recording') {
458
+ stopRecording();
459
+ } else if (recordingState === 'idle') {
460
+ await startRecording();
461
+ }
462
+ }, [recordingState, startRecording, stopRecording]);
463
+
464
+ /**
465
+ * Toggle microphone mute state
466
+ */
467
+ const toggleMute = useCallback(
468
+ (mute?: boolean): void => {
469
+ const newMuteState = mute !== undefined ? mute : !microphoneMuted;
470
+ setMicrophoneMuted(newMuteState);
471
+
472
+ if (newMuteState && recordingState === 'recording') {
473
+ stopRecording();
474
+ }
475
+ },
476
+ [microphoneMuted, recordingState, stopRecording]
477
+ );
478
+
479
+ /**
480
+ * Clean up resources
481
+ */
482
+ const cleanup = useCallback((): void => {
483
+ isRecordingRef.current = false;
484
+
485
+ if (mediaRecorderRef.current) {
486
+ if (mediaRecorderRef.current.state === 'recording') {
487
+ mediaRecorderRef.current.stop();
488
+ }
489
+ mediaRecorderRef.current = null;
490
+ }
491
+
492
+ if (audioStreamRef.current) {
493
+ audioStreamRef.current.getTracks().forEach(track => track.stop());
494
+ audioStreamRef.current = null;
495
+ }
496
+
497
+ // Clean up audio context
498
+ if (audioContextRef.current) {
499
+ audioContextRef.current.close();
500
+ audioContextRef.current = null;
501
+ }
502
+
503
+ // Stop silence detection
504
+ stopSilenceDetection();
505
+
506
+ chunksRef.current = [];
507
+ setIsListening(false);
508
+ setRecordingState('idle');
509
+ }, [stopSilenceDetection]);
510
+
511
+ /**
512
+ * Cleanup on unmount
513
+ */
514
+ useEffect(() => {
515
+ return () => {
516
+ isMountedRef.current = false;
517
+ cleanup();
518
+ };
519
+ }, [cleanup]);
520
+
521
+ /**
522
+ * Update global variables
523
+ */
524
+ useEffect(() => {
525
+ if (typeof window !== 'undefined') {
526
+ (window as any).memoriListening = isListening;
527
+ }
528
+ }, [isListening]);
529
+
530
+ return {
531
+ // State
532
+ recordingState,
533
+ microphoneMuted,
534
+ hasUserActivatedRecord,
535
+ error,
536
+ lastTranscription,
537
+ isListening,
538
+
539
+ // Actions
540
+ startRecording,
541
+ stopRecording,
542
+ toggleRecording,
543
+ toggleMute,
544
+ transcribeAudio,
545
+ setHasUserActivatedRecord,
546
+ setError,
547
+
548
+ // Utils
549
+ cleanup,
550
+ };
551
+ }
@@ -270,6 +270,17 @@
270
270
  "editAccount": "Konto bearbeiten",
271
271
  "save": "Speichern"
272
272
  },
273
+ "chatLogs": {
274
+ "anyMessage": "Jede Nachricht",
275
+ "atLeast": "Mindestens {{count}} Nachrichten",
276
+ "atLeast2": "Mindestens 2 Nachrichten",
277
+ "atLeast3": "Mindestens 3 Nachrichten",
278
+ "atLeast5": "Mindestens 5 Nachrichten",
279
+ "atLeast10": "Mindestens 10 Nachrichten",
280
+ "atLeast15": "Mindestens 15 Nachrichten",
281
+ "atLeast20": "Mindestens 20 Nachrichten",
282
+ "customMinimumMessages": "Anpassen Sie die Anzahl der Nachrichten"
283
+ },
273
284
  "success": "Erfolg",
274
285
  "Error": "Fehler",
275
286
  "internal server error": "Oupsie, tut mir leid... Auf dem Server ist ein Fehler aufgetreten",
@@ -292,6 +292,17 @@
292
292
  "editAccount": "Edit account",
293
293
  "save": "Save"
294
294
  },
295
+ "chatLogs": {
296
+ "anyMessage": "Any message",
297
+ "atLeast": "At least {{count}} messages",
298
+ "atLeast2": "At least 2 messages",
299
+ "atLeast3": "At least 3 messages",
300
+ "atLeast5": "At least 5 messages",
301
+ "atLeast10": "At least 10 messages",
302
+ "atLeast15": "At least 15 messages",
303
+ "atLeast20": "At least 20 messages",
304
+ "customMinimumMessages": "Customize the number of messages"
305
+ },
295
306
  "success": "Success",
296
307
  "Error": "Error",
297
308
  "internal server error": "Oupsie, sorry... Something went wrong on the server",
@@ -270,6 +270,17 @@
270
270
  "editAccount": "Editar cuenta",
271
271
  "save": "Ahorrar"
272
272
  },
273
+ "chatLogs": {
274
+ "anyMessage": "Cualquier mensaje",
275
+ "atLeast": "Al menos {{count}} mensajes",
276
+ "atLeast2": "Al menos 2 mensajes",
277
+ "atLeast3": "Al menos 3 mensajes",
278
+ "atLeast5": "Al menos 5 mensajes",
279
+ "atLeast10": "Al menos 10 mensajes",
280
+ "atLeast15": "Al menos 15 mensajes",
281
+ "atLeast20": "Al menos 20 mensajes",
282
+ "customMinimumMessages": "Personalizar el número de mensajes"
283
+ },
273
284
  "success": "Éxito",
274
285
  "Error": "Error",
275
286
  "internal server error": "Oupsie, lo siento... Algo salió mal en el servidor.",
@@ -279,6 +279,17 @@
279
279
  "editAccount": "Modifier le compte",
280
280
  "save": "Sauvegarder"
281
281
  },
282
+ "chatLogs": {
283
+ "anyMessage": "Tout message",
284
+ "atLeast": "Au moins {{count}} messages",
285
+ "atLeast2": "Au moins 2 messages",
286
+ "atLeast3": "Au moins 3 messages",
287
+ "atLeast5": "Au moins 5 messages",
288
+ "atLeast10": "Au moins 10 messages",
289
+ "atLeast15": "Au moins 15 messages",
290
+ "atLeast20": "Au moins 20 messages",
291
+ "customMinimumMessages": "Personnaliser le nombre de messages"
292
+ },
282
293
  "success": "Succès",
283
294
  "Error": "Erreur",
284
295
  "internal server error": "Oupsie, désolé... Quelque chose s'est mal passé sur le serveur",
@@ -293,6 +293,17 @@
293
293
  "editAccount": "Modifica account",
294
294
  "save": "Salva"
295
295
  },
296
+ "chatLogs": {
297
+ "atLeast": "Almeno {{count}} messaggi",
298
+ "anyMessage": "Qualunque messaggio",
299
+ "atLeast2": "Almeno 2 messaggi",
300
+ "atLeast3": "Almeno 3 messaggi",
301
+ "atLeast5": "Almeno 5 messaggi",
302
+ "atLeast10": "Almeno 10 messaggi",
303
+ "atLeast15": "Almeno 15 messaggi",
304
+ "atLeast20": "Almeno 20 messaggi",
305
+ "customMinimumMessages": "Personalizza il numero di messaggi"
306
+ },
296
307
  "success": "Operazione andata a buon fine",
297
308
  "Error": "Errore",
298
309
  "internal server error": "Ops, scusa... qualcosa è andato storto nel server",