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: chatbot component #1209

Merged
merged 31 commits into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
9a8f629
cohere stream test
abvthecity Jul 24, 2024
036c7cb
remove deadcode
abvthecity Jul 24, 2024
2c95bf3
fixed
abvthecity Jul 24, 2024
4941a6b
refactor
abvthecity Jul 24, 2024
44003bb
prove out streaming
abvthecity Jul 25, 2024
8d55c94
improve state
abvthecity Jul 25, 2024
f77ce22
feat: chatbot component
abvthecity Jul 26, 2024
1b84fd0
component
abvthecity Jul 26, 2024
3cbb7ea
chatbot modal prototype
abvthecity Jul 26, 2024
14e10ae
render citations
abvthecity Jul 26, 2024
b2d7ebd
modal
abvthecity Jul 26, 2024
0470a87
combine
abvthecity Jul 26, 2024
dc629cf
Merge branch 'main' into ajiang/cohere-stream
abvthecity Jul 29, 2024
de6fe2b
Merge remote-tracking branch 'origin/main' into ajiang/cohere-stream
RohinBhargava Jul 30, 2024
a25d583
feat: add algolia to cohere chat demo (#1210)
RohinBhargava Jul 30, 2024
c98be79
Merge branch 'main' into ajiang/chatbot
abvthecity Jul 31, 2024
010c345
improvements to chat
abvthecity Aug 1, 2024
f498fa7
dependencies
abvthecity Aug 1, 2024
0eb1e60
Merge branch 'main' into ajiang/chatbot
abvthecity Aug 1, 2024
9119437
Merge branch 'ajiang/cohere-stream' into ajiang/chatbot
abvthecity Aug 1, 2024
890da20
cohere is integrated
abvthecity Aug 2, 2024
d4d2f3a
fix compile break
abvthecity Aug 2, 2024
69714a2
render code block
abvthecity Aug 2, 2024
32f51ce
chatbot
abvthecity Aug 2, 2024
9eda784
Merge branch 'main' into ajiang/chatbot
abvthecity Aug 2, 2024
177e293
fixes
abvthecity Aug 2, 2024
563bc18
reset base files
abvthecity Aug 2, 2024
bc25f94
close the dialog on route change
abvthecity Aug 2, 2024
4704785
Merge branch 'main' into ajiang/chatbot
abvthecity Aug 2, 2024
37062cd
format
abvthecity Aug 2, 2024
e2d414b
add docker-compose
abvthecity Aug 2, 2024
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 .github/workflows/fdr-ete.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Up @@ -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",
Expand Down
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) =>
Expand All @@ -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":
Expand All @@ -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 {
Expand Down
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";
7 changes: 4 additions & 3 deletions packages/ui/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
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";
Expand Down
1 change: 1 addition & 0 deletions packages/ui/app/src/css/globals.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand All @@ -24,7 +37,12 @@ export const SearchDialog = (): ReactElement | null => {
}

if (config.inkeep == null) {
return <AlgoliaSearchDialog />;
return (
<>
<AlgoliaSearchDialog />
{domain.includes("cohere") && <CohereChatButton />}
</>
);
} else {
return (
<>
Expand All @@ -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],
);

Expand Down
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";
Expand All @@ -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 {
Expand Down
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>;
Expand Down
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";

Expand Down Expand Up @@ -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 (
Expand Down
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
Loading