Add custom-styling ability via themes

This commit is contained in:
Weves 2024-04-07 21:36:17 -07:00 committed by Chris Weaver
parent 153007c57c
commit ee2a5bbf49
15 changed files with 1230 additions and 226 deletions

View File

@ -208,6 +208,7 @@ services:
- NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-}
- NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME:-}
- NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED:-true}
depends_on:
- api_server
@ -215,6 +216,7 @@ services:
environment:
- INTERNAL_URL=http://api_server:8080
- WEB_DOMAIN=${WEB_DOMAIN:-}
- THEME_IS_DARK=${THEME_IS_DARK:-}
inference_model_server:
@ -271,7 +273,6 @@ services:
max-size: "50m"
max-file: "6"
relational_db:
image: postgres:15.2-alpine
restart: always

View File

@ -70,6 +70,7 @@ services:
- NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-}
- NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME:-}
- NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED:-true}
depends_on:
- api_server

View File

@ -70,6 +70,7 @@ services:
- NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-}
- NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME:-}
- NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED:-true}
depends_on:
- api_server

View File

@ -53,6 +53,9 @@ ENV NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PRED
ARG NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS
ENV NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS}
ARG NEXT_PUBLIC_THEME
ENV NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME}
ARG NEXT_PUBLIC_EE_ENABLED
ENV NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED}
@ -103,6 +106,9 @@ ENV NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PRED
ARG NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS
ENV NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS}
ARG NEXT_PUBLIC_THEME
ENV NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME}
ARG NEXT_PUBLIC_EE_ENABLED
ENV NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED}

View File

@ -160,7 +160,7 @@ export function Explorer({
<div>
{popup}
<div className="justify-center py-2">
<div className="flex items-center w-full border-2 border-border rounded-lg px-4 py-2 focus-within:border-accent">
<div className="flex items-center w-full border-2 border-border rounded-lg px-4 py-2 focus-within:border-accent bg-background-search">
<MagnifyingGlass />
<textarea
autoFocus

View File

@ -4,6 +4,10 @@ export interface Settings {
default_page: "search" | "chat";
}
export interface ColorConfig {
primary: string | null;
}
export interface EnterpriseSettings {
application_name: string | null;
use_custom_logo: boolean;
@ -12,6 +16,7 @@ export interface EnterpriseSettings {
custom_header_content: string | null;
custom_popup_header: string | null;
custom_popup_content: string | null;
color_config: ColorConfig | null;
}
export interface CombinedSettings {

968
web/src/app/chat/Chat.tsx Normal file
View File

@ -0,0 +1,968 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { FiSend, FiShare2, FiStopCircle } from "react-icons/fi";
import { AIMessage, HumanMessage } from "./message/Messages";
import { AnswerPiecePacket, DanswerDocument } from "@/lib/search/interfaces";
import {
BackendChatSession,
BackendMessage,
ChatSessionSharedStatus,
DocumentsResponse,
Message,
RetrievalType,
StreamingError,
} from "./interfaces";
import { useRouter, useSearchParams } from "next/navigation";
import { FeedbackType } from "./types";
import {
buildChatUrl,
createChatSession,
getCitedDocumentsFromMessage,
getHumanAndAIMessageFromMessageNumber,
getLastSuccessfulMessageId,
handleAutoScroll,
handleChatFeedback,
nameChatSession,
personaIncludesRetrieval,
processRawChatHistory,
sendMessage,
} from "./lib";
import { ThreeDots } from "react-loader-spinner";
import { FeedbackModal } from "./modal/FeedbackModal";
import { DocumentSidebar } from "./documentSidebar/DocumentSidebar";
import { ChatPersonaSelector } from "./ChatPersonaSelector";
import { useFilters } from "@/lib/hooks";
import { DocumentSet, Tag, ValidSources } from "@/lib/types";
import { ChatFilters } from "./modifiers/ChatFilters";
import { buildFilters } from "@/lib/search/utils";
import { SelectedDocuments } from "./modifiers/SelectedDocuments";
import { usePopup } from "@/components/admin/connectors/Popup";
import { ResizableSection } from "@/components/resizable/ResizableSection";
import { DanswerInitializingLoader } from "@/components/DanswerInitializingLoader";
import { ChatIntro } from "./ChatIntro";
import { HEADER_PADDING } from "@/lib/constants";
import { computeAvailableFilters } from "@/lib/filters";
import { useDocumentSelection } from "./useDocumentSelection";
import { StarterMessage } from "./StarterMessage";
import { ShareChatSessionModal } from "./modal/ShareChatSessionModal";
import { SEARCH_PARAM_NAMES, shouldSubmitOnLoad } from "./searchParams";
import { Persona } from "../admin/assistants/interfaces";
const MAX_INPUT_HEIGHT = 200;
export const Chat = ({
existingChatSessionId,
existingChatSessionPersonaId,
availableSources,
availableDocumentSets,
availablePersonas,
availableTags,
defaultSelectedPersonaId,
documentSidebarInitialWidth,
shouldhideBeforeScroll,
}: {
existingChatSessionId: number | null;
existingChatSessionPersonaId: number | undefined;
availableSources: ValidSources[];
availableDocumentSets: DocumentSet[];
availablePersonas: Persona[];
availableTags: Tag[];
defaultSelectedPersonaId?: number; // what persona to default to
documentSidebarInitialWidth?: number;
shouldhideBeforeScroll?: boolean;
}) => {
const router = useRouter();
const searchParams = useSearchParams();
// used to track whether or not the initial "submit on load" has been performed
// this only applies if `?submit-on-load=true` or `?submit-on-load=1` is in the URL
// NOTE: this is required due to React strict mode, where all `useEffect` hooks
// are run twice on initial load during development
const submitOnLoadPerformed = useRef<boolean>(false);
const { popup, setPopup } = usePopup();
// fetch messages for the chat session
const [isFetchingChatMessages, setIsFetchingChatMessages] = useState(
existingChatSessionId !== null
);
// needed so closures (e.g. onSubmit) can access the current value
const urlChatSessionId = useRef<number | null>();
// this is triggered every time the user switches which chat
// session they are using
useEffect(() => {
urlChatSessionId.current = existingChatSessionId;
textareaRef.current?.focus();
// only clear things if we're going from one chat session to another
if (chatSessionId !== null && existingChatSessionId !== chatSessionId) {
// de-select documents
clearSelectedDocuments();
// reset all filters
filterManager.setSelectedDocumentSets([]);
filterManager.setSelectedSources([]);
filterManager.setSelectedTags([]);
filterManager.setTimeRange(null);
if (isStreaming) {
setIsCancelled(true);
}
}
setChatSessionId(existingChatSessionId);
async function initialSessionFetch() {
if (existingChatSessionId === null) {
setIsFetchingChatMessages(false);
if (defaultSelectedPersonaId !== undefined) {
setSelectedPersona(
availablePersonas.find(
(persona) => persona.id === defaultSelectedPersonaId
)
);
} else {
setSelectedPersona(undefined);
}
setMessageHistory([]);
setChatSessionSharedStatus(ChatSessionSharedStatus.Private);
// if we're supposed to submit on initial load, then do that here
if (
shouldSubmitOnLoad(searchParams) &&
!submitOnLoadPerformed.current
) {
submitOnLoadPerformed.current = true;
await onSubmit();
}
return;
}
setIsFetchingChatMessages(true);
const response = await fetch(
`/api/chat/get-chat-session/${existingChatSessionId}`
);
const chatSession = (await response.json()) as BackendChatSession;
setSelectedPersona(
availablePersonas.find(
(persona) => persona.id === chatSession.persona_id
)
);
const newMessageHistory = processRawChatHistory(chatSession.messages);
setMessageHistory(newMessageHistory);
const latestMessageId =
newMessageHistory[newMessageHistory.length - 1]?.messageId;
setSelectedMessageForDocDisplay(
latestMessageId !== undefined ? latestMessageId : null
);
setChatSessionSharedStatus(chatSession.shared_status);
setIsFetchingChatMessages(false);
// if this is a seeded chat, then kick off the AI message generation
if (newMessageHistory.length === 1 && !submitOnLoadPerformed.current) {
submitOnLoadPerformed.current = true;
const seededMessage = newMessageHistory[0].message;
await onSubmit({
isSeededChat: true,
messageOverride: seededMessage,
});
// force re-name if the chat session doesn't have one
if (!chatSession.description) {
await nameChatSession(existingChatSessionId, seededMessage);
router.refresh(); // need to refresh to update name on sidebar
}
}
}
initialSessionFetch();
}, [existingChatSessionId]);
const [chatSessionId, setChatSessionId] = useState<number | null>(
existingChatSessionId
);
const [message, setMessage] = useState(
searchParams.get(SEARCH_PARAM_NAMES.USER_MESSAGE) || ""
);
const [messageHistory, setMessageHistory] = useState<Message[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
// for document display
// NOTE: -1 is a special designation that means the latest AI message
const [selectedMessageForDocDisplay, setSelectedMessageForDocDisplay] =
useState<number | null>(null);
const { aiMessage } = selectedMessageForDocDisplay
? getHumanAndAIMessageFromMessageNumber(
messageHistory,
selectedMessageForDocDisplay
)
: { aiMessage: null };
const [selectedPersona, setSelectedPersona] = useState<Persona | undefined>(
existingChatSessionPersonaId !== undefined
? availablePersonas.find(
(persona) => persona.id === existingChatSessionPersonaId
)
: defaultSelectedPersonaId !== undefined
? availablePersonas.find(
(persona) => persona.id === defaultSelectedPersonaId
)
: undefined
);
const livePersona = selectedPersona || availablePersonas[0];
const [chatSessionSharedStatus, setChatSessionSharedStatus] =
useState<ChatSessionSharedStatus>(ChatSessionSharedStatus.Private);
useEffect(() => {
if (messageHistory.length === 0 && chatSessionId === null) {
setSelectedPersona(
availablePersonas.find(
(persona) => persona.id === defaultSelectedPersonaId
)
);
}
}, [defaultSelectedPersonaId]);
const [
selectedDocuments,
toggleDocumentSelection,
clearSelectedDocuments,
selectedDocumentTokens,
] = useDocumentSelection();
// just choose a conservative default, this will be updated in the
// background on initial load / on persona change
const [maxTokens, setMaxTokens] = useState<number>(4096);
// fetch # of allowed document tokens for the selected Persona
useEffect(() => {
async function fetchMaxTokens() {
const response = await fetch(
`/api/chat/max-selected-document-tokens?persona_id=${livePersona.id}`
);
if (response.ok) {
const maxTokens = (await response.json()).max_tokens as number;
setMaxTokens(maxTokens);
}
}
fetchMaxTokens();
}, [livePersona]);
const filterManager = useFilters();
const [finalAvailableSources, finalAvailableDocumentSets] =
computeAvailableFilters({
selectedPersona,
availableSources,
availableDocumentSets,
});
// state for cancelling streaming
const [isCancelled, setIsCancelled] = useState(false);
const isCancelledRef = useRef(isCancelled);
useEffect(() => {
isCancelledRef.current = isCancelled;
}, [isCancelled]);
const [currentFeedback, setCurrentFeedback] = useState<
[FeedbackType, number] | null
>(null);
const [sharingModalVisible, setSharingModalVisible] =
useState<boolean>(false);
// auto scroll as message comes out
const scrollableDivRef = useRef<HTMLDivElement>(null);
const endDivRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isStreaming || !message) {
handleAutoScroll(endDivRef, scrollableDivRef);
}
});
// scroll to bottom initially
const [hasPerformedInitialScroll, setHasPerformedInitialScroll] = useState(
shouldhideBeforeScroll !== true
);
useEffect(() => {
endDivRef.current?.scrollIntoView();
setHasPerformedInitialScroll(true);
}, [isFetchingChatMessages]);
// handle re-sizing of the text area
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "0px";
textarea.style.height = `${Math.min(
textarea.scrollHeight,
MAX_INPUT_HEIGHT
)}px`;
}
}, [message]);
// used for resizing of the document sidebar
const masterFlexboxRef = useRef<HTMLDivElement>(null);
const [maxDocumentSidebarWidth, setMaxDocumentSidebarWidth] = useState<
number | null
>(null);
const adjustDocumentSidebarWidth = () => {
if (masterFlexboxRef.current && document.documentElement.clientWidth) {
// numbers below are based on the actual width the center section for different
// screen sizes. `1700` corresponds to the custom "3xl" tailwind breakpoint
// NOTE: some buffer is needed to account for scroll bars
if (document.documentElement.clientWidth > 1700) {
setMaxDocumentSidebarWidth(masterFlexboxRef.current.clientWidth - 950);
} else if (document.documentElement.clientWidth > 1420) {
setMaxDocumentSidebarWidth(masterFlexboxRef.current.clientWidth - 760);
} else {
setMaxDocumentSidebarWidth(masterFlexboxRef.current.clientWidth - 660);
}
}
};
useEffect(() => {
adjustDocumentSidebarWidth(); // Adjust the width on initial render
window.addEventListener("resize", adjustDocumentSidebarWidth); // Add resize event listener
return () => {
window.removeEventListener("resize", adjustDocumentSidebarWidth); // Cleanup the event listener
};
}, []);
if (!documentSidebarInitialWidth && maxDocumentSidebarWidth) {
documentSidebarInitialWidth = Math.min(700, maxDocumentSidebarWidth);
}
const onSubmit = async ({
messageIdToResend,
messageOverride,
queryOverride,
forceSearch,
isSeededChat,
}: {
messageIdToResend?: number;
messageOverride?: string;
queryOverride?: string;
forceSearch?: boolean;
isSeededChat?: boolean;
} = {}) => {
let currChatSessionId: number;
let isNewSession = chatSessionId === null;
const searchParamBasedChatSessionName =
searchParams.get(SEARCH_PARAM_NAMES.TITLE) || null;
if (isNewSession) {
currChatSessionId = await createChatSession(
livePersona?.id || 0,
searchParamBasedChatSessionName
);
} else {
currChatSessionId = chatSessionId as number;
}
setChatSessionId(currChatSessionId);
const messageToResend = messageHistory.find(
(message) => message.messageId === messageIdToResend
);
const messageToResendIndex = messageToResend
? messageHistory.indexOf(messageToResend)
: null;
if (!messageToResend && messageIdToResend !== undefined) {
setPopup({
message:
"Failed to re-send message - please refresh the page and try again.",
type: "error",
});
return;
}
let currMessage = messageToResend ? messageToResend.message : message;
if (messageOverride) {
currMessage = messageOverride;
}
const currMessageHistory =
messageToResendIndex !== null
? messageHistory.slice(0, messageToResendIndex)
: messageHistory;
setMessageHistory([
...currMessageHistory,
{
messageId: 0,
message: currMessage,
type: "user",
},
]);
setMessage("");
setIsStreaming(true);
let answer = "";
let query: string | null = null;
let retrievalType: RetrievalType =
selectedDocuments.length > 0
? RetrievalType.SelectedDocs
: RetrievalType.None;
let documents: DanswerDocument[] = selectedDocuments;
let error: string | null = null;
let finalMessage: BackendMessage | null = null;
try {
const lastSuccessfulMessageId =
getLastSuccessfulMessageId(currMessageHistory);
for await (const packetBunch of sendMessage({
message: currMessage,
parentMessageId: lastSuccessfulMessageId,
chatSessionId: currChatSessionId,
promptId: livePersona?.prompts[0]?.id || 0,
filters: buildFilters(
filterManager.selectedSources,
filterManager.selectedDocumentSets,
filterManager.timeRange,
filterManager.selectedTags
),
selectedDocumentIds: selectedDocuments
.filter(
(document) =>
document.db_doc_id !== undefined && document.db_doc_id !== null
)
.map((document) => document.db_doc_id as number),
queryOverride,
forceSearch,
modelVersion:
searchParams.get(SEARCH_PARAM_NAMES.MODEL_VERSION) || undefined,
temperature:
parseFloat(searchParams.get(SEARCH_PARAM_NAMES.TEMPERATURE) || "") ||
undefined,
systemPromptOverride:
searchParams.get(SEARCH_PARAM_NAMES.SYSTEM_PROMPT) || undefined,
useExistingUserMessage: isSeededChat,
})) {
for (const packet of packetBunch) {
if (Object.hasOwn(packet, "answer_piece")) {
answer += (packet as AnswerPiecePacket).answer_piece;
} else if (Object.hasOwn(packet, "top_documents")) {
documents = (packet as DocumentsResponse).top_documents;
query = (packet as DocumentsResponse).rephrased_query;
retrievalType = RetrievalType.Search;
if (documents && documents.length > 0) {
// point to the latest message (we don't know the messageId yet, which is why
// we have to use -1)
setSelectedMessageForDocDisplay(-1);
}
} else if (Object.hasOwn(packet, "error")) {
error = (packet as StreamingError).error;
} else if (Object.hasOwn(packet, "message_id")) {
finalMessage = packet as BackendMessage;
}
}
setMessageHistory([
...currMessageHistory,
{
messageId: finalMessage?.parent_message || null,
message: currMessage,
type: "user",
},
{
messageId: finalMessage?.message_id || null,
message: error || answer,
type: error ? "error" : "assistant",
retrievalType,
query: finalMessage?.rephrased_query || query,
documents: finalMessage?.context_docs?.top_documents || documents,
citations: finalMessage?.citations || {},
},
]);
if (isCancelledRef.current) {
setIsCancelled(false);
break;
}
}
} catch (e: any) {
const errorMsg = e.message;
setMessageHistory([
...currMessageHistory,
{
messageId: null,
message: currMessage,
type: "user",
},
{
messageId: null,
message: errorMsg,
type: "error",
},
]);
}
setIsStreaming(false);
if (isNewSession) {
if (finalMessage) {
setSelectedMessageForDocDisplay(finalMessage.message_id);
}
if (!searchParamBasedChatSessionName) {
await nameChatSession(currChatSessionId, currMessage);
}
// NOTE: don't switch pages if the user has navigated away from the chat
if (
currChatSessionId === urlChatSessionId.current ||
urlChatSessionId.current === null
) {
router.push(buildChatUrl(searchParams, currChatSessionId, null), {
scroll: false,
});
}
}
if (
finalMessage?.context_docs &&
finalMessage.context_docs.top_documents.length > 0 &&
retrievalType === RetrievalType.Search
) {
setSelectedMessageForDocDisplay(finalMessage.message_id);
}
};
const onFeedback = async (
messageId: number,
feedbackType: FeedbackType,
feedbackDetails: string
) => {
if (chatSessionId === null) {
return;
}
const response = await handleChatFeedback(
messageId,
feedbackType,
feedbackDetails
);
if (response.ok) {
setPopup({
message: "Thanks for your feedback!",
type: "success",
});
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: `Failed to submit feedback - ${errorMsg}`,
type: "error",
});
}
};
const retrievalDisabled = !personaIncludesRetrieval(livePersona);
return (
<div className="flex w-full overflow-x-hidden" ref={masterFlexboxRef}>
{popup}
{currentFeedback && (
<FeedbackModal
feedbackType={currentFeedback[0]}
onClose={() => setCurrentFeedback(null)}
onSubmit={(feedbackDetails) => {
onFeedback(currentFeedback[1], currentFeedback[0], feedbackDetails);
setCurrentFeedback(null);
}}
/>
)}
{sharingModalVisible && chatSessionId !== null && (
<ShareChatSessionModal
chatSessionId={chatSessionId}
existingSharedStatus={chatSessionSharedStatus}
onClose={() => setSharingModalVisible(false)}
onShare={(shared) =>
setChatSessionSharedStatus(
shared
? ChatSessionSharedStatus.Public
: ChatSessionSharedStatus.Private
)
}
/>
)}
{documentSidebarInitialWidth !== undefined ? (
<>
<div
className={`w-full sm:relative h-screen ${
retrievalDisabled ? "pb-[111px]" : "pb-[140px]"
}`}
>
<div
className={`w-full h-full ${HEADER_PADDING} flex flex-col overflow-y-auto overflow-x-hidden relative`}
ref={scrollableDivRef}
>
{livePersona && (
<div className="sticky top-0 left-80 z-10 w-full bg-background/90 flex">
<div className="ml-2 p-1 rounded mt-2 w-fit">
<ChatPersonaSelector
personas={availablePersonas}
selectedPersonaId={livePersona.id}
onPersonaChange={(persona) => {
if (persona) {
setSelectedPersona(persona);
textareaRef.current?.focus();
router.push(
buildChatUrl(searchParams, null, persona.id)
);
}
}}
/>
</div>
{chatSessionId !== null && (
<div
onClick={() => setSharingModalVisible(true)}
className="ml-auto mr-6 my-auto border-border border p-2 rounded cursor-pointer hover:bg-hover-light"
>
<FiShare2 />
</div>
)}
</div>
)}
{messageHistory.length === 0 &&
!isFetchingChatMessages &&
!isStreaming && (
<ChatIntro
availableSources={finalAvailableSources}
availablePersonas={availablePersonas}
selectedPersona={selectedPersona}
handlePersonaSelect={(persona) => {
setSelectedPersona(persona);
textareaRef.current?.focus();
router.push(buildChatUrl(searchParams, null, persona.id));
}}
/>
)}
<div
className={
"mt-4 pt-12 sm:pt-0 mx-8" +
(hasPerformedInitialScroll ? "" : " invisible")
}
>
{messageHistory.map((message, i) => {
if (message.type === "user") {
return (
<div key={i}>
<HumanMessage content={message.message} />
</div>
);
} else if (message.type === "assistant") {
const isShowingRetrieved =
(selectedMessageForDocDisplay !== null &&
selectedMessageForDocDisplay === message.messageId) ||
(selectedMessageForDocDisplay === -1 &&
i === messageHistory.length - 1);
const previousMessage =
i !== 0 ? messageHistory[i - 1] : null;
return (
<div key={i}>
<AIMessage
messageId={message.messageId}
content={message.message}
query={messageHistory[i]?.query || undefined}
personaName={livePersona.name}
citedDocuments={getCitedDocumentsFromMessage(message)}
isComplete={
i !== messageHistory.length - 1 || !isStreaming
}
hasDocs={
(message.documents &&
message.documents.length > 0) === true
}
handleFeedback={
i === messageHistory.length - 1 && isStreaming
? undefined
: (feedbackType) =>
setCurrentFeedback([
feedbackType,
message.messageId as number,
])
}
handleSearchQueryEdit={
i === messageHistory.length - 1 && !isStreaming
? (newQuery) => {
if (!previousMessage) {
setPopup({
type: "error",
message:
"Cannot edit query of first message - please refresh the page and try again.",
});
return;
}
if (previousMessage.messageId === null) {
setPopup({
type: "error",
message:
"Cannot edit query of a pending message - please wait a few seconds and try again.",
});
return;
}
onSubmit({
messageIdToResend:
previousMessage.messageId,
queryOverride: newQuery,
});
}
: undefined
}
isCurrentlyShowingRetrieved={isShowingRetrieved}
handleShowRetrieved={(messageNumber) => {
if (isShowingRetrieved) {
setSelectedMessageForDocDisplay(null);
} else {
if (messageNumber !== null) {
setSelectedMessageForDocDisplay(messageNumber);
} else {
setSelectedMessageForDocDisplay(-1);
}
}
}}
handleForceSearch={() => {
if (previousMessage && previousMessage.messageId) {
onSubmit({
messageIdToResend: previousMessage.messageId,
forceSearch: true,
});
} else {
setPopup({
type: "error",
message:
"Failed to force search - please refresh the page and try again.",
});
}
}}
retrievalDisabled={retrievalDisabled}
/>
</div>
);
} else {
return (
<div key={i}>
<AIMessage
messageId={message.messageId}
personaName={livePersona.name}
content={
<p className="text-red-700 text-sm my-auto">
{message.message}
</p>
}
/>
</div>
);
}
})}
{isStreaming &&
messageHistory.length &&
messageHistory[messageHistory.length - 1].type === "user" && (
<div key={messageHistory.length}>
<AIMessage
messageId={null}
personaName={livePersona.name}
content={
<div className="text-sm my-auto">
<ThreeDots
height="30"
width="50"
color="#3b82f6"
ariaLabel="grid-loading"
radius="12.5"
wrapperStyle={{}}
wrapperClass=""
visible={true}
/>
</div>
}
/>
</div>
)}
{/* Some padding at the bottom so the search bar has space at the bottom to not cover the last message*/}
<div className={`min-h-[30px] w-full`}></div>
{livePersona &&
livePersona.starter_messages &&
livePersona.starter_messages.length > 0 &&
selectedPersona &&
messageHistory.length === 0 &&
!isFetchingChatMessages && (
<div
className={`
mx-auto
px-4
w-searchbar-xs
2xl:w-searchbar-sm
3xl:w-searchbar
grid
gap-4
grid-cols-1
grid-rows-1
mt-4
md:grid-cols-2
mb-6`}
>
{livePersona.starter_messages.map((starterMessage, i) => (
<div key={i} className="w-full">
<StarterMessage
starterMessage={starterMessage}
onClick={() =>
onSubmit({
messageOverride: starterMessage.message,
})
}
/>
</div>
))}
</div>
)}
<div ref={endDivRef} />
</div>
</div>
<div className="absolute bottom-0 z-10 w-full bg-background border-t border-border">
<div className="w-full pb-4 pt-2">
{!retrievalDisabled && (
<div className="flex">
<div className="w-searchbar-xs 2xl:w-searchbar-sm 3xl:w-searchbar mx-auto px-4 pt-1 flex">
{selectedDocuments.length > 0 ? (
<SelectedDocuments
selectedDocuments={selectedDocuments}
/>
) : (
<ChatFilters
{...filterManager}
existingSources={finalAvailableSources}
availableDocumentSets={finalAvailableDocumentSets}
availableTags={availableTags}
/>
)}
</div>
</div>
)}
<div className="flex justify-center py-2 max-w-screen-lg mx-auto mb-2">
<div className="w-full shrink relative px-4 w-searchbar-xs 2xl:w-searchbar-sm 3xl:w-searchbar mx-auto">
<textarea
ref={textareaRef}
autoFocus
className={`
opacity-100
w-full
shrink
border
border-border
bg-background-search
rounded-lg
outline-none
placeholder-subtle
bg-background-emphasis
pl-4
pr-12
py-4
overflow-hidden
h-14
${
(textareaRef?.current?.scrollHeight || 0) >
MAX_INPUT_HEIGHT
? "overflow-y-auto"
: ""
}
whitespace-normal
break-word
overscroll-contain
resize-none
`}
style={{ scrollbarWidth: "thin" }}
role="textarea"
aria-multiline
placeholder="Ask me anything..."
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
message &&
!isStreaming
) {
onSubmit();
event.preventDefault();
}
}}
suppressContentEditableWarning={true}
/>
<div className="absolute bottom-4 right-10">
<div
className={"cursor-pointer"}
onClick={() => {
if (!isStreaming) {
if (message) {
onSubmit();
}
} else {
setIsCancelled(true);
}
}}
>
{isStreaming ? (
<FiStopCircle
size={18}
className={
"text-emphasis w-9 h-9 p-2 rounded-lg hover:bg-hover"
}
/>
) : (
<FiSend
size={18}
className={
"text-emphasis w-9 h-9 p-2 rounded-lg " +
(message ? "bg-blue-200" : "")
}
/>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{!retrievalDisabled ? (
<ResizableSection
intialWidth={documentSidebarInitialWidth}
minWidth={400}
maxWidth={maxDocumentSidebarWidth || undefined}
>
<DocumentSidebar
selectedMessage={aiMessage}
selectedDocuments={selectedDocuments}
toggleDocumentSelection={toggleDocumentSelection}
clearSelectedDocuments={clearSelectedDocuments}
selectedDocumentTokens={selectedDocumentTokens}
maxTokens={maxTokens}
isLoading={isFetchingChatMessages}
/>
</ResizableSection>
) : // Another option is to use a div with the width set to the initial width, so that the
// chat section appears in the same place as before
// <div style={documentSidebarInitialWidth ? {width: documentSidebarInitialWidth} : {}}></div>
null}
</>
) : (
<div className="mx-auto h-full flex flex-col">
<div className="my-auto">
<DanswerInitializingLoader />
</div>
</div>
)}
</div>
);
};

View File

@ -286,6 +286,9 @@ export const AIMessage = ({
code: (props) => (
<CodeBlock {...props} content={content as string} />
),
p: ({ node, ...props }) => (
<p {...props} className="text-default" />
),
}}
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypePrism, { ignoreMissing: true }]]}

View File

@ -13,7 +13,6 @@ export function ImageUpload({
}) {
const [tmpImageUrl, setTmpImageUrl] = useState<string>("");
console.log(selectedFile);
useEffect(() => {
if (selectedFile) {
setTmpImageUrl(URL.createObjectURL(selectedFile));

View File

@ -1,7 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import { EnterpriseSettings, Settings } from "@/app/admin/settings/interfaces";
import { EnterpriseSettings } from "@/app/admin/settings/interfaces";
import { useContext, useState } from "react";
import { SettingsContext } from "@/components/settings/SettingsProviderClientSideHelper";
import { Form, Formik } from "formik";
@ -42,6 +42,7 @@ export function WhitelabelingForm() {
alert(`Failed to update settings. ${errorMsg}`);
}
}
console.log(enterpriseSettings);
return (
<div>
@ -49,10 +50,14 @@ export function WhitelabelingForm() {
initialValues={{
application_name: enterpriseSettings?.application_name || null,
use_custom_logo: enterpriseSettings?.use_custom_logo || false,
color_config: enterpriseSettings?.color_config || null,
}}
validationSchema={Yup.object().shape({
application_name: Yup.string(),
use_custom_logo: Yup.boolean().required(),
color_config: Yup.object().shape({
primary: Yup.string(),
}),
})}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);

View File

@ -16,27 +16,29 @@
}
::-webkit-scrollbar-track {
background: #f9fafb; /* Track background color */
background: theme("colors.scrollbar.track"); /* Track background color */
}
/* Style the scrollbar handle */
::-webkit-scrollbar-thumb {
background: #e5e7eb; /* Handle color */
background: theme("colors.scrollbar.thumb"); /* Handle color */
border-radius: 10px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #d1d5db; /* Handle color on hover */
background: theme("colors.scrollbar.thumb-hover"); /* Handle color on hover */
}
.dark-scrollbar::-webkit-scrollbar-thumb {
background: #c7cdd2; /* Handle color */
background: theme("colors.scrollbar.dark.thumb"); /* Handle color */
border-radius: 10px;
}
.dark-scrollbar::-webkit-scrollbar-thumb:hover {
background: #989a9c; /* Handle color on hover */
background: theme(
"colors.scrollbar.dark.thumb-hover"
); /* Handle color on hover */
}
::-webkit-scrollbar {

View File

@ -27,7 +27,7 @@ export const SearchBar = ({ query, setQuery, onSearch }: SearchBarProps) => {
return (
<div className="flex justify-center">
<div className="flex items-center w-full opacity-100 border-2 border-border rounded-lg px-4 py-2 focus-within:border-accent bg-white">
<div className="flex items-center w-full opacity-100 border-2 border-border rounded-lg px-4 py-2 focus-within:border-accent bg-background-search">
<MagnifyingGlass className="text-emphasis" />
<textarea
autoFocus

4
web/tailwind-themes/custom/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

View File

@ -0,0 +1,214 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
// Or if using `src` directory:
"./src/**/*.{js,ts,jsx,tsx,mdx}",
// tremor
"./node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
],
theme: {
transparent: "transparent",
current: "currentColor",
extend: {
screens: {
"2xl": "1420px",
"3xl": "1700px",
},
fontFamily: {
sans: ["var(--font-inter)"],
},
width: {
"message-xs": "450px",
"message-sm": "550px",
"message-default": "740px",
"searchbar-xs": "560px",
"searchbar-sm": "660px",
searchbar: "850px",
"document-sidebar": "800px",
"document-sidebar-large": "1000px",
},
maxWidth: {
"document-sidebar": "1000px",
},
colors: {
// background
background: "#f9fafb", // gray-50
"background-emphasis": "#f6f7f8",
"background-strong": "#eaecef",
"background-search": "#ffffff",
// text or icons
link: "#3b82f6", // blue-500
subtle: "#6b7280", // gray-500
default: "#4b5563", // gray-600
emphasis: "#374151", // gray-700
strong: "#111827", // gray-900
inverted: "#ffffff", // white
error: "#ef4444", // red-500
success: "#059669", // emerald-600
alert: "#f59e0b", // amber-600
accent: "#6671d0",
// borders
border: "#e5e7eb", // gray-200
"border-light": "#f3f4f6", // gray-100
"border-strong": "#9ca3af", // gray-400
// hover
"hover-light": "#f3f4f6", // gray-100
hover: "#e5e7eb", // gray-200
"hover-emphasis": "#d1d5db", // gray-300
"accent-hover": "#5964c2",
// keyword highlighting
highlight: {
text: "#fef9c3", // yellow-100
},
// bubbles in chat for each "user"
user: "#fb7185", // yellow-400
ai: "#60a5fa", // blue-400
// for display documents
document: "#ec4899", // pink-500
// scrollbar
scrollbar: {
track: "#f9fafb",
thumb: "#e5e7eb",
"thumb-hover": "#d1d5db",
dark: {
thumb: "#989a9c",
"thumb-hover": "#c7cdd2",
},
},
// light mode
tremor: {
brand: {
faint: "#eff6ff", // blue-50
muted: "#bfdbfe", // blue-200
subtle: "#60a5fa", // blue-400
DEFAULT: "#3b82f6", // blue-500
emphasis: "#1d4ed8", // blue-700
inverted: "#ffffff", // white
},
background: {
muted: "#f9fafb", // gray-50
subtle: "#f3f4f6", // gray-100
DEFAULT: "#ffffff", // white
emphasis: "#374151", // gray-700
},
border: {
DEFAULT: "#e5e7eb", // gray-200
},
ring: {
DEFAULT: "#e5e7eb", // gray-200
},
content: {
subtle: "#9ca3af", // gray-400
DEFAULT: "#4b5563", // gray-600
emphasis: "#374151", // gray-700
strong: "#111827", // gray-900
inverted: "#ffffff", // white
},
},
// dark mode
"dark-tremor": {
brand: {
faint: "#0B1229", // custom
muted: "#172554", // blue-950
subtle: "#1e40af", // blue-800
DEFAULT: "#3b82f6", // blue-500
emphasis: "#60a5fa", // blue-400
inverted: "#030712", // gray-950
},
background: {
muted: "#131A2B", // custom
subtle: "#1f2937", // gray-800
DEFAULT: "#111827", // gray-900
emphasis: "#d1d5db", // gray-300
},
border: {
DEFAULT: "#1f2937", // gray-800
},
ring: {
DEFAULT: "#1f2937", // gray-800
},
content: {
subtle: "#6b7280", // gray-500
DEFAULT: "#d1d5db", // gray-300
emphasis: "#f3f4f6", // gray-100
strong: "#f9fafb", // gray-50
inverted: "#000000", // black
},
},
},
boxShadow: {
// light
"tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
"tremor-card":
"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
"tremor-dropdown":
"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
// dark
"dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
"dark-tremor-card":
"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
"dark-tremor-dropdown":
"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
},
borderRadius: {
"tremor-small": "0.375rem",
"tremor-default": "0.5rem",
"tremor-full": "9999px",
},
fontSize: {
"tremor-label": ["0.75rem"],
"tremor-default": ["0.875rem", { lineHeight: "1.25rem" }],
"tremor-title": ["1.125rem", { lineHeight: "1.75rem" }],
"tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }],
},
},
},
safelist: [
{
pattern:
/^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
variants: ["hover", "ui-selected"],
},
{
pattern:
/^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
variants: ["hover", "ui-selected"],
},
{
pattern:
/^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
variants: ["hover", "ui-selected"],
},
{
pattern:
/^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
},
{
pattern:
/^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
},
{
pattern:
/^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
},
],
plugins: [
require("@tailwindcss/typography"),
require("@headlessui/tailwindcss"),
],
};

View File

@ -1,217 +1,12 @@
var merge = require("lodash/merge");
const baseThemes = require("./tailwind-themes/tailwind.config.js");
const customThemes =
process.env.NEXT_PUBLIC_EE_ENABLED && process.env.NEXT_PUBLIC_THEME
? require(
`./tailwind-themes/custom/${process.env.NEXT_PUBLIC_THEME}/tailwind.config.js`
)
: null;
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
// Or if using `src` directory:
"./src/**/*.{js,ts,jsx,tsx,mdx}",
// tremor
"./node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
],
theme: {
transparent: "transparent",
current: "currentColor",
extend: {
screens: {
"2xl": "1420px",
"3xl": "1700px",
},
fontFamily: {
sans: ["var(--font-inter)"],
},
width: {
"message-xs": "450px",
"message-sm": "550px",
"message-default": "740px",
"searchbar-xs": "560px",
"searchbar-sm": "660px",
searchbar: "850px",
"document-sidebar": "800px",
"document-sidebar-large": "1000px",
},
maxWidth: {
"document-sidebar": "1000px",
},
colors: {
// background
background: "#f9fafb", // gray-50
"background-emphasis": "#f6f7f8",
"background-strong": "#eaecef",
"background-search": "#ffffff",
"background-custom-header": "#f3f4f6",
"background-inverted": "#000000",
// text or icons
link: "#3b82f6", // blue-500
"link-hover": "#1d4ed8", // blue-700
subtle: "#6b7280", // gray-500
default: "#4b5563", // gray-600
emphasis: "#374151", // gray-700
strong: "#111827", // gray-900
inverted: "#ffffff", // white
error: "#ef4444", // red-500
success: "#059669", // emerald-600
alert: "#f59e0b", // amber-600
accent: "#6671d0",
// borders
border: "#e5e7eb", // gray-200
"border-light": "#f3f4f6", // gray-100
"border-strong": "#9ca3af", // gray-400
// hover
"hover-light": "#f3f4f6", // gray-100
hover: "#e5e7eb", // gray-200
"hover-emphasis": "#d1d5db", // gray-300
"accent-hover": "#5964c2",
// keyword highlighting
highlight: {
text: "#fef9c3", // yellow-100
},
// scrollbar
scrollbar: {
track: "#f9fafb",
thumb: "#e5e7eb",
"thumb-hover": "#d1d5db",
dark: {
thumb: "#989a9c",
"thumb-hover": "#c7cdd2",
},
},
// bubbles in chat for each "user"
user: "#fb7185", // yellow-400
ai: "#60a5fa", // blue-400
// for display documents
document: "#ec4899", // pink-500
// light mode
tremor: {
brand: {
faint: "#eff6ff", // blue-50
muted: "#bfdbfe", // blue-200
subtle: "#60a5fa", // blue-400
DEFAULT: "#3b82f6", // blue-500
emphasis: "#1d4ed8", // blue-700
inverted: "#ffffff", // white
},
background: {
muted: "#f9fafb", // gray-50
subtle: "#f3f4f6", // gray-100
DEFAULT: "#ffffff", // white
emphasis: "#374151", // gray-700
},
border: {
DEFAULT: "#e5e7eb", // gray-200
},
ring: {
DEFAULT: "#e5e7eb", // gray-200
},
content: {
subtle: "#9ca3af", // gray-400
DEFAULT: "#4b5563", // gray-600
emphasis: "#374151", // gray-700
strong: "#111827", // gray-900
inverted: "#ffffff", // white
},
},
// dark mode
"dark-tremor": {
brand: {
faint: "#0B1229", // custom
muted: "#172554", // blue-950
subtle: "#1e40af", // blue-800
DEFAULT: "#3b82f6", // blue-500
emphasis: "#60a5fa", // blue-400
inverted: "#030712", // gray-950
},
background: {
muted: "#131A2B", // custom
subtle: "#1f2937", // gray-800
DEFAULT: "#111827", // gray-900
emphasis: "#d1d5db", // gray-300
},
border: {
DEFAULT: "#1f2937", // gray-800
},
ring: {
DEFAULT: "#1f2937", // gray-800
},
content: {
subtle: "#6b7280", // gray-500
DEFAULT: "#d1d5db", // gray-300
emphasis: "#f3f4f6", // gray-100
strong: "#f9fafb", // gray-50
inverted: "#000000", // black
},
},
},
boxShadow: {
// light
"tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
"tremor-card":
"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
"tremor-dropdown":
"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
// dark
"dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
"dark-tremor-card":
"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
"dark-tremor-dropdown":
"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
},
borderRadius: {
"tremor-small": "0.375rem",
"tremor-default": "0.5rem",
"tremor-full": "9999px",
},
fontSize: {
"tremor-label": ["0.75rem"],
"tremor-default": ["0.875rem", { lineHeight: "1.25rem" }],
"tremor-title": ["1.125rem", { lineHeight: "1.75rem" }],
"tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }],
},
},
},
safelist: [
{
pattern:
/^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
variants: ["hover", "ui-selected"],
},
{
pattern:
/^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
variants: ["hover", "ui-selected"],
},
{
pattern:
/^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
variants: ["hover", "ui-selected"],
},
{
pattern:
/^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
},
{
pattern:
/^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
},
{
pattern:
/^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
},
],
plugins: [
require("@tailwindcss/typography"),
require("@headlessui/tailwindcss"),
],
};
module.exports = customThemes ? merge(baseThemes, customThemes) : baseThemes;