feat: implement mintlify AI search into CMD+K (#18749)

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
This commit is contained in:
Amit Sharma
2025-02-20 16:07:13 +00:00
committed by GitHub
co-authored by Tushar Bhatt
parent 3f042a1077
commit 578f5dc16b
6 changed files with 326 additions and 2 deletions
+6
View File
@@ -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=
@@ -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",
+16 -2
View File
@@ -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 (
<KBarPortal>
<KBarPositioner>
<KBarPositioner className="overflow-scroll">
<KBarAnimator className="bg-default z-10 w-full max-w-screen-sm overflow-hidden rounded-md shadow-lg">
<div className="border-subtle flex items-center justify-center border-b">
<Icon name="search" className="text-default mx-3 h-4 w-4" />
<KBarSearch
defaultPlaceholder={t("kbar_search_placeholder")}
className="bg-default placeholder:text-subtle text-default w-full rounded-sm py-2.5 focus-visible:outline-none"
value={inputText}
onChange={(e) => {
setInputText(e.currentTarget.value.trim());
if (aiResponse) setAiResponse("");
}}
/>
</div>
{showAiChat && inputText && (
<MintlifyChat aiResponse={aiResponse} setAiResponse={setAiResponse} searchText={inputText} />
)}
<RenderResults />
<div className="text-subtle border-subtle hidden items-center space-x-1 border-t px-2 py-1.5 text-xs sm:flex">
<Icon name="arrow-up" className="h-4 w-4" />
@@ -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<SetStateAction<string>>;
}
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 (
<>
<div
onClick={async () => {
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"
)}>
<div>
<Icon name="star" />
</div>
<div>
<div>
{t("can_you_tell_me_about")} <span className="font-bold">{searchText}</span>
</div>
<div className="text-subtle text-sm">{t("use_ai_to_answer_your_questions")}</div>
</div>
</div>
<div className="px-2 px-4 text-sm">
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
{isGenerating && aiResponse === "" ? (
<SkeletonContainer>
<SkeletonText className="h-12 w-full" />
</SkeletonContainer>
) : (
<>
<div dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(answer) }} />
<div className="my-1 flex flex-wrap gap-2">
{baseUrl &&
citations.map((citation) => {
if (citation.title) {
const url = optionallyAddBaseUrl(baseUrl, citation.url);
return (
<a key={url} href={url} target="_blank">
<div className="flex h-6 items-center gap-1 rounded-md bg-gray-100 px-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
{citation.title}
</div>
</a>
);
}
return null;
})}
</div>
</>
)}
</div>
</>
);
};
+197
View File
@@ -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<string, unknown>;
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<string, string>;
};
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}`;
}
+3
View File
@@ -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",