Compare commits

...

5 Commits

Author SHA1 Message Date
pablodanswer
e7361dcb17 add multiple formats to tools 2024-11-03 15:21:34 -08:00
pablodanswer
00f8e431ff create portal for modal 2024-11-02 12:36:59 -07:00
pablodanswer
a019a812be restructure 2024-11-02 12:30:45 -07:00
pablodanswer
eabc519f06 add downloading 2024-11-01 19:18:50 -07:00
pablodanswer
4dbd74cacb add CSV display 2024-11-01 18:35:08 -07:00
21 changed files with 762 additions and 66 deletions

View File

@@ -156,7 +156,7 @@ class QAResponse(SearchResponse, DanswerAnswer):
error_msg: str | None = None
class ImageGenerationDisplay(BaseModel):
class FileChatDisplay(BaseModel):
file_ids: list[str]
@@ -170,7 +170,7 @@ AnswerQuestionPossibleReturn = (
| DanswerQuotes
| CitationInfo
| DanswerContexts
| ImageGenerationDisplay
| FileChatDisplay
| CustomToolResponse
| StreamingError
| StreamStopInfo

View File

@@ -11,8 +11,8 @@ from danswer.chat.models import AllCitations
from danswer.chat.models import CitationInfo
from danswer.chat.models import CustomToolResponse
from danswer.chat.models import DanswerAnswerPiece
from danswer.chat.models import FileChatDisplay
from danswer.chat.models import FinalUsedContextDocsResponse
from danswer.chat.models import ImageGenerationDisplay
from danswer.chat.models import LLMRelevanceFilterResponse
from danswer.chat.models import MessageResponseIDInfo
from danswer.chat.models import MessageSpecificCitations
@@ -275,7 +275,7 @@ ChatPacket = (
| DanswerAnswerPiece
| AllCitations
| CitationInfo
| ImageGenerationDisplay
| FileChatDisplay
| CustomToolResponse
| MessageSpecificCitations
| MessageResponseIDInfo
@@ -769,7 +769,6 @@ def stream_chat_message_objects(
yield LLMRelevanceFilterResponse(
llm_selected_doc_indices=llm_indices
)
elif packet.id == FINAL_CONTEXT_DOCUMENTS_ID:
yield FinalUsedContextDocsResponse(
final_context_docs=packet.response
@@ -787,7 +786,7 @@ def stream_chat_message_objects(
FileDescriptor(id=str(file_id), type=ChatFileType.IMAGE)
for file_id in file_ids
]
yield ImageGenerationDisplay(
yield FileChatDisplay(
file_ids=[str(file_id) for file_id in file_ids]
)
elif packet.id == INTERNET_SEARCH_RESPONSE_ID:
@@ -801,10 +800,30 @@ def stream_chat_message_objects(
yield qa_docs_response
elif packet.id == CUSTOM_TOOL_RESPONSE_ID:
custom_tool_response = cast(CustomToolCallSummary, packet.response)
yield CustomToolResponse(
response=custom_tool_response.tool_result,
tool_name=custom_tool_response.tool_name,
)
if (
custom_tool_response.response_type == "image"
or custom_tool_response.response_type == "csv"
):
file_ids = custom_tool_response.tool_result.file_ids
ai_message_files = [
FileDescriptor(
id=str(file_id),
type=ChatFileType.IMAGE
if custom_tool_response.response_type == "image"
else ChatFileType.CSV,
)
for file_id in file_ids
]
yield FileChatDisplay(
file_ids=[str(file_id) for file_id in file_ids]
)
else:
yield CustomToolResponse(
response=custom_tool_response.tool_result,
tool_name=custom_tool_response.tool_name,
)
elif isinstance(packet, StreamStopInfo):
pass
else:

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
@@ -107,11 +109,10 @@ def translate_danswer_msg_to_langchain(
files: list[InMemoryChatFile] = []
# If the message is a `ChatMessage`, it doesn't have the downloaded files
# attached. Just ignore them for now. Also, OpenAI doesn't allow files to
# be attached to AI messages, so we must remove them
if not isinstance(msg, ChatMessage) and msg.message_type != MessageType.ASSISTANT:
# attached. Just ignore them for now.
if not isinstance(msg, ChatMessage):
files = msg.files
content = build_content_with_imgs(msg.message, files)
content = build_content_with_imgs(msg.message, files, message_type=msg.message_type)
if msg.message_type == MessageType.SYSTEM:
raise ValueError("System messages are not currently part of history")
@@ -135,6 +136,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 +158,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
@@ -164,10 +187,19 @@ def build_content_with_imgs(
message: str,
files: list[InMemoryChatFile] | None = None,
img_urls: list[str] | None = None,
message_type: MessageType = MessageType.USER,
) -> str | list[str | dict[str, Any]]: # matching Langchain's BaseMessage content type
files = files or []
img_files = [file for file in files if file.file_type == ChatFileType.IMAGE]
# Only include image files for user messages
img_files = (
[file for file in files if file.file_type == ChatFileType.IMAGE]
if message_type == MessageType.USER
else []
)
img_urls = img_urls or []
message_main_content = _build_content(message, files)
if not img_files and not img_urls:

View File

@@ -253,7 +253,7 @@ def stream_answer_objects(
return_contexts=query_req.return_contexts,
skip_gen_ai_answer_generation=query_req.skip_gen_ai_answer_generation,
)
# won't be any ImageGenerationDisplay responses since that tool is never passed in
# won't be any FileChatDisplay responses since that tool is never passed in
for packet in cast(AnswerObjectIterator, answer.processed_streamed_output):
# for one-shot flow, don't currently do anything with these
if isinstance(packet, ToolResponse):

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

@@ -1,22 +1,34 @@
import csv
import json
import uuid
from collections.abc import Generator
from io import BytesIO
from io import StringIO
from typing import Any
from typing import cast
from typing import Dict
from typing import List
import requests
from langchain_core.messages import HumanMessage
from langchain_core.messages import SystemMessage
from pydantic import BaseModel
from danswer.configs.constants import FileOrigin
from danswer.db.engine import get_session_with_tenant
from danswer.file_store.file_store import get_default_file_store
from danswer.file_store.models import ChatFileType
from danswer.file_store.models import InMemoryChatFile
from danswer.key_value_store.interface import JSON_ro
from danswer.llm.answering.models import PreviousMessage
from danswer.llm.answering.prompts.build import AnswerPromptBuilder
from danswer.llm.interfaces import LLM
from danswer.tools.base_tool import BaseTool
from danswer.tools.message import ToolCallSummary
from danswer.tools.models import CHAT_SESSION_ID_PLACEHOLDER
from danswer.tools.models import DynamicSchemaInfo
from danswer.tools.models import MESSAGE_ID_PLACEHOLDER
from danswer.tools.models import ToolResponse
from danswer.tools.tool_implementations.custom.base_tool_types import ToolResultType
from danswer.tools.tool_implementations.custom.custom_tool_prompts import (
SHOULD_USE_CUSTOM_TOOL_SYSTEM_PROMPT,
)
@@ -39,6 +51,9 @@ from danswer.tools.tool_implementations.custom.openapi_parsing import REQUEST_BO
from danswer.tools.tool_implementations.custom.openapi_parsing import (
validate_openapi_schema,
)
from danswer.tools.tool_implementations.custom.prompt import (
build_custom_image_generation_user_prompt,
)
from danswer.utils.headers import header_list_to_header_dict
from danswer.utils.headers import HeaderItemDict
from danswer.utils.logger import setup_logger
@@ -48,9 +63,14 @@ logger = setup_logger()
CUSTOM_TOOL_RESPONSE_ID = "custom_tool_response"
class CustomToolFileResponse(BaseModel):
file_ids: List[str] # References to saved images or CSVs
class CustomToolCallSummary(BaseModel):
tool_name: str
tool_result: ToolResultType
response_type: str # e.g., 'json', 'image', 'csv', 'graph'
tool_result: Any # The response data
class CustomTool(BaseTool):
@@ -91,6 +111,12 @@ class CustomTool(BaseTool):
self, *args: ToolResponse
) -> str | list[str | dict[str, Any]]:
response = cast(CustomToolCallSummary, args[0].response)
if response.response_type == "image" or response.response_type == "csv":
image_response = cast(CustomToolFileResponse, response.tool_result)
return json.dumps({"file_ids": image_response.file_ids})
# For JSON or other responses, return as-is
return json.dumps(response.tool_result)
"""For LLMs which do NOT support explicit tool calling"""
@@ -158,6 +184,38 @@ class CustomTool(BaseTool):
)
return None
def _save_and_get_file_references(
self, file_content: bytes | str, content_type: str
) -> List[str]:
with get_session_with_tenant() as db_session:
file_store = get_default_file_store(db_session)
file_id = str(uuid.uuid4())
# Handle both binary and text content
if isinstance(file_content, str):
content = BytesIO(file_content.encode())
else:
content = BytesIO(file_content)
file_store.save_file(
file_name=file_id,
content=content,
display_name=file_id,
file_origin=FileOrigin.CHAT_UPLOAD,
file_type=content_type,
file_metadata={
"content_type": content_type,
},
)
return [file_id]
def _parse_csv(self, csv_text: str) -> List[Dict[str, Any]]:
csv_file = StringIO(csv_text)
reader = csv.DictReader(csv_file)
return [row for row in reader]
"""Actual execution of the tool"""
def run(self, **kwargs: Any) -> Generator[ToolResponse, None, None]:
@@ -177,20 +235,103 @@ class CustomTool(BaseTool):
url = self._method_spec.build_url(self._base_url, path_params, query_params)
method = self._method_spec.method
# Log request details
response = requests.request(
method, url, json=request_body, headers=self.headers
)
content_type = response.headers.get("Content-Type", "")
if "text/csv" in content_type:
file_ids = self._save_and_get_file_references(
response.content, content_type
)
tool_result = CustomToolFileResponse(file_ids=file_ids)
response_type = "csv"
elif "image/" in content_type:
file_ids = self._save_and_get_file_references(
response.content, content_type
)
tool_result = CustomToolFileResponse(file_ids=file_ids)
response_type = "image"
else:
tool_result = response.json()
response_type = "json"
logger.info(
f"Returning tool response for {self._name} with type {response_type}"
)
yield ToolResponse(
id=CUSTOM_TOOL_RESPONSE_ID,
response=CustomToolCallSummary(
tool_name=self._name, tool_result=response.json()
tool_name=self._name,
response_type=response_type,
tool_result=tool_result,
),
)
def build_next_prompt(
self,
prompt_builder: AnswerPromptBuilder,
tool_call_summary: ToolCallSummary,
tool_responses: list[ToolResponse],
using_tool_calling_llm: bool,
) -> AnswerPromptBuilder:
response = cast(CustomToolCallSummary, tool_responses[0].response)
# Handle non-file responses using parent class behavior
if response.response_type not in ["image", "csv"]:
return super().build_next_prompt(
prompt_builder,
tool_call_summary,
tool_responses,
using_tool_calling_llm,
)
# Handle image and CSV file responses
file_type = (
ChatFileType.IMAGE
if response.response_type == "image"
else ChatFileType.CSV
)
# Load files from storage
files = []
with get_session_with_tenant() as db_session:
file_store = get_default_file_store(db_session)
for file_id in response.tool_result.file_ids:
try:
file_io = file_store.read_file(file_id, mode="b")
files.append(
InMemoryChatFile(
file_id=file_id,
filename=file_id,
content=file_io.read(),
file_type=file_type,
)
)
except Exception:
logger.exception(f"Failed to read file {file_id}")
# Update prompt with file content
prompt_builder.update_user_prompt(
build_custom_image_generation_user_prompt(
query=prompt_builder.get_user_message_content(),
files=files,
file_type=file_type,
)
)
return prompt_builder
def final_result(self, *args: ToolResponse) -> JSON_ro:
return cast(CustomToolCallSummary, args[0].response).tool_result
response = cast(CustomToolCallSummary, args[0].response)
if isinstance(response.tool_result, CustomToolFileResponse):
return response.tool_result.model_dump()
return response.tool_result
def build_custom_tools_from_openapi_schema_and_headers(

View File

@@ -0,0 +1,25 @@
from langchain_core.messages import HumanMessage
from danswer.file_store.models import ChatFileType
from danswer.file_store.models import InMemoryChatFile
from danswer.llm.utils import build_content_with_imgs
CUSTOM_IMG_GENERATION_SUMMARY_PROMPT = """
You have just created the attached {file_type} file in response to the following query: "{query}".
Can you please summarize it in a sentence or two? Do NOT include image urls or bulleted lists.
"""
def build_custom_image_generation_user_prompt(
query: str, file_type: ChatFileType, files: list[InMemoryChatFile] | None = None
) -> HumanMessage:
return HumanMessage(
content=build_content_with_imgs(
message=CUSTOM_IMG_GENERATION_SUMMARY_PROMPT.format(
query=query, file_type=file_type.value
).strip(),
files=files,
)
)

View File

@@ -215,6 +215,7 @@ class TestCustomTool(unittest.TestCase):
mock_response = ToolResponse(
id=CUSTOM_TOOL_RESPONSE_ID,
response=CustomToolCallSummary(
response_type="json",
tool_name="getAssistant",
tool_result={"id": "789", "name": "Final Assistant"},
),

View File

@@ -10,7 +10,7 @@ import {
ChatSessionSharedStatus,
DocumentsResponse,
FileDescriptor,
ImageGenerationDisplay,
FileChatDisplay,
Message,
MessageResponseIDInfo,
RetrievalType,
@@ -1284,7 +1284,7 @@ export function ChatPage({
query = toolCalls[0].tool_args["query"];
}
} else if (Object.hasOwn(packet, "file_ids")) {
aiMessageImages = (packet as ImageGenerationDisplay).file_ids.map(
aiMessageImages = (packet as FileChatDisplay).file_ids.map(
(fileId) => {
return {
id: fileId,
@@ -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 {
@@ -135,7 +136,7 @@ export interface DocumentsResponse {
rephrased_query: string | null;
}
export interface ImageGenerationDisplay {
export interface FileChatDisplay {
file_ids: string[];
}

View File

@@ -12,7 +12,7 @@ import {
ChatSession,
DocumentsResponse,
FileDescriptor,
ImageGenerationDisplay,
FileChatDisplay,
Message,
MessageResponseIDInfo,
RetrievalType,
@@ -103,7 +103,7 @@ export type PacketType =
| BackendMessage
| AnswerPiecePacket
| DocumentsResponse
| ImageGenerationDisplay
| FileChatDisplay
| StreamingError
| MessageResponseIDInfo
| StreamStopInfo;

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(false);
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

@@ -38,7 +38,7 @@ const ToggleSwitch = () => {
return (
<div className="bg-background-toggle mobile:mt-8 flex rounded-full p-1">
<div
className={`absolute mobile:mt-8 top-1 bottom-1 ${
className={` mobile:mt-8 top-1 bottom-1 ${
activeTab === "chat" ? "w-[45%]" : "w-[50%]"
} bg-white rounded-full shadow ${
isInitialLoad ? "" : "transition-transform duration-300 ease-in-out"
@@ -53,7 +53,7 @@ const ToggleSwitch = () => {
onClick={() => handleTabChange("search")}
>
<SearchIcon size={16} className="mr-2" />
<div className="flex items-center">
<div className="flex items-center">
Search
<div className="ml-2 flex content-center">
<span className="leading-none pb-[1px] my-auto">
@@ -145,10 +145,14 @@ export default function FunctionalWrapper({
return (
<>
<div className="overscroll-y-contain overflow-y-scroll z-50 overscroll-contain left-0 top-0 w-full h-svh">
{content(toggledSidebar, toggle)}
</div>
{(!settings ||
(settings.search_page_enabled && settings.chat_page_enabled)) && (
<div
className={`mobile:hidden z-30 flex fixed ${
className={`mobile:hidden flex absolute ${
chatBannerPresent ? (twoLines ? "top-20" : "top-14") : "top-4"
} left-1/2 transform -translate-x-1/2`}
>
@@ -162,10 +166,6 @@ export default function FunctionalWrapper({
</div>
</div>
)}
<div className="overscroll-y-contain overflow-y-scroll overscroll-contain left-0 top-0 w-full h-svh">
{content(toggledSidebar, toggle)}
</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,151 @@
// 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,
}) => {
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 max-h-[400px]">
<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,131 @@
// 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;
}
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 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 max-h-[600px] p-0 relative overflow-x-scroll overflow-y-scroll mx-auto">
<CardContent className="p-0">
<ContentComponent
fileDescriptor={fileDescriptor}
isLoading={isLoading}
fadeIn={fadeIn}
/>
</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>
);
};