diff --git a/web/src/app/admin/personas/interfaces.ts b/web/src/app/admin/personas/interfaces.ts
index aaa3f3d35a..8f5e559c32 100644
--- a/web/src/app/admin/personas/interfaces.ts
+++ b/web/src/app/admin/personas/interfaces.ts
@@ -1,13 +1,27 @@
import { DocumentSet } from "@/lib/types";
+export interface Prompt {
+ id: number;
+ name: string;
+ shared: boolean;
+ description: string;
+ system_prompt: string;
+ task_prompt: string;
+ include_citations: boolean;
+ datetime_aware: boolean;
+ default_prompt: boolean;
+}
+
export interface Persona {
id: number;
name: string;
+ shared: boolean;
description: string;
- system_prompt: string;
- task_prompt: string;
document_sets: DocumentSet[];
+ prompts: Prompt[];
num_chunks?: number;
- apply_llm_relevance_filter?: boolean;
+ llm_relevance_filter?: boolean;
+ llm_filter_extraction?: boolean;
llm_model_version_override?: string;
+ default_persona: boolean;
}
diff --git a/web/src/app/admin/personas/lib.ts b/web/src/app/admin/personas/lib.ts
index ff6ef59fa0..4a76ef95b2 100644
--- a/web/src/app/admin/personas/lib.ts
+++ b/web/src/app/admin/personas/lib.ts
@@ -1,3 +1,5 @@
+import { Prompt } from "./interfaces";
+
interface PersonaCreationRequest {
name: string;
description: string;
@@ -5,41 +7,157 @@ interface PersonaCreationRequest {
task_prompt: string;
document_set_ids: number[];
num_chunks: number | null;
- apply_llm_relevance_filter: boolean | null;
-}
-
-export function createPersona(personaCreationRequest: PersonaCreationRequest) {
- return fetch("/api/admin/persona", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(personaCreationRequest),
- });
+ llm_relevance_filter: boolean | null;
}
interface PersonaUpdateRequest {
id: number;
+ existingPromptId: number;
+ name: string;
description: string;
system_prompt: string;
task_prompt: string;
document_set_ids: number[];
num_chunks: number | null;
- apply_llm_relevance_filter: boolean | null;
+ llm_relevance_filter: boolean | null;
}
-export function updatePersona(personaUpdateRequest: PersonaUpdateRequest) {
- const { id, ...requestBody } = personaUpdateRequest;
+function promptNameFromPersonaName(personaName: string) {
+ return `default-prompt__${personaName}`;
+}
- return fetch(`/api/admin/persona/${id}`, {
+function createPrompt({
+ personaName,
+ systemPrompt,
+ taskPrompt,
+}: {
+ personaName: string;
+ systemPrompt: string;
+ taskPrompt: string;
+}) {
+ return fetch("/api/prompt", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ name: promptNameFromPersonaName(personaName),
+ description: `Default prompt for persona ${personaName}`,
+ shared: true,
+ system_prompt: systemPrompt,
+ task_prompt: taskPrompt,
+ }),
+ });
+}
+
+function updatePrompt({
+ promptId,
+ personaName,
+ systemPrompt,
+ taskPrompt,
+}: {
+ promptId: number;
+ personaName: string;
+ systemPrompt: string;
+ taskPrompt: string;
+}) {
+ return fetch(`/api/prompt/${promptId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
- body: JSON.stringify(requestBody),
+ body: JSON.stringify({
+ name: promptNameFromPersonaName(personaName),
+ description: `Default prompt for persona ${personaName}`,
+ shared: true,
+ system_prompt: systemPrompt,
+ task_prompt: taskPrompt,
+ }),
});
}
+function buildPersonaAPIBody(
+ creationRequest: PersonaCreationRequest | PersonaUpdateRequest,
+ promptId: number
+) {
+ const {
+ name,
+ description,
+ document_set_ids,
+ num_chunks,
+ llm_relevance_filter,
+ } = creationRequest;
+
+ return {
+ name,
+ description,
+ shared: true,
+ num_chunks,
+ llm_relevance_filter,
+ llm_filter_extraction: false,
+ recency_bias: "base_decay",
+ prompt_ids: [promptId],
+ document_set_ids,
+ };
+}
+
+export async function createPersona(
+ personaCreationRequest: PersonaCreationRequest
+): Promise<[Response, Response | null]> {
+ // first create prompt
+ const createPromptResponse = await createPrompt({
+ personaName: personaCreationRequest.name,
+ systemPrompt: personaCreationRequest.system_prompt,
+ taskPrompt: personaCreationRequest.task_prompt,
+ });
+ const promptId = createPromptResponse.ok
+ ? (await createPromptResponse.json()).id
+ : null;
+
+ const createPersonaResponse =
+ promptId !== null
+ ? await fetch("/api/admin/persona", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(
+ buildPersonaAPIBody(personaCreationRequest, promptId)
+ ),
+ })
+ : null;
+
+ return [createPromptResponse, createPersonaResponse];
+}
+
+export async function updatePersona(
+ personaUpdateRequest: PersonaUpdateRequest
+): Promise<[Response, Response | null]> {
+ const { id, existingPromptId, ...requestBody } = personaUpdateRequest;
+
+ // first update prompt
+ const updatePromptResponse = await updatePrompt({
+ promptId: existingPromptId,
+ personaName: personaUpdateRequest.name,
+ systemPrompt: personaUpdateRequest.system_prompt,
+ taskPrompt: personaUpdateRequest.task_prompt,
+ });
+
+ const updatePersonaResponse = updatePromptResponse.ok
+ ? await fetch(`/api/admin/persona/${id}`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(
+ buildPersonaAPIBody(personaUpdateRequest, existingPromptId)
+ ),
+ })
+ : null;
+
+ return [updatePromptResponse, updatePersonaResponse];
+}
+
export function deletePersona(personaId: number) {
return fetch(`/api/admin/persona/${personaId}`, {
method: "DELETE",
diff --git a/web/src/app/admin/personas/new/page.tsx b/web/src/app/admin/personas/new/page.tsx
index 7416a587c4..6fd675ae6f 100644
--- a/web/src/app/admin/personas/new/page.tsx
+++ b/web/src/app/admin/personas/new/page.tsx
@@ -46,7 +46,7 @@ export default async function Page() {
const defaultLLM = (await defaultLLMResponse.json()) as string;
return (
-
+
+
} title="Personas" />
@@ -31,7 +31,7 @@ export default async function Page() {
for different use cases.
They allow you to customize:
-
+
The prompt used by your LLM of choice to respond to the user query
@@ -40,13 +40,13 @@ export default async function Page() {
-
+
Create a Persona
diff --git a/web/src/app/admin/users/page.tsx b/web/src/app/admin/users/page.tsx
index 380428f452..006e9c892e 100644
--- a/web/src/app/admin/users/page.tsx
+++ b/web/src/app/admin/users/page.tsx
@@ -1,101 +1,112 @@
"use client";
-import { Button } from "@/components/Button";
+import {
+ Table,
+ TableHead,
+ TableRow,
+ TableHeaderCell,
+ TableBody,
+ TableCell,
+ Button,
+} from "@tremor/react";
import { LoadingAnimation } from "@/components/Loading";
import { AdminPageTitle } from "@/components/admin/Title";
-import { BasicTable } from "@/components/admin/connectors/BasicTable";
import { usePopup } from "@/components/admin/connectors/Popup";
import { UsersIcon } from "@/components/icons/icons";
import { fetcher } from "@/lib/fetcher";
import { User } from "@/lib/types";
import useSWR, { mutate } from "swr";
-const columns = [
- {
- header: "Email",
- key: "email",
- },
- {
- header: "Role",
- key: "role",
- },
- {
- header: "Promote",
- key: "promote",
- },
-];
-
const UsersTable = () => {
const { popup, setPopup } = usePopup();
- const { data, isLoading, error } = useSWR
(
- "/api/manage/users",
- fetcher
- );
+ const {
+ data: users,
+ isLoading,
+ error,
+ } = useSWR("/api/manage/users", fetcher);
if (isLoading) {
return ;
}
- if (error || !data) {
- return Error loading users
;
+ if (error || !users) {
+ return Error loading users
;
}
return (
{popup}
-
{
- return {
- email: user.email,
- role: {user.role === "admin" ? "Admin" : "User"} ,
- promote:
- user.role !== "admin" ? (
- {
- const res = await fetch(
- "/api/manage/promote-user-to-admin",
- {
- method: "PATCH",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- user_email: user.email,
- }),
- }
- );
- if (!res.ok) {
- const errorMsg = await res.text();
- setPopup({
- message: `Unable to promote user - ${errorMsg}`,
- type: "error",
- });
- } else {
- mutate("/api/manage/users");
- setPopup({
- message: "User promoted to admin!",
- type: "success",
- });
- }
- }}
- >
- Promote to Admin!
-
- ) : (
- ""
- ),
- };
- })}
- />
+
+
+
+
+ Email
+ Role
+
+
+
+
+
+
+ {users.map((user) => {
+ return (
+
+ {user.email}
+
+ {user.role === "admin" ? "Admin" : "User"}
+
+
+
+
+ {
+ const res = await fetch(
+ "/api/manage/promote-user-to-admin",
+ {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ user_email: user.email,
+ }),
+ }
+ );
+ if (!res.ok) {
+ const errorMsg = await res.text();
+ setPopup({
+ message: `Unable to promote user - ${errorMsg}`,
+ type: "error",
+ });
+ } else {
+ mutate("/api/manage/users");
+ setPopup({
+ message: "User promoted to admin!",
+ type: "success",
+ });
+ }
+ }}
+ >
+ Promote to Admin!
+
+
+
+
+
+ );
+ })}
+
+
);
};
const Page = () => {
return (
-
+
} />
diff --git a/web/src/app/auth/login/SignInButton.tsx b/web/src/app/auth/login/SignInButton.tsx
index 0d4e35fe61..3dda2afff7 100644
--- a/web/src/app/auth/login/SignInButton.tsx
+++ b/web/src/app/auth/login/SignInButton.tsx
@@ -42,7 +42,7 @@ export function SignInButton({
return (
{button}
diff --git a/web/src/app/auth/login/page.tsx b/web/src/app/auth/login/page.tsx
index b70d49663f..5977fe24b3 100644
--- a/web/src/app/auth/login/page.tsx
+++ b/web/src/app/auth/login/page.tsx
@@ -77,7 +77,7 @@ const Page = async ({
-
+
Log In to Danswer
{authUrl && authTypeMetadata && (
diff --git a/web/src/app/chat/Chat.tsx b/web/src/app/chat/Chat.tsx
new file mode 100644
index 0000000000..6b0449c36b
--- /dev/null
+++ b/web/src/app/chat/Chat.tsx
@@ -0,0 +1,584 @@
+"use client";
+
+import Image from "next/image";
+import { useEffect, useRef, useState } from "react";
+import { FiRefreshCcw, FiSend, FiStopCircle } from "react-icons/fi";
+import { AIMessage, HumanMessage } from "./message/Messages";
+import { AnswerPiecePacket, DanswerDocument } from "@/lib/search/interfaces";
+import {
+ BackendMessage,
+ DocumentsResponse,
+ Message,
+ RetrievalType,
+ StreamingError,
+} from "./interfaces";
+import { useRouter } from "next/navigation";
+import { FeedbackType } from "./types";
+import {
+ createChatSession,
+ getCitedDocumentsFromMessage,
+ getHumanAndAIMessageFromMessageNumber,
+ handleAutoScroll,
+ handleChatFeedback,
+ nameChatSession,
+ sendMessage,
+} from "./lib";
+import { ThreeDots } from "react-loader-spinner";
+import { FeedbackModal } from "./modal/FeedbackModal";
+import { DocumentSidebar } from "./documentSidebar/DocumentSidebar";
+import { Persona } from "../admin/personas/interfaces";
+import { ChatPersonaSelector } from "./ChatPersonaSelector";
+import { useFilters } from "@/lib/hooks";
+import { DocumentSet, ValidSources } from "@/lib/types";
+import { ChatFilters } from "./modifiers/ChatFilters";
+import { buildFilters } from "@/lib/search/utils";
+import { QA, SearchTypeSelector } from "./modifiers/SearchTypeSelector";
+import { SelectedDocuments } from "./modifiers/SelectedDocuments";
+import { usePopup } from "@/components/admin/connectors/Popup";
+import { useSWRConfig } from "swr";
+
+const MAX_INPUT_HEIGHT = 200;
+
+export const Chat = ({
+ existingChatSessionId,
+ existingChatSessionPersonaId,
+ existingMessages,
+ availableSources,
+ availableDocumentSets,
+ availablePersonas,
+ shouldhideBeforeScroll,
+}: {
+ existingChatSessionId: number | null;
+ existingChatSessionPersonaId: number | undefined;
+ existingMessages: Message[];
+ availableSources: ValidSources[];
+ availableDocumentSets: DocumentSet[];
+ availablePersonas: Persona[];
+ shouldhideBeforeScroll?: boolean;
+}) => {
+ const router = useRouter();
+ const { popup, setPopup } = usePopup();
+
+ const [chatSessionId, setChatSessionId] = useState(existingChatSessionId);
+ const [message, setMessage] = useState("");
+ const [messageHistory, setMessageHistory] =
+ useState(existingMessages);
+ const [isStreaming, setIsStreaming] = useState(false);
+
+ // for document display
+ // NOTE: -1 is a special designation that means the latest AI message
+ const [selectedMessageForDocDisplay, setSelectedMessageForDocDisplay] =
+ useState(
+ messageHistory[messageHistory.length - 1]?.messageId || null
+ );
+ const { aiMessage } = selectedMessageForDocDisplay
+ ? getHumanAndAIMessageFromMessageNumber(
+ messageHistory,
+ selectedMessageForDocDisplay
+ )
+ : { aiMessage: null };
+ const [selectedDocuments, setSelectedDocuments] = useState(
+ []
+ );
+
+ const [selectedPersona, setSelectedPersona] = useState(
+ existingChatSessionPersonaId !== undefined
+ ? availablePersonas.find(
+ (persona) => persona.id === existingChatSessionPersonaId
+ )
+ : availablePersonas.find((persona) => persona.name === "Default")
+ );
+
+ const filterManager = useFilters();
+
+ const [selectedSearchType, setSelectedSearchType] = useState(QA);
+
+ // 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);
+
+ // auto scroll as message comes out
+ const scrollableDivRef = useRef(null);
+ const endDivRef = useRef(null);
+ useEffect(() => {
+ if (isStreaming || !message) {
+ handleAutoScroll(endDivRef, scrollableDivRef);
+ }
+ });
+
+ // scroll to bottom initially
+ console.log(shouldhideBeforeScroll);
+ const [hasPerformedInitialScroll, setHasPerformedInitialScroll] = useState(
+ shouldhideBeforeScroll !== true
+ );
+ useEffect(() => {
+ endDivRef.current?.scrollIntoView();
+ setHasPerformedInitialScroll(true);
+ }, []);
+
+ // handle refreshes of the server-side props
+ useEffect(() => {
+ setMessageHistory(existingMessages);
+ }, [existingMessages]);
+
+ // handle re-sizing of the text area
+ const textareaRef = useRef(null);
+ useEffect(() => {
+ const textarea = textareaRef.current;
+ if (textarea) {
+ textarea.style.height = "0px";
+ textarea.style.height = `${Math.min(
+ textarea.scrollHeight,
+ MAX_INPUT_HEIGHT
+ )}px`;
+ }
+ }, [message]);
+
+ const onSubmit = async (messageOverride?: string) => {
+ let currChatSessionId: number;
+ let isNewSession = chatSessionId === null;
+ if (isNewSession) {
+ currChatSessionId = await createChatSession(selectedPersona?.id || 0);
+ } else {
+ currChatSessionId = chatSessionId as number;
+ }
+ setChatSessionId(currChatSessionId);
+
+ const currMessage = messageOverride || message;
+ const currMessageHistory = 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 {
+ for await (const packetBunch of sendMessage({
+ message: currMessage,
+ parentMessageId:
+ currMessageHistory.length > 0
+ ? currMessageHistory[currMessageHistory.length - 1].messageId
+ : null,
+ chatSessionId: currChatSessionId,
+ // if search-only set prompt to null to tell backend to not give an answer
+ promptId:
+ selectedSearchType === QA ? selectedPersona?.prompts[0]?.id : null,
+ filters: buildFilters(
+ filterManager.selectedSources,
+ filterManager.selectedDocumentSets,
+ filterManager.timeRange
+ ),
+ selectedDocumentIds: selectedDocuments
+ .filter(
+ (document) =>
+ document.db_doc_id !== undefined && document.db_doc_id !== null
+ )
+ .map((document) => document.db_doc_id as number),
+ })) {
+ 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);
+ }
+ await nameChatSession(currChatSessionId, currMessage);
+ router.push(`/chat/${currChatSessionId}?shouldhideBeforeScroll=true`, {
+ 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",
+ });
+ }
+ };
+
+ return (
+
+ {popup}
+ {currentFeedback && (
+
setCurrentFeedback(null)}
+ onSubmit={(feedbackDetails) => {
+ onFeedback(currentFeedback[1], currentFeedback[0], feedbackDetails);
+ setCurrentFeedback(null);
+ }}
+ />
+ )}
+
+
+
+ {selectedPersona && (
+
+
+ {
+ if (persona) {
+ setSelectedPersona(persona);
+ }
+ }}
+ />
+
+
+ )}
+
+ {messageHistory.length === 0 && !isStreaming && (
+
+
+
+
+ What are you looking for today?
+
+
+
+ )}
+
+
+ {messageHistory.map((message, i) => {
+ if (message.type === "user") {
+ return (
+
+
+
+ );
+ } else if (message.type === "assistant") {
+ const isShowingRetrieved =
+ (selectedMessageForDocDisplay !== null &&
+ selectedMessageForDocDisplay === message.messageId) ||
+ (selectedMessageForDocDisplay === -1 &&
+ i === messageHistory.length - 1);
+ return (
+
+
0) ===
+ true
+ }
+ handleFeedback={
+ i === messageHistory.length - 1 && isStreaming
+ ? undefined
+ : (feedbackType) =>
+ setCurrentFeedback([
+ feedbackType,
+ message.messageId as number,
+ ])
+ }
+ isCurrentlyShowingRetrieved={isShowingRetrieved}
+ handleShowRetrieved={(messageNumber) => {
+ if (isShowingRetrieved) {
+ setSelectedMessageForDocDisplay(null);
+ } else {
+ if (messageNumber !== null) {
+ setSelectedMessageForDocDisplay(messageNumber);
+ } else {
+ setSelectedMessageForDocDisplay(-1);
+ }
+ }
+ }}
+ />
+
+ );
+ } else {
+ return (
+
+
+ {message.message}
+
+ }
+ />
+
+ );
+ }
+ })}
+
+ {isStreaming &&
+ messageHistory.length &&
+ messageHistory[messageHistory.length - 1].type === "user" && (
+
+ }
+ />
+
+ )}
+
+ {/* Some padding at the bottom so the search bar has space at the bottom to not cover the last message*/}
+
+
+
+
+
+
+
+
+ {/* {(isStreaming || messageHistory.length > 0) && (
+
+
+
+ {isStreaming ? (
+
setIsCancelled(true)}
+ className="flex"
+ >
+
+
Stop Generating
+
+ ) : (
+
{
+ if (chatSessionId) {
+ handleRegenerate(chatSessionId);
+ }
+ }}
+ >
+
+
Regenerate
+
+ )}
+
+
+
+ )} */}
+
+
+
+
+
+
+
+ {selectedDocuments.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/web/src/app/chat/ChatPage.tsx b/web/src/app/chat/ChatPage.tsx
new file mode 100644
index 0000000000..1af46d4f2d
--- /dev/null
+++ b/web/src/app/chat/ChatPage.tsx
@@ -0,0 +1,204 @@
+import { getAuthDisabledSS, getCurrentUserSS } from "@/lib/userSS";
+import { redirect } from "next/navigation";
+import { fetchSS } from "@/lib/utilsSS";
+import { Connector, DocumentSet, User, ValidSources } from "@/lib/types";
+import { ChatSidebar } from "./sessionSidebar/ChatSidebar";
+import { Chat } from "./Chat";
+import {
+ BackendMessage,
+ ChatSession,
+ Message,
+ RetrievalType,
+} from "./interfaces";
+import { unstable_noStore as noStore } from "next/cache";
+import { Persona } from "../admin/personas/interfaces";
+import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
+import { WelcomeModal } from "@/components/WelcomeModal";
+import { ApiKeyModal } from "@/components/openai/ApiKeyModal";
+
+export default async function ChatPage({
+ chatId,
+ shouldhideBeforeScroll,
+}: {
+ chatId: string | null;
+ shouldhideBeforeScroll?: boolean;
+}) {
+ noStore();
+
+ const currentChatId = chatId ? parseInt(chatId) : null;
+
+ const tasks = [
+ getAuthDisabledSS(),
+ getCurrentUserSS(),
+ fetchSS("/manage/connector"),
+ fetchSS("/manage/document-set"),
+ fetchSS("/persona?include_default=true"),
+ fetchSS("/chat/get-user-chat-sessions"),
+ chatId !== null
+ ? fetchSS(`/chat/get-chat-session/${chatId}`)
+ : (async () => null)(),
+ ];
+
+ // 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 | boolean | null)[] = [
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ];
+ try {
+ results = await Promise.all(tasks);
+ } catch (e) {
+ console.log(`Some fetch failed for the main search page - ${e}`);
+ }
+ const authDisabled = results[0] as boolean;
+ const user = results[1] as User | null;
+ const connectorsResponse = results[2] as Response | null;
+ const documentSetsResponse = results[3] as Response | null;
+ const personasResponse = results[4] as Response | null;
+ const chatSessionsResponse = results[5] as Response | null;
+ const chatSessionMessagesResponse = results[6] as Response | null;
+
+ if (!authDisabled && !user) {
+ return redirect("/auth/login");
+ }
+
+ let connectors: Connector
[] = [];
+ if (connectorsResponse?.ok) {
+ connectors = await connectorsResponse.json();
+ } else {
+ console.log(`Failed to fetch connectors - ${connectorsResponse?.status}`);
+ }
+ const availableSources: ValidSources[] = [];
+ connectors.forEach((connector) => {
+ if (!availableSources.includes(connector.source)) {
+ availableSources.push(connector.source);
+ }
+ });
+
+ let chatSessions: ChatSession[] = [];
+ if (chatSessionsResponse?.ok) {
+ chatSessions = (await chatSessionsResponse.json()).sessions;
+ } else {
+ console.log(
+ `Failed to fetch chat sessions - ${chatSessionsResponse?.text()}`
+ );
+ }
+ // Larger ID -> created later
+ chatSessions.sort((a, b) => (a.id > b.id ? -1 : 1));
+ const currentChatSession = chatSessions.find(
+ (chatSession) => chatSession.id === currentChatId
+ );
+
+ let documentSets: DocumentSet[] = [];
+ if (documentSetsResponse?.ok) {
+ documentSets = await documentSetsResponse.json();
+ } else {
+ console.log(
+ `Failed to fetch document sets - ${documentSetsResponse?.status}`
+ );
+ }
+
+ let personas: Persona[] = [];
+ if (personasResponse?.ok) {
+ personas = await personasResponse.json();
+ } else {
+ console.log(`Failed to fetch personas - ${personasResponse?.status}`);
+ }
+
+ let messages: Message[] = [];
+ if (chatSessionMessagesResponse?.ok) {
+ const chatSessionDetailJson = await chatSessionMessagesResponse.json();
+ const rawMessages = chatSessionDetailJson.messages as BackendMessage[];
+ const messageMap: Map = new Map(
+ rawMessages.map((message) => [message.message_id, message])
+ );
+
+ const rootMessage = rawMessages.find(
+ (message) => message.parent_message === null
+ );
+
+ const finalMessageList: BackendMessage[] = [];
+ if (rootMessage) {
+ let currMessage: BackendMessage | null = rootMessage;
+ while (currMessage) {
+ finalMessageList.push(currMessage);
+ const childMessageNumber = currMessage.latest_child_message;
+ if (childMessageNumber && messageMap.has(childMessageNumber)) {
+ currMessage = messageMap.get(childMessageNumber) as BackendMessage;
+ } else {
+ currMessage = null;
+ }
+ }
+ }
+
+ messages = finalMessageList
+ .filter((messageInfo) => messageInfo.message_type !== "system")
+ .map((messageInfo) => {
+ const hasContextDocs =
+ (messageInfo?.context_docs?.top_documents || []).length > 0;
+ let retrievalType;
+ if (hasContextDocs) {
+ if (messageInfo.rephrased_query) {
+ retrievalType = RetrievalType.Search;
+ } else {
+ retrievalType = RetrievalType.SelectedDocs;
+ }
+ } else {
+ retrievalType = RetrievalType.None;
+ }
+
+ return {
+ messageId: messageInfo.message_id,
+ message: messageInfo.message,
+ type: messageInfo.message_type as "user" | "assistant",
+ // only include these fields if this is an assistant message so that
+ // this is identical to what is computed at streaming time
+ ...(messageInfo.message_type === "assistant"
+ ? {
+ retrievalType: retrievalType,
+ query: messageInfo.rephrased_query,
+ documents: messageInfo?.context_docs?.top_documents || [],
+ citations: messageInfo?.citations || {},
+ }
+ : {}),
+ };
+ });
+ } else {
+ console.log(
+ `Failed to fetch chat session messages - ${chatSessionMessagesResponse?.text()}`
+ );
+ }
+
+ return (
+ <>
+
+
+
+ {connectors.length === 0 && connectorsResponse?.ok && }
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/app/chat/ChatPersonaSelector.tsx b/web/src/app/chat/ChatPersonaSelector.tsx
new file mode 100644
index 0000000000..7b26647afa
--- /dev/null
+++ b/web/src/app/chat/ChatPersonaSelector.tsx
@@ -0,0 +1,108 @@
+import { Persona } from "@/app/admin/personas/interfaces";
+import { FiCheck, FiChevronDown } from "react-icons/fi";
+import { FaRobot } from "react-icons/fa";
+import { CustomDropdown } from "@/components/Dropdown";
+
+function PersonaItem({
+ id,
+ name,
+ onSelect,
+ isSelected,
+}: {
+ id: number;
+ name: string;
+ onSelect: (personaId: number) => void;
+ isSelected: boolean;
+}) {
+ return (
+ {
+ onSelect(id);
+ }}
+ >
+ {name}
+ {isSelected && (
+
+
+
+ )}
+
+ );
+}
+
+export function ChatPersonaSelector({
+ personas,
+ selectedPersonaId,
+ onPersonaChange,
+}: {
+ personas: Persona[];
+ selectedPersonaId: number | null;
+ onPersonaChange: (persona: Persona | null) => void;
+}) {
+ const currentlySelectedPersona = personas.find(
+ (persona) => persona.id === selectedPersonaId
+ );
+
+ return (
+
+ {personas.map((persona, ind) => {
+ const isSelected = persona.id === selectedPersonaId;
+ return (
+ {
+ const clickedPersona = personas.find(
+ (persona) => persona.id === clickedPersonaId
+ );
+ if (clickedPersona) {
+ onPersonaChange(clickedPersona);
+ }
+ }}
+ isSelected={isSelected}
+ />
+ );
+ })}
+
+ }
+ >
+
+
+ {currentlySelectedPersona?.name || "Default"}
+
+
+
+
+ );
+}
diff --git a/web/src/app/chat/[chatId]/page.tsx b/web/src/app/chat/[chatId]/page.tsx
new file mode 100644
index 0000000000..f1649a741d
--- /dev/null
+++ b/web/src/app/chat/[chatId]/page.tsx
@@ -0,0 +1,14 @@
+import ChatPage from "../ChatPage";
+
+export default async function Page({
+ params,
+ searchParams,
+}: {
+ params: { chatId: string };
+ searchParams: { shouldhideBeforeScroll?: string };
+}) {
+ return await ChatPage({
+ chatId: params.chatId,
+ shouldhideBeforeScroll: searchParams.shouldhideBeforeScroll === "true",
+ });
+}
diff --git a/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx b/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx
new file mode 100644
index 0000000000..4ed753e15d
--- /dev/null
+++ b/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx
@@ -0,0 +1,217 @@
+import { HoverPopup } from "@/components/HoverPopup";
+import { SourceIcon } from "@/components/SourceIcon";
+import { PopupSpec } from "@/components/admin/connectors/Popup";
+import { DocumentFeedbackBlock } from "@/components/search/DocumentFeedbackBlock";
+import { DocumentUpdatedAtBadge } from "@/components/search/DocumentUpdatedAtBadge";
+import { DanswerDocument } from "@/lib/search/interfaces";
+import { useState } from "react";
+import { FiInfo, FiRadio } from "react-icons/fi";
+import { DocumentSelector } from "./DocumentSelector";
+
+export const buildDocumentSummaryDisplay = (
+ matchHighlights: string[],
+ blurb: string
+) => {
+ if (matchHighlights.length === 0) {
+ return blurb;
+ }
+
+ // content, isBold, isContinuation
+ let sections = [] as [string, boolean, boolean][];
+ matchHighlights.forEach((matchHighlight, matchHighlightIndex) => {
+ if (!matchHighlight) {
+ return;
+ }
+
+ const words = matchHighlight.split(new RegExp("\\s"));
+ words.forEach((word) => {
+ if (!word) {
+ return;
+ }
+
+ let isContinuation = false;
+ while (word.includes("") && word.includes("")) {
+ const start = word.indexOf("");
+ const end = word.indexOf("");
+ const before = word.slice(0, start);
+ const highlight = word.slice(start + 4, end);
+ const after = word.slice(end + 5);
+
+ if (before) {
+ sections.push([before, false, isContinuation]);
+ isContinuation = true;
+ }
+ sections.push([highlight, true, isContinuation]);
+ isContinuation = true;
+ word = after;
+ }
+
+ if (word) {
+ sections.push([word, false, isContinuation]);
+ }
+ });
+ if (matchHighlightIndex != matchHighlights.length - 1) {
+ sections.push(["...", false, false]);
+ }
+ });
+
+ let previousIsContinuation = sections[0][2];
+ let previousIsBold = sections[0][1];
+ let currentText = "";
+ const finalJSX = [] as (JSX.Element | string)[];
+ sections.forEach(([word, shouldBeBold, isContinuation], index) => {
+ if (shouldBeBold != previousIsBold) {
+ if (currentText) {
+ if (previousIsBold) {
+ // remove leading space so that we don't bold the whitespace
+ // in front of the matching keywords
+ currentText = currentText.trim();
+ if (!previousIsContinuation) {
+ finalJSX[finalJSX.length - 1] = finalJSX[finalJSX.length - 1] + " ";
+ }
+ finalJSX.push(
+
+ {currentText}
+
+ );
+ } else {
+ finalJSX.push(currentText);
+ }
+ }
+ currentText = "";
+ }
+ previousIsBold = shouldBeBold;
+ previousIsContinuation = isContinuation;
+ if (!isContinuation || index === 0) {
+ currentText += " ";
+ }
+ currentText += word;
+ });
+ if (currentText) {
+ if (previousIsBold) {
+ currentText = currentText.trim();
+ if (!previousIsContinuation) {
+ finalJSX[finalJSX.length - 1] = finalJSX[finalJSX.length - 1] + " ";
+ }
+ finalJSX.push(
+
+ {currentText}
+
+ );
+ } else {
+ finalJSX.push(currentText);
+ }
+ }
+ return finalJSX;
+};
+
+interface DocumentDisplayProps {
+ document: DanswerDocument;
+ queryEventId: number | null;
+ isAIPick: boolean;
+ isSelected: boolean;
+ handleSelect: (documentId: string) => void;
+ setPopup: (popupSpec: PopupSpec | null) => void;
+}
+
+export function ChatDocumentDisplay({
+ document,
+ queryEventId,
+ isAIPick,
+ isSelected,
+ handleSelect,
+ setPopup,
+}: DocumentDisplayProps) {
+ const [isHovered, setIsHovered] = useState(false);
+
+ // Consider reintroducing null scored docs in the future
+ if (document.score === null) {
+ return null;
+ }
+
+ return (
+ {
+ setIsHovered(true);
+ }}
+ onMouseLeave={() => setIsHovered(false)}
+ >
+
+
+
+
+ {document.semantic_identifier || document.document_id}
+
+
+ {document.score !== null && (
+
+ {isAIPick && (
+
+
}
+ popupContent={
+
+
+
+
+
+
The AI liked this doc!
+
+
+ }
+ direction="bottom"
+ style="dark"
+ />
+
+ )}
+
+ {Math.abs(document.score).toFixed(2)}
+
+
+ )}
+
+
handleSelect(document.document_id)}
+ />
+
+ {document.updated_at && (
+
+ )}
+
+ {buildDocumentSummaryDisplay(document.match_highlights, document.blurb)}
+
+
+ {queryEventId && (
+
+ )}
+
+
+ );
+}
diff --git a/web/src/app/chat/documentSidebar/DocumentSelector.tsx b/web/src/app/chat/documentSidebar/DocumentSelector.tsx
new file mode 100644
index 0000000000..29f7fe3b0c
--- /dev/null
+++ b/web/src/app/chat/documentSidebar/DocumentSelector.tsx
@@ -0,0 +1,23 @@
+export function DocumentSelector({
+ isSelected,
+ handleSelect,
+}: {
+ isSelected: boolean;
+ handleSelect: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/web/src/app/chat/documentSidebar/DocumentSidebar.tsx b/web/src/app/chat/documentSidebar/DocumentSidebar.tsx
new file mode 100644
index 0000000000..63430f8e75
--- /dev/null
+++ b/web/src/app/chat/documentSidebar/DocumentSidebar.tsx
@@ -0,0 +1,170 @@
+import { DanswerDocument } from "@/lib/search/interfaces";
+import { Text } from "@tremor/react";
+import { ChatDocumentDisplay } from "./ChatDocumentDisplay";
+import { usePopup } from "@/components/admin/connectors/Popup";
+import { FiFileText, FiSearch } from "react-icons/fi";
+import { SelectedDocumentDisplay } from "./SelectedDocumentDisplay";
+import { removeDuplicateDocs } from "@/lib/documentUtils";
+import { BasicSelectable } from "@/components/BasicClickable";
+import { Message, RetrievalType } from "../interfaces";
+
+function SectionHeader({
+ name,
+ icon,
+}: {
+ name: string;
+ icon: React.FC<{ className: string }>;
+}) {
+ return (
+
+ {icon({ className: "my-auto mr-1" })}
+ {name}
+
+ );
+}
+
+export function DocumentSidebar({
+ selectedMessage,
+ selectedDocuments,
+ setSelectedDocuments,
+}: {
+ selectedMessage: Message | null;
+ selectedDocuments: DanswerDocument[] | null;
+ setSelectedDocuments: (documents: DanswerDocument[]) => void;
+}) {
+ const { popup, setPopup } = usePopup();
+
+ const selectedMessageRetrievalType = selectedMessage?.retrievalType || null;
+
+ const selectedDocumentIds =
+ selectedDocuments?.map((document) => document.document_id) || [];
+
+ const currentDocuments = selectedMessage?.documents || null;
+ const dedupedDocuments = removeDuplicateDocs(currentDocuments || []);
+ return (
+
+ );
+}
diff --git a/web/src/app/chat/documentSidebar/SelectedDocumentDisplay.tsx b/web/src/app/chat/documentSidebar/SelectedDocumentDisplay.tsx
new file mode 100644
index 0000000000..72af16421a
--- /dev/null
+++ b/web/src/app/chat/documentSidebar/SelectedDocumentDisplay.tsx
@@ -0,0 +1,24 @@
+import { SourceIcon } from "@/components/SourceIcon";
+import { DanswerDocument } from "@/lib/search/interfaces";
+import { DocumentSelector } from "./DocumentSelector";
+
+export function SelectedDocumentDisplay({
+ document,
+ handleDeselect,
+}: {
+ document: DanswerDocument;
+ handleDeselect: (documentId: string) => void;
+}) {
+ return (
+
+
+
+ {document.semantic_identifier || document.document_id}
+
+
handleDeselect(document.document_id)}
+ />
+
+ );
+}
diff --git a/web/src/app/chat/interfaces.ts b/web/src/app/chat/interfaces.ts
new file mode 100644
index 0000000000..3bd9851619
--- /dev/null
+++ b/web/src/app/chat/interfaces.ts
@@ -0,0 +1,54 @@
+import { DanswerDocument, Filters } from "@/lib/search/interfaces";
+
+export enum RetrievalType {
+ None = "none",
+ Search = "search",
+ SelectedDocs = "selectedDocs",
+}
+
+export interface RetrievalDetails {
+ run_search: "always" | "never" | "auto";
+ real_time: boolean;
+ filters?: Filters;
+ enable_auto_detect_filters?: boolean | null;
+}
+
+type CitationMap = { [key: string]: number };
+
+export interface ChatSession {
+ id: number;
+ name: string;
+ persona_id: number;
+ time_created: string;
+}
+
+export interface Message {
+ messageId: number | null;
+ message: string;
+ type: "user" | "assistant" | "error";
+ retrievalType?: RetrievalType;
+ query?: string | null;
+ documents?: DanswerDocument[] | null;
+ citations?: CitationMap;
+}
+
+export interface BackendMessage {
+ message_id: number;
+ parent_message: number | null;
+ latest_child_message: number | null;
+ message: string;
+ rephrased_query: string | null;
+ context_docs: { top_documents: DanswerDocument[] } | null;
+ message_type: "user" | "assistant" | "system";
+ time_sent: string;
+ citations: CitationMap;
+}
+
+export interface DocumentsResponse {
+ top_documents: DanswerDocument[];
+ rephrased_query: string | null;
+}
+
+export interface StreamingError {
+ error: string;
+}
diff --git a/web/src/app/chat/lib.tsx b/web/src/app/chat/lib.tsx
new file mode 100644
index 0000000000..44f914cd14
--- /dev/null
+++ b/web/src/app/chat/lib.tsx
@@ -0,0 +1,268 @@
+import {
+ AnswerPiecePacket,
+ DanswerDocument,
+ Filters,
+} from "@/lib/search/interfaces";
+import { handleStream } from "@/lib/search/streamingUtils";
+import { FeedbackType } from "./types";
+import { RefObject } from "react";
+import {
+ BackendMessage,
+ ChatSession,
+ DocumentsResponse,
+ Message,
+ StreamingError,
+} from "./interfaces";
+
+export async function createChatSession(personaId: number): Promise {
+ const createChatSessionResponse = await fetch(
+ "/api/chat/create-chat-session",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ persona_id: personaId,
+ }),
+ }
+ );
+ if (!createChatSessionResponse.ok) {
+ console.log(
+ `Failed to create chat session - ${createChatSessionResponse.status}`
+ );
+ throw Error("Failed to create chat session");
+ }
+ const chatSessionResponseJson = await createChatSessionResponse.json();
+ return chatSessionResponseJson.chat_session_id;
+}
+
+export interface SendMessageRequest {
+ message: string;
+ parentMessageId: number | null;
+ chatSessionId: number;
+ promptId: number | null | undefined;
+ filters: Filters | null;
+ selectedDocumentIds: number[] | null;
+}
+
+export async function* sendMessage({
+ message,
+ parentMessageId,
+ chatSessionId,
+ promptId,
+ filters,
+ selectedDocumentIds,
+}: SendMessageRequest) {
+ const documentsAreSelected =
+ selectedDocumentIds && selectedDocumentIds.length > 0;
+ const sendMessageResponse = await fetch("/api/chat/send-message", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ chat_session_id: chatSessionId,
+ parent_message_id: parentMessageId,
+ message: message,
+ prompt_id: promptId,
+ search_doc_ids: documentsAreSelected ? selectedDocumentIds : null,
+ retrieval_options: !documentsAreSelected
+ ? {
+ run_search:
+ promptId === null || promptId === undefined ? "always" : "auto",
+ real_time: true,
+ filters: filters,
+ }
+ : null,
+ }),
+ });
+ if (!sendMessageResponse.ok) {
+ const errorJson = await sendMessageResponse.json();
+ const errorMsg = errorJson.message || errorJson.detail || "";
+ throw Error(`Failed to send message - ${errorMsg}`);
+ }
+
+ yield* handleStream<
+ AnswerPiecePacket | DocumentsResponse | BackendMessage | StreamingError
+ >(sendMessageResponse);
+}
+
+export async function nameChatSession(chatSessionId: number, message: string) {
+ const response = await fetch("/api/chat/rename-chat-session", {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ chat_session_id: chatSessionId,
+ name: null,
+ first_message: message,
+ }),
+ });
+ return response;
+}
+
+export async function handleChatFeedback(
+ messageId: number,
+ feedback: FeedbackType,
+ feedbackDetails: string
+) {
+ const response = await fetch("/api/chat/create-chat-message-feedback", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ chat_message_id: messageId,
+ is_positive: feedback === "like",
+ feedback_text: feedbackDetails,
+ }),
+ });
+ return response;
+}
+
+export async function renameChatSession(
+ chatSessionId: number,
+ newName: string
+) {
+ const response = await fetch(`/api/chat/rename-chat-session`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ chat_session_id: chatSessionId,
+ name: newName,
+ first_message: null,
+ }),
+ });
+ return response;
+}
+
+export async function deleteChatSession(chatSessionId: number) {
+ const response = await fetch(
+ `/api/chat/delete-chat-session/${chatSessionId}`,
+ {
+ method: "DELETE",
+ }
+ );
+ return response;
+}
+
+export async function* simulateLLMResponse(input: string, delay: number = 30) {
+ // Split the input string into tokens. This is a simple example, and in real use case, tokenization can be more complex.
+ // Iterate over tokens and yield them one by one
+ const tokens = input.match(/.{1,3}|\n/g) || [];
+
+ for (const token of tokens) {
+ // In a real-world scenario, there might be a slight delay as tokens are being generated
+ await new Promise((resolve) => setTimeout(resolve, delay)); // 40ms delay to simulate response time
+
+ // Yielding each token
+ yield token;
+ }
+}
+
+export function handleAutoScroll(
+ endRef: RefObject,
+ scrollableRef: RefObject,
+ buffer: number = 300
+) {
+ // Auto-scrolls if the user is within `buffer` of the bottom of the scrollableRef
+ if (endRef && endRef.current && scrollableRef && scrollableRef.current) {
+ if (
+ scrollableRef.current.scrollHeight -
+ scrollableRef.current.scrollTop -
+ buffer <=
+ scrollableRef.current.clientHeight
+ ) {
+ endRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }
+}
+
+export function getHumanAndAIMessageFromMessageNumber(
+ messageHistory: Message[],
+ messageId: number
+) {
+ let messageInd;
+ // -1 is special -> means use the last message
+ if (messageId === -1) {
+ messageInd = messageHistory.length - 1;
+ } else {
+ messageInd = messageHistory.findIndex(
+ (message) => message.messageId === messageId
+ );
+ }
+ if (messageInd !== -1) {
+ const matchingMessage = messageHistory[messageInd];
+ const pairedMessage =
+ matchingMessage.type === "user"
+ ? messageHistory[messageInd + 1]
+ : messageHistory[messageInd - 1];
+
+ const humanMessage =
+ matchingMessage.type === "user" ? matchingMessage : pairedMessage;
+ const aiMessage =
+ matchingMessage.type === "user" ? pairedMessage : matchingMessage;
+
+ return {
+ humanMessage,
+ aiMessage,
+ };
+ } else {
+ return {
+ humanMessage: null,
+ aiMessage: null,
+ };
+ }
+}
+
+export function getCitedDocumentsFromMessage(message: Message) {
+ if (!message.citations || !message.documents) {
+ return [];
+ }
+
+ const documentsWithCitationKey: [string, DanswerDocument][] = [];
+ Object.entries(message.citations).forEach(([citationKey, documentDbId]) => {
+ const matchingDocument = message.documents!.find(
+ (document) => document.db_doc_id === documentDbId
+ );
+ if (matchingDocument) {
+ documentsWithCitationKey.push([citationKey, matchingDocument]);
+ }
+ });
+ return documentsWithCitationKey;
+}
+
+export function groupSessionsByDateRange(chatSessions: ChatSession[]) {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0); // Set to start of today for accurate comparison
+
+ const groups: Record = {
+ Today: [],
+ "Previous 7 Days": [],
+ "Previous 30 Days": [],
+ "Over 30 days ago": [],
+ };
+
+ chatSessions.forEach((chatSession) => {
+ const chatSessionDate = new Date(chatSession.time_created);
+
+ const diffTime = today.getTime() - chatSessionDate.getTime();
+ const diffDays = diffTime / (1000 * 3600 * 24); // Convert time difference to days
+
+ if (diffDays < 1) {
+ groups["Today"].push(chatSession);
+ } else if (diffDays <= 7) {
+ groups["Previous 7 Days"].push(chatSession);
+ } else if (diffDays <= 30) {
+ groups["Previous 30 Days"].push(chatSession);
+ } else {
+ groups["Over 30 days ago"].push(chatSession);
+ }
+ });
+
+ return groups;
+}
diff --git a/web/src/app/chat/message/Messages.tsx b/web/src/app/chat/message/Messages.tsx
new file mode 100644
index 0000000000..535491c2d4
--- /dev/null
+++ b/web/src/app/chat/message/Messages.tsx
@@ -0,0 +1,235 @@
+import {
+ FiCheck,
+ FiCopy,
+ FiCpu,
+ FiThumbsDown,
+ FiThumbsUp,
+ FiUser,
+} from "react-icons/fi";
+import { FeedbackType } from "../types";
+import { useState } from "react";
+import ReactMarkdown from "react-markdown";
+import { DanswerDocument } from "@/lib/search/interfaces";
+import { SearchSummary, ShowHideDocsButton } from "./SearchSummary";
+import { SourceIcon } from "@/components/SourceIcon";
+import { ThreeDots } from "react-loader-spinner";
+
+const Hoverable: React.FC<{ children: JSX.Element; onClick?: () => void }> = ({
+ children,
+ onClick,
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const AIMessage = ({
+ messageId,
+ content,
+ query,
+ citedDocuments,
+ isComplete,
+ hasDocs,
+ handleFeedback,
+ isCurrentlyShowingRetrieved,
+ handleShowRetrieved,
+}: {
+ messageId: number | null;
+ content: string | JSX.Element;
+ query?: string;
+ citedDocuments?: [string, DanswerDocument][] | null;
+ isComplete?: boolean;
+ hasDocs?: boolean;
+ handleFeedback?: (feedbackType: FeedbackType) => void;
+ isCurrentlyShowingRetrieved?: boolean;
+ handleShowRetrieved?: (messageNumber: number | null) => void;
+}) => {
+ const [copyClicked, setCopyClicked] = useState(false);
+ return (
+
+
+
+
+
+
+
Danswer
+
+ {query === undefined &&
+ hasDocs &&
+ handleShowRetrieved !== undefined &&
+ isCurrentlyShowingRetrieved !== undefined && (
+
+ )}
+
+
+
+ {query !== undefined &&
+ handleShowRetrieved !== undefined &&
+ isCurrentlyShowingRetrieved !== undefined && (
+
+
+
+ )}
+
+ {content ? (
+ <>
+ {typeof content === "string" ? (
+
(
+
+ ),
+ }}
+ >
+ {content.replaceAll("\\n", "\n")}
+
+ ) : (
+ content
+ )}
+ >
+ ) : isComplete ? (
+
I just performed the requested search!
+ ) : (
+
+
+
+ )}
+ {citedDocuments && citedDocuments.length > 0 && (
+
+ )}
+
+ {handleFeedback && (
+
+ {
+ navigator.clipboard.writeText(content.toString());
+ setCopyClicked(true);
+ setTimeout(() => setCopyClicked(false), 3000);
+ }}
+ >
+ {copyClicked ? : }
+
+ handleFeedback("like")}>
+
+
+
+ handleFeedback("dislike")} />
+
+
+ )}
+
+
+
+ );
+};
+
+export const HumanMessage = ({
+ content,
+}: {
+ content: string | JSX.Element;
+}) => {
+ return (
+
+
+
+
+
+
+ {typeof content === "string" ? (
+
(
+
+ ),
+ }}
+ >
+ {content.replaceAll("\\n", "\n")}
+
+ ) : (
+ content
+ )}
+
+
+
+
+
+ );
+};
diff --git a/web/src/app/chat/message/SearchSummary.tsx b/web/src/app/chat/message/SearchSummary.tsx
new file mode 100644
index 0000000000..7193056b81
--- /dev/null
+++ b/web/src/app/chat/message/SearchSummary.tsx
@@ -0,0 +1,92 @@
+import {
+ BasicClickable,
+ EmphasizedClickable,
+} from "@/components/BasicClickable";
+import { HoverPopup } from "@/components/HoverPopup";
+import { DanswerDocument } from "@/lib/search/interfaces";
+import { FiBookOpen, FiSearch } from "react-icons/fi";
+
+export function ShowHideDocsButton({
+ messageId,
+ isCurrentlyShowingRetrieved,
+ handleShowRetrieved,
+}: {
+ messageId: number | null;
+ isCurrentlyShowingRetrieved: boolean;
+ handleShowRetrieved: (messageId: number | null) => void;
+}) {
+ return (
+ handleShowRetrieved(messageId)}
+ >
+ {isCurrentlyShowingRetrieved ? (
+
+ Hide Docs
+
+ ) : (
+
+ Show Docs
+
+ )}
+
+ );
+}
+
+function SearchingForDisplay({
+ query,
+ isHoverable,
+}: {
+ query: string;
+ isHoverable?: boolean;
+}) {
+ return (
+
+
+
+ Searching for: {query}
+
+
+ );
+}
+
+export function SearchSummary({
+ query,
+ hasDocs,
+ messageId,
+ isCurrentlyShowingRetrieved,
+ handleShowRetrieved,
+}: {
+ query: string;
+ hasDocs: boolean;
+ messageId: number | null;
+ isCurrentlyShowingRetrieved: boolean;
+ handleShowRetrieved: (messageId: number | null) => void;
+}) {
+ return (
+
+
+ {query.length >= 40 ? (
+
}
+ popupContent={
+
+ }
+ direction="top"
+ />
+ ) : (
+
+ )}
+
+ {hasDocs && (
+
+ )}
+
+ );
+}
diff --git a/web/src/app/chat/modal/DeleteChatModal.tsx b/web/src/app/chat/modal/DeleteChatModal.tsx
new file mode 100644
index 0000000000..ed89c9613d
--- /dev/null
+++ b/web/src/app/chat/modal/DeleteChatModal.tsx
@@ -0,0 +1,43 @@
+import { FiTrash, FiX } from "react-icons/fi";
+import { ModalWrapper } from "./ModalWrapper";
+import { BasicClickable } from "@/components/BasicClickable";
+
+export const DeleteChatModal = ({
+ chatSessionName,
+ onClose,
+ onSubmit,
+}: {
+ chatSessionName: string;
+ onClose: () => void;
+ onSubmit: () => void;
+}) => {
+ return (
+
+ <>
+
+
+ Click below to confirm that you want to delete{" "}
+ "{chatSessionName.slice(0, 30)}"
+
+
+ >
+
+ );
+};
diff --git a/web/src/app/chat/modal/FeedbackModal.tsx b/web/src/app/chat/modal/FeedbackModal.tsx
new file mode 100644
index 0000000000..5ec6bd0bf0
--- /dev/null
+++ b/web/src/app/chat/modal/FeedbackModal.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { useState } from "react";
+import { FeedbackType } from "../types";
+import { FiThumbsDown, FiThumbsUp } from "react-icons/fi";
+import { ModalWrapper } from "./ModalWrapper";
+
+interface FeedbackModalProps {
+ feedbackType: FeedbackType;
+ onClose: () => void;
+ onSubmit: (feedbackDetails: string) => void;
+}
+
+export const FeedbackModal = ({
+ feedbackType,
+ onClose,
+ onSubmit,
+}: FeedbackModalProps) => {
+ const [message, setMessage] = useState("");
+
+ return (
+
+ <>
+
+
+ {feedbackType === "like" ? (
+
+ ) : (
+
+ )}
+
+ Provide additional feedback
+
+
+ );
+};
diff --git a/web/src/app/chat/modal/ModalWrapper.tsx b/web/src/app/chat/modal/ModalWrapper.tsx
new file mode 100644
index 0000000000..63232e675f
--- /dev/null
+++ b/web/src/app/chat/modal/ModalWrapper.tsx
@@ -0,0 +1,35 @@
+export const ModalWrapper = ({
+ children,
+ bgClassName,
+ modalClassName,
+ onClose,
+}: {
+ children: JSX.Element;
+ bgClassName?: string;
+ modalClassName?: string;
+ onClose?: () => void;
+}) => {
+ return (
+ onClose && onClose()}
+ className={
+ "fixed z-30 inset-0 overflow-y-auto bg-black bg-opacity-30 flex justify-center items-center " +
+ (bgClassName || "")
+ }
+ >
+
{
+ if (onClose) {
+ e.stopPropagation();
+ }
+ }}
+ className={
+ "bg-background text-emphasis p-8 rounded shadow-xl w-3/4 max-w-3xl shadow " +
+ (modalClassName || "")
+ }
+ >
+ {children}
+
+
+ );
+};
diff --git a/web/src/app/chat/modifiers/ChatFilters.tsx b/web/src/app/chat/modifiers/ChatFilters.tsx
new file mode 100644
index 0000000000..dd2a038978
--- /dev/null
+++ b/web/src/app/chat/modifiers/ChatFilters.tsx
@@ -0,0 +1,345 @@
+import React, { useEffect, useRef, useState } from "react";
+import { DocumentSet, ValidSources } from "@/lib/types";
+import { SourceMetadata } from "@/lib/search/interfaces";
+import {
+ FiBook,
+ FiBookmark,
+ FiCalendar,
+ FiFilter,
+ FiMap,
+ FiX,
+} from "react-icons/fi";
+import { DateRangePickerValue } from "@tremor/react";
+import { listSourceMetadata } from "@/lib/sources";
+import { SourceIcon } from "@/components/SourceIcon";
+import { BasicClickable } from "@/components/BasicClickable";
+import { ControlledPopup, DefaultDropdownElement } from "@/components/Dropdown";
+import { getXDaysAgo } from "@/lib/dateUtils";
+
+enum FilterType {
+ Source = "Source",
+ KnowledgeSet = "Knowledge Set",
+ TimeRange = "Time Range",
+}
+
+interface SourceSelectorProps {
+ timeRange: DateRangePickerValue | null;
+ setTimeRange: React.Dispatch<
+ React.SetStateAction
+ >;
+ selectedSources: SourceMetadata[];
+ setSelectedSources: React.Dispatch>;
+ selectedDocumentSets: string[];
+ setSelectedDocumentSets: React.Dispatch>;
+ availableDocumentSets: DocumentSet[];
+ existingSources: ValidSources[];
+}
+
+function SelectedBubble({
+ children,
+ onClick,
+}: {
+ children: string | JSX.Element;
+ onClick: () => void;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function SelectFilterType({
+ onSelect,
+ hasSources,
+ hasKnowledgeSets,
+}: {
+ onSelect: (filterType: FilterType) => void;
+ hasSources: boolean;
+ hasKnowledgeSets: boolean;
+}) {
+ return (
+
+ {hasSources && (
+ onSelect(FilterType.Source)}
+ isSelected={false}
+ />
+ )}
+
+ {hasKnowledgeSets && (
+ onSelect(FilterType.KnowledgeSet)}
+ isSelected={false}
+ />
+ )}
+
+ onSelect(FilterType.TimeRange)}
+ isSelected={false}
+ />
+
+ );
+}
+
+function SourcesSection({
+ sources,
+ selectedSources,
+ onSelect,
+}: {
+ sources: SourceMetadata[];
+ selectedSources: string[];
+ onSelect: (source: SourceMetadata) => void;
+}) {
+ return (
+
+ {sources.map((source) => (
+ onSelect(source)}
+ isSelected={selectedSources.includes(source.internalName)}
+ includeCheckbox
+ />
+ ))}
+
+ );
+}
+
+function KnowledgeSetsSection({
+ documentSets,
+ selectedDocumentSets,
+ onSelect,
+}: {
+ documentSets: DocumentSet[];
+ selectedDocumentSets: string[];
+ onSelect: (documentSetName: string) => void;
+}) {
+ return (
+
+ {documentSets.map((documentSet) => (
+ onSelect(documentSet.name)}
+ isSelected={selectedDocumentSets.includes(documentSet.name)}
+ includeCheckbox
+ />
+ ))}
+
+ );
+}
+
+const LAST_30_DAYS = "Last 30 days";
+const LAST_7_DAYS = "Last 7 days";
+const TODAY = "Today";
+
+function TimeRangeSection({
+ selectedTimeRange,
+ onSelect,
+}: {
+ selectedTimeRange: string | null;
+ onSelect: (timeRange: DateRangePickerValue) => void;
+}) {
+ return (
+
+
+ onSelect({
+ to: new Date(),
+ from: getXDaysAgo(30),
+ selectValue: LAST_30_DAYS,
+ })
+ }
+ isSelected={selectedTimeRange === LAST_30_DAYS}
+ />
+
+
+ onSelect({
+ to: new Date(),
+ from: getXDaysAgo(7),
+ selectValue: LAST_7_DAYS,
+ })
+ }
+ isSelected={selectedTimeRange === LAST_7_DAYS}
+ />
+
+
+ onSelect({
+ to: new Date(),
+ from: getXDaysAgo(1),
+ selectValue: TODAY,
+ })
+ }
+ isSelected={selectedTimeRange === TODAY}
+ />
+
+ );
+}
+
+export function ChatFilters({
+ timeRange,
+ setTimeRange,
+ selectedSources,
+ setSelectedSources,
+ selectedDocumentSets,
+ setSelectedDocumentSets,
+ availableDocumentSets,
+ existingSources,
+}: SourceSelectorProps) {
+ const [filtersOpen, setFiltersOpen] = useState(false);
+ const handleFiltersToggle = (value: boolean) => {
+ setSelectedFilterType(null);
+ setFiltersOpen(value);
+ };
+ const [selectedFilterType, setSelectedFilterType] =
+ useState(null);
+
+ const handleSourceSelect = (source: SourceMetadata) => {
+ setSelectedSources((prev: SourceMetadata[]) => {
+ const prevSourceNames = prev.map((source) => source.internalName);
+ if (prevSourceNames.includes(source.internalName)) {
+ return prev.filter((s) => s.internalName !== source.internalName);
+ } else {
+ return [...prev, source];
+ }
+ });
+ };
+
+ const handleDocumentSetSelect = (documentSetName: string) => {
+ setSelectedDocumentSets((prev: string[]) => {
+ if (prev.includes(documentSetName)) {
+ return prev.filter((s) => s !== documentSetName);
+ } else {
+ return [...prev, documentSetName];
+ }
+ });
+ };
+
+ const allSources = listSourceMetadata();
+ const availableSources = allSources.filter((source) =>
+ existingSources.includes(source.internalName)
+ );
+
+ let popupDisplay = null;
+ if (selectedFilterType === FilterType.Source) {
+ popupDisplay = (
+ source.internalName)}
+ onSelect={handleSourceSelect}
+ />
+ );
+ } else if (selectedFilterType === FilterType.KnowledgeSet) {
+ popupDisplay = (
+
+ );
+ } else if (selectedFilterType === FilterType.TimeRange) {
+ popupDisplay = (
+ {
+ setTimeRange(timeRange);
+ handleFiltersToggle(!filtersOpen);
+ }}
+ />
+ );
+ } else {
+ popupDisplay = (
+ setSelectedFilterType(filterType)}
+ hasSources={availableSources.length > 0}
+ hasKnowledgeSets={availableDocumentSets.length > 0}
+ />
+ );
+ }
+
+ return (
+
+
+
+
handleFiltersToggle(!filtersOpen)}>
+
+ Filter
+
+
+
+
+
+
+ {((timeRange && timeRange.selectValue !== undefined) ||
+ selectedSources.length > 0 ||
+ selectedDocumentSets.length > 0) && (
+
Currently applied:
+ )}
+
+ {timeRange && timeRange.selectValue && (
+
setTimeRange(null)}>
+ {timeRange.selectValue}
+
+ )}
+ {existingSources.length > 0 &&
+ selectedSources.map((source) => (
+
handleSourceSelect(source)}
+ >
+ <>
+
+ {source.displayName}
+ >
+
+ ))}
+ {selectedDocumentSets.length > 0 &&
+ selectedDocumentSets.map((documentSetName) => (
+
handleDocumentSetSelect(documentSetName)}
+ >
+ <>
+
+
+
+ {documentSetName}
+ >
+
+ ))}
+
+
+
+ );
+}
diff --git a/web/src/app/chat/modifiers/SearchTypeSelector.tsx b/web/src/app/chat/modifiers/SearchTypeSelector.tsx
new file mode 100644
index 0000000000..c9f1f8a5ce
--- /dev/null
+++ b/web/src/app/chat/modifiers/SearchTypeSelector.tsx
@@ -0,0 +1,71 @@
+import { BasicClickable } from "@/components/BasicClickable";
+import { ControlledPopup, DefaultDropdownElement } from "@/components/Dropdown";
+import { useState } from "react";
+import { FiCpu, FiFilter, FiSearch } from "react-icons/fi";
+
+export const QA = "Question Answering";
+export const SEARCH = "Search Only";
+
+function SearchTypeSelectorContent({
+ selectedSearchType,
+ setSelectedSearchType,
+}: {
+ selectedSearchType: string;
+ setSelectedSearchType: React.Dispatch>;
+}) {
+ return (
+
+ setSelectedSearchType(QA)}
+ isSelected={selectedSearchType === QA}
+ />
+ setSelectedSearchType(SEARCH)}
+ isSelected={selectedSearchType === SEARCH}
+ />
+
+ );
+}
+
+export function SearchTypeSelector({
+ selectedSearchType,
+ setSelectedSearchType,
+}: {
+ selectedSearchType: string;
+ setSelectedSearchType: React.Dispatch>;
+}) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+ }
+ >
+ setIsOpen(!isOpen)}>
+
+ {selectedSearchType === QA ? (
+ <>
+ QA
+ >
+ ) : (
+ <>
+ Search
+ >
+ )}
+
+
+
+ );
+}
diff --git a/web/src/app/chat/modifiers/SelectedDocuments.tsx b/web/src/app/chat/modifiers/SelectedDocuments.tsx
new file mode 100644
index 0000000000..be3b81f37f
--- /dev/null
+++ b/web/src/app/chat/modifiers/SelectedDocuments.tsx
@@ -0,0 +1,25 @@
+import { BasicClickable } from "@/components/BasicClickable";
+import { DanswerDocument } from "@/lib/search/interfaces";
+import { useState } from "react";
+import { FiBook, FiFilter } from "react-icons/fi";
+
+export function SelectedDocuments({
+ selectedDocuments,
+}: {
+ selectedDocuments: DanswerDocument[];
+}) {
+ if (selectedDocuments.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
{" "}
+
+ Chatting with {selectedDocuments.length} Selected Documents
+
+
+
+ );
+}
diff --git a/web/src/app/chat/page.tsx b/web/src/app/chat/page.tsx
new file mode 100644
index 0000000000..104dbe832f
--- /dev/null
+++ b/web/src/app/chat/page.tsx
@@ -0,0 +1,12 @@
+import ChatPage from "./ChatPage";
+
+export default async function Page({
+ searchParams,
+}: {
+ searchParams: { shouldhideBeforeScroll?: string };
+}) {
+ return await ChatPage({
+ chatId: null,
+ shouldhideBeforeScroll: searchParams.shouldhideBeforeScroll === "true",
+ });
+}
diff --git a/web/src/app/chat/sessionSidebar/ChatSidebar.tsx b/web/src/app/chat/sessionSidebar/ChatSidebar.tsx
new file mode 100644
index 0000000000..6c3ec801f1
--- /dev/null
+++ b/web/src/app/chat/sessionSidebar/ChatSidebar.tsx
@@ -0,0 +1,196 @@
+"use client";
+
+import {
+ FiLogOut,
+ FiMessageSquare,
+ FiMoreHorizontal,
+ FiPlusSquare,
+ FiSearch,
+ FiTool,
+} from "react-icons/fi";
+import { useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { User } from "@/lib/types";
+import { logout } from "@/lib/user";
+import { BasicClickable, BasicSelectable } from "@/components/BasicClickable";
+import Image from "next/image";
+import { ChatSessionDisplay } from "./SessionDisplay";
+import { ChatSession } from "../interfaces";
+import { groupSessionsByDateRange } from "../lib";
+interface ChatSidebarProps {
+ existingChats: ChatSession[];
+ currentChatId: number | null;
+ user: User | null;
+}
+
+export const ChatSidebar = ({
+ existingChats,
+ currentChatId,
+ user,
+}: ChatSidebarProps) => {
+ const router = useRouter();
+
+ const groupedChatSessions = groupSessionsByDateRange(existingChats);
+
+ const [userInfoVisible, setUserInfoVisible] = useState(false);
+ const userInfoRef = useRef(null);
+
+ const handleLogout = () => {
+ logout().then((isSuccess) => {
+ if (!isSuccess) {
+ alert("Failed to logout");
+ }
+ router.push("/auth/login");
+ });
+ };
+
+ // hides logout popup on any click outside
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ userInfoRef.current &&
+ !userInfoRef.current.contains(event.target as Node)
+ ) {
+ setUserInfoVisible(false);
+ }
+ };
+
+ useEffect(() => {
+ document.addEventListener("mousedown", handleClickOutside);
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, []);
+
+ return (
+
+ );
+};
diff --git a/web/src/app/chat/sessionSidebar/SessionDisplay.tsx b/web/src/app/chat/sessionSidebar/SessionDisplay.tsx
new file mode 100644
index 0000000000..2c1c71f919
--- /dev/null
+++ b/web/src/app/chat/sessionSidebar/SessionDisplay.tsx
@@ -0,0 +1,119 @@
+import { useRouter } from "next/navigation";
+import { ChatSession } from "../interfaces";
+import { useState } from "react";
+import { deleteChatSession, renameChatSession } from "../lib";
+import { DeleteChatModal } from "../modal/DeleteChatModal";
+import { BasicSelectable } from "@/components/BasicClickable";
+import Link from "next/link";
+import { FiCheck, FiEdit, FiMessageSquare, FiTrash, FiX } from "react-icons/fi";
+
+interface ChatSessionDisplayProps {
+ chatSession: ChatSession;
+ isSelected: boolean;
+}
+
+export function ChatSessionDisplay({
+ chatSession,
+ isSelected,
+}: ChatSessionDisplayProps) {
+ const router = useRouter();
+ const [isDeletionModalVisible, setIsDeletionModalVisible] = useState(false);
+ const [isRenamingChat, setIsRenamingChat] = useState(false);
+ const [chatName, setChatName] = useState(chatSession.name);
+
+ const onRename = async () => {
+ const response = await renameChatSession(chatSession.id, chatName);
+ if (response.ok) {
+ setIsRenamingChat(false);
+ router.refresh();
+ } else {
+ alert("Failed to rename chat session");
+ }
+ };
+
+ return (
+ <>
+ {isDeletionModalVisible && (
+ setIsDeletionModalVisible(false)}
+ onSubmit={async () => {
+ const response = await deleteChatSession(chatSession.id);
+ if (response.ok) {
+ setIsDeletionModalVisible(false);
+ // go back to the main page
+ router.push("/chat");
+ } else {
+ alert("Failed to delete chat session");
+ }
+ }}
+ chatSessionName={chatSession.name}
+ />
+ )}
+
+
+
+
+
+
{" "}
+ {isRenamingChat ? (
+
setChatName(e.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ onRename();
+ event.preventDefault();
+ }
+ }}
+ className="-my-px px-1 mr-2 w-full rounded"
+ />
+ ) : (
+
+ {chatName || `Chat ${chatSession.id}`}
+
+ )}
+ {isSelected &&
+ (isRenamingChat ? (
+
+
+
+
+
{
+ setChatName(chatSession.name);
+ setIsRenamingChat(false);
+ }}
+ className={`hover:bg-black/10 p-1 -m-1 rounded ml-2`}
+ >
+
+
+
+ ) : (
+
+
setIsRenamingChat(true)}
+ className={`hover:bg-black/10 p-1 -m-1 rounded`}
+ >
+
+
+
setIsDeletionModalVisible(true)}
+ className={`hover:bg-black/10 p-1 -m-1 rounded ml-2`}
+ >
+
+
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/web/src/app/chat/types.ts b/web/src/app/chat/types.ts
new file mode 100644
index 0000000000..34c9381ff9
--- /dev/null
+++ b/web/src/app/chat/types.ts
@@ -0,0 +1 @@
+export type FeedbackType = "like" | "dislike";
diff --git a/web/src/app/globals.css b/web/src/app/globals.css
index b5c61c9567..52f28f3a75 100644
--- a/web/src/app/globals.css
+++ b/web/src/app/globals.css
@@ -1,3 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+
+::-webkit-scrollbar-track {
+ background: #f9fafb; /* Track background color */
+}
+
+/* Style the scrollbar handle */
+::-webkit-scrollbar-thumb {
+ background: #e5e7eb; /* Handle color */
+ border-radius: 10px;
+}
+
+/* Handle on hover */
+::-webkit-scrollbar-thumb:hover {
+ background: #d1d5db; /* Handle color on hover */
+}
+
+::-webkit-scrollbar {
+ width: 8px; /* Vertical scrollbar width */
+ height: 8px; /* Horizontal scrollbar height */
+}
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx
index 001058e14e..8232852932 100644
--- a/web/src/app/layout.tsx
+++ b/web/src/app/layout.tsx
@@ -21,7 +21,9 @@ export default async function RootLayout({
}) {
return (
-
+
{children}
diff --git a/web/src/app/page.tsx b/web/src/app/search/page.tsx
similarity index 97%
rename from web/src/app/page.tsx
rename to web/src/app/search/page.tsx
index 7fe058a467..d04a904000 100644
--- a/web/src/app/page.tsx
+++ b/web/src/app/search/page.tsx
@@ -8,7 +8,7 @@ import { fetchSS } from "@/lib/utilsSS";
import { Connector, DocumentSet, User } from "@/lib/types";
import { cookies } from "next/headers";
import { SearchType } from "@/lib/search/interfaces";
-import { Persona } from "./admin/personas/interfaces";
+import { Persona } from "../admin/personas/interfaces";
import { WelcomeModal } from "@/components/WelcomeModal";
import { unstable_noStore as noStore } from "next/cache";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
@@ -88,7 +88,7 @@ export default async function Home() {
{connectors.length === 0 && connectorsResponse?.ok && }
-
+
void;
+ fullWidth?: boolean;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function EmphasizedClickable({
+ children,
+ onClick,
+ fullWidth = false,
+}: {
+ children: string | JSX.Element;
+ onClick?: () => void;
+ fullWidth?: boolean;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function BasicSelectable({
+ children,
+ selected,
+ hasBorder,
+ fullWidth = false,
+}: {
+ children: string | JSX.Element;
+ selected: boolean;
+ hasBorder?: boolean;
+ fullWidth?: boolean;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web/src/components/Bubble.tsx b/web/src/components/Bubble.tsx
new file mode 100644
index 0000000000..316611f188
--- /dev/null
+++ b/web/src/components/Bubble.tsx
@@ -0,0 +1,29 @@
+export function Bubble({
+ isSelected,
+ onClick,
+ children,
+}: {
+ isSelected: boolean;
+ onClick: () => void;
+ children: string | JSX.Element;
+}) {
+ return (
+
+ );
+}
diff --git a/web/src/components/CustomCheckbox.tsx b/web/src/components/CustomCheckbox.tsx
index cf89995c5d..e06e948bef 100644
--- a/web/src/components/CustomCheckbox.tsx
+++ b/web/src/components/CustomCheckbox.tsx
@@ -16,13 +16,13 @@ export const CustomCheckbox = ({
/>
{checked && (
diff --git a/web/src/components/DeleteButton.tsx b/web/src/components/DeleteButton.tsx
new file mode 100644
index 0000000000..0a381af018
--- /dev/null
+++ b/web/src/components/DeleteButton.tsx
@@ -0,0 +1,28 @@
+import { FiTrash } from "react-icons/fi";
+
+export function DeleteButton({
+ onClick,
+ disabled,
+}: {
+ onClick?: () => void;
+ disabled?: boolean;
+}) {
+ return (
+
+
+ Delete
+
+ );
+}
diff --git a/web/src/components/Dropdown.tsx b/web/src/components/Dropdown.tsx
index 50a14ec0c5..c99bffefb3 100644
--- a/web/src/components/Dropdown.tsx
+++ b/web/src/components/Dropdown.tsx
@@ -314,7 +314,7 @@ export const CustomDropdown = ({
{isOpen && (
setIsOpen(!isOpen)}
- className="pt-2 absolute bottom w-full z-30 bg-gray-900"
+ className="pt-2 absolute bottom w-full z-30 box-shadow"
>
{dropdown}
@@ -323,48 +323,52 @@ export const CustomDropdown = ({
);
};
-function DefaultDropdownElement({
- id,
+export function DefaultDropdownElement({
name,
+ icon,
description,
onSelect,
isSelected,
- isFinal,
+ includeCheckbox = false,
}: {
- id: string | number | null;
name: string;
+ icon?: React.FC<{ size?: number; className?: string }>;
description?: string;
- onSelect: (value: string | number | null) => void;
- isSelected: boolean;
- isFinal: boolean;
+ onSelect?: () => void;
+ isSelected?: boolean;
+ includeCheckbox?: boolean;
}) {
- console.log(isFinal);
return (
{
- onSelect(id);
- }}
+ onClick={onSelect}
>
- {name}
- {description && (
-
{description}
- )}
+
+ {includeCheckbox && (
+ null}
+ />
+ )}
+ {icon && icon({ size: 16, className: "mr-2 my-auto" })}
+ {name}
+
+ {description &&
{description}
}
{isSelected && (
@@ -394,10 +398,11 @@ export function DefaultDropdown({
{
onSelect(null);
}}
isSelected={selected === null}
- isFinal={false}
/>
)}
{options.map((option, ind) => {
@@ -419,12 +422,10 @@ export function DefaultDropdown({
return (
onSelect(option.value)}
isSelected={isSelected}
- isFinal={ind === options.length - 1}
/>
);
})}
@@ -435,16 +436,15 @@ export function DefaultDropdown({
className={`
flex
text-sm
- text-gray-400
+ bg-background
px-3
py-1.5
rounded-lg
border
- border-gray-800
- cursor-pointer
- hover:bg-dark-tremor-background-muted`}
+ border-border
+ cursor-pointer`}
>
-
+
{selectedOption?.name ||
(includeDefault ? "Default" : "Select an option...")}
@@ -453,3 +453,45 @@ export function DefaultDropdown({
);
}
+
+export function ControlledPopup({
+ children,
+ popupContent,
+ isOpen,
+ setIsOpen,
+}: {
+ children: JSX.Element | string;
+ popupContent: JSX.Element | string;
+ isOpen: boolean;
+ setIsOpen: (value: boolean) => void;
+}) {
+ const filtersRef = useRef(null);
+ // hides logout popup on any click outside
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ filtersRef.current &&
+ !filtersRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ useEffect(() => {
+ document.addEventListener("mousedown", handleClickOutside);
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, []);
+
+ return (
+
+ {children}
+ {isOpen && (
+
+ {popupContent}
+
+ )}
+
+ );
+}
diff --git a/web/src/components/EditButton.tsx b/web/src/components/EditButton.tsx
index 2e9ba4f99b..1d8ad14937 100644
--- a/web/src/components/EditButton.tsx
+++ b/web/src/components/EditButton.tsx
@@ -1,8 +1,6 @@
"use client";
-import { useRouter } from "next/navigation";
-
-import { FiChevronLeft, FiEdit } from "react-icons/fi";
+import { FiEdit } from "react-icons/fi";
export function EditButton({ onClick }: { onClick: () => void }) {
return (
@@ -11,12 +9,12 @@ export function EditButton({ onClick }: { onClick: () => void }) {
my-auto
flex
mb-1
- hover:bg-gray-800
+ hover:bg-hover
w-fit
p-2
cursor-pointer
rounded-lg
- border-gray-800
+ border-border
text-sm`}
onClick={onClick}
>
diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx
index 8736102199..f521a84bb5 100644
--- a/web/src/components/Header.tsx
+++ b/web/src/components/Header.tsx
@@ -7,6 +7,8 @@ import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React, { useEffect, useRef, useState } from "react";
+import { CustomDropdown, DefaultDropdownElement } from "./Dropdown";
+import { FiMessageSquare, FiSearch } from "react-icons/fi";
interface HeaderProps {
user: User | null;
@@ -51,50 +53,78 @@ export const Header: React.FC = ({ user }) => {
}, [dropdownOpen]);
return (
-
-
-
+
+
+
-
setDropdownOpen(!dropdownOpen)}
- ref={dropdownRef}
+
-
- {dropdownOpen && (
-
+
+
+
+
+
+
+
+
+
+ {/* Show connector option if (1) auth is disabled or (2) user is an admin */}
+ {(!user || user.role === "admin") && (
+
+
+
+ )}
+ {user && (
+
+ )}
+
}
>
- {/* Show connector option if (1) auth is disabled or (2) user is an admin */}
- {(!user || user.role === "admin") && (
-
-
- Admin Panel
-
-
- )}
- {user && (
-
- Logout
+
+
+ {user && user.email ? user.email[0].toUpperCase() : "A"}
- )}
-
- )}
+
+
+
);
};
+
+/*
+
+*/
diff --git a/web/src/components/HoverPopup.tsx b/web/src/components/HoverPopup.tsx
index 886cde0238..25d68e29a4 100644
--- a/web/src/components/HoverPopup.tsx
+++ b/web/src/components/HoverPopup.tsx
@@ -4,7 +4,7 @@ interface HoverPopupProps {
mainContent: string | JSX.Element;
popupContent: string | JSX.Element;
classNameModifications?: string;
- direction?: "left" | "bottom";
+ direction?: "left" | "bottom" | "top";
style?: "basic" | "dark";
}
@@ -25,6 +25,9 @@ export const HoverPopup = ({
case "bottom":
popupDirectionClass = "top-0 left-0 mt-6 pt-2";
break;
+ case "top":
+ popupDirectionClass = "top-0 left-0 translate-y-[-100%] pb-2";
+ break;
}
return (
@@ -39,10 +42,7 @@ export const HoverPopup = ({
@@ -50,7 +50,7 @@ export const HoverPopup = ({
)}
-
{mainContent}
+
{mainContent}
);
};
diff --git a/web/src/components/Modal.tsx b/web/src/components/Modal.tsx
index 6847a6d370..4ee8b701cc 100644
--- a/web/src/components/Modal.tsx
+++ b/web/src/components/Modal.tsx
@@ -22,14 +22,14 @@ export function Modal({
>
event.stopPropagation()}
>
{title && (
-
+
{title}
)}
diff --git a/web/src/components/PageSelector.tsx b/web/src/components/PageSelector.tsx
index 1a634a4297..1e443e3c88 100644
--- a/web/src/components/PageSelector.tsx
+++ b/web/src/components/PageSelector.tsx
@@ -61,14 +61,13 @@ const PageLink = ({
py-1
leading-5
-ml-px
- text-gray-300
- border-gray-600
- ${!unclickable ? "hover:bg-gray-600" : ""}
+ border-border
+ ${!unclickable ? "hover:bg-hover" : ""}
${!unclickable ? "cursor-pointer" : ""}
first:ml-0
first:rounded-l-md
last:rounded-r-md
- ${active ? "bg-gray-700" : ""}
+ ${active ? "bg-background-strong" : ""}
`}
onClick={() => {
if (pageChangeHandler) {
diff --git a/web/src/components/Status.tsx b/web/src/components/Status.tsx
index 82e2fe2c70..9e5a6ca073 100644
--- a/web/src/components/Status.tsx
+++ b/web/src/components/Status.tsx
@@ -61,8 +61,7 @@ export function IndexAttemptStatus({
);
}
- // TODO: remove wrapping `dark` once we have light/dark mode
- return {badge}
;
+ return {badge}
;
}
export function CCPairStatus({
@@ -104,6 +103,5 @@ export function CCPairStatus({
);
}
- // TODO: remove wrapping `dark` once we have light/dark mode
- return {badge}
;
+ return {badge}
;
}
diff --git a/web/src/components/WelcomeModal.tsx b/web/src/components/WelcomeModal.tsx
index c106da2c90..418eb5c0f4 100644
--- a/web/src/components/WelcomeModal.tsx
+++ b/web/src/components/WelcomeModal.tsx
@@ -7,11 +7,11 @@ import Link from "next/link";
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
@@ -26,7 +26,7 @@ export function WelcomeModal() {
-
+
Setup your first connector!
diff --git a/web/src/components/admin/Layout.tsx b/web/src/components/admin/Layout.tsx
index 780f5cb7a4..32fc02bbd1 100644
--- a/web/src/components/admin/Layout.tsx
+++ b/web/src/components/admin/Layout.tsx
@@ -1,5 +1,5 @@
import { Header } from "@/components/Header";
-import { Sidebar } from "@/components/admin/connectors/Sidebar";
+import { AdminSidebar } from "@/components/admin/connectors/AdminSidebar";
import {
NotebookIcon,
KeyIcon,
@@ -30,11 +30,13 @@ export async function Layout({ children }: { children: React.ReactNode }) {
}
return (
-
-
-
-
-
+
+
+
+
+
-
{children}
+
+ {children}
+
);
diff --git a/web/src/components/admin/Title.tsx b/web/src/components/admin/Title.tsx
index a7a3ecf978..9168280a8e 100644
--- a/web/src/components/admin/Title.tsx
+++ b/web/src/components/admin/Title.tsx
@@ -13,12 +13,12 @@ export function AdminPageTitle({
includeDivider?: boolean;
}) {
return (
-
+
-
+
{icon} {title}
{farRightElement && {farRightElement}
}
diff --git a/web/src/components/admin/connectors/Sidebar.tsx b/web/src/components/admin/connectors/AdminSidebar.tsx
similarity index 58%
rename from web/src/components/admin/connectors/Sidebar.tsx
rename to web/src/components/admin/connectors/AdminSidebar.tsx
index 755b99feaf..9793da146b 100644
--- a/web/src/components/admin/connectors/Sidebar.tsx
+++ b/web/src/components/admin/connectors/AdminSidebar.tsx
@@ -12,25 +12,19 @@ interface Collection {
items: Item[];
}
-interface SidebarProps {
- collections: Collection[];
-}
-
-export function Sidebar({ collections }: SidebarProps) {
+export function AdminSidebar({ collections }: { collections: Collection[] }) {
return (
-
+
{collections.map((collection, collectionInd) => (
-
+
{collection.name}
{collection.items.map((item) => (
-
-
- {item.name}
-
+
+ {item.name}
))}
diff --git a/web/src/components/admin/connectors/ConnectorForm.tsx b/web/src/components/admin/connectors/ConnectorForm.tsx
index 478ac5140b..c91b22bbf8 100644
--- a/web/src/components/admin/connectors/ConnectorForm.tsx
+++ b/web/src/components/admin/connectors/ConnectorForm.tsx
@@ -13,6 +13,7 @@ import { FormBodyBuilder, RequireAtLeastOne } from "./types";
import { TextFormField } from "./Field";
import { createCredential, linkCredential } from "@/lib/credential";
import { useSWRConfig } from "swr";
+import { Button } from "@tremor/react";
const BASE_CONNECTOR_URL = "/api/manage/admin/connector";
@@ -219,17 +220,15 @@ export function ConnectorForm({
{formBody && formBody}
{formBodyBuilder && formBodyBuilder(values)}
-
Connect
-
+
)}
@@ -304,17 +303,15 @@ export function UpdateConnectorForm({
)}
diff --git a/web/src/components/admin/connectors/ConnectorTitle.tsx b/web/src/components/admin/connectors/ConnectorTitle.tsx
index 7b99cb24ed..d4262f4244 100644
--- a/web/src/components/admin/connectors/ConnectorTitle.tsx
+++ b/web/src/components/admin/connectors/ConnectorTitle.tsx
@@ -106,7 +106,7 @@ export const ConnectorTitle = ({
{mainDisplay}
)}
{showMetadata && additionalMetadata.size > 0 && (
-
+
{Array.from(additionalMetadata.entries()).map(([key, value]) => {
return (
diff --git a/web/src/components/admin/connectors/CredentialForm.tsx b/web/src/components/admin/connectors/CredentialForm.tsx
index d809e85364..db081e1043 100644
--- a/web/src/components/admin/connectors/CredentialForm.tsx
+++ b/web/src/components/admin/connectors/CredentialForm.tsx
@@ -4,6 +4,7 @@ import * as Yup from "yup";
import { Popup } from "./Popup";
import { CredentialBase } from "@/lib/types";
import { createCredential } from "@/lib/credential";
+import { Button } from "@tremor/react";
export async function submitCredential
(
credential: CredentialBase
@@ -66,17 +67,15 @@ export function CredentialForm({
)}
diff --git a/web/src/components/admin/connectors/Field.tsx b/web/src/components/admin/connectors/Field.tsx
index 9c336ef7e1..4e81d39fea 100644
--- a/web/src/components/admin/connectors/Field.tsx
+++ b/web/src/components/admin/connectors/Field.tsx
@@ -21,17 +21,15 @@ export function SectionHeader({
}
export function Label({ children }: { children: string | JSX.Element }) {
- return (
- {children}
- );
+ return {children}
;
}
export function SubLabel({ children }: { children: string | JSX.Element }) {
- return {children}
;
+ return {children}
;
}
export function ManualErrorMessage({ children }: { children: string }) {
- return {children}
;
+ return {children}
;
}
export function TextFormField({
@@ -69,15 +67,14 @@ export function TextFormField({
className={
`
border
- text-gray-200
- border-gray-600
+ border-border
rounded
w-full
py-2
px-3
mt-1
${isTextArea ? " h-28" : ""}
- ` + (disabled ? " bg-gray-900" : " bg-gray-800")
+ ` + (disabled ? " bg-background-strong" : " bg-background-emphasis")
}
disabled={disabled}
placeholder={placeholder}
@@ -150,7 +147,7 @@ export function TextArrayField({
type,
}: TextArrayFieldProps) {
return (
-
+
{label}
{subtext &&
{subtext} }
@@ -169,13 +166,12 @@ export function TextArrayField
({
id={name}
className={`
border
- text-gray-200
- border-gray-600
+ border-border
+ bg-background
rounded
w-full
py-2
px-3
- bg-gray-800
mr-4
`}
// Disable autocomplete since the browser doesn't know how to handle an array of text fields
@@ -183,7 +179,7 @@ export function TextArrayField({
/>
arrayHelpers.remove(index)}
/>
@@ -191,7 +187,7 @@ export function TextArrayField({
))}
@@ -201,7 +197,7 @@ export function TextArrayField
({
arrayHelpers.push("");
}}
className="mt-3"
- variant="secondary"
+ color="green"
size="xs"
type="button"
icon={FiPlus}
@@ -250,7 +246,7 @@ export function SelectorFormField({
const { setFieldValue } = useFormikContext();
return (
-
+
{label &&
{label} }
{subtext &&
{subtext} }
diff --git a/web/src/components/admin/connectors/FileUpload.tsx b/web/src/components/admin/connectors/FileUpload.tsx
index 9f6a2b0f42..c1605f313a 100644
--- a/web/src/components/admin/connectors/FileUpload.tsx
+++ b/web/src/components/admin/connectors/FileUpload.tsx
@@ -31,12 +31,12 @@ export const FileUpload: FC
= ({
{...getRootProps()}
className={
"flex flex-col items-center w-full px-4 py-12 rounded " +
- "shadow-lg tracking-wide border border-gray-700 cursor-pointer" +
- (dragActive ? " border-blue-500" : "")
+ "shadow-lg tracking-wide border border-border cursor-pointer" +
+ (dragActive ? " border-accent" : "")
}
>
-
+
{message ||
"Drag and drop some files here, or click to select files"}
diff --git a/web/src/components/admin/connectors/table/ConnectorsTable.tsx b/web/src/components/admin/connectors/table/ConnectorsTable.tsx
index b970b9f9c1..e9b64bc36a 100644
--- a/web/src/components/admin/connectors/table/ConnectorsTable.tsx
+++ b/web/src/components/admin/connectors/table/ConnectorsTable.tsx
@@ -6,6 +6,14 @@ import { LinkBreakIcon, LinkIcon } from "@/components/icons/icons";
import { disableConnector } from "@/lib/connector";
import { AttachCredentialButtonForTable } from "@/components/admin/connectors/buttons/AttachCredentialButtonForTable";
import { DeleteColumn } from "./DeleteColumn";
+import {
+ Table,
+ TableHead,
+ TableRow,
+ TableHeaderCell,
+ TableBody,
+ TableCell,
+} from "@tremor/react";
interface StatusRowProps {
connectorIndexingStatus: ConnectorIndexingStatus<
@@ -30,17 +38,17 @@ export function StatusRow({
let statusDisplay;
switch (connectorIndexingStatus.last_status) {
case "failed":
- statusDisplay = Failed
;
+ statusDisplay = Failed
;
break;
default:
- statusDisplay = Enabled!
;
+ statusDisplay = Enabled!
;
}
if (connector.disabled) {
const deletionAttempt = connectorIndexingStatus.deletion_attempt;
if (!deletionAttempt || deletionAttempt.status === "FAILURE") {
- statusDisplay = Disabled
;
+ statusDisplay = Disabled
;
} else {
- statusDisplay = Deleting...
;
+ statusDisplay = Deleting...
;
shouldDisplayDisabledToggle = false;
}
}
@@ -56,18 +64,18 @@ export function StatusRow({
onClick={() => disableConnector(connector, setPopup, onUpdate)}
>
{statusHovered && (
-
+
{connector.disabled ? "Enable!" : "Disable!"}
)}
{connector.disabled ? (
-
+
) : (
)}
@@ -146,84 +154,84 @@ export function ConnectorsTable
({
});
return (
- <>
+
{popup}
-
{
- const connector = connectorIndexingStatus.connector;
- // const credential = connectorIndexingStatus.credential;
- const hasValidCredentials =
- liveCredential &&
- connector.credential_ids.includes(liveCredential.id);
- const credential = connectorIncludesCredential
- ? {
- credential: hasValidCredentials ? (
-
- {getCredential(liveCredential)}
-
- ) : liveCredential ? (
- onCredentialLink(connector.id)}
+
+
+
+
+ {includeName && Name }
+ {specialColumns?.map(({ header }) => (
+ {header}
+ ))}
+ Status
+ {connectorIncludesCredential && (
+ Credential
+ )}
+ Remove
+
+
+
+ {connectorIndexingStatuses.map((connectorIndexingStatus) => {
+ const connector = connectorIndexingStatus.connector;
+ // const credential = connectorIndexingStatus.credential;
+ const hasValidCredentials =
+ liveCredential &&
+ connector.credential_ids.includes(liveCredential.id);
+ const credentialDisplay = connectorIncludesCredential ? (
+ hasValidCredentials ? (
+
+ {getCredential(liveCredential)}
+
+ ) : liveCredential ? (
+ onCredentialLink(connector.id)}
+ />
+ ) : (
+ N/A
+ )
+ ) : (
+ "-"
+ );
+ return (
+
+ {includeName && (
+
+
+ {connectorIndexingStatus.name}
+
+
+ )}
+ {specialColumns?.map(({ key, getValue }) => (
+
+ {getValue(connectorIndexingStatus)}
+
+ ))}
+
+
- ) : (
- N/A
- ),
- }
- : { credential: "" };
- return {
- status: (
-
- ),
- remove: (
-
- ),
- ...credential,
- ...(specialColumns
- ? Object.fromEntries(
- specialColumns.map(({ key, getValue }, i) => [
- key,
- getValue(connectorIndexingStatus),
- ])
- )
- : {}),
- ...(includeName
- ? {
- name: connectorIndexingStatus.name || "",
- }
- : {}),
- };
- // index: (
- // {
- // const { message, isSuccess } = await submitIndexRequest(
- // connector.source,
- // connector.connector_specific_config
- // );
- // setPopup({
- // message,
- // type: isSuccess ? "success" : "error",
- // });
- // setTimeout(() => {
- // setPopup(null);
- // }, 4000);
- // mutate("/api/admin/connector/index-attempt");
- // }}
- // />
- // ),
- })}
- />
- >
+
+ {connectorIncludesCredential && (
+ {credentialDisplay}
+ )}
+
+
+
+
+ );
+ })}
+
+
+
);
}
diff --git a/web/src/components/admin/connectors/table/DeleteColumn.tsx b/web/src/components/admin/connectors/table/DeleteColumn.tsx
index a6a84b7a24..2647598750 100644
--- a/web/src/components/admin/connectors/table/DeleteColumn.tsx
+++ b/web/src/components/admin/connectors/table/DeleteColumn.tsx
@@ -6,6 +6,7 @@ import {
import { ConnectorIndexingStatus } from "@/lib/types";
import { PopupSpec } from "../Popup";
import { useState } from "react";
+import { DeleteButton } from "@/components/DeleteButton";
interface Props {
connectorIndexingStatus: ConnectorIndexingStatus<
@@ -33,25 +34,24 @@ export function DeleteColumn({
onMouseLeave={() => setDeleteHovered(false)}
>
{connectorIndexingStatus.is_deletable ? (
-
- deleteCCPair(connector.id, credential.id, setPopup, onUpdate)
- }
- >
-
+
+
+ deleteCCPair(connector.id, credential.id, setPopup, onUpdate)
+ }
+ />
) : (
{deleteHovered && (
-
-
+
+
In order to delete a connector it must be disabled and have no
ongoing / planned index jobs.
)}
-
+
)}
diff --git a/web/src/components/admin/connectors/table/SingleUseConnectorsTable.tsx b/web/src/components/admin/connectors/table/SingleUseConnectorsTable.tsx
index a69b42a8c1..47d8483579 100644
--- a/web/src/components/admin/connectors/table/SingleUseConnectorsTable.tsx
+++ b/web/src/components/admin/connectors/table/SingleUseConnectorsTable.tsx
@@ -1,12 +1,18 @@
import { DeletionAttemptSnapshot, ValidStatuses } from "@/lib/types";
-import { BasicTable } from "@/components/admin/connectors/BasicTable";
-import { Popup } from "@/components/admin/connectors/Popup";
-import { useState } from "react";
-import { TrashIcon } from "@/components/icons/icons";
+import { usePopup } from "@/components/admin/connectors/Popup";
import { updateConnector } from "@/lib/connector";
import { AttachCredentialButtonForTable } from "@/components/admin/connectors/buttons/AttachCredentialButtonForTable";
import { scheduleDeletionJobForConnector } from "@/lib/documentDeletion";
import { ConnectorsTableProps } from "./ConnectorsTable";
+import {
+ Table,
+ TableHead,
+ TableRow,
+ TableHeaderCell,
+ TableBody,
+ TableCell,
+} from "@tremor/react";
+import { DeleteButton } from "@/components/DeleteButton";
const SingleUseConnectorStatus = ({
indexingStatus,
@@ -20,22 +26,22 @@ const SingleUseConnectorStatus = ({
(deletionAttempt.status === "PENDING" ||
deletionAttempt.status === "STARTED")
) {
- return
Deleting...
;
+ return
Deleting...
;
}
if (!indexingStatus || indexingStatus === "not_started") {
- return
Not Started
;
+ return
Not Started
;
}
if (indexingStatus === "in_progress") {
- return
In Progress
;
+ return
In Progress
;
}
if (indexingStatus === "success") {
- return
Success!
;
+ return
Success!
;
}
- return
Failed
;
+ return
Failed
;
};
export function SingleUseConnectorsTable<
@@ -50,122 +56,117 @@ export function SingleUseConnectorsTable<
onCredentialLink,
includeName = false,
}: ConnectorsTableProps
) {
- const [popup, setPopup] = useState<{
- message: string;
- type: "success" | "error";
- } | null>(null);
+ const { popup, setPopup } = usePopup();
const connectorIncludesCredential =
getCredential !== undefined && onCredentialLink !== undefined;
- const columns = [];
-
- if (includeName) {
- columns.push({
- header: "Name",
- key: "name",
- });
- }
- columns.push(...(specialColumns ?? []));
- columns.push({
- header: "Status",
- key: "status",
- });
- if (connectorIncludesCredential) {
- columns.push({
- header: "Credential",
- key: "credential",
- });
- }
- columns.push({
- header: "Remove",
- key: "remove",
- });
-
return (
- <>
- {popup && }
- {
- const connector = connectorIndexingStatus.connector;
- // const credential = connectorIndexingStatus.credential;
- const hasValidCredentials =
- liveCredential &&
- connector.credential_ids.includes(liveCredential.id);
- const credential = connectorIncludesCredential
- ? {
- credential: hasValidCredentials ? (
-
- {getCredential(liveCredential)}
-
- ) : liveCredential ? (
- onCredentialLink(connector.id)}
- />
- ) : (
- N/A
- ),
- }
- : { credential: "" };
- return {
- name: connectorIndexingStatus.name || "",
- status: (
-
- ),
- remove: (
- {
- // for one-time, just disable the connector at deletion time
- // this is required before deletion can happen
- await updateConnector({
- ...connector,
- disabled: !connector.disabled,
- });
+
+ {popup}
- const deletionScheduleError =
- await scheduleDeletionJobForConnector(
- connector.id,
- connectorIndexingStatus.credential.id
- );
- if (deletionScheduleError) {
- setPopup({
- message:
- "Failed to schedule deletion of connector - " +
- deletionScheduleError,
- type: "error",
- });
- } else {
- setPopup({
- message: "Scheduled deletion of connector!",
- type: "success",
- });
- }
- setTimeout(() => {
- setPopup(null);
- }, 4000);
- onUpdate();
- }}
- >
-
-
- ),
- ...credential,
- ...(specialColumns
- ? Object.fromEntries(
- specialColumns.map(({ key, getValue }, i) => [
- key,
- getValue(connectorIndexingStatus),
- ])
- )
- : {}),
- };
- })}
- />
- >
+
+
+
+ {includeName && Name }
+ {specialColumns?.map(({ header }) => (
+ {header}
+ ))}
+ Status
+ {connectorIncludesCredential && (
+ Credential
+ )}
+ Remove
+
+
+
+ {connectorIndexingStatuses.map((connectorIndexingStatus) => {
+ const connector = connectorIndexingStatus.connector;
+ // const credential = connectorIndexingStatus.credential;
+ const hasValidCredentials =
+ liveCredential &&
+ connector.credential_ids.includes(liveCredential.id);
+ const credentialDisplay = connectorIncludesCredential ? (
+ hasValidCredentials ? (
+
+ {getCredential(liveCredential)}
+
+ ) : liveCredential ? (
+ onCredentialLink(connector.id)}
+ />
+ ) : (
+ N/A
+ )
+ ) : (
+ "-"
+ );
+ return (
+
+ {includeName && (
+
+
+ {connectorIndexingStatus.name}
+
+
+ )}
+ {specialColumns?.map(({ key, getValue }) => (
+
+ {getValue(connectorIndexingStatus)}
+
+ ))}
+
+
+
+ {connectorIncludesCredential && (
+ {credentialDisplay}
+ )}
+
+ {
+ // for one-time, just disable the connector at deletion time
+ // this is required before deletion can happen
+ await updateConnector({
+ ...connector,
+ disabled: !connector.disabled,
+ });
+
+ const deletionScheduleError =
+ await scheduleDeletionJobForConnector(
+ connector.id,
+ connectorIndexingStatus.credential.id
+ );
+ if (deletionScheduleError) {
+ setPopup({
+ message:
+ "Failed to schedule deletion of connector - " +
+ deletionScheduleError,
+ type: "error",
+ });
+ } else {
+ setPopup({
+ message: "Scheduled deletion of connector!",
+ type: "success",
+ });
+ }
+ setTimeout(() => {
+ setPopup(null);
+ }, 4000);
+ onUpdate();
+ }}
+ >
+
+
+
+
+ );
+ })}
+
+
+
);
}
diff --git a/web/src/components/icons/icons.tsx b/web/src/components/icons/icons.tsx
index 63f0d52493..327ea12dce 100644
--- a/web/src/components/icons/icons.tsx
+++ b/web/src/components/icons/icons.tsx
@@ -57,7 +57,8 @@ interface IconProps {
className?: string;
}
-export const defaultTailwindCSS = "my-auto flex flex-shrink-0 text-blue-400";
+export const defaultTailwindCSS = "my-auto flex flex-shrink-0 text-default";
+export const defaultTailwindCSSBlue = "my-auto flex flex-shrink-0 text-link";
export const PlugIcon = ({
size = 16,
@@ -131,14 +132,14 @@ export const XSquareIcon = ({
export const GlobeIcon = ({
size = 16,
- className = defaultTailwindCSS,
+ className = defaultTailwindCSSBlue,
}: IconProps) => {
return ;
};
export const FileIcon = ({
size = 16,
- className = defaultTailwindCSS,
+ className = defaultTailwindCSSBlue,
}: IconProps) => {
return ;
};
diff --git a/web/src/components/openai/ApiKeyForm.tsx b/web/src/components/openai/ApiKeyForm.tsx
index ff7cc9cab7..77da6ad724 100644
--- a/web/src/components/openai/ApiKeyForm.tsx
+++ b/web/src/components/openai/ApiKeyForm.tsx
@@ -4,6 +4,7 @@ import { useState } from "react";
import { TextFormField } from "../admin/connectors/Field";
import { GEN_AI_API_KEY_URL } from "./constants";
import { LoadingAnimation } from "../Loading";
+import { Button } from "@tremor/react";
interface Props {
handleResponse?: (response: Response) => void;
@@ -65,17 +66,13 @@ export const ApiKeyForm = ({ handleResponse }: Props) => {
label="OpenAI API Key:"
/>
-
Submit
-
+
)
diff --git a/web/src/components/openai/ApiKeyModal.tsx b/web/src/components/openai/ApiKeyModal.tsx
index f798414d1e..9d0ffbf272 100644
--- a/web/src/components/openai/ApiKeyModal.tsx
+++ b/web/src/components/openai/ApiKeyModal.tsx
@@ -2,6 +2,8 @@
import { useState, useEffect } from "react";
import { ApiKeyForm } from "./ApiKeyForm";
+import { Modal } from "../Modal";
+import { Text } from "@tremor/react";
export const ApiKeyModal = () => {
const [isOpen, setIsOpen] = useState(false);
@@ -17,39 +19,35 @@ export const ApiKeyModal = () => {
});
}, []);
+ if (!isOpen) {
+ return null;
+ }
+
return (
-
- {isOpen && (
-
setIsOpen(false)}
- >
-
event.stopPropagation()}
- >
-
- Can't find a valid registered OpenAI API key. Please provide
- one to be able to ask questions! Or if you'd rather just look
- around for now,{" "}
- setIsOpen(false)}
- className="text-blue-300 cursor-pointer"
- >
- skip this step
-
- .
-
-
{
- if (response.ok) {
- setIsOpen(false);
- }
- }}
- />
-
+
setIsOpen(false)}>
+
+
+
+ Can't find a valid registered OpenAI API key. Please provide
+ one to be able to ask questions! Or if you'd rather just look
+ around for now,{" "}
+ setIsOpen(false)}
+ className="text-link cursor-pointer"
+ >
+ skip this step
+
+ .
+
+
{
+ if (response.ok) {
+ setIsOpen(false);
+ }
+ }}
+ />
- )}
-
+
+
);
};
diff --git a/web/src/components/search/DateRangeSelector.tsx b/web/src/components/search/DateRangeSelector.tsx
index 99d67a9f32..4ec2c4448c 100644
--- a/web/src/components/search/DateRangeSelector.tsx
+++ b/web/src/components/search/DateRangeSelector.tsx
@@ -1,7 +1,7 @@
import { getXDaysAgo } from "@/lib/dateUtils";
import { DateRangePickerValue } from "@tremor/react";
import { FiCalendar, FiChevronDown, FiXCircle } from "react-icons/fi";
-import { CustomDropdown } from "../Dropdown";
+import { CustomDropdown, DefaultDropdownElement } from "../Dropdown";
function DateSelectorItem({
children,
@@ -17,12 +17,12 @@ function DateSelectorItem({
className={`
px-3
text-sm
- text-gray-200
- hover:bg-dark-tremor-background-muted
+ bg-background
+ hover:bg-hover
py-2.5
select-none
cursor-pointer
- ${skipBottomBorder ? "" : "border-b border-gray-800"}
+ ${skipBottomBorder ? "" : "border-b border-border"}
`}
onClick={onClick}
>
@@ -31,6 +31,10 @@ function DateSelectorItem({
);
}
+export const LAST_30_DAYS = "Last 30 days";
+export const LAST_7_DAYS = "Last 7 days";
+export const TODAY = "Today";
+
export function DateRangeSelector({
value,
onValueChange,
@@ -42,66 +46,82 @@ export function DateRangeSelector({
-
+
+
onValueChange({
to: new Date(),
from: getXDaysAgo(30),
- selectValue: "Last 30 days",
+ selectValue: LAST_30_DAYS,
})
}
- >
- Last 30 days
-
-
+ isSelected={value?.selectValue === LAST_30_DAYS}
+ />
+
+
onValueChange({
to: new Date(),
from: getXDaysAgo(7),
- selectValue: "Last 7 days",
+ selectValue: LAST_7_DAYS,
})
}
- >
- Last 7 days
-
-
+ isSelected={value?.selectValue === LAST_7_DAYS}
+ />
+
+
onValueChange({
to: new Date(),
from: getXDaysAgo(1),
- selectValue: "Today",
+ selectValue: TODAY,
})
}
- skipBottomBorder={true}
- >
- Today
-
+ isSelected={value?.selectValue === TODAY}
+ />
}
>
-
{" "}
+
{" "}
{value?.selectValue ? (
-
{value.selectValue}
+
{value.selectValue}
) : (
"Any time..."
)}
{value?.selectValue ? (
{
onValueChange(null);
e.stopPropagation();
diff --git a/web/src/components/search/DocumentDisplay.tsx b/web/src/components/search/DocumentDisplay.tsx
index f2fb713823..4abf027654 100644
--- a/web/src/components/search/DocumentDisplay.tsx
+++ b/web/src/components/search/DocumentDisplay.tsx
@@ -69,7 +69,7 @@ export const buildDocumentSummaryDisplay = (
finalJSX[finalJSX.length - 1] = finalJSX[finalJSX.length - 1] + " ";
}
finalJSX.push(
-
+
{currentText}
);
@@ -93,7 +93,7 @@ export const buildDocumentSummaryDisplay = (
finalJSX[finalJSX.length - 1] = finalJSX[finalJSX.length - 1] + " ";
}
finalJSX.push(
-
+
{currentText}
);
@@ -127,7 +127,7 @@ export const DocumentDisplay = ({
return (
{
setIsHovered(true);
}}
@@ -163,8 +163,8 @@ export const DocumentDisplay = ({
)}
-
+
{buildDocumentSummaryDisplay(document.match_highlights, document.blurb)}
diff --git a/web/src/components/search/DocumentUpdatedAtBadge.tsx b/web/src/components/search/DocumentUpdatedAtBadge.tsx
index 83973fe6bc..b6c7adf93c 100644
--- a/web/src/components/search/DocumentUpdatedAtBadge.tsx
+++ b/web/src/components/search/DocumentUpdatedAtBadge.tsx
@@ -6,8 +6,9 @@ export function DocumentUpdatedAtBadge({ updatedAt }: { updatedAt: string }) {
void;
- isSelected: boolean;
- isFinal: boolean;
-}) {
- return (
-
{
- onSelect(id);
- }}
- >
- {name}
- {isSelected && (
-
-
-
- )}
-
- );
-}
+import { CustomDropdown, DefaultDropdownElement } from "../Dropdown";
+import { FiChevronDown } from "react-icons/fi";
export function PersonaSelector({
personas,
@@ -54,8 +8,8 @@ export function PersonaSelector({
onPersonaChange,
}: {
personas: Persona[];
- selectedPersonaId: number | null;
- onPersonaChange: (persona: Persona | null) => void;
+ selectedPersonaId: number;
+ onPersonaChange: (persona: Persona) => void;
}) {
const currentlySelectedPersona = personas.find(
(persona) => persona.id === selectedPersonaId
@@ -67,7 +21,8 @@ export function PersonaSelector({
-
{
- onPersonaChange(null);
- }}
- isSelected={selectedPersonaId === null}
- isFinal={false}
- />
{personas.map((persona, ind) => {
const isSelected = persona.id === selectedPersonaId;
return (
- {
- const clickedPersona = personas.find(
- (persona) => persona.id === clickedPersonaId
- );
- if (clickedPersona) {
- onPersonaChange(clickedPersona);
- }
- }}
+ onSelect={() => onPersonaChange(persona)}
isSelected={isSelected}
- isFinal={ind === personas.length - 1}
/>
);
})}
}
>
-
-
- {currentlySelectedPersona?.name || "Default"}{" "}
+
+ {currentlySelectedPersona?.name || "Default"}
diff --git a/web/src/components/search/SearchBar.tsx b/web/src/components/search/SearchBar.tsx
index 78ac5966cd..7494ad9cae 100644
--- a/web/src/components/search/SearchBar.tsx
+++ b/web/src/components/search/SearchBar.tsx
@@ -27,11 +27,11 @@ export const SearchBar = ({ query, setQuery, onSearch }: SearchBarProps) => {
return (