mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-15 20:52:39 +00:00
Compare commits
5 Commits
v2.12.7
...
add_tool_f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7361dcb17 | ||
|
|
00f8e431ff | ||
|
|
a019a812be | ||
|
|
eabc519f06 | ||
|
|
4dbd74cacb |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(
|
||||
|
||||
25
backend/danswer/tools/tool_implementations/custom/prompt.py
Normal file
25
backend/danswer/tools/tool_implementations/custom/prompt.py
Normal 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,
|
||||
)
|
||||
)
|
||||
@@ -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"},
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
@@ -135,7 +136,7 @@ export interface DocumentsResponse {
|
||||
rephrased_query: string | null;
|
||||
}
|
||||
|
||||
export interface ImageGenerationDisplay {
|
||||
export interface FileChatDisplay {
|
||||
file_ids: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
151
web/src/components/tools/CSVContent.tsx
Normal file
151
web/src/components/tools/CSVContent.tsx
Normal 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;
|
||||
131
web/src/components/tools/ExpandableContentWrapper.tsx
Normal file
131
web/src/components/tools/ExpandableContentWrapper.tsx
Normal 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;
|
||||
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