Folder support

This commit is contained in:
Weves
2024-05-10 11:59:55 -07:00
committed by Chris Weaver
parent eb1b604b8c
commit 2e0be9f2da
15 changed files with 645 additions and 149 deletions

View File

@@ -16,13 +16,13 @@ from danswer.db.folder import rename_folder
from danswer.db.folder import update_folder_display_priority from danswer.db.folder import update_folder_display_priority
from danswer.db.models import User from danswer.db.models import User
from danswer.server.features.folder.models import DeleteFolderOptions from danswer.server.features.folder.models import DeleteFolderOptions
from danswer.server.features.folder.models import FolderChatMinimalInfo
from danswer.server.features.folder.models import FolderChatSessionRequest from danswer.server.features.folder.models import FolderChatSessionRequest
from danswer.server.features.folder.models import FolderCreationRequest from danswer.server.features.folder.models import FolderCreationRequest
from danswer.server.features.folder.models import FolderResponse from danswer.server.features.folder.models import FolderResponse
from danswer.server.features.folder.models import FolderUpdateRequest from danswer.server.features.folder.models import FolderUpdateRequest
from danswer.server.features.folder.models import GetUserFoldersResponse from danswer.server.features.folder.models import GetUserFoldersResponse
from danswer.server.models import DisplayPriorityRequest from danswer.server.models import DisplayPriorityRequest
from danswer.server.query_and_chat.models import ChatSessionDetails
router = APIRouter(prefix="/folder") router = APIRouter(prefix="/folder")
@@ -44,11 +44,16 @@ def get_folders(
folder_name=folder.name, folder_name=folder.name,
display_priority=folder.display_priority, display_priority=folder.display_priority,
chat_sessions=[ chat_sessions=[
FolderChatMinimalInfo( ChatSessionDetails(
chat_session_id=chat_session.id, id=chat_session.id,
chat_session_name=chat_session.description, name=chat_session.description,
persona_id=chat_session.persona_id,
time_created=chat_session.time_created.isoformat(),
shared_status=chat_session.shared_status,
folder_id=folder.id,
) )
for chat_session in folder.chat_sessions for chat_session in folder.chat_sessions
if not chat_session.deleted
], ],
) )
for folder in folders for folder in folders

View File

@@ -1,16 +1,13 @@
from pydantic import BaseModel from pydantic import BaseModel
from danswer.server.query_and_chat.models import ChatSessionDetails
class FolderChatMinimalInfo(BaseModel):
chat_session_id: int
chat_session_name: str
class FolderResponse(BaseModel): class FolderResponse(BaseModel):
folder_id: int folder_id: int
folder_name: str | None folder_name: str | None
display_priority: int display_priority: int
chat_sessions: list[FolderChatMinimalInfo] chat_sessions: list[ChatSessionDetails]
class GetUserFoldersResponse(BaseModel): class GetUserFoldersResponse(BaseModel):

View File

@@ -81,6 +81,7 @@ def get_user_chat_sessions(
persona_id=chat.persona_id, persona_id=chat.persona_id,
time_created=chat.time_created.isoformat(), time_created=chat.time_created.isoformat(),
shared_status=chat.shared_status, shared_status=chat.shared_status,
folder_id=chat.folder_id,
) )
for chat in chat_sessions for chat in chat_sessions
] ]

View File

@@ -142,6 +142,7 @@ class ChatSessionDetails(BaseModel):
persona_id: int persona_id: int
time_created: str time_created: str
shared_status: ChatSessionSharedStatus shared_status: ChatSessionSharedStatus
folder_id: int | None
class ChatSessionsResponse(BaseModel): class ChatSessionsResponse(BaseModel):

View File

@@ -62,6 +62,7 @@ import Dropzone from "react-dropzone";
import { LLMProviderDescriptor } from "../admin/models/llm/interfaces"; import { LLMProviderDescriptor } from "../admin/models/llm/interfaces";
import { checkLLMSupportsImageInput, getFinalLLM } from "@/lib/llm/utils"; import { checkLLMSupportsImageInput, getFinalLLM } from "@/lib/llm/utils";
import { InputBarPreviewImage } from "./images/InputBarPreviewImage"; import { InputBarPreviewImage } from "./images/InputBarPreviewImage";
import { Folder } from "./folders/interfaces";
const MAX_INPUT_HEIGHT = 200; const MAX_INPUT_HEIGHT = 200;
@@ -76,6 +77,8 @@ export function ChatPage({
defaultSelectedPersonaId, defaultSelectedPersonaId,
documentSidebarInitialWidth, documentSidebarInitialWidth,
defaultSidebarTab, defaultSidebarTab,
folders,
openedFolders,
}: { }: {
user: User | null; user: User | null;
chatSessions: ChatSession[]; chatSessions: ChatSession[];
@@ -87,6 +90,8 @@ export function ChatPage({
defaultSelectedPersonaId?: number; // what persona to default to defaultSelectedPersonaId?: number; // what persona to default to
documentSidebarInitialWidth?: number; documentSidebarInitialWidth?: number;
defaultSidebarTab?: Tabs; defaultSidebarTab?: Tabs;
folders: Folder[];
openedFolders: { [key: number]: boolean };
}) { }) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -642,6 +647,8 @@ export function ChatPage({
onPersonaChange={onPersonaChange} onPersonaChange={onPersonaChange}
user={user} user={user}
defaultTab={defaultSidebarTab} defaultTab={defaultSidebarTab}
folders={folders}
openedFolders={openedFolders}
/> />
<div className="flex w-full overflow-x-hidden" ref={masterFlexboxRef}> <div className="flex w-full overflow-x-hidden" ref={masterFlexboxRef}>

View File

@@ -0,0 +1,244 @@
"use client";
import React, { useState, useEffect } from "react";
import { Folder } from "./interfaces";
import { ChatSessionDisplay } from "../sessionSidebar/ChatSessionDisplay"; // Ensure this is correctly imported
import {
FiChevronDown,
FiChevronRight,
FiFolder,
FiEdit,
FiCheck,
FiX,
FiTrash, // Import the trash icon
} from "react-icons/fi";
import { BasicSelectable } from "@/components/BasicClickable";
import {
addChatToFolder,
deleteFolder,
updateFolderName,
} from "./FolderManagement";
import { usePopup } from "@/components/admin/connectors/Popup";
import { useRouter } from "next/navigation";
import { CHAT_SESSION_ID_KEY } from "@/lib/drag/constants";
import Cookies from "js-cookie";
const FolderItem = ({
folder,
currentChatId,
isInitiallyExpanded,
}: {
folder: Folder;
currentChatId?: number;
isInitiallyExpanded: boolean;
}) => {
const [isExpanded, setIsExpanded] = useState<boolean>(isInitiallyExpanded);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [editedFolderName, setEditedFolderName] = useState<string>(
folder.folder_name
);
const [isHovering, setIsHovering] = useState<boolean>(false);
const [isDragOver, setIsDragOver] = useState<boolean>(false);
const { setPopup } = usePopup();
const router = useRouter();
const toggleFolderExpansion = () => {
if (!isEditing) {
const newIsExpanded = !isExpanded;
setIsExpanded(newIsExpanded);
// Update the cookie with the new state
const openedFoldersCookieVal = Cookies.get("openedFolders");
const openedFolders = openedFoldersCookieVal
? JSON.parse(openedFoldersCookieVal)
: {};
if (newIsExpanded) {
openedFolders[folder.folder_id] = true;
} else {
delete openedFolders[folder.folder_id];
}
Cookies.set("openedFolders", JSON.stringify(openedFolders));
}
};
const handleEditFolderName = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation(); // Prevent the event from bubbling up to the toggle expansion
setIsEditing(true);
};
const handleFolderNameChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setEditedFolderName(event.target.value);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
saveFolderName();
}
};
const saveFolderName = async () => {
try {
await updateFolderName(folder.folder_id, editedFolderName);
setIsEditing(false);
router.refresh(); // Refresh values to update the sidebar
} catch (error) {
setPopup({ message: "Failed to save folder name", type: "error" });
}
};
const deleteFolderHandler = async (
event: React.MouseEvent<HTMLDivElement>
) => {
event.stopPropagation(); // Prevent the event from bubbling up to the toggle expansion
try {
await deleteFolder(folder.folder_id);
router.refresh(); // Refresh values to update the sidebar
} catch (error) {
setPopup({ message: "Failed to delete folder", type: "error" });
}
};
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragOver(false);
const chatSessionId = parseInt(
event.dataTransfer.getData(CHAT_SESSION_ID_KEY),
10
);
try {
await addChatToFolder(folder.folder_id, chatSessionId);
router.refresh(); // Refresh to show the updated folder contents
} catch (error) {
setPopup({
message: "Failed to add chat session to folder",
type: "error",
});
}
};
const folders = folder.chat_sessions.sort((a, b) => {
return a.time_created.localeCompare(b.time_created);
});
return (
<div
key={folder.folder_id}
onDragOver={(event) => {
event.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
className={`transition duration-300 ease-in-out rounded-md ${
isDragOver ? "bg-hover" : ""
}`}
>
<BasicSelectable fullWidth selected={false}>
<div
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<div onClick={toggleFolderExpansion} className="cursor-pointer">
<div className="text-sm text-medium flex items-center justify-start w-full">
<div className="mr-2">
{isExpanded ? (
<FiChevronDown size={16} />
) : (
<FiChevronRight size={16} />
)}
</div>
<div>
<FiFolder size={16} className="mr-2" />
</div>
{isEditing ? (
<input
type="text"
value={editedFolderName}
onChange={handleFolderNameChange}
onKeyDown={handleKeyDown}
className="text-sm px-1 flex-1 min-w-0 -my-px mr-2"
/>
) : (
<div className="flex-1 min-w-0">
{editedFolderName || folder.folder_name}
</div>
)}
{isHovering && !isEditing && (
<div className="flex ml-auto my-auto">
<div
onClick={handleEditFolderName}
className="hover:bg-black/10 p-1 -m-1 rounded"
>
<FiEdit size={16} />
</div>
<div
onClick={deleteFolderHandler}
className="hover:bg-black/10 p-1 -m-1 rounded ml-2"
>
<FiTrash size={16} />
</div>
</div>
)}
{isEditing && (
<div className="flex ml-auto my-auto">
<div
onClick={saveFolderName}
className="hover:bg-black/10 p-1 -m-1 rounded"
>
<FiCheck size={16} />
</div>
<div
onClick={() => setIsEditing(false)}
className="hover:bg-black/10 p-1 -m-1 rounded ml-2"
>
<FiX size={16} />
</div>
</div>
)}
</div>
</div>
</div>
</BasicSelectable>
{isExpanded && folders && (
<div className={"ml-2 pl-2 border-l border-border"}>
{folders.map((chatSession) => (
<ChatSessionDisplay
key={chatSession.id}
chatSession={chatSession}
isSelected={chatSession.id === currentChatId}
skipGradient={isDragOver}
/>
))}
</div>
)}
</div>
);
};
export const FolderList = ({
folders,
currentChatId,
openedFolders,
}: {
folders: Folder[];
currentChatId?: number;
openedFolders: { [key: number]: boolean };
}) => {
if (folders.length === 0) {
return null;
}
return (
<div className="mt-1 pb-1 mb-1 overflow-y-auto">
{folders.map((folder) => (
<FolderItem
key={folder.folder_id}
folder={folder}
currentChatId={currentChatId}
isInitiallyExpanded={openedFolders[folder.folder_id] || false}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,82 @@
import { useState, useEffect, FC } from "react";
// Function to create a new folder
export async function createFolder(folderName: string): Promise<number> {
const response = await fetch("/api/folder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ folder_name: folderName }),
});
if (!response.ok) {
throw new Error("Failed to create folder");
}
const data = await response.json();
return data.folder_id;
}
// Function to add a chat session to a folder
export async function addChatToFolder(
folderId: number,
chatSessionId: number
): Promise<void> {
const response = await fetch(`/api/folder/${folderId}/add-chat-session`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ chat_session_id: chatSessionId }),
});
if (!response.ok) {
throw new Error("Failed to add chat to folder");
}
}
// Function to remove a chat session from a folder
export async function removeChatFromFolder(
folderId: number,
chatSessionId: number
): Promise<void> {
const response = await fetch(`/api/folder/${folderId}/remove-chat-session`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ chat_session_id: chatSessionId }),
});
if (!response.ok) {
throw new Error("Failed to remove chat from folder");
}
}
// Function to delete a folder
export async function deleteFolder(folderId: number): Promise<void> {
const response = await fetch(`/api/folder/${folderId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
if (!response.ok) {
throw new Error("Failed to delete folder");
}
}
// Function to update a folder's name
export async function updateFolderName(
folderId: number,
newName: string
): Promise<void> {
const response = await fetch(`/api/folder/${folderId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ folder_name: newName }),
});
if (!response.ok) {
throw new Error("Failed to update folder name");
}
}

View File

@@ -0,0 +1,8 @@
import { ChatSession } from "../interfaces";
export interface Folder {
folder_id: number;
folder_name: string;
display_priority: number;
chat_sessions: ChatSession[];
}

View File

@@ -31,6 +31,7 @@ export interface ChatSession {
persona_id: number; persona_id: number;
time_created: string; time_created: string;
shared_status: ChatSessionSharedStatus; shared_status: ChatSessionSharedStatus;
folder_id: number | null;
} }
export interface Message { export interface Message {

View File

@@ -31,6 +31,7 @@ import { Settings } from "../admin/settings/interfaces";
import { SIDEBAR_TAB_COOKIE, Tabs } from "./sessionSidebar/constants"; import { SIDEBAR_TAB_COOKIE, Tabs } from "./sessionSidebar/constants";
import { fetchLLMProvidersSS } from "@/lib/llm/fetchLLMs"; import { fetchLLMProvidersSS } from "@/lib/llm/fetchLLMs";
import { LLMProviderDescriptor } from "../admin/models/llm/interfaces"; import { LLMProviderDescriptor } from "../admin/models/llm/interfaces";
import { Folder } from "./folders/interfaces";
export default async function Page({ export default async function Page({
searchParams, searchParams,
@@ -48,6 +49,7 @@ export default async function Page({
fetchSS("/chat/get-user-chat-sessions"), fetchSS("/chat/get-user-chat-sessions"),
fetchSS("/query/valid-tags"), fetchSS("/query/valid-tags"),
fetchLLMProvidersSS(), fetchLLMProvidersSS(),
fetchSS("/folder"),
]; ];
// catch cases where the backend is completely unreachable here // catch cases where the backend is completely unreachable here
@@ -75,6 +77,7 @@ export default async function Page({
const chatSessionsResponse = results[5] as Response | null; const chatSessionsResponse = results[5] as Response | null;
const tagsResponse = results[6] as Response | null; const tagsResponse = results[6] as Response | null;
const llmProviders = (results[7] || []) as LLMProviderDescriptor[]; const llmProviders = (results[7] || []) as LLMProviderDescriptor[];
const foldersResponse = results[8] as Response | null; // Handle folders result
const authDisabled = authTypeMetadata?.authType === "disabled"; const authDisabled = authTypeMetadata?.authType === "disabled";
if (!authDisabled && !user) { if (!authDisabled && !user) {
@@ -170,6 +173,18 @@ export default async function Page({
personas = personas.filter((persona) => persona.num_chunks === 0); personas = personas.filter((persona) => persona.num_chunks === 0);
} }
let folders: Folder[] = [];
if (foldersResponse?.ok) {
folders = (await foldersResponse.json()).folders as Folder[];
} else {
console.log(`Failed to fetch folders - ${foldersResponse?.status}`);
}
const openedFoldersCookie = cookies().get("openedFolders");
const openedFolders = openedFoldersCookie
? JSON.parse(openedFoldersCookie.value)
: {};
return ( return (
<> <>
<InstantSSRAutoRefresh /> <InstantSSRAutoRefresh />
@@ -193,6 +208,8 @@ export default async function Page({
defaultSelectedPersonaId={defaultPersonaId} defaultSelectedPersonaId={defaultPersonaId}
documentSidebarInitialWidth={finalDocumentSidebarInitialWidth} documentSidebarInitialWidth={finalDocumentSidebarInitialWidth}
defaultSidebarTab={defaultSidebarTab} defaultSidebarTab={defaultSidebarTab}
folders={folders} // Pass folders to ChatPage
openedFolders={openedFolders} // Pass opened folders state to ChatPage
/> />
</> </>
); );

View File

@@ -2,7 +2,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ChatSession } from "../interfaces"; import { ChatSession } from "../interfaces";
import { useState } from "react"; import { useState, useEffect } from "react";
import { deleteChatSession, renameChatSession } from "../lib"; import { deleteChatSession, renameChatSession } from "../lib";
import { DeleteChatModal } from "../modal/DeleteChatModal"; import { DeleteChatModal } from "../modal/DeleteChatModal";
import { BasicSelectable } from "@/components/BasicClickable"; import { BasicSelectable } from "@/components/BasicClickable";
@@ -19,16 +19,19 @@ import {
import { DefaultDropdownElement } from "@/components/Dropdown"; import { DefaultDropdownElement } from "@/components/Dropdown";
import { Popover } from "@/components/popover/Popover"; import { Popover } from "@/components/popover/Popover";
import { ShareChatSessionModal } from "../modal/ShareChatSessionModal"; import { ShareChatSessionModal } from "../modal/ShareChatSessionModal";
import { CHAT_SESSION_ID_KEY, FOLDER_ID_KEY } from "@/lib/drag/constants";
interface ChatSessionDisplayProps {
chatSession: ChatSession;
isSelected: boolean;
}
export function ChatSessionDisplay({ export function ChatSessionDisplay({
chatSession, chatSession,
isSelected, isSelected,
}: ChatSessionDisplayProps) { skipGradient,
}: {
chatSession: ChatSession;
isSelected: boolean;
// needed when the parent is trying to apply some background effect
// if not set, the gradient will still be applied and cause weirdness
skipGradient?: boolean;
}) {
const router = useRouter(); const router = useRouter();
const [isDeletionModalVisible, setIsDeletionModalVisible] = useState(false); const [isDeletionModalVisible, setIsDeletionModalVisible] = useState(false);
const [isRenamingChat, setIsRenamingChat] = useState(false); const [isRenamingChat, setIsRenamingChat] = useState(false);
@@ -36,6 +39,18 @@ export function ChatSessionDisplay({
useState(false); useState(false);
const [isShareModalVisible, setIsShareModalVisible] = useState(false); const [isShareModalVisible, setIsShareModalVisible] = useState(false);
const [chatName, setChatName] = useState(chatSession.name); const [chatName, setChatName] = useState(chatSession.name);
const [delayedSkipGradient, setDelayedSkipGradient] = useState(skipGradient);
useEffect(() => {
if (skipGradient) {
setDelayedSkipGradient(true);
} else {
const timer = setTimeout(() => {
setDelayedSkipGradient(skipGradient);
}, 300);
return () => clearTimeout(timer);
}
}, [skipGradient]);
const onRename = async () => { const onRename = async () => {
const response = await renameChatSession(chatSession.id, chatName); const response = await renameChatSession(chatSession.id, chatName);
@@ -78,13 +93,24 @@ export function ChatSessionDisplay({
key={chatSession.id} key={chatSession.id}
href={`/chat?chatId=${chatSession.id}`} href={`/chat?chatId=${chatSession.id}`}
scroll={false} scroll={false}
draggable="true"
onDragStart={(event) => {
event.dataTransfer.setData(
CHAT_SESSION_ID_KEY,
chatSession.id.toString()
);
event.dataTransfer.setData(
FOLDER_ID_KEY,
chatSession.folder_id?.toString() || ""
);
}}
> >
<BasicSelectable fullWidth selected={isSelected}> <BasicSelectable fullWidth selected={isSelected}>
<> <>
<div className="flex relative"> <div className="flex relative">
<div className="my-auto mr-2"> <div className="my-auto mr-2">
<FiMessageSquare size={16} /> <FiMessageSquare size={16} />
</div>{" "} </div>
{isRenamingChat ? ( {isRenamingChat ? (
<input <input
value={chatName} value={chatName}
@@ -157,6 +183,7 @@ export function ChatSessionDisplay({
</div> </div>
} }
requiresContentPadding requiresContentPadding
sideOffset={6}
/> />
</div> </div>
</div> </div>
@@ -169,10 +196,10 @@ export function ChatSessionDisplay({
</div> </div>
))} ))}
</div> </div>
{isSelected && !isRenamingChat && ( {isSelected && !isRenamingChat && !delayedSkipGradient && (
<div className="absolute bottom-0 right-0 top-0 bg-gradient-to-l to-transparent from-hover w-20 from-60% rounded" /> <div className="absolute bottom-0 right-0 top-0 bg-gradient-to-l to-transparent from-hover w-20 from-60% rounded" />
)} )}
{!isSelected && ( {!isSelected && !delayedSkipGradient && (
<div className="absolute bottom-0 right-0 top-0 bg-gradient-to-l to-transparent from-background w-8 from-0% rounded" /> <div className="absolute bottom-0 right-0 top-0 bg-gradient-to-l to-transparent from-background w-8 from-0% rounded" />
)} )}
</> </>

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { import {
FiFolderPlus,
FiLogOut, FiLogOut,
FiMessageSquare, FiMessageSquare,
FiMoreHorizontal, FiMoreHorizontal,
@@ -14,9 +15,7 @@ import { useRouter } from "next/navigation";
import { User } from "@/lib/types"; import { User } from "@/lib/types";
import { logout } from "@/lib/user"; import { logout } from "@/lib/user";
import { BasicClickable, BasicSelectable } from "@/components/BasicClickable"; import { BasicClickable, BasicSelectable } from "@/components/BasicClickable";
import { ChatSessionDisplay } from "./SessionDisplay";
import { ChatSession } from "../interfaces"; import { ChatSession } from "../interfaces";
import { groupSessionsByDateRange } from "../lib";
import { import {
HEADER_PADDING, HEADER_PADDING,
NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA, NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA,
@@ -26,6 +25,9 @@ import { AssistantsTab } from "./AssistantsTab";
import { Persona } from "@/app/admin/assistants/interfaces"; import { Persona } from "@/app/admin/assistants/interfaces";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { SIDEBAR_TAB_COOKIE, Tabs } from "./constants"; import { SIDEBAR_TAB_COOKIE, Tabs } from "./constants";
import { Folder } from "../folders/interfaces";
import { createFolder } from "../folders/FolderManagement";
import { usePopup } from "@/components/admin/connectors/Popup";
export const ChatSidebar = ({ export const ChatSidebar = ({
existingChats, existingChats,
@@ -34,6 +36,8 @@ export const ChatSidebar = ({
onPersonaChange, onPersonaChange,
user, user,
defaultTab, defaultTab,
folders,
openedFolders,
}: { }: {
existingChats: ChatSession[]; existingChats: ChatSession[];
currentChatSession: ChatSession | null | undefined; currentChatSession: ChatSession | null | undefined;
@@ -41,8 +45,11 @@ export const ChatSidebar = ({
onPersonaChange: (persona: Persona | null) => void; onPersonaChange: (persona: Persona | null) => void;
user: User | null; user: User | null;
defaultTab?: Tabs; defaultTab?: Tabs;
folders: Folder[];
openedFolders: { [key: number]: boolean };
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { popup, setPopup } = usePopup();
const [openTab, _setOpenTab] = useState(defaultTab || Tabs.CHATS); const [openTab, _setOpenTab] = useState(defaultTab || Tabs.CHATS);
const setOpenTab = (tab: Tabs) => { const setOpenTab = (tab: Tabs) => {
@@ -105,8 +112,10 @@ export const ChatSidebar = ({
}, [currentChatId]); }, [currentChatId]);
return ( return (
<div <>
className={` {popup}
<div
className={`
flex-none flex-none
w-64 w-64
3xl:w-72 3xl:w-72
@@ -117,117 +126,148 @@ export const ChatSidebar = ({
flex-col flex-col
h-screen h-screen
transition-transform`} transition-transform`}
id="chat-sidebar" id="chat-sidebar"
>
<div className="flex w-full mx-4 mt-4 text-sm gap-x-4 pb-2 border-b border-border">
<TabOption tab={Tabs.CHATS} />
<TabOption tab={Tabs.ASSISTANTS} />
</div>
{openTab == Tabs.CHATS && (
<>
<Link
href={
"/chat" +
(NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA &&
currentChatSession
? `?assistantId=${currentChatSession.persona_id}`
: "")
}
className="mx-3 mt-5"
>
<BasicClickable fullWidth>
<div className="flex text-sm">
<FiPlusSquare className="my-auto mr-2" /> New Chat
</div>
</BasicClickable>
</Link>
<ChatTab
existingChats={existingChats}
currentChatId={currentChatId}
/>
</>
)}
{openTab == Tabs.ASSISTANTS && (
<>
<Link href="/assistants/new" className="mx-3 mt-5">
<BasicClickable fullWidth>
<div className="flex text-sm">
<FiPlusSquare className="my-auto mr-2" /> New Assistant
</div>
</BasicClickable>
</Link>
<AssistantsTab
personas={personas}
onPersonaChange={onPersonaChange}
user={user}
/>
</>
)}
<div
className="mt-auto py-2 border-t border-border px-3"
ref={userInfoRef}
> >
<div className="relative text-strong"> <div className="flex w-full px-3 mt-4 text-sm ">
{userInfoVisible && ( <div className="flex w-full gap-x-4 pb-2 border-b border-border">
<div <TabOption tab={Tabs.CHATS} />
className={ <TabOption tab={Tabs.ASSISTANTS} />
(user ? "translate-y-[-110%]" : "translate-y-[-115%]") + </div>
" absolute top-0 bg-background border border-border z-30 w-full rounded text-strong text-sm" </div>
}
> {openTab == Tabs.CHATS && (
<>
<div className="flex mt-5 items-center">
<Link <Link
href="/search" href={
className="flex py-3 px-4 cursor-pointer hover:bg-hover" "/chat" +
(NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA &&
currentChatSession
? `?assistantId=${currentChatSession.persona_id}`
: "")
}
className="ml-3 w-full"
> >
<FiSearch className="my-auto mr-2" /> <BasicClickable fullWidth>
Danswer Search <div className="flex items-center text-sm">
<FiPlusSquare className="mr-2" /> New Chat
</div>
</BasicClickable>
</Link> </Link>
<Link
href="/chat" <div className="ml-1.5 mr-3 h-full">
className="flex py-3 px-4 cursor-pointer hover:bg-hover" <BasicClickable
> onClick={() =>
<FiMessageSquare className="my-auto mr-2" /> createFolder("New Folder")
Danswer Chat .then((folderId) => {
</Link> console.log(`Folder created with ID: ${folderId}`);
{(!user || user.role === "admin") && ( router.refresh();
<Link })
href="/admin/indexing/status" .catch((error) => {
className="flex py-3 px-4 cursor-pointer border-t border-border hover:bg-hover" console.error("Failed to create folder:", error);
setPopup({
message: `Failed to create folder: ${error.message}`,
type: "error",
});
})
}
> >
<FiTool className="my-auto mr-2" /> <div className="flex items-center text-sm h-full">
Admin Panel <FiFolderPlus className="mx-1 my-auto" />
</Link> </div>
)} </BasicClickable>
{user && (
<div
onClick={handleLogout}
className="flex py-3 px-4 cursor-pointer border-t border-border rounded hover:bg-hover"
>
<FiLogOut className="my-auto mr-2" />
Log out
</div>
)}
</div>
)}
<BasicSelectable fullWidth selected={false}>
<div
onClick={() => setUserInfoVisible(!userInfoVisible)}
className="flex h-8"
>
<div className="my-auto mr-2 bg-user rounded-lg px-1.5">
{user && user.email ? user.email[0].toUpperCase() : "A"}
</div> </div>
<p className="my-auto">
{user ? user.email : "Anonymous Possum"}
</p>
<FiMoreHorizontal className="my-auto ml-auto mr-2" size={20} />
</div> </div>
</BasicSelectable>
<ChatTab
existingChats={existingChats}
currentChatId={currentChatId}
folders={folders}
openedFolders={openedFolders}
/>
</>
)}
{openTab == Tabs.ASSISTANTS && (
<>
<Link href="/assistants/new" className="mx-3 mt-5">
<BasicClickable fullWidth>
<div className="flex text-sm">
<FiPlusSquare className="my-auto mr-2" /> New Assistant
</div>
</BasicClickable>
</Link>
<AssistantsTab
personas={personas}
onPersonaChange={onPersonaChange}
user={user}
/>
</>
)}
<div
className="mt-auto py-2 border-t border-border px-3"
ref={userInfoRef}
>
<div className="relative text-strong">
{userInfoVisible && (
<div
className={
(user ? "translate-y-[-110%]" : "translate-y-[-115%]") +
" absolute top-0 bg-background border border-border z-30 w-full rounded text-strong text-sm"
}
>
<Link
href="/search"
className="flex py-3 px-4 cursor-pointer hover:bg-hover"
>
<FiSearch className="my-auto mr-2" />
Danswer Search
</Link>
<Link
href="/chat"
className="flex py-3 px-4 cursor-pointer hover:bg-hover"
>
<FiMessageSquare className="my-auto mr-2" />
Danswer Chat
</Link>
{(!user || user.role === "admin") && (
<Link
href="/admin/indexing/status"
className="flex py-3 px-4 cursor-pointer border-t border-border hover:bg-hover"
>
<FiTool className="my-auto mr-2" />
Admin Panel
</Link>
)}
{user && (
<div
onClick={handleLogout}
className="flex py-3 px-4 cursor-pointer border-t border-border rounded hover:bg-hover"
>
<FiLogOut className="my-auto mr-2" />
Log out
</div>
)}
</div>
)}
<BasicSelectable fullWidth selected={false}>
<div
onClick={() => setUserInfoVisible(!userInfoVisible)}
className="flex h-8"
>
<div className="my-auto mr-2 bg-user rounded-lg px-1.5">
{user && user.email ? user.email[0].toUpperCase() : "A"}
</div>
<p className="my-auto">
{user ? user.email : "Anonymous Possum"}
</p>
<FiMoreHorizontal className="my-auto ml-auto mr-2" size={20} />
</div>
</BasicSelectable>
</div>
</div> </div>
</div> </div>
</div> </>
); );
}; };

View File

@@ -1,40 +1,103 @@
import { ChatSession } from "../interfaces"; import { ChatSession } from "../interfaces";
import { groupSessionsByDateRange } from "../lib"; import { groupSessionsByDateRange } from "../lib";
import { ChatSessionDisplay } from "./SessionDisplay"; import { ChatSessionDisplay } from "./ChatSessionDisplay";
import { removeChatFromFolder } from "../folders/FolderManagement";
import { FolderList } from "../folders/FolderList";
import { Folder } from "../folders/interfaces";
import { CHAT_SESSION_ID_KEY, FOLDER_ID_KEY } from "@/lib/drag/constants";
import { usePopup } from "@/components/admin/connectors/Popup";
import { useRouter } from "next/navigation";
import { useState } from "react";
export function ChatTab({ export function ChatTab({
existingChats, existingChats,
currentChatId, currentChatId,
folders,
openedFolders,
}: { }: {
existingChats: ChatSession[]; existingChats: ChatSession[];
currentChatId?: number; currentChatId?: number;
folders: Folder[];
openedFolders: { [key: number]: boolean };
}) { }) {
const groupedChatSessions = groupSessionsByDateRange(existingChats); const groupedChatSessions = groupSessionsByDateRange(existingChats);
const { setPopup } = usePopup();
const router = useRouter();
const [isDragOver, setIsDragOver] = useState<boolean>(false);
const handleDropToRemoveFromFolder = async (
event: React.DragEvent<HTMLDivElement>
) => {
event.preventDefault();
setIsDragOver(false); // Reset drag over state on drop
const chatSessionId = parseInt(
event.dataTransfer.getData(CHAT_SESSION_ID_KEY),
10
);
const folderId = event.dataTransfer.getData(FOLDER_ID_KEY);
if (folderId) {
try {
await removeChatFromFolder(parseInt(folderId, 10), chatSessionId);
router.refresh(); // Refresh the page to reflect the changes
} catch (error) {
setPopup({
message: "Failed to remove chat from folder",
type: "error",
});
}
}
};
return ( return (
<div className="mt-1 pb-1 mb-1 ml-3 overflow-y-auto h-full"> <div className="mt-4 mb-1 ml-3 overflow-y-auto h-full">
{Object.entries(groupedChatSessions).map(([dateRange, chatSessions]) => { <div className="border-b border-border pb-1 mr-3">
if (chatSessions.length > 0) { <FolderList
return ( folders={folders}
<div key={dateRange}> currentChatId={currentChatId}
<div className="text-xs text-subtle flex pb-0.5 mb-1.5 mt-5 font-bold"> openedFolders={openedFolders}
{dateRange} />
</div> </div>
{chatSessions.map((chat) => {
const isSelected = currentChatId === chat.id; <div
return ( onDragOver={(event) => {
<div key={`${chat.id}-${chat.name}`} className="mr-3"> event.preventDefault();
<ChatSessionDisplay setIsDragOver(true);
chatSession={chat} }}
isSelected={isSelected} onDragLeave={() => setIsDragOver(false)}
/> onDrop={handleDropToRemoveFromFolder}
className={`transition duration-300 ease-in-out mr-3 ${
isDragOver ? "bg-hover" : ""
} rounded-md`}
>
{Object.entries(groupedChatSessions).map(
([dateRange, chatSessions]) => {
if (chatSessions.length > 0) {
return (
<div key={dateRange}>
<div className="text-xs text-subtle flex pb-0.5 mb-1.5 mt-5 font-bold">
{dateRange}
</div> </div>
); {chatSessions
})} .filter((chat) => chat.folder_id === null)
</div> .map((chat) => {
); const isSelected = currentChatId === chat.id;
} return (
})} <div key={`${chat.id}-${chat.name}`}>
<ChatSessionDisplay
chatSession={chat}
isSelected={isSelected}
skipGradient={isDragOver}
/>
</div>
);
})}
</div>
);
}
}
)}
</div>
</div> </div>
); );
} }

View File

@@ -19,6 +19,7 @@ export function BasicClickable({
text-emphasis text-emphasis
text-sm text-sm
p-1 p-1
h-full
select-none select-none
hover:bg-hover hover:bg-hover
${fullWidth ? "w-full" : ""}`} ${fullWidth ? "w-full" : ""}`}

View File

@@ -0,0 +1,2 @@
export const CHAT_SESSION_ID_KEY = "chatSessionId";
export const FOLDER_ID_KEY = "folderId";