ui-soxo-bootstrap-core 2.6.25 → 2.6.27

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.
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
 
17
- import React, { useState, useEffect, useContext } from 'react';
17
+ import React, { useState, useEffect, useContext, useRef } from 'react';
18
18
 
19
19
  import { Timeline, Card, Skeleton, Button, Modal, Typography, Form, Select, message, Tag } from 'antd';
20
20
 
@@ -28,7 +28,7 @@ import TaskStatus from './../task-status/task-status';
28
28
 
29
29
  import DateUtils from '../../../../utils/date/date.utils';
30
30
 
31
- import { ClockCircleOutlined, CopyOutlined, CheckCircleOutlined, LoadingOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons';
31
+ import { ClockCircleOutlined, CopyOutlined, CheckCircleOutlined, LoadingOutlined, EditOutlined, ReloadOutlined, SoundOutlined, PauseCircleOutlined } from '@ant-design/icons';
32
32
 
33
33
  import { CopyToClipBoard } from '../../../..';
34
34
 
@@ -59,6 +59,340 @@ let stepMaster = {
59
59
 
60
60
  }
61
61
 
62
+ const GEMINI_TTS_MODEL =
63
+ process.env.GEMINI_TTS_MODEL || process.env.REACT_APP_GEMINI_TTS_MODEL || 'gemini-2.5-flash-preview-tts';
64
+ const GEMINI_TTS_VOICE = process.env.GEMINI_TTS_VOICE || process.env.REACT_APP_GEMINI_TTS_VOICE || 'Kore';
65
+ const GEMINI_TTS_API_BASE_URL =
66
+ process.env.GEMINI_API_BASE_URL || process.env.REACT_APP_GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta';
67
+ const ELEVENLABS_TTS_API_BASE_URL =
68
+ process.env.ELEVENLABS_TTS_API_BASE_URL ||
69
+ process.env.REACT_APP_ELEVENLABS_TTS_API_BASE_URL ||
70
+ 'https://api.elevenlabs.io/v1/text-to-speech';
71
+ const ELEVENLABS_MODEL_ID =
72
+ process.env.ELEVENLABS_MODEL_ID || process.env.REACT_APP_ELEVENLABS_MODEL_ID || 'eleven_multilingual_v2';
73
+ const ELEVENLABS_OUTPUT_FORMAT =
74
+ process.env.ELEVENLABS_OUTPUT_FORMAT || process.env.REACT_APP_ELEVENLABS_OUTPUT_FORMAT || 'mp3_44100_128';
75
+ const DEFAULT_ELEVENLABS_VOICE_ID =
76
+ process.env.ELEVENLABS_VOICE_ID ||
77
+ process.env.ELEVEN_LABS_VOICE_ID ||
78
+ process.env.REACT_APP_ELEVENLABS_VOICE_ID ||
79
+ '21m00Tcm4TlvDq8ikWAM';
80
+
81
+ function getFromStorage(storageKey) {
82
+
83
+ if (typeof window === 'undefined' || !window.localStorage) {
84
+ return null;
85
+ }
86
+
87
+ try {
88
+ return window.localStorage.getItem(storageKey);
89
+ } catch (error) {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function getGeminiApiKey() {
95
+
96
+ if (process.env.GEMINI_API_KEY) {
97
+ return process.env.GEMINI_API_KEY;
98
+ }
99
+
100
+ if (process.env.REACT_APP_GEMINI_API_KEY) {
101
+ return process.env.REACT_APP_GEMINI_API_KEY;
102
+ }
103
+
104
+ if (typeof window !== 'undefined') {
105
+ try {
106
+ if (window.localStorage) {
107
+ return window.localStorage.getItem('gemini_api_key');
108
+ }
109
+ } catch (error) {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ return null;
115
+ }
116
+
117
+ function getElevenLabsApiKey() {
118
+ return (
119
+ process.env.ELEVEN_LABS_KEY ||
120
+ process.env.ELEVENLABS_API_KEY ||
121
+ process.env.REACT_APP_ELEVEN_LABS_KEY ||
122
+ process.env.REACT_APP_ELEVENLABS_API_KEY ||
123
+ getFromStorage('eleven_labs_key') ||
124
+ getFromStorage('elevenlabs_api_key') ||
125
+ getFromStorage('ELEVEN_LABS_KEY') ||
126
+ getFromStorage('REACT_APP_ELEVEN_LABS_KEY') ||
127
+ getFromStorage('REACT_APP_ELEVENLABS_API_KEY')
128
+ );
129
+ }
130
+
131
+ function getStepNarration({ step, step_transactions }) {
132
+
133
+ if (!step) {
134
+ return 'Step details are not available.';
135
+ }
136
+
137
+ const chunks = [];
138
+
139
+ chunks.push(`Step ${step.name || 'Unnamed step'}.`);
140
+
141
+ if (step.description) {
142
+ chunks.push(step.description);
143
+ }
144
+
145
+ const currentState = step.current_state || 'pending';
146
+
147
+ chunks.push(`Current status is ${currentState}.`);
148
+
149
+ if (step.roles && step.roles.length) {
150
+ const roleNames = step.roles.map((role) => role && role.name).filter(Boolean);
151
+
152
+ if (roleNames.length) {
153
+ chunks.push(`Pending with ${roleNames.join(', ')}.`);
154
+ }
155
+ }
156
+
157
+ if (step_transactions && step_transactions.start_time) {
158
+ const startTime = DateUtils.displayFirestoreTime(step_transactions.start_time);
159
+
160
+ if (startTime) {
161
+ chunks.push(`Started at ${startTime}.`);
162
+ }
163
+ }
164
+
165
+ if (step_transactions && step_transactions.completed && step_transactions.end_time) {
166
+ const endTime = DateUtils.displayFirestoreTime(step_transactions.end_time);
167
+
168
+ if (endTime) {
169
+ chunks.push(`Completed at ${endTime}.`);
170
+ }
171
+ }
172
+
173
+ return chunks.join(' ');
174
+ }
175
+
176
+ function extractGeminiAudio(payload) {
177
+ const candidates = payload && payload.candidates ? payload.candidates : [];
178
+
179
+ for (const candidate of candidates) {
180
+ const parts = candidate && candidate.content && candidate.content.parts ? candidate.content.parts : [];
181
+
182
+ for (const part of parts) {
183
+ const inlineData = part.inlineData || part.inline_data || part.audio;
184
+
185
+ if (inlineData && inlineData.data) {
186
+ return {
187
+ mimeType: inlineData.mimeType || inlineData.mime_type || 'audio/wav',
188
+ data: inlineData.data
189
+ };
190
+ }
191
+ }
192
+ }
193
+
194
+ return null;
195
+ }
196
+
197
+ async function synthesizeGeminiAudio(text) {
198
+
199
+ const apiKey = getGeminiApiKey();
200
+
201
+ if (!apiKey) {
202
+ throw new Error('Gemini API key is missing');
203
+ }
204
+
205
+ const endpoint = `${GEMINI_TTS_API_BASE_URL}/models/${GEMINI_TTS_MODEL}:generateContent?key=${apiKey}`;
206
+
207
+ const response = await fetch(endpoint, {
208
+ method: 'POST',
209
+ headers: {
210
+ 'Content-Type': 'application/json'
211
+ },
212
+ body: JSON.stringify({
213
+ contents: [{
214
+ role: 'user',
215
+ parts: [{ text }]
216
+ }],
217
+ generationConfig: {
218
+ responseModalities: ['AUDIO'],
219
+ speechConfig: {
220
+ voiceConfig: {
221
+ prebuiltVoiceConfig: {
222
+ voiceName: GEMINI_TTS_VOICE
223
+ }
224
+ }
225
+ }
226
+ }
227
+ })
228
+ });
229
+
230
+ if (!response.ok) {
231
+ throw new Error(`Gemini TTS request failed with status ${response.status}`);
232
+ }
233
+
234
+ const payload = await response.json();
235
+
236
+ const audio = extractGeminiAudio(payload);
237
+
238
+ if (!audio || !audio.data) {
239
+ throw new Error('Gemini did not return audio data');
240
+ }
241
+
242
+ return `data:${audio.mimeType};base64,${audio.data}`;
243
+ }
244
+
245
+ async function synthesizeElevenLabsAudio(text) {
246
+
247
+ const apiKey = getElevenLabsApiKey();
248
+
249
+ if (!apiKey) {
250
+ throw new Error('ElevenLabs API key is missing');
251
+ }
252
+
253
+ const endpoint = `${ELEVENLABS_TTS_API_BASE_URL}/${encodeURIComponent(DEFAULT_ELEVENLABS_VOICE_ID)}/stream?output_format=${encodeURIComponent(
254
+ ELEVENLABS_OUTPUT_FORMAT
255
+ )}`;
256
+
257
+ const response = await fetch(endpoint, {
258
+ method: 'POST',
259
+ headers: {
260
+ 'Content-Type': 'application/json',
261
+ Accept: 'audio/mpeg',
262
+ 'xi-api-key': apiKey
263
+ },
264
+ body: JSON.stringify({
265
+ text,
266
+ model_id: ELEVENLABS_MODEL_ID
267
+ })
268
+ });
269
+
270
+ if (!response.ok) {
271
+ throw new Error(`ElevenLabs TTS request failed with status ${response.status}`);
272
+ }
273
+
274
+ return response.blob();
275
+ }
276
+
277
+ function playAudioDataUri(dataUri, audioReference) {
278
+ return new Promise((resolve, reject) => {
279
+ const audio = new Audio(dataUri);
280
+
281
+ audioReference.current = audio;
282
+
283
+ const clean = () => {
284
+ if (audioReference.current === audio) {
285
+ audioReference.current = null;
286
+ }
287
+ };
288
+
289
+ audio.onended = () => {
290
+ clean();
291
+ resolve();
292
+ };
293
+
294
+ audio.onpause = () => {
295
+ clean();
296
+ resolve();
297
+ };
298
+
299
+ audio.onerror = () => {
300
+ clean();
301
+ reject(new Error('Audio playback failed'));
302
+ };
303
+
304
+ audio.play().catch((error) => {
305
+ clean();
306
+ reject(error);
307
+ });
308
+ });
309
+ }
310
+
311
+ function playAudioBlob(audioBlob, audioReference, audioUrlReference) {
312
+ return new Promise((resolve, reject) => {
313
+ if (typeof window === 'undefined' || !window.Audio || !window.URL) {
314
+ reject(new Error('Audio playback is not available'));
315
+ return;
316
+ }
317
+
318
+ if (audioUrlReference.current) {
319
+ window.URL.revokeObjectURL(audioUrlReference.current);
320
+ audioUrlReference.current = null;
321
+ }
322
+
323
+ const audioUrl = window.URL.createObjectURL(audioBlob);
324
+ const audio = new Audio(audioUrl);
325
+
326
+ audioReference.current = audio;
327
+ audioUrlReference.current = audioUrl;
328
+
329
+ const clean = () => {
330
+ if (audioReference.current === audio) {
331
+ audioReference.current = null;
332
+ }
333
+
334
+ if (audioUrlReference.current === audioUrl) {
335
+ window.URL.revokeObjectURL(audioUrl);
336
+ audioUrlReference.current = null;
337
+ }
338
+ };
339
+
340
+ audio.onended = () => {
341
+ clean();
342
+ resolve();
343
+ };
344
+
345
+ audio.onpause = () => {
346
+ clean();
347
+ resolve();
348
+ };
349
+
350
+ audio.onerror = () => {
351
+ clean();
352
+ reject(new Error('Audio playback failed'));
353
+ };
354
+
355
+ audio.play().catch((error) => {
356
+ clean();
357
+ reject(error);
358
+ });
359
+ });
360
+ }
361
+
362
+ function speakWithBrowser(text, speechReference) {
363
+ return new Promise((resolve, reject) => {
364
+ if (typeof window === 'undefined' || !window.speechSynthesis || !window.SpeechSynthesisUtterance) {
365
+ reject(new Error('Speech synthesis is not available'));
366
+ return;
367
+ }
368
+
369
+ const utterance = new window.SpeechSynthesisUtterance(text);
370
+
371
+ utterance.lang = process.env.REACT_APP_TTS_LANG || 'en-US';
372
+
373
+ speechReference.current = utterance;
374
+
375
+ utterance.onend = () => {
376
+ if (speechReference.current === utterance) {
377
+ speechReference.current = null;
378
+ }
379
+
380
+ resolve();
381
+ };
382
+
383
+ utterance.onerror = () => {
384
+ if (speechReference.current === utterance) {
385
+ speechReference.current = null;
386
+ }
387
+
388
+ reject(new Error('Browser narration failed'));
389
+ };
390
+
391
+ window.speechSynthesis.cancel();
392
+ window.speechSynthesis.speak(utterance);
393
+ });
394
+ }
395
+
62
396
  /**
63
397
  *
64
398
  */
@@ -257,8 +591,125 @@ function StepTimelineItem({ id, color, Icon, step, step_transactions, callback }
257
591
 
258
592
  const [loading, setLoading] = useState(false);
259
593
 
594
+ const [narrating, setNarrating] = useState(false);
595
+
260
596
  const { user = { locations: [] } } = useContext(GlobalContext);
261
597
 
598
+ const audioReference = useRef(null);
599
+
600
+ const audioUrlReference = useRef(null);
601
+
602
+ const speechReference = useRef(null);
603
+
604
+ const narrationSessionReference = useRef(0);
605
+
606
+ const fallbackNoticeShownReference = useRef(false);
607
+
608
+ useEffect(() => {
609
+ return () => {
610
+ stopNarration();
611
+ }
612
+ }, [])
613
+
614
+ /**
615
+ * Stop active narration audio
616
+ */
617
+ function stopNarration() {
618
+ narrationSessionReference.current += 1;
619
+
620
+ if (audioReference.current) {
621
+ audioReference.current.pause();
622
+ audioReference.current.src = '';
623
+ audioReference.current = null;
624
+ }
625
+
626
+ if (audioUrlReference.current && typeof window !== 'undefined' && window.URL) {
627
+ window.URL.revokeObjectURL(audioUrlReference.current);
628
+ audioUrlReference.current = null;
629
+ }
630
+
631
+ if (typeof window !== 'undefined' && window.speechSynthesis) {
632
+ window.speechSynthesis.cancel();
633
+ }
634
+
635
+ speechReference.current = null;
636
+ setNarrating(false);
637
+ }
638
+
639
+ /**
640
+ * Narrate the current step with ElevenLabs/Gemini TTS only (no browser fallback).
641
+ */
642
+ async function narrateStep() {
643
+ const narration = getStepNarration({ step, step_transactions });
644
+
645
+ if (!narration) {
646
+ message.warning('Narration text is not available for this step.');
647
+ return;
648
+ }
649
+
650
+ stopNarration();
651
+
652
+ const sessionId = narrationSessionReference.current;
653
+ const hasElevenLabsKey = Boolean(getElevenLabsApiKey());
654
+
655
+ setNarrating(true);
656
+
657
+ try {
658
+ let aiNarrationComplete = false;
659
+ let aiNarrationError = null;
660
+
661
+ if (hasElevenLabsKey) {
662
+ try {
663
+ const audioBlob = await synthesizeElevenLabsAudio(narration);
664
+
665
+ if (sessionId !== narrationSessionReference.current) {
666
+ return;
667
+ }
668
+
669
+ await playAudioBlob(audioBlob, audioReference, audioUrlReference);
670
+ aiNarrationComplete = true;
671
+ } catch (elevenLabsError) {
672
+ aiNarrationError = elevenLabsError;
673
+ }
674
+ }
675
+
676
+ if (!aiNarrationComplete) {
677
+ try {
678
+ const dataUri = await synthesizeGeminiAudio(narration);
679
+
680
+ if (sessionId !== narrationSessionReference.current) {
681
+ return;
682
+ }
683
+
684
+ await playAudioDataUri(dataUri, audioReference);
685
+ aiNarrationComplete = true;
686
+ } catch (geminiError) {
687
+ aiNarrationError = geminiError;
688
+ }
689
+ }
690
+
691
+ if (!aiNarrationComplete) {
692
+ throw aiNarrationError || new Error('AI narration unavailable');
693
+ }
694
+
695
+ } catch (error) {
696
+ if (sessionId !== narrationSessionReference.current) {
697
+ return;
698
+ }
699
+
700
+ if (!fallbackNoticeShownReference.current) {
701
+ message.warning(`${hasElevenLabsKey ? 'ElevenLabs/Gemini' : 'Gemini'} narration unavailable.`);
702
+ fallbackNoticeShownReference.current = true;
703
+ }
704
+
705
+ message.error(error?.message || 'Unable to play narration for this step.');
706
+ } finally {
707
+ if (sessionId === narrationSessionReference.current) {
708
+ setNarrating(false);
709
+ }
710
+ }
711
+ }
712
+
262
713
  /**
263
714
  * Function to open editModal for Step
264
715
  *
@@ -347,6 +798,21 @@ function StepTimelineItem({ id, color, Icon, step, step_transactions, callback }
347
798
  {/* Actions for a step */}
348
799
  <div className="actions">
349
800
 
801
+ <Button size={'small'} onClick={() => {
802
+ if (narrating) {
803
+ stopNarration();
804
+ return;
805
+ }
806
+
807
+ narrateStep();
808
+ }}>
809
+
810
+ {narrating ? <PauseCircleOutlined /> : <SoundOutlined />}
811
+
812
+ {narrating ? 'Stop Narration' : 'Narrate'}
813
+
814
+ </Button>
815
+
350
816
  {user.isAdmin && <>
351
817
 
352
818
  <CopyToClipBoard record={step} id={step.id} />
@@ -599,4 +1065,4 @@ function RevertProcessTransaction({ id, process = [], callback }) {
599
1065
  </Form>
600
1066
  </>)
601
1067
 
602
- }
1068
+ }
@@ -42,6 +42,10 @@
42
42
  }
43
43
 
44
44
  .actions {
45
+ display: flex;
46
+ gap: 6px;
47
+ align-items: center;
48
+ flex-wrap: wrap;
45
49
  }
46
50
  }
47
51
 
@@ -10,7 +10,7 @@ import './action-buttons.scss';
10
10
 
11
11
  export default function ActionButtons({
12
12
  loading,
13
- steps,
13
+ steps = [],
14
14
  activeStep,
15
15
  isStepCompleted,
16
16
  isFullscreen,
@@ -22,60 +22,70 @@ export default function ActionButtons({
22
22
  handleFinish,
23
23
  handleStartNextProcess,
24
24
  nextProcessId,
25
- timelineCollapsed,
26
25
  }) {
27
26
  const [showNextProcess, setShowNextProcess] = useState(false);
27
+ const currentStep = steps[activeStep];
28
+ const isLastStep = steps.length > 0 && activeStep >= steps.length - 1;
29
+ const isEndStep = currentStep?.order_seqtype === 'E';
30
+
28
31
  useEffect(() => {
29
- setShowNextProcess(false);
30
- }, [steps]);
32
+ if (!isEndStep) {
33
+ setShowNextProcess(false);
34
+ }
35
+ }, [isEndStep]);
36
+
31
37
  return (
32
- <div className="action-buttons-layout">
33
- <div className="action-buttons-content">{loading ? <Skeleton active /> : renderDynamicComponent()}</div>
34
- <div className="action-buttons-container">
35
- {/* Back button */}
36
- <Button disabled={activeStep === 0} onClick={handlePrevious}>
37
- Back
38
- </Button>
38
+ <div className="action-buttons-shell">
39
+ <div className="action-body">{loading ? <Skeleton active /> : typeof renderDynamicComponent === 'function' ? renderDynamicComponent() : null}</div>
40
+ <div className="action-footer">
41
+ <div className="action-buttons-container">
42
+ {/* Back button */}
43
+ <Button disabled={activeStep <= 0} onClick={handlePrevious}>
44
+ Back
45
+ </Button>
39
46
 
40
- <Button type="default" onClick={onToggleFullscreen}>
41
- {isFullscreen ? 'Exit Full Screen' : 'Switch to Full Screen'}
42
- </Button>
47
+ {typeof onToggleFullscreen === 'function' && (
48
+ <Button type="default" onClick={onToggleFullscreen}>
49
+ {isFullscreen ? 'Exit Full Screen' : 'Switch to Full Screen'}
50
+ </Button>
51
+ )}
43
52
 
44
- {/* Skip button */}
45
- {steps.length > 0 && steps[activeStep]?.allow_skip === 'Y' && (
46
- <Button type="default" onClick={handleSkip} disabled={activeStep === steps.length - 1}>
47
- Skip
48
- </Button>
49
- )}
53
+ {/* Skip button */}
54
+ {steps.length > 0 && currentStep?.allow_skip === 'Y' && (
55
+ <Button type="default" onClick={handleSkip} disabled={isLastStep}>
56
+ Skip
57
+ </Button>
58
+ )}
50
59
 
51
- {/* Next / Finish / Start Next */}
52
- {steps[activeStep]?.order_seqtype === 'E' ? (
53
- <>
54
- {!showNextProcess && (
55
- <Button
56
- type="primary"
57
- onClick={async () => {
58
- const success = await handleFinish();
59
- if (success && nextProcessId?.next_process_id) {
60
- setShowNextProcess(true);
61
- }
62
- }}
63
- >
64
- Finish
65
- </Button>
66
- )}
60
+ {/* Next / Finish / Start Next */}
61
+ {isEndStep ? (
62
+ <>
63
+ {!showNextProcess && (
64
+ <Button
65
+ type="primary"
66
+ onClick={async () => {
67
+ const success = typeof handleFinish === 'function' ? await handleFinish() : false;
68
+ if (success && nextProcessId?.next_process_id) {
69
+ setShowNextProcess(true);
70
+ }
71
+ }}
72
+ >
73
+ Finish
74
+ </Button>
75
+ )}
67
76
 
68
- {showNextProcess && nextProcessId?.next_process_id && (
69
- <Button type="primary" onClick={handleStartNextProcess}>
70
- Start {nextProcessId.next_process_name}
71
- </Button>
72
- )}
73
- </>
74
- ) : (
75
- <Button type="primary" disabled={activeStep === steps.length - 1 || !isStepCompleted} onClick={handleNext}>
76
- Next →
77
- </Button>
78
- )}
77
+ {showNextProcess && nextProcessId?.next_process_id && (
78
+ <Button type="primary" onClick={handleStartNextProcess}>
79
+ Start {nextProcessId.next_process_name}
80
+ </Button>
81
+ )}
82
+ </>
83
+ ) : (
84
+ <Button type="primary" disabled={steps.length === 0 || isLastStep || !isStepCompleted} onClick={handleNext}>
85
+ Next →
86
+ </Button>
87
+ )}
88
+ </div>
79
89
  </div>
80
90
  </div>
81
91
  );