Initial Commit 🚀 🚀

This commit is contained in:
Abhishek Kumar 2025-09-09 14:37:32 +05:30
commit 4f2a629340
444 changed files with 76863 additions and 0 deletions

View file

@ -0,0 +1,20 @@
import { redirect } from "next/navigation";
import { getServerAuthProvider, getServerUser } from "@/lib/auth/server";
import { getRedirectUrl } from "@/lib/utils";
export default async function AfterSignInPage() {
const authProvider = getServerAuthProvider();
const user = await getServerUser();
if (authProvider === 'stack' && user && 'getAuthJson' in user) {
const token = await user.getAuthJson();
const permissions = 'listPermissions' in user && 'selectedTeam' in user
? await user.listPermissions(user.selectedTeam!) ?? []
: [];
const redirectUrl = await getRedirectUrl(token?.accessToken ?? "", permissions);
redirect(redirectUrl);
}
// For local provider or if user is not available, redirect to create-workflow
redirect('/create-workflow');
}

View file

@ -0,0 +1,14 @@
import BaseHeader from "@/components/header/BaseHeader";
export default function APIKeysLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<BaseHeader/>
{children}
</>
);
}

View file

@ -0,0 +1,677 @@
"use client";
import { Copy, Eye, EyeOff, Key, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
archiveApiKeyApiV1UserApiKeysApiKeyIdDelete,
archiveServiceKeyApiV1UserServiceKeysServiceKeyIdDelete,
createApiKeyApiV1UserApiKeysPost,
createServiceKeyApiV1UserServiceKeysPost,
getApiKeysApiV1UserApiKeysGet,
getServiceKeysApiV1UserServiceKeysGet,
reactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePut
} from '@/client/sdk.gen';
import type { ApiKeyResponse, CreateApiKeyResponse, CreateServiceKeyResponse,ServiceKeyResponse } from '@/client/types.gen';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/lib/auth';
export default function APIKeysPage() {
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
const [apiKeys, setApiKeys] = useState<ApiKeyResponse[]>([]);
const [serviceKeys, setServiceKeys] = useState<ServiceKeyResponse[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isServiceKeysLoading, setIsServiceKeysLoading] = useState(true);
const [showArchived, setShowArchived] = useState(false);
const [showServiceArchived, setShowServiceArchived] = useState(false);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isCreateServiceDialogOpen, setIsCreateServiceDialogOpen] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newServiceKeyName, setNewServiceKeyName] = useState('');
const [createdKey, setCreatedKey] = useState<CreateApiKeyResponse | null>(null);
const [createdServiceKey, setCreatedServiceKey] = useState<CreateServiceKeyResponse | null>(null);
const [showCreatedKeyDialog, setShowCreatedKeyDialog] = useState(false);
const [showCreatedServiceKeyDialog, setShowCreatedServiceKeyDialog] = useState(false);
const [error, setError] = useState<string | null>(null);
// Redirect if not authenticated
useEffect(() => {
if (!loading && !user) {
redirectToLogin();
}
}, [loading, user, redirectToLogin]);
const fetchApiKeys = useCallback(async () => {
if (!user) return;
try {
setIsLoading(true);
setError(null);
const accessToken = await getAccessToken();
const response = await getApiKeysApiV1UserApiKeysGet({
query: {
include_archived: showArchived
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setApiKeys(response.data);
}
} catch (err) {
setError('Failed to fetch API keys');
console.error('Error fetching API keys:', err);
} finally {
setIsLoading(false);
}
}, [user, getAccessToken, showArchived]);
const fetchServiceKeys = useCallback(async () => {
if (!user) return;
try {
setIsServiceKeysLoading(true);
setError(null);
const accessToken = await getAccessToken();
const response = await getServiceKeysApiV1UserServiceKeysGet({
query: {
include_archived: showServiceArchived
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setServiceKeys(response.data);
}
} catch (err) {
setError('Failed to fetch service keys');
console.error('Error fetching service keys:', err);
} finally {
setIsServiceKeysLoading(false);
}
}, [user, getAccessToken, showServiceArchived]);
useEffect(() => {
fetchApiKeys();
}, [fetchApiKeys]);
useEffect(() => {
fetchServiceKeys();
}, [fetchServiceKeys]);
const handleCreateKey = async () => {
if (!newKeyName.trim()) {
setError('Please enter a name for the API key');
return;
}
try {
setError(null);
const accessToken = await getAccessToken();
const response = await createApiKeyApiV1UserApiKeysPost({
body: {
name: newKeyName
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setCreatedKey(response.data);
setIsCreateDialogOpen(false);
setShowCreatedKeyDialog(true);
setNewKeyName('');
fetchApiKeys();
}
} catch (err) {
setError('Failed to create API key');
console.error('Error creating API key:', err);
}
};
const handleCreateServiceKey = async () => {
if (!newServiceKeyName.trim()) {
setError('Please enter a name for the service key');
return;
}
try {
setError(null);
const accessToken = await getAccessToken();
const response = await createServiceKeyApiV1UserServiceKeysPost({
body: {
name: newServiceKeyName,
expires_in_days: 90
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setCreatedServiceKey(response.data);
setIsCreateServiceDialogOpen(false);
setShowCreatedServiceKeyDialog(true);
setNewServiceKeyName('');
fetchServiceKeys();
}
} catch (err) {
setError('Failed to create service key');
console.error('Error creating service key:', err);
}
};
const handleArchiveKey = async (keyId: number) => {
try {
setError(null);
const accessToken = await getAccessToken();
await archiveApiKeyApiV1UserApiKeysApiKeyIdDelete({
path: {
api_key_id: keyId
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
fetchApiKeys();
} catch (err) {
setError('Failed to archive API key');
console.error('Error archiving API key:', err);
}
};
const handleArchiveServiceKey = async (keyId: string) => {
try {
setError(null);
const accessToken = await getAccessToken();
await archiveServiceKeyApiV1UserServiceKeysServiceKeyIdDelete({
path: {
service_key_id: keyId
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
fetchServiceKeys();
} catch (err) {
setError('Failed to archive service key');
console.error('Error archiving service key:', err);
}
};
const handleReactivateKey = async (keyId: number) => {
try {
setError(null);
const accessToken = await getAccessToken();
await reactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePut({
path: {
api_key_id: keyId
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
fetchApiKeys();
} catch (err) {
setError('Failed to reactivate API key');
console.error('Error reactivating API key:', err);
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error('Failed to copy to clipboard:', err);
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'Never';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Don't render content until auth is loaded
if (loading || !user) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="space-y-4">
<Skeleton className="h-12 w-64" />
<Skeleton className="h-64 w-96" />
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Developer Portal</h1>
<p className="text-gray-600">Manage your API keys to access Dograh services programmatically</p>
</div>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
<Card className="mb-6">
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle>API Keys</CardTitle>
<CardDescription>
Create and manage API keys for your organization
</CardDescription>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowArchived(!showArchived)}
>
{showArchived ? <Eye className="w-4 h-4 mr-2" /> : <EyeOff className="w-4 h-4 mr-2" />}
{showArchived ? 'Hide' : 'Show'} Archived
</Button>
<Button
onClick={() => setIsCreateDialogOpen(true)}
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
Create New Key
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between p-4 border rounded-lg">
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-8 w-20" />
</div>
))}
</div>
) : apiKeys.length === 0 ? (
<div className="text-center py-12">
<Key className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">No API keys found</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
Create Your First API Key
</Button>
</div>
) : (
<div className="space-y-4">
{apiKeys.map((key) => (
<div
key={key.id}
className={`flex items-center justify-between p-4 border rounded-lg ${
key.archived_at ? 'bg-gray-50 opacity-60' : 'bg-white'
}`}
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{key.name}</span>
{key.archived_at ? (
<Badge variant="secondary">Archived</Badge>
) : key.is_active ? (
<Badge variant="default">Active</Badge>
) : (
<Badge variant="destructive">Inactive</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<span className="font-mono bg-gray-100 px-2 py-1 rounded">{key.key_prefix}...</span>
<span className="text-xs text-gray-400">
(Full key hidden for security)
</span>
</div>
<div className="mt-2 text-xs text-gray-500">
Created: {formatDate(key.created_at)}
Last used: {formatDate(key.last_used_at ?? null)}
</div>
</div>
<div className="flex gap-2">
{key.archived_at ? (
<Button
variant="outline"
size="sm"
onClick={() => handleReactivateKey(key.id)}
>
<RefreshCw className="w-4 h-4 mr-1" />
Reactivate
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => handleArchiveKey(key.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Dograh Service Keys Section */}
<Card className="mb-6">
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle>Dograh Service Keys</CardTitle>
<CardDescription>
Manage service keys for accessing Dograh AI services (LLM, TTS, STT)
</CardDescription>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowServiceArchived(!showServiceArchived)}
>
{showServiceArchived ? <Eye className="w-4 h-4 mr-2" /> : <EyeOff className="w-4 h-4 mr-2" />}
{showServiceArchived ? 'Hide' : 'Show'} Archived
</Button>
<Button
onClick={() => setIsCreateServiceDialogOpen(true)}
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
Create Service Key
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isServiceKeysLoading ? (
<div className="space-y-4">
{[1, 2].map((i) => (
<div key={i} className="flex items-center justify-between p-4 border rounded-lg">
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-8 w-20" />
</div>
))}
</div>
) : serviceKeys.length === 0 ? (
<div className="text-center py-12">
<Key className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">No service keys found</p>
<Button onClick={() => setIsCreateServiceDialogOpen(true)}>
Create Your First Service Key
</Button>
</div>
) : (
<div className="space-y-4">
{serviceKeys.map((key) => (
<div
key={key.id}
className={`flex items-center justify-between p-4 border rounded-lg ${
key.archived_at ? 'bg-gray-50 opacity-60' : 'bg-white'
}`}
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{key.name}</span>
{key.archived_at ? (
<Badge variant="secondary">Archived</Badge>
) : key.is_active ? (
<Badge variant="default">Active</Badge>
) : (
<Badge variant="destructive">Inactive</Badge>
)}
{key.expires_at && new Date(key.expires_at) > new Date() && (
<Badge variant="outline">
Expires: {formatDate(key.expires_at)}
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<span className="font-mono bg-gray-100 px-2 py-1 rounded">{key.key_prefix}...</span>
<span className="text-xs text-gray-400">
(Full key hidden for security)
</span>
</div>
<div className="mt-2 text-xs text-gray-500">
Created: {formatDate(key.created_at)}
Last used: {formatDate(key.last_used_at ?? null)}
</div>
</div>
<div className="flex gap-2">
{!key.archived_at && (
<Button
variant="ghost"
size="sm"
onClick={() => handleArchiveServiceKey(String(key.id))}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
<strong>Important:</strong> Keep your API keys secure. Never share them publicly or commit them to version control.
API keys provide full access to your organization&apos;s resources.
</p>
</div>
</div>
</div>
{/* Create API Key Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New API Key</DialogTitle>
<DialogDescription>
Enter a descriptive name for your API key to help you identify it later.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Key Name</Label>
<Input
id="name"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="e.g., Production Server, Development Environment"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateKey}>
Create Key
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Show Created Key Dialog */}
<Dialog open={showCreatedKeyDialog} onOpenChange={setShowCreatedKeyDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>API Key Created Successfully</DialogTitle>
<DialogDescription>
Make sure to copy your API key now. You won&apos;t be able to see it again!
</DialogDescription>
</DialogHeader>
{createdKey && (
<div className="space-y-4">
<div className="p-4 bg-gray-100 rounded-lg">
<p className="text-sm text-gray-600 mb-2">Your API Key:</p>
<div className="flex items-center gap-2">
<code className="flex-1 p-2 bg-white rounded text-sm font-mono break-all">
{createdKey.api_key}
</code>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(createdKey.api_key)}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
Store this key securely. It will only be shown once and cannot be retrieved later.
</p>
</div>
</div>
)}
<DialogFooter>
<Button onClick={() => {
setShowCreatedKeyDialog(false);
setCreatedKey(null);
}}>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create Service Key Dialog */}
<Dialog open={isCreateServiceDialogOpen} onOpenChange={setIsCreateServiceDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Service Key</DialogTitle>
<DialogDescription>
Create a service key to access Dograh AI services (LLM, TTS, STT)
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="service-name">Service Key Name</Label>
<Input
id="service-name"
value={newServiceKeyName}
onChange={(e) => setNewServiceKeyName(e.target.value)}
placeholder="e.g., Production AI Services, Development LLM Access"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateServiceDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateServiceKey}>
Create Service Key
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Show Created Service Key Dialog */}
<Dialog open={showCreatedServiceKeyDialog} onOpenChange={setShowCreatedServiceKeyDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Service Key Created Successfully</DialogTitle>
<DialogDescription>
Make sure to copy your service key now. You won&apos;t be able to see it again!
</DialogDescription>
</DialogHeader>
{createdServiceKey && (
<div className="space-y-4">
<div className="p-4 bg-gray-100 rounded-lg">
<p className="text-sm text-gray-600 mb-2">Your Service Key:</p>
<div className="flex items-center gap-2">
<code className="flex-1 p-2 bg-white rounded text-sm font-mono break-all">
{createdServiceKey.service_key}
</code>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(createdServiceKey.service_key)}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
This key provides access to Dograh AI services including LLM, Text-to-Speech, and Speech-to-Text.
{createdServiceKey.expires_at && (
<span className="block mt-1">
Expires on: {formatDate(createdServiceKey.expires_at)}
</span>
)}
</p>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
Store this key securely. It will only be shown once and cannot be retrieved later.
</p>
</div>
</div>
)}
<DialogFooter>
<Button onClick={() => {
setShowCreatedServiceKeyDialog(false);
setCreatedServiceKey(null);
}}>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,60 @@
/*
Helps provide authentication token to LocalAuthService once its loaded
in the browser
*/
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
const OSS_TOKEN_COOKIE = 'dograh_oss_token';
const OSS_USER_COOKIE = 'dograh_oss_user';
function generateOSSToken(): string {
return `oss_${Date.now()}_${crypto.randomUUID()}`;
}
export async function GET() {
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
// Only handle OSS mode
if (authProvider !== 'local') {
return NextResponse.json({ error: 'Not in OSS mode' }, { status: 400 });
}
const cookieStore = await cookies();
let token = cookieStore.get(OSS_TOKEN_COOKIE)?.value;
let user = cookieStore.get(OSS_USER_COOKIE)?.value;
// If no token exists, create one
if (!token) {
token = generateOSSToken();
user = JSON.stringify({
id: token,
name: 'Local User',
provider: 'local',
organizationId: `org_${token}`,
});
// Set cookies
cookieStore.set(OSS_TOKEN_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
});
cookieStore.set(OSS_USER_COOKIE, user, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
});
}
// Return the auth info as JSON (safe to expose to client)
return NextResponse.json({
token,
user: JSON.parse(user!),
});
}

View file

@ -0,0 +1,14 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function CampaignsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -0,0 +1,35 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function AutomationPage() {
return (
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Automation</h1>
<p className="text-gray-600">Automate your workflows and processes</p>
</div>
<Card>
<CardHeader>
<CardTitle>Coming Soon</CardTitle>
<CardDescription>
Automation features are currently under development
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<p className="text-gray-500 text-lg mb-4">
We&apos;re working on powerful automation features to help you streamline your workflows.
</p>
<p className="text-gray-500">
Check back soon for updates!
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -0,0 +1,237 @@
'use client';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet, getIntegrationsApiV1IntegrationGet } from '@/client/sdk.gen';
import type { IntegrationResponse } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import logger from '@/lib/logger';
interface GoogleSheetSelectorProps {
accessToken: string;
onSheetSelected: (sheetUrl: string, sheetName: string) => void;
selectedSheetUrl?: string;
}
interface PickerBuilder {
addView: (viewId: string) => PickerBuilder;
setOAuthToken: (token: string) => PickerBuilder;
setDeveloperKey: (key: string) => PickerBuilder;
setCallback: (callback: (data: { action: string; docs?: Array<{ id: string; name: string; url: string }> }) => void) => PickerBuilder;
setTitle: (title: string) => PickerBuilder;
build: () => { setVisible: (visible: boolean) => void };
}
declare global {
interface Window {
gapi: {
load: (library: string, callback: () => void) => void;
};
google: {
picker: {
PickerBuilder: new () => PickerBuilder;
ViewId: {
SPREADSHEETS: string;
};
Action: {
PICKED: string;
};
};
};
}
}
// Google API configuration
const GOOGLE_API_KEY = process.env.NEXT_PUBLIC_GOOGLE_API_KEY || '';
export default function GoogleSheetSelector({ accessToken, onSheetSelected, selectedSheetUrl }: GoogleSheetSelectorProps) {
const [loading, setLoading] = useState(false);
const [pickerApiLoaded, setPickerApiLoaded] = useState(false);
const [googleIntegration, setGoogleIntegration] = useState<IntegrationResponse | null>(null);
const [selectedSheetName, setSelectedSheetName] = useState<string>('');
const [checkingIntegration, setCheckingIntegration] = useState(true);
// Load Google Picker API
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://apis.google.com/js/api.js';
script.onload = () => {
window.gapi.load('picker', () => {
setPickerApiLoaded(true);
logger.info('Google Picker API loaded');
});
};
document.body.appendChild(script);
return () => {
if (document.body.contains(script)) {
document.body.removeChild(script);
}
};
}, []);
// Check for Google Sheet integration
useEffect(() => {
const checkGoogleIntegration = async () => {
if (!accessToken) {
return;
}
try {
const response = await getIntegrationsApiV1IntegrationGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
const integrations = Array.isArray(response.data) ? response.data : [response.data];
const googleSheet = integrations.find(i => i.provider === 'google-sheet');
setGoogleIntegration(googleSheet || null);
}
} catch (error) {
logger.error('Failed to check Google integration:', error);
} finally {
setCheckingIntegration(false);
}
};
checkGoogleIntegration();
}, [accessToken]);
const fetchGoogleAccessToken = async () => {
if (!googleIntegration) return null;
try {
const response = await getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet({
path: {
integration_id: googleIntegration.id,
},
headers: {
Authorization: `Bearer ${accessToken}`,
}
});
if (response.data?.access_token) {
return response.data.access_token;
}
return null;
} catch (error) {
logger.error('Failed to fetch Google access token:', error);
return null;
}
};
const openGooglePicker = async () => {
if (!pickerApiLoaded) {
toast.error('Google Picker is still loading. Please try again.');
return;
}
if (!GOOGLE_API_KEY) {
toast.error('Google API Key is not configured.');
return;
}
if (!googleIntegration) {
toast.error('Please connect Google Sheets in the Integrations page first.');
return;
}
setLoading(true);
try {
const token = await fetchGoogleAccessToken();
if (!token) {
toast.error('Failed to get Google access token. Please re-authorize in Integrations.');
setLoading(false);
return;
}
const picker = new window.google.picker.PickerBuilder()
.addView(window.google.picker.ViewId.SPREADSHEETS)
.setOAuthToken(token)
.setDeveloperKey(GOOGLE_API_KEY)
.setCallback((data: { action: string; docs?: Array<{ id: string; name: string; url: string }> }) => {
if (data.action === window.google.picker.Action.PICKED && data.docs && data.docs.length > 0) {
const doc = data.docs[0];
setSelectedSheetName(doc.name);
onSheetSelected(doc.url, doc.name);
toast.success(`Selected: ${doc.name}`);
}
setLoading(false);
})
.setTitle('Select a Google Sheet for your campaign')
.build();
picker.setVisible(true);
} catch (error) {
toast.error('Error opening Google Picker');
logger.error('Error opening Google Picker:', error);
setLoading(false);
}
};
if (checkingIntegration) {
return (
<div className="space-y-2">
<Label>Google Sheet</Label>
<div className="text-sm text-gray-500">Checking Google integration...</div>
</div>
);
}
if (!googleIntegration) {
return (
<div className="space-y-2">
<Label>Google Sheet</Label>
<div className="p-4 border border-amber-200 bg-amber-50 rounded-md">
<p className="text-sm text-amber-800 mb-2">
Google Sheets integration not found
</p>
<p className="text-sm text-amber-700">
Please go to the{' '}
<a href="/integrations" className="text-amber-900 underline font-medium">
Integrations page
</a>
{' '}and connect your Google account first.
</p>
</div>
</div>
);
}
return (
<div className="space-y-2">
<Label>Google Sheet</Label>
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
onClick={openGooglePicker}
disabled={loading}
>
{loading ? 'Opening...' : 'Select Google Sheet'}
</Button>
{selectedSheetUrl && (
<div className="flex-1 text-sm">
<span className="text-gray-600">Selected: </span>
<a
href={selectedSheetUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{selectedSheetName || selectedSheetUrl}
</a>
</div>
)}
</div>
<p className="text-sm text-gray-500">
Select a Google Sheet from your connected Google account
</p>
</div>
);
}

View file

@ -0,0 +1,463 @@
"use client";
import { ArrowLeft, Pause, Play, RefreshCw } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import {
getCampaignApiV1CampaignCampaignIdGet,
getCampaignRunsApiV1CampaignCampaignIdRunsGet,
pauseCampaignApiV1CampaignCampaignIdPausePost,
resumeCampaignApiV1CampaignCampaignIdResumePost,
startCampaignApiV1CampaignCampaignIdStartPost} from '@/client/sdk.gen';
import type { CampaignResponse, WorkflowRunResponse } from '@/client/types.gen';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useAuth } from '@/lib/auth';
export default function CampaignDetailPage() {
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
const router = useRouter();
const params = useParams();
const campaignId = parseInt(params.campaignId as string);
// Redirect if not authenticated
useEffect(() => {
if (!loading && !user) {
redirectToLogin();
}
}, [loading, user, redirectToLogin]);
// Campaign state
const [campaign, setCampaign] = useState<CampaignResponse | null>(null);
const [isLoadingCampaign, setIsLoadingCampaign] = useState(true);
// Runs state
const [runs, setRuns] = useState<WorkflowRunResponse[]>([]);
const [isLoadingRuns, setIsLoadingRuns] = useState(false);
// Action state
const [isExecutingAction, setIsExecutingAction] = useState(false);
// Fetch campaign details
const fetchCampaign = useCallback(async () => {
if (!user) return;
setIsLoadingCampaign(true);
try {
const accessToken = await getAccessToken();
const response = await getCampaignApiV1CampaignCampaignIdGet({
path: {
campaign_id: campaignId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setCampaign(response.data);
}
} catch (error) {
console.error('Failed to fetch campaign:', error);
toast.error('Failed to load campaign details');
} finally {
setIsLoadingCampaign(false);
}
}, [user, getAccessToken, campaignId]);
// Fetch campaign runs
const fetchCampaignRuns = useCallback(async () => {
if (!user) return;
setIsLoadingRuns(true);
try {
const accessToken = await getAccessToken();
const response = await getCampaignRunsApiV1CampaignCampaignIdRunsGet({
path: {
campaign_id: campaignId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setRuns(response.data);
}
} catch (error) {
console.error('Failed to fetch campaign runs:', error);
} finally {
setIsLoadingRuns(false);
}
}, [user, getAccessToken, campaignId]);
// Initial load
useEffect(() => {
fetchCampaign();
fetchCampaignRuns();
}, [fetchCampaign, fetchCampaignRuns]);
// Handle back navigation
const handleBack = () => {
router.push('/campaigns');
};
// Handle workflow link click
const handleWorkflowClick = () => {
if (campaign) {
router.push(`/workflow/${campaign.workflow_id}`);
}
};
// Handle run click
const handleRunClick = (runId: number) => {
if (campaign) {
router.push(`/workflow/${campaign.workflow_id}/run/${runId}`);
}
};
// Handle start campaign
const handleStart = async () => {
if (!user) return;
setIsExecutingAction(true);
try {
const accessToken = await getAccessToken();
const response = await startCampaignApiV1CampaignCampaignIdStartPost({
path: {
campaign_id: campaignId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setCampaign(response.data);
toast.success('Campaign started');
} else if (response.error) {
// Extract error message from response
let errorMsg = 'Failed to start campaign';
if (typeof response.error === 'string') {
errorMsg = response.error;
} else if (response.error && typeof response.error === 'object') {
errorMsg = (response.error as unknown as { detail?: string }).detail || JSON.stringify(response.error);
}
toast.error(errorMsg);
}
} catch (error) {
console.error('Failed to start campaign:', error);
toast.error('Failed to start campaign');
} finally {
setIsExecutingAction(false);
}
};
// Handle resume campaign
const handleResume = async () => {
if (!user) return;
setIsExecutingAction(true);
try {
const accessToken = await getAccessToken();
const response = await resumeCampaignApiV1CampaignCampaignIdResumePost({
path: {
campaign_id: campaignId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setCampaign(response.data);
toast.success('Campaign resumed');
} else if (response.error) {
// Extract error message from response
let errorMsg = 'Failed to resume campaign';
if (typeof response.error === 'string') {
errorMsg = response.error;
} else if (response.error && typeof response.error === 'object') {
errorMsg = (response.error as unknown as { detail?: string }).detail || JSON.stringify(response.error);
}
toast.error(errorMsg);
}
} catch (error) {
console.error('Failed to resume campaign:', error);
toast.error('Failed to resume campaign');
} finally {
setIsExecutingAction(false);
}
};
// Handle pause campaign
const handlePause = async () => {
if (!user) return;
setIsExecutingAction(true);
try {
const accessToken = await getAccessToken();
const response = await pauseCampaignApiV1CampaignCampaignIdPausePost({
path: {
campaign_id: campaignId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setCampaign(response.data);
toast.success('Campaign paused');
}
} catch (error) {
console.error('Failed to pause campaign:', error);
toast.error('Failed to pause campaign');
} finally {
setIsExecutingAction(false);
}
};
// Format date for display
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString();
};
const formatDateTime = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
// Get badge variant for state
const getStateBadgeVariant = (state: string) => {
switch (state) {
case 'created':
return 'secondary';
case 'running':
return 'default';
case 'paused':
return 'outline';
case 'completed':
return 'secondary';
case 'failed':
return 'destructive';
default:
return 'secondary';
}
};
// Render action button based on state
const renderActionButton = () => {
if (!campaign || isExecutingAction) return null;
switch (campaign.state) {
case 'created':
return (
<Button onClick={handleStart} disabled={isExecutingAction}>
<Play className="h-4 w-4 mr-2" />
Start Campaign
</Button>
);
case 'running':
return (
<Button onClick={handlePause} disabled={isExecutingAction}>
<Pause className="h-4 w-4 mr-2" />
Pause Campaign
</Button>
);
case 'paused':
return (
<Button onClick={handleResume} disabled={isExecutingAction}>
<RefreshCw className="h-4 w-4 mr-2" />
Resume Campaign
</Button>
);
default:
return null;
}
};
if (isLoadingCampaign) {
return (
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
</div>
</div>
);
}
if (!campaign) {
return (
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
<p className="text-center text-gray-500">Campaign not found</p>
</div>
</div>
);
}
return (
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
<div className="mb-6">
<Button
variant="ghost"
onClick={handleBack}
className="mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Campaigns
</Button>
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{campaign.name}</h1>
<div className="flex items-center gap-4">
<Badge variant={getStateBadgeVariant(campaign.state)}>
{campaign.state}
</Badge>
<span className="text-gray-600">
Created {formatDate(campaign.created_at)}
</span>
</div>
</div>
{renderActionButton()}
</div>
</div>
{/* Campaign Details */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Campaign Details</CardTitle>
<CardDescription>
Configuration and source information
</CardDescription>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Workflow</dt>
<dd className="mt-1">
<button
onClick={handleWorkflowClick}
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{campaign.workflow_name}
</button>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Source Type</dt>
<dd className="mt-1 capitalize">{campaign.source_type.replace('-', ' ')}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Source Sheet</dt>
<dd className="mt-1">
<a
href={campaign.source_id}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline text-sm break-all"
>
{campaign.source_id}
</a>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">State</dt>
<dd className="mt-1 capitalize">{campaign.state}</dd>
</div>
{campaign.started_at && (
<div>
<dt className="text-sm font-medium text-gray-500">Started At</dt>
<dd className="mt-1">{formatDateTime(campaign.started_at)}</dd>
</div>
)}
{campaign.completed_at && (
<div>
<dt className="text-sm font-medium text-gray-500">Completed At</dt>
<dd className="mt-1">{formatDateTime(campaign.completed_at)}</dd>
</div>
)}
</dl>
</CardContent>
</Card>
{/* Workflow Runs */}
<Card>
<CardHeader>
<CardTitle>Workflow Runs</CardTitle>
<CardDescription>
Executions triggered by this campaign
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingRuns ? (
<div className="animate-pulse space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-gray-200 rounded"></div>
))}
</div>
) : runs.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Run ID</TableHead>
<TableHead>State</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => (
<TableRow
key={run.id}
className="cursor-pointer hover:bg-gray-50"
onClick={() => handleRunClick(run.id)}
>
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
<TableCell>
<Badge variant={run.state === 'completed' ? 'secondary' : 'default'}>
{run.state}
</Badge>
</TableCell>
<TableCell>{formatDateTime(run.created_at)}</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRunClick(run.id);
}}
>
View
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<p className="text-center py-8 text-gray-500">
{campaign.state === 'created'
? 'No runs yet. Start the campaign to begin execution.'
: 'No workflow runs found for this campaign.'}
</p>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -0,0 +1,14 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function CampaignsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -0,0 +1,227 @@
"use client";
import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { createCampaignApiV1CampaignCreatePost, getWorkflowsSummaryApiV1WorkflowSummaryGet } from '@/client/sdk.gen';
import type { WorkflowSummaryResponse } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAuth } from '@/lib/auth';
import GoogleSheetSelector from '../GoogleSheetSelector';
export default function NewCampaignPage() {
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
const router = useRouter();
// Form state
const [campaignName, setCampaignName] = useState('');
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string>('');
const [selectedSheetUrl, setSelectedSheetUrl] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [userAccessToken, setUserAccessToken] = useState<string>('');
// Workflows state
const [workflows, setWorkflows] = useState<WorkflowSummaryResponse[]>([]);
const [isLoadingWorkflows, setIsLoadingWorkflows] = useState(true);
// Redirect if not authenticated
useEffect(() => {
if (!loading && !user) {
redirectToLogin();
}
}, [loading, user, redirectToLogin]);
// Fetch workflows
const fetchWorkflows = useCallback(async () => {
if (!user) return;
try {
const accessToken = await getAccessToken();
setUserAccessToken(accessToken);
const response = await getWorkflowsSummaryApiV1WorkflowSummaryGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setWorkflows(response.data);
}
} catch (error) {
console.error('Failed to fetch workflows:', error);
toast.error('Failed to load workflows');
} finally {
setIsLoadingWorkflows(false);
}
}, [user, getAccessToken]);
// Initial load
useEffect(() => {
if (user) {
fetchWorkflows();
}
}, [fetchWorkflows, user]);
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!campaignName || !selectedWorkflowId || !selectedSheetUrl) {
toast.error('Please fill in all fields');
return;
}
setIsSubmitting(true);
try {
const accessToken = await getAccessToken();
const response = await createCampaignApiV1CampaignCreatePost({
body: {
name: campaignName,
workflow_id: parseInt(selectedWorkflowId),
source_id: selectedSheetUrl,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
toast.success('Campaign created successfully');
router.push(`/campaigns/${response.data.id}`);
}
} catch (error) {
console.error('Failed to create campaign:', error);
toast.error('Failed to create campaign');
} finally {
setIsSubmitting(false);
}
};
// Handle back navigation
const handleBack = () => {
router.push('/campaigns');
};
// Handle sheet selection
const handleSheetSelected = (sheetUrl: string) => {
setSelectedSheetUrl(sheetUrl);
};
return (
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
<div className="max-w-2xl mx-auto">
<div className="mb-6">
<Button
variant="ghost"
onClick={handleBack}
className="mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Campaigns
</Button>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Create New Campaign</h1>
<p className="text-gray-600">Set up a new campaign to execute workflows at scale</p>
</div>
<Card>
<CardHeader>
<CardTitle>Campaign Details</CardTitle>
<CardDescription>
Configure your campaign settings
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="campaign-name">Campaign Name</Label>
<Input
id="campaign-name"
placeholder="Enter campaign name"
value={campaignName}
onChange={(e) => setCampaignName(e.target.value)}
maxLength={255}
required
/>
<p className="text-sm text-gray-500">
Choose a descriptive name for your campaign
</p>
</div>
<div className="space-y-2">
<Label htmlFor="workflow">Workflow</Label>
<Select
value={selectedWorkflowId}
onValueChange={setSelectedWorkflowId}
required
>
<SelectTrigger id="workflow">
<SelectValue placeholder="Select a workflow" />
</SelectTrigger>
<SelectContent>
{isLoadingWorkflows ? (
<SelectItem value="loading" disabled>
Loading workflows...
</SelectItem>
) : workflows.length === 0 ? (
<SelectItem value="none" disabled>
No workflows found
</SelectItem>
) : (
workflows.map((workflow) => (
<SelectItem
key={workflow.id}
value={workflow.id.toString()}
>
{workflow.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-sm text-gray-500">
Select the workflow to execute for each row in the spreadsheet
</p>
</div>
<GoogleSheetSelector
accessToken={userAccessToken}
onSheetSelected={handleSheetSelected}
selectedSheetUrl={selectedSheetUrl}
/>
<div className="flex gap-4 pt-4">
<Button
type="submit"
disabled={isSubmitting || !campaignName || !selectedWorkflowId || !selectedSheetUrl}
>
{isSubmitting ? 'Creating...' : 'Create Campaign'}
</Button>
<Button
type="button"
variant="outline"
onClick={handleBack}
disabled={isSubmitting}
>
Cancel
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -0,0 +1,186 @@
"use client";
import { Plus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { getCampaignsApiV1CampaignGet } from '@/client/sdk.gen';
import type { CampaignsResponse } from '@/client/types.gen';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useAuth } from '@/lib/auth';
export default function CampaignsPage() {
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
const router = useRouter();
// Campaigns state
const [campaignsData, setCampaignsData] = useState<CampaignsResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Redirect if not authenticated
useEffect(() => {
if (!loading && !user) {
redirectToLogin();
}
}, [loading, user, redirectToLogin]);
// Fetch campaigns
const fetchCampaigns = useCallback(async () => {
if (!user) return;
setIsLoading(true);
try {
const accessToken = await getAccessToken();
const response = await getCampaignsApiV1CampaignGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setCampaignsData(response.data);
}
} catch (error) {
console.error('Failed to fetch campaigns:', error);
} finally {
setIsLoading(false);
}
}, [user, getAccessToken]);
// Initial load
useEffect(() => {
if (user) {
fetchCampaigns();
}
}, [fetchCampaigns, user]);
// Handle row click to navigate to campaign detail
const handleRowClick = (campaignId: number) => {
router.push(`/campaigns/${campaignId}`);
};
// Handle create campaign button
const handleCreateCampaign = () => {
router.push('/campaigns/new');
};
// Format date for display
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString();
};
// Get badge variant for state
const getStateBadgeVariant = (state: string) => {
switch (state) {
case 'created':
return 'secondary';
case 'running':
return 'default';
case 'paused':
return 'outline';
case 'completed':
return 'secondary';
case 'failed':
return 'destructive';
default:
return 'secondary';
}
};
return (
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Campaigns</h1>
<p className="text-gray-600">Manage your bulk workflow execution campaigns</p>
</div>
<Button onClick={handleCreateCampaign}>
<Plus className="h-4 w-4 mr-2" />
Create Campaign
</Button>
</div>
{/* Campaigns Table */}
<Card>
<CardHeader>
<CardTitle>All Campaigns</CardTitle>
<CardDescription>
View and manage your campaigns
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="animate-pulse space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-12 bg-gray-200 rounded"></div>
))}
</div>
) : campaignsData && campaignsData.campaigns.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Workflow</TableHead>
<TableHead>State</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{campaignsData.campaigns.map((campaign) => (
<TableRow
key={campaign.id}
className="cursor-pointer hover:bg-gray-50"
onClick={() => handleRowClick(campaign.id)}
>
<TableCell className="font-medium">{campaign.name}</TableCell>
<TableCell>{campaign.workflow_name}</TableCell>
<TableCell>
<Badge variant={getStateBadgeVariant(campaign.state)}>
{campaign.state}
</Badge>
</TableCell>
<TableCell>{formatDate(campaign.created_at)}</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRowClick(campaign.id);
}}
>
View
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-8">
<p className="text-gray-500 mb-4">No campaigns found</p>
<Button onClick={handleCreateCampaign} variant="outline">
<Plus className="h-4 w-4 mr-2" />
Create your first campaign
</Button>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -0,0 +1,14 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function CreateWorkflowLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -0,0 +1,170 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost } from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
export default function CreateWorkflowPage() {
const router = useRouter();
const { user, getAccessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [callType, setCallType] = useState<'INBOUND' | 'OUTBOUND'>('INBOUND');
const [useCase, setUseCase] = useState('');
const [activityDescription, setActivityDescription] = useState('');
const handleCreateWorkflow = async () => {
if (!useCase || !activityDescription) {
setError('Please fill in all fields');
return;
}
if (!user) {
setError('You must be logged in to create a workflow');
return;
}
setIsLoading(true);
setError(null);
try {
const accessToken = await getAccessToken();
// Call the API to create workflow from template
const response = await createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost({
body: {
call_type: callType,
use_case: useCase,
activity_description: activityDescription,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (response.data?.id) {
router.push(`/workflow/${response.data.id}`);
}
} catch (err) {
setError('Failed to create workflow. Please try again.');
logger.error(`Error creating workflow: ${err}`);
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center p-4 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
<Card className="w-full max-w-4xl shadow-xl border-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur">
<CardHeader className="text-center pb-4 pt-6">
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
Create Your Voice Agent Workflow
</h1>
<CardDescription className="text-base mt-2 text-gray-600 dark:text-gray-400">
Tell us about your use case and we&apos;ll create a customized workflow for you
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 px-6 pb-6">
<div className="space-y-4">
<div className="flex flex-col space-y-4">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div className="flex items-center flex-wrap gap-2">
<span className="text-base font-medium text-gray-700 dark:text-gray-300">I want to create an</span>
<Select value={callType} onValueChange={(value) => setCallType(value as 'INBOUND' | 'OUTBOUND')}>
<SelectTrigger className="w-[180px] h-10 text-sm font-semibold border-2 focus:ring-2 focus:ring-blue-500">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="INBOUND" className="text-sm">
<span className="font-medium">📞 INBOUND</span>
<span className="text-xs text-gray-500 ml-1">(Users call AI)</span>
</SelectItem>
<SelectItem value="OUTBOUND" className="text-sm">
<span className="font-medium"> OUTBOUND</span>
<span className="text-xs text-gray-500 ml-1">(AI calls users)</span>
</SelectItem>
</SelectContent>
</Select>
<span className="text-base font-medium text-gray-700 dark:text-gray-300">voice agent</span>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
Use Case
</label>
<div className="flex items-start flex-col gap-2">
<span className="text-base font-medium text-gray-700 dark:text-gray-300">Which serves the use case:</span>
<Input
className="w-full h-10 text-sm px-3 border-2 focus:ring-2 focus:ring-blue-500 transition-all"
placeholder="e.g., Lead Qualification, HR Screening, Customer Support"
value={useCase}
onChange={(e) => setUseCase(e.target.value)}
/>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
Activity Description
</label>
<div className="flex items-start flex-col gap-2">
<span className="text-base font-medium text-gray-700 dark:text-gray-300">Which can:</span>
<textarea
className="w-full min-h-[80px] text-sm px-3 py-2 border-2 rounded-md focus:ring-2 focus:ring-blue-500 transition-all resize-none"
placeholder="Describe what your voice agent will do (e.g., Qualify leads for real estate, Screen candidates for roles, Handle customer support)"
value={activityDescription}
onChange={(e) => setActivityDescription(e.target.value)}
/>
</div>
</div>
</div>
</div>
{error && (
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800 flex items-center gap-2">
<svg className="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
{error}
</div>
)}
<div className="pt-2">
<Button
onClick={handleCreateWorkflow}
disabled={isLoading || !useCase || !activityDescription}
className="w-full h-12 text-base font-semibold bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 transition-all transform hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
size="lg"
>
{isLoading ? (
<span className="flex items-center gap-3">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Creating Your Workflow...
</span>
) : (
<span className="flex items-center gap-2">
Create Workflow
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</span>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

BIN
ui/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -0,0 +1,23 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import NextError from "next/error";
import { useEffect } from "react";
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html>
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</body>
</html>
);
}

125
ui/src/app/globals.css Normal file
View file

@ -0,0 +1,125 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -0,0 +1,14 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function StackLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -0,0 +1,22 @@
import { StackHandler } from "@stackframe/stack";
import { stackServerApp } from "../../../stack";
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER;
export default function Handler(props: unknown) {
if (authProvider === "local") {
// Return a simple message when using local auth
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h1>Local Auth Mode</h1>
<p>Stack Auth handler is disabled when using local authentication.</p>
</div>
);
}
return <StackHandler
fullPage
app={stackServerApp}
routeProps={props}
/>;
}

View file

@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
/**
* Helper route that receives a refresh token via query parameters, stores it as
* the regular Stack cookie *for the current sub-domain only* and finally
* redirects the user to the requested path.
*
* Example usage (client side):
* /impersonate?refresh_token=<TOKEN>&redirect_path=/workflow/123
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const refreshToken = searchParams.get("refresh_token");
const redirectPath = searchParams.get("redirect_path") ?? "/create-workflow";
if (!refreshToken) {
return new Response("Missing refresh_token", { status: 400 });
}
// Prepare redirect if the supplied redirect path is an absolute URL we use
// it as-is, otherwise we resolve it relative to the current request.
const redirectUrl = redirectPath.startsWith("http")
? redirectPath
: new URL(redirectPath, request.url).toString();
const response = NextResponse.redirect(redirectUrl);
// One day in seconds
const maxAge = 60 * 60 * 24;
// Store the refresh token cookie without an explicit domain so that it is
// scoped to the current (sub-)domain. This avoids collisions between the
// admin (superadmin.*) and the regular app (app.*) domains.
response.cookies.set(`stack-refresh-${process.env.NEXT_PUBLIC_STACK_PROJECT_ID}` as string, refreshToken, {
path: "/",
maxAge,
secure: true,
httpOnly: false, // Must be accessible from the browser for Stack SDK
sameSite: "lax",
});
return response;
}

View file

@ -0,0 +1,69 @@
'use client';
import Nango from '@nangohq/frontend';
import { useState } from 'react';
import { createSessionApiV1IntegrationSessionPost } from "@/client/sdk.gen";
import { Button } from '@/components/ui/button';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
export default function CreateIntegrationButton() {
const [isLoading, setIsLoading] = useState(false);
const { user, getAccessToken } = useAuth();
const handleCreateIntegration = async () => {
setIsLoading(true);
try {
if (!user) {
throw new Error('User not authenticated');
}
const accessToken = await getAccessToken();
// Fetch session details from our API
const sessionResponse = await createSessionApiV1IntegrationSessionPost({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!sessionResponse.data?.session_token) {
throw new Error('Failed to get session token');
}
// Initialize Nango and open connect UI
const nango = new Nango();
const connect = nango.openConnectUI({
onEvent: (event) => {
if (event.type === 'close') {
// Handle modal closed
setIsLoading(false);
logger.info('Nango connect UI closed');
} else if (event.type === 'connect') {
// Handle auth flow successful
setIsLoading(false);
logger.info('Integration connected successfully');
// Refresh the page to show new integrations
window.location.reload();
}
},
});
// Set the session token to initialize the connect UI
connect.setSessionToken(sessionResponse.data.session_token);
} catch (err) {
logger.error(`Error creating integration: ${err}`);
setIsLoading(false);
// You might want to show a toast notification here
alert('Failed to create integration. Please try again.');
}
};
return (
<Button onClick={handleCreateIntegration} disabled={isLoading}>
{isLoading ? 'Loading...' : 'Create Integration'}
</Button>
);
}

View file

@ -0,0 +1,14 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function IntegrationsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -0,0 +1,179 @@
import { Suspense } from 'react';
import { getIntegrationsApiV1IntegrationGet } from "@/client/sdk.gen";
import { getServerAccessToken,getServerAuthProvider } from '@/lib/auth/server';
import logger from '@/lib/logger';
import CreateIntegrationButton from "./CreateIntegrationButton";
// Server component for integration list
async function IntegrationList() {
const authProvider = getServerAuthProvider();
const accessToken = await getServerAccessToken();
if (!accessToken) {
const { redirect } = await import('next/navigation');
if (authProvider === 'stack') {
redirect('/');
} else {
// For OSS mode, this shouldn't happen as token is auto-generated
return (
<div className="text-red-500">
Authentication required. Please refresh the page.
</div>
);
}
}
try {
const response = await getIntegrationsApiV1IntegrationGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
const integrationData = response.data ? (Array.isArray(response.data) ? response.data : [response.data]) : [];
const integrations = [...integrationData].sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
if (integrations.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No integrations found. Create your first integration to get started.
</div>
);
}
return (
<div className="space-y-6">
<div className="overflow-x-auto">
<table className="min-w-full bg-white border border-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Provider
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Channel
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Action
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created At
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{integrations.map((integration) => (
<tr key={integration.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{integration.provider}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{integration.provider === 'slack' && integration.provider_data ? (integration.provider_data.channel as string) || '-' : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{integration.action}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(integration.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
} catch (err) {
logger.error(`Error fetching integrations: ${err}`);
return (
<div className="text-red-500 text-center py-8">
Failed to load Integrations. Please Try Again Later.
</div>
);
}
}
async function PageContent() {
const integrationList = await IntegrationList();
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Your Integrations</h1>
<CreateIntegrationButton />
</div>
{integrationList}
</div>
</div>
);
}
function IntegrationsLoading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<div className="h-8 w-48 bg-gray-200 rounded"></div>
<div className="h-10 w-32 bg-gray-200 rounded"></div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full bg-white border border-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Integration ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Channel
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Action
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created At
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-32 bg-gray-200 rounded"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-24 bg-gray-200 rounded"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-24 bg-gray-200 rounded"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-24 bg-gray-200 rounded"></div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
export default function IntegrationsPage() {
return (
<Suspense fallback={<IntegrationsLoading />}>
<PageContent />
</Suspense>
);
}

51
ui/src/app/layout.tsx Normal file
View file

@ -0,0 +1,51 @@
import "./globals.css";
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Suspense } from "react";
import PostHogIdentify from "@/components/PostHogIdentify";
import SpinLoader from "@/components/SpinLoader";
import { Toaster } from "@/components/ui/sonner";
import { UserConfigProvider } from "@/context/UserConfigContext";
import { AuthProvider } from "@/lib/auth";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Dograh",
description: "Open Source Voice Assistant Workflow Builder",
};
export default function RootLayout({
children
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<AuthProvider>
<Suspense fallback={<SpinLoader />}>
<UserConfigProvider>
<PostHogIdentify />
{children}
<Toaster />
</UserConfigProvider>
</Suspense>
</AuthProvider>
</body>
</html>
);
}

5
ui/src/app/loading.tsx Normal file
View file

@ -0,0 +1,5 @@
export default function Loading() {
// Stack uses React Suspense, which will render this page while user data is being fetched.
// See: https://nextjs.org/docs/app/api-reference/file-conventions/loading
return <></>;
}

View file

@ -0,0 +1,20 @@
import React, { ReactNode } from 'react'
import BaseHeader from '@/components/header/BaseHeader'
interface LoopTalkLayoutProps {
children: ReactNode,
headerActions?: ReactNode,
backButton?: ReactNode,
}
const LoopTalkLayout: React.FC<LoopTalkLayoutProps> = ({ children, headerActions, backButton }) => {
return (
<>
<BaseHeader headerActions={headerActions} backButton={backButton} />
{children}
</>
)
}
export default LoopTalkLayout

View file

@ -0,0 +1,126 @@
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Suspense } from 'react';
import { getTestSessionApiV1LooptalkTestSessionsTestSessionIdGet } from '@/client/sdk.gen';
import { ConversationsList } from '@/components/looptalk/ConversationsList';
import { LiveAudioPlayer } from '@/components/looptalk/LiveAudioPlayer';
import { TestSessionControls } from '@/components/looptalk/TestSessionControls';
import { TestSessionDetails } from '@/components/looptalk/TestSessionDetails';
import { Button } from '@/components/ui/button';
import { getServerAccessToken,getServerAuthProvider } from '@/lib/auth/server';
import logger from '@/lib/logger';
import LoopTalkLayout from "../LoopTalkLayout";
interface PageProps {
params: Promise<{
id: string;
}>;
}
async function PageContent({ params }: PageProps) {
const authProvider = getServerAuthProvider();
const accessToken = await getServerAccessToken();
if (!accessToken) {
const { redirect } = await import('next/navigation');
if (authProvider === 'stack') {
redirect('/');
} else {
// For OSS mode, this shouldn't happen as token is auto-generated
return (
<div className="text-red-500">
Authentication required. Please refresh the page.
</div>
);
}
}
try {
const resolvedParams = await params;
const testSessionId = parseInt(resolvedParams.id);
const response = await getTestSessionApiV1LooptalkTestSessionsTestSessionIdGet({
path: {
test_session_id: testSessionId
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
const testSession = response.data;
if (!testSession) {
notFound();
}
// Transform the API response to match our UI types
const sessionForUI = {
id: testSession.id,
name: testSession.name,
description: '', // API doesn't return description
test_type: testSession.test_index !== null ? 'load_test' : 'single',
status: testSession.status,
actor_workflow_name: `Workflow ${testSession.actor_workflow_id}`, // We'll need to fetch actual names
adversary_workflow_name: `Workflow ${testSession.adversary_workflow_id}`,
created_at: testSession.created_at,
updated_at: testSession.created_at, // API doesn't have updated_at
test_metadata: testSession.config
};
return (
<div className="container mx-auto px-4 py-8">
<TestSessionDetails session={sessionForUI} />
<TestSessionControls session={sessionForUI} />
{/* Persistent Audio Player */}
<div className="mt-6">
<LiveAudioPlayer
testSessionId={testSessionId}
sessionStatus={testSession.status as 'pending' | 'running' | 'completed' | 'failed'}
autoStart={true}
/>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold mb-4">Conversations</h2>
<ConversationsList testSessionId={testSessionId} />
</div>
</div>
);
} catch (err) {
logger.error(`Error fetching test session: ${err}`);
notFound();
}
}
function TestSessionLoading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="space-y-4">
<div className="h-32 bg-gray-200 rounded-lg animate-pulse"></div>
<div className="h-20 bg-gray-200 rounded-lg animate-pulse"></div>
<div className="h-64 bg-gray-200 rounded-lg animate-pulse"></div>
</div>
</div>
);
}
export default function TestSessionPage({ params }: PageProps) {
const backButton = (
<Link href="/looptalk">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Test Sessions
</Button>
</Link>
);
return (
<LoopTalkLayout backButton={backButton}>
<Suspense fallback={<TestSessionLoading />}>
<PageContent params={params} />
</Suspense>
</LoopTalkLayout>
);
}

View file

@ -0,0 +1,77 @@
import { Suspense } from 'react';
import { CreateTestSessionButton } from '@/components/looptalk/CreateTestSessionButton';
import { LoopTalkTestSessionsList } from '@/components/looptalk/LoopTalkTestSessionsList';
import { getServerAuthProvider, isServerAuthenticated } from '@/lib/auth/server';
import LoopTalkLayout from "./LoopTalkLayout";
async function PageContent() {
const authProvider = getServerAuthProvider();
const isAuthenticated = await isServerAuthenticated();
if (authProvider === 'stack' && !isAuthenticated) {
const { redirect } = await import('next/navigation');
redirect('/');
}
return (
<div className="container mx-auto px-4 py-8">
{/* Active Tests Section */}
<div className="mb-12">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Active Tests</h2>
</div>
<LoopTalkTestSessionsList status="active" />
</div>
{/* Test Sessions Section */}
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Test Sessions</h1>
<CreateTestSessionButton />
</div>
<LoopTalkTestSessionsList />
</div>
</div>
);
}
function LoopTalkLoading() {
return (
<div className="container mx-auto px-4 py-8">
{/* Active Tests Section Loading */}
<div className="mb-12">
<div className="h-8 w-48 bg-gray-200 rounded mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="bg-gray-200 rounded-lg h-40"></div>
))}
</div>
</div>
{/* Test Sessions Section Loading */}
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<div className="h-8 w-48 bg-gray-200 rounded"></div>
<div className="h-10 w-32 bg-gray-200 rounded"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="bg-gray-200 rounded-lg h-32"></div>
))}
</div>
</div>
</div>
);
}
export default function LoopTalkPage() {
return (
<LoopTalkLayout>
<Suspense fallback={<LoopTalkLoading />}>
<PageContent />
</Suspense>
</LoopTalkLayout>
);
}

57
ui/src/app/page.tsx Normal file
View file

@ -0,0 +1,57 @@
import { redirect } from "next/navigation";
import SignInClient from "@/components/SignInClient";
import { getServerAuthProvider,getServerUser } from "@/lib/auth/server";
import logger from '@/lib/logger';
import { getRedirectUrl } from "@/lib/utils";
export default async function Home() {
const authProvider = getServerAuthProvider();
// For local/OSS provider, always redirect to workflow page
if (authProvider === 'local') {
logger.debug('Redirecting to workflow page for local provider');
redirect('/create-workflow');
}
const user = await getServerUser();
logger.debug(`authProvider: ${authProvider}, user: ${JSON.stringify(user)}`);
if (user) {
try {
// For Stack provider, get the token and permissions
if (authProvider === 'stack' && 'getAuthJson' in user) {
const token = await user.getAuthJson();
const permissions = 'listPermissions' in user && 'selectedTeam' in user
? await user.listPermissions(user.selectedTeam!) ?? []
: [];
const redirectUrl = await getRedirectUrl(token?.accessToken ?? "", permissions);
logger.debug(`redirectUrl: ${redirectUrl}`);
redirect(redirectUrl);
}
} catch (error) {
// If it's a Next.js redirect, let it through
if (error instanceof Error && 'digest' in error &&
typeof error.digest === 'string' && error.digest.startsWith('NEXT_REDIRECT')) {
throw error;
}
// Only catch actual API errors
console.error("API unavailable, showing sign-in:", error);
// Show sign-in page if API is unavailable
}
}
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<SignInClient />
</div>
);
}

View file

@ -0,0 +1,96 @@
'use client';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface DispositionData {
disposition: string;
count: number;
percentage: number;
}
interface DispositionChartProps {
data: DispositionData[];
}
const COLORS = [
'#3b82f6', // blue-500
'#10b981', // emerald-500
'#f59e0b', // amber-500
'#8b5cf6', // violet-500
'#ef4444', // red-500
'#6b7280', // gray-500 for "Other"
];
export function DispositionChart({ data }: DispositionChartProps) {
const chartData = data.map((item, index) => ({
...item,
fill: COLORS[index % COLORS.length],
}));
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: DispositionData & { fill: string } }> }) => {
if (active && payload && payload[0]) {
const data = payload[0].payload;
return (
<div className="bg-background border rounded-lg shadow-lg p-3">
<p className="font-semibold">{data.disposition}</p>
<p className="text-sm">Count: {data.count}</p>
<p className="text-sm">{data.percentage}% of total</p>
</div>
);
}
return null;
};
return (
<Card>
<CardHeader>
<CardTitle>Disposition Distribution</CardTitle>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No disposition data available
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={chartData}
layout="horizontal"
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" opacity={0.1} />
<XAxis
dataKey="disposition"
angle={-45}
textAnchor="end"
height={80}
interval={0}
tick={{ fontSize: 12 }}
/>
<YAxis
tick={{ fontSize: 12 }}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,94 @@
'use client';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface DurationData {
bucket: string;
range_start: number;
range_end: number | null;
count: number;
percentage: number;
}
interface DurationChartProps {
data: DurationData[];
}
const COLORS = {
'0-10': '#dcfce7', // green-100
'10-30': '#bbf7d0', // green-200
'30-60': '#86efac', // green-300
'60-120': '#4ade80', // green-400
'120-180': '#22c55e', // green-500
'>180': '#16a34a', // green-600
};
export function DurationChart({ data }: DurationChartProps) {
const chartData = data.map((item) => ({
...item,
label: `${item.bucket}s`,
fill: COLORS[item.bucket as keyof typeof COLORS] || '#6b7280',
}));
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: DurationData & { label: string; fill: string } }> }) => {
if (active && payload && payload[0]) {
const data = payload[0].payload;
return (
<div className="bg-background border rounded-lg shadow-lg p-3">
<p className="font-semibold">{data.label}</p>
<p className="text-sm">Calls: {data.count}</p>
<p className="text-sm">{data.percentage}% of total</p>
</div>
);
}
return null;
};
return (
<Card>
<CardHeader>
<CardTitle>Call Duration Distribution</CardTitle>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No duration data available
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={chartData}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" opacity={0.1} />
<XAxis
dataKey="label"
tick={{ fontSize: 12 }}
/>
<YAxis
tick={{ fontSize: 12 }}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,42 @@
import { Phone,PhoneForwarded } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface MetricsCardsProps {
metrics: {
total_runs: number;
xfer_count: number;
};
}
export function MetricsCards({ metrics }: MetricsCardsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Workflow Runs</CardTitle>
<Phone className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metrics.total_runs.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">
Total calls processed today
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Transfer Dispositions</CardTitle>
<PhoneForwarded className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metrics.xfer_count.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">
Calls transferred (XFER)
</p>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,14 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function ReportsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

323
ui/src/app/reports/page.tsx Normal file
View file

@ -0,0 +1,323 @@
'use client';
import { addDays, format, subDays } from 'date-fns';
import { Calendar, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import { useEffect,useState } from 'react';
import {
getDailyReportApiV1OrganizationsReportsDailyGet,
getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet,
getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet
} from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { Calendar as CalendarPicker } from '@/components/ui/calendar';
import { Card } from '@/components/ui/card';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { useUserConfig } from '@/context/UserConfigContext';
import { DispositionChart } from './components/DispositionChart';
import { DurationChart } from './components/DurationChart';
import { MetricsCards } from './components/MetricsCards';
interface WorkflowOption {
id: number;
name: string;
}
interface DailyReport {
date: string;
timezone: string;
workflow_id: number | null;
metrics: {
total_runs: number;
xfer_count: number;
};
disposition_distribution: Array<{
disposition: string;
count: number;
percentage: number;
}>;
call_duration_distribution: Array<{
bucket: string;
range_start: number;
range_end: number | null;
count: number;
percentage: number;
}>;
}
export default function ReportsPage() {
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [selectedWorkflow, setSelectedWorkflow] = useState<string>('all');
const [workflows, setWorkflows] = useState<WorkflowOption[]>([]);
const [report, setReport] = useState<DailyReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { userConfig, accessToken } = useUserConfig();
const timezone = userConfig?.timezone || 'America/New_York';
// Fetch workflows on mount
useEffect(() => {
const fetchWorkflows = async () => {
if (!accessToken) return;
try {
const response = await getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet({
headers: {
Authorization: `Bearer ${accessToken}`
}
});
if (response.data) {
setWorkflows(response.data);
}
} catch (err) {
console.error('Failed to fetch workflows:', err);
}
};
fetchWorkflows();
}, [accessToken]);
// Fetch report data when date or workflow changes
useEffect(() => {
const fetchReport = async () => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const dateStr = format(selectedDate, 'yyyy-MM-dd');
const workflowId = selectedWorkflow === 'all' ? undefined : parseInt(selectedWorkflow);
const response = await getDailyReportApiV1OrganizationsReportsDailyGet({
query: {
date: dateStr,
timezone,
...(workflowId && { workflow_id: workflowId })
},
headers: {
Authorization: `Bearer ${accessToken}`
}
});
if (response.data) {
setReport(response.data as DailyReport);
}
} catch (err) {
console.error('Failed to fetch report:', err);
setError('Failed to load report data');
} finally {
setLoading(false);
}
};
fetchReport();
}, [selectedDate, selectedWorkflow, timezone, accessToken]);
const handlePreviousDay = () => {
setSelectedDate(subDays(selectedDate, 1));
};
const handleNextDay = () => {
setSelectedDate(addDays(selectedDate, 1));
};
const handleDownloadCSV = async () => {
if (!accessToken) return;
try {
const dateStr = format(selectedDate, 'yyyy-MM-dd');
const workflowId = selectedWorkflow === 'all' ? undefined : parseInt(selectedWorkflow);
// Fetch detailed runs data
const response = await getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet({
query: {
date: dateStr,
timezone,
...(workflowId && { workflow_id: workflowId })
},
headers: {
Authorization: `Bearer ${accessToken}`
}
});
if (response.data && response.data.length > 0) {
// Prepare CSV content
const headers = ['Phone Number', 'Disposition', 'Duration (seconds)', 'Workflow Run URL'];
const rows = response.data.map(run => {
const url = `${window.location.origin}/workflow/${run.workflow_id}/run/${run.run_id}`;
return [
run.phone_number || '',
run.disposition || '',
run.duration_seconds.toString(),
url
];
});
// Create CSV content
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n');
// Create blob and download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
const workflowName = selectedWorkflow === 'all'
? 'all_workflows'
: workflows.find(w => w.id.toString() === selectedWorkflow)?.name?.replace(/\s+/g, '_') || 'workflow';
link.setAttribute('href', url);
link.setAttribute('download', `workflow_runs_${dateStr}_${workflowName}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
alert('No data available for download');
}
} catch (err) {
console.error('Failed to download CSV:', err);
alert('Failed to download CSV data');
}
};
const isToday = format(selectedDate, 'yyyy-MM-dd') === format(new Date(), 'yyyy-MM-dd');
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<h1 className="text-3xl font-bold">Daily Reports</h1>
{/* Date Navigation & Workflow Selector */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
{/* Workflow Selector */}
<Select value={selectedWorkflow} onValueChange={setSelectedWorkflow}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select workflow" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Workflows</SelectItem>
{workflows.map((workflow) => (
<SelectItem key={workflow.id} value={workflow.id.toString()}>
{workflow.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Date Navigation */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={handlePreviousDay}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-[200px]">
<Calendar className="mr-2 h-4 w-4" />
{format(selectedDate, 'MMM dd, yyyy')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<CalendarPicker
mode="single"
selected={selectedDate}
onSelect={(date) => date && setSelectedDate(date)}
disabled={(date) => date > new Date()}
/>
</PopoverContent>
</Popover>
<Button
variant="outline"
size="icon"
onClick={handleNextDay}
disabled={isToday}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Timezone Display and Download Button */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
<div className="text-sm text-muted-foreground">
Showing data for {timezone} timezone
{selectedWorkflow !== 'all' && (
<span> Filtered by: {workflows.find(w => w.id.toString() === selectedWorkflow)?.name}</span>
)}
</div>
{/* Download CSV Button */}
{!loading && report && report.metrics.total_runs > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleDownloadCSV}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
Download CSV
</Button>
)}
</div>
{/* Loading State */}
{loading && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Skeleton className="h-[120px]" />
<Skeleton className="h-[120px]" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Skeleton className="h-[300px]" />
<Skeleton className="h-[300px]" />
</div>
</div>
)}
{/* Error State */}
{error && !loading && (
<Card className="p-6">
<p className="text-center text-red-500">{error}</p>
</Card>
)}
{/* Report Content */}
{report && !loading && !error && (
<>
{/* Metrics Cards */}
<MetricsCards metrics={report.metrics} />
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<DispositionChart data={report.disposition_distribution} />
<DurationChart data={report.call_duration_distribution} />
</div>
{/* No Data Message */}
{report.metrics.total_runs === 0 && (
<Card className="p-6">
<p className="text-center text-muted-foreground">
No workflow runs found for {format(selectedDate, 'MMMM dd, yyyy')}
{selectedWorkflow !== 'all' && ' for the selected workflow'}
</p>
</Card>
)}
</>
)}
</div>
);
}

View file

@ -0,0 +1,14 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function ServiceConfigurationLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -0,0 +1,13 @@
import ServiceConfiguration from "@/components/ServiceConfiguration";
export default function ServiceConfigurationPage() {
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<ServiceConfiguration />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,14 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function SuperAdminLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -0,0 +1,134 @@
"use client";
import { ArrowRight, List, Loader2 } from 'lucide-react';
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from '@/lib/auth';
import { impersonateAsSuperadmin } from "@/lib/utils";
export default function SuperadminPage() {
const [userId, setUserId] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { user, getAccessToken } = useAuth();
const handleImpersonate = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsLoading(true);
try {
if (!user) {
setError("User not authenticated. Please log in and try again.");
setIsLoading(false);
return;
}
const accessToken = await getAccessToken();
if (!accessToken) {
throw new Error('Missing admin access token');
}
await impersonateAsSuperadmin({
accessToken: accessToken,
providerUserId: userId,
redirectPath: '/workflow',
openInNewTab: true,
});
} catch (err) {
setError("Failed to impersonate user. Please try again.");
console.error("Impersonation error:", err);
} finally {
setIsLoading(false);
}
};
return (
<>
<main className="min-h-[calc(100vh-73px)] bg-gray-50 px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Superadmin Dashboard</h1>
<p className="text-sm text-gray-600">Manage users and view system-wide data</p>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* User Impersonation Card */}
<Card>
<CardHeader>
<CardTitle>User Impersonation</CardTitle>
<CardDescription>
Impersonate a user account for debugging or support purposes
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleImpersonate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="userId">Provider User ID</Label>
<Input
id="userId"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="Enter provider user ID"
required
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<Button
type="submit"
disabled={isLoading}
className="w-full"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
'Impersonate User'
)}
</Button>
</form>
</CardContent>
</Card>
{/* Workflow Runs Card */}
<Card>
<CardHeader>
<CardTitle>Workflow Runs</CardTitle>
<CardDescription>
View and manage all workflow runs across organizations
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<p className="text-sm text-gray-600">
Access detailed information about all workflow runs, including status,
recordings, transcripts, and usage data.
</p>
<Link href="/superadmin/runs">
<Button className="w-full">
<List className="mr-2 h-4 w-4" />
View All Runs
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
</div>
</main>
</>
);
}

View file

@ -0,0 +1,677 @@
"use client";
import { AlertTriangle, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, Info, Loader2, MessageSquare, RefreshCw } from 'lucide-react';
import Image from 'next/image';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from "react";
import { getWorkflowRunsApiV1SuperuserWorkflowRunsGet, setAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPost } from '@/client/sdk.gen';
import { FilterBuilder } from "@/components/filters/FilterBuilder";
import { MediaPreviewButtons, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useUserConfig } from '@/context/UserConfigContext';
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
import{ superadminFilterAttributes } from "@/lib/filterAttributes";
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
import { impersonateAsSuperadmin } from '@/lib/utils';
import { ActiveFilter } from '@/types/filters';
interface WorkflowRun {
id: number;
name: string;
workflow_id: number;
workflow_name?: string;
user_id?: number;
organization_id?: number;
organization_name?: string;
mode: string;
is_completed: boolean;
recording_url?: string;
transcript_url?: string;
usage_info?: Record<string, unknown>;
cost_info?: Record<string, unknown>;
initial_context?: Record<string, unknown>;
gathered_context?: Record<string, unknown>;
admin_comment?: string;
created_at: string;
}
interface WorkflowRunsResponse {
workflow_runs: WorkflowRun[];
total_count: number;
page: number;
limit: number;
total_pages: number;
}
export default function RunsPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [runs, setRuns] = useState<WorkflowRun[]>([]);
const [currentPage, setCurrentPage] = useState(() => {
const pageParam = searchParams.get('page');
return pageParam ? parseInt(pageParam, 10) : 1;
});
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(false);
const [isAutoRefreshing, setIsAutoRefreshing] = useState(false);
const [currentTime, setCurrentTime] = useState(Date.now());
const limit = 50;
// Initialize filters from URL
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
return decodeFiltersFromURL(searchParams, superadminFilterAttributes);
});
// Dialog state for comment editing
const [isCommentDialogOpen, setIsCommentDialogOpen] = useState(false);
const [commentRunId, setCommentRunId] = useState<number | null>(null);
const [commentText, setCommentText] = useState('');
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
const { accessToken } = useUserConfig();
// Media preview dialog
const mediaPreview = MediaPreviewDialog({ accessToken });
const fetchRuns = useCallback(async (page: number, filters?: ActiveFilter[], isAutoRefresh = false) => {
if (!accessToken) return;
// Don't show loading state for auto-refresh to prevent UI flicker
if (!isAutoRefresh) {
setIsLoading(true);
} else {
setIsAutoRefreshing(true);
}
setError("");
try {
let filterParam = undefined;
if (filters && filters.length > 0) {
const filterData = filters.map(filter => ({
attribute: filter.attribute.id,
type: filter.attribute.type,
value: filter.value,
}));
filterParam = JSON.stringify(filterData);
}
const response = await getWorkflowRunsApiV1SuperuserWorkflowRunsGet({
query: {
page,
limit,
...(filterParam && { filters: filterParam })
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
const data = response.data as WorkflowRunsResponse;
setRuns(data.workflow_runs);
setCurrentPage(data.page);
setTotalPages(data.total_pages);
setTotalCount(data.total_count);
}
} catch (err) {
setError("Failed to fetch workflow runs. Please try again.");
console.error("Fetch runs error:", err);
} finally {
if (!isAutoRefresh) {
setIsLoading(false);
} else {
setIsAutoRefreshing(false);
}
}
}, [limit, accessToken]);
const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => {
const params = new URLSearchParams();
params.set('page', page.toString());
// Add filters to URL if present
if (filters && filters.length > 0) {
const filterString = encodeFiltersToURL(filters);
if (filterString) {
const filterParams = new URLSearchParams(filterString);
filterParams.forEach((value, key) => params.set(key, value));
}
}
router.push(`/superadmin/runs?${params.toString()}`);
}, [router]);
useEffect(() => {
// Fetch runs when token is available and when page changes
if (accessToken) {
fetchRuns(currentPage, activeFilters);
}
}, [currentPage, accessToken, activeFilters, fetchRuns]);
// Auto-refresh every 5 seconds when enabled and filters are active
useEffect(() => {
// Only set up interval if auto-refresh is enabled and there are active filters
if (!autoRefresh || activeFilters.length === 0) {
return;
}
const intervalId = setInterval(() => {
// Pass true to indicate this is an auto-refresh
fetchRuns(currentPage, activeFilters, true);
}, 5000);
// Cleanup interval on unmount or when dependencies change
return () => clearInterval(intervalId);
}, [currentPage, activeFilters, fetchRuns, autoRefresh]);
// Update current time every second to show live duration for running calls
useEffect(() => {
const hasRunningCalls = runs.some(run => !run.is_completed);
if (!hasRunningCalls) {
return;
}
const intervalId = setInterval(() => {
setCurrentTime(Date.now());
}, 1000);
return () => clearInterval(intervalId);
}, [runs]);
const handlePageChange = (page: number) => {
setCurrentPage(page);
updatePageInUrl(page, activeFilters);
fetchRuns(page, activeFilters);
};
const handleApplyFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1); // Reset to first page when applying filters
updatePageInUrl(1, activeFilters);
await fetchRuns(1, activeFilters);
setIsExecutingFilters(false);
}, [activeFilters, fetchRuns, updatePageInUrl]);
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
setActiveFilters(filters);
}, []);
const handleClearFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1);
updatePageInUrl(1, []); // Clear filters from URL
await fetchRuns(1, []); // Fetch all runs without filters
setIsExecutingFilters(false);
}, [fetchRuns, updatePageInUrl]);
// Save comment function declared outside JSX (requirement #2)
const saveAdminComment = useCallback(async () => {
if (commentRunId === null || !accessToken) return;
try {
await setAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPost({
path: {
run_id: commentRunId,
},
body: {
admin_comment: commentText,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
// Optimistically update UI
setRuns(prev => prev.map(r => r.id === commentRunId ? { ...r, admin_comment: commentText } : r));
setIsCommentDialogOpen(false);
} catch (err) {
console.error('Failed to set admin comment', err);
alert('Failed to save comment. Please try again.');
}
}, [commentRunId, commentText, accessToken]);
/**
* ----------------------------------------------------------------------------------
* Helpers
* ----------------------------------------------------------------------------------
*/
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
const calculateDuration = (createdAt: string, isCompleted: boolean, usageInfo?: Record<string, unknown>) => {
if (isCompleted && typeof usageInfo?.call_duration_seconds === 'number') {
return `${Number(usageInfo.call_duration_seconds).toFixed(2)}s`;
}
if (!isCompleted) {
const startTime = new Date(createdAt).getTime();
const duration = Math.floor((currentTime - startTime) / 1000);
// If duration exceeds 5 minutes (300 seconds), show "-" as it's likely an error
if (duration > 300) {
return '-';
}
if (duration < 60) {
return `${duration}s`;
} else {
const minutes = Math.floor(duration / 60);
const seconds = duration % 60;
return `${minutes}m ${seconds}s`;
}
}
return '-';
};
/**
* Wrapper around shared impersonation util we only need to fetch the
* current superadmin token and then delegate the heavy lifting.
*/
const impersonateAndMaybeRedirect = useCallback(
async (targetUserId: number | undefined, redirectPath?: string) => {
if (!targetUserId || !accessToken) return;
try {
await impersonateAsSuperadmin({
accessToken: accessToken,
userId: targetUserId,
redirectPath,
openInNewTab: true,
});
} catch (err) {
console.error('Failed to impersonate user', err);
alert('Failed to impersonate the user. Please try again.');
}
},
[accessToken],
);
if (isLoading && runs.length === 0) {
return (
<div className="min-h-[calc(100vh-73px)] bg-gray-50 flex items-center justify-center">
<div className="flex items-center space-x-2">
<Loader2 className="h-6 w-6 animate-spin" />
<span>Loading workflow runs...</span>
</div>
</div>
);
}
return (
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
<div className="max-w-full mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Workflow Runs</h1>
<p className="text-gray-600">View and manage all workflow runs across organizations</p>
</div>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg">
{error}
</div>
)}
<FilterBuilder
availableAttributes={superadminFilterAttributes}
activeFilters={activeFilters}
onFiltersChange={handleFiltersChange}
onApplyFilters={handleApplyFilters}
onClearFilters={handleClearFilters}
isExecuting={isExecutingFilters}
autoRefresh={autoRefresh}
onAutoRefreshChange={setAutoRefresh}
/>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>All Workflow Runs</CardTitle>
<CardDescription>
Showing {runs.length} of {totalCount} total runs
</CardDescription>
</div>
{isAutoRefreshing && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" />
<span>Refreshing...</span>
</div>
)}
</div>
</CardHeader>
<CardContent>
{runs.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No workflow runs found.
</div>
) : (
<>
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Workflow</TableHead>
<TableHead className="font-semibold">Status</TableHead>
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Tags</TableHead>
<TableHead className="font-semibold">Comment</TableHead>
<TableHead className="font-semibold">Duration</TableHead>
<TableHead className="font-semibold">Dograh Token</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => (
<TableRow
key={run.id}
className={selectedRowId === run.id ? "bg-blue-50" : ""}>
<TableCell className="font-mono text-sm">
#{run.id}
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-sm">
{run.workflow_name ? (
run.workflow_name.length > 15
? `${run.workflow_name.substring(0, 15)}...`
: run.workflow_name
) : 'Unknown Workflow'}
</span>
<span className="text-xs text-gray-500 font-mono">
ID: {String(run.workflow_id).length > 12
? `${String(run.workflow_id).substring(0, 12)}...`
: run.workflow_id}
</span>
</div>
</TableCell>
<TableCell className="text-center">
{run.is_completed ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<AlertTriangle className="h-5 w-5 text-yellow-500" />
)}
</TableCell>
<TableCell>
{run.gathered_context?.mapped_call_disposition ? (
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
{run.gathered_context.mapped_call_disposition as string}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{Array.isArray(run.gathered_context?.call_tags) && run.gathered_context.call_tags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{run.gathered_context.call_tags.map((tag: string) => (
<Badge key={tag} variant="default">
{tag}
</Badge>
))}
</div>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="max-w-sm whitespace-pre-wrap break-words">
{run.admin_comment ? (
<span>{run.admin_comment}</span>
) : (
<span className="text-gray-400 italic">No comment</span>
)}
</TableCell>
<TableCell className="text-sm whitespace-pre-wrap break-words">
<span className={!run.is_completed ? "font-semibold text-blue-600" : ""}>
{calculateDuration(run.created_at, run.is_completed, run.usage_info)}
</span>
</TableCell>
<TableCell className="text-sm">
<div className="flex items-center space-x-1">
<span>
{typeof run.cost_info?.total_cost_usd === 'number'
? `${Number(run.cost_info.total_cost_usd * 100).toFixed(2)}`
: '-'}
</span>
{(run.usage_info || run.cost_info) && (
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-gray-500 cursor-pointer" />
</TooltipTrigger>
<TooltipContent sideOffset={4} className="max-w-xs whitespace-pre-wrap break-words">
<pre className="max-w-xs whitespace-pre-wrap break-words">
{`Usage Info: ${JSON.stringify(run.usage_info ?? {}, null, 2)}\n\nCost Info: ${JSON.stringify(run.cost_info ?? {}, null, 2)}`}
</pre>
</TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
<TableCell className="text-sm">
{formatDate(run.created_at)}
</TableCell>
<TableCell>
<div className="flex space-x-2">
<MediaPreviewButtons
recordingUrl={run.recording_url}
transcriptUrl={run.transcript_url}
runId={run.id}
onOpenAudio={mediaPreview.openAudioModal}
onOpenTranscript={mediaPreview.openTranscriptModal}
onSelect={setSelectedRowId}
/>
<Button
variant="outline"
size="icon"
onClick={() => {
const query = encodeURIComponent(
JSON.stringify({
children: [
{
field: 'extra.run_id',
op: '==',
value: run.id,
},
],
field: '',
op: 'and',
}),
);
window.open(
`https://app.axiom.co/dograh-of6c/stream/${process.env.NEXT_PUBLIC_AXIOM_LOG_DATASET}?q=${query}`,
'_blank',
);
}}
>
<Image
src="/axiom_icon.svg"
alt="Traces"
width={16}
height={16}
className="h-4 w-4"
/>
</Button>
<Button
variant="outline"
size="icon"
onClick={() => {
const filter = encodeURIComponent(
`metadata;stringObject;attributes;contains;conversation.id,metadata;stringObject;attributes;contains;${run.id}`,
);
window.open(
`${process.env.NEXT_PUBLIC_LANGFUSE_ENDPOINT}/project/${process.env.NEXT_PUBLIC_LANGFUSE_PROJECT_ID}/traces?search=&filter=${filter}&dateRange=All+time`,
'_blank',
);
}}
>
<Image
src="/langfuse_icon.svg"
alt="Langfuse Traces"
width={16}
height={16}
className="h-4 w-4"
/>
</Button>
{/* Quick-link to open the workflow inside the *regular* app after
successfully impersonating the owner of the workflow. */}
<Button
variant="outline"
size="icon"
title="Open workflow as user"
onClick={() => {
const appBaseUrl = window.location.origin.includes('superadmin.')
? window.location.origin.replace('superadmin.', 'app.')
: window.location.origin;
impersonateAndMaybeRedirect(
run.user_id,
`${appBaseUrl}/workflow/${run.workflow_id}`,
);
}}
>
<ExternalLink className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => {
setCommentRunId(run.id);
setCommentText(run.admin_comment || '');
setIsCommentDialogOpen(true);
}}
title="Add/Edit Comment"
>
<MessageSquare className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<div className="text-sm text-gray-500">
Page {currentPage} of {totalPages} ({totalCount} total runs)
</div>
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
{/* Page numbers */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(pageNum)}
disabled={isLoading}
>
{pageNum}
</Button>
);
})}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages || isLoading}
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Comment Dialog */}
<Dialog open={isCommentDialogOpen} onOpenChange={setIsCommentDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{commentRunId ? 'Edit Comment' : 'Add Comment'}</DialogTitle>
<DialogDescription>
Admin-only comment for run #{commentRunId}
</DialogDescription>
</DialogHeader>
<Textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Enter comment here..."
className="min-h-[120px]"
/>
<DialogFooter className="pt-4">
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button onClick={saveAdminComment}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Media Preview Dialog */}
{mediaPreview.dialog}
</div>
</div>
);
}

View file

@ -0,0 +1,14 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function UsageLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

590
ui/src/app/usage/page.tsx Normal file
View file

@ -0,0 +1,590 @@
"use client";
import { Calendar, ChevronLeft, ChevronRight, Globe } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
import { getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet,getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen';
import type { CurrentUsageResponse, DailyUsageBreakdownResponse,UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
import { DailyUsageTable } from '@/components/DailyUsageTable';
import { FilterBuilder } from '@/components/filters/FilterBuilder';
import { MediaPreviewButtons, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useUserConfig } from '@/context/UserConfigContext';
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
import { usageFilterAttributes } from '@/lib/filterAttributes';
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
import { ActiveFilter, DateRangeValue } from '@/types/filters';
// Get local timezone
const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
export default function UsagePage() {
const router = useRouter();
const searchParams = useSearchParams();
const { userConfig, saveUserConfig, loading: userConfigLoading, accessToken, organizationPricing } = useUserConfig();
// Current usage state
const [currentUsage, setCurrentUsage] = useState<CurrentUsageResponse | null>(null);
const [isLoadingCurrent, setIsLoadingCurrent] = useState(true);
// Usage history state
const [usageHistory, setUsageHistory] = useState<UsageHistoryResponse | null>(null);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [currentPage, setCurrentPage] = useState(() => {
const pageParam = searchParams.get('page');
return pageParam ? parseInt(pageParam, 10) : 1;
});
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
// Daily usage breakdown state (only for paid orgs)
const [dailyUsage, setDailyUsage] = useState<DailyUsageBreakdownResponse | null>(null);
const [isLoadingDaily, setIsLoadingDaily] = useState(false);
// Initialize filters from URL
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
return decodeFiltersFromURL(searchParams, usageFilterAttributes);
});
// Media preview dialog
const mediaPreview = MediaPreviewDialog({ accessToken });
// Timezone state - wait for userConfig to load before setting default
const localTimezone = getLocalTimezone();
const [selectedTimezone, setSelectedTimezone] = useState<ITimezoneOption | string>(() => {
// Only use local timezone if we know for sure there's no saved timezone
// (i.e., userConfig has loaded and has no timezone)
if (!userConfigLoading && !userConfig?.timezone) {
return localTimezone;
}
// Otherwise return the saved timezone or empty string while loading
return userConfig?.timezone || '';
});
const [savingTimezone, setSavingTimezone] = useState(false);
// Fetch current usage
const fetchCurrentUsage = useCallback(async () => {
if (!accessToken) return;
try {
const response = await getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setCurrentUsage(response.data);
}
} catch (error) {
console.error('Failed to fetch current usage:', error);
} finally {
setIsLoadingCurrent(false);
}
}, [accessToken]);
// Fetch usage history
const fetchUsageHistory = useCallback(async (page: number, filters?: ActiveFilter[]) => {
if (!accessToken) return;
setIsLoadingHistory(true);
try {
let filterParam = undefined;
let startDate = '';
let endDate = '';
if (filters && filters.length > 0) {
// Extract date range filter if present
const dateRangeFilter = filters.find(f => f.attribute.id === 'dateRange');
if (dateRangeFilter && dateRangeFilter.value) {
const dateValue = dateRangeFilter.value as DateRangeValue;
if (dateValue.from) {
// The dates are already in the user's local timezone
// Convert to UTC ISO string for the backend
startDate = dateValue.from.toISOString();
}
if (dateValue.to) {
// Convert to UTC ISO string for the backend
endDate = dateValue.to.toISOString();
}
}
// Process other filters (excluding dateRange)
const otherFilters = filters.filter(f => f.attribute.id !== 'dateRange');
if (otherFilters.length > 0) {
const filterData = otherFilters.map(filter => ({
attribute: filter.attribute.id,
type: filter.attribute.type,
value: filter.value,
}));
filterParam = JSON.stringify(filterData);
}
}
const response = await getUsageHistoryApiV1OrganizationsUsageRunsGet({
query: {
page,
limit: 50,
...(startDate && { start_date: startDate }),
...(endDate && { end_date: endDate }),
...(filterParam && { filters: filterParam })
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setUsageHistory(response.data);
}
} catch (error) {
console.error('Failed to fetch usage history:', error);
} finally {
setIsLoadingHistory(false);
}
}, [accessToken]);
// Fetch daily usage breakdown
const fetchDailyUsage = useCallback(async () => {
if (!accessToken || !organizationPricing?.price_per_second_usd) return;
setIsLoadingDaily(true);
try {
const response = await getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet({
query: { days: 7 },
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setDailyUsage(response.data);
}
} catch (error) {
console.error('Failed to fetch daily usage:', error);
} finally {
setIsLoadingDaily(false);
}
}, [accessToken, organizationPricing]);
// Handle timezone change
const handleTimezoneChange = async (timezone: ITimezoneOption | string) => {
setSelectedTimezone(timezone);
setSavingTimezone(true);
try {
const tzValue = typeof timezone === 'string' ? timezone : timezone.value;
await saveUserConfig({ timezone: tzValue });
} catch (error) {
console.error('Failed to save timezone:', error);
// Revert to previous timezone on error
const prevTz = userConfig?.timezone || localTimezone;
setSelectedTimezone(prevTz);
} finally {
setSavingTimezone(false);
}
};
// Update timezone when userConfig loads
useEffect(() => {
if (!userConfigLoading) {
// Config has loaded - set the timezone
if (userConfig?.timezone) {
setSelectedTimezone(userConfig.timezone);
} else {
// No saved timezone, use local
setSelectedTimezone(localTimezone);
}
}
}, [userConfig, userConfigLoading, localTimezone]);
// Initial load - fetch when accessToken becomes available
useEffect(() => {
if (accessToken) {
fetchCurrentUsage();
fetchUsageHistory(currentPage, activeFilters);
}
}, [accessToken, currentPage, activeFilters, fetchUsageHistory, fetchCurrentUsage]);
// Fetch daily usage when organizationPricing becomes available
useEffect(() => {
if (accessToken && organizationPricing?.price_per_second_usd) {
fetchDailyUsage();
}
}, [accessToken, organizationPricing, fetchDailyUsage]);
// Update URL with query parameters
const updateUrlParams = useCallback((params: { page?: number; filters?: ActiveFilter[] }) => {
const newParams = new URLSearchParams();
if (params.page !== undefined) {
newParams.set('page', params.page.toString());
}
// Add filters to URL if present
if (params.filters && params.filters.length > 0) {
const filterString = encodeFiltersToURL(params.filters);
if (filterString) {
const filterParams = new URLSearchParams(filterString);
filterParams.forEach((value, key) => newParams.set(key, value));
}
}
router.push(`/usage?${newParams.toString()}`);
}, [router]);
const handleApplyFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1); // Reset to first page when applying filters
updateUrlParams({ page: 1, filters: activeFilters });
await fetchUsageHistory(1, activeFilters);
setIsExecutingFilters(false);
}, [activeFilters, fetchUsageHistory, updateUrlParams]);
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
setActiveFilters(filters);
}, []);
const handleClearFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1);
updateUrlParams({ page: 1, filters: [] }); // Clear filters from URL
await fetchUsageHistory(1, []); // Fetch all runs without filters
setIsExecutingFilters(false);
}, [fetchUsageHistory, updateUrlParams]);
// Handle page change
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
updateUrlParams({ page: newPage, filters: activeFilters });
fetchUsageHistory(newPage, activeFilters);
};
// Handle row click to navigate to workflow run
const handleRowClick = (run: WorkflowRunUsageResponse) => {
router.push(`/workflow/${run.workflow_id}/run/${run.id}`);
};
// Format date for display with timezone support
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const tzValue = typeof selectedTimezone === 'string' ? selectedTimezone : selectedTimezone.value;
// Use local timezone if none selected (during loading)
const effectiveTz = tzValue || localTimezone;
return date.toLocaleDateString('en-US', {
timeZone: effectiveTz,
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
// Format datetime for display with timezone support
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
const tzValue = typeof selectedTimezone === 'string' ? selectedTimezone : selectedTimezone.value;
// Use local timezone if none selected (during loading)
const effectiveTz = tzValue || localTimezone;
return date.toLocaleString('en-US', {
timeZone: effectiveTz,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
});
};
// Format duration for display
const formatDuration = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes === 0) return `${remainingSeconds}s`;
if (remainingSeconds === 0) return `${minutes}m`;
return `${minutes}m ${remainingSeconds}s`;
};
return (
<div className="min-h-[calc(100vh-73px)] bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
<div className="mb-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Usage Dashboard</h1>
<p className="text-gray-600">Monitor your Dograh Token usage and quota</p>
</div>
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-gray-500" />
<div className="w-[300px]">
<TimezoneSelect
value={selectedTimezone}
onChange={handleTimezoneChange}
isDisabled={savingTimezone || userConfigLoading}
placeholder={userConfigLoading ? "Loading..." : "Select timezone"}
styles={{
control: (base) => ({
...base,
minHeight: '36px',
fontSize: '14px',
}),
menu: (base) => ({
...base,
zIndex: 9999,
}),
}}
/>
</div>
</div>
</div>
</div>
{/* Current Period Card */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Current Billing Period</CardTitle>
<CardDescription>
{currentUsage && `${formatDate(currentUsage.period_start)} - ${formatDate(currentUsage.period_end)}`}
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingCurrent ? (
<div className="animate-pulse space-y-4">
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
<div className="h-8 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-1/3"></div>
</div>
) : currentUsage ? (
<div className="space-y-4">
<div className="flex justify-between items-baseline">
<div>
{organizationPricing?.price_per_second_usd ? (
<>
<p className="text-2xl font-bold">
${(currentUsage.used_amount_usd || 0).toFixed(2)}
</p>
<p className="text-sm text-gray-600">Total Cost (USD)</p>
<p className="text-xs text-gray-500 mt-1">
Rate: ${(organizationPricing.price_per_second_usd * 60).toFixed(4)}/minute
</p>
</>
) : (
<>
<p className="text-2xl font-bold">
{currentUsage.used_dograh_tokens.toLocaleString()} / {currentUsage.quota_dograh_tokens.toLocaleString()}
</p>
<p className="text-sm text-gray-600">Dograh Tokens</p>
</>
)}
</div>
{!organizationPricing?.price_per_second_usd && (
<div className="text-right">
<p className="text-lg font-semibold">{currentUsage.percentage_used}%</p>
<p className="text-sm text-gray-600">Used</p>
</div>
)}
</div>
{!organizationPricing?.price_per_second_usd && (
<Progress value={currentUsage.percentage_used} className="h-3" />
)}
<div className="flex justify-between items-center text-sm text-gray-600">
<div className="flex items-center">
<Calendar className="h-4 w-4 mr-1" />
Next refresh: {formatDate(currentUsage.next_refresh_date)}
</div>
<div>
Total Duration: <span className="font-medium text-gray-900">{formatDuration(currentUsage.total_duration_seconds)}</span>
</div>
</div>
{!currentUsage.quota_enabled && (
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-3">
<p className="text-sm text-yellow-800">
Quota enforcement is not enabled for your organization.
</p>
</div>
)}
</div>
) : (
<p className="text-gray-500">Unable to load usage data</p>
)}
</CardContent>
</Card>
{/* Daily Usage Table - Only for paid organizations */}
{organizationPricing?.price_per_second_usd && (
<div className="mb-6">
<DailyUsageTable
data={dailyUsage}
isLoading={isLoadingDaily}
/>
</div>
)}
{/* Filter Builder */}
<div className="mb-6">
<FilterBuilder
availableAttributes={usageFilterAttributes}
activeFilters={activeFilters}
onFiltersChange={handleFiltersChange}
onApplyFilters={handleApplyFilters}
onClearFilters={handleClearFilters}
isExecuting={isExecutingFilters}
/>
</div>
{/* Usage History */}
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<div className="space-y-1.5">
<CardTitle>Usage History</CardTitle>
<CardDescription>
View detailed usage by workflow run
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{isLoadingHistory ? (
<div className="animate-pulse space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-12 bg-gray-200 rounded"></div>
))}
</div>
) : usageHistory && usageHistory.runs.length > 0 ? (
<>
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="font-semibold">Run ID</TableHead>
<TableHead className="font-semibold">Workflow Name</TableHead>
<TableHead className="font-semibold">Phone Number</TableHead>
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Date</TableHead>
<TableHead className="font-semibold text-right">Duration</TableHead>
<TableHead className="font-semibold text-right">
{organizationPricing?.price_per_second_usd ? 'Cost (USD)' : 'Dograh Tokens'}
</TableHead>
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usageHistory.runs.map((run) => (
<TableRow
key={run.id}
>
<TableCell
className="font-mono text-sm cursor-pointer hover:underline"
onClick={() => handleRowClick(run)}
>
#{run.id}
</TableCell>
<TableCell>{run.workflow_name || 'Unknown'}</TableCell>
<TableCell className="text-sm">
{run.phone_number || '-'}
</TableCell>
<TableCell>
{run.disposition ? (
<Badge variant={getDispositionBadgeVariant(run.disposition)}>
{run.disposition}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>{formatDateTime(run.created_at)}</TableCell>
<TableCell className="text-right">
{formatDuration(run.call_duration_seconds)}
</TableCell>
<TableCell className="text-right font-medium">
{organizationPricing?.price_per_second_usd && run.charge_usd !== undefined && run.charge_usd !== null
? `$${run.charge_usd.toFixed(2)}`
: run.dograh_token_usage.toLocaleString()
}
</TableCell>
<TableCell>
<MediaPreviewButtons
recordingUrl={run.recording_url}
transcriptUrl={run.transcript_url}
runId={run.id}
onOpenAudio={mediaPreview.openAudioModal}
onOpenTranscript={mediaPreview.openTranscriptModal}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Summary */}
{activeFilters.length > 0 && (
<div className="mt-4 p-3 bg-gray-50 rounded-md">
<p className="text-sm text-gray-600">
Total for filtered period: <span className="font-semibold text-gray-900">
{usageHistory.total_dograh_tokens.toLocaleString()} Dograh Tokens
</span>
{' • '}
<span className="font-semibold text-gray-900">
{formatDuration(usageHistory.total_duration_seconds)}
</span>
</p>
</div>
)}
{/* Pagination */}
{usageHistory.total_pages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-gray-600">
Page {usageHistory.page} of {usageHistory.total_pages} ({usageHistory.total_count} total runs)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === usageHistory.total_pages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
) : (
<p className="text-center py-8 text-gray-500">No usage history found</p>
)}
</CardContent>
</Card>
{/* Media Preview Dialog */}
{mediaPreview.dialog}
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
import React, { ReactNode } from 'react'
import BaseHeader from '@/components/header/BaseHeader'
interface WorkflowLayoutProps {
children: ReactNode,
headerActions?: ReactNode,
backButton?: ReactNode,
showFeaturesNav?: boolean
}
const WorkflowLayout: React.FC<WorkflowLayoutProps> = ({ children, headerActions, backButton, showFeaturesNav = true }) => {
return (
<>
<BaseHeader headerActions={headerActions} backButton={backButton} showFeaturesNav={showFeaturesNav} />
{children}
</>
)
}
export default WorkflowLayout

View file

@ -0,0 +1,149 @@
import '@xyflow/react/dist/style.css';
import {
Background,
Panel,
ReactFlow,
} from "@xyflow/react";
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import { Button } from '@/components/ui/button';
import { WorkflowConfigurations } from '@/types/workflow-configurations';
import AddNodePanel from "../../../components/flow/AddNodePanel";
import CustomEdge from "../../../components/flow/edges/CustomEdge";
import { AgentNode, EndCall, GlobalNode, StartCall } from "../../../components/flow/nodes";
import WorkflowControls from "./components/WorkflowControls";
import WorkflowHeader from "./components/WorkflowHeader";
import { WorkflowProvider } from "./contexts/WorkflowContext";
import { useWorkflowState } from "./hooks/useWorkflowState";
// Define the node types dynamically based on the onSave prop
const nodeTypes = {
[NodeType.START_CALL]: StartCall,
[NodeType.AGENT_NODE]: AgentNode,
[NodeType.END_CALL]: EndCall,
[NodeType.GLOBAL_NODE]: GlobalNode,
};
const edgeTypes = {
custom: CustomEdge,
};
interface RenderWorkflowProps {
initialWorkflowName: string;
workflowId: number;
initialFlow?: {
nodes: FlowNode[];
edges: FlowEdge[];
viewport: {
x: number;
y: number;
zoom: number;
};
};
initialTemplateContextVariables?: Record<string, string>;
initialWorkflowConfigurations?: WorkflowConfigurations;
}
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: RenderWorkflowProps) {
const {
rfInstance,
nodes,
edges,
isAddNodePanelOpen,
workflowName,
isEditingName,
isDirty,
workflowValidationErrors,
templateContextVariables,
workflowConfigurations,
setNodes,
setIsAddNodePanelOpen,
setIsEditingName,
handleNodeSelect,
handleNameChange,
saveWorkflow,
onConnect,
onEdgesChange,
onNodesChange,
onRun,
saveTemplateContextVariables,
saveWorkflowConfigurations
} = useWorkflowState({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations });
const backButton = (
<Link href="/workflow">
<Button variant="outline" size="sm" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Workflows
</Button>
</Link>
);
const headerActions = (
<WorkflowHeader
workflowValidationErrors={workflowValidationErrors}
isDirty={isDirty}
workflowName={workflowName}
rfInstance={rfInstance}
onRun={onRun}
workflowId={workflowId}
saveWorkflow={saveWorkflow}
/>
);
return (
<WorkflowProvider value={{ saveWorkflow }}>
<WorkflowLayout headerActions={headerActions} backButton={backButton} showFeaturesNav={false}>
<div className="h-[calc(100vh-80px)]">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onConnect={onConnect}
onInit={(instance) => {
rfInstance.current = instance;
}}
defaultEdgeOptions={{ animated: true, type: "custom" }}
>
<Background />
<Panel position="top-left">
<WorkflowControls
workflowId={workflowId}
workflowName={workflowName}
isEditingName={isEditingName}
setIsEditingName={setIsEditingName}
handleNameChange={handleNameChange}
setIsAddNodePanelOpen={setIsAddNodePanelOpen}
saveWorkflow={saveWorkflow}
nodes={nodes}
edges={edges}
setNodes={setNodes}
rfInstance={rfInstance}
templateContextVariables={templateContextVariables}
saveTemplateContextVariables={saveTemplateContextVariables}
workflowConfigurations={workflowConfigurations}
saveWorkflowConfigurations={saveWorkflowConfigurations}
/>
</Panel>
</ReactFlow>
</div>
<AddNodePanel
isOpen={isAddNodePanelOpen}
onNodeSelect={handleNodeSelect}
onClose={() => setIsAddNodePanelOpen(false)}
/>
</WorkflowLayout>
</WorkflowProvider>
);
}
export default RenderWorkflow;

View file

@ -0,0 +1,276 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { AmbientNoiseConfiguration, VADConfiguration, WorkflowConfigurations } from "@/types/workflow-configurations";
interface ConfigurationsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workflowConfigurations: WorkflowConfigurations | null;
onSave: (configurations: WorkflowConfigurations) => Promise<void>;
}
const DEFAULT_VAD_CONFIG: VADConfiguration = {
confidence: 0.7,
start_seconds: 0.4,
stop_seconds: 0.8,
minimum_volume: 0.6,
};
const DEFAULT_AMBIENT_NOISE_CONFIG: AmbientNoiseConfiguration = {
enabled: false,
volume: 0.3,
};
export const ConfigurationsDialog = ({
open,
onOpenChange,
workflowConfigurations,
onSave
}: ConfigurationsDialogProps) => {
const [vadConfig, setVadConfig] = useState<VADConfiguration>(
workflowConfigurations?.vad_configuration || DEFAULT_VAD_CONFIG
);
const [ambientNoiseConfig, setAmbientNoiseConfig] = useState<AmbientNoiseConfiguration>(
workflowConfigurations?.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG
);
const [maxCallDuration, setMaxCallDuration] = useState<number>(
workflowConfigurations?.max_call_duration || 600 // Default 10 minutes
);
const [maxUserIdleTimeout, setMaxUserIdleTimeout] = useState<number>(
workflowConfigurations?.max_user_idle_timeout || 10 // Default 10 seconds
);
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
await onSave({
vad_configuration: vadConfig,
ambient_noise_configuration: ambientNoiseConfig,
max_call_duration: maxCallDuration,
max_user_idle_timeout: maxUserIdleTimeout
});
onOpenChange(false);
} catch (error) {
console.error("Failed to save configurations:", error);
} finally {
setIsSaving(false);
}
};
const handleDialogOpenChange = (isOpen: boolean) => {
onOpenChange(isOpen);
if (isOpen) {
setVadConfig(workflowConfigurations?.vad_configuration || DEFAULT_VAD_CONFIG);
setAmbientNoiseConfig(workflowConfigurations?.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG);
setMaxCallDuration(workflowConfigurations?.max_call_duration || 600);
setMaxUserIdleTimeout(workflowConfigurations?.max_user_idle_timeout || 10);
}
};
const handleVadChange = (field: keyof VADConfiguration, value: string) => {
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
setVadConfig(prev => ({
...prev,
[field]: numValue
}));
}
};
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Configurations</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Voice Activity Detection Section */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-1">Voice Activity Detection</h3>
<p className="text-xs text-gray-500">
Hyperparameters to set for voice activity detection. Already configured with defaults.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="confidence" className="text-xs">
Confidence
</Label>
<Input
id="confidence"
type="number"
step="0.1"
min="0"
max="1"
value={vadConfig.confidence}
onChange={(e) => handleVadChange('confidence', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="start_seconds" className="text-xs">
Start Seconds
</Label>
<Input
id="start_seconds"
type="number"
step="0.1"
min="0"
value={vadConfig.start_seconds}
onChange={(e) => handleVadChange('start_seconds', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="stop_seconds" className="text-xs">
Stop Seconds
</Label>
<Input
id="stop_seconds"
type="number"
step="0.1"
min="0"
value={vadConfig.stop_seconds}
onChange={(e) => handleVadChange('stop_seconds', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="minimum_volume" className="text-xs">
Minimum Volume
</Label>
<Input
id="minimum_volume"
type="number"
step="0.1"
min="0"
max="1"
value={vadConfig.minimum_volume}
onChange={(e) => handleVadChange('minimum_volume', e.target.value)}
/>
</div>
</div>
</div>
{/* Ambient Noise Section */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-1">Ambient Noise</h3>
<p className="text-xs text-gray-500">
Add background office ambient noise to make the conversation sound more natural.
</p>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="ambient-noise-enabled" className="text-sm">
Use Ambient Noise
</Label>
<Switch
id="ambient-noise-enabled"
checked={ambientNoiseConfig.enabled}
onCheckedChange={(checked) =>
setAmbientNoiseConfig(prev => ({ ...prev, enabled: checked }))
}
/>
</div>
{ambientNoiseConfig.enabled && (
<div className="space-y-2">
<Label htmlFor="ambient-volume" className="text-xs">
Volume
</Label>
<Input
id="ambient-volume"
type="number"
step="0.1"
min="0"
max="1"
value={ambientNoiseConfig.volume}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value)) {
setAmbientNoiseConfig(prev => ({ ...prev, volume: value }));
}
}}
/>
</div>
)}
</div>
</div>
{/* Call Management Section */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-1">Call Management</h3>
<p className="text-xs text-gray-500">
Configure call duration limits and idle timeout settings.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="max_call_duration" className="text-xs">
Max Call Duration (seconds)
</Label>
<Input
id="max_call_duration"
type="number"
step="1"
min="1"
value={maxCallDuration}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value > 0) {
setMaxCallDuration(value);
}
}}
/>
<p className="text-xs text-gray-500">Default: 600 (10 minutes)</p>
</div>
<div className="space-y-2">
<Label htmlFor="max_user_idle_timeout" className="text-xs">
Max User Idle Timeout (seconds)
</Label>
<Input
id="max_user_idle_timeout"
type="number"
step="1"
min="1"
value={maxUserIdleTimeout}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value > 0) {
setMaxUserIdleTimeout(value);
}
}}
/>
<p className="text-xs text-gray-500">Default: 10 seconds</p>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,137 @@
import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface TemplateContextVariablesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
templateContextVariables: Record<string, string>;
onSave: (variables: Record<string, string>) => Promise<void>;
}
export const TemplateContextVariablesDialog = ({
open,
onOpenChange,
templateContextVariables,
onSave
}: TemplateContextVariablesDialogProps) => {
const [contextVars, setContextVars] = useState<Record<string, string>>(templateContextVariables);
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
const handleAddContextVar = () => {
if (newKey && newValue) {
setContextVars(prev => ({ ...prev, [newKey]: newValue }));
}
setNewKey("");
setNewValue("");
};
const handleRemoveContextVar = (key: string) => {
setContextVars(prev => {
const newVars = { ...prev };
delete newVars[key];
return newVars;
});
};
const handleSave = async () => {
let varsToSave = contextVars;
// Include any newly typed key/value that hasn't been added via the "Add Variable" button
if (newKey && newValue) {
varsToSave = { ...varsToSave, [newKey]: newValue };
}
await onSave(varsToSave);
onOpenChange(false);
};
const handleDialogOpenChange = (isOpen: boolean) => {
onOpenChange(isOpen);
if (isOpen) {
setContextVars(templateContextVariables);
setNewKey("");
setNewValue("");
}
};
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Template Context Variables</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Existing Variables */}
{Object.entries(contextVars).length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Current Variables</Label>
{Object.entries(contextVars).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 p-2 border rounded-md">
<div className="flex-1">
<div className="text-sm font-medium">{key}</div>
<div className="text-xs text-gray-500 truncate">{value}</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveContextVar(key)}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
{/* Add New Variable */}
<div className="space-y-3">
<Label className="text-sm font-medium">Add New Variable</Label>
<div className="space-y-2">
<div className="flex gap-2">
<div className="flex-1">
<Label htmlFor="key" className="text-xs">Key</Label>
<Input
id="key"
placeholder="Enter variable key"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
/>
</div>
<div className="flex-1">
<Label htmlFor="value" className="text-xs">Value</Label>
<Input
id="value"
placeholder="Enter variable value"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
/>
</div>
</div>
<Button
size="sm"
onClick={handleAddContextVar}
disabled={!newKey || !newValue}
>
Add Variable
</Button>
</div>
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
Save Variables
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,178 @@
import dagre from '@dagrejs/dagre';
import { ReactFlowInstance } from "@xyflow/react";
import { Check, Pencil } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { WorkflowConfigurations } from "@/types/workflow-configurations";
import { ConfigurationsDialog } from "./ConfigurationsDialog";
import { TemplateContextVariablesDialog } from "./TemplateContextVariablesDialog";
interface WorkflowControlsProps {
workflowId: number;
workflowName: string;
isEditingName: boolean;
setIsEditingName: (isEditing: boolean) => void;
handleNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
setIsAddNodePanelOpen: (isOpen: boolean) => void;
saveWorkflow: (updateWorkflowDefinition: boolean) => Promise<void>;
nodes: FlowNode[];
edges: FlowEdge[];
setNodes: (nodes: FlowNode[] | ((nds: FlowNode[]) => FlowNode[])) => void;
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
templateContextVariables?: Record<string, string>;
saveTemplateContextVariables: (variables: Record<string, string>) => Promise<void>;
workflowConfigurations: WorkflowConfigurations | null;
saveWorkflowConfigurations: (configurations: WorkflowConfigurations) => Promise<void>;
}
export const layoutNodes = (
nodes: FlowNode[],
edges: FlowEdge[],
rankdir: 'TB' | 'LR',
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>,
saveWorkflow: (updateWorkflowDefinition: boolean) => Promise<void>
) => {
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir, nodesep: 250, ranksep: 250 });
g.setDefaultEdgeLabel(() => ({}));
// Sort nodes so startCall nodes come first and endCall nodes come last
const sortedNodes = [...nodes].sort((a, b) => {
if (a.type === 'startCall') return -1;
if (b.type === 'startCall') return 1;
if (a.type === 'endCall') return 1;
if (b.type === 'endCall') return -1;
return 0;
});
sortedNodes.forEach((node) => {
g.setNode(node.id, { width: 180, height: 60 });
});
edges.forEach((edge) => {
g.setEdge(edge.source, edge.target);
});
dagre.layout(g);
const newNodes = sortedNodes.map((node) => {
const nodeWithPosition = g.node(node.id);
return {
...node,
position: { x: nodeWithPosition.x, y: nodeWithPosition.y }
};
});
// Fit view to the new layout and save the viewport position
setTimeout(() => {
rfInstance.current?.fitView();
saveWorkflow(true);
}, 0);
return newNodes;
};
const WorkflowControls = ({
workflowId,
workflowName,
isEditingName,
setIsEditingName,
handleNameChange,
setIsAddNodePanelOpen,
saveWorkflow,
nodes,
edges,
setNodes,
rfInstance,
templateContextVariables = {},
saveTemplateContextVariables,
workflowConfigurations,
saveWorkflowConfigurations
}: WorkflowControlsProps) => {
const router = useRouter();
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
return (
<div>
<div className="mb-2">
<div className="flex items-center relative bg-white border border-gray-200 rounded-md px-3 py-1 shadow-sm group hover:border-gray-300 transition-colors w-45">
{isEditingName ? (
<input
type="text"
value={workflowName}
onChange={handleNameChange}
className="pr-8 bg-transparent focus:outline-none w-full text-lg"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && (setIsEditingName(false), saveWorkflow(false))}
/>
) : (
<h1 className="text-lg font-medium pr-8 truncate">{workflowName}</h1>
)}
<Button
size="icon"
variant="ghost"
onClick={() => {
if (isEditingName) {
setIsEditingName(false);
saveWorkflow(false);
} else {
setIsEditingName(true);
}
}}
className="h-7 w-7 absolute right-2 top-1/2 transform -translate-y-1/2"
>
{isEditingName ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Pencil className="h-4 w-4 opacity-50 group-hover:opacity-100 transition-opacity" />
)}
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<Button onClick={() => setIsAddNodePanelOpen(true)}>Add New Node</Button>
<Button onClick={() => setNodes(layoutNodes(nodes, edges, 'TB', rfInstance, saveWorkflow))}>Vertical Layout</Button>
<Button onClick={() => setNodes(layoutNodes(nodes, edges, 'LR', rfInstance, saveWorkflow))}>Horizontal Layout</Button>
<Button
onClick={() => setIsConfigurationsDialogOpen(true)}
className="flex items-center gap-2"
>
Configurations
</Button>
<Button
onClick={() => setIsContextVarsDialogOpen(true)}
className="flex items-center gap-2"
>
Template Context Variables
</Button>
<Button
onClick={() => router.push(`/workflow/${workflowId}/runs`)}
className="flex items-center gap-1"
>
View Run History
</Button>
</div>
<ConfigurationsDialog
open={isConfigurationsDialogOpen}
onOpenChange={setIsConfigurationsDialogOpen}
workflowConfigurations={workflowConfigurations}
onSave={saveWorkflowConfigurations}
/>
<TemplateContextVariablesDialog
open={isContextVarsDialogOpen}
onOpenChange={setIsContextVarsDialogOpen}
templateContextVariables={templateContextVariables}
onSave={saveTemplateContextVariables}
/>
</div>
);
};
export default WorkflowControls;

View file

@ -0,0 +1,321 @@
import 'react-international-phone/style.css';
import { ReactFlowInstance, ReactFlowJsonObject } from "@xyflow/react";
import { AlertTriangle, CheckCheck, Download, LoaderCircle, Phone, ShieldCheck } from "lucide-react";
import { useEffect,useState } from "react";
import { PhoneInput } from 'react-international-phone';
import { initiateCallApiV1TwilioInitiateCallPost } from '@/client/sdk.gen';
import { WorkflowError } from '@/client/types.gen';
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useUserConfig } from "@/context/UserConfigContext";
import { useAuth } from '@/lib/auth';
interface WorkflowHeaderProps {
isDirty: boolean;
workflowName: string;
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
onRun: (mode: string) => Promise<void>;
workflowId: number;
workflowValidationErrors: WorkflowError[];
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
}
const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonObject<FlowNode, FlowEdge> | undefined) => {
if (!workflow_definition) return { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } };
const exportData = {
name: workflow_name,
workflow_definition: workflow_definition
};
// Convert to JSON string with proper formatting
const jsonString = JSON.stringify(exportData, null, 2);
// Create a blob with the JSON data
const blob = new Blob([jsonString], { type: 'application/json' });
// Create a download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${workflow_name.replace(/\s+/g, '_')}.json`;
// Trigger download
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow }: WorkflowHeaderProps) => {
const { userConfig, saveUserConfig } = useUserConfig();
const [dialogOpen, setDialogOpen] = useState(false);
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
const [saving, setSaving] = useState(false);
const [savingWorkflow, setSavingWorkflow] = useState(false);
const [callLoading, setCallLoading] = useState(false);
const [callError, setCallError] = useState<string | null>(null);
const [callSuccessMsg, setCallSuccessMsg] = useState<string | null>(null);
const [phoneChanged, setPhoneChanged] = useState(false);
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
const { user, getAccessToken } = useAuth();
const hasValidationErrors = workflowValidationErrors.length > 0;
// Reset call-related state whenever the dialog is closed so that a new call can be placed
useEffect(() => {
if (!dialogOpen) {
setCallError(null);
setCallSuccessMsg(null);
setCallLoading(false);
}
}, [dialogOpen]);
// Keep phoneNumber in sync with userConfig when dialog opens
const handleDialogOpenChange = (open: boolean) => {
setDialogOpen(open);
if (open) {
setPhoneNumber(userConfig?.test_phone_number || "");
setPhoneChanged(false);
setCallError(null);
setCallSuccessMsg(null);
setCallLoading(false);
setSaving(false);
}
};
const handlePhoneInputChange = (
formattedValue: string
) => {
// `value` is the raw E.164 value, e.g. "+14155552671"
setPhoneNumber(formattedValue);
setPhoneChanged(formattedValue !== userConfig?.test_phone_number);
// clear any prior errors, etc.
setCallError(null);
setCallSuccessMsg(null);
};
const handleSavePhone = async () => {
if (!userConfig) return;
setSaving(true);
try {
await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber });
setPhoneChanged(false);
} catch (err: unknown) {
setCallError(err instanceof Error ? err.message : "Failed to save phone number");
} finally {
setSaving(false);
}
};
const handleStartCall = async () => {
setCallLoading(true);
setCallError(null);
setCallSuccessMsg(null);
try {
if (!user) return;
const accessToken = await getAccessToken();
const response = await initiateCallApiV1TwilioInitiateCallPost({
body: { workflow_id: workflowId },
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (response.error) {
let errMsg = "Failed to initiate call";
if (typeof response.error === "string") {
errMsg = response.error;
} else if (response.error && typeof response.error === "object") {
errMsg = (response.error as unknown as { detail: string }).detail || JSON.stringify(response.error);
}
setCallError(errMsg);
} else {
// Try to show a message from the response, fallback to generic
const msg = response.data && (response.data as unknown as { message: string }).message || "Call initiated successfully!";
setCallSuccessMsg(typeof msg === "string" ? msg : JSON.stringify(msg));
}
} catch (err: unknown) {
setCallError(err instanceof Error ? err.message : "Failed to initiate call");
} finally {
setCallLoading(false);
}
};
return (
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-sm text-gray-500 mr-2">
{hasValidationErrors ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<ShieldCheck className="h-4 w-4 text-green-500" />
)}
<span>{hasValidationErrors ? 'Invalid' : 'Valid'}</span>
{hasValidationErrors && (
<Button
size="sm"
className="ml-1 h-6 px-2 text-xs"
onClick={() => setValidationDialogOpen(true)}
>
View Issues
</Button>
)}
</div>
</TooltipTrigger>
<TooltipContent>
{hasValidationErrors
? `Workflow has ${workflowValidationErrors.length} validation ${workflowValidationErrors.length === 1 ? 'issue' : 'issues'}`
: 'Workflow is valid'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
onClick={() => handleExport(workflowName, rfInstance.current?.toObject())}
>
<Download className="mr-2 h-4 w-4" />
Export Pathway
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onRun("smallwebrtc")} // Don't change the mode since its defined in the database enum
disabled={hasValidationErrors}
>
<Phone className="mr-2 h-4 w-4" />
Web Call
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(true)}
disabled={hasValidationErrors}
>
<Phone className="mr-2 h-4 w-4" />
Phone Call
</Button>
{isDirty ? (
<Button
variant="default"
size="sm"
onClick={async () => {
setSavingWorkflow(true);
await saveWorkflow();
setSavingWorkflow(false);
}}
disabled={savingWorkflow}
className="animate-pulse"
>
{savingWorkflow ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
) : (
<div className="flex items-center gap-1 text-sm text-gray-500">
<CheckCheck className="h-4 w-4 text-green-500" />
<span className='mr-2'>Saved</span>
</div>
)}
{/* Validation Errors Dialog */}
<Dialog open={validationDialogOpen} onOpenChange={setValidationDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Workflow Validation Issues</DialogTitle>
<DialogDescription>
Please fix the following issues before running the workflow.
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
<ul className="space-y-2">
{workflowValidationErrors.map((error, index) => (
<li key={index} className="border-l-2 border-red-500 pl-3 py-2">
<div className="font-medium">{error.message}</div>
{error.id && (
<div className="text-sm text-gray-500">
{error.kind === 'node' ? 'Node' : error.kind === 'edge' ? 'Edge' : 'Workflow'} ID: {error.id}
</div>
)}
{error.field && (
<div className="text-sm mt-1">
Field: {error.field}
</div>
)}
</li>
))}
</ul>
</div>
<DialogFooter>
<Button onClick={() => setValidationDialogOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Phone Call Dialog */}
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Phone Call</DialogTitle>
<DialogDescription>
Enter the phone number to call. This will be saved to your user config.
</DialogDescription>
</DialogHeader>
<PhoneInput
defaultCountry="in"
value={phoneNumber}
onChange={handlePhoneInputChange}
/>
{phoneChanged && (
<Button
variant="outline"
size="sm"
onClick={handleSavePhone}
disabled={saving}
>
{saving ? "Saving..." : "Save Number"}
</Button>
)}
<DialogFooter>
{!callSuccessMsg ? (
<Button
onClick={handleStartCall}
disabled={callLoading || phoneChanged || !phoneNumber || saving}
>
{callLoading ? "Calling..." : "Start Call"}
</Button>
) : (
<Button onClick={() => setDialogOpen(false)}>
Close
</Button>
)}
<DialogClose asChild>
<Button variant="ghost">Cancel</Button>
</DialogClose>
</DialogFooter>
{callError && <div className="text-red-500 text-sm mt-2">{callError}</div>}
{callSuccessMsg && <div className="text-green-600 text-sm mt-2">{callSuccessMsg}</div>}
</DialogContent>
</Dialog>
</div>
);
};
export default WorkflowHeader;

View file

@ -0,0 +1,2 @@
export * from './WorkflowControls';
export * from './WorkflowHeader';

View file

@ -0,0 +1,17 @@
import { createContext, useContext } from 'react';
interface WorkflowContextType {
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
}
const WorkflowContext = createContext<WorkflowContextType | undefined>(undefined);
export const WorkflowProvider = WorkflowContext.Provider;
export const useWorkflow = () => {
const context = useContext(WorkflowContext);
if (!context) {
throw new Error('useWorkflow must be used within a WorkflowProvider');
}
return context;
};

View file

@ -0,0 +1 @@
export * from './useWorkflowState';

View file

@ -0,0 +1,446 @@
import {
applyEdgeChanges,
applyNodeChanges,
OnConnect,
OnEdgesChange,
OnNodesChange,
ReactFlowInstance,
useEdgesState,
useNodesState
} from "@xyflow/react";
import { addEdge } from "@xyflow/react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import {
createWorkflowRunApiV1WorkflowWorkflowIdRunsPost,
updateWorkflowApiV1WorkflowWorkflowIdPut,
validateWorkflowApiV1WorkflowWorkflowIdValidatePost
} from "@/client";
import { WorkflowError } from "@/client/types.gen";
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import { getRandomId } from "@/lib/utils";
import { DEFAULT_WORKFLOW_CONFIGURATIONS,WorkflowConfigurations } from "@/types/workflow-configurations";
export function getDefaultAllowInterrupt(type: string = NodeType.START_CALL): boolean {
switch (type) {
case NodeType.AGENT_NODE:
return true; // Agents can be interrupted
case NodeType.START_CALL:
case NodeType.END_CALL:
return false; // Start/End messages should not be interrupted
default:
return false;
}
}
const defaultNodes: FlowNode[] = [
{
id: "1",
type: NodeType.START_CALL,
position: { x: 200, y: 200 },
data: {
prompt: "",
name: "",
allow_interrupt: getDefaultAllowInterrupt(NodeType.START_CALL),
},
},
];
const getNewNode = (type: string, position: { x: number, y: number }) => {
return {
id: `${getRandomId()}`,
type,
position,
data: {
prompt: {
[NodeType.GLOBAL_NODE]: "You are a helpful assistant whose mode of interaction with the user is voice. So don't use any special characters which can not be pronounced. Use short sentences and simple language.",
}[type] || "",
name: {
[NodeType.GLOBAL_NODE]: "Global Node",
[NodeType.START_CALL]: "Start Call",
[NodeType.END_CALL]: "End Call",
}[type] || "",
allow_interrupt: getDefaultAllowInterrupt(type),
},
};
};
interface UseWorkflowStateProps {
initialWorkflowName: string;
workflowId: number;
initialFlow?: {
nodes: FlowNode[];
edges: FlowEdge[];
viewport: {
x: number;
y: number;
zoom: number;
};
};
initialTemplateContextVariables?: Record<string, string>;
initialWorkflowConfigurations?: WorkflowConfigurations;
}
export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: UseWorkflowStateProps) => {
const rfInstance = useRef<ReactFlowInstance<FlowNode, FlowEdge> | null>(null);
const router = useRouter();
const { user, getAccessToken } = useAuth();
const [nodes, setNodes] = useNodesState(
initialFlow?.nodes?.length
? initialFlow.nodes.map(node => ({
...node,
data: {
...node.data,
invalid: false,
allow_interrupt: node.data.allow_interrupt !== undefined
? node.data.allow_interrupt
: getDefaultAllowInterrupt(node.type),
}
}))
: defaultNodes
);
const [edges, setEdges] = useEdgesState(initialFlow?.edges ?? []);
const [isAddNodePanelOpen, setIsAddNodePanelOpen] = useState(false);
const [workflowName, setWorkflowName] = useState(initialWorkflowName);
const [isEditingName, setIsEditingName] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [workflowValidationErrors, setWorkflowValidationErrors] = useState<WorkflowError[]>([]);
const [templateContextVariables, setTemplateContextVariables] = useState<Record<string, string>>(
initialTemplateContextVariables || {}
);
const [workflowConfigurations, setWorkflowConfigurations] = useState<WorkflowConfigurations | null>(
initialWorkflowConfigurations || DEFAULT_WORKFLOW_CONFIGURATIONS
);
const handleNodeSelect = useCallback((nodeType: string) => {
/*
Used to add new node to the workflow. Receives nodeType as param.
Example: nodeType can be agentNode/ startNode etc. as defined by NodeType in
types.ts
We then pass nodeTypes which contais the NodeType keyword and the component.
Those components then contain all the component speecific functioanlity like edit
button etc.
*/
const newNode = getNewNode(nodeType, { x: 150, y: 150 });
setNodes((nds) => [...nds, newNode]);
setIsAddNodePanelOpen(false);
}, [setNodes, setIsAddNodePanelOpen]);
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setWorkflowName(e.target.value);
setIsDirty(true);
};
// Validate workflow function (without saving)
const validateWorkflow = useCallback(async () => {
if (!user) return;
try {
const accessToken = await getAccessToken();
const response = await validateWorkflowApiV1WorkflowWorkflowIdValidatePost({
path: {
workflow_id: workflowId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
// Reset validation state for all nodes and edges
setNodes((nds) => nds.map(node => ({ ...node, data: { ...node.data, invalid: false, validationMessage: null } })));
setEdges((eds) => eds.map(edge => ({ ...edge, data: { ...edge.data, invalid: false, validationMessage: null } })));
setWorkflowValidationErrors([]);
// Check if we have a 422 error with validation errors
if (response.error) {
// The error could be in different formats depending on the status code
let errors: WorkflowError[] = [];
// Type assertion for validation response structure
const errorResponse = response.error as {
is_valid?: boolean;
errors?: WorkflowError[];
detail?: { errors: WorkflowError[] };
};
// For 422 responses, the error contains the validation response
if (errorResponse.is_valid === false && errorResponse.errors) {
errors = errorResponse.errors;
}
// Also check for detail.errors format
else if (errorResponse.detail?.errors) {
errors = errorResponse.detail.errors;
}
if (errors.length > 0) {
// Update nodes with validation state
setNodes((nds) => nds.map(node => {
const nodeErrors = errors.filter((err) => err.kind === 'node' && err.id === node.id);
if (nodeErrors.length > 0) {
return {
...node,
data: {
...node.data,
invalid: true,
validationMessage: nodeErrors.map(err => err.message).join(', ')
}
};
}
return node;
}));
// Update edges with validation state
setEdges((eds) => eds.map(edge => {
const edgeErrors = errors.filter((err) => err.kind === 'edge' && err.id === edge.id);
if (edgeErrors.length > 0) {
return {
...edge,
data: {
...edge.data,
invalid: true,
validationMessage: edgeErrors.map(err => err.message).join(', ')
}
};
}
return edge;
}));
// Set workflow validation errors (all types of errors)
setWorkflowValidationErrors(errors);
}
} else if (response.data) {
// If we get a 200 response with data, check if it's valid
if (response.data.is_valid === false && response.data.errors) {
const errors = response.data.errors;
// Update nodes with validation state
setNodes((nds) => nds.map(node => {
const nodeErrors = errors.filter((err) => err.kind === 'node' && err.id === node.id);
if (nodeErrors.length > 0) {
return {
...node,
data: {
...node.data,
invalid: true,
validationMessage: nodeErrors.map((err) => err.message).join(', ')
}
};
}
return node;
}));
// Update edges with validation state
setEdges((eds) => eds.map(edge => {
const edgeErrors = errors.filter((err) => err.kind === 'edge' && err.id === edge.id);
if (edgeErrors.length > 0) {
return {
...edge,
data: {
...edge.data,
invalid: true,
validationMessage: edgeErrors.map((err) => err.message).join(', ')
}
};
}
return edge;
}));
// Set workflow validation errors (all types of errors)
setWorkflowValidationErrors(errors);
} else {
logger.info('Workflow is valid');
}
}
} catch (error) {
logger.error(`Unexpected validation error: ${error}`);
}
}, [workflowId, user, getAccessToken, setNodes, setEdges]);
// Save function
const saveWorkflow = useCallback(async (updateWorkflowDefinition: boolean = true) => {
/*
validates and saves workflow
*/
if (!user || !rfInstance.current) return;
const flow = rfInstance.current.toObject();
const accessToken = await getAccessToken();
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
workflow_id: workflowId,
},
body: {
name: workflowName,
workflow_definition: updateWorkflowDefinition ? flow : null,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
setIsDirty(false);
} catch (error) {
logger.error(`Error auto-saving workflow: ${error}`);
}
// Validate after saving
await validateWorkflow();
}, [workflowId, workflowName, setIsDirty, user, getAccessToken, rfInstance, validateWorkflow]);
// Handle debounced save - REMOVED AUTOSAVE FUNCTIONALITY
// const debouncedSave = useCallback(() => {
// // Clear any existing timeout
// if (saveTimeoutRef.current) {
// clearTimeout(saveTimeoutRef.current);
// }
// // Set a new timeout
// saveTimeoutRef.current = setTimeout(() => {
// saveWorkflow();
// saveTimeoutRef.current = null;
// }, 2000);
// }, [saveWorkflow]);
const onConnect: OnConnect = useCallback((connection) => {
setEdges((eds) => addEdge({
...connection,
data: {
label: '',
condition: ''
}
}, eds));
setIsDirty(true);
// Trigger validation after connection
setTimeout(() => validateWorkflow(), 100);
}, [setEdges, validateWorkflow]);
const onEdgesChange: OnEdgesChange = useCallback(
(changes) => setEdges((eds) => {
const newEdges = applyEdgeChanges(changes, eds) as FlowEdge[];
setIsDirty(true);
// Trigger validation after edge changes
setTimeout(() => validateWorkflow(), 100);
return newEdges;
}),
[setEdges, validateWorkflow],
);
const onNodesChange: OnNodesChange = useCallback(
(changes) => setNodes((nds) => {
const newNodes = applyNodeChanges(changes, nds) as FlowNode[];
setIsDirty(true);
// Trigger validation after node changes
setTimeout(() => validateWorkflow(), 100);
return newNodes;
}),
[setNodes, validateWorkflow],
);
const onRun = async (mode: string) => {
if (!user) return;
const workflowRunName = `WR-${getRandomId()}`;
const accessToken = await getAccessToken();
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: {
workflow_id: workflowId,
},
body: {
mode,
name: workflowRunName
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
router.push(`/workflow/${workflowId}/run/${response.data?.id}`);
};
// Save template context variables function
const saveTemplateContextVariables = useCallback(async (variables: Record<string, string>) => {
if (!user) return;
const accessToken = await getAccessToken();
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
workflow_id: workflowId,
},
body: {
name: workflowName,
workflow_definition: null,
template_context_variables: variables,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
setTemplateContextVariables(variables);
logger.info('Template context variables saved successfully');
} catch (error) {
logger.error(`Error saving template context variables: ${error}`);
throw error;
}
}, [workflowId, workflowName, user, getAccessToken]);
// Save workflow configurations function
const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations) => {
if (!user) return;
const accessToken = await getAccessToken();
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
workflow_id: workflowId,
},
body: {
name: workflowName,
workflow_definition: null,
workflow_configurations: configurations as Record<string, unknown>,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
setWorkflowConfigurations(configurations);
logger.info('Workflow configurations saved successfully');
} catch (error) {
logger.error(`Error saving workflow configurations: ${error}`);
throw error;
}
}, [workflowId, workflowName, user, getAccessToken]);
// Validate workflow on mount
useEffect(() => {
validateWorkflow();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Removed useEffect for clearing auto-save timeout as autosave is disabled
return {
rfInstance,
nodes,
edges,
isAddNodePanelOpen,
workflowName,
isEditingName,
isDirty,
workflowValidationErrors,
templateContextVariables,
workflowConfigurations,
setNodes,
setEdges,
setIsAddNodePanelOpen,
setWorkflowName,
setIsEditingName,
handleNodeSelect,
handleNameChange,
saveWorkflow,
onConnect,
onEdgesChange,
onNodesChange,
onRun,
saveTemplateContextVariables,
saveWorkflowConfigurations
};
};

View file

@ -0,0 +1,91 @@
'use client';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import RenderWorkflow from '@/app/workflow/[workflowId]/RenderWorkflow';
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet } from '@/client/sdk.gen';
import type { WorkflowResponse } from '@/client/types.gen';
import { FlowEdge, FlowNode } from '@/components/flow/types';
import SpinLoader from '@/components/SpinLoader';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import { DEFAULT_WORKFLOW_CONFIGURATIONS,WorkflowConfigurations } from '@/types/workflow-configurations';
import WorkflowLayout from '../WorkflowLayout';
export default function WorkflowDetailPage() {
const params = useParams();
const [workflow, setWorkflow] = useState<WorkflowResponse | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { user, getAccessToken, redirectToLogin, loading: authLoading } = useAuth();
// Redirect if not authenticated
useEffect(() => {
if (!authLoading && !user) {
redirectToLogin();
}
}, [authLoading, user, redirectToLogin]);
useEffect(() => {
const fetchWorkflow = async () => {
if (!user) return;
try {
const accessToken = await getAccessToken();
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
path: {
workflow_id: Number(params.workflowId)
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
const workflow = response.data;
setWorkflow(workflow);
} catch (err) {
setError('Failed to fetch workflow');
logger.error(`Error fetching workflow: ${err}`);
} finally {
setLoading(false);
}
};
if (user) {
fetchWorkflow();
}
}, [params.workflowId, user, getAccessToken]);
if (loading) {
return (
<WorkflowLayout>
<SpinLoader />
</WorkflowLayout>
);
}
else if (error || !workflow) {
return (
<WorkflowLayout showFeaturesNav={false}>
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg text-red-500">{error || 'Workflow not found'}</div>
</div>
</WorkflowLayout>
);
}
else {
return (
// We are sending custom header actions to WorkflowLayout from RenderWorkflow component
<RenderWorkflow
initialWorkflowName={workflow.name}
workflowId={workflow.id}
initialFlow={{
nodes: workflow.workflow_definition.nodes as FlowNode[],
edges: workflow.workflow_definition.edges as FlowEdge[],
viewport: { x: 0, y: 0, zoom: 1 }
}}
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
/>
);
}
}

View file

@ -0,0 +1,114 @@
import { useRouter } from "next/navigation";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import {
ApiKeyErrorDialog,
AudioControls,
ConnectionStatus,
ContextVariablesSection,
WorkflowConfigErrorDialog
} from "./components";
import { useWebRTC } from "./hooks";
const Pipecat = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: {
workflowId: number,
workflowRunId: number,
accessToken: string | null,
initialContextVariables?: Record<string, string> | null
}) => {
const router = useRouter();
const {
audioRef,
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
connectionActive,
permissionError,
isCompleted,
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
iceGatheringState,
iceConnectionState,
start,
stop,
isStarting,
initialContext,
setInitialContext
} = useWebRTC({ workflowId, workflowRunId, accessToken, initialContextVariables });
const navigateToApiKeys = () => {
router.push('/api-keys');
};
const navigateToWorkflow = () => {
router.push(`/workflow/${workflowId}`)
}
return (
<>
<Card className="w-full max-w-4xl mx-auto">
<CardHeader>
<CardTitle>Workflow Run</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4">
<>
<ContextVariablesSection
initialContext={initialContext}
setInitialContext={setInitialContext}
disabled={connectionActive || isCompleted}
/>
<AudioControls
audioInputs={audioInputs}
selectedAudioInput={selectedAudioInput}
setSelectedAudioInput={setSelectedAudioInput}
isCompleted={isCompleted}
connectionActive={connectionActive}
permissionError={permissionError}
start={start}
stop={stop}
isStarting={isStarting}
/>
<ConnectionStatus
iceGatheringState={iceGatheringState}
iceConnectionState={iceConnectionState}
/>
</>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<p className="text-xs text-muted-foreground">
WebRTC connection status: {connectionActive ? 'Active' : 'Inactive'}
</p>
<audio ref={audioRef} autoPlay playsInline className="hidden" />
</CardFooter>
</Card>
<ApiKeyErrorDialog
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
error={apiKeyError}
onNavigateToApiKeys={navigateToApiKeys}
/>
<WorkflowConfigErrorDialog
open={workflowConfigModalOpen}
onOpenChange={setWorkflowConfigModalOpen}
error={workflowConfigError}
onNavigateToWorkflow={navigateToWorkflow}
/>
</>
);
};
export default Pipecat;

View file

@ -0,0 +1,34 @@
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
interface ApiKeyErrorDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
error: string | null;
onNavigateToApiKeys: () => void;
}
export const ApiKeyErrorDialog = ({
open,
onOpenChange,
error,
onNavigateToApiKeys
}: ApiKeyErrorDialogProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>API Key Error</DialogTitle>
<DialogDescription className="text-red-500 whitespace-pre-line">
{error}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={onNavigateToApiKeys}>
Go to API Keys Settings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,103 @@
import { Mic, MicOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface AudioControlsProps {
audioInputs: MediaDeviceInfo[];
selectedAudioInput: string;
setSelectedAudioInput: (deviceId: string) => void;
isCompleted: boolean;
connectionActive: boolean;
permissionError: string | null;
start: () => Promise<void>;
stop: () => void;
isStarting: boolean;
}
export const AudioControls = ({
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
isCompleted,
connectionActive,
permissionError,
start,
stop,
isStarting
}: AudioControlsProps) => {
// Check if we have valid audio devices (permissions granted)
const hasValidDevices = audioInputs.length > 0 && audioInputs.some(device => device.deviceId && device.deviceId.trim() !== '');
const validAudioInputs = audioInputs.filter(device => device.deviceId && device.deviceId.trim() !== '');
const requestAudioPermissions = async () => {
try {
await navigator.mediaDevices.getUserMedia({ audio: true });
// This will trigger the parent component to refresh the device list
window.location.reload();
} catch (error) {
console.error('Failed to request audio permissions:', error);
}
};
return (
<>
<div className="space-y-2">
<h3 className="text-sm font-medium">Audio Input</h3>
{!hasValidDevices ? (
<div className="space-y-3">
<div className="flex items-center space-x-2 text-amber-600 bg-amber-50 p-3 rounded-md border border-amber-200">
<MicOff className="h-4 w-4" />
<span className="text-sm">Audio permissions are required to start the call</span>
</div>
<Button
onClick={requestAudioPermissions}
variant="outline"
className="w-full"
>
<Mic className="h-4 w-4 mr-2" />
Grant Audio Permissions
</Button>
</div>
) : (
<Select value={selectedAudioInput} onValueChange={setSelectedAudioInput}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select audio input" />
</SelectTrigger>
<SelectContent>
{validAudioInputs.map((device, index) => (
<SelectItem key={device.deviceId} value={device.deviceId}>
{device.label || `Audio Device #${index + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{isCompleted && (
<div className="flex items-center space-x-4">
<p className="text-red-500">
Workflow run completed. Please refresh the page in a while to see the recording and transcript.
</p>
</div>
)}
{!isCompleted && hasValidDevices && (
<div className="flex items-center space-x-4">
{!connectionActive ? (
<Button onClick={start} disabled={isStarting}>
{isStarting ? 'Starting...' : 'Start'}
</Button>
) : (
<Button onClick={stop} variant="destructive">Stop</Button>
)}
{permissionError && (
<p className="text-red-500">{permissionError}</p>
)}
</div>
)}
</>
);
};

View file

@ -0,0 +1,22 @@
interface ConnectionStatusProps {
iceGatheringState: string;
iceConnectionState: string;
}
export const ConnectionStatus = ({
iceGatheringState,
iceConnectionState
}: ConnectionStatusProps) => {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<h3 className="text-sm font-medium">ICE gathering state</h3>
<p className="text-sm text-muted-foreground">{iceGatheringState}</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">ICE connection state</h3>
<p className="text-sm text-muted-foreground">{iceConnectionState}</p>
</div>
</div>
);
};

View file

@ -0,0 +1,43 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface ContextDisplayProps {
title: string;
context: Record<string, string | number | boolean | object> | null;
}
export const ContextDisplay = ({ title, context }: ContextDisplayProps) => {
if (!context || Object.keys(context).length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No {title.toLowerCase()} available</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Object.entries(context).map(([key, value]) => (
<div key={key} className="space-y-1">
<label className="text-sm font-medium text-gray-700">
{key}
</label>
<div className="p-3 bg-gray-50 border rounded-md">
<p className="text-sm text-gray-900 whitespace-pre-wrap">
{typeof value === 'object' && value !== null ? JSON.stringify(value, null, 2) : (value || 'No value')}
</p>
</div>
</div>
))}
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,109 @@
import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface ContextVariablesSectionProps {
initialContext: Record<string, string>;
setInitialContext: (variables: Record<string, string>) => void;
disabled?: boolean;
}
export const ContextVariablesSection = ({
initialContext,
setInitialContext,
disabled = false
}: ContextVariablesSectionProps) => {
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
const handleAddContextVar = () => {
if (newKey && newValue && !initialContext[newKey]) {
setInitialContext({ ...initialContext, [newKey]: newValue });
setNewKey("");
setNewValue("");
}
};
const handleRemoveContextVar = (key: string) => {
const newVars = { ...initialContext };
delete newVars[key];
setInitialContext(newVars);
};
const handleUpdateContextVar = (key: string, value: string) => {
setInitialContext({ ...initialContext, [key]: value });
};
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Template Context Variables</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Existing Variables */}
{Object.entries(initialContext).length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Current Variables</Label>
{Object.entries(initialContext).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 p-3 border rounded-md bg-gray-50">
<div className="flex-1">
<Label className="text-xs text-gray-600">{key}</Label>
<Input
value={value}
onChange={(e) => handleUpdateContextVar(key, e.target.value)}
disabled={disabled}
className="mt-1"
/>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveContextVar(key)}
disabled={disabled}
>
<Trash2Icon className="w-4 h-4 text-red-500" />
</Button>
</div>
))}
</div>
)}
{/* Add New Variable */}
<div className="space-y-3">
<Label className="text-sm font-medium">Add New Variable</Label>
<div className="flex gap-2">
<div className="flex-1">
<Input
placeholder="Variable key"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
disabled={disabled}
/>
</div>
<div className="flex-1">
<Input
placeholder="Variable value"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
disabled={disabled}
/>
</div>
<Button
onClick={handleAddContextVar}
disabled={!newKey || !newValue || disabled || !!initialContext[newKey]}
>
Add
</Button>
</div>
{newKey && initialContext[newKey] && (
<p className="text-sm text-red-500">Variable with this key already exists</p>
)}
</div>
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,34 @@
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
interface WorkflowConfigErrorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
error: string | null;
onNavigateToWorkflow: () => void;
}
export const WorkflowConfigErrorDialog = ({
open,
onOpenChange,
error,
onNavigateToWorkflow
}: WorkflowConfigErrorProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Workflow Error</DialogTitle>
<DialogDescription className="text-red-500 whitespace-pre-line">
{error}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={onNavigateToWorkflow}>
Go to Workflow
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,6 @@
export * from './ApiKeyErrorDialog';
export * from './AudioControls';
export * from './ConnectionStatus';
export * from './ContextDisplay';
export * from './ContextVariablesSection';
export * from './WorkflowConfigErrorDialog'

View file

@ -0,0 +1,2 @@
export * from './useDeviceInputs';
export * from './useWebRTC';

View file

@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
import logger from '@/lib/logger';
export const useDeviceInputs = () => {
const [audioInputs, setAudioInputs] = useState<MediaDeviceInfo[]>([]);
const [selectedAudioInput, setSelectedAudioInput] = useState('');
const [permissionError, setPermissionError] = useState<string | null>(null);
useEffect(() => {
const getAudioInputs = async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioDevices = devices.filter(device => device.kind === 'audioinput');
setAudioInputs(audioDevices);
const defaultAudioInput = audioDevices.find(device => device.deviceId === 'default');
if (defaultAudioInput) {
setSelectedAudioInput(defaultAudioInput.deviceId);
}
} catch (error) {
setPermissionError('Could not enumerate devices');
logger.error(`Error enumerating devices: ${error}`);
}
};
getAudioInputs();
}, []);
return {
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
permissionError,
setPermissionError
};
};

View file

@ -0,0 +1,275 @@
import { useRef, useState } from "react";
import { offerApiV1PipecatRtcOfferPost, validateUserConfigurationsApiV1UserConfigurationsUserValidateGet, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from "@/client/sdk.gen";
import { WorkflowValidationError } from "@/components/flow/types";
import logger from '@/lib/logger';
import { getRandomId } from "@/lib/utils";
import { sdpFilterCodec } from "../utils";
import { useDeviceInputs } from "./useDeviceInputs";
interface UseWebRTCProps {
workflowId: number;
workflowRunId: number;
accessToken: string | null;
initialContextVariables?: Record<string, string> | null;
}
export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: UseWebRTCProps) => {
const [iceGatheringState, setIceGatheringState] = useState('');
const [iceConnectionState, setIceConnectionState] = useState('');
const [connectionActive, setConnectionActive] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
const [apiKeyModalOpen, setApiKeyModalOpen] = useState(false);
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
const [workflowConfigModalOpen, setWorkflowConfigModalOpen] = useState(false);
const [workflowConfigError, setWorkflowConfigError] = useState<string | null>(null);
const [isStarting, setIsStarting] = useState(false);
const [initialContext, setInitialContext] = useState<Record<string, string>>(
initialContextVariables || {}
);
const {
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
permissionError,
setPermissionError
} = useDeviceInputs();
const useStun = true;
const useAudio = true;
const audioCodec = 'default';
const audioRef = useRef<HTMLAudioElement>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const timeStartRef = useRef<number | null>(null);
const pc_id = 'PC-' + getRandomId().toString();
const createPeerConnection = () => {
const config: RTCConfiguration = {
iceServers: useStun ? [{ urls: ['stun:stun.l.google.com:19302'] }] : []
};
const pc = new RTCPeerConnection(config);
pc.addEventListener('icegatheringstatechange', () => {
logger.info(`ICE gathering state changed in createPeerConnection, ${pc.iceGatheringState}`);
setIceGatheringState(prevState => prevState + ' -> ' + pc.iceGatheringState);
});
setIceGatheringState(pc.iceGatheringState);
pc.addEventListener('iceconnectionstatechange', () => {
setIceConnectionState(prevState => prevState + ' -> ' + pc.iceConnectionState);
});
setIceConnectionState(pc.iceConnectionState);
pc.addEventListener('track', (evt) => {
if (evt.track.kind === 'audio' && audioRef.current) {
audioRef.current.srcObject = evt.streams[0];
}
});
pcRef.current = pc;
return pc;
};
const negotiate = async () => {
const pc = pcRef.current;
if (!pc) return;
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await new Promise<void>((resolve) => {
if (pc.iceGatheringState === 'complete') {
resolve();
} else {
const checkState = () => {
if (pc.iceGatheringState === 'complete') {
logger.debug(`ICE gathering is complete in negotiate, ${pc.iceGatheringState}`);
pc.removeEventListener('icegatheringstatechange', checkState);
resolve();
}
};
pc.addEventListener('icegatheringstatechange', checkState);
}
});
const localDescription = pc.localDescription;
if (!localDescription) return;
let sdp = localDescription.sdp;
if (audioCodec !== 'default') {
sdp = sdpFilterCodec('audio', audioCodec, sdp);
}
if (!accessToken) return;
const response = await offerApiV1PipecatRtcOfferPost({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
body: {
sdp: sdp,
type: 'offer',
pc_id: pc_id,
restart_pc: false,
workflow_id: workflowId,
workflow_run_id: workflowRunId,
call_context_vars: initialContext
}
});
if (response && response.data) {
const answerSdpText = typeof response.data === 'object' && 'sdp' in response.data
? response.data.sdp as string
: '';
await pc.setRemoteDescription({
type: 'answer',
sdp: answerSdpText
});
setConnectionActive(true);
}
} catch (e) {
logger.error(`Negotiation failed: ${e}`);
}
};
const start = async () => {
if (isStarting || !accessToken) return;
setIsStarting(true);
try {
const response = await validateUserConfigurationsApiV1UserConfigurationsUserValidateGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
query: {
validity_ttl_seconds: 86400
},
});
if (response.error) {
setApiKeyModalOpen(true);
let msg = 'API Key Error';
const detail = (response.error as unknown as { detail?: { errors: { model: string; message: string }[] } }).detail;
if (Array.isArray(detail)) {
msg = detail
.map((e: { model: string; message: string }) => `${e.model}: ${e.message}`)
.join('\n');
}
setApiKeyError(msg);
return;
}
// Then check workflow validation
const workflowResponse = await validateWorkflowApiV1WorkflowWorkflowIdValidatePost({
path: {
workflow_id: workflowId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (workflowResponse.error) {
setWorkflowConfigModalOpen(true);
let msg = 'Workflow validation failed';
const errorDetail = workflowResponse.error as { detail?: { errors: WorkflowValidationError[] } };
if (errorDetail?.detail?.errors) {
msg = errorDetail.detail.errors
.map(err => `${err.kind}: ${err.message}`)
.join('\n');
}
setWorkflowConfigError(msg);
return;
}
timeStartRef.current = null;
const pc = createPeerConnection();
const constraints: MediaStreamConstraints = {
audio: false,
};
if (useAudio) {
const audioConstraints: MediaTrackConstraints = {};
if (selectedAudioInput) {
audioConstraints.deviceId = { exact: selectedAudioInput };
}
constraints.audio = Object.keys(audioConstraints).length ? audioConstraints : true;
}
if (constraints.audio) {
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
await negotiate();
} catch (err) {
logger.error(`Could not acquire media: ${err}`);
setPermissionError('Could not acquire media');
}
} else {
await negotiate();
}
} finally {
setIsStarting(false);
}
};
const stop = () => {
setConnectionActive(false);
setIsCompleted(true);
const pc = pcRef.current;
if (!pc) return;
if (pc.getTransceivers) {
pc.getTransceivers().forEach((transceiver) => {
if (transceiver.stop) {
transceiver.stop();
}
});
}
pc.getSenders().forEach((sender) => {
if (sender.track) {
sender.track.stop();
}
});
setTimeout(() => {
if (pcRef.current) {
pcRef.current.close();
pcRef.current = null;
}
}, 500);
};
return {
audioRef,
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
connectionActive,
permissionError,
isCompleted,
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
iceGatheringState,
iceConnectionState,
start,
stop,
isStarting,
initialContext,
setInitialContext
};
};

View file

@ -0,0 +1,217 @@
'use client';
import { ArrowLeft, FileText, Video } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import Pipecat from '@/app/workflow/[workflowId]/run/[runId]/Pipecat';
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from '@/client/sdk.gen';
import { MediaPreviewButtons, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/lib/auth';
import { downloadFile } from '@/lib/files';
import { ContextDisplay } from './components';
interface WorkflowRunResponse {
is_completed: boolean;
transcript_url: string | null;
recording_url: string | null;
initial_context: Record<string, string | number | boolean | object> | null;
gathered_context: Record<string, string | number | boolean | object> | null;
}
export default function WorkflowRunPage() {
const params = useParams();
const [isLoading, setIsLoading] = useState(true);
const auth = useAuth();
const [workflowRun, setWorkflowRun] = useState<WorkflowRunResponse | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
// Redirect if not authenticated
useEffect(() => {
if (!auth.loading && !auth.isAuthenticated) {
auth.redirectToLogin();
}
}, [auth]);
// Get access token
useEffect(() => {
if (auth.isAuthenticated && !auth.loading) {
auth.getAccessToken().then(setAccessToken);
}
}, [auth]);
const { openAudioModal, openTranscriptModal, dialog } = MediaPreviewDialog({ accessToken });
useEffect(() => {
const fetchWorkflowRun = async () => {
if (!auth.isAuthenticated || auth.loading) return;
setIsLoading(true);
const token = await auth.getAccessToken();
const workflowId = params.workflowId;
const runId = params.runId;
const response = await getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet({
path: {
workflow_id: Number(workflowId),
run_id: Number(runId),
},
headers: {
'Authorization': `Bearer ${token}`,
},
});
setIsLoading(false);
setWorkflowRun({
is_completed: response.data?.is_completed ?? false,
transcript_url: response.data?.transcript_url ?? null,
recording_url: response.data?.recording_url ?? null,
initial_context: response.data?.initial_context as Record<string, string> | null ?? null,
gathered_context: response.data?.gathered_context as Record<string, string> | null ?? null,
});
};
fetchWorkflowRun();
}, [params.workflowId, params.runId, auth]);
const backButton = (
<div className="flex gap-2">
<Link href={`/workflow/${params.workflowId}`}>
<Button variant="outline" size="sm" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Workflow
</Button>
</Link>
<Link href={`/workflow/${params.workflowId}/runs`}>
<Button variant="outline" size="sm" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Workflow Runs
</Button>
</Link>
</div>
);
let returnValue = null;
if (isLoading) {
returnValue = (
<div className="min-h-screen flex mt-40 justify-center">
<div className="w-full max-w-4xl p-6">
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
<CardFooter className="flex gap-4">
<Skeleton className="h-10 w-32" />
<Skeleton className="h-10 w-32" />
</CardFooter>
</Card>
</div>
</div>
);
}
else if (workflowRun?.is_completed) {
returnValue = (
<div className="min-h-screen flex mt-40 justify-center p-6">
<div className="w-full max-w-4xl space-y-6">
<Card className="border-gray-100">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-2xl">Workflow Run Completed</CardTitle>
<div className="h-8 w-8 bg-green-100 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
</div>
</CardHeader>
<CardContent>
<p className="text-gray-600 mb-8">Your workflow run has been completed successfully. You can preview or download the transcript and recording.</p>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Preview:</span>
<MediaPreviewButtons
recordingUrl={workflowRun?.recording_url}
transcriptUrl={workflowRun?.transcript_url}
runId={Number(params.runId)}
onOpenAudio={openAudioModal}
onOpenTranscript={openTranscriptModal}
/>
</div>
<div className="flex items-center gap-2 border-l pl-4">
<span className="text-sm text-gray-600">Download:</span>
<Button
onClick={() => downloadFile(workflowRun?.transcript_url, accessToken!)}
disabled={!workflowRun?.transcript_url || !accessToken}
size="sm"
className="gap-2"
>
<FileText className="h-4 w-4" />
Transcript
</Button>
<Button
onClick={() => downloadFile(workflowRun?.recording_url, accessToken!)}
disabled={!workflowRun?.recording_url || !accessToken}
size="sm"
className="gap-2"
>
<Video className="h-4 w-4" />
Recording
</Button>
</div>
</div>
</CardContent>
</Card>
<div className="grid gap-6 md:grid-cols-2">
<ContextDisplay
title="Initial Context"
context={workflowRun?.initial_context}
/>
<ContextDisplay
title="Gathered Context"
context={workflowRun?.gathered_context}
/>
</div>
</div>
</div>
);
}
else {
returnValue =
<div className="min-h-screen mt-40">
<Pipecat
workflowId={Number(params.workflowId)}
workflowRunId={Number(params.runId)}
accessToken={accessToken}
initialContextVariables={
workflowRun?.initial_context
? Object.fromEntries(
Object.entries(workflowRun.initial_context).map(([key, value]) => [
key,
typeof value === 'object' && value !== null
? JSON.stringify(value)
: String(value)
])
)
: null
}
/>
</div>
}
return (
<WorkflowLayout backButton={backButton}>
{returnValue}
{dialog}
</WorkflowLayout>
);
}

View file

@ -0,0 +1 @@
export * from './webrtcUtils';

View file

@ -0,0 +1,66 @@
/**
* Escapes special characters in a string for use in a regular expression.
*/
export const escapeRegExp = (string: string): string => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
/**
* Filters codecs in an SDP string.
*/
export const sdpFilterCodec = (kind: string, codec: string, realSdp: string): string => {
const allowed: number[] = [];
const rtxRegex = new RegExp('a=fmtp:(\\d+) apt=(\\d+)\r$');
const codecRegex = new RegExp('a=rtpmap:([0-9]+) ' + escapeRegExp(codec));
const videoRegex = new RegExp('(m=' + kind + ' .*?)( ([0-9]+))*\\s*$');
const lines = realSdp.split('\n');
let isKind = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('m=' + kind + ' ')) {
isKind = true;
} else if (lines[i].startsWith('m=')) {
isKind = false;
}
if (isKind) {
let match = lines[i].match(codecRegex);
if (match) {
allowed.push(parseInt(match[1]));
}
match = lines[i].match(rtxRegex);
if (match && allowed.includes(parseInt(match[2]))) {
allowed.push(parseInt(match[1]));
}
}
}
const skipRegex = 'a=(fmtp|rtcp-fb|rtpmap):([0-9]+)';
let sdp = '';
isKind = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('m=' + kind + ' ')) {
isKind = true;
} else if (lines[i].startsWith('m=')) {
isKind = false;
}
if (isKind) {
const skipMatch = lines[i].match(skipRegex);
if (skipMatch && !allowed.includes(parseInt(skipMatch[2]))) {
continue;
} else if (lines[i].match(videoRegex)) {
sdp += lines[i].replace(videoRegex, '$1 ' + allowed.join(' ')) + '\n';
} else {
sdp += lines[i] + '\n';
}
} else {
sdp += lines[i] + '\n';
}
}
return sdp;
};

View file

@ -0,0 +1,359 @@
"use client";
import { ArrowLeft, ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet,getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet } from "@/client/sdk.gen";
import { WorkflowRunResponseSchema } from "@/client/types.gen";
import { FilterBuilder } from "@/components/filters/FilterBuilder";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { DISPOSITION_CODES } from "@/constants/dispositionCodes";
import { useUserConfig } from '@/context/UserConfigContext';
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
import { downloadFile } from "@/lib/files";
import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
import WorkflowLayout from '../../WorkflowLayout';
export default function WorkflowRunsPage() {
const { workflowId } = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const [workflowRuns, setWorkflowRuns] = useState<WorkflowRunResponseSchema[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(() => {
const pageParam = searchParams.get('page');
return pageParam ? parseInt(pageParam, 10) : 1;
});
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
const [configuredAttributes, setConfiguredAttributes] = useState<FilterAttribute[]>(availableAttributes);
const { accessToken } = useUserConfig();
// Initialize filters from URL
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
return decodeFiltersFromURL(searchParams, availableAttributes);
});
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 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);
}
};
loadDispositionCodes();
}, [workflowId, accessToken, configuredAttributes]);
const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => {
if (!accessToken) return;
try {
setLoading(true);
// Prepare filter data for API
let filterParam = undefined;
if (filters && filters.length > 0) {
const filterData = filters.map(filter => ({
attribute: filter.attribute.id,
type: filter.attribute.type,
value: filter.value
}));
filterParam = JSON.stringify(filterData);
}
const response = await getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet({
path: { workflow_id: Number(workflowId) },
query: {
page: page,
limit: 50,
...(filterParam && { filters: filterParam })
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.error) {
throw new Error("Failed to fetch workflow runs");
}
if (response.data) {
setWorkflowRuns(response.data.runs || []);
setTotalPages(response.data.total_pages || 1);
setTotalCount(response.data.total_count || 0);
setCurrentPage(response.data.page || 1);
}
setError(null);
} catch (err) {
console.error("Error fetching workflow runs:", err);
setError("Failed to load workflow runs");
} finally {
setLoading(false);
}
}, [workflowId, accessToken]);
const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => {
const params = new URLSearchParams();
params.set('page', page.toString());
// Add filters to URL if present
if (filters && filters.length > 0) {
const filterString = encodeFiltersToURL(filters);
if (filterString) {
const filterParams = new URLSearchParams(filterString);
filterParams.forEach((value, key) => params.set(key, value));
}
}
router.push(`/workflow/${workflowId}/runs?${params.toString()}`);
}, [router, workflowId]);
useEffect(() => {
fetchWorkflowRuns(currentPage, activeFilters);
}, [currentPage, activeFilters, fetchWorkflowRuns]);
const handleApplyFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1); // Reset to first page when applying filters
updatePageInUrl(1, activeFilters);
await fetchWorkflowRuns(1, activeFilters);
setIsExecutingFilters(false);
}, [activeFilters, fetchWorkflowRuns, updatePageInUrl]);
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
setActiveFilters(filters);
}, []);
const handleClearFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1);
updatePageInUrl(1, []); // Clear filters from URL
await fetchWorkflowRuns(1, []); // Fetch all workflows without filters
setIsExecutingFilters(false);
}, [fetchWorkflowRuns, updatePageInUrl]);
const backButton = (
<Link href={`/workflow/${workflowId}`}>
<Button variant="outline" size="sm" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Workflow
</Button>
</Link>
);
return (
<WorkflowLayout backButton={backButton}>
<div className="container mx-auto py-8">
<div className="mb-6">
<h1 className="text-2xl font-bold mb-4">Workflow Run History</h1>
<FilterBuilder
availableAttributes={configuredAttributes}
activeFilters={activeFilters}
onFiltersChange={handleFiltersChange}
onApplyFilters={handleApplyFilters}
onClearFilters={handleClearFilters}
isExecuting={isExecutingFilters}
/>
</div>
{loading ? (
<div className="flex justify-center">
<div className="animate-pulse">Loading workflow runs...</div>
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
) : workflowRuns.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500">No workflow runs found</p>
</div>
) : (
<Card>
<CardHeader>
<CardTitle>Workflow Runs</CardTitle>
<CardDescription>
Showing {workflowRuns.length} of {totalCount} total runs
</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Status</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold">Duration</TableHead>
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Dograh Token</TableHead>
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workflowRuns.map((run) => (
<TableRow
key={run.id}
className="cursor-pointer"
onClick={() => router.push(`/workflow/${workflowId}/run/${run.id}`)}
>
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
<TableCell>
<Badge variant={run.is_completed ? "default" : "secondary"}>
{run.is_completed ? "Completed" : "In Progress"}
</Badge>
</TableCell>
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.call_duration_seconds === 'number'
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`
: "-"}
</TableCell>
<TableCell>
{run.gathered_context?.mapped_call_disposition ? (
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
{run.gathered_context.mapped_call_disposition as string}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.dograh_token_usage === 'number'
? `${run.cost_info.dograh_token_usage.toFixed(2)}`
: "-"}
</TableCell>
<TableCell>
<div className="flex space-x-2">
{run.transcript_url && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
if (accessToken) downloadFile(run.transcript_url, accessToken);
}}
>
<Download className="h-3 w-3 mr-1" />
Transcript
</Button>
)}
{run.recording_url && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
if (accessToken) downloadFile(run.recording_url, accessToken);
}}
>
<Download className="h-3 w-3 mr-1" />
Recording
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
router.push(`/workflow/${workflowId}/run/${run.id}`);
}}
>
<ExternalLink className="h-3 w-3 mr-1" />
View
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
const newPage = currentPage - 1;
setCurrentPage(newPage);
updatePageInUrl(newPage, activeFilters);
}}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const newPage = currentPage + 1;
setCurrentPage(newPage);
updatePageInUrl(newPage, activeFilters);
}}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
</WorkflowLayout>
);
}

View file

@ -0,0 +1,215 @@
import { Settings } from 'lucide-react';
import Link from 'next/link';
import { Suspense } from 'react';
import { getWorkflowsApiV1WorkflowFetchGet, getWorkflowTemplatesApiV1WorkflowTemplatesGet } from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { CreateWorkflowButton } from "@/components/workflow/CreateWorkflowButton";
import { DuplicateWorkflowTemplate } from "@/components/workflow/TemplateCard";
import { UploadWorkflowButton } from '@/components/workflow/UploadWorkflowButton';
import { WorkflowTable } from "@/components/workflow/WorkflowTable";
import { getServerAccessToken, getServerAuthProvider } from '@/lib/auth/server';
import logger from '@/lib/logger';
import WorkflowLayout from "./WorkflowLayout";
// Server component for workflow templates
async function WorkflowTemplatesList() {
try {
const response = await getWorkflowTemplatesApiV1WorkflowTemplatesGet();
// Log request URL if available
if (response.request?.url) {
logger.info(`Template Request URL: ${response.request.url}`);
}
const templates = response.data || [];
// Get access token on server side to pass to client component
const accessToken = await getServerAccessToken();
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{templates.map((template) => (
<DuplicateWorkflowTemplate
key={template.id}
id={template.id}
title={template.template_name}
description={template.template_description}
serverAccessToken={accessToken}
/>
))}
</div>
);
} catch (err) {
logger.error(`Error fetching workflow templates: ${err}`);
return (
<div className="text-red-500">
Failed to load Workflow Templates. Please Try Again Later.
</div>
);
}
}
// Server component for workflow list
async function WorkflowList() {
const authProvider = getServerAuthProvider();
const accessToken = await getServerAccessToken();
logger.debug(`In WorkflowList, authProvider: ${authProvider}, accessToken: ${accessToken}`);
if (!accessToken) {
// If no token, user needs to sign in
const { redirect } = await import('next/navigation');
if (authProvider === 'stack') {
redirect('/');
} else {
// For OSS mode, this shouldn't happen as token is auto-generated
return (
<div className="text-red-500">
Authentication required. Please refresh the page.
</div>
);
}
}
try {
// Fetch both active and archived workflows in a single request
const response = await getWorkflowsApiV1WorkflowFetchGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
query: {
status: 'active,archived'
}
});
const allWorkflowData = response.data ? (Array.isArray(response.data) ? response.data : [response.data]) : [];
// Separate active and archived workflows
const activeWorkflows = allWorkflowData
.filter(w => w.status === 'active')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const archivedWorkflows = allWorkflowData
.filter(w => w.status === 'archived')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
return (
<>
{/* Active Workflows Section */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Active Workflows</h2>
{activeWorkflows.length > 0 ? (
<WorkflowTable workflows={activeWorkflows} showArchived={false} />
) : (
<div className="text-gray-500 bg-gray-50 rounded-lg p-8 text-center">
No active workflows found. Create your first workflow to get started.
</div>
)}
</div>
{/* Archived Workflows Section */}
{archivedWorkflows.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4 text-gray-600">Archived Workflows</h2>
<WorkflowTable workflows={archivedWorkflows} showArchived={true} />
</div>
)}
</>
);
} catch (err) {
logger.error(`Error fetching workflows: ${err}`);
return (
<div className="text-red-500">
Failed to load Workflows. Please Try Again Later.
</div>
);
}
}
async function PageContent() {
const workflowList = await WorkflowList();
return (
<div className="container mx-auto px-4 py-8">
{/* Get Started Section */}
<div className="mb-12">
<div className="flex justify-between items-center px-4">
<h2 className="text-2xl font-bold mb-6">Get Started</h2>
<div className="flex gap-2">
<Link href="/service-configurations">
<Button className="flex items-center gap-2 mb-6">
<Settings size={16} />
Configure Services
</Button>
</Link>
<Link href="/integrations">
<Button className="flex items-center gap-2 mb-6">
<Settings size={16} />
Integrations
</Button>
</Link>
</div>
</div>
<Suspense fallback={
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="bg-gray-200 rounded-lg h-40"></div>
))}
</div>
}>
<WorkflowTemplatesList />
</Suspense>
</div>
{/* Your Workflows Section */}
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Your Workflows</h1>
<div className="flex gap-2">
<UploadWorkflowButton />
<CreateWorkflowButton />
</div>
</div>
{workflowList}
</div>
</div>
);
}
function WorkflowsLoading() {
return (
<div className="container mx-auto px-4 py-8">
{/* Get Started Section Loading */}
<div className="mb-12">
<div className="h-8 w-48 bg-gray-200 rounded mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="bg-gray-200 rounded-lg h-40"></div>
))}
</div>
</div>
{/* Your Workflows Section Loading */}
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<div className="h-8 w-48 bg-gray-200 rounded"></div>
<div className="h-10 w-32 bg-gray-200 rounded"></div>
</div>
<div className="bg-gray-200 rounded-lg h-96"></div>
</div>
</div>
);
}
export default function WorkflowPage() {
return (
<WorkflowLayout showFeaturesNav={true}>
<Suspense fallback={<WorkflowsLoading />}>
<PageContent />
</Suspense>
</WorkflowLayout>
);
}

View file

@ -0,0 +1,20 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type ClientOptions as DefaultClientOptions, type Config, createClient, createConfig } from '@hey-api/client-fetch';
import { createClientConfig } from '../lib/apiClient';
import type { ClientOptions } from './types.gen';
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> = (override?: Config<DefaultClientOptions & T>) => Config<Required<DefaultClientOptions> & T>;
export const client = createClient(createClientConfig(createConfig<ClientOptions>({
baseUrl: 'http://127.0.0.1:8000'
})));

3
ui/src/client/index.ts Normal file
View file

@ -0,0 +1,3 @@
// This file is auto-generated by @hey-api/openapi-ts
export * from './sdk.gen';
export * from './types.gen';

858
ui/src/client/sdk.gen.ts Normal file

File diff suppressed because one or more lines are too long

2621
ui/src/client/types.gen.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,115 @@
import type { DailyUsageBreakdownResponse } from '@/client/types.gen';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface DailyUsageTableProps {
data: DailyUsageBreakdownResponse | null;
isLoading: boolean;
}
export function DailyUsageTable({ data, isLoading }: DailyUsageTableProps) {
// Format date for display
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Daily Usage Breakdown</CardTitle>
<CardDescription>Last 7 days of usage</CardDescription>
</CardHeader>
<CardContent>
<div className="animate-pulse space-y-3">
{[...Array(7)].map((_, i) => (
<div key={i} className="h-12 bg-gray-200 rounded"></div>
))}
</div>
</CardContent>
</Card>
);
}
if (!data || !data.breakdown || data.breakdown.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Daily Usage Breakdown</CardTitle>
<CardDescription>Last 7 days of usage</CardDescription>
</CardHeader>
<CardContent>
<p className="text-center py-8 text-gray-500">No usage data available</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Daily Usage Breakdown</CardTitle>
<CardDescription>Last 7 days of usage</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="font-semibold">Date</TableHead>
<TableHead className="font-semibold text-right">Usage (minutes)</TableHead>
<TableHead className="font-semibold text-right">Cost (USD)</TableHead>
<TableHead className="font-semibold text-right">Calls</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.breakdown.map((day) => (
<TableRow key={day.date}>
<TableCell className="font-medium">
{formatDate(day.date)}
</TableCell>
<TableCell className="text-right">
{day.minutes.toFixed(1)}
</TableCell>
<TableCell className="text-right font-medium">
${(day.cost_usd || 0).toFixed(2)}
</TableCell>
<TableCell className="text-right">
{day.call_count}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow className="bg-gray-50 font-semibold">
<TableCell>Total</TableCell>
<TableCell className="text-right">
{data.total_minutes.toFixed(1)}
</TableCell>
<TableCell className="text-right">
${(data.total_cost_usd || 0).toFixed(2)}
</TableCell>
<TableCell className="text-right">
{data.breakdown.reduce((sum, day) => sum + day.call_count, 0)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,158 @@
'use client';
import { FileText, Loader2, Video } from 'lucide-react';
import { useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { downloadFile, getSignedUrl } from '@/lib/files';
interface MediaPreviewDialogProps {
accessToken: string | null;
}
export function MediaPreviewDialog({ accessToken }: MediaPreviewDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [mediaType, setMediaType] = useState<'audio' | 'transcript' | null>(null);
const [mediaSignedUrl, setMediaSignedUrl] = useState<string | null>(null);
const [selectedRunId, setSelectedRunId] = useState<number | null>(null);
const [mediaDownloadKey, setMediaDownloadKey] = useState<string | null>(null);
const [mediaLoading, setMediaLoading] = useState(false);
const openAudioModal = useCallback(
async (fileKey: string | null, runId: number) => {
if (!fileKey || !accessToken) return;
setMediaLoading(true);
const signed = await getSignedUrl(fileKey, accessToken);
if (signed) {
setMediaType('audio');
setMediaSignedUrl(signed);
setMediaDownloadKey(fileKey);
setSelectedRunId(runId);
setIsOpen(true);
}
setMediaLoading(false);
},
[accessToken],
);
const openTranscriptModal = useCallback(
async (fileKey: string | null, runId: number) => {
if (!fileKey || !accessToken) return;
setMediaLoading(true);
const signed = await getSignedUrl(fileKey, accessToken, true);
if (signed) {
setMediaType('transcript');
setMediaSignedUrl(signed);
setMediaDownloadKey(fileKey);
setSelectedRunId(runId);
setIsOpen(true);
}
setMediaLoading(false);
},
[accessToken],
);
return {
openAudioModal,
openTranscriptModal,
dialog: (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{mediaType === 'audio' ? 'Recording Preview' : 'Transcript Preview'}
{selectedRunId && ` - Run #${selectedRunId}`}
</DialogTitle>
</DialogHeader>
{mediaLoading && (
<div className="flex items-center justify-center py-8 space-x-2">
<Loader2 className="h-6 w-6 animate-spin" />
<span>Loading...</span>
</div>
)}
{!mediaLoading && mediaType === 'audio' && mediaSignedUrl && (
<audio src={mediaSignedUrl} controls autoPlay className="w-full mt-4" />
)}
{!mediaLoading && mediaType === 'transcript' && mediaSignedUrl && (
<iframe
src={mediaSignedUrl}
title="Transcript"
className="w-full h-[60vh] border rounded-md mt-4"
/>
)}
<DialogFooter className="pt-4">
<DialogClose asChild>
<Button variant="secondary">Close</Button>
</DialogClose>
{mediaDownloadKey && accessToken && (
<Button onClick={() => downloadFile(mediaDownloadKey, accessToken)}>Download</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
),
};
}
interface MediaPreviewButtonsProps {
recordingUrl: string | null | undefined;
transcriptUrl: string | null | undefined;
runId: number;
onOpenAudio: (fileKey: string | null, runId: number) => void;
onOpenTranscript: (fileKey: string | null, runId: number) => void;
onSelect?: (runId: number) => void;
}
export function MediaPreviewButtons({
recordingUrl,
transcriptUrl,
runId,
onOpenAudio,
onOpenTranscript,
onSelect,
}: MediaPreviewButtonsProps) {
const handleOpenAudio = () => {
onSelect?.(runId);
onOpenAudio(recordingUrl ?? null, runId);
};
const handleOpenTranscript = () => {
onSelect?.(runId);
onOpenTranscript(transcriptUrl ?? null, runId);
};
return (
<div className="flex space-x-2">
{recordingUrl && (
<Button
variant="outline"
size="icon"
onClick={handleOpenAudio}
>
<Video className="h-4 w-4" />
</Button>
)}
{transcriptUrl && (
<Button
variant="outline"
size="icon"
onClick={handleOpenTranscript}
>
<FileText className="h-4 w-4" />
</Button>
)}
</div>
);
}

View file

@ -0,0 +1,46 @@
'use client';
import posthog from 'posthog-js';
import { useEffect } from 'react';
import { useAuth } from '@/lib/auth';
/**
* PostHogIdentify
* ---------------
* A tiny client-side component that calls `posthog.identify` once the
* authenticated user object is available. It also resets PostHog when the
* user logs out or switches accounts.
*
* This component is intended to be rendered high in the React tree (e.g. in
* `app/layout.tsx`) so that PostHog always knows which user is active for the
* current browser session.
*/
export default function PostHogIdentify() {
const { user } = useAuth();
useEffect(() => {
// Only run if PostHog is enabled
if (process.env.NEXT_PUBLIC_ENABLE_POSTHOG !== 'true') {
return;
}
if (user) {
try {
// Identify the user in PostHog with their unique id and useful traits
posthog.identify(String(user.id ?? ''));
} catch (err) {
// Silently ignore identification errors so they don't break the app
console.warn('Failed to identify user in PostHog', err);
}
} else {
// If the user logs out, clear the PostHog identity so future anonymous
// interactions aren't associated with the previous account.
posthog.reset();
}
}, [user]);
// This component does not render anything
return null;
}

View file

@ -0,0 +1,340 @@
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet } from '@/client/sdk.gen';
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useUserConfig } from "@/context/UserConfigContext";
type ServiceSegment = "llm" | "tts" | "stt";
interface SchemaProperty {
type?: string;
default?: string | number | boolean;
enum?: string[];
$ref?: string;
description?: string;
format?: string;
}
interface ProviderSchema {
properties: Record<string, SchemaProperty>;
required?: string[];
$defs?: Record<string, SchemaProperty>;
[key: string]: unknown;
}
interface FormValues {
[key: string]: string | number | boolean;
}
export default function ServiceConfiguration() {
const [apiError, setApiError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const { userConfig, saveUserConfig } = useUserConfig();
const [schemas, setSchemas] = useState<Record<ServiceSegment, Record<string, ProviderSchema>>>({
llm: {},
tts: {},
stt: {}
});
const [serviceProviders, setServiceProviders] = useState<Record<ServiceSegment, string>>({
llm: "",
tts: "",
stt: ""
});
const {
register,
handleSubmit,
formState: { errors },
reset,
getValues,
setValue,
watch
} = useForm();
useEffect(() => {
const fetchConfigurations = async () => {
const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet();
if (response.data) {
setSchemas({
llm: response.data.llm as Record<string, ProviderSchema>,
tts: response.data.tts as Record<string, ProviderSchema>,
stt: response.data.stt as Record<string, ProviderSchema>
});
} else {
console.error("Failed to fetch configurations");
return;
}
const defaultValues: Record<string, string | number | boolean> = {};
const selectedProviders: Record<ServiceSegment, string> = {
llm: response.data.default_providers.llm,
tts: response.data.default_providers.tts,
stt: response.data.default_providers.stt
};
const setServicePropertyValues = (service: ServiceSegment) => {
/*
sets service properties like api_key, model etc. from default configurations
if not present in user configurations
service - llm/ tts/ stt
userConfig['llm'] = {
provider: 'openai',
api_key: 'sk-...'
}
response.data.llm = {
openai: {
properties: {
provider: 'openai'
api_key: 'sk-...'
}
}
}
*/
if (userConfig?.[service]?.provider) {
Object.entries(userConfig?.[service]).forEach(([field, value]) => {
if (field !== "provider") {
defaultValues[`${service}_${field}`] = value;
}
});
selectedProviders[service] = userConfig?.[service]?.provider as string;
} else {
// response.data['service'] will all providers for the given service
// selectedProviders[service] will have the provider name
const properties = response.data[service]?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
if (properties) {
Object.entries(properties).forEach(([field, schema]) => {
if (field !== "provider" && schema.default) {
defaultValues[`${service}_${field}`] = schema.default;
}
});
}
}
}
setServicePropertyValues("llm");
setServicePropertyValues("tts");
setServicePropertyValues("stt");
setServiceProviders(selectedProviders);
reset(defaultValues);
};
fetchConfigurations();
}, [reset, userConfig]);
const handleProviderChange = (service: ServiceSegment, providerName: string) => {
/*
service can be llm/ tts/ stt
providerName is openAI/ Deepgram etc.
*/
if (!providerName) {
return;
}
const currentValues = getValues();
const preservedValues: Record<string, string | number | boolean> = {};
// Preserve values from other services
Object.keys(currentValues).forEach(key => {
if (!key.startsWith(`${service}_`)) {
preservedValues[key] = currentValues[key];
}
});
// Set default values from schema
if (schemas?.[service]?.[providerName]) {
const providerSchema = schemas[service][providerName];
Object.entries(providerSchema.properties).forEach(([field, schema]: [string, SchemaProperty]) => {
if (field !== "provider" && schema.default !== undefined) {
preservedValues[`${service}_${field}`] = schema.default;
}
});
}
preservedValues[`${service}_provider`] = providerName;
reset(preservedValues);
setServiceProviders(prev => ({ ...prev, [service]: providerName }));
}
const onSubmit = async (data: FormValues) => {
/*
data contains form values like llm_api_key: "sk...", llm_model: "gpt-4o" etc.
extract the values in relevant form
*/
setApiError(null);
setIsSaving(true);
const userConfig = {
llm: {
provider: serviceProviders.llm,
api_key: data.llm_api_key as string,
model: data.llm_model as string
},
tts: {
provider: serviceProviders.tts,
api_key: data.tts_api_key as string
},
stt: {
provider: serviceProviders.stt,
api_key: data.stt_api_key as string
}
};
// Add any extra properties in the payload
Object.entries(data).forEach(([property, value]) => {
const parts = property.split('_');
const service = parts[0] as ServiceSegment;
const field = parts.slice(1).join('_'); // Join all parts after the service name
if (userConfig[service] && !(field in userConfig[service])) {
(userConfig[service] as Record<string, string>)[field] = value as string;
}
});
try {
await saveUserConfig({
llm: userConfig.llm,
tts: userConfig.tts,
stt: userConfig.stt
});
setApiError(null);
} catch (error: unknown) {
if (error instanceof Error) {
setApiError(error.message);
} else {
setApiError('An unknown error occurred');
}
} finally {
setIsSaving(false);
}
};
const renderServiceSegmentFields = (service: ServiceSegment) => {
// Segment is segments like llm, tts and stt
const currentProvider = serviceProviders[service];
const providerSchema = schemas?.[service]?.[currentProvider];
const availableProviders = schemas?.[service] ? Object.keys(schemas[service]) : [];
return (
<Card className="mb-6">
<CardHeader>
<CardTitle>{service.toUpperCase()} Configuration</CardTitle>
<CardDescription>
Configure your {service.toUpperCase()} service
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label>Provider</Label>
<Select
value={currentProvider}
onValueChange={(providerName) => {
handleProviderChange(service, providerName);
}}
>
<SelectTrigger>
<SelectValue placeholder={`Select ${service.toUpperCase()} provider`} />
</SelectTrigger>
<SelectContent>
{availableProviders.map((provider) => (
<SelectItem key={provider} value={provider}>
{provider}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{currentProvider && providerSchema && (
<div className="space-y-4">
{Object.entries(providerSchema.properties).map(([field, schema]: [string, SchemaProperty]) => {
// Handle $ref fields by getting the actual schema from $defs
const actualSchema = schema.$ref && providerSchema.$defs
? providerSchema.$defs[schema.$ref.split('/').pop() || '']
: schema;
// Skip provider field as it's handled separately
return field !== "provider" && (
<div key={`${service}_${field}_${currentProvider}`} className="space-y-2">
<Label>{field}</Label>
{actualSchema?.enum ? (
<Select
value={watch(`${service}_${field}`) as string || ""}
onValueChange={(value) => {
setValue(`${service}_${field}`, value, { shouldDirty: true });
}}
>
<SelectTrigger>
<SelectValue placeholder={`Select ${field}`} />
</SelectTrigger>
<SelectContent>
{actualSchema.enum.map((value: string) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type={actualSchema?.type === "number" ? "number" : "text"}
{...(actualSchema?.type === "number" && { step: "any" })}
placeholder={`Enter ${field}`}
{...register(`${service}_${field}`, {
required: providerSchema.required?.includes(field),
valueAsNumber: actualSchema?.type === "number"
})}
/>
)}
{errors[`${service}_${field}`] && (
<p className="text-sm text-red-500">
{typeof errors[`${service}_${field}`]?.message === 'string'
? String(errors[`${service}_${field}`]?.message)
: "This field is required"}
</p>
)}
</div>
);
})}
</div>
)}
</div>
</CardContent>
</Card>
);
};
return (
<div className="w-full max-w-4xl mx-auto py-8">
<h1 className="text-2xl font-bold mb-6">Service Configuration</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{renderServiceSegmentFields("llm")}
{renderServiceSegmentFields("tts")}
{renderServiceSegmentFields("stt")}
{apiError && <p className="text-red-500">{apiError}</p>}
<Button type="submit" className="w-full" disabled={isSaving}>
{isSaving ? "Saving..." : "Save Configuration"}
</Button>
</form>
</div>
);
}

View file

@ -0,0 +1,27 @@
"use client";
import { Loader2 } from 'lucide-react';
import dynamic from 'next/dynamic';
// Only load Stack's SignIn component when Stack provider is active
const SignIn = dynamic(
() => import('@stackframe/stack').then(mod => ({ default: mod.SignIn })),
{ ssr: false, loading: () => <Loader2 className="w-5 h-5 animate-spin text-gray-600" /> }
);
export default function SignInClient() {
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
if (authProvider !== 'stack') {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Local Authentication</h1>
<p className="text-gray-600">Local authentication is enabled. No sign-in required.</p>
</div>
</div>
);
}
return <SignIn />;
}

View file

@ -0,0 +1,9 @@
import { Loader2 } from "lucide-react";
export default function SpinLoader(){
return(
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-15 h-15 animate-spin" />
</div>
)
}

View file

@ -0,0 +1,29 @@
"use client";
import { Moon,Sun } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
export default function ThemeToggle() {
const [theme, setTheme] = useState("light");
useEffect(() => {
const storedTheme = localStorage.getItem("theme") || "light";
setTheme(storedTheme);
document.documentElement.classList.toggle("dark", storedTheme === "dark");
}, []);
const toggleTheme = () => {
const newTheme = theme === "light" ? "dark" : "light";
setTheme(newTheme);
localStorage.setItem("theme", newTheme);
document.documentElement.classList.toggle("dark", newTheme === "dark");
};
return (
<Button variant="outline" className="absolute top-4 right-4" onClick={toggleTheme}>
{theme === "light" ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
</Button>
);
}

View file

@ -0,0 +1,169 @@
import { CalendarIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { getDatePresetValue } from "@/lib/filters";
import { cn } from "@/lib/utils";
import { DateRangeValue } from "@/types/filters";
interface DateRangeFilterProps {
value: DateRangeValue;
onChange: (value: DateRangeValue) => void;
error?: string;
presets?: string[];
}
export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
value,
onChange,
error,
presets = [],
}) => {
const [isFromOpen, setIsFromOpen] = useState(false);
const [isToOpen, setIsToOpen] = useState(false);
const formatDate = (date: Date | null) => {
if (!date) return "Select date";
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const handlePresetClick = (preset: string) => {
const presetValue = getDatePresetValue(preset);
onChange(presetValue);
};
const handleFromChange = (date: Date | undefined) => {
if (date) {
// Keep the time from the existing date if available
if (value.from) {
date.setHours(value.from.getHours(), value.from.getMinutes());
}
onChange({ ...value, from: date });
}
setIsFromOpen(false);
};
const handleToChange = (date: Date | undefined) => {
if (date) {
// Set to end of day by default
date.setHours(23, 59, 59, 999);
onChange({ ...value, to: date });
}
setIsToOpen(false);
};
const handleTimeChange = (type: 'from' | 'to', timeString: string) => {
const [hours, minutes] = timeString.split(':').map(Number);
const date = type === 'from' ? value.from : value.to;
if (date) {
const newDate = new Date(date);
newDate.setHours(hours, minutes);
onChange({ ...value, [type]: newDate });
}
};
return (
<div className="space-y-3">
{presets.length > 0 && (
<div className="flex flex-wrap gap-2">
{presets.map((preset) => (
<Button
key={preset}
variant="outline"
size="sm"
onClick={() => handlePresetClick(preset)}
>
{preset.charAt(0).toUpperCase() + preset.slice(1).replace(/(\d+)/, ' $1 ')}
</Button>
))}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>From</Label>
<Popover open={isFromOpen} onOpenChange={setIsFromOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!value.from && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{formatDate(value.from)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={value.from || undefined}
onSelect={handleFromChange}
initialFocus
/>
{value.from && (
<div className="p-3 border-t">
<Label htmlFor="from-time">Time</Label>
<input
id="from-time"
type="time"
className="w-full mt-1 px-3 py-2 border rounded-md"
value={value.from.toTimeString().slice(0, 5)}
onChange={(e) => handleTimeChange('from', e.target.value)}
/>
</div>
)}
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label>To</Label>
<Popover open={isToOpen} onOpenChange={setIsToOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!value.to && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{formatDate(value.to)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={value.to || undefined}
onSelect={handleToChange}
initialFocus
disabled={(date) => value.from ? date < value.from : false}
/>
{value.to && (
<div className="p-3 border-t">
<Label htmlFor="to-time">Time</Label>
<input
id="to-time"
type="time"
className="w-full mt-1 px-3 py-2 border rounded-md"
value={value.to.toTimeString().slice(0, 5)}
onChange={(e) => handleTimeChange('to', e.target.value)}
/>
</div>
)}
</PopoverContent>
</Popover>
</div>
</div>
{error && (
<p className="text-sm text-red-500 mt-1">{error}</p>
)}
</div>
);
};

View file

@ -0,0 +1,455 @@
import { AlertCircle, Calendar, CheckSquare, Hash, Radio, RefreshCw, Tag, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { DateRangeFilter } from "@/components/filters/DateRangeFilter";
import { MultiSelectFilter } from "@/components/filters/MultiSelectFilter";
import { NumberFilter } from "@/components/filters/NumberFilter";
import { NumberRangeFilter } from "@/components/filters/NumberRangeFilter";
import { RadioFilter } from "@/components/filters/RadioFilter";
import { TagInputFilter } from "@/components/filters/TagInputFilter";
import { TextFilter } from "@/components/filters/TextFilter";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { formatDateRange, formatNumberRange, getDefaultValue, validateFilter } from "@/lib/filters";
import { ActiveFilter, DateRangeValue, FilterAttribute, FilterTemplate, filterTemplates, FilterValue, MultiSelectValue, NumberRangeValue, NumberValue, RadioValue, TextValue } from "@/types/filters";
interface FilterBuilderProps {
availableAttributes: FilterAttribute[];
activeFilters: ActiveFilter[];
onFiltersChange: (filters: ActiveFilter[]) => void;
onApplyFilters: () => void;
onClearFilters?: () => void;
isExecuting?: boolean;
autoRefresh?: boolean;
onAutoRefreshChange?: (enabled: boolean) => void;
}
export const FilterBuilder: React.FC<FilterBuilderProps> = ({
availableAttributes,
activeFilters,
onFiltersChange,
onApplyFilters,
onClearFilters,
isExecuting = false,
autoRefresh = false,
onAutoRefreshChange,
}) => {
const [selectedAttribute, setSelectedAttribute] = useState<string>("");
const [expandedFilters, setExpandedFilters] = useState<Set<number>>(new Set());
// Auto-expand new filters
useEffect(() => {
if (activeFilters.length > 0) {
setExpandedFilters(new Set([activeFilters.length - 1]));
}
}, [activeFilters.length]);
// Handle Command+Enter (Mac) or Ctrl+Enter (Windows/Linux) to apply filters
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const isMac = navigator.userAgent.toUpperCase().indexOf('MAC') >= 0;
const isModifierPressed = isMac ? event.metaKey : event.ctrlKey;
if (isModifierPressed && event.key === 'Enter') {
event.preventDefault();
const allFiltersValid = activeFilters.every(f => f.isValid);
if (activeFilters.length > 0 && allFiltersValid && !isExecuting) {
onApplyFilters();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [activeFilters, isExecuting, onApplyFilters]);
const addFilter = useCallback((attributeId: string) => {
const attribute = availableAttributes.find(attr => attr.id === attributeId);
if (!attribute) return;
const defaultValue = getDefaultValue(attribute.type);
const newFilter: ActiveFilter = {
attribute,
value: defaultValue,
isValid: false,
};
onFiltersChange([...activeFilters, newFilter]);
setSelectedAttribute("");
}, [availableAttributes, activeFilters, onFiltersChange]);
const updateFilter = useCallback((index: number, value: FilterValue) => {
const newFilters = [...activeFilters];
newFilters[index].value = value;
newFilters[index].isValid = validateFilter(newFilters[index]) === null;
onFiltersChange(newFilters);
}, [activeFilters, onFiltersChange]);
const removeFilter = useCallback((index: number) => {
onFiltersChange(activeFilters.filter((_, i) => i !== index));
setExpandedFilters(prev => {
const newSet = new Set(prev);
newSet.delete(index);
return newSet;
});
}, [activeFilters, onFiltersChange]);
const clearAllFilters = useCallback(() => {
onFiltersChange([]);
setExpandedFilters(new Set());
if (onClearFilters) {
onClearFilters();
}
}, [onFiltersChange, onClearFilters]);
const applyTemplate = useCallback((template: FilterTemplate) => {
const newFilters: ActiveFilter[] = template.filters.map(filterConfig => {
const attribute = availableAttributes.find(attr => attr.id === filterConfig.attributeId);
if (!attribute) {
console.warn(`Attribute ${filterConfig.attributeId} not found`);
return null;
}
const filter: ActiveFilter = {
attribute,
value: filterConfig.value,
isValid: false,
};
filter.isValid = validateFilter(filter) === null;
return filter;
}).filter((f): f is ActiveFilter => f !== null);
onFiltersChange(newFilters);
setExpandedFilters(new Set());
}, [availableAttributes, onFiltersChange]);
const toggleFilterExpanded = (index: number) => {
setExpandedFilters(prev => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
return newSet;
});
};
const getFilterIcon = (type: FilterAttribute["type"]) => {
switch (type) {
case "dateRange":
return <Calendar className="h-4 w-4" />;
case "multiSelect":
return <CheckSquare className="h-4 w-4" />;
case "number":
case "numberRange":
return <Hash className="h-4 w-4" />;
case "radio":
return <Radio className="h-4 w-4" />;
case "tags":
return <Tag className="h-4 w-4" />;
case "text":
return <Hash className="h-4 w-4" />;
}
};
const getFilterSummary = (filter: ActiveFilter): string => {
switch (filter.attribute.type) {
case "dateRange":
return formatDateRange(filter.value as DateRangeValue);
case "multiSelect": {
const value = filter.value as MultiSelectValue;
if (value.codes.length === 0) return "No options selected";
if (value.codes.length <= 3) return value.codes.join(", ");
return `${value.codes.slice(0, 3).join(", ")} +${value.codes.length - 3} more`;
}
case "number": {
const value = filter.value as NumberValue;
return value.value !== null ? value.value.toString() : "No value";
}
case "numberRange":
return formatNumberRange(filter.value as NumberRangeValue, filter.attribute.config.unit);
case "radio": {
const value = filter.value as RadioValue;
const option = filter.attribute.config.radioOptions?.find(opt => opt.value === value.status);
return option?.label || value.status;
}
case "tags": {
const value = filter.value as MultiSelectValue;
if (value.codes.length === 0) return "No tags";
if (value.codes.length <= 3) return value.codes.join(", ");
return `${value.codes.slice(0, 3).join(", ")} +${value.codes.length - 3} more`;
}
case "text": {
const value = filter.value as TextValue;
return value.value || "No value";
}
}
};
const renderFilterInput = (filter: ActiveFilter, index: number) => {
const error = filter.isValid ? undefined : validateFilter(filter) || undefined;
switch (filter.attribute.type) {
case "dateRange":
return (
<DateRangeFilter
value={filter.value as DateRangeValue}
onChange={(value) => updateFilter(index, value)}
error={error}
presets={filter.attribute.config.datePresets}
/>
);
case "multiSelect":
return (
<MultiSelectFilter
options={filter.attribute.config.options || []}
value={filter.value as MultiSelectValue}
onChange={(value) => updateFilter(index, value)}
error={error}
showSelectAll={filter.attribute.config.showSelectAll}
searchable={filter.attribute.config.searchable}
/>
);
case "number":
return (
<NumberFilter
value={filter.value as NumberValue}
onChange={(value) => updateFilter(index, value)}
error={error}
placeholder={filter.attribute.config.placeholder}
min={filter.attribute.config.min}
max={filter.attribute.config.max}
step={filter.attribute.config.step}
/>
);
case "numberRange":
return (
<NumberRangeFilter
value={filter.value as NumberRangeValue}
onChange={(value) => updateFilter(index, value)}
error={error}
unit={filter.attribute.config.unit}
min={filter.attribute.config.min}
max={filter.attribute.config.max}
step={filter.attribute.config.step}
presets={filter.attribute.config.numberPresets}
/>
);
case "radio":
return (
<RadioFilter
value={filter.value as RadioValue}
onChange={(value) => updateFilter(index, value)}
error={error}
options={filter.attribute.config.radioOptions || []}
/>
);
case "tags":
return (
<TagInputFilter
value={filter.value as MultiSelectValue}
onChange={(value) => updateFilter(index, value)}
error={error}
/>
);
case "text":
return (
<TextFilter
value={filter.value as TextValue}
onChange={(value) => updateFilter(index, value)}
error={error}
placeholder={filter.attribute.config.placeholder}
maxLength={filter.attribute.config.maxLength}
/>
);
}
};
const allFiltersValid = activeFilters.every(f => f.isValid);
const availableAttributesForAdding = availableAttributes.filter(
attr => !activeFilters.some(f => f.attribute.id === attr.id)
);
return (
<Card className="mb-6">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Filter Workflow Runs</CardTitle>
<CardDescription>
Build custom filters to find specific workflow runs
</CardDescription>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Templates
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[250px]">
<DropdownMenuLabel>Filter Templates</DropdownMenuLabel>
<DropdownMenuSeparator />
{filterTemplates.map((template) => (
<DropdownMenuItem
key={template.id}
onClick={() => applyTemplate(template)}
>
<div className="flex flex-col">
<span className="font-medium">{template.name}</span>
<span className="text-xs text-muted-foreground">
{template.description}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Add Filter Row */}
<div className="flex gap-2">
<Select value={selectedAttribute} onValueChange={(value) => {
addFilter(value);
}}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select attribute to filter by" />
</SelectTrigger>
<SelectContent>
{availableAttributesForAdding.map((attr) => (
<SelectItem key={attr.id} value={attr.id}>
<div className="flex items-center gap-2">
{getFilterIcon(attr.type)}
<span>{attr.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Active Filters */}
{activeFilters.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">Active Filters</h4>
{activeFilters.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
>
Clear All
</Button>
)}
</div>
{activeFilters.map((filter, index) => (
<Card key={index} className={filter.isValid ? "" : "border-red-200"}>
<CardHeader className="pb-3">
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => toggleFilterExpanded(index)}
>
<div className="flex items-center gap-2">
{getFilterIcon(filter.attribute.type)}
<span className="font-medium">{filter.attribute.label}</span>
{!filter.isValid && (
<AlertCircle className="h-4 w-4 text-red-500" />
)}
</div>
<div className="flex items-center gap-2">
{!expandedFilters.has(index) && (
<span className="text-sm text-muted-foreground">
{getFilterSummary(filter)}
</span>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
removeFilter(index);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
{expandedFilters.has(index) && (
<CardContent>
{renderFilterInput(filter, index)}
</CardContent>
)}
</Card>
))}
</div>
)}
{/* Apply Filters Button */}
{activeFilters.length > 0 && (
<div className="flex justify-between items-center gap-2 pt-2">
{/* Auto-refresh toggle on the left */}
{onAutoRefreshChange && (
<div className="flex items-center space-x-2">
<Switch
checked={autoRefresh}
onCheckedChange={onAutoRefreshChange}
id="auto-refresh"
/>
<label htmlFor="auto-refresh" className="text-sm font-medium cursor-pointer">
Auto-refresh every 5s
</label>
{autoRefresh && (
<RefreshCw className="h-4 w-4 text-gray-500 animate-spin" />
)}
</div>
)}
{/* Buttons on the right */}
<div className="flex gap-2 ml-auto">
<Button
variant="outline"
onClick={clearAllFilters}
>
Clear All
</Button>
<Button
onClick={onApplyFilters}
disabled={!allFiltersValid || isExecuting}
title={"Apply filters"}
>
{isExecuting ? "Applying..." : `Apply (${navigator.userAgent.toUpperCase().indexOf('MAC') >= 0 ? '⌘' : 'Ctrl'}+Enter)`}
</Button>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,154 @@
import { ChevronDown, Search } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { MultiSelectValue } from "@/types/filters";
interface MultiSelectFilterProps {
options: string[];
value: MultiSelectValue;
onChange: (value: MultiSelectValue) => void;
error?: string;
showSelectAll?: boolean;
searchable?: boolean;
}
export const MultiSelectFilter: React.FC<MultiSelectFilterProps> = ({
options,
value,
onChange,
error,
showSelectAll = true,
searchable = true,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const filteredOptions = searchable
? options.filter(option =>
option.toLowerCase().includes(searchTerm.toLowerCase())
)
: options;
const handleSelectAll = () => {
onChange({ codes: options });
};
const handleSelectNone = () => {
onChange({ codes: [] });
};
const handleToggleOption = (option: string) => {
const newCodes = value.codes.includes(option)
? value.codes.filter(code => code !== option)
: [...value.codes, option];
onChange({ codes: newCodes });
};
const getDisplayText = () => {
if (value.codes.length === 0) return "Select options";
if (value.codes.length <= 3) return value.codes.join(", ");
return `${value.codes.slice(0, 3).join(", ")} +${value.codes.length - 3} more`;
};
return (
<div className="space-y-2">
<Label>Select Options</Label>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isOpen}
className={cn(
"w-full justify-between",
value.codes.length === 0 && "text-muted-foreground"
)}
>
<span className="truncate">{getDisplayText()}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<div className="p-2 space-y-2">
{searchable && (
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search options..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
)}
{showSelectAll && (
<div className="flex gap-2 pb-2 border-b">
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
className="flex-1"
>
Select All
</Button>
<Button
variant="outline"
size="sm"
onClick={handleSelectNone}
className="flex-1"
>
Select None
</Button>
</div>
)}
<div className="max-h-[200px] overflow-auto space-y-1">
{filteredOptions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">
No options found
</p>
) : (
filteredOptions.map((option) => (
<div
key={option}
className="flex items-center space-x-2 p-2 hover:bg-accent rounded-sm cursor-pointer"
onClick={() => handleToggleOption(option)}
>
<Checkbox
checked={value.codes.includes(option)}
onCheckedChange={() => handleToggleOption(option)}
onClick={(e) => e.stopPropagation()}
/>
<Label
htmlFor={option}
className="text-sm font-normal cursor-pointer flex-1"
>
{option}
</Label>
</div>
))
)}
</div>
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground">
{value.codes.length} selected
</p>
</div>
</div>
</PopoverContent>
</Popover>
{error && (
<p className="text-sm text-red-500 mt-1">{error}</p>
)}
</div>
);
};

View file

@ -0,0 +1,55 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NumberValue } from "@/types/filters";
interface NumberFilterProps {
value: NumberValue;
onChange: (value: NumberValue) => void;
error?: string;
placeholder?: string;
min?: number;
max?: number;
step?: number;
}
export const NumberFilter: React.FC<NumberFilterProps> = ({
value,
onChange,
error,
placeholder = "Enter value",
min,
max,
step = 1,
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (newValue === '') {
onChange({ value: null });
} else {
const num = parseInt(newValue, 10);
if (!isNaN(num)) {
onChange({ value: num });
}
}
};
return (
<div className="space-y-2">
<div className="space-y-2">
<Label htmlFor="number-filter">Value</Label>
<Input
id="number-filter"
type="number"
value={value.value ?? ''}
onChange={handleChange}
placeholder={placeholder}
min={min}
max={max}
step={step}
className={error ? "border-red-500" : ""}
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
};

View file

@ -0,0 +1,97 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NumberRangeValue } from "@/types/filters";
interface NumberRangeFilterProps {
value: NumberRangeValue;
onChange: (value: NumberRangeValue) => void;
error?: string;
unit?: string;
min?: number;
max?: number;
step?: number;
presets?: { label: string; min: number; max: number }[];
}
export const NumberRangeFilter: React.FC<NumberRangeFilterProps> = ({
value,
onChange,
error,
unit,
min = 0,
max = 999999,
step = 1,
presets = [],
}) => {
const handleMinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value === "" ? null : Number(e.target.value);
onChange({ ...value, min: newValue });
};
const handleMaxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value === "" ? null : Number(e.target.value);
onChange({ ...value, max: newValue });
};
const handlePresetClick = (preset: { min: number; max: number }) => {
onChange({ min: preset.min, max: preset.max });
};
return (
<div className="space-y-3">
{presets.length > 0 && (
<div className="flex flex-wrap gap-2">
{presets.map((preset, index) => (
<Button
key={index}
variant="outline"
size="sm"
onClick={() => handlePresetClick(preset)}
>
{preset.label}
</Button>
))}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="min-value">
Min {unit && `(${unit})`}
</Label>
<Input
id="min-value"
type="number"
placeholder={`Min ${unit || 'value'}`}
value={value.min ?? ""}
onChange={handleMinChange}
min={min}
max={max}
step={step}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-value">
Max {unit && `(${unit})`}
</Label>
<Input
id="max-value"
type="number"
placeholder={`Max ${unit || 'value'}`}
value={value.max ?? ""}
onChange={handleMaxChange}
min={min}
max={max}
step={step}
/>
</div>
</div>
{error && (
<p className="text-sm text-red-500 mt-1">{error}</p>
)}
</div>
);
};

View file

@ -0,0 +1,41 @@
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { RadioValue } from "@/types/filters";
interface RadioFilterProps {
value: RadioValue;
onChange: (value: RadioValue) => void;
error?: string;
options: { label: string; value: string }[];
}
export const RadioFilter: React.FC<RadioFilterProps> = ({
value,
onChange,
error,
options,
}) => {
const handleChange = (newValue: string) => {
onChange({ status: newValue });
};
return (
<div className="space-y-3">
<Label>Select Status</Label>
<RadioGroup value={value.status} onValueChange={handleChange}>
{options.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={option.value} />
<Label htmlFor={option.value} className="font-normal cursor-pointer">
{option.label}
</Label>
</div>
))}
</RadioGroup>
{error && (
<p className="text-sm text-red-500 mt-1">{error}</p>
)}
</div>
);
};

View file

@ -0,0 +1,37 @@
import { ChangeEvent, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { MultiSelectValue } from "@/types/filters";
interface TagInputFilterProps {
value: MultiSelectValue;
onChange: (value: MultiSelectValue) => void;
error?: string;
placeholder?: string;
}
export const TagInputFilter: React.FC<TagInputFilterProps> = ({ value, onChange, error, placeholder="Enter tags (comma separated)" }) => {
const [text, setText] = useState(value.codes.join(", "));
const handleBlur = (e: ChangeEvent<HTMLInputElement>) => {
const tags = e.target.value
.split(/[,\n]/)
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
onChange({ codes: Array.from(new Set(tags)) });
};
return (
<div className="space-y-2">
<Label>Tags</Label>
<Input
value={text}
placeholder={placeholder}
onChange={(e) => setText(e.target.value)}
onBlur={handleBlur}
/>
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
</div>
);
};

View file

@ -0,0 +1,32 @@
import { Input } from "@/components/ui/input";
import { TextValue } from "@/types/filters";
interface TextFilterProps {
value: TextValue;
onChange: (value: TextValue) => void;
error?: string;
placeholder?: string;
maxLength?: number;
}
export const TextFilter: React.FC<TextFilterProps> = ({
value,
onChange,
error,
placeholder = "Enter text",
maxLength,
}) => {
return (
<div className="space-y-2">
<Input
type="text"
value={value.value || ""}
onChange={(e) => onChange({ value: e.target.value })}
placeholder={placeholder}
maxLength={maxLength}
className={error ? "border-red-500" : ""}
/>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
);
};

View file

@ -0,0 +1,105 @@
import { Globe, Headset, OctagonX, Play, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { NodeType } from './types';
type AddNodePanelProps = {
isOpen: boolean;
onClose: () => void;
onNodeSelect: (nodeType: NodeType) => void;
};
const NODE_TYPES = [
{
type: NodeType.START_CALL,
label: 'Start Call',
description: 'Create a start call node',
icon: Play
},
{
type: NodeType.AGENT_NODE,
label: 'Agent Node',
description: 'Create an agent node',
icon: Headset
},
{
type: NodeType.END_CALL,
label: 'End Call',
description: 'Create an end call node',
icon: OctagonX
}
];
const GLOBAL_NODE_TYPES = [
{
type: NodeType.GLOBAL_NODE,
label: 'Global Node',
description: 'Create a global node',
icon: Globe
}
]
export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodePanelProps) {
return (
<div
className={`fixed z-51 right-0 top-0 h-full w-80 bg-white shadow-lg transform transition-transform duration-300 ease-in-out ${isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="p-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Add New Node</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
<h1 className="text-sm text-gray-500 mb-2">Agent Nodes</h1>
<div className="space-y-2">
{NODE_TYPES.map((node) => (
<Button
key={node.type}
variant="outline"
className="w-full justify-start p-4 h-auto"
onClick={() => onNodeSelect(node.type)}
>
<div className="flex items-center">
<div className="bg-gray-100 p-2 rounded-lg mr-3 border border-gray-200">
<node.icon className="h-6 w-6" />
</div>
<div className="flex flex-col items-start">
<span className="font-medium">{node.label}</span>
<span className="text-sm text-gray-500">{node.description}</span>
</div>
</div>
</Button>
))}
</div>
<h1 className="text-sm text-gray-500 mb-2">Global Nodes</h1>
<div className="space-y-2">
{GLOBAL_NODE_TYPES.map((node) => (
<Button
variant="outline"
className="w-full justify-start p-4 h-auto"
key={node.type}
onClick={() => onNodeSelect(node.type)}
>
<div className="flex items-center">
<div className="bg-gray-100 p-2 rounded-lg mr-3 border border-gray-200">
<node.icon className="h-6 w-6" />
</div>
<div className="flex flex-col items-start">
<span className="font-medium">{node.label}</span>
<span className="text-sm text-gray-500">{node.description}</span>
</div>
</div>
</Button>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,188 @@
import { BaseEdge, type Edge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath, useReactFlow } from '@xyflow/react';
import { AlertCircle, Pencil } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from '@/components/ui/textarea';
import { cn } from "@/lib/utils";
import { FlowEdge, FlowEdgeData, FlowNode } from '../types';
type CustomEdge = Edge<{ value: number }, 'custom'>;
interface EdgeDetailsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
data?: FlowEdgeData;
onSave: (value: FlowEdgeData) => void;
}
const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDialogProps) => {
const [condition, setCondition] = useState(data?.condition ?? '');
const [label, setLabel] = useState(data?.label ?? '');
const handleSave = () => {
onSave({ condition: condition, label: label });
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Condition</DialogTitle>
{data?.invalid && data.validationMessage && (
<div className="mt-2 flex items-center gap-2 rounded-md bg-red-50 p-2 text-sm text-red-500 border border-red-200">
<AlertCircle className="h-4 w-4" />
<span>{data.validationMessage}</span>
</div>
)}
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Condition Label</Label>
<Label className="text-xs text-gray-500">
Enter a short label which helps identify this pathway in logs
</Label>
<Input
type="text"
value={label}
maxLength={64}
onChange={(e) => setLabel(e.target.value)}
/>
<div className="text-xs text-gray-500">
{label.length}/64 characters
</div>
</div>
<div className="grid gap-2">
<Label>Condition</Label>
<Label className="text-xs text-gray-500">
Describe a condition that will be evaluated to determine if this pathway should be taken
</Label>
<Textarea
value={condition}
onChange={(e) => setCondition(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSave}>Save</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
interface CustomEdgeProps extends EdgeProps {
data: FlowEdgeData;
}
export default function CustomEdge(props: CustomEdgeProps) {
const { id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data } = props;
const { getEdges, setEdges } = useReactFlow<FlowNode, FlowEdge>();
const { saveWorkflow } = useWorkflow();
const parallel = getEdges().filter(
(e) =>
(e.source === source && e.target === target) ||
(e.source === target && e.target === source)
);
// 2) if there are two, sort by id and pick an index
let offsetX = 0;
let offsetY = 0;
if (parallel.length > 1) {
const sorted = parallel.slice().sort((a, b) => a.id.localeCompare(b.id));
const idx = sorted.findIndex((e) => e.id === id);
// first edge (idx 0) moves right & down;
// second edge (idx 1) moves left & up
if (idx === 0) {
offsetX = 100;
offsetY = 0;
} else {
offsetX = 0;
offsetY = -50;
}
}
// 3) draw the straight path + get label coords
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const [open, setOpen] = useState(false);
const handleSaveEdgeData = useCallback(async (updatedData: FlowEdgeData) => {
// Update the node data in the ReactFlow nodes state
setEdges((edges) => {
const updatedEdges = edges.map((edge) =>
edge.id === id
? { ...edge, data: updatedData }
: edge
)
return updatedEdges;
}
);
// Save the workflow after updating edge data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
}, [id, setEdges, saveWorkflow]);
return (
<>
<BaseEdge
id={id}
path={edgePath}
/>
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
pointerEvents: 'all',
transformOrigin: 'center',
transform: `translate(-50%, -50%) translate(${labelX + offsetX}px, ${labelY + offsetY}px)`,
}}
className="nodrag nopan"
>
<div className={cn(
"flex items-center gap-2 bg-white pl-3 pr-1 py-1 rounded-md border shadow-sm",
data?.invalid ? "border-red-500/30 shadow-[0_0_10px_rgba(239,68,68,0.5)]" : "border-gray-200"
)}>
<div className="flex flex-col">
<span className="text-sm">{data?.label || data?.condition || 'Set Condition'}</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0"
onClick={() => setOpen(true)}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
</div>
</EdgeLabelRenderer>
<EdgeDetailsDialog
open={open}
onOpenChange={setOpen}
data={data}
onSave={handleSaveEdgeData}
/>
</>
);
}

View file

@ -0,0 +1,298 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Headset, PlusIcon,Trash2Icon } from "lucide-react";
import { memo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { ExtractionVariable,FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface AgentNodeEditFormProps {
nodeData: FlowNodeData;
prompt: string;
setPrompt: (value: string) => void;
name: string;
setName: (value: string) => void;
allowInterrupt: boolean;
setAllowInterrupt: (value: boolean) => void;
extractionEnabled: boolean;
setExtractionEnabled: (value: boolean) => void;
extractionPrompt: string;
setExtractionPrompt: (value: string) => void;
variables: ExtractionVariable[];
setVariables: (vars: ExtractionVariable[]) => void;
addGlobalPrompt: boolean;
setAddGlobalPrompt: (value: boolean) => void;
}
interface AgentNodeProps extends NodeProps {
data: FlowNodeData;
}
export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
const { saveWorkflow } = useWorkflow();
// Form state
const [prompt, setPrompt] = useState(data.prompt);
const [name, setName] = useState(data.name);
const [allowInterrupt, setAllowInterrupt] = useState(data.allow_interrupt ?? true);
// Variable Extraction state
const [extractionEnabled, setExtractionEnabled] = useState(data.extraction_enabled ?? false);
const [extractionPrompt, setExtractionPrompt] = useState(data.extraction_prompt ?? "");
const [variables, setVariables] = useState<ExtractionVariable[]>(data.extraction_variables ?? []);
const [addGlobalPrompt, setAddGlobalPrompt] = useState(data.add_global_prompt ?? true);
const handleSave = async () => {
handleSaveNodeData({
...data,
prompt,
name,
allow_interrupt: allowInterrupt,
extraction_enabled: extractionEnabled,
extraction_prompt: extractionPrompt,
extraction_variables: variables,
add_global_prompt: addGlobalPrompt,
});
setOpen(false);
// Save the workflow after updating node data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
};
// Reset form state when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setPrompt(data.prompt);
setName(data.name);
setAllowInterrupt(data.allow_interrupt ?? true);
setExtractionEnabled(data.extraction_enabled ?? false);
setExtractionPrompt(data.extraction_prompt ?? "");
setVariables(data.extraction_variables ?? []);
setAddGlobalPrompt(data.add_global_prompt ?? true);
}
setOpen(newOpen);
};
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
title={data.name || 'Agent'}
icon={<Headset />}
bgColor="bg-blue-300"
hasSourceHandle={true}
hasTargetHandle={true}
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
</div>
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
<div className="flex flex-col gap-1">
<Button onClick={() => setOpen(true)} variant="outline" size="icon">
<Edit />
</Button>
<Button onClick={handleDeleteNode} variant="outline" size="icon">
<Trash2Icon />
</Button>
</div>
</NodeToolbar>
<NodeEditDialog
open={open}
onOpenChange={handleOpenChange}
nodeData={data}
title="Edit Agent"
onSave={handleSave}
>
{open && (
<AgentNodeEditForm
nodeData={data}
prompt={prompt}
setPrompt={setPrompt}
name={name}
setName={setName}
allowInterrupt={allowInterrupt}
setAllowInterrupt={setAllowInterrupt}
extractionEnabled={extractionEnabled}
setExtractionEnabled={setExtractionEnabled}
extractionPrompt={extractionPrompt}
setExtractionPrompt={setExtractionPrompt}
variables={variables}
setVariables={setVariables}
addGlobalPrompt={addGlobalPrompt}
setAddGlobalPrompt={setAddGlobalPrompt}
/>
)}
</NodeEditDialog>
</>
);
});
const AgentNodeEditForm = ({
prompt,
setPrompt,
name,
setName,
allowInterrupt,
setAllowInterrupt,
extractionEnabled,
setExtractionEnabled,
extractionPrompt,
setExtractionPrompt,
variables,
setVariables,
addGlobalPrompt,
setAddGlobalPrompt,
}: AgentNodeEditFormProps) => {
const handleVariableNameChange = (idx: number, value: string) => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], name: value };
setVariables(newVars);
};
const handleVariableTypeChange = (idx: number, value: 'string' | 'number' | 'boolean') => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], type: value };
setVariables(newVars);
};
const handleVariablePromptChange = (idx: number, value: string) => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], prompt: value };
setVariables(newVars);
};
const handleRemoveVariable = (idx: number) => {
const newVars = variables.filter((_, i) => i !== idx);
setVariables(newVars);
};
const handleAddVariable = () => {
setVariables([...variables, { name: '', type: 'string', prompt: '' }]);
};
return (
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-gray-500">
The name of the agent that will be used to identify the agent in the call logs. It should be short and should identify the step in the call.
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="flex items-center space-x-2 p-2 border rounded-md bg-muted/20">
<Switch id="allow-interrupt" checked={allowInterrupt} onCheckedChange={setAllowInterrupt} />
<Label htmlFor="allow-interrupt">Allow Interruption</Label>
<Label className="text-xs text-gray-500 ml-2">
Whether you would like user to be able to interrupt the bot.
</Label>
</div>
<div className="flex items-center space-x-2 p-2 border rounded-md bg-muted/20">
<Switch id="add-global-prompt" checked={addGlobalPrompt} onCheckedChange={setAddGlobalPrompt} />
<Label htmlFor="add-global-prompt">Add Global Prompt</Label>
<Label className="text-xs text-gray-500 ml-2">
Whether you want to add global prompt with this node&apos;s prompt.
</Label>
</div>
<div className="pt-2 space-y-2">
<Label>Prompt</Label>
<Label className="text-xs text-gray-500">
Enter the prompt for the agent. This will be used to generate the agent&apos;s response. Prompt engineering&apos;s best practices apply.
</Label>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px] max-h-[300px] resize-none"
style={{
overflowY: 'auto'
}}
/>
</div>
{/* Variable Extraction Section */}
<div className="flex items-center space-x-2 pt-2">
<Switch id="enable-extraction" checked={extractionEnabled} onCheckedChange={setExtractionEnabled} />
<Label htmlFor="enable-extraction">Enable Variable Extraction</Label>
<Label className="text-xs text-gray-500 ml-2">
Are there any variables you would like to extract from the conversation?
</Label>
</div>
{extractionEnabled && (
<div className="border rounded-md p-3 mt-2 space-y-2 bg-muted/20">
<Label>Extraction Prompt</Label>
<Label className="text-xs text-gray-500">
Provide an overall extraction prompt that guides how variables should be extracted from the conversation.
</Label>
<Textarea
value={extractionPrompt}
onChange={(e) => setExtractionPrompt(e.target.value)}
className="min-h-[80px] max-h-[200px] resize-none"
style={{ overflowY: 'auto' }}
/>
<Label>Variables</Label>
<Label className="text-xs text-gray-500">
Define each variable you want to extract along with its data type.
</Label>
{variables.map((v, idx) => (
<div key={idx} className="space-y-2 border rounded-md p-2 bg-background">
<div className="flex items-center gap-2">
<Input
placeholder="Variable name"
value={v.name}
onChange={(e) => handleVariableNameChange(idx, e.target.value)}
/>
<select
className="border rounded-md p-2 text-sm bg-background"
value={v.type}
onChange={(e) => handleVariableTypeChange(idx, e.target.value as 'string' | 'number' | 'boolean')}
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
</select>
<Button variant="outline" size="icon" onClick={() => handleRemoveVariable(idx)}>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
<Textarea
placeholder="Extraction prompt for this variable"
value={v.prompt ?? ''}
onChange={(e) => handleVariablePromptChange(idx, e.target.value)}
className="min-h-[60px] resize-none"
/>
</div>
))}
<Button variant="outline" size="sm" className="w-fit" onClick={handleAddVariable}>
<PlusIcon className="w-4 h-4 mr-1" /> Add Variable
</Button>
</div>
)}
</div>
);
};
AgentNode.displayName = "AgentNode";

View file

@ -0,0 +1,26 @@
import { Handle, HandleProps } from "@xyflow/react";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
export type BaseHandleProps = HandleProps;
export const BaseHandle = forwardRef<HTMLDivElement, BaseHandleProps>(
({ className, children, ...props }, ref) => {
return (
<Handle
ref={ref}
{...props}
className={cn(
"h-[11px] w-[11px] rounded-full border border-slate-300 bg-slate-100 transition dark:border-secondary dark:bg-secondary",
className,
)}
{...props}
>
{children}
</Handle>
);
},
);
BaseHandle.displayName = "BaseHandle";

View file

@ -0,0 +1,26 @@
import { forwardRef, HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export const BaseNode = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement> & {
selected?: boolean;
invalid?: boolean;
}
>(({ className, selected, invalid, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative rounded-md border bg-card p-5 text-card-foreground min-w-[300px] min-h-[100px]",
className,
selected ? "border-muted-foreground shadow-lg" : "",
invalid ? "border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]" : "",
"hover:ring-1",
)}
tabIndex={0}
{...props}
/>
));
BaseNode.displayName = "BaseNode";

View file

@ -0,0 +1,283 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, OctagonX, PlusIcon, Trash2Icon } from "lucide-react";
import { memo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { ExtractionVariable, FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface EndCallEditFormProps {
nodeData: FlowNodeData;
prompt: string;
setPrompt: (value: string) => void;
isStatic: boolean;
setIsStatic: (value: boolean) => void;
name: string;
setName: (value: string) => void;
extractionEnabled: boolean;
setExtractionEnabled: (value: boolean) => void;
extractionPrompt: string;
setExtractionPrompt: (value: string) => void;
variables: ExtractionVariable[];
setVariables: (vars: ExtractionVariable[]) => void;
addGlobalPrompt: boolean;
setAddGlobalPrompt: (value: boolean) => void;
}
interface EndCallNodeProps extends NodeProps {
data: FlowNodeData;
}
export const EndCall = memo(({ data, selected, id }: EndCallNodeProps) => {
const { open, setOpen, handleSaveNodeData } = useNodeHandlers({
id,
additionalData: { is_end: true }
});
const { saveWorkflow } = useWorkflow();
// Form state
const [prompt, setPrompt] = useState(data.prompt);
const [isStatic, setIsStatic] = useState(data.is_static ?? true);
const [name, setName] = useState(data.name);
// Variable Extraction state
const [extractionEnabled, setExtractionEnabled] = useState(data.extraction_enabled ?? false);
const [extractionPrompt, setExtractionPrompt] = useState(data.extraction_prompt ?? "");
const [variables, setVariables] = useState<ExtractionVariable[]>(data.extraction_variables ?? []);
const [addGlobalPrompt, setAddGlobalPrompt] = useState(data.add_global_prompt ?? true);
const handleSave = async () => {
handleSaveNodeData({
...data,
prompt,
is_static: isStatic,
name,
allow_interrupt: false, // Always set to false for end nodes
extraction_enabled: extractionEnabled,
extraction_prompt: extractionPrompt,
extraction_variables: variables,
add_global_prompt: addGlobalPrompt,
});
setOpen(false);
// Save the workflow after updating node data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
};
// Reset form state when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setPrompt(data.prompt);
setIsStatic(data.is_static ?? true);
setName(data.name);
setExtractionEnabled(data.extraction_enabled ?? false);
setExtractionPrompt(data.extraction_prompt ?? "");
setVariables(data.extraction_variables ?? []);
setAddGlobalPrompt(data.add_global_prompt ?? true);
}
setOpen(newOpen);
};
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
title="End Call"
icon={<OctagonX />}
bgColor="bg-red-300"
hasTargetHandle={true}
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
</div>
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
<Button onClick={() => setOpen(true)} variant="outline" size="icon">
<Edit />
</Button>
</NodeToolbar>
<NodeEditDialog
open={open}
onOpenChange={handleOpenChange}
nodeData={data}
title="End Call"
onSave={handleSave}
>
{open && (
<EndCallEditForm
nodeData={data}
prompt={prompt}
setPrompt={setPrompt}
isStatic={isStatic}
setIsStatic={setIsStatic}
name={name}
setName={setName}
extractionEnabled={extractionEnabled}
setExtractionEnabled={setExtractionEnabled}
extractionPrompt={extractionPrompt}
setExtractionPrompt={setExtractionPrompt}
variables={variables}
setVariables={setVariables}
addGlobalPrompt={addGlobalPrompt}
setAddGlobalPrompt={setAddGlobalPrompt}
/>
)}
</NodeEditDialog>
</>
);
});
const EndCallEditForm = ({
prompt,
setPrompt,
isStatic,
setIsStatic,
name,
setName,
extractionEnabled,
setExtractionEnabled,
extractionPrompt,
setExtractionPrompt,
variables,
setVariables,
addGlobalPrompt,
setAddGlobalPrompt,
}: EndCallEditFormProps) => {
const handleVariableNameChange = (idx: number, value: string) => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], name: value };
setVariables(newVars);
};
const handleVariableTypeChange = (idx: number, value: 'string' | 'number' | 'boolean') => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], type: value };
setVariables(newVars);
};
const handleVariablePromptChange = (idx: number, value: string) => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], prompt: value };
setVariables(newVars);
};
const handleRemoveVariable = (idx: number) => {
const newVars = variables.filter((_, i) => i !== idx);
setVariables(newVars);
};
const handleAddVariable = () => {
setVariables([...variables, { name: '', type: 'string', prompt: '' }]);
};
return (
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-gray-500">
The name of the agent that will be used to identify the agent in the call logs. It should be short and should identify the step in the call.
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
<Label>{isStatic ? "Text" : "Prompt"}</Label>
<Label className="text-xs text-gray-500">
What would you like the agent to say when the call ends? Its a good idea to have a static goodbye message.
</Label>
<div className="flex items-center space-x-2">
<Switch id="static-text" checked={isStatic} onCheckedChange={setIsStatic} />
<Label htmlFor="static-text">Static Text</Label>
</div>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px] max-h-[300px] resize-none"
style={{
overflowY: 'auto'
}}
placeholder={isStatic ? "Thank you for calling Dograh. Have a great day!" : "Enter a dynamic prompt"}
/>
<div className="flex items-center space-x-2">
<Switch id="add-global-prompt" checked={addGlobalPrompt} onCheckedChange={setAddGlobalPrompt} />
<Label htmlFor="add-global-prompt">Add Global Prompt</Label>
<Label className="text-xs text-gray-500">
Whether you want to add global prompt with this node&apos;s prompt.
</Label>
</div>
{/* Variable Extraction Section */}
<div className="flex items-center space-x-2 pt-2">
<Switch id="enable-extraction" checked={extractionEnabled} onCheckedChange={setExtractionEnabled} />
<Label htmlFor="enable-extraction">Enable Variable Extraction</Label>
<Label className="text-xs text-gray-500 ml-2">
Are there any variables you would like to extract from the conversation?
</Label>
</div>
{extractionEnabled && (
<div className="border rounded-md p-3 mt-2 space-y-2 bg-muted/20">
<Label>Extraction Prompt</Label>
<Label className="text-xs text-gray-500">
Provide an overall extraction prompt that guides how variables should be extracted from the conversation.
</Label>
<Textarea
value={extractionPrompt}
onChange={(e) => setExtractionPrompt(e.target.value)}
className="min-h-[80px] max-h-[200px] resize-none"
style={{ overflowY: 'auto' }}
/>
<Label>Variables</Label>
<Label className="text-xs text-gray-500">
Define each variable you want to extract along with its data type.
</Label>
{variables.map((v, idx) => (
<div key={idx} className="space-y-2 border rounded-md p-2 bg-background">
<div className="flex items-center gap-2">
<Input
placeholder="Variable name"
value={v.name}
onChange={(e) => handleVariableNameChange(idx, e.target.value)}
/>
<select
className="border rounded-md p-2 text-sm bg-background"
value={v.type}
onChange={(e) => handleVariableTypeChange(idx, e.target.value as 'string' | 'number' | 'boolean')}
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
</select>
<Button variant="outline" size="icon" onClick={() => handleRemoveVariable(idx)}>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
<Textarea
placeholder="Extraction prompt for this variable"
value={v.prompt ?? ''}
onChange={(e) => handleVariablePromptChange(idx, e.target.value)}
className="min-h-[60px] resize-none"
/>
</div>
))}
<Button variant="outline" size="sm" className="w-fit" onClick={handleAddVariable}>
<PlusIcon className="w-4 h-4 mr-1" /> Add Variable
</Button>
</div>
)}
</div>
);
};
EndCall.displayName = "EndCall";

View file

@ -0,0 +1,139 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Headset, Trash2Icon } from "lucide-react";
import { memo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface GlobalNodeEditFormProps {
nodeData: FlowNodeData;
prompt: string;
setPrompt: (value: string) => void;
name: string;
setName: (value: string) => void;
}
interface GlobalNodeProps extends NodeProps {
data: FlowNodeData;
}
export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => {
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
const { saveWorkflow } = useWorkflow();
// Form state
const [prompt, setPrompt] = useState(data.prompt);
const [name, setName] = useState(data.name);
const handleSave = async () => {
handleSaveNodeData({
...data,
prompt,
is_static: false,
name
});
setOpen(false);
// Save the workflow after updating node data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
};
// Reset form state when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setPrompt(data.prompt);
setName(data.name);
}
setOpen(newOpen);
};
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
title={data.name || 'Global'}
icon={<Headset />}
bgColor="bg-orange-300"
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
</div>
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
<div className="flex flex-col gap-1">
<Button onClick={() => setOpen(true)} variant="outline" size="icon">
<Edit />
</Button>
<Button onClick={handleDeleteNode} variant="outline" size="icon">
<Trash2Icon />
</Button>
</div>
</NodeToolbar>
<NodeEditDialog
open={open}
onOpenChange={handleOpenChange}
nodeData={data}
title="Edit Global Node"
onSave={handleSave}
>
{open && (
<GlobalNodeEditForm
nodeData={data}
prompt={prompt}
setPrompt={setPrompt}
name={name}
setName={setName}
/>
)}
</NodeEditDialog>
</>
);
});
const GlobalNodeEditForm = ({
prompt,
setPrompt,
name,
setName
}: GlobalNodeEditFormProps) => {
return (
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-gray-500">
The name of the global node.
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Label>Prompt</Label>
<Label className="text-xs text-gray-500">
This is the global prompt. This will be added to the system prompt of all the agents.
</Label>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px] max-h-[300px] resize-none"
style={{
overflowY: 'auto'
}}
/>
</div>
);
};
GlobalNode.displayName = "GlobalNode";

View file

@ -0,0 +1,193 @@
import { Slot } from "@radix-ui/react-slot";
import { useNodeId, useReactFlow } from "@xyflow/react";
import { EllipsisVertical, Trash } from "lucide-react";
import { forwardRef, HTMLAttributes, ReactNode,useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
/* NODE HEADER -------------------------------------------------------------- */
export type NodeHeaderProps = HTMLAttributes<HTMLElement>;
/**
* A container for a consistent header layout intended to be used inside the
* `<BaseNode />` component.
*/
export const NodeHeader = forwardRef<HTMLElement, NodeHeaderProps>(
({ className, ...props }, ref) => {
return (
<header
ref={ref}
{...props}
className={cn(
"flex items-center justify-between gap-2 px-3 py-2",
// Remove or modify these classes if you modify the padding in the
// `<BaseNode />` component.
className,
)}
/>
);
},
);
NodeHeader.displayName = "NodeHeader";
/* NODE HEADER TITLE -------------------------------------------------------- */
export type NodeHeaderTitleProps = HTMLAttributes<HTMLHeadingElement> & {
asChild?: boolean;
};
/**
* The title text for the node. To maintain a native application feel, the title
* text is not selectable.
*/
export const NodeHeaderTitle = forwardRef<
HTMLHeadingElement,
NodeHeaderTitleProps
>(({ className, asChild, ...props }, ref) => {
const Comp = asChild ? Slot : "h3";
return (
<Comp
ref={ref}
{...props}
className={cn(className, "user-select-none flex-1 font-semibold")}
/>
);
});
NodeHeaderTitle.displayName = "NodeHeaderTitle";
/* NODE HEADER ICON --------------------------------------------------------- */
export type NodeHeaderIconProps = HTMLAttributes<HTMLSpanElement>;
export const NodeHeaderIcon = forwardRef<HTMLSpanElement, NodeHeaderIconProps>(
({ className, ...props }, ref) => {
return (
<span ref={ref} {...props} className={cn(className, "[&>*]:size-5")} />
);
},
);
NodeHeaderIcon.displayName = "NodeHeaderIcon";
/* NODE HEADER ACTIONS ------------------------------------------------------ */
export type NodeHeaderActionsProps = HTMLAttributes<HTMLDivElement>;
/**
* A container for right-aligned action buttons in the node header.
*/
export const NodeHeaderActions = forwardRef<
HTMLDivElement,
NodeHeaderActionsProps
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
{...props}
className={cn(
"ml-auto flex items-center gap-1 justify-self-end",
className,
)}
/>
);
});
NodeHeaderActions.displayName = "NodeHeaderActions";
/* NODE HEADER ACTION ------------------------------------------------------- */
export type NodeHeaderActionProps = React.ComponentProps<"button"> & {
label: string;
};
/**
* A thin wrapper around the `<Button />` component with a fixed sized suitable
* for icons.
*
* Because the `<NodeHeaderAction />` component is intended to render icons, it's
* important to provide a meaningful and accessible `label` prop that describes
* the action.
*/
export const NodeHeaderAction = forwardRef<
HTMLButtonElement,
NodeHeaderActionProps
>(({ className, label, title, ...props }, ref) => {
return (
<Button
ref={ref}
variant="ghost"
aria-label={label}
title={title ?? label}
className={cn(className, "nodrag size-6 p-1")}
{...props}
/>
);
});
NodeHeaderAction.displayName = "NodeHeaderAction";
//
export type NodeHeaderMenuActionProps = Omit<
NodeHeaderActionProps,
"onClick"
> & {
trigger?: ReactNode;
};
/**
* Renders a header action that opens a dropdown menu when clicked. The dropdown
* trigger is a button with an ellipsis icon. The trigger's content can be changed
* by using the `trigger` prop.
*
* Any children passed to the `<NodeHeaderMenuAction />` component will be rendered
* inside the dropdown menu. You can read the docs for the shadcn dropdown menu
* here: https://ui.shadcn.com/docs/components/dropdown-menu
*
*/
export const NodeHeaderMenuAction = forwardRef<
HTMLButtonElement,
NodeHeaderMenuActionProps
>(({ trigger, children, ...props }, ref) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<NodeHeaderAction ref={ref} {...props}>
{trigger ?? <EllipsisVertical />}
</NodeHeaderAction>
</DropdownMenuTrigger>
<DropdownMenuContent>{children}</DropdownMenuContent>
</DropdownMenu>
);
});
NodeHeaderMenuAction.displayName = "NodeHeaderMenuAction";
/* NODE HEADER DELETE ACTION --------------------------------------- */
export const NodeHeaderDeleteAction = () => {
const id = useNodeId();
const { setNodes } = useReactFlow();
const handleClick = useCallback(() => {
setNodes((prevNodes) => prevNodes.filter((node) => node.id !== id));
}, [id, setNodes]);
return (
<NodeHeaderAction onClick={handleClick} label="Delete node">
<Trash />
</NodeHeaderAction>
);
};
NodeHeaderDeleteAction.displayName = "NodeHeaderDeleteAction";

View file

@ -0,0 +1,292 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Play } from "lucide-react";
import { memo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface StartCallEditFormProps {
nodeData: FlowNodeData;
prompt: string;
setPrompt: (value: string) => void;
isStatic: boolean;
setIsStatic: (value: boolean) => void;
name: string;
setName: (value: string) => void;
allowInterrupt: boolean;
setAllowInterrupt: (value: boolean) => void;
addGlobalPrompt: boolean;
setAddGlobalPrompt: (value: boolean) => void;
waitForUserResponse: boolean;
setWaitForUserResponse: (value: boolean) => void;
detectVoicemail: boolean;
setDetectVoicemail: (value: boolean) => void;
delayedStart: boolean;
setDelayedStart: (value: boolean) => void;
delayedStartDuration: number;
setDelayedStartDuration: (value: number) => void;
}
interface StartCallNodeProps extends NodeProps {
data: FlowNodeData;
}
export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
const { open, setOpen, handleSaveNodeData } = useNodeHandlers({
id,
additionalData: { is_start: true }
});
const { saveWorkflow } = useWorkflow();
// Form state
const [prompt, setPrompt] = useState(data.prompt ?? "");
const [isStatic, setIsStatic] = useState(data.is_static ?? true);
const [name, setName] = useState(data.name);
const [allowInterrupt, setAllowInterrupt] = useState(data.allow_interrupt ?? true);
const [addGlobalPrompt, setAddGlobalPrompt] = useState(data.add_global_prompt ?? true);
const [waitForUserResponse, setWaitForUserResponse] = useState(data.wait_for_user_response ?? false);
const [detectVoicemail, setDetectVoicemail] = useState(data.detect_voicemail ?? true);
const [delayedStart, setDelayedStart] = useState(data.delayed_start ?? false);
const [delayedStartDuration, setDelayedStartDuration] = useState(data.delayed_start_duration ?? 2);
const handleSave = async () => {
handleSaveNodeData({
...data,
prompt,
is_static: isStatic,
name,
allow_interrupt: allowInterrupt,
add_global_prompt: addGlobalPrompt,
wait_for_user_response: waitForUserResponse,
detect_voicemail: detectVoicemail,
delayed_start: delayedStart,
delayed_start_duration: delayedStart ? delayedStartDuration : undefined
});
setOpen(false);
// Save the workflow after updating node data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
};
// Reset form state when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setPrompt(data.prompt ?? "");
setIsStatic(data.is_static ?? true);
setName(data.name);
setAllowInterrupt(data.allow_interrupt ?? true);
setAddGlobalPrompt(data.add_global_prompt ?? true);
setWaitForUserResponse(data.wait_for_user_response ?? false);
setDetectVoicemail(data.detect_voicemail ?? true);
setDelayedStart(data.delayed_start ?? false);
setDelayedStartDuration(data.delayed_start_duration ?? 3);
}
setOpen(newOpen);
};
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
title="Start Call"
icon={<Play />}
bgColor="bg-green-300"
hasSourceHandle={true}
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
</div>
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
<Button onClick={() => setOpen(true)} variant="outline" size="icon">
<Edit />
</Button>
</NodeToolbar>
<NodeEditDialog
open={open}
onOpenChange={handleOpenChange}
nodeData={data}
title="Start Call"
onSave={handleSave}
>
{open && (
<StartCallEditForm
nodeData={data}
prompt={prompt}
setPrompt={setPrompt}
isStatic={isStatic}
setIsStatic={setIsStatic}
name={name}
setName={setName}
allowInterrupt={allowInterrupt}
setAllowInterrupt={setAllowInterrupt}
addGlobalPrompt={addGlobalPrompt}
setAddGlobalPrompt={setAddGlobalPrompt}
waitForUserResponse={waitForUserResponse}
setWaitForUserResponse={setWaitForUserResponse}
detectVoicemail={detectVoicemail}
setDetectVoicemail={setDetectVoicemail}
delayedStart={delayedStart}
setDelayedStart={setDelayedStart}
delayedStartDuration={delayedStartDuration}
setDelayedStartDuration={setDelayedStartDuration}
/>
)}
</NodeEditDialog>
</>
);
});
const StartCallEditForm = ({
prompt,
setPrompt,
isStatic,
setIsStatic,
name,
setName,
allowInterrupt,
setAllowInterrupt,
addGlobalPrompt,
setAddGlobalPrompt,
waitForUserResponse,
setWaitForUserResponse,
detectVoicemail,
setDetectVoicemail,
delayedStart,
setDelayedStart,
delayedStartDuration,
setDelayedStartDuration
}: StartCallEditFormProps) => {
return (
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-gray-500">
The name of the agent that will be used to identify the agent in the call logs. It should be short and should identify the step in the call.
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Label>{isStatic ? "Text" : "Prompt"}</Label>
<Label className="text-xs text-gray-500">
What would you like the agent to say when the call starts? Its a good idea to have a static greeting that can be used to identify the call.
</Label>
<div className="flex items-center space-x-2">
<Switch id="static-text" checked={isStatic} onCheckedChange={setIsStatic} />
<Label htmlFor="static-text">Static Text</Label>
</div>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px] max-h-[300px] resize-none"
style={{
overflowY: 'auto'
}}
placeholder={isStatic ? "Hello, welcome to Dograh. How can I help you today?" : "Enter a dynamic prompt"}
/>
<div className="flex items-center space-x-2">
<Switch id="allow-interrupt" checked={allowInterrupt} onCheckedChange={setAllowInterrupt} />
<Label htmlFor="allow-interrupt">Allow Interruption</Label>
<Label className="text-xs text-gray-500">
Whether you would like user to be able to interrupt the bot.
</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="add-global-prompt"
checked={addGlobalPrompt}
onCheckedChange={setAddGlobalPrompt}
disabled={isStatic}
/>
<Label htmlFor="add-global-prompt" className={isStatic ? "opacity-50" : ""}>
Add Global Prompt
</Label>
<Label className={`text-xs text-gray-500 ${isStatic ? "opacity-50" : ""}`}>
{isStatic
? "Not applicable for static text"
: "Whether you want to add global prompt with this node's prompt."}
</Label>
</div>
<div className="flex flex-col space-y-2">
<div className="flex items-center space-x-2">
<Switch
id="wait-for-user-response"
checked={waitForUserResponse}
onCheckedChange={setWaitForUserResponse}
disabled={!isStatic}
/>
<Label htmlFor="wait-for-user-response" className={!isStatic ? "opacity-50" : ""}>
Wait for user&apos;s response
</Label>
<Label className={`text-xs text-gray-500 ${!isStatic ? "opacity-50" : ""}`}>
{!isStatic
? "Only applicable for static text"
: "Wait for user to respond before disconnecting the call."}
</Label>
</div>
</div>
<div className="flex items-center space-x-2">
<Switch
id="detect-voicemail"
checked={detectVoicemail}
onCheckedChange={setDetectVoicemail}
/>
<Label htmlFor="detect-voicemail">
Detect Voicemail
</Label>
<Label className="text-xs text-gray-500">
Automatically detect and end call if voicemail is reached.
</Label>
</div>
<div className="flex flex-col space-y-2">
<div className="flex items-center space-x-2">
<Switch
id="delayed-start"
checked={delayedStart}
onCheckedChange={setDelayedStart}
/>
<Label htmlFor="delayed-start">
Delayed Start
</Label>
<Label className="text-xs text-gray-500">
Introduce a delay before the agent starts speaking.
</Label>
</div>
{delayedStart && (
<div className="ml-6 flex items-center space-x-2">
<Label htmlFor="delay-duration" className="text-sm">
Delay (seconds):
</Label>
<Input
id="delay-duration"
type="number"
step="0.1"
min="0.1"
max="10"
value={delayedStartDuration}
onChange={(e) => setDelayedStartDuration(parseFloat(e.target.value) || 3)}
className="w-20"
/>
</div>
)}
</div>
</div>
);
};
StartCall.displayName = "StartCall";

View file

@ -0,0 +1,44 @@
import { Position } from "@xyflow/react";
import { ReactNode } from "react";
import { BaseHandle } from "@/components/flow/nodes/BaseHandle";
import { BaseNode } from "@/components/flow/nodes/BaseNode";
import { NodeHeader, NodeHeaderIcon, NodeHeaderTitle } from "@/components/flow/nodes/NodeHeader";
interface NodeContentProps {
selected: boolean;
invalid?: boolean;
title: string;
icon: ReactNode;
bgColor: string;
hasSourceHandle?: boolean;
hasTargetHandle?: boolean;
children?: ReactNode;
className?: string;
}
export const NodeContent = ({
selected,
invalid,
title,
icon,
bgColor,
hasSourceHandle = false,
hasTargetHandle = false,
children,
className = "",
}: NodeContentProps) => {
return (
<BaseNode selected={selected} invalid={invalid} className={`p-0 overflow-hidden ${className}`}>
{hasTargetHandle && <BaseHandle type="target" position={Position.Top} />}
<NodeHeader className={`px-3 py-2 border-b ${bgColor}`}>
<NodeHeaderIcon>{icon}</NodeHeaderIcon>
<NodeHeaderTitle>{title}</NodeHeaderTitle>
</NodeHeader>
<div className="p-3">
{children}
</div>
{hasSourceHandle && <BaseHandle type="source" position={Position.Bottom} />}
</BaseNode>
);
};

View file

@ -0,0 +1,63 @@
import { AlertCircle } from "lucide-react";
import { ReactNode } from "react";
import { FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
interface NodeEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
nodeData: FlowNodeData;
title: string;
children: ReactNode;
onSave?: () => void;
}
export const NodeEditDialog = ({
open,
onOpenChange,
nodeData,
title,
children,
onSave
}: NodeEditDialogProps) => {
const handleClose = () => onOpenChange(false);
const handleSave = () => {
if (onSave) {
onSave();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[85vh] overflow-y-auto"
style={{ maxWidth: "1200px", width: "95vw" }}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
Configure the settings for this node in your workflow.
</DialogDescription>
{nodeData.invalid && nodeData.validationMessage && (
<div className="mt-2 flex items-center gap-2 rounded-md bg-red-50 p-2 text-sm text-red-500 border border-red-200">
<AlertCircle className="h-4 w-4" />
<span>{nodeData.validationMessage}</span>
</div>
)}
</DialogHeader>
<div className="grid gap-4 py-4">
{children}
</div>
<DialogFooter>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleClose}>Cancel</Button>
<Button onClick={handleSave}>Save</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,39 @@
import { useReactFlow } from "@xyflow/react";
import { useCallback, useState } from "react";
import { FlowEdge, FlowNode, FlowNodeData } from "@/components/flow/types";
interface UseNodeHandlersProps {
id: string;
additionalData?: Record<string, string | boolean>;
}
export const useNodeHandlers = ({ id, additionalData = {} }: UseNodeHandlersProps) => {
const [open, setOpen] = useState(false);
const { setNodes } = useReactFlow<FlowNode, FlowEdge>();
const handleSaveNodeData = useCallback(
(updatedData: FlowNodeData) => {
setNodes((nodes) => {
const updatedNodes = nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, ...updatedData, ...additionalData } }
: node
);
return updatedNodes;
});
},
[id, setNodes, additionalData]
);
const handleDeleteNode = useCallback(() => {
setNodes((nodes) => nodes.filter((node) => node.id !== id));
}, [id, setNodes]);
return {
open,
setOpen,
handleSaveNodeData,
handleDeleteNode,
};
};

View file

@ -0,0 +1,4 @@
export * from './AgentNode';
export * from './EndCall';
export * from './GlobalNode';
export * from './StartCall';

View file

@ -0,0 +1,86 @@
export enum NodeType {
START_CALL = 'startCall',
AGENT_NODE = 'agentNode',
END_CALL = 'endCall',
GLOBAL_NODE = 'globalNode'
}
export type FlowNodeData = {
prompt: string;
name: string;
is_start?: boolean;
is_static?: boolean;
is_end?: boolean;
invalid?: boolean;
validationMessage?: string | null;
allow_interrupt?: boolean;
extraction_enabled?: boolean;
extraction_prompt?: string;
extraction_variables?: ExtractionVariable[];
add_global_prompt?: boolean;
wait_for_user_response?: boolean;
wait_for_user_response_timeout?: number;
wait_for_user_greeting?: boolean;
detect_voicemail?: boolean;
delayed_start?: boolean;
delayed_start_duration?: number;
}
export type FlowNode = {
id: string;
type: string;
position: { x: number; y: number };
data: FlowNodeData;
measured?: {
width: number;
height: number;
};
selected?: boolean;
dragging?: boolean;
};
export type FlowEdgeData = {
condition: string;
label: string;
invalid?: boolean;
validationMessage?: string | null;
}
export type FlowEdge = {
id: string;
source: string;
target: string;
type?: string;
data: FlowEdgeData;
animated?: boolean;
invalid?: boolean;
};
export interface WorkflowDefinition {
nodes: FlowNode[];
edges: FlowEdge[];
viewport: {
x: number;
y: number;
zoom: number;
};
}
export interface WorkflowData {
name: string;
workflow_definition: WorkflowDefinition;
}
export type WorkflowValidationError = {
kind: 'node' | 'edge' | 'workflow';
id: string;
field: string;
message: string;
}
export type ExtractionVariable = {
name: string;
type: 'string' | 'number' | 'boolean';
prompt?: string;
};

Some files were not shown because too many files have changed in this diff Show more