add CSV display (#3028)

* add CSV display

* add downloading

* restructure

* create portal for modal

* update requirements

* nit
This commit is contained in:
pablodanswer
2024-11-03 10:43:05 -08:00
committed by GitHub
parent 93d0104d3c
commit a7002dfa1d
14 changed files with 549 additions and 34 deletions

View File

@@ -13,6 +13,7 @@ class ChatFileType(str, Enum):
DOC = "document"
# Plain text only contain the text
PLAIN_TEXT = "plain_text"
CSV = "csv"
class FileDescriptor(TypedDict):

View File

@@ -1,3 +1,4 @@
import io
import json
from collections.abc import Callable
from collections.abc import Iterator
@@ -7,6 +8,7 @@ from typing import TYPE_CHECKING
from typing import Union
import litellm # type: ignore
import pandas as pd
import tiktoken
from langchain.prompts.base import StringPromptValue
from langchain.prompts.chat import ChatPromptValue
@@ -135,6 +137,18 @@ def translate_history_to_basemessages(
return history_basemessages, history_token_counts
def _process_csv_file(file: InMemoryChatFile) -> str:
df = pd.read_csv(io.StringIO(file.content.decode("utf-8")))
csv_preview = df.head().to_string()
file_name_section = (
f"CSV FILE NAME: {file.filename}\n"
if file.filename
else "CSV FILE (NO NAME PROVIDED):\n"
)
return f"{file_name_section}{CODE_BLOCK_PAT.format(csv_preview)}\n\n\n"
def _build_content(
message: str,
files: list[InMemoryChatFile] | None = None,
@@ -145,16 +159,26 @@ def _build_content(
if files
else None
)
if not text_files:
csv_files = (
[file for file in files if file.file_type == ChatFileType.CSV]
if files
else None
)
if not text_files and not csv_files:
return message
final_message_with_files = "FILES:\n\n"
for file in text_files:
for file in text_files or []:
file_content = file.content.decode("utf-8")
file_name_section = f"DOCUMENT: {file.filename}\n" if file.filename else ""
final_message_with_files += (
f"{file_name_section}{CODE_BLOCK_PAT.format(file_content.strip())}\n\n\n"
)
for file in csv_files or []:
final_message_with_files += _process_csv_file(file)
final_message_with_files += message
return final_message_with_files

View File

@@ -557,9 +557,9 @@ def upload_files_for_chat(
_: User | None = Depends(current_user),
) -> dict[str, list[FileDescriptor]]:
image_content_types = {"image/jpeg", "image/png", "image/webp"}
csv_content_types = {"text/csv"}
text_content_types = {
"text/plain",
"text/csv",
"text/markdown",
"text/x-markdown",
"text/x-config",
@@ -578,8 +578,10 @@ def upload_files_for_chat(
"application/epub+zip",
}
allowed_content_types = image_content_types.union(text_content_types).union(
document_content_types
allowed_content_types = (
image_content_types.union(text_content_types)
.union(document_content_types)
.union(csv_content_types)
)
for file in files:
@@ -589,6 +591,10 @@ def upload_files_for_chat(
elif file.content_type in text_content_types:
error_detail = "Unsupported text file type. Supported text types include .txt, .csv, .md, .mdx, .conf, "
".log, .tsv."
elif file.content_type in csv_content_types:
error_detail = (
"Unsupported CSV file type. Supported CSV types include .csv."
)
else:
error_detail = (
"Unsupported document file type. Supported document types include .pdf, .docx, .pptx, .xlsx, "
@@ -614,6 +620,10 @@ def upload_files_for_chat(
file_type = ChatFileType.IMAGE
# Convert image to JPEG
file_content, new_content_type = convert_to_jpeg(file)
elif file.content_type in csv_content_types:
file_type = ChatFileType.CSV
file_content = io.BytesIO(file.file.read())
new_content_type = file.content_type or ""
elif file.content_type in document_content_types:
file_type = ChatFileType.DOC
file_content = io.BytesIO(file.file.read())

View File

@@ -24,4 +24,6 @@ types-urllib3==1.26.25.11
trafilatura==1.12.2
lxml==5.3.0
lxml_html_clean==0.2.2
boto3-stubs[s3]==1.34.133
boto3-stubs[s3]==1.34.133
pandas==2.2.3
pandas-stubs==2.2.3.241009

View File

@@ -1490,6 +1490,7 @@ export function ChatPage({
const imageFiles = acceptedFiles.filter((file) =>
file.type.startsWith("image/")
);
if (imageFiles.length > 0 && !llmAcceptsImages) {
setPopup({
type: "error",

View File

@@ -1,70 +1,78 @@
import { FiFileText } from "react-icons/fi";
import { useState, useRef, useEffect } from "react";
import { Tooltip } from "@/components/tooltip/Tooltip";
import { ExpandTwoIcon } from "@/components/icons/icons";
export function DocumentPreview({
fileName,
maxWidth,
alignBubble,
open,
}: {
fileName: string;
open?: () => void;
maxWidth?: string;
alignBubble?: boolean;
}) {
const [isOverflowing, setIsOverflowing] = useState(false);
const fileNameRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (fileNameRef.current) {
setIsOverflowing(
fileNameRef.current.scrollWidth > fileNameRef.current.clientWidth
);
}
}, [fileName]);
return (
<div
className={`
${alignBubble && "w-64"}
flex
items-center
p-2
p-3
bg-hover
border
border-border
rounded-md
rounded-lg
box-border
h-16
h-20
hover:shadow-sm
transition-all
`}
>
<div className="flex-shrink-0">
<div
className="
w-12
h-12
w-14
h-14
bg-document
flex
items-center
justify-center
rounded-md
rounded-lg
transition-all
duration-200
hover:bg-document-dark
"
>
<FiFileText className="w-6 h-6 text-white" />
<FiFileText className="w-7 h-7 text-white" />
</div>
</div>
<div className="ml-4 relative">
<div className="ml-4 flex-grow">
<Tooltip content={fileName} side="top" align="start">
<div
ref={fileNameRef}
className={`font-medium text-sm line-clamp-1 break-all ellipses ${
className={`font-medium text-sm line-clamp-1 break-all ellipsis ${
maxWidth ? maxWidth : "max-w-48"
}`}
>
{fileName}
</div>
</Tooltip>
<div className="text-subtle text-sm">Document</div>
<div className="text-subtle text-xs mt-1">Document</div>
</div>
{open && (
<button
onClick={() => open()}
className="ml-2 p-2 rounded-full hover:bg-gray-200 transition-colors duration-200"
aria-label="Expand document"
>
<ExpandTwoIcon className="w-5 h-5 text-gray-600" />
</button>
)}
</div>
);
}

View File

@@ -32,6 +32,7 @@ export enum ChatFileType {
IMAGE = "image",
DOCUMENT = "document",
PLAIN_TEXT = "plain_text",
CSV = "csv",
}
export interface FileDescriptor {

View File

@@ -55,6 +55,8 @@ import { LlmOverride } from "@/lib/hooks";
import { ContinueGenerating } from "./ContinueMessage";
import { MemoizedLink, MemoizedParagraph } from "./MemoizedTextComponents";
import { extractCodeText } from "./codeUtils";
import ToolResult from "../../../components/tools/ToolResult";
import CsvContent from "../../../components/tools/CSVContent";
const TOOLS_WITH_CUSTOM_HANDLING = [
SEARCH_TOOL_NAME,
@@ -69,8 +71,13 @@ function FileDisplay({
files: FileDescriptor[];
alignBubble?: boolean;
}) {
const [close, setClose] = useState(true);
const imageFiles = files.filter((file) => file.type === ChatFileType.IMAGE);
const nonImgFiles = files.filter((file) => file.type !== ChatFileType.IMAGE);
const nonImgFiles = files.filter(
(file) => file.type !== ChatFileType.IMAGE && file.type !== ChatFileType.CSV
);
const csvImgFiles = files.filter((file) => file.type == ChatFileType.CSV);
return (
<>
@@ -94,6 +101,7 @@ function FileDisplay({
</div>
</div>
)}
{imageFiles && imageFiles.length > 0 && (
<div
id="danswer-image"
@@ -106,6 +114,35 @@ function FileDisplay({
</div>
</div>
)}
{csvImgFiles && csvImgFiles.length > 0 && (
<div className={` ${alignBubble && "ml-auto"} mt-2 auto mb-4`}>
<div className="flex flex-col gap-2">
{csvImgFiles.map((file) => {
return (
<div key={file.id} className="w-fit">
{close ? (
<>
<ToolResult
csvFileDescriptor={file}
close={() => setClose(false)}
contentComponent={CsvContent}
/>
</>
) : (
<DocumentPreview
open={() => setClose(true)}
fileName={file.name || file.id}
maxWidth="max-w-64"
alignBubble={alignBubble}
/>
)}
</div>
);
})}
</div>
</div>
)}
</>
);
}

View File

@@ -4,6 +4,8 @@ import { FiX } from "react-icons/fi";
import { IconProps, XIcon } from "./icons/icons";
import { useRef } from "react";
import { isEventWithinRef } from "@/lib/contains";
import ReactDOM from "react-dom";
import { useEffect, useState } from "react";
interface ModalProps {
icon?: ({ size, className }: IconProps) => JSX.Element;
@@ -14,6 +16,7 @@ interface ModalProps {
width?: string;
titleSize?: string;
hideDividerForTitle?: boolean;
hideCloseButton?: boolean;
noPadding?: boolean;
}
@@ -27,8 +30,17 @@ export function Modal({
hideDividerForTitle,
noPadding,
icon,
hideCloseButton,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
return () => {
setIsMounted(false);
};
}, []);
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (
@@ -41,11 +53,11 @@ export function Modal({
}
};
return (
const modalContent = (
<div
onMouseDown={handleMouseDown}
className={`fixed inset-0 bg-black bg-opacity-25 backdrop-blur-sm h-full
flex items-center justify-center z-50 transition-opacity duration-300 ease-in-out`}
flex items-center justify-center z-[9999] transition-opacity duration-300 ease-in-out`}
>
<div
ref={modalRef}
@@ -54,13 +66,13 @@ export function Modal({
e.stopPropagation();
}
}}
className={`bg-background text-emphasis rounded shadow-2xl
className={`bg-background text-emphasis rounded shadow-2xl
transform transition-all duration-300 ease-in-out
${width ?? "w-11/12 max-w-4xl"}
${noPadding ? "" : "p-10"}
${className || ""}`}
>
{onOutsideClick && (
{onOutsideClick && !hideCloseButton && (
<div className="absolute top-2 right-2">
<button
onClick={onOutsideClick}
@@ -93,4 +105,6 @@ export function Modal({
</div>
</div>
);
return isMounted ? ReactDOM.createPortal(modalContent, document.body) : null;
}

View File

@@ -2588,3 +2588,99 @@ export const WindowsIcon = ({
</svg>
);
};
export const OpenIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return (
<svg
style={{ width: `${size}px`, height: `${size}px` }}
className={`w-[${size}px] h-[${size}px] ` + className}
xmlns="http://www.w3.org/2000/svg"
width="200"
height="200"
viewBox="0 0 14 14"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
d="M7 13.5a9.26 9.26 0 0 0-5.61-2.95a1 1 0 0 1-.89-1V1.5A1 1 0 0 1 1.64.51A9.3 9.3 0 0 1 7 3.43zm0 0a9.26 9.26 0 0 1 5.61-2.95a1 1 0 0 0 .89-1V1.5a1 1 0 0 0-1.14-.99A9.3 9.3 0 0 0 7 3.43z"
/>
</svg>
);
};
export const DexpandTwoIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return (
<svg
style={{ width: `${size}px`, height: `${size}px` }}
className={`w-[${size}px] h-[${size}px] ` + className}
xmlns="http://www.w3.org/2000/svg"
width="200"
height="200"
viewBox="0 0 14 14"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
d="m.5 13.5l5-5m-4 0h4v4m8-12l-5 5m4 0h-4v-4"
/>
</svg>
);
};
export const ExpandTwoIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return (
<svg
style={{ width: `${size}px`, height: `${size}px` }}
className={`w-[${size}px] h-[${size}px] ` + className}
xmlns="http://www.w3.org/2000/svg"
width="200"
height="200"
viewBox="0 0 14 14"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
d="m8.5 5.5l5-5m-4 0h4v4m-8 4l-5 5m4 0h-4v-4"
/>
</svg>
);
};
export const DownloadCSVIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return (
<svg
style={{ width: `${size}px`, height: `${size}px` }}
className={`w-[${size}px] h-[${size}px] ` + className}
xmlns="http://www.w3.org/2000/svg"
width="200"
height="200"
viewBox="0 0 14 14"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
d="M.5 10.5v1a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-1M4 6l3 3.5L10 6M7 9.5v-9"
/>
</svg>
);
};

View File

@@ -0,0 +1,156 @@
// CsvContent
import React, { useState, useEffect } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ContentComponentProps } from "./ExpandableContentWrapper";
import { WarningCircle } from "@phosphor-icons/react";
const CsvContent: React.FC<ContentComponentProps> = ({
fileDescriptor,
isLoading,
fadeIn,
expanded = false,
}) => {
const [data, setData] = useState<Record<string, string>[]>([]);
const [headers, setHeaders] = useState<string[]>([]);
useEffect(() => {
fetchCSV(fileDescriptor.id);
}, [fileDescriptor.id]);
const fetchCSV = async (id: string) => {
try {
const response = await fetch(`api/chat/file/${id}`);
if (!response.ok) {
throw new Error("Failed to fetch CSV file");
}
const contentLength = response.headers.get("Content-Length");
const fileSizeInMB = contentLength
? parseInt(contentLength) / (1024 * 1024)
: 0;
const MAX_FILE_SIZE_MB = 5;
if (fileSizeInMB > MAX_FILE_SIZE_MB) {
throw new Error("File size exceeds the maximum limit of 5MB");
}
const csvData = await response.text();
const rows = csvData.trim().split("\n");
const parsedHeaders = rows[0].split(",");
setHeaders(parsedHeaders);
const parsedData: Record<string, string>[] = rows.slice(1).map((row) => {
const values = row.split(",");
return parsedHeaders.reduce<Record<string, string>>(
(obj, header, index) => {
obj[header] = values[index];
return obj;
},
{}
);
});
setData(parsedData);
} catch (error) {
console.error("Error fetching CSV file:", error);
setData([]);
setHeaders([]);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-[300px]">
<div className="animate-pulse flex space-x-4">
<div className="rounded-full bg-background-200 h-10 w-10"></div>
<div className="w-full flex-1 space-y-4 py-1">
<div className="h-2 w-full bg-background-200 rounded"></div>
<div className="w-full space-y-3">
<div className="grid grid-cols-3 gap-4">
<div className="h-2 bg-background-200 rounded col-span-2"></div>
<div className="h-2 bg-background-200 rounded col-span-1"></div>
</div>
<div className="h-2 bg-background-200 rounded"></div>
</div>
</div>
</div>
</div>
);
}
return (
<div
className={`transition-opacity transform relative duration-1000 ease-in-out ${
fadeIn ? "opacity-100" : "opacity-0"
}`}
>
<div
className={`overflow-y-hidden flex relative ${
expanded ? "max-h-2/3" : "max-h-[300px]"
}`}
>
<Table>
<TableHeader className="sticky z-[1000] top-0">
<TableRow className="hover:bg-background-125 bg-background-125">
{headers.map((header, index) => (
<TableHead key={index}>
<p className="text-text-600 line-clamp-2 my-2 font-medium">
{header}
</p>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="h-[300px] overflow-y-scroll">
{data.length > 0 ? (
data.map((row, rowIndex) => (
<TableRow key={rowIndex}>
{headers.map((header, cellIndex) => (
<TableCell
className={`${
cellIndex === 0 ? "sticky left-0 bg-background-100" : ""
}`}
key={cellIndex}
>
{row[header]}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={headers.length}
className="text-center py-8"
>
<div className="flex flex-col items-center justify-center space-y-2">
<WarningCircle className="w-8 h-8 text-error" />
<p className="text-text-600 font-medium">
{headers.length === 0
? "Error loading CSV"
: "No data available"}
</p>
<p className="text-text-400 text-sm">
{headers.length === 0
? "The CSV file may be too large or couldn't be loaded properly."
: "The CSV file appears to be empty."}
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
};
export default CsvContent;

View File

@@ -0,0 +1,137 @@
// ExpandableContentWrapper
import React, { useState, useEffect } from "react";
import {
CustomTooltip,
TooltipGroup,
} from "@/components/tooltip/CustomTooltip";
import {
DexpandTwoIcon,
DownloadCSVIcon,
ExpandTwoIcon,
OpenIcon,
} from "@/components/icons/icons";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Modal } from "@/components/Modal";
import { FileDescriptor } from "@/app/chat/interfaces";
export interface ExpandableContentWrapperProps {
fileDescriptor: FileDescriptor;
close: () => void;
ContentComponent: React.ComponentType<ContentComponentProps>;
}
export interface ContentComponentProps {
fileDescriptor: FileDescriptor;
isLoading: boolean;
fadeIn: boolean;
expanded?: boolean;
}
const ExpandableContentWrapper: React.FC<ExpandableContentWrapperProps> = ({
fileDescriptor,
close,
ContentComponent,
}) => {
const [expanded, setExpanded] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [fadeIn, setFadeIn] = useState(false);
const toggleExpand = () => setExpanded((prev) => !prev);
// Prevent a jarring fade in
useEffect(() => {
setTimeout(() => setIsLoading(false), 300);
}, []);
useEffect(() => {
if (!isLoading) {
setTimeout(() => setFadeIn(true), 50);
} else {
setFadeIn(false);
}
}, [isLoading]);
const downloadFile = () => {
const a = document.createElement("a");
a.href = `api/chat/file/${fileDescriptor.id}`;
a.download = fileDescriptor.name || "download.csv";
a.setAttribute("download", fileDescriptor.name || "download.csv");
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const Content = (
<div
className={`${
!expanded ? "w-message-sm" : "w-full"
} !rounded !rounded-lg overflow-y-hidden border h-full border-border`}
>
<CardHeader className="w-full py-4 border-b border-border bg-white z-[10] top-0">
<div className="flex justify-between items-center">
<CardTitle className="text-ellipsis line-clamp-1 text-xl font-semibold text-text-700 pr-4">
{fileDescriptor.name || "Untitled"}
</CardTitle>
<div className="flex items-center">
<TooltipGroup gap="gap-x-4">
<CustomTooltip showTick line content="Download file">
<button onClick={downloadFile}>
<DownloadCSVIcon className="cursor-pointer hover:text-text-800 h-6 w-6 text-text-400" />
</button>
</CustomTooltip>
<CustomTooltip
line
showTick
content={expanded ? "Minimize" : "Full screen"}
>
<button onClick={toggleExpand}>
{!expanded ? (
<ExpandTwoIcon className="hover:text-text-800 h-6 w-6 cursor-pointer text-text-400" />
) : (
<DexpandTwoIcon className="hover:text-text-800 h-6 w-6 cursor-pointer text-text-400" />
)}
</button>
</CustomTooltip>
<CustomTooltip showTick line content="Hide">
<button onClick={close}>
<OpenIcon className="hover:text-text-800 h-6 w-6 cursor-pointer text-text-400" />
</button>
</CustomTooltip>
</TooltipGroup>
</div>
</div>
</CardHeader>
<Card
className={`!rounded-none w-full ${
expanded ? "max-h-[600px]" : "max-h-[300px] h"
} p-0 relative overflow-x-scroll overflow-y-scroll mx-auto`}
>
<CardContent className="p-0">
<ContentComponent
fileDescriptor={fileDescriptor}
isLoading={isLoading}
fadeIn={fadeIn}
expanded={expanded}
/>
</CardContent>
</Card>
</div>
);
return (
<>
{expanded && (
<Modal
hideCloseButton
onOutsideClick={() => setExpanded(false)}
className="!max-w-5xl overflow-hidden rounded-lg !p-0 !m-0"
>
{Content}
</Modal>
)}
{!expanded && Content}
</>
);
};
export default ExpandableContentWrapper;

View File

@@ -0,0 +1,27 @@
import React from "react";
import ExpandableContentWrapper, {
ContentComponentProps,
} from "./ExpandableContentWrapper";
import { FileDescriptor } from "@/app/chat/interfaces";
interface ToolResultProps {
csvFileDescriptor: FileDescriptor;
close: () => void;
contentComponent: React.ComponentType<ContentComponentProps>;
}
const ToolResult: React.FC<ToolResultProps> = ({
csvFileDescriptor,
close,
contentComponent,
}) => {
return (
<ExpandableContentWrapper
fileDescriptor={csvFileDescriptor}
close={close}
ContentComponent={contentComponent}
/>
);
};
export default ToolResult;

View File

@@ -19,9 +19,10 @@ const TooltipGroupContext = createContext<{
hoverCountRef: { current: false },
});
export const TooltipGroup: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
export const TooltipGroup: React.FC<{
children: React.ReactNode;
gap?: string;
}> = ({ children, gap }) => {
const [groupHovered, setGroupHovered] = useState(false);
const hoverCountRef = useRef(false);
@@ -29,7 +30,7 @@ export const TooltipGroup: React.FC<{ children: React.ReactNode }> = ({
<TooltipGroupContext.Provider
value={{ groupHovered, setGroupHovered, hoverCountRef }}
>
<div className="inline-flex">{children}</div>
<div className={`inline-flex ${gap}`}>{children}</div>
</TooltipGroupContext.Provider>
);
};