From 20009aed53d8864c9204d43a17895168a777d2cc Mon Sep 17 00:00:00 2001 From: Ilan Bigio Date: Mon, 16 Dec 2024 13:06:08 -0800 Subject: Initial commit --- webapp/components/backend-tag.tsx | 7 + webapp/components/call-interface.tsx | 95 ++++++ webapp/components/checklist-and-config.tsx | 352 ++++++++++++++++++++++ webapp/components/function-calls-panel.tsx | 118 ++++++++ webapp/components/phone-number-checklist.tsx | 67 ++++ webapp/components/session-configuration-panel.tsx | 291 ++++++++++++++++++ webapp/components/tool-configuration-dialog.tsx | 104 +++++++ webapp/components/top-bar.tsx | 37 +++ webapp/components/transcript.tsx | 104 +++++++ webapp/components/types.ts | 31 ++ webapp/components/ui/alert.tsx | 59 ++++ webapp/components/ui/badge.tsx | 36 +++ webapp/components/ui/button.tsx | 56 ++++ webapp/components/ui/card.tsx | 79 +++++ webapp/components/ui/checkbox.tsx | 30 ++ webapp/components/ui/dialog.tsx | 122 ++++++++ webapp/components/ui/input.tsx | 25 ++ webapp/components/ui/label.tsx | 26 ++ webapp/components/ui/scroll-area.tsx | 48 +++ webapp/components/ui/select.tsx | 160 ++++++++++ webapp/components/ui/textarea.tsx | 24 ++ 21 files changed, 1871 insertions(+) create mode 100644 webapp/components/backend-tag.tsx create mode 100644 webapp/components/call-interface.tsx create mode 100644 webapp/components/checklist-and-config.tsx create mode 100644 webapp/components/function-calls-panel.tsx create mode 100644 webapp/components/phone-number-checklist.tsx create mode 100644 webapp/components/session-configuration-panel.tsx create mode 100644 webapp/components/tool-configuration-dialog.tsx create mode 100644 webapp/components/top-bar.tsx create mode 100644 webapp/components/transcript.tsx create mode 100644 webapp/components/types.ts create mode 100644 webapp/components/ui/alert.tsx create mode 100644 webapp/components/ui/badge.tsx create mode 100644 webapp/components/ui/button.tsx create mode 100644 webapp/components/ui/card.tsx create mode 100644 webapp/components/ui/checkbox.tsx create mode 100644 webapp/components/ui/dialog.tsx create mode 100644 webapp/components/ui/input.tsx create mode 100644 webapp/components/ui/label.tsx create mode 100644 webapp/components/ui/scroll-area.tsx create mode 100644 webapp/components/ui/select.tsx create mode 100644 webapp/components/ui/textarea.tsx (limited to 'webapp/components') diff --git a/webapp/components/backend-tag.tsx b/webapp/components/backend-tag.tsx new file mode 100644 index 0000000..722a00a --- /dev/null +++ b/webapp/components/backend-tag.tsx @@ -0,0 +1,7 @@ +import React from "react"; + +export const BackendTag = () => ( + + backend + +); diff --git a/webapp/components/call-interface.tsx b/webapp/components/call-interface.tsx new file mode 100644 index 0000000..6ead90a --- /dev/null +++ b/webapp/components/call-interface.tsx @@ -0,0 +1,95 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import TopBar from "@/components/top-bar"; +import ChecklistAndConfig from "@/components/checklist-and-config"; +import SessionConfigurationPanel from "@/components/session-configuration-panel"; +import Transcript from "@/components/transcript"; +import FunctionCallsPanel from "@/components/function-calls-panel"; +import { Item } from "@/components/types"; +import handleRealtimeEvent from "@/lib/handle-realtime-event"; +import PhoneNumberChecklist from "@/components/phone-number-checklist"; + +const CallInterface = () => { + const [selectedPhoneNumber, setSelectedPhoneNumber] = useState(""); + const [allConfigsReady, setAllConfigsReady] = useState(false); + const [items, setItems] = useState([]); + const [callStatus, setCallStatus] = useState("disconnected"); + const [ws, setWs] = useState(null); + + useEffect(() => { + if (allConfigsReady && !ws) { + const newWs = new WebSocket("ws://localhost:8081/logs"); + + newWs.onopen = () => { + console.log("Connected to logs websocket"); + setCallStatus("connected"); + }; + + newWs.onmessage = (event) => { + const data = JSON.parse(event.data); + console.log("Received logs event:", data); + handleRealtimeEvent(data, setItems); + }; + + newWs.onclose = () => { + console.log("Logs websocket disconnected"); + setWs(null); + setCallStatus("disconnected"); + }; + + setWs(newWs); + } + }, [allConfigsReady, ws]); + + return ( +
+ + +
+
+ {/* Left Column */} +
+ { + if (ws && ws.readyState === WebSocket.OPEN) { + const updateEvent = { + type: "session.update", + session: { + ...config, + }, + }; + console.log("Sending update event:", updateEvent); + ws.send(JSON.stringify(updateEvent)); + } + }} + /> +
+ + {/* Middle Column: Transcript */} +
+ + +
+ + {/* Right Column: Function Calls */} +
+ +
+
+
+
+ ); +}; + +export default CallInterface; diff --git a/webapp/components/checklist-and-config.tsx b/webapp/components/checklist-and-config.tsx new file mode 100644 index 0000000..21e47d5 --- /dev/null +++ b/webapp/components/checklist-and-config.tsx @@ -0,0 +1,352 @@ +"use client"; + +import React, { useEffect, useState, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Circle, CheckCircle, Loader2 } from "lucide-react"; +import { PhoneNumber } from "@/components/types"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export default function ChecklistAndConfig({ + ready, + setReady, + selectedPhoneNumber, + setSelectedPhoneNumber, +}: { + ready: boolean; + setReady: (val: boolean) => void; + selectedPhoneNumber: string; + setSelectedPhoneNumber: (val: string) => void; +}) { + const [hasCredentials, setHasCredentials] = useState(false); + const [phoneNumbers, setPhoneNumbers] = useState([]); + const [currentNumberSid, setCurrentNumberSid] = useState(""); + const [currentVoiceUrl, setCurrentVoiceUrl] = useState(""); + + const [publicUrl, setPublicUrl] = useState(""); + const [localServerUp, setLocalServerUp] = useState(false); + const [publicUrlAccessible, setPublicUrlAccessible] = useState(false); + + const [allChecksPassed, setAllChecksPassed] = useState(false); + const [webhookLoading, setWebhookLoading] = useState(false); + const [ngrokLoading, setNgrokLoading] = useState(false); + + const appendedTwimlUrl = publicUrl ? `${publicUrl}/twiml` : ""; + const isWebhookMismatch = + appendedTwimlUrl && currentVoiceUrl && appendedTwimlUrl !== currentVoiceUrl; + + useEffect(() => { + let polling = true; + + const pollChecks = async () => { + try { + // 1. Check credentials + let res = await fetch("/api/twilio"); + if (!res.ok) throw new Error("Failed credentials check"); + const credData = await res.json(); + setHasCredentials(!!credData?.credentialsSet); + + // 2. Fetch numbers + res = await fetch("/api/twilio/numbers"); + if (!res.ok) throw new Error("Failed to fetch phone numbers"); + const numbersData = await res.json(); + if (Array.isArray(numbersData) && numbersData.length > 0) { + setPhoneNumbers(numbersData); + // If currentNumberSid not set or not in the list, use first + const selected = + numbersData.find((p: PhoneNumber) => p.sid === currentNumberSid) || + numbersData[0]; + setCurrentNumberSid(selected.sid); + setCurrentVoiceUrl(selected.voiceUrl || ""); + setSelectedPhoneNumber(selected.friendlyName || ""); + } + + // 3. Check local server & public URL + let foundPublicUrl = ""; + try { + const resLocal = await fetch("http://localhost:8081/public-url"); + if (resLocal.ok) { + const pubData = await resLocal.json(); + foundPublicUrl = pubData?.publicUrl || ""; + setLocalServerUp(true); + setPublicUrl(foundPublicUrl); + } else { + throw new Error("Local server not responding"); + } + } catch { + setLocalServerUp(false); + setPublicUrl(""); + } + } catch (err) { + console.error(err); + } + }; + + pollChecks(); + const intervalId = setInterval(() => polling && pollChecks(), 1000); + return () => { + polling = false; + clearInterval(intervalId); + }; + }, [currentNumberSid, setSelectedPhoneNumber]); + + const updateWebhook = async () => { + if (!currentNumberSid || !appendedTwimlUrl) return; + try { + setWebhookLoading(true); + const res = await fetch("/api/twilio/numbers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + phoneNumberSid: currentNumberSid, + voiceUrl: appendedTwimlUrl, + }), + }); + if (!res.ok) throw new Error("Failed to update webhook"); + setCurrentVoiceUrl(appendedTwimlUrl); + } catch (err) { + console.error(err); + } finally { + setWebhookLoading(false); + } + }; + + const checkNgrok = async () => { + if (!localServerUp || !publicUrl) return; + setNgrokLoading(true); + let success = false; + for (let i = 0; i < 5; i++) { + try { + const resTest = await fetch(publicUrl + "/public-url"); + if (resTest.ok) { + setPublicUrlAccessible(true); + success = true; + break; + } + } catch { + // retry + } + if (i < 4) { + await new Promise((r) => setTimeout(r, 3000)); + } + } + if (!success) { + setPublicUrlAccessible(false); + } + setNgrokLoading(false); + }; + + const checklist = useMemo(() => { + return [ + { + label: "Set up Twilio account", + done: hasCredentials, + description: "Then update account details in webapp/.env", + field: ( + + ), + }, + { + label: "Set up Twilio phone number", + done: phoneNumbers.length > 0, + description: "Costs around $1.15/month", + field: + phoneNumbers.length > 0 ? ( + phoneNumbers.length === 1 ? ( + + ) : ( + + ) + ) : ( + + ), + }, + { + label: "Start local WebSocket server", + done: localServerUp, + description: "cd websocket-server && npm run dev", + field: null, + }, + { + label: "Start ngrok", + done: publicUrlAccessible, + description: "Then set ngrok URL in websocket-server/.env", + field: ( +
+
+ +
+
+ +
+
+ ), + }, + { + label: "Update Twilio webhook URL", + done: !!publicUrl && !isWebhookMismatch, + description: "Can also be done manually in Twilio console", + field: ( +
+
+ +
+
+ +
+
+ ), + }, + ]; + }, [ + hasCredentials, + phoneNumbers, + currentNumberSid, + localServerUp, + publicUrl, + publicUrlAccessible, + currentVoiceUrl, + isWebhookMismatch, + appendedTwimlUrl, + webhookLoading, + ngrokLoading, + setSelectedPhoneNumber, + ]); + + useEffect(() => { + setAllChecksPassed(checklist.every((item) => item.done)); + }, [checklist]); + + useEffect(() => { + if (!ready) { + checkNgrok(); + } + }, [localServerUp, ready]); + + useEffect(() => { + if (!allChecksPassed) { + setReady(false); + } + }, [allChecksPassed, setReady]); + + const handleDone = () => setReady(true); + + return ( + + + + Setup Checklist + + This sample app requires a few steps before you get started + + + +
+ {checklist.map((item, i) => ( +
+
+
+ {item.done ? ( + + ) : ( + + )} + {item.label} +
+ {item.description && ( +

+ {item.description} +

+ )} +
+
{item.field}
+
+ ))} +
+ +
+ +
+
+
+ ); +} diff --git a/webapp/components/function-calls-panel.tsx b/webapp/components/function-calls-panel.tsx new file mode 100644 index 0000000..75be81f --- /dev/null +++ b/webapp/components/function-calls-panel.tsx @@ -0,0 +1,118 @@ +import React, { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Item } from "@/components/types"; + +type FunctionCallsPanelProps = { + items: Item[]; + ws?: WebSocket | null; // pass down ws from parent +}; + +const FunctionCallsPanel: React.FC = ({ + items, + ws, +}) => { + const [responses, setResponses] = useState>({}); + + // Filter function_call items + const functionCalls = items.filter((it) => it.type === "function_call"); + + // For each function_call, check for a corresponding function_call_output + const functionCallsWithStatus = functionCalls.map((call) => { + const outputs = items.filter( + (it) => it.type === "function_call_output" && it.call_id === call.call_id + ); + const outputItem = outputs[0]; + const completed = call.status === "completed" || !!outputItem; + const response = outputItem ? outputItem.output : undefined; + return { + ...call, + completed, + response, + }; + }); + + const handleChange = (call_id: string, value: string) => { + setResponses((prev) => ({ ...prev, [call_id]: value })); + }; + + const handleSubmit = (call: Item) => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const call_id = call.call_id || ""; + ws.send( + JSON.stringify({ + type: "conversation.item.create", + item: { + type: "function_call_output", + call_id: call_id, + output: JSON.stringify(responses[call_id] || ""), + }, + }) + ); + // Ask the model to continue after providing the tool response + ws.send(JSON.stringify({ type: "response.create" })); + }; + + return ( + + + + Function Calls + + + + +
+ {functionCallsWithStatus.map((call) => ( +
+
+

{call.name}

+ + {call.completed ? "Completed" : "Pending"} + +
+ +
+ {JSON.stringify(call.params)} +
+ + {!call.completed ? ( +
+ + handleChange(call.call_id || "", e.target.value) + } + /> + +
+ ) : ( +
+ {JSON.stringify(JSON.parse(call.response || ""))} +
+ )} +
+ ))} +
+
+
+
+ ); +}; + +export default FunctionCallsPanel; diff --git a/webapp/components/phone-number-checklist.tsx b/webapp/components/phone-number-checklist.tsx new file mode 100644 index 0000000..5aa50db --- /dev/null +++ b/webapp/components/phone-number-checklist.tsx @@ -0,0 +1,67 @@ +// PhoneNumberChecklist.tsx +"use client"; + +import React, { useState } from "react"; +import { Card } from "@/components/ui/card"; +import { CheckCircle, Circle, Eye, EyeOff } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +type PhoneNumberChecklistProps = { + selectedPhoneNumber: string; + allConfigsReady: boolean; + setAllConfigsReady: (ready: boolean) => void; +}; + +const PhoneNumberChecklist: React.FC = ({ + selectedPhoneNumber, + allConfigsReady, + setAllConfigsReady, +}) => { + const [isVisible, setIsVisible] = useState(true); + + return ( + +
+ Number +
+ + {isVisible ? selectedPhoneNumber || "None" : "••••••••••"} + + +
+
+
+
+ {allConfigsReady ? ( + + ) : ( + + )} + + {allConfigsReady ? "Setup Ready" : "Setup Not Ready"} + +
+ +
+
+ ); +}; + +export default PhoneNumberChecklist; diff --git a/webapp/components/session-configuration-panel.tsx b/webapp/components/session-configuration-panel.tsx new file mode 100644 index 0000000..85c9fc1 --- /dev/null +++ b/webapp/components/session-configuration-panel.tsx @@ -0,0 +1,291 @@ +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Plus, Edit, Trash, Check, AlertCircle } from "lucide-react"; +import { toolTemplates } from "@/lib/tool-templates"; +import { ToolConfigurationDialog } from "./tool-configuration-dialog"; +import { BackendTag } from "./backend-tag"; +import { useBackendTools } from "@/lib/use-backend-tools"; + +interface SessionConfigurationPanelProps { + callStatus: string; + onSave: (config: any) => void; +} + +const SessionConfigurationPanel: React.FC = ({ + callStatus, + onSave, +}) => { + const [instructions, setInstructions] = useState( + "You are a helpful assistant in a phone call." + ); + const [voice, setVoice] = useState("ash"); + const [tools, setTools] = useState([]); + const [editingIndex, setEditingIndex] = useState(null); + const [editingSchemaStr, setEditingSchemaStr] = useState(""); + const [isJsonValid, setIsJsonValid] = useState(true); + const [openDialog, setOpenDialog] = useState(false); + const [selectedTemplate, setSelectedTemplate] = useState(""); + const [saveStatus, setSaveStatus] = useState< + "idle" | "saving" | "saved" | "error" + >("idle"); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + // Custom hook to fetch backend tools every 3 seconds + const backendTools = useBackendTools("http://localhost:8081/tools", 3000); + + // Track changes to determine if there are unsaved modifications + useEffect(() => { + setHasUnsavedChanges(true); + }, [instructions, voice, tools]); + + // Reset save status after a delay when saved + useEffect(() => { + if (saveStatus === "saved") { + const timer = setTimeout(() => { + setSaveStatus("idle"); + }, 3000); + return () => clearTimeout(timer); + } + }, [saveStatus]); + + const handleSave = async () => { + setSaveStatus("saving"); + try { + await onSave({ + instructions, + voice, + tools: tools.map((tool) => JSON.parse(tool)), + }); + setSaveStatus("saved"); + setHasUnsavedChanges(false); + } catch (error) { + setSaveStatus("error"); + } + }; + + const handleAddTool = () => { + setEditingIndex(null); + setEditingSchemaStr(""); + setSelectedTemplate(""); + setIsJsonValid(true); + setOpenDialog(true); + }; + + const handleEditTool = (index: number) => { + setEditingIndex(index); + setEditingSchemaStr(tools[index] || ""); + setSelectedTemplate(""); + setIsJsonValid(true); + setOpenDialog(true); + }; + + const handleDeleteTool = (index: number) => { + const newTools = [...tools]; + newTools.splice(index, 1); + setTools(newTools); + }; + + const handleDialogSave = () => { + try { + JSON.parse(editingSchemaStr); + } catch { + return; + } + const newTools = [...tools]; + if (editingIndex === null) { + newTools.push(editingSchemaStr); + } else { + newTools[editingIndex] = editingSchemaStr; + } + setTools(newTools); + setOpenDialog(false); + }; + + const handleTemplateChange = (val: string) => { + setSelectedTemplate(val); + + // Determine if the selected template is from local or backend + let templateObj = + toolTemplates.find((t) => t.name === val) || + backendTools.find((t: any) => t.name === val); + + if (templateObj) { + setEditingSchemaStr(JSON.stringify(templateObj, null, 2)); + setIsJsonValid(true); + } + }; + + const onSchemaChange = (value: string) => { + setEditingSchemaStr(value); + try { + JSON.parse(value); + setIsJsonValid(true); + } catch { + setIsJsonValid(false); + } + }; + + const getToolNameFromSchema = (schema: string): string => { + try { + const parsed = JSON.parse(schema); + return parsed?.name || "Untitled Tool"; + } catch { + return "Invalid JSON"; + } + }; + + const isBackendTool = (name: string): boolean => { + return backendTools.some((t: any) => t.name === name); + }; + + return ( + + +
+ + Session Configuration + +
+ {saveStatus === "error" ? ( + + + Save failed + + ) : hasUnsavedChanges ? ( + Not saved + ) : ( + + + Saved + + )} +
+
+
+ + +
+
+ +