mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-26 08:56:22 +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 { DataSourceDoc } from "./types/datasource_types";
|
||||||
import { DataSource } from "./types/datasource_types";
|
import { DataSource } from "./types/datasource_types";
|
||||||
import { TestScenario, TestResult, TestRun, TestProfile, TestSimulation } from "./types/testing_types";
|
import { TestScenario, TestResult, TestRun, TestProfile, TestSimulation } from "./types/testing_types";
|
||||||
|
import { TwilioConfig } from "./types/voice_types";
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { apiV1 } from "rowboat-shared";
|
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 testResultsCollection = db.collection<z.infer<typeof TestResult>>("test_results");
|
||||||
export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
|
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 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 { updateMcpServers } from "../../../actions/mcp_actions";
|
||||||
import { CopyButton } from "../../../lib/components/copy-button";
|
import { CopyButton } from "../../../lib/components/copy-button";
|
||||||
import { EditableField } from "../../../lib/components/editable-field";
|
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 { WithStringId } from "../../../lib/types/types";
|
||||||
import { ApiKey } from "../../../lib/types/project_types";
|
import { ApiKey } from "../../../lib/types/project_types";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { RelativeTime } from "@primer/react";
|
import { RelativeTime } from "@primer/react";
|
||||||
import { Label } from "../../../lib/components/label";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Project config",
|
title: "Project config",
|
||||||
|
|
@ -79,38 +88,29 @@ export function BasicSettingsSection({
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Section title="Basic settings">
|
return <Section title="Basic settings">
|
||||||
|
<FormSection label="Project name">
|
||||||
<SectionRow>
|
{loading && <Spinner size="sm" />}
|
||||||
<LeftLabel label="Project name" />
|
{!loading && <EditableField
|
||||||
<RightContent>
|
value={projectName || ''}
|
||||||
<div className="flex flex-row gap-2 items-center">
|
onChange={updateName}
|
||||||
{loading && <Spinner size="sm" />}
|
className="w-full"
|
||||||
{!loading && <EditableField
|
/>}
|
||||||
value={projectName || ''}
|
</FormSection>
|
||||||
onChange={updateName}
|
|
||||||
className="w-full"
|
|
||||||
/>}
|
|
||||||
</div>
|
|
||||||
</RightContent>
|
|
||||||
</SectionRow>
|
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<SectionRow>
|
<FormSection label="Project ID">
|
||||||
<LeftLabel label="Project ID" />
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<RightContent>
|
<div className="text-gray-600 text-sm font-mono">{projectId}</div>
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<CopyButton
|
||||||
<div className="text-gray-600 text-sm font-mono">{projectId}</div>
|
onCopy={() => {
|
||||||
<CopyButton
|
navigator.clipboard.writeText(projectId);
|
||||||
onCopy={() => {
|
}}
|
||||||
navigator.clipboard.writeText(projectId);
|
label="Copy"
|
||||||
}}
|
successLabel="Copied"
|
||||||
label="Copy"
|
/>
|
||||||
successLabel="Copied"
|
</div>
|
||||||
/>
|
</FormSection>
|
||||||
</div>
|
|
||||||
</RightContent>
|
|
||||||
</SectionRow>
|
|
||||||
</Section>;
|
</Section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -630,20 +630,15 @@ export function WebhookUrlSection({
|
||||||
In workflow editor, tool calls will be posted to this URL, unless they are mocked.
|
In workflow editor, tool calls will be posted to this URL, unless they are mocked.
|
||||||
</p>
|
</p>
|
||||||
<Divider />
|
<Divider />
|
||||||
<SectionRow>
|
<FormSection label="Webhook URL">
|
||||||
<LeftLabel label="Webhook URL" />
|
{loading && <Spinner size="sm" />}
|
||||||
<RightContent>
|
{!loading && <EditableField
|
||||||
<div className="flex flex-row gap-2 items-center">
|
value={webhookUrl || ''}
|
||||||
{loading && <Spinner size="sm" />}
|
onChange={update}
|
||||||
{!loading && <EditableField
|
validate={validate}
|
||||||
value={webhookUrl || ''}
|
className="w-full"
|
||||||
onChange={update}
|
/>}
|
||||||
validate={validate}
|
</FormSection>
|
||||||
className="w-full"
|
|
||||||
/>}
|
|
||||||
</div>
|
|
||||||
</RightContent>
|
|
||||||
</SectionRow>
|
|
||||||
</Section>;
|
</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,
|
projectId,
|
||||||
useChatWidget,
|
useChatWidget,
|
||||||
chatWidgetHost,
|
chatWidgetHost,
|
||||||
|
|
@ -798,22 +816,54 @@ export default function App({
|
||||||
useChatWidget: boolean;
|
useChatWidget: boolean;
|
||||||
chatWidgetHost: string;
|
chatWidgetHost: string;
|
||||||
}) {
|
}) {
|
||||||
return <div className="flex flex-col h-full">
|
const [selectedPage, setSelectedPage] = useState('Project');
|
||||||
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-border">
|
|
||||||
<div className="flex flex-col">
|
const renderContent = () => {
|
||||||
<h1 className="text-lg">Project config</h1>
|
switch (selectedPage) {
|
||||||
</div>
|
case 'Project':
|
||||||
</div>
|
return (
|
||||||
<div className="grow overflow-auto py-4">
|
<div className="h-full overflow-auto p-6 space-y-6">
|
||||||
<div className="max-w-[768px] mx-auto flex flex-col gap-4">
|
<BasicSettingsSection projectId={projectId} />
|
||||||
<BasicSettingsSection projectId={projectId} />
|
<SecretSection projectId={projectId} />
|
||||||
<SecretSection projectId={projectId} />
|
<McpServersSection projectId={projectId} />
|
||||||
<ApiKeysSection projectId={projectId} />
|
<WebhookUrlSection projectId={projectId} />
|
||||||
<McpServersSection projectId={projectId} />
|
<ApiKeysSection projectId={projectId} />
|
||||||
<WebhookUrlSection projectId={projectId} />
|
{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />}
|
||||||
{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />}
|
<DeleteProjectSection projectId={projectId} />
|
||||||
<DeleteProjectSection projectId={projectId} />
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
case 'Tools':
|
||||||
</div>;
|
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",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tiktoken": "^1.0.17",
|
"tiktoken": "^1.0.17",
|
||||||
|
"twilio": "^5.4.5",
|
||||||
"typewriter-effect": "^2.21.0",
|
"typewriter-effect": "^2.21.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zod-to-json-schema": "^3.23.5"
|
"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}
|
- CHAT_WIDGET_SESSION_JWT_SECRET=${CHAT_WIDGET_SESSION_JWT_SECRET}
|
||||||
- MAX_QUERIES_PER_MINUTE=${MAX_QUERIES_PER_MINUTE}
|
- MAX_QUERIES_PER_MINUTE=${MAX_QUERIES_PER_MINUTE}
|
||||||
- MAX_PROJECTS_PER_USER=${MAX_PROJECTS_PER_USER}
|
- MAX_PROJECTS_PER_USER=${MAX_PROJECTS_PER_USER}
|
||||||
|
- VOICE_API_URL=${VOICE_API_URL}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
rowboat_agents:
|
rowboat_agents:
|
||||||
|
|
@ -161,4 +162,21 @@ services:
|
||||||
profiles: [ "docs" ]
|
profiles: [ "docs" ]
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "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
|
restart: unless-stopped
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue