mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-01 08:59:46 +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_NODE_ENV=local
|
||||||
ENV NEXT_PUBLIC_AUTH_PROVIDER=local
|
ENV NEXT_PUBLIC_AUTH_PROVIDER=local
|
||||||
|
ENV NEXT_PUBLIC_DEPLOYMENT_MODE=oss
|
||||||
ENV NEXT_PUBLIC_BACKEND_URL="http://localhost:8000"
|
ENV NEXT_PUBLIC_BACKEND_URL="http://localhost:8000"
|
||||||
ENV BACKEND_URL="http://api: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 (
|
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">
|
<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">
|
<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">
|
<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>
|
||||||
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
<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">
|
<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
|
<Input
|
||||||
className="w-full h-10 text-sm px-3 border-2 focus:ring-2 focus:ring-blue-500 transition-all"
|
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"
|
placeholder="e.g., Lead Qualification, HR Screening, Customer Support"
|
||||||
|
|
@ -130,14 +127,11 @@ export default function CreateWorkflowPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
<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">
|
<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
|
<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"
|
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}
|
value={activityDescription}
|
||||||
onChange={(e) => setActivityDescription(e.target.value)}
|
onChange={(e) => setActivityDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Suspense } from "react";
|
||||||
import PostHogIdentify from "@/components/PostHogIdentify";
|
import PostHogIdentify from "@/components/PostHogIdentify";
|
||||||
import SpinLoader from "@/components/SpinLoader";
|
import SpinLoader from "@/components/SpinLoader";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { OnboardingProvider } from "@/context/OnboardingContext";
|
||||||
import { UserConfigProvider } from "@/context/UserConfigContext";
|
import { UserConfigProvider } from "@/context/UserConfigContext";
|
||||||
import { AuthProvider } from "@/lib/auth";
|
import { AuthProvider } from "@/lib/auth";
|
||||||
|
|
||||||
|
|
@ -39,9 +40,11 @@ export default function RootLayout({
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Suspense fallback={<SpinLoader />}>
|
<Suspense fallback={<SpinLoader />}>
|
||||||
<UserConfigProvider>
|
<UserConfigProvider>
|
||||||
<PostHogIdentify />
|
<OnboardingProvider>
|
||||||
{children}
|
<PostHogIdentify />
|
||||||
<Toaster />
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</OnboardingProvider>
|
||||||
</UserConfigProvider>
|
</UserConfigProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,20 @@ import 'react-international-phone/style.css';
|
||||||
|
|
||||||
import { ReactFlowInstance, ReactFlowJsonObject } from "@xyflow/react";
|
import { ReactFlowInstance, ReactFlowJsonObject } from "@xyflow/react";
|
||||||
import { AlertTriangle, CheckCheck, Download, LoaderCircle, Phone, ShieldCheck } from "lucide-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 { PhoneInput } from 'react-international-phone';
|
||||||
|
|
||||||
import { initiateCallApiV1TwilioInitiateCallPost } from '@/client/sdk.gen';
|
import { initiateCallApiV1TwilioInitiateCallPost } from '@/client/sdk.gen';
|
||||||
import { WorkflowError } from '@/client/types.gen';
|
import { WorkflowError } from '@/client/types.gen';
|
||||||
import { FlowEdge, FlowNode } from "@/components/flow/types";
|
import { FlowEdge, FlowNode } from "@/components/flow/types";
|
||||||
|
import { OnboardingTooltip } from '@/components/onboarding/OnboardingTooltip';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useOnboarding } from '@/context/OnboardingContext';
|
||||||
import { useUserConfig } from "@/context/UserConfigContext";
|
import { useUserConfig } from "@/context/UserConfigContext";
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
|
import logger from '@/lib/logger';
|
||||||
|
|
||||||
interface WorkflowHeaderProps {
|
interface WorkflowHeaderProps {
|
||||||
isDirty: boolean;
|
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 WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow }: WorkflowHeaderProps) => {
|
||||||
const { userConfig, saveUserConfig } = useUserConfig();
|
const { userConfig, saveUserConfig } = useUserConfig();
|
||||||
|
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
|
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
@ -65,10 +69,13 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
|
||||||
const [phoneChanged, setPhoneChanged] = useState(false);
|
const [phoneChanged, setPhoneChanged] = useState(false);
|
||||||
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
|
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
|
||||||
const { user, getAccessToken } = useAuth();
|
const { user, getAccessToken } = useAuth();
|
||||||
|
const webCallButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const hasValidationErrors = workflowValidationErrors.length > 0;
|
const hasValidationErrors = workflowValidationErrors.length > 0;
|
||||||
const isOSSDeployment = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE === 'oss';
|
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
|
// Reset call-related state whenever the dialog is closed so that a new call can be placed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dialogOpen) {
|
if (!dialogOpen) {
|
||||||
|
|
@ -188,9 +195,16 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
|
||||||
Export Pathway
|
Export Pathway
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
ref={webCallButtonRef}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
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}
|
disabled={hasValidationErrors}
|
||||||
>
|
>
|
||||||
<Phone className="mr-2 h-4 w-4" />
|
<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>}
|
{callSuccessMsg && <div className="text-green-600 text-sm mt-2">{callSuccessMsg}</div>}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</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 { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
|
|
||||||
interface AudioControlsProps {
|
interface AudioControlsProps {
|
||||||
audioInputs: MediaDeviceInfo[];
|
audioInputs: MediaDeviceInfo[];
|
||||||
|
|
@ -28,7 +27,6 @@ export const AudioControls = ({
|
||||||
}: AudioControlsProps) => {
|
}: AudioControlsProps) => {
|
||||||
// Check if we have valid audio devices (permissions granted)
|
// Check if we have valid audio devices (permissions granted)
|
||||||
const hasValidDevices = audioInputs.length > 0 && audioInputs.some(device => device.deviceId && device.deviceId.trim() !== '');
|
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 () => {
|
const requestAudioPermissions = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -40,64 +38,73 @@ export const AudioControls = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// Handle auto-selection of first device if none selected
|
||||||
<>
|
if (hasValidDevices && !selectedAudioInput) {
|
||||||
<div className="space-y-2">
|
const firstValidDevice = audioInputs.find(device => device.deviceId && device.deviceId.trim() !== '');
|
||||||
<h3 className="text-sm font-medium">Audio Input</h3>
|
if (firstValidDevice) {
|
||||||
|
setSelectedAudioInput(firstValidDevice.deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{!hasValidDevices ? (
|
if (isCompleted) {
|
||||||
<div className="space-y-3">
|
return null; // The parent component will handle showing the loading state
|
||||||
<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>
|
if (!hasValidDevices) {
|
||||||
</div>
|
return (
|
||||||
<Button
|
<div className="flex flex-col items-center justify-center space-y-4 p-8">
|
||||||
onClick={requestAudioPermissions}
|
<div className="text-center space-y-2">
|
||||||
variant="outline"
|
<p className="text-gray-700 font-medium">Audio permissions required</p>
|
||||||
className="w-full"
|
<p className="text-sm text-gray-500">Click below to grant microphone access</p>
|
||||||
>
|
</div>
|
||||||
<Mic className="h-4 w-4 mr-2" />
|
<Button
|
||||||
Grant Audio Permissions
|
onClick={requestAudioPermissions}
|
||||||
</Button>
|
size="lg"
|
||||||
</div>
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
) : (
|
>
|
||||||
<Select value={selectedAudioInput} onValueChange={setSelectedAudioInput}>
|
<Mic className="h-5 w-5 mr-2" />
|
||||||
<SelectTrigger className="w-full">
|
Grant Audio Permissions
|
||||||
<SelectValue placeholder="Select audio input" />
|
</Button>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{validAudioInputs.map((device, index) => (
|
|
||||||
<SelectItem key={device.deviceId} value={device.deviceId}>
|
|
||||||
{device.label || `Audio Device #${index + 1}`}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{isCompleted && (
|
return (
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex flex-col items-center justify-center space-y-6 p-8">
|
||||||
<p className="text-red-500">
|
{!connectionActive ? (
|
||||||
Workflow run completed. Please refresh the page in a while to see the recording and transcript.
|
<>
|
||||||
</p>
|
<p className="text-sm text-gray-600">Ready to start your call</p>
|
||||||
</div>
|
<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>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
{permissionError && (
|
||||||
{!isCompleted && hasValidDevices && (
|
<p className="text-sm text-red-500 text-center">{permissionError}</p>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,38 @@
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface ConnectionStatusProps {
|
interface ConnectionStatusProps {
|
||||||
iceGatheringState: string;
|
connectionStatus: 'idle' | 'connecting' | 'connected' | 'failed';
|
||||||
iceConnectionState: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConnectionStatus = ({
|
export const ConnectionStatus = ({ connectionStatus }: ConnectionStatusProps) => {
|
||||||
iceGatheringState,
|
if (connectionStatus === 'idle') return null;
|
||||||
iceConnectionState
|
|
||||||
}: ConnectionStatusProps) => {
|
if (connectionStatus === 'connecting') {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="flex items-center justify-center space-x-2 text-blue-600">
|
||||||
<div className="space-y-2">
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
<h3 className="text-sm font-medium">ICE gathering state</h3>
|
<span className="text-sm font-medium">Establishing Connection...</span>
|
||||||
<p className="text-sm text-muted-foreground">{iceGatheringState}</p>
|
|
||||||
</div>
|
</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>
|
||||||
</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 './AudioControls';
|
||||||
export * from './ConnectionStatus';
|
export * from './ConnectionStatus';
|
||||||
export * from './ContextDisplay';
|
export * from './ContextDisplay';
|
||||||
export * from './ContextVariablesSection';
|
|
||||||
export * from './WorkflowConfigErrorDialog'
|
export * from './WorkflowConfigErrorDialog'
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,7 @@ interface UseWebRTCProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: UseWebRTCProps) => {
|
export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: UseWebRTCProps) => {
|
||||||
const [iceGatheringState, setIceGatheringState] = useState('');
|
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected' | 'failed'>('idle');
|
||||||
const [iceConnectionState, setIceConnectionState] = useState('');
|
|
||||||
const [connectionActive, setConnectionActive] = useState(false);
|
const [connectionActive, setConnectionActive] = useState(false);
|
||||||
const [isCompleted, setIsCompleted] = useState(false);
|
const [isCompleted, setIsCompleted] = useState(false);
|
||||||
const [apiKeyModalOpen, setApiKeyModalOpen] = useState(false);
|
const [apiKeyModalOpen, setApiKeyModalOpen] = useState(false);
|
||||||
|
|
@ -25,9 +24,8 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
|
||||||
const [workflowConfigModalOpen, setWorkflowConfigModalOpen] = useState(false);
|
const [workflowConfigModalOpen, setWorkflowConfigModalOpen] = useState(false);
|
||||||
const [workflowConfigError, setWorkflowConfigError] = useState<string | null>(null);
|
const [workflowConfigError, setWorkflowConfigError] = useState<string | null>(null);
|
||||||
const [isStarting, setIsStarting] = useState(false);
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
const [initialContext, setInitialContext] = useState<Record<string, string>>(
|
// Use initial context variables directly, no UI for editing
|
||||||
initialContextVariables || {}
|
const initialContext = initialContextVariables || {};
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
audioInputs,
|
audioInputs,
|
||||||
|
|
@ -55,14 +53,16 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
|
||||||
|
|
||||||
pc.addEventListener('icegatheringstatechange', () => {
|
pc.addEventListener('icegatheringstatechange', () => {
|
||||||
logger.info(`ICE gathering state changed in createPeerConnection, ${pc.iceGatheringState}`);
|
logger.info(`ICE gathering state changed in createPeerConnection, ${pc.iceGatheringState}`);
|
||||||
setIceGatheringState(prevState => prevState + ' -> ' + pc.iceGatheringState);
|
|
||||||
});
|
});
|
||||||
setIceGatheringState(pc.iceGatheringState);
|
|
||||||
|
|
||||||
pc.addEventListener('iceconnectionstatechange', () => {
|
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) => {
|
pc.addEventListener('track', (evt) => {
|
||||||
if (evt.track.kind === 'audio' && audioRef.current) {
|
if (evt.track.kind === 'audio' && audioRef.current) {
|
||||||
|
|
@ -142,6 +142,7 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
if (isStarting || !accessToken) return;
|
if (isStarting || !accessToken) return;
|
||||||
setIsStarting(true);
|
setIsStarting(true);
|
||||||
|
setConnectionStatus('connecting');
|
||||||
try {
|
try {
|
||||||
const response = await validateUserConfigurationsApiV1UserConfigurationsUserValidateGet({
|
const response = await validateUserConfigurationsApiV1UserConfigurationsUserValidateGet({
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -212,6 +213,7 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Could not acquire media: ${err}`);
|
logger.error(`Could not acquire media: ${err}`);
|
||||||
setPermissionError('Could not acquire media');
|
setPermissionError('Could not acquire media');
|
||||||
|
setConnectionStatus('failed');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await negotiate();
|
await negotiate();
|
||||||
|
|
@ -224,6 +226,7 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
setConnectionActive(false);
|
setConnectionActive(false);
|
||||||
setIsCompleted(true);
|
setIsCompleted(true);
|
||||||
|
setConnectionStatus('idle');
|
||||||
|
|
||||||
const pc = pcRef.current;
|
const pc = pcRef.current;
|
||||||
if (!pc) return;
|
if (!pc) return;
|
||||||
|
|
@ -264,12 +267,10 @@ export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialConte
|
||||||
workflowConfigError,
|
workflowConfigError,
|
||||||
workflowConfigModalOpen,
|
workflowConfigModalOpen,
|
||||||
setWorkflowConfigModalOpen,
|
setWorkflowConfigModalOpen,
|
||||||
iceGatheringState,
|
connectionStatus,
|
||||||
iceConnectionState,
|
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
isStarting,
|
isStarting,
|
||||||
initialContext,
|
initialContext
|
||||||
setInitialContext
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
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 WorkflowLayout from '@/app/workflow/WorkflowLayout';
|
||||||
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from '@/client/sdk.gen';
|
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from '@/client/sdk.gen';
|
||||||
import { MediaPreviewButtons, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
|
import { MediaPreviewButtons, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
|
||||||
|
|
@ -15,8 +15,6 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import { downloadFile } from '@/lib/files';
|
import { downloadFile } from '@/lib/files';
|
||||||
|
|
||||||
import { ContextDisplay } from './components';
|
|
||||||
|
|
||||||
interface WorkflowRunResponse {
|
interface WorkflowRunResponse {
|
||||||
is_completed: boolean;
|
is_completed: boolean;
|
||||||
transcript_url: string | null;
|
transcript_url: string | null;
|
||||||
|
|
@ -125,7 +123,7 @@ export default function WorkflowRunPage() {
|
||||||
<div className="w-full max-w-4xl space-y-6">
|
<div className="w-full max-w-4xl space-y-6">
|
||||||
<Card className="border-gray-100">
|
<Card className="border-gray-100">
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<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">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||||
|
|
@ -133,7 +131,7 @@ export default function WorkflowRunPage() {
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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 flex-wrap gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -171,7 +169,7 @@ export default function WorkflowRunPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
{/* <div className="grid gap-6 md:grid-cols-2">
|
||||||
<ContextDisplay
|
<ContextDisplay
|
||||||
title="Initial Context"
|
title="Initial Context"
|
||||||
context={workflowRun?.initial_context}
|
context={workflowRun?.initial_context}
|
||||||
|
|
@ -180,7 +178,7 @@ export default function WorkflowRunPage() {
|
||||||
title="Gathered Context"
|
title="Gathered Context"
|
||||||
context={workflowRun?.gathered_context}
|
context={workflowRun?.gathered_context}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -188,7 +186,7 @@ export default function WorkflowRunPage() {
|
||||||
else {
|
else {
|
||||||
returnValue =
|
returnValue =
|
||||||
<div className="min-h-screen mt-40">
|
<div className="min-h-screen mt-40">
|
||||||
<Pipecat
|
<BrowserCall
|
||||||
workflowId={Number(params.workflowId)}
|
workflowId={Number(params.workflowId)}
|
||||||
workflowRunId={Number(params.runId)}
|
workflowRunId={Number(params.runId)}
|
||||||
accessToken={accessToken}
|
accessToken={accessToken}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CircleDollarSign, Loader2 } from 'lucide-react';
|
import { CircleDollarSign, Loader2, Star } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
@ -126,9 +126,15 @@ export default function BaseHeader({ headerActions, backButton, showFeaturesNav
|
||||||
/>
|
/>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-gray-600">
|
<a
|
||||||
Github Star Link
|
href="https://github.com/dograh-hq/dograh"
|
||||||
</div>
|
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>
|
</div>
|
||||||
</nav>
|
</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