feat: enable workflows to be embedded in websites as a script tag (#47)

* feat: add deployment configuration options

* Simplify EmbedDialog

* Add options for inline vs floating embedding of agent
This commit is contained in:
Abhishek 2025-11-15 17:32:37 +05:30 committed by GitHub
parent 5e4aef346d
commit 99a768f291
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3551 additions and 645 deletions

5
ui/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "ui",
"version": "1.1.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ui",
"version": "1.1.0",
"version": "1.2.0",
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@hey-api/client-fetch": "^0.10.0",
@ -23,6 +23,7 @@
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.0",
"@sentry/nextjs": "^9.28.1",
"@stackframe/stack": "^2.8.28",

View file

@ -26,6 +26,7 @@
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.0",
"@sentry/nextjs": "^9.28.1",
"@stackframe/stack": "^2.8.28",

View file

@ -0,0 +1,951 @@
/**
* 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,
connectionStatus: 'idle', // idle, connecting, connected, failed
audioElement: null,
callbacks: {
onReady: null,
onCallStart: 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
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,
autoStart: configData.auto_start || false
};
} catch (error) {
console.error('Dograh Widget: Failed to fetch configuration', error);
return;
}
state.isInitialized = true;
// Load styles
injectStyles();
// Create widget UI based on mode
if (state.config.embedMode === 'inline') {
createInlineWidget();
} else {
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-button {
color: white;
border: none;
border-radius: 50%;
width: 60px;
height: 60px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
}
.dograh-widget-button:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
}
.dograh-widget-button:active {
transform: scale(0.95);
}
/* Green button for idle/ready state */
.dograh-widget-button-idle {
background: #10b981;
}
.dograh-widget-button-idle:hover {
background: #059669;
}
/* Orange button for connecting state */
.dograh-widget-button-connecting {
background: #f59e0b;
animation: pulse 2s infinite;
}
/* Red button for connected state (to end call) */
.dograh-widget-button-connected {
background: #ef4444;
}
.dograh-widget-button-connected:hover {
background: #dc2626;
}
/* Red button for failed state */
.dograh-widget-button-failed {
background: #ef4444;
opacity: 0.8;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
`;
const styleSheet = document.createElement('style');
styleSheet.id = 'dograh-widget-styles';
styleSheet.textContent = styles;
document.head.appendChild(styleSheet);
}
/**
* Create floating widget UI (simplified - no modal)
*/
function createFloatingWidget() {
// Create container
const container = document.createElement('div');
container.className = `dograh-widget-container ${state.config.position}`;
container.id = 'dograh-widget';
// Create button (green to start, red to end)
const button = document.createElement('button');
button.className = 'dograh-widget-button dograh-widget-button-idle';
button.id = 'dograh-widget-button';
button.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
`;
button.onclick = toggleCall;
// Create hidden audio element
const audio = document.createElement('audio');
audio.id = 'dograh-widget-audio';
audio.autoplay = true;
audio.style.display = 'none';
// Append elements
container.appendChild(button);
container.appendChild(audio);
document.body.appendChild(container);
// Store audio element reference
state.audioElement = audio;
}
/**
* Toggle call (start or stop based on current state)
*/
function toggleCall() {
if (state.connectionStatus === 'idle' || state.connectionStatus === 'failed') {
startCall();
} else {
stopCall();
}
}
/**
* Update floating widget button appearance
*/
function updateFloatingButton(status) {
const button = document.getElementById('dograh-widget-button');
if (!button) return;
// Remove all status classes
button.classList.remove('dograh-widget-button-idle', 'dograh-widget-button-connecting', 'dograh-widget-button-connected', 'dograh-widget-button-failed');
// Add current status class
button.classList.add(`dograh-widget-button-${status}`);
// Update title attribute for tooltip
const titles = {
idle: 'Start Call',
connecting: 'Connecting...',
connected: 'End Call',
failed: 'Retry Call'
};
button.title = titles[status] || 'Voice Call';
}
/**
* 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: 'Click to start voice conversation',
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') {
// Green button to start
buttonHTML = `
<button class="dograh-inline-btn dograh-inline-btn-start" id="dograh-inline-start-btn">
${status === 'failed' ? 'Retry' : 'Start Call'}
</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 {
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() {
updateStatus('connecting', 'Connecting...', 'Please wait while we establish the connection');
// Trigger call start callback
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 });
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);
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;
}
/**
* Create WebRTC peer connection
*/
function createWebRTCConnection() {
const config = {
iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }]
};
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 = () => {
console.log('ICE connection state:', state.pc.iceConnectionState);
if (state.pc.iceConnectionState === 'connected' || state.pc.iceConnectionState === 'completed') {
updateStatus('connected', 'Connected', 'Your voice call is now active');
} else if (state.pc.iceConnectionState === 'failed' || state.pc.iceConnectionState === 'disconnected') {
updateStatus('failed', 'Connection lost', 'The call has been disconnected');
stopCall();
}
};
// Handle ICE candidates for trickling
state.pc.onicecandidate = (event) => {
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 = () => {
console.log('WebSocket closed');
if (state.connectionStatus === 'connected') {
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;
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() {
updateStatus('idle', 'Call ended', 'Click below to start a new call');
// Trigger call end callback
if (state.callbacks.onCallEnd) {
state.callbacks.onCallEnd();
}
// Close WebSocket
if (state.ws) {
state.ws.close();
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) {
state.pc.close();
state.pc = null;
}
// 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; },
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.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();
}
})();

View file

@ -7,7 +7,7 @@ import {
Panel,
ReactFlow,
} from "@xyflow/react";
import { BrushCleaning, Maximize2, Minus, Plus, Settings, Variable } from 'lucide-react';
import { BrushCleaning, Maximize2, Minus, Plus, Rocket, Settings, Variable } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
@ -20,6 +20,7 @@ import AddNodePanel from "../../../components/flow/AddNodePanel";
import CustomEdge from "../../../components/flow/edges/CustomEdge";
import { AgentNode, EndCall, GlobalNode, StartCall } from "../../../components/flow/nodes";
import { ConfigurationsDialog } from './components/ConfigurationsDialog';
import { EmbedDialog } from './components/EmbedDialog';
import { TemplateContextVariablesDialog } from './components/TemplateContextVariablesDialog';
import WorkflowHeader from "./components/WorkflowHeader";
import { WorkflowTabs } from './components/WorkflowTabs';
@ -76,6 +77,7 @@ interface RenderWorkflowProps {
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, user, getAccessToken }: RenderWorkflowProps) {
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
const {
rfInstance,
@ -218,6 +220,22 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
<p>Template Context Variables</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsEmbedDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<Rocket className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Deploy Workflow</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
@ -317,6 +335,14 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
templateContextVariables={templateContextVariables}
onSave={saveTemplateContextVariables}
/>
<EmbedDialog
open={isEmbedDialogOpen}
onOpenChange={setIsEmbedDialogOpen}
workflowId={workflowId}
workflowName={workflowName}
getAccessToken={getAccessToken}
/>
</WorkflowLayout>
</WorkflowProvider>
);

View file

@ -0,0 +1,507 @@
import { Check, Copy, Loader2, Plus, Rocket, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { client } from "@/client/client.gen";
import {
createOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPost,
deactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDelete,
getEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGet,
} from "@/client/sdk.gen";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
interface EmbedDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workflowId: number;
workflowName: string;
getAccessToken: () => Promise<string>;
}
interface EmbedToken {
id: number;
token: string;
allowed_domains: string[] | null;
settings: Record<string, unknown> | null;
is_active: boolean;
usage_count: number;
usage_limit: number | null;
expires_at: string | null;
created_at: string;
embed_script: string;
}
export function EmbedDialog({
open,
onOpenChange,
workflowId,
workflowName,
getAccessToken,
}: EmbedDialogProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [embedToken, setEmbedToken] = useState<EmbedToken | null>(null);
const [copied, setCopied] = useState(false);
// Form state
const [isEnabled, setIsEnabled] = useState(false);
const [domains, setDomains] = useState<string[]>([]);
const [newDomain, setNewDomain] = useState("");
const [embedMode, setEmbedMode] = useState<"floating" | "inline">("floating");
const [position, setPosition] = useState("bottom-right");
const [buttonText, setButtonText] = useState("Start Voice Call");
const [buttonColor, setButtonColor] = useState("#3B82F6");
const loadEmbedToken = useCallback(async () => {
setLoading(true);
try {
const token = await getAccessToken();
client.setConfig({
baseUrl: window.location.origin.replace(/:\d+$/, ':8000'),
headers: { Authorization: `Bearer ${token}` },
});
const response = await getEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGet({
path: { workflow_id: workflowId },
});
if (response.data) {
setEmbedToken(response.data as EmbedToken);
setIsEnabled(response.data.is_active);
// Load settings
if (response.data.settings) {
const settings = response.data.settings as Record<string, string>;
setEmbedMode((settings.embedMode as "floating" | "inline") || "floating");
setPosition(settings.position || "bottom-right");
setButtonText(settings.buttonText || "Start Voice Call");
setButtonColor(settings.buttonColor || "#3B82F6");
}
// Load domains
if (response.data.allowed_domains) {
setDomains(response.data.allowed_domains);
}
}
} catch (error) {
console.error("Failed to load embed token:", error);
} finally {
setLoading(false);
}
}, [workflowId, getAccessToken]);
useEffect(() => {
if (open) {
loadEmbedToken();
}
}, [open, loadEmbedToken]);
const handleSave = async () => {
setSaving(true);
try {
const token = await getAccessToken();
client.setConfig({
baseUrl: window.location.origin.replace(/:\d+$/, ':8000'),
headers: { Authorization: `Bearer ${token}` },
});
if (!isEnabled && embedToken) {
// Deactivate token
await deactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDelete({
path: { workflow_id: workflowId },
});
setEmbedToken(null);
} else if (isEnabled) {
// Create or update token
const response = await createOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPost({
path: { workflow_id: workflowId },
body: {
allowed_domains: domains.length > 0 ? domains : null,
settings: {
embedMode,
position,
buttonText,
buttonColor,
size: "medium",
autoStart: false,
containerId: embedMode === "inline" ? "dograh-inline-container" : undefined,
},
usage_limit: null,
expires_in_days: null,
},
});
if (response.data) {
setEmbedToken(response.data as EmbedToken);
}
}
// Don't close modal after saving - let user copy the embed code
} catch (error) {
console.error("Failed to save embed token:", error);
} finally {
setSaving(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const addDomain = () => {
if (newDomain.trim() && !domains.includes(newDomain.trim())) {
setDomains([...domains, newDomain.trim()]);
setNewDomain("");
}
};
const removeDomain = (domain: string) => {
setDomains(domains.filter(d => d !== domain));
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
addDomain();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
Deploy Workflow
</DialogTitle>
<DialogDescription>
Embed &quot;{workflowName}&quot; on any website with a simple script tag
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-gray-500" />
</div>
) : (
<div className="space-y-6">
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="embed-enabled">Enable Embedding</Label>
<p className="text-sm text-muted-foreground">
Allow this workflow to be embedded on external websites
</p>
</div>
<Switch
id="embed-enabled"
checked={isEnabled}
onCheckedChange={setIsEnabled}
/>
</div>
{isEnabled && (
<>
<Separator />
{/* Allowed Domains */}
<div className="space-y-3">
<Label>
Allowed Domains
<span className="text-xs text-muted-foreground ml-2">
(leave empty to allow all domains)
</span>
</Label>
{/* Domain Input */}
<div className="flex gap-2">
<Input
placeholder="example.com or *.example.com"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
onKeyPress={handleKeyPress}
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={addDomain}
disabled={!newDomain.trim()}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Domain List */}
{domains.length > 0 && (
<div className="space-y-2">
{domains.map((domain, index) => (
<div
key={index}
className="flex items-center justify-between bg-muted/50 rounded-lg px-3 py-2"
>
<span className="text-sm font-mono">{domain}</span>
<Button
type="button"
size="icon"
variant="ghost"
className="h-6 w-6"
onClick={() => removeDomain(domain)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
{/* Embed Mode Selection */}
<div className="space-y-4">
<Label>Embed Mode</Label>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => setEmbedMode("floating")}
className={`p-4 rounded-lg border-2 transition-all ${
embedMode === "floating"
? "border-primary bg-primary/5"
: "border-muted hover:border-muted-foreground/20"
}`}
>
<div className="space-y-2">
<div className="font-medium">Floating Widget</div>
<div className="text-xs text-muted-foreground">
Shows as a button in corner of the page
</div>
</div>
</button>
<button
type="button"
onClick={() => setEmbedMode("inline")}
className={`p-4 rounded-lg border-2 transition-all ${
embedMode === "inline"
? "border-primary bg-primary/5"
: "border-muted hover:border-muted-foreground/20"
}`}
>
<div className="space-y-2">
<div className="font-medium">Inline Component</div>
<div className="text-xs text-muted-foreground">
Embeds directly in your page content
</div>
</div>
</button>
</div>
</div>
{/* Configuration based on mode */}
<div className="space-y-4">
<Label>Configuration</Label>
{embedMode === "floating" ? (
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="position" className="text-sm">Position</Label>
<Select value={position} onValueChange={setPosition}>
<SelectTrigger id="position">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bottom-right">Bottom Right</SelectItem>
<SelectItem value="bottom-left">Bottom Left</SelectItem>
<SelectItem value="top-right">Top Right</SelectItem>
<SelectItem value="top-left">Top Left</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="button-color" className="text-sm">Button Color</Label>
<div className="flex gap-2">
<Input
id="button-color-picker"
type="color"
value={buttonColor}
onChange={(e) => setButtonColor(e.target.value)}
className="w-14 h-10 cursor-pointer"
/>
<Input
id="button-color"
value={buttonColor}
onChange={(e) => setButtonColor(e.target.value)}
placeholder="#3B82F6"
className="flex-1"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="button-text" className="text-sm">Button Text</Label>
<Input
id="button-text"
value={buttonText}
onChange={(e) => setButtonText(e.target.value)}
placeholder="Start Voice Call"
/>
</div>
</>
) : (
<>
<div className="space-y-3">
<div className="rounded-lg bg-muted/50 p-4">
<h4 className="font-medium mb-2">Integration Instructions</h4>
<ul className="text-sm space-y-2 text-muted-foreground">
<li> Add a div with id=&quot;dograh-inline-container&quot; where you want the widget</li>
<li> The widget will render inside this container</li>
<li> You have full control over the container&apos;s styling</li>
<li> Call window.DograhWidget.start() to begin the call</li>
<li> Call window.DograhWidget.end() to end the call</li>
</ul>
</div>
<div className="rounded-lg bg-blue-50 dark:bg-blue-950/20 p-4 border border-blue-200 dark:border-blue-800">
<h4 className="font-medium mb-2 text-blue-900 dark:text-blue-100">Example React Component</h4>
<pre className="text-xs overflow-x-auto">
<code className="text-blue-800 dark:text-blue-200">{`export function DograhAgent() {
const [isCallActive, setIsCallActive] = useState(false);
useEffect(() => {
// Widget will auto-initialize when script loads
window.DograhWidget?.onCallStart(() => {
setIsCallActive(true);
});
window.DograhWidget?.onCallEnd(() => {
setIsCallActive(false);
});
}, []);
return (
<div className="my-8">
<h2>Talk to Our Agent</h2>
<div id="dograh-inline-container" className="min-h-[400px]">
{/* Widget renders here */}
</div>
<button
onClick={() => window.DograhWidget?.start()}
disabled={isCallActive}
>
Start Call
</button>
</div>
);
}`}</code>
</pre>
</div>
</div>
</>
)}
{/* Preview for floating mode only */}
{embedMode === "floating" && (
<div className="rounded-lg border bg-background p-4 flex items-center justify-center">
<button
className="px-5 py-2.5 rounded-full font-medium shadow-lg hover:shadow-xl transition-all flex items-center gap-2"
style={{
backgroundColor: buttonColor,
color: "white",
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="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 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
{buttonText}
</button>
</div>
)}
</div>
<Separator />
{/* Save Button */}
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save Configurations"
)}
</Button>
</div>
{/* Embed Script (shows after saving) */}
{embedToken && embedToken.is_active && (
<>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Embed Code</Label>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(embedToken.embed_script)}
>
{copied ? (
<>
<Check className="h-4 w-4 mr-1" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy Code
</>
)}
</Button>
</div>
<div className="relative">
<pre className="bg-muted/50 rounded-lg p-4 text-xs overflow-x-auto whitespace-pre-wrap break-all">
<code>{embedToken.embed_script}</code>
</pre>
</div>
<p className="text-xs text-muted-foreground">
Add this script to your website&apos;s HTML to enable the voice widget.
Configuration changes will apply automatically without re-embedding.
</p>
</div>
</>
)}
</>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -54,42 +54,41 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
// Load disposition codes from workflow configuration
useEffect(() => {
const loadDispositionCodes = async () => {
if (!accessToken) return;
try {
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
path: { workflow_id: Number(workflowId) },
headers: { 'Authorization': `Bearer ${accessToken}` }
});
const loadDispositionCodes = useCallback(async () => {
if (!accessToken) return;
try {
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
path: { workflow_id: Number(workflowId) },
headers: { 'Authorization': `Bearer ${accessToken}` }
});
const workflow = response.data;
if (workflow?.call_disposition_codes) {
// Update the disposition code attribute with actual options
const updatedAttributes = configuredAttributes.map(attr => {
if (attr.id === 'dispositionCode') {
return {
...attr,
config: {
...attr.config,
options: Object.keys(workflow.call_disposition_codes || {}).length > 0
? Object.keys(workflow.call_disposition_codes || {})
: [...DISPOSITION_CODES]
}
};
}
return attr;
});
setConfiguredAttributes(updatedAttributes);
}
} catch (err) {
console.error("Failed to load disposition codes:", err);
const workflow = response.data;
if (workflow?.call_disposition_codes) {
// Update the disposition code attribute with actual options
setConfiguredAttributes(prev => prev.map(attr => {
if (attr.id === 'dispositionCode') {
return {
...attr,
config: {
...attr.config,
options: Object.keys(workflow.call_disposition_codes || {}).length > 0
? Object.keys(workflow.call_disposition_codes || {})
: [...DISPOSITION_CODES]
}
};
}
return attr;
}));
}
};
loadDispositionCodes();
} catch (err) {
console.error("Failed to load disposition codes:", err);
}
}, [workflowId, accessToken]);
useEffect(() => {
loadDispositionCodes();
}, [loadDispositionCodes]);
const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => {
if (!accessToken) return;
try {

View file

@ -150,7 +150,7 @@ export const useWorkflowState = ({
initialTemplateContextVariables,
initialWorkflowConfigurations
);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []);
// Set up keyboard shortcuts for undo/redo
useEffect(() => {
@ -418,7 +418,7 @@ export const useWorkflowState = ({
// Validate workflow on mount
useEffect(() => {
validateWorkflow();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []);
return {
rfInstance,

View file

@ -65,7 +65,7 @@ export default function WorkflowDetailPage() {
const stickyTabs = workflow ? <WorkflowTabs workflowId={workflow.id} currentTab={currentTab} /> : null;
// Memoize user and getAccessToken to prevent unnecessary re-renders
const stableUser = useMemo(() => user, [user?.id]);
const stableUser = useMemo(() => user, [user]);
const stableGetAccessToken = useMemo(() => getAccessToken, [getAccessToken]);
if (loading) {

File diff suppressed because one or more lines are too long

View file

@ -234,6 +234,44 @@ export type DuplicateTemplateRequest = {
workflow_name: string;
};
/**
* Response model for embed configuration
*/
export type EmbedConfigResponse = {
workflow_id: number;
settings: {
[key: string]: unknown;
};
theme: string;
position: string;
button_text: string;
button_color: string;
};
export type EmbedTokenRequest = {
allowed_domains?: Array<string> | null;
settings?: {
[key: string]: unknown;
} | null;
usage_limit?: number | null;
expires_in_days?: number | null;
};
export type EmbedTokenResponse = {
id: number;
token: string;
allowed_domains: Array<string> | null;
settings: {
[key: string]: unknown;
} | null;
is_active: boolean;
usage_count: number;
usage_limit: number | null;
expires_at: string | null;
created_at: string;
embed_script: string;
};
export type FileMetadataResponse = {
key: string;
metadata: {
@ -261,6 +299,27 @@ export type ImpersonateResponse = {
access_token: string;
};
/**
* Request model for initializing an embed session
*/
export type InitEmbedRequest = {
token: string;
context_variables?: {
[key: string]: unknown;
} | null;
};
/**
* Response model for embed initialization
*/
export type InitEmbedResponse = {
session_token: string;
workflow_run_id: number;
config: {
[key: string]: unknown;
};
};
export type InitiateCallRequest = {
workflow_id: number;
workflow_run_id?: number | null;
@ -2888,6 +2947,220 @@ export type GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses = {
export type GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse = GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses[keyof GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses];
export type OptionsInitApiV1PublicEmbedInitOptionsData = {
body?: never;
path?: never;
query?: never;
url: '/api/v1/public/embed/init';
};
export type OptionsInitApiV1PublicEmbedInitOptionsErrors = {
/**
* Not found
*/
404: unknown;
};
export type OptionsInitApiV1PublicEmbedInitOptionsResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type InitializeEmbedSessionApiV1PublicEmbedInitPostData = {
body: InitEmbedRequest;
path?: never;
query?: never;
url: '/api/v1/public/embed/init';
};
export type InitializeEmbedSessionApiV1PublicEmbedInitPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type InitializeEmbedSessionApiV1PublicEmbedInitPostError = InitializeEmbedSessionApiV1PublicEmbedInitPostErrors[keyof InitializeEmbedSessionApiV1PublicEmbedInitPostErrors];
export type InitializeEmbedSessionApiV1PublicEmbedInitPostResponses = {
/**
* Successful Response
*/
200: InitEmbedResponse;
};
export type InitializeEmbedSessionApiV1PublicEmbedInitPostResponse = InitializeEmbedSessionApiV1PublicEmbedInitPostResponses[keyof InitializeEmbedSessionApiV1PublicEmbedInitPostResponses];
export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetData = {
body?: never;
path: {
token: string;
};
query?: never;
url: '/api/v1/public/embed/config/{token}';
};
export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetError = GetEmbedConfigApiV1PublicEmbedConfigTokenGetErrors[keyof GetEmbedConfigApiV1PublicEmbedConfigTokenGetErrors];
export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses = {
/**
* Successful Response
*/
200: EmbedConfigResponse;
};
export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse = GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses[keyof GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses];
export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsData = {
body?: never;
path: {
token: string;
};
query?: never;
url: '/api/v1/public/embed/config/{token}';
};
export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsError = OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors[keyof OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors];
export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData = {
body?: never;
headers?: {
authorization?: string | null;
};
path: {
workflow_id: number;
};
query?: never;
url: '/api/v1/workflow/{workflow_id}/embed-token';
};
export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteError = DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteErrors[keyof DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteErrors];
export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponses = {
/**
* Successful Response
*/
200: {
[key: string]: unknown;
};
};
export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponse = DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponses[keyof DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponses];
export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData = {
body?: never;
headers?: {
authorization?: string | null;
};
path: {
workflow_id: number;
};
query?: never;
url: '/api/v1/workflow/{workflow_id}/embed-token';
};
export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetError = GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetErrors[keyof GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetErrors];
export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponses = {
/**
* Successful Response
*/
200: EmbedTokenResponse | null;
};
export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponse = GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponses[keyof GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponses];
export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData = {
body: EmbedTokenRequest;
headers?: {
authorization?: string | null;
};
path: {
workflow_id: number;
};
query?: never;
url: '/api/v1/workflow/{workflow_id}/embed-token';
};
export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostError = CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostErrors[keyof CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostErrors];
export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses = {
/**
* Successful Response
*/
200: EmbedTokenResponse;
};
export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse = CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses[keyof CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses];
export type HealthApiV1HealthGetData = {
body?: never;
path?: never;

View file

@ -0,0 +1,66 @@
"use client"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from "react"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsContent,TabsList, TabsTrigger }

View file

@ -80,8 +80,6 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
if (!auth.loading) {
fetchPermissions();
}
// We intentionally depend only on specific auth properties to avoid infinite loops
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [auth.loading, auth.provider, auth.getSelectedTeam, auth.listPermissions]);
@ -152,8 +150,6 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
} finally {
setLoading(false);
}
// We intentionally depend only on specific auth properties to avoid infinite loops
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [auth.loading, auth.isAuthenticated, auth.getAccessToken]);
useEffect(() => {