ui-soxo-bootstrap-core 2.6.1-dev.16 → 2.6.1-dev.18
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/landing-api/landing-api.js +29 -12
- package/core/components/license-management/license-alert.js +97 -0
- package/core/lib/components/global-header/animations.js +78 -4
- package/core/lib/components/global-header/global-header.js +49 -23
- 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 +175 -55
- package/core/lib/components/sidemenu/sidemenu.scss +221 -14
- 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/login/login.js +20 -37
- package/core/lib/utils/common/common.utils.js +0 -35
- package/core/models/menus/menus.js +6 -0
- package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.js +0 -1
- package/core/modules/steps/action-buttons.js +60 -46
- package/core/modules/steps/action-buttons.scss +45 -34
- 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 +1063 -85
- package/core/modules/steps/steps.scss +462 -280
- package/core/modules/steps/voice-navigation.js +709 -0
- package/package.json +1 -1
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
* Handles navigation and action controls for a multi-step process,
|
|
4
4
|
* including dynamic content rendering and process completion actions.
|
|
5
5
|
*/
|
|
6
|
-
import React, {
|
|
6
|
+
import React, { useEffect, useState } from 'react';
|
|
7
7
|
import { Skeleton } from 'antd';
|
|
8
8
|
import { Button } from '../../lib';
|
|
9
9
|
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,62 +22,76 @@ 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
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!isEndStep) {
|
|
33
|
+
setShowNextProcess(false);
|
|
34
|
+
}
|
|
35
|
+
}, [isEndStep]);
|
|
28
36
|
|
|
29
37
|
useEffect(() => {
|
|
30
38
|
setShowNextProcess(false);
|
|
31
39
|
}, [steps]);
|
|
32
40
|
|
|
33
41
|
return (
|
|
34
|
-
<div className="action-buttons-
|
|
35
|
-
<div className="action-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
<div className="action-buttons-shell">
|
|
43
|
+
<div className="action-body">
|
|
44
|
+
{loading ? <Skeleton active /> : typeof renderDynamicComponent === 'function' ? renderDynamicComponent() : null}
|
|
45
|
+
</div>
|
|
46
|
+
<div className="action-footer">
|
|
47
|
+
<div className="action-buttons-container">
|
|
48
|
+
{/* Back button */}
|
|
49
|
+
<Button disabled={activeStep <= 0} onClick={handlePrevious}>
|
|
50
|
+
Back
|
|
51
|
+
</Button>
|
|
41
52
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
53
|
+
{typeof onToggleFullscreen === 'function' && (
|
|
54
|
+
<Button type="default" onClick={onToggleFullscreen}>
|
|
55
|
+
{isFullscreen ? 'Exit Full Screen' : 'Switch to Full Screen'}
|
|
56
|
+
</Button>
|
|
57
|
+
)}
|
|
45
58
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
{/* Skip button */}
|
|
60
|
+
{steps.length > 0 && currentStep?.allow_skip === 'Y' && (
|
|
61
|
+
<Button type="default" onClick={handleSkip} disabled={isLastStep}>
|
|
62
|
+
Skip
|
|
63
|
+
</Button>
|
|
64
|
+
)}
|
|
52
65
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
{/* Next / Finish / Start Next */}
|
|
67
|
+
{isEndStep ? (
|
|
68
|
+
<>
|
|
69
|
+
{!showNextProcess && (
|
|
70
|
+
<Button
|
|
71
|
+
type="primary"
|
|
72
|
+
onClick={async () => {
|
|
73
|
+
const success = typeof handleFinish === 'function' ? await handleFinish() : false;
|
|
74
|
+
if (success && nextProcessId?.next_process_id) {
|
|
75
|
+
setShowNextProcess(true);
|
|
76
|
+
}
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
Finish
|
|
80
|
+
</Button>
|
|
81
|
+
)}
|
|
69
82
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
{showNextProcess && nextProcessId?.next_process_id && (
|
|
84
|
+
<Button type="primary" onClick={handleStartNextProcess}>
|
|
85
|
+
Start {nextProcessId.next_process_name}
|
|
86
|
+
</Button>
|
|
87
|
+
)}
|
|
88
|
+
</>
|
|
89
|
+
) : (
|
|
90
|
+
<Button type="primary" disabled={steps.length === 0 || isLastStep || !isStepCompleted} onClick={handleNext}>
|
|
91
|
+
Next →
|
|
92
|
+
</Button>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
81
95
|
</div>
|
|
82
96
|
</div>
|
|
83
97
|
);
|
|
@@ -1,51 +1,62 @@
|
|
|
1
|
-
.action-buttons-
|
|
2
|
-
display:
|
|
3
|
-
|
|
1
|
+
.action-buttons-shell {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
4
|
flex: 1;
|
|
5
|
-
min-height:
|
|
6
|
-
overflow: hidden;
|
|
5
|
+
min-height: 100%;
|
|
7
6
|
}
|
|
8
7
|
|
|
9
|
-
.action-
|
|
8
|
+
.action-body {
|
|
9
|
+
flex: 1 1 auto;
|
|
10
10
|
min-height: 0;
|
|
11
|
-
overflow-y: auto;
|
|
12
|
-
overflow-x: hidden;
|
|
13
|
-
overscroll-behavior: contain;
|
|
14
|
-
|
|
15
11
|
padding-bottom: 8px;
|
|
12
|
+
overflow: auto;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.action-footer {
|
|
16
|
+
position: sticky;
|
|
17
|
+
bottom: 0;
|
|
18
|
+
margin-top: auto;
|
|
19
|
+
z-index: 8;
|
|
20
|
+
background: linear-gradient(180deg, rgba(251, 253, 255, 0) 0%, #fbfdff 32%, #fbfdff 100%);
|
|
21
|
+
padding-top: 12px;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
.action-buttons-container {
|
|
19
|
-
|
|
20
|
-
flex-shrink: 0;
|
|
21
|
-
// display: flex;
|
|
25
|
+
display: flex;
|
|
22
26
|
justify-content: flex-start;
|
|
23
27
|
gap: 10px;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
background: #fff;
|
|
27
|
-
padding: 12px 0 10px;
|
|
28
|
-
border-top: 1px solid #f0f0f0;
|
|
29
|
-
box-shadow: 0 -2px 10px rgba(15, 23, 42, 0.04);
|
|
30
|
-
|
|
31
|
-
width: 61%;
|
|
32
|
-
padding: 10px;
|
|
33
|
-
position: fixed;
|
|
34
|
-
bottom: 10px;
|
|
35
|
-
border: 1px solid #f1f1f1;
|
|
36
|
-
margin: flx;
|
|
37
|
-
display: flex;
|
|
38
|
-
border-radius: 4px;
|
|
39
|
-
box-shadow: -1px -4px 10px 2px #f7f7f76e;
|
|
28
|
+
padding: 12px 0 14px;
|
|
29
|
+
border-top: 1px solid #edf2f8;
|
|
40
30
|
flex-wrap: wrap;
|
|
41
31
|
|
|
42
32
|
.ant-btn {
|
|
43
|
-
border-radius:
|
|
33
|
+
border-radius: 8px;
|
|
34
|
+
min-width: 110px;
|
|
35
|
+
font-weight: 600;
|
|
44
36
|
}
|
|
45
37
|
}
|
|
46
38
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
@media (max-width: 992px) {
|
|
40
|
+
.action-body {
|
|
41
|
+
min-height: 180px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.action-footer {
|
|
45
|
+
position: static;
|
|
46
|
+
padding-top: 4px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.action-buttons-container {
|
|
50
|
+
padding: 10px 0 0;
|
|
51
|
+
border-top: 0;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@media (max-width: 576px) {
|
|
56
|
+
.action-buttons-container {
|
|
57
|
+
.ant-btn {
|
|
58
|
+
flex: 1 1 calc(50% - 10px);
|
|
59
|
+
min-width: 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
51
62
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
const GEMINI_CHAT_MODEL = process.env.GEMINI_CHAT_MODEL || process.env.REACT_APP_GEMINI_CHAT_MODEL || 'gemini-2.5-flash';
|
|
2
|
+
|
|
3
|
+
const GEMINI_API_BASE_URL =
|
|
4
|
+
process.env.GEMINI_API_BASE_URL || process.env.REACT_APP_GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta';
|
|
5
|
+
|
|
6
|
+
function getGeminiApiKey() {
|
|
7
|
+
if (process.env.GEMINI_API_KEY) {
|
|
8
|
+
return process.env.GEMINI_API_KEY;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (process.env.REACT_APP_GEMINI_API_KEY) {
|
|
12
|
+
return process.env.REACT_APP_GEMINI_API_KEY;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (typeof window !== 'undefined') {
|
|
16
|
+
try {
|
|
17
|
+
if (window.localStorage) {
|
|
18
|
+
return window.localStorage.getItem('gemini_api_key');
|
|
19
|
+
}
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeHistory(history = []) {
|
|
29
|
+
return history.slice(-10).map((item) => ({
|
|
30
|
+
role: item.role === 'assistant' ? 'model' : 'user',
|
|
31
|
+
parts: [{ text: item.text }],
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extractText(payload) {
|
|
36
|
+
const candidates = payload && payload.candidates ? payload.candidates : [];
|
|
37
|
+
|
|
38
|
+
for (const candidate of candidates) {
|
|
39
|
+
const parts = candidate && candidate.content && candidate.content.parts ? candidate.content.parts : [];
|
|
40
|
+
|
|
41
|
+
const text = parts
|
|
42
|
+
.map((part) => (part && typeof part.text === 'string' ? part.text : ''))
|
|
43
|
+
.join(' ')
|
|
44
|
+
.trim();
|
|
45
|
+
|
|
46
|
+
if (text) {
|
|
47
|
+
return text;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function generateText({ prompt, history = [], temperature = 0.65, maxOutputTokens = 360 }) {
|
|
55
|
+
const apiKey = getGeminiApiKey();
|
|
56
|
+
|
|
57
|
+
if (!apiKey) {
|
|
58
|
+
throw new Error('Gemini API key is missing');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const endpoint = `${GEMINI_API_BASE_URL}/models/${GEMINI_CHAT_MODEL}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
|
62
|
+
const contents = [...normalizeHistory(history), { role: 'user', parts: [{ text: prompt }] }];
|
|
63
|
+
|
|
64
|
+
const response = await fetch(endpoint, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: {
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
contents,
|
|
71
|
+
generationConfig: {
|
|
72
|
+
temperature,
|
|
73
|
+
maxOutputTokens,
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error(`Gemini chat request failed with status ${response.status}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const payload = await response.json();
|
|
83
|
+
const text = extractText(payload);
|
|
84
|
+
|
|
85
|
+
if (!text) {
|
|
86
|
+
throw new Error('Gemini chat response did not include text');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function generateStepAssistantMessage({ step, index, total, fallbackText, history = [] }) {
|
|
93
|
+
const stepName = (step && step.step_name) || `Step ${index + 1}`;
|
|
94
|
+
const stepDescription = (step && step.step_description) || '';
|
|
95
|
+
|
|
96
|
+
const prompt = [
|
|
97
|
+
'You are a warm healthcare concierge assistant inside a step-by-step consultation app.',
|
|
98
|
+
'Write a short, patient-friendly message in 3-4 sentences.',
|
|
99
|
+
'It must be reassuring, clear, and engaging.',
|
|
100
|
+
`Current step: ${index + 1} of ${total}.`,
|
|
101
|
+
`Step title: ${stepName}.`,
|
|
102
|
+
`Step details: ${stepDescription || 'No additional description'}.`,
|
|
103
|
+
'Explain what the guest can expect in this step and what happens next.',
|
|
104
|
+
'Avoid medical claims and avoid markdown bullets.',
|
|
105
|
+
].join(' ');
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
return await generateText({
|
|
109
|
+
prompt,
|
|
110
|
+
history,
|
|
111
|
+
temperature: 0.7,
|
|
112
|
+
maxOutputTokens: 260,
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return fallbackText;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function generateChatAssistantReply({ step, index, total, userMessage, history = [] }) {
|
|
120
|
+
const stepName = (step && step.step_name) || `Step ${index + 1}`;
|
|
121
|
+
const stepDescription = (step && step.step_description) || '';
|
|
122
|
+
|
|
123
|
+
const prompt = [
|
|
124
|
+
'You are a conversational healthcare onboarding assistant inside a guided consultation workflow.',
|
|
125
|
+
'Respond in 2-5 short sentences.',
|
|
126
|
+
'Stay patient-friendly, practical, and calm.',
|
|
127
|
+
`Current step: ${index + 1} of ${total}.`,
|
|
128
|
+
`Current step title: ${stepName}.`,
|
|
129
|
+
`Current step description: ${stepDescription || 'No additional description'}.`,
|
|
130
|
+
`Guest message: "${userMessage}".`,
|
|
131
|
+
'If the user asks about next or previous steps, explain the flow simply.',
|
|
132
|
+
'Do not provide diagnosis or treatment advice.',
|
|
133
|
+
].join(' ');
|
|
134
|
+
|
|
135
|
+
return generateText({
|
|
136
|
+
prompt,
|
|
137
|
+
history,
|
|
138
|
+
temperature: 0.6,
|
|
139
|
+
maxOutputTokens: 320,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
const DEFAULT_MODEL =
|
|
2
|
+
process.env.OPENAI_REALTIME_MODEL ||
|
|
3
|
+
process.env.REACT_APP_OPENAI_REALTIME_MODEL ||
|
|
4
|
+
'gpt-realtime';
|
|
5
|
+
const DEFAULT_VOICE =
|
|
6
|
+
process.env.OPENAI_REALTIME_VOICE ||
|
|
7
|
+
process.env.REACT_APP_OPENAI_REALTIME_VOICE ||
|
|
8
|
+
'alloy';
|
|
9
|
+
const DEFAULT_CALLS_ENDPOINT =
|
|
10
|
+
process.env.OPENAI_REALTIME_ENDPOINT ||
|
|
11
|
+
process.env.REACT_APP_OPENAI_REALTIME_ENDPOINT ||
|
|
12
|
+
'https://api.openai.com/v1/realtime/calls';
|
|
13
|
+
const DEFAULT_TOKEN_ENDPOINT =
|
|
14
|
+
process.env.OPENAI_REALTIME_TOKEN_ENDPOINT ||
|
|
15
|
+
process.env.REACT_APP_OPENAI_REALTIME_TOKEN_ENDPOINT ||
|
|
16
|
+
'';
|
|
17
|
+
const DEFAULT_INSTRUCTIONS =
|
|
18
|
+
process.env.OPENAI_REALTIME_INSTRUCTIONS ||
|
|
19
|
+
process.env.REACT_APP_OPENAI_REALTIME_INSTRUCTIONS ||
|
|
20
|
+
'You are a warm, concise healthcare concierge assisting a guest during a guided process.';
|
|
21
|
+
|
|
22
|
+
function getFromStorage(storageKey) {
|
|
23
|
+
if (typeof window === 'undefined' || !window.localStorage) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
return window.localStorage.getItem(storageKey);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveOpenAIKey() {
|
|
35
|
+
return (
|
|
36
|
+
process.env.OPENAI_API_KEY ||
|
|
37
|
+
process.env.OPEN_AI_KEY ||
|
|
38
|
+
process.env.REACT_APP_OPENAI_API_KEY ||
|
|
39
|
+
process.env.REACT_APP_OPEN_AI_KEY ||
|
|
40
|
+
getFromStorage('openai_api_key') ||
|
|
41
|
+
getFromStorage('open_ai_key') ||
|
|
42
|
+
getFromStorage('OPENAI_API_KEY') ||
|
|
43
|
+
getFromStorage('OPEN_AI_KEY') ||
|
|
44
|
+
getFromStorage('REACT_APP_OPENAI_API_KEY') ||
|
|
45
|
+
getFromStorage('REACT_APP_OPEN_AI_KEY') ||
|
|
46
|
+
null
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function fetchEphemeralKey(tokenEndpoint) {
|
|
51
|
+
const response = await fetch(tokenEndpoint, {
|
|
52
|
+
method: 'GET',
|
|
53
|
+
headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw new Error(`OpenAI token endpoint failed with status ${response.status}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const payload = await response.json().catch(() => null);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
payload?.client_secret?.value ||
|
|
64
|
+
payload?.session?.client_secret?.value ||
|
|
65
|
+
payload?.value ||
|
|
66
|
+
null
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function resolveRealtimeKey(tokenEndpoint) {
|
|
71
|
+
if (tokenEndpoint) {
|
|
72
|
+
const key = await fetchEphemeralKey(tokenEndpoint);
|
|
73
|
+
if (key) {
|
|
74
|
+
return key;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
throw new Error('OpenAI token endpoint did not return a client secret');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const key = resolveOpenAIKey();
|
|
81
|
+
if (key) {
|
|
82
|
+
return key;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new Error('OpenAI Realtime credentials are missing. Set OPEN_AI_KEY or configure OPENAI_REALTIME_TOKEN_ENDPOINT.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function hasOpenAIRealtimeCredentials(tokenEndpoint = DEFAULT_TOKEN_ENDPOINT) {
|
|
89
|
+
if (tokenEndpoint) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return Boolean(resolveOpenAIKey());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createOpenAIRealtimeSession({
|
|
97
|
+
model = DEFAULT_MODEL,
|
|
98
|
+
voice = DEFAULT_VOICE,
|
|
99
|
+
instructions = DEFAULT_INSTRUCTIONS,
|
|
100
|
+
callsEndpoint = DEFAULT_CALLS_ENDPOINT,
|
|
101
|
+
tokenEndpoint = DEFAULT_TOKEN_ENDPOINT,
|
|
102
|
+
onEvent = () => {},
|
|
103
|
+
onStatus = () => {},
|
|
104
|
+
onError = () => {},
|
|
105
|
+
} = {}) {
|
|
106
|
+
let peerConnection = null;
|
|
107
|
+
let dataChannel = null;
|
|
108
|
+
let localStream = null;
|
|
109
|
+
let audioElement = null;
|
|
110
|
+
|
|
111
|
+
const state = {
|
|
112
|
+
status: 'idle',
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function updateStatus(status) {
|
|
116
|
+
state.status = status;
|
|
117
|
+
onStatus(status);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sendEvent(eventPayload) {
|
|
121
|
+
if (!dataChannel || dataChannel.readyState !== 'open') {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
dataChannel.send(JSON.stringify(eventPayload));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function configureSession() {
|
|
129
|
+
sendEvent({
|
|
130
|
+
type: 'session.update',
|
|
131
|
+
session: {
|
|
132
|
+
instructions,
|
|
133
|
+
output_modalities: ['audio'],
|
|
134
|
+
audio: {
|
|
135
|
+
output: {
|
|
136
|
+
voice,
|
|
137
|
+
},
|
|
138
|
+
input: {
|
|
139
|
+
turn_detection: {
|
|
140
|
+
type: 'semantic_vad',
|
|
141
|
+
create_response: true,
|
|
142
|
+
interrupt_response: true,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function connect() {
|
|
151
|
+
if (peerConnection) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
updateStatus('connecting');
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
159
|
+
throw new Error('OpenAI Realtime requires a browser environment');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
163
|
+
throw new Error('Microphone access is not available in this browser');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const token = await resolveRealtimeKey(tokenEndpoint);
|
|
167
|
+
|
|
168
|
+
peerConnection = new RTCPeerConnection();
|
|
169
|
+
dataChannel = peerConnection.createDataChannel('oai-events');
|
|
170
|
+
|
|
171
|
+
dataChannel.addEventListener('message', (event) => {
|
|
172
|
+
try {
|
|
173
|
+
const payload = JSON.parse(event.data);
|
|
174
|
+
onEvent(payload);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
onError(error);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
dataChannel.addEventListener('open', () => {
|
|
181
|
+
configureSession();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
185
|
+
localStream.getTracks().forEach((track) => peerConnection.addTrack(track, localStream));
|
|
186
|
+
|
|
187
|
+
audioElement = document.createElement('audio');
|
|
188
|
+
audioElement.autoplay = true;
|
|
189
|
+
|
|
190
|
+
peerConnection.addEventListener('track', (event) => {
|
|
191
|
+
const stream = event.streams?.[0];
|
|
192
|
+
if (stream && audioElement.srcObject !== stream) {
|
|
193
|
+
audioElement.srcObject = stream;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const offer = await peerConnection.createOffer();
|
|
198
|
+
await peerConnection.setLocalDescription(offer);
|
|
199
|
+
|
|
200
|
+
const response = await fetch(callsEndpoint, {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: {
|
|
203
|
+
Authorization: `Bearer ${token}`,
|
|
204
|
+
'Content-Type': 'application/sdp',
|
|
205
|
+
},
|
|
206
|
+
body: offer.sdp,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
throw new Error(`OpenAI Realtime SDP exchange failed with status ${response.status}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const answer = await response.text();
|
|
214
|
+
await peerConnection.setRemoteDescription({ type: 'answer', sdp: answer });
|
|
215
|
+
|
|
216
|
+
updateStatus('connected');
|
|
217
|
+
} catch (error) {
|
|
218
|
+
updateStatus('error');
|
|
219
|
+
onError(error);
|
|
220
|
+
disconnect();
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function disconnect() {
|
|
226
|
+
updateStatus('idle');
|
|
227
|
+
|
|
228
|
+
if (dataChannel) {
|
|
229
|
+
dataChannel.close();
|
|
230
|
+
dataChannel = null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (peerConnection) {
|
|
234
|
+
peerConnection.close();
|
|
235
|
+
peerConnection = null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (localStream) {
|
|
239
|
+
localStream.getTracks().forEach((track) => track.stop());
|
|
240
|
+
localStream = null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (audioElement) {
|
|
244
|
+
audioElement.srcObject = null;
|
|
245
|
+
audioElement = null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function sendText(text) {
|
|
250
|
+
if (!text) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
sendEvent({
|
|
255
|
+
type: 'conversation.item.create',
|
|
256
|
+
item: {
|
|
257
|
+
type: 'message',
|
|
258
|
+
role: 'user',
|
|
259
|
+
content: [{ type: 'input_text', text }],
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
sendEvent({ type: 'response.create' });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
connect,
|
|
268
|
+
disconnect,
|
|
269
|
+
sendText,
|
|
270
|
+
sendEvent,
|
|
271
|
+
get status() {
|
|
272
|
+
return state.status;
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|