mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-25 08:48:13 +02:00
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:
parent
5e4aef346d
commit
99a768f291
40 changed files with 3551 additions and 645 deletions
5
ui/package-lock.json
generated
5
ui/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
951
ui/public/embed/dograh-widget.js
Normal file
951
ui/public/embed/dograh-widget.js
Normal 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();
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
507
ui/src/app/workflow/[workflowId]/components/EmbedDialog.tsx
Normal file
507
ui/src/app/workflow/[workflowId]/components/EmbedDialog.tsx
Normal 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 "{workflowName}" 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="dograh-inline-container" where you want the widget</li>
|
||||
<li>• The widget will render inside this container</li>
|
||||
<li>• You have full control over the container'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's HTML to enable the voice widget.
|
||||
Configuration changes will apply automatically without re-embedding.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
66
ui/src/components/ui/tabs.tsx
Normal file
66
ui/src/components/ui/tabs.tsx
Normal 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 }
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue