mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
Initial Commit 🚀 🚀
This commit is contained in:
commit
4f2a629340
444 changed files with 76863 additions and 0 deletions
20
ui/src/app/after-sign-in/page.tsx
Normal file
20
ui/src/app/after-sign-in/page.tsx
Normal 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');
|
||||
}
|
||||
14
ui/src/app/api-keys/layout.tsx
Normal file
14
ui/src/app/api-keys/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader";
|
||||
|
||||
export default function APIKeysLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
677
ui/src/app/api-keys/page.tsx
Normal file
677
ui/src/app/api-keys/page.tsx
Normal 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'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'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'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>
|
||||
);
|
||||
}
|
||||
60
ui/src/app/api/auth/oss/route.ts
Normal file
60
ui/src/app/api/auth/oss/route.ts
Normal 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!),
|
||||
});
|
||||
}
|
||||
14
ui/src/app/automation/layout.tsx
Normal file
14
ui/src/app/automation/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function CampaignsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
35
ui/src/app/automation/page.tsx
Normal file
35
ui/src/app/automation/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
237
ui/src/app/campaigns/GoogleSheetSelector.tsx
Normal file
237
ui/src/app/campaigns/GoogleSheetSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
463
ui/src/app/campaigns/[campaignId]/page.tsx
Normal file
463
ui/src/app/campaigns/[campaignId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
ui/src/app/campaigns/layout.tsx
Normal file
14
ui/src/app/campaigns/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function CampaignsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
227
ui/src/app/campaigns/new/page.tsx
Normal file
227
ui/src/app/campaigns/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
ui/src/app/campaigns/page.tsx
Normal file
186
ui/src/app/campaigns/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
ui/src/app/create-workflow/layout.tsx
Normal file
14
ui/src/app/create-workflow/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function CreateWorkflowLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
170
ui/src/app/create-workflow/page.tsx
Normal file
170
ui/src/app/create-workflow/page.tsx
Normal 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'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
BIN
ui/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
23
ui/src/app/global-error.tsx
Normal file
23
ui/src/app/global-error.tsx
Normal 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
125
ui/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
14
ui/src/app/handler/[...stack]/layout.tsx
Normal file
14
ui/src/app/handler/[...stack]/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function StackLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
22
ui/src/app/handler/[...stack]/page.tsx
Normal file
22
ui/src/app/handler/[...stack]/page.tsx
Normal 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}
|
||||
/>;
|
||||
}
|
||||
44
ui/src/app/impersonate/route.ts
Normal file
44
ui/src/app/impersonate/route.ts
Normal 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;
|
||||
}
|
||||
69
ui/src/app/integrations/CreateIntegrationButton.tsx
Normal file
69
ui/src/app/integrations/CreateIntegrationButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
ui/src/app/integrations/layout.tsx
Normal file
14
ui/src/app/integrations/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function IntegrationsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
179
ui/src/app/integrations/page.tsx
Normal file
179
ui/src/app/integrations/page.tsx
Normal 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
51
ui/src/app/layout.tsx
Normal 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
5
ui/src/app/loading.tsx
Normal 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 <></>;
|
||||
}
|
||||
20
ui/src/app/looptalk/LoopTalkLayout.tsx
Normal file
20
ui/src/app/looptalk/LoopTalkLayout.tsx
Normal 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
|
||||
126
ui/src/app/looptalk/[id]/page.tsx
Normal file
126
ui/src/app/looptalk/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
ui/src/app/looptalk/page.tsx
Normal file
77
ui/src/app/looptalk/page.tsx
Normal 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
57
ui/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
ui/src/app/reports/components/DispositionChart.tsx
Normal file
96
ui/src/app/reports/components/DispositionChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
ui/src/app/reports/components/DurationChart.tsx
Normal file
94
ui/src/app/reports/components/DurationChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
ui/src/app/reports/components/MetricsCards.tsx
Normal file
42
ui/src/app/reports/components/MetricsCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
ui/src/app/reports/layout.tsx
Normal file
14
ui/src/app/reports/layout.tsx
Normal 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
323
ui/src/app/reports/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
ui/src/app/service-configurations/layout.tsx
Normal file
14
ui/src/app/service-configurations/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function ServiceConfigurationLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
13
ui/src/app/service-configurations/page.tsx
Normal file
13
ui/src/app/service-configurations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
ui/src/app/superadmin/layout.tsx
Normal file
14
ui/src/app/superadmin/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function SuperAdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
134
ui/src/app/superadmin/page.tsx
Normal file
134
ui/src/app/superadmin/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
677
ui/src/app/superadmin/runs/page.tsx
Normal file
677
ui/src/app/superadmin/runs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
ui/src/app/usage/layout.tsx
Normal file
14
ui/src/app/usage/layout.tsx
Normal 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
590
ui/src/app/usage/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
21
ui/src/app/workflow/WorkflowLayout.tsx
Normal file
21
ui/src/app/workflow/WorkflowLayout.tsx
Normal 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
|
||||
149
ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx
Normal file
149
ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
178
ui/src/app/workflow/[workflowId]/components/WorkflowControls.tsx
Normal file
178
ui/src/app/workflow/[workflowId]/components/WorkflowControls.tsx
Normal 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;
|
||||
321
ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx
Normal file
321
ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx
Normal 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;
|
||||
2
ui/src/app/workflow/[workflowId]/components/index.ts
Normal file
2
ui/src/app/workflow/[workflowId]/components/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './WorkflowControls';
|
||||
export * from './WorkflowHeader';
|
||||
|
|
@ -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;
|
||||
};
|
||||
1
ui/src/app/workflow/[workflowId]/hooks/index.ts
Normal file
1
ui/src/app/workflow/[workflowId]/hooks/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './useWorkflowState';
|
||||
446
ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts
Normal file
446
ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts
Normal 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
|
||||
};
|
||||
};
|
||||
91
ui/src/app/workflow/[workflowId]/page.tsx
Normal file
91
ui/src/app/workflow/[workflowId]/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
114
ui/src/app/workflow/[workflowId]/run/[runId]/Pipecat.tsx
Normal file
114
ui/src/app/workflow/[workflowId]/run/[runId]/Pipecat.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export * from './ApiKeyErrorDialog';
|
||||
export * from './AudioControls';
|
||||
export * from './ConnectionStatus';
|
||||
export * from './ContextDisplay';
|
||||
export * from './ContextVariablesSection';
|
||||
export * from './WorkflowConfigErrorDialog'
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './useDeviceInputs';
|
||||
export * from './useWebRTC';
|
||||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
275
ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebRTC.tsx
Normal file
275
ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebRTC.tsx
Normal 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
|
||||
};
|
||||
};
|
||||
217
ui/src/app/workflow/[workflowId]/run/[runId]/page.tsx
Normal file
217
ui/src/app/workflow/[workflowId]/run/[runId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './webrtcUtils';
|
||||
|
|
@ -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;
|
||||
};
|
||||
359
ui/src/app/workflow/[workflowId]/runs/page.tsx
Normal file
359
ui/src/app/workflow/[workflowId]/runs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
ui/src/app/workflow/page.tsx
Normal file
215
ui/src/app/workflow/page.tsx
Normal 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>
|
||||
|
||||
);
|
||||
}
|
||||
20
ui/src/client/client.gen.ts
Normal file
20
ui/src/client/client.gen.ts
Normal 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
3
ui/src/client/index.ts
Normal 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
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
2621
ui/src/client/types.gen.ts
Normal file
File diff suppressed because it is too large
Load diff
115
ui/src/components/DailyUsageTable.tsx
Normal file
115
ui/src/components/DailyUsageTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
ui/src/components/MediaPreviewDialog.tsx
Normal file
158
ui/src/components/MediaPreviewDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
ui/src/components/PostHogIdentify.tsx
Normal file
46
ui/src/components/PostHogIdentify.tsx
Normal 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;
|
||||
}
|
||||
340
ui/src/components/ServiceConfiguration.tsx
Normal file
340
ui/src/components/ServiceConfiguration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
ui/src/components/SignInClient.tsx
Normal file
27
ui/src/components/SignInClient.tsx
Normal 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 />;
|
||||
}
|
||||
9
ui/src/components/SpinLoader.tsx
Normal file
9
ui/src/components/SpinLoader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
ui/src/components/ThemeSwitcher.tsx
Normal file
29
ui/src/components/ThemeSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
ui/src/components/filters/DateRangeFilter.tsx
Normal file
169
ui/src/components/filters/DateRangeFilter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
455
ui/src/components/filters/FilterBuilder.tsx
Normal file
455
ui/src/components/filters/FilterBuilder.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
154
ui/src/components/filters/MultiSelectFilter.tsx
Normal file
154
ui/src/components/filters/MultiSelectFilter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
55
ui/src/components/filters/NumberFilter.tsx
Normal file
55
ui/src/components/filters/NumberFilter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
97
ui/src/components/filters/NumberRangeFilter.tsx
Normal file
97
ui/src/components/filters/NumberRangeFilter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
41
ui/src/components/filters/RadioFilter.tsx
Normal file
41
ui/src/components/filters/RadioFilter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
ui/src/components/filters/TagInputFilter.tsx
Normal file
37
ui/src/components/filters/TagInputFilter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
ui/src/components/filters/TextFilter.tsx
Normal file
32
ui/src/components/filters/TextFilter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
105
ui/src/components/flow/AddNodePanel.tsx
Normal file
105
ui/src/components/flow/AddNodePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
ui/src/components/flow/edges/CustomEdge.tsx
Normal file
188
ui/src/components/flow/edges/CustomEdge.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
298
ui/src/components/flow/nodes/AgentNode.tsx
Normal file
298
ui/src/components/flow/nodes/AgentNode.tsx
Normal 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'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's response. Prompt engineering'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";
|
||||
|
||||
26
ui/src/components/flow/nodes/BaseHandle.tsx
Normal file
26
ui/src/components/flow/nodes/BaseHandle.tsx
Normal 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";
|
||||
26
ui/src/components/flow/nodes/BaseNode.tsx
Normal file
26
ui/src/components/flow/nodes/BaseNode.tsx
Normal 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";
|
||||
283
ui/src/components/flow/nodes/EndCall.tsx
Normal file
283
ui/src/components/flow/nodes/EndCall.tsx
Normal 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'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";
|
||||
139
ui/src/components/flow/nodes/GlobalNode.tsx
Normal file
139
ui/src/components/flow/nodes/GlobalNode.tsx
Normal 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";
|
||||
|
||||
193
ui/src/components/flow/nodes/NodeHeader.tsx
Normal file
193
ui/src/components/flow/nodes/NodeHeader.tsx
Normal 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";
|
||||
292
ui/src/components/flow/nodes/StartCall.tsx
Normal file
292
ui/src/components/flow/nodes/StartCall.tsx
Normal 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'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";
|
||||
44
ui/src/components/flow/nodes/common/NodeContent.tsx
Normal file
44
ui/src/components/flow/nodes/common/NodeContent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
63
ui/src/components/flow/nodes/common/NodeEditDialog.tsx
Normal file
63
ui/src/components/flow/nodes/common/NodeEditDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
ui/src/components/flow/nodes/common/useNodeHandlers.ts
Normal file
39
ui/src/components/flow/nodes/common/useNodeHandlers.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
4
ui/src/components/flow/nodes/index.ts
Normal file
4
ui/src/components/flow/nodes/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './AgentNode';
|
||||
export * from './EndCall';
|
||||
export * from './GlobalNode';
|
||||
export * from './StartCall';
|
||||
86
ui/src/components/flow/types.ts
Normal file
86
ui/src/components/flow/types.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue