Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,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=
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://leaves.mintlify.com/api/chat/calcomhelp

you can add this in the .env. The one present on mintlify docs website doesn't seem to be working

NEXT_PUBLIC_DOCS_URL=
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sometimes api request does not return X-Mintlify-Base-Url header in response, so you cannot form absolute links for docs citations. This will be used as base url to form absolute urls. Not required unless you want to have citations.


# 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=
Expand Down
4 changes: 4 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -2925,6 +2925,10 @@
"remove_option": "Remove option",
"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",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
Expand Down
18 changes: 16 additions & 2 deletions packages/features/kbar/Kbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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[];
};
Expand Down Expand Up @@ -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" />
Expand Down
100 changes: 100 additions & 0 deletions packages/features/mintlify-chat/MintlifyChat.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 197 additions & 0 deletions packages/features/mintlify-chat/util.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 3 additions & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,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",
Expand Down
Loading