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:
co-authored by
Tushar Bhatt
parent
3f042a1077
commit
578f5dc16b
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user