mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-10-10 21:26:01 +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"
|
DOC = "document"
|
||||||
# Plain text only contain the text
|
# Plain text only contain the text
|
||||||
PLAIN_TEXT = "plain_text"
|
PLAIN_TEXT = "plain_text"
|
||||||
|
CSV = "csv"
|
||||||
|
|
||||||
|
|
||||||
class FileDescriptor(TypedDict):
|
class FileDescriptor(TypedDict):
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import io
|
||||||
import json
|
import json
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
@@ -7,6 +8,7 @@ from typing import TYPE_CHECKING
|
|||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
import litellm # type: ignore
|
import litellm # type: ignore
|
||||||
|
import pandas as pd
|
||||||
import tiktoken
|
import tiktoken
|
||||||
from langchain.prompts.base import StringPromptValue
|
from langchain.prompts.base import StringPromptValue
|
||||||
from langchain.prompts.chat import ChatPromptValue
|
from langchain.prompts.chat import ChatPromptValue
|
||||||
@@ -135,6 +137,18 @@ def translate_history_to_basemessages(
|
|||||||
return history_basemessages, history_token_counts
|
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(
|
def _build_content(
|
||||||
message: str,
|
message: str,
|
||||||
files: list[InMemoryChatFile] | None = None,
|
files: list[InMemoryChatFile] | None = None,
|
||||||
@@ -145,16 +159,26 @@ def _build_content(
|
|||||||
if files
|
if files
|
||||||
else None
|
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
|
return message
|
||||||
|
|
||||||
final_message_with_files = "FILES:\n\n"
|
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_content = file.content.decode("utf-8")
|
||||||
file_name_section = f"DOCUMENT: {file.filename}\n" if file.filename else ""
|
file_name_section = f"DOCUMENT: {file.filename}\n" if file.filename else ""
|
||||||
final_message_with_files += (
|
final_message_with_files += (
|
||||||
f"{file_name_section}{CODE_BLOCK_PAT.format(file_content.strip())}\n\n\n"
|
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
|
final_message_with_files += message
|
||||||
|
|
||||||
return final_message_with_files
|
return final_message_with_files
|
||||||
|
@@ -557,9 +557,9 @@ def upload_files_for_chat(
|
|||||||
_: User | None = Depends(current_user),
|
_: User | None = Depends(current_user),
|
||||||
) -> dict[str, list[FileDescriptor]]:
|
) -> dict[str, list[FileDescriptor]]:
|
||||||
image_content_types = {"image/jpeg", "image/png", "image/webp"}
|
image_content_types = {"image/jpeg", "image/png", "image/webp"}
|
||||||
|
csv_content_types = {"text/csv"}
|
||||||
text_content_types = {
|
text_content_types = {
|
||||||
"text/plain",
|
"text/plain",
|
||||||
"text/csv",
|
|
||||||
"text/markdown",
|
"text/markdown",
|
||||||
"text/x-markdown",
|
"text/x-markdown",
|
||||||
"text/x-config",
|
"text/x-config",
|
||||||
@@ -578,8 +578,10 @@ def upload_files_for_chat(
|
|||||||
"application/epub+zip",
|
"application/epub+zip",
|
||||||
}
|
}
|
||||||
|
|
||||||
allowed_content_types = image_content_types.union(text_content_types).union(
|
allowed_content_types = (
|
||||||
document_content_types
|
image_content_types.union(text_content_types)
|
||||||
|
.union(document_content_types)
|
||||||
|
.union(csv_content_types)
|
||||||
)
|
)
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
@@ -589,6 +591,10 @@ def upload_files_for_chat(
|
|||||||
elif file.content_type in text_content_types:
|
elif file.content_type in text_content_types:
|
||||||
error_detail = "Unsupported text file type. Supported text types include .txt, .csv, .md, .mdx, .conf, "
|
error_detail = "Unsupported text file type. Supported text types include .txt, .csv, .md, .mdx, .conf, "
|
||||||
".log, .tsv."
|
".log, .tsv."
|
||||||
|
elif file.content_type in csv_content_types:
|
||||||
|
error_detail = (
|
||||||
|
"Unsupported CSV file type. Supported CSV types include .csv."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
error_detail = (
|
error_detail = (
|
||||||
"Unsupported document file type. Supported document types include .pdf, .docx, .pptx, .xlsx, "
|
"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
|
file_type = ChatFileType.IMAGE
|
||||||
# Convert image to JPEG
|
# Convert image to JPEG
|
||||||
file_content, new_content_type = convert_to_jpeg(file)
|
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:
|
elif file.content_type in document_content_types:
|
||||||
file_type = ChatFileType.DOC
|
file_type = ChatFileType.DOC
|
||||||
file_content = io.BytesIO(file.file.read())
|
file_content = io.BytesIO(file.file.read())
|
||||||
|
@@ -25,3 +25,5 @@ trafilatura==1.12.2
|
|||||||
lxml==5.3.0
|
lxml==5.3.0
|
||||||
lxml_html_clean==0.2.2
|
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) =>
|
const imageFiles = acceptedFiles.filter((file) =>
|
||||||
file.type.startsWith("image/")
|
file.type.startsWith("image/")
|
||||||
);
|
);
|
||||||
|
|
||||||
if (imageFiles.length > 0 && !llmAcceptsImages) {
|
if (imageFiles.length > 0 && !llmAcceptsImages) {
|
||||||
setPopup({
|
setPopup({
|
||||||
type: "error",
|
type: "error",
|
||||||
|
@@ -1,70 +1,78 @@
|
|||||||
import { FiFileText } from "react-icons/fi";
|
import { FiFileText } from "react-icons/fi";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Tooltip } from "@/components/tooltip/Tooltip";
|
import { Tooltip } from "@/components/tooltip/Tooltip";
|
||||||
|
import { ExpandTwoIcon } from "@/components/icons/icons";
|
||||||
|
|
||||||
export function DocumentPreview({
|
export function DocumentPreview({
|
||||||
fileName,
|
fileName,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
alignBubble,
|
alignBubble,
|
||||||
|
open,
|
||||||
}: {
|
}: {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
open?: () => void;
|
||||||
maxWidth?: string;
|
maxWidth?: string;
|
||||||
alignBubble?: boolean;
|
alignBubble?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
|
||||||
const fileNameRef = useRef<HTMLDivElement>(null);
|
const fileNameRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (fileNameRef.current) {
|
|
||||||
setIsOverflowing(
|
|
||||||
fileNameRef.current.scrollWidth > fileNameRef.current.clientWidth
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [fileName]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
${alignBubble && "w-64"}
|
${alignBubble && "w-64"}
|
||||||
flex
|
flex
|
||||||
items-center
|
items-center
|
||||||
p-2
|
p-3
|
||||||
bg-hover
|
bg-hover
|
||||||
border
|
border
|
||||||
border-border
|
border-border
|
||||||
rounded-md
|
rounded-lg
|
||||||
box-border
|
box-border
|
||||||
h-16
|
h-20
|
||||||
|
hover:shadow-sm
|
||||||
|
transition-all
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
w-12
|
w-14
|
||||||
h-12
|
h-14
|
||||||
bg-document
|
bg-document
|
||||||
flex
|
flex
|
||||||
items-center
|
items-center
|
||||||
justify-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>
|
</div>
|
||||||
<div className="ml-4 relative">
|
<div className="ml-4 flex-grow">
|
||||||
<Tooltip content={fileName} side="top" align="start">
|
<Tooltip content={fileName} side="top" align="start">
|
||||||
<div
|
<div
|
||||||
ref={fileNameRef}
|
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"
|
maxWidth ? maxWidth : "max-w-48"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{fileName}
|
{fileName}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div className="text-subtle text-sm">Document</div>
|
<div className="text-subtle text-xs mt-1">Document</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -32,6 +32,7 @@ export enum ChatFileType {
|
|||||||
IMAGE = "image",
|
IMAGE = "image",
|
||||||
DOCUMENT = "document",
|
DOCUMENT = "document",
|
||||||
PLAIN_TEXT = "plain_text",
|
PLAIN_TEXT = "plain_text",
|
||||||
|
CSV = "csv",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileDescriptor {
|
export interface FileDescriptor {
|
||||||
|
@@ -55,6 +55,8 @@ import { LlmOverride } from "@/lib/hooks";
|
|||||||
import { ContinueGenerating } from "./ContinueMessage";
|
import { ContinueGenerating } from "./ContinueMessage";
|
||||||
import { MemoizedLink, MemoizedParagraph } from "./MemoizedTextComponents";
|
import { MemoizedLink, MemoizedParagraph } from "./MemoizedTextComponents";
|
||||||
import { extractCodeText } from "./codeUtils";
|
import { extractCodeText } from "./codeUtils";
|
||||||
|
import ToolResult from "../../../components/tools/ToolResult";
|
||||||
|
import CsvContent from "../../../components/tools/CSVContent";
|
||||||
|
|
||||||
const TOOLS_WITH_CUSTOM_HANDLING = [
|
const TOOLS_WITH_CUSTOM_HANDLING = [
|
||||||
SEARCH_TOOL_NAME,
|
SEARCH_TOOL_NAME,
|
||||||
@@ -69,8 +71,13 @@ function FileDisplay({
|
|||||||
files: FileDescriptor[];
|
files: FileDescriptor[];
|
||||||
alignBubble?: boolean;
|
alignBubble?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [close, setClose] = useState(true);
|
||||||
const imageFiles = files.filter((file) => file.type === ChatFileType.IMAGE);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -94,6 +101,7 @@ function FileDisplay({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{imageFiles && imageFiles.length > 0 && (
|
{imageFiles && imageFiles.length > 0 && (
|
||||||
<div
|
<div
|
||||||
id="danswer-image"
|
id="danswer-image"
|
||||||
@@ -106,6 +114,35 @@ function FileDisplay({
|
|||||||
</div>
|
</div>
|
||||||
</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 { IconProps, XIcon } from "./icons/icons";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { isEventWithinRef } from "@/lib/contains";
|
import { isEventWithinRef } from "@/lib/contains";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
icon?: ({ size, className }: IconProps) => JSX.Element;
|
icon?: ({ size, className }: IconProps) => JSX.Element;
|
||||||
@@ -14,6 +16,7 @@ interface ModalProps {
|
|||||||
width?: string;
|
width?: string;
|
||||||
titleSize?: string;
|
titleSize?: string;
|
||||||
hideDividerForTitle?: boolean;
|
hideDividerForTitle?: boolean;
|
||||||
|
hideCloseButton?: boolean;
|
||||||
noPadding?: boolean;
|
noPadding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +30,17 @@ export function Modal({
|
|||||||
hideDividerForTitle,
|
hideDividerForTitle,
|
||||||
noPadding,
|
noPadding,
|
||||||
icon,
|
icon,
|
||||||
|
hideCloseButton,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
return () => {
|
||||||
|
setIsMounted(false);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (
|
if (
|
||||||
@@ -41,11 +53,11 @@ export function Modal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const modalContent = (
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
className={`fixed inset-0 bg-black bg-opacity-25 backdrop-blur-sm h-full
|
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
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
@@ -60,7 +72,7 @@ export function Modal({
|
|||||||
${noPadding ? "" : "p-10"}
|
${noPadding ? "" : "p-10"}
|
||||||
${className || ""}`}
|
${className || ""}`}
|
||||||
>
|
>
|
||||||
{onOutsideClick && (
|
{onOutsideClick && !hideCloseButton && (
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute top-2 right-2">
|
||||||
<button
|
<button
|
||||||
onClick={onOutsideClick}
|
onClick={onOutsideClick}
|
||||||
@@ -93,4 +105,6 @@ export function Modal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return isMounted ? ReactDOM.createPortal(modalContent, document.body) : null;
|
||||||
}
|
}
|
||||||
|
@@ -2588,3 +2588,99 @@ export const WindowsIcon = ({
|
|||||||
</svg>
|
</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 },
|
hoverCountRef: { current: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TooltipGroup: React.FC<{ children: React.ReactNode }> = ({
|
export const TooltipGroup: React.FC<{
|
||||||
children,
|
children: React.ReactNode;
|
||||||
}) => {
|
gap?: string;
|
||||||
|
}> = ({ children, gap }) => {
|
||||||
const [groupHovered, setGroupHovered] = useState(false);
|
const [groupHovered, setGroupHovered] = useState(false);
|
||||||
const hoverCountRef = useRef(false);
|
const hoverCountRef = useRef(false);
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ export const TooltipGroup: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
<TooltipGroupContext.Provider
|
<TooltipGroupContext.Provider
|
||||||
value={{ groupHovered, setGroupHovered, hoverCountRef }}
|
value={{ groupHovered, setGroupHovered, hoverCountRef }}
|
||||||
>
|
>
|
||||||
<div className="inline-flex">{children}</div>
|
<div className={`inline-flex ${gap}`}>{children}</div>
|
||||||
</TooltipGroupContext.Provider>
|
</TooltipGroupContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user