feat: cal ai templates (#15391)
* chore: save progress * feat: add schema and fieds * chore: update code * chore: save * refactor: code and type error * feat: use facade + command design pattern * chore: missing param * feat: add custom template feature * refactor: code * chore: move the default prompt * fix: type err * fix: code and ux improvements * chore: change default template * fix: ui and typo * fix: add validation check for my phone number --------- Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
co-authored by
Joe Au-Yeung
parent
c6febad364
commit
199d95b35f
@@ -1,14 +1,18 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { EventTypeSetup } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useFormContext, Controller } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getTemplateFieldsSchema } from "@calcom/features/ee/cal-ai-phone/getTemplateFieldsSchema";
|
||||
import { TEMPLATES_FIELDS } from "@calcom/features/ee/cal-ai-phone/template-fields-map";
|
||||
import type { TemplateType } from "@calcom/features/ee/cal-ai-phone/zod-utils";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { ComponentForField } from "@calcom/features/form-builder/FormBuilderField";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { AIPhoneSettingSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
@@ -17,9 +21,9 @@ import {
|
||||
SettingsToggle,
|
||||
Divider,
|
||||
TextField,
|
||||
TextAreaField,
|
||||
PhoneInput,
|
||||
showToast,
|
||||
Icon,
|
||||
} from "@calcom/ui";
|
||||
|
||||
type AIEventControllerProps = {
|
||||
@@ -85,10 +89,64 @@ export default function AIEventController({ eventType, isTeamEvent }: AIEventCon
|
||||
);
|
||||
}
|
||||
|
||||
const ErrorMessage = ({ fieldName, message }: { fieldName: string; message: string }) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div data-testid={`error-message-${fieldName}`} className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<Icon name="info" className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p>{t(message || "invalid_input")}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateFields = () => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { control, watch } = formMethods;
|
||||
|
||||
const templateType = watch("aiPhoneCallConfig.templateType");
|
||||
const fields = TEMPLATES_FIELDS[templateType as TemplateType];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{fields?.map((field) => (
|
||||
<div key={field.name}>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`aiPhoneCallConfig.${field.name}`}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => {
|
||||
const { variableName, ...restField } = field;
|
||||
const variableInfo = !!variableName ? `: ${t("variable")} {{${variableName}}}` : "";
|
||||
return (
|
||||
<div>
|
||||
<ComponentForField
|
||||
field={{
|
||||
...restField,
|
||||
label: `${t(field.defaultLabel)}${variableInfo}`,
|
||||
placeholder: t(field.placeholder),
|
||||
}}
|
||||
value={value ?? ""}
|
||||
readOnly={false}
|
||||
setValue={(val: unknown) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
{error?.message && <ErrorMessage message={error.message} fieldName={field.name} />}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
const [calApiKey, setCalApiKey] = useState("");
|
||||
|
||||
const createCallMutation = trpc.viewer.organizations.createPhoneCall.useMutation({
|
||||
@@ -98,43 +156,25 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(t("something_went_wrong"), "error");
|
||||
const message = err?.message ? err.message : t("something_went_wrong");
|
||||
showToast(message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
// const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
// const NewPhoneButton = () => {
|
||||
// const { t } = useLocale();
|
||||
// return (
|
||||
// <Button
|
||||
// color="primary"
|
||||
// data-testid="new_phone_number"
|
||||
// StartIcon={Plus}
|
||||
// onClick={() => setCreateModalOpen(true)}>
|
||||
// {t("New Phone number")}
|
||||
// </Button>
|
||||
// );
|
||||
// };
|
||||
|
||||
// v1 will require the user to log in to Retellai.com to create a phone number, and an agent and
|
||||
// authorize it with the Cal.com API key / OAuth
|
||||
// const retellAuthorized = true; // TODO: call retellAPI here
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = formMethods.getValues("aiPhoneCallConfig");
|
||||
const { templateType } = values;
|
||||
|
||||
const data = await AIPhoneSettingSchema.parseAsync({
|
||||
generalPrompt: values.generalPrompt,
|
||||
beginMessage: values.beginMessage,
|
||||
enabled: values.enabled,
|
||||
guestName: values.guestName,
|
||||
guestEmail: values.guestEmail.trim().length ? values.guestEmail : undefined,
|
||||
guestCompany: values.guestCompany.trim().length ? values.guestCompany : undefined,
|
||||
const schema = getTemplateFieldsSchema({ templateType });
|
||||
|
||||
const data = schema.parse({
|
||||
...values,
|
||||
guestEmail: values.guestEmail && values.guestEmail.trim().length ? values.guestEmail : undefined,
|
||||
guestCompany:
|
||||
values.guestCompany && values.guestCompany.trim().length ? values.guestCompany : undefined,
|
||||
guestName: values.guestName && values.guestName.trim().length ? values.guestName : undefined,
|
||||
eventTypeId: eventType.id,
|
||||
numberToCall: values.numberToCall,
|
||||
yourPhoneNumber: values.yourPhoneNumber,
|
||||
calApiKey,
|
||||
});
|
||||
|
||||
@@ -144,6 +184,15 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const fieldName = err.issues?.[0]?.path?.[0];
|
||||
const message = err.issues?.[0]?.message;
|
||||
showToast(`Error on ${fieldName}: ${message} `, "error");
|
||||
|
||||
const issues = err.issues;
|
||||
for (const issue of issues) {
|
||||
const fieldName = `aiPhoneCallConfig.${issue.path[0]}` as unknown as keyof FormValues;
|
||||
formMethods.setError(fieldName, {
|
||||
type: "custom",
|
||||
message: issue.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showToast(t("something_went_wrong"), "error");
|
||||
}
|
||||
@@ -157,68 +206,89 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
<Label>{t("your_phone_number")}</Label>
|
||||
<Controller
|
||||
name="aiPhoneCallConfig.yourPhoneNumber"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
render={({ field: { onChange, value, name }, fieldState: { error } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
placeholder={t("your_phone_number")}
|
||||
id="aiPhoneCallConfig.yourPhoneNumber"
|
||||
name="aiPhoneCallConfig.yourPhoneNumber"
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<PhoneInput
|
||||
required
|
||||
placeholder={t("your_phone_number")}
|
||||
id="aiPhoneCallConfig.yourPhoneNumber"
|
||||
name="aiPhoneCallConfig.yourPhoneNumber"
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
{error?.message && <ErrorMessage message={error.message} fieldName={name} />}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<>
|
||||
<RadioGroup.Root
|
||||
defaultValue={eventType?.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE"}
|
||||
onValueChange={(val) => {
|
||||
formMethods.setValue("aiPhoneCallConfig.templateType", val, { shouldDirty: true });
|
||||
}}>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-1 rounded-lg border p-4">
|
||||
<RadioGroup.Item
|
||||
id="CHECK_IN_APPOINTMENT"
|
||||
key="CHECK_IN_APPOINTMENT"
|
||||
value="CHECK_IN_APPOINTMENT"
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2 ">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
<label className="h-full" htmlFor="CHECK_IN_APPOINTMENT">
|
||||
<h2 className="font-semibold leading-tight">{t("check_in_assistant")}</h2>
|
||||
<p className="text-subtle mt-2">{t("check_in_assistant_description")}</p>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-1 rounded-lg border p-4">
|
||||
<RadioGroup.Item
|
||||
id="CUSTOM_TEMPLATE"
|
||||
key="CUSTOM_TEMPLATE"
|
||||
value="CUSTOM_TEMPLATE"
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
<label htmlFor="CUSTOM_TEMPLATE">
|
||||
<h2 className="font-semibold leading-tight">{t("custom_template")}</h2>
|
||||
<p className="text-subtle mt-2">{t("create_your_own_prompt")}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</>
|
||||
|
||||
<Label>{t("number_to_call")}</Label>
|
||||
<Controller
|
||||
name="aiPhoneCallConfig.numberToCall"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
render={({ field: { onChange, value, name }, fieldState: { error } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
placeholder={t("phone_number")}
|
||||
id="aiPhoneCallConfig.numberToCall"
|
||||
name="aiPhoneCallConfig.numberToCall"
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<PhoneInput
|
||||
required
|
||||
placeholder={t("phone_number")}
|
||||
id="aiPhoneCallConfig.numberToCall"
|
||||
name="aiPhoneCallConfig.numberToCall"
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
{error?.message && <ErrorMessage message={error.message} fieldName={name} />}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TemplateFields />
|
||||
|
||||
<Divider />
|
||||
</>
|
||||
|
||||
<TextField
|
||||
type="text"
|
||||
hint="Variable: {{name}}"
|
||||
label={t("guest_name")}
|
||||
placeholder="Jane Doe"
|
||||
{...formMethods.register("aiPhoneCallConfig.guestName")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="text"
|
||||
hint="Variable: {{email}}"
|
||||
label={t("guest_email")}
|
||||
placeholder="jane@acme.com"
|
||||
{...formMethods.register("aiPhoneCallConfig.guestEmail")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="text"
|
||||
hint="Variable: {{company}}"
|
||||
label={t("guest_company")}
|
||||
placeholder="Acme"
|
||||
{...formMethods.register("aiPhoneCallConfig.guestCompany")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="text"
|
||||
hint="For eg:- cal_live_0123.."
|
||||
@@ -232,26 +302,6 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<TextAreaField
|
||||
rows={3}
|
||||
required
|
||||
placeholder={t("general_prompt")}
|
||||
label={t("general_prompt")}
|
||||
{...formMethods.register("aiPhoneCallConfig.generalPrompt")}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("aiPhoneCallConfig.generalPrompt", e.target.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextAreaField
|
||||
rows={3}
|
||||
placeholder={t("begin_message")}
|
||||
label={t("begin_message")}
|
||||
{...formMethods.register("aiPhoneCallConfig.beginMessage")}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("aiPhoneCallConfig.beginMessage", e.target.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={createCallMutation.isPending}
|
||||
@@ -268,34 +318,6 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
and learn how to build workflows.
|
||||
</small> */}
|
||||
</div>
|
||||
|
||||
{/* TODO:
|
||||
<>
|
||||
<EmptyScreen
|
||||
Icon={Phone}
|
||||
headline={t("Create your phone number")}
|
||||
description={t(
|
||||
"This phone number can be called by guests but can also do proactive outbound calls by the AI agent."
|
||||
)}
|
||||
buttonRaw={
|
||||
<div className="flex justify-between gap-2">
|
||||
<NewPhoneButton />
|
||||
<Button color="secondary">{t("learn_more")}</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Dialog open={createModalOpen} onOpenChange={(isOpen) => !isOpen && setCreateModalOpen(false)}>
|
||||
<DialogContent
|
||||
enableOverflow
|
||||
title={t("Create phone number")}
|
||||
description={t("This number can later be called or can do proactive outbound calls")}>
|
||||
<div className="mb-12 mt-4">
|
||||
<TextField placeholder="+415" hint="Area Code" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
*/}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,10 @@ import { z } from "zod";
|
||||
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
|
||||
import { getEventLocationType } from "@calcom/app-store/locations";
|
||||
import { validateCustomEventName } from "@calcom/core/event";
|
||||
import {
|
||||
DEFAULT_PROMPT_VALUE,
|
||||
DEFAULT_BEGIN_MESSAGE,
|
||||
} from "@calcom/features/ee/cal-ai-phone/promptTemplates";
|
||||
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
|
||||
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
@@ -40,42 +44,6 @@ import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayo
|
||||
|
||||
import { type PageProps } from "~/event-types/views/event-types-single-view.getServerSideProps";
|
||||
|
||||
const DEFAULT_PROMPT_VALUE = `## You are helping user set up a call with the support team. The appointment is 15 min long. You are a pleasant and friendly.
|
||||
|
||||
## Style Guardrails
|
||||
Be Concise: Respond succinctly, addressing one topic at most.
|
||||
Embrace Variety: Use diverse language and rephrasing to enhance clarity without repeating content.
|
||||
Be Conversational: Use everyday language, making the chat feel like talking to a friend.
|
||||
Be Proactive: Lead the conversation, often wrapping up with a question or next-step suggestion.
|
||||
Avoid multiple questions in a single response.
|
||||
Get clarity: If the user only partially answers a question, or if the answer is unclear, keep asking to get clarity.
|
||||
Use a colloquial way of referring to the date (like Friday, Jan 14th, or Tuesday, Jan 12th, 2024 at 8am).
|
||||
If you are saying a time like 8:00 AM, just say 8 AM and emit the trailing zeros.
|
||||
|
||||
## Response Guideline
|
||||
Adapt and Guess: Try to understand transcripts that may contain transcription errors. Avoid mentioning \"transcription error\" in the response.
|
||||
Stay in Character: Keep conversations within your role'''s scope, guiding them back creatively without repeating.
|
||||
Ensure Fluid Dialogue: Respond in a role-appropriate, direct manner to maintain a smooth conversation flow.
|
||||
|
||||
## Schedule Rule
|
||||
Current time is {{current_time}}. You only schedule time in current calendar year, you cannot schedule time that'''s in the past.
|
||||
|
||||
## Task Steps
|
||||
1. I am here to learn more about your issue and help schedule an appointment with our support team.
|
||||
2. If {{email}} is not unknown then Use name {{name}} and email {{email}} for creating booking else Ask for user name and email and Confirm the name and email with user by reading it back to user.
|
||||
3. Ask user for \"When would you want to meet with one of our representive\".
|
||||
4. Call function check_availability to check for availability in the user provided time range.
|
||||
- if availability exists, inform user about the availability range (do not repeat the detailed available slot) and ask user to choose from it. Make sure user chose a slot within detailed available slot.
|
||||
- if availability does not exist, ask user to select another time range for the appointment, repeat this step 3.
|
||||
5. Confirm the date and time selected by user: \"Just to confirm, you want to book the appointment at ...\".
|
||||
6. Once confirmed, call function book_appointment to book the appointment.
|
||||
- if booking returned booking detail, it means booking is successful, proceed to step 7.
|
||||
- if booking returned error message, let user know why the booking was not successful, and maybe start over with step 3.
|
||||
7. Inform the user booking is successful, and ask if user have any questions. Answer them if there are any.
|
||||
8. After all questions answered, call function end_call to hang up.`;
|
||||
|
||||
const DEFAULT_BEGIN_MESSAGE = "Hi. How are you doing?";
|
||||
|
||||
// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings;
|
||||
const EventSetupTab = dynamic(() =>
|
||||
import("@components/eventtype/EventSetupTab").then((mod) => mod.EventSetupTab)
|
||||
@@ -372,6 +340,8 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
|
||||
guestCompany: eventType.aiPhoneCallConfig?.guestCompany,
|
||||
yourPhoneNumber: eventType.aiPhoneCallConfig?.yourPhoneNumber,
|
||||
numberToCall: eventType.aiPhoneCallConfig?.numberToCall,
|
||||
templateType: eventType.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE",
|
||||
schedulerName: eventType.aiPhoneCallConfig?.schedulerName,
|
||||
},
|
||||
};
|
||||
}, [eventType, periodDates, metadata]);
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ZGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
|
||||
import type { TGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
|
||||
import { fetcher } from "@calcom/lib/retellAIFetcher";
|
||||
import { defaultHandler } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { getRetellLLMSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { TGetRetellLLMSchema } from "@calcom/prisma/zod-utils";
|
||||
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||
|
||||
dayjs.extend(advancedFormat);
|
||||
@@ -53,7 +53,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const body = response.data;
|
||||
|
||||
const retellLLM = await fetcher(`/get-retell-llm/${body.llm_id}`).then(getRetellLLMSchema.parse);
|
||||
const retellLLM = await fetcher(`/get-retell-llm/${body.llm_id}`).then(ZGetRetellLLMSchema.parse);
|
||||
|
||||
const { eventTypeId, timezone } = getEventTypeIdFromRetellLLM(retellLLM);
|
||||
|
||||
|
||||
@@ -103,6 +103,9 @@
|
||||
"new_event_request": "New event request: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"confirm_or_reject_request": "Confirm or reject the request",
|
||||
"check_bookings_page_to_confirm_or_reject": "Check your bookings page to confirm or reject the booking.",
|
||||
"check_in_assistant": "Check-in Assistant",
|
||||
"check_in_assistant_description": "Makes an outbound call to check if scheduled appointment works or if you want to reschedule.",
|
||||
"create_your_own_prompt": "Create your own prompt and use it to make an outbound call.",
|
||||
"event_awaiting_approval": "An event is waiting for your approval",
|
||||
"event_awaiting_approval_recurring": "A recurring event is waiting for your approval",
|
||||
"someone_requested_an_event": "Someone has requested to schedule an event on your calendar.",
|
||||
@@ -2409,6 +2412,7 @@
|
||||
"confirm_email": "Confirm your email",
|
||||
"scheduler_first_name": "The first name of the person booking",
|
||||
"scheduler_last_name": "The last name of the person booking",
|
||||
"scheduler_name": "Scheduler Name",
|
||||
"organizer_first_name": "Your first name",
|
||||
"confirm_email_description": "We sent an email to <strong>{{email}}</strong>. Click the link in the email to verify this address.",
|
||||
"send_event_details_to": "Send event details to",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { TemplateType } from "./zod-utils";
|
||||
import { fieldSchemaMap } from "./zod-utils";
|
||||
|
||||
export const getTemplateFieldsSchema = ({ templateType }: { templateType: string }) => {
|
||||
return fieldSchemaMap[templateType as TemplateType];
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { templateTypeEnum } from "./zod-utils";
|
||||
import type { TemplateType } from "./zod-utils";
|
||||
|
||||
export const PROMPT_TEMPLATES: Partial<Record<TemplateType, { generalPrompt: string }>> = {
|
||||
[templateTypeEnum.enum.CHECK_IN_APPOINTMENT]: {
|
||||
generalPrompt: `## Identity
|
||||
You are {{scheduler_name}}'s virtual assistant named Chloe, calling to notify user of a scheduled appointment. You are a pleasant and friendly assistant, here to help manage calendar notifications.
|
||||
|
||||
## Style Guardrails
|
||||
Be Concise: Respond succinctly, addressing one topic at most.
|
||||
Embrace Variety: Use diverse language and rephrasing to enhance clarity without repeating content.
|
||||
Be Conversational: Use everyday language, making the chat feel like talking to a friend.
|
||||
Be Proactive: Lead the conversation, often wrapping up with a question or next-step suggestion.
|
||||
Avoid multiple questions in a single response.
|
||||
Get clarity: If the user only partially answers a question, or if the answer is unclear, keep asking to get clarity.
|
||||
Use a colloquial way of referring to the date (like 'next Friday', 'tomorrow').
|
||||
One question at a time: Ask only one question at a time, do not pack more topics into one response.
|
||||
|
||||
## Response Guideline
|
||||
Adapt and Guess: Try to understand transcripts that may contain transcription errors. Avoid mentioning "transcription error" in the response.
|
||||
Stay in Character: Keep conversations within your role's scope, guiding them back creatively without repeating.
|
||||
Ensure Fluid Dialogue: Respond in a role-appropriate, direct manner to maintain a smooth conversation flow.
|
||||
Do not make up answers: If you do not know the answer to a question, simply say so. Do not fabricate or deviate from listed responses.
|
||||
If at any moment the conversation deviates, kindly lead it back to the relevant topic. Do not repeat from start, keep asking from where you stopped.`,
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_PROMPT_VALUE = `## You are helping user set up a call with the support team. The appointment is 15 min long. You are a pleasant and friendly.
|
||||
|
||||
## Style Guardrails
|
||||
Be Concise: Respond succinctly, addressing one topic at most.
|
||||
Embrace Variety: Use diverse language and rephrasing to enhance clarity without repeating content.
|
||||
Be Conversational: Use everyday language, making the chat feel like talking to a friend.
|
||||
Be Proactive: Lead the conversation, often wrapping up with a question or next-step suggestion.
|
||||
Avoid multiple questions in a single response.
|
||||
Get clarity: If the user only partially answers a question, or if the answer is unclear, keep asking to get clarity.
|
||||
Use a colloquial way of referring to the date (like Friday, Jan 14th, or Tuesday, Jan 12th, 2024 at 8am).
|
||||
If you are saying a time like 8:00 AM, just say 8 AM and emit the trailing zeros.
|
||||
|
||||
## Response Guideline
|
||||
Adapt and Guess: Try to understand transcripts that may contain transcription errors. Avoid mentioning \"transcription error\" in the response.
|
||||
Stay in Character: Keep conversations within your role'''s scope, guiding them back creatively without repeating.
|
||||
Ensure Fluid Dialogue: Respond in a role-appropriate, direct manner to maintain a smooth conversation flow.
|
||||
|
||||
## Schedule Rule
|
||||
Current time is {{current_time}}. You only schedule time in current calendar year, you cannot schedule time that'''s in the past.
|
||||
|
||||
## Task Steps
|
||||
1. I am here to learn more about your issue and help schedule an appointment with our support team.
|
||||
2. If {{email}} is not unknown then Use name {{name}} and email {{email}} for creating booking else Ask for user name and email and Confirm the name and email with user by reading it back to user.
|
||||
3. Ask user for \"When would you want to meet with one of our representive\".
|
||||
4. Call function check_availability to check for availability in the user provided time range.
|
||||
- if availability exists, inform user about the availability range (do not repeat the detailed available slot) and ask user to choose from it. Make sure user chose a slot within detailed available slot.
|
||||
- if availability does not exist, ask user to select another time range for the appointment, repeat this step 3.
|
||||
5. Confirm the date and time selected by user: \"Just to confirm, you want to book the appointment at ...\".
|
||||
6. Once confirmed, call function book_appointment to book the appointment.
|
||||
- if booking returned booking detail, it means booking is successful, proceed to step 7.
|
||||
- if booking returned error message, let user know why the booking was not successful, and maybe start over with step 3.
|
||||
7. Inform the user booking is successful, and ask if user have any questions. Answer them if there are any.
|
||||
8. After all questions answered, call function end_call to hang up.`;
|
||||
|
||||
export const DEFAULT_BEGIN_MESSAGE = "Hi. How are you doing?";
|
||||
@@ -0,0 +1,227 @@
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { fetcher } from "@calcom/lib/retellAIFetcher";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
|
||||
import type {
|
||||
TCreateRetellLLMSchema,
|
||||
TGetRetellLLMSchema,
|
||||
TCreatePhoneCallSchema,
|
||||
TemplateType,
|
||||
TGetPhoneNumberSchema,
|
||||
TCreatePhoneSchema,
|
||||
} from "./zod-utils";
|
||||
import {
|
||||
ZGetRetellLLMSchema,
|
||||
ZCreatePhoneSchema,
|
||||
ZCreateRetellLLMSchema,
|
||||
ZGetPhoneNumberSchema,
|
||||
} from "./zod-utils";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["retellAIService: "] });
|
||||
|
||||
type DynamicVariables = Pick<
|
||||
TCreatePhoneCallSchema,
|
||||
"guestName" | "guestEmail" | "guestCompany" | "schedulerName"
|
||||
>;
|
||||
|
||||
type initProps = {
|
||||
templateType: TemplateType;
|
||||
yourPhoneNumber: string;
|
||||
eventTypeId: number;
|
||||
calApiKey: string;
|
||||
loggedInUserTimeZone: string;
|
||||
beginMessage?: string;
|
||||
dynamicVariables: DynamicVariables;
|
||||
generalPrompt: string;
|
||||
};
|
||||
|
||||
const updateAgentWebsocketUrl = async (phoneNumber: string, llmWebsocketUrl: string): Promise<void> => {
|
||||
try {
|
||||
const phoneNumberDetails = await fetcher(`/get-phone-number/${phoneNumber}`).then(
|
||||
ZGetPhoneNumberSchema.parse
|
||||
);
|
||||
|
||||
await fetcher(`/update-agent/${phoneNumberDetails.outbound_agent_id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
llm_websocket_url: llmWebsocketUrl,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Unable to Update Agent", safeStringify(error));
|
||||
throw new Error("Something went wrong! Unable to Update Agent");
|
||||
}
|
||||
};
|
||||
|
||||
// Command Design Pattern
|
||||
interface Command<T> {
|
||||
execute(): Promise<T>;
|
||||
}
|
||||
|
||||
class CreateRetellLLMCommand implements Command<TCreateRetellLLMSchema> {
|
||||
constructor(private props: initProps) {}
|
||||
|
||||
async execute(): Promise<TCreateRetellLLMSchema> {
|
||||
try {
|
||||
const createdRetellLLM = await fetcher("/create-retell-llm", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
general_prompt: this.props.generalPrompt,
|
||||
begin_message: this.props.beginMessage,
|
||||
inbound_dynamic_variables_webhook_url: `${WEBAPP_URL}/api/get-inbound-dynamic-variables`,
|
||||
general_tools: [
|
||||
{
|
||||
type: "end_call",
|
||||
name: "end_call",
|
||||
description: "Hang up the call, triggered only after appointment successfully scheduled.",
|
||||
},
|
||||
{
|
||||
type: "check_availability_cal",
|
||||
name: "check_availability",
|
||||
cal_api_key: this.props.calApiKey,
|
||||
event_type_id: this.props.eventTypeId,
|
||||
timezone: this.props.loggedInUserTimeZone,
|
||||
},
|
||||
{
|
||||
type: "book_appointment_cal",
|
||||
name: "book_appointment",
|
||||
cal_api_key: this.props.calApiKey,
|
||||
event_type_id: this.props.eventTypeId,
|
||||
timezone: this.props.loggedInUserTimeZone,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}).then(ZCreateRetellLLMSchema.parse);
|
||||
|
||||
const llmWebSocketUrlToBeUpdated = createdRetellLLM.llm_websocket_url;
|
||||
await updateAgentWebsocketUrl(this.props.yourPhoneNumber, llmWebSocketUrlToBeUpdated);
|
||||
|
||||
return createdRetellLLM;
|
||||
} catch (error) {
|
||||
log.error("Unable to Create Retell LLM", safeStringify(error));
|
||||
throw new Error("Something went wrong! Unable to Create Retell LLM");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GetRetellLLMCommand implements Command<TGetRetellLLMSchema> {
|
||||
constructor(private llmId: string) {}
|
||||
|
||||
async execute(): Promise<TGetRetellLLMSchema> {
|
||||
try {
|
||||
const retellLLM = await fetcher(`/get-retell-llm/${this.llmId}`).then(ZGetRetellLLMSchema.parse);
|
||||
return retellLLM;
|
||||
} catch (err) {
|
||||
log.error("Unable to get Retell LLM", safeStringify(err));
|
||||
throw new Error("Something went wrong! Unable to get Retell LLM");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UpdateRetellLLMCommand implements Command<TGetRetellLLMSchema> {
|
||||
constructor(private props: initProps, private llmId: string) {}
|
||||
|
||||
async execute(): Promise<TGetRetellLLMSchema> {
|
||||
try {
|
||||
const updatedRetellLLM = await fetcher(`/update-retell-llm/${this.llmId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
general_prompt: this.props.generalPrompt,
|
||||
begin_message: this.props.beginMessage,
|
||||
inbound_dynamic_variables_webhook_url: `${WEBAPP_URL}/api/get-inbound-dynamic-variables`,
|
||||
}),
|
||||
}).then(ZGetRetellLLMSchema.parse);
|
||||
|
||||
const llmWebSocketUrlToBeUpdated = updatedRetellLLM.llm_websocket_url;
|
||||
await updateAgentWebsocketUrl(this.props.yourPhoneNumber, llmWebSocketUrlToBeUpdated);
|
||||
|
||||
return updatedRetellLLM;
|
||||
} catch (err) {
|
||||
log.error("Unable to Update Retell LLM", safeStringify(err));
|
||||
throw new Error("Something went wrong! Unable to Update Retell LLM");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GetPhoneNumberDetailsCommand implements Command<TGetPhoneNumberSchema> {
|
||||
constructor(private phoneNumber: string) {}
|
||||
|
||||
async execute(): Promise<TGetPhoneNumberSchema> {
|
||||
try {
|
||||
const phoneNumberDetails = await fetcher(`/get-phone-number/${this.phoneNumber}`).then(
|
||||
ZGetPhoneNumberSchema.parse
|
||||
);
|
||||
return phoneNumberDetails;
|
||||
} catch (err) {
|
||||
log.error("Unable to Get Phone number", safeStringify(err));
|
||||
throw new Error(
|
||||
"Something went wrong! Unable to Get Phone number. Please only use the phone number assigned to you."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const validatePhoneNumber = (phoneNumber: string) => {
|
||||
const command = new GetPhoneNumberDetailsCommand(phoneNumber);
|
||||
return command.execute();
|
||||
};
|
||||
|
||||
class CreateRetellPhoneCallCommand implements Command<TCreatePhoneSchema> {
|
||||
constructor(private props: initProps, private numberToCall: string) {}
|
||||
|
||||
async execute(): Promise<TCreatePhoneSchema> {
|
||||
try {
|
||||
const createPhoneCallRes = await fetcher("/create-phone-call", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
from_number: this.props.yourPhoneNumber,
|
||||
to_number: this.numberToCall,
|
||||
retell_llm_dynamic_variables: {
|
||||
name: this.props.dynamicVariables.guestName,
|
||||
company: this.props.dynamicVariables.guestCompany,
|
||||
email: this.props.dynamicVariables.guestEmail,
|
||||
},
|
||||
}),
|
||||
}).then(ZCreatePhoneSchema.parse);
|
||||
|
||||
return createPhoneCallRes;
|
||||
} catch (err) {
|
||||
log.error("Unable to Get Phone number", safeStringify(err));
|
||||
throw new Error("Something went wrong! Unable to Get Phone number");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RetellAIService {
|
||||
private props: initProps;
|
||||
|
||||
constructor(props: initProps) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
async createRetellLLMAndUpdateWebsocketUrl(): Promise<TCreateRetellLLMSchema> {
|
||||
const command = new CreateRetellLLMCommand(this.props);
|
||||
return command.execute();
|
||||
}
|
||||
|
||||
async getRetellLLM(llmId: string): Promise<TGetRetellLLMSchema> {
|
||||
const command = new GetRetellLLMCommand(llmId);
|
||||
return command.execute();
|
||||
}
|
||||
|
||||
async updatedRetellLLMAndUpdateWebsocketUrl(llmId: string): Promise<TGetRetellLLMSchema> {
|
||||
const command = new UpdateRetellLLMCommand(this.props, llmId);
|
||||
return command.execute();
|
||||
}
|
||||
|
||||
async getPhoneNumberDetails(): Promise<TGetPhoneNumberSchema> {
|
||||
const command = new GetPhoneNumberDetailsCommand(this.props.yourPhoneNumber);
|
||||
return command.execute();
|
||||
}
|
||||
|
||||
async createRetellPhoneCall(numberToCall: string): Promise<TCreatePhoneSchema> {
|
||||
const command = new CreateRetellPhoneCallCommand(this.props, numberToCall);
|
||||
return command.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { TemplateType, Fields } from "./zod-utils";
|
||||
import { fieldNameEnum } from "./zod-utils";
|
||||
|
||||
export const TEMPLATES_FIELDS: Record<TemplateType, Fields> = {
|
||||
CHECK_IN_APPOINTMENT: [
|
||||
{
|
||||
type: "text",
|
||||
name: fieldNameEnum.enum.schedulerName,
|
||||
required: true,
|
||||
defaultLabel: "scheduler_name",
|
||||
placeholder: "Enter your name",
|
||||
},
|
||||
],
|
||||
CUSTOM_TEMPLATE: [
|
||||
{
|
||||
type: "textarea",
|
||||
name: fieldNameEnum.enum.generalPrompt,
|
||||
required: true,
|
||||
defaultLabel: "general_prompt",
|
||||
placeholder: "Enter your prompt",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: fieldNameEnum.enum.beginMessage,
|
||||
required: true,
|
||||
defaultLabel: "begin_message",
|
||||
placeholder: "begin_message",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: fieldNameEnum.enum.guestName,
|
||||
required: false,
|
||||
defaultLabel: "guest_name",
|
||||
placeholder: "guest_name",
|
||||
variableName: "name",
|
||||
},
|
||||
{
|
||||
type: "email",
|
||||
name: fieldNameEnum.enum.guestEmail,
|
||||
required: false,
|
||||
defaultLabel: "guest_email",
|
||||
placeholder: "guest_email",
|
||||
variableName: "email",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: fieldNameEnum.enum.guestCompany,
|
||||
required: false,
|
||||
defaultLabel: "guest_company",
|
||||
placeholder: "guest_company",
|
||||
variableName: "company",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import z from "zod";
|
||||
|
||||
export const templateTypeEnum = z.enum(["CHECK_IN_APPOINTMENT", "CUSTOM_TEMPLATE"]);
|
||||
|
||||
const requiredFields = z.object({
|
||||
yourPhoneNumber: z.string().refine((val) => isValidPhoneNumber(val)),
|
||||
numberToCall: z.string().refine((val) => isValidPhoneNumber(val)),
|
||||
calApiKey: z.string().trim().min(1, {
|
||||
message: "Please enter CAL API Key",
|
||||
}),
|
||||
eventTypeId: z.number(),
|
||||
enabled: z.boolean().default(false),
|
||||
templateType: templateTypeEnum,
|
||||
});
|
||||
|
||||
export const createPhoneCallSchema = requiredFields.merge(
|
||||
z.object({
|
||||
schedulerName: z.string().min(1).optional().nullable(),
|
||||
guestName: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
return !!val ? val : undefined;
|
||||
}),
|
||||
guestEmail: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
return !!val ? val : undefined;
|
||||
}),
|
||||
guestCompany: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
return !!val ? val : undefined;
|
||||
}),
|
||||
beginMessage: z.string().optional(),
|
||||
generalPrompt: z.string().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
export type TCreatePhoneCallSchema = z.infer<typeof createPhoneCallSchema>;
|
||||
|
||||
export const ZGetPhoneNumberSchema = z
|
||||
.object({
|
||||
agent_id: z.string(),
|
||||
inbound_agent_id: z.string(),
|
||||
outbound_agent_id: z.string(),
|
||||
error_message: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type TGetPhoneNumberSchema = z.infer<typeof ZGetPhoneNumberSchema>;
|
||||
|
||||
export const ZCreatePhoneSchema = z
|
||||
.object({
|
||||
call_id: z.string(),
|
||||
agent_id: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type TCreatePhoneSchema = z.infer<typeof ZCreatePhoneSchema>;
|
||||
|
||||
export const fieldSchemaMap = {
|
||||
CHECK_IN_APPOINTMENT: requiredFields.merge(
|
||||
z.object({
|
||||
schedulerName: z.string().min(1),
|
||||
})
|
||||
),
|
||||
CUSTOM_TEMPLATE: createPhoneCallSchema.omit({ generalPrompt: true }).merge(
|
||||
z.object({
|
||||
generalPrompt: z.string(),
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
export type TemplateType = z.infer<typeof templateTypeEnum>;
|
||||
|
||||
const fieldTypeEnum = z.enum([
|
||||
"name",
|
||||
"text",
|
||||
"textarea",
|
||||
"number",
|
||||
"email",
|
||||
"phone",
|
||||
"address",
|
||||
"multiemail",
|
||||
"select",
|
||||
"multiselect",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"radioInput",
|
||||
"boolean",
|
||||
]);
|
||||
|
||||
export const fieldNameEnum = z.enum([
|
||||
"schedulerName",
|
||||
"generalPrompt",
|
||||
"guestName",
|
||||
"guestEmail",
|
||||
"guestCompany",
|
||||
"beginMessage",
|
||||
]);
|
||||
|
||||
export type FieldType = z.infer<typeof fieldTypeEnum>;
|
||||
|
||||
const FieldSchema = z.object({
|
||||
type: fieldTypeEnum,
|
||||
name: fieldNameEnum,
|
||||
required: z.boolean(),
|
||||
defaultLabel: z.string(),
|
||||
placeholder: z.string(),
|
||||
variableName: z.string().optional(),
|
||||
});
|
||||
|
||||
const FieldsSchema = z.array(FieldSchema);
|
||||
|
||||
export type Fields = z.infer<typeof FieldsSchema>;
|
||||
|
||||
export const ZCreateRetellLLMSchema = z
|
||||
.object({
|
||||
llm_id: z.string(),
|
||||
llm_websocket_url: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type TCreateRetellLLMSchema = z.infer<typeof ZCreateRetellLLMSchema>;
|
||||
|
||||
export const ZGetRetellLLMSchema = z
|
||||
.object({
|
||||
general_prompt: z.string(),
|
||||
begin_message: z.string().nullable(),
|
||||
llm_id: z.string(),
|
||||
llm_websocket_url: z.string(),
|
||||
general_tools: z.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
cal_api_key: z.string().optional(),
|
||||
event_type_id: z.number().optional(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
),
|
||||
states: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string(),
|
||||
tools: z.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
cal_api_key: z.string().optional(),
|
||||
event_type_id: z.number().optional(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type TGetRetellLLMSchema = z.infer<typeof ZGetRetellLLMSchema>;
|
||||
@@ -65,9 +65,11 @@ export type FormValues = {
|
||||
beginMessage: string;
|
||||
yourPhoneNumber: string;
|
||||
numberToCall: string;
|
||||
guestName: string;
|
||||
guestEmail: string;
|
||||
guestCompany: string;
|
||||
guestName?: string;
|
||||
guestEmail?: string;
|
||||
guestCompany?: string;
|
||||
templateType: string;
|
||||
schedulerName?: string;
|
||||
};
|
||||
customInputs: CustomInputParsed[];
|
||||
schedule: number | null;
|
||||
|
||||
@@ -53,9 +53,11 @@ export type Event = {
|
||||
beginMessage: string | null;
|
||||
yourPhoneNumber: string;
|
||||
numberToCall: string;
|
||||
guestName: string;
|
||||
guestEmail: string;
|
||||
guestCompany: string;
|
||||
guestName?: string;
|
||||
guestEmail?: string;
|
||||
guestCompany?: string;
|
||||
templateType: string;
|
||||
schedulerName?: string;
|
||||
};
|
||||
schedulingType: string;
|
||||
length: number;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "AIPhoneCallConfiguration" ADD COLUMN "schedulerName" TEXT,
|
||||
ADD COLUMN "templateType" TEXT NOT NULL DEFAULT 'CUSTOM_TEMPLATE',
|
||||
ALTER COLUMN "generalPrompt" DROP NOT NULL,
|
||||
ALTER COLUMN "guestName" DROP NOT NULL;
|
||||
@@ -953,10 +953,12 @@ model AIPhoneCallConfiguration {
|
||||
id Int @id @default(autoincrement())
|
||||
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
|
||||
eventTypeId Int
|
||||
generalPrompt String
|
||||
templateType String @default("CUSTOM_TEMPLATE")
|
||||
schedulerName String?
|
||||
generalPrompt String?
|
||||
yourPhoneNumber String
|
||||
numberToCall String
|
||||
guestName String
|
||||
guestName String?
|
||||
guestEmail String?
|
||||
guestCompany String?
|
||||
enabled Boolean @default(false)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { UnitTypeLongPlural } from "dayjs";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import type { TFunction } from "next-i18next";
|
||||
import z, { ZodNullable, ZodObject, ZodOptional } from "zod";
|
||||
import type {
|
||||
@@ -710,64 +709,3 @@ export const bookingSeatDataSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
responses: bookingResponses,
|
||||
});
|
||||
|
||||
export const AIPhoneSettingSchema = z.object({
|
||||
yourPhoneNumber: z.string().refine((val) => isValidPhoneNumber(val)),
|
||||
numberToCall: z.string().refine((val) => isValidPhoneNumber(val)),
|
||||
guestName: z.string().trim().min(1, {
|
||||
message: "Please enter Guest Name",
|
||||
}),
|
||||
guestEmail: z.string().email().nullable().optional(),
|
||||
guestCompany: z.string().nullable().optional(),
|
||||
generalPrompt: z.string().trim().min(1, {
|
||||
message: "Please enter prompt",
|
||||
}),
|
||||
beginMessage: z.string().nullable(),
|
||||
eventTypeId: z.number(),
|
||||
calApiKey: z.string().trim().min(1, {
|
||||
message: "Please enter CAL API Key",
|
||||
}),
|
||||
});
|
||||
|
||||
export const getRetellLLMSchema = z
|
||||
.object({
|
||||
general_prompt: z.string(),
|
||||
begin_message: z.string().nullable(),
|
||||
llm_id: z.string(),
|
||||
llm_websocket_url: z.string(),
|
||||
general_tools: z.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
cal_api_key: z.string().optional(),
|
||||
event_type_id: z.number().optional(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
),
|
||||
states: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string(),
|
||||
tools: z.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
cal_api_key: z.string().optional(),
|
||||
event_type_id: z.number().optional(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type TGetRetellLLMSchema = z.infer<typeof getRetellLLMSchema>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { templateTypeEnum } from "@calcom/features/ee/cal-ai-phone/zod-utils";
|
||||
import { _DestinationCalendarModel, _EventTypeModel } from "@calcom/prisma/zod";
|
||||
import { customInputSchema, EventTypeMetaDataSchema, stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
@@ -16,9 +17,14 @@ export const EventTypeUpdateInput = _EventTypeModel
|
||||
beginMessage: z.string().nullable(),
|
||||
yourPhoneNumber: z.string().default(""),
|
||||
numberToCall: z.string().default(""),
|
||||
guestName: z.string().default(""),
|
||||
guestName: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((val) => (!!val ? val : undefined)),
|
||||
guestEmail: z.string().nullable().default(null),
|
||||
guestCompany: z.string().nullable().default(null),
|
||||
templateType: templateTypeEnum,
|
||||
})
|
||||
.optional(),
|
||||
calAiPhoneScript: z.string().optional(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPhoneCallSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
|
||||
import { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
|
||||
import { AIPhoneSettingSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import authedProcedure, {
|
||||
authedAdminProcedure,
|
||||
@@ -161,7 +161,7 @@ export const viewerOrganizationsRouter = router({
|
||||
const handler = await importHandler(namespaced("adminDelete"), () => import("./adminDelete.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
createPhoneCall: authedProcedure.input(AIPhoneSettingSchema).mutation(async (opts) => {
|
||||
createPhoneCall: authedProcedure.input(createPhoneCallSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("createPhoneCall"),
|
||||
() => import("./createPhoneCall.handler")
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { PROMPT_TEMPLATES } from "@calcom/features/ee/cal-ai-phone/promptTemplates";
|
||||
import { RetellAIService, validatePhoneNumber } from "@calcom/features/ee/cal-ai-phone/retellAIService";
|
||||
import type { createPhoneCallSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
|
||||
import { templateTypeEnum } from "@calcom/features/ee/cal-ai-phone/zod-utils";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { fetcher } from "@calcom/lib/retellAIFetcher";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { getRetellLLMSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { AIPhoneSettingSchema } from "@calcom/prisma/zod-utils";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
type CreatePhoneCallProps = {
|
||||
@@ -14,35 +15,21 @@ type CreatePhoneCallProps = {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
input: z.infer<typeof AIPhoneSettingSchema>;
|
||||
input: z.infer<typeof createPhoneCallSchema>;
|
||||
};
|
||||
|
||||
const createRetellLLMSchema = z
|
||||
.object({
|
||||
llm_id: z.string(),
|
||||
llm_websocket_url: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const createPhoneSchema = z
|
||||
.object({
|
||||
call_id: z.string(),
|
||||
agent_id: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const getPhoneNumberSchema = z
|
||||
.object({
|
||||
agent_id: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const createPhoneCallHandler = async ({ input, ctx }: CreatePhoneCallProps) => {
|
||||
if (!!!ctx.user.profile.organization) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `createPhoneCall:${ctx.user.id}`,
|
||||
});
|
||||
|
||||
await validatePhoneNumber(input.yourPhoneNumber);
|
||||
|
||||
const {
|
||||
yourPhoneNumber,
|
||||
numberToCall,
|
||||
@@ -51,69 +38,66 @@ const createPhoneCallHandler = async ({ input, ctx }: CreatePhoneCallProps) => {
|
||||
guestCompany,
|
||||
eventTypeId,
|
||||
beginMessage,
|
||||
generalPrompt,
|
||||
calApiKey,
|
||||
templateType,
|
||||
schedulerName,
|
||||
generalPrompt: userCustomPrompt,
|
||||
} = input;
|
||||
|
||||
const generalPrompt =
|
||||
templateType === templateTypeEnum.enum.CUSTOM_TEMPLATE
|
||||
? userCustomPrompt
|
||||
: PROMPT_TEMPLATES[templateType]?.generalPrompt;
|
||||
|
||||
const retellAI = new RetellAIService({
|
||||
templateType,
|
||||
generalPrompt: generalPrompt ?? "",
|
||||
yourPhoneNumber,
|
||||
loggedInUserTimeZone: ctx.user.timeZone,
|
||||
eventTypeId,
|
||||
calApiKey,
|
||||
dynamicVariables: {
|
||||
guestName,
|
||||
guestEmail,
|
||||
guestCompany,
|
||||
schedulerName,
|
||||
},
|
||||
});
|
||||
|
||||
const aiPhoneCallConfig = await ctx.prisma.aIPhoneCallConfiguration.upsert({
|
||||
where: {
|
||||
eventTypeId,
|
||||
},
|
||||
update: {
|
||||
beginMessage,
|
||||
generalPrompt,
|
||||
enabled: true,
|
||||
guestName,
|
||||
guestEmail,
|
||||
guestCompany,
|
||||
numberToCall,
|
||||
yourPhoneNumber,
|
||||
schedulerName,
|
||||
templateType,
|
||||
generalPrompt,
|
||||
},
|
||||
create: {
|
||||
eventTypeId,
|
||||
beginMessage,
|
||||
generalPrompt,
|
||||
enabled: true,
|
||||
guestName,
|
||||
guestEmail,
|
||||
guestCompany,
|
||||
numberToCall,
|
||||
yourPhoneNumber,
|
||||
schedulerName,
|
||||
templateType,
|
||||
generalPrompt,
|
||||
},
|
||||
});
|
||||
|
||||
let llmWebSocketUrlToBeUpdated = null;
|
||||
|
||||
// If no retell LLM is associated with the event type, create one
|
||||
if (!aiPhoneCallConfig.llmId) {
|
||||
const createdRetellLLM = await fetcher("/create-retell-llm", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
general_prompt: generalPrompt,
|
||||
begin_message: beginMessage,
|
||||
inbound_dynamic_variables_webhook_url: `${WEBAPP_URL}/api/get-inbound-dynamic-variables`,
|
||||
general_tools: [
|
||||
{
|
||||
type: "end_call",
|
||||
name: "end_call",
|
||||
description: "Hang up the call, triggered only after appointment successfully scheduled.",
|
||||
},
|
||||
{
|
||||
type: "check_availability_cal",
|
||||
name: "check_availability",
|
||||
cal_api_key: calApiKey,
|
||||
event_type_id: eventTypeId,
|
||||
timezone: ctx.user.timeZone,
|
||||
},
|
||||
{
|
||||
type: "book_appointment_cal",
|
||||
name: "book_appointment",
|
||||
cal_api_key: calApiKey,
|
||||
event_type_id: eventTypeId,
|
||||
timezone: ctx.user.timeZone,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}).then(createRetellLLMSchema.parse);
|
||||
const createdRetellLLM = await retellAI.createRetellLLMAndUpdateWebsocketUrl();
|
||||
|
||||
await ctx.prisma.aIPhoneCallConfiguration.update({
|
||||
where: {
|
||||
@@ -123,54 +107,18 @@ const createPhoneCallHandler = async ({ input, ctx }: CreatePhoneCallProps) => {
|
||||
llmId: createdRetellLLM.llm_id,
|
||||
},
|
||||
});
|
||||
|
||||
llmWebSocketUrlToBeUpdated = createdRetellLLM.llm_websocket_url;
|
||||
} else {
|
||||
const retellLLM = await fetcher(`/get-retell-llm/${aiPhoneCallConfig.llmId}`).then(
|
||||
getRetellLLMSchema.parse
|
||||
);
|
||||
|
||||
if (retellLLM.general_prompt !== generalPrompt || retellLLM.begin_message !== beginMessage) {
|
||||
const updatedRetellLLM = await fetcher(`/update-retell-llm/${aiPhoneCallConfig.llmId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
general_prompt: generalPrompt,
|
||||
begin_message: beginMessage,
|
||||
inbound_dynamic_variables_webhook_url: `${WEBAPP_URL}/api/get-inbound-dynamic-variables`,
|
||||
}),
|
||||
}).then(getRetellLLMSchema.parse);
|
||||
const retellLLM = await retellAI.getRetellLLM(aiPhoneCallConfig.llmId);
|
||||
|
||||
const doWeNeedToUpdateLLM =
|
||||
retellLLM.general_prompt !== generalPrompt || retellLLM.begin_message !== beginMessage;
|
||||
if (doWeNeedToUpdateLLM) {
|
||||
const updatedRetellLLM = await retellAI.updatedRetellLLMAndUpdateWebsocketUrl(aiPhoneCallConfig.llmId);
|
||||
logger.debug("updated Retell LLM", updatedRetellLLM);
|
||||
|
||||
llmWebSocketUrlToBeUpdated = updatedRetellLLM.llm_websocket_url;
|
||||
}
|
||||
}
|
||||
|
||||
if (llmWebSocketUrlToBeUpdated) {
|
||||
const getPhoneNumberDetails = await fetcher(`/get-phone-number/${yourPhoneNumber}`).then(
|
||||
getPhoneNumberSchema.parse
|
||||
);
|
||||
|
||||
const updated = await fetcher(`/update-agent/${getPhoneNumberDetails.agent_id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
llm_websocket_url: llmWebSocketUrlToBeUpdated,
|
||||
}),
|
||||
});
|
||||
|
||||
logger.debug("updated Retell Agent", updated);
|
||||
}
|
||||
|
||||
// Create Phone Call
|
||||
const createPhoneCallRes = await fetcher("/create-phone-call", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
from_number: yourPhoneNumber,
|
||||
to_number: numberToCall,
|
||||
retell_llm_dynamic_variables: { name: guestName, company: guestCompany, email: guestEmail },
|
||||
}),
|
||||
}).then(createPhoneSchema.parse);
|
||||
|
||||
const createPhoneCallRes = await retellAI.createRetellPhoneCall(numberToCall);
|
||||
logger.debug("Create Call Response", createPhoneCallRes);
|
||||
|
||||
return createPhoneCallRes;
|
||||
|
||||
Reference in New Issue
Block a user