diff --git a/apps/rowboat/app/actions/voice_actions.ts b/apps/rowboat/app/actions/voice_actions.ts
new file mode 100644
index 00000000..52a3622f
--- /dev/null
+++ b/apps/rowboat/app/actions/voice_actions.ts
@@ -0,0 +1,280 @@
+'use server';
+
+import { TwilioConfigParams, TwilioConfigResponse, TwilioConfig, InboundConfigResponse } from "../lib/types/voice_types";
+import { twilioConfigsCollection } from "../lib/mongodb";
+import { ObjectId } from "mongodb";
+import twilio from 'twilio';
+import { Twilio } from 'twilio';
+
+// Helper function to serialize MongoDB documents
+function serializeConfig(config: any) {
+ return {
+ ...config,
+ _id: config._id.toString(),
+ createdAt: config.createdAt.toISOString(),
+ };
+}
+
+// Real implementation for configuring Twilio number
+export async function configureTwilioNumber(params: TwilioConfigParams): Promise {
+ console.log('configureTwilioNumber - Received params:', params);
+ try {
+ const client = twilio(params.account_sid, params.auth_token);
+
+ try {
+ // List all phone numbers and find the matching one
+ const numbers = await client.incomingPhoneNumbers.list();
+ console.log('Twilio numbers for this account:', numbers);
+ const phoneExists = numbers.some(
+ number => number.phoneNumber === params.phone_number
+ );
+
+ if (!phoneExists) {
+ throw new Error('Phone number not found in this account');
+ }
+ } catch (error) {
+ console.error('Error verifying phone number:', error);
+ throw new Error(
+ error instanceof Error
+ ? error.message
+ : 'Invalid phone number or phone number does not belong to this account'
+ );
+ }
+
+ // Save to MongoDB after successful validation
+ const savedConfig = await saveTwilioConfig(params);
+ console.log('configureTwilioNumber - Saved config result:', savedConfig);
+
+ return { success: true };
+ } catch (error) {
+ console.error('Error in configureTwilioNumber:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to configure Twilio number'
+ };
+ }
+}
+
+// Save Twilio configuration to MongoDB
+export async function saveTwilioConfig(params: TwilioConfigParams): Promise {
+ console.log('saveTwilioConfig - Incoming params:', {
+ ...params,
+ label: {
+ value: params.label,
+ type: typeof params.label,
+ length: params.label?.length,
+ isEmpty: params.label === ''
+ }
+ });
+
+ // First, list all configs to see what's in the database
+ const allConfigs = await twilioConfigsCollection
+ .find({ status: 'active' as const })
+ .toArray();
+ console.log('saveTwilioConfig - All active configs in DB:', allConfigs);
+
+ // Find existing config for this project
+ const existingConfig = await twilioConfigsCollection.findOne({
+ project_id: params.project_id,
+ status: 'active' as const
+ });
+ console.log('saveTwilioConfig - Existing config search by project:', {
+ searchCriteria: {
+ project_id: params.project_id,
+ status: 'active'
+ },
+ found: existingConfig
+ });
+
+ const configToSave = {
+ phone_number: params.phone_number,
+ account_sid: params.account_sid,
+ auth_token: params.auth_token,
+ label: params.label || '', // Use empty string instead of undefined
+ project_id: params.project_id,
+ workflow_id: params.workflow_id,
+ createdAt: existingConfig?.createdAt || new Date(),
+ status: 'active' as const
+ };
+ console.log('saveTwilioConfig - Config to save:', configToSave);
+
+ try {
+ // Configure inbound calls first
+ await configureInboundCall(
+ params.phone_number,
+ params.account_sid,
+ params.auth_token,
+ params.workflow_id
+ );
+
+ // Then save/update the config in database
+ if (existingConfig) {
+ console.log('saveTwilioConfig - Updating existing config:', existingConfig._id);
+ const result = await twilioConfigsCollection.updateOne(
+ { _id: existingConfig._id },
+ { $set: configToSave }
+ );
+ console.log('saveTwilioConfig - Update result:', result);
+ } else {
+ console.log('saveTwilioConfig - No existing config found, creating new');
+ const result = await twilioConfigsCollection.insertOne(configToSave);
+ console.log('saveTwilioConfig - Insert result:', result);
+ }
+
+ const savedConfig = await twilioConfigsCollection.findOne({
+ project_id: params.project_id,
+ status: 'active'
+ });
+
+ if (!savedConfig) {
+ throw new Error('Failed to save Twilio configuration');
+ }
+
+ console.log('configureTwilioNumber - Saved config result:', savedConfig);
+ return savedConfig;
+
+ } catch (error) {
+ console.error('Error saving Twilio config:', error);
+ throw error;
+ }
+}
+
+// Get Twilio configuration for a workflow
+export async function getTwilioConfigs(projectId: string) {
+ console.log('getTwilioConfigs - Fetching for projectId:', projectId);
+ const configs = await twilioConfigsCollection
+ .find({
+ project_id: projectId,
+ status: 'active' as const
+ })
+ .sort({ createdAt: -1 })
+ .limit(1)
+ .toArray();
+
+ console.log('getTwilioConfigs - Raw configs:', configs);
+ const serializedConfigs = configs.map(serializeConfig);
+ console.log('getTwilioConfigs - Serialized configs:', serializedConfigs);
+ return serializedConfigs;
+}
+
+// Delete a Twilio configuration (soft delete)
+export async function deleteTwilioConfig(projectId: string, configId: string) {
+ console.log('deleteTwilioConfig - Deleting config:', { projectId, configId });
+ const result = await twilioConfigsCollection.updateOne(
+ {
+ _id: new ObjectId(configId),
+ project_id: projectId
+ },
+ {
+ $set: { status: 'deleted' as const }
+ }
+ );
+ console.log('deleteTwilioConfig - Delete result:', result);
+ return result;
+}
+
+// Mock implementation for testing/development
+export async function mockConfigureTwilioNumber(params: TwilioConfigParams): Promise {
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ await saveTwilioConfig(params);
+ return { success: true };
+}
+
+export async function configureInboundCall(
+ phone_number: string,
+ account_sid: string,
+ auth_token: string,
+ workflow_id: string
+): Promise {
+ try {
+ // Normalize phone number format
+ if (!phone_number.startsWith('+')) {
+ phone_number = '+' + phone_number;
+ }
+
+ console.log('Configuring inbound call for:', {
+ phone_number,
+ workflow_id
+ });
+
+ // Initialize Twilio client
+ const client = new Twilio(account_sid, auth_token);
+
+ // Find the phone number in Twilio account
+ const incomingPhoneNumbers = await client.incomingPhoneNumbers.list({ phoneNumber: phone_number });
+ console.log('Found Twilio numbers:', incomingPhoneNumbers.map(n => ({
+ phoneNumber: n.phoneNumber,
+ currentVoiceUrl: n.voiceUrl,
+ currentStatusCallback: n.statusCallback,
+ sid: n.sid
+ })));
+
+ if (!incomingPhoneNumbers.length) {
+ throw new Error(`Phone number ${phone_number} not found in Twilio account`);
+ }
+
+ const phoneSid = incomingPhoneNumbers[0].sid;
+ const currentVoiceUrl = incomingPhoneNumbers[0].voiceUrl;
+ const wasPreviouslyConfigured = Boolean(currentVoiceUrl);
+
+ // Get base URL from environment - MUST be a public URL
+ const baseUrl = process.env.VOICE_API_URL;
+ if (!baseUrl) {
+ throw new Error('Voice service URL not configured. Please set VOICE_API_URL environment variable.');
+ }
+
+ // Validate URL is not localhost
+ if (baseUrl.includes('localhost')) {
+ throw new Error('Voice service must use a public URL, not localhost.');
+ }
+
+ const inboundUrl = `${baseUrl}/inbound?workflow_id=${workflow_id}`;
+ console.log('Setting up webhooks:', {
+ voiceUrl: inboundUrl,
+ statusCallback: `${baseUrl}/call-status`,
+ currentConfig: {
+ voiceUrl: currentVoiceUrl,
+ statusCallback: incomingPhoneNumbers[0].statusCallback
+ }
+ });
+
+ // Update the phone number configuration
+ const updatedNumber = await client.incomingPhoneNumbers(phoneSid).update({
+ voiceUrl: inboundUrl,
+ voiceMethod: 'POST',
+ statusCallback: `${baseUrl}/call-status`,
+ statusCallbackMethod: 'POST'
+ });
+
+ console.log('Webhook configuration complete:', {
+ phoneNumber: updatedNumber.phoneNumber,
+ newVoiceUrl: updatedNumber.voiceUrl,
+ newStatusCallback: updatedNumber.statusCallback,
+ success: updatedNumber.voiceUrl === inboundUrl
+ });
+
+ return {
+ status: wasPreviouslyConfigured ? 'reconfigured' : 'configured',
+ phone_number: phone_number,
+ workflow_id: workflow_id,
+ previous_webhook: wasPreviouslyConfigured ? currentVoiceUrl : undefined
+ };
+
+ } catch (err: unknown) {
+ console.error('Error configuring inbound call:', err);
+
+ // Type guard for error with message property
+ if (err instanceof Error) {
+ if (err.message.includes('localhost')) {
+ throw new Error('Voice service needs to be accessible from the internet. Please check your configuration.');
+ }
+ // Type guard for Twilio error
+ if ('code' in err && err.code === 21402) {
+ throw new Error('Invalid voice service URL. Please make sure it\'s a public, secure URL.');
+ }
+ }
+
+ // If we can't determine the specific error, throw a generic one
+ throw new Error('Failed to configure phone number. Please check your settings and try again.');
+ }
+}
\ No newline at end of file
diff --git a/apps/rowboat/app/lib/components/editable-field-with-immediate-save.tsx b/apps/rowboat/app/lib/components/editable-field-with-immediate-save.tsx
new file mode 100644
index 00000000..472053ac
--- /dev/null
+++ b/apps/rowboat/app/lib/components/editable-field-with-immediate-save.tsx
@@ -0,0 +1,204 @@
+import { Button, Input, Textarea } from "@heroui/react";
+import { useEffect, useRef, useState } from "react";
+import { useClickAway } from "../../../hooks/use-click-away";
+import MarkdownContent from "./markdown-content";
+import clsx from "clsx";
+import { Label } from "./label";
+import { SparklesIcon } from "lucide-react";
+
+interface EditableFieldProps {
+ value: string;
+ onChange: (value: string) => void;
+ label?: string;
+ placeholder?: string;
+ markdown?: boolean;
+ multiline?: boolean;
+ locked?: boolean;
+ className?: string;
+ validate?: (value: string) => { valid: boolean; errorMessage?: string };
+ light?: boolean;
+ error?: string | null;
+ inline?: boolean;
+ showGenerateButton?: {
+ show: boolean;
+ setShow: (show: boolean) => void;
+ };
+ disabled?: boolean;
+ type?: string;
+}
+
+export function EditableField({
+ value,
+ onChange,
+ label,
+ placeholder = "Click to edit...",
+ markdown = false,
+ multiline = false,
+ locked = false,
+ className = "flex flex-col gap-1 w-full",
+ validate,
+ light = false,
+ error,
+ inline = false,
+ showGenerateButton,
+ disabled = false,
+ type = "text",
+}: EditableFieldProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [localValue, setLocalValue] = useState(value);
+ const ref = useRef(null);
+
+ const validationResult = validate?.(localValue);
+ const isValid = !validate || validationResult?.valid;
+
+ useEffect(() => {
+ setLocalValue(value);
+ }, [value]);
+
+ useClickAway(ref, () => {
+ if (isEditing) {
+ if (isValid && localValue !== value) {
+ onChange(localValue);
+ } else {
+ setLocalValue(value);
+ }
+ setIsEditing(false);
+ }
+ });
+
+ const onValueChange = (newValue: string) => {
+ setLocalValue(newValue);
+ onChange(newValue); // Always save immediately
+ };
+
+ const commonProps = {
+ autoFocus: true,
+ value: localValue,
+ onValueChange: onValueChange,
+ variant: "bordered" as const,
+ labelPlacement: "outside" as const,
+ placeholder: markdown ? '' : placeholder,
+ classNames: {
+ input: "rounded-md",
+ inputWrapper: "rounded-md border-medium"
+ },
+ radius: "md" as const,
+ isInvalid: !isValid,
+ errorMessage: validationResult?.errorMessage,
+ onKeyDown: (e: React.KeyboardEvent) => {
+ if (!multiline && e.key === "Enter") {
+ e.preventDefault();
+ if (isValid && localValue !== value) {
+ onChange(localValue);
+ }
+ setIsEditing(false);
+ }
+ if (e.key === "Escape") {
+ setLocalValue(value);
+ setIsEditing(false);
+ }
+ },
+ };
+
+ if (isEditing) {
+ return (
+
+ {label && (
+
+
+
+ {showGenerateButton && (
+ }
+ onPress={() => showGenerateButton.setShow(true)}
+ >
+ Generate
+
+ )}
+
+
+ )}
+ {multiline &&
}
+ {!multiline &&
}
+
+ );
+ }
+
+ return (
+
+ {label && (
+
+
+ {showGenerateButton && (
+ }
+ onPress={() => showGenerateButton.setShow(true)}
+ >
+ Generate
+
+ )}
+
+ )}
+
!locked && setIsEditing(true)}
+ >
+ {value ? (
+ <>
+ {markdown &&
+
+
}
+ {!markdown &&
+ {value}
+
}
+ >
+ ) : (
+ <>
+ {markdown &&
+
+
}
+ {!markdown &&
{placeholder}}
+ >
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/rowboat/app/lib/mongodb.ts b/apps/rowboat/app/lib/mongodb.ts
index 4ebda976..b6e0ca4e 100644
--- a/apps/rowboat/app/lib/mongodb.ts
+++ b/apps/rowboat/app/lib/mongodb.ts
@@ -8,6 +8,7 @@ import { EmbeddingDoc } from "./types/datasource_types";
import { DataSourceDoc } from "./types/datasource_types";
import { DataSource } from "./types/datasource_types";
import { TestScenario, TestResult, TestRun, TestProfile, TestSimulation } from "./types/testing_types";
+import { TwilioConfig } from "./types/voice_types";
import { z } from 'zod';
import { apiV1 } from "rowboat-shared";
@@ -28,4 +29,16 @@ export const testSimulationsCollection = db.collection>("test_runs");
export const testResultsCollection = db.collection>("test_results");
export const chatsCollection = db.collection>("chats");
-export const chatMessagesCollection = db.collection>("chat_messages");
\ No newline at end of file
+export const chatMessagesCollection = db.collection>("chat_messages");
+export const twilioConfigsCollection = db.collection>("twilio_configs");
+
+// Create indexes
+twilioConfigsCollection.createIndexes([
+ {
+ key: { workflow_id: 1, status: 1 },
+ name: "workflow_status_idx",
+ // This ensures only one active config per workflow
+ unique: true,
+ partialFilterExpression: { status: "active" }
+ }
+]);
\ No newline at end of file
diff --git a/apps/rowboat/app/lib/types/voice_types.ts b/apps/rowboat/app/lib/types/voice_types.ts
new file mode 100644
index 00000000..7780103e
--- /dev/null
+++ b/apps/rowboat/app/lib/types/voice_types.ts
@@ -0,0 +1,32 @@
+import { z } from 'zod';
+import { WithId } from 'mongodb';
+
+export const TwilioConfigParams = z.object({
+ phone_number: z.string(),
+ account_sid: z.string(),
+ auth_token: z.string(),
+ label: z.string(),
+ project_id: z.string(),
+ workflow_id: z.string(),
+});
+
+export const TwilioConfig = TwilioConfigParams.extend({
+ createdAt: z.date(),
+ status: z.enum(['active', 'deleted']),
+});
+
+export type TwilioConfigParams = z.infer;
+export type TwilioConfig = WithId>;
+
+export interface TwilioConfigResponse {
+ success: boolean;
+ error?: string;
+}
+
+export interface InboundConfigResponse {
+ status: 'configured' | 'reconfigured';
+ phone_number: string;
+ workflow_id: string;
+ previous_webhook?: string;
+ error?: string;
+}
\ No newline at end of file
diff --git a/apps/rowboat/app/projects/[projectId]/config/app.tsx b/apps/rowboat/app/projects/[projectId]/config/app.tsx
index 0716d32b..2d341763 100644
--- a/apps/rowboat/app/projects/[projectId]/config/app.tsx
+++ b/apps/rowboat/app/projects/[projectId]/config/app.tsx
@@ -7,12 +7,21 @@ import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, de
import { updateMcpServers } from "../../../actions/mcp_actions";
import { CopyButton } from "../../../lib/components/copy-button";
import { EditableField } from "../../../lib/components/editable-field";
-import { EyeIcon, EyeOffIcon, CopyIcon, MoreVerticalIcon, PlusIcon, EllipsisVerticalIcon } from "lucide-react";
+import { EyeIcon, EyeOffIcon, CopyIcon, MoreVerticalIcon, PlusIcon, EllipsisVerticalIcon, CheckCircleIcon, XCircleIcon } from "lucide-react";
import { WithStringId } from "../../../lib/types/types";
import { ApiKey } from "../../../lib/types/project_types";
import { z } from "zod";
import { RelativeTime } from "@primer/react";
import { Label } from "../../../lib/components/label";
+import { ListItem } from "../../../lib/components/structured-list";
+import { FormSection } from "../../../lib/components/form-section";
+import { StructuredPanel } from "../../../lib/components/structured-panel";
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+} from "../../../../components/ui/resizable"
+import { VoiceSection } from './voice';
export const metadata: Metadata = {
title: "Project config",
@@ -79,38 +88,29 @@ export function BasicSettingsSection({
}
return
-
-
-
-
-
- {loading && }
- {!loading && }
-
-
-
+
+ {loading && }
+ {!loading && }
+
-
-
-
-
-
{projectId}
-
{
- navigator.clipboard.writeText(projectId);
- }}
- label="Copy"
- successLabel="Copied"
- />
-
-
-
+
+
+
{projectId}
+
{
+ navigator.clipboard.writeText(projectId);
+ }}
+ label="Copy"
+ successLabel="Copied"
+ />
+
+
;
}
@@ -630,20 +630,15 @@ export function WebhookUrlSection({
In workflow editor, tool calls will be posted to this URL, unless they are mocked.
-
-
-
-
- {loading && }
- {!loading && }
-
-
-
+
+ {loading && }
+ {!loading && }
+
;
}
@@ -789,7 +784,30 @@ export function DeleteProjectSection({
);
}
-export default function App({
+function NavigationMenu({
+ selected,
+ onSelect
+}: {
+ selected: string;
+ onSelect: (page: string) => void;
+}) {
+ const items = ['Project', 'Tools', 'Voice'];
+
+ return (
+
+ {items.map((item) => (
+ onSelect(item)}
+ />
+ ))}
+
+ );
+}
+
+export function ConfigApp({
projectId,
useChatWidget,
chatWidgetHost,
@@ -798,22 +816,54 @@ export default function App({
useChatWidget: boolean;
chatWidgetHost: string;
}) {
- return
-
-
-
-
-
-
-
-
- {useChatWidget &&
}
-
-
-
-
;
-}
\ No newline at end of file
+ const [selectedPage, setSelectedPage] = useState('Project');
+
+ const renderContent = () => {
+ switch (selectedPage) {
+ case 'Project':
+ return (
+
+
+
+
+
+
+ {useChatWidget &&
}
+
+
+ );
+ case 'Tools':
+ return (
+
+
+
+ );
+ case 'Voice':
+ return (
+
+
+
+ );
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {renderContent()}
+
+
+ );
+}
+
+// Add default export
+export default ConfigApp;
\ No newline at end of file
diff --git a/apps/rowboat/app/projects/[projectId]/config/voice.tsx b/apps/rowboat/app/projects/[projectId]/config/voice.tsx
new file mode 100644
index 00000000..b12df972
--- /dev/null
+++ b/apps/rowboat/app/projects/[projectId]/config/voice.tsx
@@ -0,0 +1,229 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { Button } from "@nextui-org/react";
+import { configureTwilioNumber, mockConfigureTwilioNumber, getTwilioConfigs, deleteTwilioConfig } from "../../../actions/voice_actions";
+import { FormSection } from "../../../lib/components/form-section";
+import { EditableField } from "../../../lib/components/editable-field-with-immediate-save";
+import { StructuredPanel } from "../../../lib/components/structured-panel";
+import { TwilioConfig } from "../../../lib/types/voice_types";
+import { CheckCircleIcon, XCircleIcon, InfoIcon } from "lucide-react";
+
+export function VoiceSection({
+ projectId,
+}: {
+ projectId: string;
+}) {
+ const [formState, setFormState] = useState({
+ phone: '',
+ accountSid: '',
+ authToken: '',
+ label: ''
+ });
+ const [existingConfig, setExistingConfig] = useState(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [success, setSuccess] = useState(false);
+ const [configurationValid, setConfigurationValid] = useState(false);
+ const [isDirty, setIsDirty] = useState(false);
+
+ const loadConfig = useCallback(async () => {
+ try {
+ const configs = await getTwilioConfigs(projectId);
+ if (configs.length > 0) {
+ const config = configs[0];
+ setExistingConfig(config);
+ setFormState({
+ phone: config.phone_number,
+ accountSid: config.account_sid,
+ authToken: config.auth_token,
+ label: config.label || ''
+ });
+ setConfigurationValid(true);
+ setIsDirty(false);
+ }
+ } catch (err) {
+ console.error('Error loading config:', err);
+ }
+ }, [projectId]);
+
+ useEffect(() => {
+ loadConfig();
+ }, [loadConfig]);
+
+ const handleFieldChange = (field: string, value: string) => {
+ setFormState(prev => ({
+ ...prev,
+ [field]: value
+ }));
+ setIsDirty(true);
+ setError(null);
+ };
+
+ const handleConfigureTwilio = async () => {
+ if (!formState.phone || !formState.accountSid || !formState.authToken) {
+ setError('Please fill in all required fields');
+ setConfigurationValid(false);
+ return;
+ }
+
+ const workflowId = localStorage.getItem(`lastWorkflowId_${projectId}`);
+ if (!workflowId) {
+ setError('No workflow selected. Please select a workflow first.');
+ setConfigurationValid(false);
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ const configParams = {
+ phone_number: formState.phone,
+ account_sid: formState.accountSid,
+ auth_token: formState.authToken,
+ label: formState.label,
+ project_id: projectId,
+ workflow_id: workflowId,
+ };
+
+ const result = await configureTwilioNumber(configParams);
+
+ if (result.success) {
+ await loadConfig();
+ setSuccess(true);
+ setConfigurationValid(true);
+ setIsDirty(false);
+ setTimeout(() => setSuccess(false), 3000);
+ } else {
+ setError(result.error || 'Failed to validate Twilio credentials or phone number');
+ setConfigurationValid(false);
+ }
+
+ setLoading(false);
+ };
+
+ const handleDeleteConfig = async () => {
+ if (!existingConfig) return;
+
+ if (confirm('Are you sure you want to delete this phone number configuration?')) {
+ await deleteTwilioConfig(projectId, existingConfig._id.toString());
+ setExistingConfig(null);
+ setFormState({
+ phone: '',
+ accountSid: '',
+ authToken: '',
+ label: ''
+ });
+ setConfigurationValid(false);
+ setIsDirty(false);
+ }
+ };
+
+ return (
+
+
+
+ {success && (
+
+
+
+ {existingConfig
+ ? 'Twilio number validated and updated successfully!'
+ : 'Twilio number validated and configured successfully!'}
+
+
+ )}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {existingConfig && configurationValid && !error && (
+
+
+ This is your currently assigned phone number for this project
+
+ )}
+
+
+ handleFieldChange('phone', value)}
+ placeholder="+14156021922"
+ disabled={loading}
+ />
+
+
+
+ handleFieldChange('accountSid', value)}
+ placeholder="AC5588686d3ec65df89615274..."
+ disabled={loading}
+ />
+
+
+
+ handleFieldChange('authToken', value)}
+ placeholder="b74e48f9098764ef834cf6bd..."
+ type="password"
+ disabled={loading}
+ />
+
+
+
+ handleFieldChange('label', value)}
+ placeholder="Enter a label for this number..."
+ disabled={loading}
+ />
+
+
+
+
+ {existingConfig ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/rowboat/package.json b/apps/rowboat/package.json
index bbdefb15..6ee4e6ee 100644
--- a/apps/rowboat/package.json
+++ b/apps/rowboat/package.json
@@ -58,6 +58,7 @@
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"tiktoken": "^1.0.17",
+ "twilio": "^5.4.5",
"typewriter-effect": "^2.21.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.23.5"
diff --git a/apps/voice/Dockerfile b/apps/voice/Dockerfile
new file mode 100644
index 00000000..b0d7fe4c
--- /dev/null
+++ b/apps/voice/Dockerfile
@@ -0,0 +1,27 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y \
+ build-essential \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy requirements first to leverage Docker cache
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy application code
+COPY . .
+
+# Debug: List files in /app
+RUN ls -la /app
+
+# Set environment variables
+ENV PYTHONPATH=/app
+
+# Expose the port the app runs on
+EXPOSE 3009
+
+# Command to run the application directly with Python
+CMD ["python", "twilio_server.py"]
\ No newline at end of file
diff --git a/apps/voice/__init__.py b/apps/voice/__init__.py
new file mode 100644
index 00000000..3aae30f6
--- /dev/null
+++ b/apps/voice/__init__.py
@@ -0,0 +1 @@
+# Empty file to mark directory as Python package
\ No newline at end of file
diff --git a/apps/voice/load_env.py b/apps/voice/load_env.py
new file mode 100644
index 00000000..309c2390
--- /dev/null
+++ b/apps/voice/load_env.py
@@ -0,0 +1,6 @@
+from dotenv import load_dotenv
+import os
+
+def load_environment():
+ """Load environment variables from .env file"""
+ load_dotenv()
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index f54fba01..e5f81a8c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -35,6 +35,7 @@ services:
- CHAT_WIDGET_SESSION_JWT_SECRET=${CHAT_WIDGET_SESSION_JWT_SECRET}
- MAX_QUERIES_PER_MINUTE=${MAX_QUERIES_PER_MINUTE}
- MAX_PROJECTS_PER_USER=${MAX_PROJECTS_PER_USER}
+ - VOICE_API_URL=${VOICE_API_URL}
restart: unless-stopped
rowboat_agents:
@@ -161,4 +162,21 @@ services:
profiles: [ "docs" ]
ports:
- "8000:8000"
+
+ voice:
+ build:
+ context: ./apps/voice
+ dockerfile: Dockerfile
+ ports:
+ - "3009:3009"
+ environment:
+ - TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID}
+ - TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN}
+ - DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY}
+ - ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY}
+ - ROWBOAT_API_HOST=http://rowboat:3000
+ - ROWBOAT_PROJECT_ID=${ROWBOAT_PROJECT_ID}
+ - ROWBOAT_API_KEY=${ROWBOAT_API_KEY}
+ - REDIS_URL=redis://redis:6379
+ - BASE_URL=${BASE_URL}
restart: unless-stopped