From b541dbe0cdae3a84ebca3aea8aca06a4d1abf08c Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 27 Jan 2025 10:14:46 -0500 Subject: [PATCH] Group management, Group documents, and User settings Now you can create groups, assign roles, and control who has access to group documents. Group documents function similarly to your documents. You can chat with them via chat or from the Group documents page. You may be a member of one or more groups. Your active group is how you interact with Group documents. User settings are new and are used to save your active group. --- application/single_app/app.py | 24 +- application/single_app/config.py | 36 +- .../single_app/functions_authentication.py | 14 +- .../single_app/functions_bing_search.py | 2 + application/single_app/functions_content.py | 2 + application/single_app/functions_documents.py | 36 +- application/single_app/functions_group.py | 169 +++++ .../single_app/functions_group_documents.py | 338 +++++++++ application/single_app/functions_search.py | 75 +- application/single_app/functions_settings.py | 32 + application/single_app/route_backend_chats.py | 4 +- .../single_app/route_backend_conversations.py | 2 + .../single_app/route_backend_documents.py | 64 +- .../route_backend_group_documents.py | 117 +++ .../single_app/route_backend_groups.py | 502 ++++++++++++ application/single_app/route_backend_users.py | 95 +++ .../route_frontend_admin_settings.py | 2 + .../route_frontend_authentication.py | 2 + .../single_app/route_frontend_chats.py | 2 + .../route_frontend_conversations.py | 2 + .../single_app/route_frontend_documents.py | 2 + .../route_frontend_group_documents.py | 13 + .../single_app/route_frontend_groups.py | 22 + .../single_app/route_frontend_profile.py | 2 + application/single_app/static/js/chats.js | 716 ++++++++++-------- application/single_app/templates/base.html | 6 + application/single_app/templates/chats.html | 12 +- .../single_app/templates/group_documents.html | 254 +++++++ .../single_app/templates/manage_group.html | 616 +++++++++++++++ .../single_app/templates/my_groups.html | 374 +++++++++ artifacts/group-index.json | 254 +++++++ 31 files changed, 3428 insertions(+), 363 deletions(-) create mode 100644 application/single_app/functions_group.py create mode 100644 application/single_app/functions_group_documents.py create mode 100644 application/single_app/route_backend_group_documents.py create mode 100644 application/single_app/route_backend_groups.py create mode 100644 application/single_app/route_backend_users.py create mode 100644 application/single_app/route_frontend_group_documents.py create mode 100644 application/single_app/route_frontend_groups.py create mode 100644 application/single_app/templates/group_documents.html create mode 100644 application/single_app/templates/manage_group.html create mode 100644 application/single_app/templates/my_groups.html create mode 100644 artifacts/group-index.json diff --git a/application/single_app/app.py b/application/single_app/app.py index cea249d..8e8519b 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -1,3 +1,5 @@ +# app.py + from config import * from functions_authentication import * @@ -12,10 +14,15 @@ from route_frontend_documents import * from route_frontend_chats import * from route_frontend_conversations import * +from route_frontend_groups import * +from route_frontend_group_documents import * from route_backend_chats import * from route_backend_conversations import * from route_backend_documents import * +from route_backend_groups import * +from route_backend_users import * +from route_backend_group_documents import * # =================== Helper Functions =================== @app.context_processor @@ -36,7 +43,7 @@ def format_datetime_filter(value): def index(): return render_template('index.html') -@app.route('/robots.txt') +@app.route('/robots933456.txt') def robots(): return send_from_directory('static', 'robots.txt') @@ -63,6 +70,12 @@ def favicon(): # ------------------- Documents Routes ------------------- register_route_frontend_documents(app) +# ------------------- Groups Routes ---------------------- +register_route_frontend_groups(app) + +# ------------------- Group Documents Routes ------------- +register_route_frontend_group_documents(app) + # =================== Back End Routes ==================== # ------------------- API Chat Routes -------------------- register_route_backend_chats(app) @@ -73,5 +86,14 @@ def favicon(): # ------------------- API Documents Routes --------------- register_route_backend_documents(app) +# ------------------- API Groups Routes ------------------ +register_route_backend_groups(app) + +# ------------------- API User Routes -------------------- +register_route_backend_users(app) + +# ------------------- API Group Documents Routes --------- +register_route_backend_group_documents(app) + if __name__ == '__main__': app.run(debug=True) \ No newline at end of file diff --git a/application/single_app/config.py b/application/single_app/config.py index cfc3b5e..46fec2d 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -1,3 +1,4 @@ +# config.py import os import requests import uuid @@ -20,22 +21,23 @@ from threading import Thread from openai import AzureOpenAI from cryptography.fernet import Fernet, InvalidToken +from urllib.parse import quote -from azure.cosmos import CosmosClient, PartitionKey +from azure.cosmos import CosmosClient, PartitionKey, exceptions from azure.cosmos.exceptions import CosmosResourceNotFoundError from azure.core.credentials import AzureKeyCredential from azure.ai.documentintelligence import DocumentIntelligenceClient from azure.ai.formrecognizer import DocumentAnalysisClient from azure.search.documents import SearchClient, IndexDocumentsBatch from azure.search.documents.models import VectorizedQuery -from azure.core.exceptions import AzureError +from azure.core.exceptions import AzureError, ResourceNotFoundError from azure.core.polling import LROPoller app = Flask(__name__) app.config['SECRET_KEY'] = os.getenv("SECRET_KEY") app.config['SESSION_TYPE'] = 'filesystem' -app.config['VERSION'] = '0.176.doc_target.1' +app.config['VERSION'] = '0.179.group_documents.13' Session(app) ALLOWED_EXTENSIONS = { @@ -49,6 +51,7 @@ TENANT_ID = os.getenv("TENANT_ID") AUTHORITY = f"https://login.microsoftonline.us/{TENANT_ID}" SCOPE = ["User.Read"] # Adjust scope according to your needs +MICROSOFT_PROVIDER_AUTHENTICATION_SECRET = os.getenv("MICROSOFT_PROVIDER_AUTHENTICATION_SECRET") # Azure Document Intelligence Configuration AZURE_DI_ENDPOINT = os.getenv("AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT") @@ -82,6 +85,7 @@ AZURE_AI_SEARCH_ENDPOINT = os.getenv('AZURE_AI_SEARCH_ENDPOINT') AZURE_AI_SEARCH_KEY = os.getenv('AZURE_AI_SEARCH_KEY') AZURE_AI_SEARCH_USER_INDEX = os.getenv('AZURE_AI_SEARCH_USER_INDEX') +AZURE_AI_SEARCH_GROUP_INDEX = os.getenv('AZURE_AI_SEARCH_GROUP_INDEX') BING_SEARCH_ENDPOINT = os.getenv("BING_SEARCH_ENDPOINT") BING_SEARCH_KEY = os.getenv("BING_SEARCH_KEY") @@ -111,6 +115,12 @@ credential=AzureKeyCredential(AZURE_AI_SEARCH_KEY) ) +search_client_group = SearchClient( + endpoint=AZURE_AI_SEARCH_ENDPOINT, + index_name=AZURE_AI_SEARCH_GROUP_INDEX, + credential=AzureKeyCredential(AZURE_AI_SEARCH_KEY) +) + settings_container_name = "settings" settings_container = database.create_container_if_not_exists( id=settings_container_name, @@ -118,3 +128,23 @@ offer_throughput=400 ) +groups_container_name = "groups" +groups_container = database.create_container_if_not_exists( + id=groups_container_name, + partition_key=PartitionKey(path="/id"), + offer_throughput=400 +) + +group_documents_container_name = "group_documents" +group_documents_container = database.create_container_if_not_exists( + id=group_documents_container_name, + partition_key=PartitionKey(path="/id"), + offer_throughput=400 +) + +user_settings_container_name = "user_settings" +user_settings_container = database.create_container_if_not_exists( + id=user_settings_container_name, + partition_key=PartitionKey(path="/id"), + offer_throughput=400 +) \ No newline at end of file diff --git a/application/single_app/functions_authentication.py b/application/single_app/functions_authentication.py index 46a99df..b05349c 100644 --- a/application/single_app/functions_authentication.py +++ b/application/single_app/functions_authentication.py @@ -1,3 +1,5 @@ +# functions_authentication.py + from config import * def login_required(f): @@ -13,4 +15,14 @@ def get_current_user_id(): user = session.get('user') if user: return user.get('oid') - return None \ No newline at end of file + return None + +def get_current_user_info(): + user = session.get("user") + if not user: + return None + return { + "userId": user.get("oid"), + "email": user.get("preferred_username"), + "displayName": user.get("name") + } \ No newline at end of file diff --git a/application/single_app/functions_bing_search.py b/application/single_app/functions_bing_search.py index b671531..83bba02 100644 --- a/application/single_app/functions_bing_search.py +++ b/application/single_app/functions_bing_search.py @@ -1,3 +1,5 @@ +# functions_bing_search.py + from config import * def get_suggestions(query): diff --git a/application/single_app/functions_content.py b/application/single_app/functions_content.py index 9c7d5d2..593dfe1 100644 --- a/application/single_app/functions_content.py +++ b/application/single_app/functions_content.py @@ -1,3 +1,5 @@ +# functions_content.py + from config import * from functions_settings import * diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index 982ec37..8a54f55 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -1,3 +1,5 @@ +# functions_documents.py + from config import * from functions_content import * from functions_settings import * @@ -336,4 +338,36 @@ def get_document_versions(user_id, document_id): except Exception as e: #print(f'Error retrieving document versions: {str(e)}') - return [] \ No newline at end of file + return [] + +def detect_doc_type(document_id, user_id=None): + """ + Check Cosmos to see if this doc belongs to the user's docs (has user_id) + or the group's docs (has group_id). + Returns one of: "user", "group", or None if not found. + Optionally checks if user_id matches (for user docs). + """ + + # 1) Try user docs container + try: + # For user docs, the container is "documents_container" + doc_item = documents_container.read_item(document_id, partition_key=document_id) + # If found, confirm it belongs to this user_id if given + if user_id and doc_item.get('user_id') != user_id: + # doesn't match the user -> not a user doc for this user + pass + else: + return "user" + except: + pass # Not found in user docs + + # 2) Try group docs container + try: + group_doc_item = group_documents_container.read_item(document_id, partition_key=document_id) + # If found, it must be a group doc + return "group" + except: + pass + + # If not found in either container + return None \ No newline at end of file diff --git a/application/single_app/functions_group.py b/application/single_app/functions_group.py new file mode 100644 index 0000000..461fe57 --- /dev/null +++ b/application/single_app/functions_group.py @@ -0,0 +1,169 @@ +# functions_group.py +from config import * +from functions_authentication import * +from functions_settings import * + + +def create_group(name, description): + """Creates a new group. The creator is the Owner by default.""" + user_info = get_current_user_info() + if not user_info: + raise Exception("No user in session") + + new_group_id = str(uuid.uuid4()) + now_str = datetime.utcnow().isoformat() + + group_doc = { + "id": new_group_id, + "name": name, + "description": description, + "owner": + { + "id": user_info["userId"], + "email": user_info["email"], + "displayName": user_info["displayName"] + }, + "admins": [], + "documentManagers": [], + "users": [ + { + "userId": user_info["userId"], + "email": user_info["email"], + "displayName": user_info["displayName"] + } + ], + "pendingUsers": [], + "createdDate": now_str, + "modifiedDate": now_str + } + groups_container.create_item(group_doc) + return group_doc + +def search_groups(search_query, user_id): + """ + Return a list of groups the user is in or (optionally) + you can expand to also return public groups. + For simplicity, this only returns groups where the user is a member. + """ + query = query = """ + SELECT * + FROM c + WHERE EXISTS ( + SELECT VALUE u + FROM u IN c.users + WHERE u.userId = @user_id + ) + """ + + params = [ + { "name": "@user_id", "value": user_id } + ] + if search_query: + # You can refine this to also match group name or description + query += " AND CONTAINS(c.name, @search) " + params.append({"name": "@search", "value": search_query}) + + results = list(groups_container.query_items( + query=query, + parameters=params, + enable_cross_partition_query=True + )) + return results + +def get_user_groups(user_id): + """ + Fetch all groups for which this user is a member. + """ + query = query = """ + SELECT * + FROM c + WHERE EXISTS ( + SELECT VALUE x + FROM x IN c.users + WHERE x.userId = @user_id + ) + """ + + params = [{ "name": "@user_id", "value": user_id }] + results = list(groups_container.query_items( + query=query, + parameters=params, + enable_cross_partition_query=True + )) + return results + +def find_group_by_id(group_id): + """Retrieve a single group doc by its ID.""" + try: + group_doc = groups_container.read_item( + item=group_id, + partition_key=group_id + ) + return group_doc + except exceptions.CosmosResourceNotFoundError: + return None + +def update_active_group_for_user(user_id, group_id): + new_settings = { + "settings": { + "activeGroupOid": group_id + }, + "lastUpdated": datetime.utcnow().isoformat() + } + update_user_settings(user_id, new_settings) + +def get_user_role_in_group(group_doc, user_id): + """Determine the user's role in the given group doc.""" + if not group_doc: + return None + + if group_doc.get("owner", {}).get("id") == user_id: + return "Owner" + elif user_id in group_doc.get("admins", []): + return "Admin" + elif user_id in group_doc.get("documentManagers", []): + return "DocumentManager" + else: + for u in group_doc.get("users", []): + if u["userId"] == user_id: + return "User" + + return None + + +def map_group_list_for_frontend(groups, current_user_id): + """ + Utility to produce a simplified list of group data + for the front-end, including userRole and isActive. + """ + active_group_id = session.get("active_group") + response = [] + for g in groups: + role = get_user_role_in_group(g, current_user_id) + response.append({ + "id": g["id"], + "name": g["name"], + "description": g.get("description", ""), + "userRole": role, + "isActive": (g["id"] == active_group_id) + }) + return response + +def delete_group(group_id): + """ + Deletes a group from Cosmos DB. Typically only owner can do this. + """ + groups_container.delete_item(item=group_id, partition_key=group_id) + +def is_user_in_group(group_doc, user_id): + """ + Helper to check if a user is in the given group's users[] or is the owner. + """ + if group_doc.get("owner", {}).get("id") == user_id: + return True + + # Or if userId appears in the 'users' array + for u in group_doc.get("users", []): + if u["userId"] == user_id: + return True + return False \ No newline at end of file diff --git a/application/single_app/functions_group_documents.py b/application/single_app/functions_group_documents.py new file mode 100644 index 0000000..ea288f6 --- /dev/null +++ b/application/single_app/functions_group_documents.py @@ -0,0 +1,338 @@ +# functions_group_documents.py +from config import * +from functions_content import * +from functions_content import * + +def get_group_documents(group_id): + """ + List the *latest* version of each file_name that belongs to this group. + Similar to your user docs approach: + 1) We query all metadata docs for the group + 2) For each file_name, keep the doc with the highest version + """ + query = """ + SELECT c.file_name, c.id, c.upload_date, c.group_id, c.num_chunks, c.version + FROM c + WHERE c.group_id = @group_id + AND c.type = "document_metadata" + """ + parameters = [{"name": "@group_id", "value": group_id}] + + items = list(group_documents_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True + )) + + latest_by_filename = {} + for doc in items: + fname = doc['file_name'] + if fname not in latest_by_filename or doc['version'] > latest_by_filename[fname]['version']: + latest_by_filename[fname] = doc + + return list(latest_by_filename.values()) + + +def get_group_document(group_id, document_id): + """ + Return the *latest version* of a specific document by ID, + verifying it belongs to the group. + We do a TOP 1 query, ordered by version DESC. + """ + try: + query = """ + SELECT TOP 1 * + FROM c + WHERE c.id = @document_id + AND c.group_id = @group_id + ORDER BY c.version DESC + """ + params = [ + {"name": "@document_id", "value": document_id}, + {"name": "@group_id", "value": group_id} + ] + results = list(group_documents_container.query_items( + query=query, parameters=params, enable_cross_partition_query=True + )) + if not results: + return None # not found + return results[0] + except Exception as ex: + print(f"Error in get_group_document: {ex}") + return None + + +def get_group_document_version(group_id, document_id, version): + """ + Return the *specific version* of a group doc in Cosmos. + """ + try: + query = """ + SELECT * + FROM c + WHERE c.id = @document_id + AND c.group_id = @group_id + AND c.version = @version + """ + params = [ + {"name": "@document_id", "value": document_id}, + {"name": "@group_id", "value": group_id}, + {"name": "@version", "value": version} + ] + results = list(group_documents_container.query_items( + query=query, parameters=params, enable_cross_partition_query=True + )) + return results[0] if results else None + except Exception as ex: + print(f"Error in get_group_document_version: {ex}") + return None + + +def get_group_document_versions(group_id, document_id): + """ + Return *all versions* of the doc in descending order by version. + """ + try: + query = """ + SELECT c.id, c.file_name, c.version, c.upload_date + FROM c + WHERE c.id = @document_id + AND c.group_id = @group_id + ORDER BY c.version DESC + """ + params = [ + {"name": "@document_id", "value": document_id}, + {"name": "@group_id", "value": group_id} + ] + results = list(group_documents_container.query_items( + query=query, parameters=params, enable_cross_partition_query=True + )) + return results + except Exception as ex: + print(f"Error retrieving group doc versions: {ex}") + return [] + + +def get_latest_group_doc_version(group_id, document_id): + """ + Return the *latest version number* of the doc. + If not found, returns None. + """ + try: + docs = get_group_document_versions(group_id, document_id) + if not docs: + return None + return max(d['version'] for d in docs) + except: + return None + + +def process_group_document_upload(file, group_id, user_id): + """ + 1) Validate extension & file size + 2) Extract text (Azure DI or simpler) + 3) chunk -> embed + 4) Insert doc metadata into Cosmos (type=document_metadata) + 5) Insert chunk docs into Azure Search index + """ + settings = get_settings() + + filename = secure_filename(file.filename) + file_ext = os.path.splitext(filename)[1].lower() + if file_ext.replace('.', '') not in ALLOWED_EXTENSIONS: + raise Exception("Unsupported file extension") + + # Check file size + file.seek(0, os.SEEK_END) + file_length = file.tell() + max_bytes = settings.get('max_file_size_mb', 16) * 1024 * 1024 + if file_length > max_bytes: + raise Exception("File size exceeds maximum allowed size") + file.seek(0) + + # Save to temp + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + file.save(tmp_file.name) + temp_file_path = tmp_file.name + + # Extract text + try: + if file_ext in ['.pdf', '.docx', '.xlsx', '.pptx', '.html', + '.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif', '.heif']: + extracted = extract_content_with_azure_di(temp_file_path) + elif file_ext == '.txt': + extracted = extract_text_file(temp_file_path) + elif file_ext == '.md': + extracted = extract_markdown_file(temp_file_path) + elif file_ext == '.json': + with open(temp_file_path, 'r', encoding='utf-8') as f: + extracted = json.dumps(json.load(f)) + else: + raise Exception("Unsupported file type") + finally: + os.remove(temp_file_path) + + # Chunk & embed + chunks = chunk_text(extracted) + + # Check existing doc version + existing_query = """ + SELECT c.version + FROM c + WHERE c.file_name = @file_name + AND c.group_id = @group_id + AND c.type = "document_metadata" + """ + params = [ + {"name": "@file_name", "value": filename}, + {"name": "@group_id", "value": group_id} + ] + existing_docs = list(group_documents_container.query_items( + query=existing_query, parameters=params, enable_cross_partition_query=True + )) + version = max(d['version'] for d in existing_docs) + 1 if existing_docs else 1 + + # Insert metadata doc + document_id = str(uuid4()) + now_utc = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + + doc_metadata = { + "id": document_id, + "group_id": group_id, + "file_name": filename, + "uploaded_by_user_id": user_id, + "upload_date": now_utc, + "version": version, + "num_chunks": len(chunks), + "type": "document_metadata" + } + group_documents_container.upsert_item(doc_metadata) + + # Build chunk docs for Azure Search + chunk_docs = [] + for idx, text_chunk in enumerate(chunks): + embedding = generate_embedding(text_chunk) # from your OpenAI / Azure AI code + chunk_id = f"{document_id}_{idx}" + + chunk_docs.append({ + "id": chunk_id, + "document_id": document_id, + "chunk_id": str(idx), + "chunk_text": text_chunk, + "embedding": embedding, + "file_name": filename, + "group_id": group_id, + "chunk_sequence": idx, + "upload_date": now_utc, + "version": version + }) + + # Upload chunk docs to Azure Search + try: + search_client_group.upload_documents(documents=chunk_docs) + except AzureError as ex: + print("Error uploading group doc chunks to search index:", ex) + # handle or raise + + return True + + +def delete_group_document(group_id, document_id): + """ + Deletes *all versions* of the group doc from Cosmos + AND all chunk docs from the group search index. + + If you only want to delete the *latest version*, use delete_group_document_version instead. + """ + # 1) Verify the doc_id belongs to this group + doc_item = get_group_document(group_id, document_id) + if not doc_item: + raise Exception("Document not found or group mismatch") + + # 2) Gather *all versions* of the doc from Cosmos + # then delete them + doc_versions = get_group_document_versions(group_id, document_id) + for ver_doc in doc_versions: + # Remove each version doc + try: + group_documents_container.delete_item( + item=ver_doc['id'], + partition_key=ver_doc['id'] + ) + except exceptions.CosmosResourceNotFoundError: + pass + + # 3) Delete the chunk docs from search index + delete_group_document_chunks(document_id) + + return True + + +def delete_group_document_chunks(document_id): + """ + Remove from Azure Search index all chunks whose 'document_id' == document_id + """ + try: + # Query the search index for chunk docs with matching doc_id + results = search_client_group.search( + search_text="*", + filter=f"document_id eq '{document_id}'", + select=["id"] + ) + chunk_ids = [doc["id"] for doc in results] + + if not chunk_ids: + return + + # Build the delete batch + docs_to_delete = [{"id": cid} for cid in chunk_ids] + batch = IndexDocumentsBatch() + batch.add_delete_actions(docs_to_delete) + search_client_group.index_documents(batch) + except AzureError as ex: + print("Error deleting group doc chunks from search:", ex) + raise + + +def delete_group_document_version(group_id, document_id, version): + """ + Deletes exactly one version from Cosmos, plus those chunk docs from search index. + Does not remove older/newer versions. + """ + # 1) find that version doc + version_doc = get_group_document_version(group_id, document_id, version) + if not version_doc: + raise Exception("Document version not found or group mismatch") + + # 2) delete it in cosmos + group_documents_container.delete_item( + item=version_doc['id'], + partition_key=version_doc['id'] + ) + + # 3) delete chunk docs for that version from search + delete_group_document_version_chunks(document_id, version) + + +def delete_group_document_version_chunks(document_id, version): + """ + Remove all chunk docs from the Azure Search index that match + document_id eq {document_id} AND version eq {version}. + """ + try: + search_results = search_client_group.search( + search_text="*", + filter=f"document_id eq '{document_id}' and version eq {version}", + select=["id"] + ) + chunk_ids = [doc["id"] for doc in search_results] + if not chunk_ids: + return + batch_docs = [{"id": cid} for cid in chunk_ids] + + batch = IndexDocumentsBatch() + batch.add_delete_actions(batch_docs) + search_client_group.index_documents(batch) + except AzureError as ex: + print("Error deleting chunk docs for version from group index:", ex) + raise \ No newline at end of file diff --git a/application/single_app/functions_search.py b/application/single_app/functions_search.py index f596ef3..800dc94 100644 --- a/application/single_app/functions_search.py +++ b/application/single_app/functions_search.py @@ -1,42 +1,65 @@ +# functions_search.py + from config import * from functions_content import * +from functions_documents import * def hybrid_search(query, user_id, document_id=None, top_n=3): - try: - query_embedding = generate_embedding(query) + """ + Hybrid search that queries the user doc index or the group doc index + depending on doc type. + If document_id is None, we just search the user index for the user's docs + OR you could unify that logic further (maybe search both). + """ + query_embedding = generate_embedding(query) + if query_embedding is None: + return None - if query_embedding is None: - #print("Failed to generate query embedding.") - return None + vector_query = VectorizedQuery( + vector=query_embedding, + k_nearest_neighbors=top_n, + fields="embedding" + ) - vector_query = VectorizedQuery(vector=query_embedding, k_nearest_neighbors=top_n, fields="embedding") + if not document_id: + # If no doc selected, let's default to user docs with a filter user_id = ... + results = search_client_user.search( + search_text=query, + vector_queries=[vector_query], + filter=f"user_id eq '{user_id}'", + select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date"] + ) + else: + # We need to detect doc type + doc_type = detect_doc_type(document_id, user_id=user_id) - if (document_id==None): + if doc_type == "user": + # search user docs index results = search_client_user.search( search_text=query, vector_queries=[vector_query], - filter=f"user_id eq '{user_id}'", + filter=f"user_id eq '{user_id}' and document_id eq '{document_id}'", select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date"] ) - else: - results = search_client_user.search( + elif doc_type == "group": + # search group docs index + # We might not strictly need group_id, + # but if you want to ensure the user can only search the group + # they're in, you'd need to check that in your chat logic or the detect code. + results = search_client_group.search( search_text=query, vector_queries=[vector_query], - filter=f"user_id eq '{user_id}' and document_id eq '{document_id}'", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date"] + filter=f"document_id eq '{document_id}'", # or also "group_id eq X" + select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date"] ) - - - limited_results = [] - for i, result in enumerate(results): - if i >= top_n: - break - limited_results.append(result) - - documents = [doc for doc in limited_results] - #print(f"Hybrid search completed successfully with {len(documents)} results.") - return documents + else: + # doc not found or mismatch user + return None - except Exception as e: - #print(f"Error during hybrid search: {str(e)}") - return None \ No newline at end of file + # Now collect top_n results + final_results = [] + for i, r in enumerate(results): + if i >= top_n: + break + final_results.append(r) + return final_results \ No newline at end of file diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 74a0d4e..70e1ee4 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -1,3 +1,5 @@ +# functions_settings.py + from config import * def get_settings(): @@ -72,3 +74,33 @@ def decrypt_key(encrypted_key): except InvalidToken: print("Decryption failed: Invalid token") return None + +def get_user_settings(user_id): + doc_id = str(user_id) + try: + return user_settings_container.read_item(item=doc_id, partition_key=doc_id) + except exceptions.CosmosResourceNotFoundError: + # Return default user settings if not found + return { + "id": user_id, + "settings": { + "activeGroupOid": "" + }, + "lastUpdated": None + } + +def update_user_settings(user_id, new_settings): + doc_id = str(user_id) + try: + # Try to fetch the existing document + doc = user_settings_container.read_item(item=doc_id, partition_key=doc_id) + # Update the settings document + doc.update(new_settings) + user_settings_container.upsert_item(doc) + except exceptions.CosmosResourceNotFoundError: + # If the document doesn't exist, create a new one + doc = { + "id": doc_id, + **new_settings + } + user_settings_container.upsert_item(doc) \ No newline at end of file diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index e2e2632..1b417df 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -1,3 +1,5 @@ +# route_backend_chats.py + from config import * from functions_authentication import * from functions_search import * @@ -102,7 +104,7 @@ def chat_api(): # If hybrid search is enabled, perform it and include the results if hybrid_search_enabled: if selected_document_id: - search_results = hybrid_search(user_message, user_id, top_n=10, document_id=selected_document_id) + search_results = hybrid_search(user_message, user_id, document_id=selected_document_id, top_n=10) else: search_results = hybrid_search(user_message, user_id, top_n=10) if search_results: diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index 916d1c2..02921e7 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -1,3 +1,5 @@ +# route_backend_conversations.py + from config import * from functions_authentication import * diff --git a/application/single_app/route_backend_documents.py b/application/single_app/route_backend_documents.py index 79a0d08..d2db740 100644 --- a/application/single_app/route_backend_documents.py +++ b/application/single_app/route_backend_documents.py @@ -1,3 +1,5 @@ +# route_backend_documents.py + from config import * from functions_authentication import * from functions_documents import * @@ -147,51 +149,51 @@ def api_delete_user_document(document_id): #print(f"Error deleting document: {str(e)}") return jsonify({'error': f'Error deleting document: {str(e)}'}), 500 - @app.route('/api/get_citation', methods=['POST']) + @app.route("/api/get_citation", methods=["POST"]) @login_required def get_citation(): data = request.get_json() user_id = get_current_user_id() - citation_id = data.get('citation_id') + citation_id = data.get("citation_id") if not user_id: - #print("User not authenticated.") - return jsonify({'error': 'User not authenticated'}), 401 - + return jsonify({"error": "User not authenticated"}), 401 if not citation_id: - #print("Missing citation_id.") - return jsonify({'error': 'Missing citation_id'}), 400 + return jsonify({"error": "Missing citation_id"}), 400 + # 1) Try user docs index try: - result = search_client_user.get_document(key=citation_id) - - if not result: - #print("Citation not found.") - return jsonify({'error': 'Citation not found'}), 404 + chunk = search_client_user.get_document(key=citation_id) + # If we found it in user docs, check user_id, etc. + if chunk.get("user_id") != user_id: + return jsonify({"error": "Unauthorized access to citation"}), 403 - chunk = result - if chunk.get('user_id') != user_id: - #print("Unauthorized access to citation.") - return jsonify({'error': 'Unauthorized access to citation'}), 403 + # Build the response + return jsonify({ + "cited_text": chunk.get("chunk_text", ""), + "file_name": chunk.get("file_name", ""), + "page_number": chunk.get("chunk_sequence", 0) + }), 200 - cited_text = chunk.get('chunk_text') - file_name = chunk.get('file_name') - page_number = chunk.get('chunk_sequence') + except ResourceNotFoundError: + # Not found in user index, let's try group index + pass - if not cited_text: - #print("Cited text not found.") - return jsonify({'error': 'Cited text not found'}), 404 + # 2) Try group docs index + try: + group_chunk = search_client_group.get_document(key=citation_id) - #print(f"Citation {citation_id} retrieved successfully.") + # The chunk should have "group_id", "chunk_text", etc. + # Check if user is in that group, or skip if not needed + # build response return jsonify({ - 'cited_text': cited_text, - 'file_name': file_name, - 'page_number': page_number + "cited_text": group_chunk.get("chunk_text", ""), + "file_name": group_chunk.get("file_name", ""), + "page_number": group_chunk.get("chunk_sequence", 0) }), 200 - except AzureError as e: - #print(f"Error retrieving citation from Azure Cognitive Search: {str(e)}") - return jsonify({'error': 'Error retrieving citation'}), 500 + except ResourceNotFoundError: + return jsonify({"error": "Citation not found in user or group docs"}), 404 + except Exception as e: - #print(f"Unexpected error: {str(e)}") - return jsonify({'error': 'An unexpected error occurred'}), 500 \ No newline at end of file + return jsonify({"error": f"Unexpected error: {str(e)}"}), 500 \ No newline at end of file diff --git a/application/single_app/route_backend_group_documents.py b/application/single_app/route_backend_group_documents.py new file mode 100644 index 0000000..f9f965b --- /dev/null +++ b/application/single_app/route_backend_group_documents.py @@ -0,0 +1,117 @@ +from config import * +from functions_authentication import * +from functions_settings import get_user_settings +from functions_group import get_user_role_in_group, find_group_by_id +from functions_group_documents import * + +def register_route_backend_group_documents(app): + """ + Provides backend routes for group-level document management: + - GET /api/group_documents (list) + - POST /api/group_documents/upload + - DELETE /api/group_documents/ + """ + + @app.route('/api/group_documents', methods=['GET']) + @login_required + def api_get_group_documents(): + """ + Return the list of documents for the user's *active* group. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + # Retrieve the user's active group + user_settings = get_user_settings(user_id) + active_group_id = user_settings["settings"].get("activeGroupOid") + + if not active_group_id: + return jsonify({'error': 'No active group selected'}), 400 + + # Check membership in that group + group_doc = find_group_by_id(active_group_id) + if not group_doc: + return jsonify({'error': 'Active group not found'}), 404 + + role = get_user_role_in_group(group_doc, user_id) + if not role: + return jsonify({'error': 'You are not a member of the active group'}), 403 + + # Retrieve documents from group_documents_container + try: + docs = get_group_documents(active_group_id) + return jsonify({'documents': docs}), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + + @app.route('/api/group_documents/upload', methods=['POST']) + @login_required + def api_upload_group_document(): + """ + Upload a new document into the active group’s collection, if user role + is Owner/Admin/Document Manager. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + # Retrieve the user's active group + user_settings = get_user_settings(user_id) + active_group_id = user_settings["settings"].get("activeGroupOid") + if not active_group_id: + return jsonify({'error': 'No active group selected'}), 400 + + group_doc = find_group_by_id(active_group_id) + if not group_doc: + return jsonify({'error': 'Active group not found'}), 404 + + role = get_user_role_in_group(group_doc, user_id) + if role not in ["Owner", "Admin", "DocumentManager"]: + return jsonify({'error': 'You do not have permission to upload documents'}), 403 + + if 'file' not in request.files: + return jsonify({'error': 'No file uploaded'}), 400 + + file = request.files['file'] + if not file.filename: + return jsonify({'error': 'No selected file'}), 400 + + try: + result = process_group_document_upload(file, active_group_id, user_id) + return jsonify({'message': 'Document uploaded successfully'}), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + + @app.route('/api/group_documents/', methods=['DELETE']) + @login_required + def api_delete_group_document(doc_id): + """ + Delete a document from the active group, if user role + is Owner/Admin/Document Manager. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + # Retrieve the user's active group + user_settings = get_user_settings(user_id) + active_group_id = user_settings["settings"].get("activeGroupOid") + if not active_group_id: + return jsonify({'error': 'No active group selected'}), 400 + + group_doc = find_group_by_id(active_group_id) + if not group_doc: + return jsonify({'error': 'Active group not found'}), 404 + + role = get_user_role_in_group(group_doc, user_id) + if role not in ["Owner", "Admin", "DocumentManager"]: + return jsonify({'error': 'You do not have permission to delete documents'}), 403 + + try: + delete_group_document(doc_id, active_group_id) + return jsonify({'message': 'Document deleted successfully'}), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/application/single_app/route_backend_groups.py b/application/single_app/route_backend_groups.py new file mode 100644 index 0000000..d4741a6 --- /dev/null +++ b/application/single_app/route_backend_groups.py @@ -0,0 +1,502 @@ +# route_backend_groups.py + +from config import * +from functions_authentication import * +from functions_group import * + +def register_route_backend_groups(app): + """ + Register all group-related API endpoints under '/api/groups/...' + """ + + @app.route("/api/groups/discover", methods=["GET"]) + @login_required + def discover_groups(): + """ + GET /api/groups/discover?search=&showAll= + Returns a list of ALL groups (or only those the user is not a member of), + based on 'showAll' query param. Defaults to NOT showing the groups + the user is already in. + """ + user_info = get_current_user_info() + user_id = user_info["userId"] + + search_query = request.args.get("search", "").lower() + show_all_str = request.args.get("showAll", "false").lower() # default = "false" + show_all = (show_all_str == "true") + + # Query all groups in Cosmos + # Adjust your query based on how you store group docs + query = "SELECT * FROM c WHERE c.type = 'group' or NOT IS_DEFINED(c.type)" + all_items = list(groups_container.query_items( + query=query, + enable_cross_partition_query=True + )) + + results = [] + for g in all_items: + name = g.get("name", "").lower() + desc = g.get("description", "").lower() + + # ---- Filter by search term (name or description) ---- + if search_query: + if search_query not in name and search_query not in desc: + continue + + # ---- If showAll is FALSE, exclude groups the user is in ---- + if not show_all: + # Check membership + # (If the user is in group["users"] or is the owner, they're a member.) + if is_user_in_group(g, user_id): + continue + + # Include minimal fields + results.append({ + "id": g["id"], + "name": g.get("name", ""), + "description": g.get("description", ""), + }) + + return jsonify(results), 200 + + @app.route("/api/groups", methods=["GET"]) + @login_required + def api_list_groups(): + user_info = get_current_user_info() + user_id = user_info["userId"] + + # Get the DB's notion of active group + user_settings_data = get_user_settings(user_id) + db_active_group_id = user_settings_data["settings"].get("activeGroupOid", "") + + search_query = request.args.get("search", "") + if search_query: + results = search_groups(search_query, user_id) + else: + results = get_user_groups(user_id) + + mapped = [] + for g in results: + role = get_user_role_in_group(g, user_id) + mapped.append({ + "id": g["id"], + "name": g["name"], + "description": g.get("description", ""), + "userRole": role, + "isActive": (g["id"] == db_active_group_id) # <--- Compare to DB + }) + + return jsonify(mapped), 200 + + + @app.route("/api/groups", methods=["POST"]) + @login_required + def api_create_group(): + """ + POST /api/groups + Expects JSON: { "name": "", "description": "" } + Creates a new group with the current user as the owner. + """ + data = request.get_json() + name = data.get("name", "Untitled Group") + description = data.get("description", "") + + try: + group_doc = create_group(name, description) + return jsonify({"id": group_doc["id"], "name": group_doc["name"]}), 201 + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + @app.route("/api/groups/", methods=["GET"]) + @login_required + def api_get_group_details(group_id): + """ + GET /api/groups/ + Returns the full group details for that group. + """ + group_doc = find_group_by_id(group_id) + if not group_doc: + return jsonify({"error": "Group not found"}), 404 + return jsonify(group_doc), 200 + + @app.route("/api/groups/", methods=["DELETE"]) + @login_required + def api_delete_group(group_id): + """ + DELETE /api/groups/ + Only the owner can delete the group by default. + """ + user_info = get_current_user_info() + user_id = user_info["userId"] + + group_doc = find_group_by_id(group_id) + if not group_doc: + return jsonify({"error": "Group not found"}), 404 + + if group_doc["owner"]["id"] != user_id: + return jsonify({"error": "Only the owner can delete the group"}), 403 + + delete_group(group_id) + return jsonify({"message": "Group deleted successfully"}), 200 + + @app.route("/api/groups/", methods=["PATCH", "PUT"]) + @login_required + def api_update_group(group_id): + """ + PATCH /api/groups/ or PUT /api/groups/ + Allows the owner to modify group name, description, etc. + Expects JSON: { "name": "...", "description": "..." } + """ + user_info = get_current_user_info() + user_id = user_info["userId"] + group_doc = find_group_by_id(group_id) + if not group_doc: + return jsonify({"error": "Group not found"}), 404 + + if group_doc["owner"]["id"] != user_id: + return jsonify({"error": "Only the owner can rename/edit the group"}), 403 + + data = request.get_json() + name = data.get("name", group_doc.get("name")) + description = data.get("description", group_doc.get("description")) + + # Update the doc + group_doc["name"] = name + group_doc["description"] = description + group_doc["modifiedDate"] = datetime.utcnow().isoformat() + try: + groups_container.upsert_item(group_doc) + except exceptions.CosmosHttpResponseError as ex: + return jsonify({"error": str(ex)}), 400 + + return jsonify({"message": "Group updated", "id": group_id}), 200 + + @app.route("/api/groups/setActive", methods=["PATCH"]) + @login_required + def api_set_active_group(): + """ + PATCH /api/groups/setActive + Expects JSON: { "groupId": "" } + """ + data = request.get_json() + group_id = data.get("groupId") + if not group_id: + return jsonify({"error": "Missing groupId"}), 400 + + user_info = get_current_user_info() + user_id = user_info["userId"] + + # Validate the group exists and user is in that group + group_doc = find_group_by_id(group_id) + if not group_doc: + return jsonify({"error": "Group not found"}), 404 + + role = get_user_role_in_group(group_doc, user_id) + if not role: + return jsonify({"error": "You are not a member of this group"}), 403 + + # Update user_settings with the new active group + update_active_group_for_user(user_id, group_id) + + return jsonify({"message": f"Active group set to {group_id}"}), 200 + + + # + # ---------- Membership Management Routes ---------- + # + + @app.route("/api/groups//requests", methods=["POST"]) + @login_required + def request_to_join(group_id): + """ + POST /api/groups//requests + Creates a membership request. + We add the user to the group's 'pendingUsers' list if not already a member. + """ + user_info = get_current_user_info() + user_id = user_info["userId"] + group_doc = find_group_by_id(group_id) + if not group_doc: + return jsonify({"error": "Group not found"}), 404 + + # Check if user is already a member + existing_role = get_user_role_in_group(group_doc, user_id) + if existing_role: + return jsonify({"error": "User is already a member"}), 400 + + # Check if user is already pending + for p in group_doc.get("pendingUsers", []): + if p["userId"] == user_id: + return jsonify({"error": "User has already requested to join"}), 400 + + # Add to pendingUsers + group_doc["pendingUsers"].append({ + "userId": user_id, + "email": user_info["email"], + "displayName": user_info["displayName"] + }) + + group_doc["modifiedDate"] = datetime.utcnow().isoformat() + groups_container.upsert_item(group_doc) + + return jsonify({"message": "Membership request created"}), 201 + + @app.route("/api/groups//requests", methods=["GET"]) + @login_required + def view_pending_requests(group_id): + """ + GET /api/groups//requests + Allows Owner or Admin to see pending membership requests. + """ + user_info = get_current_user_info() + user_id = user_info["userId"] + group_doc = find_group_by_id(group_id) + if not group_doc: + return jsonify({"error": "Group not found"}), 404 + + role = get_user_role_in_group(group_doc, user_id) + if role not in ["Owner", "Admin"]: + return jsonify({"error": "Only the owner or admin can view requests"}), 403 + + return jsonify(group_doc.get("pendingUsers", [])), 200 + + @app.route("/api/groups//requests/", methods=["PATCH"]) + @login_required + def approve_reject_request(group_id, request_id): + """ + PATCH /api/groups//requests/ + Body can contain { "action": "approve" } or { "action": "reject" } + Only Owner or Admin can do so. + """ + user_info = get_current_user_info() + user_id = user_info["userId"] + group_doc = find_group_by_id(group_id) + if not group_doc: + return jsonify({"error": "Group not found"}), 404 + + role = get_user_role_in_group(group_doc, user_id) + if role not in ["Owner", "Admin"]: + return jsonify({"error": "Only the owner or admin can approve/reject requests"}), 403 + + data = request.get_json() + action = data.get("action") + if action not in ["approve", "reject"]: + return jsonify({"error": "Invalid or missing 'action'. Must be 'approve' or 'reject'."}), 400 + + pending_list = group_doc.get("pendingUsers", []) + user_index = None + for i, pending_user in enumerate(pending_list): + if pending_user["userId"] == request_id: + user_index = i + break + if user_index is None: + return jsonify({"error": "Request not found"}), 404 + + if action == "approve": + # Move from pending to actual members (basic user) + member_to_add = pending_list.pop(user_index) + group_doc["users"].append(member_to_add) + msg = "User approved and added as a member" + else: + # Reject -> remove from pending + pending_list.pop(user_index) + msg = "User rejected" + + group_doc["pendingUsers"] = pending_list + group_doc["modifiedDate"] = datetime.utcnow().isoformat() + groups_container.upsert_item(group_doc) + + return jsonify({"message": msg}), 200 + + @app.route("/api/groups//members", methods=["POST"]) + @login_required + def add_member_directly(group_id): + """ + POST /api/groups//members + Body: { "userId": "", "displayName": "...", etc. } + Only Owner or Admin can add members directly (bypass request flow). + """ + user_info = get_current_user_info() + user_id = user_info["userId"] + group_doc = find_group_by_id(group_id) + if not group_doc: + return jsonify({"error": "Group not found"}), 404 + + role = get_user_role_in_group(group_doc, user_id) + if role not in ["Owner", "Admin"]: + return jsonify({"error": "Only the owner or admin can add members"}), 403 + + data = request.get_json() + new_user_id = data.get("userId") + if not new_user_id: + return jsonify({"error": "Missing userId"}), 400 + + # Check if they are already in the group + if get_user_role_in_group(group_doc, new_user_id): + return jsonify({"error": "User is already a member"}), 400 + + # Optionally, you could call Microsoft Graph to get user info from new_user_id + # But here we assume it is provided in the body or we have minimal info + new_member_doc = { + "userId": new_user_id, + "email": data.get("email", ""), + "displayName": data.get("displayName", "New User") + } + group_doc["users"].append(new_member_doc) + group_doc["modifiedDate"] = datetime.utcnow().isoformat() + + groups_container.upsert_item(group_doc) + return jsonify({"message": "Member added"}), 200 + + @app.route("/api/groups//members/", methods=["DELETE"]) + @login_required + def remove_member(group_id, member_id): + """ + DELETE /api/groups//members/ + Remove a user from the group. Only Owner or Admin can do so. + """ + user_info = get_current_user_info() + user_id = user_info["userId"] + group_doc = find_group_by_id(group_id) + if not group_doc: + return jsonify({"error": "Group not found"}), 404 + + role = get_user_role_in_group(group_doc, user_id) + if role not in ["Owner", "Admin"]: + return jsonify({"error": "Only the owner or admin can remove members"}), 403 + + # Ensure not removing the owner + if member_id == group_doc["owner"]["id"]: + return jsonify({"error": "Cannot remove the group owner"}), 403 + + # Remove if in users + removed = False + updated_users = [] + for u in group_doc["users"]: + if u["userId"] == member_id: + removed = True + continue + updated_users.append(u) + group_doc["users"] = updated_users + + # Also remove from admins, docManagers if present + if member_id in group_doc.get("admins", []): + group_doc["admins"].remove(member_id) + if member_id in group_doc.get("documentManagers", []): + group_doc["documentManagers"].remove(member_id) + + group_doc["modifiedDate"] = datetime.utcnow().isoformat() + groups_container.upsert_item(group_doc) + + if removed: + return jsonify({"message": "User removed"}), 200 + else: + return jsonify({"error": "User not found in group"}), 404 + + @app.route("/api/groups//members/", methods=["PATCH"]) + @login_required + def update_member_role(group_id, member_id): + """ + PATCH /api/groups//members/ + Body: { "role": "Admin" | "DocumentManager" | "User" } + Only Owner or Admin can do so (but only Owner can promote Admins if you want). + """ + user_info = get_current_user_info() + user_id = user_info["userId"] + group_doc = find_group_by_id(group_id) + if not group_doc: + return jsonify({"error": "Group not found"}), 404 + + current_role = get_user_role_in_group(group_doc, user_id) + if current_role not in ["Owner", "Admin"]: + return jsonify({"error": "Only the owner or admin can update roles"}), 403 + + data = request.get_json() + new_role = data.get("role") + if new_role not in ["Admin", "DocumentManager", "User"]: + return jsonify({"error": "Invalid role. Must be Admin, DocumentManager, or User"}), 400 + + # If the current user is Admin but not Owner, we might disallow promoting to Admin + # (depending on your logic). For now, let's allow Admin to do it: + # if current_role == "Admin" and new_role == "Admin" and member_id != user_id: + # return jsonify({"error": "Admins cannot promote others to Admin"}), 403 + + # Ensure target is actually in the group + target_role = get_user_role_in_group(group_doc, member_id) + if not target_role: + return jsonify({"error": "Member is not in the group"}), 404 + + # Remove user from all role lists + if member_id in group_doc.get("admins", []): + group_doc["admins"].remove(member_id) + if member_id in group_doc.get("documentManagers", []): + group_doc["documentManagers"].remove(member_id) + + # Add to the new role list if needed + if new_role == "Admin": + group_doc["admins"].append(member_id) + elif new_role == "DocumentManager": + group_doc["documentManagers"].append(member_id) + else: + # "User" is the default role, do nothing special here + pass + + group_doc["modifiedDate"] = datetime.utcnow().isoformat() + groups_container.upsert_item(group_doc) + + return jsonify({"message": f"User {member_id} updated to {new_role}"}), 200 + + @app.route("/api/groups//members", methods=["GET"]) + @login_required + def view_group_members(group_id): + """ + GET /api/groups//members?search=&role= + Returns the list of members with their roles, optionally filtered. + """ + user_info = get_current_user_info() + user_id = user_info["userId"] + group_doc = find_group_by_id(group_id) + if not group_doc: + return jsonify({"error": "Group not found"}), 404 + + # Optional: Only let group members view the member list + if not get_user_role_in_group(group_doc, user_id): + return jsonify({"error": "You are not a member of this group"}), 403 + + # --- Read Query Parameters for Search and Role --- + search = request.args.get("search", "").strip().lower() # text search + role_filter = request.args.get("role", "").strip() # e.g. "Admin", "User", etc. + + results = [] + for u in group_doc["users"]: + uid = u["userId"] + # Determine user’s role + user_role = ( + "Owner" if uid == group_doc["owner"]["id"] else + "Admin" if uid in group_doc.get("admins", []) else + "DocumentManager" if uid in group_doc.get("documentManagers", []) else + "User" + ) + + # --- Filter by role if requested --- + if role_filter and role_filter != user_role: + continue + + # --- Filter by search term if provided --- + dn = u.get("displayName", "").lower() + em = u.get("email", "").lower() + + # If `search` is non-empty, require a partial match in displayName or email + if search and (search not in dn and search not in em): + continue + + # Passed filters; include in the results + results.append({ + "userId": uid, + "displayName": u.get("displayName", ""), + "email": u.get("email", ""), + "role": user_role + }) + + return jsonify(results), 200 + diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py new file mode 100644 index 0000000..7379a7d --- /dev/null +++ b/application/single_app/route_backend_users.py @@ -0,0 +1,95 @@ +# route_backend_users.py + +from config import * +from functions_authentication import * +from functions_settings import * + +def register_route_backend_users(app): + """ + This route will expose GET /api/userSearch?query= which calls + Microsoft Graph to find users by displayName, mail, userPrincipalName, etc. + """ + + # route_backend_users.py +from functions_authentication import * +from config import * + +def register_route_backend_users(app): + @app.route("/api/userSearch", methods=["GET"]) + @login_required + def api_user_search(): + query = request.args.get("query", "").strip() + if not query: + return jsonify([]), 200 # No query provided + + token = session.get("access_token") + if not token: + return jsonify({"error": "No access token in session"}), 401 + + user_endpoint = "https://graph.microsoft.com/v1.0/users" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + # Example filter using startsWith on displayName, mail, or userPrincipalName + filter_str = ( + f"startswith(displayName, '{query}') " + f"or startswith(mail, '{query}') " + f"or startswith(userPrincipalName, '{query}')" + ) + params = { + "$filter": filter_str, + "$top": 10, # up to 10 matches + "$select": "id,displayName,mail,userPrincipalName" + } + + response = requests.get(user_endpoint, headers=headers, params=params) + if response.status_code != 200: + return jsonify({ + "error": "Graph API error", + "status": response.status_code, + "details": response.text + }), 500 + + user_results = response.json().get("value", []) + results = [] + for user in user_results: + # If the user doesn't have 'mail', fallback to userPrincipalName, etc. + email = user.get("mail") or user.get("userPrincipalName") or "" + results.append({ + "id": user.get("id"), # <-- This will be the real Azure AD Object ID (GUID) + "displayName": user.get("displayName", "(no name)"), + "email": email + }) + + return jsonify(results), 200 + + @app.route('/api/user/settings', methods=['GET', 'POST']) + @login_required + def user_settings(): + user_id = get_current_user_id() + + if request.method == 'POST': + # Get settings from the form or request JSON + active_group_oid = request.form.get('activeGroupOid', '') or request.json.get('activeGroupOid', '') + + # Construct new settings dictionary + new_settings = { + "settings": { + "activeGroupOid": active_group_oid + }, + "lastUpdated": datetime.utcnow().isoformat() + } + + # Update user settings in the database + update_user_settings(user_id, new_settings) + return jsonify({"message": "User settings updated successfully"}), 200 + + # If GET request, fetch the settings + user_settings_data = get_user_settings(user_id) + return jsonify(user_settings_data if user_settings_data else {}) + + + + diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index c5efada..67f34fb 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -1,3 +1,5 @@ +# route_frontend_admin_settings.py + from config import * from functions_documents import * from functions_authentication import * diff --git a/application/single_app/route_frontend_authentication.py b/application/single_app/route_frontend_authentication.py index 85ebe70..9b98108 100644 --- a/application/single_app/route_frontend_authentication.py +++ b/application/single_app/route_frontend_authentication.py @@ -1,3 +1,5 @@ +# route_frontend_authentication.py + from config import * def register_route_frontend_authentication(app): diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index 767fa68..1a52288 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -1,3 +1,5 @@ +# route_frontend_chats.py + from config import * from functions_authentication import * from functions_content import * diff --git a/application/single_app/route_frontend_conversations.py b/application/single_app/route_frontend_conversations.py index 7152a5d..3f3893f 100644 --- a/application/single_app/route_frontend_conversations.py +++ b/application/single_app/route_frontend_conversations.py @@ -1,3 +1,5 @@ +# route_frontend_conversations.py + from config import * from functions_authentication import * diff --git a/application/single_app/route_frontend_documents.py b/application/single_app/route_frontend_documents.py index b116ae4..25b01a2 100644 --- a/application/single_app/route_frontend_documents.py +++ b/application/single_app/route_frontend_documents.py @@ -1,3 +1,5 @@ +# route_frontend_documents.py + from config import * from functions_authentication import * diff --git a/application/single_app/route_frontend_group_documents.py b/application/single_app/route_frontend_group_documents.py new file mode 100644 index 0000000..1131b2b --- /dev/null +++ b/application/single_app/route_frontend_group_documents.py @@ -0,0 +1,13 @@ +from config import * +from functions_authentication import * + +def register_route_frontend_group_documents(app): + @app.route('/group_documents', methods=['GET']) + @login_required + def group_documents(): + """Render the Group Documents page for the current active group.""" + user_id = get_current_user_id() + if not user_id: + return redirect(url_for('login')) + # Just render the template. The front-end JS will call the new APIs. + return render_template('group_documents.html') diff --git a/application/single_app/route_frontend_groups.py b/application/single_app/route_frontend_groups.py new file mode 100644 index 0000000..a29ff32 --- /dev/null +++ b/application/single_app/route_frontend_groups.py @@ -0,0 +1,22 @@ +# route_frontend_groups.py + +from config import * +from functions_authentication import * + +def register_route_frontend_groups(app): + @app.route("/my_groups", methods=["GET"]) + @login_required + def my_groups(): + """ + Renders the My Groups page (templates/my_groups.html). + """ + return render_template("my_groups.html") + + @app.route("/groups/", methods=["GET"]) + @login_required + def manage_group(group_id): + """ + Renders a page or view for managing a single group (not shown in detail here). + Could be a second template like 'manage_group.html'. + """ + return render_template("manage_group.html", group_id=group_id) diff --git a/application/single_app/route_frontend_profile.py b/application/single_app/route_frontend_profile.py index 6e88733..5ffe970 100644 --- a/application/single_app/route_frontend_profile.py +++ b/application/single_app/route_frontend_profile.py @@ -1,3 +1,5 @@ +# route_frontend_profile.py + from config import * from functions_authentication import * diff --git a/application/single_app/static/js/chats.js b/application/single_app/static/js/chats.js index d2000ab..6d25a59 100644 --- a/application/single_app/static/js/chats.js +++ b/application/single_app/static/js/chats.js @@ -1,7 +1,12 @@ +// chats.js + // Global variables +let personalDocs = []; +let groupDocs = []; +let activeGroupName = ""; let currentConversationId = null; -// Function to load all conversations +// ===================== LOAD / DISPLAY CONVERSATIONS ===================== function loadConversations() { fetch("/api/get_conversations") .then((response) => response.json()) @@ -12,23 +17,22 @@ function loadConversations() { const convoItem = document.createElement("div"); convoItem.classList.add("list-group-item", "conversation-item"); convoItem.setAttribute("data-conversation-id", convo.id); - convoItem.setAttribute("data-conversation-title", convo.title); // Add this line + convoItem.setAttribute("data-conversation-title", convo.title); + const date = new Date(convo.last_updated); convoItem.innerHTML = ` -
-
- ${ - convo.title - }
- ${date.toLocaleString()} -
- -
- `; +
+
+ ${convo.title}
+ ${date.toLocaleString()} +
+ +
+ `; conversationsList.appendChild(convoItem); }); }) @@ -37,42 +41,162 @@ function loadConversations() { }); } -// Toggle the active class on the button when clicked +function populateDocumentSelectScope() { + const scopeSel = document.getElementById("doc-scope-select"); + const docSel = document.getElementById("document-select"); + docSel.innerHTML = ""; + + // Always add a "None" or "No Document Selected" + const noneOpt = document.createElement("option"); + noneOpt.value = ""; + noneOpt.textContent = "None"; + docSel.appendChild(noneOpt); + + const scopeVal = scopeSel.value || "all"; + + let finalDocs = []; + if (scopeVal === "all") { + // Merge personal + group docs + const pDocs = personalDocs.map((d) => ({ + id: d.id, + label: `[Personal] ${d.file_name}`, + })); + const gDocs = groupDocs.map((d) => ({ + id: d.id, + label: `[Group: ${activeGroupName}] ${d.file_name}`, + })); + finalDocs = pDocs.concat(gDocs); + } else if (scopeVal === "personal") { + finalDocs = personalDocs.map((d) => ({ + id: d.id, + label: `[Personal] ${d.file_name}`, + })); + } else if (scopeVal === "group") { + finalDocs = groupDocs.map((d) => ({ + id: d.id, + label: `[Group: ${activeGroupName}] ${d.file_name}`, + })); + } + + // Add them to "document-select" + finalDocs.forEach((doc) => { + const opt = document.createElement("option"); + opt.value = doc.id; + opt.textContent = doc.label; + docSel.appendChild(opt); + }); +} + +// Listen for user selecting a different scope +document + .getElementById("doc-scope-select") + .addEventListener("change", populateDocumentSelectScope); + +// Loads personal documents from /api/documents +function loadPersonalDocs() { + return fetch("/api/documents") + .then((r) => r.json()) + .then((data) => { + if (data.error) { + console.warn("Error fetching user docs:", data.error); + personalDocs = []; + return; + } + personalDocs = data.documents || []; + }) + .catch((err) => { + console.error("Error loading personal docs:", err); + personalDocs = []; + }); +} + +// Loads group documents from /api/group_documents, +// but also calls /api/groups to find the active group name +function loadGroupDocs() { + // 1) get user groups to find which is active + return fetch("/api/groups") + .then((r) => r.json()) + .then((groups) => { + const activeGroup = groups.find((g) => g.isActive); + if (activeGroup) { + activeGroupName = activeGroup.name || "Active Group"; + // 2) now fetch the actual docs for that active group + return fetch("/api/group_documents") + .then((r) => r.json()) + .then((data) => { + if (data.error) { + console.warn("Error fetching group docs:", data.error); + groupDocs = []; + return; + } + groupDocs = data.documents || []; + }) + .catch((err) => { + console.error("Error loading group docs:", err); + groupDocs = []; + }); + } else { + // If the user has no active group + activeGroupName = ""; + groupDocs = []; + } + }) + .catch((err) => { + console.error("Error loading groups:", err); + groupDocs = []; + }); +} + +// Helper to load both personal & group docs in sequence +function loadAllDocs() { + return loadPersonalDocs().then(() => loadGroupDocs()); +} + +// Toggle the active class for "Search Documents" document .getElementById("search-documents-btn") .addEventListener("click", function () { this.classList.toggle("active"); - const documentSelect = document.getElementById("document-select"); + const docScopeSel = document.getElementById("doc-scope-select"); + const docSelectEl = document.getElementById("document-select"); + if (this.classList.contains("active")) { - documentSelect.style.display = "block"; - loadDocuments(); + docScopeSel.style.display = "inline-block"; + docSelectEl.style.display = "inline-block"; + // Now that we know docs are loaded (via loadAllDocs in window.onload), + // we can populate: + populateDocumentSelectScope(); } else { - documentSelect.style.display = "none"; + docScopeSel.style.display = "none"; + docSelectEl.style.display = "none"; + docSelectEl.innerHTML = ""; } }); -// Toggle the active class on the button when clicked +// Toggle the active class for "Search Web" document .getElementById("search-web-btn") .addEventListener("click", function () { this.classList.toggle("active"); }); -// Toggle the active class on the image generation button +// Toggle the active class for "Image Generation" document .getElementById("image-generate-btn") ?.addEventListener("click", function () { // Toggle on/off this.classList.toggle("active"); - + // Check if Image Generation is active const isImageGenEnabled = this.classList.contains("active"); - // Grab the two existing search buttons + // Grab existing search buttons const docBtn = document.getElementById("search-documents-btn"); const webBtn = document.getElementById("search-web-btn"); const fileBtn = document.getElementById("choose-file-btn"); - const documentSelectionContainer = document.getElementById("document-selection-container"); + const documentSelectionContainer = document.getElementById( + "document-selection-container" + ); // If image generation is enabled, disable the search buttons if (isImageGenEnabled) { @@ -91,11 +215,12 @@ document } }); -// Function to select a conversation +// ===================== SELECTING A CONVERSATION ===================== function selectConversation(conversationId) { currentConversationId = conversationId; document.getElementById("user-input").disabled = false; document.getElementById("send-btn").disabled = false; + // Get the conversation title const convoItem = document.querySelector( `.conversation-item[data-conversation-id="${conversationId}"]` @@ -103,13 +228,15 @@ function selectConversation(conversationId) { const conversationTitle = convoItem ? convoItem.getAttribute("data-conversation-title") : "Conversation"; + document.getElementById("current-conversation-title").textContent = conversationTitle; + loadMessages(conversationId); highlightSelectedConversation(conversationId); } -// Function to highlight the selected conversation +// Highlight the selected conversation in the list function highlightSelectedConversation(conversationId) { const items = document.querySelectorAll(".conversation-item"); items.forEach((item) => { @@ -121,12 +248,11 @@ function highlightSelectedConversation(conversationId) { }); } -// Function to append a message to the chatbox +// ===================== APPEND MESSAGE (supports citations) ===================== function appendMessage(sender, messageContent) { const messageDiv = document.createElement("div"); messageDiv.classList.add("mb-2"); - // Declare variables at the top let avatarImg = ""; let messageClass = ""; let senderLabel = ""; @@ -138,79 +264,89 @@ function appendMessage(sender, messageContent) { } if (sender === "image") { - // Treat it as an AI-style message but replace the text with an tag + // An AI-style message but it's an tag messageClass = "ai-message"; senderLabel = "AI"; avatarImg = "/static/images/ai-avatar.png"; - - // Create an image at 25% size; set a data attribute so we can capture clicks and open a modal + + // Create an image at 25% size const imageHtml = ` Generated Image `; - messageContentHtml = imageHtml; } else if (sender === "You") { + // user's own message messageClass = "user-message"; senderLabel = "You"; avatarImg = "/static/images/user-avatar.png"; - // Optionally, parse and sanitize user messages if they contain Markdown + // If you want to parse user content as markdown, or not: const sanitizedContent = DOMPurify.sanitize(marked.parse(messageContent)); messageContentHtml = sanitizedContent; } else if (sender === "AI") { + // assistant message messageClass = "ai-message"; senderLabel = "AI"; avatarImg = "/static/images/ai-avatar.png"; - // Clean up message content - let cleanedMessage = messageContent.trim(); - cleanedMessage = cleanedMessage.replace(/\n{3,}/g, "\n\n"); - // Parse message to convert citations into links - const parsedMessage = parseCitations(cleanedMessage); - // Parse Markdown and sanitize the output - const htmlContent = DOMPurify.sanitize(marked.parse(parsedMessage)); + // Clean up + let cleaned = messageContent.trim().replace(/\n{3,}/g, "\n\n"); + + // 1) Parse citations => clickable links + const withCitations = parseCitations(cleaned); + + // 2) Convert to Markdown + sanitize + const htmlContent = DOMPurify.sanitize(marked.parse(withCitations)); messageContentHtml = htmlContent; } else if (sender === "File") { + // If it's a file message messageClass = "file-message"; senderLabel = "File Added"; - // messageContent is the message object + // messageContent is the object const filename = messageContent.filename; const fileId = messageContent.file_id; - messageContentHtml = `${filename}`; + messageContentHtml = ` + ${filename} + `; } // Build the message bubble messageDiv.classList.add("message", messageClass); messageDiv.innerHTML = ` -
- ${ - sender !== "File" - ? `${senderLabel}` - : "" - } -
-
${senderLabel}
-
${messageContentHtml}
-
+
+ ${ + sender !== "File" + ? `${senderLabel}` + : "" + } +
+
${senderLabel}
+
${messageContentHtml}
+
`; document.getElementById("chatbox").appendChild(messageDiv); - // Scroll to the bottom + + // Scroll to bottom document.getElementById("chatbox").scrollTop = document.getElementById("chatbox").scrollHeight; } -// Function to load messages for a conversation +// ===================== LOADING MESSAGES FOR CONVERSATION ===================== function loadMessages(conversationId) { fetch(`/conversation/${conversationId}/messages`) .then((response) => response.json()) @@ -234,33 +370,44 @@ function loadMessages(conversationId) { }); } -// Function to parse citations and convert them into clickable links +// ===================== CITATION PARSING ===================== function parseCitations(message) { - // Regular expression to match citations in the format: - // (Source: filename, Pages: page number) [#ID] - // (Source: filename, Pages: page number-page number) [#ID] [#ID] [#ID] - // (Source: filename, Pages: page number–page number) [#ID], [#ID], and [#ID] - const citationRegex = /\(Source: ([^,]+), Page(?:s)?: ([^)]+)\)([^]*)/g; - - // Replace citations with links - const parsedMessage = message.replace(citationRegex, (match, filename, pages, ids) => { - const pageRange = pages.trim(); - const idMatches = ids.match(/\[#([^\]]+)\]/g); - if (!idMatches) return match; - - const citationLinks = idMatches.map(idMatch => { - const citationId = idMatch.slice(2, -1); // Remove [# and ] - const pageNumber = citationId.split('_').pop(); // Extract the page number - return `[Page ${pageNumber}]`; - }).join(', '); - - return `(Source: ${filename}, Pages: ${pageRange}) ${citationLinks}`; - }); - - return parsedMessage; + /* + This regex will match patterns like: + (Source: FILENAME, Page(s): PAGE) [#doc_1] [#doc_2] ... + We'll capture: + 1) filename + 2) the pages + 3) bracket references + */ + const citationRegex = + /\(Source:\s*([^,]+),\s*Page(?:s)?:\s*([^)]+)\)\s*((?:\[#\S+?\]\s*)+)/g; + + return message.replace( + citationRegex, + (whole, filename, pages, bracketSection) => { + // bracketSection might contain multiple [#...] references + const idMatches = bracketSection.match(/\[#([^\]]+)\]/g); + if (!idMatches) return whole; + + // Build clickable links for each bracket + const citationLinks = idMatches + .map((m) => { + // remove "[#" and "]" + const rawId = m.slice(2, -1); + // guess the page number from the last chunk + const pageNumber = rawId.split("_").pop(); + // Build a link that triggers "fetchCitedText(rawId)" + return `[Page ${pageNumber}]`; + }) + .join(" "); + + return `(Source: ${filename}, Pages: ${pages}) ${citationLinks}`; + } + ); } -// Event delegation to handle clicks on conversation items and delete buttons +// ===================== DELETE A CONVERSATION ===================== document .getElementById("conversations-list") .addEventListener("click", (event) => { @@ -278,32 +425,28 @@ document } }); -// Function to delete a conversation function deleteConversation(conversationId) { if (confirm("Are you sure you want to delete this conversation?")) { fetch(`/api/conversations/${conversationId}`, { method: "DELETE", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, }) .then((response) => { if (response.ok) { - // Remove the conversation from the list + // remove from list const convoItem = document.querySelector( `.conversation-item[data-conversation-id="${conversationId}"]` ); if (convoItem) { convoItem.remove(); } - // If the deleted conversation was selected, clear the chatbox + // If it was the selected conversation, clear UI if (currentConversationId === conversationId) { currentConversationId = null; document.getElementById("user-input").disabled = true; document.getElementById("send-btn").disabled = true; - document.getElementById( - "current-conversation-title" - ).textContent = "Select a conversation"; + document.getElementById("current-conversation-title").textContent = + "Select a conversation"; document.getElementById("chatbox").innerHTML = ""; } } else { @@ -317,9 +460,9 @@ function deleteConversation(conversationId) { } } -// Function to fetch cited text from the backend +// ===================== CITED TEXT FUNCTIONS ===================== function fetchCitedText(citationId) { - // Show loading indicator + // Show loading showLoadingIndicator(); fetch("/api/get_citation", { @@ -331,12 +474,8 @@ function fetchCitedText(citationId) { .then((data) => { hideLoadingIndicator(); - if ( - data.cited_text && - data.file_name && - data.page_number !== undefined - ) { - // Display the cited text in a popup or sidebar with dynamic title + if (data.cited_text && data.file_name && data.page_number !== undefined) { + // show in modal showCitedTextPopup(data.cited_text, data.file_name, data.page_number); } else if (data.error) { alert(data.error); @@ -351,9 +490,7 @@ function fetchCitedText(citationId) { }); } -// Function to display cited text in a Bootstrap modal with dynamic title function showCitedTextPopup(citedText, fileName, pageNumber) { - // Create the modal container if it doesn't exist let modalContainer = document.getElementById("citation-modal"); if (!modalContainer) { modalContainer = document.createElement("div"); @@ -363,46 +500,44 @@ function showCitedTextPopup(citedText, fileName, pageNumber) { modalContainer.setAttribute("aria-hidden", "true"); modalContainer.innerHTML = ` - - `; + + `; document.body.appendChild(modalContainer); } else { - // Update the modal title if it already exists + // update existing const modalTitle = modalContainer.querySelector(".modal-title"); modalTitle.textContent = `Source: ${fileName}, Page: ${pageNumber}`; } - // Set the cited text content const citedTextContent = document.getElementById("cited-text-content"); citedTextContent.textContent = citedText; - // Show the modal using Bootstrap's modal plugin + // show modal const modal = new bootstrap.Modal(modalContainer); modal.show(); } -// Function to show loading indicator +// ===================== LOADING / HIDING INDICATORS ===================== function showLoadingIndicator() { - // Create a loading spinner if it doesn't exist let loadingSpinner = document.getElementById("loading-spinner"); if (!loadingSpinner) { loadingSpinner = document.createElement("div"); loadingSpinner.id = "loading-spinner"; loadingSpinner.innerHTML = ` -
- Loading... -
- `; +
+ Loading... +
+ `; loadingSpinner.style.position = "fixed"; loadingSpinner.style.top = "50%"; loadingSpinner.style.left = "50%"; @@ -413,91 +548,112 @@ function showLoadingIndicator() { loadingSpinner.style.display = "block"; } } - -// Function to hide loading indicator function hideLoadingIndicator() { const loadingSpinner = document.getElementById("loading-spinner"); if (loadingSpinner) { loadingSpinner.style.display = "none"; } } +function showLoadingIndicatorInChatbox() { + const chatbox = document.getElementById("chatbox"); + const loadingIndicator = document.createElement("div"); + loadingIndicator.classList.add("loading-indicator"); + loadingIndicator.id = "loading-indicator"; + + loadingIndicator.innerHTML = ` +
+ AI is typing... +
+ AI is typing... + `; + chatbox.appendChild(loadingIndicator); + chatbox.scrollTop = chatbox.scrollHeight; +} +function hideLoadingIndicatorInChatbox() { + const loadingIndicator = document.getElementById("loading-indicator"); + if (loadingIndicator) { + loadingIndicator.remove(); + } +} -// Function to send a message (user input) +// ===================== SENDING A MESSAGE ===================== function sendMessage() { - const userInput = document.getElementById("user-input").value.trim(); - if (userInput === "" || !currentConversationId) return; + const userInput = document.getElementById("user-input"); + const textVal = userInput.value.trim(); + if (textVal === "" || !currentConversationId) return; - appendMessage("You", userInput); - document.getElementById("user-input").value = ""; + appendMessage("You", textVal); + userInput.value = ""; - // Show the loading indicator + // Show spinner in chatbox showLoadingIndicatorInChatbox(); - // Get the state of the search documents button + // Hybrid search? const hybridSearchEnabled = document .getElementById("search-documents-btn") - ?.classList.contains("active") || false; - - // Get the selected document ID if hybrid search is enabled - const selectedDocumentId = (hybridSearchEnabled && (document.getElementById("document-select").value != "None")) ? document.getElementById("document-select").value : null; + .classList.contains("active"); + + // If doc is selected + let selectedDocumentId = null; + if (hybridSearchEnabled) { + const docSel = document.getElementById("document-select"); + if (docSel.value !== "" && docSel.value !== "None") { + selectedDocumentId = docSel.value; + } + } - // Get the state of the search web button + // Bing search? const bingSearchEnabled = document .getElementById("search-web-btn") - ?.classList.contains("active") || false; + .classList.contains("active"); - // Get the state of the image generation button + // Image gen? const imageGenEnabled = document .getElementById("image-generate-btn") - ?.classList.contains("active") || false; + ?.classList.contains("active"); + // Post to /api/chat fetch("/api/chat", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - message: userInput, + message: textVal, conversation_id: currentConversationId, hybrid_search: hybridSearchEnabled, - selected_document_id: selectedDocumentId, // Include selected document ID + selected_document_id: selectedDocumentId, bing_search: bingSearchEnabled, - image_generation: imageGenEnabled + image_generation: imageGenEnabled, }), }) .then((response) => response.json()) .then((data) => { - // Hide the loading indicator hideLoadingIndicatorInChatbox(); - if (data.reply) { appendMessage("AI", data.reply); } if (data.conversation_id) { - currentConversationId = data.conversation_id; // Update conversation ID if needed + currentConversationId = data.conversation_id; } if (data.conversation_title) { - // Update the conversation title in the UI document.getElementById("current-conversation-title").textContent = data.conversation_title; - // Update the conversation item in the list + // update the item in list const convoItem = document.querySelector( `.conversation-item[data-conversation-id="${currentConversationId}"]` ); if (convoItem) { - const date = new Date(); + const d = new Date(); convoItem.innerHTML = ` -
-
- ${data.conversation_title}
- ${date.toLocaleString()} -
- -
- `; - // Update the data-conversation-title attribute +
+
+ ${data.conversation_title}
+ ${d.toLocaleString()} +
+ +
+ `; convoItem.setAttribute( "data-conversation-title", data.conversation_title @@ -507,17 +663,28 @@ function sendMessage() { }) .catch((error) => { console.error("Error:", error); - // Hide the loading indicator even if there's an error hideLoadingIndicatorInChatbox(); appendMessage("Error", "Could not get a response."); }); } -// Function to load documents for the dropdown +// --------------------- User Input Event Listeners --------------------- + +document.getElementById("send-btn").addEventListener("click", sendMessage); + +document + .getElementById("user-input") + .addEventListener("keypress", function (e) { + if (e.key === "Enter") { + sendMessage(); + } + }); + +// ===================== LOADING DOCUMENTS FOR DROPDOWN ===================== function loadDocuments() { fetch("/api/documents") - .then(response => response.json()) - .then(data => { + .then((response) => response.json()) + .then((data) => { const documentSelect = document.getElementById("document-select"); documentSelect.innerHTML = ""; @@ -527,55 +694,45 @@ function loadDocuments() { defaultOption.textContent = "None"; documentSelect.appendChild(defaultOption); - data.documents.forEach(doc => { + data.documents.forEach((doc) => { const option = document.createElement("option"); option.value = doc.id; - option.textContent = doc.file_name; + option.textContent = doc.file_name; documentSelect.appendChild(option); }); - // Check URL parameters to pre-select document and enable search - const searchDocuments = getUrlParameter('search_documents') === 'true'; - const documentId = getUrlParameter('document_id'); + // Check URL params + const searchDocuments = getUrlParameter("search_documents") === "true"; + const documentId = getUrlParameter("document_id"); if (searchDocuments && documentId) { document.getElementById("search-documents-btn").classList.add("active"); documentSelect.style.display = "block"; documentSelect.value = documentId; } }) - .catch(error => { + .catch((error) => { console.error("Error loading documents:", error); }); } -// Function to get URL parameters +// Get a URL parameter function getUrlParameter(name) { - name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]'); - const regex = new RegExp('[\\?&]' + name + '=([^&#]*)'); - const results = regex.exec(location.search); - return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); + name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]"); + const regex = new RegExp("[\\?&]" + name + "=([^&#]*)"); + const results = regex.exec(location.search); + return results === null + ? "" + : decodeURIComponent(results[1].replace(/\+/g, " ")); } -// Event listener for send button -document.getElementById("send-btn").addEventListener("click", sendMessage); - -// Event listener for Enter key -document - .getElementById("user-input") - .addEventListener("keypress", function (e) { - if (e.key === "Enter") { - sendMessage(); - } - }); - -// Event listener for New Conversation button +// ===================== CREATE A NEW CONVERSATION ===================== document .getElementById("new-conversation-btn") .addEventListener("click", () => { fetch("/api/create_conversation", { method: "POST", headers: { "Content-Type": "application/json" }, - credentials: "same-origin", // Include cookies for same-origin requests + credentials: "same-origin", }) .then((response) => { if (!response.ok) { @@ -587,9 +744,10 @@ document }) .then((data) => { if (data.conversation_id) { - // Automatically select the new conversation + // automatically select selectConversation(data.conversation_id); - // Optionally, add it to the top of the conversations list + + // Add it to top of conversation list const conversationsList = document.getElementById("conversations-list"); const convoItem = document.createElement("div"); @@ -598,32 +756,29 @@ document "conversation-item", "active" ); - convoItem.setAttribute( - "data-conversation-id", - data.conversation_id - ); + convoItem.setAttribute("data-conversation-id", data.conversation_id); + const date = new Date(); convoItem.innerHTML = ` -
-
- ${data.conversation_id}
- ${date.toLocaleString()} -
- +
+
+ ${data.conversation_id}
+ ${date.toLocaleString()}
+ +
`; - // Prepend the new conversation conversationsList.prepend(convoItem); - // Disable active state for others + + // remove 'active' from others const items = document.querySelectorAll(".conversation-item"); items.forEach((item) => { if ( - item.getAttribute("data-conversation-id") !== - data.conversation_id + item.getAttribute("data-conversation-id") !== data.conversation_id ) { item.classList.remove("active"); } @@ -633,12 +788,12 @@ document } }) .catch((error) => { - console.error("Error creating new conversation:", error); + console.error("Error creating conversation:", error); alert(`Failed to create a new conversation: ${error.message}`); }); }); -// Event listener for 'choose-file-btn' click +// ===================== FILE UPLOAD LOGIC ===================== document .getElementById("choose-file-btn") .addEventListener("click", function () { @@ -646,38 +801,29 @@ document document.getElementById("file-input").click(); }); -// Event listener for 'file-input' change document.getElementById("file-input").addEventListener("change", function () { const fileInput = this; const file = fileInput.files[0]; if (file) { - // Get the file name const fileName = file.name; - // Update the button to display the file name const fileBtn = document.getElementById("choose-file-btn"); fileBtn.classList.add("active"); fileBtn.querySelector(".file-btn-text").textContent = fileName; // Show the upload button document.getElementById("upload-btn").style.display = "block"; } else { - // No file selected, reset the button resetFileButton(); } }); -// Function to reset the file button function resetFileButton() { - // Clear the file input document.getElementById("file-input").value = ""; - // Reset the button const fileBtn = document.getElementById("choose-file-btn"); fileBtn.classList.remove("active"); fileBtn.querySelector(".file-btn-text").textContent = ""; - // Hide the upload button document.getElementById("upload-btn").style.display = "none"; } -// Modify the upload button event listener document.getElementById("upload-btn").addEventListener("click", () => { const fileInput = document.getElementById("file-input"); const file = fileInput.files[0]; @@ -699,11 +845,9 @@ document.getElementById("upload-btn").addEventListener("click", () => { body: formData, }) .then((response) => { - // Clone the response to read the JSON body let clonedResponse = response.clone(); return response.json().then((data) => { if (!response.ok) { - // Handle HTTP errors console.error("Upload failed:", data.error || "Unknown error"); alert("Error uploading file: " + (data.error || "Unknown error")); throw new Error(data.error || "Upload failed"); @@ -714,24 +858,22 @@ document.getElementById("upload-btn").addEventListener("click", () => { .then((data) => { console.log("Upload response data:", data); if (data.conversation_id) { - currentConversationId = data.conversation_id; // Update conversation ID - loadMessages(currentConversationId); // Fetch and display updated conversation + currentConversationId = data.conversation_id; + loadMessages(currentConversationId); } else { console.error("No conversation_id returned from server."); alert("Error: No conversation ID returned from server."); } - // Reset the file input and button resetFileButton(); }) .catch((error) => { console.error("Error:", error); alert("Error uploading file: " + error.message); - // Reset the file input and button resetFileButton(); }); }); -// Event delegation to handle clicks on citation links and file links +// ===================== CITATION LINKS & FILE LINKS ===================== document.getElementById("chatbox").addEventListener("click", (event) => { if (event.target && event.target.matches("a.citation-link")) { event.preventDefault(); @@ -745,7 +887,7 @@ document.getElementById("chatbox").addEventListener("click", (event) => { } }); -// Listen for clicks on any images with the 'generated-image' class +// If user clicks on the generated image document.getElementById("chatbox").addEventListener("click", (event) => { if (event.target.classList.contains("generated-image")) { const imageSrc = event.target.getAttribute("data-image-src"); @@ -755,8 +897,6 @@ document.getElementById("chatbox").addEventListener("click", (event) => { function showImagePopup(imageSrc) { let modalContainer = document.getElementById("image-modal"); - - // If the modal doesn't exist yet, create it if (!modalContainer) { modalContainer = document.createElement("div"); modalContainer.id = "image-modal"; @@ -775,18 +915,14 @@ function showImagePopup(imageSrc) { `; document.body.appendChild(modalContainer); } - - // Update the src for the image inside the modal const modalImage = modalContainer.querySelector("#image-modal-img"); modalImage.src = imageSrc; - - // Show the modal using Bootstrap const modal = new bootstrap.Modal(modalContainer); modal.show(); } function fetchFileContent(conversationId, fileId) { - // Show loading indicator + // Show loading showLoadingIndicator(); fetch("/api/get_file_content", { @@ -802,7 +938,6 @@ function fetchFileContent(conversationId, fileId) { hideLoadingIndicator(); if (data.file_content && data.filename) { - // Display the file content in a popup or sidebar with dynamic title showFileContentPopup(data.file_content, data.filename, data.is_table); } else if (data.error) { alert(data.error); @@ -818,7 +953,6 @@ function fetchFileContent(conversationId, fileId) { } function showFileContentPopup(fileContent, filename, isTable) { - // Create the modal container if it doesn't exist let modalContainer = document.getElementById("file-modal"); if (!modalContainer) { modalContainer = document.createElement("div"); @@ -828,76 +962,43 @@ function showFileContentPopup(fileContent, filename, isTable) { modalContainer.setAttribute("aria-hidden", "true"); modalContainer.innerHTML = ` - - +
+ + - +

Group Documents

+ + + + + +
+ +
+ +
+
+ +
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
File NameUpload DateVersionChunk CountActions
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/application/single_app/templates/manage_group.html b/application/single_app/templates/manage_group.html new file mode 100644 index 0000000..fcc7d7e --- /dev/null +++ b/application/single_app/templates/manage_group.html @@ -0,0 +1,616 @@ +{% extends "base.html" %} {% block title %} Manage Group - {{ +app_settings.app_title }} {% endblock %} {% block content %} +
+

Manage Group

+
+ +
+ + + + +
+ +
+
Membership
+ + +
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + + + +
NameRoleActions
+ + + +
+ + +
+ + + + + + +{% endblock %} {% block scripts %} {{ super() }} + +{% endblock %} diff --git a/application/single_app/templates/my_groups.html b/application/single_app/templates/my_groups.html new file mode 100644 index 0000000..0b3cb50 --- /dev/null +++ b/application/single_app/templates/my_groups.html @@ -0,0 +1,374 @@ +{% extends "base.html" %} +{% block title %}My Groups - {{ app_settings.app_title }}{% endblock %} + +{% block content %} +
+

My Groups

+ + + + + + + +
+ +
+ + +
+
+ + +
+ +

Loading groups...

+
+
+ + + + + + + +{% endblock %} + +{% block scripts %} +{{ super() }} + +{% endblock %} diff --git a/artifacts/group-index.json b/artifacts/group-index.json new file mode 100644 index 0000000..749b987 --- /dev/null +++ b/artifacts/group-index.json @@ -0,0 +1,254 @@ +{ + "name": "simplechat-group-index", + "defaultScoringProfile": null, + "fields": [ + { + "name": "id", + "type": "Edm.String", + "searchable": true, + "filterable": true, + "retrievable": true, + "stored": true, + "sortable": true, + "facetable": false, + "key": true, + "indexAnalyzer": null, + "searchAnalyzer": null, + "analyzer": null, + "normalizer": null, + "dimensions": null, + "vectorSearchProfile": null, + "vectorEncoding": null, + "synonymMaps": [] + }, + { + "name": "document_id", + "type": "Edm.String", + "searchable": true, + "filterable": true, + "retrievable": true, + "stored": true, + "sortable": true, + "facetable": false, + "key": false, + "indexAnalyzer": null, + "searchAnalyzer": null, + "analyzer": "standard.lucene", + "normalizer": null, + "dimensions": null, + "vectorSearchProfile": null, + "vectorEncoding": null, + "synonymMaps": [] + }, + { + "name": "chunk_id", + "type": "Edm.String", + "searchable": true, + "filterable": true, + "retrievable": true, + "stored": true, + "sortable": true, + "facetable": false, + "key": false, + "indexAnalyzer": null, + "searchAnalyzer": null, + "analyzer": "standard.lucene", + "normalizer": null, + "dimensions": null, + "vectorSearchProfile": null, + "vectorEncoding": null, + "synonymMaps": [] + }, + { + "name": "chunk_text", + "type": "Edm.String", + "searchable": true, + "filterable": false, + "retrievable": true, + "stored": true, + "sortable": false, + "facetable": false, + "key": false, + "indexAnalyzer": null, + "searchAnalyzer": null, + "analyzer": "standard.lucene", + "normalizer": null, + "dimensions": null, + "vectorSearchProfile": null, + "vectorEncoding": null, + "synonymMaps": [] + }, + { + "name": "embedding", + "type": "Collection(Edm.Single)", + "searchable": true, + "filterable": false, + "retrievable": true, + "stored": true, + "sortable": false, + "facetable": false, + "key": false, + "indexAnalyzer": null, + "searchAnalyzer": null, + "analyzer": null, + "normalizer": null, + "dimensions": 1536, + "vectorSearchProfile": "vector-profile-1728235379870", + "vectorEncoding": null, + "synonymMaps": [] + }, + { + "name": "file_name", + "type": "Edm.String", + "searchable": true, + "filterable": true, + "retrievable": true, + "stored": true, + "sortable": true, + "facetable": false, + "key": false, + "indexAnalyzer": null, + "searchAnalyzer": null, + "analyzer": "standard.lucene", + "normalizer": null, + "dimensions": null, + "vectorSearchProfile": null, + "vectorEncoding": null, + "synonymMaps": [] + }, + { + "name": "group_id", + "type": "Edm.String", + "searchable": true, + "filterable": true, + "retrievable": true, + "stored": true, + "sortable": true, + "facetable": false, + "key": false, + "indexAnalyzer": null, + "searchAnalyzer": null, + "analyzer": "standard.lucene", + "normalizer": null, + "dimensions": null, + "vectorSearchProfile": null, + "vectorEncoding": null, + "synonymMaps": [] + }, + { + "name": "chunk_sequence", + "type": "Edm.Int32", + "searchable": false, + "filterable": true, + "retrievable": true, + "stored": true, + "sortable": true, + "facetable": false, + "key": false, + "indexAnalyzer": null, + "searchAnalyzer": null, + "analyzer": null, + "normalizer": null, + "dimensions": null, + "vectorSearchProfile": null, + "vectorEncoding": null, + "synonymMaps": [] + }, + { + "name": "upload_date", + "type": "Edm.DateTimeOffset", + "searchable": false, + "filterable": true, + "retrievable": true, + "stored": true, + "sortable": true, + "facetable": false, + "key": false, + "indexAnalyzer": null, + "searchAnalyzer": null, + "analyzer": null, + "normalizer": null, + "dimensions": null, + "vectorSearchProfile": null, + "vectorEncoding": null, + "synonymMaps": [] + }, + { + "name": "version", + "type": "Edm.Int32", + "searchable": false, + "filterable": true, + "retrievable": true, + "stored": true, + "sortable": true, + "facetable": false, + "key": false, + "indexAnalyzer": null, + "searchAnalyzer": null, + "analyzer": null, + "normalizer": null, + "dimensions": null, + "vectorSearchProfile": null, + "vectorEncoding": null, + "synonymMaps": [] + } + ], + "scoringProfiles": [], + "corsOptions": null, + "suggesters": [], + "analyzers": [], + "normalizers": [], + "tokenizers": [], + "tokenFilters": [], + "charFilters": [], + "encryptionKey": null, + "similarity": { + "@odata.type": "#Microsoft.Azure.Search.BM25Similarity", + "k1": null, + "b": null + }, + "semantic": { + "defaultConfiguration": null, + "configurations": [ + { + "name": "nexus-group-index-semantic-configuration", + "prioritizedFields": { + "titleField": { + "fieldName": "file_name" + }, + "prioritizedContentFields": [ + { + "fieldName": "chunk_text" + } + ], + "prioritizedKeywordsFields": [] + } + } + ] + }, + "vectorSearch": { + "algorithms": [ + { + "name": "vector-config-1728235384685", + "kind": "hnsw", + "hnswParameters": { + "metric": "cosine", + "m": 4, + "efConstruction": 400, + "efSearch": 500 + }, + "exhaustiveKnnParameters": null + } + ], + "profiles": [ + { + "name": "vector-profile-1728235379870", + "algorithm": "vector-config-1728235384685", + "vectorizer": null, + "compression": null + } + ], + "vectorizers": [], + "compressions": [] + } +} \ No newline at end of file