summaryrefslogtreecommitdiff
path: root/webapp/components
diff options
context:
space:
mode:
Diffstat (limited to 'webapp/components')
-rw-r--r--webapp/components/backend-tag.tsx7
-rw-r--r--webapp/components/call-interface.tsx95
-rw-r--r--webapp/components/checklist-and-config.tsx352
-rw-r--r--webapp/components/function-calls-panel.tsx118
-rw-r--r--webapp/components/phone-number-checklist.tsx67
-rw-r--r--webapp/components/session-configuration-panel.tsx291
-rw-r--r--webapp/components/tool-configuration-dialog.tsx104
-rw-r--r--webapp/components/top-bar.tsx37
-rw-r--r--webapp/components/transcript.tsx104
-rw-r--r--webapp/components/types.ts31
-rw-r--r--webapp/components/ui/alert.tsx59
-rw-r--r--webapp/components/ui/badge.tsx36
-rw-r--r--webapp/components/ui/button.tsx56
-rw-r--r--webapp/components/ui/card.tsx79
-rw-r--r--webapp/components/ui/checkbox.tsx30
-rw-r--r--webapp/components/ui/dialog.tsx122
-rw-r--r--webapp/components/ui/input.tsx25
-rw-r--r--webapp/components/ui/label.tsx26
-rw-r--r--webapp/components/ui/scroll-area.tsx48
-rw-r--r--webapp/components/ui/select.tsx160
-rw-r--r--webapp/components/ui/textarea.tsx24
21 files changed, 1871 insertions, 0 deletions
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 = () => (
+ <span className="ml-2 text-xs text-green-600 border border-green-600 rounded px-1 py-[1px]">
+ backend
+ </span>
+);
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<Item[]>([]);
+ const [callStatus, setCallStatus] = useState("disconnected");
+ const [ws, setWs] = useState<WebSocket | null>(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 (
+ <div className="h-screen bg-white flex flex-col">
+ <ChecklistAndConfig
+ ready={allConfigsReady}
+ setReady={setAllConfigsReady}
+ selectedPhoneNumber={selectedPhoneNumber}
+ setSelectedPhoneNumber={setSelectedPhoneNumber}
+ />
+ <TopBar />
+ <div className="flex-grow p-4 h-full overflow-hidden flex flex-col">
+ <div className="grid grid-cols-12 gap-4 h-full">
+ {/* Left Column */}
+ <div className="col-span-3 flex flex-col h-full overflow-hidden">
+ <SessionConfigurationPanel
+ callStatus={callStatus}
+ onSave={(config) => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ const updateEvent = {
+ type: "session.update",
+ session: {
+ ...config,
+ },
+ };
+ console.log("Sending update event:", updateEvent);
+ ws.send(JSON.stringify(updateEvent));
+ }
+ }}
+ />
+ </div>
+
+ {/* Middle Column: Transcript */}
+ <div className="col-span-6 flex flex-col gap-4 h-full overflow-hidden">
+ <PhoneNumberChecklist
+ selectedPhoneNumber={selectedPhoneNumber}
+ allConfigsReady={allConfigsReady}
+ setAllConfigsReady={setAllConfigsReady}
+ />
+ <Transcript items={items} />
+ </div>
+
+ {/* Right Column: Function Calls */}
+ <div className="col-span-3 flex flex-col h-full overflow-hidden">
+ <FunctionCallsPanel items={items} ws={ws} />
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+};
+
+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<PhoneNumber[]>([]);
+ 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: (
+ <Button
+ className="w-full"
+ onClick={() => window.open("https://console.twilio.com/", "_blank")}
+ >
+ Open Twilio Console
+ </Button>
+ ),
+ },
+ {
+ label: "Set up Twilio phone number",
+ done: phoneNumbers.length > 0,
+ description: "Costs around $1.15/month",
+ field:
+ phoneNumbers.length > 0 ? (
+ phoneNumbers.length === 1 ? (
+ <Input value={phoneNumbers[0].friendlyName || ""} disabled />
+ ) : (
+ <Select
+ onValueChange={(value) => {
+ setCurrentNumberSid(value);
+ const selected = phoneNumbers.find((p) => p.sid === value);
+ if (selected) {
+ setSelectedPhoneNumber(selected.friendlyName || "");
+ setCurrentVoiceUrl(selected.voiceUrl || "");
+ }
+ }}
+ value={currentNumberSid}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue placeholder="Select a phone number" />
+ </SelectTrigger>
+ <SelectContent>
+ {phoneNumbers.map((phone) => (
+ <SelectItem key={phone.sid} value={phone.sid}>
+ {phone.friendlyName}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ )
+ ) : (
+ <Button
+ className="w-full"
+ onClick={() =>
+ window.open(
+ "https://console.twilio.com/us1/develop/phone-numbers/manage/incoming",
+ "_blank"
+ )
+ }
+ >
+ Set up Twilio phone number
+ </Button>
+ ),
+ },
+ {
+ 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: (
+ <div className="flex items-center gap-2 w-full">
+ <div className="flex-1">
+ <Input value={publicUrl} disabled />
+ </div>
+ <div className="flex-1">
+ <Button
+ variant="outline"
+ onClick={checkNgrok}
+ disabled={ngrokLoading || !localServerUp || !publicUrl}
+ className="w-full"
+ >
+ {ngrokLoading ? (
+ <Loader2 className="mr-2 h-4 animate-spin" />
+ ) : (
+ "Check ngrok"
+ )}
+ </Button>
+ </div>
+ </div>
+ ),
+ },
+ {
+ label: "Update Twilio webhook URL",
+ done: !!publicUrl && !isWebhookMismatch,
+ description: "Can also be done manually in Twilio console",
+ field: (
+ <div className="flex items-center gap-2 w-full">
+ <div className="flex-1">
+ <Input value={currentVoiceUrl} disabled className="w-full" />
+ </div>
+ <div className="flex-1">
+ <Button
+ onClick={updateWebhook}
+ disabled={webhookLoading}
+ className="w-full"
+ >
+ {webhookLoading ? (
+ <Loader2 className="mr-2 h-4 animate-spin" />
+ ) : (
+ "Update Webhook"
+ )}
+ </Button>
+ </div>
+ </div>
+ ),
+ },
+ ];
+ }, [
+ 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 (
+ <Dialog open={!ready}>
+ <DialogContent className="w-full max-w-[800px]">
+ <DialogHeader>
+ <DialogTitle>Setup Checklist</DialogTitle>
+ <DialogDescription>
+ This sample app requires a few steps before you get started
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="mt-4 space-y-0">
+ {checklist.map((item, i) => (
+ <div
+ key={i}
+ className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 py-2"
+ >
+ <div className="flex flex-col">
+ <div className="flex items-center gap-2 mb-1">
+ {item.done ? (
+ <CheckCircle className="text-green-500" />
+ ) : (
+ <Circle className="text-gray-400" />
+ )}
+ <span className="font-medium">{item.label}</span>
+ </div>
+ {item.description && (
+ <p className="text-sm text-gray-500 ml-8">
+ {item.description}
+ </p>
+ )}
+ </div>
+ <div className="flex items-center mt-2 sm:mt-0">{item.field}</div>
+ </div>
+ ))}
+ </div>
+
+ <div className="mt-6 flex flex-col sm:flex-row sm:justify-end">
+ <Button
+ variant="outline"
+ onClick={handleDone}
+ disabled={!allChecksPassed}
+ >
+ Let's go!
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}
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<FunctionCallsPanelProps> = ({
+ items,
+ ws,
+}) => {
+ const [responses, setResponses] = useState<Record<string, string>>({});
+
+ // 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 (
+ <Card className="flex flex-col h-full">
+ <CardHeader className="space-y-1.5 pb-0">
+ <CardTitle className="text-base font-semibold">
+ Function Calls
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="flex-1 p-4">
+ <ScrollArea className="h-full">
+ <div className="space-y-4">
+ {functionCallsWithStatus.map((call) => (
+ <div
+ key={call.id}
+ className="rounded-lg border bg-card p-4 space-y-3"
+ >
+ <div className="flex items-center justify-between">
+ <h3 className="font-medium text-sm">{call.name}</h3>
+ <Badge variant={call.completed ? "default" : "secondary"}>
+ {call.completed ? "Completed" : "Pending"}
+ </Badge>
+ </div>
+
+ <div className="text-sm text-muted-foreground font-mono break-all">
+ {JSON.stringify(call.params)}
+ </div>
+
+ {!call.completed ? (
+ <div className="space-y-2">
+ <Input
+ placeholder="Enter response"
+ value={responses[call.call_id || ""] || ""}
+ onChange={(e) =>
+ handleChange(call.call_id || "", e.target.value)
+ }
+ />
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleSubmit(call)}
+ disabled={!responses[call.call_id || ""]}
+ className="w-full"
+ >
+ Submit Response
+ </Button>
+ </div>
+ ) : (
+ <div className="text-sm rounded-md bg-muted p-3">
+ {JSON.stringify(JSON.parse(call.response || ""))}
+ </div>
+ )}
+ </div>
+ ))}
+ </div>
+ </ScrollArea>
+ </CardContent>
+ </Card>
+ );
+};
+
+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<PhoneNumberChecklistProps> = ({
+ selectedPhoneNumber,
+ allConfigsReady,
+ setAllConfigsReady,
+}) => {
+ const [isVisible, setIsVisible] = useState(true);
+
+ return (
+ <Card className="flex items-center justify-between p-4">
+ <div className="flex flex-col">
+ <span className="text-sm text-gray-500">Number</span>
+ <div className="flex items-center">
+ <span className="font-medium w-36">
+ {isVisible ? selectedPhoneNumber || "None" : "••••••••••"}
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => setIsVisible(!isVisible)}
+ className="h-8 w-8"
+ >
+ {isVisible ? (
+ <Eye className="h-4 w-4" />
+ ) : (
+ <EyeOff className="h-4 w-4" />
+ )}
+ </Button>
+ </div>
+ </div>
+ <div className="flex items-center gap-4">
+ <div className="flex items-center gap-2">
+ {allConfigsReady ? (
+ <CheckCircle className="text-green-500 w-4 h-4" />
+ ) : (
+ <Circle className="text-gray-400 w-4 h-4" />
+ )}
+ <span className="text-sm text-gray-700">
+ {allConfigsReady ? "Setup Ready" : "Setup Not Ready"}
+ </span>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setAllConfigsReady(false)}
+ >
+ Checklist
+ </Button>
+ </div>
+ </Card>
+ );
+};
+
+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<SessionConfigurationPanelProps> = ({
+ callStatus,
+ onSave,
+}) => {
+ const [instructions, setInstructions] = useState(
+ "You are a helpful assistant in a phone call."
+ );
+ const [voice, setVoice] = useState("ash");
+ const [tools, setTools] = useState<string[]>([]);
+ const [editingIndex, setEditingIndex] = useState<number | null>(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 (
+ <Card className="flex flex-col h-full w-full mx-auto">
+ <CardHeader className="pb-0 px-4 sm:px-6">
+ <div className="flex items-center justify-between">
+ <CardTitle className="text-base font-semibold">
+ Session Configuration
+ </CardTitle>
+ <div className="flex items-center gap-2">
+ {saveStatus === "error" ? (
+ <span className="text-xs text-red-500 flex items-center gap-1">
+ <AlertCircle className="h-3 w-3" />
+ Save failed
+ </span>
+ ) : hasUnsavedChanges ? (
+ <span className="text-xs text-muted-foreground">Not saved</span>
+ ) : (
+ <span className="text-xs text-muted-foreground flex items-center gap-1">
+ <Check className="h-3 w-3" />
+ Saved
+ </span>
+ )}
+ </div>
+ </div>
+ </CardHeader>
+ <CardContent className="flex-1 p-3 sm:p-5">
+ <ScrollArea className="h-full">
+ <div className="space-y-4 sm:space-y-6 m-1">
+ <div className="space-y-2">
+ <label className="text-sm font-medium leading-none">
+ Instructions
+ </label>
+ <Textarea
+ placeholder="Enter instructions"
+ className="min-h-[100px] resize-none"
+ value={instructions}
+ onChange={(e) => setInstructions(e.target.value)}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <label className="text-sm font-medium leading-none">Voice</label>
+ <Select value={voice} onValueChange={setVoice}>
+ <SelectTrigger className="w-full">
+ <SelectValue placeholder="Select voice" />
+ </SelectTrigger>
+ <SelectContent>
+ {["ash", "ballad", "coral", "sage", "verse"].map((v) => (
+ <SelectItem key={v} value={v}>
+ {v}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <label className="text-sm font-medium leading-none">Tools</label>
+ <div className="space-y-2">
+ {tools.map((tool, index) => {
+ const name = getToolNameFromSchema(tool);
+ const backend = isBackendTool(name);
+ return (
+ <div
+ key={index}
+ className="flex items-center justify-between rounded-md border p-2 sm:p-3 gap-2"
+ >
+ <span className="text-sm truncate flex-1 min-w-0 flex items-center">
+ {name}
+ {backend && <BackendTag />}
+ </span>
+ <div className="flex gap-1 flex-shrink-0">
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => handleEditTool(index)}
+ className="h-8 w-8"
+ >
+ <Edit className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => handleDeleteTool(index)}
+ className="h-8 w-8"
+ >
+ <Trash className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ );
+ })}
+ <Button
+ variant="outline"
+ className="w-full"
+ onClick={handleAddTool}
+ >
+ <Plus className="h-4 w-4 mr-2" />
+ Add Tool
+ </Button>
+ </div>
+ </div>
+
+ <Button
+ className="w-full mt-4"
+ onClick={handleSave}
+ disabled={saveStatus === "saving" || !hasUnsavedChanges}
+ >
+ {saveStatus === "saving" ? (
+ "Saving..."
+ ) : saveStatus === "saved" ? (
+ <span className="flex items-center">
+ Saved Successfully
+ <Check className="ml-2 h-4 w-4" />
+ </span>
+ ) : saveStatus === "error" ? (
+ "Error Saving"
+ ) : (
+ "Save Configuration"
+ )}
+ </Button>
+ </div>
+ </ScrollArea>
+ </CardContent>
+
+ <ToolConfigurationDialog
+ open={openDialog}
+ onOpenChange={setOpenDialog}
+ editingIndex={editingIndex}
+ selectedTemplate={selectedTemplate}
+ editingSchemaStr={editingSchemaStr}
+ isJsonValid={isJsonValid}
+ onTemplateChange={handleTemplateChange}
+ onSchemaChange={onSchemaChange}
+ onSave={handleDialogSave}
+ backendTools={backendTools}
+ />
+ </Card>
+ );
+};
+
+export default SessionConfigurationPanel;
diff --git a/webapp/components/tool-configuration-dialog.tsx b/webapp/components/tool-configuration-dialog.tsx
new file mode 100644
index 0000000..7d3b9ea
--- /dev/null
+++ b/webapp/components/tool-configuration-dialog.tsx
@@ -0,0 +1,104 @@
+import React from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import { toolTemplates } from "@/lib/tool-templates";
+import { BackendTag } from "./backend-tag";
+
+interface ToolConfigurationDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ editingIndex: number | null;
+ selectedTemplate: string;
+ editingSchemaStr: string;
+ isJsonValid: boolean;
+ onTemplateChange: (val: string) => void;
+ onSchemaChange: (val: string) => void;
+ onSave: () => void;
+ backendTools: any[]; // schemas returned from the server
+}
+
+export const ToolConfigurationDialog: React.FC<
+ ToolConfigurationDialogProps
+> = ({
+ open,
+ onOpenChange,
+ editingIndex,
+ selectedTemplate,
+ editingSchemaStr,
+ isJsonValid,
+ onTemplateChange,
+ onSchemaChange,
+ onSave,
+ backendTools,
+}) => {
+ // Combine local templates and backend templates
+ const localTemplateOptions = toolTemplates.map((template) => ({
+ ...template,
+ source: "local",
+ }));
+
+ const backendTemplateOptions = backendTools.map((t: any) => ({
+ ...t,
+ source: "backend",
+ }));
+
+ const allTemplates = [...localTemplateOptions, ...backendTemplateOptions];
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-lg">
+ <DialogHeader>
+ <DialogTitle>
+ {editingIndex === null ? "Add Tool" : "Edit Tool"}
+ </DialogTitle>
+ </DialogHeader>
+ <div className="space-y-4 py-4">
+ <Select value={selectedTemplate} onValueChange={onTemplateChange}>
+ <SelectTrigger>
+ <SelectValue placeholder="Select a template (optional)" />
+ </SelectTrigger>
+ <SelectContent>
+ {allTemplates.map((template) => (
+ <SelectItem key={template.name} value={template.name}>
+ <span className="flex items-center">
+ {template.name}
+ {template.source === "backend" && <BackendTag />}
+ </span>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+
+ <Textarea
+ className="min-h-[200px] font-mono text-sm"
+ value={editingSchemaStr}
+ onChange={(e) => onSchemaChange(e.target.value)}
+ placeholder="Enter tool JSON schema"
+ />
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ Cancel
+ </Button>
+ <Button onClick={onSave} disabled={!isJsonValid}>
+ Save Changes
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+};
diff --git a/webapp/components/top-bar.tsx b/webapp/components/top-bar.tsx
new file mode 100644
index 0000000..6061c6c
--- /dev/null
+++ b/webapp/components/top-bar.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import { Button } from "@/components/ui/button";
+import { BookOpen, FileText } from "lucide-react";
+import Link from "next/link";
+
+const TopBar = () => {
+ return (
+ <div className="flex justify-between items-center px-6 py-4 border-b">
+ <div className="flex items-center gap-4">
+ <svg
+ id="openai-symbol"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 32 32"
+ className="w-8 h-8"
+ >
+ <path d="M29.71,13.09A8.09,8.09,0,0,0,20.34,2.68a8.08,8.08,0,0,0-13.7,2.9A8.08,8.08,0,0,0,2.3,18.9,8,8,0,0,0,3,25.45a8.08,8.08,0,0,0,8.69,3.87,8,8,0,0,0,6,2.68,8.09,8.09,0,0,0,7.7-5.61,8,8,0,0,0,5.33-3.86A8.09,8.09,0,0,0,29.71,13.09Zm-12,16.82a6,6,0,0,1-3.84-1.39l.19-.11,6.37-3.68a1,1,0,0,0,.53-.91v-9l2.69,1.56a.08.08,0,0,1,.05.07v7.44A6,6,0,0,1,17.68,29.91ZM4.8,24.41a6,6,0,0,1-.71-4l.19.11,6.37,3.68a1,1,0,0,0,1,0l7.79-4.49V22.8a.09.09,0,0,1,0,.08L13,26.6A6,6,0,0,1,4.8,24.41ZM3.12,10.53A6,6,0,0,1,6.28,7.9v7.57a1,1,0,0,0,.51.9l7.75,4.47L11.85,22.4a.14.14,0,0,1-.09,0L5.32,18.68a6,6,0,0,1-2.2-8.18Zm22.13,5.14-7.78-4.52L20.16,9.6a.08.08,0,0,1,.09,0l6.44,3.72a6,6,0,0,1-.9,10.81V16.56A1.06,1.06,0,0,0,25.25,15.67Zm2.68-4-.19-.12-6.36-3.7a1,1,0,0,0-1.05,0l-7.78,4.49V9.2a.09.09,0,0,1,0-.09L19,5.4a6,6,0,0,1,8.91,6.21ZM11.08,17.15,8.38,15.6a.14.14,0,0,1-.05-.08V8.1a6,6,0,0,1,9.84-4.61L18,3.6,11.61,7.28a1,1,0,0,0-.53.91ZM12.54,14,16,12l3.47,2v4L16,20l-3.47-2Z" />
+ </svg>
+ <h1 className="text-xl font-semibold">OpenAI Call Assistant</h1>
+ </div>
+ <div className="flex gap-3">
+ <Button variant="ghost" size="sm">
+ <Link
+ href="https://platform.openai.com/docs/guides/realtime"
+ className="flex items-center gap-2"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <BookOpen className="w-4 h-4" />
+ Documentation
+ </Link>
+ </Button>
+ </div>
+ </div>
+ );
+};
+
+export default TopBar;
diff --git a/webapp/components/transcript.tsx b/webapp/components/transcript.tsx
new file mode 100644
index 0000000..bc2ecbd
--- /dev/null
+++ b/webapp/components/transcript.tsx
@@ -0,0 +1,104 @@
+import React, { useEffect, useRef } from "react";
+import { Card, CardContent } from "@/components/ui/card";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Bot, Phone, MessageSquare, Wrench } from "lucide-react";
+import { Item } from "@/components/types";
+
+type TranscriptProps = {
+ items: Item[];
+};
+
+const Transcript: React.FC<TranscriptProps> = ({ items }) => {
+ const scrollRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ scrollRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [items]);
+
+ // Show messages, function calls, and function call outputs in the transcript
+ const transcriptItems = items.filter(
+ (it) =>
+ it.type === "message" ||
+ it.type === "function_call" ||
+ it.type === "function_call_output"
+ );
+
+ return (
+ <Card className="h-full flex flex-col overflow-hidden">
+ <CardContent className="flex-1 h-full min-h-0 overflow-hidden flex flex-col p-0">
+ {transcriptItems.length === 0 && (
+ <div className="flex flex-1 h-full items-center justify-center mt-36">
+ <div className="flex flex-col items-center gap-3 justify-center h-full">
+ <div className="h-[140px] w-[140px] rounded-full bg-secondary/20 flex items-center justify-center">
+ <MessageSquare className="h-16 w-16 text-foreground/10 bg-transparent" />
+ </div>
+ <div className="text-center space-y-1">
+ <p className="text-sm font-medium text-foreground/60">
+ No messages yet
+ </p>
+ <p className="text-sm text-muted-foreground">
+ Start a call to see the transcript
+ </p>
+ </div>
+ </div>
+ </div>
+ )}
+ <ScrollArea className="h-full">
+ <div className="flex flex-col gap-6 p-6">
+ {transcriptItems.map((msg, i) => {
+ const isUser = msg.role === "user";
+ const isTool = msg.role === "tool";
+ // Default to assistant if not user or tool
+ const Icon = isUser ? Phone : isTool ? Wrench : Bot;
+
+ // Combine all text parts into a single string for display
+ const displayText = msg.content
+ ? msg.content.map((c) => c.text).join("")
+ : "";
+
+ return (
+ <div key={i} className="flex items-start gap-3">
+ <div
+ className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center border ${
+ isUser
+ ? "bg-background border-border"
+ : isTool
+ ? "bg-secondary border-secondary"
+ : "bg-secondary border-secondary"
+ }`}
+ >
+ <Icon className="h-4 w-4" />
+ </div>
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 mb-1.5">
+ <span
+ className={`text-sm font-medium ${
+ isUser ? "text-muted-foreground" : "text-foreground"
+ }`}
+ >
+ {isUser
+ ? "Caller"
+ : isTool
+ ? "Tool Response"
+ : "Assistant"}
+ </span>
+ <span className="text-xs text-muted-foreground">
+ {msg.timestamp}
+ </span>
+ </div>
+ <p className="text-sm text-muted-foreground leading-relaxed break-words">
+ {displayText}
+ </p>
+ </div>
+ </div>
+ );
+ })}
+ <div ref={scrollRef} />
+ </div>
+ </ScrollArea>
+ </CardContent>
+ </Card>
+ );
+};
+
+export default Transcript;
diff --git a/webapp/components/types.ts b/webapp/components/types.ts
new file mode 100644
index 0000000..76992b5
--- /dev/null
+++ b/webapp/components/types.ts
@@ -0,0 +1,31 @@
+export type Item = {
+ id: string;
+ object: string; // e.g. "realtime.item"
+ type: "message" | "function_call" | "function_call_output";
+ timestamp?: string;
+ status?: "running" | "completed";
+ // For "message" items
+ role?: "system" | "user" | "assistant" | "tool";
+ content?: { type: string; text: string }[];
+ // For "function_call" items
+ name?: string;
+ call_id?: string;
+ params?: Record<string, any>;
+ // For "function_call_output" items
+ output?: string;
+};
+
+export interface PhoneNumber {
+ sid: string;
+ friendlyName: string;
+ voiceUrl?: string;
+}
+
+export type FunctionCall = {
+ name: string;
+ params: Record<string, any>;
+ completed?: boolean;
+ response?: string;
+ status?: string;
+ call_id?: string; // ensure each call has a call_id
+};
diff --git a/webapp/components/ui/alert.tsx b/webapp/components/ui/alert.tsx
new file mode 100644
index 0000000..41fa7e0
--- /dev/null
+++ b/webapp/components/ui/alert.tsx
@@ -0,0 +1,59 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
+>(({ className, variant, ...props }, ref) => (
+ <div
+ ref={ref}
+ role="alert"
+ className={cn(alertVariants({ variant }), className)}
+ {...props}
+ />
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLHeadingElement>
+>(({ className, ...props }, ref) => (
+ <h5
+ ref={ref}
+ className={cn("mb-1 font-medium leading-none tracking-tight", className)}
+ {...props}
+ />
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn("text-sm [&_p]:leading-relaxed", className)}
+ {...props}
+ />
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/webapp/components/ui/badge.tsx b/webapp/components/ui/badge.tsx
new file mode 100644
index 0000000..f000e3e
--- /dev/null
+++ b/webapp/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes<HTMLDivElement>,
+ VariantProps<typeof badgeVariants> {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/webapp/components/ui/button.tsx b/webapp/components/ui/button.tsx
new file mode 100644
index 0000000..0ba4277
--- /dev/null
+++ b/webapp/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+ VariantProps<typeof buttonVariants> {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+ <Comp
+ className={cn(buttonVariants({ variant, size, className }))}
+ ref={ref}
+ {...props}
+ />
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/webapp/components/ui/card.tsx b/webapp/components/ui/card.tsx
new file mode 100644
index 0000000..afa13ec
--- /dev/null
+++ b/webapp/components/ui/card.tsx
@@ -0,0 +1,79 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn(
+ "rounded-lg border bg-card text-card-foreground shadow-sm",
+ className
+ )}
+ {...props}
+ />
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
+ {...props}
+ />
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLHeadingElement>
+>(({ className, ...props }, ref) => (
+ <h3
+ ref={ref}
+ className={cn(
+ "text-2xl font-semibold leading-none tracking-tight",
+ className
+ )}
+ {...props}
+ />
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, ...props }, ref) => (
+ <p
+ ref={ref}
+ className={cn("text-sm text-muted-foreground", className)}
+ {...props}
+ />
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn("flex items-center p-6 pt-0", className)}
+ {...props}
+ />
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/webapp/components/ui/checkbox.tsx b/webapp/components/ui/checkbox.tsx
new file mode 100644
index 0000000..df61a13
--- /dev/null
+++ b/webapp/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { Check } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Checkbox = React.forwardRef<
+ React.ElementRef<typeof CheckboxPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
+>(({ className, ...props }, ref) => (
+ <CheckboxPrimitive.Root
+ ref={ref}
+ className={cn(
+ "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
+ className
+ )}
+ {...props}
+ >
+ <CheckboxPrimitive.Indicator
+ className={cn("flex items-center justify-center text-current")}
+ >
+ <Check className="h-4 w-4" />
+ </CheckboxPrimitive.Indicator>
+ </CheckboxPrimitive.Root>
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/webapp/components/ui/dialog.tsx b/webapp/components/ui/dialog.tsx
new file mode 100644
index 0000000..ab24cb3
--- /dev/null
+++ b/webapp/components/ui/dialog.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Overlay
+ ref={ref}
+ className={cn(
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+ className
+ )}
+ {...props}
+ />
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+ <DialogPortal>
+ <DialogOverlay />
+ <DialogPrimitive.Content
+ ref={ref}
+ className={cn(
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
+ className
+ )}
+ {...props}
+ >
+ {children}
+ {/* <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
+ <X className="h-4 w-4" />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close> */}
+ </DialogPrimitive.Content>
+ </DialogPortal>
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col space-y-1.5 text-center sm:text-left",
+ className
+ )}
+ {...props}
+ />
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
+ className
+ )}
+ {...props}
+ />
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Title>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Title
+ ref={ref}
+ className={cn(
+ "text-lg font-semibold leading-none tracking-tight",
+ className
+ )}
+ {...props}
+ />
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Description>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Description
+ ref={ref}
+ className={cn("text-sm text-muted-foreground", className)}
+ {...props}
+ />
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/webapp/components/ui/input.tsx b/webapp/components/ui/input.tsx
new file mode 100644
index 0000000..677d05f
--- /dev/null
+++ b/webapp/components/ui/input.tsx
@@ -0,0 +1,25 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface InputProps
+ extends React.InputHTMLAttributes<HTMLInputElement> {}
+
+const Input = React.forwardRef<HTMLInputElement, InputProps>(
+ ({ className, type, ...props }, ref) => {
+ return (
+ <input
+ type={type}
+ className={cn(
+ "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
+ className
+ )}
+ ref={ref}
+ {...props}
+ />
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/webapp/components/ui/label.tsx b/webapp/components/ui/label.tsx
new file mode 100644
index 0000000..5341821
--- /dev/null
+++ b/webapp/components/ui/label.tsx
@@ -0,0 +1,26 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+ React.ElementRef<typeof LabelPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
+ VariantProps<typeof labelVariants>
+>(({ className, ...props }, ref) => (
+ <LabelPrimitive.Root
+ ref={ref}
+ className={cn(labelVariants(), className)}
+ {...props}
+ />
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/webapp/components/ui/scroll-area.tsx b/webapp/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..0b4a48d
--- /dev/null
+++ b/webapp/components/ui/scroll-area.tsx
@@ -0,0 +1,48 @@
+"use client"
+
+import * as React from "react"
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+
+import { cn } from "@/lib/utils"
+
+const ScrollArea = React.forwardRef<
+ React.ElementRef<typeof ScrollAreaPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
+>(({ className, children, ...props }, ref) => (
+ <ScrollAreaPrimitive.Root
+ ref={ref}
+ className={cn("relative overflow-hidden", className)}
+ {...props}
+ >
+ <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
+ {children}
+ </ScrollAreaPrimitive.Viewport>
+ <ScrollBar />
+ <ScrollAreaPrimitive.Corner />
+ </ScrollAreaPrimitive.Root>
+))
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
+
+const ScrollBar = React.forwardRef<
+ React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
+>(({ className, orientation = "vertical", ...props }, ref) => (
+ <ScrollAreaPrimitive.ScrollAreaScrollbar
+ ref={ref}
+ orientation={orientation}
+ className={cn(
+ "flex touch-none select-none transition-colors",
+ orientation === "vertical" &&
+ "h-full w-2.5 border-l border-l-transparent p-[1px]",
+ orientation === "horizontal" &&
+ "h-2.5 flex-col border-t border-t-transparent p-[1px]",
+ className
+ )}
+ {...props}
+ >
+ <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
+ </ScrollAreaPrimitive.ScrollAreaScrollbar>
+))
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
+
+export { ScrollArea, ScrollBar }
diff --git a/webapp/components/ui/select.tsx b/webapp/components/ui/select.tsx
new file mode 100644
index 0000000..cbe5a36
--- /dev/null
+++ b/webapp/components/ui/select.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { Check, ChevronDown, ChevronUp } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Trigger>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
+>(({ className, children, ...props }, ref) => (
+ <SelectPrimitive.Trigger
+ ref={ref}
+ className={cn(
+ "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
+ className
+ )}
+ {...props}
+ >
+ {children}
+ <SelectPrimitive.Icon asChild>
+ <ChevronDown className="h-4 w-4 opacity-50" />
+ </SelectPrimitive.Icon>
+ </SelectPrimitive.Trigger>
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.ScrollUpButton
+ ref={ref}
+ className={cn(
+ "flex cursor-default items-center justify-center py-1",
+ className
+ )}
+ {...props}
+ >
+ <ChevronUp className="h-4 w-4" />
+ </SelectPrimitive.ScrollUpButton>
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.ScrollDownButton
+ ref={ref}
+ className={cn(
+ "flex cursor-default items-center justify-center py-1",
+ className
+ )}
+ {...props}
+ >
+ <ChevronDown className="h-4 w-4" />
+ </SelectPrimitive.ScrollDownButton>
+))
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
+>(({ className, children, position = "popper", ...props }, ref) => (
+ <SelectPrimitive.Portal>
+ <SelectPrimitive.Content
+ ref={ref}
+ className={cn(
+ "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ position === "popper" &&
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+ className
+ )}
+ position={position}
+ {...props}
+ >
+ <SelectScrollUpButton />
+ <SelectPrimitive.Viewport
+ className={cn(
+ "p-1",
+ position === "popper" &&
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
+ )}
+ >
+ {children}
+ </SelectPrimitive.Viewport>
+ <SelectScrollDownButton />
+ </SelectPrimitive.Content>
+ </SelectPrimitive.Portal>
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Label>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.Label
+ ref={ref}
+ className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
+ {...props}
+ />
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
+>(({ className, children, ...props }, ref) => (
+ <SelectPrimitive.Item
+ ref={ref}
+ className={cn(
+ "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ className
+ )}
+ {...props}
+ >
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+ <SelectPrimitive.ItemIndicator>
+ <Check className="h-4 w-4" />
+ </SelectPrimitive.ItemIndicator>
+ </span>
+
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+ </SelectPrimitive.Item>
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Separator>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.Separator
+ ref={ref}
+ className={cn("-mx-1 my-1 h-px bg-muted", className)}
+ {...props}
+ />
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+}
diff --git a/webapp/components/ui/textarea.tsx b/webapp/components/ui/textarea.tsx
new file mode 100644
index 0000000..9f9a6dc
--- /dev/null
+++ b/webapp/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
+
+const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
+ ({ className, ...props }, ref) => {
+ return (
+ <textarea
+ className={cn(
+ "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
+ className
+ )}
+ ref={ref}
+ {...props}
+ />
+ )
+ }
+)
+Textarea.displayName = "Textarea"
+
+export { Textarea }