diff --git a/.vscode/settings.json b/.vscode/settings.json index aae6b8db93..837e049774 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,7 +8,7 @@ "editor.formatOnSave": true }, "[typescriptreact]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.defaultFormatter": "vscode.typescript-language-features", "editor.formatOnSave": true }, "[css]": { diff --git a/app/backend/app.py b/app/backend/app.py index 1d34861f88..6a6b36c60b 100644 --- a/app/backend/app.py +++ b/app/backend/app.py @@ -5,6 +5,7 @@ import mimetypes import os import time +import aiofiles from pathlib import Path from typing import Any, AsyncGenerator, Dict, Union, cast @@ -16,7 +17,7 @@ SpeechSynthesizer, ) from azure.core.exceptions import ResourceNotFoundError -from azure.identity.aio import DefaultAzureCredential, get_bearer_token_provider +from azure.identity.aio import DefaultAzureCredential, AzureCliCredential, ManagedIdentityCredential, ChainedTokenCredential, get_bearer_token_provider from azure.monitor.opentelemetry import configure_azure_monitor from azure.search.documents.aio import SearchClient from azure.search.documents.indexes.aio import SearchIndexClient @@ -85,6 +86,8 @@ from prepdocslib.filestrategy import UploadUserFileStrategy from prepdocslib.listfilestrategy import File +import webprepdocs + bp = Blueprint("routes", __name__, static_folder="static") # Fix Windows registry issue with mimetypes mimetypes.add_type("application/javascript", ".js") @@ -377,6 +380,277 @@ async def list_uploaded(auth_claims: dict[str, Any]): current_app.logger.exception("Error listing uploaded files", error) return jsonify(files), 200 +@bp.post("/chunk_upload") +@authenticated +async def chunk_upload(auth_claims: dict[str, Any]): + """ + Endpoint for chunked file upload. Expects fields: 'filename', 'chunk', 'chunkIndex', 'totalChunks'. + Assembles chunks into a file in a temp directory, then uploads to Azure Storage when complete. + All uploads go directly to the content container root (no user-specific folders). + """ + import os + from azure.storage.blob.aio import BlobServiceClient + + form = await request.form + files = await request.files # Await only once + + filename = form.get("filename") + chunk_index = int(form.get("chunkIndex", -1)) + total_chunks = int(form.get("totalChunks", -1)) + chunk = files.get("chunk") + + if not chunk or not filename or chunk_index < 0 or total_chunks <= 0: + return jsonify({"error": "Missing or invalid required fields"}), 400 + + temp_dir = os.path.join("/tmp", "chunk_upload") + os.makedirs(temp_dir, exist_ok=True) + + chunk_path = os.path.join(temp_dir, f"{filename}.part{chunk_index}") + async with aiofiles.open(chunk_path, "wb") as f: + await f.write(chunk.read()) + + # Check if all chunks are present + chunk_files = [os.path.join(temp_dir, f"{filename}.part{i}") for i in range(total_chunks)] + if all(os.path.exists(p) for p in chunk_files): + # Assemble file + assembled_path = os.path.join(temp_dir, filename) + async with aiofiles.open(assembled_path, "wb") as outfile: + for i in range(total_chunks): + async with aiofiles.open(chunk_files[i], "rb") as infile: + await outfile.write(await infile.read()) + + # Upload to Azure Blob Storage (content container root) + container_name = os.environ["AZURE_STORAGE_CONTAINER"] + storage_con_str = os.environ["AZURE_STORAGE_CON_STRING"] + blob_service_client = BlobServiceClient.from_connection_string(storage_con_str) + container_client = blob_service_client.get_container_client(container_name) + + blob_path = filename # Upload to root, not user folder + blob_client = container_client.get_blob_client(blob_path) + + async with aiofiles.open(assembled_path, "rb") as f: + await blob_client.upload_blob(await f.read(), overwrite=True, metadata={"UploadedBy": auth_claims.get("oid", "")}) + + # Clean up + for p in chunk_files: + os.remove(p) + os.remove(assembled_path) + + # Call reindex_file logic after successful upload + userid = auth_claims.get("oid", "") + if userid: + await indexerIncremental(filename, filename, userid, False, userid) + + return jsonify({"message": "File uploaded successfully"}), 200 + + else: + # Upload partial chunk to 'chunks/' folder in content container (optional, or skip this step) + # For now, just acknowledge chunk upload, do not upload partials to blob storage + return jsonify({"message": f"Chunk {chunk_index + 1}/{total_chunks} uploaded"}), 202 + +@bp.get("/list_container_files") +@authenticated +async def list_container_files(auth_claims: dict[str, Any]): + """ + List all files in the Azure storage container specified by AZURE_STORAGE_CONTAINER, including size and last modified date. + Enhanced with error logging for better production diagnostics. + """ + try: + current_app.logger.info("/list_container_files endpoint hit.") + from azure.storage.blob.aio import BlobServiceClient + container_name = os.environ.get("AZURE_STORAGE_CONTAINER") + storage_con_str = os.environ.get("AZURE_STORAGE_CON_STRING") + + current_app.logger.info("Container Name: %s", container_name) + current_app.logger.info("Storage Connection String: %s", storage_con_str) + + if not container_name or not storage_con_str: + raise ValueError("Environment variables AZURE_STORAGE_CONTAINER and AZURE_STORAGE_CON_STRING must be set.") + + blob_service_client = BlobServiceClient.from_connection_string(storage_con_str) + container_client = blob_service_client.get_container_client(container_name) + files = [] + async for blob in container_client.list_blobs(): + files.append({ + "name": blob.name, + "size": blob.size, + "last_modified": blob.last_modified.isoformat() if blob.last_modified else None + }) + return jsonify(files), 200 + except Exception as e: + import traceback + current_app.logger.error("Exception in /list_container_files: %s\n%s", str(e), traceback.format_exc()) + return jsonify({"error": str(e)}), 500 + + +@bp.post("/delete_container_file") +@authenticated +async def delete_container_file(auth_claims: dict[str, Any]): + """ + Delete a file from the Azure Blob Storage container using env vars. + Expects JSON: { "filename": "..." } + """ + from azure.storage.blob.aio import BlobServiceClient + data = await request.get_json() + filename = data.get("filename") + if not filename: + return jsonify({"error": "Missing filename"}), 400 + try: + container_name = os.environ["AZURE_STORAGE_CONTAINER"] + storage_con_str = os.environ["AZURE_STORAGE_CON_STRING"] + blob_service_client = BlobServiceClient.from_connection_string(storage_con_str) + container_client = blob_service_client.get_container_client(container_name) + blob_client = container_client.get_blob_client(filename) + await blob_client.delete_blob() + return jsonify({"message": f"File {filename} deleted successfully"}), 200 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@bp.post("/reindex_after_delete") +@authenticated +async def reindex_after_delete(auth_claims: dict[str, Any]): + """ + Remove a file's content from the Azure Search index after it has been deleted from blob storage. + Expects JSON: { "filename": "...", "userId": "..." } + """ + from webprepdocslib.strategy import SearchInfo + from webprepdocslib.searchmanager import SearchManager + data = await request.get_json() + filename = data.get("filename") + userId = data.get("userId") + if not filename or not userId: + return jsonify({"error": "Missing filename or userId"}), 400 + + endpoint = f"https://{os.environ['AZURE_SEARCH_SERVICE']}.search.windows.net" + index_name = os.environ['AZURE_SEARCH_INDEX'] + credential = DefaultAzureCredential(exclude_shared_token_cache_credential=True) + search_info = SearchInfo(endpoint=endpoint, credential=credential, index_name=index_name) + search_manager = SearchManager(search_info) + await search_manager.remove_content_v2(path=None, category=filename, users=userId) + return jsonify({"message": f"Requested reindex/removal for {filename} (user: {userId}) in search index."}), 200 + +@bp.post("/reindex_file") +@authenticated +async def reindex_file(auth_claims: dict[str, Any]): + """ + Reindex a selected file for a user. Expects JSON: { "filename": "...", "userid": "..." } + """ + data = await request.get_json() + filename = data.get("filename") + userid = data.get("userid") + if not filename or not userid: + return jsonify({"error": "Missing filename or userid"}), 400 + # Call indexerIncremental to reindex the file + await indexerIncremental(filename, filename, userid, False, userid) + return jsonify({"message": f"Reindexing started for {filename} (user: {userid})"}), 200 + + +@bp.post("/reindex_container_file") +@authenticated +async def reindex_container_file(auth_claims: dict[str, Any]): + """ + Reindex a selected file in the main container. Expects JSON: { "filename": "...", "userid": "..." } + """ + data = await request.get_json() + filename = data.get("filename") + userid = data.get("userid") + if not filename or not userid: + return jsonify({"error": "Missing filename or userid"}), 400 + await indexerIncremental(filename, filename, userid, False, userid) + return jsonify({"message": f"Reindexing started for {filename} (user: {userid})"}), 200 + + + +@bp.get("/download_container_file") +@authenticated +async def download_container_file(auth_claims: dict[str, Any]): + """ + Download a file from the Azure Blob Storage container using env vars. + Expects query param: filename=... + """ + from azure.storage.blob.aio import BlobServiceClient + from quart import send_file + import tempfile + + filename = request.args.get("filename") + if not filename: + return jsonify({"error": "Missing filename"}), 400 + try: + container_name = os.environ["AZURE_STORAGE_CONTAINER"] + storage_con_str = os.environ["AZURE_STORAGE_CON_STRING"] + blob_service_client = BlobServiceClient.from_connection_string(storage_con_str) + container_client = blob_service_client.get_container_client(container_name) + blob_client = container_client.get_blob_client(filename) + # Download blob to a temp file + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + stream = await blob_client.download_blob() + data = await stream.readall() + tmp_file.write(data) + tmp_file_path = tmp_file.name + # Send file as attachment + return await send_file(tmp_file_path, as_attachment=True, attachment_filename=filename) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +async def indexerIncremental(fileName: str, fltrCat: str, userid: str, removeFile: bool, space: str): # @PY ¦ indexerIncremental + print("II¦INDXRINCTR⇒Start","fileName", fileName,"fltrCat", fltrCat,"userid",userid, "removeFile",removeFile) + # Use AzureKeyCredential if key is set, otherwise fall back to DefaultAzureCredential + from azure.core.credentials import AzureKeyCredential + from azure.identity.aio import DefaultAzureCredential + search_key = os.environ.get("AZURE_SEARCH_KEY", "") + storage_key = os.environ.get("AZURE_STORAGE_KEY", "") + if search_key: + azure_credential = AzureKeyCredential(search_key) + else: + azure_credential = DefaultAzureCredential( + exclude_environment_credential=True, + exclude_managed_identity_credential=False, + exclude_shared_token_cache_credential=True, + exclude_visual_studio_code_credential=True, + exclude_cli_credential=True, + ) + if fileName: + srchTrgt = os.path.join(os.getcwd(),"data",userid,fileName) + else: + srchTrgt = os.path.join(os.getcwd(),"data",userid) + + print("indexerIncremental¦fileName",fileName,"fltrCat",fltrCat,"userid",userid,"removeFile",removeFile,"space",space, "srchTrgt",srchTrgt) + args_dict = { + 'files': srchTrgt, + 'storageaccount': os.environ.get("AZURE_STORAGE_ACCOUNT", ""), + 'container': os.environ.get("AZURE_STORAGE_CONTAINER", ""), + 'storagekey': storage_key, + 'openaihost': os.environ.get("OPENAI_HOST", ""), + 'openaiservice': os.environ.get("AZURE_OPENAI_SERVICE", ""), + 'openaikey': os.environ.get("AZURE_API_KEY", ""), + 'openaiorg': os.environ.get("AZURE_OPENAI_ORGANIZATION", ""), + 'openaideployment': os.environ.get("AZURE_OPENAI_EMB_DEPLOYMENT", ""), + 'openaimodelname': os.environ.get("AZURE_OPENAI_EMB_MODEL_NAME", ""), + 'searchservice': os.environ.get("AZURE_SEARCH_SERVICE", ""), + 'index': os.environ.get("AZURE_SEARCH_INDEX", ""), + 'searchanalyzername': os.environ.get("AZURE_SEARCH_ANALYZER_NAME", ""), + 'datalakestorageaccount': os.environ.get("AZURE_ADLS_GEN2_STORAGE_ACCOUNT", ""), + 'tenantid': os.environ.get("AZURE_TENANT_ID", ""), + 'verbose': False, + 'localpdfparser': True, + 'novectors': False, + 'disablebatchvectors': False, + 'removeall': False, + 'remove': removeFile, + 'useacls': False, + 'category': fltrCat, + 'searchkey': search_key, + 'users': userid, + 'space': space + } + args = webprepdocs.Args(args_dict) + + file_strategy = webprepdocs.setup_file_strategy(azure_credential, args) + await webprepdocs.runStrat(file_strategy, azure_credential, args) + return jsonify({"message": "Document Processed Successfully"}) + @bp.before_app_serving async def setup_clients(): @@ -436,7 +710,11 @@ async def setup_clients(): # just use 'az login' locally, and managed identity when deployed on Azure). If you need to use keys, use separate AzureKeyCredential instances with the # keys for each service # If you encounter a blocking error during a DefaultAzureCredential resolution, you can exclude the problematic credential by using a parameter (ex. exclude_shared_token_cache_credential=True) - azure_credential = DefaultAzureCredential(exclude_shared_token_cache_credential=True) + # azure_credential = DefaultAzureCredential(exclude_shared_token_cache_credential=True) + azure_credential = ChainedTokenCredential( + AzureCliCredential(), # Dev + ManagedIdentityCredential() # Prod + ) # Set up clients for AI Search and Storage search_client = SearchClient( diff --git a/app/backend/approaches/chatapproach.py b/app/backend/approaches/chatapproach.py index 2b133eca1a..c8b4922594 100644 --- a/app/backend/approaches/chatapproach.py +++ b/app/backend/approaches/chatapproach.py @@ -9,11 +9,17 @@ class ChatApproach(Approach, ABC): + # query_prompt_few_shots: list[ChatCompletionMessageParam] = [ + # {"role": "user", "content": "How did crypto do last year?"}, + # {"role": "assistant", "content": "Summarize Cryptocurrency Market Dynamics from last year"}, + # {"role": "user", "content": "What are my health plans?"}, + # {"role": "assistant", "content": "Show available health plans"}, + # ] query_prompt_few_shots: list[ChatCompletionMessageParam] = [ {"role": "user", "content": "How did crypto do last year?"}, {"role": "assistant", "content": "Summarize Cryptocurrency Market Dynamics from last year"}, - {"role": "user", "content": "What are my health plans?"}, - {"role": "assistant", "content": "Show available health plans"}, + {"role": "user", "content": "What is Infection Control Policy?"}, + {"role": "assistant", "content": "Show available policies"}, ] NO_RESPONSE = "0" @@ -36,6 +42,16 @@ class ChatApproach(Approach, ABC): If you cannot generate a search query, return just the number 0. """ + # query_prompt_template = """You are an "Whiddon Company Knowledge Assistant" that helps the employees with their Policy and Procedures questions, and questions about the employee handbook. Be brief in your answers. Answer ONLY with the facts listed in the list of sources below. If there isn't enough information below, say you don't know. Do not generate answers that don't use the sources below. If asking a clarifying question to the user would help, ask the question. For tabular information return it as an html table. Do not return markdown format. If the question is not in English, answer in the language used in the question. Each source has a name followed by colon and the actual information, always include the source name for each fact you use in the response. Use square brackets to reference the source, for example [info1.txt]. Don't combine sources, list each source separately, for example [info1.txt][info2.pdf]. + # You have access to Azure AI Search index with 100's of documents. + # Generate a search query based on the conversation and the new question. + # Do not include cited source filenames and document names e.g info.txt or doc.pdf in the search query terms. + # Do not include any text inside [] or <<>> in the search query terms. + # Do not include any special characters like '+'. + # If the question is not in English, translate the question to English before generating the search query. + # If you cannot generate a search query, return just the number 0. + # """ + @property @abstractmethod def system_message_chat_conversation(self) -> str: diff --git a/app/backend/approaches/chatreadretrieveread.py b/app/backend/approaches/chatreadretrieveread.py index 7b3a7d2c3c..8ad3c71603 100644 --- a/app/backend/approaches/chatreadretrieveread.py +++ b/app/backend/approaches/chatreadretrieveread.py @@ -54,8 +54,17 @@ def __init__( self.chatgpt_token_limit = get_token_limit(chatgpt_model) @property + # def system_message_chat_conversation(self): + # return """Assistant helps the company employees with their healthcare plan questions, and questions about the employee handbook. Be brief in your answers. + # Answer ONLY with the facts listed in the list of sources below. If there isn't enough information below, say you don't know. Do not generate answers that don't use the sources below. If asking a clarifying question to the user would help, ask the question. + # For tabular information return it as an html table. Do not return markdown format. If the question is not in English, answer in the language used in the question. + # Each source has a name followed by colon and the actual information, always include the source name for each fact you use in the response. Use square brackets to reference the source, for example [info1.txt]. Don't combine sources, list each source separately, for example [info1.txt][info2.pdf]. + # {follow_up_questions_prompt} + # {injected_prompt} + # """ + def system_message_chat_conversation(self): - return """Assistant helps the company employees with their healthcare plan questions, and questions about the employee handbook. Be brief in your answers. + return """You are an Whiddon Company Knowledge Assistant that helps the employees with their Policy and Procedures questions, and questions about the employee handbook and dont Include Northwind Health Plus Benefits. Be brief in your answers. Answer ONLY with the facts listed in the list of sources below. If there isn't enough information below, say you don't know. Do not generate answers that don't use the sources below. If asking a clarifying question to the user would help, ask the question. For tabular information return it as an html table. Do not return markdown format. If the question is not in English, answer in the language used in the question. Each source has a name followed by colon and the actual information, always include the source name for each fact you use in the response. Use square brackets to reference the source, for example [info1.txt]. Don't combine sources, list each source separately, for example [info1.txt][info2.pdf]. @@ -113,7 +122,7 @@ async def run_until_final_call( "properties": { "search_query": { "type": "string", - "description": "Query string to retrieve documents from azure search eg: 'Health care plan'", + "description": "Query string to retrieve documents from azure search eg: 'Policy and Procedures'", } }, "required": ["search_query"], @@ -122,6 +131,9 @@ async def run_until_final_call( } ] + # "description": "Query string to retrieve documents from azure search eg: 'Health care plan'", + + # STEP 1: Generate an optimized keyword search query based on the chat history and the last question query_response_token_limit = 100 query_messages = build_messages( diff --git a/app/backend/approaches/chatreadretrievereadvision.py b/app/backend/approaches/chatreadretrievereadvision.py index 96664cd0c8..c8407392f6 100644 --- a/app/backend/approaches/chatreadretrievereadvision.py +++ b/app/backend/approaches/chatreadretrievereadvision.py @@ -66,9 +66,24 @@ def __init__( self.chatgpt_token_limit = get_token_limit(gpt4v_model) @property + # def system_message_chat_conversation(self): + # return """ + # You are an intelligent assistant helping analyze the Annual Financial Report of Contoso Ltd., The documents contain text, graphs, tables and images. + # Each image source has the file name in the top left corner of the image with coordinates (10,10) pixels and is in the format SourceFileName: + # Each text source starts in a new line and has the file name followed by colon and the actual information + # Always include the source name from the image or text for each fact you use in the response in the format: [filename] + # Answer the following question using only the data provided in the sources below. + # If asking a clarifying question to the user would help, ask the question. + # Be brief in your answers. + # For tabular information return it as an html table. Do not return markdown format. + # The text and image source can be the same file name, don't use the image title when citing the image source, only use the file name as mentioned + # If you cannot answer using the sources below, say you don't know. Return just the answer without any input texts. + # {follow_up_questions_prompt} + # {injected_prompt} + # """ def system_message_chat_conversation(self): return """ - You are an intelligent assistant helping analyze the Annual Financial Report of Contoso Ltd., The documents contain text, graphs, tables and images. + You are an intelligent assistant helping analyze the Reports., The documents contain text, graphs, tables and images. Each image source has the file name in the top left corner of the image with coordinates (10,10) pixels and is in the format SourceFileName: Each text source starts in a new line and has the file name followed by colon and the actual information Always include the source name from the image or text for each fact you use in the response in the format: [filename] @@ -81,7 +96,7 @@ def system_message_chat_conversation(self): {follow_up_questions_prompt} {injected_prompt} """ - + async def run_until_final_call( self, messages: list[ChatCompletionMessageParam], diff --git a/app/backend/approaches/retrievethenread.py b/app/backend/approaches/retrievethenread.py index 649b2f84fd..403bf8ae39 100644 --- a/app/backend/approaches/retrievethenread.py +++ b/app/backend/approaches/retrievethenread.py @@ -17,8 +17,19 @@ class RetrieveThenReadApproach(Approach): (answer) with that prompt. """ + # system_chat_template = ( + # "You are an intelligent assistant helping Contoso Inc employees with their healthcare plan questions and employee handbook questions. " + # + "Use 'you' to refer to the individual asking the questions even if they ask with 'I'. " + # + "Answer the following question using only the data provided in the sources below. " + # + "For tabular information return it as an html table. Do not return markdown format. " + # + "Each source has a name followed by colon and the actual information, always include the source name for each fact you use in the response. " + # + "If you cannot answer using the sources below, say you don't know. Use below example to answer" + # ) + + system_chat_template = ( - "You are an intelligent assistant helping Contoso Inc employees with their healthcare plan questions and employee handbook questions. " + "You are an Whiddon Company Knowledge Assistant that helps the employees with their Policy and Procedures questions, " + + "and questions about the employee handbook and dont Include Northwind Health Plus Benefits and Contoso. " + "Use 'you' to refer to the individual asking the questions even if they ask with 'I'. " + "Answer the following question using only the data provided in the sources below. " + "For tabular information return it as an html table. Do not return markdown format. " diff --git a/app/backend/approaches/retrievethenreadvision.py b/app/backend/approaches/retrievethenreadvision.py index b4b4fb85e9..cd73d78630 100644 --- a/app/backend/approaches/retrievethenreadvision.py +++ b/app/backend/approaches/retrievethenreadvision.py @@ -22,8 +22,19 @@ class RetrieveThenReadVisionApproach(Approach): (answer) with that prompt. """ + # system_chat_template_gpt4v = ( + # "You are an intelligent assistant helping analyze the Annual Financial Report of Contoso Ltd., The documents contain text, graphs, tables and images. " + # + "Each image source has the file name in the top left corner of the image with coordinates (10,10) pixels and is in the format SourceFileName: " + # + "Each text source starts in a new line and has the file name followed by colon and the actual information " + # + "Always include the source name from the image or text for each fact you use in the response in the format: [filename] " + # + "Answer the following question using only the data provided in the sources below. " + # + "For tabular information return it as an html table. Do not return markdown format. " + # + "The text and image source can be the same file name, don't use the image title when citing the image source, only use the file name as mentioned " + # + "If you cannot answer using the sources below, say you don't know. Return just the answer without any input texts " + # ) + system_chat_template_gpt4v = ( - "You are an intelligent assistant helping analyze the Annual Financial Report of Contoso Ltd., The documents contain text, graphs, tables and images. " + "You are an intelligent assistant helping analyze the Reports., The documents contain text, graphs, tables and images. " + "Each image source has the file name in the top left corner of the image with coordinates (10,10) pixels and is in the format SourceFileName: " + "Each text source starts in a new line and has the file name followed by colon and the actual information " + "Always include the source name from the image or text for each fact you use in the response in the format: [filename] " @@ -33,6 +44,7 @@ class RetrieveThenReadVisionApproach(Approach): + "If you cannot answer using the sources below, say you don't know. Return just the answer without any input texts " ) + def __init__( self, *, diff --git a/app/backend/prepdocs.py b/app/backend/prepdocs.py index bd7cb22062..82831e05a5 100644 --- a/app/backend/prepdocs.py +++ b/app/backend/prepdocs.py @@ -213,6 +213,7 @@ async def main(strategy: Strategy, setup_index: bool = True): await strategy.run() + if __name__ == "__main__": parser = argparse.ArgumentParser( description="Prepare documents by extracting content from PDFs, splitting content into sections, uploading to blob storage, and indexing in a search index.", diff --git a/app/backend/requirements.txt b/app/backend/requirements.txt index c4c2fd6fec..c942f24263 100644 --- a/app/backend/requirements.txt +++ b/app/backend/requirements.txt @@ -458,3 +458,8 @@ zipp==3.19.2 # The following packages are considered to be unsafe in a requirements file: # setuptools +asyncpg +pdfminer.six +fpdf +azure.cosmos +opencensus-ext-azure==1.1.11 \ No newline at end of file diff --git a/app/backend/webprepdocs.py b/app/backend/webprepdocs.py new file mode 100644 index 0000000000..6966c54999 --- /dev/null +++ b/app/backend/webprepdocs.py @@ -0,0 +1,174 @@ +import argparse +import asyncio +from typing import Any, Optional, Union + +from azure.core.credentials import AzureKeyCredential +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import AzureDeveloperCliCredential + +from webprepdocslib.blobmanager import BlobManager +from webprepdocslib.embeddings import ( + AzureOpenAIEmbeddingService, + OpenAIEmbeddings, + OpenAIEmbeddingService, +) +from webprepdocslib.filestrategy import DocumentAction, FileStrategy +from webprepdocslib.listfilestrategy import ( + ADLSGen2ListFileStrategy, + ListFileStrategy, + LocalListFileStrategy, +) +from webprepdocslib.pdfparser import DocumentAnalysisPdfParser, LocalPdfParser, PdfParser +from webprepdocslib.strategy import SearchInfo, Strategy +from webprepdocslib.textsplitter import TextSplitter +import base64 +from fpdf import FPDF +import io + +def is_key_empty(key): + return key is None or len(key.strip()) == 0 + +def setup_file_strategy(credential: AsyncTokenCredential, args: Any) -> FileStrategy: + storage_creds = credential if is_key_empty(args.storagekey) else args.storagekey + blob_manager = BlobManager( + endpoint=f"https://{args.storageaccount}.blob.core.windows.net", + container=args.container, + credential=storage_creds, + verbose=args.verbose, + ) + + pdf_parser: PdfParser + if args.localpdfparser: + pdf_parser = LocalPdfParser() + else: + # check if Azure Document Intelligence credentials are provided + if args.formrecognizerservice is None: + print( + "Error: Azure Document Intelligence service is not provided. Please provide --formrecognizerservice or use --localpdfparser for local pypdf parser." + ) + exit(1) + formrecognizer_creds: Union[AsyncTokenCredential, AzureKeyCredential] = ( + credential if is_key_empty(args.formrecognizerkey) else AzureKeyCredential(args.formrecognizerkey) + ) + pdf_parser = DocumentAnalysisPdfParser( + endpoint=f"https://{args.formrecognizerservice}.cognitiveservices.azure.com/", + credential=formrecognizer_creds, + verbose=args.verbose, + ) + + use_vectors = not args.novectors + embeddings: Optional[OpenAIEmbeddings] = None + if use_vectors and args.openaihost != "openai": + azure_open_ai_credential: Union[AsyncTokenCredential, AzureKeyCredential] = ( + credential if is_key_empty(args.openaikey) else AzureKeyCredential(args.openaikey) + ) + embeddings = AzureOpenAIEmbeddingService( + open_ai_service=args.openaiservice, + open_ai_deployment=args.openaideployment, + open_ai_model_name=args.openaimodelname, + credential=azure_open_ai_credential, + disable_batch=args.disablebatchvectors, + verbose=args.verbose, + ) + elif use_vectors: + embeddings = OpenAIEmbeddingService( + open_ai_model_name=args.openaimodelname, + credential=args.openaikey, + organization=args.openaiorg, + disable_batch=args.disablebatchvectors, + verbose=args.verbose, + ) + + print("Processing Files") + list_file_strategy: ListFileStrategy + if args.datalakestorageaccount: + adls_gen2_creds = credential if is_key_empty(args.datalakekey) else args.datalakekey + print(f"Using Data Lake Gen2 Storage Account {args.datalakestorageaccount}") + list_file_strategy = ADLSGen2ListFileStrategy( + data_lake_storage_account=args.datalakestorageaccount, + data_lake_filesystem=args.datalakefilesystem, + data_lake_path=args.datalakepath, + credential=adls_gen2_creds, + verbose=args.verbose, + ) + else: + print(f"Using Local Files ⇒ {args.files}") + list_file_strategy = LocalListFileStrategy(path_pattern=args.files, verbose=args.verbose) + + if args.removeall: + document_action = DocumentAction.RemoveAll + elif args.remove: + print("Removing Files", args.category) + document_action = DocumentAction.Remove + + else: + document_action = DocumentAction.Add + + return FileStrategy( ## @PY INIT STARTEGY + list_file_strategy=list_file_strategy, + blob_manager=blob_manager, + pdf_parser=pdf_parser, + text_splitter=TextSplitter(), + document_action=document_action, + embeddings=embeddings, + search_analyzer_name=args.searchanalyzername, + use_acls=args.useacls, + category=args.category, + users=args.users, + space=args.space, + ) + +async def base64_to_pdf(base64_string, filename): + # Decode the base64 string + pdf_bytes = base64.b64decode(base64_string) + + # Create a PDF object and add a page + pdf = FPDF() + pdf.add_page() + + # Move the file pointer to the beginning of the byte stream + pdf_file = io.BytesIO(pdf_bytes) + pdf_file.seek(0) + + # Save the PDF to a file + with open(filename, "wb") as file: + file.write(pdf_file.read()) + +class Args: + def __init__(self, dictionary): + for key in dictionary: + setattr(self, key, dictionary[key]) + +async def runStrat(strategy: Strategy, credential: AsyncTokenCredential, args: Any): + search_creds: Union[AsyncTokenCredential, AzureKeyCredential] = ( + credential if is_key_empty(args.searchkey) else AzureKeyCredential(args.searchkey) + ) + print(f"[DEBUG] runStrat: args.searchkey={repr(args.searchkey)}") + print(f"[DEBUG] runStrat: Using search_creds type: {type(search_creds).__name__}") + search_info = SearchInfo( + endpoint=f"https://{args.searchservice}.search.windows.net/", + credential=search_creds, + index_name=args.index, + verbose=args.verbose, + ) + if not args.remove and not args.removeall: + await strategy.setup(search_info) + + file_strategy = setup_file_strategy(credential, args) + await main(file_strategy, credential, args) ## @PY ¦ runStrat ⇒ main + +async def main(strategy: Strategy, credential: AsyncTokenCredential, args: Any): + search_creds: Union[AsyncTokenCredential, AzureKeyCredential] = ( + credential if is_key_empty(args.searchkey) else AzureKeyCredential(args.searchkey) + ) + search_info = SearchInfo( + endpoint=f"https://{args.searchservice}.search.windows.net/", + credential=search_creds, + index_name=args.index, + verbose=args.verbose, + ) + + if not args.remove and not args.removeall: + await strategy.setup(search_info) + + await strategy.run(search_info) ## @PY ¦ STRAT main ⇒ run \ No newline at end of file diff --git a/app/backend/webprepdocslib/__init__.py b/app/backend/webprepdocslib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/backend/webprepdocslib/blobmanager.py b/app/backend/webprepdocslib/blobmanager.py new file mode 100644 index 0000000000..f8d38da3ab --- /dev/null +++ b/app/backend/webprepdocslib/blobmanager.py @@ -0,0 +1,87 @@ +import os +import re +import logging +from typing import Optional, Union + +from azure.core.credentials_async import AsyncTokenCredential +from azure.storage.blob.aio import BlobServiceClient + +from .listfilestrategy import File + + +class BlobManager: + """ + Class to manage uploading and deleting blobs containing citation information from a blob storage account + """ + + def __init__( + self, + endpoint: str, + container: str, + credential: Union[AsyncTokenCredential, str], + verbose: bool = False, + ): + self.endpoint = endpoint + self.credential = credential + self.container = container + self.verbose = verbose + + async def upload_blob(self, file: File, virtualSubDir: Optional[str] = None, category: Optional[str] = None): + async with BlobServiceClient( + account_url=self.endpoint, credential=self.credential, max_single_put_size=4 * 1024 * 1024 + ) as service_client, service_client.get_container_client(self.container) as container_client: + if not await container_client.exists(): + await container_client.create_container() + + # Re-open and upload the original file + with open(file.content.name, "rb") as reopened_file: + if virtualSubDir is not None: + # blob_name = BlobManager.blob_name_from_file_name_subfoldered(file.content.name, virtualSubDir) + blob_name = BlobManager.blob_name_from_file_name_subfoldered(f"{category}", virtualSubDir) ## @PY ¦ upload_blob ⇒ Blob Subfoldering on Upload + else: + #blob_name = BlobManager.blob_name_from_file_name(file.content.name) + blob_name = BlobManager.blob_name_from_file_name(f"{category}") + print(f"\tUploading Blob For Whole File ⇒ {blob_name} SRC ⇒ {file.content.name} ¦ CAT ⇒ {category}") + + await container_client.upload_blob(blob_name, reopened_file, overwrite=True) + + + async def remove_blob(self, path: Optional[str] = None, category: Optional[str] = None, user: Optional[str] = None ): ## @PY ¦ remove_blob ⇒ Remove Blob by Path + async with BlobServiceClient( + account_url=self.endpoint, credential=self.credential + ) as service_client, service_client.get_container_client(self.container) as container_client: + print(f"\tREMOVEBLOB¦Path {path} USER {user}") + ## logging.info("REMBLB¦path ", path) ## Leads to Big Error with the logging + if not await container_client.exists(): + return + if path is None: + prefix = None + blobs = container_client.list_blob_names() + else: + prefix = None + if(user is not None and user != ""): + prefix = f"{user}/{category}" + catt = f"{user}/{category}" + else: + catt = f"{category}" + + print("CATTTTTTTTTT",catt) + blobs = container_client.list_blob_names(name_starts_with=catt) + async for blob_path in blobs: + print(f"\tREMOVEBLOB¦Removing {blob_path}") + await container_client.delete_blob(blob_path) + + @classmethod + def sourcepage_from_file_page(cls, filename, page=0) -> str: + if os.path.splitext(filename)[1].lower() == ".pdf": + return f"{os.path.basename(filename)}#page={page+1}" + else: + return os.path.basename(filename) + + @classmethod + def blob_name_from_file_name(cls, filename) -> str: + return os.path.basename(filename) + + @classmethod + def blob_name_from_file_name_subfoldered(cls, filename, subFolder) -> str: + return os.path.join(subFolder, os.path.basename(filename)) diff --git a/app/backend/webprepdocslib/embeddings.py b/app/backend/webprepdocslib/embeddings.py new file mode 100644 index 0000000000..4dff2bc033 --- /dev/null +++ b/app/backend/webprepdocslib/embeddings.py @@ -0,0 +1,182 @@ +import time +from abc import ABC +from typing import List, Optional, Union + +import tiktoken +from azure.core.credentials import AccessToken, AzureKeyCredential +from azure.core.credentials_async import AsyncTokenCredential +from openai import AsyncAzureOpenAI, AsyncOpenAI, RateLimitError +from tenacity import ( + AsyncRetrying, + retry_if_exception_type, + stop_after_attempt, + wait_random_exponential, +) + + +class EmbeddingBatch: + """ + Represents a batch of text that is going to be embedded + """ + + def __init__(self, texts: List[str], token_length: int): + self.texts = texts + self.token_length = token_length + + +class OpenAIEmbeddings(ABC): + """ + Contains common logic across both OpenAI and Azure OpenAI embedding services + Can split source text into batches for more efficient embedding calls + """ + + SUPPORTED_BATCH_AOAI_MODEL = {"text-embedding-ada-002": {"token_limit": 8100, "max_batch_size": 16}} + + def __init__(self, open_ai_model_name: str, disable_batch: bool = False, verbose: bool = False): + self.open_ai_model_name = open_ai_model_name + self.disable_batch = disable_batch + self.verbose = verbose + + async def create_client(self) -> AsyncOpenAI: + raise NotImplementedError + + def before_retry_sleep(self, retry_state): + if self.verbose: + print("Rate limited on the OpenAI embeddings API, sleeping before retrying...") + + def calculate_token_length(self, text: str): + encoding = tiktoken.encoding_for_model(self.open_ai_model_name) + return len(encoding.encode(text)) + + def split_text_into_batches(self, texts: List[str]) -> List[EmbeddingBatch]: + batch_info = OpenAIEmbeddings.SUPPORTED_BATCH_AOAI_MODEL.get(self.open_ai_model_name) + if not batch_info: + raise NotImplementedError( + f"Model {self.open_ai_model_name} is not supported with batch embedding operations" + ) + + batch_token_limit = batch_info["token_limit"] + batch_max_size = batch_info["max_batch_size"] + batches: List[EmbeddingBatch] = [] + batch: List[str] = [] + batch_token_length = 0 + for text in texts: + text_token_length = self.calculate_token_length(text) + if batch_token_length + text_token_length >= batch_token_limit and len(batch) > 0: + batches.append(EmbeddingBatch(batch, batch_token_length)) + batch = [] + batch_token_length = 0 + + batch.append(text) + batch_token_length = batch_token_length + text_token_length + if len(batch) == batch_max_size: + batches.append(EmbeddingBatch(batch, batch_token_length)) + batch = [] + batch_token_length = 0 + + if len(batch) > 0: + batches.append(EmbeddingBatch(batch, batch_token_length)) + + return batches + + async def create_embedding_batch(self, texts: List[str]) -> List[List[float]]: + batches = self.split_text_into_batches(texts) + embeddings = [] + client = await self.create_client() + for batch in batches: + async for attempt in AsyncRetrying( + retry=retry_if_exception_type(RateLimitError), + wait=wait_random_exponential(min=15, max=60), + stop=stop_after_attempt(15), + before_sleep=self.before_retry_sleep, + ): + with attempt: + emb_response = await client.embeddings.create(model=self.open_ai_model_name, input=batch.texts) + embeddings.extend([data.embedding for data in emb_response.data]) + if self.verbose: + print(f"Batch Completed. Batch size {len(batch.texts)} Token count {batch.token_length}") + + return embeddings + + async def create_embedding_single(self, text: str) -> List[float]: + client = await self.create_client() + async for attempt in AsyncRetrying( + retry=retry_if_exception_type(RateLimitError), + wait=wait_random_exponential(min=15, max=60), + stop=stop_after_attempt(15), + before_sleep=self.before_retry_sleep, + ): + with attempt: + emb_response = await client.embeddings.create(model=self.open_ai_model_name, input=text) + + return emb_response.data[0].embedding + + async def create_embeddings(self, texts: List[str]) -> List[List[float]]: + if not self.disable_batch and self.open_ai_model_name in OpenAIEmbeddings.SUPPORTED_BATCH_AOAI_MODEL: + return await self.create_embedding_batch(texts) + + return [await self.create_embedding_single(text) for text in texts] + + +class AzureOpenAIEmbeddingService(OpenAIEmbeddings): + """ + Class for using Azure OpenAI embeddings + To learn more please visit https://learn.microsoft.com/azure/ai-services/openai/concepts/understand-embeddings + """ + + def __init__( + self, + open_ai_service: str, + open_ai_deployment: str, + open_ai_model_name: str, + credential: Union[AsyncTokenCredential, AzureKeyCredential], + disable_batch: bool = False, + verbose: bool = False, + ): + super().__init__(open_ai_model_name, disable_batch, verbose) + self.open_ai_service = open_ai_service + self.open_ai_deployment = open_ai_deployment + self.credential = credential + self.cached_token: Optional[AccessToken] = None + + async def create_client(self) -> AsyncOpenAI: + return AsyncAzureOpenAI( + azure_endpoint=f"https://{self.open_ai_service}.openai.azure.com", + azure_deployment=self.open_ai_deployment, + api_key=await self.wrap_credential(), + api_version="2023-05-15", + ) + + async def wrap_credential(self) -> str: + if isinstance(self.credential, AzureKeyCredential): + return self.credential.key + + if isinstance(self.credential, AsyncTokenCredential): + if not self.cached_token or self.cached_token.expires_on <= time.time(): + self.cached_token = await self.credential.get_token("https://cognitiveservices.azure.com/.default") + + return self.cached_token.token + + raise TypeError("Invalid credential type") + + +class OpenAIEmbeddingService(OpenAIEmbeddings): + """ + Class for using OpenAI embeddings + To learn more please visit https://platform.openai.com/docs/guides/embeddings + """ + + def __init__( + self, + open_ai_model_name: str, + credential: str, + organization: Optional[str] = None, + disable_batch: bool = False, + verbose: bool = False, + ): + super().__init__(open_ai_model_name, disable_batch, verbose) + self.credential = credential + self.organization = organization + + async def create_client(self) -> AsyncOpenAI: + return AsyncOpenAI(api_key=self.credential, organization=self.organization) diff --git a/app/backend/webprepdocslib/filestrategy.py b/app/backend/webprepdocslib/filestrategy.py new file mode 100644 index 0000000000..c425692875 --- /dev/null +++ b/app/backend/webprepdocslib/filestrategy.py @@ -0,0 +1,277 @@ +import os +import re +from enum import Enum +import traceback +from typing import Optional +import logging +from .blobmanager import BlobManager +from .embeddings import OpenAIEmbeddings +from .listfilestrategy import ListFileStrategy +from .pdfparser import PdfParser +from .searchmanager import SearchManager, Section +from .strategy import SearchInfo, Strategy +from .textsplitter import TextSplitter +import asyncio + +import asyncpg +import json + +from pdfminer.converter import PDFPageAggregator +from pdfminer.pdfinterp import PDFResourceManager, PDFPageInterpreter +from pdfminer.pdfpage import PDFPage +from pdfminer.utils import open_filename +from pdfminer.pdftypes import resolve1 +from pdfminer.layout import LAParams, LTTextBox, LTTextLine, LTChar,LTPage +from pdfminer.high_level import extract_text + +class DocumentAction(Enum): + Add = 0 + Remove = 1 + RemoveAll = 2 + + +class FileStrategy(Strategy): + """ + Strategy for ingesting documents into a search service from files stored either locally or in a data lake storage account + """ + + def __init__( + self, + list_file_strategy: ListFileStrategy, + blob_manager: BlobManager, + pdf_parser: PdfParser, + text_splitter: TextSplitter, + document_action: DocumentAction = DocumentAction.Add, + embeddings: Optional[OpenAIEmbeddings] = None, + search_analyzer_name: Optional[str] = None, + use_acls: bool = False, + category: Optional[str] = None, + users: Optional[str] = None, + space: Optional[str] = None, + ): + self.list_file_strategy = list_file_strategy + self.blob_manager = blob_manager + self.pdf_parser = pdf_parser + self.text_splitter = text_splitter + self.document_action = document_action + self.embeddings = embeddings + self.search_analyzer_name = search_analyzer_name + self.use_acls = use_acls + self.category = category + self.users = users + self.space = space + + async def extractPDFFeatures(self, pdf_path, file_Id, split_pages_dicts): + + def extract_text_by_rect(page_layout, rect): + """Extracts text from a defined rectangular area within the page layout.""" + x0, y0, x1, y1 = rect + text_content = [] + elements = [] + for element in page_layout: + # if isinstance(element, (LTTextBox, LTTextLine)): + # Check for any overlap between the text element and the rectangle + ex0, ey0, ex1, ey1 = element.bbox + if not (ex1 < x0 or ex0 > x1 or ey1 < y0 or ey0 > y1): + try: + text_content.append(element.get_text()) + except: + pass + return ''.join(text_content) + + async def addDocumentMetadata(file_id, page_id_to_number, extracted_data, split_pages_dicts, page_dimensions): + + user = os.environ.get("SQL_USER", "") + password = os.environ.get("SQL_PASSWORD", "") + database = os.environ.get("SQL_DATABASE", "") + host = os.environ.get("SQL_SERVER", "") + + conn = await asyncpg.connect(user=user, password=password, database=database, host=host) + print("PAGE_DIMS", page_dimensions) + records = await conn.fetch(""" + INSERT INTO fileMetadata (fileid, pagemap, contentlist, pagecontent, pagedimensions) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (fileid) + DO UPDATE SET + pagemap = EXCLUDED.pagemap, + contentlist = EXCLUDED.contentlist, + pagecontent = EXCLUDED.pagecontent + pagedimensions = EXCLUDED.pagedimensions; + """, file_id, + json.dumps(page_id_to_number, ensure_ascii=False), + json.dumps([anotation.to_dict() for anotation in extracted_data], ensure_ascii=False), + json.dumps(split_pages_dicts, ensure_ascii=False), + json.dumps(page_dimensions, ensure_ascii=False)) + + await conn.close() + + class pdfFeatures: + def __init__(self, fileid, pageMap, contentList): + self.fileid = fileid + self.pageMap = pageMap + self.contentList = contentList + + class anotationInfo: + def __init__(self, fileid, srcPageNum, srcText, destPageId, destPageNum): + self.fileid = fileid + self.srcPageNum = srcPageNum + self.srcText = srcText + self.destPageId = destPageId + self.destPageNum = destPageNum + + def to_dict(self): + return { + 'fileid': self.fileid, + 'srcPageNum': self.srcPageNum, + 'srcText': self.srcText, + 'destPageId': self.destPageId, + 'destPageNum': self.destPageNum + } + + def extract_contents(text, page_id_to_number): + reversed_page_id_to_number = {value: key for key, value in page_id_to_number.items()} + pattern = re.compile(r'(\d+(\.\d+)*)\.\s+(.+?)\s+(\d+)', re.MULTILINE) + contents = pattern.findall(text) + annotations = [] # Step 1: Initialize an empty list + occurrences_map = {} + last_matchpagenum = -1 + for match in contents: # Step 2: Iterate over each match + try: + matchpagenum = match[3].strip() + pageid = 0 + if matchpagenum: + pageid = reversed_page_id_to_number[int(matchpagenum)] + + formatted_str = f"{match[0]} {match[2].strip()}" + + if formatted_str not in occurrences_map: + if int(matchpagenum) >= last_matchpagenum: + last_matchpagenum = int(matchpagenum) + occurrences_map[formatted_str] = True + annotation = anotationInfo(file_Id, 1, formatted_str, pageid, matchpagenum) + annotations.append(annotation) # Step 4: Append to the list + + except Exception: + traceback.print_exc() + + return annotations + + with open_filename(pdf_path, "rb") as fp: + print("FL_OPEN_FILE") + resource_manager = PDFResourceManager() + device = PDFPageAggregator(resource_manager) + interpreter = PDFPageInterpreter(resource_manager, device) + extracted_data = [] + page_id_to_number= {} + page_number = 1 + page_dimensions = [] + for page in PDFPage.get_pages(fp): + page_id_to_number[page.pageid] = page_number + page_number += 1 + + pageMap = list(page_id_to_number.items()) + fp.seek(0) + page_number = 0 + for page in PDFPage.get_pages(fp): + page_number += 1 + interpreter.process_page(page) + layout = device.get_result() + if isinstance(layout, LTPage): + page_dimensions.append({ + "page_number": page_number, + "width": layout.width, + "height": layout.height + }) + + if page.annots is not None: + for annotation in page.annots: + anotResolved = resolve1(annotation) + destPDFObjRef = anotResolved.get('Dest') + if destPDFObjRef == None: + continue + destPageid = destPDFObjRef[0].objid + + anotationText = extract_text_by_rect( + layout, anotResolved.get('Rect')) + + destPageNum = page_id_to_number[destPageid] + extracted_data.append(anotationInfo(file_Id, page_number, + anotationText, destPageid, destPageNum)) + + ##logging.info("extracted_data",extracted_data) + if extracted_data == []: + try: + joined_text = ' '.join(d['text'] for d in split_pages_dicts) + extracted_data = extract_contents(joined_text, page_id_to_number) + except Exception as err: + print(f"FILESTRATEGYPY¦ERROR¦{err}") + + await addDocumentMetadata(file_Id, page_id_to_number, extracted_data, split_pages_dicts, page_dimensions) + + async def setup(self, search_info: SearchInfo): + search_manager = SearchManager( + search_info, self.search_analyzer_name, self.use_acls, self.embeddings) + await search_manager.create_index() + + async def run(self, search_info: SearchInfo,): + print("FILESTRATEGYPY¦List Paths From Filter Strategy") + search_manager = SearchManager( + search_info, self.search_analyzer_name, self.use_acls, self.embeddings) + + if self.document_action == DocumentAction.Add: + files = self.list_file_strategy.list() + async for file in files: + try: + # Category is FileID [TODO: Rename] + print("FFF!", file.path) + pages = [page async for page in self.pdf_parser.parse(content=file.content)] + if search_info.verbose: + print(f"SPLIT '{file.filename()}' INTO SECTIONS") + sections = [ + Section(split_page, content=file, category=self.category, + users=self.users, space=self.space) + for split_page in self.text_splitter.split_pages(pages) + ] + + + await search_manager.update_content(sections) + print("FILESTRATEGYPY¦ExtractPDF⇛Path", file.path) + + try: + split_pages = [section.split_page for section in sections] + split_pages_dicts = [{'page_num': split_page.page_num, 'text': split_page.text} for split_page in split_pages] + # Extract Annotations into DB + + #await self.extractPDFFeatures( + # file.path, self.category,split_pages_dicts) + + asyncio.create_task(self.extractPDFFeatures( + file.path, self.category, split_pages_dicts)) + + + except Exception as err: + print(f"FILESTRATEGYPY¦ERROR¦{err}") + + + # @PY ¦ run ⇛ upload_blob ⇒ Blob Subfoldering on Upload + await self.blob_manager.upload_blob(file, self.users, self.category) + finally: + if file: + file.close() + + if os.path.exists(file.content.name): + os.remove(file.content.name) + if os.path.exists(f"{file.content.name}.md5"): + os.remove(f"{file.content.name}.md5") + + elif self.document_action == DocumentAction.Remove: + print("FILESTRATEGYPY¦Remove Action") + paths = self.list_file_strategy.list_paths() + async for path in paths: + pathParse = path.replace("\\", "/").replace("data/", "") + print(f"FILESTRATEGYPY¦REMOVEBLOB¦Removing {pathParse}") + await self.blob_manager.remove_blob(pathParse, self.category, self.users) + await search_manager.remove_content(pathParse, self.category, self.users) + elif self.document_action == DocumentAction.RemoveAll: + + await self.blob_manager.remove_blob() + await search_manager.remove_content() diff --git a/app/backend/webprepdocslib/listfilestrategy.py b/app/backend/webprepdocslib/listfilestrategy.py new file mode 100644 index 0000000000..2a70e57956 --- /dev/null +++ b/app/backend/webprepdocslib/listfilestrategy.py @@ -0,0 +1,176 @@ +import base64 +import hashlib +import os +import re +import tempfile +from abc import ABC +from glob import glob +from typing import IO, AsyncGenerator, Dict, List, Optional, Union + +from azure.core.credentials_async import AsyncTokenCredential +#from azure.storage.filedatalake.aio import ( +# DataLakeServiceClient, +#) + + +class File: + """ + Represents a file stored either locally or in a data lake storage account + This file might contain access control information about which users or groups can access it + """ + + def __init__(self, content: IO, acls: Optional[dict[str, list]] = None): + self.content = content + self.acls = acls or {} + self.path = content.name + + def filename(self): + return os.path.basename(self.content.name) + + def filename_to_id(self): + filename_ascii = re.sub("[^0-9a-zA-Z_-]", "_", self.filename()) + filename_hash = base64.b16encode(self.filename().encode("utf-8")).decode("ascii") + return f"file-{filename_ascii}-{filename_hash}" + + def close(self): + if self.content: + self.content.close() + + +class ListFileStrategy(ABC): + """ + Abstract strategy for listing files that are located somewhere. For example, on a local computer or remotely in a storage account + """ + + async def list(self) -> AsyncGenerator[File, None]: + if False: # pragma: no cover - this is necessary for mypy to type check + yield + + async def list_paths(self) -> AsyncGenerator[str, None]: + if False: # pragma: no cover - this is necessary for mypy to type check + yield + + +class LocalListFileStrategy(ListFileStrategy): + """ + Concrete strategy for listing files that are located in a local filesystem + """ + + def __init__(self, path_pattern: str, verbose: bool = False): + self.path_pattern = path_pattern + self.verbose = verbose + + async def list_paths(self) -> AsyncGenerator[str, None]: + async for p in self._list_paths(self.path_pattern): + yield p + + async def _list_paths(self, path_pattern: str) -> AsyncGenerator[str, None]: + for path in glob(path_pattern): + if os.path.isdir(path): + async for p in self._list_paths(f"{path}/*"): + yield p + else: + # Only list files, not directories + yield path + + async def list(self) -> AsyncGenerator[File, None]: + async for path in self.list_paths(): + if not self.check_md5(path): + yield File(content=open(path, mode="rb")) + + def check_md5(self, path: str) -> bool: + # if filename ends in .md5 skip + if path.endswith(".md5"): + return True + + # if there is a file called .md5 in this directory, see if its updated + stored_hash = None + with open(path, "rb") as file: + existing_hash = hashlib.md5(file.read()).hexdigest() + hash_path = f"{path}.md5" + if os.path.exists(hash_path): + with open(hash_path, encoding="utf-8") as md5_f: + stored_hash = md5_f.read() + + if stored_hash and stored_hash.strip() == existing_hash.strip(): + if self.verbose: + print(f"Skipping {path}, no changes detected.") + return True + + # Write the hash + with open(hash_path, "w", encoding="utf-8") as md5_f: + md5_f.write(existing_hash) + + return False + +class ADLSGen2ListFileStrategy(ListFileStrategy): + """ + Concrete strategy for listing files that are located in a data lake storage account + """ + + def __init__( + self, + data_lake_storage_account: str, + data_lake_filesystem: str, + data_lake_path: str, + credential: Union[AsyncTokenCredential, str], + verbose: bool = False, + ): + self.data_lake_storage_account = data_lake_storage_account + self.data_lake_filesystem = data_lake_filesystem + self.data_lake_path = data_lake_path + self.credential = credential + self.verbose = verbose +""" + + async def list_paths(self) -> AsyncGenerator[str, None]: + return None + async with DataLakeServiceClient( + account_url=f"https://{self.data_lake_storage_account}.dfs.core.windows.net", credential=self.credential + ) as service_client, service_client.get_file_system_client(self.data_lake_filesystem) as filesystem_client: + async for path in filesystem_client.get_paths(path=self.data_lake_path, recursive=True): + if path.is_directory: + continue + + yield path.name + + async def list(self) -> AsyncGenerator[File, None]: + return None + async with DataLakeServiceClient( + account_url=f"https://{self.data_lake_storage_account}.dfs.core.windows.net", credential=self.credential + ) as service_client, service_client.get_file_system_client(self.data_lake_filesystem) as filesystem_client: + async for path in self.list_paths(): + temp_file_path = os.path.join(tempfile.gettempdir(), os.path.basename(path)) + try: + async with filesystem_client.get_file_client(path) as file_client: + with open(temp_file_path, "wb") as temp_file: + downloader = await file_client.download_file() + await downloader.readinto(temp_file) + # Parse out user ids and group ids + acls: Dict[str, List[str]] = {"oids": [], "groups": []} + # https://learn.microsoft.com/python/api/azure-storage-file-datalake/azure.storage.filedatalake.datalakefileclient?view=azure-python#azure-storage-filedatalake-datalakefileclient-get-access-control + # Request ACLs as GUIDs + access_control = await file_client.get_access_control(upn=False) + acl_list = access_control["acl"] + # https://learn.microsoft.com/azure/storage/blobs/data-lake-storage-access-control + # ACL Format: user::rwx,group::r-x,other::r--,user:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:r-- + acl_list = acl_list.split(",") + for acl in acl_list: + acl_parts: list = acl.split(":") + if len(acl_parts) != 3: + continue + if len(acl_parts[1]) == 0: + continue + if acl_parts[0] == "user" and "r" in acl_parts[2]: + acls["oids"].append(acl_parts[1]) + if acl_parts[0] == "group" and "r" in acl_parts[2]: + acls["groups"].append(acl_parts[1]) + yield File(content=open(temp_file_path, "rb"), acls=acls) + except Exception as data_lake_exception: + print(f"\tGot an error while reading {path} -> {data_lake_exception} --> skipping file") + try: + os.remove(temp_file_path) + except Exception as file_delete_exception: + print(f"\tGot an error while deleting {temp_file_path} -> {file_delete_exception}") + +""" \ No newline at end of file diff --git a/app/backend/webprepdocslib/pdffeaturesextract.py b/app/backend/webprepdocslib/pdffeaturesextract.py new file mode 100644 index 0000000000..e1fd17e655 --- /dev/null +++ b/app/backend/webprepdocslib/pdffeaturesextract.py @@ -0,0 +1,115 @@ +import os +import asyncpg +import json + +from pdfminer.converter import PDFPageAggregator +from pdfminer.pdfinterp import PDFResourceManager, PDFPageInterpreter +from pdfminer.pdfpage import PDFPage +from pdfminer.utils import open_filename +from pdfminer.pdftypes import resolve1 +from pdfminer.layout import LAParams, LTTextBox, LTTextLine, LTChar + + +def extract_text_by_rect(page_layout, rect): + """Extracts text from a defined rectangular area within the page layout.""" + x0, y0, x1, y1 = rect + text_content = [] + for element in page_layout: + # if isinstance(element, (LTTextBox, LTTextLine)): + # Check for any overlap between the text element and the rectangle + ex0, ey0, ex1, ey1 = element.bbox + if not (ex1 < x0 or ex0 > x1 or ey1 < y0 or ey0 > y1): + + # print(element.bbox) + + try: + text_content.append(element.get_text()) + except: + pass + return ''.join(text_content) + + +class pdfFeatures: + def __init__(self, fileid, pageMap, contentList): + self.fileid = fileid + self.pageMap = pageMap + self.contentList = contentList + + +class anotationInfo: + def __init__(self, fileid, srcPageNum, srcText, destPageId, destPageNum): + self.fileid = fileid + self.srcPageNum = srcPageNum + self.srcText = srcText + self.destPageId = destPageId + self.destPageNum = destPageNum + + +def extractPDFFeatures(pdf_path, file_Id): + + with open_filename(pdf_path, "rb") as fp: + resource_manager = PDFResourceManager() + device = PDFPageAggregator(resource_manager) + interpreter = PDFPageInterpreter(resource_manager, device) + + # Content List + extracted_data = [] + + # Dictionary to map internal page IDs to actual page numbers + page_id_to_number = {} + + # First pass to populate the page ID to page number mapping + page_number = 1 + for page in PDFPage.get_pages(fp): + page_id_to_number[page.pageid] = page_number + page_number += 1 + + pageMap = list(page_id_to_number.items()) + + # Reset file pointer to start for the second pass + fp.seek(0) + page_number = 0 + for page in PDFPage.get_pages(fp): + page_number += 1 + interpreter.process_page(page) + layout = device.get_result() + # print(layout) + if page.annots is not None: + for annotation in page.annots: + anotResolved = resolve1(annotation) + destPDFObjRef = anotResolved.get('Dest') + if destPDFObjRef == None: + continue + destPageid = destPDFObjRef[0].objid + + anotationText = extract_text_by_rect( + layout, anotResolved.get('Rect')) + + destPageNum = page_id_to_number[destPageid] + extracted_data.append(anotationInfo(file_Id, page_number, + anotationText, destPageid, destPageNum)) + + ##addDocumentMetadata(file_Id, page_id_to_number, extracted_data) + + +async def addDocumentMetadata(file_id, page_id_to_number, extracted_data): + + user = os.environ.get("SQL_USER", "") + password = os.environ.get("SQL_PASSWORD", "") + database = os.environ.get("SQL_DATABASE", "") + host = os.environ.get("SQL_SERVER", "") + + conn = await asyncpg.connect(user=user, password=password, database=database, host=host) + + + print("!JSON_extracted_data",json.dumps(extracted_data)) + print("!extracted_data", extracted_data) + + records = await conn.fetch(""" + INSERT INTO fileMetadata (fileid, pagemap, contentlist) VALUES ($1, $2, $3) + ON CONFLICT (fileid) + DO UPDATE SET + pagemap = EXCLUDED.pagemap, + contentlist = EXCLUDED.contentlist; + """, file_id, json.dumps(page_id_to_number), json.dumps(extracted_data)) + await conn.close() diff --git a/app/backend/webprepdocslib/pdfparser.py b/app/backend/webprepdocslib/pdfparser.py new file mode 100644 index 0000000000..63ce893443 --- /dev/null +++ b/app/backend/webprepdocslib/pdfparser.py @@ -0,0 +1,139 @@ +import html +from abc import ABC +from typing import IO, AsyncGenerator, Union + +##from azure.ai.formrecognizer import DocumentTable +##from azure.ai.formrecognizer.aio import DocumentAnalysisClient +from azure.core.credentials import AzureKeyCredential +from azure.core.credentials_async import AsyncTokenCredential + +from pypdf import PdfReader +from .strategy import USER_AGENT + + +class Page: + """ + A single page from a pdf + + Attributes: + page_num (int): Page number + offset (int): If the text of the entire PDF was concatenated into a single string, the index of the first character on the page. For example, if page 1 had the text "hello" and page 2 had the text "world", the offset of page 2 is 5 ("hellow") + text (str): The text of the page + """ + + def __init__(self, page_num: int, offset: int, text: str): + self.page_num = page_num + self.offset = offset + self.text = text + + +class PdfParser(ABC): + """ + Abstract parser that parses PDFs into pages + """ + + async def parse(self, content: IO) -> AsyncGenerator[Page, None]: + if False: + yield + + +class LocalPdfParser(PdfParser): + """ + Concrete parser backed by PyPDF that can parse PDFs into pages + To learn more, please visit https://pypi.org/project/pypdf/ + """ + + async def parse(self, content: IO) -> AsyncGenerator[Page, None]: + reader = PdfReader(content) + pages = reader.pages + offset = 0 + for page_num, p in enumerate(pages): + page_text = p.extract_text() + yield Page(page_num=page_num, offset=offset, text=page_text) + offset += len(page_text) + + +class DocumentAnalysisPdfParser(PdfParser): + """ + Concrete parser backed by Azure AI Document Intelligence that can parse PDFS into pages + To learn more, please visit https://learn.microsoft.com/azure/ai-services/document-intelligence/overview + """ + + def __init__( + self, + endpoint: str, + credential: Union[AsyncTokenCredential, AzureKeyCredential], + model_id="prebuilt-layout", + verbose: bool = False, + ): + self.model_id = model_id + self.endpoint = endpoint + self.credential = credential + self.verbose = verbose + +""" + async def parse(self, content: IO) -> AsyncGenerator[Page, None]: + if self.verbose: + print(f"Extract Text From '{content.name}' With Azure Document Intelligence") + + async with DocumentAnalysisClient( + endpoint=self.endpoint, credential=self.credential, headers={"x-ms-useragent": USER_AGENT} + ) as form_recognizer_client: + poller = await form_recognizer_client.begin_analyze_document(model_id=self.model_id, document=content) + form_recognizer_results = await poller.result() + + offset = 0 + for page_num, page in enumerate(form_recognizer_results.pages): + tables_on_page = [ + table + for table in (form_recognizer_results.tables or []) + if table.bounding_regions and table.bounding_regions[0].page_number == page_num + 1 + ] + + # mark all positions of the table spans in the page + page_offset = page.spans[0].offset + page_length = page.spans[0].length + table_chars = [-1] * page_length + for table_id, table in enumerate(tables_on_page): + for span in table.spans: + # replace all table spans with "table_id" in table_chars array + for i in range(span.length): + idx = span.offset - page_offset + i + if idx >= 0 and idx < page_length: + table_chars[idx] = table_id + + # build page text by replacing characters in table spans with table html + page_text = "" + added_tables = set() + for idx, table_id in enumerate(table_chars): + if table_id == -1: + page_text += form_recognizer_results.content[page_offset + idx] + elif table_id not in added_tables: + page_text += DocumentAnalysisPdfParser.table_to_html(tables_on_page[table_id]) + added_tables.add(table_id) + + yield Page(page_num=page_num, offset=offset, text=page_text) + offset += len(page_text) + + @classmethod + def table_to_html(cls, table: DocumentTable): + table_html = "" + rows = [ + sorted([cell for cell in table.cells if cell.row_index == i], key=lambda cell: cell.column_index) + for i in range(table.row_count) + ] + for row_cells in rows: + table_html += "" + for cell in row_cells: + tag = "th" if (cell.kind == "columnHeader" or cell.kind == "rowHeader") else "td" + cell_spans = "" + if cell.column_span is not None and cell.column_span > 1: + cell_spans += f" colSpan={cell.column_span}" + if cell.row_span is not None and cell.row_span > 1: + cell_spans += f" rowSpan={cell.row_span}" + table_html += f"<{tag}{cell_spans}>{html.escape(cell.content)}" + table_html += "" + table_html += "
" + return table_html + +""" \ No newline at end of file diff --git a/app/backend/webprepdocslib/searchmanager.py b/app/backend/webprepdocslib/searchmanager.py new file mode 100644 index 0000000000..f84086ccef --- /dev/null +++ b/app/backend/webprepdocslib/searchmanager.py @@ -0,0 +1,266 @@ +import asyncio +import os +import asyncpg + +from typing import List, Optional + +from azure.search.documents.indexes.models import ( + HnswAlgorithmConfiguration, + HnswParameters, + SearchableField, + SearchField, + SearchFieldDataType, + SearchIndex, + SemanticConfiguration, + SemanticField, + SemanticPrioritizedFields, + SemanticSearch, + SimpleField, + VectorSearch, + VectorSearchProfile, + VectorSearchVectorizer, + VectorSearchAlgorithmKind +) + +from .blobmanager import BlobManager +from .embeddings import OpenAIEmbeddings +from .listfilestrategy import File +from .strategy import SearchInfo +from .textsplitter import SplitPage + +user = os.environ.get("SQL_USER", "") +password = os.environ.get("SQL_PASSWORD", "") +database = os.environ.get("SQL_DATABASE", "") +host = os.environ.get("SQL_SERVER", "") +bloburl = os.environ.get("AZURE_STORAGE_ROOT_URL", "") + +class Section: + """ + A section of a page that is stored in a search service. These sections are used as context by Azure OpenAI service + """ + + def __init__(self, split_page: SplitPage, content: File, category: Optional[str] = None, users: Optional[str] = None, space: Optional[str] = None ): + self.split_page = split_page + self.content = content + self.category = category + self.users = users + self.space = space + + +class SearchManager: + """ + Class to manage a search service. It can create indexes, and update or remove sections stored in these indexes + To learn more, please visit https://learn.microsoft.com/azure/search/search-what-is-azure-search + """ + + def __init__( + self, + search_info: SearchInfo, + search_analyzer_name: Optional[str] = None, + use_acls: bool = False, + embeddings: Optional[OpenAIEmbeddings] = None, + ): + self.search_info = search_info + self.search_analyzer_name = search_analyzer_name + self.use_acls = use_acls + self.embeddings = embeddings + + async def create_index(self): + if self.search_info.verbose: + print(f"Ensuring search index {self.search_info.index_name} exists") + + async with self.search_info.create_search_index_client() as search_index_client: + fields = [ + SimpleField(name="id", type="Edm.String", key=True), + SearchableField(name="content", type="Edm.String", analyzer_name=self.search_analyzer_name), + SearchField( + name="embedding", + type=SearchFieldDataType.Collection(SearchFieldDataType.Single), + hidden=False, + searchable=True, + filterable=False, + sortable=False, + facetable=False, + vector_search_dimensions=1536, + vector_search_profile_name="embedding_config", + ), + SimpleField(name="category", type="Edm.String", filterable=True, facetable=True), + SimpleField(name="sourcepage", type="Edm.String", filterable=True, facetable=True), + SimpleField(name="sourcefile", type="Edm.String", filterable=True, facetable=True), + ] + if self.use_acls: + fields.append( + SimpleField( + name="oids", type=SearchFieldDataType.Collection(SearchFieldDataType.String), filterable=True + ) + ) + fields.append( + SimpleField( + name="groups", type=SearchFieldDataType.Collection(SearchFieldDataType.String), filterable=True + ) + ) + + index = SearchIndex( + name=self.search_info.index_name, + fields=fields, + semantic_search=SemanticSearch( + configurations=[ + SemanticConfiguration( + name="default", + prioritized_fields=SemanticPrioritizedFields( + title_field=None, content_fields=[SemanticField(field_name="content")] + ), + ) + ] + ), + vector_search=VectorSearch( + algorithms=[ + HnswAlgorithmConfiguration( + name="hnsw_config", + kind=VectorSearchAlgorithmKind.HNSW, + parameters=HnswParameters(metric="cosine"), + ) + ], + profiles=[ + VectorSearchProfile( + name="embedding_config", + algorithm_configuration_name="hnsw_config", + ), + ], + ), + ) + if self.search_info.index_name not in [name async for name in search_index_client.list_index_names()]: + if self.search_info.verbose: + print(f"Creating {self.search_info.index_name} search index") + await search_index_client.create_index(index) + else: + if self.search_info.verbose: + print(f"Search index {self.search_info.index_name} already exists") + + async def addIndexLog(self, sourcefile, category, id, sourcepage): + conn = await asyncpg.connect(user=user, password=password, database=database, host=host) + result = await conn.execute(""" + INSERT INTO fileindexes (indexid, fileid, created, updated, filename, filepage) VALUES ($1::text, $2::text, NOW(), NOW(), $3::text, $4::text) + ON CONFLICT (indexid) DO + UPDATE SET + fileid = EXCLUDED.fileid, + updated = EXCLUDED.updated, + filename = EXCLUDED.filename, + filepage = EXCLUDED.filepage; + ;""",id, category, sourcefile, sourcepage) + await conn.close() + async def removeIndexLog(self, id): ## @PY ¦ Q ⇒ Remove Index Log + conn = await asyncpg.connect(user=user, password=password, database=database, host=host) + result = await conn.execute(""" + DELETE FROM fileindexes WHERE indexid = $1::text + ;""",id) + await conn.close() + async def update_content(self, sections: List[Section]): + MAX_BATCH_SIZE = 1000 + section_batches = [sections[i : i + MAX_BATCH_SIZE] for i in range(0, len(sections), MAX_BATCH_SIZE)] + + async with self.search_info.create_search_client() as search_client: + for batch_index, batch in enumerate(section_batches): + documents = [ + { + ## "id": f"{section.Users.split(';')[0]}_{section.content.filename_to_id()}-page-{section_index + batch_index * MAX_BATCH_SIZE}", + ##"id": f"{str(section.category)}-page-{section_index + batch_index * MAX_BATCH_SIZE}", + "id": f"{section.content.filename_to_id()}-page-{section_index + batch_index * MAX_BATCH_SIZE}", + "content": section.split_page.text, + "category": str(section.category), + "sourcepage": BlobManager.sourcepage_from_file_page( + filename=section.content.filename(), page=section.split_page.page_num + ), ## + "#fid=" + str(section.category), + "sourcefile": section.content.filename(), + "Users": section.space.split(';'), + **section.content.acls, + } + for section_index, section in enumerate(batch) + ] + #for sec in documents: + # await self.addIndexLog(sec["sourcefile"], str(sec["category"]), sec["id"], sec["sourcepage"]) + + if self.embeddings: + embeddings = await self.embeddings.create_embeddings( + texts=[section.split_page.text for section in batch] + ) + for i, document in enumerate(documents): + document["embedding"] = embeddings[i] + await search_client.upload_documents(documents) ## https://learn.microsoft.com/en-us/python/api/azure-search-documents/azure.search.documents.searchclient?view=azure-python#azure-search-documents-searchclient-upload-documents + async def remove_content(self, path: Optional[str] = None, category: Optional[str] = None, users: Optional[str] = None, ): + print("!!remove_content!!","users", users,"category", category,"path", path) + if not (category or path) or not users: + raise ValueError("Refusing to remove content from index: both category/path and users must be provided to avoid deleting all data.") + if self.search_info.verbose: + print(f"REMOVING SECTIONS '{path or ''}' FROM SRCH INDX '{self.search_info.index_name}'") + async with self.search_info.create_search_client() as search_client: + while True: + catFilter = None if path is None else f"category eq '{category}'" # Category is FileId in Postgres + usrFilter = None + if users is not None and users != "": + uFil = "u eq '{}'".format(users) + usrFilter = " Users/any(u: " + uFil + ")" if len(users) > 0 else " not Users/any()" + + if catFilter is not None and usrFilter is not None: + filter = f"{catFilter} and {usrFilter}" + elif catFilter is not None: + filter = catFilter + elif usrFilter is not None: + filter = usrFilter + else: + filter = None + + print("SRCHMNGR¦REMOVING¦catFilter",catFilter) + print("SRCHMNGR¦REMOVING¦usrFilter",usrFilter) + print("SRCHMNGR¦REMOVING¦filter",filter) + + result = await search_client.search("", filter=filter, top=1000, include_total_count=True) if filter else await search_client.search("", top=1000, include_total_count=True) + if await result.get_count() == 0: + print("NOREMOVESRCH RESULTS") + break + documents_to_delete = [] + async for document in result: + documents_to_delete.append({"id": document["id"]}) + #await self.removeIndexLog(document["id"]) + print("SRCHMNGR¦REMOVING ID¦",document["id"]) + removed_docs = await search_client.delete_documents(documents=documents_to_delete) + + if self.search_info.verbose: + print(f"\tRemoved {len(removed_docs)} sections from index") + # It can take a few seconds for search results to reflect changes, so wait a bit + await asyncio.sleep(2) + async def remove_content_v2(self, path: Optional[str] = None, category: Optional[str] = None, users: Optional[str] = None): + """ + Remove content from index by filtering on sourcefile (filename). + """ + print("!!remove_content_v2!!", "users", users, "category", category, "path", path) + if not (category or path): + raise ValueError("Refusing to remove content from index: category/path must be provided to avoid deleting all data.") + if self.search_info.verbose: + print(f"REMOVING SECTIONS (v2) '{path or ''}' FROM SRCH INDX '{self.search_info.index_name}'") + async with self.search_info.create_search_client() as search_client: + while True: + # Use sourcefile for filename-based deletion + sourcefile_filter = f"sourcefile eq '{category}'" if category else None + filter = sourcefile_filter + + print("SRCHMNGR¦REMOVING¦sourcefile_filter", sourcefile_filter) + print("SRCHMNGR¦REMOVING¦filter", filter) + + try: + result = await search_client.search("", filter=filter, top=1000, include_total_count=True) if filter else await search_client.search("", top=1000, include_total_count=True) + except Exception as e: + print(f"Azure Search error: {e}. Filter used: {filter}") + raise + if await result.get_count() == 0: + print("NOREMOVESRCH RESULTS") + break + documents_to_delete = [] + async for document in result: + documents_to_delete.append({"id": document["id"]}) + print("SRCHMNGR¦REMOVING ID¦", document["id"]) + removed_docs = await search_client.delete_documents(documents=documents_to_delete) + + if self.search_info.verbose: + print(f"\tRemoved {len(removed_docs)} sections from index (v2)") + await asyncio.sleep(2) diff --git a/app/backend/webprepdocslib/strategy.py b/app/backend/webprepdocslib/strategy.py new file mode 100644 index 0000000000..d2150a10a0 --- /dev/null +++ b/app/backend/webprepdocslib/strategy.py @@ -0,0 +1,49 @@ +from abc import ABC +from typing import Union + +from azure.core.credentials import AzureKeyCredential +from azure.core.credentials_async import AsyncTokenCredential +from azure.search.documents.aio import SearchClient +from azure.search.documents.indexes.aio import SearchIndexClient, SearchIndexerClient + +USER_AGENT = "azure-search-chat-demo/1.0.0" + + +class SearchInfo: + """ + Class representing a connection to a search service + To learn more, please visit https://learn.microsoft.com/azure/search/search-what-is-azure-search + """ + + def __init__( + self, + endpoint: str, + credential: Union[AsyncTokenCredential, AzureKeyCredential], + index_name: str, + verbose: bool = False, + ): + self.endpoint = endpoint + self.credential = credential + self.index_name = index_name + self.verbose = verbose + + def create_search_client(self) -> SearchClient: + return SearchClient(endpoint=self.endpoint, index_name=self.index_name, credential=self.credential) + + def create_search_index_client(self) -> SearchIndexClient: + return SearchIndexClient(endpoint=self.endpoint, credential=self.credential) + + def create_search_indexer_client(self) -> SearchIndexerClient: + return SearchIndexerClient(endpoint=self.endpoint, credential=self.credential) + + +class Strategy(ABC): + """ + Abstract strategy for ingesting documents into a search service. It has a single setup step to perform any required initialization, and then a run step that actually ingests documents into the search service. + """ + + async def setup(self, search_info: SearchInfo): + raise NotImplementedError + + async def run(self, search_info: SearchInfo): + raise NotImplementedError diff --git a/app/backend/webprepdocslib/textsplitter.py b/app/backend/webprepdocslib/textsplitter.py new file mode 100644 index 0000000000..906321fa8f --- /dev/null +++ b/app/backend/webprepdocslib/textsplitter.py @@ -0,0 +1,94 @@ +from typing import Generator, List + +from .pdfparser import Page + + +class SplitPage: + """ + A section of a page that has been split into a smaller chunk. + """ + + def __init__(self, page_num: int, text: str): + self.page_num = page_num + self.text = text + + +class TextSplitter: + """ + Class that splits pages into smaller chunks. This is required because embedding models may not be able to analyze an entire page at once + """ + + def __init__(self, verbose: bool = False): + self.sentence_endings = [".", "!", "?"] + self.word_breaks = [",", ";", ":", " ", "(", ")", "[", "]", "{", "}", "\t", "\n"] + self.max_section_length = 3000 ## 1000 + self.sentence_search_limit = 200 ## 100 + self.section_overlap = 200 ## 100 + self.verbose = verbose + + def split_pages(self, pages: List[Page]) -> Generator[SplitPage, None, None]: + def find_page(offset): + num_pages = len(pages) + for i in range(num_pages - 1): + if offset >= pages[i].offset and offset < pages[i + 1].offset: + return pages[i].page_num + return pages[num_pages - 1].page_num + + all_text = "".join(page.text for page in pages) + length = len(all_text) + start = 0 + end = length + while start + self.section_overlap < length: + last_word = -1 + end = start + self.max_section_length + + if end > length: + end = length + else: + # Try to find the end of the sentence + while ( + end < length + and (end - start - self.max_section_length) < self.sentence_search_limit + and all_text[end] not in self.sentence_endings + ): + if all_text[end] in self.word_breaks: + last_word = end + end += 1 + if end < length and all_text[end] not in self.sentence_endings and last_word > 0: + end = last_word # Fall back to at least keeping a whole word + if end < length: + end += 1 + + # Try to find the start of the sentence or at least a whole word boundary + last_word = -1 + while ( + start > 0 + and start > end - self.max_section_length - 2 * self.sentence_search_limit + and all_text[start] not in self.sentence_endings + ): + if all_text[start] in self.word_breaks: + last_word = start + start -= 1 + if all_text[start] not in self.sentence_endings and last_word > 0: + start = last_word + if start > 0: + start += 1 + + section_text = all_text[start:end] + yield SplitPage(page_num=find_page(start), text=section_text) + + last_table_start = section_text.rfind(" 2 * self.sentence_search_limit and last_table_start > section_text.rfind(" + - GPT + Enterprise data | Sample + GPT Enterprise
diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json index 9378a874a9..8aa97cc1e1 100644 --- a/app/frontend/package-lock.json +++ b/app/frontend/package-lock.json @@ -13,14 +13,18 @@ "@fluentui/react": "^8.112.5", "@fluentui/react-components": "^9.37.3", "@fluentui/react-icons": "^2.0.221", + "@mui/icons-material": "^6.4.7", "@react-spring/web": "^9.7.3", + "axios": "^1.8.2", "dompurify": "^3.0.6", + "jwt-decode": "^4.0.0", "marked": "^13.0.0", "ndjson-readablestream": "^1.0.7", "react": "^18.3.1", "react-dom": "^18.2.0", "react-router-dom": "^6.23.1", "react-syntax-highlighter": "^15.5.0", + "react-toastify": "^11.0.5", "scheduler": "^0.20.2" }, "devDependencies": { @@ -342,7 +346,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.22.15", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", + "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -397,10 +403,82 @@ "node": ">=6.9.0" } }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache/node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT", + "peer": true + }, "node_modules/@emotion/hash": { - "version": "0.9.1", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", "license": "MIT" }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT", + "peer": true + }, "node_modules/@floating-ui/core": { "version": "1.5.0", "license": "MIT", @@ -2430,6 +2508,268 @@ "version": "1.10.295", "license": "MIT" }, + "node_modules/@mui/core-downloads-tracker": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.7.tgz", + "integrity": "sha512-XjJrKFNt9zAKvcnoIIBquXyFyhfrHYuttqMsoDS7lM7VwufYG4fAPw4kINjBFg++fqXM2BNAuWR9J7XVIuKIKg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.4.7.tgz", + "integrity": "sha512-Rk8cs9ufQoLBw582Rdqq7fnSXXZTqhYRbpe1Y5SAz9lJKZP3CIdrj0PfG8HJLGw1hrsHFN/rkkm70IDzhJsG1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^6.4.7", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.7.tgz", + "integrity": "sha512-K65StXUeGAtFJ4ikvHKtmDCO5Ab7g0FZUu2J5VpoKD+O6Y3CjLYzRi+TMlI3kaL4CL158+FccMoOd/eaddmeRQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.4.7", + "@mui/system": "^6.4.7", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.6", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.4.7", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "license": "MIT", + "peer": true + }, + "node_modules/@mui/private-theming": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.6.tgz", + "integrity": "sha512-T5FxdPzCELuOrhpA2g4Pi6241HAxRwZudzAuL9vBvniuB5YU82HCmrARw32AuCiyTfWzbrYGGpZ4zyeqqp9RvQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.4.6", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.4.6.tgz", + "integrity": "sha512-vSWYc9ZLX46be5gP+FCzWVn5rvDr4cXC5JBZwSIkYk9xbC7GeV+0kCvB8Q6XLFQJy+a62bbqtmdwS4Ghi9NBlQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.4.7.tgz", + "integrity": "sha512-7wwc4++Ak6tGIooEVA9AY7FhH2p9fvBMORT4vNLMAysH3Yus/9B9RYMbrn3ANgsOyvT3Z7nE+SP8/+3FimQmcg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.4.6", + "@mui/styled-engine": "^6.4.6", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.6", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.21", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", + "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.6.tgz", + "integrity": "sha512-43nZeE1pJF2anGafNydUcYFPtHwAqiBiauRtaMvurdrZI3YrUjHkAu43RBsxef7OFtJMXGiHFvq43kb7lig0sA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.21", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "license": "MIT", + "peer": true + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@react-spring/animated": { "version": "9.7.3", "license": "MIT", @@ -2607,7 +2947,9 @@ } }, "node_modules/@types/prop-types": { - "version": "15.7.5", + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", "license": "MIT" }, "node_modules/@types/react": { @@ -2634,6 +2976,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.3", "dev": true, @@ -2672,6 +3024,23 @@ "node": ">=4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", + "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/browserslist": { "version": "4.22.1", "dev": true, @@ -2703,6 +3072,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001547", "dev": true, @@ -2759,6 +3141,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "1.9.3", "dev": true, @@ -2772,6 +3163,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/comma-separated-tokens": { "version": "1.0.8", "license": "MIT", @@ -2786,7 +3189,9 @@ "license": "MIT" }, "node_modules/csstype": { - "version": "3.1.2", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, "node_modules/debug": { @@ -2805,6 +3210,15 @@ } } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dom-helpers": { "version": "5.2.1", "license": "MIT", @@ -2817,11 +3231,70 @@ "version": "3.0.6", "license": "(MPL-2.0 OR Apache-2.0)" }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.549", "dev": true, "license": "ISC" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.18.11", "dev": true, @@ -2895,6 +3368,41 @@ "@floating-ui/dom": ">=1.0.0 <2.0.0" } }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/format": { "version": "0.2.2", "engines": { @@ -2915,8 +3423,13 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "license": "MIT" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/gensync": { "version": "1.0.0-beta.2", @@ -2926,6 +3439,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/globals": { "version": "11.12.0", "dev": true, @@ -2934,6 +3484,18 @@ "node": ">=4" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has": { "version": "1.0.3", "license": "MIT", @@ -2952,6 +3514,45 @@ "node": ">=4" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-parse-selector": { "version": "2.2.5", "license": "MIT", @@ -3054,6 +3655,15 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyborg": { "version": "2.2.0", "license": "MIT" @@ -3099,6 +3709,36 @@ "node": ">= 18" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ms": { "version": "2.1.2", "dev": true, @@ -3244,6 +3884,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -3329,6 +3975,19 @@ "react": ">= 0.14.0" } }, + "node_modules/react-toastify": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", + "integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "license": "BSD-3-Clause", @@ -3786,7 +4445,9 @@ } }, "@babel/runtime": { - "version": "7.22.15", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", + "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", "requires": { "regenerator-runtime": "^0.14.0" } @@ -3825,8 +4486,74 @@ "to-fast-properties": "^2.0.0" } }, + "@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "peer": true, + "requires": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + }, + "dependencies": { + "stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "peer": true + } + } + }, "@emotion/hash": { - "version": "0.9.1" + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, + "@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "peer": true + }, + "@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "peer": true, + "requires": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "peer": true + }, + "@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "peer": true + }, + "@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "peer": true + }, + "@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "peer": true }, "@floating-ui/core": { "version": "1.5.0", @@ -5197,6 +5924,124 @@ "@microsoft/load-themed-styles": { "version": "1.10.295" }, + "@mui/core-downloads-tracker": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.7.tgz", + "integrity": "sha512-XjJrKFNt9zAKvcnoIIBquXyFyhfrHYuttqMsoDS7lM7VwufYG4fAPw4kINjBFg++fqXM2BNAuWR9J7XVIuKIKg==", + "peer": true + }, + "@mui/icons-material": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.4.7.tgz", + "integrity": "sha512-Rk8cs9ufQoLBw582Rdqq7fnSXXZTqhYRbpe1Y5SAz9lJKZP3CIdrj0PfG8HJLGw1hrsHFN/rkkm70IDzhJsG1g==", + "requires": { + "@babel/runtime": "^7.26.0" + } + }, + "@mui/material": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.7.tgz", + "integrity": "sha512-K65StXUeGAtFJ4ikvHKtmDCO5Ab7g0FZUu2J5VpoKD+O6Y3CjLYzRi+TMlI3kaL4CL158+FccMoOd/eaddmeRQ==", + "peer": true, + "requires": { + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.4.7", + "@mui/system": "^6.4.7", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.6", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "dependencies": { + "react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "peer": true + } + } + }, + "@mui/private-theming": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.6.tgz", + "integrity": "sha512-T5FxdPzCELuOrhpA2g4Pi6241HAxRwZudzAuL9vBvniuB5YU82HCmrARw32AuCiyTfWzbrYGGpZ4zyeqqp9RvQ==", + "peer": true, + "requires": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.4.6", + "prop-types": "^15.8.1" + } + }, + "@mui/styled-engine": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.4.6.tgz", + "integrity": "sha512-vSWYc9ZLX46be5gP+FCzWVn5rvDr4cXC5JBZwSIkYk9xbC7GeV+0kCvB8Q6XLFQJy+a62bbqtmdwS4Ghi9NBlQ==", + "peer": true, + "requires": { + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + } + }, + "@mui/system": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.4.7.tgz", + "integrity": "sha512-7wwc4++Ak6tGIooEVA9AY7FhH2p9fvBMORT4vNLMAysH3Yus/9B9RYMbrn3ANgsOyvT3Z7nE+SP8/+3FimQmcg==", + "peer": true, + "requires": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.4.6", + "@mui/styled-engine": "^6.4.6", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.6", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + } + }, + "@mui/types": { + "version": "7.2.21", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", + "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", + "peer": true, + "requires": {} + }, + "@mui/utils": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.6.tgz", + "integrity": "sha512-43nZeE1pJF2anGafNydUcYFPtHwAqiBiauRtaMvurdrZI3YrUjHkAu43RBsxef7OFtJMXGiHFvq43kb7lig0sA==", + "peer": true, + "requires": { + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.21", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "dependencies": { + "react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "peer": true + } + } + }, + "@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "peer": true + }, "@react-spring/animated": { "version": "9.7.3", "requires": { @@ -5312,7 +6157,9 @@ } }, "@types/prop-types": { - "version": "15.7.5" + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" }, "@types/react": { "version": "18.3.3", @@ -5336,6 +6183,13 @@ "@types/react": "*" } }, + "@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "peer": true, + "requires": {} + }, "@types/trusted-types": { "version": "2.0.3", "dev": true @@ -5361,6 +6215,21 @@ "color-convert": "^1.9.0" } }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", + "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", + "requires": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "browserslist": { "version": "4.22.1", "dev": true, @@ -5371,6 +6240,15 @@ "update-browserslist-db": "^1.0.13" } }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, "caniuse-lite": { "version": "1.0.30001547", "dev": true @@ -5393,6 +6271,11 @@ "character-reference-invalid": { "version": "1.1.4" }, + "clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" + }, "color-convert": { "version": "1.9.3", "dev": true, @@ -5404,6 +6287,14 @@ "version": "1.1.3", "dev": true }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, "comma-separated-tokens": { "version": "1.0.8" }, @@ -5412,7 +6303,9 @@ "dev": true }, "csstype": { - "version": "3.1.2" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "debug": { "version": "4.3.4", @@ -5421,6 +6314,11 @@ "ms": "2.1.2" } }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, "dom-helpers": { "version": "5.2.1", "requires": { @@ -5431,10 +6329,49 @@ "dompurify": { "version": "3.0.6" }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, "electron-to-chromium": { "version": "1.4.549", "dev": true }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "requires": { + "es-errors": "^1.3.0" + } + }, + "es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "requires": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + } + }, "esbuild": { "version": "0.18.11", "dev": true, @@ -5484,6 +6421,22 @@ "version": "0.1.2", "requires": {} }, + "follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==" + }, + "form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + } + }, "format": { "version": "0.2.2" }, @@ -5494,16 +6447,49 @@ "optional": true }, "function-bind": { - "version": "1.1.1" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, "gensync": { "version": "1.0.0-beta.2", "dev": true }, + "get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + } + }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, "globals": { "version": "11.12.0", "dev": true }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, "has": { "version": "1.0.3", "requires": { @@ -5514,6 +6500,27 @@ "version": "3.0.0", "dev": true }, + "has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "requires": { + "has-symbols": "^1.0.3" + } + }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "requires": { + "function-bind": "^1.1.2" + } + }, "hast-util-parse-selector": { "version": "2.2.5" }, @@ -5563,6 +6570,11 @@ "version": "2.2.3", "dev": true }, + "jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==" + }, "keyborg": { "version": "2.2.0" }, @@ -5591,6 +6603,24 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-13.0.0.tgz", "integrity": "sha512-VTeDCd9txf4KLLljUZ0nljE/Incb9SrWuueE44QVuU0pkOdh4sfCeW1Z6lPcxyDRSVY6rm8db/0OPaN75RNUmw==" }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, "ms": { "version": "2.1.2", "dev": true @@ -5665,6 +6695,11 @@ "xtend": "^4.0.0" } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -5722,6 +6757,14 @@ "refractor": "^3.6.0" } }, + "react-toastify": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", + "integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==", + "requires": { + "clsx": "^2.1.1" + } + }, "react-transition-group": { "version": "4.4.5", "requires": { diff --git a/app/frontend/package.json b/app/frontend/package.json index dfaf56bc32..d943b1b3d8 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -12,30 +12,34 @@ "preview": "vite preview" }, "dependencies": { - "@azure/msal-react": "^2.0.6", "@azure/msal-browser": "^3.17.0", + "@azure/msal-react": "^2.0.6", "@fluentui/react": "^8.112.5", "@fluentui/react-components": "^9.37.3", "@fluentui/react-icons": "^2.0.221", + "@mui/icons-material": "^6.4.7", "@react-spring/web": "^9.7.3", - "marked": "^13.0.0", + "axios": "^1.8.2", "dompurify": "^3.0.6", + "jwt-decode": "^4.0.0", + "marked": "^13.0.0", + "ndjson-readablestream": "^1.0.7", "react": "^18.3.1", "react-dom": "^18.2.0", "react-router-dom": "^6.23.1", - "ndjson-readablestream": "^1.0.7", "react-syntax-highlighter": "^15.5.0", + "react-toastify": "^11.0.5", "scheduler": "^0.20.2" }, "devDependencies": { + "@types/dom-speech-recognition": "^0.0.4", "@types/dompurify": "^3.0.4", "@types/react": "^18.3.3", "@types/react-dom": "^18.2.14", + "@types/react-syntax-highlighter": "^15.5.7", "@vitejs/plugin-react": "^4.1.1", "prettier": "^3.0.3", "typescript": "^5.4.5", - "@types/react-syntax-highlighter": "^15.5.7", - "@types/dom-speech-recognition": "^0.0.4", "vite": "^4.5.3" } } diff --git a/app/frontend/public/pow_whiddon.svg b/app/frontend/public/pow_whiddon.svg new file mode 100644 index 0000000000..099070e17d --- /dev/null +++ b/app/frontend/public/pow_whiddon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/splash_screen.png b/app/frontend/public/splash_screen.png new file mode 100644 index 0000000000..ced4de0ad4 Binary files /dev/null and b/app/frontend/public/splash_screen.png differ diff --git a/app/frontend/public/splash_screen_svg.svg b/app/frontend/public/splash_screen_svg.svg new file mode 100644 index 0000000000..fe239db198 --- /dev/null +++ b/app/frontend/public/splash_screen_svg.svg @@ -0,0 +1,2648 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/src/api/api.ts b/app/frontend/src/api/api.ts index 021418cf0f..a26591a12b 100644 --- a/app/frontend/src/api/api.ts +++ b/app/frontend/src/api/api.ts @@ -120,3 +120,100 @@ export async function listUploadedFilesApi(idToken: string): Promise { const dataResponse: string[] = await response.json(); return dataResponse; } + + +export async function chunkUploadApi( + formData: FormData, + idToken: string +): Promise<{ message: string }> { + const response = await fetch(`/chunk_upload`, { + method: "POST", + headers: getHeaders(idToken), + body: formData + }); + if (!response.ok) { + throw new Error(`Chunk upload failed: ${response.statusText}`); + } + return await response.json(); +} + +export interface ContainerFileInfo { + name: string; + size: number; + last_modified: string; +} + +export async function listContainerFilesApi(idToken: string): Promise { + const response = await fetch(`/list_container_files`, { + method: "GET", + headers: getHeaders(idToken) + }); + if (!response.ok) { + throw new Error(`Listing files failed: ${response.statusText}`); + } + return await response.json(); +} + +export async function deleteContainerFileApi(filename: string, idToken: string, userId: string): Promise { + // Delete from blob storage + const response = await fetch("/delete_container_file", { + method: "POST", + headers: { ...getHeaders(idToken), "Content-Type": "application/json" }, + body: JSON.stringify({ filename }) + }); + if (!response.ok) { + throw new Error(`Deleting file failed: ${response.statusText}`); + } + const result = await response.json(); + + // Call reindex_after_delete to update search index + try { + await fetch("/reindex_after_delete", { + method: "POST", + headers: { ...getHeaders(idToken), "Content-Type": "application/json" }, + body: JSON.stringify({ filename, userId }) + }); + } catch (e) { + // Optionally handle error, but don't block UI + console.error("Failed to call reindex_after_delete", e); + } + return result; +} + +export async function reindexContainerFileApi(filename: string, idToken: string, userId: string): Promise<{ message: string }> { + const response = await fetch("/reindex_container_file", { + method: "POST", + headers: { ...getHeaders(idToken), "Content-Type": "application/json" }, + body: JSON.stringify({ filename, userid: userId }) + }); + if (!response.ok) { + throw new Error(`Reindexing file failed: ${response.statusText}`); + } + return await response.json(); +} + +export async function downloadContainerFileApi(filename: string, idToken: string): Promise { + const response = await fetch(`/download_container_file?filename=${encodeURIComponent(filename)}`, { + method: "GET", + headers: getHeaders(idToken) + }); + if (!response.ok) { + throw new Error(`Download failed: ${response.statusText}`); + } + // Get filename from Content-Disposition header if available + const disposition = response.headers.get("Content-Disposition"); + let downloadName = filename; + if (disposition) { + const match = disposition.match(/filename="?([^";]+)"?/); + if (match) downloadName = match[1]; + } + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = downloadName; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); +} \ No newline at end of file diff --git a/app/frontend/src/authConfig.ts b/app/frontend/src/authConfig.ts index 1ea1b6c4e1..9a61dd67be 100644 --- a/app/frontend/src/authConfig.ts +++ b/app/frontend/src/authConfig.ts @@ -51,6 +51,32 @@ interface AuthSetup { }; } +// Mock data for local testing +const mockAuthSetup: AuthSetup = { + useLogin: true, + requireAccessControl: false, + enableUnauthenticatedAccess: true, + msalConfig: { + auth: { + clientId: "mock-client-id", + authority: "https://login.microsoftonline.com/mock-tenant-id", + redirectUri: "/", + postLogoutRedirectUri: "/", + navigateToLoginRequestUrl: true, + }, + cache: { + cacheLocation: "localStorage", + storeAuthStateInCookie: false, + }, + }, + loginRequest: { + scopes: ["openid", "profile", "email"], + }, + tokenRequest: { + scopes: ["api://mock-scope-id/access_as_user"], + }, +}; + // Fetch the auth setup JSON data from the API if not already cached async function fetchAuthSetup(): Promise { const response = await fetch("/auth_setup"); @@ -60,7 +86,9 @@ async function fetchAuthSetup(): Promise { return await response.json(); } -const authSetup = await fetchAuthSetup(); +// Use mock data for local testing +//const authSetup = mockAuthSetup; +const authSetup = await fetchAuthSetup(); export const useLogin = authSetup.useLogin; diff --git a/app/frontend/src/components/AnalysisPanel/AnalysisPanel.module.css b/app/frontend/src/components/AnalysisPanel/AnalysisPanel.module.css index 5bb03c848e..34e015fb03 100644 --- a/app/frontend/src/components/AnalysisPanel/AnalysisPanel.module.css +++ b/app/frontend/src/components/AnalysisPanel/AnalysisPanel.module.css @@ -56,9 +56,68 @@ border-radius: 10px; margin-bottom: 8px; } - .citationImg { - height: 450px; + height: 28.125rem; max-width: 100%; object-fit: contain; } + +.customModal { + overflow: hidden !important; + width: 50% !important; + max-width: none !important; + min-width: none !important; + padding: 20px; + background-color: white; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + max-height: 80vh; /* Ensure the modal does not exceed 80% of the viewport height */ +} + +.customModal { + overflow: hidden !important; +} + +.customModal .ms-Dialog-main { + overflow: hidden !important; +} + +.customModal .ms-Modal-scrollableContent { + overflow: hidden !important; + max-height: none !important; +} + + +.modalHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 10px; + border-bottom: 1px solid #e5e5e5; +} + +.modalBody { + padding-top: 10px; + overflow: hidden !important; + flex: 1; + display: flex; + flex-direction: column; +} + +.pdfViewer { + flex: 1; + width: 100%; + height: 70vh; /* Set a specific height for the PDF viewer */ + border: none; +} + +/* Ensure parent containers do not cause overflow */ +.modalContainer { + overflow: hidden !important; +} + +.modalBackdrop { + overflow: hidden !important; +} diff --git a/app/frontend/src/components/AnalysisPanel/AnalysisPanel.tsx b/app/frontend/src/components/AnalysisPanel/AnalysisPanel.tsx index 3f4853797a..d42320fe8a 100644 --- a/app/frontend/src/components/AnalysisPanel/AnalysisPanel.tsx +++ b/app/frontend/src/components/AnalysisPanel/AnalysisPanel.tsx @@ -1,16 +1,17 @@ -import { Stack, Pivot, PivotItem } from "@fluentui/react"; +import { Stack, Pivot, PivotItem, Modal } from "@fluentui/react"; +import { IconButton } from "@mui/material"; +import { Close } from "@mui/icons-material"; +import { useState, useEffect } from "react"; +import { useMsal } from "@azure/msal-react"; import styles from "./AnalysisPanel.module.css"; - import { SupportingContent } from "../SupportingContent"; import { ChatAppResponse } from "../../api"; import { AnalysisPanelTabs } from "./AnalysisPanelTabs"; import { ThoughtProcess } from "./ThoughtProcess"; import { MarkdownViewer } from "../MarkdownViewer"; -import { useMsal } from "@azure/msal-react"; import { getHeaders } from "../../api"; import { useLogin, getToken } from "../../authConfig"; -import { useState, useEffect } from "react"; interface Props { className: string; @@ -28,14 +29,13 @@ export const AnalysisPanel = ({ answer, activeTab, activeCitation, citationHeigh const isDisabledSupportingContentTab: boolean = !answer.context.data_points; const isDisabledCitationTab: boolean = !activeCitation; const [citation, setCitation] = useState(""); + const [isModalOpen, setIsModalOpen] = useState(false); const client = useLogin ? useMsal().instance : undefined; const fetchCitation = async () => { const token = client ? await getToken(client) : undefined; if (activeCitation) { - // Get hash from the URL as it may contain #page=N - // which helps browser PDF renderer jump to correct page N const originalHash = activeCitation.indexOf("#") ? activeCitation.split("#")[1] : ""; const response = await fetch(activeCitation, { method: "GET", @@ -43,18 +43,20 @@ export const AnalysisPanel = ({ answer, activeTab, activeCitation, citationHeigh }); const citationContent = await response.blob(); let citationObjectUrl = URL.createObjectURL(citationContent); - // Add hash back to the new blob URL if (originalHash) { citationObjectUrl += "#" + originalHash; } setCitation(citationObjectUrl); + setIsModalOpen(true); } }; + useEffect(() => { fetchCitation(); - }, []); + }, [activeCitation]); const renderFileViewer = () => { + console.log("active citation: " + activeCitation); if (!activeCitation) { return null; } @@ -70,33 +72,58 @@ export const AnalysisPanel = ({ answer, activeTab, activeCitation, citationHeigh } }; + + const handleModalClose = () => { + setIsModalOpen(false); + // onActiveTabChanged(undefined as unknown as AnalysisPanelTabs); // Update activeCitation to undefined + }; + return ( - pivotItem && onActiveTabChanged(pivotItem.props.itemKey! as AnalysisPanelTabs)} - > - - - - - - - + {!activeCitation && ( + pivotItem && onActiveTabChanged(pivotItem.props.itemKey! as AnalysisPanelTabs)} + > + {/* + + */} + + + + {/* + */} + + )} + - {renderFileViewer()} - - +
+ +
+
+
+ {renderFileViewer()} +
+
+ + ); }; diff --git a/app/frontend/src/components/Answer/Answer.module.css b/app/frontend/src/components/Answer/Answer.module.css index 782f05d7dc..a367ecae0a 100644 --- a/app/frontend/src/components/Answer/Answer.module.css +++ b/app/frontend/src/components/Answer/Answer.module.css @@ -135,3 +135,37 @@ sup { content: ""; animation: loading 1s infinite; } + +.modalContainer { + padding: 20px; + max-width: 500px; + margin: auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); +} + +.modalHeader { + margin-bottom: 20px; +} + +.modalBody { + margin-bottom: 20px; +} + +.modalFooter { + display: flex; + justify-content: flex-end; +} + +.modalSubmitBtn +{ + background-color: #4ec0ad; + color: #f2f2f2; + border-radius: 5px; +} + +.modalCloseBtn +{ + border-radius: 5px; +} diff --git a/app/frontend/src/components/Answer/Answer.tsx b/app/frontend/src/components/Answer/Answer.tsx index 57cfd8effa..d63c0b1531 100644 --- a/app/frontend/src/components/Answer/Answer.tsx +++ b/app/frontend/src/components/Answer/Answer.tsx @@ -1,6 +1,11 @@ -import { useMemo } from "react"; -import { Stack, IconButton } from "@fluentui/react"; +import { useMemo, useState, useEffect } from "react"; +import { Stack, Modal, TextField, PrimaryButton, DefaultButton } from "@fluentui/react"; +import { IconButton, Button as MUIButton } from "@mui/material"; +import { Lightbulb, Assignment, ThumbUp, ThumbDown } from "@mui/icons-material" import DOMPurify from "dompurify"; +import axios from "axios"; +import { toast, ToastContainer } from 'react-toastify'; // Import react-toastify +import { LoginButton } from "../LoginButton"; // Import LoginButton import styles from "./Answer.module.css"; import { ChatAppResponse, getCitationFilePath } from "../../api"; @@ -8,12 +13,14 @@ import { parseAnswerToHtml } from "./AnswerParser"; import { AnswerIcon } from "./AnswerIcon"; import { SpeechOutputBrowser } from "./SpeechOutputBrowser"; import { SpeechOutputAzure } from "./SpeechOutputAzure"; +import style from "react-syntax-highlighter/dist/esm/styles/hljs/a11y-dark"; interface Props { + question: string; // Add question prop answer: ChatAppResponse; isSelected?: boolean; isStreaming: boolean; - onCitationClicked: (filePath: string) => void; + onCitationClicked: (filePath: string, showSidePanel?: boolean) => void; onThoughtProcessClicked: () => void; onSupportingContentClicked: () => void; onFollowupQuestionClicked?: (question: string) => void; @@ -24,6 +31,7 @@ interface Props { } export const Answer = ({ + question, // Add question prop answer, isSelected, isStreaming, @@ -36,12 +44,158 @@ export const Answer = ({ showSpeechOutputBrowser, speechUrl }: Props) => { + const [like, setLike] = useState(null); + const [dislike, setDislike] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [reason, setReason] = useState(""); + const [isSupportingContentVisible, setIsSupportingContentVisible] = useState(false); + const followupQuestions = answer.context?.followup_questions; const messageContent = answer.message.content; const parsedAnswer = useMemo(() => parseAnswerToHtml(messageContent, isStreaming, onCitationClicked), [answer]); const sanitizedAnswerHtml = DOMPurify.sanitize(parsedAnswer.answerHtml); + const saveLikeDislike = async (like: boolean | null, dislike: boolean | null) => { + const userProfileString = localStorage.getItem("whiddon-userProfile"); + const userProfile = userProfileString ? JSON.parse(userProfileString) : null; + + if (!userProfile) { + toast.error( +
+ Session Expired. Please re-login. +
, + { + position: "top-right", + autoClose: false, + hideProgressBar: false, + closeOnClick: false, + pauseOnHover: true, + draggable: true, + progress: undefined, + } + ); + + return; + } + + const userName = userProfile.email; + + const message = `[Question:<${userName}>]: ${question}\n[Answer:]: ${messageContent}`; + + const payload = { + id: null, + username: userName, + like, + dislike, + message, + reason, + }; + + const logPayload = { + id: null, + page: "Answer.tsx", + action: "saveLikeDislike", + message: "", + userName: userName, + }; + + try { + const response = await axios.post( + "https://app-api-twg-azu-ai-inf-assist-d-01-fkfnera5h3cjhtfh.australiaeast-01.azurewebsites.net/api/feedback/add", + payload + ); + + if (response.status !== 200) { + throw new Error("Network response was not ok"); + } + + toast.success("Feedback saved successfully", { + position: "top-right", + autoClose: 3000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + progress: undefined, + }); + + logPayload.message = "Success - feedbackId: " + response.data.id; + + console.log("Successfully saved like/dislike"); + } catch (error: unknown) { + toast.error("Failed to save feedback", { + position: "top-right", + autoClose: 3000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + progress: undefined, + }); + + const errorMessage = (error as Error).message; + + logPayload.message = errorMessage; + + console.error("Failed to save like/dislike:", error); + } finally { + await axios.post( + "https://app-api-twg-azu-ai-inf-assist-d-01-fkfnera5h3cjhtfh.australiaeast-01.azurewebsites.net/api/log/add", + logPayload + ); + } + }; + + const handleLikeClick = () => { + setLike((prevLike) => { + const newLike = prevLike ? null : true; + setDislike(null); + saveLikeDislike(newLike, null); + return newLike; + }); + + console.log("Liked"); + }; + + const handleDislikeClick = () => { + setDislike((prevDislike) => { + const newDislike = prevDislike ? null : true; + setLike(null); + setIsModalOpen(true); + return newDislike; + }); + console.log("Disliked"); + }; + + const handleModalClose = () => { + if (!reason.trim()) { + setDislike(null); + } + setIsModalOpen(false); + setReason(""); + }; + + const handleReasonChange = (event: React.FormEvent, newValue?: string) => { + setReason(newValue || ""); + }; + + const handleSubmitReason = () => { + saveLikeDislike(null, true); + handleModalClose(); + }; + + const handleSupportingContentClick = () => { + setIsSupportingContentVisible(!isSupportingContentVisible); + onSupportingContentClicked(); + }; + + useEffect(() => { + if (!isModalOpen) { + setIsSupportingContentVisible(false); + } + }, [isModalOpen]); + return ( @@ -49,21 +203,20 @@ export const Answer = ({
onThoughtProcessClicked()} - disabled={!answer.context.thoughts?.length} - /> - onSupportingContentClicked()} + title={isSupportingContentVisible ? "Hide supporting content" : "Show supporting content"} + onClick={handleSupportingContentClick} disabled={!answer.context.data_points} - /> + > + + {showSpeechOutputAzure && } {showSpeechOutputBrowser && }
@@ -81,7 +234,7 @@ export const Answer = ({ {parsedAnswer.citations.map((x, i) => { const path = getCitationFilePath(x); return ( - onCitationClicked(path)}> + onCitationClicked(path, false)}> {`${++i}. ${x}`} ); @@ -104,6 +257,43 @@ export const Answer = ({
)} + + +
+

Reason for Dislike

+
+
+ +
+
+ + Submit + Cancel + +
+
); }; diff --git a/app/frontend/src/components/ChunkUpload/ChunkUpload.module.css b/app/frontend/src/components/ChunkUpload/ChunkUpload.module.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/frontend/src/components/ChunkUpload/ChunkUpload.tsx b/app/frontend/src/components/ChunkUpload/ChunkUpload.tsx new file mode 100644 index 0000000000..839b80e5b5 --- /dev/null +++ b/app/frontend/src/components/ChunkUpload/ChunkUpload.tsx @@ -0,0 +1,370 @@ +import React, { useState, useRef } from "react"; +import { chunkUploadApi, listContainerFilesApi, deleteContainerFileApi, reindexContainerFileApi, downloadContainerFileApi } from "../../api/api"; +import { + Box, + Button, + LinearProgress, + Typography, + Alert, + Stack, + Table, + TableHead, + TableBody, + TableRow, + TableCell, + TableContainer, + IconButton, + TextField, + Snackbar, + Tooltip +} from "@mui/material"; +import Grid from "@mui/material/Grid"; +import DeleteIcon from "@mui/icons-material/Delete"; +import UploadFileIcon from '@mui/icons-material/UploadFile'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import CircularProgress from '@mui/material/CircularProgress'; +import SyncIcon from '@mui/icons-material/Sync'; +import DownloadIcon from '@mui/icons-material/Download'; + +const CHUNK_SIZE = 100 * 1024; // 100KB per chunk for better progress feedback + +interface ChunkUploadProps { + onClose?: () => void; +} + +const ChunkUpload: React.FC = ({ onClose }) => { + const [selectedFile, setSelectedFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); + const [uploadedFiles, setUploadedFiles] = useState<{ name: string; size: number; last_modified: string }[]>([]); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [searchText, setSearchText] = useState(""); + const [isLoadingFiles, setIsLoadingFiles] = useState(false); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(""); + const [snackbarSeverity, setSnackbarSeverity] = useState<'success' | 'error'>("success"); + const [deletingFile, setDeletingFile] = useState(null); + const [reindexingFile, setReindexingFile] = useState(null); + const [downloadingFile, setDownloadingFile] = useState(null); // NEW + const inputRef = useRef(null); + + // Replace with your auth token logic + const idToken = undefined; + + // Simulate getting userId from auth context or props + const userProfile = JSON.parse(localStorage.getItem("whiddon-userProfile") || "{}"); + const userId = userProfile.userId; + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + setSelectedFile(e.target.files[0]); + setError(""); + } + }; + + const handleUpload = async () => { + if (!selectedFile) return; + setUploading(true); + setProgress(0); + setMessage(""); + setError(""); + const totalChunks = Math.ceil(selectedFile.size / CHUNK_SIZE); + for (let i = 0; i < totalChunks; i++) { + const start = i * CHUNK_SIZE; + const end = Math.min(selectedFile.size, start + CHUNK_SIZE); + const chunk = selectedFile.slice(start, end); + const formData = new FormData(); + formData.append("filename", selectedFile.name); + formData.append("chunkIndex", i.toString()); + formData.append("totalChunks", totalChunks.toString()); + formData.append("chunk", chunk); + try { + // Set progress BEFORE uploading chunk for immediate feedback + setProgress(Math.round(((i + 1) / totalChunks) * 100)); + console.log(`Uploading chunk ${i + 1} of ${totalChunks} (${Math.round(((i + 1) / totalChunks) * 100)}%)`); + await new Promise(resolve => setTimeout(resolve, 0)); // Force UI update + const res = await chunkUploadApi(formData, idToken as any); + setMessage(res.message); + } catch (err: any) { + setError(err.message || "Upload failed"); + setSnackbarMessage(err.message || "Upload failed"); + setSnackbarSeverity("error"); + setSnackbarOpen(true); + setUploading(false); + return; + } + } + setUploading(false); + setSelectedFile(null); + if (inputRef.current) inputRef.current.value = ""; + setSnackbarMessage("File uploaded successfully"); + setSnackbarSeverity("success"); + setSnackbarOpen(true); + await fetchFiles(); + }; + + const fetchFiles = async () => { + console.log("fetch files called."); + setIsLoadingFiles(true); + try { + const files = await listContainerFilesApi(idToken as any); + setUploadedFiles(files); + } catch (err) { + setUploadedFiles([]); + } finally { + setIsLoadingFiles(false); + } + }; + + const handleDelete = async (fileName: string) => { + setDeletingFile(fileName); + try { + await deleteContainerFileApi(fileName, idToken as any, userId); // Pass userId + setMessage(`Deleted ${fileName}`); + setSnackbarMessage(`Deleted ${fileName}`); + setSnackbarSeverity("success"); + setSnackbarOpen(true); + await fetchFiles(); + } catch (err: any) { + setError(err.message || "Delete failed"); + setSnackbarMessage(err.message || "Delete failed"); + setSnackbarSeverity("error"); + setSnackbarOpen(true); + } finally { + setDeletingFile(null); + } + }; + + React.useEffect(() => { + fetchFiles(); + }, []); + + const filteredFiles = uploadedFiles.filter(file => + file.name.toLowerCase().includes(searchText.toLowerCase()) + ); + + return ( + + + + + setSearchText(e.target.value)} + sx={{ minWidth: { xs: 120, sm: 125 }, flex: 0.5 }} + /> + + { + if (!selectedFile && !uploading) { + inputRef.current?.click(); + } else if (selectedFile && !uploading) { + handleUpload(); + } + }} + disabled={uploading} + > + {uploading ? ( + + + + ) : selectedFile ? ( + + + + ) : ( + + + + )} + + + {/* Show file name and progress beside the upload button */} + {selectedFile && !uploading && ( + + Selected: {selectedFile.name} + + )} + {uploading && selectedFile && ( + + + Uploading: {selectedFile.name} ({progress}%) + + + + )} + {!selectedFile && !uploading && ( + + Select File to Upload + + )} + + + {uploading && ( + + + Uploading: {progress}% + + + + )} + {message && setMessage("")}>{message}} + {error && setError("")}>{error}} + + + + + + Name + Size + Modified + + + + + {isLoadingFiles ? ( + + + + Loading files... + + + ) : filteredFiles.length === 0 ? ( + + + No files uploaded yet. + + + ) : ( + filteredFiles.map((file) => { + const sizeKB = file.size / 1024; + const sizeDisplay = sizeKB > 1000 + ? `${(sizeKB / 1024).toFixed(2)} MB` + : `${sizeKB.toFixed(2)} KB`; + return ( + + {file.name} + {sizeDisplay} + {file.last_modified ? new Date(file.last_modified).toLocaleString() : ''} + + + + { + setDownloadingFile(file.name); + try { + await downloadContainerFileApi(file.name, idToken as any); + } catch (err: any) { + setSnackbarMessage(err.message || "Download failed"); + setSnackbarSeverity("error"); + setSnackbarOpen(true); + } finally { + setDownloadingFile(null); + } + }} + disabled={uploading || deletingFile === file.name || reindexingFile === file.name || downloadingFile === file.name} + > + {downloadingFile === file.name ? : } + + + + + + { + setReindexingFile(file.name); + try { + setSnackbarMessage("Reindexing..."); + setSnackbarSeverity("success"); + setSnackbarOpen(true); + await reindexContainerFileApi(file.name, idToken as any, userId); + setSnackbarMessage(`Reindex started for ${file.name}`); + setSnackbarSeverity("success"); + setSnackbarOpen(true); + } catch (err: any) { + setSnackbarMessage(err.message || "Reindex failed"); + setSnackbarSeverity("error"); + setSnackbarOpen(true); + } finally { + setReindexingFile(null); + } + }} + style={{ marginLeft: 8 }} + disabled={reindexingFile === file.name} + > + {reindexingFile === file.name ? : } + + + + + handleDelete(file.name)} + disabled={deletingFile === file.name}> + {deletingFile === file.name ? : } + + + + + ); + }) + )} + +
+
+
+
+ {/* setSnackbarOpen(false)} + anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + > + setSnackbarOpen(false)} severity={snackbarSeverity} sx={{ width: '100%' }}> + {snackbarMessage} + + */} +
+ ); +}; + +export default ChunkUpload; diff --git a/app/frontend/src/components/ChunkUpload/index.tsx b/app/frontend/src/components/ChunkUpload/index.tsx new file mode 100644 index 0000000000..365277296a --- /dev/null +++ b/app/frontend/src/components/ChunkUpload/index.tsx @@ -0,0 +1 @@ +export * from "./ChunkUpload"; diff --git a/app/frontend/src/components/Example/Example.module.css b/app/frontend/src/components/Example/Example.module.css index de1bcc5615..be80314b1e 100644 --- a/app/frontend/src/components/Example/Example.module.css +++ b/app/frontend/src/components/Example/Example.module.css @@ -28,14 +28,14 @@ .exampleText { margin: 0; - font-size: 22px; + font-size: 20px; width: 280px; min-height: 100px; } @media only screen and (max-height: 780px) { .exampleText { - font-size: 20px; + font-size: 18px; height: 80px; } } diff --git a/app/frontend/src/components/Example/Example.tsx b/app/frontend/src/components/Example/Example.tsx index 82f01d3d0b..5c97cb82ae 100644 --- a/app/frontend/src/components/Example/Example.tsx +++ b/app/frontend/src/components/Example/Example.tsx @@ -3,12 +3,14 @@ import styles from "./Example.module.css"; interface Props { text: string; value: string; + bgColor: string; + fontColor: string, onClick: (value: string) => void; } -export const Example = ({ text, value, onClick }: Props) => { +export const Example = ({ text, value, bgColor, fontColor, onClick }: Props) => { return ( -
onClick(value)}> +
onClick(value)} style={{ backgroundColor: bgColor, color: fontColor }}>

{text}

); diff --git a/app/frontend/src/components/Example/ExampleList.tsx b/app/frontend/src/components/Example/ExampleList.tsx index 49c35cbd2d..338d4f6e37 100644 --- a/app/frontend/src/components/Example/ExampleList.tsx +++ b/app/frontend/src/components/Example/ExampleList.tsx @@ -3,9 +3,10 @@ import { Example } from "./Example"; import styles from "./Example.module.css"; const DEFAULT_EXAMPLES: string[] = [ - "What is included in my Northwind Health Plus plan that is not in standard?", - "What happens in a performance review?", - "What does a Product Manager do?" + // "What is included in my Northwind Health Plus plan that is not in standard?", + "What's in RAC Grief Bereavement Protocol 24?", + "What happens in a GOV Dignity of Risk Policy 2024?", + "Summarize Infection Control Policy?" ]; const GPT4V_EXAMPLES: string[] = [ @@ -20,11 +21,17 @@ interface Props { } export const ExampleList = ({ onExampleClicked, useGPT4V }: Props) => { + const backgroundColors = ["#4ec0ad", "#f36f4c", "#e3e0d1"]; + const fonColors = ["#342E37", "#F2F2F2", "#342E37"]; + return (
    {(useGPT4V ? GPT4V_EXAMPLES : DEFAULT_EXAMPLES).map((question, i) => (
  • - +
  • ))}
diff --git a/app/frontend/src/components/LoginButton/LoginButton.module.css b/app/frontend/src/components/LoginButton/LoginButton.module.css index f808ac94cf..a10eb4cc19 100644 --- a/app/frontend/src/components/LoginButton/LoginButton.module.css +++ b/app/frontend/src/components/LoginButton/LoginButton.module.css @@ -1,5 +1,20 @@ -.loginButton { +/* .loginButton { border-radius: 5px; padding: 30px 30px; font-weight: 100; +} */ + +.loginButton { + background-color: #ff6b4a; + color: white; + font-size: 1rem; + padding: 12px 24px; + border: none; + border-radius: 8px; + cursor: pointer; + margin-top: 20px; +} + +.loginButton:hover { + background-color: #e05a3d; } diff --git a/app/frontend/src/components/LoginButton/LoginButton.tsx b/app/frontend/src/components/LoginButton/LoginButton.tsx index 2f65e9089d..e3e14688f8 100644 --- a/app/frontend/src/components/LoginButton/LoginButton.tsx +++ b/app/frontend/src/components/LoginButton/LoginButton.tsx @@ -1,11 +1,19 @@ import { DefaultButton } from "@fluentui/react"; import { useMsal } from "@azure/msal-react"; +import axios from "axios"; +import { PublicClientApplication } from "@azure/msal-browser"; import styles from "./LoginButton.module.css"; import { getRedirectUri, loginRequest } from "../../authConfig"; import { appServicesToken, appServicesLogout } from "../../authConfig"; +import { fetchUserProfile } from "../../utils/userProfileUtils"; -export const LoginButton = () => { +interface LoginButtonProps { + onLogin?: () => void; + onLogout?: () => void; +} + +export const LoginButton: React.FC = ({ onLogin, onLogout }) => { const { instance } = useMsal(); const activeAccount = instance.getActiveAccount(); const isLoggedIn = (activeAccount || appServicesToken) != null; @@ -21,6 +29,20 @@ export const LoginButton = () => { ...loginRequest, redirectUri: getRedirectUri() }) + .then(() => { + localStorage.setItem("isLoggedIn", "true"); + onLogin && onLogin(); // Notify MainLayout about login + console.log("Dispatching userProfileUpdated event"); + window.dispatchEvent(new Event("whiddon-userProfileUpdated")); // Notify other components about login + + // Store user profile and picture in localStorage + fetchUserProfile(instance as PublicClientApplication).then(userData => { + if (userData) { + console.log("User profile fetched successfully:", userData); + window.dispatchEvent(new Event("whiddon-userProfileUpdated")); + } + }).catch((error: any) => console.error("Error in fetchUserProfile:", error)); + }) .catch(error => console.log(error)); }; const handleLogoutPopup = () => { @@ -30,6 +52,10 @@ export const LoginButton = () => { mainWindowRedirectUri: "/", // redirects the top level app after logout account: instance.getActiveAccount() }) + .then(() => { + localStorage.removeItem("isLoggedIn"); + onLogout && onLogout(); // Notify MainLayout about logout + }) .catch(error => console.log(error)); } else { appServicesLogout(); @@ -37,10 +63,12 @@ export const LoginButton = () => { }; const logoutText = `Logout\n${activeAccount?.username ?? appServicesToken?.user_claims?.preferred_username}`; return ( - + // + ); }; diff --git a/app/frontend/src/components/QuestionInput/QuestionInput.module.css b/app/frontend/src/components/QuestionInput/QuestionInput.module.css index 419523e453..22f9afc41d 100644 --- a/app/frontend/src/components/QuestionInput/QuestionInput.module.css +++ b/app/frontend/src/components/QuestionInput/QuestionInput.module.css @@ -1,21 +1,91 @@ -.questionInputContainer { - border-radius: 8px; +/* .questionInputContainer { + border-radius: 0.5rem; box-shadow: - 0px 8px 16px rgba(0, 0, 0, 0.14), - 0px 0px 2px rgba(0, 0, 0, 0.12); - height: 90px; + 0px 0.5rem 1rem rgba(0, 0, 0, 0.14), + 0px 0px 0.125rem rgba(0, 0, 0, 0.12); width: 100%; - padding: 15px; + padding: 0.8rem; background: white; } .questionInputTextArea { width: 100%; - line-height: 40px; + line-height: 2.5rem; } .questionInputButtonsContainer { display: flex; flex-direction: column; justify-content: flex-end; +} */ + +@media (min-width: 992px) { + .questionInputContainer { + height: 5.625rem; + } +} +/* Container wrapping the entire input field */ +.questionInputContainer { + width: 63%; + display: flex; + justify-content: center; + align-items: center; + padding: 16px; + background-color: #f2f2f2; /* Light gray background */ + position: fixed; + bottom: 0; + +} + +div > input{ + background-color: #E7E6E6 !important; +} + +/* Wrapper for input + send button */ +.inputWrapper { + width: 100%; + display: flex; + align-items: center; + background-color: #E7E6E6; /* Slightly darker than outer container */ + border-radius: 30px; /* Rounded edges */ + padding: 5px 15px; + box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.1); +} + +/* Input field */ +.questionInputTextArea { + flex-grow: 1; + background: #E7E6E6; + border: none; + font-size: 16px; + font-family: 'Poppins', sans-serif; + color: #342E37; + padding: 12px; + border-radius: 25px; +} + +/* Hide Fluent UI default border */ +.questionInputTextArea:focus { + outline: none; +} + +/* Send Button */ +.sendButton { + display: flex; + justify-content: center; + align-items: center; + padding: 10px; + border-radius: 50%; + cursor: pointer; + transition: background 0.2s ease-in-out; +} + +.sendButton:hover { + background-color: #e0e0e0; +} + +/* Send Icon */ +.sendIcon { + color: #342E37; /* Dark gray */ + font-size: 24px; } diff --git a/app/frontend/src/components/QuestionInput/QuestionInput.tsx b/app/frontend/src/components/QuestionInput/QuestionInput.tsx index f0667e37f8..febe55b492 100644 --- a/app/frontend/src/components/QuestionInput/QuestionInput.tsx +++ b/app/frontend/src/components/QuestionInput/QuestionInput.tsx @@ -60,24 +60,45 @@ export const QuestionInput = ({ onSend, disabled, placeholder, clearOnSend, init } return ( + // + // + //
+ // + //
+ // {showSpeechInput && } + //
+
+ {/* Styled Input Field */} -
- -
+ + ); }; diff --git a/app/frontend/src/components/SplashScreen/SplashScreen.module.css b/app/frontend/src/components/SplashScreen/SplashScreen.module.css new file mode 100644 index 0000000000..648bb9ed73 --- /dev/null +++ b/app/frontend/src/components/SplashScreen/SplashScreen.module.css @@ -0,0 +1,61 @@ +.splashScreen { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; /* Ensure full width */ + background-color: #f0f0f0; + overflow: hidden; /* Prevent scrollbars */ +} + +.imageContainer { + position: relative; + width: 100%; + height: 100%; +} + +.splashImage { + width: 100%; + height: 100%; + object-fit: cover; /* Cover the entire screen without distortion */ + display: block; +} + +.textOverlay { + position: absolute; + top: 50%; + left: 26%; + transform: translate(-50%, -50%); + text-align: center; + color: white; + width: 80%; +} + +.title { + font-size: 2.5rem; + font-weight: 400; +} + +.bold { + font-weight: 700; +} + +.subtitle { + font-size: 1.2rem; + margin-top: 10px; +} + +.getStartedButton { + background-color: #ff6b4a; + color: white; + font-size: 1rem; + padding: 12px 24px; + border: none; + border-radius: 8px; + cursor: pointer; + margin-top: 20px; +} + +.getStartedButton:hover { + background-color: #e05a3d; +} diff --git a/app/frontend/src/components/SplashScreen/SplashScreen.tsx b/app/frontend/src/components/SplashScreen/SplashScreen.tsx new file mode 100644 index 0000000000..bbb306763b --- /dev/null +++ b/app/frontend/src/components/SplashScreen/SplashScreen.tsx @@ -0,0 +1,25 @@ +import styles from "./SplashScreen.module.css"; +import { LoginButton } from "../../components/LoginButton"; + +interface SplashScreenProps { + onLogin?: () => void; +} + +export const SplashScreen: React.FC = ({ onLogin }) => { + return ( +
+
+ Splash Screen + {/* Text overlay */} +
+ +
+
+
+ ); +}; diff --git a/app/frontend/src/components/SplashScreen/index.tsx b/app/frontend/src/components/SplashScreen/index.tsx new file mode 100644 index 0000000000..101d34aac6 --- /dev/null +++ b/app/frontend/src/components/SplashScreen/index.tsx @@ -0,0 +1 @@ +export * from "./SplashScreen"; \ No newline at end of file diff --git a/app/frontend/src/components/UserProfile/UserProfile.module.css b/app/frontend/src/components/UserProfile/UserProfile.module.css new file mode 100644 index 0000000000..b9f029c52e --- /dev/null +++ b/app/frontend/src/components/UserProfile/UserProfile.module.css @@ -0,0 +1,24 @@ +.infoIcon { + font-size: 24px; + color: #1abc9c; + cursor: pointer; + margin-top: 10px; + margin-right: 10px; +} + +.logoutContainer +{ + display:flex; +} + +.logoutButton +{ + margin-top: 1px; + margin-left: 10px; + background-color: #f36f4c; + color: white; + border: 5px; + border-radius: 5px; + width: 10vh; + cursor: pointer; +} \ No newline at end of file diff --git a/app/frontend/src/components/UserProfile/UserProfile.tsx b/app/frontend/src/components/UserProfile/UserProfile.tsx new file mode 100644 index 0000000000..8ef3cc3095 --- /dev/null +++ b/app/frontend/src/components/UserProfile/UserProfile.tsx @@ -0,0 +1,200 @@ +import React, { useState, useContext, useEffect } from "react"; +import { Avatar, Popover, Button } from "@mui/material"; +import { IconButton, Tooltip, Typography, Divider, Box, Link as MULink } from "@mui/material"; +import { Close, Settings as SettingsIcon } from "@mui/icons-material"; +import { Person24Regular } from "@fluentui/react-icons"; +import ChunkUpload from "../ChunkUpload/ChunkUpload"; +import styles from "./UserProfile.module.css"; // Optional: adjust based on your project + +export interface UserProfileProps { + isLoggedIn: boolean; + userName: string; + userEmail: string; + userInitials: string; + profilePicture: string | null; + onLogin: () => Promise; + onLogout: () => Promise; +} + +export const UserProfile = ({ isLoggedIn, onLogin, onLogout }: UserProfileProps) => { + + const [anchorEl, setAnchorEl] = useState(null); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + + const open = Boolean(anchorEl); + const id = open ? "user-popover" : undefined; + + const [userProfile, setUserProfile] = useState({}); + const [profilePicture, setProfilePicture] = useState(null); + const [userInitials, setUserInitials] = useState("U"); + const [userName, setUserName] = useState(""); + const [userEmail, setUserEmail] = useState(""); + const [showChunkUpload, setShowChunkUpload] = useState(false); + + const fetchUserProfile = () => { + const storedProfile = JSON.parse(localStorage.getItem("whiddon-userProfile") || "{}"); + const storedPicture = localStorage.getItem("whiddon-userProfilePicture"); + + setUserProfile(storedProfile); + setProfilePicture(storedPicture); + setUserInitials(storedProfile.initials || storedProfile.name?.charAt(0) || "U"); + setUserName(storedProfile.name || ""); + setUserEmail(storedProfile.email || ""); + }; + + useEffect(() => { + if (isLoggedIn) { + fetchUserProfile(); + } + }, [isLoggedIn]); // run effect again when login state changes + + useEffect(() => { + const handleUserProfileUpdate = () => { + console.log("Handling userProfileUpdated event"); + fetchUserProfile(); + }; + window.addEventListener("whiddon-userProfileUpdated", handleUserProfileUpdate); + + return () => { + window.removeEventListener("whiddon-userProfileUpdated", handleUserProfileUpdate); + }; + }, []); + + const handleShowChunkUpload = () => { + setShowChunkUpload(true); + + handleClose(); + }; + + return ( +
+ {isLoggedIn ? ( + <> + + {!profilePicture && userInitials} + + + ) : ( + + + + + + )} + + + + + + + + + Profile + + + + + + {!profilePicture && userInitials} + + + + {/* + Workspace Owner + */} + + {userName} + + + {userEmail} + + + + View Profile + + + + {/* Show Settings text if role is Admin */} + {userProfile.role.toLower() === "admin" && ( + + + + + Settings + + + + )} + + + + + + + {/* Show ChunkUpload modal/dialog if showChunkUpload is true */} + {showChunkUpload && ( +
+ setShowChunkUpload(false)} /> +
+ )} +
+ ); +}; diff --git a/app/frontend/src/components/UserProfile/index.tsx b/app/frontend/src/components/UserProfile/index.tsx new file mode 100644 index 0000000000..2e76d15da1 --- /dev/null +++ b/app/frontend/src/components/UserProfile/index.tsx @@ -0,0 +1 @@ +export * from './UserProfile'; \ No newline at end of file diff --git a/app/frontend/src/index.css b/app/frontend/src/index.css index 44e9bf2eea..f8034282c1 100644 --- a/app/frontend/src/index.css +++ b/app/frontend/src/index.css @@ -1,5 +1,6 @@ * { box-sizing: border-box; + font-family: 'Poppins', sans-serif !important; } html, diff --git a/app/frontend/src/index.tsx b/app/frontend/src/index.tsx index edd2f5eded..398f946220 100644 --- a/app/frontend/src/index.tsx +++ b/app/frontend/src/index.tsx @@ -4,46 +4,36 @@ import { createHashRouter, RouterProvider } from "react-router-dom"; import { initializeIcons } from "@fluentui/react"; import { MsalProvider } from "@azure/msal-react"; import { PublicClientApplication, EventType, AccountInfo } from "@azure/msal-browser"; -import { msalConfig, useLogin } from "./authConfig"; +import { msalConfig } from "./authConfig"; // Remove useLogin here import "./index.css"; - import Layout from "./pages/layout/Layout"; import Chat from "./pages/chat/Chat"; -var layout; -if (useLogin) { - var msalInstance = new PublicClientApplication(msalConfig); - - // Default to using the first account if no account is active on page load - if (!msalInstance.getActiveAccount() && msalInstance.getAllAccounts().length > 0) { - // Account selection logic is app dependent. Adjust as needed for different use cases. - msalInstance.setActiveAccount(msalInstance.getActiveAccount()); - } +// ✅ Always create MSAL instance +const msalInstance = new PublicClientApplication(msalConfig); - // Listen for sign-in event and set active account - msalInstance.addEventCallback(event => { - if (event.eventType === EventType.LOGIN_SUCCESS && event.payload) { - const account = event.payload as AccountInfo; - msalInstance.setActiveAccount(account); - } - }); - - layout = ( - - - - ); -} else { - layout = ; +// ✅ Select the first available account if no active account exists +const accounts = msalInstance.getAllAccounts(); +if (!msalInstance.getActiveAccount() && accounts.length > 0) { + msalInstance.setActiveAccount(accounts[0]); } +// ✅ Listen for sign-in events and set active account +msalInstance.addEventCallback(event => { + if (event.eventType === EventType.LOGIN_SUCCESS && event.payload) { + const account = event.payload as AccountInfo; + msalInstance.setActiveAccount(account); + } +}); + +// ✅ Wrap the entire app in MsalProvider initializeIcons(); const router = createHashRouter([ { path: "/", - element: layout, + element: , // Layout will handle authentication logic children: [ { index: true, @@ -63,6 +53,8 @@ const router = createHashRouter([ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + ); diff --git a/app/frontend/src/pages/ask/Ask.tsx b/app/frontend/src/pages/ask/Ask.tsx index 93c55a7510..4f467c5a19 100644 --- a/app/frontend/src/pages/ask/Ask.tsx +++ b/app/frontend/src/pages/ask/Ask.tsx @@ -245,6 +245,7 @@ export function Component(): JSX.Element { {!isLoading && answer && !error && (
onShowCitation(x)} diff --git a/app/frontend/src/pages/chat/Chat.module.css b/app/frontend/src/pages/chat/Chat.module.css index 4aaaa1ecb2..5719e6e7a8 100644 --- a/app/frontend/src/pages/chat/Chat.module.css +++ b/app/frontend/src/pages/chat/Chat.module.css @@ -29,10 +29,11 @@ } .chatEmptyStateTitle { - font-size: 4rem; - font-weight: 600; + font-size: 40px; + font-weight: 300; + line-height: '45px'; margin-top: 0; - margin-bottom: 30px; + margin-bottom: 1.875rem; } .chatEmptyStateSubtitle { diff --git a/app/frontend/src/pages/chat/Chat.tsx b/app/frontend/src/pages/chat/Chat.tsx index abd8725e04..f64e5a074c 100644 --- a/app/frontend/src/pages/chat/Chat.tsx +++ b/app/frontend/src/pages/chat/Chat.tsx @@ -3,6 +3,10 @@ import { Checkbox, Panel, DefaultButton, TextField, ITextFieldProps, ICheckboxPr import { SparkleFilled } from "@fluentui/react-icons"; import { useId } from "@fluentui/react-hooks"; import readNDJSONStream from "ndjson-readablestream"; +import axios from 'axios'; // Import axios for API calls +import { toast, ToastContainer } from 'react-toastify'; // Import react-toastify +import 'react-toastify/dist/ReactToastify.css'; // Import react-toastify CSS +import { LoginButton } from "../../components/LoginButton"; // Import LoginButton import styles from "./Chat.module.css"; @@ -182,16 +186,20 @@ const Chat = () => { if (!response.body) { throw Error("No response body"); } + let parsedResponse: ChatAppResponse; if (shouldStream) { - const parsedResponse: ChatAppResponse = await handleAsyncRequest(question, answers, response.body); + parsedResponse = await handleAsyncRequest(question, answers, response.body); setAnswers([...answers, [question, parsedResponse]]); } else { - const parsedResponse: ChatAppResponseOrError = await response.json(); + const parsedResponseOrError: ChatAppResponseOrError = await response.json(); if (response.status > 299 || !response.ok) { - throw Error(parsedResponse.error || "Unknown error"); + throw Error(parsedResponseOrError.error || "Unknown error"); } - setAnswers([...answers, [question, parsedResponse as ChatAppResponse]]); + parsedResponse = parsedResponseOrError as ChatAppResponse; + setAnswers([...answers, [question, parsedResponse]]); } + // Save the latest question and answer after receiving the answer + await saveConversation(question, parsedResponse); } catch (e) { setError(e); } finally { @@ -199,6 +207,100 @@ const Chat = () => { } }; + // const testRelogin = async () => { + // const latestQuestion = "test"; + // const latestAnswer: ChatAppResponse = { + // message: { content: "test", role: "assistant" }, + // delta: { content: "test", role: "assistant" }, // Added delta property + // session_state: null, + // context: { + // data_points: [], + // followup_questions: [], + // thoughts: [] + // } + // }; + + // await saveConversation(latestQuestion, latestAnswer); + // } + + + const saveConversation = async (latestQuestion: string, latestAnswer: ChatAppResponse) => { + const userProfileString = localStorage.getItem("whiddon-userProfile"); + const userProfile = userProfileString ? JSON.parse(userProfileString) : null; + + if (!userProfile) { + toast.error( +
+ Session Expired. Please re-login. +
, + { + position: "top-right", + autoClose: false, + hideProgressBar: false, + closeOnClick: false, + pauseOnHover: true, + draggable: true, + progress: undefined, + } + ); + + return; + } + + const userName = userProfile ? userProfile.email : ""; + console.log(userName); + + const conversation = `[Question:<${userName}>]: ${latestQuestion}\n[Answer:]: ${latestAnswer.message.content}`; + + const payload = { + id: null, + username: userName, + details: conversation + }; + + const logPayload = { + id: null, + page: 'Chat.tsx', + action: 'saveConversation', + message: '', + userName: userName + }; + + try { + const result = await axios.post('https://app-api-twg-azu-ai-inf-assist-d-01-fkfnera5h3cjhtfh.australiaeast-01.azurewebsites.net/api/conversation/add', payload); + + logPayload.message = 'Success - conversationId: ' + result.data.id; + + // toast.success('Conversation saved successfully', { + // position: "top-right", + // autoClose: 3000, + // hideProgressBar: false, + // closeOnClick: true, + // pauseOnHover: true, + // draggable: true, + // progress: undefined, + // }); + } catch (error: unknown) { + console.error('Error saving conversation:', error); + toast.error('Failed to save conversation', { + position: "top-right", + autoClose: 3000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + progress: undefined, + }); + + const errorMessage = (error as Error).message; + + logPayload.message = errorMessage; + } finally { + await axios.post('https://app-api-twg-azu-ai-inf-assist-d-01-fkfnera5h3cjhtfh.australiaeast-01.azurewebsites.net/api/log/add', logPayload); + } + }; + + const clearChat = () => { lastQuestionRef.current = ""; error && setError(undefined); @@ -293,6 +395,8 @@ const Chat = () => { }; const onToggleTab = (tab: AnalysisPanelTabs, index: number) => { + console.log(tab); + setActiveCitation(undefined); if (activeAnalysisPanelTab === tab && selectedAnswer === index) { setActiveAnalysisPanelTab(undefined); } else { @@ -330,7 +434,14 @@ const Chat = () => { return (
+
+ + {/* */} +
+ +
{showUserUpload && } setIsConfigPanelOpen(!isConfigPanelOpen)} /> @@ -339,9 +450,9 @@ const Chat = () => {
{!lastQuestionRef.current ? (
-
) : ( @@ -352,6 +463,7 @@ const Chat = () => {
{
{
makeApiRequest(question)} showSpeechInput={showSpeechInput} diff --git a/app/frontend/src/pages/layout/Layout.module.css b/app/frontend/src/pages/layout/Layout.module.css index ebb860e216..76daa5da05 100644 --- a/app/frontend/src/pages/layout/Layout.module.css +++ b/app/frontend/src/pages/layout/Layout.module.css @@ -1,13 +1,13 @@ .layout { display: flex; - flex-direction: column; - height: 100%; + /* flex-direction: column; */ + height: 100vh; } -.header { +/* .header { background-color: #222222; color: #f2f2f2; -} +} */ .headerContainer { display: flex; @@ -76,3 +76,239 @@ .githubLogo { height: 20px; } +/* Sidebar */ +.sidebar { + width: 200px; + background-color: #ffffff; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + position: fixed; + height: 100%; + padding-top: 20px; + border-right: 1px solid #e0e0e0; +} + +/* Logo Container */ +.logoContainer { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding-bottom: 10px; +} + +.logo { + width: 80%; + height: auto; +} + + +/* Powered By Text */ +.poweredBy { + font-size: 12px; + color: #888; + margin-top: 10px; + margin-left: 10px; +} + +.poweredBy a { + text-decoration: none !important; /* Removes underline for any linked text */ + color: inherit; /* Keeps default color */ +} + + +/* Main Content */ +/*.mainContent { + margin-left: 200px; // offset sidebar + flex-grow: 1; + display: flex; + flex-direction: column; +}*/ + +/* Header */ +/* .header { + background-color: #ffffff; + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px 20px; + border-bottom: 1px solid #e0e0e0; +} */ + +.headerTitle { + font-size: 20px; + font-weight: bold; + color: #1abc9c; +} + +/* Info Icon */ +/* .infoIcon { + font-size: 24px; + color: #1abc9c; + cursor: pointer; + margin-top: 10px; +} */ + +/* Page Content */ +.pageContent { + flex-grow: 1; + padding: 20px; + overflow-y: auto; +} + + +.landingPage { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background: url("/image.png") no-repeat center center; + background-size: cover; + color: white; + text-align: center; + flex-direction: column; + position: relative; +} + +.content { + z-index: 2; + text-align: center; + max-width: 500px; +} + +.boldText { + font-weight: bold; +} + +.subText { + font-size: 1.2rem; + margin-bottom: 20px; +} + +.getStartedButton { + background-color: #ff7f50; + color: white; + padding: 12px 24px; + font-size: 1.1rem; + border-radius: 8px; + cursor: pointer; + border: none; + transition: 0.3s ease; +} + +.getStartedButton:hover { + background-color: #ff6333; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); /* Dark overlay for better readability */ + z-index: 1; +} + +.logoutContainer +{ + display:flex; +} + +.logoutButton +{ + margin-top: 1px; + margin-left: 10px; + background-color: #f36f4c; + color: white; + border: 5px; + border-radius: 5px; + width: 10vh; + cursor: pointer; +} + + +/*Whiddon*/ +.mainContent { + flex-grow: 1; + display: flex; + flex-direction: column; +} + +.header { + background-color: #4ec0ad; + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px 20px; + border-bottom: 1px solid #e0e0e0; +} + +.infoIcon { + font-size: 24px; + color: white; + cursor: pointer; + margin-top: 10px; +} + +.profilePicture { + height: 30px; + width: 30px; + border-radius: 50%; + margin-right: 10px; + cursor: pointer; +} +/*END : Whiddon*/ + + +.profileWrapper { + cursor: pointer; + position: relative; + display: flex; + align-items: center; +} + +.initialsAvatar { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #0078D4; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 16px; + text-transform: uppercase; + cursor: pointer; +} + +.popover { + position: absolute; + top: 50px; + right: 0; + background: white; + box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1); + border-radius: 8px; + padding: 10px; + display: flex; + flex-direction: column; + z-index: 1000; +} + +.logoutButton { + background-color: #ff4d4f; + color: white; + border: none; + padding: 8px 12px; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + width: 100%; +} + +.logoutButton:hover { + background-color: #d9363e; +} diff --git a/app/frontend/src/pages/layout/Layout.tsx b/app/frontend/src/pages/layout/Layout.tsx index 157a6ffd3a..d8bbc5371d 100644 --- a/app/frontend/src/pages/layout/Layout.tsx +++ b/app/frontend/src/pages/layout/Layout.tsx @@ -1,54 +1,246 @@ -import { Outlet, NavLink, Link } from "react-router-dom"; +import { Outlet, useNavigate } from "react-router-dom"; +import { useState, useEffect } from "react"; +import { useMsal } from "@azure/msal-react"; +import { EventType } from "@azure/msal-browser"; +import styles from "./Layout.module.css"; +import { SplashScreen } from "../../components/SplashScreen"; +import { appServicesToken, appServicesLogout } from "../../authConfig"; +import axios, { AxiosError } from "axios"; +import { + Popover, + Typography, + Button, + Avatar, + IconButton, + Divider, + Box +} from "@mui/material"; +import { Close } from "@mui/icons-material"; +import { UserProfile } from "../../components/UserProfile/UserProfile"; -import github from "../../assets/github.svg"; +const Layout = () => { + const { instance, accounts } = useMsal(); + const navigate = useNavigate(); + const [isLoggedIn, setIsLoggedIn] = useState(null); + const [profilePicture, setProfilePicture] = useState(null); + const [userInitials, setUserInitials] = useState(null); + const [userName, setUserName] = useState(""); + const [userEmail, setUserEmail] = useState(""); + const [userRole, setUserRole] = useState("No Role"); + const [anchorEl, setAnchorEl] = useState(null); -import styles from "./Layout.module.css"; + const [isLoggingOut, setIsLoggingOut] = useState(false); -import { useLogin } from "../../authConfig"; + useEffect(() => { + const checkAuthStatus = async () => { + if (isLoggingOut) return; // Prevent navigation while logout is in progress -import { LoginButton } from "../../components/LoginButton"; + const activeAccount = instance.getActiveAccount(); + if (activeAccount || appServicesToken) { + setIsLoggedIn(true); -const Layout = () => { - return ( + const storedProfile = localStorage.getItem("whiddon-userProfile"); + const storedPicture = localStorage.getItem("whiddon-userProfilePicture"); + + if (storedProfile) { + const userData = JSON.parse(storedProfile); + setUserName(userData.name); + setUserEmail(userData.email); + setUserInitials(userData.initials); + setUserRole(userData.role || "No Role"); + } + + if (storedPicture) { + setProfilePicture(storedPicture); + } else { + await fetchUserProfile(); + } + } else { + setIsLoggedIn(false); + } + }; + + checkAuthStatus(); + + const accountListener = instance.addEventCallback((event) => { + if (event.eventType === EventType.LOGIN_SUCCESS) { + setIsLoggedIn(true); + fetchUserProfile(); + navigate("/", { replace: true }); + } else if (event.eventType === EventType.LOGOUT_SUCCESS) { + if (!isLoggingOut) { // Prevent automatic navigation if already logging out manually + setIsLoggedIn(false); + setProfilePicture(null); + setUserInitials(null); + setUserName(""); + setUserEmail(""); + setUserRole("No Role"); + + localStorage.removeItem("whiddon-userProfile"); + localStorage.removeItem("whiddon-userProfilePicture"); + + navigate("/"); + } + } + }); + + return () => { + if (accountListener) { + instance.removeEventCallback(accountListener); + } + }; + }, [instance, accounts, navigate, isLoggingOut]); + + + + const fetchUserProfile = async () => { + try { + const activeAccount = instance.getActiveAccount(); + if (!activeAccount) { + console.error("No active account found."); + return; + } + + const tokenResponse = await instance.acquireTokenSilent({ + scopes: ["https://graph.microsoft.com/User.Read"], + account: activeAccount, + }); + + if (!tokenResponse.accessToken) { + console.error("Failed to acquire access token."); + return; + } + + const profileResponse = await axios.get("https://graph.microsoft.com/v1.0/me", { + headers: { Authorization: `Bearer ${tokenResponse.accessToken}` }, + }); + + const { givenName, surname, mail, jobTitle } = profileResponse.data; + const initials = (givenName?.charAt(0) || "") + (surname?.charAt(0) || ""); + + const userData = { + name: `${givenName} ${surname}`, + email: mail || "No Email", + initials: initials.toUpperCase(), + role: jobTitle || "No Role" + }; + + setUserName(userData.name); + setUserEmail(userData.email); + setUserInitials(userData.initials); + setUserRole(userData.role); + localStorage.setItem("whiddon-userProfile", JSON.stringify(userData)); + + try { + const pictureResponse = await axios.get("https://graph.microsoft.com/v1.0/me/photo/$value", { + headers: { Authorization: `Bearer ${tokenResponse.accessToken}` }, + responseType: "blob", + }); + + const base64Image = await blobToBase64(pictureResponse.data); + setProfilePicture(base64Image); + localStorage.setItem("whiddon-userProfilePicture", base64Image); + } catch (error: unknown) { + console.warn("No profile picture found, using initials."); + setProfilePicture(null); + localStorage.removeItem("whiddon-userProfilePicture"); + } + } catch (error) { + console.error("Error fetching user profile:", error); + } + }; + + + const blobToBase64 = (blob: Blob): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + }; + + const handleLogout = async () => { + const activeAccount = instance.getActiveAccount(); + if (activeAccount) { + try { + setIsLoggingOut(true); // Prevent immediate navigation + + await instance.logoutPopup({ account: activeAccount }); // Wait for user confirmation + + // Clear user state and local storage after successful logout + localStorage.removeItem("whiddon-userProfile"); + localStorage.removeItem("whiddon-userProfilePicture"); + + setIsLoggedIn(false); + setProfilePicture(null); + setUserInitials(null); + setUserName(""); + setUserEmail(""); + setUserRole("No Role"); + + // Close the popover + setAnchorEl(null); + + navigate("/"); // Now navigate after logout is confirmed + } catch (error) { + console.error("Logout failed:", error); + setIsLoggingOut(false); // Reset state if logout fails + } + } else { + appServicesLogout(); + setAnchorEl(null); + setIsLoggedIn(false); + navigate("/"); + } + }; + + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const open = Boolean(anchorEl); + const id = open ? "profile-popover" : undefined; + + if (isLoggedIn === null) { + return ; + } + + return isLoggedIn ? (
-
-
- -

GPT + Enterprise data | Sample

- - -

Azure OpenAI + AI Search

- {useLogin && } -
-
- - +
+
+ Logo + + { + // Add login logic here + }} + onLogout={handleLogout} + /> +
+ +
+ +
+
+ ) : ( + ); }; diff --git a/app/frontend/src/utils/userProfileUtils.ts b/app/frontend/src/utils/userProfileUtils.ts new file mode 100644 index 0000000000..ac25a20d4a --- /dev/null +++ b/app/frontend/src/utils/userProfileUtils.ts @@ -0,0 +1,75 @@ +import axios from "axios"; +import { PublicClientApplication } from "@azure/msal-browser"; +import { jwtDecode } from "jwt-decode"; + +export const fetchUserProfile = async (instance: PublicClientApplication) => { + try { + const activeAccount = instance.getActiveAccount(); + if (!activeAccount) { + console.error("No active account found."); + return null; + } + + let userRole = ""; + if (activeAccount && activeAccount.idToken) { + const decoded: any = jwtDecode(activeAccount.idToken); + const roles = decoded.roles || decoded.role || []; + userRole = Array.isArray(roles) ? roles[0] : roles; + } + + const tokenResponse = await instance.acquireTokenSilent({ + scopes: ["https://graph.microsoft.com/User.Read"], + account: activeAccount, + }); + + if (!tokenResponse.accessToken) { + console.error("Failed to acquire access token."); + return null; + } + + const profileResponse = await axios.get("https://graph.microsoft.com/v1.0/me", { + headers: { Authorization: `Bearer ${tokenResponse.accessToken}` }, + }); + + const { givenName, surname, mail, id } = profileResponse.data; + + const initials = givenName ? (givenName?.charAt(0) || "") + (surname?.charAt(0) || "") : activeAccount?.name?.charAt(0) || ""; + + const userData = { + name: givenName ? `${givenName} ${surname}` : activeAccount?.name || "", + email: mail || tokenResponse.account.username, + initials: initials.toUpperCase(), + role: userRole, + userId: id + }; + + localStorage.setItem("whiddon-userProfile", JSON.stringify(userData)); + + try { + const pictureResponse = await axios.get("https://graph.microsoft.com/v1.0/me/photo/$value", { + headers: { Authorization: `Bearer ${tokenResponse.accessToken}` }, + responseType: "blob", + }); + + const base64Image = await blobToBase64(pictureResponse.data); + localStorage.setItem("whiddon-userProfilePicture", base64Image); + } catch (error) { + console.warn("No profile picture found, using initials."); + localStorage.removeItem("whiddon-userProfilePicture"); + } + + return userData; + } catch (error) { + console.error("Error fetching user profile:", error); + return null; + } +}; + +export const blobToBase64 = (blob: Blob): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +}; diff --git a/app/frontend/vite.config.ts b/app/frontend/vite.config.ts index 7fe15e7533..d6d4820e75 100644 --- a/app/frontend/vite.config.ts +++ b/app/frontend/vite.config.ts @@ -25,16 +25,23 @@ export default defineConfig({ }, server: { proxy: { - "/content/": "http://localhost:50505", - "/auth_setup": "http://localhost:50505", - "/.auth/me": "http://localhost:50505", - "/ask": "http://localhost:50505", - "/chat": "http://localhost:50505", - "/speech": "http://localhost:50505", - "/config": "http://localhost:50505", - "/upload": "http://localhost:50505", - "/delete_uploaded": "http://localhost:50505", - "/list_uploaded": "http://localhost:50505" + "/content/": "http://127.0.0.1:50505", + "/auth_setup": "http://127.0.0.1:50505", + "/.auth/me": "http://127.0.0.1:50505", + "/ask": "http://127.0.0.1:50505", + "/chat": "http://127.0.0.1:50505", + "/speech": "http://127.0.0.1:50505", + "/config": "http://127.0.0.1:50505", + "/upload": "http://127.0.0.1:50505", + "/delete_uploaded": "http://127.0.0.1:50505", + "/list_uploaded": "http://127.0.0.1:50505", + "/chunk_upload": "http://127.0.0.1:50505", + "/list_container_files": "http://127.0.0.1:50505", + "/delete_container_file": "http://127.0.0.1:50505", + "/reindex_after_delete": "http://127.0.0.1:50505", + "/reindex_file": "http://127.0.0.1:50505", + "/reindex_container_file": "http://127.0.0.1:50505", + "/download_container_file": "http://127.0.0.1:50505", } } }); diff --git a/data/Benefit_Options.pdf b/data/Benefit_Options.pdf deleted file mode 100644 index 6a4c07dc94..0000000000 Binary files a/data/Benefit_Options.pdf and /dev/null differ diff --git a/data/CC Client Protection Policy and Guidelines (1).pdf b/data/CC Client Protection Policy and Guidelines (1).pdf new file mode 100644 index 0000000000..840d181e19 Binary files /dev/null and b/data/CC Client Protection Policy and Guidelines (1).pdf differ diff --git a/data/Contoso_Electronics_Company_Overview.md b/data/Contoso_Electronics_Company_Overview.md deleted file mode 100644 index 033d7dd84a..0000000000 --- a/data/Contoso_Electronics_Company_Overview.md +++ /dev/null @@ -1,48 +0,0 @@ -# Contoso Electronics - -*Disclaimer: This content is generated by AI and may not accurately represent factual information about any real entity. Use this information with caution and verify details from reliable sources.* - -## History - -Contoso Electronics, a pioneering force in the tech industry, was founded in 1985 by visionary entrepreneurs with a passion for innovation. Over the years, the company has played a pivotal role in shaping the landscape of consumer electronics. - -| Year | Milestone | -|------|-----------| -| 1985 | Company founded with a focus on cutting-edge technology | -| 1990 | Launched the first-ever handheld personal computer | -| 2000 | Introduced groundbreaking advancements in AI and robotics | -| 2015 | Expansion into sustainable and eco-friendly product lines | - -## Company Overview - -At Contoso Electronics, we take pride in fostering a dynamic and inclusive workplace. Our dedicated team of experts collaborates to create innovative solutions that empower and connect people globally. - -### Core Values - -- **Innovation:** Constantly pushing the boundaries of technology. -- **Diversity:** Embracing different perspectives for creative excellence. -- **Sustainability:** Committed to eco-friendly practices in our products. - -## Vacation Perks - -We believe in work-life balance and understand the importance of well-deserved breaks. Our vacation perks are designed to help our employees recharge and return with renewed enthusiasm. - -| Vacation Tier | Duration | Additional Benefits | -|---------------|----------|---------------------| -| Standard | 2 weeks | Health and wellness stipend | -| Senior | 4 weeks | Travel vouchers for a dream destination | -| Executive | 6 weeks | Luxury resort getaway with family | - -## Employee Recognition - -Recognizing the hard work and dedication of our employees is at the core of our culture. Here are some ways we celebrate achievements: - -- Monthly "Innovator of the Month" awards -- Annual gala with awards for outstanding contributions -- Team-building retreats for high-performing departments - -## Join Us! - -Contoso Electronics is always on the lookout for talented individuals who share our passion for innovation. If you're ready to be part of a dynamic team shaping the future of technology, check out our [careers page](http://www.contoso.com) for exciting opportunities. - -[Learn more about Contoso Electronics!](http://www.contoso.com) diff --git a/data/GOV Clinical Governance Policy and Framework 2024.pdf b/data/GOV Clinical Governance Policy and Framework 2024.pdf new file mode 100644 index 0000000000..25778f45d7 Binary files /dev/null and b/data/GOV Clinical Governance Policy and Framework 2024.pdf differ diff --git a/data/GOV Complaints Resolution Policy and Guide Residential and Community Care Nov 24.pdf b/data/GOV Complaints Resolution Policy and Guide Residential and Community Care Nov 24.pdf new file mode 100644 index 0000000000..e95098b990 Binary files /dev/null and b/data/GOV Complaints Resolution Policy and Guide Residential and Community Care Nov 24.pdf differ diff --git a/data/GOV Antimicrobial Stewardship Policy and Framework 24.pdf b/data/GOV Antimicrobial Stewardship Policy and Framework 24.pdf new file mode 100644 index 0000000000..17d21885b7 Binary files /dev/null and b/data/GOV Antimicrobial Stewardship Policy and Framework 24.pdf differ diff --git a/data/GOV Continuous Improvement Management Policy.pdf b/data/GOV Continuous Improvement Management Policy.pdf new file mode 100644 index 0000000000..d90d49668e Binary files /dev/null and b/data/GOV Continuous Improvement Management Policy.pdf differ diff --git a/data/GOV Dignity of Risk Policy 2024.pdf b/data/GOV Dignity of Risk Policy 2024.pdf new file mode 100644 index 0000000000..d21cc91950 Binary files /dev/null and b/data/GOV Dignity of Risk Policy 2024.pdf differ diff --git a/data/GOV Firearm and prohibited weapons Policy.pdf b/data/GOV Firearm and prohibited weapons Policy.pdf new file mode 100644 index 0000000000..fd7560688d Binary files /dev/null and b/data/GOV Firearm and prohibited weapons Policy.pdf differ diff --git a/data/GOV Firearms Gun storage self assessment.pdf b/data/GOV Firearms Gun storage self assessment.pdf new file mode 100644 index 0000000000..6becac109d Binary files /dev/null and b/data/GOV Firearms Gun storage self assessment.pdf differ diff --git a/data/GOV Firearms Prohibited_Weapons_Schedule.pdf b/data/GOV Firearms Prohibited_Weapons_Schedule.pdf new file mode 100644 index 0000000000..f8e5a4ea98 Binary files /dev/null and b/data/GOV Firearms Prohibited_Weapons_Schedule.pdf differ diff --git a/data/GOV Heatwave Flyer - Residents, Clients and Families.pdf b/data/GOV Heatwave Flyer - Residents, Clients and Families.pdf new file mode 100644 index 0000000000..fe0f2ff68f Binary files /dev/null and b/data/GOV Heatwave Flyer - Residents, Clients and Families.pdf differ diff --git a/data/GOV Heatwave Flyer for Staff.pdf b/data/GOV Heatwave Flyer for Staff.pdf new file mode 100644 index 0000000000..7829498c81 Binary files /dev/null and b/data/GOV Heatwave Flyer for Staff.pdf differ diff --git a/data/GOV Heatwave Staying Healthy in a Heatwave Service Plan and Heatwave Preparedness and Management Guide 23.pdf b/data/GOV Heatwave Staying Healthy in a Heatwave Service Plan and Heatwave Preparedness and Management Guide 23.pdf new file mode 100644 index 0000000000..395355c719 Binary files /dev/null and b/data/GOV Heatwave Staying Healthy in a Heatwave Service Plan and Heatwave Preparedness and Management Guide 23.pdf differ diff --git a/data/GOV Infection Control Policy Nov 24.pdf b/data/GOV Infection Control Policy Nov 24.pdf new file mode 100644 index 0000000000..12da9d94bb Binary files /dev/null and b/data/GOV Infection Control Policy Nov 24.pdf differ diff --git a/data/GOV Open Disclosure Policy for RAC and CC.pdf b/data/GOV Open Disclosure Policy for RAC and CC.pdf new file mode 100644 index 0000000000..7d2deeeb0a Binary files /dev/null and b/data/GOV Open Disclosure Policy for RAC and CC.pdf differ diff --git a/data/GOV Privacy Policy Guideline.pdf b/data/GOV Privacy Policy Guideline.pdf new file mode 100644 index 0000000000..22cb4ffca9 Binary files /dev/null and b/data/GOV Privacy Policy Guideline.pdf differ diff --git a/data/GOV Social Media Policy and Guidelines 2024.pdf b/data/GOV Social Media Policy and Guidelines 2024.pdf new file mode 100644 index 0000000000..3202118d85 Binary files /dev/null and b/data/GOV Social Media Policy and Guidelines 2024.pdf differ diff --git a/data/GOV Substance Management Plan QLD - Whiddon.pdf b/data/GOV Substance Management Plan QLD - Whiddon.pdf new file mode 100644 index 0000000000..d12ee8f8db Binary files /dev/null and b/data/GOV Substance Management Plan QLD - Whiddon.pdf differ diff --git a/data/GOV Supporting Consumer Relationships Policy Aug 2022.pdf b/data/GOV Supporting Consumer Relationships Policy Aug 2022.pdf new file mode 100644 index 0000000000..92fb05fe03 Binary files /dev/null and b/data/GOV Supporting Consumer Relationships Policy Aug 2022.pdf differ diff --git a/data/GOV firearms notification to Police by health professional form.pdf b/data/GOV firearms notification to Police by health professional form.pdf new file mode 100644 index 0000000000..7ca159c6c5 Binary files /dev/null and b/data/GOV firearms notification to Police by health professional form.pdf differ diff --git a/data/Northwind_Health_Plus_Benefits_Details.pdf b/data/Northwind_Health_Plus_Benefits_Details.pdf deleted file mode 100644 index 97579a4fb5..0000000000 Binary files a/data/Northwind_Health_Plus_Benefits_Details.pdf and /dev/null differ diff --git a/data/Northwind_Standard_Benefits_Details.pdf b/data/Northwind_Standard_Benefits_Details.pdf deleted file mode 100644 index 7d50ff8c02..0000000000 Binary files a/data/Northwind_Standard_Benefits_Details.pdf and /dev/null differ diff --git a/data/PerksPlus.pdf b/data/PerksPlus.pdf deleted file mode 100644 index 2e167a2a6a..0000000000 Binary files a/data/PerksPlus.pdf and /dev/null differ diff --git a/data/RAC Death Screen Policy and Procedure Nov 2024.pdf b/data/RAC Death Screen Policy and Procedure Nov 2024.pdf new file mode 100644 index 0000000000..0f94d1e3f5 Binary files /dev/null and b/data/RAC Death Screen Policy and Procedure Nov 2024.pdf differ diff --git a/data/RAC Dementia Framework guide.pdf b/data/RAC Dementia Framework guide.pdf new file mode 100644 index 0000000000..80d7a45765 Binary files /dev/null and b/data/RAC Dementia Framework guide.pdf differ diff --git a/data/RAC Diabetes Management Policy and Guide 24.pdf b/data/RAC Diabetes Management Policy and Guide 24.pdf new file mode 100644 index 0000000000..e63eabcecc Binary files /dev/null and b/data/RAC Diabetes Management Policy and Guide 24.pdf differ diff --git a/data/RAC Documentation guide._ 2024.pdf b/data/RAC Documentation guide._ 2024.pdf new file mode 100644 index 0000000000..e7e2d056d1 Binary files /dev/null and b/data/RAC Documentation guide._ 2024.pdf differ diff --git a/data/RAC Dysphagia Management Policy.pdf b/data/RAC Dysphagia Management Policy.pdf new file mode 100644 index 0000000000..e866c60cc5 Binary files /dev/null and b/data/RAC Dysphagia Management Policy.pdf differ diff --git a/data/RAC Falls Prevention and Management Policy and Guide Nov 2024.pdf b/data/RAC Falls Prevention and Management Policy and Guide Nov 2024.pdf new file mode 100644 index 0000000000..91fceafb48 Binary files /dev/null and b/data/RAC Falls Prevention and Management Policy and Guide Nov 2024.pdf differ diff --git a/data/RAC Grief Bereavement Protocol 24.pdf b/data/RAC Grief Bereavement Protocol 24.pdf new file mode 100644 index 0000000000..8b7c7f7a62 Binary files /dev/null and b/data/RAC Grief Bereavement Protocol 24.pdf differ diff --git a/data/RAC Identifying Deterioration - Process Guide Exposure Hyperthermia.pdf b/data/RAC Identifying Deterioration - Process Guide Exposure Hyperthermia.pdf new file mode 100644 index 0000000000..3aa5c8bd4b Binary files /dev/null and b/data/RAC Identifying Deterioration - Process Guide Exposure Hyperthermia.pdf differ diff --git a/data/RAC Identifying and Managing the Deteriorating Resident and Client V. .2.1.pdf b/data/RAC Identifying and Managing the Deteriorating Resident and Client V. .2.1.pdf new file mode 100644 index 0000000000..d64c834c9e Binary files /dev/null and b/data/RAC Identifying and Managing the Deteriorating Resident and Client V. .2.1.pdf differ diff --git a/data/employee_handbook.pdf b/data/employee_handbook.pdf deleted file mode 100644 index 878f36f7dd..0000000000 Binary files a/data/employee_handbook.pdf and /dev/null differ diff --git a/data/role_library.pdf b/data/role_library.pdf deleted file mode 100644 index ff70c65651..0000000000 Binary files a/data/role_library.pdf and /dev/null differ diff --git a/docs/deploy_private.md b/docs/deploy_private.md index c7f449c29a..c9a731381e 100644 --- a/docs/deploy_private.md +++ b/docs/deploy_private.md @@ -3,6 +3,8 @@ If you want to disable public access when deploying the Chat App, you can do so by setting `azd` environment values. +[📺 Watch a video overview of the VM provisioning process](https://www.youtube.com/watch?v=RbITd0a5who) + ## Before you begin Deploying with public access disabled adds additional cost to your deployment. Please see pricing for the following products: diff --git a/package-lock.json b/package-lock.json index 257a62346b..e6e4b0cbc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2,5 +2,1679 @@ "name": "azure-search-openai-demo", "lockfileVersion": 2, "requires": true, - "packages": {} + "packages": { + "": { + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/material": "^6.4.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", + "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.9" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", + "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", + "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", + "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", + "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.7.tgz", + "integrity": "sha512-XjJrKFNt9zAKvcnoIIBquXyFyhfrHYuttqMsoDS7lM7VwufYG4fAPw4kINjBFg++fqXM2BNAuWR9J7XVIuKIKg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/material": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.7.tgz", + "integrity": "sha512-K65StXUeGAtFJ4ikvHKtmDCO5Ab7g0FZUu2J5VpoKD+O6Y3CjLYzRi+TMlI3kaL4CL158+FccMoOd/eaddmeRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.4.7", + "@mui/system": "^6.4.7", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.6", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.4.7", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.6.tgz", + "integrity": "sha512-T5FxdPzCELuOrhpA2g4Pi6241HAxRwZudzAuL9vBvniuB5YU82HCmrARw32AuCiyTfWzbrYGGpZ4zyeqqp9RvQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.4.6", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.4.6.tgz", + "integrity": "sha512-vSWYc9ZLX46be5gP+FCzWVn5rvDr4cXC5JBZwSIkYk9xbC7GeV+0kCvB8Q6XLFQJy+a62bbqtmdwS4Ghi9NBlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.4.7.tgz", + "integrity": "sha512-7wwc4++Ak6tGIooEVA9AY7FhH2p9fvBMORT4vNLMAysH3Yus/9B9RYMbrn3ANgsOyvT3Z7nE+SP8/+3FimQmcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.4.6", + "@mui/styled-engine": "^6.4.6", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.6", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.21", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", + "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.6.tgz", + "integrity": "sha512-43nZeE1pJF2anGafNydUcYFPtHwAqiBiauRtaMvurdrZI3YrUjHkAu43RBsxef7OFtJMXGiHFvq43kb7lig0sA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.21", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.0.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", + "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "license": "MIT" + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT", + "peer": true + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + } + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "requires": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + } + }, + "@babel/generator": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", + "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "requires": { + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + } + }, + "@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "requires": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + } + }, + "@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==" + }, + "@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==" + }, + "@babel/parser": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "requires": { + "@babel/types": "^7.26.9" + } + }, + "@babel/runtime": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", + "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", + "requires": { + "regenerator-runtime": "^0.14.0" + } + }, + "@babel/template": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "requires": { + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" + } + }, + "@babel/traverse": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", + "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", + "requires": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + } + }, + "@babel/types": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "requires": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + } + }, + "@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "requires": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "requires": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, + "@emotion/is-prop-valid": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", + "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "requires": { + "@emotion/memoize": "^0.9.0" + } + }, + "@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "requires": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + } + }, + "@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "requires": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + }, + "@emotion/styled": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", + "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "requires": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + } + }, + "@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "requires": {} + }, + "@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + }, + "@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + }, + "@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "requires": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" + }, + "@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==" + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + }, + "@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@mui/core-downloads-tracker": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.7.tgz", + "integrity": "sha512-XjJrKFNt9zAKvcnoIIBquXyFyhfrHYuttqMsoDS7lM7VwufYG4fAPw4kINjBFg++fqXM2BNAuWR9J7XVIuKIKg==" + }, + "@mui/material": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.7.tgz", + "integrity": "sha512-K65StXUeGAtFJ4ikvHKtmDCO5Ab7g0FZUu2J5VpoKD+O6Y3CjLYzRi+TMlI3kaL4CL158+FccMoOd/eaddmeRQ==", + "requires": { + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.4.7", + "@mui/system": "^6.4.7", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.6", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + } + }, + "@mui/private-theming": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.6.tgz", + "integrity": "sha512-T5FxdPzCELuOrhpA2g4Pi6241HAxRwZudzAuL9vBvniuB5YU82HCmrARw32AuCiyTfWzbrYGGpZ4zyeqqp9RvQ==", + "requires": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.4.6", + "prop-types": "^15.8.1" + } + }, + "@mui/styled-engine": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.4.6.tgz", + "integrity": "sha512-vSWYc9ZLX46be5gP+FCzWVn5rvDr4cXC5JBZwSIkYk9xbC7GeV+0kCvB8Q6XLFQJy+a62bbqtmdwS4Ghi9NBlQ==", + "requires": { + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + } + }, + "@mui/system": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.4.7.tgz", + "integrity": "sha512-7wwc4++Ak6tGIooEVA9AY7FhH2p9fvBMORT4vNLMAysH3Yus/9B9RYMbrn3ANgsOyvT3Z7nE+SP8/+3FimQmcg==", + "requires": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.4.6", + "@mui/styled-engine": "^6.4.6", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.6", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + } + }, + "@mui/types": { + "version": "7.2.21", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", + "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", + "requires": {} + }, + "@mui/utils": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.6.tgz", + "integrity": "sha512-43nZeE1pJF2anGafNydUcYFPtHwAqiBiauRtaMvurdrZI3YrUjHkAu43RBsxef7OFtJMXGiHFvq43kb7lig0sA==", + "requires": { + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.21", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + } + }, + "@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" + }, + "@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, + "@types/prop-types": { + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" + }, + "@types/react": { + "version": "19.0.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", + "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", + "peer": true, + "requires": { + "csstype": "^3.0.2" + } + }, + "@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "requires": {} + }, + "babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "requires": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" + }, + "clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" + }, + "convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + } + }, + "csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "requires": { + "ms": "^2.1.3" + } + }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + }, + "find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==" + }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "requires": { + "function-bind": "^1.1.2" + } + }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + }, + "dependencies": { + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + } + } + }, + "import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "requires": { + "hasown": "^2.0.2" + } + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==" + }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "requires": { + "callsites": "^3.0.0" + } + }, + "parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + }, + "dependencies": { + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + } + } + }, + "react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "peer": true + }, + "react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "peer": true, + "requires": { + "scheduler": "^0.25.0" + } + }, + "react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" + }, + "react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, + "regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "requires": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" + }, + "scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "peer": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" + }, + "stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" + }, + "yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" + } + } } diff --git a/package.json b/package.json new file mode 100644 index 0000000000..b3829f247d --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/material": "^6.4.7" + } +}