Merge pull request #1716 from ManishMadan2882/main

Sources + Tools
This commit is contained in:
Alex
2025-04-03 02:03:47 +03:00
committed by GitHub
32 changed files with 1316 additions and 259 deletions

View File

@@ -23,6 +23,7 @@ class BaseAgent(ABC):
prompt: str = "",
chat_history: Optional[List[Dict]] = None,
decoded_token: Optional[Dict] = None,
attachments: Optional[List[Dict]]=None,
):
self.endpoint = endpoint
self.llm_name = llm_name
@@ -43,6 +44,7 @@ class BaseAgent(ABC):
decoded_token=decoded_token,
)
self.llm_handler = get_llm_handler(llm_name)
self.attachments = attachments or []
@log_activity()
def gen(
@@ -241,8 +243,9 @@ class BaseAgent(ABC):
tools_dict: Dict,
messages: List[Dict],
log_context: Optional[LogContext] = None,
attachments: Optional[List[Dict]] = None
):
resp = self.llm_handler.handle_response(self, resp, tools_dict, messages)
resp = self.llm_handler.handle_response(self, resp, tools_dict, messages, attachments)
if log_context:
data = build_stack_data(self.llm_handler)
log_context.stacks.append({"component": "llm_handler", "data": data})

View File

@@ -4,7 +4,8 @@ from application.agents.base import BaseAgent
from application.logging import LogContext
from application.retriever.base import BaseRetriever
import logging
logger = logging.getLogger(__name__)
class ClassicAgent(BaseAgent):
def _gen_inner(
@@ -18,6 +19,8 @@ class ClassicAgent(BaseAgent):
messages = self._build_messages(self.prompt, query, retrieved_data)
resp = self._llm_gen(messages, log_context)
attachments = self.attachments
if isinstance(resp, str):
yield {"answer": resp}
@@ -30,7 +33,7 @@ class ClassicAgent(BaseAgent):
yield {"answer": resp.message.content}
return
resp = self._llm_handler(resp, tools_dict, messages, log_context)
resp = self._llm_handler(resp, tools_dict, messages, log_context,attachments)
if isinstance(resp, str):
yield {"answer": resp}

View File

@@ -1,8 +1,11 @@
import json
import logging
from abc import ABC, abstractmethod
from application.logging import build_stack_data
logger = logging.getLogger(__name__)
class LLMHandler(ABC):
def __init__(self):
@@ -10,12 +13,61 @@ class LLMHandler(ABC):
self.tool_calls = []
@abstractmethod
def handle_response(self, agent, resp, tools_dict, messages, **kwargs):
def handle_response(self, agent, resp, tools_dict, messages, attachments=None, **kwargs):
pass
def prepare_messages_with_attachments(self, agent, messages, attachments=None):
"""
Prepare messages with attachment content if available.
Args:
agent: The current agent instance.
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.
"""
if not attachments:
return messages
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)
# Otherwise, append attachment content 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")
if 'content' in attachment:
attachment_texts.append(f"Attached file content:\n\n{attachment['content']}")
if attachment_texts:
combined_attachment_text = "\n\n".join(attachment_texts)
system_found = False
for i in range(len(prepared_messages)):
if prepared_messages[i].get("role") == "system":
prepared_messages[i]["content"] += f"\n\n{combined_attachment_text}"
system_found = True
break
if not system_found:
prepared_messages.insert(0, {"role": "system", "content": combined_attachment_text})
return prepared_messages
class OpenAILLMHandler(LLMHandler):
def handle_response(self, agent, resp, tools_dict, messages, stream: bool = True):
def handle_response(self, agent, resp, tools_dict, messages, attachments=None, stream: bool = True):
messages = self.prepare_messages_with_attachments(agent, messages, attachments)
logger.info(f"Messages with attachments: {messages}")
if not stream:
while hasattr(resp, "finish_reason") and resp.finish_reason == "tool_calls":
message = json.loads(resp.model_dump_json())["message"]
@@ -54,6 +106,7 @@ class OpenAILLMHandler(LLMHandler):
{"role": "tool", "content": [function_response_dict]}
)
messages = self.prepare_messages_with_attachments(agent, messages, attachments)
except Exception as e:
messages.append(
{
@@ -69,6 +122,7 @@ class OpenAILLMHandler(LLMHandler):
return resp
else:
while True:
tool_calls = {}
for chunk in resp:
@@ -160,7 +214,8 @@ class OpenAILLMHandler(LLMHandler):
return
elif isinstance(chunk, str) and len(chunk) == 0:
continue
logger.info(f"Regenerating with messages: {messages}")
resp = agent.llm.gen_stream(
model=agent.gpt_model, messages=messages, tools=agent.tools
)
@@ -168,8 +223,10 @@ class OpenAILLMHandler(LLMHandler):
class GoogleLLMHandler(LLMHandler):
def handle_response(self, agent, resp, tools_dict, messages, stream: bool = True):
def handle_response(self, agent, resp, tools_dict, messages, attachments=None, stream: bool = True):
from google.genai import types
messages = self.prepare_messages_with_attachments(agent, messages, attachments)
while True:
if not stream:

View File

@@ -29,6 +29,7 @@ sources_collection = db["sources"]
prompts_collection = db["prompts"]
api_key_collection = db["api_keys"]
user_logs_collection = db["user_logs"]
attachments_collection = db["attachments"]
answer = Blueprint("answer", __name__)
answer_ns = Namespace("answer", description="Answer related operations", path="/")
@@ -127,7 +128,7 @@ def save_conversation(
llm,
decoded_token,
index=None,
api_key=None,
api_key=None
):
current_time = datetime.datetime.now(datetime.timezone.utc)
if conversation_id is not None and index is not None:
@@ -232,9 +233,15 @@ def complete_stream(
isNoneDoc=False,
index=None,
should_save_conversation=True,
attachments=None,
):
try:
response_full, thought, source_log_docs, tool_calls = "", "", [], []
attachment_ids = []
if attachments:
attachment_ids = [attachment["id"] for attachment in attachments]
logger.info(f"Processing request with {len(attachments)} attachments: {attachment_ids}")
answer = agent.gen(query=question, retriever=retriever)
@@ -287,7 +294,7 @@ def complete_stream(
llm,
decoded_token,
index,
api_key=user_api_key,
api_key=user_api_key
)
else:
conversation_id = None
@@ -307,6 +314,7 @@ def complete_stream(
"response": response_full,
"sources": source_log_docs,
"retriever_params": retriever_params,
"attachments": attachment_ids,
"timestamp": datetime.datetime.now(datetime.timezone.utc),
}
)
@@ -355,10 +363,13 @@ class Stream(Resource):
required=False, description="Flag indicating if no document is used"
),
"index": fields.Integer(
required=False, description="The position where query is to be updated"
required=False, description="Index of the query to update"
),
"save_conversation": fields.Boolean(
required=False, default=True, description="Flag to save conversation"
required=False, default=True, description="Whether to save the conversation"
),
"attachments": fields.List(
fields.String, required=False, description="List of attachment IDs"
),
},
)
@@ -383,6 +394,7 @@ class Stream(Resource):
)
conversation_id = data.get("conversation_id")
prompt_id = data.get("prompt_id", "default")
attachment_ids = data.get("attachments", [])
index = data.get("index", None)
chunks = int(data.get("chunks", 2))
@@ -411,9 +423,11 @@ class Stream(Resource):
if not decoded_token:
return make_response({"error": "Unauthorized"}, 401)
attachments = get_attachments_content(attachment_ids, decoded_token.get("sub"))
logger.info(
f"/stream - request_data: {data}, source: {source}",
f"/stream - request_data: {data}, source: {source}, attachments: {len(attachments)}",
extra={"data": json.dumps({"request_data": data, "source": source})},
)
@@ -431,6 +445,7 @@ class Stream(Resource):
prompt=prompt,
chat_history=history,
decoded_token=decoded_token,
attachments=attachments,
)
retriever = RetrieverCreator.create_retriever(
@@ -791,3 +806,38 @@ class Search(Resource):
return bad_request(500, str(e))
return make_response(docs, 200)
def get_attachments_content(attachment_ids, user):
"""
Retrieve content from attachment documents based on their IDs.
Args:
attachment_ids (list): List of attachment document IDs
user (str): User identifier to verify ownership
Returns:
list: List of dictionaries containing attachment content and metadata
"""
if not attachment_ids:
return []
attachments = []
for attachment_id in attachment_ids:
try:
attachment_doc = attachments_collection.find_one({
"_id": ObjectId(attachment_id),
"user": 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", "")
})
except Exception as e:
logger.error(f"Error retrieving attachment {attachment_id}: {e}")
return attachments

View File

@@ -14,7 +14,7 @@ from werkzeug.utils import secure_filename
from application.agents.tools.tool_manager import ToolManager
from application.api.user.tasks import ingest, ingest_remote
from application.api.user.tasks import ingest, ingest_remote, store_attachment
from application.core.mongo_db import MongoDB
from application.core.settings import settings
from application.extensions import api
@@ -2476,3 +2476,71 @@ class UpdateChunk(Resource):
except Exception as e:
current_app.logger.error(f"Error updating chunk: {e}")
return make_response(jsonify({"success": False}), 500)
@user_ns.route("/api/store_attachment")
class StoreAttachment(Resource):
@api.expect(
api.model(
"AttachmentModel",
{
"file": fields.Raw(required=True, description="File to upload"),
},
)
)
@api.doc(description="Stores a single attachment without vectorization or training")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
# Get single file instead of list
file = request.files.get("file")
if not file or file.filename == "":
return make_response(
jsonify({"status": "error", "message": "Missing file"}),
400,
)
user = secure_filename(decoded_token.get("sub"))
try:
original_filename = secure_filename(file.filename)
folder_name = original_filename
save_dir = os.path.join(current_dir, settings.UPLOAD_FOLDER, user, "attachments",folder_name)
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}
current_app.logger.info(f"Saved file: {file_path}")
# Start async task to process single file
task = store_attachment.delay(
save_dir,
file_info,
user
)
return make_response(
jsonify({
"success": True,
"task_id": task.id,
"message": "File uploaded successfully. Processing started."
}),
200
)
except Exception as err:
current_app.logger.error(f"Error storing attachment: {err}")
return make_response(jsonify({"success": False, "error": str(err)}), 400)

View File

@@ -1,7 +1,7 @@
from datetime import timedelta
from application.celery_init import celery
from application.worker import ingest_worker, remote_worker, sync_worker
from application.worker import ingest_worker, remote_worker, sync_worker, attachment_worker
@celery.task(bind=True)
@@ -22,6 +22,12 @@ def schedule_syncs(self, frequency):
return resp
@celery.task(bind=True)
def store_attachment(self, directory, saved_files, user):
resp = attachment_worker(self, directory, saved_files, user)
return resp
@celery.on_after_configure.connect
def setup_periodic_tasks(sender, **kwargs):
sender.add_periodic_task(

View File

@@ -312,3 +312,85 @@ def sync_worker(self, frequency):
key: sync_counts[key]
for key in ["total_sync_count", "sync_success", "sync_failure"]
}
def attachment_worker(self, directory, file_info, user):
"""
Process and store a single attachment without vectorization.
Args:
self: Reference to the instance of the task.
directory (str): Base directory for storing files.
file_info (dict): Dictionary with folder and filename info.
user (str): User identifier.
Returns:
dict: Information about processed attachment.
"""
import datetime
import os
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})
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"}
try:
reader = SimpleDirectoryReader(
input_files=[file_path]
)
documents = reader.load_data()
self.update_state(state="PROGRESS", meta={"current": 50})
if documents:
content = documents[0].text
token_count = num_tokens_from_string(content)
file_path_relative = f"{user}/attachments/{folder_name}/{filename}"
attachment_id = attachments_collection.insert_one({
"user": user,
"path": file_path_relative,
"content": content,
"token_count": token_count,
"date": datetime.datetime.now(),
}).inserted_id
logging.info(f"Stored attachment with ID: {attachment_id}",
extra={"user": user, "job": job_name})
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
}
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"}
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)}"}

View File

@@ -63,7 +63,7 @@ export default function App() {
>
<Route index element={<Conversation />} />
<Route path="/about" element={<About />} />
<Route path="/settings" element={<Setting />} />
<Route path="/settings/*" element={<Setting />} />
</Route>
<Route path="/share/:identifier" element={<SharedConversation />} />
<Route path="/*" element={<PageNotFound />} />

View File

@@ -18,7 +18,6 @@ import Spinner from './assets/spinner.svg';
import Twitter from './assets/TwitterX.svg';
import UploadIcon from './assets/upload.svg';
import Help from './components/Help';
import SourceDropdown from './components/SourceDropdown';
import {
handleAbort,
selectQueries,
@@ -31,22 +30,16 @@ import useDefaultDocument from './hooks/useDefaultDocument';
import useTokenAuth from './hooks/useTokenAuth';
import DeleteConvModal from './modals/DeleteConvModal';
import JWTModal from './modals/JWTModal';
import { ActiveState, Doc } from './models/misc';
import { getConversations, getDocs } from './preferences/preferenceApi';
import { ActiveState } from './models/misc';
import { getConversations } from './preferences/preferenceApi';
import {
selectApiKeyStatus,
selectConversationId,
selectConversations,
selectModalStateDeleteConv,
selectPaginatedDocuments,
selectSelectedDocs,
selectSourceDocs,
selectToken,
setConversations,
setModalStateDeleteConv,
setPaginatedDocuments,
setSelectedDocs,
setSourceDocs,
} from './preferences/preferenceSlice';
import Upload from './upload/Upload';
@@ -59,17 +52,13 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
const dispatch = useDispatch();
const token = useSelector(selectToken);
const queries = useSelector(selectQueries);
const docs = useSelector(selectSourceDocs);
const selectedDocs = useSelector(selectSelectedDocs);
const conversations = useSelector(selectConversations);
const modalStateDeleteConv = useSelector(selectModalStateDeleteConv);
const conversationId = useSelector(selectConversationId);
const paginatedDocuments = useSelector(selectPaginatedDocuments);
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
const { isMobile } = useMediaQuery();
const [isDarkTheme] = useDarkTheme();
const [isDocsListOpen, setIsDocsListOpen] = useState(false);
const { t } = useTranslation();
const isApiKeySet = useSelector(selectApiKeyStatus);
@@ -124,32 +113,6 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
.catch((error) => console.error(error));
};
const handleDeleteClick = (doc: Doc) => {
userService
.deletePath(doc.id ?? '', token)
.then(() => {
return getDocs(token);
})
.then((updatedDocs) => {
dispatch(setSourceDocs(updatedDocs));
const updatedPaginatedDocs = paginatedDocuments?.filter(
(document) => document.id !== doc.id,
);
dispatch(
setPaginatedDocuments(updatedPaginatedDocs || paginatedDocuments),
);
dispatch(
setSelectedDocs(
Array.isArray(updatedDocs) &&
updatedDocs?.find(
(doc: Doc) => doc.name.toLowerCase() === 'default',
),
),
);
})
.catch((error) => console.error(error));
};
const handleConversationClick = (index: string) => {
conversationService
.getConversation(index, token)
@@ -174,11 +137,13 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
}),
);
};
const newChat = () => {
if (queries && queries?.length > 0) {
resetConversation();
}
};
async function updateConversationName(updatedConversation: {
name: string;
id: string;
@@ -197,10 +162,6 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
});
}
/*
Needed to fix bug where if mobile nav was closed and then window was resized to desktop, nav would still be closed but the button to open would be gone, as per #1 on issue #146
*/
useEffect(() => {
setNavOpen(!isMobile);
}, [isMobile]);
@@ -209,7 +170,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
return (
<>
{!navOpen && (
<div className="duration-25 absolute top-3 left-3 z-20 hidden transition-all md:block">
<div className="duration-25 absolute top-3 left-3 z-20 hidden transition-all md:block">
<div className="flex gap-3 items-center">
<button
onClick={() => {
@@ -247,7 +208,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
ref={navRef}
className={`${
!navOpen && '-ml-96 md:-ml-[18rem]'
} duration-20 fixed top-0 z-20 flex h-full w-72 flex-col border-r-[1px] border-b-0 bg-lotion dark:bg-chinese-black transition-all dark:border-r-purple-taupe dark:text-white`}
} duration-20 fixed top-0 z-20 flex h-full w-72 flex-col border-r-[1px] border-b-0 bg-lotion dark:bg-chinese-black transition-all dark:border-r-purple-taupe dark:text-white`}
>
<div
className={'visible mt-2 flex h-[6vh] w-full justify-between md:h-12'}
@@ -299,7 +260,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
alt="Create new chat"
className="opacity-80 group-hover:opacity-100"
/>
<p className=" text-sm text-dove-gray group-hover:text-neutral-600 dark:text-chinese-silver dark:group-hover:text-bright-gray">
<p className="text-sm text-dove-gray group-hover:text-neutral-600 dark:text-chinese-silver dark:group-hover:text-bright-gray">
{t('newChat')}
</p>
</NavLink>
@@ -318,7 +279,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
)}
{conversations?.data && conversations.data.length > 0 ? (
<div>
<div className=" my-auto mx-4 mt-2 flex h-6 items-center justify-between gap-4 rounded-3xl">
<div className="my-auto mx-4 mt-2 flex h-6 items-center justify-between gap-4 rounded-3xl">
<p className="mt-1 ml-4 text-sm font-semibold">{t('chats')}</p>
</div>
<div className="conversations-container">
@@ -345,37 +306,6 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
)}
</div>
<div className="flex h-auto flex-col justify-end text-eerie-black dark:text-white">
<div className="flex flex-col-reverse border-b-[1px] dark:border-b-purple-taupe">
<div className="relative my-4 mx-4 flex gap-4 items-center">
<SourceDropdown
options={docs}
selectedDocs={selectedDocs}
setSelectedDocs={setSelectedDocs}
isDocsListOpen={isDocsListOpen}
setIsDocsListOpen={setIsDocsListOpen}
handleDeleteClick={handleDeleteClick}
handlePostDocumentSelect={(option?: string) => {
if (isMobile) {
setNavOpen(!navOpen);
}
}}
/>
<img
className="hover:cursor-pointer"
src={UploadIcon}
width={28}
height={25}
alt="Upload document"
onClick={() => {
setUploadModalState('ACTIVE');
if (isMobile) {
setNavOpen(!navOpen);
}
}}
></img>
</div>
<p className="ml-5 mt-3 text-sm font-semibold">{t('sourceDocs')}</p>
</div>
<div className="flex flex-col gap-2 border-b-[1px] py-2 dark:border-b-purple-taupe">
<NavLink
onClick={() => {

View File

@@ -32,6 +32,7 @@ const endpoints = {
DELETE_CHUNK: (docId: string, chunkId: string) =>
`/api/delete_chunk?id=${docId}&chunk_id=${chunkId}`,
UPDATE_CHUNK: '/api/update_chunk',
STORE_ATTACHMENT: '/api/store_attachment',
},
CONVERSATION: {
ANSWER: '/api/answer',

View File

@@ -0,0 +1,3 @@
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.08485 4.07133L3.08485 9.20109C3.08485 9.49263 3.20066 9.77222 3.40681 9.97837C3.61295 10.1845 3.89255 10.3003 4.18408 10.3003C4.47562 10.3003 4.75521 10.1845 4.96136 9.97837C5.1675 9.77222 5.28332 9.49263 5.28332 9.20109L5.28332 3.70492C5.28332 3.12185 5.05169 2.56266 4.6394 2.15037C4.22711 1.73808 3.66792 1.50645 3.08485 1.50645C2.50178 1.50645 1.94259 1.73808 1.5303 2.15037C1.118 2.56266 0.88638 3.12185 0.88638 3.70492L0.886379 9.20109C0.886379 10.0757 1.23381 10.9145 1.85225 11.5329C2.47069 12.1514 3.30948 12.4988 4.18408 12.4988C5.05869 12.4988 5.89747 12.1514 6.51591 11.5329C7.13435 10.9145 7.48178 10.0757 7.48178 9.20109L7.48178 4.07133" stroke="#5D5D5D" stroke-width="1.03637" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 854 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.6294 0.371048C15.4618 0.204652 15.2516 0.0876771 15.0218 0.0329776C14.7921 -0.0217218 14.5517 -0.012028 14.3271 0.0609937L1.36688 4.38403C1.12652 4.4602 0.914122 4.60588 0.756509 4.80269C0.598897 4.9995 0.503143 5.2386 0.481341 5.48979C0.459539 5.74099 0.512667 5.99301 0.634015 6.21403C0.755364 6.43505 0.939488 6.61515 1.16313 6.73159L6.54036 9.38919L9.19796 14.7841C9.30478 14.9953 9.46824 15.1726 9.67006 15.2962C9.87187 15.4198 10.1041 15.4848 10.3407 15.484H10.4293C10.6828 15.4653 10.9247 15.3708 11.1238 15.2129C11.3228 15.0549 11.4698 14.8407 11.5455 14.5981L15.9306 1.67327C16.0089 1.44998 16.0221 1.20903 15.9688 0.978485C15.9155 0.747942 15.7978 0.537288 15.6294 0.371048ZM1.91612 5.60653L13.2287 1.83273L6.94786 8.11354L1.91612 5.60653ZM10.4027 14.0843L7.88688 9.05256L14.1677 2.77175L10.4027 14.0843Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 947 B

View File

@@ -0,0 +1,3 @@
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.22066 0.46934C5.36129 0.328889 5.55191 0.25 5.75066 0.25C5.94941 0.25 6.14003 0.328889 6.28066 0.46934L10.5307 4.71934C10.6711 4.85997 10.75 5.05059 10.75 5.24934C10.75 5.44809 10.6711 5.63871 10.5307 5.77934L6.28066 10.0293C6.13851 10.162 5.95041 10.2342 5.75602 10.2309C5.56163 10.2275 5.37614 10.1488 5.23866 10.0113C5.10119 9.87386 5.02247 9.68837 5.01911 9.49398C5.01576 9.29959 5.08802 9.11149 5.22066 8.96934L8.19066 5.99934L0.75066 5.99934C0.551747 5.99934 0.360983 5.92032 0.22033 5.77967C0.0796773 5.63902 0.000659715 5.44825 0.000659724 5.24934C0.000659733 5.05043 0.0796774 4.85966 0.22033 4.71901C0.360983 4.57836 0.551747 4.49934 0.75066 4.49934L8.19066 4.49934L5.22066 1.52934C5.08021 1.38871 5.00132 1.19809 5.00132 0.99934C5.00132 0.800589 5.08021 0.609965 5.22066 0.46934Z" fill="#7D54D1"/>
</svg>

After

Width:  |  Height:  |  Size: 924 B

View File

@@ -0,0 +1,4 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 10.5994V3.39941M11.6 3.39941V10.5994" stroke="#5D5D5D" stroke-width="0.9" stroke-linecap="round"/>
<path d="M11.6 7C11.6 8.326 9.4508 9.4 6.8 9.4C4.1492 9.4 2 8.326 2 7M11.6 10.6C11.6 11.926 9.4508 13 6.8 13C4.1492 13 2 11.926 2 10.6M6.8 5.8C9.4508 5.8 11.6 4.726 11.6 3.4C11.6 2.074 9.4508 1 6.8 1C4.1492 1 2 2.074 2 3.4C2 4.726 4.1492 5.8 6.8 5.8Z" stroke="#5D5D5D" stroke-width="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 504 B

View File

@@ -0,0 +1,6 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.82047 15.07H3.23047C2.40047 15.07 1.73047 14.4 1.73047 13.57V10.98C1.73047 10.58 1.89047 10.2 2.17047 9.91999L4.38047 7.70999C4.58047 7.50999 4.89047 7.50999 5.09047 7.70999C5.29047 7.90999 5.29047 8.21999 5.09047 8.41999L2.88047 10.63C2.78626 10.7223 2.73235 10.8481 2.73047 10.98V13.57C2.73047 13.85 2.95047 14.07 3.23047 14.07H5.82047C5.95047 14.07 6.08047 14.02 6.17047 13.92L8.38047 11.71C8.58047 11.51 8.89047 11.51 9.09047 11.71C9.29047 11.91 9.29047 12.22 9.09047 12.42L6.88047 14.63C6.60047 14.91 6.22047 15.07 5.82047 15.07ZM12.8505 8.44999C12.7849 8.45079 12.7199 8.43786 12.6596 8.41202C12.5993 8.38619 12.5451 8.34803 12.5005 8.29999C12.3005 8.09999 12.3005 7.78999 12.5005 7.58999L14.6005 5.48999C14.7905 5.29999 14.7905 4.97999 14.6005 4.77999L12.0105 2.18999C11.915 2.09844 11.7878 2.04732 11.6555 2.04732C11.5232 2.04732 11.396 2.09844 11.3005 2.18999L9.14047 4.34999C8.94047 4.54999 8.63047 4.54999 8.43047 4.34999C8.23047 4.14999 8.23047 3.83999 8.43047 3.63999L10.5905 1.47999C11.1605 0.90999 12.1405 0.90999 12.7105 1.47999L15.3005 4.06999C15.8805 4.64999 15.8805 5.60999 15.3005 6.18999L13.2005 8.28999C13.1005 8.38999 12.9705 8.43999 12.8505 8.43999V8.44999Z" fill="#5D5D5D"/>
<path d="M11.6305 15.0605C11.2305 15.0605 10.8505 14.9005 10.5705 14.6205L8.38047 12.4305C8.18047 12.2305 8.18047 11.9205 8.38047 11.7205C8.58047 11.5205 8.89047 11.5205 9.09047 11.7205L11.2805 13.9105C11.4705 14.1005 11.8005 14.1005 11.9905 13.9105L14.5805 11.3205C14.7705 11.1305 14.7705 10.8105 14.5805 10.6105L12.3905 8.42055C12.1905 8.22055 12.1905 7.91055 12.3905 7.71055C12.5905 7.51055 12.9005 7.51055 13.1005 7.71055L15.2905 9.90055C15.8705 10.4805 15.8705 11.4405 15.2905 12.0205L12.7005 14.6105C12.4205 14.8905 12.0405 15.0505 11.6405 15.0505L11.6305 15.0605Z" fill="#5D5D5D"/>
<path d="M8.98125 12.8195C8.91567 12.8203 8.85066 12.8073 8.79038 12.7815C8.7301 12.7557 8.6759 12.7175 8.63125 12.6695L2.09125 6.13945C1.51125 5.55945 1.51125 4.59945 2.09125 4.01945L4.68125 1.42945C5.25125 0.859453 6.23125 0.859453 6.80125 1.42945L13.3412 7.96945C13.5412 8.16945 13.5412 8.47945 13.3412 8.67945C13.1412 8.87945 12.8312 8.87945 12.6312 8.67945L6.09125 2.13945C5.99574 2.0479 5.86855 1.99678 5.73625 1.99678C5.60395 1.99678 5.47676 2.0479 5.38125 2.13945L2.79125 4.72945C2.60125 4.91945 2.60125 5.23945 2.79125 5.43945L9.33125 11.9795C9.53125 12.1795 9.53125 12.4895 9.33125 12.6895C9.23125 12.7895 9.10125 12.8395 8.98125 12.8395V12.8195Z" fill="#5D5D5D"/>
<path d="M6.28906 6.01055C6.22349 6.01135 6.15847 5.99841 6.09819 5.97258C6.03791 5.94675 5.98371 5.90858 5.93906 5.86055C5.73906 5.66055 5.73906 5.35055 5.93906 5.15055L7.87906 3.21055C8.07906 3.01055 8.38906 3.01055 8.58906 3.21055C8.78906 3.41055 8.78906 3.72055 8.58906 3.92055L6.64906 5.86055C6.54906 5.96055 6.41906 6.01055 6.29906 6.01055H6.28906ZM8.31906 8.04055C8.25349 8.04135 8.18847 8.02841 8.12819 8.00258C8.06791 7.97675 8.01371 7.93858 7.96906 7.89055C7.76906 7.69055 7.76906 7.38055 7.96906 7.18055L9.90906 5.24055C10.1091 5.04055 10.4191 5.04055 10.6191 5.24055C10.8191 5.44055 10.8191 5.75055 10.6191 5.95055L8.67906 7.89055C8.57906 7.99055 8.44906 8.04055 8.32906 8.04055H8.31906ZM10.3491 10.0705C10.2835 10.0713 10.2185 10.0584 10.1582 10.0326C10.0979 10.0067 10.0437 9.96858 9.99906 9.92055C9.79906 9.72055 9.79906 9.41055 9.99906 9.21055L11.9391 7.27055C12.1391 7.07055 12.4491 7.07055 12.6491 7.27055C12.8491 7.47055 12.8491 7.78055 12.6491 7.98055L10.7091 9.92055C10.6091 10.0205 10.4791 10.0705 10.3591 10.0705H10.3491Z" fill="#5D5D5D"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1,10 +1,21 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDarkTheme } from '../hooks';
import Send from '../assets/send.svg';
import SendDark from '../assets/send_dark.svg';
import { useSelector, useDispatch } from 'react-redux';
import userService from '../api/services/userService';
import endpoints from '../api/endpoints';
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 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';
interface MessageInputProps {
value: string;
@@ -13,6 +24,15 @@ interface MessageInputProps {
loading: boolean;
}
interface UploadState {
taskId: string;
fileName: string;
progress: number;
attachment_id?: string;
token_count?: number;
status: 'uploading' | 'processing' | 'completed' | 'failed';
}
export default function MessageInput({
value,
onChange,
@@ -22,6 +42,145 @@ export default function MessageInput({
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
const inputRef = useRef<HTMLTextAreaElement>(null);
const sourceButtonRef = useRef<HTMLButtonElement>(null);
const toolButtonRef = useRef<HTMLButtonElement>(null);
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 dispatch = useDispatch();
const handleFileAttachment = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
const file = e.target.files[0];
const formData = new FormData();
formData.append('file', file);
const apiHost = import.meta.env.VITE_API_HOST;
const xhr = new XMLHttpRequest();
const uploadState: UploadState = {
taskId: '',
fileName: file.name,
progress: 0,
status: 'uploading'
};
setUploads(prev => [...prev, uploadState]);
const uploadIndex = uploads.length;
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
));
}
});
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
));
}
} else {
setUploads(prev => prev.map((upload, index) =>
index === uploadIndex
? { ...upload, status: 'failed' }
: upload
));
console.error('Error uploading file:', xhr.responseText);
}
};
xhr.onerror = () => {
setUploads(prev => prev.map((upload, index) =>
index === uploadIndex
? { ...upload, status: 'failed' }
: upload
));
console.error('Network error during file upload');
};
xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.send(formData);
e.target.value = '';
};
useEffect(() => {
let timeoutIds: number[] = [];
const checkTaskStatus = () => {
const processingUploads = uploads.filter(upload =>
upload.status === 'processing' && upload.taskId
);
processingUploads.forEach(upload => {
userService
.getTaskStatus(upload.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,
status: 'completed',
progress: 100,
attachment_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);
}
})
.catch((error) => {
console.error('Error checking task status:', error);
setUploads(prev => prev.map(u =>
u.taskId === upload.taskId
? { ...u, status: 'failed' }
: u
));
});
});
};
if (uploads.some(upload => upload.status === 'processing')) {
const timeoutId = window.setTimeout(checkTaskStatus, 2000);
timeoutIds.push(timeoutId);
}
return () => {
timeoutIds.forEach(id => clearTimeout(id));
};
}, [uploads]);
const handleInput = () => {
if (inputRef.current) {
@@ -34,7 +193,6 @@ export default function MessageInput({
}
};
// Focus the textarea and set initial height on mount.
useEffect(() => {
inputRef.current?.focus();
handleInput();
@@ -43,7 +201,7 @@ export default function MessageInput({
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSubmit();
handleSubmit();
if (inputRef.current) {
inputRef.current.value = '';
handleInput();
@@ -51,43 +209,176 @@ export default function MessageInput({
}
};
const handlePostDocumentSelect = (doc: any) => {
console.log('Selected document:', doc);
};
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 w-full mx-2 items-center rounded-[40px] border dark:border-grey border-dark-gray bg-lotion dark:bg-charleston-green-3">
<label htmlFor="message-input" className="sr-only">
{t('inputPlaceholder')}
</label>
<textarea
id="message-input"
ref={inputRef}
value={value}
onChange={onChange}
tabIndex={1}
placeholder={t('inputPlaceholder')}
className="inputbox-style w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap rounded-full bg-lotion dark:bg-charleston-green-3 py-5 text-base leading-tight opacity-100 focus:outline-none dark:text-bright-gray dark:placeholder-bright-gray dark:placeholder-opacity-50 px-6"
onInput={handleInput}
onKeyDown={handleKeyDown}
aria-label={t('inputPlaceholder')}
/>
{loading ? (
<img
src={isDarkTheme ? SpinnerDark : Spinner}
className="relative right-[38px] bottom-[24px] -mr-[30px] animate-spin cursor-pointer self-end bg-transparent"
alt={t('loading')}
/>
) : (
<div className="mx-1 cursor-pointer rounded-full p-3 text-center hover:bg-gray-3000 dark:hover:bg-dark-charcoal">
<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) => (
<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"
>
<span className="font-medium truncate max-w-[120px] sm:max-w-[150px]">{upload.fileName}</span>
{upload.status === 'completed' && (
<span className="ml-2 text-green-500"></span>
)}
{upload.status === 'failed' && (
<span className="ml-2 text-red-500"></span>
)}
{(upload.status === 'uploading' || upload.status === 'processing') && (
<div className="ml-2 w-4 h-4 relative">
<svg className="w-4 h-4" viewBox="0 0 24 24">
<circle
className="text-gray-200 dark:text-gray-700"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<circle
className="text-blue-600 dark:text-blue-400"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
strokeDasharray="62.83"
strokeDashoffset={62.83 - (upload.progress / 100) * 62.83}
transform="rotate(-90 12 12)"
/>
</svg>
</div>
)}
</div>
))}
</div>
<div className="w-full">
<label htmlFor="message-input" className="sr-only">
{t('inputPlaceholder')}
</label>
<textarea
id="message-input"
ref={inputRef}
value={value}
onChange={onChange}
tabIndex={1}
placeholder={t('inputPlaceholder')}
className="inputbox-style w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap rounded-t-[23px] bg-lotion dark:bg-transparent py-3 sm:py-5 text-base leading-tight opacity-100 focus:outline-none dark:text-bright-gray dark:placeholder-bright-gray dark:placeholder-opacity-50 px-4 sm:px-6 no-scrollbar"
onInput={handleInput}
onKeyDown={handleKeyDown}
aria-label={t('inputPlaceholder')}
/>
</div>
<div className="flex items-center px-3 sm:px-4 py-1.5 sm:py-2">
<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]"
onClick={() => setIsSourcesPopupOpen(!isSourcesPopupOpen)}
>
<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" />
<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>
</button>
<button
ref={toolButtonRef}
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]"
onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)}
>
<img src={ToolIcon} alt="Tools" className="w-3.5 sm:w-4 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">
{t('settings.tools.label')}
</span>
</button>
<label 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 cursor-pointer">
<img src={ClipIcon} alt="Attach" className="w-3.5 sm:w-4 h-3.5 sm:h-4 mr-1 sm:mr-1.5" />
<span className="text-[10px] xs:text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray font-medium">
Attach
</span>
<input
type="file"
className="hidden"
onChange={handleFileAttachment}
/>
</label>
{/* Additional badges can be added here in the future */}
</div>
<button
onClick={onSubmit}
aria-label={t('send')}
className="flex items-center justify-center"
onClick={loading ? undefined : handleSubmit}
aria-label={loading ? t('loading') : t('send')}
className={`flex items-center justify-center p-2 sm:p-2.5 rounded-full ${loading ? 'bg-gray-300 dark:bg-gray-600' : 'bg-black dark:bg-white'} ml-auto flex-shrink-0`}
disabled={loading}
>
<img
className="ml-[4px] h-6 w-6 text-white filter dark:invert-[0.45] invert-[0.35]"
src={isDarkTheme ? SendDark : Send}
alt={t('send')}
/>
{loading ? (
<img
src={isDarkTheme ? SpinnerDark : Spinner}
className="w-3.5 sm:w-4 h-3.5 sm:h-4 animate-spin"
alt={t('loading')}
/>
) : (
<img
className={`w-3.5 sm:w-4 h-3.5 sm:h-4 ${isDarkTheme ? 'filter invert' : ''}`}
src={PaperPlane}
alt={t('send')}
/>
)}
</button>
</div>
</div>
<SourcesPopup
isOpen={isSourcesPopupOpen}
onClose={() => setIsSourcesPopupOpen(false)}
anchorRef={sourceButtonRef}
handlePostDocumentSelect={handlePostDocumentSelect}
setUploadModalState={setUploadModalState}
/>
<ToolsPopup
isOpen={isToolsPopupOpen}
onClose={() => setIsToolsPopupOpen(false)}
anchorRef={toolButtonRef}
/>
{uploadModalState === 'ACTIVE' && (
<Upload
receivedFile={[]}
setModalState={setUploadModalState}
isOnboarding={false}
renderTab={null}
close={() => setUploadModalState('INACTIVE')}
/>
)}
</div>
);

View File

@@ -0,0 +1,229 @@
import React, { useRef, useEffect, useState, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Doc } from '../models/misc';
import SourceIcon from '../assets/source.svg';
import CheckIcon from '../assets/checkmark.svg';
import RedirectIcon from '../assets/redirect.svg';
import Input from './Input';
import {
selectSourceDocs,
selectSelectedDocs,
setSelectedDocs,
} from '../preferences/preferenceSlice';
import { ActiveState } from '../models/misc';
type SourcesPopupProps = {
isOpen: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLButtonElement>;
handlePostDocumentSelect: (doc: Doc | null) => void;
setUploadModalState: React.Dispatch<React.SetStateAction<ActiveState>>;
};
export default function SourcesPopup({
isOpen,
onClose,
anchorRef,
handlePostDocumentSelect,
setUploadModalState,
}: SourcesPopupProps) {
const dispatch = useDispatch();
const { t } = useTranslation();
const popupRef = useRef<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState('');
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, maxHeight: 0, showAbove: false });
const embeddingsName =
import.meta.env.VITE_EMBEDDINGS_NAME ||
'huggingface_sentence-transformers/all-mpnet-base-v2';
const options = useSelector(selectSourceDocs);
const selectedDocs = useSelector(selectSelectedDocs);
const filteredOptions = options?.filter(option =>
option.name.toLowerCase().includes(searchTerm.toLowerCase())
);
useLayoutEffect(() => {
if (!isOpen || !anchorRef.current) return;
const updatePosition = () => {
if (!anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const spaceAbove = rect.top;
const spaceBelow = viewportHeight - rect.bottom;
const showAbove = spaceAbove > spaceBelow && spaceAbove >= 300;
const maxHeight = showAbove ? spaceAbove - 16 : spaceBelow - 16;
const left = Math.min(
rect.left,
viewportWidth - Math.min(480, viewportWidth * 0.95) - 10
);
setPopupPosition({
top: showAbove ? rect.top - 8 : rect.bottom + 8,
left,
maxHeight: Math.min(600, maxHeight),
showAbove
});
};
updatePosition();
window.addEventListener('resize', updatePosition);
return () => window.removeEventListener('resize', updatePosition);
}, [isOpen, anchorRef]);
const handleEmptyDocumentSelect = () => {
dispatch(setSelectedDocs(null));
handlePostDocumentSelect(null);
onClose();
};
const handleClickOutside = (event: MouseEvent) => {
if (
popupRef.current &&
!popupRef.current.contains(event.target as Node) &&
!anchorRef.current?.contains(event.target as Node)
) {
onClose();
}
};
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
if (!isOpen) return null;
const handleUploadClick = () => {
setUploadModalState('ACTIVE');
onClose();
};
return (
<div
ref={popupRef}
className="fixed z-50 bg-lotion dark:bg-charleston-green-2 rounded-xl shadow-[0px_9px_46px_8px_#0000001F,0px_24px_38px_3px_#00000024,0px_11px_15px_-7px_#00000033] flex flex-col"
style={{
top: popupPosition.showAbove ? popupPosition.top : undefined,
bottom: popupPosition.showAbove ? undefined : window.innerHeight - popupPosition.top,
left: popupPosition.left,
maxWidth: Math.min(480, window.innerWidth * 0.95),
width: '100%',
height: popupPosition.maxHeight,
transform: popupPosition.showAbove ? 'translateY(-100%)' : 'none',
}}
>
<div className="flex flex-col h-full">
<div className="px-4 md:px-6 py-4 flex-shrink-0">
<h2 className="text-lg font-bold text-[#141414] dark:text-bright-gray mb-4 dark:text-[20px]">
{t('conversation.sources.text')}
</h2>
<Input
id="source-search"
name="source-search"
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('settings.documents.searchPlaceholder')}
borderVariant="thin"
className="mb-4"
labelBgClassName="bg-lotion dark:bg-charleston-green-2"
/>
</div>
<div className="flex-grow overflow-y-auto mx-4 border border-[#D9D9D9] dark:border-dim-gray rounded-md [&::-webkit-scrollbar-thumb]:bg-[#888] [&::-webkit-scrollbar-thumb]:hover:bg-[#555] [&::-webkit-scrollbar-track]:bg-[#E2E8F0] dark:[&::-webkit-scrollbar-track]:bg-[#2C2E3C]">
{options ? (
<>
{filteredOptions?.map((option: any, index: number) => {
if (option.model === embeddingsName) {
return (
<div
key={index}
className="flex cursor-pointer items-center p-3 hover:bg-gray-100 dark:hover:bg-[#2C2E3C] transition-colors border-b border-[#D9D9D9] dark:border-dim-gray border-opacity-80 dark:text-[14px]"
onClick={() => {
dispatch(setSelectedDocs(option));
handlePostDocumentSelect(option);
onClose();
}}
>
<img
src={SourceIcon}
alt="Source"
width={14} height={14}
className="mr-3 flex-shrink-0"
/>
<span className="text-[#5D5D5D] dark:text-bright-gray font-medium flex-grow overflow-hidden overflow-ellipsis whitespace-nowrap mr-3">
{option.name}
</span>
<div className={`w-4 h-4 border flex-shrink-0 flex items-center justify-center p-[0.5px] dark:border-[#757783] border-[#C6C6C6]`}>
{selectedDocs &&
(option.id ?
selectedDocs.id === option.id : // For documents with MongoDB IDs
selectedDocs.date === option.date) && // For preloaded sources
<img
src={CheckIcon}
alt="Selected"
className="h-3 w-3"
/>
}
</div>
</div>
);
}
return null;
})}
<div
className="flex cursor-pointer items-center p-3 hover:bg-gray-100 dark:hover:bg-[#2C2E3C] transition-colors border-b border-[#D9D9D9] dark:border-dim-gray border-opacity-80 dark:text-[14px]"
onClick={handleEmptyDocumentSelect}
>
<img width={14} height={14} src={SourceIcon} alt="Source" className="mr-3 flex-shrink-0" />
<span className="text-[#5D5D5D] dark:text-bright-gray font-medium flex-grow mr-3">
{t('none')}
</span>
<div className={`w-4 h-4 border flex-shrink-0 flex items-center justify-center p-[0.5px] dark:border-[#757783] border-[#C6C6C6]`}>
{selectedDocs === null && (
<img src={CheckIcon} alt="Selected" className="h-3 w-3" />
)}
</div>
</div>
</>
) : (
<div className="p-4 text-center text-gray-500 dark:text-bright-gray dark:text-[14px]">
{t('noSourcesAvailable')}
</div>
)}
</div>
<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"
onClick={onClose}
>
Go to Documents
<img src={RedirectIcon} alt="Redirect" className="w-3 h-3" />
</a>
</div>
<div className="px-4 md:px-6 py-3 flex justify-start flex-shrink-0">
<button
onClick={handleUploadClick}
className="py-2 px-4 rounded-full border text-violets-are-blue hover:bg-violets-are-blue border-violets-are-blue hover:text-white transition-colors duration-200 text-[14px] font-medium w-auto"
>
Upload new
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,236 @@
import React, { useEffect, useRef, useState, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { selectToken } from '../preferences/preferenceSlice';
import userService from '../api/services/userService';
import { UserToolType } from '../settings/types';
import Input from './Input';
import RedirectIcon from '../assets/redirect.svg';
import NoFilesIcon from '../assets/no-files.svg';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import CheckmarkIcon from '../assets/checkmark.svg';
import { useDarkTheme } from '../hooks';
interface ToolsPopupProps {
isOpen: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLButtonElement>;
}
export default function ToolsPopup({
isOpen,
onClose,
anchorRef,
}: ToolsPopupProps) {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [userTools, setUserTools] = React.useState<UserToolType[]>([]);
const [loading, setLoading] = React.useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [isDarkTheme] = useDarkTheme();
const popupRef = useRef<HTMLDivElement>(null);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, maxHeight: 0, showAbove: false });
useLayoutEffect(() => {
if (!isOpen || !anchorRef.current) return;
const updatePosition = () => {
if (!anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const spaceAbove = rect.top;
const spaceBelow = viewportHeight - rect.bottom;
const showAbove = spaceAbove > spaceBelow && spaceAbove >= 300;
const maxHeight = showAbove ? spaceAbove - 16 : spaceBelow - 16;
const left = Math.min(
rect.left,
viewportWidth - Math.min(462, viewportWidth * 0.95) - 10
);
setPopupPosition({
top: showAbove ? rect.top - 8 : rect.bottom + 8,
left,
maxHeight: Math.min(600, maxHeight),
showAbove
});
};
updatePosition();
window.addEventListener('resize', updatePosition);
return () => window.removeEventListener('resize', updatePosition);
}, [isOpen, anchorRef]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
popupRef.current &&
!popupRef.current.contains(event.target as Node) &&
anchorRef.current &&
!anchorRef.current.contains(event.target as Node)
) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose, anchorRef, isOpen]);
useEffect(() => {
if (isOpen) {
getUserTools();
}
}, [isOpen, token]);
const getUserTools = () => {
setLoading(true);
userService
.getUserTools(token)
.then((res) => {
return res.json();
})
.then((data) => {
setUserTools(data.tools);
setLoading(false);
})
.catch((error) => {
console.error('Error fetching tools:', error);
setLoading(false);
});
};
const updateToolStatus = (toolId: string, newStatus: boolean) => {
userService
.updateToolStatus({ id: toolId, status: newStatus }, token)
.then(() => {
setUserTools((prevTools) =>
prevTools.map((tool) =>
tool.id === toolId ? { ...tool, status: newStatus } : tool,
),
);
})
.catch((error) => {
console.error('Failed to update tool status:', error);
});
};
if (!isOpen) return null;
const filteredTools = userTools.filter((tool) =>
tool.displayName.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div
ref={popupRef}
className="fixed z-[9999] rounded-lg border border-light-silver dark:border-dim-gray bg-lotion dark:bg-charleston-green-2 shadow-[0px_9px_46px_8px_#0000001F,0px_24px_38px_3px_#00000024,0px_11px_15px_-7px_#00000033]"
style={{
top: popupPosition.showAbove ? popupPosition.top : undefined,
bottom: popupPosition.showAbove ? undefined : window.innerHeight - popupPosition.top,
left: popupPosition.left,
maxWidth: Math.min(462, window.innerWidth * 0.95),
width: '100%',
height: popupPosition.maxHeight,
transform: popupPosition.showAbove ? 'translateY(-100%)' : 'none',
}}
>
<div className="flex flex-col h-full">
<div className="p-4 flex-shrink-0">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
{t('settings.tools.label')}
</h3>
<Input
id="tool-search"
name="tool-search"
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('settings.tools.searchPlaceholder')}
labelBgClassName="bg-lotion dark:bg-charleston-green-2"
borderVariant="thin"
className="mb-4"
/>
</div>
{loading ? (
<div className="flex justify-center py-4 flex-grow">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900 dark:border-white"></div>
</div>
) : (
<div className="mx-4 border border-[#D9D9D9] dark:border-dim-gray rounded-md overflow-hidden flex-grow">
<div className="h-full overflow-y-auto [&::-webkit-scrollbar-thumb]:bg-[#888] [&::-webkit-scrollbar-thumb]:hover:bg-[#555] [&::-webkit-scrollbar-track]:bg-[#E2E8F0] dark:[&::-webkit-scrollbar-track]:bg-[#2C2E3C]">
{filteredTools.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full py-8">
<img
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt="No tools found"
className="h-24 w-24 mx-auto mb-4"
/>
<p className="text-gray-500 dark:text-gray-400 text-center">
{t('settings.tools.noToolsFound')}
</p>
</div>
) : (
filteredTools.map((tool) => (
<div
key={tool.id}
onClick={() => updateToolStatus(tool.id, !tool.status)}
className="flex items-center justify-between p-3 border-b border-[#D9D9D9] dark:border-dim-gray hover:bg-gray-100 dark:hover:bg-charleston-green-3"
>
<div className="flex items-center flex-grow mr-3">
<img
src={`/toolIcons/tool_${tool.name}.svg`}
alt={`${tool.displayName} icon`}
className="h-5 w-5 mr-4 flex-shrink-0"
/>
<div className="overflow-hidden">
<p className="text-xs font-medium text-gray-900 dark:text-white overflow-hidden overflow-ellipsis whitespace-nowrap">
{tool.displayName}
</p>
</div>
</div>
<div className="flex items-center flex-shrink-0">
<div className={`w-4 h-4 border flex items-center justify-center p-[0.5px] dark:border-[#757783] border-[#C6C6C6]`}>
{tool.status && (
<img
src={CheckmarkIcon}
alt="Tool enabled"
width={12}
height={12}
/>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
)}
<div className="p-4 flex-shrink-0">
<a
href="/settings/tools"
className="text-base text-purple-30 font-medium hover:text-violets-are-blue flex items-center"
>
{t('settings.tools.manageTools')}
<img
src={RedirectIcon}
alt="Go to tools"
className="ml-2 h-[11px] w-[11px]"
/>
</a>
</div>
</div>
</div>
);
}

View File

@@ -103,12 +103,12 @@ export default function Conversation() {
}) => {
if (updated === true) {
!isRetry &&
dispatch(resendQuery({ index: indx as number, prompt: question })); //dispatch only new queries
dispatch(resendQuery({ index: indx as number, prompt: question }));
fetchStream.current = dispatch(fetchAnswer({ question, indx }));
} else {
question = question.trim();
if (question === '') return;
!isRetry && dispatch(addQuery({ prompt: question })); //dispatch only new queries
!isRetry && dispatch(addQuery({ prompt: question }));
fetchStream.current = dispatch(fetchAnswer({ question }));
}
};
@@ -160,7 +160,9 @@ export default function Conversation() {
isRetry: true,
});
} else {
handleQuestion({ question: input });
handleQuestion({
question: input,
});
}
setInput('');
}

View File

@@ -57,6 +57,7 @@ const ConversationBubble = forwardRef<
updated?: boolean,
index?: number,
) => void;
attachments?: { fileName: string; id: string }[];
}
>(function ConversationBubble(
{
@@ -71,6 +72,7 @@ const ConversationBubble = forwardRef<
retryBtn,
questionNumber,
handleUpdatedQuestionSubmission,
attachments,
},
ref,
) {
@@ -97,6 +99,36 @@ 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
@@ -105,7 +137,7 @@ const ConversationBubble = forwardRef<
>
<div
ref={ref}
className={`flex flex-row-reverse justify-items-start ${className}`}
className={`flex flex-row-reverse justify-items-start ${className}`}
>
<Avatar
size="SMALL"
@@ -116,13 +148,16 @@ const ConversationBubble = forwardRef<
/>
{!isEditClicked && (
<>
<div
style={{
wordBreak: 'break-word',
}}
className="text-sm sm:text-base ml-2 mr-2 flex items-center rounded-[28px] bg-gradient-to-b from-medium-purple to-slate-blue py-[14px] px-[19px] text-white max-w-full whitespace-pre-wrap leading-normal"
>
{message}
<div className="flex flex-col mr-2">
<div
style={{
wordBreak: 'break-word',
}}
className="text-sm sm:text-base ml-2 mr-2 flex items-center rounded-[28px] bg-gradient-to-b from-medium-purple to-slate-blue py-[14px] px-[19px] text-white max-w-full whitespace-pre-wrap leading-normal"
>
{message}
</div>
{renderAttachments()}
</div>
<button
onClick={() => {

View File

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

View File

@@ -13,6 +13,7 @@ export function handleFetchAnswer(
promptId: string | null,
chunks: string,
token_limit: number,
attachments?: string[],
): Promise<
| {
result: any;
@@ -50,6 +51,12 @@ export function handleFetchAnswer(
token_limit: token_limit,
isNoneDoc: selectedDocs === null,
};
// Add attachments to payload if they exist
if (attachments && attachments.length > 0) {
payload.attachments = attachments;
}
if (selectedDocs && 'id' in selectedDocs) {
payload.active_docs = selectedDocs.id as string;
}
@@ -90,6 +97,7 @@ export function handleFetchAnswerSteaming(
token_limit: number,
onEvent: (event: MessageEvent) => void,
indx?: number,
attachments?: string[],
): Promise<Answer> {
history = history.map((item) => {
return {
@@ -109,6 +117,12 @@ export function handleFetchAnswerSteaming(
isNoneDoc: selectedDocs === null,
index: indx,
};
// Add attachments to payload if they exist
if (attachments && attachments.length > 0) {
payload.attachments = attachments;
}
if (selectedDocs && 'id' in selectedDocs) {
payload.active_docs = selectedDocs.id as string;
}

View File

@@ -13,6 +13,7 @@ export interface ConversationState {
queries: Query[];
status: Status;
conversationId: string | null;
attachments?: { fileName: string; id: string }[];
}
export interface Answer {
@@ -30,12 +31,13 @@ export interface Query {
prompt: string;
response?: string;
feedback?: FEEDBACK;
error?: string;
conversationId?: string | null;
title?: string | null;
thought?: string;
sources?: { title: string; text: string; source: string }[];
tool_calls?: ToolCallsType[];
error?: string;
attachments?: { fileName: string; id: string }[];
}
export interface RetrievalPayload {
@@ -49,4 +51,5 @@ export interface RetrievalPayload {
token_limit: number;
isNoneDoc: boolean;
index?: number;
attachments?: string[];
}

View File

@@ -13,6 +13,7 @@ const initialState: ConversationState = {
queries: [],
status: 'idle',
conversationId: null,
attachments: [],
};
const API_STREAMING = import.meta.env.VITE_API_STREAMING === 'true';
@@ -37,6 +38,8 @@ export const fetchAnswer = createAsyncThunk<
let isSourceUpdated = false;
const state = getState() as RootState;
const attachments = state.conversation.attachments?.map(a => a.id) || [];
if (state.preference) {
if (API_STREAMING) {
await handleFetchAnswerSteaming(
@@ -119,6 +122,7 @@ export const fetchAnswer = createAsyncThunk<
}
},
indx,
attachments
);
} else {
const answer = await handleFetchAnswer(
@@ -131,6 +135,7 @@ export const fetchAnswer = createAsyncThunk<
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
attachments
);
if (answer) {
let sourcesPrepped = [];
@@ -281,6 +286,9 @@ export const conversationSlice = createSlice({
const { index, message } = action.payload;
state.queries[index].error = message;
},
setAttachments: (state, action: PayloadAction<{ fileName: string; id: string }[]>) => {
state.attachments = action.payload;
},
},
extraReducers(builder) {
builder
@@ -314,5 +322,6 @@ export const {
updateStreamingSource,
updateToolCalls,
setConversation,
setAttachments,
} = conversationSlice.actions;
export default conversationSlice.reducer;

View File

@@ -3,9 +3,7 @@
"chat": "Chat",
"chats": "Chats",
"newChat": "New Chat",
"myPlan": "My Plan",
"about": "About",
"inputPlaceholder": "Type your message here...",
"inputPlaceholder": "How can DocsGPT help you?",
"tagline": "DocsGPT uses GenAI, please review critical information using sources.",
"sourceDocs": "Source",
"none": "None",
@@ -120,7 +118,8 @@
"selectToolSetup": "Select a tool to set up",
"settingsIconAlt": "Settings icon",
"configureToolAria": "Configure {toolName}",
"toggleToolAria": "Toggle {toolName}"
"toggleToolAria": "Toggle {toolName}",
"manageTools": "Go to Tools"
}
},
"modals": {
@@ -238,7 +237,7 @@
},
"sources": {
"title": "Sources",
"text": "Source text",
"text": "Choose Your Sources",
"link": "Source link",
"view_more": "{{count}} more sources"
},

View File

@@ -3,9 +3,7 @@
"chat": "Chat",
"chats": "Chats",
"newChat": "Nuevo Chat",
"myPlan": "Mi Plan",
"about": "Acerca de",
"inputPlaceholder": "Escribe tu mensaje aquí...",
"inputPlaceholder": "¿Cómo puede DocsGPT ayudarte?",
"tagline": "DocsGPT utiliza GenAI, por favor revisa información crítica utilizando fuentes.",
"sourceDocs": "Fuente",
"none": "Ninguno",

View File

@@ -3,9 +3,7 @@
"chat": "チャット",
"chats": "チャット",
"newChat": "新しいチャット",
"myPlan": "私のプラン",
"about": "について",
"inputPlaceholder": "ここにメッセージを入力してください...",
"inputPlaceholder": "DocsGPTはどのようにお手伝いできますか",
"tagline": "DocsGPTはGenAIを使用しています。重要な情報はソースで確認してください。",
"sourceDocs": "ソース",
"none": "なし",

View File

@@ -3,9 +3,7 @@
"chat": "Чат",
"chats": "Чаты",
"newChat": "Новый чат",
"myPlan": "Мой план",
"about": "О нас",
"inputPlaceholder": "Введите свое сообщение здесь...",
"inputPlaceholder": "Как DocsGPT может вам помочь?",
"tagline": "DocsGPT использует GenAI, пожалуйста, проверьте важную информацию, используя источники.",
"sourceDocs": "Источник",
"none": "Нет",

View File

@@ -3,9 +3,7 @@
"chat": "對話",
"chats": "對話",
"newChat": "新對話",
"myPlan": "我的方案",
"about": "關於",
"inputPlaceholder": "在此輸入您的訊息...",
"inputPlaceholder": "DocsGPT 如何幫助您?",
"tagline": "DocsGPT 使用生成式 AI請使用原始資料來源審閱重要資訊。",
"sourceDocs": "原始文件",
"none": "無",

View File

@@ -3,9 +3,7 @@
"chat": "聊天",
"chats": "聊天",
"newChat": "新聊天",
"myPlan": "我的计划",
"about": "关于",
"inputPlaceholder": "在这里输入您的消息...",
"inputPlaceholder": "DocsGPT 如何帮助您?",
"tagline": "DocsGPT 使用 GenAI, 请使用来源审核关键信息.",
"sourceDocs": "源",
"none": "无",

View File

@@ -4,9 +4,12 @@ import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
import CogwheelIcon from '../assets/cogwheel.svg';
import NoFilesIcon from '../assets/no-files.svg';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import Input from '../components/Input';
import Spinner from '../components/Spinner';
import ToggleSwitch from '../components/ToggleSwitch';
import { useDarkTheme } from '../hooks';
import AddToolModal from '../modals/AddToolModal';
import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
@@ -16,6 +19,7 @@ import { APIToolType, UserToolType } from './types';
export default function Tools() {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [isDarkTheme] = useDarkTheme();
const [searchTerm, setSearchTerm] = React.useState('');
const [addToolModalState, setAddToolModalState] =
@@ -132,65 +136,78 @@ export default function Tools() {
</div>
) : (
<div className="flex flex-wrap gap-4 justify-center sm:justify-start">
{userTools
.filter((tool) =>
tool.displayName
.toLowerCase()
.includes(searchTerm.toLowerCase()),
)
.map((tool, index) => (
<div
key={index}
className="h-52 w-[300px] p-6 border rounded-2xl border-light-gainsboro dark:border-arsenic bg-white-3000 dark:bg-transparent flex flex-col justify-between relative"
>
<button
onClick={() => handleSettingsClick(tool)}
aria-label={t('settings.tools.configureToolAria', {
toolName: tool.displayName,
})}
className="absolute top-4 right-4"
{userTools.length === 0 ? (
<div className="flex flex-col items-center justify-center w-full py-12">
<img
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt="No tools found"
className="h-32 w-32 mx-auto mb-6"
/>
<p className="text-gray-500 dark:text-gray-400 text-center text-lg">
{t('settings.tools.noToolsFound')}
</p>
</div>
) : (
userTools
.filter((tool) =>
tool.displayName
.toLowerCase()
.includes(searchTerm.toLowerCase()),
)
.map((tool, index) => (
<div
key={index}
className="h-52 w-[300px] p-6 border rounded-2xl border-light-gainsboro dark:border-arsenic bg-white-3000 dark:bg-transparent flex flex-col justify-between relative"
>
<img
src={CogwheelIcon}
alt={t('settings.tools.settingsIconAlt')}
className="h-[19px] w-[19px]"
/>
</button>
<div className="w-full">
<div className="px-1 w-full flex items-center">
<img
src={`/toolIcons/tool_${tool.name}.svg`}
alt={`${tool.displayName} icon`}
className="h-6 w-6"
/>
</div>
<div className="mt-[9px]">
<p
title={tool.displayName}
className="px-1 text-[13px] font-semibold text-raisin-black-light dark:text-bright-gray leading-relaxed capitalize truncate"
>
{tool.displayName}
</p>
<p className="mt-1 px-1 h-24 overflow-auto text-[12px] text-old-silver dark:text-sonic-silver-light leading-relaxed">
{tool.description}
</p>
</div>
</div>
<div className="absolute bottom-4 right-4">
<ToggleSwitch
checked={tool.status}
onChange={(checked) =>
updateToolStatus(tool.id, checked)
}
size="small"
id={`toolToggle-${index}`}
ariaLabel={t('settings.tools.toggleToolAria', {
<button
onClick={() => handleSettingsClick(tool)}
aria-label={t('settings.tools.configureToolAria', {
toolName: tool.displayName,
})}
/>
className="absolute top-4 right-4"
>
<img
src={CogwheelIcon}
alt={t('settings.tools.settingsIconAlt')}
className="h-[19px] w-[19px]"
/>
</button>
<div className="w-full">
<div className="px-1 w-full flex items-center">
<img
src={`/toolIcons/tool_${tool.name}.svg`}
alt={`${tool.displayName} icon`}
className="h-6 w-6"
/>
</div>
<div className="mt-[9px]">
<p
title={tool.displayName}
className="px-1 text-[13px] font-semibold text-raisin-black-light dark:text-bright-gray leading-relaxed capitalize truncate"
>
{tool.displayName}
</p>
<p className="mt-1 px-1 h-24 overflow-auto text-[12px] text-old-silver dark:text-sonic-silver-light leading-relaxed">
{tool.description}
</p>
</div>
</div>
<div className="absolute bottom-4 right-4">
<ToggleSwitch
checked={tool.status}
onChange={(checked) =>
updateToolStatus(tool.id, checked)
}
size="small"
id={`toolToggle-${index}`}
ariaLabel={t('settings.tools.toggleToolAria', {
toolName: tool.displayName,
})}
/>
</div>
</div>
</div>
))}
))
)}
</div>
)}
</div>

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation, useNavigate, Routes, Route, Navigate } from 'react-router-dom';
import userService from '../api/services/userService';
import SettingsBar from '../components/SettingsBar';
@@ -24,10 +25,42 @@ import Widgets from './Widgets';
export default function Settings() {
const dispatch = useDispatch();
const { t } = useTranslation();
const [activeTab, setActiveTab] = React.useState(t('settings.general.label'));
const [widgetScreenshot, setWidgetScreenshot] = React.useState<File | null>(
null,
);
const navigate = useNavigate();
const location = useLocation();
const [widgetScreenshot, setWidgetScreenshot] = React.useState<File | null>(null);
const getActiveTabFromPath = () => {
const path = location.pathname;
if (path.includes('/settings/documents')) return t('settings.documents.label');
if (path.includes('/settings/apikeys')) return t('settings.apiKeys.label');
if (path.includes('/settings/analytics')) return t('settings.analytics.label');
if (path.includes('/settings/logs')) return t('settings.logs.label');
if (path.includes('/settings/tools')) return t('settings.tools.label');
if (path.includes('/settings/widgets')) return 'Widgets';
return t('settings.general.label');
};
const [activeTab, setActiveTab] = React.useState(getActiveTabFromPath());
const handleTabChange = (tab: string) => {
setActiveTab(tab);
if (tab === t('settings.general.label')) navigate('/settings');
else if (tab === t('settings.documents.label')) navigate('/settings/documents');
else if (tab === t('settings.apiKeys.label')) navigate('/settings/apikeys');
else if (tab === t('settings.analytics.label')) navigate('/settings/analytics');
else if (tab === t('settings.logs.label')) navigate('/settings/logs');
else if (tab === t('settings.tools.label')) navigate('/settings/tools');
else if (tab === 'Widgets') navigate('/settings/widgets');
};
React.useEffect(() => {
setActiveTab(getActiveTabFromPath());
}, [location.pathname]);
React.useEffect(() => {
const newActiveTab = getActiveTabFromPath();
setActiveTab(newActiveTab);
}, [i18n.language]);
const token = useSelector(selectToken);
const documents = useSelector(selectSourceDocs);
@@ -59,54 +92,32 @@ export default function Settings() {
.catch((error) => console.error(error));
};
React.useEffect(() => {
setActiveTab(t('settings.general.label'));
}, [i18n.language]);
return (
<div className="p-4 md:p-12 h-full overflow-auto">
<p className="text-2xl font-bold text-eerie-black dark:text-bright-gray">
{t('settings.label')}
</p>
<SettingsBar activeTab={activeTab} setActiveTab={setActiveTab} />
{renderActiveTab()}
{/* {activeTab === 'Widgets' && (
<Widgets
widgetScreenshot={widgetScreenshot}
onWidgetScreenshotChange={updateWidgetScreenshot}
/>
)} */}
</div>
);
function renderActiveTab() {
switch (activeTab) {
case t('settings.general.label'):
return <General />;
case t('settings.documents.label'):
return (
<SettingsBar activeTab={activeTab} setActiveTab={(tab) => handleTabChange(tab as string)} />
<Routes>
<Route index element={<General />} />
<Route path="documents" element={
<Documents
paginatedDocuments={paginatedDocuments}
handleDeleteDocument={handleDeleteClick}
/>
);
case 'Widgets':
return (
} />
<Route path="apikeys" element={<APIKeys />} />
<Route path="analytics" element={<Analytics />} />
<Route path="logs" element={<Logs />} />
<Route path="tools" element={<Tools />} />
<Route path="widgets" element={
<Widgets
widgetScreenshot={widgetScreenshot}
onWidgetScreenshotChange={updateWidgetScreenshot}
/>
);
case t('settings.apiKeys.label'):
return <APIKeys />;
case t('settings.analytics.label'):
return <Analytics />;
case t('settings.logs.label'):
return <Logs />;
case t('settings.tools.label'):
return <Tools />;
default:
return null;
}
}
} />
<Route path="*" element={<Navigate to="/settings" replace />} />
</Routes>
</div>
);
}