mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-09-27 20:38:32 +02:00
add CSV display (#3028)
* add CSV display * add downloading * restructure * create portal for modal * update requirements * nit
This commit is contained in:
@@ -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):
|
||||
|
@@ -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
|
||||
|
@@ -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())
|
||||
|
@@ -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
|
@@ -1490,6 +1490,7 @@ export function ChatPage({
|
||||
const imageFiles = acceptedFiles.filter((file) =>
|
||||
file.type.startsWith("image/")
|
||||
);
|
||||
|
||||
if (imageFiles.length > 0 && !llmAcceptsImages) {
|
||||
setPopup({
|
||||
type: "error",
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -32,6 +32,7 @@ export enum ChatFileType {
|
||||
IMAGE = "image",
|
||||
DOCUMENT = "document",
|
||||
PLAIN_TEXT = "plain_text",
|
||||
CSV = "csv",
|
||||
}
|
||||
|
||||
export interface FileDescriptor {
|
||||
|
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
156
web/src/components/tools/CSVContent.tsx
Normal file
156
web/src/components/tools/CSVContent.tsx
Normal 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;
|
137
web/src/components/tools/ExpandableContentWrapper.tsx
Normal file
137
web/src/components/tools/ExpandableContentWrapper.tsx
Normal 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;
|
27
web/src/components/tools/ToolResult.tsx
Normal file
27
web/src/components/tools/ToolResult.tsx
Normal 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;
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
Reference in New Issue
Block a user