diff options
Diffstat (limited to 'webapp/components')
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 } |
