mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
Merge pull request #3 from dograh-hq/improve-oss-onboarding
Improve we call experience and add tooltips
This commit is contained in:
commit
7501a3fb5a
15 changed files with 535 additions and 242 deletions
|
|
@ -4,6 +4,7 @@ WORKDIR /app
|
|||
|
||||
ENV NEXT_PUBLIC_NODE_ENV=local
|
||||
ENV NEXT_PUBLIC_AUTH_PROVIDER=local
|
||||
ENV NEXT_PUBLIC_DEPLOYMENT_MODE=oss
|
||||
ENV NEXT_PUBLIC_BACKEND_URL="http://localhost:8000"
|
||||
ENV BACKEND_URL="http://api:8000"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function CreateWorkflowLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -79,7 +79,7 @@ export default function CreateWorkflowPage() {
|
|||
};
|
||||
|
||||
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">
|
||||
<div className="min-h-[100vh] 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">
|
||||
|
|
@ -115,11 +115,8 @@ export default function CreateWorkflowPage() {
|
|||
</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>
|
||||
<span className="text-base font-medium text-gray-700 dark:text-gray-300">For the use case of</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"
|
||||
|
|
@ -130,14 +127,11 @@ export default function CreateWorkflowPage() {
|
|||
</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>
|
||||
<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)"
|
||||
placeholder="Describe briefly what your voice agent will do (e.g., Qualify leads for real estate, Screen candidates for roles, Handle customer support). This will be a prompt to an LLM."
|
||||
value={activityDescription}
|
||||
onChange={(e) => setActivityDescription(e.target.value)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Suspense } from "react";
|
|||
import PostHogIdentify from "@/components/PostHogIdentify";
|
||||
import SpinLoader from "@/components/SpinLoader";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { OnboardingProvider } from "@/context/OnboardingContext";
|
||||
import { UserConfigProvider } from "@/context/UserConfigContext";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
|
||||
|
|
@ -39,9 +40,11 @@ export default function RootLayout({
|
|||
<AuthProvider>
|
||||
<Suspense fallback={<SpinLoader />}>
|
||||
<UserConfigProvider>
|
||||
<PostHogIdentify />
|
||||
{children}
|
||||
<Toaster />
|
||||
<OnboardingProvider>
|
||||
<PostHogIdentify />
|
||||
{children}
|
||||
<Toaster />
|
||||
</OnboardingProvider>
|
||||
</UserConfigProvider>
|
||||
</Suspense>
|
||||
</AuthProvider>
|
||||
|
|
|
|||
|
|
@ -2,17 +2,20 @@ 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 { useEffect, useRef, 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 { OnboardingTooltip } from '@/components/onboarding/OnboardingTooltip';
|
||||
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 { useOnboarding } from '@/context/OnboardingContext';
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
interface WorkflowHeaderProps {
|
||||
isDirty: boolean;
|
||||
|
|
@ -55,6 +58,7 @@ const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonO
|
|||
|
||||
const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow }: WorkflowHeaderProps) => {
|
||||
const { userConfig, saveUserConfig } = useUserConfig();
|
||||
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
|
@ -65,10 +69,13 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
|
|||
const [phoneChanged, setPhoneChanged] = useState(false);
|
||||
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const webCallButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const hasValidationErrors = workflowValidationErrors.length > 0;
|
||||
const isOSSDeployment = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE === 'oss';
|
||||
|
||||
logger.info(`isOSSDeployment: ${isOSSDeployment}`);
|
||||
|
||||
// Reset call-related state whenever the dialog is closed so that a new call can be placed
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
|
|
@ -188,9 +195,16 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
|
|||
Export Pathway
|
||||
</Button>
|
||||
<Button
|
||||
ref={webCallButtonRef}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRun("smallwebrtc")} // Don't change the mode since its defined in the database enum
|
||||
onClick={() => {
|
||||
// Mark the tooltip as seen when the button is clicked
|
||||
if (!hasSeenTooltip('web_call')) {
|
||||
markTooltipSeen('web_call');
|
||||
}
|
||||
onRun("smallwebrtc"); // Don't change the mode since its defined in the database enum
|
||||
}}
|
||||
disabled={hasValidationErrors}
|
||||
>
|
||||
<Phone className="mr-2 h-4 w-4" />
|
||||
|
|
@ -317,6 +331,16 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
|
|||
{callSuccessMsg && <div className="text-green-600 text-sm mt-2">{callSuccessMsg}</div>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Onboarding Tooltip */}
|
||||
<OnboardingTooltip
|
||||
title='Test your Voice Agent'
|
||||
targetRef={webCallButtonRef}
|
||||
message="Test this workflow now in your browser (no phone required)"
|
||||
onDismiss={() => markTooltipSeen('web_call')}
|
||||
showNext={false}
|
||||
isVisible={!hasSeenTooltip('web_call') && !hasValidationErrors}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
150
ui/src/app/workflow/[workflowId]/run/[runId]/BrowserCall.tsx
Normal file
150
ui/src/app/workflow/[workflowId]/run/[runId]/BrowserCall.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from "@/client/sdk.gen";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
import {
|
||||
ApiKeyErrorDialog,
|
||||
AudioControls,
|
||||
ConnectionStatus,
|
||||
WorkflowConfigErrorDialog
|
||||
} from "./components";
|
||||
import { useWebRTC } from "./hooks";
|
||||
|
||||
const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: {
|
||||
workflowId: number,
|
||||
workflowRunId: number,
|
||||
accessToken: string | null,
|
||||
initialContextVariables?: Record<string, string> | null
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [checkingForRecording, setCheckingForRecording] = useState(false);
|
||||
|
||||
const {
|
||||
audioRef,
|
||||
audioInputs,
|
||||
selectedAudioInput,
|
||||
setSelectedAudioInput,
|
||||
connectionActive,
|
||||
permissionError,
|
||||
isCompleted,
|
||||
apiKeyModalOpen,
|
||||
setApiKeyModalOpen,
|
||||
apiKeyError,
|
||||
workflowConfigError,
|
||||
workflowConfigModalOpen,
|
||||
setWorkflowConfigModalOpen,
|
||||
connectionStatus,
|
||||
start,
|
||||
stop,
|
||||
isStarting
|
||||
} = useWebRTC({ workflowId, workflowRunId, accessToken, initialContextVariables });
|
||||
|
||||
// Poll for recording availability after call ends
|
||||
useEffect(() => {
|
||||
if (!isCompleted || !accessToken) return;
|
||||
|
||||
setCheckingForRecording(true);
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
const response = await getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet({
|
||||
path: {
|
||||
workflow_id: workflowId,
|
||||
run_id: workflowRunId,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.transcript_url || response.data?.recording_url) {
|
||||
setCheckingForRecording(false);
|
||||
clearInterval(intervalId);
|
||||
// Refresh the page to show the recording
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for recording:', error);
|
||||
}
|
||||
}, 5000); // Check every 5 seconds
|
||||
|
||||
// Clean up after 2 minutes
|
||||
const timeoutId = setTimeout(() => {
|
||||
clearInterval(intervalId);
|
||||
setCheckingForRecording(false);
|
||||
}, 120000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isCompleted, accessToken, workflowId, workflowRunId]);
|
||||
|
||||
const navigateToApiKeys = () => {
|
||||
router.push('/api-keys');
|
||||
};
|
||||
|
||||
const navigateToWorkflow = () => {
|
||||
router.push(`/workflow/${workflowId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="w-full max-w-4xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Agent Run</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isCompleted && checkingForRecording ? (
|
||||
<div className="flex flex-col items-center justify-center space-y-4 p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-gray-700 font-medium">Processing your call</p>
|
||||
<p className="text-sm text-gray-500">Fetching transcript and recording...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AudioControls
|
||||
audioInputs={audioInputs}
|
||||
selectedAudioInput={selectedAudioInput}
|
||||
setSelectedAudioInput={setSelectedAudioInput}
|
||||
isCompleted={isCompleted}
|
||||
connectionActive={connectionActive}
|
||||
permissionError={permissionError}
|
||||
start={start}
|
||||
stop={stop}
|
||||
isStarting={isStarting}
|
||||
/>
|
||||
|
||||
<ConnectionStatus
|
||||
connectionStatus={connectionStatus}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<audio ref={audioRef} autoPlay playsInline className="hidden" />
|
||||
</Card>
|
||||
|
||||
<ApiKeyErrorDialog
|
||||
open={apiKeyModalOpen}
|
||||
onOpenChange={setApiKeyModalOpen}
|
||||
error={apiKeyError}
|
||||
onNavigateToApiKeys={navigateToApiKeys}
|
||||
/>
|
||||
|
||||
<WorkflowConfigErrorDialog
|
||||
open={workflowConfigModalOpen}
|
||||
onOpenChange={setWorkflowConfigModalOpen}
|
||||
error={workflowConfigError}
|
||||
onNavigateToWorkflow={navigateToWorkflow}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrowserCall;
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { Mic, MicOff } from "lucide-react";
|
||||
import { Mic, Phone, PhoneOff } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface AudioControlsProps {
|
||||
audioInputs: MediaDeviceInfo[];
|
||||
|
|
@ -28,7 +27,6 @@ export const AudioControls = ({
|
|||
}: 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 {
|
||||
|
|
@ -40,64 +38,73 @@ export const AudioControls = ({
|
|||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Audio Input</h3>
|
||||
// Handle auto-selection of first device if none selected
|
||||
if (hasValidDevices && !selectedAudioInput) {
|
||||
const firstValidDevice = audioInputs.find(device => device.deviceId && device.deviceId.trim() !== '');
|
||||
if (firstValidDevice) {
|
||||
setSelectedAudioInput(firstValidDevice.deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
{!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>
|
||||
)}
|
||||
if (isCompleted) {
|
||||
return null; // The parent component will handle showing the loading state
|
||||
}
|
||||
|
||||
if (!hasValidDevices) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center space-y-4 p-8">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-gray-700 font-medium">Audio permissions required</p>
|
||||
<p className="text-sm text-gray-500">Click below to grant microphone access</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={requestAudioPermissions}
|
||||
size="lg"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Mic className="h-5 w-5 mr-2" />
|
||||
Grant Audio Permissions
|
||||
</Button>
|
||||
</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>
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center space-y-6 p-8">
|
||||
{!connectionActive ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-600">Ready to start your call</p>
|
||||
<button
|
||||
onClick={start}
|
||||
disabled={isStarting}
|
||||
className="group relative h-20 w-20 rounded-full bg-green-600 hover:bg-green-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
aria-label="Start Call"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-full bg-green-600 animate-ping opacity-25"></div>
|
||||
<div className="relative flex items-center justify-center h-full">
|
||||
<Phone className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
</button>
|
||||
<p className="text-sm font-medium text-gray-700">Start Call</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-600">Call in progress</p>
|
||||
<button
|
||||
onClick={stop}
|
||||
className="group relative h-20 w-20 rounded-full bg-red-600 hover:bg-red-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
aria-label="End Call"
|
||||
>
|
||||
<div className="relative flex items-center justify-center h-full">
|
||||
<PhoneOff className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
</button>
|
||||
<p className="text-sm font-medium text-gray-700">End Call</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
{permissionError && (
|
||||
<p className="text-sm text-red-500 text-center">{permissionError}</p>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,22 +1,38 @@
|
|||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
iceGatheringState: string;
|
||||
iceConnectionState: string;
|
||||
connectionStatus: 'idle' | 'connecting' | 'connected' | 'failed';
|
||||
}
|
||||
|
||||
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>
|
||||
export const ConnectionStatus = ({ connectionStatus }: ConnectionStatusProps) => {
|
||||
if (connectionStatus === 'idle') return null;
|
||||
|
||||
if (connectionStatus === 'connecting') {
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-2 text-blue-600">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span className="text-sm font-medium">Establishing Connection...</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
if (connectionStatus === 'connected') {
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-2 text-green-600">
|
||||
<div className="h-2 w-2 bg-green-600 rounded-full animate-pulse" />
|
||||
<span className="text-sm font-medium">Connected</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (connectionStatus === 'failed') {
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-2 text-red-600">
|
||||
<div className="h-2 w-2 bg-red-600 rounded-full" />
|
||||
<span className="text-sm font-medium">Connection Failed</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,5 +2,4 @@ export * from './ApiKeyErrorDialog';
|
|||
export * from './AudioControls';
|
||||
export * from './ConnectionStatus';
|
||||
export * from './ContextDisplay';
|
||||
export * from './ContextVariablesSection';
|
||||
export * from './WorkflowConfigErrorDialog'
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ interface UseWebRTCProps {
|
|||
}
|
||||
|
||||
export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: UseWebRTCProps) => {
|
||||
const [iceGatheringState, setIceGatheringState] = useState('');
|
||||
const [iceConnectionState, setIceConnectionState] = useState('');
|
||||
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected' | 'failed'>('idle');
|
||||
const [connectionActive, setConnectionActive] = useState(false);
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const [apiKeyModalOpen, setApiKeyModalOpen] = useState(false);
|
||||
|
|
@ -25,9 +24,8 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
|
|||
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 || {}
|
||||
);
|
||||
// Use initial context variables directly, no UI for editing
|
||||
const initialContext = initialContextVariables || {};
|
||||
|
||||
const {
|
||||
audioInputs,
|
||||
|
|
@ -55,14 +53,16 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
|
|||
|
||||
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);
|
||||
logger.info(`ICE connection state changed: ${pc.iceConnectionState}`);
|
||||
if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
|
||||
setConnectionStatus('connected');
|
||||
} else if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') {
|
||||
setConnectionStatus('failed');
|
||||
}
|
||||
});
|
||||
setIceConnectionState(pc.iceConnectionState);
|
||||
|
||||
pc.addEventListener('track', (evt) => {
|
||||
if (evt.track.kind === 'audio' && audioRef.current) {
|
||||
|
|
@ -142,6 +142,7 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
|
|||
const start = async () => {
|
||||
if (isStarting || !accessToken) return;
|
||||
setIsStarting(true);
|
||||
setConnectionStatus('connecting');
|
||||
try {
|
||||
const response = await validateUserConfigurationsApiV1UserConfigurationsUserValidateGet({
|
||||
headers: {
|
||||
|
|
@ -212,6 +213,7 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
|
|||
} catch (err) {
|
||||
logger.error(`Could not acquire media: ${err}`);
|
||||
setPermissionError('Could not acquire media');
|
||||
setConnectionStatus('failed');
|
||||
}
|
||||
} else {
|
||||
await negotiate();
|
||||
|
|
@ -224,6 +226,7 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
|
|||
const stop = () => {
|
||||
setConnectionActive(false);
|
||||
setIsCompleted(true);
|
||||
setConnectionStatus('idle');
|
||||
|
||||
const pc = pcRef.current;
|
||||
if (!pc) return;
|
||||
|
|
@ -264,12 +267,10 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
|
|||
workflowConfigError,
|
||||
workflowConfigModalOpen,
|
||||
setWorkflowConfigModalOpen,
|
||||
iceGatheringState,
|
||||
iceConnectionState,
|
||||
connectionStatus,
|
||||
start,
|
||||
stop,
|
||||
isStarting,
|
||||
initialContext,
|
||||
setInitialContext
|
||||
initialContext
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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 BrowserCall from '@/app/workflow/[workflowId]/run/[runId]/BrowserCall';
|
||||
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
|
||||
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from '@/client/sdk.gen';
|
||||
import { MediaPreviewButtons, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
|
||||
|
|
@ -15,8 +15,6 @@ 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;
|
||||
|
|
@ -125,7 +123,7 @@ export default function WorkflowRunPage() {
|
|||
<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>
|
||||
<CardTitle className="text-2xl">Agent 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" />
|
||||
|
|
@ -133,7 +131,7 @@ export default function WorkflowRunPage() {
|
|||
</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>
|
||||
<p className="text-gray-600 mb-8">Your voice agent 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">
|
||||
|
|
@ -171,7 +169,7 @@ export default function WorkflowRunPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* <div className="grid gap-6 md:grid-cols-2">
|
||||
<ContextDisplay
|
||||
title="Initial Context"
|
||||
context={workflowRun?.initial_context}
|
||||
|
|
@ -180,7 +178,7 @@ export default function WorkflowRunPage() {
|
|||
title="Gathered Context"
|
||||
context={workflowRun?.gathered_context}
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -188,7 +186,7 @@ export default function WorkflowRunPage() {
|
|||
else {
|
||||
returnValue =
|
||||
<div className="min-h-screen mt-40">
|
||||
<Pipecat
|
||||
<BrowserCall
|
||||
workflowId={Number(params.workflowId)}
|
||||
workflowRunId={Number(params.runId)}
|
||||
accessToken={accessToken}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { CircleDollarSign, Loader2 } from 'lucide-react';
|
||||
import { CircleDollarSign, Loader2, Star } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
|
@ -126,9 +126,15 @@ export default function BaseHeader({ headerActions, backButton, showFeaturesNav
|
|||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
<div className="text-sm text-gray-600">
|
||||
Github Star Link
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/dograh-hq/dograh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
|
||||
Star us on GitHub
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
139
ui/src/components/onboarding/OnboardingTooltip.tsx
Normal file
139
ui/src/components/onboarding/OnboardingTooltip.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface OnboardingTooltipProps {
|
||||
targetRef: React.RefObject<HTMLElement | HTMLButtonElement | null>;
|
||||
title?: string;
|
||||
message: string;
|
||||
onDismiss: () => void;
|
||||
onNext?: () => void;
|
||||
showNext?: boolean;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export const OnboardingTooltip = ({
|
||||
targetRef,
|
||||
title = "One more thing...",
|
||||
message,
|
||||
onDismiss,
|
||||
onNext,
|
||||
showNext = true,
|
||||
isVisible
|
||||
}: OnboardingTooltipProps) => {
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !targetRef.current) return;
|
||||
|
||||
const calculatePosition = () => {
|
||||
if (!targetRef.current || !tooltipRef.current) return;
|
||||
|
||||
const targetRect = targetRef.current.getBoundingClientRect();
|
||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
||||
|
||||
// Position tooltip below the target element with some offset for the arrow
|
||||
const top = targetRect.bottom + 8; // 8px gap for arrow
|
||||
|
||||
// Center the tooltip horizontally relative to the target
|
||||
let left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2);
|
||||
|
||||
// Ensure tooltip doesn't go off-screen
|
||||
const padding = 16;
|
||||
if (left < padding) {
|
||||
left = padding;
|
||||
} else if (left + tooltipRect.width > window.innerWidth - padding) {
|
||||
left = window.innerWidth - tooltipRect.width - padding;
|
||||
}
|
||||
|
||||
setPosition({ top, left });
|
||||
};
|
||||
|
||||
// Small delay to ensure tooltip is rendered before calculating position
|
||||
const timer = setTimeout(() => {
|
||||
calculatePosition();
|
||||
}, 10);
|
||||
|
||||
// Recalculate on window resize
|
||||
window.addEventListener('resize', calculatePosition);
|
||||
window.addEventListener('scroll', calculatePosition);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('resize', calculatePosition);
|
||||
window.removeEventListener('scroll', calculatePosition);
|
||||
};
|
||||
}, [isVisible, targetRef]);
|
||||
|
||||
if (!mounted || !isVisible) return null;
|
||||
|
||||
const tooltipContent = (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="fixed z-[100] animate-in fade-in slide-in-from-top-2 duration-300"
|
||||
style={{ top: `${position.top}px`, left: `${position.left}px` }}
|
||||
>
|
||||
{/* Arrow pointing up */}
|
||||
<div
|
||||
className="absolute -top-2 left-1/2 -translate-x-1/2 w-4 h-4 bg-blue-500 rotate-45"
|
||||
style={{
|
||||
boxShadow: '-2px -2px 4px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Tooltip content */}
|
||||
<div className="relative bg-blue-500 text-white rounded-lg shadow-2xl p-6 max-w-sm">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="absolute top-2 right-2 p-1 hover:bg-blue-600 rounded-full transition-colors"
|
||||
aria-label="Close tooltip"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold mb-3">{title}</h3>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-sm leading-relaxed mb-4 pr-4">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="bg-white text-blue-500 px-4 py-1.5 rounded font-medium text-sm hover:bg-blue-50 transition-colors cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
{showNext && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onNext?.();
|
||||
onDismiss();
|
||||
}}
|
||||
className="bg-white text-blue-500 px-4 py-1.5 rounded font-medium text-sm hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Use portal to render tooltip at document root
|
||||
return createPortal(tooltipContent, document.body);
|
||||
};
|
||||
83
ui/src/context/OnboardingContext.tsx
Normal file
83
ui/src/context/OnboardingContext.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
export type TooltipKey = 'web_call'; // Add more tooltip keys as needed
|
||||
|
||||
interface OnboardingState {
|
||||
seenTooltips: TooltipKey[];
|
||||
}
|
||||
|
||||
interface OnboardingContextType {
|
||||
hasSeenTooltip: (key: TooltipKey) => boolean;
|
||||
markTooltipSeen: (key: TooltipKey) => void;
|
||||
resetOnboarding: () => void;
|
||||
}
|
||||
|
||||
const ONBOARDING_STORAGE_KEY = 'dograh_onboarding_state';
|
||||
|
||||
const defaultState: OnboardingState = {
|
||||
seenTooltips: [],
|
||||
};
|
||||
|
||||
const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined);
|
||||
|
||||
export const OnboardingProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [onboardingState, setOnboardingState] = useState<OnboardingState>(defaultState);
|
||||
|
||||
// Load state from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedState = localStorage.getItem(ONBOARDING_STORAGE_KEY);
|
||||
if (savedState) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedState);
|
||||
setOnboardingState({ ...defaultState, ...parsed });
|
||||
} catch (error) {
|
||||
console.error('Failed to parse onboarding state:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save state to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(onboardingState));
|
||||
}, [onboardingState]);
|
||||
|
||||
const hasSeenTooltip = (key: TooltipKey): boolean => {
|
||||
return onboardingState.seenTooltips.includes(key);
|
||||
};
|
||||
|
||||
const markTooltipSeen = (key: TooltipKey) => {
|
||||
setOnboardingState(prev => ({
|
||||
...prev,
|
||||
seenTooltips: prev.seenTooltips.includes(key)
|
||||
? prev.seenTooltips
|
||||
: [...prev.seenTooltips, key]
|
||||
}));
|
||||
};
|
||||
|
||||
const resetOnboarding = () => {
|
||||
setOnboardingState(defaultState);
|
||||
localStorage.removeItem(ONBOARDING_STORAGE_KEY);
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingContext.Provider
|
||||
value={{
|
||||
hasSeenTooltip,
|
||||
markTooltipSeen,
|
||||
resetOnboarding
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</OnboardingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useOnboarding = () => {
|
||||
const context = useContext(OnboardingContext);
|
||||
if (!context) {
|
||||
throw new Error('useOnboarding must be used within an OnboardingProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue