diff --git a/backend/alembic/versions/dbaa756c2ccf_embedding_models.py b/backend/alembic/versions/dbaa756c2ccf_embedding_models.py index 592614176ca1..c7b6fd58db1b 100644 --- a/backend/alembic/versions/dbaa756c2ccf_embedding_models.py +++ b/backend/alembic/versions/dbaa756c2ccf_embedding_models.py @@ -109,5 +109,4 @@ def downgrade() -> None: ) op.drop_column("index_attempt", "embedding_model_id") op.drop_table("embedding_model") - op.drop_index("ix_embedding_model_present_unique", table_name="embedding_model") - op.drop_index("ix_embedding_model_future_unique", table_name="embedding_model") + op.execute("DROP TYPE indexmodelstatus;") diff --git a/web/src/app/admin/bot/page.tsx b/web/src/app/admin/bot/page.tsx index 257153735ad3..d6816ce28cc7 100644 --- a/web/src/app/admin/bot/page.tsx +++ b/web/src/app/admin/bot/page.tsx @@ -2,7 +2,12 @@ import { ThreeDotsLoader } from "@/components/Loading"; import { PageSelector } from "@/components/PageSelector"; -import { CPUIcon, EditIcon, TrashIcon } from "@/components/icons/icons"; +import { + CPUIcon, + EditIcon, + SlackIcon, + TrashIcon, +} from "@/components/icons/icons"; import { SlackBotConfig } from "@/lib/types"; import { useState } from "react"; import { useSlackBotConfigs, useSlackBotTokens } from "./hooks"; @@ -21,7 +26,12 @@ import { Text, Title, } from "@tremor/react"; -import { FiArrowUpRight, FiChevronDown, FiChevronUp } from "react-icons/fi"; +import { + FiArrowUpRight, + FiChevronDown, + FiChevronUp, + FiSlack, +} from "react-icons/fi"; import Link from "next/link"; import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh"; @@ -283,7 +293,7 @@ const Page = () => { return (
} + icon={} title="Slack Bot Configuration" /> diff --git a/web/src/app/admin/connector/[ccPairId]/page.tsx b/web/src/app/admin/connector/[ccPairId]/page.tsx index 96020d060b66..7e613461bfcd 100644 --- a/web/src/app/admin/connector/[ccPairId]/page.tsx +++ b/web/src/app/admin/connector/[ccPairId]/page.tsx @@ -31,7 +31,8 @@ function Main({ ccPairId }: { ccPairId: number }) { error, } = useSWR( buildCCPairInfoUrl(ccPairId), - errorHandlingFetcher + errorHandlingFetcher, + { refreshInterval: 5000 } // 5 seconds ); if (isLoading) { diff --git a/web/src/app/admin/connectors/google-drive/ConnectorEditPopup.tsx b/web/src/app/admin/connectors/google-drive/ConnectorEditPopup.tsx index ee8e14a677bc..68bcd037fc17 100644 --- a/web/src/app/admin/connectors/google-drive/ConnectorEditPopup.tsx +++ b/web/src/app/admin/connectors/google-drive/ConnectorEditPopup.tsx @@ -18,7 +18,7 @@ interface Props { export const ConnectorEditPopup = ({ existingConnector, onSubmit }: Props) => { return ( -
+

Update Google Drive Connector
{ @@ -51,7 +52,10 @@ const ExistingKeys = () => { const Page = () => { return (
- } /> + } + /> diff --git a/web/src/app/admin/models/embedding/ModelSelectionConfirmation.tsx b/web/src/app/admin/models/embedding/ModelSelectionConfirmation.tsx new file mode 100644 index 000000000000..949c5d46da98 --- /dev/null +++ b/web/src/app/admin/models/embedding/ModelSelectionConfirmation.tsx @@ -0,0 +1,56 @@ +import { Modal } from "@/components/Modal"; +import { Button, Text } from "@tremor/react"; + +export function ModelSelectionConfirmaion({ + selectedModel, + onConfirm, +}: { + selectedModel: string; + onConfirm: () => void; +}) { + return ( +
+ + You have selected: {selectedModel}. Are you sure you want to + update to this new embedding model? + + + We will re-index all your documents in the background so you will be + able to continue to use Danswer as normal with the old model in the + meantime. Depending on how many documents you have indexed, this may + take a while. + + + NOTE: this re-indexing process will consume more resources than + normal. If you are self-hosting, we recommend that you allocate at least + 16GB of RAM to Danswer during this process. + +
+ +
+
+ ); +} + +export function ModelSelectionConfirmaionModal({ + selectedModel, + onConfirm, + onCancel, +}: { + selectedModel: string; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( + +
+ +
+
+ ); +} diff --git a/web/src/app/admin/models/embedding/ModelSelector.tsx b/web/src/app/admin/models/embedding/ModelSelector.tsx new file mode 100644 index 000000000000..4ac80785e836 --- /dev/null +++ b/web/src/app/admin/models/embedding/ModelSelector.tsx @@ -0,0 +1,77 @@ +import { DefaultDropdown, StringOrNumberOption } from "@/components/Dropdown"; +import { Title, Text } from "@tremor/react"; +import { FullEmbeddingModelDescriptor } from "./embeddingModels"; +import { FiStar } from "react-icons/fi"; + +export function ModelOption({ + model, + onSelect, +}: { + model: FullEmbeddingModelDescriptor; + onSelect?: (modelName: string) => void; +}) { + return ( +
+
+ {model.isDefault && } + {model.model_name} +
+
{model.description}
+ {model.link && ( + + See More Details + + )} + {onSelect && ( +
onSelect(model.model_name)} + > + Select Model +
+ )} +
+ ); +} + +export function ModelSelector({ + modelOptions, + setSelectedModel, +}: { + modelOptions: FullEmbeddingModelDescriptor[]; + setSelectedModel: (modelName: string) => void; +}) { + return ( +
+ {modelOptions.map((modelOption) => ( + + ))} +
+ ); +} diff --git a/web/src/app/admin/models/embedding/ReindexingProgressTable.tsx b/web/src/app/admin/models/embedding/ReindexingProgressTable.tsx new file mode 100644 index 000000000000..3b366c19226e --- /dev/null +++ b/web/src/app/admin/models/embedding/ReindexingProgressTable.tsx @@ -0,0 +1,78 @@ +import { PageSelector } from "@/components/PageSelector"; +import { CCPairStatus, IndexAttemptStatus } from "@/components/Status"; +import { ConnectorIndexingStatus, ValidStatuses } from "@/lib/types"; +import { + Button, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from "@tremor/react"; +import Link from "next/link"; +import { useState } from "react"; +import { FiMaximize2 } from "react-icons/fi"; + +export function ReindexingProgressTable({ + reindexingProgress, +}: { + reindexingProgress: ConnectorIndexingStatus[]; +}) { + const numToDisplay = 10; + const [page, setPage] = useState(1); + + return ( +
+ + + + Connector Name + Status + Docs Re-Indexed + + + + {reindexingProgress + .slice(numToDisplay * (page - 1), numToDisplay * page) + .map((reindexingProgress) => { + return ( + + + + + {reindexingProgress.name} + + + + {reindexingProgress.latest_index_attempt?.status && ( + + )} + + + {reindexingProgress?.latest_index_attempt + ?.total_docs_indexed || "-"} + + + ); + })} + +
+ +
+
+ setPage(newPage)} + /> +
+
+
+ ); +} diff --git a/web/src/app/admin/models/embedding/embeddingModels.ts b/web/src/app/admin/models/embedding/embeddingModels.ts new file mode 100644 index 000000000000..4bacdab99181 --- /dev/null +++ b/web/src/app/admin/models/embedding/embeddingModels.ts @@ -0,0 +1,73 @@ +export interface EmbeddingModelResponse { + model_name: string | null; +} + +export interface EmbeddingModelDescriptor { + model_name: string; + model_dim: number; + normalize: boolean; + query_prefix?: string; + passage_prefix?: string; +} + +export interface FullEmbeddingModelDescriptor extends EmbeddingModelDescriptor { + description: string; + isDefault?: boolean; + link?: string; +} + +export const AVAILABLE_MODELS: FullEmbeddingModelDescriptor[] = [ + { + model_name: "intfloat/e5-base-v2", + model_dim: 768, + normalize: true, + description: + "The recommended default for most situations. If you aren't sure which model to use, this is probably the one.", + isDefault: true, + link: "https://huggingface.co/intfloat/e5-base-v2", + query_prefix: "query: ", + passage_prefix: "passage: ", + }, + { + model_name: "intfloat/e5-small-v2", + model_dim: 384, + normalize: true, + description: + "A smaller / faster version of the default model. If you're running Danswer on a resource constrained system, then this is a good choice.", + link: "https://huggingface.co/intfloat/e5-small-v2", + query_prefix: "query: ", + passage_prefix: "passage: ", + }, + { + model_name: "intfloat/multilingual-e5-base", + model_dim: 768, + normalize: true, + description: + "If you have many documents in other languages besides English, this is the one to go for.", + link: "https://huggingface.co/intfloat/multilingual-e5-base", + query_prefix: "query: ", + passage_prefix: "passage: ", + }, + { + model_name: "intfloat/multilingual-e5-small", + model_dim: 384, + normalize: true, + description: + "If you have many documents in other languages besides English, and you're running on a resource constrained system, then this is the one to go for.", + link: "https://huggingface.co/intfloat/multilingual-e5-base", + query_prefix: "query: ", + passage_prefix: "passage: ", + }, +]; + +export const INVALID_OLD_MODEL = "thenlper/gte-small"; + +export function checkModelNameIsValid(modelName: string | undefined | null) { + if (!modelName) { + return false; + } + if (modelName === INVALID_OLD_MODEL) { + return false; + } + return true; +} diff --git a/web/src/app/admin/models/embedding/page.tsx b/web/src/app/admin/models/embedding/page.tsx new file mode 100644 index 000000000000..a3fb394af0d2 --- /dev/null +++ b/web/src/app/admin/models/embedding/page.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { LoadingAnimation, ThreeDotsLoader } from "@/components/Loading"; +import { AdminPageTitle } from "@/components/admin/Title"; +import { KeyIcon, TrashIcon } from "@/components/icons/icons"; +import { ApiKeyForm } from "@/components/openai/ApiKeyForm"; +import { GEN_AI_API_KEY_URL } from "@/components/openai/constants"; +import { errorHandlingFetcher, fetcher } from "@/lib/fetcher"; +import { Button, Divider, Text, Title } from "@tremor/react"; +import { FiCpu, FiPackage } from "react-icons/fi"; +import useSWR, { mutate } from "swr"; +import { ModelOption, ModelSelector } from "./ModelSelector"; +import { useState } from "react"; +import { ModelSelectionConfirmaionModal } from "./ModelSelectionConfirmation"; +import { ReindexingProgressTable } from "./ReindexingProgressTable"; +import { Modal } from "@/components/Modal"; +import { + AVAILABLE_MODELS, + EmbeddingModelResponse, + INVALID_OLD_MODEL, +} from "./embeddingModels"; +import { ErrorCallout } from "@/components/ErrorCallout"; +import { Connector, ConnectorIndexingStatus } from "@/lib/types"; +import Link from "next/link"; + +function Main() { + const [tentativeNewEmbeddingModel, setTentativeNewEmbeddingModel] = useState< + string | null + >(null); + const [isCancelling, setIsCancelling] = useState(false); + const [showAddConnectorPopup, setShowAddConnectorPopup] = + useState(false); + + const { + data: currentEmeddingModel, + isLoading: isLoadingCurrentModel, + error: currentEmeddingModelError, + } = useSWR( + "/api/secondary-index/get-current-embedding-model", + errorHandlingFetcher, + { refreshInterval: 5000 } // 5 seconds + ); + const { + data: futureEmeddingModel, + isLoading: isLoadingFutureModel, + error: futureEmeddingModelError, + } = useSWR( + "/api/secondary-index/get-secondary-embedding-model", + errorHandlingFetcher, + { refreshInterval: 5000 } // 5 seconds + ); + const { + data: ongoingReIndexingStatus, + isLoading: isLoadingOngoingReIndexingStatus, + } = useSWR[]>( + "/api/manage/admin/connector/indexing-status?secondary_index=true", + errorHandlingFetcher, + { refreshInterval: 5000 } // 5 seconds + ); + const { data: connectors } = useSWR[]>( + "/api/manage/connector", + errorHandlingFetcher, + { refreshInterval: 5000 } // 5 seconds + ); + + const onSelect = async (modelName: string) => { + if (currentEmeddingModel?.model_name === INVALID_OLD_MODEL) { + await onConfirm(modelName); + } else { + setTentativeNewEmbeddingModel(modelName); + } + }; + + const onConfirm = async (modelName: string) => { + const modelDescriptor = AVAILABLE_MODELS.find( + (model) => model.model_name === modelName + ); + + const response = await fetch( + "/api/secondary-index/set-new-embedding-model", + { + method: "POST", + body: JSON.stringify(modelDescriptor), + headers: { + "Content-Type": "application/json", + }, + } + ); + if (response.ok) { + setTentativeNewEmbeddingModel(null); + mutate("/api/secondary-index/get-secondary-embedding-model"); + if (!connectors || !connectors.length) { + setShowAddConnectorPopup(true); + } + } else { + alert(`Failed to update embedding model - ${await response.text()}`); + } + }; + + const onCancel = async () => { + const response = await fetch("/api/secondary-index/cancel-new-embedding", { + method: "POST", + }); + if (response.ok) { + setTentativeNewEmbeddingModel(null); + mutate("/api/secondary-index/get-secondary-embedding-model"); + } else { + alert( + `Failed to cancel embedding model update - ${await response.text()}` + ); + } + + setIsCancelling(false); + }; + + if (isLoadingCurrentModel || isLoadingFutureModel) { + return ; + } + + if ( + currentEmeddingModelError || + !currentEmeddingModel || + futureEmeddingModelError || + !futureEmeddingModel + ) { + return ; + } + + const currentModelName = currentEmeddingModel.model_name; + const currentModel = AVAILABLE_MODELS.find( + (model) => model.model_name === currentModelName + ); + + const newModelSelection = AVAILABLE_MODELS.find( + (model) => model.model_name === futureEmeddingModel.model_name + ); + + return ( +
+ {tentativeNewEmbeddingModel && ( + onConfirm(tentativeNewEmbeddingModel)} + onCancel={() => setTentativeNewEmbeddingModel(null)} + /> + )} + + {showAddConnectorPopup && ( + +
+
+ Embeding model successfully selected 🙌 +
+
+ To complete the initial setup, let's add a connector. +
+
+ + + +
+
+
+ )} + + {isCancelling && ( + setIsCancelling(false)} + title="Cancel Embedding Model Switch" + > +
+
+ Are you sure you want to cancel? +
+
+ Cancelling will revert to the previous model and all progress will + be lost. +
+
+ +
+
+
+ )} + + + Embedding models are used to generate embeddings for your documents, + which then power Danswer's search. + + + {currentModel ? ( + <> + Current Embedding Model + + + + + + ) : ( + newModelSelection && + (!connectors || !connectors.length) && ( + <> + Current Embedding Model + + + + + + ) + )} + + {!newModelSelection ? ( +
+ {currentModel ? ( + <> + Switch your Embedding Model + + + If the current model is not working for you, you can update your + model choice below. Note that this will require a complete + re-indexing of all your documents across every connected source. + We will take care of this in the background, but depending on + the size of your corpus, this could take hours, day, or even + weeks. You can monitor the progress of the re-indexing on this + page. + + + ) : ( + <> + Choose your Embedding Model + + )} + + modelOption.model_name !== currentModelName + )} + setSelectedModel={onSelect} + /> +
+ ) : ( + connectors && + connectors.length > 0 && ( +
+ Current Upgrade Status +
+
+ Currently in the process of switching to: +
+ + + + + + The table below shows the re-indexing progress of all existing + connectors. Once all connectors have been re-indexed, the new + model will be used for all search queries. Until then, we will + use the old model so that no downtime is necessary during this + transition. + + + {isLoadingOngoingReIndexingStatus ? ( + + ) : ongoingReIndexingStatus ? ( + + ) : ( + + )} +
+
+ ) + )} +
+ ); +} + +function Page() { + return ( +
+ } + /> + +
+
+ ); +} + +export default Page; diff --git a/web/src/app/chat/ChatIntro.tsx b/web/src/app/chat/ChatIntro.tsx index d0d47712ccaf..a94e1cc2af59 100644 --- a/web/src/app/chat/ChatIntro.tsx +++ b/web/src/app/chat/ChatIntro.tsx @@ -38,7 +38,7 @@ function AllPersonaOptionDisplay({ }) { return ( -
+

diff --git a/web/src/app/chat/page.tsx b/web/src/app/chat/page.tsx index 193041577fd5..9092bc7af319 100644 --- a/web/src/app/chat/page.tsx +++ b/web/src/app/chat/page.tsx @@ -6,12 +6,7 @@ import { import { redirect } from "next/navigation"; import { fetchSS } from "@/lib/utilsSS"; import { Connector, DocumentSet, Tag, User, ValidSources } from "@/lib/types"; -import { - BackendMessage, - ChatSession, - Message, - RetrievalType, -} from "./interfaces"; +import { ChatSession } from "./interfaces"; import { unstable_noStore as noStore } from "next/cache"; import { Persona } from "../admin/personas/interfaces"; import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh"; @@ -21,6 +16,11 @@ import { cookies } from "next/headers"; import { DOCUMENT_SIDEBAR_WIDTH_COOKIE_NAME } from "@/components/resizable/contants"; import { personaComparator } from "../admin/personas/lib"; import { ChatLayout } from "./ChatPage"; +import { + EmbeddingModelResponse, + checkModelNameIsValid, +} from "../admin/models/embedding/embeddingModels"; +import { SwitchModelModal } from "@/components/SwitchModelModal"; export default async function Page({ searchParams, @@ -37,20 +37,19 @@ export default async function Page({ fetchSS("/persona?include_default=true"), fetchSS("/chat/get-user-chat-sessions"), fetchSS("/query/valid-tags"), + fetchSS("/secondary-index/get-current-embedding-model"), ]; // catch cases where the backend is completely unreachable here // without try / catch, will just raise an exception and the page // will not render - let results: (User | Response | AuthTypeMetadata | null)[] = [ - null, - null, - null, - null, - null, - null, - null, - ]; + let results: ( + | User + | Response + | AuthTypeMetadata + | EmbeddingModelResponse + | null + )[] = [null, null, null, null, null, null, null, null]; try { results = await Promise.all(tasks); } catch (e) { @@ -63,6 +62,7 @@ export default async function Page({ const personasResponse = results[4] as Response | null; const chatSessionsResponse = results[5] as Response | null; const tagsResponse = results[6] as Response | null; + const embeddingModelResponse = results[7] as Response | null; const authDisabled = authTypeMetadata?.authType === "disabled"; if (!authDisabled && !user) { @@ -124,6 +124,11 @@ export default async function Page({ console.log(`Failed to fetch tags - ${tagsResponse?.status}`); } + const embeddingModelName = + embeddingModelResponse && embeddingModelResponse.ok + ? ((await embeddingModelResponse.json()).model_name as string) + : null; + const defaultPersonaIdRaw = searchParams["personaId"]; const defaultPersonaId = defaultPersonaIdRaw ? parseInt(defaultPersonaIdRaw) @@ -141,7 +146,13 @@ export default async function Page({ - {connectors.length === 0 && } + {connectors.length === 0 && ( + + )} + + {!checkModelNameIsValid(embeddingModelName) && ( + + )} - {connectors.length === 0 && connectorsResponse?.ok && } + + {connectors.length === 0 && connectorsResponse?.ok && ( + + )} + + {!checkModelNameIsValid(embeddingModelName) && ( + + )} +
void; className?: string; + width?: string; } export function Modal({ @@ -10,6 +14,7 @@ export function Modal({ title, onOutsideClick, className, + width, }: ModalProps) { return (
@@ -23,15 +28,26 @@ export function Modal({
event.stopPropagation()} > {title && ( -

- {title} -

+ <> +
+

{title}

+ {onOutsideClick && ( +
+ +
+ )} +
+ + )} {children}
diff --git a/web/src/components/SwitchModelModal.tsx b/web/src/components/SwitchModelModal.tsx new file mode 100644 index 000000000000..bdb1ec22a83c --- /dev/null +++ b/web/src/components/SwitchModelModal.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Button, Text } from "@tremor/react"; +import { Modal } from "./Modal"; +import Link from "next/link"; +import { FiCheckCircle } from "react-icons/fi"; +import { checkModelNameIsValid } from "@/app/admin/models/embedding/embeddingModels"; + +export function SwitchModelModal({ + embeddingModelName, +}: { + embeddingModelName: null | string; +}) { + return ( + +
+

+ ❗ Switch Embedding Model ❗ +

+ + We've detected you are using our old default embedding model ( + {embeddingModelName || "thenlper/gte-small"}). We believe that + search performance can be dramatically improved by a simple model + switch. +
+
+ Please click the button below to choose a new model. Don't worry, + the re-indexing necessary for the switch will happen in the background + - your use of Danswer will not be interrupted. +
+ +
+ + + +
+
+
+ ); +} diff --git a/web/src/components/WelcomeModal.tsx b/web/src/components/WelcomeModal.tsx index 418eb5c0f42a..06eb172e5b9d 100644 --- a/web/src/components/WelcomeModal.tsx +++ b/web/src/components/WelcomeModal.tsx @@ -1,34 +1,68 @@ "use client"; -import { Button } from "@tremor/react"; +import { Button, Text } from "@tremor/react"; import { Modal } from "./Modal"; import Link from "next/link"; +import { FiCheckCircle } from "react-icons/fi"; +import { checkModelNameIsValid } from "@/app/admin/models/embedding/embeddingModels"; + +export function WelcomeModal({ + embeddingModelName, +}: { + embeddingModelName: null | string; +}) { + const validModelSelected = checkModelNameIsValid(embeddingModelName); -export function WelcomeModal() { return ( -
+

Welcome to Danswer 🎉

-

+

Danswer is the AI-powered search engine for your organization's internal knowledge. Whenever you need to find any piece of internal information, Danswer is there to help!

-

- To get started, the first step is to configure some{" "} - connectors. Connectors are the way that Danswer gets data - from your organization's various data sources. Once setup, - we'll automatically sync data from your apps and docs into - Danswer, so you can search all through all of them in one place. -

- +
+ {validModelSelected && ( + + )} + Step 1: Choose Your Embedding Model +
+ {!validModelSelected && ( + <> + To get started, the first step is to choose your{" "} + embedding model. This machine learning model helps power + Danswer's search. Different models have different strengths, + but don't worry we'll guide you through the process of + choosing the right one for your organization. + + )}
- - + + + +
+ + Step 2: Add Your First Connector + + Next, we need to to configure some connectors. Connectors are the + way that Danswer gets data from your organization's various data + sources. Once setup, we'll automatically sync data from your apps + and docs into Danswer, so you can search all through all of them in one + place. +
+ +
diff --git a/web/src/components/admin/Layout.tsx b/web/src/components/admin/Layout.tsx index 10a6408c39cc..fadeaee8d94d 100644 --- a/web/src/components/admin/Layout.tsx +++ b/web/src/components/admin/Layout.tsx @@ -10,6 +10,7 @@ import { ZoomInIcon, RobotIcon, ConnectorIcon, + SlackIcon, } from "@/components/icons/icons"; import { User } from "@/lib/types"; import { @@ -18,6 +19,7 @@ import { getCurrentUserSS, } from "@/lib/userSS"; import { redirect } from "next/navigation"; +import { FiCpu, FiLayers, FiPackage, FiSlack } from "react-icons/fi"; export async function Layout({ children }: { children: React.ReactNode }) { const tasks = [getAuthTypeMetadataSS(), getCurrentUserSS()]; @@ -128,7 +130,7 @@ export async function Layout({ children }: { children: React.ReactNode }) { { name: (
- +
Slack Bots
), @@ -137,17 +139,26 @@ export async function Layout({ children }: { children: React.ReactNode }) { ], }, { - name: "Keys", + name: "Model Configs", items: [ { name: (
- -
OpenAI
+ +
LLM
), link: "/admin/keys/openai", }, + { + name: ( +
+ +
Embedding
+
+ ), + link: "/admin/models/embedding", + }, ], }, { diff --git a/web/src/components/openai/ApiKeyModal.tsx b/web/src/components/openai/ApiKeyModal.tsx index 9d0ffbf2725e..8ed58bd45405 100644 --- a/web/src/components/openai/ApiKeyModal.tsx +++ b/web/src/components/openai/ApiKeyModal.tsx @@ -25,7 +25,7 @@ export const ApiKeyModal = () => { return ( setIsOpen(false)}> -
+
Can't find a valid registered OpenAI API key. Please provide