Skip to content

Commit

Permalink
feat: chatbot component (#1209)
Browse files Browse the repository at this point in the history
Co-authored-by: Rohin Bhargava <rohin@buildwithfern.com>
Co-authored-by: Rohin Bhargava <rohinbharg@gmail.com>
Co-authored-by: Rohin Bhargava <rohinbhargava@Rohins-MacBook-Pro.local>
  • Loading branch information
4 people authored Aug 2, 2024
1 parent fe162af commit e623285
Showing 60 changed files with 3,773 additions and 797 deletions.
6 changes: 6 additions & 0 deletions .github/workflows/fdr-ete.yml
Original file line number Diff line number Diff line change
@@ -41,5 +41,11 @@ jobs:
- name: Install
uses: ./.github/actions/install

- name: Install Docker Compose
run: |
sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
docker-compose --version
- name: Run ete test
run: pnpm --filter=@fern-platform/fdr test:ete
1 change: 1 addition & 0 deletions packages/commons/search-utils/package.json
Original file line number Diff line number Diff line change
@@ -39,6 +39,7 @@
"@types/node": "^18.7.18",
"depcheck": "^1.4.3",
"eslint": "^8.56.0",
"instantsearch.js": "^4.63.0",
"organize-imports-cli": "^0.10.0",
"prettier": "^3.3.2",
"stylelint": "^16.1.0",
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { Algolia, FernNavigation } from "@fern-api/fdr-sdk";
import { SidebarVersionInfo } from "@fern-ui/fdr-utils";
import { UnreachableCaseError } from "ts-essentials";
import type { SearchRecord } from "./types";

export function getSlugForSearchRecord(record: SearchRecord, basePath: string | undefined): string {
export function getSlugForSearchRecord(record: Algolia.AlgoliaRecord, basePath: string | undefined): string {
return visitSearchRecord<string>(record)._visit({
v3: (record) => record.slug,
v2: (record) =>
@@ -21,6 +19,25 @@ export function getSlugForSearchRecord(record: SearchRecord, basePath: string |
});
}

export function getTitleForSearchRecord(record: Algolia.AlgoliaRecord): string {
return visitSearchRecord<string>(record)._visit({
v3: (record) => record.title,
v2: (record) =>
record.type === "endpoint-v2"
? record.endpoint.path.parts.map((p) => (p.type === "pathParameter" ? `:${p.value}` : p.value)).join("")
: record.title,
v1: (record) => record.title,
});
}

export function getContentForSearchRecord(record: Algolia.AlgoliaRecord): string | undefined {
return visitSearchRecord<string | undefined>(record)._visit({
v3: (record) => record.content ?? undefined,
v2: (record) => (record.type === "page-v2" ? record.content : undefined),
v1: () => undefined,
});
}

function getLeadingPathForSearchRecord(record: Algolia.AlgoliaRecord): string[] {
switch (record.type) {
case "page":
@@ -35,10 +52,10 @@ function getLeadingPathForSearchRecord(record: Algolia.AlgoliaRecord): string[]
}

export function createSearchPlaceholderWithVersion(
activeVersion: SidebarVersionInfo | undefined,
version: string | undefined,
sidebar: FernNavigation.SidebarRootNode | undefined,
): string {
return `Search ${activeVersion != null ? `across ${activeVersion.id} ` : ""}for ${createSearchPlaceholder(sidebar)}...`;
return `Search ${version != null ? `across ${version} ` : ""}for ${createSearchPlaceholder(sidebar)}...`;
}

function createSearchPlaceholder(sidebar: FernNavigation.SidebarRootNode | undefined): string {
2 changes: 2 additions & 0 deletions packages/commons/search-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from "./SearchConfig";
export * from "./getSlugForSearchRecord";
export * from "./types";
File renamed without changes.
7 changes: 4 additions & 3 deletions packages/ui/app/package.json
Original file line number Diff line number Diff line change
@@ -50,13 +50,14 @@
"@emotion/is-prop-valid": "^1.2.2",
"@fern-api/fdr-sdk": "workspace:*",
"@fern-api/template-resolver": "workspace:*",
"@fern-ui/chatbot": "workspace:*",
"@fern-ui/components": "workspace:*",
"@fern-ui/core-utils": "workspace:*",
"@fern-ui/fdr-utils": "workspace:*",
"@fern-ui/loadable": "workspace:*",
"@fern-ui/next-seo": "workspace:*",
"@fern-ui/react-commons": "workspace:*",
"@fern-ui/search-utils": "workspace:^",
"@fern-ui/search-utils": "workspace:*",
"@headlessui/react": "^1.7.18",
"@inkeep/widgets": "^0.2.288",
"@next/third-parties": "^14.2.4",
@@ -73,7 +74,7 @@
"@shikijs/transformers": "^1.2.2",
"@types/nprogress": "^0.2.3",
"@vercel/edge-config": "^1.1.0",
"algoliasearch": "^4.22.1",
"algoliasearch": "^4.24.0",
"bezier-easing": "^2.1.0",
"clsx": "^2.1.0",
"colorjs.io": "^0.5.0",
@@ -90,7 +91,6 @@
"hastscript": "^9.0.0",
"httpsnippet-lite": "^3.0.5",
"iconoir-react": "^7.7.0",
"instantsearch.js": "^4.63.0",
"jose": "^5.2.3",
"jotai": "^2.8.1",
"jotai-devtools": "^0.10.0",
@@ -168,6 +168,7 @@
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"chromatic": "^11.3.0",
"cohere-ai": "^7.9.5",
"depcheck": "^1.4.3",
"eslint": "^8.56.0",
"eslint-plugin-storybook": "^0.8.0",
3 changes: 3 additions & 0 deletions packages/ui/app/src/atoms/cohere.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { atom } from "jotai";

export const COHERE_ASK_AI = atom(false);
1 change: 1 addition & 0 deletions packages/ui/app/src/atoms/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./apis";
export * from "./auth";
export * from "./cohere";
export * from "./docs";
export * from "./files";
export * from "./flags";
1 change: 1 addition & 0 deletions packages/ui/app/src/css/globals.scss
Original file line number Diff line number Diff line change
@@ -18,3 +18,4 @@
@import "../themes";
@import "../docs/Header";
@import "../sidebar/Sidebar";
@import "@fern-ui/chatbot/src/index";
15 changes: 15 additions & 0 deletions packages/ui/app/src/hooks/useRouteChanged.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Router } from "next/router";
import { useEffect } from "react";

export function useRouteChanged(callback: () => void): void {
useEffect(() => {
Router.events.on("routeChangeComplete", callback);
Router.events.on("routeChangeError", callback);
Router.events.on("hashChangeComplete", callback);
return () => {
Router.events.off("routeChangeComplete", callback);
Router.events.off("routeChangeError", callback);
Router.events.off("hashChangeComplete", callback);
};
}, [callback]);
}
26 changes: 22 additions & 4 deletions packages/ui/app/src/search/SearchDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { createSearchPlaceholderWithVersion } from "@fern-ui/search-utils";
import { useAtomValue, useSetAtom } from "jotai";
import dynamic from "next/dynamic";
import { PropsWithChildren, ReactElement, useMemo, useRef } from "react";
import { InstantSearch } from "react-instantsearch";
import { CURRENT_VERSION_ATOM, IS_MOBILE_SCREEN_ATOM, SEARCH_DIALOG_OPEN_ATOM, useSidebarNodes } from "../atoms";
import {
CURRENT_VERSION_ATOM,
IS_MOBILE_SCREEN_ATOM,
SEARCH_DIALOG_OPEN_ATOM,
useDomain,
useSidebarNodes,
} from "../atoms";
import { useSearchConfig } from "../services/useSearchService";
import { SidebarSearchBar } from "../sidebar/SidebarSearchBar";
import { SearchMobileHits } from "./SearchHits";
@@ -11,10 +19,15 @@ import { useAlgoliaSearchClient } from "./algolia/useAlgoliaSearchClient";
import { InkeepChatButton } from "./inkeep/InkeepChatButton";
import { InkeepCustomTrigger } from "./inkeep/InkeepCustomTrigger";
import { useSearchTrigger } from "./useSearchTrigger";
import { createSearchPlaceholderWithVersion } from "./util";

const CohereChatButton = dynamic(
() => import("./cohere/CohereChatButton").then(({ CohereChatButton }) => CohereChatButton),
{ ssr: false },
);

export const SearchDialog = (): ReactElement | null => {
const setSearchDialogState = useSetAtom(SEARCH_DIALOG_OPEN_ATOM);
const domain = useDomain();
useSearchTrigger(setSearchDialogState);

const [config] = useSearchConfig();
@@ -24,7 +37,12 @@ export const SearchDialog = (): ReactElement | null => {
}

if (config.inkeep == null) {
return <AlgoliaSearchDialog />;
return (
<>
<AlgoliaSearchDialog />
{domain.includes("cohere") && <CohereChatButton />}
</>
);
} else {
return (
<>
@@ -43,7 +61,7 @@ export const SearchSidebar: React.FC<PropsWithChildren<SearchSidebar.Props>> = (
const sidebar = useSidebarNodes();
const activeVersion = useAtomValue(CURRENT_VERSION_ATOM);
const placeholder = useMemo(
() => createSearchPlaceholderWithVersion(activeVersion, sidebar),
() => createSearchPlaceholderWithVersion(activeVersion?.id, sidebar),
[activeVersion, sidebar],
);

3 changes: 1 addition & 2 deletions packages/ui/app/src/search/SearchHit.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { visitDiscriminatedUnion } from "@fern-ui/core-utils";
import { getSlugForSearchRecord, type SearchRecord } from "@fern-ui/search-utils";
import cn from "clsx";
import Link from "next/link";
import { ReactElement, useMemo } from "react";
@@ -9,8 +10,6 @@ import { EndpointRecordV3 } from "./content/EndpointRecordV3";
import { PageRecord } from "./content/PageRecord";
import { PageRecordV2 } from "./content/PageRecordV2";
import { PageRecordV3 } from "./content/PageRecordV3";
import type { SearchRecord } from "./types";
import { getSlugForSearchRecord } from "./util";

export declare namespace SearchHit {
export interface Props {
3 changes: 1 addition & 2 deletions packages/ui/app/src/search/SearchHits.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { FernScrollArea } from "@fern-ui/components";
import { useKeyboardPress } from "@fern-ui/react-commons";
import { getSlugForSearchRecord, type SearchRecord } from "@fern-ui/search-utils";
import { useRouter } from "next/router";
import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from "react";
import { useInfiniteHits, useInstantSearch } from "react-instantsearch";
import { useBasePath, useCloseSearchDialog } from "../atoms";
import { SearchHit } from "./SearchHit";
import type { SearchRecord } from "./types";
import { getSlugForSearchRecord } from "./util";

export const EmptyStateView: React.FC<PropsWithChildren> = ({ children }) => {
return <div className="justify t-muted flex h-24 w-full flex-col items-center py-3">{children}</div>;
16 changes: 10 additions & 6 deletions packages/ui/app/src/search/algolia/AlgoliaSearchDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
// import { Dialog, Transition } from "@headlessui/react";
import { createSearchPlaceholderWithVersion } from "@fern-ui/search-utils";
import * as Dialog from "@radix-ui/react-dialog";
import { SearchClient } from "algoliasearch";
import clsx from "clsx";
import { useAtomValue, useSetAtom } from "jotai";
import { ReactElement, useMemo, useRef } from "react";
import { InstantSearch } from "react-instantsearch";
import { CURRENT_VERSION_ATOM, POSITION_SEARCH_DIALOG_OVER_HEADER_ATOM, useSidebarNodes } from "../../atoms";

import { IS_MOBILE_SCREEN_ATOM, SEARCH_DIALOG_OPEN_ATOM, useIsSearchDialogOpen } from "../../atoms";
import {
CURRENT_VERSION_ATOM,
IS_MOBILE_SCREEN_ATOM,
POSITION_SEARCH_DIALOG_OVER_HEADER_ATOM,
SEARCH_DIALOG_OPEN_ATOM,
useIsSearchDialogOpen,
useSidebarNodes,
} from "../../atoms";
import { SearchHits } from "../SearchHits";
import { createSearchPlaceholderWithVersion } from "../util";
import { SearchBox } from "./SearchBox";
import { useAlgoliaSearchClient } from "./useAlgoliaSearchClient";

@@ -52,7 +56,7 @@ function FernInstantSearch({ searchClient, indexName, inputRef }: FernInstantSea
const sidebar = useSidebarNodes();
const activeVersion = useAtomValue(CURRENT_VERSION_ATOM);
const placeholder = useMemo(
() => createSearchPlaceholderWithVersion(activeVersion, sidebar),
() => createSearchPlaceholderWithVersion(activeVersion?.id, sidebar),
[activeVersion, sidebar],
);
return (
126 changes: 126 additions & 0 deletions packages/ui/app/src/search/cohere/CohereChatButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { ChatbotMessage, ChatbotModal, Citation, CohereIcon } from "@fern-ui/chatbot";
import * as Dialog from "@radix-ui/react-dialog";
import type { Cohere } from "cohere-ai";
import { useAtom } from "jotai";
import { ReactElement, isValidElement } from "react";
import { createPortal } from "react-dom";
import urlJoin from "url-join";
import { useCallbackOne } from "use-memo-one";
import { Stream } from "../../api-playground/Stream";
import { COHERE_ASK_AI, useBasePath } from "../../atoms";
import { FernLink } from "../../components/FernLink";
import { useRouteChanged } from "../../hooks/useRouteChanged";
import { CodeBlock } from "../../mdx/components/code";
import { useSearchConfig } from "../../services/useSearchService";
import { BuiltWithFern } from "../../sidebar/BuiltWithFern";

export function CohereChatButton(): ReactElement | null {
const [config] = useSearchConfig();
const [enabled, setEnabled] = useAtom(COHERE_ASK_AI);
const basePath = useBasePath();

// Close the dialog when the route changes
useRouteChanged(
useCallbackOne(() => {
setEnabled(false);
}, [setEnabled]),
);

const chatStream = async (message: string, conversationId: string) => {
const abortController = new AbortController();
const body = await fetch(urlJoin(basePath || "/", "/api/fern-docs/search/cohere"), {
method: "POST",
signal: abortController.signal,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ conversationId, message }),
}).then((res) => res.body);

if (body == null) {
return [undefined, abortController] as const;
}

let text = "";
const citations: Citation[] = [];

const stream = new Stream<ChatbotMessage>({
stream: body,
parse: async (val) => {
const event = val as Cohere.StreamedChatResponse;
if (event.eventType === "text-generation") {
text += event.text;
}

if (event.eventType === "citation-generation") {
event.citations.forEach((citation) => {
citations.push({
text: citation.text,
start: citation.start,
end: citation.end,
slugs: citation.documentIds,
});
});
}

return { message: text, role: "AI", citations };
},
terminator: "\n",
});

return [stream, abortController] as const;
};

if (!config.isAvailable || config.inkeep != null || typeof window === "undefined") {
return null;
}

return (
<Dialog.Root open={enabled} onOpenChange={setEnabled}>
{createPortal(
<Dialog.Trigger asChild>
<button className="fixed bottom-6 right-6 bg-background px-5 py-3 rounded-full border border-default inline-flex gap-2 items-center">
<CohereIcon />
<span>Ask AI</span>
</button>
</Dialog.Trigger>,
document.body,
)}
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-0 bg-background/50 backdrop-blur-sm" />
<Dialog.Content className="fixed md:max-w-content-width my-[10vh] top-0 inset-x-0 mx-6 max-h-[80vh] md:mx-auto flex flex-col">
<ChatbotModal
chatStream={chatStream}
className="bg-search-dialog border-default flex h-auto min-h-0 shrink flex-col overflow-hidden rounded-xl border text-left align-middle shadow-2xl backdrop-blur-lg"
components={{
pre(props) {
if (isValidElement(props.children) && props.children.type === "code") {
const { children, className } = props.children.props;
if (typeof children === "string") {
const match = /language-(\w+)/.exec(className || "")?.[1] ?? "plaintext";
return <CodeBlock code={children} language={match} />;
}
}
return <pre {...props} />;
},
a({ href, ...props }) {
if (href == null) {
return <a {...props} />;
}
return <FernLink href={href} {...props} />;
},
}}
belowInput={
<div className="mt-4 px-5 text-grayscale-a10 flex justify-between items-center gap-2">
<FernLink href="https://cohere.com/" className="text-xs font-medium">
Powered by Cohere (command-r-plus)
</FernLink>
<BuiltWithFern />
</div>
}
/>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Loading

0 comments on commit e623285

Please sign in to comment.