From 578f5dc16b3340bd80d9a965b6e167388d1d1384 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Thu, 20 Feb 2025 21:37:13 +0530 Subject: [PATCH] feat: implement mintlify AI search into CMD+K (#18749) Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com> --- .env.example | 6 + apps/web/public/static/locales/en/common.json | 4 + packages/features/kbar/Kbar.tsx | 18 +- .../features/mintlify-chat/MintlifyChat.tsx | 100 +++++++++ packages/features/mintlify-chat/util.ts | 197 ++++++++++++++++++ turbo.json | 3 + 6 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 packages/features/mintlify-chat/MintlifyChat.tsx create mode 100644 packages/features/mintlify-chat/util.ts diff --git a/.env.example b/.env.example index ce124f9661..60c9537af3 100644 --- a/.env.example +++ b/.env.example @@ -388,6 +388,12 @@ BLACKLISTED_GUEST_EMAILS= NEXT_PUBLIC_VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= +# Mintlify chat api +# Power AI chat in for docs +NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY= +NEXT_PUBLIC_CHAT_API_URL= +NEXT_PUBLIC_DOCS_URL= + # Custom privacy policy / terms URLs (for self-hosters: change to your privacy policy / terms URLs) NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL= NEXT_PUBLIC_WEBSITE_TERMS_URL= diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index ebcb8ad03d..80242b0754 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2941,6 +2941,10 @@ "attribute_for_weights": "Attribute for weights", "managed_users": "Managed Users", "managed_users_description": "See all the managed users created by your OAuth client", + "can_you_tell_me_about": "Can you tell me about ", + "use_ai_to_answer_your_questions": "Use AI to answer your questions", + "error_fetching_answer": "Error while fetching answer ", + "k_bar_ai_error": "An unexpected error occured. Please try again later", "select_oAuth_client": "Select Oauth Client", "on_every_instance": "On every instance", "internal_booking_note": "Internal Note", diff --git a/packages/features/kbar/Kbar.tsx b/packages/features/kbar/Kbar.tsx index d9680d810c..008c2abaa1 100644 --- a/packages/features/kbar/Kbar.tsx +++ b/packages/features/kbar/Kbar.tsx @@ -11,7 +11,7 @@ import { useRegisterActions, } from "kbar"; import { useRouter } from "next/navigation"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -20,6 +20,8 @@ import { trpc } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react"; import { Icon, Tooltip } from "@calcom/ui"; +import { MintlifyChat } from "../mintlify-chat/MintlifyChat"; + type shortcutArrayType = { shortcuts?: string[]; }; @@ -248,17 +250,29 @@ export const KBarRoot = ({ children }: { children: React.ReactNode }) => { export const KBarContent = () => { const { t } = useLocale(); useEventTypesAction(); + const [inputText, setInputText] = useState(""); + const [aiResponse, setAiResponse] = useState(""); + const showAiChat = process.env.NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY && process.env.NEXT_PUBLIC_CHAT_API_URL; + return ( - +
{ + setInputText(e.currentTarget.value.trim()); + if (aiResponse) setAiResponse(""); + }} />
+ {showAiChat && inputText && ( + + )}
diff --git a/packages/features/mintlify-chat/MintlifyChat.tsx b/packages/features/mintlify-chat/MintlifyChat.tsx new file mode 100644 index 0000000000..956d589023 --- /dev/null +++ b/packages/features/mintlify-chat/MintlifyChat.tsx @@ -0,0 +1,100 @@ +/* eslint-disable react/no-danger */ +import type { Dispatch, SetStateAction } from "react"; +import { useState } from "react"; + +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; +import { Icon, SkeletonContainer, SkeletonText } from "@calcom/ui"; + +import { getFormattedCitations, handleAiChat, optionallyAddBaseUrl } from "../mintlify-chat/util"; + +interface MintlifyChatProps { + searchText: string; + aiResponse: string; + setAiResponse: Dispatch>; +} + +export const MintlifyChat = ({ searchText, aiResponse, setAiResponse }: MintlifyChatProps) => { + const { t } = useLocale(); + const [topicId, setTopicId] = useState(""); + const [baseUrl, setBaseUrl] = useState(process.env.NEXT_PUBLIC_DOCS_URL ?? ""); + const [isGenerating, setIsGenerating] = useState(false); + const [error, setError] = useState(""); + + const onChunkReceived = (chunk: string, baseUrl?: string, finalChunk?: boolean) => { + setAiResponse((prev) => { + return prev + chunk; + }); + if (baseUrl) { + setBaseUrl(baseUrl); + } + if (finalChunk) { + setIsGenerating(false); + } + }; + + const citations = getFormattedCitations(aiResponse.split("||")[1]) ?? []; + const answer = aiResponse.split("||")[0] ?? ""; + + return ( + <> +
{ + if (isGenerating) return; + setIsGenerating(true); + setAiResponse(""); + setError(""); + const { id, error } = await handleAiChat(onChunkReceived, searchText, topicId); + if (id) { + setTopicId(id); + } else if (error) { + setIsGenerating(false); + setError(error); + } + }} + className={classNames( + "hover:bg-subtle flex items-center gap-3 px-4 py-2 transition", + isGenerating ? "cursor-not-allowed" : "cursor-pointer" + )}> +
+ +
+
+
+ {t("can_you_tell_me_about")} {searchText} +
+
{t("use_ai_to_answer_your_questions")}
+
+
+
+ {error &&

{error}

} + {isGenerating && aiResponse === "" ? ( + + + + ) : ( + <> +
+
+ {baseUrl && + citations.map((citation) => { + if (citation.title) { + const url = optionallyAddBaseUrl(baseUrl, citation.url); + return ( + +
+ {citation.title} +
+
+ ); + } + return null; + })} +
+ + )} +
+ + ); +}; diff --git a/packages/features/mintlify-chat/util.ts b/packages/features/mintlify-chat/util.ts new file mode 100644 index 0000000000..c73fbf616d --- /dev/null +++ b/packages/features/mintlify-chat/util.ts @@ -0,0 +1,197 @@ +/** + * This file contains utility functions for interacting with the Mintlify chat API. + * The code was adapted from https://mintlify.com/docs/advanced/rest-api/overview#getting-started. The original source can be found at https://github.com/mintlify/discovery-api-example/tree/main/src/utils. + */ + +const API_KEY = process.env.NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY; +const apiBaseUrl = process.env.NEXT_PUBLIC_CHAT_API_URL; + +export const createChat = async () => { + if (!API_KEY || !apiBaseUrl) return; + + const topicResponse = await fetch(`${apiBaseUrl}/topic`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${API_KEY}`, + }, + }); + + if (!topicResponse.ok) { + return; + } + + const topic: unknown = await topicResponse.json(); + + if (topic && typeof topic === "object" && "topicId" in topic && typeof topic.topicId === "string") { + return topic.topicId; + } else { + return undefined; + } +}; + +export const generateResponse = async ({ + topicId, + userQuery, + onChunkReceived, +}: { + topicId: string; + userQuery: string; + onChunkReceived: (chunk: string, baseUrl?: string, finalChunk?: boolean) => void; +}) => { + if (!API_KEY || !apiBaseUrl) return; + + const queryResponse = await fetch( + ` +${apiBaseUrl}/message`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${API_KEY}`, + }, + body: JSON.stringify({ message: userQuery, topicId }), + } + ); + + if (!queryResponse.ok || !queryResponse.body) { + throw Error(queryResponse.statusText); + } + const streamReader = queryResponse.body.getReader(); + + for (;;) { + const { done, value } = await streamReader.read(); + if (done) { + const newValue = new TextDecoder().decode(value); + + onChunkReceived(newValue, queryResponse.headers.get("X-Mintlify-Base-Url") ?? "", true); + return; + } + + const newValue = new TextDecoder().decode(value); + onChunkReceived(newValue); + } +}; + +export const handleAiChat = async ( + onChunkReceived: (chunk: string, baseUrl?: string, finalChunk?: boolean) => void, + userQuery: string, + topicId?: string +) => { + let id = null; + let error = ""; + try { + if (!topicId) { + id = await createChat(); + } + + if (!id) + return { + id, + error: "Error creating topic. Please try again later", + }; + + await generateResponse({ + topicId: id, + onChunkReceived, + userQuery, + }); + } catch (err) { + if (err instanceof Error) { + error = err.message; + } else { + error = "k_bar_ai_error"; + } + } + + return { + id, + error, + }; +}; + +type ChunkMetadata = { + id: string; + link?: string; + metadata?: Record; + chunk_html?: string; +}; + +export const generateDeeplink = (chunkMetadata: ChunkMetadata) => { + if ( + !( + "metadata" in chunkMetadata && + !!chunkMetadata.metadata && + "title" in chunkMetadata.metadata && + "link" in chunkMetadata && + typeof chunkMetadata.link === "string" + ) + ) + return ""; + const section = chunkMetadata.metadata.title; + const link = optionallyAddLeadingSlash(chunkMetadata.link); + if (section && typeof section === "string") { + const sectionSlug = section + .toLowerCase() + .replaceAll(" ", "-") + .replaceAll(/[^a-zA-Z0-9-_#]/g, ""); + + return `${link}#${sectionSlug}`; + } + + return link; +}; + +type UnformattedCitation = { + id: string; + link: string; + chunk_html: string; + metadata: Record; +}; + +export type Citation = { + citationNumber: number; + title: string; + url: string; + rootRecordId?: number; + rootRecordType?: string; +}; + +export function getFormattedCitations(rawContent?: string): Citation[] { + try { + const citations: UnformattedCitation[] = JSON.parse(rawContent ?? "[]"); + + const uniqueCitations = new Map( + citations.map((citation, index) => { + const title = citation.metadata.title ?? ""; + const formattedCitation = { + citationNumber: index, + title: citation.metadata.title ?? "", + url: generateDeeplink(citation), + }; + + return [title, formattedCitation]; + }) + ); + + return Array.from(uniqueCitations.values()); + } catch { + return []; + } +} + +export function optionallyRemoveLeadingSlash(path: string) { + return path.startsWith("/") ? path.substring(1) : path; +} + +export function optionallyAddLeadingSlash(path: string) { + return path.startsWith("/") ? path : `/${path}`; +} + +export function optionallyAddBaseUrl(baseUrl: string, url: string) { + // absolute urls + if (url.startsWith("https://")) return url; + + const urlWithLeadingSlash = optionallyAddLeadingSlash(url); + return `${baseUrl}${urlWithLeadingSlash}`; +} diff --git a/turbo.json b/turbo.json index 1e175b4ec1..f340dddaa0 100644 --- a/turbo.json +++ b/turbo.json @@ -432,6 +432,9 @@ "LOCAL_TESTING_DOMAIN_VERCEL", "AUTH_BEARER_TOKEN_CLOUDFLARE", "CLOUDFLARE_ZONE_ID", + "NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY", + "NEXT_PUBLIC_CHAT_API_URL", + "NEXT_PUBLIC_DOCS_URL", "CLOUDFLARE_VERCEL_CNAME", "CLOUDFLARE_DNS", "EMBED_PUBLIC_EMBED_FINGER_PRINT",