ui-soxo-bootstrap-core 2.6.1-dev.2 → 2.6.1-dev.20
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.
- package/core/components/extra-info/extra-info-details.js +2 -2
- package/core/components/index.js +2 -11
- package/core/components/landing-api/landing-api.js +91 -15
- package/core/components/landing-api/landing-api.scss +22 -0
- package/core/components/license-management/license-alert.js +97 -0
- package/core/lib/Store.js +3 -3
- package/core/lib/components/global-header/animations.js +78 -4
- package/core/lib/components/global-header/global-header.js +224 -255
- package/core/lib/components/global-header/global-header.scss +162 -24
- package/core/lib/components/sidemenu/animations.js +84 -2
- package/core/lib/components/sidemenu/sidemenu.js +191 -65
- package/core/lib/components/sidemenu/sidemenu.scss +221 -14
- package/core/lib/elements/basic/country-phone-input/country-phone-input.js +14 -8
- package/core/lib/elements/basic/dragabble-wrapper/draggable-wrapper.js +1 -1
- package/core/lib/elements/basic/menu-tree/menu-tree.js +26 -13
- package/core/lib/models/forms/components/form-creator/form-creator.scss +4 -3
- package/core/lib/models/menus/components/menu-list/menu-list.js +424 -467
- package/core/lib/models/process/components/process-dashboard/process-dashboard.js +469 -3
- package/core/lib/models/process/components/process-dashboard/process-dashboard.scss +4 -0
- package/core/lib/pages/change-password/change-password.js +17 -24
- package/core/lib/pages/change-password/change-password.scss +45 -48
- package/core/lib/pages/login/commnication-mode-selection.js +2 -2
- package/core/lib/pages/login/login.js +47 -62
- package/core/lib/pages/login/login.scss +9 -0
- package/core/lib/pages/login/reset-password.js +17 -17
- package/core/lib/pages/login/reset-password.scss +10 -1
- package/core/lib/pages/profile/themes.json +4 -4
- package/core/lib/utils/api/api.utils.js +30 -18
- package/core/lib/utils/common/common.utils.js +49 -35
- package/core/lib/utils/http/http.utils.js +2 -1
- package/core/lib/utils/index.js +4 -1
- package/core/models/base/base.js +7 -3
- package/core/models/core-scripts/core-scripts.js +134 -126
- package/core/models/doctor/components/doctor-add/doctor-add.js +9 -4
- package/core/models/menus/components/menu-add/menu-add.js +1 -1
- package/core/models/menus/components/menu-lists/menu-lists.js +53 -54
- package/core/models/menus/menus.js +27 -2
- package/core/models/roles/components/role-add/role-add.js +92 -59
- package/core/models/roles/components/role-list/role-list.js +1 -1
- package/core/models/staff/components/staff-add/staff-add.js +20 -32
- package/core/models/users/components/assign-role/assign-role.js +145 -50
- package/core/models/users/components/assign-role/assign-role.scss +209 -45
- package/core/models/users/components/assign-role/avatar-props.js +45 -0
- package/core/models/users/components/user-add/user-add.js +46 -55
- package/core/models/users/components/user-add/user-edit.js +25 -4
- package/core/models/users/users.js +9 -1
- package/core/modules/dashboard/components/dashboard-card/menu-dashboard-card.js +1 -1
- package/core/modules/reporting/components/reporting-dashboard/README.md +316 -0
- package/core/modules/reporting/components/reporting-dashboard/adavance-search/advance-search.js +147 -0
- package/core/modules/reporting/components/reporting-dashboard/adavance-search/advance-search.scss +76 -0
- package/core/modules/reporting/components/reporting-dashboard/display-columns/build-display-columns.js +90 -0
- package/core/modules/reporting/components/reporting-dashboard/display-columns/build-display-columns.test.js +74 -0
- package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.js +252 -0
- package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.test.js +126 -0
- package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.js +326 -436
- package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.scss +7 -0
- package/core/modules/steps/action-buttons.js +33 -15
- package/core/modules/steps/action-buttons.scss +55 -9
- package/core/modules/steps/chat-assistant.js +141 -0
- package/core/modules/steps/openai-realtime.js +275 -0
- package/core/modules/steps/readme.md +167 -0
- package/core/modules/steps/steps.js +1078 -57
- package/core/modules/steps/steps.scss +539 -90
- package/core/modules/steps/timeline.js +21 -19
- package/core/modules/steps/voice-navigation.js +709 -0
- package/package.json +2 -1
|
@@ -4,24 +4,273 @@
|
|
|
4
4
|
* - Manages a multi-step, time-tracked process workflow.
|
|
5
5
|
* - Dynamically renders step-specific components based on configuration.
|
|
6
6
|
* - Tracks step and process durations with local persistence support.
|
|
7
|
-
* - Supports step navigation (next, previous, skip,
|
|
7
|
+
* - Supports step navigation (next, previous, skip, breadcrumb, keyboard).
|
|
8
8
|
* - Handles process submission and optional chaining to the next process.
|
|
9
|
-
* -
|
|
9
|
+
* - Renders a single active step view with compact breadcrumb controls.
|
|
10
10
|
*/
|
|
11
|
-
import React, { useEffect, useState } from 'react';
|
|
12
|
-
import { Row, Col, Empty } from 'antd';
|
|
13
|
-
import { Card } from './../../lib';
|
|
11
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
12
|
+
import { Row, Col, Empty, Spin, Select, message } from 'antd';
|
|
13
|
+
import { Card, Button } from './../../lib';
|
|
14
14
|
import * as genericComponents from './../../lib';
|
|
15
15
|
import moment from 'moment';
|
|
16
16
|
import { Location } from './../../lib';
|
|
17
|
-
import ActionButtons from './action-buttons';
|
|
18
17
|
import { Dashboard } from '../../models';
|
|
19
18
|
import './steps.scss';
|
|
20
|
-
import TimelinePanel from './timeline';
|
|
21
19
|
import { ExternalWindow } from '../../components';
|
|
20
|
+
import { SoundOutlined, ExpandOutlined, CompressOutlined } from '@ant-design/icons';
|
|
21
|
+
import { createOpenAIRealtimeSession, hasOpenAIRealtimeCredentials } from './openai-realtime';
|
|
22
|
+
|
|
23
|
+
const STEP_WELCOME_LINES = [
|
|
24
|
+
'Welcome to your AI Automated Consultation process.',
|
|
25
|
+
'You are in the right place for a smooth and guided health journey.',
|
|
26
|
+
'This experience is designed to keep your consultation easy and stress-free.',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const STEP_INTRO_LINES = [
|
|
30
|
+
'In this process, we will walk you through a seamless and friendly AI interaction experience.',
|
|
31
|
+
'Each step is simple, guided, and focused on helping you feel prepared.',
|
|
32
|
+
'We will guide you through each stage so you always know what happens next.',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const STEP_EXPECTATION_LINES = [
|
|
36
|
+
'A care specialist may ask quick clarification questions to ensure your details are accurate.',
|
|
37
|
+
'This step focuses on collecting clear inputs so the care team can support you faster.',
|
|
38
|
+
'You can expect a guided workflow with minimal waiting and clear instructions.',
|
|
39
|
+
'The goal in this step is to keep your consultation organized and easy to follow.',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const STEP_COMFORT_LINES = [
|
|
43
|
+
'Take your time. There is no rush and assistance is always available nearby.',
|
|
44
|
+
'If anything feels unclear, the next prompt will guide you before you continue.',
|
|
45
|
+
'You can pause and review information before moving to the next stage.',
|
|
46
|
+
'Your responses here help personalize the rest of your consultation flow.',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const STEP_TIP_LINES = [
|
|
50
|
+
'Tip: Keep your previous reports or test details ready for quicker progress.',
|
|
51
|
+
'Tip: Follow on-screen prompts one at a time for the smoothest experience.',
|
|
52
|
+
'Tip: If you are unsure about a question, answer what you know and continue.',
|
|
53
|
+
'Tip: Stay relaxed; this process is built to be simple and patient-friendly.',
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const VOICE_PROVIDER_OPTIONS = [
|
|
57
|
+
{ label: 'Gemini', value: 'gemini' },
|
|
58
|
+
{ label: 'ElevenLabs', value: 'elevenlabs' },
|
|
59
|
+
{ label: 'OpenAI', value: 'openai' },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const GEMINI_VOICE_OPTIONS = [{ label: 'Kore', value: 'Kore' }];
|
|
63
|
+
const DEFAULT_GEMINI_TTS_VOICE = process.env.GEMINI_TTS_VOICE || process.env.REACT_APP_GEMINI_TTS_VOICE || GEMINI_VOICE_OPTIONS[0].value;
|
|
64
|
+
const OPENAI_TTS_VOICE_OPTIONS = [
|
|
65
|
+
{ label: 'Alloy', value: 'alloy' },
|
|
66
|
+
{ label: 'Ash', value: 'ash' },
|
|
67
|
+
{ label: 'Coral', value: 'coral' },
|
|
68
|
+
{ label: 'Echo', value: 'echo' },
|
|
69
|
+
{ label: 'Fable', value: 'fable' },
|
|
70
|
+
{ label: 'Nova', value: 'nova' },
|
|
71
|
+
{ label: 'Onyx', value: 'onyx' },
|
|
72
|
+
{ label: 'Sage', value: 'sage' },
|
|
73
|
+
{ label: 'Shimmer', value: 'shimmer' },
|
|
74
|
+
];
|
|
75
|
+
const DEFAULT_OPENAI_TTS_VOICE =
|
|
76
|
+
process.env.OPENAI_TTS_VOICE ||
|
|
77
|
+
process.env.REACT_APP_OPENAI_TTS_VOICE ||
|
|
78
|
+
process.env.OPENAI_REALTIME_VOICE ||
|
|
79
|
+
process.env.REACT_APP_OPENAI_REALTIME_VOICE ||
|
|
80
|
+
OPENAI_TTS_VOICE_OPTIONS[0].value;
|
|
81
|
+
|
|
82
|
+
const ELEVENLABS_VOICE_OPTIONS = [
|
|
83
|
+
{ label: 'Rachel', value: '21m00Tcm4TlvDq8ikWAM' },
|
|
84
|
+
{ label: 'Adam', value: 'pNInz6obpgDQGcFmaJgB' },
|
|
85
|
+
{ label: 'Bella', value: 'EXAVITQu4vr4xnSDxMaL' },
|
|
86
|
+
{ label: 'Antoni', value: 'ErXwobaYiN019PkySvjV' },
|
|
87
|
+
{ label: 'Josh', value: 'TxGEqnHWrfWFTfGW9XjX' },
|
|
88
|
+
];
|
|
89
|
+
const DEFAULT_ELEVENLABS_VOICE_ID =
|
|
90
|
+
process.env.ELEVENLABS_VOICE_ID ||
|
|
91
|
+
process.env.ELEVEN_LABS_VOICE_ID ||
|
|
92
|
+
process.env.REACT_APP_ELEVENLABS_VOICE_ID ||
|
|
93
|
+
ELEVENLABS_VOICE_OPTIONS[0].value;
|
|
94
|
+
|
|
95
|
+
const SARVAM_VOICE_OPTIONS = [
|
|
96
|
+
{ label: 'Anushka', value: 'anushka' },
|
|
97
|
+
{ label: 'Manisha', value: 'manisha' },
|
|
98
|
+
{ label: 'Vidya', value: 'vidya' },
|
|
99
|
+
{ label: 'Arya', value: 'arya' },
|
|
100
|
+
{ label: 'Karun', value: 'karun' },
|
|
101
|
+
{ label: 'Hitesh', value: 'hitesh' },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const ELEVENLABS_TTS_API_BASE_URL =
|
|
105
|
+
process.env.ELEVENLABS_TTS_API_BASE_URL || process.env.REACT_APP_ELEVENLABS_TTS_API_BASE_URL || 'https://api.elevenlabs.io/v1/text-to-speech';
|
|
106
|
+
const ELEVENLABS_MODEL_ID = process.env.ELEVENLABS_MODEL_ID || process.env.REACT_APP_ELEVENLABS_MODEL_ID || 'eleven_multilingual_v2';
|
|
107
|
+
const ELEVENLABS_OUTPUT_FORMAT = process.env.ELEVENLABS_OUTPUT_FORMAT || process.env.REACT_APP_ELEVENLABS_OUTPUT_FORMAT || 'mp3_44100_128';
|
|
108
|
+
const GEMINI_TTS_MODEL = process.env.GEMINI_TTS_MODEL || process.env.REACT_APP_GEMINI_TTS_MODEL || 'gemini-2.5-flash-preview-tts';
|
|
109
|
+
const GEMINI_TTS_API_BASE_URL =
|
|
110
|
+
process.env.GEMINI_API_BASE_URL || process.env.REACT_APP_GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta';
|
|
111
|
+
const OPENAI_TTS_ENDPOINT = process.env.OPENAI_TTS_ENDPOINT || process.env.REACT_APP_OPENAI_TTS_ENDPOINT || 'https://api.openai.com/v1/audio/speech';
|
|
112
|
+
const OPENAI_TTS_MODEL = process.env.OPENAI_TTS_MODEL || process.env.REACT_APP_OPENAI_TTS_MODEL || 'gpt-4o-mini-tts';
|
|
113
|
+
const OPENAI_TTS_FORMAT = process.env.OPENAI_TTS_FORMAT || process.env.REACT_APP_OPENAI_TTS_FORMAT || 'mp3';
|
|
114
|
+
const SARVAM_TTS_ENDPOINT = process.env.SARVAM_TTS_ENDPOINT || process.env.REACT_APP_SARVAM_TTS_ENDPOINT || 'https://api.sarvam.ai/text-to-speech';
|
|
115
|
+
const SARVAM_TTS_MODEL = process.env.SARVAM_TTS_MODEL || process.env.REACT_APP_SARVAM_TTS_MODEL || 'bulbul:v2';
|
|
116
|
+
const SARVAM_TARGET_LANGUAGE_CODE = process.env.SARVAM_TARGET_LANGUAGE_CODE || process.env.REACT_APP_SARVAM_TARGET_LANGUAGE_CODE || 'en-IN';
|
|
117
|
+
const SARVAM_OUTPUT_AUDIO_CODEC = process.env.SARVAM_OUTPUT_AUDIO_CODEC || process.env.REACT_APP_SARVAM_OUTPUT_AUDIO_CODEC || 'wav';
|
|
118
|
+
const NARRATION_CONTROLS_ENABLED = false;
|
|
119
|
+
|
|
120
|
+
function getFromStorage(storageKey) {
|
|
121
|
+
if (typeof window === 'undefined' || !window.localStorage) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
return window.localStorage.getItem(storageKey);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getElevenLabsApiKey() {
|
|
133
|
+
return (
|
|
134
|
+
process.env.ELEVEN_LABS_KEY ||
|
|
135
|
+
process.env.ELEVENLABS_API_KEY ||
|
|
136
|
+
process.env.REACT_APP_ELEVEN_LABS_KEY ||
|
|
137
|
+
process.env.REACT_APP_ELEVENLABS_API_KEY ||
|
|
138
|
+
getFromStorage('eleven_labs_key') ||
|
|
139
|
+
getFromStorage('elevenlabs_api_key') ||
|
|
140
|
+
getFromStorage('ELEVEN_LABS_KEY') ||
|
|
141
|
+
getFromStorage('REACT_APP_ELEVEN_LABS_KEY') ||
|
|
142
|
+
getFromStorage('REACT_APP_ELEVENLABS_API_KEY')
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getGeminiApiKey() {
|
|
147
|
+
return (
|
|
148
|
+
process.env.GEMINI_API_KEY ||
|
|
149
|
+
process.env.REACT_APP_GEMINI_API_KEY ||
|
|
150
|
+
getFromStorage('gemini_api_key') ||
|
|
151
|
+
getFromStorage('GEMINI_API_KEY') ||
|
|
152
|
+
getFromStorage('REACT_APP_GEMINI_API_KEY')
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function getOpenAIApiKey() {
|
|
157
|
+
return (
|
|
158
|
+
process.env.OPEN_AI_KEY ||
|
|
159
|
+
process.env.OPENAI_API_KEY ||
|
|
160
|
+
process.env.REACT_APP_OPEN_AI_KEY ||
|
|
161
|
+
process.env.REACT_APP_OPENAI_API_KEY ||
|
|
162
|
+
getFromStorage('open_ai_key') ||
|
|
163
|
+
getFromStorage('openai_api_key') ||
|
|
164
|
+
getFromStorage('OPEN_AI_KEY') ||
|
|
165
|
+
getFromStorage('OPENAI_API_KEY') ||
|
|
166
|
+
getFromStorage('REACT_APP_OPEN_AI_KEY') ||
|
|
167
|
+
getFromStorage('REACT_APP_OPENAI_API_KEY')
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getSarvamApiKey() {
|
|
172
|
+
return (
|
|
173
|
+
process.env.SARVAM_API_KEY ||
|
|
174
|
+
process.env.REACT_APP_SARVAM_API_KEY ||
|
|
175
|
+
getFromStorage('sarvam_api_key') ||
|
|
176
|
+
getFromStorage('REACT_APP_SARVAM_API_KEY')
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function base64AudioToBlob(base64Audio = '', mimeType = 'audio/wav') {
|
|
181
|
+
const cleanedBase64 = base64Audio.includes(',') ? base64Audio.split(',').pop() : base64Audio;
|
|
182
|
+
const binaryString = typeof window !== 'undefined' && window.atob ? window.atob(cleanedBase64) : atob(cleanedBase64);
|
|
183
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
184
|
+
|
|
185
|
+
for (let index = 0; index < binaryString.length; index += 1) {
|
|
186
|
+
bytes[index] = binaryString.charCodeAt(index);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return new Blob([bytes], { type: mimeType });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function extractGeminiAudio(payload) {
|
|
193
|
+
const candidates = payload && payload.candidates ? payload.candidates : [];
|
|
194
|
+
|
|
195
|
+
for (const candidate of candidates) {
|
|
196
|
+
const parts = candidate && candidate.content && candidate.content.parts ? candidate.content.parts : [];
|
|
197
|
+
|
|
198
|
+
for (const part of parts) {
|
|
199
|
+
const inlineData = part.inlineData || part.inline_data || part.audio;
|
|
200
|
+
|
|
201
|
+
if (inlineData && inlineData.data) {
|
|
202
|
+
return {
|
|
203
|
+
mimeType: inlineData.mimeType || inlineData.mime_type || 'audio/wav',
|
|
204
|
+
data: inlineData.data,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function hashText(value = '') {
|
|
214
|
+
let hash = 0;
|
|
215
|
+
|
|
216
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
217
|
+
hash = (hash << 5) - hash + value.charCodeAt(index);
|
|
218
|
+
hash |= 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return Math.abs(hash);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function pickBySeed(items = [], seed = 0) {
|
|
225
|
+
if (!items.length) {
|
|
226
|
+
return '';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return items[seed % items.length];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function buildGuestStepGuide(step, index, total) {
|
|
233
|
+
if (!step) {
|
|
234
|
+
return {
|
|
235
|
+
welcome: STEP_WELCOME_LINES[0],
|
|
236
|
+
intro: STEP_INTRO_LINES[0],
|
|
237
|
+
expectation: 'We are preparing your consultation journey.',
|
|
238
|
+
comfort: STEP_COMFORT_LINES[0],
|
|
239
|
+
tip: STEP_TIP_LINES[0],
|
|
240
|
+
narration: '',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const stepName = step.step_name || `Step ${index + 1}`;
|
|
245
|
+
const seedSource = `${step.step_id || step.id || index}-${stepName}`;
|
|
246
|
+
const seed = hashText(seedSource);
|
|
247
|
+
|
|
248
|
+
const welcome = index === 0 ? STEP_WELCOME_LINES[0] : `Now entering ${stepName}.`;
|
|
249
|
+
const intro = index === 0 ? STEP_INTRO_LINES[0] : pickBySeed(STEP_INTRO_LINES, seed + 1);
|
|
250
|
+
const expectation = step.step_description || pickBySeed(STEP_EXPECTATION_LINES, seed + 2);
|
|
251
|
+
const comfort = pickBySeed(STEP_COMFORT_LINES, seed + 3);
|
|
252
|
+
const tip = pickBySeed(STEP_TIP_LINES, seed + 4);
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
welcome,
|
|
256
|
+
intro,
|
|
257
|
+
expectation,
|
|
258
|
+
comfort,
|
|
259
|
+
tip,
|
|
260
|
+
narration: [
|
|
261
|
+
`Step ${index + 1} of ${total}. ${stepName}.`,
|
|
262
|
+
welcome,
|
|
263
|
+
intro,
|
|
264
|
+
`What to expect: ${expectation}`,
|
|
265
|
+
`Comfort note: ${comfort}`,
|
|
266
|
+
`Helpful tip: ${tip}`,
|
|
267
|
+
].join(' '),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
22
270
|
|
|
23
271
|
export default function ProcessStepsPage({ match, CustomComponents = {}, ...props }) {
|
|
24
272
|
const allComponents = { ...genericComponents, ...CustomComponents };
|
|
273
|
+
const GuestInfoComponent = allComponents.EntryInfo;
|
|
25
274
|
|
|
26
275
|
const [loading, setLoading] = useState(false);
|
|
27
276
|
const [steps, setSteps] = useState([]);
|
|
@@ -32,22 +281,60 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
32
281
|
const [stepStartTime, setStepStartTime] = useState(null);
|
|
33
282
|
const [processStartTime, setProcessStartTime] = useState(null);
|
|
34
283
|
const [processTimings, setProcessTimings] = useState([]);
|
|
35
|
-
const [timelineCollapsed, setTimelineCollapsed] = useState(true);
|
|
36
284
|
const [showExternalWindow, setShowExternalWindow] = useState(false);
|
|
37
285
|
const [externalWin, setExternalWin] = useState(null);
|
|
286
|
+
const [autoNarration, setAutoNarration] = useState(NARRATION_CONTROLS_ENABLED);
|
|
287
|
+
const [voiceProvider, setVoiceProvider] = useState(
|
|
288
|
+
process.env.REACT_APP_STEP_TTS_PROVIDER && process.env.REACT_APP_STEP_TTS_PROVIDER !== 'browser'
|
|
289
|
+
? process.env.REACT_APP_STEP_TTS_PROVIDER
|
|
290
|
+
: 'gemini'
|
|
291
|
+
);
|
|
292
|
+
const [browserVoiceOptions, setBrowserVoiceOptions] = useState([]);
|
|
293
|
+
const [voiceSelections, setVoiceSelections] = useState({
|
|
294
|
+
browser: process.env.REACT_APP_STEP_BROWSER_VOICE || process.env.REACT_APP_STEP_TTS_VOICE || '',
|
|
295
|
+
gemini: DEFAULT_GEMINI_TTS_VOICE,
|
|
296
|
+
elevenlabs: DEFAULT_ELEVENLABS_VOICE_ID,
|
|
297
|
+
openai: DEFAULT_OPENAI_TTS_VOICE,
|
|
298
|
+
sarvam: process.env.REACT_APP_SARVAM_SPEAKER || SARVAM_VOICE_OPTIONS[0].value,
|
|
299
|
+
});
|
|
300
|
+
const [stepSlideDirection, setStepSlideDirection] = useState('forward');
|
|
301
|
+
const [showNextProcessAction, setShowNextProcessAction] = useState(false);
|
|
302
|
+
const [isStepFullscreen, setIsStepFullscreen] = useState(false);
|
|
303
|
+
const [realtimeStatus, setRealtimeStatus] = useState('idle');
|
|
304
|
+
|
|
305
|
+
const narrationUtteranceRef = useRef(null);
|
|
306
|
+
const narrationAudioRef = useRef(null);
|
|
307
|
+
const narrationAudioUrlRef = useRef(null);
|
|
308
|
+
const narrationFallbackNoticeRef = useRef(false);
|
|
309
|
+
const realtimeSessionRef = useRef(null);
|
|
310
|
+
const fullscreenViewportRef = useRef(null);
|
|
38
311
|
|
|
39
312
|
const urlParams = Location.search();
|
|
313
|
+
const isConsultationMode = String(urlParams?.consultation).toLowerCase() === 'true';
|
|
40
314
|
let processId = urlParams.processId;
|
|
41
315
|
const [currentProcessId, setCurrentProcessId] = useState(processId);
|
|
42
316
|
// Load process details based on the current process ID
|
|
43
317
|
useEffect(() => {
|
|
44
318
|
loadProcess(currentProcessId);
|
|
45
319
|
|
|
46
|
-
|
|
47
|
-
|
|
320
|
+
let savedTimings = [];
|
|
321
|
+
try {
|
|
322
|
+
const saved = localStorage.getItem(`processTimings_${currentProcessId}`);
|
|
323
|
+
if (saved) {
|
|
324
|
+
const parsed = JSON.parse(saved);
|
|
325
|
+
if (Array.isArray(parsed)) {
|
|
326
|
+
savedTimings = parsed;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.warn('Unable to restore process timings from local storage.', error);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
setProcessTimings(savedTimings);
|
|
48
334
|
|
|
49
335
|
setProcessStartTime(Date.now());
|
|
50
336
|
setStepStartTime(Date.now());
|
|
337
|
+
setShowNextProcessAction(false);
|
|
51
338
|
}, [currentProcessId]);
|
|
52
339
|
|
|
53
340
|
//// Reset step start time whenever the active step changes
|
|
@@ -63,10 +350,110 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
63
350
|
}
|
|
64
351
|
}, [activeStep, steps]);
|
|
65
352
|
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
if (steps[activeStep]?.order_seqtype !== 'E') {
|
|
355
|
+
setShowNextProcessAction(false);
|
|
356
|
+
}
|
|
357
|
+
}, [activeStep, steps]);
|
|
358
|
+
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
if (typeof window === 'undefined' || !window.speechSynthesis) {
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const updateBrowserVoices = () => {
|
|
365
|
+
const voices = window.speechSynthesis
|
|
366
|
+
.getVoices()
|
|
367
|
+
.map((voice) => ({
|
|
368
|
+
label: `${voice.name} (${voice.lang})`,
|
|
369
|
+
value: voice.voiceURI || voice.name,
|
|
370
|
+
}))
|
|
371
|
+
.sort((voiceA, voiceB) => voiceA.label.localeCompare(voiceB.label));
|
|
372
|
+
|
|
373
|
+
setBrowserVoiceOptions(voices);
|
|
374
|
+
|
|
375
|
+
if (voices.length) {
|
|
376
|
+
setVoiceSelections((oldSelections) => {
|
|
377
|
+
if (oldSelections.browser) {
|
|
378
|
+
return oldSelections;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
...oldSelections,
|
|
383
|
+
browser: voices[0].value,
|
|
384
|
+
};
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
updateBrowserVoices();
|
|
390
|
+
if (typeof window.speechSynthesis.addEventListener === 'function') {
|
|
391
|
+
window.speechSynthesis.addEventListener('voiceschanged', updateBrowserVoices);
|
|
392
|
+
} else {
|
|
393
|
+
window.speechSynthesis.onvoiceschanged = updateBrowserVoices;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return () => {
|
|
397
|
+
if (typeof window.speechSynthesis.removeEventListener === 'function') {
|
|
398
|
+
window.speechSynthesis.removeEventListener('voiceschanged', updateBrowserVoices);
|
|
399
|
+
} else if (window.speechSynthesis.onvoiceschanged === updateBrowserVoices) {
|
|
400
|
+
window.speechSynthesis.onvoiceschanged = null;
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
}, []);
|
|
404
|
+
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
narrationFallbackNoticeRef.current = false;
|
|
407
|
+
}, [voiceProvider]);
|
|
408
|
+
|
|
409
|
+
useEffect(() => {
|
|
410
|
+
const isSupportedProvider = VOICE_PROVIDER_OPTIONS.some((option) => option.value === voiceProvider);
|
|
411
|
+
|
|
412
|
+
if (!isSupportedProvider) {
|
|
413
|
+
setVoiceProvider('gemini');
|
|
414
|
+
}
|
|
415
|
+
}, [voiceProvider]);
|
|
416
|
+
|
|
417
|
+
useEffect(() => {
|
|
418
|
+
stopNarration();
|
|
419
|
+
}, [voiceProvider, voiceSelections.browser, voiceSelections.gemini, voiceSelections.elevenlabs, voiceSelections.openai, voiceSelections.sarvam]);
|
|
420
|
+
|
|
421
|
+
useEffect(() => {
|
|
422
|
+
const providerVoices =
|
|
423
|
+
voiceProvider === 'gemini'
|
|
424
|
+
? GEMINI_VOICE_OPTIONS
|
|
425
|
+
: voiceProvider === 'elevenlabs'
|
|
426
|
+
? ELEVENLABS_VOICE_OPTIONS
|
|
427
|
+
: voiceProvider === 'openai'
|
|
428
|
+
? OPENAI_TTS_VOICE_OPTIONS
|
|
429
|
+
: SARVAM_VOICE_OPTIONS;
|
|
430
|
+
|
|
431
|
+
if (!providerVoices.length) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
setVoiceSelections((oldSelections) => {
|
|
436
|
+
if (oldSelections[voiceProvider]) {
|
|
437
|
+
return oldSelections;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
...oldSelections,
|
|
442
|
+
[voiceProvider]: providerVoices[0].value,
|
|
443
|
+
};
|
|
444
|
+
});
|
|
445
|
+
}, [voiceProvider, browserVoiceOptions]);
|
|
446
|
+
|
|
66
447
|
// Save updated process timings to state and localStorage
|
|
67
448
|
const saveTimings = (updated) => {
|
|
68
|
-
|
|
69
|
-
|
|
449
|
+
const safeTimings = Array.isArray(updated) ? updated : [];
|
|
450
|
+
setProcessTimings(safeTimings);
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
localStorage.setItem(`processTimings_${currentProcessId}`, JSON.stringify(safeTimings));
|
|
454
|
+
} catch (error) {
|
|
455
|
+
console.warn('Unable to persist process timings to local storage.', error);
|
|
456
|
+
}
|
|
70
457
|
};
|
|
71
458
|
// Record time spent on the current step
|
|
72
459
|
|
|
@@ -81,7 +468,8 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
81
468
|
const stepId = steps[activeStep].step_id;
|
|
82
469
|
// Clone existing timings
|
|
83
470
|
|
|
84
|
-
const
|
|
471
|
+
const previousTimings = Array.isArray(processTimings) ? processTimings : [];
|
|
472
|
+
const updated = [...previousTimings];
|
|
85
473
|
const index = updated.findIndex((t) => t.step_id === stepId);
|
|
86
474
|
// Create timing entry for the step
|
|
87
475
|
|
|
@@ -153,7 +541,11 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
153
541
|
const response = await Dashboard.processLog(payload);
|
|
154
542
|
|
|
155
543
|
if (response.success) {
|
|
156
|
-
|
|
544
|
+
try {
|
|
545
|
+
localStorage.removeItem(`processTimings_${currentProcessId}`);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
console.warn('Unable to clear process timings from local storage.', error);
|
|
548
|
+
}
|
|
157
549
|
setProcessTimings([]);
|
|
158
550
|
return true;
|
|
159
551
|
}
|
|
@@ -173,9 +565,21 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
173
565
|
* - Navigates to the specified step index.
|
|
174
566
|
*/
|
|
175
567
|
const gotoStep = (index, status = 'completed') => {
|
|
568
|
+
if (!steps.length) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const nextIndex = Math.max(0, Math.min(index, steps.length - 1));
|
|
573
|
+
|
|
574
|
+
if (nextIndex === activeStep) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
setStepSlideDirection(nextIndex > activeStep ? 'forward' : 'backward');
|
|
579
|
+
|
|
176
580
|
const updated = recordStepTime(status);
|
|
177
581
|
saveTimings(updated);
|
|
178
|
-
setActiveStep(
|
|
582
|
+
setActiveStep(nextIndex);
|
|
179
583
|
};
|
|
180
584
|
/**
|
|
181
585
|
* Navigate to the next step
|
|
@@ -194,7 +598,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
194
598
|
*/
|
|
195
599
|
const handleSkip = () => gotoStep(activeStep + 1, 'skipped');
|
|
196
600
|
/**
|
|
197
|
-
*
|
|
601
|
+
* Breadcrumb Navigation
|
|
198
602
|
* - Navigates directly to the selected step.
|
|
199
603
|
* - Records timing data for the current step.
|
|
200
604
|
*/
|
|
@@ -226,6 +630,398 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
226
630
|
setShowExternalWindow(true);
|
|
227
631
|
}
|
|
228
632
|
};
|
|
633
|
+
|
|
634
|
+
function clearNarrationAudio() {
|
|
635
|
+
if (narrationAudioRef.current) {
|
|
636
|
+
narrationAudioRef.current.pause();
|
|
637
|
+
narrationAudioRef.current.src = '';
|
|
638
|
+
narrationAudioRef.current = null;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (narrationAudioUrlRef.current && typeof window !== 'undefined' && window.URL) {
|
|
642
|
+
window.URL.revokeObjectURL(narrationAudioUrlRef.current);
|
|
643
|
+
narrationAudioUrlRef.current = null;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function stopNarration() {
|
|
648
|
+
clearNarrationAudio();
|
|
649
|
+
|
|
650
|
+
if (typeof window !== 'undefined' && window.speechSynthesis) {
|
|
651
|
+
window.speechSynthesis.cancel();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
narrationUtteranceRef.current = null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function buildRealtimeInstructions() {
|
|
658
|
+
const step = steps[activeStep];
|
|
659
|
+
const stepName = step?.step_name || `Step ${activeStep + 1}`;
|
|
660
|
+
const stepDescription = step?.step_description || 'No additional description.';
|
|
661
|
+
|
|
662
|
+
return [
|
|
663
|
+
'You are a warm, concise healthcare concierge assisting a guest during a guided process.',
|
|
664
|
+
`Current step: ${stepName}.`,
|
|
665
|
+
`Step description: ${stepDescription}.`,
|
|
666
|
+
'Answer in short, helpful sentences and keep the guest calm and informed.',
|
|
667
|
+
'Avoid medical diagnosis or treatment advice.',
|
|
668
|
+
].join(' ');
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function startRealtimeConversation() {
|
|
672
|
+
if (realtimeSessionRef.current) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const session = createOpenAIRealtimeSession({
|
|
677
|
+
instructions: buildRealtimeInstructions(),
|
|
678
|
+
onStatus: (status) => {
|
|
679
|
+
setRealtimeStatus(status);
|
|
680
|
+
},
|
|
681
|
+
onError: (error) => {
|
|
682
|
+
console.error('OpenAI Realtime error:', error);
|
|
683
|
+
message.error(error?.message || 'OpenAI Realtime connection failed.');
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
realtimeSessionRef.current = session;
|
|
688
|
+
try {
|
|
689
|
+
await session.connect();
|
|
690
|
+
} catch (error) {
|
|
691
|
+
realtimeSessionRef.current = null;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function stopRealtimeConversation() {
|
|
696
|
+
if (realtimeSessionRef.current) {
|
|
697
|
+
realtimeSessionRef.current.disconnect();
|
|
698
|
+
realtimeSessionRef.current = null;
|
|
699
|
+
}
|
|
700
|
+
setRealtimeStatus('idle');
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function playAudioBlob(audioBlob) {
|
|
704
|
+
return new Promise((resolve, reject) => {
|
|
705
|
+
if (typeof window === 'undefined' || !window.Audio || !window.URL) {
|
|
706
|
+
reject(new Error('Audio playback is not available.'));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const audioUrl = window.URL.createObjectURL(audioBlob);
|
|
711
|
+
const audio = new window.Audio(audioUrl);
|
|
712
|
+
|
|
713
|
+
narrationAudioRef.current = audio;
|
|
714
|
+
narrationAudioUrlRef.current = audioUrl;
|
|
715
|
+
|
|
716
|
+
const cleanup = () => {
|
|
717
|
+
if (narrationAudioRef.current === audio) {
|
|
718
|
+
narrationAudioRef.current = null;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (narrationAudioUrlRef.current === audioUrl) {
|
|
722
|
+
window.URL.revokeObjectURL(audioUrl);
|
|
723
|
+
narrationAudioUrlRef.current = null;
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
audio.onended = () => {
|
|
728
|
+
cleanup();
|
|
729
|
+
resolve();
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
audio.onpause = () => {
|
|
733
|
+
cleanup();
|
|
734
|
+
resolve();
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
audio.onerror = () => {
|
|
738
|
+
cleanup();
|
|
739
|
+
reject(new Error('Audio playback failed.'));
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
audio.play().catch((error) => {
|
|
743
|
+
cleanup();
|
|
744
|
+
reject(error);
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function speakWithBrowser(text) {
|
|
750
|
+
return new Promise((resolve, reject) => {
|
|
751
|
+
if (typeof window === 'undefined' || !window.speechSynthesis || !window.SpeechSynthesisUtterance) {
|
|
752
|
+
reject(new Error('Speech synthesis is not available.'));
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const utterance = new window.SpeechSynthesisUtterance(text);
|
|
757
|
+
utterance.lang = process.env.REACT_APP_STEP_TTS_LANG || 'en-US';
|
|
758
|
+
|
|
759
|
+
const rate = Number(process.env.REACT_APP_STEP_TTS_RATE || 1);
|
|
760
|
+
const pitch = Number(process.env.REACT_APP_STEP_TTS_PITCH || 1);
|
|
761
|
+
|
|
762
|
+
utterance.rate = Number.isFinite(rate) ? rate : 1;
|
|
763
|
+
utterance.pitch = Number.isFinite(pitch) ? pitch : 1;
|
|
764
|
+
|
|
765
|
+
const selectedBrowserVoice = voiceSelections.browser;
|
|
766
|
+
if (selectedBrowserVoice) {
|
|
767
|
+
const browserVoice = window.speechSynthesis.getVoices().find((voice) => (voice.voiceURI || voice.name) === selectedBrowserVoice);
|
|
768
|
+
|
|
769
|
+
if (browserVoice) {
|
|
770
|
+
utterance.voice = browserVoice;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
utterance.onend = () => {
|
|
775
|
+
if (narrationUtteranceRef.current === utterance) {
|
|
776
|
+
narrationUtteranceRef.current = null;
|
|
777
|
+
}
|
|
778
|
+
resolve();
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
utterance.onerror = () => {
|
|
782
|
+
if (narrationUtteranceRef.current === utterance) {
|
|
783
|
+
narrationUtteranceRef.current = null;
|
|
784
|
+
}
|
|
785
|
+
reject(new Error('Browser narration failed.'));
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
narrationUtteranceRef.current = utterance;
|
|
789
|
+
window.speechSynthesis.speak(utterance);
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function synthesizeGeminiAudio(text) {
|
|
794
|
+
const apiKey = getGeminiApiKey();
|
|
795
|
+
|
|
796
|
+
if (!apiKey) {
|
|
797
|
+
throw new Error('Gemini API key is missing.');
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const selectedVoiceName = voiceSelections.gemini || DEFAULT_GEMINI_TTS_VOICE;
|
|
801
|
+
const endpoint = `${GEMINI_TTS_API_BASE_URL}/models/${GEMINI_TTS_MODEL}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
|
802
|
+
const response = await fetch(endpoint, {
|
|
803
|
+
method: 'POST',
|
|
804
|
+
headers: {
|
|
805
|
+
'Content-Type': 'application/json',
|
|
806
|
+
},
|
|
807
|
+
body: JSON.stringify({
|
|
808
|
+
contents: [
|
|
809
|
+
{
|
|
810
|
+
role: 'user',
|
|
811
|
+
parts: [{ text }],
|
|
812
|
+
},
|
|
813
|
+
],
|
|
814
|
+
generationConfig: {
|
|
815
|
+
responseModalities: ['AUDIO'],
|
|
816
|
+
speechConfig: {
|
|
817
|
+
voiceConfig: {
|
|
818
|
+
prebuiltVoiceConfig: {
|
|
819
|
+
voiceName: selectedVoiceName,
|
|
820
|
+
},
|
|
821
|
+
},
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
}),
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
if (!response.ok) {
|
|
828
|
+
throw new Error(`Gemini TTS request failed with status ${response.status}.`);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const payload = await response.json();
|
|
832
|
+
const audio = extractGeminiAudio(payload);
|
|
833
|
+
|
|
834
|
+
if (!audio || !audio.data) {
|
|
835
|
+
throw new Error('Gemini did not return audio data.');
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return base64AudioToBlob(audio.data, audio.mimeType || 'audio/wav');
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async function synthesizeOpenAIAudio(text) {
|
|
842
|
+
const apiKey = getOpenAIApiKey();
|
|
843
|
+
|
|
844
|
+
if (!apiKey) {
|
|
845
|
+
throw new Error('OpenAI API key is missing.');
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const selectedVoice = voiceSelections.openai || DEFAULT_OPENAI_TTS_VOICE;
|
|
849
|
+
const response = await fetch(OPENAI_TTS_ENDPOINT, {
|
|
850
|
+
method: 'POST',
|
|
851
|
+
headers: {
|
|
852
|
+
'Content-Type': 'application/json',
|
|
853
|
+
Authorization: `Bearer ${apiKey}`,
|
|
854
|
+
},
|
|
855
|
+
body: JSON.stringify({
|
|
856
|
+
model: OPENAI_TTS_MODEL,
|
|
857
|
+
voice: selectedVoice,
|
|
858
|
+
input: text,
|
|
859
|
+
response_format: OPENAI_TTS_FORMAT,
|
|
860
|
+
}),
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
if (!response.ok) {
|
|
864
|
+
throw new Error(`OpenAI TTS request failed with status ${response.status}.`);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return response.blob();
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function synthesizeElevenLabsAudio(text) {
|
|
871
|
+
const apiKey = getElevenLabsApiKey();
|
|
872
|
+
|
|
873
|
+
if (!apiKey) {
|
|
874
|
+
throw new Error('ElevenLabs API key is missing.');
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const selectedVoiceId = voiceSelections.elevenlabs || DEFAULT_ELEVENLABS_VOICE_ID;
|
|
878
|
+
const endpoint = `${ELEVENLABS_TTS_API_BASE_URL}/${encodeURIComponent(selectedVoiceId)}/stream?output_format=${encodeURIComponent(
|
|
879
|
+
ELEVENLABS_OUTPUT_FORMAT
|
|
880
|
+
)}`;
|
|
881
|
+
const response = await fetch(endpoint, {
|
|
882
|
+
method: 'POST',
|
|
883
|
+
headers: {
|
|
884
|
+
'Content-Type': 'application/json',
|
|
885
|
+
Accept: 'audio/mpeg',
|
|
886
|
+
'xi-api-key': apiKey,
|
|
887
|
+
},
|
|
888
|
+
body: JSON.stringify({
|
|
889
|
+
text,
|
|
890
|
+
model_id: ELEVENLABS_MODEL_ID,
|
|
891
|
+
}),
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
if (!response.ok) {
|
|
895
|
+
throw new Error(`ElevenLabs TTS request failed with status ${response.status}.`);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
return response.blob();
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function synthesizeSarvamAudio(text) {
|
|
902
|
+
const apiKey = getSarvamApiKey();
|
|
903
|
+
|
|
904
|
+
if (!apiKey) {
|
|
905
|
+
throw new Error('Sarvam API key is missing.');
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const selectedSpeaker = voiceSelections.sarvam || SARVAM_VOICE_OPTIONS[0].value;
|
|
909
|
+
const response = await fetch(SARVAM_TTS_ENDPOINT, {
|
|
910
|
+
method: 'POST',
|
|
911
|
+
headers: {
|
|
912
|
+
'Content-Type': 'application/json',
|
|
913
|
+
'api-subscription-key': apiKey,
|
|
914
|
+
},
|
|
915
|
+
body: JSON.stringify({
|
|
916
|
+
text,
|
|
917
|
+
target_language_code: SARVAM_TARGET_LANGUAGE_CODE,
|
|
918
|
+
model: SARVAM_TTS_MODEL,
|
|
919
|
+
speaker: selectedSpeaker,
|
|
920
|
+
output_audio_codec: SARVAM_OUTPUT_AUDIO_CODEC,
|
|
921
|
+
}),
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
const payload = await response.json().catch(() => null);
|
|
925
|
+
|
|
926
|
+
if (!response.ok) {
|
|
927
|
+
throw new Error(`Sarvam TTS request failed with status ${response.status}.`);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const audioBase64 = payload?.audios?.[0];
|
|
931
|
+
if (!audioBase64) {
|
|
932
|
+
throw new Error('Sarvam did not return any audio data.');
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const codec = (SARVAM_OUTPUT_AUDIO_CODEC || '').toLowerCase();
|
|
936
|
+
const mimeType = codec === 'mp3' ? 'audio/mpeg' : 'audio/wav';
|
|
937
|
+
|
|
938
|
+
return base64AudioToBlob(audioBase64, mimeType);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function speakText(text) {
|
|
942
|
+
if (!text || typeof window === 'undefined') {
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
stopNarration();
|
|
947
|
+
|
|
948
|
+
if (voiceProvider === 'gemini') {
|
|
949
|
+
const geminiAudio = await synthesizeGeminiAudio(text);
|
|
950
|
+
await playAudioBlob(geminiAudio);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (voiceProvider === 'elevenlabs') {
|
|
955
|
+
const elevenLabsAudio = await synthesizeElevenLabsAudio(text);
|
|
956
|
+
await playAudioBlob(elevenLabsAudio);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (voiceProvider === 'openai') {
|
|
961
|
+
const openAiAudio = await synthesizeOpenAIAudio(text);
|
|
962
|
+
await playAudioBlob(openAiAudio);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
if (voiceProvider === 'sarvam') {
|
|
967
|
+
const sarvamAudio = await synthesizeSarvamAudio(text);
|
|
968
|
+
await playAudioBlob(sarvamAudio);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
throw new Error('Browser narration is disabled. Use Gemini, ElevenLabs, or OpenAI.');
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function speakCurrentStep() {
|
|
976
|
+
const step = steps[activeStep];
|
|
977
|
+
const guide = buildGuestStepGuide(step, activeStep, steps.length);
|
|
978
|
+
|
|
979
|
+
try {
|
|
980
|
+
await speakText(guide.narration);
|
|
981
|
+
} catch (error) {
|
|
982
|
+
if (!narrationFallbackNoticeRef.current) {
|
|
983
|
+
const providerLabel =
|
|
984
|
+
voiceProvider === 'gemini'
|
|
985
|
+
? 'Gemini'
|
|
986
|
+
: voiceProvider === 'elevenlabs'
|
|
987
|
+
? 'ElevenLabs'
|
|
988
|
+
: voiceProvider === 'openai'
|
|
989
|
+
? 'OpenAI'
|
|
990
|
+
: 'Selected provider';
|
|
991
|
+
message.warning(`${providerLabel} narration failed.`);
|
|
992
|
+
narrationFallbackNoticeRef.current = true;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
message.error(error?.message || 'Unable to play narration for this step.');
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function toggleStepFullscreen() {
|
|
1000
|
+
if (typeof document === 'undefined') {
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const targetElement = fullscreenViewportRef.current;
|
|
1005
|
+
|
|
1006
|
+
if (!targetElement || !targetElement.requestFullscreen) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
try {
|
|
1011
|
+
if (document.fullscreenElement === targetElement) {
|
|
1012
|
+
await document.exitFullscreen();
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (document.fullscreenElement) {
|
|
1017
|
+
await document.exitFullscreen();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
await targetElement.requestFullscreen();
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
console.error('Failed to toggle step fullscreen mode:', error);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
229
1025
|
/**
|
|
230
1026
|
* Dynamic Step Renderer
|
|
231
1027
|
* - Resolves and renders step-specific components dynamically.
|
|
@@ -270,50 +1066,275 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
270
1066
|
};
|
|
271
1067
|
}, [activeStep, steps, externalWin]);
|
|
272
1068
|
|
|
1069
|
+
useEffect(() => {
|
|
1070
|
+
if (typeof document === 'undefined') {
|
|
1071
|
+
return undefined;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const handleFullscreenChange = () => {
|
|
1075
|
+
setIsStepFullscreen(document.fullscreenElement === fullscreenViewportRef.current);
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
|
1079
|
+
handleFullscreenChange();
|
|
1080
|
+
|
|
1081
|
+
return () => {
|
|
1082
|
+
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
|
1083
|
+
};
|
|
1084
|
+
}, []);
|
|
1085
|
+
|
|
1086
|
+
useEffect(() => {
|
|
1087
|
+
if (!NARRATION_CONTROLS_ENABLED || !autoNarration) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (loading || !steps.length || !steps[activeStep]) {
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
speakCurrentStep();
|
|
1096
|
+
}, [
|
|
1097
|
+
activeStep,
|
|
1098
|
+
steps,
|
|
1099
|
+
loading,
|
|
1100
|
+
autoNarration,
|
|
1101
|
+
voiceProvider,
|
|
1102
|
+
voiceSelections.browser,
|
|
1103
|
+
voiceSelections.elevenlabs,
|
|
1104
|
+
voiceSelections.openai,
|
|
1105
|
+
voiceSelections.sarvam,
|
|
1106
|
+
]);
|
|
1107
|
+
|
|
1108
|
+
useEffect(() => {
|
|
1109
|
+
const session = realtimeSessionRef.current;
|
|
1110
|
+
if (!session || session.status !== 'connected') {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
session.sendEvent({
|
|
1115
|
+
type: 'session.update',
|
|
1116
|
+
session: {
|
|
1117
|
+
instructions: buildRealtimeInstructions(),
|
|
1118
|
+
},
|
|
1119
|
+
});
|
|
1120
|
+
}, [activeStep, steps]);
|
|
1121
|
+
|
|
1122
|
+
useEffect(() => {
|
|
1123
|
+
return () => {
|
|
1124
|
+
stopNarration();
|
|
1125
|
+
stopRealtimeConversation();
|
|
1126
|
+
};
|
|
1127
|
+
}, []);
|
|
1128
|
+
|
|
273
1129
|
/**
|
|
274
|
-
* Renders the main process UI including
|
|
1130
|
+
* Renders the main process UI including breadcrumb, step details,
|
|
275
1131
|
* and action buttons. This content is reused in both normal view
|
|
276
1132
|
* and external window view.
|
|
277
1133
|
*/
|
|
278
|
-
const renderContent = () =>
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
1134
|
+
const renderContent = () => {
|
|
1135
|
+
const currentStep = steps[activeStep];
|
|
1136
|
+
const isFinalStep = currentStep?.order_seqtype === 'E';
|
|
1137
|
+
const currentVoiceOptions =
|
|
1138
|
+
voiceProvider === 'gemini'
|
|
1139
|
+
? GEMINI_VOICE_OPTIONS
|
|
1140
|
+
: voiceProvider === 'elevenlabs'
|
|
1141
|
+
? ELEVENLABS_VOICE_OPTIONS
|
|
1142
|
+
: voiceProvider === 'openai'
|
|
1143
|
+
? OPENAI_TTS_VOICE_OPTIONS
|
|
1144
|
+
: SARVAM_VOICE_OPTIONS;
|
|
1145
|
+
const currentVoiceValue = voiceSelections[voiceProvider] || undefined;
|
|
1146
|
+
const openAiTokenEndpoint = process.env.OPENAI_REALTIME_TOKEN_ENDPOINT || process.env.REACT_APP_OPENAI_REALTIME_TOKEN_ENDPOINT;
|
|
1147
|
+
const canStartRealtime = hasOpenAIRealtimeCredentials(openAiTokenEndpoint);
|
|
1148
|
+
|
|
1149
|
+
return (
|
|
1150
|
+
<div className="process-steps-page">
|
|
1151
|
+
<div ref={fullscreenViewportRef} className="steps-viewport">
|
|
1152
|
+
<Card className="steps-main-card">
|
|
1153
|
+
<div className="steps-header">
|
|
1154
|
+
<div className="steps-breadcrumb-strip">
|
|
1155
|
+
{steps.length ? (
|
|
1156
|
+
steps.map((stepItem, stepIndex) => {
|
|
1157
|
+
const isActiveBreadcrumb = stepIndex === activeStep;
|
|
1158
|
+
const isCompletedBreadcrumb = stepIndex < activeStep;
|
|
1159
|
+
|
|
1160
|
+
return (
|
|
1161
|
+
<button
|
|
1162
|
+
key={stepItem.step_id || `${stepItem.step_name || 'step'}_${stepIndex}`}
|
|
1163
|
+
type="button"
|
|
1164
|
+
className={`steps-breadcrumb-item${isActiveBreadcrumb ? ' active' : ''}${isCompletedBreadcrumb ? ' completed' : ''}`}
|
|
1165
|
+
onClick={() => handleTimelineClick(stepIndex)}
|
|
1166
|
+
>
|
|
1167
|
+
<span className="steps-breadcrumb-index">{stepIndex + 1}</span>
|
|
1168
|
+
<span className="steps-breadcrumb-label">{stepItem.step_name || `Step ${stepIndex + 1}`}</span>
|
|
1169
|
+
</button>
|
|
1170
|
+
);
|
|
1171
|
+
})
|
|
1172
|
+
) : (
|
|
1173
|
+
<span className="steps-breadcrumb-empty">No steps loaded</span>
|
|
1174
|
+
)}
|
|
1175
|
+
</div>
|
|
1176
|
+
|
|
1177
|
+
<div className="steps-header-actions">
|
|
1178
|
+
<Button disabled={activeStep === 0} onClick={handlePrevious}>
|
|
1179
|
+
Back
|
|
1180
|
+
</Button>
|
|
1181
|
+
|
|
1182
|
+
{isFinalStep ? (
|
|
1183
|
+
<>
|
|
1184
|
+
{!showNextProcessAction && (
|
|
1185
|
+
<Button
|
|
1186
|
+
type="primary"
|
|
1187
|
+
onClick={async () => {
|
|
1188
|
+
const success = await handleFinish();
|
|
1189
|
+
|
|
1190
|
+
if (success && nextProcessId?.next_process_id) {
|
|
1191
|
+
setShowNextProcessAction(true);
|
|
1192
|
+
}
|
|
1193
|
+
}}
|
|
1194
|
+
>
|
|
1195
|
+
Finish
|
|
1196
|
+
</Button>
|
|
1197
|
+
)}
|
|
1198
|
+
|
|
1199
|
+
{showNextProcessAction && nextProcessId?.next_process_id && (
|
|
1200
|
+
<Button type="primary" onClick={handleStartNextProcess}>
|
|
1201
|
+
Start {nextProcessId.next_process_name}
|
|
1202
|
+
</Button>
|
|
1203
|
+
)}
|
|
1204
|
+
</>
|
|
1205
|
+
) : (
|
|
1206
|
+
<Button type="primary" disabled={activeStep === steps.length - 1 || !isStepCompleted} onClick={handleNext}>
|
|
1207
|
+
Next
|
|
1208
|
+
</Button>
|
|
1209
|
+
)}
|
|
1210
|
+
|
|
1211
|
+
{steps.length > 0 && currentStep?.allow_skip === 'Y' && (
|
|
1212
|
+
<Button type="default" onClick={handleSkip} disabled={activeStep === steps.length - 1}>
|
|
1213
|
+
Skip
|
|
1214
|
+
</Button>
|
|
1215
|
+
)}
|
|
1216
|
+
|
|
1217
|
+
{NARRATION_CONTROLS_ENABLED ? (
|
|
1218
|
+
<>
|
|
1219
|
+
<Select
|
|
1220
|
+
className="steps-voice-provider-select"
|
|
1221
|
+
value={voiceProvider}
|
|
1222
|
+
options={VOICE_PROVIDER_OPTIONS}
|
|
1223
|
+
onChange={(value) => setVoiceProvider(value)}
|
|
1224
|
+
/>
|
|
1225
|
+
|
|
1226
|
+
<Select
|
|
1227
|
+
className="steps-voice-select"
|
|
1228
|
+
value={currentVoiceValue}
|
|
1229
|
+
options={currentVoiceOptions}
|
|
1230
|
+
onChange={(value) =>
|
|
1231
|
+
setVoiceSelections((oldSelections) => ({
|
|
1232
|
+
...oldSelections,
|
|
1233
|
+
[voiceProvider]: value,
|
|
1234
|
+
}))
|
|
1235
|
+
}
|
|
1236
|
+
placeholder="Select Voice"
|
|
1237
|
+
optionFilterProp="label"
|
|
1238
|
+
showSearch
|
|
1239
|
+
disabled={!currentVoiceOptions.length}
|
|
1240
|
+
/>
|
|
1241
|
+
|
|
1242
|
+
<Button icon={<SoundOutlined />} onClick={speakCurrentStep} disabled={!currentStep}>
|
|
1243
|
+
Read Step
|
|
1244
|
+
</Button>
|
|
1245
|
+
|
|
1246
|
+
<Button
|
|
1247
|
+
type={realtimeStatus === 'connected' ? 'default' : 'primary'}
|
|
1248
|
+
disabled={!canStartRealtime}
|
|
1249
|
+
onClick={() => {
|
|
1250
|
+
if (realtimeStatus === 'connected' || realtimeStatus === 'connecting') {
|
|
1251
|
+
stopRealtimeConversation();
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
startRealtimeConversation();
|
|
1255
|
+
}}
|
|
1256
|
+
>
|
|
1257
|
+
{realtimeStatus === 'connected'
|
|
1258
|
+
? 'Stop Conversation'
|
|
1259
|
+
: realtimeStatus === 'connecting'
|
|
1260
|
+
? 'Connecting...'
|
|
1261
|
+
: 'Start Conversation'}
|
|
1262
|
+
</Button>
|
|
1263
|
+
|
|
1264
|
+
<Button onClick={() => setAutoNarration((oldValue) => !oldValue)}>Auto Narration: {autoNarration ? 'On' : 'Off'}</Button>
|
|
1265
|
+
</>
|
|
1266
|
+
) : null}
|
|
1267
|
+
|
|
1268
|
+
<Button icon={isStepFullscreen ? <CompressOutlined /> : <ExpandOutlined />} onClick={toggleStepFullscreen}>
|
|
1269
|
+
{isStepFullscreen ? 'Exit Full Screen' : 'Full Screen'}
|
|
1270
|
+
</Button>
|
|
1271
|
+
</div>
|
|
297
1272
|
</div>
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
1273
|
+
|
|
1274
|
+
<Row gutter={[16, 16]} className="steps-layout">
|
|
1275
|
+
<Col xs={24} sm={24} lg={16} xl={17} className="steps-content-column">
|
|
1276
|
+
<div className={`steps-content-panel steps-stage-panel${isStepFullscreen ? ' is-fullscreen' : ''}`}>
|
|
1277
|
+
<div className="steps-stage-body">
|
|
1278
|
+
<div
|
|
1279
|
+
key={`${currentProcessId}_${activeStep}`}
|
|
1280
|
+
className={`steps-chat-step-card ${stepSlideDirection === 'backward' ? 'slide-backward' : 'slide-forward'}`}
|
|
1281
|
+
>
|
|
1282
|
+
<div className="steps-chat-step-top">
|
|
1283
|
+
<span className="steps-index-pill">
|
|
1284
|
+
Step {Math.min(activeStep + 1, steps.length || 1)} of {steps.length || 1}
|
|
1285
|
+
</span>
|
|
1286
|
+
<h2 className="steps-title">{currentStep?.step_name || 'No step selected'}</h2>
|
|
1287
|
+
{currentStep?.step_description ? <p className="steps-description">{currentStep.step_description}</p> : null}
|
|
1288
|
+
</div>
|
|
1289
|
+
|
|
1290
|
+
<div className="steps-chat-step-component">
|
|
1291
|
+
{loading ? (
|
|
1292
|
+
<div className="steps-chat-loading">
|
|
1293
|
+
<Spin />
|
|
1294
|
+
</div>
|
|
1295
|
+
) : null}
|
|
1296
|
+
{!loading ? <DynamicComponent /> : null}
|
|
1297
|
+
</div>
|
|
1298
|
+
</div>
|
|
1299
|
+
</div>
|
|
1300
|
+
</div>
|
|
1301
|
+
</Col>
|
|
1302
|
+
|
|
1303
|
+
<Col xs={24} sm={24} lg={8} xl={7} className="steps-guest-column">
|
|
1304
|
+
<div className="steps-guest-panel">
|
|
1305
|
+
<div className="steps-guest-body">
|
|
1306
|
+
<div className="steps-guest-highlight">
|
|
1307
|
+
<div className="steps-guest-name">{urlParams?.opb_name || '-'}</div>
|
|
1308
|
+
<div className="steps-guest-meta">
|
|
1309
|
+
OP: {urlParams?.opb_opno || urlParams?.opno || '-'} | Order: {urlParams?.opb_no || urlParams?.opb_bno || '-'}
|
|
1310
|
+
</div>
|
|
1311
|
+
</div>
|
|
1312
|
+
|
|
1313
|
+
{GuestInfoComponent ? (
|
|
1314
|
+
<GuestInfoComponent params={urlParams} />
|
|
1315
|
+
) : (
|
|
1316
|
+
<div className="steps-guest-fallback">
|
|
1317
|
+
<p className="steps-guest-fallback-text">Information card is unavailable in this context. Basic details are shown below.</p>
|
|
1318
|
+
<p>
|
|
1319
|
+
<strong>Name:</strong> {urlParams?.opb_name || '-'}
|
|
1320
|
+
</p>
|
|
1321
|
+
<p>
|
|
1322
|
+
<strong>Order:</strong> {urlParams?.opb_no || urlParams?.opb_bno || '-'}
|
|
1323
|
+
</p>
|
|
1324
|
+
<p>
|
|
1325
|
+
<strong>OP No:</strong> {urlParams?.opno || urlParams?.opb_opno || '-'}
|
|
1326
|
+
</p>
|
|
1327
|
+
</div>
|
|
1328
|
+
)}
|
|
1329
|
+
</div>
|
|
1330
|
+
</div>
|
|
1331
|
+
</Col>
|
|
1332
|
+
</Row>
|
|
1333
|
+
</Card>
|
|
1334
|
+
</div>
|
|
1335
|
+
</div>
|
|
1336
|
+
);
|
|
1337
|
+
};
|
|
317
1338
|
/**
|
|
318
1339
|
* Renders content in both the main window and an external window
|
|
319
1340
|
* when external window mode is enabled.
|
|
@@ -333,9 +1354,9 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
333
1354
|
width={props.ExternalWindowWidth || 1000}
|
|
334
1355
|
height={props.ExternalWindowHeight || 1000}
|
|
335
1356
|
>
|
|
336
|
-
{renderContent()}
|
|
1357
|
+
{renderContent(false)}
|
|
337
1358
|
</ExternalWindow>
|
|
338
|
-
{renderContent()}
|
|
1359
|
+
{renderContent(true)}
|
|
339
1360
|
</>
|
|
340
1361
|
);
|
|
341
1362
|
}
|