dograh/ui/public/embed/dograh-widget.js
Aymenbenpakiss 37c4bda0c7
fix(ui): release microphone stream on call teardown so a second test call works (#446)
* fix(ui): release microphone stream on call teardown

The browser "Call Voice Agent" test call worked only once per page load.
A second attempt failed (mic stuck / "Could not acquire media") until the
user reloaded the page or cleared site data and re-granted mic permission.

Root cause: the MediaStream from getUserMedia() was added to the peer
connection but never retained or explicitly stopped on teardown. On hangup
only sender.track.stop() (via pc.getSenders()) ran; browsers can keep the
microphone device held through the original MediaStream reference, so the
next getUserMedia() is blocked.

Fix: keep the stream in localStreamRef and stop all of its tracks in
cleanupConnection() (the shared teardown path) and in the unmount cleanup,
so the device is fully released between calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ui): harden microphone release in webrtc hook and embed widget

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: AYMENPAKISS2 <tech.nomatrade@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
2026-06-18 12:47:14 +05:30

1128 lines
34 KiB
JavaScript

/**
* Dograh Voice Widget
* Embeddable voice call widget for Dograh workflows
* Version: 1.0.0
*/
(function() {
'use strict';
// Widget configuration defaults
const DEFAULT_CONFIG = {
position: 'bottom-right',
autoStart: false,
apiBaseUrl: window.location.hostname === 'localhost'
? 'http://localhost:8000'
: 'https://api.dograh.com'
};
// Widget state
const state = {
config: {},
isInitialized: false,
isOpen: false,
pc: null,
ws: null,
stream: null,
sessionToken: null,
workflowRunId: null,
pcId: null,
connectionStatus: 'idle', // idle, connecting, connected, failed
audioElement: null,
turnCredentials: null, // TURN server credentials
callStartedAt: null, // Timestamp when call connected (for duration tracking)
gracefulDisconnect: false,
callbacks: {
onReady: null,
onCallStart: null,
onCallConnected: null,
onCallDisconnected: null,
onCallEnd: null,
onError: null,
onStatusChange: null
}
};
/**
* Initialize the widget
*/
async function init() {
if (state.isInitialized) return;
// Get token from script URL
const script = document.currentScript || document.querySelector('script[src*="dograh-widget.js"]');
if (!script) {
console.error('Dograh Widget: Script not found');
return;
}
// Extract parameters from URL
const scriptUrl = new URL(script.src);
const token = scriptUrl.searchParams.get('token');
const apiEndpoint = scriptUrl.searchParams.get('apiEndpoint');
const environment = scriptUrl.searchParams.get('environment');
if (!token) {
console.error('Dograh Widget: No token found in script URL');
return;
}
// Determine API base URL
let apiBaseUrl = DEFAULT_CONFIG.apiBaseUrl;
if (apiEndpoint) {
// Use the apiEndpoint from URL parameter if provided
// Ensure it has a protocol
if (!apiEndpoint.startsWith('http://') && !apiEndpoint.startsWith('https://')) {
// Default to https for production endpoints
apiBaseUrl = 'https://' + apiEndpoint.replace(/\/+$/, '');
} else {
apiBaseUrl = apiEndpoint.replace(/\/+$/, ''); // Remove trailing slashes
}
} else if (scriptUrl.origin.includes('localhost')) {
apiBaseUrl = 'http://localhost:8000';
} else {
apiBaseUrl = scriptUrl.origin.replace(/:\d+$/, ':8000');
}
// Store base configuration
state.config = {
...DEFAULT_CONFIG,
token: token,
apiBaseUrl: apiBaseUrl,
environment: environment || 'production',
// Allow data attributes to override fetched config
contextVariables: parseContextVariables(script.getAttribute('data-dograh-context'))
};
try {
// Fetch configuration from API
const configResponse = await fetch(`${state.config.apiBaseUrl}/api/v1/public/embed/config/${token}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Origin': window.location.origin
}
});
if (!configResponse.ok) {
throw new Error(`Failed to fetch config: ${configResponse.status}`);
}
const configData = await configResponse.json();
// Merge fetched configuration with defaults
state.config = {
...state.config,
workflowId: configData.workflow_id,
embedMode: configData.settings?.embedMode || 'floating',
containerId: configData.settings?.containerId || 'dograh-inline-container',
position: configData.position || DEFAULT_CONFIG.position,
buttonColor: configData.settings?.buttonColor || '#10b981',
buttonText: configData.settings?.buttonText || 'Talk to Agent',
callToActionText: configData.settings?.callToActionText || 'Click to start voice conversation',
autoStart: configData.auto_start || false
};
} catch (error) {
console.error('Dograh Widget: Failed to fetch configuration', error);
return;
}
state.isInitialized = true;
// Create widget UI based on mode
if (state.config.embedMode === 'inline') {
injectStyles();
createInlineWidget();
} else if (state.config.embedMode === 'headless') {
createHeadlessWidget();
} else {
injectStyles();
createFloatingWidget();
}
// Trigger ready callback
if (state.callbacks.onReady) {
state.callbacks.onReady();
}
// Auto-start if configured
if (state.config.autoStart) {
setTimeout(() => startCall(), 1000);
}
}
/**
* Parse context variables from JSON string
*/
function parseContextVariables(contextStr) {
if (!contextStr) return {};
try {
return JSON.parse(contextStr);
} catch (e) {
console.warn('Dograh Widget: Invalid context variables', e);
return {};
}
}
/**
* Inject widget styles
*/
function injectStyles() {
if (document.getElementById('dograh-widget-styles')) return;
const styles = `
.dograh-widget-container {
position: fixed;
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.dograh-widget-container.bottom-right {
bottom: 20px;
right: 20px;
}
.dograh-widget-container.bottom-left {
bottom: 20px;
left: 20px;
}
.dograh-widget-container.top-right {
top: 20px;
right: 20px;
}
.dograh-widget-container.top-left {
top: 20px;
left: 20px;
}
.dograh-widget-cta {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border: none;
border-radius: 9999px;
color: #ffffff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
max-width: calc(100vw - 40px);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
transition: filter 150ms ease, transform 100ms ease, box-shadow 200ms ease;
animation: dograh-cta-in 220ms ease-out;
}
.dograh-widget-cta:hover {
filter: brightness(1.08);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
}
.dograh-widget-cta:active { transform: scale(0.98); }
.dograh-widget-cta.dograh-state-connecting { background: #f59e0b !important; animation: dograh-pulse 1.6s infinite; }
.dograh-widget-cta.dograh-state-connected { background: #ef4444 !important; }
.dograh-widget-cta.dograh-state-failed { background: #ef4444 !important; opacity: 0.85; }
@keyframes dograh-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
@keyframes dograh-cta-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
`;
const styleSheet = document.createElement('style');
styleSheet.id = 'dograh-widget-styles';
styleSheet.textContent = styles;
document.head.appendChild(styleSheet);
}
function ctaLabelForStatus(status) {
switch (status) {
case 'connecting': return 'Connecting…';
case 'connected': return 'End Call';
case 'failed': return 'Retry';
default: return state.config.buttonText || 'Talk to Agent';
}
}
/**
* Create floating widget UI — a single CTA pill button anchored to the
* configured corner of the viewport.
*/
function createFloatingWidget() {
const container = document.createElement('div');
container.className = `dograh-widget-container ${state.config.position}`;
container.id = 'dograh-widget-root';
const audio = document.createElement('audio');
audio.id = 'dograh-widget-audio';
audio.autoplay = true;
audio.style.display = 'none';
container.appendChild(audio);
state.audioElement = audio;
document.body.appendChild(container);
renderFloating();
}
/**
* Render the floating CTA pill. Re-renders preserve the hidden audio
* element so an in-progress call is not interrupted on status changes.
*/
function renderFloating() {
const container = document.getElementById('dograh-widget-root');
if (!container) return;
Array.from(container.children).forEach((child) => {
if (child !== state.audioElement) container.removeChild(child);
});
const status = state.connectionStatus || 'idle';
const button = document.createElement('button');
button.id = 'dograh-widget-cta';
button.type = 'button';
button.className = `dograh-widget-cta dograh-state-${status}`;
// Idle uses configured color; status states use CSS-defined colors.
if (status === 'idle') {
button.style.backgroundColor = state.config.buttonColor;
}
button.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<span></span>
`;
button.querySelector('span').textContent = ctaLabelForStatus(status);
button.onclick = toggleCall;
container.appendChild(button);
}
/**
* Create headless widget (no UI — host page drives everything via window.DograhWidget API)
*/
function createHeadlessWidget() {
const audio = document.createElement('audio');
audio.id = 'dograh-widget-audio';
audio.autoplay = true;
audio.style.display = 'none';
document.body.appendChild(audio);
state.audioElement = audio;
}
/**
* Toggle call (start or stop based on current state)
*/
function toggleCall() {
if (state.connectionStatus === 'idle' || state.connectionStatus === 'failed') {
startCall();
} else {
stopCall();
}
}
function updateFloatingButton(status) {
state.connectionStatus = status;
renderFloating();
}
/**
* Create inline widget UI
*/
function createInlineWidget() {
// Find container element
const container = document.getElementById(state.config.containerId);
if (!container) {
console.error(`Dograh Widget: Container element with id "${state.config.containerId}" not found`);
if (state.callbacks.onError) {
state.callbacks.onError(new Error('Container element not found'));
}
return;
}
// Clear container
container.innerHTML = '';
container.className = 'dograh-inline-container';
// Add minimal inline styles
const inlineStyles = `
.dograh-inline-container {
min-height: 200px;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.dograh-inline-status {
text-align: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.dograh-inline-status-icon {
width: 64px;
height: 64px;
margin: 0 auto 20px;
}
.dograh-inline-status-text {
font-size: 18px;
font-weight: 500;
margin: 0 0 8px;
color: #111827;
}
.dograh-inline-status-subtext {
font-size: 14px;
color: #6b7280;
margin: 0 0 20px;
}
.dograh-inline-button-container {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 20px;
}
.dograh-inline-btn {
padding: 12px 32px;
border-radius: 8px;
border: none;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.dograh-inline-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.dograh-inline-btn:active {
transform: translateY(0);
}
.dograh-inline-btn-start {
background: #10b981;
}
.dograh-inline-btn-start:hover {
background: #059669;
}
.dograh-inline-btn-end {
background: #ef4444;
}
.dograh-inline-btn-end:hover {
background: #dc2626;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.dograh-inline-pulse {
animation: pulse 2s infinite;
}
`;
// Add inline styles if not already added
if (!document.getElementById('dograh-inline-styles')) {
const styleSheet = document.createElement('style');
styleSheet.id = 'dograh-inline-styles';
styleSheet.textContent = inlineStyles;
document.head.appendChild(styleSheet);
}
// Create initial status display
updateInlineStatus('idle');
// Store audio element (hidden)
state.audioElement = document.createElement('audio');
state.audioElement.autoplay = true;
state.audioElement.style.display = 'none';
container.appendChild(state.audioElement);
// Mark widget as open (for inline mode, it's always "open")
state.isOpen = true;
}
/**
* Update inline widget status
*/
function updateInlineStatus(status, text, subtext) {
const container = document.getElementById(state.config.containerId);
if (!container) return;
// Update state
state.connectionStatus = status;
// Determine display text
const displayText = text || {
idle: 'Ready to Connect',
connecting: 'Connecting...',
connected: 'Call Active',
failed: 'Connection Failed'
}[status];
const displaySubtext = subtext || {
idle: state.config.callToActionText,
connecting: 'Please wait while we establish connection',
connected: 'You can speak now',
failed: 'Please check your microphone and try again'
}[status];
// Simple button design: green to start, red to end
let buttonHTML = '';
if (status === 'idle' || status === 'failed') {
// Button to start with configured color
buttonHTML = `
<button class="dograh-inline-btn dograh-inline-btn-start" id="dograh-inline-start-btn" style="background: ${state.config.buttonColor};">
${status === 'failed' ? 'Retry' : state.config.buttonText}
</button>
`;
} else if (status === 'connecting' || status === 'connected') {
// Red button to end
buttonHTML = `
<button class="dograh-inline-btn dograh-inline-btn-end" id="dograh-inline-end-btn">
End Call
</button>
`;
}
// Update container content (preserve audio element)
const audioElement = state.audioElement;
container.innerHTML = `
<div class="dograh-inline-status">
<div class="dograh-inline-status-icon ${status === 'connecting' ? 'dograh-inline-pulse' : ''}">
${getStatusIcon(status)}
</div>
<p class="dograh-inline-status-text">${displayText}</p>
<p class="dograh-inline-status-subtext">${displaySubtext}</p>
<div class="dograh-inline-button-container">
${buttonHTML}
</div>
</div>
`;
// Re-append audio element
if (audioElement) {
container.appendChild(audioElement);
}
// Attach event handlers
const startBtn = document.getElementById('dograh-inline-start-btn');
if (startBtn) startBtn.onclick = startCall;
const endBtn = document.getElementById('dograh-inline-end-btn');
if (endBtn) endBtn.onclick = stopCall;
// Trigger status change callback
if (state.callbacks.onStatusChange) {
state.callbacks.onStatusChange(status, displayText, displaySubtext);
}
}
/**
* Get status icon SVG
*/
function getStatusIcon(status) {
const icons = {
idle: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>`,
connecting: `<svg class="dograh-widget-spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v4"/>
<path d="M12 18v4"/>
<path d="M4.93 4.93l2.83 2.83"/>
<path d="M16.24 16.24l2.83 2.83"/>
<path d="M2 12h4"/>
<path d="M18 12h4"/>
<path d="M4.93 19.07l2.83-2.83"/>
<path d="M16.24 7.76l2.83-2.83"/>
</svg>`,
connected: `<svg viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72"/>
<path d="M15 7a2 2 0 0 1 2 2"/>
<path d="M15 3a6 6 0 0 1 6 6"/>
</svg>`,
failed: `<svg viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>`
};
return icons[status] || icons.idle;
}
/**
* Update widget status
*/
function updateStatus(status, text, subtext) {
state.connectionStatus = status;
// Use appropriate update function based on mode
if (state.config.embedMode === 'inline') {
updateInlineStatus(status, text, subtext);
} else if (state.config.embedMode === 'headless') {
if (state.callbacks.onStatusChange) {
state.callbacks.onStatusChange(status, text, subtext);
}
} else {
updateFloatingButton(status);
}
}
/**
* Open widget (deprecated - kept for backwards compatibility)
*/
function openWidget() {
// No-op since we removed the modal
}
/**
* Close widget (deprecated - kept for backwards compatibility)
*/
function closeWidget() {
// Stop call if active
if (state.connectionStatus === 'connected' || state.connectionStatus === 'connecting') {
stopCall();
}
}
/**
* Start voice call
*/
async function startCall() {
state.gracefulDisconnect = false;
updateStatus('connecting', 'Connecting...', 'Please wait while we establish the connection');
if (state.callbacks.onCallStart) {
state.callbacks.onCallStart();
}
try {
// Initialize session if using embed token
if (state.config.token) {
await initializeEmbedSession();
} else {
// Direct mode with workflow and run IDs
state.sessionToken = 'direct-mode';
state.workflowRunId = state.config.runId;
}
// Request microphone permission
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Release any stream still held from a prior attempt before retaining
// the new one, so a re-entrant start can't leak the microphone.
if (state.stream) {
state.stream.getTracks().forEach(track => track.stop());
}
state.stream = stream;
} catch (micError) {
// Handle specific microphone permission errors
let errorMessage = 'Please check your microphone and try again';
if (micError.name === 'NotAllowedError' || micError.name === 'PermissionDeniedError') {
errorMessage = 'Microphone permission denied. Please allow microphone access to start the call.';
} else if (micError.name === 'NotFoundError' || micError.name === 'DevicesNotFoundError') {
errorMessage = 'No microphone found. Please connect a microphone and try again.';
} else if (micError.name === 'NotReadableError' || micError.name === 'TrackStartError') {
errorMessage = 'Microphone is already in use by another application.';
}
throw new Error(errorMessage);
}
// Create WebRTC connection
await createWebRTCConnection();
// Connect WebSocket
await connectWebSocket();
// Start negotiation
await negotiate();
} catch (error) {
console.error('Dograh Widget: Failed to start call', error);
// Release anything acquired before the failure so a retry starts clean.
// getUserMedia may have succeeded before a later step (WebSocket /
// negotiation) threw, which would otherwise leave the mic held and block
// the next getUserMedia(). Null the refs before close() so the peer/ws
// state handlers short-circuit instead of re-entering teardown.
if (state.stream) {
state.stream.getTracks().forEach(track => track.stop());
state.stream = null;
}
if (state.pc) {
const pc = state.pc;
state.pc = null;
if (pc.signalingState !== 'closed') {
pc.close();
}
}
if (state.ws) {
const ws = state.ws;
state.ws = null;
if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
ws.close();
}
}
updateStatus('failed', 'Connection failed', error.message || 'Please check your microphone and try again');
// Trigger error callback
if (state.callbacks.onError) {
state.callbacks.onError(error);
}
}
}
/**
* Initialize embed session
*/
async function initializeEmbedSession() {
const response = await fetch(`${state.config.apiBaseUrl}/api/v1/public/embed/init`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': window.location.origin
},
body: JSON.stringify({
token: state.config.token,
context_variables: state.config.contextVariables
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to initialize session');
}
const data = await response.json();
state.sessionToken = data.session_token;
state.workflowRunId = data.workflow_run_id;
state.workflowId = data.config.workflow_id;
// Fetch TURN credentials after session initialization
await fetchTurnCredentials();
}
/**
* Fetch TURN credentials for WebRTC connection
*/
async function fetchTurnCredentials() {
if (!state.sessionToken) {
console.warn('Dograh Widget: No session token available for TURN credentials');
return;
}
try {
const response = await fetch(`${state.config.apiBaseUrl}/api/v1/public/embed/turn-credentials/${state.sessionToken}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Origin': window.location.origin
}
});
if (response.ok) {
state.turnCredentials = await response.json();
console.log(`TURN credentials obtained, TTL: ${state.turnCredentials.ttl}s`);
} else if (response.status === 503) {
// TURN not configured on server - this is OK, we'll use STUN only
console.log('TURN server not configured, using STUN only');
} else {
console.warn(`Failed to fetch TURN credentials: ${response.status}`);
}
} catch (error) {
console.warn('Failed to fetch TURN credentials, continuing without TURN:', error);
}
}
/**
* Create WebRTC peer connection
*/
function createWebRTCConnection() {
// Build ICE servers list
const iceServers = [{ urls: ['stun:stun.l.google.com:19302'] }];
// Add TURN server if credentials are available
if (state.turnCredentials && state.turnCredentials.uris && state.turnCredentials.uris.length > 0) {
iceServers.push({
urls: state.turnCredentials.uris,
username: state.turnCredentials.username,
credential: state.turnCredentials.password
});
console.log(`TURN server configured with ${state.turnCredentials.uris.length} URIs`);
}
const config = {
iceServers: iceServers
};
state.pc = new RTCPeerConnection(config);
// Add audio track
if (state.stream) {
state.stream.getTracks().forEach(track => {
state.pc.addTrack(track, state.stream);
});
}
// Handle incoming audio
state.pc.ontrack = (event) => {
if (event.track.kind === 'audio' && state.audioElement) {
state.audioElement.srcObject = event.streams[0];
}
};
// Monitor connection state
state.pc.oniceconnectionstatechange = handlePeerConnectionStateChange;
state.pc.onconnectionstatechange = handlePeerConnectionStateChange;
state.pc.onicecandidate = sendIceCandidate;
}
function handlePeerConnectionStateChange() {
const pc = state.pc;
if (!pc) return;
console.log('Peer connection state:', pc.connectionState, 'ICE:', pc.iceConnectionState);
if (pc.connectionState === 'connected' || pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
const wasAlreadyConnected = state.callStartedAt !== null;
updateStatus('connected', 'Connected', 'Your voice call is now active');
if (!wasAlreadyConnected) {
state.callStartedAt = Date.now();
if (state.callbacks.onCallConnected) {
state.callbacks.onCallConnected({
agentId: state.config.workflowId || null,
token: state.config.token || null,
workflowRunId: state.workflowRunId || null
});
}
}
return;
}
if (pc.connectionState === 'failed' || pc.iceConnectionState === 'failed') {
stopCall({
graceful: false,
status: 'failed',
text: 'Connection lost',
subtext: 'The call has been disconnected'
});
return;
}
if (
pc.connectionState === 'closed' ||
pc.connectionState === 'disconnected' ||
pc.iceConnectionState === 'closed' ||
pc.iceConnectionState === 'disconnected'
) {
stopCall({ graceful: true });
}
}
function sendIceCandidate(event) {
// Handle ICE candidates for trickling
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
const message = {
type: 'ice-candidate',
payload: {
candidate: event.candidate ? {
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex
} : null,
pc_id: state.pcId
}
};
state.ws.send(JSON.stringify(message));
}
}
/**
* Connect WebSocket for signaling
*/
async function connectWebSocket() {
return new Promise((resolve, reject) => {
// Use public signaling endpoint for embed tokens
const wsUrl = `${state.config.apiBaseUrl.replace('http', 'ws')}/api/v1/ws/public/signaling/${state.sessionToken}`;
state.ws = new WebSocket(wsUrl);
state.pcId = generatePeerId();
state.ws.onopen = () => {
console.log('WebSocket connected');
resolve();
};
state.ws.onerror = (error) => {
console.error('WebSocket error:', error);
reject(error);
};
state.ws.onclose = (event) => {
console.log('WebSocket closed');
state.ws = null;
if (event.reason === 'call ended') {
stopCall({ graceful: true, closeWebSocket: false });
return;
}
if (state.connectionStatus === 'connected' && !state.gracefulDisconnect) {
updateStatus('failed', 'Connection lost', 'The call has been disconnected');
}
};
state.ws.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
await handleWebSocketMessage(message);
} catch (e) {
console.error('Failed to handle WebSocket message:', e);
}
};
});
}
/**
* Handle WebSocket messages
*/
async function handleWebSocketMessage(message) {
switch (message.type) {
case 'answer':
const answer = message.payload;
console.log('Received answer from server');
await state.pc.setRemoteDescription({
type: 'answer',
sdp: answer.sdp
});
break;
case 'ice-candidate':
const candidate = message.payload.candidate;
if (candidate) {
try {
await state.pc.addIceCandidate({
candidate: candidate.candidate,
sdpMid: candidate.sdpMid,
sdpMLineIndex: candidate.sdpMLineIndex
});
console.log('Added remote ICE candidate');
} catch (e) {
console.error('Failed to add ICE candidate:', e);
}
}
break;
case 'error':
console.error('Server error:', message.payload);
updateStatus('failed', 'Server error', message.payload.message || 'An error occurred');
break;
case 'call-ended':
console.log('Call ended by server:', message.payload);
stopCall({ graceful: true });
break;
default:
console.warn('Unknown message type:', message.type);
}
}
/**
* Negotiate WebRTC connection
*/
async function negotiate() {
const offer = await state.pc.createOffer();
await state.pc.setLocalDescription(offer);
const message = {
type: 'offer',
payload: {
sdp: offer.sdp,
type: 'offer',
pc_id: state.pcId,
workflow_id: parseInt(state.config.workflowId),
workflow_run_id: parseInt(state.workflowRunId),
call_context_vars: state.config.contextVariables || {}
}
};
state.ws.send(JSON.stringify(message));
console.log('Sent offer via WebSocket');
}
/**
* Stop voice call
*/
function stopCall(options = {}) {
const graceful = options.graceful !== false;
const closeWebSocket = options.closeWebSocket !== false;
const status = options.status || 'idle';
const text = options.text || 'Call ended';
const subtext = options.subtext || 'Click below to start a new call';
state.gracefulDisconnect = graceful;
// Fire onCallDisconnected only if the call had actually connected, with
// identifiers and duration. Must run before we clear callStartedAt.
if (state.callStartedAt && state.callbacks.onCallDisconnected) {
const durationSeconds = Math.round((Date.now() - state.callStartedAt) / 1000);
state.callbacks.onCallDisconnected({
agentId: state.config.workflowId || null,
token: state.config.token || null,
workflowRunId: state.workflowRunId || null,
durationSeconds
});
}
state.callStartedAt = null;
updateStatus(status, text, subtext);
if (state.callbacks.onCallEnd) {
state.callbacks.onCallEnd();
}
// Close WebSocket
if (closeWebSocket && state.ws) {
const ws = state.ws;
state.ws = null;
if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
ws.close();
}
} else if (!closeWebSocket) {
state.ws = null;
}
// Stop media tracks
if (state.stream) {
state.stream.getTracks().forEach(track => track.stop());
state.stream = null;
}
// Close peer connection
if (state.pc) {
const pc = state.pc;
state.pc = null;
if (pc.signalingState !== 'closed') {
pc.close();
}
}
// Clear audio
if (state.audioElement) {
state.audioElement.srcObject = null;
}
}
/**
* Retry connection
*/
function retryCall() {
updateStatus('idle', 'Ready to start', 'Click below to begin your voice call');
setTimeout(() => startCall(), 500);
}
/**
* Generate unique peer ID
*/
function generatePeerId() {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return 'PC-' + Array.from(array)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
// Public API
window.DograhWidget = {
// Core methods
init: init,
start: startCall,
stop: stopCall,
end: stopCall, // Alias for stop
retry: retryCall,
// Floating widget specific
open: openWidget,
close: closeWidget,
// State and callbacks
getState: () => state,
onReady: (callback) => { state.callbacks.onReady = callback; },
onCallStart: (callback) => { state.callbacks.onCallStart = callback; },
onCallConnected: (callback) => { state.callbacks.onCallConnected = callback; },
onCallDisconnected: (callback) => { state.callbacks.onCallDisconnected = callback; },
onCallEnd: (callback) => { state.callbacks.onCallEnd = callback; },
onError: (callback) => { state.callbacks.onError = callback; },
onStatusChange: (callback) => { state.callbacks.onStatusChange = callback; },
// Check if inline mode
isInlineMode: () => state.config.embedMode === 'inline',
// Re-render the inline widget (useful when React component remounts)
refresh: () => {
if (state.config.embedMode === 'inline') {
// Re-render inline widget with current status
updateInlineStatus(state.connectionStatus);
}
},
// Initialize inline mode manually (for advanced use cases)
initInline: (options) => {
if (options.container) {
state.config.containerId = options.container.id || 'dograh-inline-container';
}
state.config.embedMode = 'inline';
// Set callbacks if provided
if (options.onReady) state.callbacks.onReady = options.onReady;
if (options.onCallStart) state.callbacks.onCallStart = options.onCallStart;
if (options.onCallConnected) state.callbacks.onCallConnected = options.onCallConnected;
if (options.onCallDisconnected) state.callbacks.onCallDisconnected = options.onCallDisconnected;
if (options.onCallEnd) state.callbacks.onCallEnd = options.onCallEnd;
if (options.onError) state.callbacks.onError = options.onError;
if (options.onStatusChange) state.callbacks.onStatusChange = options.onStatusChange;
// Initialize
if (!state.isInitialized) {
init();
}
}
};
// Auto-initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();