mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
Add twilio configuration user flow
This commit is contained in:
parent
4b3395ea3a
commit
de6b3cbbbb
11 changed files with 926 additions and 65 deletions
280
apps/rowboat/app/actions/voice_actions.ts
Normal file
280
apps/rowboat/app/actions/voice_actions.ts
Normal file
|
|
@ -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<TwilioConfigResponse> {
|
||||
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<TwilioConfig> {
|
||||
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<TwilioConfigResponse> {
|
||||
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<InboundConfigResponse> {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div ref={ref} className={clsx("flex flex-col gap-1 w-full", className)}>
|
||||
{label && (
|
||||
<div className="flex justify-between items-center">
|
||||
<Label label={label} />
|
||||
<div className="flex gap-2 items-center">
|
||||
{showGenerateButton && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
startContent={<SparklesIcon size={16} />}
|
||||
onPress={() => showGenerateButton.setShow(true)}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{multiline && <Textarea
|
||||
{...commonProps}
|
||||
minRows={3}
|
||||
maxRows={20}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
...commonProps.classNames,
|
||||
input: "rounded-md py-2",
|
||||
inputWrapper: "rounded-md border-medium py-1"
|
||||
}}
|
||||
/>}
|
||||
{!multiline && <Input
|
||||
{...commonProps}
|
||||
type={type}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
...commonProps.classNames,
|
||||
input: "rounded-md py-2",
|
||||
inputWrapper: "rounded-md border-medium py-1"
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={clsx("cursor-text", className)}>
|
||||
{label && (
|
||||
<div className="flex justify-between items-center">
|
||||
<Label label={label} />
|
||||
{showGenerateButton && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
startContent={<SparklesIcon size={16} />}
|
||||
onPress={() => showGenerateButton.setShow(true)}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
{
|
||||
"border border-gray-300 dark:border-gray-600 rounded px-3 py-3": !inline,
|
||||
"bg-transparent focus:outline-none focus:ring-0 border-0 rounded-none text-gray-900 dark:text-gray-100": inline,
|
||||
}
|
||||
)}
|
||||
style={inline ? {
|
||||
border: 'none',
|
||||
borderRadius: '0',
|
||||
padding: '0'
|
||||
} : undefined}
|
||||
onClick={() => !locked && setIsEditing(true)}
|
||||
>
|
||||
{value ? (
|
||||
<>
|
||||
{markdown && <div className="max-h-[420px] overflow-y-auto">
|
||||
<MarkdownContent content={value} />
|
||||
</div>}
|
||||
{!markdown && <div className={`${multiline ? 'whitespace-pre-wrap max-h-[420px] overflow-y-auto' : 'flex items-center'}`}>
|
||||
{value}
|
||||
</div>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400">
|
||||
<MarkdownContent content={placeholder} />
|
||||
</div>}
|
||||
{!markdown && <span className="text-gray-400">{placeholder}</span>}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-xs text-red-500 mt-1">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -29,3 +30,15 @@ export const testRunsCollection = db.collection<z.infer<typeof TestRun>>("test_r
|
|||
export const testResultsCollection = db.collection<z.infer<typeof TestResult>>("test_results");
|
||||
export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
|
||||
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");
|
||||
export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("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" }
|
||||
}
|
||||
]);
|
||||
32
apps/rowboat/app/lib/types/voice_types.ts
Normal file
32
apps/rowboat/app/lib/types/voice_types.ts
Normal file
|
|
@ -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<typeof TwilioConfigParams>;
|
||||
export type TwilioConfig = WithId<z.infer<typeof TwilioConfig>>;
|
||||
|
||||
export interface TwilioConfigResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface InboundConfigResponse {
|
||||
status: 'configured' | 'reconfigured';
|
||||
phone_number: string;
|
||||
workflow_id: string;
|
||||
previous_webhook?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -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 <Section title="Basic settings">
|
||||
|
||||
<SectionRow>
|
||||
<LeftLabel label="Project name" />
|
||||
<RightContent>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
{loading && <Spinner size="sm" />}
|
||||
{!loading && <EditableField
|
||||
value={projectName || ''}
|
||||
onChange={updateName}
|
||||
className="w-full"
|
||||
/>}
|
||||
</div>
|
||||
</RightContent>
|
||||
</SectionRow>
|
||||
<FormSection label="Project name">
|
||||
{loading && <Spinner size="sm" />}
|
||||
{!loading && <EditableField
|
||||
value={projectName || ''}
|
||||
onChange={updateName}
|
||||
className="w-full"
|
||||
/>}
|
||||
</FormSection>
|
||||
|
||||
<Divider />
|
||||
|
||||
<SectionRow>
|
||||
<LeftLabel label="Project ID" />
|
||||
<RightContent>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<div className="text-gray-600 text-sm font-mono">{projectId}</div>
|
||||
<CopyButton
|
||||
onCopy={() => {
|
||||
navigator.clipboard.writeText(projectId);
|
||||
}}
|
||||
label="Copy"
|
||||
successLabel="Copied"
|
||||
/>
|
||||
</div>
|
||||
</RightContent>
|
||||
</SectionRow>
|
||||
<FormSection label="Project ID">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<div className="text-gray-600 text-sm font-mono">{projectId}</div>
|
||||
<CopyButton
|
||||
onCopy={() => {
|
||||
navigator.clipboard.writeText(projectId);
|
||||
}}
|
||||
label="Copy"
|
||||
successLabel="Copied"
|
||||
/>
|
||||
</div>
|
||||
</FormSection>
|
||||
</Section>;
|
||||
}
|
||||
|
||||
|
|
@ -630,20 +630,15 @@ export function WebhookUrlSection({
|
|||
In workflow editor, tool calls will be posted to this URL, unless they are mocked.
|
||||
</p>
|
||||
<Divider />
|
||||
<SectionRow>
|
||||
<LeftLabel label="Webhook URL" />
|
||||
<RightContent>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
{loading && <Spinner size="sm" />}
|
||||
{!loading && <EditableField
|
||||
value={webhookUrl || ''}
|
||||
onChange={update}
|
||||
validate={validate}
|
||||
className="w-full"
|
||||
/>}
|
||||
</div>
|
||||
</RightContent>
|
||||
</SectionRow>
|
||||
<FormSection label="Webhook URL">
|
||||
{loading && <Spinner size="sm" />}
|
||||
{!loading && <EditableField
|
||||
value={webhookUrl || ''}
|
||||
onChange={update}
|
||||
validate={validate}
|
||||
className="w-full"
|
||||
/>}
|
||||
</FormSection>
|
||||
</Section>;
|
||||
}
|
||||
|
||||
|
|
@ -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 (
|
||||
<StructuredPanel title="SETTINGS">
|
||||
{items.map((item) => (
|
||||
<ListItem
|
||||
key={item}
|
||||
name={item}
|
||||
isSelected={selected === item}
|
||||
onClick={() => onSelect(item)}
|
||||
/>
|
||||
))}
|
||||
</StructuredPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigApp({
|
||||
projectId,
|
||||
useChatWidget,
|
||||
chatWidgetHost,
|
||||
|
|
@ -798,22 +816,54 @@ export default function App({
|
|||
useChatWidget: boolean;
|
||||
chatWidgetHost: string;
|
||||
}) {
|
||||
return <div className="flex flex-col h-full">
|
||||
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-border">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-lg">Project config</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow overflow-auto py-4">
|
||||
<div className="max-w-[768px] mx-auto flex flex-col gap-4">
|
||||
<BasicSettingsSection projectId={projectId} />
|
||||
<SecretSection projectId={projectId} />
|
||||
<ApiKeysSection projectId={projectId} />
|
||||
<McpServersSection projectId={projectId} />
|
||||
<WebhookUrlSection projectId={projectId} />
|
||||
{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />}
|
||||
<DeleteProjectSection projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
const [selectedPage, setSelectedPage] = useState('Project');
|
||||
|
||||
const renderContent = () => {
|
||||
switch (selectedPage) {
|
||||
case 'Project':
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6 space-y-6">
|
||||
<BasicSettingsSection projectId={projectId} />
|
||||
<SecretSection projectId={projectId} />
|
||||
<McpServersSection projectId={projectId} />
|
||||
<WebhookUrlSection projectId={projectId} />
|
||||
<ApiKeysSection projectId={projectId} />
|
||||
{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />}
|
||||
<DeleteProjectSection projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
case 'Tools':
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<WebhookUrlSection projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
case 'Voice':
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<VoiceSection projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup direction="horizontal" className="h-screen gap-1">
|
||||
<ResizablePanel minSize={10} defaultSize={15}>
|
||||
<NavigationMenu
|
||||
selected={selectedPage}
|
||||
onSelect={setSelectedPage}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel minSize={20} defaultSize={85}>
|
||||
{renderContent()}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// Add default export
|
||||
export default ConfigApp;
|
||||
229
apps/rowboat/app/projects/[projectId]/config/voice.tsx
Normal file
229
apps/rowboat/app/projects/[projectId]/config/voice.tsx
Normal file
|
|
@ -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<TwilioConfig | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<StructuredPanel title="CONFIGURE TWILIO PHONE NUMBER">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
{success && (
|
||||
<div className="bg-green-50 text-green-700 p-4 rounded-md flex items-center gap-2">
|
||||
<CheckCircleIcon className="w-5 h-5" />
|
||||
<span>
|
||||
{existingConfig
|
||||
? 'Twilio number validated and updated successfully!'
|
||||
: 'Twilio number validated and configured successfully!'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-700 p-4 rounded-md flex items-center gap-2">
|
||||
<XCircleIcon className="w-5 h-5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingConfig && configurationValid && !error && (
|
||||
<div className="bg-blue-50 text-blue-700 p-4 rounded-md flex items-center gap-2">
|
||||
<InfoIcon className="w-5 h-5" />
|
||||
<span>This is your currently assigned phone number for this project</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormSection label="TWILIO PHONE NUMBER">
|
||||
<EditableField
|
||||
value={formState.phone}
|
||||
onChange={(value) => handleFieldChange('phone', value)}
|
||||
placeholder="+14156021922"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection label="TWILIO ACCOUNT SID">
|
||||
<EditableField
|
||||
value={formState.accountSid}
|
||||
onChange={(value) => handleFieldChange('accountSid', value)}
|
||||
placeholder="AC5588686d3ec65df89615274..."
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection label="TWILIO AUTH TOKEN">
|
||||
<EditableField
|
||||
value={formState.authToken}
|
||||
onChange={(value) => handleFieldChange('authToken', value)}
|
||||
placeholder="b74e48f9098764ef834cf6bd..."
|
||||
type="password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection label="LABEL">
|
||||
<EditableField
|
||||
value={formState.label}
|
||||
onChange={(value) => handleFieldChange('label', value)}
|
||||
placeholder="Enter a label for this number..."
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleConfigureTwilio}
|
||||
isLoading={loading}
|
||||
disabled={loading || !isDirty}
|
||||
>
|
||||
{existingConfig ? 'Update Twilio Config' : 'Import from Twilio'}
|
||||
</Button>
|
||||
{existingConfig ? (
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onClick={handleDeleteConfig}
|
||||
disabled={loading}
|
||||
>
|
||||
Delete Configuration
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="flat"
|
||||
onClick={() => {
|
||||
setFormState({
|
||||
phone: '',
|
||||
accountSid: '',
|
||||
authToken: '',
|
||||
label: ''
|
||||
});
|
||||
setError(null);
|
||||
setIsDirty(false);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StructuredPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
27
apps/voice/Dockerfile
Normal file
27
apps/voice/Dockerfile
Normal file
|
|
@ -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"]
|
||||
1
apps/voice/__init__.py
Normal file
1
apps/voice/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Empty file to mark directory as Python package
|
||||
6
apps/voice/load_env.py
Normal file
6
apps/voice/load_env.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
def load_environment():
|
||||
"""Load environment variables from .env file"""
|
||||
load_dotenv()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue