Merge pull request #1733 from ManishMadan2882/main

Attachments: Enhancements , strategy specific to certain LLMs
This commit is contained in:
Alex
2025-04-15 01:55:46 +03:00
committed by GitHub
16 changed files with 652 additions and 198 deletions

View File

@@ -44,10 +44,13 @@ class ClassicAgent(BaseAgent):
):
yield {"answer": resp.message.content}
else:
completion = self.llm.gen_stream(
model=self.gpt_model, messages=messages, tools=self.tools
)
for line in completion:
# completion = self.llm.gen_stream(
# model=self.gpt_model, messages=messages, tools=self.tools
# )
# log type of resp
logger.info(f"Response type: {type(resp)}")
logger.info(f"Response: {resp}")
for line in resp:
if isinstance(line, str):
yield {"answer": line}

View File

@@ -33,15 +33,53 @@ class LLMHandler(ABC):
logger.info(f"Preparing messages with {len(attachments)} attachments")
# Check if the LLM has its own custom attachment handling implementation
if hasattr(agent.llm, "prepare_messages_with_attachments") and agent.llm.__class__.__name__ != "BaseLLM":
logger.info(f"Using {agent.llm.__class__.__name__}'s own prepare_messages_with_attachments method")
return agent.llm.prepare_messages_with_attachments(messages, attachments)
supported_types = agent.llm.get_supported_attachment_types()
# Otherwise, append attachment content to the system prompt
supported_attachments = []
unsupported_attachments = []
for attachment in attachments:
mime_type = attachment.get('mime_type')
if not mime_type:
import mimetypes
file_path = attachment.get('path')
if file_path:
mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
else:
unsupported_attachments.append(attachment)
continue
if mime_type in supported_types:
supported_attachments.append(attachment)
else:
unsupported_attachments.append(attachment)
# Process supported attachments with the LLM's custom method
prepared_messages = messages
if supported_attachments:
logger.info(f"Processing {len(supported_attachments)} supported attachments with {agent.llm.__class__.__name__}'s method")
prepared_messages = agent.llm.prepare_messages_with_attachments(messages, supported_attachments)
# Process unsupported attachments with the default method
if unsupported_attachments:
logger.info(f"Processing {len(unsupported_attachments)} unsupported attachments with default method")
prepared_messages = self._append_attachment_content_to_system(prepared_messages, unsupported_attachments)
return prepared_messages
def _append_attachment_content_to_system(self, messages, attachments):
"""
Default method to append attachment content to the system prompt.
Args:
messages (list): List of message dictionaries.
attachments (list): List of attachment dictionaries with content.
Returns:
list: Messages with attachment context added to the system prompt.
"""
prepared_messages = messages.copy()
# Build attachment content string
attachment_texts = []
for attachment in attachments:
logger.info(f"Adding attachment {attachment.get('id')} to context")
@@ -122,12 +160,13 @@ class OpenAILLMHandler(LLMHandler):
return resp
else:
text_buffer = ""
while True:
tool_calls = {}
for chunk in resp:
if isinstance(chunk, str) and len(chunk) > 0:
return
yield chunk
continue
elif hasattr(chunk, "delta"):
chunk_delta = chunk.delta
@@ -206,12 +245,17 @@ class OpenAILLMHandler(LLMHandler):
}
)
tool_calls = {}
if hasattr(chunk_delta, "content") and chunk_delta.content:
# Add to buffer or yield immediately based on your preference
text_buffer += chunk_delta.content
yield text_buffer
text_buffer = ""
if (
hasattr(chunk, "finish_reason")
and chunk.finish_reason == "stop"
):
return
return resp
elif isinstance(chunk, str) and len(chunk) == 0:
continue
@@ -298,6 +342,9 @@ class GoogleLLMHandler(LLMHandler):
"content": [function_response_part.to_json_dict()],
}
)
else:
tool_call_found = False
yield result
if not tool_call_found:
return response

View File

@@ -835,12 +835,7 @@ def get_attachments_content(attachment_ids, user):
})
if attachment_doc:
attachments.append({
"id": str(attachment_doc["_id"]),
"content": attachment_doc["content"],
"token_count": attachment_doc.get("token_count", 0),
"path": attachment_doc.get("path", "")
})
attachments.append(attachment_doc)
except Exception as e:
logger.error(f"Error retrieving attachment {attachment_id}: {e}")

View File

@@ -2506,23 +2506,26 @@ class StoreAttachment(Resource):
user = secure_filename(decoded_token.get("sub"))
try:
attachment_id = ObjectId()
original_filename = secure_filename(file.filename)
folder_name = original_filename
save_dir = os.path.join(current_dir, settings.UPLOAD_FOLDER, user, "attachments",folder_name)
save_dir = os.path.join(
current_dir,
settings.UPLOAD_FOLDER,
user,
"attachments",
str(attachment_id)
)
os.makedirs(save_dir, exist_ok=True)
# Create directory structure: user/attachments/filename/
file_path = os.path.join(save_dir, original_filename)
# Handle filename conflicts
if os.path.exists(file_path):
name_parts = os.path.splitext(original_filename)
timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
new_filename = f"{name_parts[0]}_{timestamp}{name_parts[1]}"
file_path = os.path.join(save_dir, new_filename)
original_filename = new_filename
file.save(file_path)
file_info = {"folder": folder_name, "filename": original_filename}
file_info = {
"filename": original_filename,
"attachment_id": str(attachment_id)
}
current_app.logger.info(f"Saved file: {file_path}")
# Start async task to process single file

View File

@@ -55,3 +55,12 @@ class BaseLLM(ABC):
def _supports_tools(self):
raise NotImplementedError("Subclass must implement _supports_tools method")
def get_supported_attachment_types(self):
"""
Return a list of MIME types supported by this LLM for file uploads.
Returns:
list: List of supported MIME types
"""
return [] # Default: no attachments supported

View File

@@ -1,5 +1,9 @@
from google import genai
from google.genai import types
import os
import logging
import mimetypes
import json
from application.llm.base import BaseLLM
@@ -9,6 +13,138 @@ class GoogleLLM(BaseLLM):
super().__init__(*args, **kwargs)
self.api_key = api_key
self.user_api_key = user_api_key
self.client = genai.Client(api_key=self.api_key)
def get_supported_attachment_types(self):
"""
Return a list of MIME types supported by Google Gemini for file uploads.
Returns:
list: List of supported MIME types
"""
return [
'application/pdf',
'image/png',
'image/jpeg',
'image/jpg',
'image/webp',
'image/gif'
]
def prepare_messages_with_attachments(self, messages, attachments=None):
"""
Process attachments using Google AI's file API for more efficient handling.
Args:
messages (list): List of message dictionaries.
attachments (list): List of attachment dictionaries with content and metadata.
Returns:
list: Messages formatted with file references for Google AI API.
"""
if not attachments:
return messages
prepared_messages = messages.copy()
# Find the user message to attach files to the last one
user_message_index = None
for i in range(len(prepared_messages) - 1, -1, -1):
if prepared_messages[i].get("role") == "user":
user_message_index = i
break
if user_message_index is None:
user_message = {"role": "user", "content": []}
prepared_messages.append(user_message)
user_message_index = len(prepared_messages) - 1
if isinstance(prepared_messages[user_message_index].get("content"), str):
text_content = prepared_messages[user_message_index]["content"]
prepared_messages[user_message_index]["content"] = [
{"type": "text", "text": text_content}
]
elif not isinstance(prepared_messages[user_message_index].get("content"), list):
prepared_messages[user_message_index]["content"] = []
files = []
for attachment in attachments:
mime_type = attachment.get('mime_type')
if not mime_type:
file_path = attachment.get('path')
if file_path:
mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
if mime_type in self.get_supported_attachment_types():
try:
file_uri = self._upload_file_to_google(attachment)
logging.info(f"GoogleLLM: Successfully uploaded file, got URI: {file_uri}")
files.append({"file_uri": file_uri, "mime_type": mime_type})
except Exception as e:
logging.error(f"GoogleLLM: Error uploading file: {e}")
if 'content' in attachment:
prepared_messages[user_message_index]["content"].append({
"type": "text",
"text": f"[File could not be processed: {attachment.get('path', 'unknown')}]"
})
if files:
logging.info(f"GoogleLLM: Adding {len(files)} files to message")
prepared_messages[user_message_index]["content"].append({
"files": files
})
return prepared_messages
def _upload_file_to_google(self, attachment):
"""
Upload a file to Google AI and return the file URI.
Args:
attachment (dict): Attachment dictionary with path and metadata.
Returns:
str: Google AI file URI for the uploaded file.
"""
if 'google_file_uri' in attachment:
return attachment['google_file_uri']
file_path = attachment.get('path')
if not file_path:
raise ValueError("No file path provided in attachment")
if not os.path.isabs(file_path):
current_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
file_path = os.path.join(current_dir, "application", file_path)
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
mime_type = attachment.get('mime_type')
if not mime_type:
mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
try:
response = self.client.files.upload(file=file_path)
file_uri = response.uri
from application.core.mongo_db import MongoDB
mongo = MongoDB.get_client()
db = mongo["docsgpt"]
attachments_collection = db["attachments"]
if '_id' in attachment:
attachments_collection.update_one(
{"_id": attachment['_id']},
{"$set": {"google_file_uri": file_uri}}
)
return file_uri
except Exception as e:
logging.error(f"Error uploading file to Google AI: {e}")
raise
def _clean_messages_google(self, messages):
cleaned_messages = []
@@ -26,7 +162,7 @@ class GoogleLLM(BaseLLM):
elif isinstance(content, list):
for item in content:
if "text" in item:
parts.append(types.Part.from_text(item["text"]))
parts.append(types.Part.from_text(text=item["text"]))
elif "function_call" in item:
parts.append(
types.Part.from_function_call(
@@ -41,6 +177,14 @@ class GoogleLLM(BaseLLM):
response=item["function_response"]["response"],
)
)
elif "files" in item:
for file_data in item["files"]:
parts.append(
types.Part.from_uri(
file_uri=file_data["file_uri"],
mime_type=file_data["mime_type"]
)
)
else:
raise ValueError(
f"Unexpected content dictionary format:{item}"
@@ -146,11 +290,25 @@ class GoogleLLM(BaseLLM):
cleaned_tools = self._clean_tools_format(tools)
config.tools = cleaned_tools
# Check if we have both tools and file attachments
has_attachments = False
for message in messages:
for part in message.parts:
if hasattr(part, 'file_data') and part.file_data is not None:
has_attachments = True
break
if has_attachments:
break
logging.info(f"GoogleLLM: Starting stream generation. Model: {model}, Messages: {json.dumps(messages, default=str)}, Has attachments: {has_attachments}")
response = client.models.generate_content_stream(
model=model,
contents=messages,
config=config,
)
for chunk in response:
if hasattr(chunk, "candidates") and chunk.candidates:
for candidate in chunk.candidates:

View File

@@ -1,4 +1,8 @@
import json
import base64
import os
import mimetypes
import logging
from application.core.settings import settings
from application.llm.base import BaseLLM
@@ -65,6 +69,15 @@ class OpenAILLM(BaseLLM):
),
}
)
elif isinstance(item, dict):
content_parts = []
if "text" in item:
content_parts.append({"type": "text", "text": item["text"]})
elif "type" in item and item["type"] == "text" and "text" in item:
content_parts.append(item)
elif "type" in item and item["type"] == "file" and "file" in item:
content_parts.append(item)
cleaned_messages.append({"role": role, "content": content_parts})
else:
raise ValueError(
f"Unexpected content dictionary format: {item}"
@@ -133,6 +146,183 @@ class OpenAILLM(BaseLLM):
def _supports_tools(self):
return True
def get_supported_attachment_types(self):
"""
Return a list of MIME types supported by OpenAI for file uploads.
Returns:
list: List of supported MIME types
"""
return [
'application/pdf',
'image/png',
'image/jpeg',
'image/jpg',
'image/webp',
'image/gif'
]
def prepare_messages_with_attachments(self, messages, attachments=None):
"""
Process attachments using OpenAI's file API for more efficient handling.
Args:
messages (list): List of message dictionaries.
attachments (list): List of attachment dictionaries with content and metadata.
Returns:
list: Messages formatted with file references for OpenAI API.
"""
if not attachments:
return messages
prepared_messages = messages.copy()
# Find the user message to attach file_id to the last one
user_message_index = None
for i in range(len(prepared_messages) - 1, -1, -1):
if prepared_messages[i].get("role") == "user":
user_message_index = i
break
if user_message_index is None:
user_message = {"role": "user", "content": []}
prepared_messages.append(user_message)
user_message_index = len(prepared_messages) - 1
if isinstance(prepared_messages[user_message_index].get("content"), str):
text_content = prepared_messages[user_message_index]["content"]
prepared_messages[user_message_index]["content"] = [
{"type": "text", "text": text_content}
]
elif not isinstance(prepared_messages[user_message_index].get("content"), list):
prepared_messages[user_message_index]["content"] = []
for attachment in attachments:
mime_type = attachment.get('mime_type')
if not mime_type:
file_path = attachment.get('path')
if file_path:
mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
if mime_type and mime_type.startswith('image/'):
try:
base64_image = self._get_base64_image(attachment)
prepared_messages[user_message_index]["content"].append({
"type": "image_url",
"image_url": {
"url": f"data:{mime_type};base64,{base64_image}"
}
})
except Exception as e:
logging.error(f"Error processing image attachment: {e}")
if 'content' in attachment:
prepared_messages[user_message_index]["content"].append({
"type": "text",
"text": f"[Image could not be processed: {attachment.get('path', 'unknown')}]"
})
# Handle PDFs using the file API
elif mime_type == 'application/pdf':
try:
file_id = self._upload_file_to_openai(attachment)
prepared_messages[user_message_index]["content"].append({
"type": "file",
"file": {"file_id": file_id}
})
except Exception as e:
logging.error(f"Error uploading PDF to OpenAI: {e}")
if 'content' in attachment:
prepared_messages[user_message_index]["content"].append({
"type": "text",
"text": f"File content:\n\n{attachment['content']}"
})
return prepared_messages
def _get_base64_image(self, attachment):
"""
Convert an image file to base64 encoding.
Args:
attachment (dict): Attachment dictionary with path and metadata.
Returns:
str: Base64-encoded image data.
"""
file_path = attachment.get('path')
if not file_path:
raise ValueError("No file path provided in attachment")
if not os.path.isabs(file_path):
current_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
file_path = os.path.join(current_dir, "application", file_path)
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
with open(file_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode('utf-8')
def _upload_file_to_openai(self, attachment): ##pdfs
"""
Upload a file to OpenAI and return the file_id.
Args:
attachment (dict): Attachment dictionary with path and metadata.
Expected keys:
- path: Path to the file
- id: Optional MongoDB ID for caching
Returns:
str: OpenAI file_id for the uploaded file.
"""
import os
import logging
if 'openai_file_id' in attachment:
return attachment['openai_file_id']
file_path = attachment.get('path')
if not file_path:
raise ValueError("No file path provided in attachment")
if not os.path.isabs(file_path):
current_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
file_path = os.path.join(current_dir,"application", file_path)
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
try:
with open(file_path, 'rb') as file:
response = self.client.files.create(
file=file,
purpose="assistants"
)
file_id = response.id
from application.core.mongo_db import MongoDB
mongo = MongoDB.get_client()
db = mongo["docsgpt"]
attachments_collection = db["attachments"]
if '_id' in attachment:
attachments_collection.update_one(
{"_id": attachment['_id']},
{"$set": {"openai_file_id": file_id}}
)
return file_id
except Exception as e:
logging.error(f"Error uploading file to OpenAI: {e}")
raise
class AzureOpenAILLM(OpenAILLM):

View File

@@ -328,34 +328,30 @@ def attachment_worker(self, directory, file_info, user):
"""
import datetime
import os
import mimetypes
from application.utils import num_tokens_from_string
mongo = MongoDB.get_client()
db = mongo["docsgpt"]
attachments_collection = db["attachments"]
job_name = file_info["folder"]
logging.info(f"Processing attachment: {job_name}", extra={"user": user, "job": job_name})
filename = file_info["filename"]
attachment_id = file_info["attachment_id"]
logging.info(f"Processing attachment: {attachment_id}/{filename}", extra={"user": user})
self.update_state(state="PROGRESS", meta={"current": 10})
folder_name = file_info["folder"]
filename = file_info["filename"]
file_path = os.path.join(directory, filename)
logging.info(f"Processing file: {file_path}", extra={"user": user, "job": job_name})
if not os.path.exists(file_path):
logging.warning(f"File not found: {file_path}", extra={"user": user, "job": job_name})
return {"error": "File not found"}
logging.warning(f"File not found: {file_path}", extra={"user": user})
raise FileNotFoundError(f"File not found: {file_path}")
try:
reader = SimpleDirectoryReader(
input_files=[file_path]
)
documents = reader.load_data()
self.update_state(state="PROGRESS", meta={"current": 50})
@@ -364,33 +360,37 @@ def attachment_worker(self, directory, file_info, user):
content = documents[0].text
token_count = num_tokens_from_string(content)
file_path_relative = f"{user}/attachments/{folder_name}/{filename}"
file_path_relative = f"{settings.UPLOAD_FOLDER}/{user}/attachments/{attachment_id}/{filename}"
attachment_id = attachments_collection.insert_one({
mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
doc_id = ObjectId(attachment_id)
attachments_collection.insert_one({
"_id": doc_id,
"user": user,
"path": file_path_relative,
"content": content,
"token_count": token_count,
"mime_type": mime_type,
"date": datetime.datetime.now(),
}).inserted_id
})
logging.info(f"Stored attachment with ID: {attachment_id}",
extra={"user": user, "job": job_name})
extra={"user": user})
self.update_state(state="PROGRESS", meta={"current": 100})
return {
"attachment_id": str(attachment_id),
"filename": filename,
"folder": folder_name,
"path": file_path_relative,
"token_count": token_count
"token_count": token_count,
"attachment_id": attachment_id,
"mime_type": mime_type
}
else:
logging.warning("No content was extracted from the file",
extra={"user": user, "job": job_name})
return {"error": "No content was extracted from the file"}
extra={"user": user})
raise ValueError("No content was extracted from the file")
except Exception as e:
logging.error(f"Error processing file {filename}: {e}",
extra={"user": user, "job": job_name}, exc_info=True)
return {"error": f"Error processing file: {str(e)}"}
logging.error(f"Error processing file {filename}: {e}", extra={"user": user}, exc_info=True)
raise

View File

@@ -1,21 +1,30 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef,useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDarkTheme } from '../hooks';
import { useSelector, useDispatch } from 'react-redux';
import userService from '../api/services/userService';
import endpoints from '../api/endpoints';
import { getOS, isTouchDevice } from '../utils/browserUtils';
import PaperPlane from '../assets/paper_plane.svg';
import SourceIcon from '../assets/source.svg';
import ToolIcon from '../assets/tool.svg';
import SpinnerDark from '../assets/spinner-dark.svg';
import Spinner from '../assets/spinner.svg';
import ExitIcon from '../assets/exit.svg';
import AlertIcon from '../assets/alert.svg';
import SourcesPopup from './SourcesPopup';
import ToolsPopup from './ToolsPopup';
import { selectSelectedDocs, selectToken } from '../preferences/preferenceSlice';
import { ActiveState } from '../models/misc';
import Upload from '../upload/Upload';
import ClipIcon from '../assets/clip.svg';
import { setAttachments } from '../conversation/conversationSlice';
import {
addAttachment,
updateAttachment,
removeAttachment,
selectAttachments
} from '../conversation/conversationSlice';
interface MessageInputProps {
value: string;
@@ -47,13 +56,33 @@ export default function MessageInput({
const [isSourcesPopupOpen, setIsSourcesPopupOpen] = useState(false);
const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false);
const [uploadModalState, setUploadModalState] = useState<ActiveState>('INACTIVE');
const [uploads, setUploads] = useState<UploadState[]>([]);
const selectedDocs = useSelector(selectSelectedDocs);
const token = useSelector(selectToken);
const attachments = useSelector(selectAttachments);
const dispatch = useDispatch();
const browserOS = getOS();
const isTouch = isTouchDevice();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
((browserOS === 'win' || browserOS === 'linux') && event.ctrlKey && event.key === 'k') ||
(browserOS === 'mac' && event.metaKey && event.key === 'k')
) {
event.preventDefault();
setIsSourcesPopupOpen(!isSourcesPopupOpen);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [browserOS]);
const handleFileAttachment = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
@@ -64,56 +93,51 @@ export default function MessageInput({
const apiHost = import.meta.env.VITE_API_HOST;
const xhr = new XMLHttpRequest();
const uploadState: UploadState = {
taskId: '',
const newAttachment = {
fileName: file.name,
progress: 0,
status: 'uploading'
status: 'uploading' as const,
taskId: '',
};
setUploads(prev => [...prev, uploadState]);
const uploadIndex = uploads.length;
dispatch(addAttachment(newAttachment));
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
setUploads(prev => prev.map((upload, index) =>
index === uploadIndex
? { ...upload, progress }
: upload
));
dispatch(updateAttachment({
taskId: newAttachment.taskId,
updates: { progress }
}));
}
});
xhr.onload = () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
console.log('File uploaded successfully:', response);
if (response.task_id) {
setUploads(prev => prev.map((upload, index) =>
index === uploadIndex
? { ...upload, taskId: response.task_id, status: 'processing' }
: upload
));
dispatch(updateAttachment({
taskId: newAttachment.taskId,
updates: {
taskId: response.task_id,
status: 'processing',
progress: 10
}
}));
}
} else {
setUploads(prev => prev.map((upload, index) =>
index === uploadIndex
? { ...upload, status: 'failed' }
: upload
));
console.error('Error uploading file:', xhr.responseText);
dispatch(updateAttachment({
taskId: newAttachment.taskId,
updates: { status: 'failed' }
}));
}
};
xhr.onerror = () => {
setUploads(prev => prev.map((upload, index) =>
index === uploadIndex
? { ...upload, status: 'failed' }
: upload
));
console.error('Network error during file upload');
dispatch(updateAttachment({
taskId: newAttachment.taskId,
updates: { status: 'failed' }
}));
};
xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`);
@@ -123,64 +147,55 @@ export default function MessageInput({
};
useEffect(() => {
let timeoutIds: number[] = [];
const checkTaskStatus = () => {
const processingUploads = uploads.filter(upload =>
upload.status === 'processing' && upload.taskId
const processingAttachments = attachments.filter(att =>
att.status === 'processing' && att.taskId
);
processingUploads.forEach(upload => {
processingAttachments.forEach(attachment => {
userService
.getTaskStatus(upload.taskId, null)
.getTaskStatus(attachment.taskId!, null)
.then((data) => data.json())
.then((data) => {
console.log('Task status:', data);
setUploads(prev => prev.map(u => {
if (u.taskId !== upload.taskId) return u;
if (data.status === 'SUCCESS') {
return {
...u,
dispatch(updateAttachment({
taskId: attachment.taskId!,
updates: {
status: 'completed',
progress: 100,
attachment_id: data.result?.attachment_id,
id: data.result?.attachment_id,
token_count: data.result?.token_count
};
} else if (data.status === 'FAILURE') {
return { ...u, status: 'failed' };
} else if (data.status === 'PROGRESS' && data.result?.current) {
return { ...u, progress: data.result.current };
}
return u;
}));
if (data.status !== 'SUCCESS' && data.status !== 'FAILURE') {
const timeoutId = window.setTimeout(() => checkTaskStatus(), 2000);
timeoutIds.push(timeoutId);
} else if (data.status === 'FAILURE') {
dispatch(updateAttachment({
taskId: attachment.taskId!,
updates: { status: 'failed' }
}));
} else if (data.status === 'PROGRESS' && data.result?.current) {
dispatch(updateAttachment({
taskId: attachment.taskId!,
updates: { progress: data.result.current }
}));
}
})
.catch((error) => {
console.error('Error checking task status:', error);
setUploads(prev => prev.map(u =>
u.taskId === upload.taskId
? { ...u, status: 'failed' }
: u
));
.catch(() => {
dispatch(updateAttachment({
taskId: attachment.taskId!,
updates: { status: 'failed' }
}));
});
});
};
if (uploads.some(upload => upload.status === 'processing')) {
const timeoutId = window.setTimeout(checkTaskStatus, 2000);
timeoutIds.push(timeoutId);
const interval = setInterval(() => {
if (attachments.some(att => att.status === 'processing')) {
checkTaskStatus();
}
}, 2000);
return () => {
timeoutIds.forEach(id => clearTimeout(id));
};
}, [uploads]);
return () => clearInterval(interval);
}, [attachments, dispatch]);
const handleInput = () => {
if (inputRef.current) {
@@ -215,39 +230,53 @@ export default function MessageInput({
const handleSubmit = () => {
const completedAttachments = uploads
.filter(upload => upload.status === 'completed' && upload.attachment_id)
.map(upload => ({
fileName: upload.fileName,
id: upload.attachment_id as string
}));
dispatch(setAttachments(completedAttachments));
onSubmit();
};
return (
<div className="flex flex-col w-full mx-2">
<div className="flex flex-col w-full rounded-[23px] border dark:border-grey border-dark-gray bg-lotion dark:bg-transparent relative">
<div className="flex flex-wrap gap-1.5 sm:gap-2 px-4 sm:px-6 pt-3 pb-0">
{uploads.map((upload, index) => (
{attachments.map((attachment, index) => (
<div
key={index}
className="flex items-center px-2 sm:px-3 py-1 sm:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe bg-white dark:bg-[#1F2028] text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray"
className={`flex items-center px-2 sm:px-3 py-1 sm:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe bg-white dark:bg-[#1F2028] text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray group relative ${
attachment.status !== 'completed' ? 'opacity-70' : 'opacity-100'
}`}
title={attachment.fileName}
>
<span className="font-medium truncate max-w-[120px] sm:max-w-[150px]">{upload.fileName}</span>
<span className="font-medium truncate max-w-[120px] sm:max-w-[150px]">{attachment.fileName}</span>
{upload.status === 'completed' && (
<span className="ml-2 text-green-500"></span>
{attachment.status === 'completed' && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity bg-white dark:bg-[#1F2028] rounded-full p-1 hover:bg-white/95 dark:hover:bg-[#1F2028]/95"
onClick={() => {
if (attachment.id) {
dispatch(removeAttachment(attachment.id));
}
}}
aria-label="Remove attachment"
>
<img
src={ExitIcon}
alt="Remove"
className="w-2.5 h-2.5 filter dark:invert"
/>
</button>
)}
{upload.status === 'failed' && (
<span className="ml-2 text-red-500"></span>
{attachment.status === 'failed' && (
<img
src={AlertIcon}
alt="Upload failed"
className="ml-2 w-3.5 h-3.5"
title="Upload failed"
/>
)}
{(upload.status === 'uploading' || upload.status === 'processing') && (
{(attachment.status === 'uploading' || attachment.status === 'processing') && (
<div className="ml-2 w-4 h-4 relative">
<svg className="w-4 h-4" viewBox="0 0 24 24">
{/* Background circle */}
<circle
className="text-gray-200 dark:text-gray-700"
cx="12"
@@ -266,7 +295,7 @@ export default function MessageInput({
strokeWidth="4"
fill="none"
strokeDasharray="62.83"
strokeDashoffset={62.83 - (upload.progress / 100) * 62.83}
strokeDashoffset={62.83 * (1 - attachment.progress / 100)}
transform="rotate(-90 12 12)"
/>
</svg>
@@ -298,15 +327,21 @@ export default function MessageInput({
<div className="flex-grow flex flex-wrap gap-1 sm:gap-2">
<button
ref={sourceButtonRef}
className="flex items-center px-2 xs:px-3 py-1 xs:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe hover:bg-gray-100 dark:hover:bg-[#2C2E3C] transition-colors max-w-[130px] xs:max-w-[150px]"
className="flex items-center px-2 xs:px-3 py-1 xs:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe hover:bg-gray-100 dark:hover:bg-[#2C2E3C] transition-colors max-w-[130px] sm:max-w-[150px]"
onClick={() => setIsSourcesPopupOpen(!isSourcesPopupOpen)}
title={selectedDocs ? selectedDocs.name : t('conversation.sources.title')}
>
<img src={SourceIcon} alt="Sources" className="w-3.5 sm:w-4 h-3.5 sm:h-4 mr-1 sm:mr-1.5 flex-shrink-0" />
<img src={SourceIcon} alt="Sources" className="w-3.5 h-3.5 sm:h-4 mr-1 sm:mr-1.5 flex-shrink-0" />
<span className="text-[10px] xs:text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray font-medium truncate overflow-hidden">
{selectedDocs
? selectedDocs.name
: t('conversation.sources.title')}
</span>
{!isTouch && (
<span className="hidden sm:inline-block ml-1 text-[10px] text-gray-500 dark:text-gray-400">
{browserOS === 'mac' ? '(⌘K)' : '(ctrl+K)'}
</span>
)}
</button>
<button

View File

@@ -207,7 +207,7 @@ export default function SourcesPopup({
<div className="px-4 md:px-6 py-4 opacity-75 hover:opacity-100 transition-opacity duration-200 flex-shrink-0">
<a
href="/settings/documents"
className="text-violets-are-blue text-base font-medium flex items-center gap-2"
className="text-violets-are-blue text-base font-medium inline-flex items-center gap-2"
onClick={onClose}
>
Go to Documents

View File

@@ -217,10 +217,10 @@ export default function ToolsPopup({
</div>
)}
<div className="p-4 flex-shrink-0">
<div className="p-4 flex-shrink-0 opacity-75 hover:opacity-100 transition-opacity duration-200">
<a
href="/settings/tools"
className="text-base text-purple-30 font-medium hover:text-violets-are-blue flex items-center"
className="text-base text-purple-30 font-medium inline-flex items-center"
>
{t('settings.tools.manageTools')}
<img

View File

@@ -57,7 +57,6 @@ const ConversationBubble = forwardRef<
updated?: boolean,
index?: number,
) => void;
attachments?: { fileName: string; id: string }[];
}
>(function ConversationBubble(
{
@@ -72,7 +71,6 @@ const ConversationBubble = forwardRef<
retryBtn,
questionNumber,
handleUpdatedQuestionSubmission,
attachments,
},
ref,
) {
@@ -99,36 +97,6 @@ const ConversationBubble = forwardRef<
handleUpdatedQuestionSubmission?.(editInputBox, true, questionNumber);
};
let bubble;
const renderAttachments = () => {
if (!attachments || attachments.length === 0) return null;
return (
<div className="mt-2 flex flex-wrap gap-2">
{attachments.map((attachment, index) => (
<div
key={index}
className="flex items-center rounded-md bg-gray-100 px-2 py-1 text-sm dark:bg-gray-700"
>
<svg
className="mr-1 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
/>
</svg>
<span>{attachment.fileName}</span>
</div>
))}
</div>
);
};
if (type === 'QUESTION') {
bubble = (
<div
@@ -157,7 +125,6 @@ const ConversationBubble = forwardRef<
>
{message}
</div>
{renderAttachments()}
</div>
<button
onClick={() => {

View File

@@ -161,7 +161,6 @@ export default function ConversationMessages({
{queries.length > 0 ? (
queries.map((query, index) => (
<Fragment key={index}>
<ConversationBubble
className={'first:mt-5'}
key={`${index}QUESTION`}
@@ -170,7 +169,6 @@ export default function ConversationMessages({
handleUpdatedQuestionSubmission={handleQuestionSubmission}
questionNumber={index}
sources={query.sources}
attachments={query.attachments}
/>
{prepResponseView(query, index)}
</Fragment>

View File

@@ -9,11 +9,21 @@ export interface Message {
type: MESSAGE_TYPE;
}
export interface Attachment {
id?: string;
fileName: string;
status: 'uploading' | 'processing' | 'completed' | 'failed';
progress: number;
taskId?: string;
token_count?: number;
}
export interface ConversationState {
queries: Query[];
status: Status;
conversationId: string | null;
attachments?: { fileName: string; id: string }[];
attachments: Attachment[];
}
export interface Answer {

View File

@@ -7,7 +7,7 @@ import {
handleFetchAnswer,
handleFetchAnswerSteaming,
} from './conversationHandlers';
import { Answer, ConversationState, Query, Status } from './conversationModels';
import { Answer, Query, Status, ConversationState, Attachment } from './conversationModels';
const initialState: ConversationState = {
queries: [],
@@ -38,7 +38,9 @@ export const fetchAnswer = createAsyncThunk<
let isSourceUpdated = false;
const state = getState() as RootState;
const attachments = state.conversation.attachments?.map(a => a.id) || [];
const attachmentIds = state.conversation.attachments
.filter(a => a.id && a.status === 'completed')
.map(a => a.id) as string[];
if (state.preference) {
if (API_STREAMING) {
@@ -122,7 +124,7 @@ export const fetchAnswer = createAsyncThunk<
}
},
indx,
attachments
attachmentIds
);
} else {
const answer = await handleFetchAnswer(
@@ -135,7 +137,7 @@ export const fetchAnswer = createAsyncThunk<
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
attachments
attachmentIds
);
if (answer) {
let sourcesPrepped = [];
@@ -286,9 +288,29 @@ export const conversationSlice = createSlice({
const { index, message } = action.payload;
state.queries[index].error = message;
},
setAttachments: (state, action: PayloadAction<{ fileName: string; id: string }[]>) => {
setAttachments: (state, action: PayloadAction<Attachment[]>) => {
state.attachments = action.payload;
},
addAttachment: (state, action: PayloadAction<Attachment>) => {
state.attachments.push(action.payload);
},
updateAttachment: (state, action: PayloadAction<{
taskId: string;
updates: Partial<Attachment>;
}>) => {
const index = state.attachments.findIndex(att => att.taskId === action.payload.taskId);
if (index !== -1) {
state.attachments[index] = {
...state.attachments[index],
...action.payload.updates
};
}
},
removeAttachment: (state, action: PayloadAction<string>) => {
state.attachments = state.attachments.filter(att =>
att.taskId !== action.payload && att.id !== action.payload
);
},
},
extraReducers(builder) {
builder
@@ -312,6 +334,10 @@ export const selectQueries = (state: RootState) => state.conversation.queries;
export const selectStatus = (state: RootState) => state.conversation.status;
export const selectAttachments = (state: RootState) => state.conversation.attachments;
export const selectCompletedAttachments = (state: RootState) =>
state.conversation.attachments.filter(att => att.status === 'completed');
export const {
addQuery,
updateQuery,
@@ -323,5 +349,8 @@ export const {
updateToolCalls,
setConversation,
setAttachments,
addAttachment,
updateAttachment,
removeAttachment,
} = conversationSlice.actions;
export default conversationSlice.reducer;

View File

@@ -0,0 +1,10 @@
export function getOS() {
const userAgent = window.navigator.userAgent;
if (userAgent.indexOf('Mac') !== -1) return 'mac';
if (userAgent.indexOf('Win') !== -1) return 'win';
return 'linux';
}
export function isTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
}