From 640d6f5dd8af1650bed9f4910a06472e08a06922 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 16 Apr 2026 05:53:53 -0400 Subject: [PATCH 1/7] search api and search plugin --- application/single_app/app.py | 4 + application/single_app/config.py | 2 +- application/single_app/functions_documents.py | 242 +++--- application/single_app/functions_search.py | 124 ++- .../single_app/functions_search_service.py | 741 ++++++++++++++++++ application/single_app/route_backend_chats.py | 24 +- .../single_app/route_backend_search.py | 136 ++++ .../single_app/semantic_kernel_loader.py | 20 + .../document_search_plugin.py | 167 ++++ docs/explanation/features/index.md | 6 +- .../CORE_DOCUMENT_SEARCH_AND_SUMMARIZATION.md | 123 +++ docs/explanation/release_notes.md | 10 + .../test_document_search_api_and_plugin.py | 231 ++++++ 13 files changed, 1696 insertions(+), 134 deletions(-) create mode 100644 application/single_app/functions_search_service.py create mode 100644 application/single_app/route_backend_search.py create mode 100644 application/single_app/semantic_kernel_plugins/document_search_plugin.py create mode 100644 docs/explanation/features/v0.241.007/CORE_DOCUMENT_SEARCH_AND_SUMMARIZATION.md create mode 100644 functional_tests/test_document_search_api_and_plugin.py diff --git a/application/single_app/app.py b/application/single_app/app.py index fdbaee55d..0ab62afba 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -54,6 +54,7 @@ from route_frontend_notifications import * from route_backend_chats import * +from route_backend_search import * from route_backend_conversations import * from route_backend_documents import * from route_backend_groups import * @@ -873,6 +874,9 @@ def list_semantic_kernel_plugins(): # ------------------- API Chat Routes -------------------- register_route_backend_chats(app) +# ------------------- API Search Routes ------------------ +register_route_backend_search(app) + # ------------------- API Conversation Routes ------------ register_route_backend_conversations(app) diff --git a/application/single_app/config.py b/application/single_app/config.py index 7196cfe80..3ccb6ca98 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.241.006" +VERSION = "0.241.007" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index 7c6e4a272..85be6ffcc 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -2573,49 +2573,13 @@ def get_document_metadata_for_citations(document_id, user_id=None, group_id=None return None def get_all_chunks(document_id, user_id, group_id=None, public_workspace_id=None): - is_group = group_id is not None - is_public_workspace = public_workspace_id is not None - - # For personal documents, first check if user has access (owner or shared) - if not is_group and not is_public_workspace: - # Check if user has access to this document - if not is_document_shared_with_user(document_id, user_id): - print(f"User {user_id} does not have access to document {document_id}") - return [] - elif is_group: - # For group documents, check if group has access (owner or shared) - if not is_document_shared_with_group(document_id, group_id): - print(f"Group {group_id} does not have access to document {document_id}") - return [] - - search_client = CLIENTS["search_client_public"] if is_public_workspace else CLIENTS["search_client_group"] if is_group else CLIENTS["search_client_user"] - filter_expr = ( - f"document_id eq '{document_id}' and public_workspace_id eq '{public_workspace_id}'" - if is_public_workspace else - f"document_id eq '{document_id}' and (group_id eq '{group_id}' or shared_group_ids/any(g: g eq '{group_id}'))" - if is_group else - f"document_id eq '{document_id}'" # For personal documents, just filter by document_id since access is already verified - ) - - select_fields = [ - "id", - "chunk_text", - "chunk_id", - "file_name", - "public_workspace_id" if is_public_workspace else ("group_id" if is_group else "user_id"), - "version", - "chunk_sequence", - "upload_date" - ] - try: - results = search_client.search( - search_text="*", - filter=filter_expr, - select=",".join(select_fields) + return get_ordered_document_chunks( + document_id=document_id, + user_id=user_id, + group_id=group_id, + public_workspace_id=public_workspace_id, ) - return results - except Exception as e: print(f"Error retrieving chunks for document {document_id}: {e}") raise @@ -2723,6 +2687,134 @@ def chunk_pdf(input_pdf_path: str, max_pages: int = 500) -> list: return chunks + +def get_document_record(user_id, document_id, group_id=None, public_workspace_id=None): + """Return a document record when the caller has access to it, otherwise None.""" + is_group = group_id is not None + is_public_workspace = public_workspace_id is not None + + cosmos_container = _get_documents_container( + group_id=group_id, + public_workspace_id=public_workspace_id, + ) + + try: + document_item = cosmos_container.read_item( + item=document_id, + partition_key=document_id, + ) + except CosmosResourceNotFoundError: + return None + except Exception as e: + print(f"Error retrieving document record {document_id}: {e}") + return None + + if is_public_workspace: + if document_item.get('public_workspace_id') != public_workspace_id: + return None + return _normalize_document_enhanced_citations(document_item) + + if is_group: + shared_group_ids = document_item.get('shared_group_ids', []) + if ( + document_item.get('group_id') != group_id + and not any(str(entry).startswith(f"{group_id},") for entry in shared_group_ids) + ): + return None + return _normalize_document_enhanced_citations(document_item) + + shared_user_ids = document_item.get('shared_user_ids', []) + if ( + document_item.get('user_id') != user_id + and not any(str(entry).startswith(f"{user_id},") for entry in shared_user_ids) + ): + return None + + return _normalize_document_enhanced_citations(document_item) + + +def get_ordered_document_chunks(document_id, user_id, group_id=None, public_workspace_id=None, max_chunks=None): + """Return ordered chunk records for a document after access has been verified.""" + document_item = get_document_record( + user_id=user_id, + document_id=document_id, + group_id=group_id, + public_workspace_id=public_workspace_id, + ) + + if not document_item: + return [] + + search_client = _get_search_client( + group_id=group_id, + public_workspace_id=public_workspace_id, + ) + select_fields = [ + 'id', + 'document_id', + 'chunk_text', + 'chunk_id', + 'file_name', + 'user_id', + 'group_id', + 'public_workspace_id', + 'version', + 'chunk_sequence', + 'page_number', + 'upload_date', + 'document_classification', + 'document_tags', + 'author', + 'chunk_keywords', + 'title', + 'chunk_summary', + ] + search_kwargs = { + 'search_text': '*', + 'filter': f"document_id eq '{document_id}'", + 'select': ','.join(select_fields), + } + if max_chunks is not None: + search_kwargs['top'] = max(1, int(max_chunks)) + + try: + results = list(search_client.search(**search_kwargs)) + except Exception as e: + print(f"Error retrieving chunks for document {document_id}: {e}") + raise + + ordered_chunks = [] + for result in results: + ordered_chunks.append({ + 'id': result.get('id'), + 'document_id': result.get('document_id'), + 'chunk_text': result.get('chunk_text', ''), + 'chunk_id': result.get('chunk_id'), + 'file_name': result.get('file_name'), + 'user_id': result.get('user_id'), + 'group_id': result.get('group_id'), + 'public_workspace_id': result.get('public_workspace_id'), + 'version': result.get('version'), + 'chunk_sequence': result.get('chunk_sequence', 0), + 'page_number': result.get('page_number'), + 'upload_date': result.get('upload_date'), + 'document_classification': result.get('document_classification'), + 'document_tags': result.get('document_tags', []), + 'author': result.get('author'), + 'chunk_keywords': result.get('chunk_keywords'), + 'title': result.get('title'), + 'chunk_summary': result.get('chunk_summary'), + }) + + ordered_chunks.sort( + key=lambda chunk: ( + _safe_int(chunk.get('page_number')) if chunk.get('page_number') is not None else 10**9, + _safe_int(chunk.get('chunk_sequence')), + str(chunk.get('id') or ''), + ) + ) + return ordered_chunks + def get_documents(user_id, group_id=None, public_workspace_id=None): try: documents = _query_accessible_documents( @@ -2736,72 +2828,18 @@ def get_documents(user_id, group_id=None, public_workspace_id=None): return jsonify({'error': f'Error retrieving documents: {str(e)}'}), 500 def get_document(user_id, document_id, group_id=None, public_workspace_id=None): - is_group = group_id is not None - is_public_workspace = public_workspace_id is not None - - # Choose the correct cosmos_container and query parameters - if is_public_workspace: - cosmos_container = cosmos_public_documents_container - elif is_group: - cosmos_container = cosmos_group_documents_container - else: - cosmos_container = cosmos_user_documents_container - - if is_public_workspace: - query = """ - SELECT TOP 1 * - FROM c - WHERE c.id = @document_id - AND c.public_workspace_id = @public_workspace_id - ORDER BY c.version DESC - """ - parameters = [ - {"name": "@document_id", "value": document_id}, - {"name": "@public_workspace_id", "value": public_workspace_id} - ] - elif is_group: - query = """ - SELECT TOP 1 * - FROM c - WHERE c.id = @document_id - AND (c.group_id = @group_id OR ARRAY_CONTAINS(c.shared_group_ids, @group_id)) - ORDER BY c.version DESC - """ - parameters = [ - {"name": "@document_id", "value": document_id}, - {"name": "@group_id", "value": group_id} - ] - else: - query = """ - SELECT TOP 1 * - FROM c - WHERE c.id = @document_id - AND ( - c.user_id = @user_id - OR ARRAY_CONTAINS(c.shared_user_ids, @user_id) - OR EXISTS(SELECT VALUE s FROM s IN c.shared_user_ids WHERE STARTSWITH(s, @user_id_prefix)) - ) - ORDER BY c.version DESC - """ - parameters = [ - {"name": "@document_id", "value": document_id}, - {"name": "@user_id", "value": user_id}, - {"name": "@user_id_prefix", "value": f"{user_id},"} - ] - try: - document_results = list( - cosmos_container.query_items( - query=query, - parameters=parameters, - enable_cross_partition_query=True - ) + document_record = get_document_record( + user_id=user_id, + document_id=document_id, + group_id=group_id, + public_workspace_id=public_workspace_id, ) - if not document_results: + if not document_record: return jsonify({'error': 'Document not found or access denied'}), 404 - return jsonify(_normalize_document_enhanced_citations(document_results[0])), 200 + return jsonify(document_record), 200 except Exception as e: return jsonify({'error': f'Error retrieving document: {str(e)}'}), 500 diff --git a/application/single_app/functions_search.py b/application/single_app/functions_search.py index 6851778f0..e7d117a64 100644 --- a/application/single_app/functions_search.py +++ b/application/single_app/functions_search.py @@ -16,6 +16,97 @@ logger = logging.getLogger(__name__) +SEARCH_DEFAULT_TOP_N = 12 +SEARCH_MAX_TOP_N = 500 +VALID_SEARCH_SCOPES = {"all", "personal", "group", "public"} +BASE_SEARCH_SELECT_FIELDS = [ + "id", + "document_id", + "chunk_text", + "chunk_id", + "file_name", + "version", + "chunk_sequence", + "upload_date", + "document_classification", + "document_tags", + "page_number", + "author", + "chunk_keywords", + "title", + "chunk_summary", +] +SEARCH_SELECT_FIELDS_BY_SCOPE = { + "personal": BASE_SEARCH_SELECT_FIELDS + ["user_id"], + "group": BASE_SEARCH_SELECT_FIELDS + ["group_id"], + "public": BASE_SEARCH_SELECT_FIELDS + ["public_workspace_id"], +} + + +def normalize_search_top_n(top_n, default_top_n=SEARCH_DEFAULT_TOP_N, max_top_n=SEARCH_MAX_TOP_N): + """Return a bounded integer top-N value for search-style operations.""" + try: + normalized_top_n = int(top_n) + except (TypeError, ValueError): + return default_top_n + + if normalized_top_n < 1: + return default_top_n + + return min(normalized_top_n, max_top_n) + + +def normalize_search_scope(doc_scope, default_scope="all"): + """Normalize search scope values to the supported set.""" + normalized_scope = str(doc_scope or default_scope).strip().lower() + if normalized_scope not in VALID_SEARCH_SCOPES: + return default_scope + return normalized_scope + + +def normalize_search_id_list(raw_ids): + """Normalize an optional list or comma-separated string of ids.""" + if raw_ids is None: + return [] + + if isinstance(raw_ids, str): + candidate_ids = [value.strip() for value in raw_ids.split(",") if value.strip()] + elif isinstance(raw_ids, list): + candidate_ids = [str(value).strip() for value in raw_ids if str(value).strip()] + else: + candidate_ids = [str(raw_ids).strip()] if str(raw_ids).strip() else [] + + normalized_ids = [] + seen_ids = set() + for candidate_id in candidate_ids: + if candidate_id in seen_ids: + continue + seen_ids.add(candidate_id) + normalized_ids.append(candidate_id) + + return normalized_ids + + +def get_search_select_fields(scope_name): + return SEARCH_SELECT_FIELDS_BY_SCOPE.get(scope_name, SEARCH_SELECT_FIELDS_BY_SCOPE["personal"]) + + +def get_search_result_scope(result_item): + if result_item.get("public_workspace_id"): + return "public" + if result_item.get("group_id"): + return "group" + return "personal" + + +def get_search_result_scope_id(result_item): + return ( + result_item.get("public_workspace_id") + or result_item.get("group_id") + or result_item.get("user_id") + ) + + def normalize_scores(results: List[Dict[str, Any]], index_name: str = "unknown") -> List[Dict[str, Any]]: """ Normalize search scores to [0, 1] range using min-max normalization. @@ -109,6 +200,10 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, across identical queries against the same document set. """ + top_n = normalize_search_top_n(top_n) + doc_scope = normalize_search_scope(doc_scope) + document_ids = normalize_search_id_list(document_ids) + # Backwards compat: wrap single group ID into list if not active_group_ids and active_group_id: active_group_ids = [active_group_id] @@ -253,7 +348,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-user-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("personal") ) # Only search group index if active_group_ids is provided @@ -271,7 +366,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-group-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("group") ) else: group_results = [] @@ -298,7 +393,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-public-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("public") ) else: # Build user filter with optional tags @@ -317,7 +412,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-user-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("personal") ) # Only search group index if active_group_ids is provided @@ -335,7 +430,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-group-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("group") ) else: group_results = [] @@ -362,7 +457,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-public-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("public") ) # Extract results from each index @@ -412,7 +507,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-user-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("personal") ) results = extract_search_results(user_results, top_n) else: @@ -431,7 +526,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-user-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("personal") ) results = extract_search_results(user_results, top_n) @@ -452,7 +547,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-group-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("group") ) results = extract_search_results(group_results, top_n) else: @@ -469,7 +564,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-group-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("group") ) results = extract_search_results(group_results, top_n) @@ -497,7 +592,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-public-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("public") ) results = extract_search_results(public_results, top_n) else: @@ -523,7 +618,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-public-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("public") ) results = extract_search_results(public_results, top_n) @@ -613,13 +708,18 @@ def extract_search_results(paged_results, top_n): for i, r in enumerate(paged_results): if i >= top_n: break + result_scope = get_search_result_scope(r) extracted.append({ "id": r["id"], + "document_id": r.get("document_id"), "chunk_text": r["chunk_text"], "chunk_id": r["chunk_id"], "file_name": r["file_name"], + "user_id": r.get("user_id"), "group_id": r.get("group_id"), "public_workspace_id": r.get("public_workspace_id"), + "scope": result_scope, + "scope_id": get_search_result_scope_id(r), "version": r["version"], "chunk_sequence": r["chunk_sequence"], "upload_date": r["upload_date"], diff --git a/application/single_app/functions_search_service.py b/application/single_app/functions_search_service.py new file mode 100644 index 000000000..f00fe0caf --- /dev/null +++ b/application/single_app/functions_search_service.py @@ -0,0 +1,741 @@ +# functions_search_service.py +"""Shared search, retrieval, and summarization services for documents.""" + +import logging +import math +from typing import Any, Dict, List, Optional + +from azure.identity import DefaultAzureCredential, get_bearer_token_provider +from openai import AzureOpenAI + +from config import cognitive_services_scope +from functions_appinsights import log_event +from functions_debug import debug_print +from functions_documents import get_document_record, get_ordered_document_chunks +from functions_group import get_user_groups +from functions_public_workspaces import get_user_visible_public_workspace_ids_from_settings +from functions_search import ( + SEARCH_DEFAULT_TOP_N, + SEARCH_MAX_TOP_N, + hybrid_search, + normalize_search_id_list, + normalize_search_scope, + normalize_search_top_n, +) +from functions_settings import get_settings, get_user_settings + + +SUMMARY_DEFAULT_WINDOW_UNIT = "pages" +SUMMARY_DEFAULT_WINDOW_SUMMARY_TARGET = "2 pages" +SUMMARY_DEFAULT_FINAL_TARGET = "2 pages" +SUMMARY_DEFAULT_REDUCTION_BATCH_SIZE = 4 +SUMMARY_DEFAULT_MAX_REDUCTION_ROUNDS = 4 +SUMMARY_DEFAULT_MIN_PAGE_WINDOW = 5 +SUMMARY_DEFAULT_MAX_PAGE_WINDOW = 25 +SUMMARY_DEFAULT_CHUNK_WINDOW = 20 +SUMMARY_MAX_WINDOW_SIZE = 50 + + +def _coerce_positive_int(value, default_value, min_value=1, max_value=None): + try: + normalized_value = int(value) + except (TypeError, ValueError): + normalized_value = default_value + + if normalized_value < min_value: + normalized_value = default_value + if max_value is not None: + normalized_value = min(normalized_value, max_value) + return normalized_value + + +def _normalize_window_unit(window_unit, chunks): + normalized_window_unit = str(window_unit or SUMMARY_DEFAULT_WINDOW_UNIT).strip().lower() + if normalized_window_unit == "pages": + has_page_numbers = any(chunk.get("page_number") is not None for chunk in chunks or []) + if has_page_numbers: + return "pages" + return "chunks" + + +def _resolve_active_group_ids(user_id, active_group_ids=None, fallback_to_memberships=False): + normalized_group_ids = normalize_search_id_list(active_group_ids) + if normalized_group_ids: + return normalized_group_ids + + user_settings = get_user_settings(user_id) + active_group_id = str(user_settings.get("settings", {}).get("activeGroupOid") or "").strip() + if active_group_id: + return [active_group_id] + + if not fallback_to_memberships: + return [] + + try: + user_groups = get_user_groups(user_id) + except Exception: + return [] + + return normalize_search_id_list([group.get("id") for group in user_groups if group.get("id")]) + + +def _resolve_public_workspace_ids(user_id, active_public_workspace_id=None): + normalized_workspace_ids = normalize_search_id_list(active_public_workspace_id) + if normalized_workspace_ids: + return normalized_workspace_ids + + try: + return normalize_search_id_list(get_user_visible_public_workspace_ids_from_settings(user_id)) + except Exception: + return [] + + +def _serialize_document(document_item, scope_name): + return { + "id": document_item.get("id"), + "file_name": document_item.get("file_name"), + "title": document_item.get("title"), + "abstract": document_item.get("abstract"), + "version": document_item.get("version"), + "revision_family_id": document_item.get("revision_family_id"), + "document_classification": document_item.get("document_classification"), + "tags": document_item.get("tags", []), + "scope": scope_name, + "scope_id": ( + document_item.get("public_workspace_id") + or document_item.get("group_id") + or document_item.get("user_id") + ), + "group_id": document_item.get("group_id"), + "public_workspace_id": document_item.get("public_workspace_id"), + "user_id": document_item.get("user_id"), + } + + +def resolve_document_context( + document_id, + user_id, + doc_scope="all", + active_group_ids=None, + active_public_workspace_id=None, +): + normalized_scope = normalize_search_scope(doc_scope) + + if normalized_scope in ("all", "personal"): + personal_document = get_document_record(user_id=user_id, document_id=document_id) + if personal_document: + return { + "scope": "personal", + "group_id": None, + "public_workspace_id": None, + "document": personal_document, + } + + if normalized_scope in ("all", "group"): + for group_id in _resolve_active_group_ids( + user_id, + active_group_ids=active_group_ids, + fallback_to_memberships=True, + ): + group_document = get_document_record( + user_id=user_id, + document_id=document_id, + group_id=group_id, + ) + if group_document: + return { + "scope": "group", + "group_id": group_id, + "public_workspace_id": None, + "document": group_document, + } + + if normalized_scope in ("all", "public"): + for public_workspace_id in _resolve_public_workspace_ids( + user_id, + active_public_workspace_id=active_public_workspace_id, + ): + public_document = get_document_record( + user_id=user_id, + document_id=document_id, + public_workspace_id=public_workspace_id, + ) + if public_document: + return { + "scope": "public", + "group_id": None, + "public_workspace_id": public_workspace_id, + "document": public_document, + } + + return None + + +def build_search_request( + query, + user_id, + top_n=None, + doc_scope="all", + document_id=None, + document_ids=None, + tags_filter=None, + active_group_ids=None, + active_public_workspace_id=None, + enable_file_sharing=True, +): + normalized_query = str(query or "").strip() + if not normalized_query: + raise ValueError("Query is required") + + normalized_scope = normalize_search_scope(doc_scope) + normalized_top_n = normalize_search_top_n(top_n, SEARCH_DEFAULT_TOP_N, SEARCH_MAX_TOP_N) + normalized_document_ids = normalize_search_id_list(document_ids) + if document_id and not normalized_document_ids: + normalized_document_ids = [str(document_id).strip()] + + search_request = { + "query": normalized_query, + "user_id": user_id, + "top_n": normalized_top_n, + "doc_scope": normalized_scope, + "enable_file_sharing": bool(enable_file_sharing), + } + + if normalized_document_ids: + search_request["document_ids"] = normalized_document_ids + + normalized_tags = normalize_search_id_list(tags_filter) + if normalized_tags: + search_request["tags_filter"] = normalized_tags + + resolved_group_ids = _resolve_active_group_ids( + user_id, + active_group_ids=active_group_ids, + fallback_to_memberships=False, + ) + if resolved_group_ids and normalized_scope in ("all", "group"): + search_request["active_group_ids"] = resolved_group_ids + + resolved_public_workspace_ids = _resolve_public_workspace_ids( + user_id, + active_public_workspace_id=active_public_workspace_id, + ) + if resolved_public_workspace_ids and normalized_scope in ("all", "public"): + search_request["active_public_workspace_id"] = resolved_public_workspace_ids[0] + + return search_request + + +def search_documents( + query, + user_id, + top_n=None, + doc_scope="all", + document_id=None, + document_ids=None, + tags_filter=None, + active_group_ids=None, + active_public_workspace_id=None, + enable_file_sharing=True, +): + search_request = build_search_request( + query=query, + user_id=user_id, + top_n=top_n, + doc_scope=doc_scope, + document_id=document_id, + document_ids=document_ids, + tags_filter=tags_filter, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + enable_file_sharing=enable_file_sharing, + ) + results = hybrid_search(**search_request) or [] + unique_document_ids = { + result.get("document_id") + for result in results + if result.get("document_id") + } + + return { + "query": search_request.get("query"), + "scope": search_request.get("doc_scope"), + "top_n": search_request.get("top_n"), + "document_ids": search_request.get("document_ids", []), + "tags_filter": search_request.get("tags_filter", []), + "group_ids": search_request.get("active_group_ids", []), + "active_public_workspace_id": search_request.get("active_public_workspace_id"), + "result_count": len(results), + "document_count": len(unique_document_ids), + "results": results, + } + + +def _derive_window_size(chunks, window_unit, window_size=None, window_percent=None): + if not chunks: + return 0 + + if window_unit == "pages": + total_units = len({chunk.get("page_number") for chunk in chunks if chunk.get("page_number") is not None}) + if total_units <= 0: + return 0 + + if window_size is not None and str(window_size).strip() != "": + return _coerce_positive_int( + window_size, + default_value=min(total_units, SUMMARY_DEFAULT_MAX_PAGE_WINDOW), + min_value=1, + max_value=min(total_units, SUMMARY_MAX_WINDOW_SIZE), + ) + + if window_percent: + computed_size = int(math.ceil(total_units * (float(window_percent) / 100.0))) + else: + computed_size = int(math.ceil(total_units / 4.0)) + + computed_size = max(SUMMARY_DEFAULT_MIN_PAGE_WINDOW, computed_size) + computed_size = min(SUMMARY_DEFAULT_MAX_PAGE_WINDOW, computed_size) + return min(total_units, computed_size) + + total_units = len(chunks) + if total_units <= 0: + return 0 + + default_chunk_window = min(total_units, SUMMARY_DEFAULT_CHUNK_WINDOW) + if window_size is not None and str(window_size).strip() != "": + return _coerce_positive_int( + window_size, + default_value=default_chunk_window, + min_value=1, + max_value=min(total_units, SUMMARY_MAX_WINDOW_SIZE), + ) + + if window_percent: + computed_size = int(math.ceil(total_units * (float(window_percent) / 100.0))) + return min(total_units, max(1, computed_size)) + + return default_chunk_window + + +def build_document_chunk_windows(chunks, window_unit="pages", window_size=None, window_percent=None): + if not chunks: + return [] + + normalized_window_unit = _normalize_window_unit(window_unit, chunks) + resolved_window_size = _derive_window_size( + chunks, + normalized_window_unit, + window_size=window_size, + window_percent=window_percent, + ) + if resolved_window_size <= 0: + return [] + + windows = [] + if normalized_window_unit == "pages": + ordered_pages = sorted({chunk.get("page_number") for chunk in chunks if chunk.get("page_number") is not None}) + for window_index, page_offset in enumerate(range(0, len(ordered_pages), resolved_window_size), start=1): + window_pages = ordered_pages[page_offset:page_offset + resolved_window_size] + window_chunks = [ + chunk for chunk in chunks + if chunk.get("page_number") in window_pages + ] + windows.append({ + "window_number": window_index, + "window_unit": normalized_window_unit, + "window_size": resolved_window_size, + "chunk_count": len(window_chunks), + "page_count": len(window_pages), + "start_page": window_pages[0], + "end_page": window_pages[-1], + "start_chunk_sequence": window_chunks[0].get("chunk_sequence") if window_chunks else None, + "end_chunk_sequence": window_chunks[-1].get("chunk_sequence") if window_chunks else None, + "chunks": window_chunks, + }) + else: + for window_index, chunk_offset in enumerate(range(0, len(chunks), resolved_window_size), start=1): + window_chunks = chunks[chunk_offset:chunk_offset + resolved_window_size] + page_numbers = [chunk.get("page_number") for chunk in window_chunks if chunk.get("page_number") is not None] + windows.append({ + "window_number": window_index, + "window_unit": normalized_window_unit, + "window_size": resolved_window_size, + "chunk_count": len(window_chunks), + "page_count": len(set(page_numbers)) if page_numbers else 0, + "start_page": min(page_numbers) if page_numbers else None, + "end_page": max(page_numbers) if page_numbers else None, + "start_chunk_sequence": window_chunks[0].get("chunk_sequence") if window_chunks else None, + "end_chunk_sequence": window_chunks[-1].get("chunk_sequence") if window_chunks else None, + "chunks": window_chunks, + }) + + return windows + + +def get_document_chunks_payload( + document_id, + user_id, + doc_scope="all", + active_group_ids=None, + active_public_workspace_id=None, + window_unit="pages", + window_size=None, + window_percent=None, + window_number=None, +): + document_context = resolve_document_context( + document_id=document_id, + user_id=user_id, + doc_scope=doc_scope, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + ) + if not document_context: + raise LookupError("Document not found or access denied") + + chunks = get_ordered_document_chunks( + document_id=document_id, + user_id=user_id, + group_id=document_context.get("group_id"), + public_workspace_id=document_context.get("public_workspace_id"), + ) + windows = build_document_chunk_windows( + chunks, + window_unit=window_unit, + window_size=window_size, + window_percent=window_percent, + ) + selected_window = None + selected_chunks = chunks + + if window_number not in (None, ""): + resolved_window_number = _coerce_positive_int(window_number, default_value=1) + selected_window = next( + (window for window in windows if window.get("window_number") == resolved_window_number), + None, + ) + if not selected_window: + raise LookupError(f"Window {resolved_window_number} was not found for this document") + selected_chunks = selected_window.get("chunks", []) + + return { + "document": _serialize_document(document_context.get("document"), document_context.get("scope")), + "scope": document_context.get("scope"), + "scope_id": ( + document_context.get("public_workspace_id") + or document_context.get("group_id") + or document_context.get("document", {}).get("user_id") + ), + "chunk_count": len(chunks), + "returned_chunk_count": len(selected_chunks), + "window_count": len(windows), + "windowing": { + "window_unit": windows[0].get("window_unit") if windows else _normalize_window_unit(window_unit, chunks), + "window_size": windows[0].get("window_size") if windows else None, + "window_percent": window_percent, + "selected_window_number": selected_window.get("window_number") if selected_window else None, + }, + "windows": [ + { + "window_number": window.get("window_number"), + "window_unit": window.get("window_unit"), + "window_size": window.get("window_size"), + "chunk_count": window.get("chunk_count"), + "page_count": window.get("page_count"), + "start_page": window.get("start_page"), + "end_page": window.get("end_page"), + "start_chunk_sequence": window.get("start_chunk_sequence"), + "end_chunk_sequence": window.get("end_chunk_sequence"), + } + for window in windows + ], + "chunks": selected_chunks, + } + + +def _render_window_source_text(window_payload): + source_parts = [] + for chunk in window_payload.get("chunks", []): + chunk_text = str(chunk.get("chunk_text") or "").strip() + if not chunk_text: + continue + + chunk_labels = [] + if chunk.get("page_number") is not None: + chunk_labels.append(f"Page {chunk.get('page_number')}") + if chunk.get("chunk_sequence") is not None: + chunk_labels.append(f"Chunk {chunk.get('chunk_sequence')}") + prefix = f"[{', '.join(chunk_labels)}] " if chunk_labels else "" + source_parts.append(f"{prefix}{chunk_text}") + + return "\n\n".join(source_parts) + + +def _create_summary_client(settings): + if settings.get('enable_gpt_apim', False): + return AzureOpenAI( + api_version=settings.get('azure_apim_gpt_api_version'), + azure_endpoint=settings.get('azure_apim_gpt_endpoint'), + api_key=settings.get('azure_apim_gpt_subscription_key'), + ) + + auth_type = settings.get('azure_openai_gpt_authentication_type', 'key') + if auth_type == 'managed_identity': + token_provider = get_bearer_token_provider( + DefaultAzureCredential(), + cognitive_services_scope, + ) + return AzureOpenAI( + api_version=settings.get('azure_openai_gpt_api_version'), + azure_endpoint=settings.get('azure_openai_gpt_endpoint'), + azure_ad_token_provider=token_provider, + ) + + return AzureOpenAI( + api_version=settings.get('azure_openai_gpt_api_version'), + azure_endpoint=settings.get('azure_openai_gpt_endpoint'), + api_key=settings.get('azure_openai_gpt_key'), + ) + + +def _resolve_summary_model(settings): + selected_model = settings.get('gpt_model', {}).get('selected', [{}]) + selected_model = selected_model[0] if selected_model else {} + model_name = ( + settings.get('metadata_extraction_model') + or settings.get('azure_openai_gpt_deployment') + or selected_model.get('deploymentName') + ) + if not model_name: + raise RuntimeError('No GPT deployment is configured for document summarization') + return model_name + + +def _build_summary_api_params(model_name, messages, max_output_tokens=1600): + uses_completion_tokens = any( + marker in model_name.lower() + for marker in ('o1', 'o3', 'gpt-5') + ) + api_params = { + 'model': model_name, + 'messages': messages, + } + if uses_completion_tokens: + api_params['max_completion_tokens'] = max_output_tokens + else: + api_params['temperature'] = 0.2 + api_params['max_tokens'] = max_output_tokens + return api_params + + +def _summarize_text_block( + gpt_client, + model_name, + file_name, + stage_label, + target_length, + focus_instructions, + coverage_note, + source_text, +): + messages = [ + { + 'role': 'system', + 'content': ( + 'You summarize document content accurately and conservatively. ' + 'Do not invent details. Preserve factual meaning, decisions, risks, dates, and action items when present.' + ), + }, + { + 'role': 'user', + 'content': ( + f'Document: {file_name}\n' + f'Stage: {stage_label}\n' + f'Coverage: {coverage_note}\n' + f'Target length: {target_length}\n' + f'Focus instructions: {focus_instructions or "Summarize the most important facts, decisions, risks, dependencies, and open questions."}\n\n' + 'Write a clear summary with short section headers when useful. ' + 'Call out important caveats or ambiguities explicitly.\n\n' + f'\n{source_text}\n' + ), + }, + ] + response = gpt_client.chat.completions.create( + **_build_summary_api_params(model_name, messages) + ) + return str(response.choices[0].message.content or '').strip() + + +def _build_reduction_windows(summary_items, batch_size): + reduction_windows = [] + for window_number, index in enumerate(range(0, len(summary_items), batch_size), start=1): + batch_items = summary_items[index:index + batch_size] + source_text = [] + for batch_item in batch_items: + source_text.append( + f"[Section {batch_item.get('source_window_numbers')}]\n{batch_item.get('summary', '')}" + ) + reduction_windows.append({ + 'window_number': window_number, + 'window_unit': 'summaries', + 'window_size': batch_size, + 'chunk_count': sum(item.get('chunk_count', 0) for item in batch_items), + 'page_count': sum(item.get('page_count', 0) for item in batch_items), + 'start_page': batch_items[0].get('start_page') if batch_items else None, + 'end_page': batch_items[-1].get('end_page') if batch_items else None, + 'source_text': '\n\n'.join(source_text), + 'source_window_numbers': [item.get('source_window_numbers') for item in batch_items], + }) + return reduction_windows + + +def summarize_document_content( + document_id, + user_id, + doc_scope='all', + active_group_ids=None, + active_public_workspace_id=None, + focus_instructions='', + final_target_length=SUMMARY_DEFAULT_FINAL_TARGET, + window_target_length=SUMMARY_DEFAULT_WINDOW_SUMMARY_TARGET, + window_unit=SUMMARY_DEFAULT_WINDOW_UNIT, + window_size=None, + window_percent=None, + reduction_batch_size=SUMMARY_DEFAULT_REDUCTION_BATCH_SIZE, + max_reduction_rounds=SUMMARY_DEFAULT_MAX_REDUCTION_ROUNDS, +): + chunk_payload = get_document_chunks_payload( + document_id=document_id, + user_id=user_id, + doc_scope=doc_scope, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + window_unit=window_unit, + window_size=window_size, + window_percent=window_percent, + ) + windows = build_document_chunk_windows( + chunk_payload.get('chunks', []), + window_unit=window_unit, + window_size=window_size, + window_percent=window_percent, + ) + if not windows: + raise LookupError('No document chunks were available for summarization') + + settings = get_settings() + model_name = _resolve_summary_model(settings) + gpt_client = _create_summary_client(settings) + reduction_batch_size = _coerce_positive_int( + reduction_batch_size, + SUMMARY_DEFAULT_REDUCTION_BATCH_SIZE, + min_value=1, + max_value=8, + ) + max_reduction_rounds = _coerce_positive_int( + max_reduction_rounds, + SUMMARY_DEFAULT_MAX_REDUCTION_ROUNDS, + min_value=1, + max_value=8, + ) + file_name = chunk_payload.get('document', {}).get('file_name') or document_id + + stage_records = [] + current_stage_inputs = windows + stage_number = 1 + final_summary = '' + + while current_stage_inputs and stage_number <= max_reduction_rounds: + debug_print( + f"[SearchService] Summarization stage {stage_number} for {file_name} with {len(current_stage_inputs)} input windows" + ) + output_items = [] + + for stage_input in current_stage_inputs: + if stage_number == 1: + coverage_note = ( + f"pages {stage_input.get('start_page')} to {stage_input.get('end_page')}" + if stage_input.get('start_page') is not None else + f"chunks {stage_input.get('start_chunk_sequence')} to {stage_input.get('end_chunk_sequence')}" + ) + source_text = _render_window_source_text(stage_input) + source_window_numbers = [stage_input.get('window_number')] + target_length = window_target_length + page_count = stage_input.get('page_count', 0) + chunk_count = stage_input.get('chunk_count', 0) + start_page = stage_input.get('start_page') + end_page = stage_input.get('end_page') + else: + coverage_note = f"summary windows {stage_input.get('source_window_numbers')}" + source_text = stage_input.get('source_text', '') + source_window_numbers = stage_input.get('source_window_numbers', []) + target_length = final_target_length + page_count = stage_input.get('page_count', 0) + chunk_count = stage_input.get('chunk_count', 0) + start_page = stage_input.get('start_page') + end_page = stage_input.get('end_page') + + if not source_text.strip(): + continue + + summary_text = _summarize_text_block( + gpt_client=gpt_client, + model_name=model_name, + file_name=file_name, + stage_label=f'stage-{stage_number}', + target_length=target_length, + focus_instructions=focus_instructions, + coverage_note=coverage_note, + source_text=source_text, + ) + output_items.append({ + 'window_number': stage_input.get('window_number'), + 'source_window_numbers': source_window_numbers, + 'chunk_count': chunk_count, + 'page_count': page_count, + 'start_page': start_page, + 'end_page': end_page, + 'summary': summary_text, + }) + + stage_records.append({ + 'stage_number': stage_number, + 'input_count': len(current_stage_inputs), + 'output_count': len(output_items), + 'target_length': window_target_length if stage_number == 1 else final_target_length, + 'outputs': output_items, + }) + + if len(output_items) <= 1: + final_summary = output_items[0].get('summary', '') if output_items else '' + break + + current_stage_inputs = _build_reduction_windows(output_items, reduction_batch_size) + stage_number += 1 + + log_event( + '[SearchService] Document summarization completed', + extra={ + 'document_id': document_id, + 'file_name': file_name, + 'stage_count': len(stage_records), + 'window_count': len(windows), + 'scope': chunk_payload.get('scope'), + }, + level=logging.INFO, + ) + + return { + 'document': chunk_payload.get('document'), + 'scope': chunk_payload.get('scope'), + 'scope_id': chunk_payload.get('scope_id'), + 'chunk_count': chunk_payload.get('chunk_count'), + 'window_count': len(windows), + 'windowing': chunk_payload.get('windowing'), + 'focus_instructions': focus_instructions, + 'window_target_length': window_target_length, + 'final_target_length': final_target_length, + 'stage_count': len(stage_records), + 'stages': stage_records, + 'summary': final_summary, + } \ 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 e16d72423..d698e008f 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -6935,24 +6935,12 @@ def result_requires_message_reload(result: Any) -> bool: ) try: # Prepare search arguments - # Set default and maximum values for top_n - default_top_n = 12 - max_top_n = 500 # Reasonable cap to prevent excessive resource usage - - # Process top_n_results if provided - if top_n_results is not None: - try: - top_n = int(top_n_results) - # Ensure top_n is within reasonable bounds - if top_n < 1: - top_n = default_top_n - elif top_n > max_top_n: - top_n = max_top_n - except (ValueError, TypeError): - # If conversion fails, use default - top_n = default_top_n - else: - top_n = default_top_n + default_top_n = SEARCH_DEFAULT_TOP_N + top_n = normalize_search_top_n( + top_n_results, + default_top_n=SEARCH_DEFAULT_TOP_N, + max_top_n=SEARCH_MAX_TOP_N, + ) search_args = { "query": search_query, diff --git a/application/single_app/route_backend_search.py b/application/single_app/route_backend_search.py new file mode 100644 index 000000000..5a5a98bc1 --- /dev/null +++ b/application/single_app/route_backend_search.py @@ -0,0 +1,136 @@ +# route_backend_search.py + +from config import * +from functions_appinsights import log_event +from functions_authentication import get_current_user_id, login_required, user_required +from functions_search_service import ( + get_document_chunks_payload, + search_documents as run_document_search, + summarize_document_content, +) +from swagger_wrapper import swagger_route, get_auth_security + + +def register_route_backend_search(app): + @app.route('/api/search/documents', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_search_documents(): + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + + try: + payload = run_document_search( + query=data.get('query'), + user_id=user_id, + top_n=data.get('top_n'), + doc_scope=data.get('doc_scope', 'all'), + document_id=data.get('document_id'), + document_ids=data.get('document_ids'), + tags_filter=data.get('tags_filter', data.get('tags')), + active_group_ids=data.get('active_group_ids', data.get('active_group_id')), + active_public_workspace_id=data.get('active_public_workspace_id'), + enable_file_sharing=data.get('enable_file_sharing', True), + ) + return jsonify(payload), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + log_event( + '[Backend Search] Document search failed.', + extra={'user_id': user_id, 'error_message': str(e)}, + level=logging.ERROR, + ) + return jsonify({'error': 'Document search failed'}), 500 + + @app.route('/api/search/document-chunks', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_get_document_chunks(): + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + document_id = str(data.get('document_id') or '').strip() + if not document_id: + return jsonify({'error': 'document_id is required'}), 400 + + try: + payload = get_document_chunks_payload( + document_id=document_id, + user_id=user_id, + doc_scope=data.get('doc_scope', 'all'), + active_group_ids=data.get('active_group_ids', data.get('active_group_id')), + active_public_workspace_id=data.get('active_public_workspace_id'), + window_unit=data.get('window_unit', 'pages'), + window_size=data.get('window_size'), + window_percent=data.get('window_percent'), + window_number=data.get('window_number'), + ) + return jsonify(payload), 200 + except LookupError as e: + return jsonify({'error': str(e)}), 404 + except Exception as e: + log_event( + '[Backend Search] Document chunk retrieval failed.', + extra={ + 'user_id': user_id, + 'document_id': document_id, + 'error_message': str(e), + }, + level=logging.ERROR, + ) + return jsonify({'error': 'Document chunk retrieval failed'}), 500 + + @app.route('/api/search/document-summary', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_summarize_document(): + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + document_id = str(data.get('document_id') or '').strip() + if not document_id: + return jsonify({'error': 'document_id is required'}), 400 + + try: + payload = summarize_document_content( + document_id=document_id, + user_id=user_id, + doc_scope=data.get('doc_scope', 'all'), + active_group_ids=data.get('active_group_ids', data.get('active_group_id')), + active_public_workspace_id=data.get('active_public_workspace_id'), + focus_instructions=data.get('focus_instructions', ''), + final_target_length=data.get('final_target_length', data.get('target_length', '2 pages')), + window_target_length=data.get('window_target_length', '2 pages'), + window_unit=data.get('window_unit', 'pages'), + window_size=data.get('window_size'), + window_percent=data.get('window_percent'), + reduction_batch_size=data.get('reduction_batch_size'), + max_reduction_rounds=data.get('max_reduction_rounds'), + ) + return jsonify(payload), 200 + except LookupError as e: + return jsonify({'error': str(e)}), 404 + except RuntimeError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + log_event( + '[Backend Search] Document summarization failed.', + extra={ + 'user_id': user_id, + 'document_id': document_id, + 'error_message': str(e), + }, + level=logging.ERROR, + ) + return jsonify({'error': 'Document summarization failed'}), 500 \ No newline at end of file diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 3a2ca4b54..0942baf32 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -22,6 +22,7 @@ from semantic_kernel.functions.kernel_plugin import KernelPlugin from semantic_kernel_plugins.embedding_model_plugin import EmbeddingModelPlugin from semantic_kernel_plugins.fact_memory_plugin import FactMemoryPlugin +from semantic_kernel_plugins.document_search_plugin import DocumentSearchPlugin from semantic_kernel_plugins.tabular_processing_plugin import TabularProcessingPlugin from functions_settings import get_settings, get_user_settings, is_tabular_processing_enabled from foundry_agent_runtime import ( @@ -792,6 +793,13 @@ def load_fact_memory_plugin(kernel: Kernel): description="Provides functions for managing persistent facts." ) +def load_document_search_plugin(kernel: Kernel): + kernel.add_plugin( + DocumentSearchPlugin(), + plugin_name="document_search", + description="Provides hybrid document search, exhaustive chunk retrieval, and hierarchical document summarization." + ) + def load_embedding_model_plugin(kernel: Kernel, settings): embedding_endpoint = settings.get('azure_openai_embedding_endpoint') embedding_key = settings.get('azure_openai_embedding_key') @@ -824,6 +832,12 @@ def load_core_plugins_only(kernel: Kernel, settings): load_fact_memory_plugin(kernel) log_event("[SK Loader] Loaded Fact Memory plugin.", level=logging.INFO) + try: + load_document_search_plugin(kernel) + log_event("[SK Loader] Loaded Document Search plugin.", level=logging.INFO) + except Exception as e: + log_event(f"[SK Loader] Failed to load Document Search plugin: {e}", level=logging.WARNING) + if settings.get('enable_math_plugin', True): load_math_plugin(kernel) log_event("[SK Loader] Loaded Math plugin.", level=logging.INFO) @@ -1692,6 +1706,12 @@ def load_plugins_for_kernel(kernel, plugin_manifests, settings, mode_label="glob except Exception as e: log_event(f"[SK Loader] Failed to load Fact Memory Plugin: {e}", level=logging.WARNING) + try: + load_document_search_plugin(kernel) + log_event("[SK Loader] Loaded Document Search Plugin.", level=logging.INFO) + except Exception as e: + log_event(f"[SK Loader] Failed to load Document Search Plugin: {e}", level=logging.WARNING) + # Register Tabular Processing Plugin if enabled (requires enhanced citations) if is_tabular_processing_enabled(settings): try: diff --git a/application/single_app/semantic_kernel_plugins/document_search_plugin.py b/application/single_app/semantic_kernel_plugins/document_search_plugin.py new file mode 100644 index 000000000..e391d95e6 --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/document_search_plugin.py @@ -0,0 +1,167 @@ +# document_search_plugin.py + +from typing import Annotated, Any, Dict + +from semantic_kernel.functions import kernel_function + +from functions_authentication import get_current_user_id +from functions_search_service import ( + get_document_chunks_payload, + search_documents as run_document_search, + summarize_document_content, +) +from semantic_kernel_plugins.base_plugin import BasePlugin +from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger + + +class DocumentSearchPlugin(BasePlugin): + def __init__(self, manifest: Dict[str, Any] = None): + super().__init__(manifest) + + @property + def display_name(self) -> str: + return 'Document Search' + + @property + def metadata(self) -> Dict[str, Any]: + return { + 'name': 'document_search_plugin', + 'type': 'search', + 'description': ( + 'Hybrid document search, exhaustive chunk retrieval, and hierarchical document summarization ' + 'for personal, group, and public workspaces.' + ), + 'methods': [ + { + 'name': 'search_documents', + 'description': 'Run relevance-ranked hybrid search and return chunk-level results with document ids.', + 'parameters': [ + {'name': 'query', 'type': 'str', 'description': 'Natural-language search query.', 'required': True}, + {'name': 'doc_scope', 'type': 'str', 'description': 'all, personal, group, or public.', 'required': False}, + {'name': 'top_n', 'type': 'int', 'description': 'Maximum number of results to return.', 'required': False}, + ], + 'returns': {'type': 'dict', 'description': 'Search results with scope and document metadata.'}, + }, + { + 'name': 'retrieve_document_chunks', + 'description': 'Retrieve ordered chunks for one accessible document, optionally in windows.', + 'parameters': [ + {'name': 'document_id', 'type': 'str', 'description': 'Document id to retrieve.', 'required': True}, + {'name': 'doc_scope', 'type': 'str', 'description': 'all, personal, group, or public.', 'required': False}, + {'name': 'window_number', 'type': 'int', 'description': 'Optional 1-based window number to return.', 'required': False}, + ], + 'returns': {'type': 'dict', 'description': 'Ordered chunks and window metadata.'}, + }, + { + 'name': 'summarize_document', + 'description': 'Summarize a document hierarchically across ordered chunk windows.', + 'parameters': [ + {'name': 'document_id', 'type': 'str', 'description': 'Document id to summarize.', 'required': True}, + {'name': 'focus_instructions', 'type': 'str', 'description': 'Optional focus areas to emphasize.', 'required': False}, + {'name': 'final_target_length', 'type': 'str', 'description': 'Desired final summary length.', 'required': False}, + ], + 'returns': {'type': 'dict', 'description': 'Summary text plus stage and window metadata.'}, + }, + ], + } + + def _get_user_id(self): + user_id = get_current_user_id() + if not user_id: + raise RuntimeError('User context is unavailable for document search') + return user_id + + @plugin_function_logger('DocumentSearchPlugin') + @kernel_function( + name='search_documents', + description='Run hybrid document search over accessible workspaces and return chunk-level results with document ids.', + ) + def search_documents( + self, + query: Annotated[str, 'Natural-language query to run against accessible documents.'], + doc_scope: Annotated[str, 'all, personal, group, or public.'] = 'all', + top_n: Annotated[int, 'Maximum number of chunk results to return.'] = 12, + document_ids: Annotated[str, 'Optional comma-separated document ids to restrict the search.'] = '', + tags_filter: Annotated[str, 'Optional comma-separated document tags that must all match.'] = '', + active_group_ids: Annotated[str, 'Optional comma-separated group ids when searching group content.'] = '', + active_public_workspace_id: Annotated[str, 'Optional public workspace id when searching public content.'] = '', + ) -> Annotated[dict, 'Search results and request metadata.']: + try: + return run_document_search( + query=query, + user_id=self._get_user_id(), + top_n=top_n, + doc_scope=doc_scope, + document_ids=document_ids, + tags_filter=tags_filter, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + ) + except Exception as e: + return {'error': str(e)} + + @plugin_function_logger('DocumentSearchPlugin') + @kernel_function( + name='retrieve_document_chunks', + description='Retrieve ordered chunks for one accessible document, optionally selecting one window of chunks.', + ) + def retrieve_document_chunks( + self, + document_id: Annotated[str, 'Document id to retrieve chunk content from.'], + doc_scope: Annotated[str, 'all, personal, group, or public.'] = 'all', + window_unit: Annotated[str, 'pages or chunks for chunk windowing.'] = 'pages', + window_size: Annotated[int, 'Optional explicit number of pages or chunks per window.'] = 0, + window_percent: Annotated[int, 'Optional percentage of the document to include per window.'] = 0, + window_number: Annotated[int, 'Optional 1-based window number to return instead of the full document.'] = 0, + active_group_ids: Annotated[str, 'Optional comma-separated group ids when resolving group content.'] = '', + active_public_workspace_id: Annotated[str, 'Optional public workspace id when resolving public content.'] = '', + ) -> Annotated[dict, 'Ordered chunks and window metadata for one document.']: + try: + return get_document_chunks_payload( + document_id=document_id, + user_id=self._get_user_id(), + doc_scope=doc_scope, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + window_unit=window_unit, + window_size=window_size if int(window_size or 0) > 0 else None, + window_percent=window_percent if int(window_percent or 0) > 0 else None, + window_number=window_number if int(window_number or 0) > 0 else None, + ) + except Exception as e: + return {'error': str(e)} + + @plugin_function_logger('DocumentSearchPlugin') + @kernel_function( + name='summarize_document', + description='Summarize one accessible document hierarchically across ordered chunk windows, with optional focus guidance.', + ) + def summarize_document( + self, + document_id: Annotated[str, 'Document id to summarize.'], + doc_scope: Annotated[str, 'all, personal, group, or public.'] = 'all', + focus_instructions: Annotated[str, 'Optional focus areas such as risks, deadlines, or architectural decisions.'] = '', + final_target_length: Annotated[str, 'Desired final summary length, for example 2 pages or 500 words.'] = '2 pages', + window_target_length: Annotated[str, 'Target length for each first-pass window summary.'] = '2 pages', + window_unit: Annotated[str, 'pages or chunks for chunk windowing.'] = 'pages', + window_size: Annotated[int, 'Optional explicit number of pages or chunks per window.'] = 0, + window_percent: Annotated[int, 'Optional percentage of the document to include per first-pass window.'] = 0, + active_group_ids: Annotated[str, 'Optional comma-separated group ids when resolving group content.'] = '', + active_public_workspace_id: Annotated[str, 'Optional public workspace id when resolving public content.'] = '', + ) -> Annotated[dict, 'Final summary text plus stage and window metadata.']: + try: + return summarize_document_content( + document_id=document_id, + user_id=self._get_user_id(), + doc_scope=doc_scope, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + focus_instructions=focus_instructions, + final_target_length=final_target_length, + window_target_length=window_target_length, + window_unit=window_unit, + window_size=window_size if int(window_size or 0) > 0 else None, + window_percent=window_percent if int(window_percent or 0) > 0 else None, + ) + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/docs/explanation/features/index.md b/docs/explanation/features/index.md index 800ebdae4..208a69f17 100644 --- a/docs/explanation/features/index.md +++ b/docs/explanation/features/index.md @@ -13,4 +13,8 @@ category: Version History ## Admin Experience Features -- [AI Voice Conversations Setup Guide](AI_VOICE_CONVERSATIONS_SETUP_GUIDE.md) \ No newline at end of file +- [AI Voice Conversations Setup Guide](AI_VOICE_CONVERSATIONS_SETUP_GUIDE.md) + +## Versioned Features + +- [Core Document Search And Summarization](v0.241.007/CORE_DOCUMENT_SEARCH_AND_SUMMARIZATION.md) \ No newline at end of file diff --git a/docs/explanation/features/v0.241.007/CORE_DOCUMENT_SEARCH_AND_SUMMARIZATION.md b/docs/explanation/features/v0.241.007/CORE_DOCUMENT_SEARCH_AND_SUMMARIZATION.md new file mode 100644 index 000000000..7879fd0aa --- /dev/null +++ b/docs/explanation/features/v0.241.007/CORE_DOCUMENT_SEARCH_AND_SUMMARIZATION.md @@ -0,0 +1,123 @@ +# Core Document Search And Summarization + +Version implemented: **0.241.007** + +## Overview and Purpose + +This feature adds a shared backend document-search service, a dedicated authenticated search API, and an always-loaded Semantic Kernel core plugin for search and summarization. + +The goal is to give agents and backend callers a common way to: + +- Run relevance-ranked hybrid search with a bounded, caller-overridable top-N. +- Retrieve ordered chunks for a specific document instead of relying only on distilled search hits. +- Generate hierarchical summaries across the full document by summarizing chunk windows and then reducing the intermediate summaries. + +Dependencies: + +- Azure AI Search chunk indexes for personal, group, and public workspaces +- Azure OpenAI chat model configuration for summary generation +- Semantic Kernel core plugin loading in the shared kernel loader + +## Technical Specifications + +### Architecture Overview + +The feature is split into three thin layers over one shared service: + +1. `functions_search.py` keeps hybrid search and now exposes shared top-N and scope normalization helpers. +2. `functions_search_service.py` resolves document scope, retrieves ordered chunks, builds chunk windows, and performs hierarchical summarization. +3. Adapters expose the shared service through: + - `route_backend_search.py` for authenticated backend APIs + - `semantic_kernel_plugins/document_search_plugin.py` for always-loaded Semantic Kernel access + +This keeps the HTTP API and the Semantic Kernel plugin behavior aligned and makes future comparison workflows easier to add. + +### API Endpoints + +The backend route module adds three authenticated endpoints: + +- `POST /api/search/documents` + - Runs hybrid search. + - Supports `query`, `doc_scope`, `top_n`, `document_id` or `document_ids`, optional tags, and optional scope hints. +- `POST /api/search/document-chunks` + - Resolves an accessible document and returns ordered chunks. + - Supports optional windowing by pages or chunks. +- `POST /api/search/document-summary` + - Resolves a document, retrieves its ordered chunks, windows the content, summarizes each window, and reduces the intermediate summaries into a final result. + - Supports optional focus instructions and target-length overrides. + +### Semantic Kernel Plugin Functions + +The core plugin is loaded for model-only and agent-backed kernel sessions and exposes three functions: + +- `search_documents` +- `retrieve_document_chunks` +- `summarize_document` + +The plugin resolves the current signed-in user at invocation time and calls the shared service directly rather than going through HTTP. + +### Summary Workflow + +The summarization workflow is hierarchical: + +1. Resolve an accessible document in personal, group, or public scope. +2. Retrieve ordered chunks for the document. +3. Build chunk windows by pages when page numbers are available, otherwise by chunk count. +4. Summarize each window to a configurable intermediate target. +5. Reduce intermediate summaries in batches until a single final summary remains or the configured reduction limit is reached. + +Default behavior keeps the first-pass window summaries and final summary at two pages unless the caller overrides those values. + +### File Structure + +- `application/single_app/functions_search.py` +- `application/single_app/functions_documents.py` +- `application/single_app/functions_search_service.py` +- `application/single_app/route_backend_search.py` +- `application/single_app/semantic_kernel_plugins/document_search_plugin.py` +- `application/single_app/semantic_kernel_loader.py` +- `functional_tests/test_document_search_api_and_plugin.py` + +## Usage Instructions + +### Backend API Usage + +Use the backend endpoints when server-side code or future UI features need direct access to search, chunk retrieval, or summarization without invoking an agent. + +Typical request patterns: + +- Search a workspace with default distilled behavior and an explicit `top_n`. +- Retrieve all ordered chunks for a document before downstream processing. +- Summarize a document with focus instructions such as risks, deadlines, implementation details, or policy requirements. + +### Agent Usage + +Agents can use the core plugin to: + +- Search for relevant chunks across accessible documents. +- Pull the full ordered chunk stream for one document when distilled search is not enough. +- Generate a hierarchical summary with caller-specified focus and length guidance. + +This is intended to improve document-grounded summarization today and to support future document-to-document or version-to-version comparison workflows without changing the core retrieval contract. + +## Testing and Validation + +### Test Coverage + +Functional coverage validates: + +- Shared search helper exposure and document-id propagation +- Shared search service entry points for search, retrieval, and summarization +- Backend route registration and authentication decorators +- Core plugin kernel-function contract +- Loader and Flask app registration + +Related test: + +- `functional_tests/test_document_search_api_and_plugin.py` + +### Known Limitations + +- Summaries are generated on demand and are not persisted or cached in this implementation. +- Very large documents can require multiple summarization stages and multiple model calls. +- Comparison workflows are not included yet, but the retrieval and scope-resolution shapes are designed to support them. \ No newline at end of file diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index da34cbadc..18196cf52 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -4,6 +4,16 @@ This page tracks notable Simple Chat releases and organizes the detailed change For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.241.007)** + +#### New Features + +* **Core Document Search And Summarization** + * Added a shared backend document search service with a dedicated authenticated API for hybrid search, ordered document-chunk retrieval, and on-demand document summarization. + * Added an always-loaded Semantic Kernel core plugin so every agent and model-only kernel session can search accessible workspace documents, pull full ordered chunk windows for a document, and run hierarchical summarization with optional focus and target-length guidance. + * The new summarization flow can now work across the whole document instead of relying only on distilled top search hits, which improves long-document summarization and creates a reusable foundation for future document comparison workflows. + * (Ref: `functions_search.py`, `functions_search_service.py`, `functions_documents.py`, `route_backend_search.py`, `document_search_plugin.py`, `semantic_kernel_loader.py`) + ### **(v0.241.006)** #### Bug Fixes diff --git a/functional_tests/test_document_search_api_and_plugin.py b/functional_tests/test_document_search_api_and_plugin.py new file mode 100644 index 000000000..e6fe84a02 --- /dev/null +++ b/functional_tests/test_document_search_api_and_plugin.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +# test_document_search_api_and_plugin.py +""" +Functional test for document search API and core Semantic Kernel plugin. +Version: 0.241.007 +Implemented in: 0.241.007 + +This test ensures that the shared search service, backend API, and always-loaded +Semantic Kernel document search plugin expose the expected contract for hybrid +search, ordered chunk retrieval, and hierarchical summarization. +""" + +import ast +import os +import sys + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +APP_ROOT = os.path.join(REPO_ROOT, 'application', 'single_app') + + +def read_source(*relative_parts): + file_path = os.path.join(REPO_ROOT, *relative_parts) + with open(file_path, 'r', encoding='utf-8') as source_file: + return source_file.read() + + +def parse_module(*relative_parts): + source = read_source(*relative_parts) + return source, ast.parse(source) + + +def get_function_names(module_ast): + return { + node.name + for node in module_ast.body + if isinstance(node, ast.FunctionDef) + } + + +def get_class_method_names(module_ast, class_name): + for node in module_ast.body: + if isinstance(node, ast.ClassDef) and node.name == class_name: + return { + child.name + for child in node.body + if isinstance(child, ast.FunctionDef) + } + return set() + + +def test_functions_search_contract(): + print('šŸ” Validating shared search helper contract...') + source, module_ast = parse_module('application', 'single_app', 'functions_search.py') + function_names = get_function_names(module_ast) + + required_functions = { + 'normalize_search_top_n', + 'normalize_search_scope', + 'normalize_search_id_list', + 'extract_search_results', + } + missing_functions = sorted(required_functions - function_names) + if missing_functions: + print(f'āŒ Missing search helper functions: {missing_functions}') + return False + + required_snippets = [ + 'SEARCH_DEFAULT_TOP_N = 12', + 'SEARCH_MAX_TOP_N = 500', + '"document_id": r.get("document_id")', + 'select=get_search_select_fields("personal")', + 'select=get_search_select_fields("group")', + 'select=get_search_select_fields("public")', + ] + missing_snippets = [snippet for snippet in required_snippets if snippet not in source] + if missing_snippets: + print(f'āŒ Missing search helper snippets: {missing_snippets}') + return False + + print('āœ… Shared search helpers expose normalized top-n, scope, and document ids') + return True + + +def test_search_service_contract(): + print('\nšŸ” Validating shared search service contract...') + source, module_ast = parse_module('application', 'single_app', 'functions_search_service.py') + function_names = get_function_names(module_ast) + + required_functions = { + 'resolve_document_context', + 'build_search_request', + 'search_documents', + 'build_document_chunk_windows', + 'get_document_chunks_payload', + 'summarize_document_content', + } + missing_functions = sorted(required_functions - function_names) + if missing_functions: + print(f'āŒ Missing search service functions: {missing_functions}') + return False + + if 'SUMMARY_DEFAULT_WINDOW_SUMMARY_TARGET = "2 pages"' not in source: + print('āŒ Missing hierarchical summary window target default') + return False + + if 'SUMMARY_DEFAULT_FINAL_TARGET = "2 pages"' not in source: + print('āŒ Missing hierarchical summary final target default') + return False + + print('āœ… Shared search service exposes retrieval and summarization entry points') + return True + + +def test_backend_search_routes(): + print('\nšŸ” Validating backend search route contract...') + source, _ = parse_module('application', 'single_app', 'route_backend_search.py') + + required_routes = [ + "/api/search/documents", + "/api/search/document-chunks", + "/api/search/document-summary", + ] + missing_routes = [route for route in required_routes if route not in source] + if missing_routes: + print(f'āŒ Missing backend search routes: {missing_routes}') + return False + + required_decorators = [ + '@swagger_route(security=get_auth_security())', + '@login_required', + '@user_required', + ] + missing_decorators = [decorator for decorator in required_decorators if decorator not in source] + if missing_decorators: + print(f'āŒ Missing backend route decorators: {missing_decorators}') + return False + + print('āœ… Backend search routes expose authenticated search, chunk retrieval, and summary endpoints') + return True + + +def test_document_search_plugin_contract(): + print('\nšŸ” Validating document search plugin contract...') + source, module_ast = parse_module('application', 'single_app', 'semantic_kernel_plugins', 'document_search_plugin.py') + method_names = get_class_method_names(module_ast, 'DocumentSearchPlugin') + + required_methods = { + 'search_documents', + 'retrieve_document_chunks', + 'summarize_document', + } + missing_methods = sorted(required_methods - method_names) + if missing_methods: + print(f'āŒ Missing plugin methods: {missing_methods}') + return False + + required_kernel_names = [ + "name='search_documents'", + "name='retrieve_document_chunks'", + "name='summarize_document'", + ] + missing_kernel_names = [value for value in required_kernel_names if value not in source] + if missing_kernel_names: + print(f'āŒ Missing kernel function names: {missing_kernel_names}') + return False + + print('āœ… Document search plugin exposes the expected kernel functions') + return True + + +def test_loader_and_app_registration(): + print('\nšŸ” Validating loader and app registration...') + loader_source = read_source('application', 'single_app', 'semantic_kernel_loader.py') + app_source = read_source('application', 'single_app', 'app.py') + + if 'from semantic_kernel_plugins.document_search_plugin import DocumentSearchPlugin' not in loader_source: + print('āŒ Semantic Kernel loader does not import DocumentSearchPlugin') + return False + + loader_registration_count = loader_source.count('load_document_search_plugin(kernel)') + if loader_registration_count < 2: + print(f'āŒ Expected document search plugin to be loaded in both SK paths, found {loader_registration_count}') + return False + + if 'from route_backend_search import *' not in app_source: + print('āŒ Flask app does not import route_backend_search') + return False + + if 'register_route_backend_search(app)' not in app_source: + print('āŒ Flask app does not register route_backend_search') + return False + + print('āœ… Loader and app register the core plugin and backend routes') + return True + + +def main(): + print('🧪 Running Document Search API And Plugin Tests') + print('=' * 64) + + tests = [ + test_functions_search_contract, + test_search_service_contract, + test_backend_search_routes, + test_document_search_plugin_contract, + test_loader_and_app_registration, + ] + + results = [] + for test in tests: + try: + results.append(test()) + except Exception as e: + print(f'āŒ {test.__name__} raised an exception: {e}') + results.append(False) + + passed_count = sum(results) + total_count = len(results) + + print('\n' + '=' * 64) + print(f'šŸ“Š Results: {passed_count}/{total_count} tests passed') + + return passed_count == total_count + + +if __name__ == '__main__': + if APP_ROOT not in sys.path: + sys.path.insert(0, APP_ROOT) + success = main() + sys.exit(0 if success else 1) \ No newline at end of file From 9bded1e913b87421d681f9420381c65449e65775 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 16 Apr 2026 10:17:16 -0400 Subject: [PATCH 2/7] initial --- application/single_app/app.py | 4 + .../single_app/collaboration_models.py | 619 +++++++++ application/single_app/config.py | 20 +- .../single_app/functions_collaboration.py | 1019 ++++++++++++++ application/single_app/functions_settings.py | 3 + .../single_app/route_backend_collaboration.py | 1021 ++++++++++++++ .../route_backend_conversation_export.py | 66 +- .../single_app/route_backend_thoughts.py | 39 + application/single_app/route_backend_users.py | 82 ++ .../route_frontend_conversations.py | 24 +- application/single_app/static/css/chats.css | 49 + .../static/js/chat/chat-collaboration.js | 1181 +++++++++++++++++ .../js/chat/chat-conversation-details.js | 378 +++++- .../static/js/chat/chat-conversations.js | 557 +++++++- .../single_app/static/js/chat/chat-export.js | 23 +- .../static/js/chat/chat-messages.js | 52 +- .../js/chat/chat-sidebar-conversations.js | 141 +- application/single_app/templates/chats.html | 98 +- .../COLLABORATIVE_CONVERSATIONS_FOUNDATION.md | 108 ++ .../COLLABORATIVE_CONVERSATION_UI_UPGRADE.md | 88 ++ ...ABORATION_INVITE_ACCESS_AND_ACTIONS_FIX.md | 60 + ...ABORATION_PARTICIPANT_PICKER_HELPER_FIX.md | 54 + ...TION_SHARED_CONVERSATION_MANAGEMENT_FIX.md | 78 ++ .../COLLABORATION_TYPING_DEACTIVATION_FIX.md | 55 + docs/explanation/release_notes.md | 6 + ...aboration_invite_access_and_actions_fix.py | 57 + ...collaboration_legacy_message_conversion.py | 139 ++ ...tion_shared_conversation_management_fix.py | 87 ++ ...t_collaborative_conversation_foundation.py | 158 +++ .../test_chat_collaboration_ui_scaffolding.py | 214 +++ 30 files changed, 6339 insertions(+), 141 deletions(-) create mode 100644 application/single_app/collaboration_models.py create mode 100644 application/single_app/functions_collaboration.py create mode 100644 application/single_app/route_backend_collaboration.py create mode 100644 application/single_app/static/js/chat/chat-collaboration.js create mode 100644 docs/explanation/features/COLLABORATIVE_CONVERSATIONS_FOUNDATION.md create mode 100644 docs/explanation/features/COLLABORATIVE_CONVERSATION_UI_UPGRADE.md create mode 100644 docs/explanation/fixes/COLLABORATION_INVITE_ACCESS_AND_ACTIONS_FIX.md create mode 100644 docs/explanation/fixes/COLLABORATION_PARTICIPANT_PICKER_HELPER_FIX.md create mode 100644 docs/explanation/fixes/COLLABORATION_SHARED_CONVERSATION_MANAGEMENT_FIX.md create mode 100644 docs/explanation/fixes/COLLABORATION_TYPING_DEACTIVATION_FIX.md create mode 100644 functional_tests/test_collaboration_invite_access_and_actions_fix.py create mode 100644 functional_tests/test_collaboration_legacy_message_conversion.py create mode 100644 functional_tests/test_collaboration_shared_conversation_management_fix.py create mode 100644 functional_tests/test_collaborative_conversation_foundation.py create mode 100644 ui_tests/test_chat_collaboration_ui_scaffolding.py diff --git a/application/single_app/app.py b/application/single_app/app.py index 0ab62afba..ac1c76265 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -80,6 +80,7 @@ from route_backend_thoughts import register_route_backend_thoughts from route_backend_speech import register_route_backend_speech from route_backend_tts import register_route_backend_tts +from route_backend_collaboration import register_route_backend_collaboration from route_enhanced_citations import register_enhanced_citations_routes from plugin_validation_endpoint import plugin_validation_bp from route_openapi import register_openapi_routes @@ -880,6 +881,9 @@ def list_semantic_kernel_plugins(): # ------------------- API Conversation Routes ------------ register_route_backend_conversations(app) +# ------------------- API Collaboration Routes ----------- +register_route_backend_collaboration(app) + # ------------------- API Documents Routes --------------- register_route_backend_documents(app) diff --git a/application/single_app/collaboration_models.py b/application/single_app/collaboration_models.py new file mode 100644 index 000000000..ecc4aacb6 --- /dev/null +++ b/application/single_app/collaboration_models.py @@ -0,0 +1,619 @@ +# collaboration_models.py + +"""Pure collaboration data-model helpers for multi-user conversations.""" + +from datetime import datetime, timedelta +import uuid + + +COLLABORATION_KIND = 'collaborative' + +PERSONAL_MULTI_USER_CHAT_TYPE = 'personal_multi_user' +GROUP_MULTI_USER_CHAT_TYPE = 'group_multi_user' + +MEMBERSHIP_STATUS_ACCEPTED = 'accepted' +MEMBERSHIP_STATUS_PENDING = 'pending' +MEMBERSHIP_STATUS_DECLINED = 'declined' +MEMBERSHIP_STATUS_REMOVED = 'removed' + +MEMBERSHIP_ROLE_OWNER = 'owner' +MEMBERSHIP_ROLE_ADMIN = 'admin' +MEMBERSHIP_ROLE_MEMBER = 'member' + +MESSAGE_KIND_HUMAN = 'human_message' +MESSAGE_KIND_AI_REQUEST = 'ai_request' +MESSAGE_KIND_ASSISTANT = 'assistant_response' + +DEFAULT_PERSONAL_COLLABORATION_TITLE = 'New Collaborative Conversation' +DEFAULT_GROUP_COLLABORATION_TITLE = 'New Group Collaborative Conversation' + + +def utc_now_iso(): + return datetime.utcnow().isoformat() + + +def add_seconds_to_iso(timestamp, seconds): + base_timestamp = datetime.fromisoformat(str(timestamp or utc_now_iso())) + return (base_timestamp + timedelta(seconds=int(seconds or 0))).isoformat() + + +def _clean_string(value): + return str(value or '').strip() + + +def normalize_collaboration_user(raw_user, fallback_user_id=None): + raw_user = raw_user or {} + if not isinstance(raw_user, dict): + raw_user = {} + + user_id = _clean_string( + raw_user.get('user_id') + or raw_user.get('userId') + or raw_user.get('id') + or fallback_user_id + ) + if not user_id: + return None + + display_name = _clean_string( + raw_user.get('display_name') + or raw_user.get('displayName') + or raw_user.get('name') + or raw_user.get('username') + ) + email = _clean_string( + raw_user.get('email') + or raw_user.get('mail') + or raw_user.get('userPrincipalName') + ) + + return { + 'user_id': user_id, + 'display_name': display_name or email or 'Unknown User', + 'email': email, + } + + +def build_collaboration_context(scope_type, scope_id, scope_name): + return [ + { + 'type': 'primary', + 'scope': _clean_string(scope_type), + 'id': _clean_string(scope_id), + 'name': _clean_string(scope_name), + } + ] + + +def build_default_collaboration_title(conversation_type, group_name=''): + normalized_type = _clean_string(conversation_type).lower() + normalized_group_name = _clean_string(group_name) + if normalized_type == 'group': + if normalized_group_name: + return f'{normalized_group_name} collaborative conversation' + return DEFAULT_GROUP_COLLABORATION_TITLE + return DEFAULT_PERSONAL_COLLABORATION_TITLE + + +def refresh_personal_participant_indexes(conversation_doc): + participants = conversation_doc.setdefault('participants', []) + accepted_participant_ids = [] + pending_participant_ids = [] + owner_user_ids = [] + admin_user_ids = [] + + for participant in participants: + participant_user_id = _clean_string(participant.get('user_id')) + participant_status = _clean_string(participant.get('status')) + participant_role = _clean_string(participant.get('role')) + + if not participant_user_id: + continue + + if participant_status == MEMBERSHIP_STATUS_ACCEPTED: + if participant_user_id not in accepted_participant_ids: + accepted_participant_ids.append(participant_user_id) + if participant_role == MEMBERSHIP_ROLE_OWNER and participant_user_id not in owner_user_ids: + owner_user_ids.append(participant_user_id) + if participant_role == MEMBERSHIP_ROLE_ADMIN and participant_user_id not in admin_user_ids: + admin_user_ids.append(participant_user_id) + elif participant_status == MEMBERSHIP_STATUS_PENDING: + if participant_user_id not in pending_participant_ids: + pending_participant_ids.append(participant_user_id) + + conversation_doc['accepted_participant_ids'] = accepted_participant_ids + conversation_doc['pending_participant_ids'] = pending_participant_ids + conversation_doc['owner_user_ids'] = owner_user_ids + conversation_doc['admin_user_ids'] = admin_user_ids + conversation_doc['participant_count'] = len(accepted_participant_ids) + conversation_doc['pending_invite_count'] = len(pending_participant_ids) + return conversation_doc + + +def build_personal_collaboration_conversation( + title, + creator_user, + invited_participants=None, + conversation_id=None, + created_at=None, +): + created_at = _clean_string(created_at) or utc_now_iso() + conversation_id = _clean_string(conversation_id) or str(uuid.uuid4()) + creator_summary = normalize_collaboration_user(creator_user) + if not creator_summary: + raise ValueError('creator_user is required') + + conversation_title = _clean_string(title) or build_default_collaboration_title('personal') + + participants = [ + { + 'user_id': creator_summary['user_id'], + 'display_name': creator_summary['display_name'], + 'email': creator_summary['email'], + 'role': MEMBERSHIP_ROLE_OWNER, + 'status': MEMBERSHIP_STATUS_ACCEPTED, + 'invited_at': created_at, + 'joined_at': created_at, + } + ] + existing_user_ids = {creator_summary['user_id']} + + for raw_participant in invited_participants or []: + participant_summary = normalize_collaboration_user(raw_participant) + if not participant_summary: + continue + if participant_summary['user_id'] in existing_user_ids: + continue + + existing_user_ids.add(participant_summary['user_id']) + participants.append({ + 'user_id': participant_summary['user_id'], + 'display_name': participant_summary['display_name'], + 'email': participant_summary['email'], + 'role': MEMBERSHIP_ROLE_MEMBER, + 'status': MEMBERSHIP_STATUS_PENDING, + 'invited_at': created_at, + }) + + conversation_doc = { + 'id': conversation_id, + 'conversation_kind': COLLABORATION_KIND, + 'chat_type': PERSONAL_MULTI_USER_CHAT_TYPE, + 'title': conversation_title, + 'created_at': created_at, + 'updated_at': created_at, + 'last_message_at': None, + 'last_message_preview': '', + 'status': 'active', + 'created_by_user_id': creator_summary['user_id'], + 'created_by_display_name': creator_summary['display_name'], + 'scope': { + 'type': 'personal', + 'group_id': None, + 'group_name': None, + 'visibility_mode': 'invited_members', + 'allowed_scope_types': ['personal', 'public'], + }, + 'context': build_collaboration_context( + 'personal', + creator_summary['user_id'], + creator_summary['display_name'], + ), + 'scope_locked': True, + 'locked_contexts': [{'scope': 'personal', 'id': creator_summary['user_id']}], + 'participants': participants, + 'conversation_settings': { + 'ai_invocation_mode': 'explicit_only', + 'reply_mode_enabled': True, + }, + 'message_count': 0, + 'tags': [], + } + return refresh_personal_participant_indexes(conversation_doc) + + +def build_group_collaboration_conversation( + title, + creator_user, + group_id, + group_name, + conversation_id=None, + created_at=None, +): + created_at = _clean_string(created_at) or utc_now_iso() + conversation_id = _clean_string(conversation_id) or str(uuid.uuid4()) + creator_summary = normalize_collaboration_user(creator_user) + if not creator_summary: + raise ValueError('creator_user is required') + + normalized_group_id = _clean_string(group_id) + if not normalized_group_id: + raise ValueError('group_id is required') + + normalized_group_name = _clean_string(group_name) or 'Group Workspace' + conversation_title = _clean_string(title) or build_default_collaboration_title( + 'group', + normalized_group_name, + ) + + return { + 'id': conversation_id, + 'conversation_kind': COLLABORATION_KIND, + 'chat_type': GROUP_MULTI_USER_CHAT_TYPE, + 'title': conversation_title, + 'created_at': created_at, + 'updated_at': created_at, + 'last_message_at': None, + 'last_message_preview': '', + 'status': 'active', + 'created_by_user_id': creator_summary['user_id'], + 'created_by_display_name': creator_summary['display_name'], + 'owner_user_ids': [creator_summary['user_id']], + 'accepted_participant_ids': [creator_summary['user_id']], + 'pending_participant_ids': [], + 'participant_count': 1, + 'pending_invite_count': 0, + 'scope': { + 'type': 'group', + 'group_id': normalized_group_id, + 'group_name': normalized_group_name, + 'visibility_mode': 'group_membership', + 'allowed_scope_types': ['group', 'public'], + }, + 'context': build_collaboration_context('group', normalized_group_id, normalized_group_name), + 'scope_locked': True, + 'locked_contexts': [{'scope': 'group', 'id': normalized_group_id}], + 'participants': [ + { + 'user_id': creator_summary['user_id'], + 'display_name': creator_summary['display_name'], + 'email': creator_summary['email'], + 'role': MEMBERSHIP_ROLE_OWNER, + 'status': MEMBERSHIP_STATUS_ACCEPTED, + 'invited_at': created_at, + 'joined_at': created_at, + } + ], + 'conversation_settings': { + 'ai_invocation_mode': 'explicit_only', + 'reply_mode_enabled': True, + }, + 'message_count': 0, + 'tags': [], + } + + +def get_collaboration_user_state_doc_id(user_id, conversation_id): + return f'{_clean_string(user_id)}:{_clean_string(conversation_id)}' + + +def build_collaboration_user_state( + conversation_doc, + user_summary, + role, + membership_status, + invited_by_user_id=None, + created_at=None, +): + normalized_user = normalize_collaboration_user(user_summary) + if not normalized_user: + raise ValueError('user_summary is required') + + created_at = _clean_string(created_at) or utc_now_iso() + conversation_id = _clean_string(conversation_doc.get('id')) + scope = conversation_doc.get('scope', {}) if isinstance(conversation_doc, dict) else {} + + state_doc = { + 'id': get_collaboration_user_state_doc_id(normalized_user['user_id'], conversation_id), + 'conversation_kind': COLLABORATION_KIND, + 'conversation_id': conversation_id, + 'user_id': normalized_user['user_id'], + 'user_display_name': normalized_user['display_name'], + 'user_email': normalized_user['email'], + 'chat_type': conversation_doc.get('chat_type'), + 'scope_type': scope.get('type'), + 'group_id': scope.get('group_id'), + 'group_name': scope.get('group_name'), + 'title_snapshot': conversation_doc.get('title'), + 'role': _clean_string(role) or MEMBERSHIP_ROLE_MEMBER, + 'membership_status': _clean_string(membership_status) or MEMBERSHIP_STATUS_PENDING, + 'invited_by_user_id': _clean_string(invited_by_user_id), + 'created_at': created_at, + 'updated_at': created_at, + 'last_read_message_id': None, + 'last_read_at': None, + 'last_seen_at': None, + 'is_hidden': False, + 'is_pinned': False, + } + + if state_doc['membership_status'] == MEMBERSHIP_STATUS_ACCEPTED: + state_doc['joined_at'] = created_at + + return state_doc + + +def apply_personal_invite_response(conversation_doc, invited_user_id, action, responded_at=None): + normalized_user_id = _clean_string(invited_user_id) + normalized_action = _clean_string(action).lower() + if normalized_action not in ('accept', 'decline'): + raise ValueError('action must be accept or decline') + + responded_at = _clean_string(responded_at) or utc_now_iso() + + participant_record = None + for participant in conversation_doc.get('participants', []): + if _clean_string(participant.get('user_id')) != normalized_user_id: + continue + + if _clean_string(participant.get('status')) != MEMBERSHIP_STATUS_PENDING: + raise ValueError('participant invite is not pending') + + participant_record = participant + if normalized_action == 'accept': + participant['status'] = MEMBERSHIP_STATUS_ACCEPTED + participant['joined_at'] = responded_at + else: + participant['status'] = MEMBERSHIP_STATUS_DECLINED + participant['responded_at'] = responded_at + break + + if participant_record is None: + raise LookupError('pending participant not found') + + conversation_doc['updated_at'] = responded_at + refresh_personal_participant_indexes(conversation_doc) + return participant_record + + +def add_personal_pending_participants(conversation_doc, new_participants, invited_at=None): + invited_at = _clean_string(invited_at) or utc_now_iso() + existing_by_user_id = { + _clean_string(participant.get('user_id')): participant + for participant in conversation_doc.get('participants', []) + if _clean_string(participant.get('user_id')) + } + + added_participants = [] + for raw_participant in new_participants or []: + participant_summary = normalize_collaboration_user(raw_participant) + if not participant_summary: + continue + + participant_user_id = participant_summary['user_id'] + existing_participant = existing_by_user_id.get(participant_user_id) + if existing_participant: + existing_status = _clean_string(existing_participant.get('status')) + if existing_status in (MEMBERSHIP_STATUS_ACCEPTED, MEMBERSHIP_STATUS_PENDING): + continue + + existing_participant['display_name'] = participant_summary['display_name'] + existing_participant['email'] = participant_summary['email'] + existing_participant['status'] = MEMBERSHIP_STATUS_PENDING + existing_participant['role'] = MEMBERSHIP_ROLE_MEMBER + existing_participant['invited_at'] = invited_at + existing_participant.pop('joined_at', None) + existing_participant.pop('removed_at', None) + existing_participant.pop('responded_at', None) + added_participants.append(existing_participant) + continue + + participant_record = { + 'user_id': participant_user_id, + 'display_name': participant_summary['display_name'], + 'email': participant_summary['email'], + 'role': MEMBERSHIP_ROLE_MEMBER, + 'status': MEMBERSHIP_STATUS_PENDING, + 'invited_at': invited_at, + } + conversation_doc.setdefault('participants', []).append(participant_record) + existing_by_user_id[participant_user_id] = participant_record + added_participants.append(participant_record) + + if added_participants: + conversation_doc['updated_at'] = invited_at + refresh_personal_participant_indexes(conversation_doc) + + return added_participants + + +def remove_personal_participant(conversation_doc, participant_user_id, removed_at=None): + normalized_user_id = _clean_string(participant_user_id) + removed_at = _clean_string(removed_at) or utc_now_iso() + owner_ids = set(conversation_doc.get('owner_user_ids', []) or []) + if normalized_user_id in owner_ids: + raise ValueError('owners cannot be removed from personal collaborative conversations') + + removed_participant = None + for participant in conversation_doc.get('participants', []): + if _clean_string(participant.get('user_id')) != normalized_user_id: + continue + + participant['status'] = MEMBERSHIP_STATUS_REMOVED + participant['removed_at'] = removed_at + removed_participant = participant + break + + if removed_participant is None: + raise LookupError('participant not found') + + conversation_doc['updated_at'] = removed_at + refresh_personal_participant_indexes(conversation_doc) + return removed_participant + + +def ensure_group_participant_record(conversation_doc, user_summary, joined_at=None): + joined_at = _clean_string(joined_at) or utc_now_iso() + normalized_user = normalize_collaboration_user(user_summary) + if not normalized_user: + raise ValueError('user_summary is required') + + participant_record = None + for participant in conversation_doc.get('participants', []): + if _clean_string(participant.get('user_id')) != normalized_user['user_id']: + continue + + participant['display_name'] = normalized_user['display_name'] + participant['email'] = normalized_user['email'] + participant['status'] = MEMBERSHIP_STATUS_ACCEPTED + participant.setdefault('joined_at', joined_at) + participant_record = participant + break + + if participant_record is None: + participant_record = { + 'user_id': normalized_user['user_id'], + 'display_name': normalized_user['display_name'], + 'email': normalized_user['email'], + 'role': MEMBERSHIP_ROLE_MEMBER, + 'status': MEMBERSHIP_STATUS_ACCEPTED, + 'invited_at': joined_at, + 'joined_at': joined_at, + } + conversation_doc.setdefault('participants', []).append(participant_record) + + accepted_participant_ids = list(conversation_doc.get('accepted_participant_ids', []) or []) + if normalized_user['user_id'] not in accepted_participant_ids: + accepted_participant_ids.append(normalized_user['user_id']) + conversation_doc['accepted_participant_ids'] = accepted_participant_ids + conversation_doc['participant_count'] = len(accepted_participant_ids) + conversation_doc['updated_at'] = joined_at + return participant_record + + +def _truncate_preview(content, max_length=160): + content = _clean_string(content) + if len(content) <= int(max_length): + return content + return f"{content[: int(max_length) - 3]}..." + + +def build_collaboration_message_doc( + conversation_id, + sender_user, + content, + reply_to_message_id=None, + message_kind=MESSAGE_KIND_HUMAN, + message_id=None, + timestamp=None, +): + normalized_sender = normalize_collaboration_user(sender_user) + if not normalized_sender: + raise ValueError('sender_user is required') + + normalized_conversation_id = _clean_string(conversation_id) + if not normalized_conversation_id: + raise ValueError('conversation_id is required') + + normalized_content = str(content or '') + normalized_timestamp = _clean_string(timestamp) or utc_now_iso() + normalized_message_kind = _clean_string(message_kind) or MESSAGE_KIND_HUMAN + + if normalized_message_kind == MESSAGE_KIND_ASSISTANT: + role = 'assistant' + else: + role = 'user' + + return { + 'id': _clean_string(message_id) or f'{normalized_conversation_id}_{uuid.uuid4().hex}', + 'conversation_id': normalized_conversation_id, + 'role': role, + 'message_kind': normalized_message_kind, + 'content': normalized_content, + 'reply_to_message_id': _clean_string(reply_to_message_id) or None, + 'timestamp': normalized_timestamp, + 'metadata': { + 'sender': normalized_sender, + 'explicit_ai_invocation': normalized_message_kind == MESSAGE_KIND_AI_REQUEST, + 'last_message_preview': _truncate_preview(normalized_content), + }, + } + + +def build_collaboration_message_doc_from_legacy( + conversation_id, + legacy_message, + default_sender_user, +): + legacy_message = legacy_message or {} + legacy_role = _clean_string(legacy_message.get('role')).lower() + legacy_metadata = legacy_message.get('metadata', {}) if isinstance(legacy_message.get('metadata'), dict) else {} + + if legacy_role in ('assistant_artifact', 'assistant_artifact_chunk'): + return None + + content = str(legacy_message.get('content') or '') + message_kind = MESSAGE_KIND_HUMAN + sender_user = normalize_collaboration_user( + legacy_metadata.get('user_info') or default_sender_user, + ) + + if legacy_role == 'assistant': + message_kind = MESSAGE_KIND_ASSISTANT + sender_user = { + 'user_id': 'assistant', + 'display_name': _clean_string(legacy_message.get('agent_display_name')) or 'AI', + 'email': '', + } + elif legacy_role == 'safety': + message_kind = MESSAGE_KIND_ASSISTANT + sender_user = { + 'user_id': 'assistant', + 'display_name': 'Content Safety', + 'email': '', + } + elif legacy_role == 'file': + filename = _clean_string(legacy_message.get('filename')) or 'file' + content = f'[File shared] {filename}' + elif legacy_role == 'image': + is_user_upload = bool(legacy_metadata.get('is_user_upload')) + if is_user_upload: + filename = _clean_string(legacy_message.get('filename')) or 'image' + content = f'[Uploaded image] {filename}' + else: + message_kind = MESSAGE_KIND_ASSISTANT + sender_user = { + 'user_id': 'assistant', + 'display_name': _clean_string(legacy_message.get('agent_display_name')) or 'AI', + 'email': '', + } + image_url = _clean_string(legacy_message.get('content')) + content = f'[Generated image] {image_url}' if image_url else '[Generated image]' + elif legacy_role not in ('user', '') and not content.strip(): + return None + + if not sender_user: + return None + + collaboration_message = build_collaboration_message_doc( + conversation_id=conversation_id, + sender_user=sender_user, + content=content, + reply_to_message_id=legacy_message.get('reply_to_message_id'), + message_kind=message_kind, + timestamp=legacy_message.get('timestamp'), + ) + + collaboration_message['metadata']['source_message_id'] = _clean_string(legacy_message.get('id')) or None + collaboration_message['metadata']['source_role'] = legacy_role or None + if legacy_role == 'image': + collaboration_message['metadata']['legacy_image_url'] = _clean_string(legacy_message.get('content')) or None + if legacy_role == 'file': + collaboration_message['metadata']['legacy_filename'] = _clean_string(legacy_message.get('filename')) or None + + for optional_key in ( + 'model_deployment_name', + 'augmented', + 'hybrid_citations', + 'web_search_citations', + 'agent_citations', + 'agent_display_name', + 'agent_name', + 'extracted_text', + 'vision_analysis', + 'filename', + ): + if optional_key in legacy_message: + collaboration_message[optional_key] = legacy_message.get(optional_key) + + return collaboration_message \ No newline at end of file diff --git a/application/single_app/config.py b/application/single_app/config.py index 3ccb6ca98..cb16bfd40 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.241.007" +VERSION = "0.241.012" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') @@ -320,6 +320,24 @@ def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str: partition_key=PartitionKey(path="/conversation_id") ) +cosmos_collaboration_conversations_container_name = "collaboration_conversations" +cosmos_collaboration_conversations_container = cosmos_database.create_container_if_not_exists( + id=cosmos_collaboration_conversations_container_name, + partition_key=PartitionKey(path="/id") +) + +cosmos_collaboration_messages_container_name = "collaboration_messages" +cosmos_collaboration_messages_container = cosmos_database.create_container_if_not_exists( + id=cosmos_collaboration_messages_container_name, + partition_key=PartitionKey(path="/conversation_id") +) + +cosmos_collaboration_user_state_container_name = "collaboration_user_state" +cosmos_collaboration_user_state_container = cosmos_database.create_container_if_not_exists( + id=cosmos_collaboration_user_state_container_name, + partition_key=PartitionKey(path="/user_id") +) + cosmos_settings_container_name = "settings" cosmos_settings_container = cosmos_database.create_container_if_not_exists( id=cosmos_settings_container_name, diff --git a/application/single_app/functions_collaboration.py b/application/single_app/functions_collaboration.py new file mode 100644 index 000000000..e081089e0 --- /dev/null +++ b/application/single_app/functions_collaboration.py @@ -0,0 +1,1019 @@ +# functions_collaboration.py + +"""Persistence, authorization, and serialization helpers for collaborative conversations.""" + +from config import * +from collaboration_models import ( + COLLABORATION_KIND, + GROUP_MULTI_USER_CHAT_TYPE, + MEMBERSHIP_ROLE_ADMIN, + MEMBERSHIP_ROLE_MEMBER, + MEMBERSHIP_ROLE_OWNER, + MEMBERSHIP_STATUS_ACCEPTED, + MEMBERSHIP_STATUS_DECLINED, + MEMBERSHIP_STATUS_PENDING, + MEMBERSHIP_STATUS_REMOVED, + MESSAGE_KIND_HUMAN, + PERSONAL_MULTI_USER_CHAT_TYPE, + add_personal_pending_participants, + apply_personal_invite_response, + build_collaboration_message_doc, + build_collaboration_message_doc_from_legacy, + build_collaboration_user_state, + build_group_collaboration_conversation, + build_personal_collaboration_conversation, + ensure_group_participant_record, + get_collaboration_user_state_doc_id, + refresh_personal_participant_indexes, + remove_personal_participant, + utc_now_iso, +) +from functions_appinsights import log_event +from functions_group import ( + assert_group_role, + check_group_status_allows_operation, + find_group_by_id, + get_user_groups, +) +from functions_message_artifacts import filter_assistant_artifact_items +from functions_thoughts import delete_thoughts_for_conversation, get_thoughts_for_message + + +PERSONAL_COLLABORATION_MANAGER_ROLES = { + MEMBERSHIP_ROLE_OWNER, + MEMBERSHIP_ROLE_ADMIN, +} + + +def is_collaboration_conversation(conversation_doc): + return bool((conversation_doc or {}).get('conversation_kind') == COLLABORATION_KIND) + + +def is_personal_collaboration_conversation(conversation_doc): + return bool((conversation_doc or {}).get('chat_type') == PERSONAL_MULTI_USER_CHAT_TYPE) + + +def is_group_collaboration_conversation(conversation_doc): + return bool((conversation_doc or {}).get('chat_type') == GROUP_MULTI_USER_CHAT_TYPE) + + +def get_collaboration_conversation(conversation_id): + return cosmos_collaboration_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, + ) + + +def get_collaboration_user_state(user_id, conversation_id): + return cosmos_collaboration_user_state_container.read_item( + item=get_collaboration_user_state_doc_id(user_id, conversation_id), + partition_key=user_id, + ) + + +def get_collaboration_message(message_id): + query = 'SELECT TOP 1 * FROM c WHERE c.id = @message_id' + items = list(cosmos_collaboration_messages_container.query_items( + query=query, + parameters=[{'name': '@message_id', 'value': message_id}], + enable_cross_partition_query=True, + )) + if not items: + raise CosmosResourceNotFoundError(message='Collaborative message not found') + return items[0] + + +def get_personal_collaboration_participant(conversation_doc, participant_user_id): + normalized_user_id = str(participant_user_id or '').strip() + for participant in list((conversation_doc or {}).get('participants', []) or []): + if str(participant.get('user_id') or '').strip() == normalized_user_id: + return participant + return None + + +def get_personal_collaboration_role(conversation_doc, participant_user_id, user_state=None): + if user_state and str(user_state.get('role') or '').strip(): + return str(user_state.get('role') or '').strip() + + participant = get_personal_collaboration_participant(conversation_doc, participant_user_id) + if not participant: + return '' + return str(participant.get('role') or '').strip() + + +def serialize_collaboration_message(message_doc): + metadata = message_doc.get('metadata', {}) if isinstance(message_doc, dict) else {} + return { + 'id': message_doc.get('id'), + 'conversation_id': message_doc.get('conversation_id'), + 'role': message_doc.get('role'), + 'message_kind': message_doc.get('message_kind', MESSAGE_KIND_HUMAN), + 'content': message_doc.get('content', ''), + 'reply_to_message_id': message_doc.get('reply_to_message_id'), + 'timestamp': message_doc.get('timestamp'), + 'sender': metadata.get('sender', {}), + 'metadata': metadata, + 'explicit_ai_invocation': bool(metadata.get('explicit_ai_invocation', False)), + 'model_deployment_name': message_doc.get('model_deployment_name'), + 'augmented': bool(message_doc.get('augmented', False)), + 'hybrid_citations': list(message_doc.get('hybrid_citations', []) or []), + 'web_search_citations': list(message_doc.get('web_search_citations', []) or []), + 'agent_citations': list(message_doc.get('agent_citations', []) or []), + 'agent_display_name': message_doc.get('agent_display_name'), + 'agent_name': message_doc.get('agent_name'), + } + + +def serialize_collaboration_conversation(conversation_doc, current_user_id, user_state=None): + conversation_doc = conversation_doc or {} + participants = list(conversation_doc.get('participants', []) or []) + membership_status = None + if user_state: + membership_status = user_state.get('membership_status') + elif current_user_id in set(conversation_doc.get('accepted_participant_ids', []) or []): + membership_status = MEMBERSHIP_STATUS_ACCEPTED + elif is_group_collaboration_conversation(conversation_doc): + membership_status = 'group_member' + + owner_user_ids = list(conversation_doc.get('owner_user_ids', []) or []) + admin_user_ids = list(conversation_doc.get('admin_user_ids', []) or []) + scope = conversation_doc.get('scope', {}) if isinstance(conversation_doc.get('scope'), dict) else {} + current_user_role = get_personal_collaboration_role( + conversation_doc, + current_user_id, + user_state=user_state, + ) + can_manage_members = bool( + is_personal_collaboration_conversation(conversation_doc) + and membership_status == MEMBERSHIP_STATUS_ACCEPTED + and current_user_role in PERSONAL_COLLABORATION_MANAGER_ROLES + ) + can_manage_roles = bool( + is_personal_collaboration_conversation(conversation_doc) + and membership_status == MEMBERSHIP_STATUS_ACCEPTED + and current_user_role == MEMBERSHIP_ROLE_OWNER + ) + can_accept_invite = membership_status == MEMBERSHIP_STATUS_PENDING + can_post_messages = bool( + is_group_collaboration_conversation(conversation_doc) + or membership_status == MEMBERSHIP_STATUS_ACCEPTED + ) + can_delete_conversation = bool( + is_personal_collaboration_conversation(conversation_doc) + and membership_status == MEMBERSHIP_STATUS_ACCEPTED + and current_user_role == MEMBERSHIP_ROLE_OWNER + ) + can_leave_conversation = bool( + is_personal_collaboration_conversation(conversation_doc) + and membership_status == MEMBERSHIP_STATUS_ACCEPTED + ) + + return { + 'id': conversation_doc.get('id'), + 'title': conversation_doc.get('title', ''), + 'conversation_kind': conversation_doc.get('conversation_kind'), + 'chat_type': conversation_doc.get('chat_type'), + 'status': conversation_doc.get('status', 'active'), + 'created_at': conversation_doc.get('created_at'), + 'updated_at': conversation_doc.get('updated_at'), + 'last_message_at': conversation_doc.get('last_message_at'), + 'last_message_preview': conversation_doc.get('last_message_preview', ''), + 'message_count': conversation_doc.get('message_count', 0), + 'participant_count': conversation_doc.get('participant_count', 0), + 'pending_invite_count': conversation_doc.get('pending_invite_count', 0), + 'participants': participants, + 'accepted_participant_ids': list(conversation_doc.get('accepted_participant_ids', []) or []), + 'pending_participant_ids': list(conversation_doc.get('pending_participant_ids', []) or []), + 'owner_user_ids': owner_user_ids, + 'admin_user_ids': admin_user_ids, + 'current_user_role': current_user_role, + 'membership_status': membership_status, + 'can_manage_members': can_manage_members, + 'can_manage_roles': can_manage_roles, + 'can_accept_invite': can_accept_invite, + 'can_post_messages': can_post_messages, + 'can_delete_conversation': can_delete_conversation, + 'can_leave_conversation': can_leave_conversation, + 'scope': scope, + 'context': list(conversation_doc.get('context', []) or []), + 'scope_locked': conversation_doc.get('scope_locked', True), + 'locked_contexts': list(conversation_doc.get('locked_contexts', []) or []), + 'conversation_settings': dict(conversation_doc.get('conversation_settings', {}) or {}), + 'group_id': scope.get('group_id'), + 'group_name': scope.get('group_name'), + 'last_updated': conversation_doc.get('updated_at'), + 'is_pinned': bool((user_state or {}).get('is_pinned', False)), + 'is_hidden': bool((user_state or {}).get('is_hidden', False)), + 'classification': list(conversation_doc.get('classification', []) or []), + 'tags': list(conversation_doc.get('tags', []) or []), + 'strict': bool(conversation_doc.get('strict', False)), + 'summary': conversation_doc.get('summary'), + 'has_unread_assistant_response': False, + 'last_unread_assistant_message_id': None, + 'last_unread_assistant_at': None, + 'user_id': conversation_doc.get('created_by_user_id'), + 'source_conversation_id': conversation_doc.get('source_conversation_id'), + } + + +def get_personal_collaboration_conversation_by_source_conversation(source_conversation_id): + query = ( + 'SELECT TOP 1 * FROM c WHERE c.conversation_kind = @conversation_kind ' + 'AND c.chat_type = @chat_type AND c.source_conversation_id = @source_conversation_id' + ) + items = list(cosmos_collaboration_conversations_container.query_items( + query=query, + parameters=[ + {'name': '@conversation_kind', 'value': COLLABORATION_KIND}, + {'name': '@chat_type', 'value': PERSONAL_MULTI_USER_CHAT_TYPE}, + {'name': '@source_conversation_id', 'value': source_conversation_id}, + ], + enable_cross_partition_query=True, + )) + return items[0] if items else None + + +def _is_eligible_legacy_personal_conversation(source_conversation_doc): + chat_type = str(source_conversation_doc.get('chat_type') or '').strip().lower() + if chat_type.startswith('group') or chat_type.startswith('public'): + return False + + primary_context = next( + ( + context_item + for context_item in list(source_conversation_doc.get('context', []) or []) + if context_item.get('type') == 'primary' + ), + None, + ) + if primary_context and str(primary_context.get('scope') or '').strip().lower() in ('group', 'public'): + return False + + return True + + +def _copy_legacy_personal_messages_to_collaboration(source_conversation_id, collaboration_conversation_id, owner_user): + query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id ORDER BY c.timestamp ASC' + raw_messages = list(cosmos_messages_container.query_items( + query=query, + parameters=[{'name': '@conversation_id', 'value': source_conversation_id}], + partition_key=source_conversation_id, + )) + raw_messages = filter_assistant_artifact_items(raw_messages) + + copied_messages = [] + for raw_message in raw_messages: + collaboration_message = build_collaboration_message_doc_from_legacy( + collaboration_conversation_id, + raw_message, + owner_user, + ) + if not collaboration_message: + continue + + metadata = collaboration_message.setdefault('metadata', {}) + metadata.setdefault('source_message_id', raw_message.get('id')) + metadata.setdefault('source_conversation_id', source_conversation_id) + metadata.setdefault('source_thought_user_id', str((owner_user or {}).get('user_id') or '').strip()) + + cosmos_collaboration_messages_container.upsert_item(collaboration_message) + copied_messages.append(collaboration_message) + + return copied_messages + + +def ensure_personal_collaboration_for_legacy_conversation(source_conversation_id, owner_user, invited_participants=None): + source_conversation_doc = cosmos_conversations_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + owner_summary = owner_user or {} + owner_user_id = str(owner_summary.get('user_id') or '').strip() + if not owner_user_id: + raise PermissionError('User not authenticated') + + if str(source_conversation_doc.get('user_id') or '').strip() != owner_user_id: + raise PermissionError('Only the conversation owner can convert this conversation') + + if not _is_eligible_legacy_personal_conversation(source_conversation_doc): + raise PermissionError('Only personal single-user conversations can be converted into personal collaborative conversations') + + collaboration_conversation_doc = None + linked_collaboration_id = str(source_conversation_doc.get('collaboration_conversation_id') or '').strip() + if linked_collaboration_id: + try: + collaboration_conversation_doc = get_collaboration_conversation(linked_collaboration_id) + except CosmosResourceNotFoundError: + collaboration_conversation_doc = None + + if collaboration_conversation_doc is None: + collaboration_conversation_doc = get_personal_collaboration_conversation_by_source_conversation( + source_conversation_id, + ) + + if collaboration_conversation_doc is not None: + invited_state_docs = [] + if invited_participants: + collaboration_conversation_doc, invited_state_docs = invite_personal_collaboration_participants( + collaboration_conversation_doc.get('id'), + owner_user_id, + invited_participants, + ) + return collaboration_conversation_doc, invited_state_docs, False, source_conversation_doc + + collaboration_conversation_doc, user_state_docs = create_personal_collaboration_conversation_record( + title=source_conversation_doc.get('title') or '', + creator_user=owner_summary, + invited_participants=invited_participants, + ) + invited_state_docs = [ + state_doc + for state_doc in user_state_docs + if state_doc.get('user_id') != owner_user_id + ] + + collaboration_conversation_doc['source_conversation_id'] = source_conversation_id + collaboration_conversation_doc['classification'] = list(source_conversation_doc.get('classification', []) or []) + collaboration_conversation_doc['tags'] = list(source_conversation_doc.get('tags', []) or []) + collaboration_conversation_doc['strict'] = bool(source_conversation_doc.get('strict', False)) + collaboration_conversation_doc['summary'] = source_conversation_doc.get('summary') + + source_context = list(source_conversation_doc.get('context', []) or []) + if source_context: + collaboration_conversation_doc['context'] = source_context + source_scope_locked = source_conversation_doc.get('scope_locked') + if source_scope_locked is not None: + collaboration_conversation_doc['scope_locked'] = bool(source_scope_locked) + source_locked_contexts = list(source_conversation_doc.get('locked_contexts', []) or []) + if source_locked_contexts: + collaboration_conversation_doc['locked_contexts'] = source_locked_contexts + + copied_messages = _copy_legacy_personal_messages_to_collaboration( + source_conversation_id, + collaboration_conversation_doc.get('id'), + owner_summary, + ) + if copied_messages: + last_copied_message = copied_messages[-1] + collaboration_conversation_doc['last_message_at'] = last_copied_message.get('timestamp') + collaboration_conversation_doc['last_message_preview'] = ( + (last_copied_message.get('metadata') or {}).get('last_message_preview') or '' + ) + collaboration_conversation_doc['updated_at'] = last_copied_message.get('timestamp') + collaboration_conversation_doc['message_count'] = len(copied_messages) + + cosmos_collaboration_conversations_container.upsert_item(collaboration_conversation_doc) + + conversion_timestamp = utc_now_iso() + source_conversation_doc['collaboration_conversation_id'] = collaboration_conversation_doc.get('id') + source_conversation_doc['converted_to_collaboration_at'] = conversion_timestamp + source_conversation_doc['is_hidden'] = True + source_conversation_doc['last_updated'] = conversion_timestamp + cosmos_conversations_container.upsert_item(source_conversation_doc) + + log_event( + '[Collaboration] Converted personal conversation into collaborative conversation', + extra={ + 'source_conversation_id': source_conversation_id, + 'conversation_id': collaboration_conversation_doc.get('id'), + 'created_by_user_id': owner_user_id, + 'copied_message_count': len(copied_messages), + }, + level=logging.INFO, + ) + return collaboration_conversation_doc, invited_state_docs, True, source_conversation_doc + + +def create_personal_collaboration_conversation_record(title, creator_user, invited_participants=None): + conversation_doc = build_personal_collaboration_conversation( + title=title, + creator_user=creator_user, + invited_participants=invited_participants, + ) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + user_states = [] + for participant in conversation_doc.get('participants', []): + membership_status = participant.get('status') + role = participant.get('role') + invited_by_user_id = '' + if participant.get('user_id') != conversation_doc.get('created_by_user_id'): + invited_by_user_id = conversation_doc.get('created_by_user_id') + state_doc = build_collaboration_user_state( + conversation_doc=conversation_doc, + user_summary=participant, + role=role, + membership_status=membership_status, + invited_by_user_id=invited_by_user_id, + created_at=participant.get('invited_at') or conversation_doc.get('created_at'), + ) + cosmos_collaboration_user_state_container.upsert_item(state_doc) + user_states.append(state_doc) + + log_event( + '[Collaboration] Created personal collaborative conversation', + extra={ + 'conversation_id': conversation_doc.get('id'), + 'created_by_user_id': conversation_doc.get('created_by_user_id'), + 'participant_count': conversation_doc.get('participant_count', 0), + 'pending_invite_count': conversation_doc.get('pending_invite_count', 0), + }, + level=logging.INFO, + ) + return conversation_doc, user_states + + +def create_group_collaboration_conversation_record(title, creator_user, group_doc): + conversation_doc = build_group_collaboration_conversation( + title=title, + creator_user=creator_user, + group_id=group_doc.get('id'), + group_name=group_doc.get('name', 'Group Workspace'), + ) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + log_event( + '[Collaboration] Created group collaborative conversation', + extra={ + 'conversation_id': conversation_doc.get('id'), + 'group_id': group_doc.get('id'), + 'created_by_user_id': conversation_doc.get('created_by_user_id'), + }, + level=logging.INFO, + ) + return conversation_doc + + +def list_personal_collaboration_conversations_for_user(user_id): + query = ( + 'SELECT * FROM c WHERE c.user_id = @user_id ' + 'AND c.conversation_kind = @conversation_kind' + ) + states = list(cosmos_collaboration_user_state_container.query_items( + query=query, + parameters=[ + {'name': '@user_id', 'value': user_id}, + {'name': '@conversation_kind', 'value': COLLABORATION_KIND}, + ], + partition_key=user_id, + )) + + conversations = [] + for state_doc in states: + membership_status = state_doc.get('membership_status') + if membership_status not in (MEMBERSHIP_STATUS_ACCEPTED, MEMBERSHIP_STATUS_PENDING): + continue + + if state_doc.get('chat_type') != PERSONAL_MULTI_USER_CHAT_TYPE: + continue + + conversation_id = state_doc.get('conversation_id') + if not conversation_id: + continue + + try: + conversation_doc = get_collaboration_conversation(conversation_id) + except CosmosResourceNotFoundError: + continue + + conversations.append((conversation_doc, state_doc)) + + conversations.sort( + key=lambda item: item[0].get('updated_at') or item[0].get('created_at') or '', + reverse=True, + ) + return conversations + + +def list_group_collaboration_conversations_for_user(user_id): + user_groups = get_user_groups(user_id) + group_map = { + str(group_doc.get('id')): group_doc + for group_doc in user_groups + if group_doc.get('id') + } + if not group_map: + return [] + + query = ( + 'SELECT * FROM c WHERE c.conversation_kind = @conversation_kind ' + 'AND c.chat_type = @chat_type AND c.status = @status' + ) + items = list(cosmos_collaboration_conversations_container.query_items( + query=query, + parameters=[ + {'name': '@conversation_kind', 'value': COLLABORATION_KIND}, + {'name': '@chat_type', 'value': GROUP_MULTI_USER_CHAT_TYPE}, + {'name': '@status', 'value': 'active'}, + ], + enable_cross_partition_query=True, + )) + + filtered_items = [] + for conversation_doc in items: + group_id = str((conversation_doc.get('scope') or {}).get('group_id') or '') + if group_id not in group_map: + continue + + allowed, _ = check_group_status_allows_operation(group_map[group_id], 'view') + if allowed: + filtered_items.append(conversation_doc) + + filtered_items.sort( + key=lambda item: item.get('updated_at') or item.get('created_at') or '', + reverse=True, + ) + return filtered_items + + +def assert_user_can_view_collaboration_conversation(user_id, conversation_doc, allow_pending=False): + if not is_collaboration_conversation(conversation_doc): + raise LookupError('Collaboration conversation not found') + + if is_personal_collaboration_conversation(conversation_doc): + try: + user_state = get_collaboration_user_state(user_id, conversation_doc.get('id')) + except CosmosResourceNotFoundError as exc: + raise PermissionError('You are not a participant in this collaborative conversation') from exc + + membership_status = user_state.get('membership_status') + if membership_status == MEMBERSHIP_STATUS_ACCEPTED: + return { + 'user_state': user_state, + 'membership_status': membership_status, + } + if allow_pending and membership_status == MEMBERSHIP_STATUS_PENDING: + return { + 'user_state': user_state, + 'membership_status': membership_status, + } + raise PermissionError('You do not have access to this collaborative conversation') + + if is_group_collaboration_conversation(conversation_doc): + group_id = str((conversation_doc.get('scope') or {}).get('group_id') or '').strip() + if not group_id: + raise LookupError('Group collaborative conversation is missing group context') + + group_doc = find_group_by_id(group_id) + if not group_doc: + raise LookupError('Group not found') + + group_role = assert_group_role( + user_id, + group_id, + allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'), + ) + allowed, reason = check_group_status_allows_operation(group_doc, 'view') + if not allowed: + raise PermissionError(reason) + return { + 'group_doc': group_doc, + 'group_role': group_role, + 'membership_status': 'group_member', + } + + raise PermissionError('Unsupported collaboration conversation type') + + +def assert_user_can_participate_in_collaboration_conversation(user_id, conversation_doc): + access_context = assert_user_can_view_collaboration_conversation( + user_id, + conversation_doc, + allow_pending=False, + ) + + if is_group_collaboration_conversation(conversation_doc): + group_doc = access_context.get('group_doc') + allowed, reason = check_group_status_allows_operation(group_doc, 'chat') + if not allowed: + raise PermissionError(reason) + + return access_context + + +def record_personal_invite_response(conversation_id, user_id, action): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Invite responses are only supported for personal collaborative conversations') + + user_state = get_collaboration_user_state(user_id, conversation_id) + participant_record = apply_personal_invite_response( + conversation_doc, + invited_user_id=user_id, + action=action, + responded_at=utc_now_iso(), + ) + + membership_status = MEMBERSHIP_STATUS_ACCEPTED if str(action).lower() == 'accept' else MEMBERSHIP_STATUS_DECLINED + user_state['membership_status'] = membership_status + user_state['updated_at'] = participant_record.get('responded_at') + user_state['responded_at'] = participant_record.get('responded_at') + if membership_status == MEMBERSHIP_STATUS_ACCEPTED: + user_state['joined_at'] = participant_record.get('joined_at') + + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + cosmos_collaboration_user_state_container.upsert_item(user_state) + return conversation_doc, user_state, participant_record + + +def invite_personal_collaboration_participants(conversation_id, owner_user_id, participants_to_add): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Member invites are only supported for personal collaborative conversations') + + actor_user_state = None + try: + actor_user_state = get_collaboration_user_state(owner_user_id, conversation_id) + except CosmosResourceNotFoundError: + actor_user_state = None + + actor_role = get_personal_collaboration_role( + conversation_doc, + owner_user_id, + user_state=actor_user_state, + ) + if actor_role not in PERSONAL_COLLABORATION_MANAGER_ROLES: + raise PermissionError('Only conversation owners or admins can invite members') + + invite_timestamp = utc_now_iso() + added_participants = add_personal_pending_participants( + conversation_doc, + participants_to_add, + invited_at=invite_timestamp, + ) + if not added_participants: + return conversation_doc, [] + + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + created_state_docs = [] + for participant in added_participants: + state_doc = build_collaboration_user_state( + conversation_doc=conversation_doc, + user_summary=participant, + role=participant.get('role', MEMBERSHIP_ROLE_MEMBER), + membership_status=participant.get('status', MEMBERSHIP_STATUS_PENDING), + invited_by_user_id=owner_user_id, + created_at=invite_timestamp, + ) + cosmos_collaboration_user_state_container.upsert_item(state_doc) + created_state_docs.append(state_doc) + + return conversation_doc, created_state_docs + + +def remove_personal_collaboration_member(conversation_id, owner_user_id, member_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Member removal is only supported for personal collaborative conversations') + + actor_user_state = None + try: + actor_user_state = get_collaboration_user_state(owner_user_id, conversation_id) + except CosmosResourceNotFoundError: + actor_user_state = None + + actor_role = get_personal_collaboration_role( + conversation_doc, + owner_user_id, + user_state=actor_user_state, + ) + if actor_role not in PERSONAL_COLLABORATION_MANAGER_ROLES: + raise PermissionError('Only conversation owners or admins can remove members') + + member_participant = get_personal_collaboration_participant(conversation_doc, member_user_id) + if member_participant is None: + raise LookupError('participant not found') + member_role = str(member_participant.get('role') or '').strip() + if actor_role != MEMBERSHIP_ROLE_OWNER and member_role != MEMBERSHIP_ROLE_MEMBER: + raise PermissionError('Only conversation owners can remove admins') + + removed_participant = remove_personal_participant( + conversation_doc, + participant_user_id=member_user_id, + removed_at=utc_now_iso(), + ) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + try: + user_state = get_collaboration_user_state(member_user_id, conversation_id) + except CosmosResourceNotFoundError: + user_state = None + + if user_state: + user_state['membership_status'] = MEMBERSHIP_STATUS_REMOVED + user_state['updated_at'] = removed_participant.get('removed_at') + user_state['removed_at'] = removed_participant.get('removed_at') + cosmos_collaboration_user_state_container.upsert_item(user_state) + + return conversation_doc, removed_participant + + +def list_collaboration_messages(conversation_id): + query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id ORDER BY c.timestamp ASC' + return list(cosmos_collaboration_messages_container.query_items( + query=query, + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + partition_key=conversation_id, + )) + + +def persist_collaboration_message(conversation_doc, sender_user, content, reply_to_message_id=None): + conversation_id = conversation_doc.get('id') + message_doc = build_collaboration_message_doc( + conversation_id=conversation_id, + sender_user=sender_user, + content=content, + reply_to_message_id=reply_to_message_id, + message_kind=MESSAGE_KIND_HUMAN, + timestamp=utc_now_iso(), + ) + + if is_group_collaboration_conversation(conversation_doc): + ensure_group_participant_record(conversation_doc, sender_user, joined_at=message_doc.get('timestamp')) + + cosmos_collaboration_messages_container.upsert_item(message_doc) + + conversation_doc['last_message_at'] = message_doc.get('timestamp') + conversation_doc['last_message_preview'] = ( + message_doc.get('metadata', {}).get('last_message_preview', '') + ) + conversation_doc['updated_at'] = message_doc.get('timestamp') + conversation_doc['message_count'] = int(conversation_doc.get('message_count', 0) or 0) + 1 + + if is_personal_collaboration_conversation(conversation_doc): + refresh_personal_participant_indexes(conversation_doc) + + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + return message_doc, conversation_doc + + +def update_personal_collaboration_title(conversation_id, current_user_id, new_title): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Title updates are only supported for personal collaborative conversations') + if current_user_id not in set(conversation_doc.get('owner_user_ids', []) or []): + raise PermissionError('Only conversation owners can rename collaborative conversations') + + normalized_title = str(new_title or '').strip() + if not normalized_title: + raise ValueError('Title is required') + + conversation_doc['title'] = normalized_title + conversation_doc['updated_at'] = utc_now_iso() + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + return conversation_doc + + +def toggle_personal_collaboration_pin(conversation_id, current_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Pin is only supported for personal collaborative conversations') + + user_state = get_collaboration_user_state(current_user_id, conversation_id) + user_state['is_pinned'] = not bool(user_state.get('is_pinned', False)) + user_state['updated_at'] = utc_now_iso() + cosmos_collaboration_user_state_container.upsert_item(user_state) + return conversation_doc, user_state + + +def toggle_personal_collaboration_hide(conversation_id, current_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Hide is only supported for personal collaborative conversations') + + user_state = get_collaboration_user_state(current_user_id, conversation_id) + user_state['is_hidden'] = not bool(user_state.get('is_hidden', False)) + user_state['updated_at'] = utc_now_iso() + cosmos_collaboration_user_state_container.upsert_item(user_state) + return conversation_doc, user_state + + +def update_personal_collaboration_member_role(conversation_id, current_user_id, member_user_id, new_role): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Role updates are only supported for personal collaborative conversations') + if current_user_id not in set(conversation_doc.get('owner_user_ids', []) or []): + raise PermissionError('Only conversation owners can change participant roles') + + normalized_role = str(new_role or '').strip().lower() + if normalized_role not in (MEMBERSHIP_ROLE_ADMIN, MEMBERSHIP_ROLE_MEMBER): + raise ValueError('role must be admin or member') + + participant = get_personal_collaboration_participant(conversation_doc, member_user_id) + if participant is None: + raise LookupError('participant not found') + if str(participant.get('status') or '').strip() != MEMBERSHIP_STATUS_ACCEPTED: + raise ValueError('Only active participants can have admin access') + if str(participant.get('role') or '').strip() == MEMBERSHIP_ROLE_OWNER: + raise ValueError('Use owner transfer to change owner access') + + if str(participant.get('role') or '').strip() == normalized_role: + return conversation_doc, participant + + timestamp = utc_now_iso() + participant['role'] = normalized_role + conversation_doc['updated_at'] = timestamp + refresh_personal_participant_indexes(conversation_doc) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + try: + user_state = get_collaboration_user_state(member_user_id, conversation_id) + except CosmosResourceNotFoundError: + user_state = None + + if user_state: + user_state['role'] = normalized_role + user_state['updated_at'] = timestamp + cosmos_collaboration_user_state_container.upsert_item(user_state) + + return conversation_doc, participant + + +def leave_personal_collaboration_conversation(conversation_id, current_user_id, new_owner_user_id=None): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Leave is only supported for personal collaborative conversations') + + participant = get_personal_collaboration_participant(conversation_doc, current_user_id) + if participant is None: + raise PermissionError('You are not a participant in this collaborative conversation') + if str(participant.get('status') or '').strip() != MEMBERSHIP_STATUS_ACCEPTED: + raise PermissionError('Only active participants can leave this collaborative conversation') + + normalized_new_owner_user_id = str(new_owner_user_id or '').strip() + current_role = str(participant.get('role') or '').strip() + promoted_participant = None + timestamp = utc_now_iso() + + if current_role == MEMBERSHIP_ROLE_OWNER: + owner_user_ids = list(conversation_doc.get('owner_user_ids', []) or []) + other_owner_ids = [owner_user_id for owner_user_id in owner_user_ids if owner_user_id != current_user_id] + if not other_owner_ids and not normalized_new_owner_user_id: + raise ValueError('Assign a new owner before leaving this shared conversation') + + if normalized_new_owner_user_id: + if normalized_new_owner_user_id == current_user_id: + raise ValueError('Choose another participant as the new owner') + + promoted_participant = get_personal_collaboration_participant( + conversation_doc, + normalized_new_owner_user_id, + ) + if promoted_participant is None: + raise LookupError('The selected new owner is not a participant in this conversation') + if str(promoted_participant.get('status') or '').strip() != MEMBERSHIP_STATUS_ACCEPTED: + raise ValueError('The selected new owner must already be an active participant') + promoted_participant['role'] = MEMBERSHIP_ROLE_OWNER + + try: + new_owner_state = get_collaboration_user_state(normalized_new_owner_user_id, conversation_id) + except CosmosResourceNotFoundError: + new_owner_state = None + + if new_owner_state: + new_owner_state['role'] = MEMBERSHIP_ROLE_OWNER + new_owner_state['updated_at'] = timestamp + cosmos_collaboration_user_state_container.upsert_item(new_owner_state) + + participant['status'] = MEMBERSHIP_STATUS_REMOVED + participant['removed_at'] = timestamp + conversation_doc['updated_at'] = timestamp + refresh_personal_participant_indexes(conversation_doc) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + try: + user_state = get_collaboration_user_state(current_user_id, conversation_id) + except CosmosResourceNotFoundError: + user_state = None + + if user_state: + user_state['membership_status'] = MEMBERSHIP_STATUS_REMOVED + user_state['role'] = MEMBERSHIP_ROLE_MEMBER + user_state['removed_at'] = timestamp + user_state['updated_at'] = timestamp + cosmos_collaboration_user_state_container.upsert_item(user_state) + + return conversation_doc, participant, promoted_participant + + +def _delete_source_personal_conversation(conversation_doc, current_user_id): + source_conversation_id = str((conversation_doc or {}).get('source_conversation_id') or '').strip() + if not source_conversation_id: + return + + try: + source_conversation = cosmos_conversations_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + except CosmosResourceNotFoundError: + return + + if str(source_conversation.get('user_id') or '').strip() != str(current_user_id or '').strip(): + return + if str(source_conversation.get('collaboration_conversation_id') or '').strip() != str(conversation_doc.get('id') or '').strip(): + return + + message_query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id' + source_messages = list(cosmos_messages_container.query_items( + query=message_query, + parameters=[{'name': '@conversation_id', 'value': source_conversation_id}], + partition_key=source_conversation_id, + )) + + for message_doc in source_messages: + cosmos_messages_container.delete_item( + item=message_doc.get('id'), + partition_key=source_conversation_id, + ) + + delete_thoughts_for_conversation(source_conversation_id, current_user_id) + cosmos_conversations_container.delete_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + + +def delete_personal_collaboration_conversation(conversation_id, current_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Delete is only supported for personal collaborative conversations') + if current_user_id not in set(conversation_doc.get('owner_user_ids', []) or []): + raise PermissionError('Only conversation owners can delete this shared conversation') + + message_query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id' + messages = list(cosmos_collaboration_messages_container.query_items( + query=message_query, + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + partition_key=conversation_id, + )) + for message_doc in messages: + cosmos_collaboration_messages_container.delete_item( + item=message_doc.get('id'), + partition_key=conversation_id, + ) + + state_query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id' + state_docs = list(cosmos_collaboration_user_state_container.query_items( + query=state_query, + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + enable_cross_partition_query=True, + )) + for state_doc in state_docs: + cosmos_collaboration_user_state_container.delete_item( + item=state_doc.get('id'), + partition_key=state_doc.get('user_id'), + ) + + _delete_source_personal_conversation(conversation_doc, current_user_id) + cosmos_collaboration_conversations_container.delete_item( + item=conversation_id, + partition_key=conversation_id, + ) + return conversation_doc + + +def get_accessible_collaboration_message_thoughts(conversation_doc, message_doc, viewer_user_id): + conversation_id = str((conversation_doc or {}).get('id') or '').strip() + message_id = str((message_doc or {}).get('id') or '').strip() + metadata = (message_doc or {}).get('metadata', {}) if isinstance(message_doc, dict) else {} + + if not conversation_id or not message_id: + return [] + + direct_thoughts = get_thoughts_for_message(conversation_id, message_id, viewer_user_id) + if direct_thoughts: + return direct_thoughts + + fallback_user_ids = [] + for candidate_user_id in ( + metadata.get('source_thought_user_id'), + (conversation_doc or {}).get('created_by_user_id'), + ): + normalized_candidate = str(candidate_user_id or '').strip() + if not normalized_candidate or normalized_candidate in fallback_user_ids: + continue + fallback_user_ids.append(normalized_candidate) + + fallback_conversation_id = str( + metadata.get('source_conversation_id') + or (conversation_doc or {}).get('source_conversation_id') + or '' + ).strip() + fallback_message_id = str(metadata.get('source_message_id') or '').strip() + + for candidate_user_id in fallback_user_ids: + candidate_thoughts = get_thoughts_for_message(conversation_id, message_id, candidate_user_id) + if candidate_thoughts: + return candidate_thoughts + + if fallback_conversation_id and fallback_message_id: + candidate_thoughts = get_thoughts_for_message( + fallback_conversation_id, + fallback_message_id, + candidate_user_id, + ) + if candidate_thoughts: + return candidate_thoughts + + return [] \ No newline at end of file diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 8d09ee614..8958f26bd 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -264,6 +264,9 @@ def get_settings(use_cosmos=False, include_source=False): # Processing Thoughts 'enable_thoughts': True, + # Collaborative Conversations + 'enable_collaborative_conversations': True, + # Search and Extract 'azure_ai_search_endpoint': '', 'azure_ai_search_key': '', diff --git a/application/single_app/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py new file mode 100644 index 000000000..c9e18941b --- /dev/null +++ b/application/single_app/route_backend_collaboration.py @@ -0,0 +1,1021 @@ +# route_backend_collaboration.py + +import json +import threading +import time + +import app_settings_cache +from flask import Response, jsonify, request, stream_with_context + +from config import * +from collaboration_models import MEMBERSHIP_STATUS_PENDING, add_seconds_to_iso, normalize_collaboration_user, utc_now_iso +from functions_appinsights import log_event +from functions_authentication import * +from functions_collaboration import ( + assert_user_can_participate_in_collaboration_conversation, + assert_user_can_view_collaboration_conversation, + create_group_collaboration_conversation_record, + create_personal_collaboration_conversation_record, + delete_personal_collaboration_conversation, + ensure_personal_collaboration_for_legacy_conversation, + get_collaboration_conversation, + get_collaboration_user_state, + invite_personal_collaboration_participants, + leave_personal_collaboration_conversation, + list_collaboration_messages, + list_group_collaboration_conversations_for_user, + list_personal_collaboration_conversations_for_user, + persist_collaboration_message, + record_personal_invite_response, + remove_personal_collaboration_member, + serialize_collaboration_conversation, + serialize_collaboration_message, + toggle_personal_collaboration_hide, + toggle_personal_collaboration_pin, + update_personal_collaboration_member_role, + update_personal_collaboration_title, +) +from functions_group import assert_group_role, check_group_status_allows_operation, find_group_by_id +from functions_settings import get_settings, get_user_settings +from swagger_wrapper import swagger_route, get_auth_security + + +COLLABORATION_EVENT_HEARTBEAT_SECONDS = 15 +COLLABORATION_EVENT_TTL_SECONDS = 3600 + + +class CollaborationEventSession: + HEARTBEAT_EVENT = ': keep-alive\n\n' + + def __init__(self, conversation_id, heartbeat_interval_seconds=15, session_ttl_seconds=3600): + self.conversation_id = conversation_id + self.heartbeat_interval_seconds = heartbeat_interval_seconds + self.session_ttl_seconds = session_ttl_seconds + self.cache_key = f'collaboration:{conversation_id}' + self._condition = threading.Condition() + + def _build_metadata(self): + return { + 'conversation_id': self.conversation_id, + 'active': True, + 'heartbeat_interval_seconds': self.heartbeat_interval_seconds, + 'updated_at': utc_now_iso(), + } + + def initialize(self): + existing_metadata = app_settings_cache.get_stream_session_meta(self.cache_key) + if existing_metadata: + app_settings_cache.set_stream_session_meta( + self.cache_key, + self._build_metadata(), + ttl_seconds=self.session_ttl_seconds, + ) + return + + app_settings_cache.initialize_stream_session_cache( + self.cache_key, + self._build_metadata(), + ttl_seconds=self.session_ttl_seconds, + ) + + def publish(self, event_payload): + self.initialize() + event_text = f'data: {json.dumps(event_payload)}\n\n' + app_settings_cache.append_stream_session_event( + self.cache_key, + event_text, + ttl_seconds=self.session_ttl_seconds, + ) + app_settings_cache.set_stream_session_meta( + self.cache_key, + self._build_metadata(), + ttl_seconds=self.session_ttl_seconds, + ) + with self._condition: + self._condition.notify_all() + + def iter_events(self, start_index=0): + self.initialize() + next_index = max(int(start_index or 0), 0) + last_heartbeat_at = time.time() + + while True: + pending_events = app_settings_cache.get_stream_session_events( + self.cache_key, + start_index=next_index, + ) or [] + if pending_events: + for event_to_yield in pending_events: + next_index += 1 + last_heartbeat_at = time.time() + yield event_to_yield + continue + + metadata = app_settings_cache.get_stream_session_meta(self.cache_key) + if not metadata: + self.initialize() + metadata = app_settings_cache.get_stream_session_meta(self.cache_key) + if not metadata: + return + + heartbeat_interval_seconds = int( + metadata.get('heartbeat_interval_seconds') or self.heartbeat_interval_seconds + ) + remaining_heartbeat_seconds = max( + heartbeat_interval_seconds - (time.time() - last_heartbeat_at), + 0.25, + ) + with self._condition: + self._condition.wait(timeout=min(1.0, remaining_heartbeat_seconds)) + + if (time.time() - last_heartbeat_at) >= heartbeat_interval_seconds: + last_heartbeat_at = time.time() + yield self.HEARTBEAT_EVENT + + +class CollaborationEventRegistry: + def __init__(self, heartbeat_interval_seconds=15, session_ttl_seconds=3600): + self.heartbeat_interval_seconds = heartbeat_interval_seconds + self.session_ttl_seconds = session_ttl_seconds + self._sessions = {} + self._lock = threading.Lock() + + def get_session(self, conversation_id): + with self._lock: + session = self._sessions.get(conversation_id) + if session is None: + session = CollaborationEventSession( + conversation_id=conversation_id, + heartbeat_interval_seconds=self.heartbeat_interval_seconds, + session_ttl_seconds=self.session_ttl_seconds, + ) + self._sessions[conversation_id] = session + session.initialize() + return session + + def publish(self, conversation_id, event_payload): + self.get_session(conversation_id).publish(event_payload) + + +COLLABORATION_EVENT_REGISTRY = CollaborationEventRegistry( + heartbeat_interval_seconds=COLLABORATION_EVENT_HEARTBEAT_SECONDS, + session_ttl_seconds=COLLABORATION_EVENT_TTL_SECONDS, +) + + +def get_user_state_or_none(user_id, conversation_id): + try: + return get_collaboration_user_state(user_id, conversation_id) + except CosmosResourceNotFoundError: + return None + + +def _build_collaboration_event(conversation_id, event_type, payload): + return { + 'conversation_id': conversation_id, + 'event_type': event_type, + 'occurred_at': utc_now_iso(), + 'payload': payload, + } + + +def _require_collaboration_feature_enabled(): + settings = get_settings() + if not settings.get('enable_collaborative_conversations', False): + raise PermissionError('Collaborative conversations are disabled by configuration') + return settings + + +def _get_current_collaboration_user(): + current_user = get_current_user_info() + return normalize_collaboration_user(current_user) + + +def _normalize_participant_payload(raw_payload): + if raw_payload is None: + return [] + if isinstance(raw_payload, dict): + raw_payload = [raw_payload] + + normalized_participants = [] + for raw_participant in raw_payload: + participant_summary = normalize_collaboration_user(raw_participant) + if participant_summary: + normalized_participants.append(participant_summary) + return normalized_participants + + +def register_route_backend_collaboration(app): + @app.route('/api/collaboration/conversations', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def list_collaboration_conversations_api(): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + scope_filter = str(request.args.get('scope') or 'all').strip().lower() + include_pending = str(request.args.get('include_pending', 'true')).strip().lower() != 'false' + conversations = [] + + if scope_filter in ('all', 'personal'): + for conversation_doc, user_state in list_personal_collaboration_conversations_for_user(current_user['user_id']): + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=user_state, + ) + if include_pending or serialized.get('membership_status') != MEMBERSHIP_STATUS_PENDING: + conversations.append(serialized) + + if scope_filter in ('all', 'group'): + for conversation_doc in list_group_collaboration_conversations_for_user(current_user['user_id']): + conversations.append(serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + )) + + conversations.sort( + key=lambda item: item.get('updated_at') or item.get('created_at') or '', + reverse=True, + ) + return jsonify({'conversations': conversations}), 200 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to list conversations: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load collaborative conversations'}), 500 + + @app.route('/api/collaboration/conversations', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def create_collaboration_conversation_api(): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + conversation_type = str(data.get('conversation_type') or '').strip().lower() + title = str(data.get('title') or '').strip() + + if conversation_type == 'personal': + participants_to_invite = _normalize_participant_payload(data.get('participants', [])) + conversation_doc, user_states = create_personal_collaboration_conversation_record( + title=title, + creator_user=current_user, + invited_participants=participants_to_invite, + ) + creator_state = next( + (state for state in user_states if state.get('user_id') == current_user['user_id']), + None, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=creator_state, + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_doc.get('id'), + _build_collaboration_event( + conversation_doc.get('id'), + 'collaboration.created', + {'conversation': serialized}, + ), + ) + return jsonify({'conversation': serialized}), 201 + + if conversation_type == 'group': + group_id = str(data.get('group_id') or '').strip() + if not group_id: + user_settings = get_user_settings(current_user['user_id']) + group_id = str( + ((user_settings or {}).get('settings') or {}).get('activeGroupOid') or '' + ).strip() + if not group_id: + return jsonify({'error': 'group_id is required for group collaborative conversations'}), 400 + + group_doc = find_group_by_id(group_id) + if not group_doc: + return jsonify({'error': 'Group not found'}), 404 + + assert_group_role( + current_user['user_id'], + group_id, + allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'), + ) + allowed, reason = check_group_status_allows_operation(group_doc, 'chat') + if not allowed: + return jsonify({'error': reason}), 403 + + conversation_doc = create_group_collaboration_conversation_record( + title=title, + creator_user=current_user, + group_doc=group_doc, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_doc.get('id'), + _build_collaboration_event( + conversation_doc.get('id'), + 'collaboration.created', + {'conversation': serialized}, + ), + ) + return jsonify({'conversation': serialized}), 201 + + return jsonify({'error': 'conversation_type must be personal or group'}), 400 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to create conversation: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to create collaborative conversation'}), 500 + + @app.route('/api/collaboration/conversations/', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def get_collaboration_conversation_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc = get_collaboration_conversation(conversation_id) + access_context = assert_user_can_view_collaboration_conversation( + current_user['user_id'], + conversation_doc, + allow_pending=True, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=access_context.get('user_state'), + ) + return jsonify({'conversation': serialized}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to load conversation {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load collaborative conversation'}), 500 + + @app.route('/api/collaboration/conversations//invite-response', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def respond_to_collaboration_invite_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + action = str(data.get('action') or '').strip().lower() + if action not in ('accept', 'decline'): + return jsonify({'error': 'action must be accept or decline'}), 400 + + conversation_doc, user_state, participant_record = record_personal_invite_response( + conversation_id, + current_user['user_id'], + action, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=user_state, + ) + event_type = 'collaboration.invite.accepted' if action == 'accept' else 'collaboration.invite.declined' + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + event_type, + { + 'conversation': serialized, + 'participant': participant_record, + }, + ), + ) + return jsonify({'conversation': serialized}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except (LookupError, PermissionError, ValueError) as exc: + return jsonify({'error': str(exc)}), 403 if isinstance(exc, PermissionError) else 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to respond to invite for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to update invite response'}), 500 + + @app.route('/api/collaboration/conversations//members', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def invite_collaboration_members_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + participants_to_add = _normalize_participant_payload( + data.get('participants', data.get('participant')) + ) + if not participants_to_add: + return jsonify({'error': 'participants are required'}), 400 + + conversation_doc, state_docs = invite_personal_collaboration_participants( + conversation_id, + current_user['user_id'], + participants_to_add, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + ) + invited_participants = [] + for state_doc in state_docs: + invited_participants.append({ + 'user_id': state_doc.get('user_id'), + 'display_name': state_doc.get('user_display_name'), + 'email': state_doc.get('user_email'), + 'membership_status': state_doc.get('membership_status'), + }) + if invited_participants: + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.member.invited', + { + 'conversation': serialized, + 'participants': invited_participants, + }, + ), + ) + return jsonify({'conversation': serialized, 'invited_participants': invited_participants}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to invite members for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to invite collaborative conversation members'}), 500 + + @app.route('/api/collaboration/conversations/from-personal//members', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def convert_personal_conversation_to_collaboration_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + participants_to_add = _normalize_participant_payload( + data.get('participants', data.get('participant')) + ) + if not participants_to_add: + return jsonify({'error': 'participants are required'}), 400 + + conversation_doc, invited_state_docs, created_new, _ = ensure_personal_collaboration_for_legacy_conversation( + conversation_id, + current_user, + invited_participants=participants_to_add, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + ) + invited_participants = [ + { + 'user_id': state_doc.get('user_id'), + 'display_name': state_doc.get('user_display_name'), + 'email': state_doc.get('user_email'), + 'membership_status': state_doc.get('membership_status'), + } + for state_doc in invited_state_docs + ] + + if created_new: + COLLABORATION_EVENT_REGISTRY.publish( + conversation_doc.get('id'), + _build_collaboration_event( + conversation_doc.get('id'), + 'collaboration.created', + {'conversation': serialized, 'source_conversation_id': conversation_id}, + ), + ) + + if invited_participants: + COLLABORATION_EVENT_REGISTRY.publish( + conversation_doc.get('id'), + _build_collaboration_event( + conversation_doc.get('id'), + 'collaboration.member.invited', + { + 'conversation': serialized, + 'participants': invited_participants, + 'source_conversation_id': conversation_id, + }, + ), + ) + + return jsonify({ + 'conversation': serialized, + 'invited_participants': invited_participants, + 'created': created_new, + 'source_conversation_id': conversation_id, + }), 201 if created_new else 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to convert personal conversation {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to convert conversation to collaborative conversation'}), 500 + + @app.route('/api/collaboration/conversations//members/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def remove_collaboration_member_api(conversation_id, member_user_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc, removed_participant = remove_personal_collaboration_member( + conversation_id, + current_user['user_id'], + member_user_id, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.member.removed', + { + 'conversation': serialized, + 'participant': removed_participant, + }, + ), + ) + return jsonify({'conversation': serialized, 'removed_participant': removed_participant}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except (LookupError, ValueError) as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to remove member for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to remove collaborative conversation member'}), 500 + + @app.route('/api/collaboration/conversations//members//role', methods=['PUT']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def update_collaboration_member_role_api(conversation_id, member_user_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + new_role = str(data.get('role') or '').strip().lower() + if not new_role: + return jsonify({'error': 'role is required'}), 400 + + conversation_doc, updated_participant = update_personal_collaboration_member_role( + conversation_id, + current_user['user_id'], + member_user_id, + new_role, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.member.role_updated', + { + 'conversation': serialized, + 'participant': updated_participant, + }, + ), + ) + return jsonify({'conversation': serialized, 'participant': updated_participant}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except (LookupError, ValueError) as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to update member role for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to update collaborative conversation role'}), 500 + + @app.route('/api/collaboration/conversations/', methods=['PUT']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def update_collaboration_conversation_title_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + new_title = str(data.get('title') or '').strip() + if not new_title: + return jsonify({'error': 'Title is required'}), 400 + + conversation_doc = update_personal_collaboration_title( + conversation_id, + current_user['user_id'], + new_title, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.updated', + {'conversation': serialized}, + ), + ) + return jsonify(serialized), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to update title for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to update collaborative conversation'}), 500 + + @app.route('/api/collaboration/conversations//pin', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def toggle_collaboration_conversation_pin_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + _, user_state = toggle_personal_collaboration_pin(conversation_id, current_user['user_id']) + return jsonify({'success': True, 'is_pinned': bool(user_state.get('is_pinned', False))}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to toggle pin for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to toggle collaborative pin status'}), 500 + + @app.route('/api/collaboration/conversations//hide', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def toggle_collaboration_conversation_hide_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + _, user_state = toggle_personal_collaboration_hide(conversation_id, current_user['user_id']) + return jsonify({'success': True, 'is_hidden': bool(user_state.get('is_hidden', False))}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to toggle hide for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to toggle collaborative hide status'}), 500 + + @app.route('/api/collaboration/conversations//delete-action', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def collaboration_delete_action_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + action = str(data.get('action') or '').strip().lower() + new_owner_user_id = str(data.get('new_owner_user_id') or '').strip() or None + + if action == 'delete': + conversation_doc = get_collaboration_conversation(conversation_id) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.deleted', + { + 'conversation': serialized, + 'deleted_by_user_id': current_user['user_id'], + }, + ), + ) + delete_personal_collaboration_conversation(conversation_id, current_user['user_id']) + return jsonify({'success': True, 'action': 'delete', 'conversation_id': conversation_id}), 200 + + if action == 'leave': + conversation_doc, removed_participant, promoted_participant = leave_personal_collaboration_conversation( + conversation_id, + current_user['user_id'], + new_owner_user_id=new_owner_user_id, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.member.removed', + { + 'conversation': serialized, + 'participant': removed_participant, + 'promoted_participant': promoted_participant, + }, + ), + ) + return jsonify({ + 'success': True, + 'action': 'leave', + 'conversation': serialized, + 'removed_participant': removed_participant, + 'promoted_participant': promoted_participant, + }), 200 + + return jsonify({'error': 'action must be delete or leave'}), 400 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except (LookupError, ValueError) as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to complete delete action for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to update collaborative conversation membership'}), 500 + + @app.route('/api/collaboration/conversations//messages', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def get_collaboration_messages_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + current_user['user_id'], + conversation_doc, + allow_pending=True, + ) + messages = [serialize_collaboration_message(doc) for doc in list_collaboration_messages(conversation_id)] + return jsonify({'messages': messages}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to load messages for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load collaborative conversation messages'}), 500 + + @app.route('/api/collaboration/conversations//messages', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def post_collaboration_message_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + message_content = str(data.get('content') or '').strip() + reply_to_message_id = str(data.get('reply_to_message_id') or '').strip() or None + if not message_content: + return jsonify({'error': 'content is required'}), 400 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_participate_in_collaboration_conversation(current_user['user_id'], conversation_doc) + message_doc, updated_conversation_doc = persist_collaboration_message( + conversation_doc, + current_user, + message_content, + reply_to_message_id=reply_to_message_id, + ) + serialized_message = serialize_collaboration_message(message_doc) + serialized_conversation = serialize_collaboration_conversation( + updated_conversation_doc, + current_user_id=current_user['user_id'], + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.message.created', + { + 'conversation': serialized_conversation, + 'message': serialized_message, + }, + ), + ) + return jsonify({'conversation': serialized_conversation, 'message': serialized_message}), 201 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to post message for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to post collaborative conversation message'}), 500 + + @app.route('/api/collaboration/conversations//typing', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def collaboration_typing_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + is_typing = bool(data.get('is_typing', True)) + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_participate_in_collaboration_conversation(current_user['user_id'], conversation_doc) + + typing_payload = { + 'user': current_user, + 'is_typing': is_typing, + 'expires_at': add_seconds_to_iso(utc_now_iso(), 8), + } + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.typing.updated', + typing_payload, + ), + ) + return jsonify({'success': True}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to publish typing event for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to publish typing event'}), 500 + + @app.route('/api/collaboration/conversations//events', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def collaboration_events_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + current_user['user_id'], + conversation_doc, + allow_pending=True, + ) + + start_index = request.args.get('start_index', 0) + session = COLLABORATION_EVENT_REGISTRY.get_session(conversation_id) + return Response( + stream_with_context(session.iter_events(start_index=start_index)), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + 'Connection': 'keep-alive', + }, + ) + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to attach event stream for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to attach collaborative event stream'}), 500 \ No newline at end of file diff --git a/application/single_app/route_backend_conversation_export.py b/application/single_app/route_backend_conversation_export.py index 086019f1e..b3237bbbd 100644 --- a/application/single_app/route_backend_conversation_export.py +++ b/application/single_app/route_backend_conversation_export.py @@ -17,6 +17,13 @@ from functions_appinsights import log_event from functions_authentication import * from functions_chat import sort_messages_by_thread +from functions_collaboration import ( + assert_user_can_view_collaboration_conversation, + get_accessible_collaboration_message_thoughts, + get_collaboration_conversation, + is_collaboration_conversation, + list_collaboration_messages, +) from functions_conversation_metadata import update_conversation_with_metadata from functions_debug import debug_print from functions_message_artifacts import ( @@ -84,29 +91,43 @@ def api_export_conversations(): settings = get_settings() exported = [] for conv_id in conversation_ids: + conversation = None + messages = [] try: conversation = cosmos_conversations_container.read_item( item=conv_id, partition_key=conv_id ) + if conversation.get('user_id') != user_id: + debug_print(f"Export: user {user_id} does not own conversation {conv_id}") + continue + + message_query = """ + SELECT * FROM c + WHERE c.conversation_id = @conversation_id + ORDER BY c.timestamp ASC + """ + messages = list(cosmos_messages_container.query_items( + query=message_query, + parameters=[{'name': '@conversation_id', 'value': conv_id}], + partition_key=conv_id + )) except Exception: - debug_print(f"Export: conversation {conv_id} not found or access denied") - continue - - if conversation.get('user_id') != user_id: - debug_print(f"Export: user {user_id} does not own conversation {conv_id}") - continue - - message_query = """ - SELECT * FROM c - WHERE c.conversation_id = @conversation_id - ORDER BY c.timestamp ASC - """ - messages = list(cosmos_messages_container.query_items( - query=message_query, - parameters=[{'name': '@conversation_id', 'value': conv_id}], - partition_key=conv_id - )) + try: + conversation = get_collaboration_conversation(conv_id) + access_context = assert_user_can_view_collaboration_conversation( + user_id, + conversation, + allow_pending=True, + ) + user_state = access_context.get('user_state') or {} + conversation = dict(conversation) + conversation['is_pinned'] = bool(user_state.get('is_pinned', False)) + conversation['is_hidden'] = bool(user_state.get('is_hidden', False)) + messages = list_collaboration_messages(conv_id) + except Exception: + debug_print(f"Export: conversation {conv_id} not found or access denied") + continue exported.append( _build_export_entry( @@ -254,7 +275,7 @@ def _build_export_entry( filtered_messages = hydrate_agent_citations_from_artifacts(filtered_messages, artifact_payload_map) ordered_messages = sort_messages_by_thread(filtered_messages) - raw_thoughts = get_thoughts_for_conversation(conversation.get('id'), user_id) + raw_thoughts = [] if is_collaboration_conversation(conversation) else get_thoughts_for_conversation(conversation.get('id'), user_id) thoughts_by_message = defaultdict(list) for thought in raw_thoughts: thoughts_by_message[thought.get('message_id')].append(_sanitize_thought(thought)) @@ -275,6 +296,13 @@ def _build_export_entry( message_transcript_index = transcript_index thoughts = thoughts_by_message.get(message.get('id'), []) + if not thoughts and is_collaboration_conversation(conversation): + collaboration_thoughts = get_accessible_collaboration_message_thoughts( + conversation, + message, + user_id, + ) + thoughts = [_sanitize_thought(thought) for thought in collaboration_thoughts] exported_message = _sanitize_message( message, sequence_index=sequence_index, @@ -349,7 +377,7 @@ def _sanitize_conversation( return { 'id': conversation.get('id'), 'title': conversation.get('title', 'Untitled'), - 'last_updated': conversation.get('last_updated', ''), + 'last_updated': conversation.get('last_updated') or conversation.get('updated_at', ''), 'chat_type': conversation.get('chat_type', 'personal'), 'tags': conversation.get('tags', []), 'context': conversation.get('context', []), diff --git a/application/single_app/route_backend_thoughts.py b/application/single_app/route_backend_thoughts.py index 69aed2710..406a59ae9 100644 --- a/application/single_app/route_backend_thoughts.py +++ b/application/single_app/route_backend_thoughts.py @@ -1,7 +1,14 @@ # route_backend_thoughts.py from flask import request, jsonify +from config import CosmosResourceNotFoundError from functions_authentication import login_required, user_required, get_current_user_id +from functions_collaboration import ( + assert_user_can_view_collaboration_conversation, + get_accessible_collaboration_message_thoughts, + get_collaboration_conversation, + get_collaboration_message, +) from functions_settings import get_settings from functions_thoughts import get_thoughts_for_message, get_pending_thoughts from swagger_wrapper import swagger_route, get_auth_security @@ -26,6 +33,23 @@ def api_get_message_thoughts(conversation_id, message_id): try: thoughts = get_thoughts_for_message(conversation_id, message_id, user_id) + if not thoughts: + try: + message_doc = get_collaboration_message(message_id) + if str(message_doc.get('conversation_id') or '') == str(conversation_id or ''): + collaboration_conversation = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + user_id, + collaboration_conversation, + allow_pending=True, + ) + thoughts = get_accessible_collaboration_message_thoughts( + collaboration_conversation, + message_doc, + user_id, + ) + except CosmosResourceNotFoundError: + thoughts = thoughts or [] # Strip internal Cosmos fields before returning sanitized = [] for t in thoughts: @@ -40,6 +64,8 @@ def api_get_message_thoughts(conversation_id, message_id): 'timestamp': t.get('timestamp') }) return jsonify({'thoughts': sanitized, 'enabled': True}), 200 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as e: log_event(f"api_get_message_thoughts error: {e}", level="WARNING") return jsonify({'error': 'Failed to retrieve thoughts'}), 500 @@ -63,6 +89,17 @@ def api_get_pending_thoughts(conversation_id): return jsonify({'thoughts': [], 'enabled': False}), 200 try: + try: + collaboration_conversation = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + user_id, + collaboration_conversation, + allow_pending=True, + ) + return jsonify({'thoughts': [], 'enabled': True}), 200 + except CosmosResourceNotFoundError: + pass + message_id = request.args.get('message_id') thoughts = get_pending_thoughts(conversation_id, user_id, message_id=message_id) sanitized = [] @@ -78,6 +115,8 @@ def api_get_pending_thoughts(conversation_id): 'timestamp': t.get('timestamp') }) return jsonify({'thoughts': sanitized, 'enabled': True}), 200 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as e: log_event(f"api_get_pending_thoughts error: {e}", level="WARNING") return jsonify({'error': 'Failed to retrieve pending thoughts'}), 500 diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index 459aa800c..80e26ae0b 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -1,6 +1,7 @@ # route_backend_users.py from config import * +from collaboration_models import normalize_collaboration_user from functions_authentication import * from functions_settings import * from swagger_wrapper import swagger_route, get_auth_security @@ -97,6 +98,86 @@ def api_get_user_info(user_id): return jsonify({ "error": f"User not found for oid {user_id}" }), 404 + + @app.route('/api/user/collaboration-suggestions', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_collaboration_suggestions(): + user_id = get_current_user_id() + if not user_id: + return jsonify({"error": "Unable to identify user"}), 401 + + query = str(request.args.get('query') or '').strip().lower() + recent_only = str(request.args.get('recent_only', 'false')).strip().lower() == 'true' + + try: + requested_limit = int(request.args.get('limit', 8)) + except (TypeError, ValueError): + requested_limit = 8 + limit = max(1, min(requested_limit, 20)) + + user_settings_doc = get_user_settings(user_id) or {} + recent_collaborators = ((user_settings_doc.get('settings') or {}).get('recentCollaborators') or []) + + suggestions = [] + seen_user_ids = set() + + def add_suggestion(raw_value, source_label): + fallback_user_id = None + if isinstance(raw_value, dict): + fallback_user_id = raw_value.get('id') + + normalized_user = normalize_collaboration_user(raw_value, fallback_user_id=fallback_user_id) + if not normalized_user: + return + + normalized_user_id = normalized_user.get('user_id') + if not normalized_user_id or normalized_user_id == user_id or normalized_user_id in seen_user_ids: + return + + haystack = f"{normalized_user.get('display_name', '')} {normalized_user.get('email', '')}".strip().lower() + if query and query not in haystack: + return + + seen_user_ids.add(normalized_user_id) + suggestions.append({ + 'user_id': normalized_user_id, + 'display_name': normalized_user.get('display_name'), + 'email': normalized_user.get('email'), + 'source': source_label, + }) + + for recent_collaborator in recent_collaborators: + add_suggestion(recent_collaborator, 'recent') + if len(suggestions) >= limit: + return jsonify({'results': suggestions[:limit]}), 200 + + if not recent_only and query: + user_query = ( + f'SELECT TOP {max(limit * 3, 12)} c.id, c.display_name, c.email FROM c ' + 'WHERE c.id != @current_user_id AND ' + '((IS_DEFINED(c.display_name) AND CONTAINS(LOWER(c.display_name), @query)) ' + 'OR (IS_DEFINED(c.email) AND CONTAINS(LOWER(c.email), @query)))' + ) + local_results = list(cosmos_user_settings_container.query_items( + query=user_query, + parameters=[ + {'name': '@current_user_id', 'value': user_id}, + {'name': '@query', 'value': query}, + ], + enable_cross_partition_query=True, + )) + for local_result in local_results: + add_suggestion({ + 'id': local_result.get('id'), + 'display_name': local_result.get('display_name'), + 'email': local_result.get('email'), + }, 'local') + if len(suggestions) >= limit: + break + + return jsonify({'results': suggestions[:limit]}), 200 @app.route('/api/user/settings', methods=['GET', 'POST']) @swagger_route(security=get_auth_security()) @@ -157,6 +238,7 @@ def user_settings(): 'ttsEnabled', 'ttsVoice', 'ttsSpeed', 'ttsAutoplay', # Tutorial visibility settings 'showTutorialButtons', + 'recentCollaborators', # Metrics and other settings 'metrics', 'lastUpdated' } # Add others as needed diff --git a/application/single_app/route_frontend_conversations.py b/application/single_app/route_frontend_conversations.py index d2b428fe2..b54d09ab0 100644 --- a/application/single_app/route_frontend_conversations.py +++ b/application/single_app/route_frontend_conversations.py @@ -1,9 +1,15 @@ # route_frontend_conversations.py from config import * +from functions_appinsights import log_event from functions_authentication import * from functions_debug import debug_print from functions_chat import sort_messages_by_thread +from functions_collaboration import ( + assert_user_can_view_collaboration_conversation, + get_collaboration_conversation, + get_collaboration_message, +) from functions_message_artifacts import ( build_message_artifact_payload_map, filter_assistant_artifact_items, @@ -253,7 +259,16 @@ def get_message_metadata(message_id): )) if not messages: - return jsonify({'error': 'Message not found'}), 404 + message = get_collaboration_message(message_id) + conversation = get_collaboration_conversation(message.get('conversation_id')) + assert_user_can_view_collaboration_conversation( + user_id, + conversation, + allow_pending=True, + ) + if message.get('role', '') == 'user': + return jsonify(message.get('metadata', {})) + return jsonify(message) message = messages[0] @@ -282,7 +297,12 @@ def get_message_metadata(message_id): else: # Assistant, image, file messages - return full document return jsonify(message) + + except CosmosResourceNotFoundError: + return jsonify({'error': 'Message not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as e: - print(f"Error fetching message metadata: {str(e)}") + log_event(f"get_message_metadata failed: {e}", level="WARNING") return jsonify({'error': 'Failed to fetch message metadata'}), 500 \ No newline at end of file diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index 1ab7b2829..124569845 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -1307,6 +1307,10 @@ a.citation-link:hover { justify-content: flex-start; } +.collaborator-message { + justify-content: flex-start; +} + /* Message bubble */ .message-bubble { max-width: 90%; @@ -1337,6 +1341,13 @@ a.citation-link:hover { min-width: min(320px, 90%); } +.collaborator-message .message-bubble { + background-color: #eef6ef; + color: black; + border-bottom-left-radius: 0; + min-width: min(280px, 90%); +} + /* File message bubble styling */ .file-message .message-bubble { background-color: #e8f5e9; /* Green */ @@ -1370,6 +1381,11 @@ a.citation-link:hover { color: #e9ecef; } +[data-bs-theme="dark"] .collaborator-message .message-bubble { + background-color: #35533f; + color: #f8f9fa; +} + [data-bs-theme="dark"] .file-message .message-bubble { background-color: #198754; /* Darker green for dark mode */ color: #ffffff; @@ -1477,10 +1493,43 @@ a.citation-link:hover { margin-right: 10px; } +.collaborator-message .avatar { + margin-right: 10px; +} + .file-message { justify-content: flex-end; } +.collaboration-typing-indicator { + min-height: 24px; + padding: 0 1rem 0.5rem 1rem; + font-size: 0.875rem; + color: #6c757d; +} + +.collaboration-mention-menu { + position: absolute; + left: 0; + right: 0; + bottom: calc(100% + 0.5rem); + z-index: 1055; + max-height: 240px; + overflow-y: auto; + border: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.12); +} + +.collaboration-mention-menu .list-group-item.active { + background-color: #0d6efd; + border-color: #0d6efd; +} + +.collaboration-participant-results { + max-height: 320px; + overflow-y: auto; +} + /* Style code blocks */ /* Code blocks: force internal scroll, never overflow parent */ diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js new file mode 100644 index 000000000..7a91126bb --- /dev/null +++ b/application/single_app/static/js/chat/chat-collaboration.js @@ -0,0 +1,1181 @@ +// chat-collaboration.js + +import { appendMessage, updateSendButtonVisibility, updateUserMessageId, userInput } from './chat-messages.js'; +import { applyConversationMetadataUpdate } from './chat-conversations.js'; +import { loadUserSettings, saveUserSetting } from './chat-layout.js'; +import { showToast } from './chat-toast.js'; + +const RECENT_COLLABORATORS_KEY = 'recentCollaborators'; +const MAX_RECENT_COLLABORATORS = 12; +const DEFAULT_SUGGESTION_LIMIT = 8; + +const mentionMenu = document.getElementById('collaboration-mention-menu'); +const participantModalEl = document.getElementById('collaboration-participant-modal'); +const participantSearchInput = document.getElementById('collaboration-participant-search-input'); +const participantResults = document.getElementById('collaboration-participant-results'); +const participantConversationIdInput = document.getElementById('collaboration-participant-conversation-id'); +const confirmModalEl = document.getElementById('collaboration-confirm-modal'); +const confirmMessageEl = document.getElementById('collaboration-confirm-message'); +const confirmAddBtn = document.getElementById('collaboration-confirm-add-btn'); +const sendBtn = document.getElementById('send-btn'); + +let cachedUserSettingsPromise = null; +let activeCollaborativeConversationId = null; +let activeCollaborationEventSource = null; +let typingUsers = new Map(); +let lastTypingState = false; +let typingStopHandle = null; +let mentionSearchToken = 0; +let activeMentionState = null; +let pendingParticipantConfirmation = null; +const notifiedPendingInviteConversationIds = new Set(); +const promptedPendingInviteConversationIds = new Set(); + +function isCollaborationEnabled() { + return Boolean(window.appSettings?.enable_collaborative_conversations); +} + +function getConversationDomItem(conversationId) { + if (!conversationId) { + return null; + } + + return document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); +} + +function getConversationKind(conversationId) { + const item = getConversationDomItem(conversationId); + return item?.dataset?.conversationKind || null; +} + +function isCollaborationConversation(conversationId) { + return getConversationKind(conversationId) === 'collaborative'; +} + +function getConversationChatType(conversationId) { + const item = getConversationDomItem(conversationId); + return item?.getAttribute('data-chat-type') || null; +} + +function setConversationDataset(conversationId, metadata = {}) { + const conversationSelectors = [ + `.conversation-item[data-conversation-id="${conversationId}"]`, + `.sidebar-conversation-item[data-conversation-id="${conversationId}"]`, + ]; + + conversationSelectors.forEach(selector => { + const element = document.querySelector(selector); + if (!element) { + return; + } + + if (metadata.conversation_kind) { + element.dataset.conversationKind = metadata.conversation_kind; + } + if (metadata.membership_status) { + element.dataset.membershipStatus = metadata.membership_status; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_manage_members')) { + element.dataset.canManageMembers = metadata.can_manage_members ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_manage_roles')) { + element.dataset.canManageRoles = metadata.can_manage_roles ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_accept_invite')) { + element.dataset.canAcceptInvite = metadata.can_accept_invite ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_post_messages')) { + element.dataset.canPostMessages = metadata.can_post_messages ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_delete_conversation')) { + element.dataset.canDeleteConversation = metadata.can_delete_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_leave_conversation')) { + element.dataset.canLeaveConversation = metadata.can_leave_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'current_user_role')) { + element.dataset.currentUserRole = metadata.current_user_role || ''; + } + }); +} + +function normalizeCollaborator(rawUser) { + if (!rawUser || typeof rawUser !== 'object') { + return null; + } + + const userId = String(rawUser.user_id || rawUser.userId || rawUser.id || '').trim(); + if (!userId) { + return null; + } + + const displayName = String(rawUser.display_name || rawUser.displayName || rawUser.name || rawUser.email || '').trim(); + const email = String(rawUser.email || rawUser.mail || '').trim(); + return { + user_id: userId, + display_name: displayName || email || 'Unknown User', + email, + }; +} + +function normalizeCollaborationConversation(rawConversation = {}) { + return { + ...rawConversation, + conversation_kind: rawConversation.conversation_kind || 'collaborative', + last_updated: rawConversation.last_updated || rawConversation.updated_at || rawConversation.last_message_at || rawConversation.created_at || new Date().toISOString(), + classification: Array.isArray(rawConversation.classification) ? rawConversation.classification : [], + tags: Array.isArray(rawConversation.tags) ? rawConversation.tags : [], + context: Array.isArray(rawConversation.context) ? rawConversation.context : [], + is_pinned: Boolean(rawConversation.is_pinned), + is_hidden: Boolean(rawConversation.is_hidden), + has_unread_assistant_response: Boolean(rawConversation.has_unread_assistant_response), + }; +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +async function getCachedUserSettings() { + if (!cachedUserSettingsPromise) { + cachedUserSettingsPromise = loadUserSettings().then(settings => settings || {}); + } + return cachedUserSettingsPromise; +} + +function setCachedUserSettings(settings = {}) { + cachedUserSettingsPromise = Promise.resolve(settings || {}); +} + +async function rememberRecentCollaborator(collaborator) { + const normalizedCollaborator = normalizeCollaborator(collaborator); + if (!normalizedCollaborator) { + return; + } + + const userSettings = await getCachedUserSettings(); + const existing = Array.isArray(userSettings[RECENT_COLLABORATORS_KEY]) + ? userSettings[RECENT_COLLABORATORS_KEY] + : []; + + const updatedCollaborators = [ + { + ...normalizedCollaborator, + last_used_at: new Date().toISOString(), + }, + ...existing.filter(item => String(item?.user_id || item?.userId || item?.id || '').trim() !== normalizedCollaborator.user_id), + ].slice(0, MAX_RECENT_COLLABORATORS); + + const nextSettings = { + ...userSettings, + [RECENT_COLLABORATORS_KEY]: updatedCollaborators, + }; + setCachedUserSettings(nextSettings); + saveUserSetting({ [RECENT_COLLABORATORS_KEY]: updatedCollaborators }); +} + +async function fetchJson(url, options = {}) { + const response = await fetch(url, { + credentials: 'same-origin', + ...options, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error || `Request failed (${response.status})`); + } + return payload; +} + +async function searchLocalCollaborators(query = '', options = {}) { + const search = new URLSearchParams(); + search.set('query', query); + search.set('limit', String(options.limit || DEFAULT_SUGGESTION_LIMIT)); + if (options.recentOnly) { + search.set('recent_only', 'true'); + } + + const payload = await fetchJson(`/api/user/collaboration-suggestions?${search.toString()}`); + return Array.isArray(payload.results) ? payload.results : []; +} + +function ensureTypingIndicator() { + let typingIndicator = document.getElementById('collaboration-typing-indicator'); + if (typingIndicator) { + return typingIndicator; + } + + const chatbox = document.getElementById('chatbox'); + if (!chatbox || !chatbox.parentElement) { + return null; + } + + typingIndicator = document.createElement('div'); + typingIndicator.id = 'collaboration-typing-indicator'; + typingIndicator.className = 'collaboration-typing-indicator d-none'; + chatbox.insertAdjacentElement('afterend', typingIndicator); + return typingIndicator; +} + +function renderTypingIndicator() { + const typingIndicator = ensureTypingIndicator(); + if (!typingIndicator) { + return; + } + + const now = Date.now(); + typingUsers.forEach((entry, userId) => { + const expiresAt = entry?.expiresAt ? Date.parse(entry.expiresAt) : 0; + if (!expiresAt || expiresAt <= now) { + typingUsers.delete(userId); + } + }); + + if (typingUsers.size === 0) { + typingIndicator.textContent = ''; + typingIndicator.classList.add('d-none'); + return; + } + + const names = Array.from(typingUsers.values()) + .map(entry => entry.displayName) + .filter(Boolean); + + if (names.length === 1) { + typingIndicator.textContent = `${names[0]} is typing...`; + } else if (names.length === 2) { + typingIndicator.textContent = `${names[0]} and ${names[1]} are typing...`; + } else { + typingIndicator.textContent = `${names[0]} and ${names.length - 1} others are typing...`; + } + + typingIndicator.classList.remove('d-none'); +} + +function clearTypingState() { + typingUsers = new Map(); + renderTypingIndicator(); +} + +function updateComposerAvailability(metadata = null) { + if (!userInput || !sendBtn) { + return; + } + + if (!metadata || metadata.conversation_kind !== 'collaborative') { + userInput.disabled = false; + sendBtn.disabled = false; + userInput.placeholder = 'Type your message...'; + return; + } + + const canPostMessages = metadata.can_post_messages !== false; + userInput.disabled = !canPostMessages; + sendBtn.disabled = !canPostMessages; + + if (canPostMessages) { + userInput.placeholder = 'Type a shared message...'; + return; + } + + if (metadata.membership_status === 'pending') { + userInput.placeholder = 'Accept the invite before posting messages...'; + } else { + userInput.placeholder = 'You cannot post messages in this conversation.'; + } +} + +function resolveMessageSenderType(message) { + if (message.role === 'assistant') { + return 'AI'; + } + + const senderUserId = message.sender?.user_id || message.metadata?.sender?.user_id || null; + if (senderUserId && senderUserId === getCurrentUserId()) { + return 'You'; + } + + return 'Collaborator'; +} + +function getCurrentUserId() { + return String(window.currentUser?.id || window.currentUser?.user_id || '').trim(); +} + +function getLatestPendingCollaborativeMessageId() { + const pendingMessages = Array.from(document.querySelectorAll('[data-message-id^="temp_user_"]')); + if (pendingMessages.length === 0) { + return null; + } + + return pendingMessages[pendingMessages.length - 1].getAttribute('data-message-id'); +} + +function reconcilePendingCollaborativeUserMessage(message, preferredTempId = null) { + const senderUserId = String(message?.sender?.user_id || message?.metadata?.sender?.user_id || '').trim(); + if (!senderUserId || senderUserId !== getCurrentUserId()) { + return false; + } + + const realMessageId = String(message?.id || '').trim(); + if (!realMessageId) { + return false; + } + + const pendingMessageId = preferredTempId || getLatestPendingCollaborativeMessageId(); + const existingRealMessage = document.querySelector(`[data-message-id="${realMessageId}"]`); + + if (existingRealMessage && pendingMessageId) { + const pendingMessage = document.querySelector(`[data-message-id="${pendingMessageId}"]`); + if (pendingMessage) { + pendingMessage.remove(); + } + return true; + } + + if (pendingMessageId) { + updateUserMessageId(pendingMessageId, realMessageId); + return true; + } + + return Boolean(existingRealMessage); +} + +function renderCollaborationMessage(message, options = {}) { + if (!message || !message.id) { + return; + } + + if (document.querySelector(`[data-message-id="${message.id}"]`)) { + return; + } + + const senderType = resolveMessageSenderType(message); + appendMessage( + senderType, + message.content || '', + message.model_deployment_name || null, + message.id, + Boolean(message.augmented), + Array.isArray(message.hybrid_citations) ? message.hybrid_citations : [], + Array.isArray(message.web_search_citations) ? message.web_search_citations : [], + Array.isArray(message.agent_citations) ? message.agent_citations : [], + message.agent_display_name || null, + message.agent_name || null, + { + ...message, + metadata: message.metadata || {}, + sender: message.sender || {}, + }, + Boolean(options.isNewMessage) + ); +} + +async function loadConversationMessages(conversationId) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/messages`); + const chatbox = document.getElementById('chatbox'); + if (!chatbox) { + return []; + } + + chatbox.innerHTML = ''; + clearTypingState(); + + const messages = Array.isArray(payload.messages) ? payload.messages : []; + messages.forEach(message => renderCollaborationMessage(message)); + return messages; +} + +function handleTypingEvent(payload = {}) { + const currentUserId = getCurrentUserId(); + const typingUser = normalizeCollaborator(payload.user); + if (!typingUser || typingUser.user_id === currentUserId) { + return; + } + + if (payload.is_typing === false) { + typingUsers.delete(typingUser.user_id); + renderTypingIndicator(); + return; + } + + typingUsers.set(typingUser.user_id, { + displayName: typingUser.display_name, + expiresAt: payload.expires_at, + }); + renderTypingIndicator(); +} + +function disconnectConversationEvents() { + const previousConversationId = activeCollaborativeConversationId; + + if (activeCollaborationEventSource) { + activeCollaborationEventSource.close(); + activeCollaborationEventSource = null; + } + + if (typingStopHandle) { + window.clearTimeout(typingStopHandle); + typingStopHandle = null; + } + + setTypingState(false, { + force: true, + conversationId: previousConversationId, + }); + + activeCollaborativeConversationId = null; + clearTypingState(); + lastTypingState = false; +} + +function handleConversationEvent(eventEnvelope = {}) { + if (!eventEnvelope || !eventEnvelope.event_type) { + return; + } + + const payload = eventEnvelope.payload || {}; + if (payload.conversation) { + const normalizedConversation = normalizeCollaborationConversation(payload.conversation); + setConversationDataset(normalizedConversation.id, normalizedConversation); + applyConversationMetadataUpdate(normalizedConversation.id, normalizedConversation); + if (!['collaboration.message.created', 'collaboration.typing.updated'].includes(eventEnvelope.event_type)) { + void fetchConversationMetadata(normalizedConversation.id).catch(() => {}); + } + } + + if (eventEnvelope.event_type === 'collaboration.message.created' && payload.message) { + if (reconcilePendingCollaborativeUserMessage(payload.message)) { + return; + } + renderCollaborationMessage(payload.message, { isNewMessage: true }); + return; + } + + if (eventEnvelope.event_type === 'collaboration.typing.updated') { + handleTypingEvent(payload); + return; + } + + if (eventEnvelope.event_type === 'collaboration.member.invited' && Array.isArray(payload.participants)) { + return; + } + + if (eventEnvelope.event_type === 'collaboration.member.removed' && payload.participant?.display_name) { + if (String(payload.participant.user_id || '').trim() === getCurrentUserId()) { + showToast('You no longer have access to this shared conversation.', 'warning'); + window.chatConversations?.removeConversationFromUi?.(eventEnvelope.conversation_id || payload.conversation?.id, { + refreshList: true, + skipToast: true, + }); + return; + } + showToast(`${payload.participant.display_name} was removed from the conversation.`, 'info'); + return; + } + + if (eventEnvelope.event_type === 'collaboration.member.role_updated' && payload.participant?.display_name) { + const roleLabel = payload.participant.role === 'admin' ? 'admin' : 'member'; + showToast(`${payload.participant.display_name} is now ${roleLabel}.`, 'success'); + return; + } + + if (eventEnvelope.event_type === 'collaboration.invite.accepted' && payload.participant?.display_name) { + showToast(`${payload.participant.display_name} accepted the invite.`, 'success'); + return; + } + + if (eventEnvelope.event_type === 'collaboration.deleted') { + showToast('This shared conversation was deleted.', 'warning'); + window.chatConversations?.removeConversationFromUi?.(eventEnvelope.conversation_id || payload.conversation?.id, { + refreshList: true, + skipToast: true, + }); + } +} + +function subscribeToConversationEvents(conversationId) { + if (!isCollaborationEnabled() || !conversationId || typeof EventSource === 'undefined') { + return; + } + + disconnectConversationEvents(); + activeCollaborativeConversationId = conversationId; + activeCollaborationEventSource = new EventSource(`/api/collaboration/conversations/${encodeURIComponent(conversationId)}/events`); + activeCollaborationEventSource.onmessage = event => { + if (!event?.data) { + return; + } + + try { + handleConversationEvent(JSON.parse(event.data)); + } catch (error) { + console.warn('Failed to parse collaboration event:', error); + } + }; + activeCollaborationEventSource.onerror = () => { + console.warn('Collaboration event stream disconnected.'); + }; +} + +async function fetchConversationMetadata(conversationId) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}`); + const normalizedConversation = normalizeCollaborationConversation(payload.conversation || {}); + setConversationDataset(conversationId, normalizedConversation); + applyConversationMetadataUpdate(conversationId, normalizedConversation); + return normalizedConversation; +} + +function showPendingInviteToast(conversation) { + if (!conversation?.id || !conversation.can_accept_invite) { + return; + } + + if (notifiedPendingInviteConversationIds.has(conversation.id)) { + return; + } + + notifiedPendingInviteConversationIds.add(conversation.id); + const actionId = `collaboration-invite-review-${conversation.id}-${Date.now()}`; + showToast( + `You were invited to ${escapeHtml(conversation.title || 'a collaborative conversation')}. `, + 'warning' + ); + + window.setTimeout(() => { + const actionButton = document.getElementById(actionId); + if (!actionButton) { + return; + } + + actionButton.addEventListener('click', async event => { + event.preventDefault(); + if (window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(conversation.id); + } + if (window.showConversationDetails) { + window.showConversationDetails(conversation.id); + } + }, { once: true }); + }, 0); +} + +function notifyPendingInvites(conversations = []) { + conversations.forEach(conversation => { + if (conversation?.can_accept_invite) { + showPendingInviteToast(conversation); + } + }); +} + +async function fetchCollaborationConversationList() { + if (!isCollaborationEnabled()) { + return []; + } + + const payload = await fetchJson('/api/collaboration/conversations?include_pending=true'); + const conversations = Array.isArray(payload.conversations) ? payload.conversations : []; + const normalizedConversations = conversations.map(conversation => normalizeCollaborationConversation(conversation)); + notifyPendingInvites(normalizedConversations); + return normalizedConversations; +} + +async function activateConversation(conversationId, metadata = null) { + const conversationMetadata = metadata || await fetchConversationMetadata(conversationId); + updateComposerAvailability(conversationMetadata); + await loadConversationMessages(conversationId); + subscribeToConversationEvents(conversationId); + + if (conversationMetadata.can_accept_invite && !promptedPendingInviteConversationIds.has(conversationId)) { + promptedPendingInviteConversationIds.add(conversationId); + showPendingInviteToast(conversationMetadata); + if (window.showConversationDetails) { + window.setTimeout(() => { + window.showConversationDetails(conversationId); + }, 0); + } + } + + return conversationMetadata; +} + +function deactivateConversation() { + disconnectConversationEvents(); + updateComposerAvailability(null); + hideMentionMenu(); +} + +async function sendCollaborativeMessage(messageText, tempMessageId = null) { + const conversationId = window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId) { + return null; + } + + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content: messageText, + }), + }); + + if (payload.conversation) { + const normalizedConversation = normalizeCollaborationConversation(payload.conversation); + setConversationDataset(conversationId, normalizedConversation); + applyConversationMetadataUpdate(conversationId, normalizedConversation); + } + + if (payload.message) { + if (!reconcilePendingCollaborativeUserMessage(payload.message, tempMessageId)) { + renderCollaborationMessage(payload.message, { isNewMessage: true }); + } + } + + setTypingState(false, { force: true }); + return payload; +} + +function setTypingState(isTyping, options = {}) { + const conversationId = options.conversationId || window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId || !isCollaborationConversation(conversationId) || !canPostMessages(conversationId)) { + return; + } + + if (!options.force && lastTypingState === isTyping) { + return; + } + + lastTypingState = isTyping; + fetch(`/api/collaboration/conversations/${conversationId}/typing`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ is_typing: isTyping }), + }).catch(error => { + console.warn('Failed to update collaboration typing state:', error); + }); +} + +function scheduleTypingState() { + if (!isCollaborationConversation(window.chatConversations?.getCurrentConversationId?.())) { + return; + } + + const hasContent = Boolean(userInput?.value?.trim()); + setTypingState(hasContent); + + if (typingStopHandle) { + window.clearTimeout(typingStopHandle); + } + typingStopHandle = window.setTimeout(() => { + setTypingState(false); + }, 3000); +} + +function getMentionMatch() { + if (!userInput) { + return null; + } + + const selectionStart = typeof userInput.selectionStart === 'number' + ? userInput.selectionStart + : userInput.value.length; + const beforeCursor = userInput.value.slice(0, selectionStart); + const match = beforeCursor.match(/(^|\s)@([^\s@]*)$/); + if (!match) { + return null; + } + + const startIndex = selectionStart - match[2].length - 1; + return { + query: match[2] || '', + startIndex, + endIndex: selectionStart, + }; +} + +function buildSuggestionItemHtml(suggestion) { + const subtitle = suggestion.email + ? `
${suggestion.email}
` + : '
No email recorded
'; + const sourceLabel = suggestion.source === 'recent' + ? 'Recent' + : 'Local'; + + return ` +
+
+
${suggestion.display_name}
+ ${subtitle} +
+ ${sourceLabel} +
+ `; +} + +function hideMentionMenu() { + if (!mentionMenu) { + return; + } + + mentionMenu.innerHTML = ''; + mentionMenu.classList.add('d-none'); + activeMentionState = null; +} + +function renderMentionMenu(results, mentionState) { + if (!mentionMenu) { + return; + } + + if (!Array.isArray(results) || results.length === 0) { + mentionMenu.innerHTML = '
No local collaborators found.
'; + mentionMenu.classList.remove('d-none'); + activeMentionState = { + ...mentionState, + results: [], + activeIndex: -1, + }; + return; + } + + activeMentionState = { + ...mentionState, + results, + activeIndex: 0, + }; + + mentionMenu.innerHTML = ''; + results.forEach((result, index) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `list-group-item list-group-item-action collaboration-mention-item${index === 0 ? ' active' : ''}`; + button.innerHTML = buildSuggestionItemHtml(result); + button.setAttribute('data-index', String(index)); + button.addEventListener('mousedown', event => { + event.preventDefault(); + openParticipantConfirmation(result, { + conversationId: window.chatConversations?.getCurrentConversationId?.(), + source: 'mention', + mentionState, + }); + }); + mentionMenu.appendChild(button); + }); + mentionMenu.classList.remove('d-none'); +} + +function updateMentionMenuActiveItem() { + if (!mentionMenu || !activeMentionState) { + return; + } + + const items = mentionMenu.querySelectorAll('.collaboration-mention-item'); + items.forEach((item, index) => { + item.classList.toggle('active', index === activeMentionState.activeIndex); + }); +} + +async function refreshMentionSuggestions() { + const conversationId = window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId || !canUseParticipantFlow(conversationId)) { + hideMentionMenu(); + return; + } + + const mentionState = getMentionMatch(); + if (!mentionState) { + hideMentionMenu(); + return; + } + + const searchToken = ++mentionSearchToken; + try { + const results = await searchLocalCollaborators(mentionState.query, { recentOnly: false, limit: DEFAULT_SUGGESTION_LIMIT }); + if (searchToken !== mentionSearchToken) { + return; + } + renderMentionMenu(results, mentionState); + } catch (error) { + if (searchToken !== mentionSearchToken) { + return; + } + hideMentionMenu(); + console.warn('Failed to load mention suggestions:', error); + } +} + +function removeMentionFromComposer(mentionState) { + if (!userInput || !mentionState) { + return; + } + + const beforeMention = userInput.value.slice(0, mentionState.startIndex); + const afterMention = userInput.value.slice(mentionState.endIndex); + const nextValue = `${beforeMention}${afterMention}`.replace(/\s{2,}/g, ' ').trimStart(); + userInput.value = nextValue; + updateSendButtonVisibility(); + userInput.focus(); +} + +function renderParticipantResults(results, emptyMessage = 'No collaborators found.') { + if (!participantResults) { + return; + } + + if (!Array.isArray(results) || results.length === 0) { + participantResults.innerHTML = `
${emptyMessage}
`; + return; + } + + participantResults.innerHTML = ''; + results.forEach(result => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'list-group-item list-group-item-action'; + button.innerHTML = buildSuggestionItemHtml(result); + button.addEventListener('click', () => { + openParticipantConfirmation(result, { + conversationId: participantConversationIdInput?.value || window.chatConversations?.getCurrentConversationId?.(), + source: 'picker', + }); + }); + participantResults.appendChild(button); + }); +} + +async function refreshParticipantPickerResults(query = '') { + try { + const results = await searchLocalCollaborators(query, { recentOnly: false, limit: 12 }); + renderParticipantResults(results); + } catch (error) { + renderParticipantResults([], 'Failed to load collaborators.'); + console.warn('Failed to refresh participant picker results:', error); + } +} + +function openParticipantPicker(options = {}) { + const conversationId = options.conversationId || window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId) { + showToast('Select a conversation first.', 'warning'); + return; + } + + if (!canUseParticipantFlow(conversationId)) { + showToast('Participants can only be added to eligible personal conversations you manage.', 'warning'); + return; + } + + if (!participantModalEl || !participantSearchInput || !participantConversationIdInput) { + return; + } + + participantConversationIdInput.value = conversationId; + participantSearchInput.value = ''; + renderParticipantResults([], 'Loading collaborators...'); + bootstrap.Modal.getOrCreateInstance(participantModalEl).show(); + refreshParticipantPickerResults(''); +} + +function openParticipantConfirmation(userSummary, context = {}) { + const collaborator = normalizeCollaborator(userSummary); + if (!collaborator || !confirmModalEl || !confirmMessageEl) { + return; + } + + pendingParticipantConfirmation = { + collaborator, + context, + }; + confirmMessageEl.innerHTML = `Are you sure you want to add ${collaborator.display_name}${collaborator.email ? ` (${collaborator.email})` : ''} to this conversation?`; + bootstrap.Modal.getOrCreateInstance(confirmModalEl).show(); +} + +function canUseParticipantFlow(conversationId) { + const chatType = getConversationChatType(conversationId); + if (!chatType || !['personal_single_user', 'personal_multi_user'].includes(chatType)) { + return false; + } + + if (!isCollaborationConversation(conversationId)) { + return true; + } + + const item = getConversationDomItem(conversationId); + return item?.dataset?.canManageMembers === 'true'; +} + +function canPostMessages(conversationId) { + if (!isCollaborationConversation(conversationId)) { + return true; + } + + const item = getConversationDomItem(conversationId); + return item?.dataset?.canPostMessages !== 'false'; +} + +async function addParticipantToConversation(conversationId, collaborator) { + const isCollaborative = isCollaborationConversation(conversationId); + const endpoint = isCollaborative + ? `/api/collaboration/conversations/${conversationId}/members` + : `/api/collaboration/conversations/from-personal/${conversationId}/members`; + + const payload = await fetchJson(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + participants: [collaborator], + }), + }); + + const normalizedConversation = normalizeCollaborationConversation(payload.conversation || {}); + await rememberRecentCollaborator(collaborator); + + if (window.chatConversations?.loadConversations) { + await window.chatConversations.loadConversations(); + } + + if (normalizedConversation.id && window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(normalizedConversation.id); + } + + setConversationDataset(normalizedConversation.id, normalizedConversation); + return { + ...payload, + conversation: normalizedConversation, + }; +} + +async function confirmPendingParticipant() { + if (!pendingParticipantConfirmation) { + return; + } + + const { collaborator, context } = pendingParticipantConfirmation; + const conversationId = context.conversationId || window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId) { + return; + } + + confirmAddBtn.disabled = true; + try { + const payload = await addParticipantToConversation(conversationId, collaborator); + bootstrap.Modal.getOrCreateInstance(confirmModalEl).hide(); + bootstrap.Modal.getOrCreateInstance(participantModalEl)?.hide(); + + if (context.source === 'mention' && context.mentionState) { + removeMentionFromComposer(context.mentionState); + hideMentionMenu(); + } + + showToast( + payload.created + ? 'Conversation converted to a collaborative chat and participant invited.' + : 'Participant invited to the conversation.', + 'success' + ); + + const detailsModalVisible = document.getElementById('conversation-details-modal')?.classList.contains('show'); + if (detailsModalVisible && window.showConversationDetails && payload.conversation?.id) { + window.showConversationDetails(payload.conversation.id); + } + } catch (error) { + showToast(error.message || 'Failed to add participant.', 'danger'); + } finally { + confirmAddBtn.disabled = false; + pendingParticipantConfirmation = null; + } +} + +async function respondToInvite(conversationId, action) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/invite-response`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ action }), + }); + + if (window.chatConversations?.loadConversations) { + await window.chatConversations.loadConversations(); + } + + if (action === 'accept') { + notifiedPendingInviteConversationIds.delete(conversationId); + promptedPendingInviteConversationIds.delete(conversationId); + } + + if (action === 'accept' && payload.conversation?.id && window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(payload.conversation.id); + } + + window.hideConversationDetails?.(); + showToast(action === 'accept' ? 'Invite accepted.' : 'Invite declined.', 'success'); + return payload; +} + +async function removeParticipant(conversationId, memberUserId) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/members/${encodeURIComponent(memberUserId)}`, { + method: 'DELETE', + }); + + if (window.chatConversations?.loadConversations) { + await window.chatConversations.loadConversations(); + } + if (window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(conversationId); + } + + showToast('Participant removed from the conversation.', 'success'); + if (window.showConversationDetails) { + window.showConversationDetails(conversationId); + } + return payload; +} + +async function updateParticipantRole(conversationId, memberUserId, role) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/members/${encodeURIComponent(memberUserId)}/role`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ role }), + }); + + if (window.chatConversations?.loadConversations) { + await window.chatConversations.loadConversations(); + } + if (window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(conversationId); + } + + showToast(role === 'admin' ? 'Participant promoted to admin.' : 'Participant admin access removed.', 'success'); + if (window.showConversationDetails) { + window.showConversationDetails(conversationId); + } + return payload; +} + +function handleComposerInput() { + if (!isCollaborationEnabled()) { + return; + } + + scheduleTypingState(); + void refreshMentionSuggestions(); +} + +function handleComposerKeydown(event) { + if (!activeMentionState || mentionMenu?.classList.contains('d-none')) { + return false; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + activeMentionState.activeIndex = Math.min(activeMentionState.activeIndex + 1, Math.max(activeMentionState.results.length - 1, 0)); + updateMentionMenuActiveItem(); + return true; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + activeMentionState.activeIndex = Math.max(activeMentionState.activeIndex - 1, 0); + updateMentionMenuActiveItem(); + return true; + } + + if (event.key === 'Enter' && activeMentionState.activeIndex >= 0) { + event.preventDefault(); + const collaborator = activeMentionState.results[activeMentionState.activeIndex]; + if (collaborator) { + openParticipantConfirmation(collaborator, { + conversationId: window.chatConversations?.getCurrentConversationId?.(), + source: 'mention', + mentionState: activeMentionState, + }); + } + return true; + } + + if (event.key === 'Escape') { + hideMentionMenu(); + return true; + } + + return false; +} + +function handleComposerBlur() { + window.setTimeout(() => { + hideMentionMenu(); + }, 100); +} + +function initializeUi() { + if (!isCollaborationEnabled()) { + return; + } + + if (participantSearchInput) { + participantSearchInput.addEventListener('input', event => { + void refreshParticipantPickerResults(event.target.value || ''); + }); + } + + if (participantModalEl) { + participantModalEl.addEventListener('shown.bs.modal', () => { + participantSearchInput?.focus(); + }); + } + + if (confirmAddBtn) { + confirmAddBtn.addEventListener('click', () => { + void confirmPendingParticipant(); + }); + } + + document.addEventListener('click', event => { + if (!mentionMenu || mentionMenu.classList.contains('d-none')) { + return; + } + + const withinMentionMenu = mentionMenu.contains(event.target); + if (!withinMentionMenu && event.target !== userInput) { + hideMentionMenu(); + } + }); +} + +window.chatCollaboration = { + activateConversation, + deactivateConversation, + fetchCollaborationConversationList, + fetchConversationMetadata, + handleComposerBlur, + handleComposerInput, + handleComposerKeydown, + isCollaborationConversation, + openParticipantPicker, + removeParticipant, + respondToInvite, + sendCollaborativeMessage, + updateParticipantRole, + canUseParticipantFlow, +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeUi); +} else { + initializeUi(); +} \ No newline at end of file diff --git a/application/single_app/static/js/chat/chat-conversation-details.js b/application/single_app/static/js/chat/chat-conversation-details.js index c8438617a..dac9ad1c1 100644 --- a/application/single_app/static/js/chat/chat-conversation-details.js +++ b/application/single_app/static/js/chat/chat-conversation-details.js @@ -5,20 +5,105 @@ import { isColorLight } from "./chat-utils.js"; +function getConversationDetailsModalElements() { + return { + modal: document.getElementById('conversation-details-modal'), + modalTitle: document.getElementById('conversationDetailsModalLabel'), + content: document.getElementById('conversation-details-content'), + actionContainer: document.getElementById('conversation-details-actions'), + }; +} + +function cleanupConversationDetailsModalState() { + const anyVisibleModal = document.querySelector('.modal.show'); + if (anyVisibleModal) { + return; + } + + document.querySelectorAll('.modal-backdrop').forEach(backdrop => backdrop.remove()); + document.body.classList.remove('modal-open'); + document.body.style.removeProperty('padding-right'); +} + +function getConversationDetailsModalInstance() { + const { modal } = getConversationDetailsModalElements(); + if (!modal || !window.bootstrap?.Modal) { + return null; + } + return bootstrap.Modal.getOrCreateInstance(modal); +} + +export function hideConversationDetails() { + const { modal } = getConversationDetailsModalElements(); + const modalInstance = getConversationDetailsModalInstance(); + if (!modal || !modalInstance) { + cleanupConversationDetailsModalState(); + return; + } + + if (modal.classList.contains('show')) { + modalInstance.hide(); + window.setTimeout(cleanupConversationDetailsModalState, 200); + return; + } + + cleanupConversationDetailsModalState(); +} + +function renderConversationDetailsActions(metadata, conversationId) { + const { actionContainer } = getConversationDetailsModalElements(); + if (!actionContainer) { + return; + } + + if (!metadata || !conversationId) { + actionContainer.innerHTML = ''; + return; + } + + const actionButtons = []; + + if (window.chatExport?.openExportWizard) { + actionButtons.push(` + + `); + } + + const isCollaborativeConversation = metadata.conversation_kind === 'collaborative'; + const canShowDeleteAction = isCollaborativeConversation + ? Boolean(metadata.can_delete_conversation || metadata.can_leave_conversation) + : true; + + if (canShowDeleteAction) { + const deleteLabel = isCollaborativeConversation + ? (metadata.can_delete_conversation ? 'Delete / Leave' : 'Leave') + : 'Delete'; + actionButtons.push(` + + `); + } + + actionContainer.innerHTML = actionButtons.join(''); +} + /** * Show conversation details in a modal * @param {string} conversationId - The conversation ID to show details for */ export async function showConversationDetails(conversationId) { - const modal = document.getElementById('conversation-details-modal'); - const modalTitle = document.getElementById('conversationDetailsModalLabel'); - const content = document.getElementById('conversation-details-content'); + const { modal, modalTitle, content } = getConversationDetailsModalElements(); if (!modal || !content) { console.error('Conversation details modal not found'); return; } + renderConversationDetailsActions(null, null); + // Show loading state content.innerHTML = `
@@ -30,19 +115,29 @@ export async function showConversationDetails(conversationId) { `; // Show the modal - const bsModal = new bootstrap.Modal(modal); - bsModal.show(); + const bsModal = getConversationDetailsModalInstance(); + if (bsModal && !modal.classList.contains('show')) { + bsModal.show(); + } try { - // Fetch conversation metadata - const response = await fetch(`/api/conversations/${conversationId}/metadata`); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + const conversationItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); + const isCollaborativeConversation = conversationItem?.dataset?.conversationKind === 'collaborative'; + let metadata = null; + + if (isCollaborativeConversation && window.chatCollaboration?.fetchConversationMetadata) { + metadata = await window.chatCollaboration.fetchConversationMetadata(conversationId); + } else { + const response = await fetch(`/api/conversations/${conversationId}/metadata`); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + metadata = await response.json(); } - const metadata = await response.json(); - // Update modal title with conversation title, pin icon, and hidden icon const pinIcon = metadata.is_pinned ? '' : ''; const hiddenIcon = metadata.is_hidden ? '' : ''; @@ -53,9 +148,12 @@ export async function showConversationDetails(conversationId) { // Render the metadata content.innerHTML = renderConversationMetadata(metadata, conversationId); + renderConversationDetailsActions(metadata, conversationId); + attachConversationDetailActions(metadata, conversationId); } catch (error) { console.error('Error fetching conversation details:', error); + renderConversationDetailsActions(null, null); content.innerHTML = `
@@ -75,7 +173,32 @@ export async function showConversationDetails(conversationId) { * @returns {string} HTML string */ function renderConversationMetadata(metadata, conversationId) { - const { context = [], tags = [], strict = false, classification = [], last_updated, chat_type = 'personal', is_pinned = false, is_hidden = false, scope_locked, locked_contexts = [], summary = null } = metadata; + const { + context = [], + tags = [], + strict = false, + classification = [], + last_updated, + updated_at, + chat_type = 'personal', + is_pinned = false, + is_hidden = false, + scope_locked, + locked_contexts = [], + summary = null, + conversation_kind = null, + participants = [], + membership_status = null, + can_manage_members = false, + can_manage_roles = false, + can_accept_invite = false, + can_post_messages = true, + can_delete_conversation = false, + can_leave_conversation = false, + current_user_role = '', + pending_invite_count = 0, + } = metadata; + const resolvedLastUpdated = last_updated || updated_at; // Organize tags by category const tagsByCategory = { @@ -94,6 +217,13 @@ function renderConversationMetadata(metadata, conversationId) { } }); + const participantRecords = Array.isArray(participants) && participants.length > 0 + ? participants + : tagsByCategory.participant; + const collaborationStatusHtml = conversation_kind === 'collaborative' + ? renderCollaborationMembershipStatus(membership_status, can_post_messages, pending_invite_count) + : `${is_pinned ? 'Pinned' : ''} ${is_hidden ? 'Hidden' : ''}${!is_pinned && !is_hidden ? 'Normal' : ''}`; + // Build HTML sections let html = `
@@ -121,7 +251,7 @@ function renderConversationMetadata(metadata, conversationId) { Conversation ID: ${conversationId}
- Last Updated: ${formatDate(last_updated)} + Last Updated: ${formatDate(resolvedLastUpdated)}
Strict Mode: ${strict ? 'Enabled' : 'Disabled'} @@ -133,11 +263,16 @@ function renderConversationMetadata(metadata, conversationId) { Classifications: ${formatClassifications(classification)}
- Status: ${is_pinned ? 'Pinned' : ''} ${is_hidden ? 'Hidden' : ''}${!is_pinned && !is_hidden ? 'Normal' : ''} + Status: ${collaborationStatusHtml}
Scope Lock: ${formatScopeLockStatus(scope_locked, locked_contexts)}
+ ${conversation_kind === 'collaborative' ? ` +
+ Your Role: ${formatCollaborationRole(current_user_role, can_delete_conversation, can_leave_conversation)} +
+ ` : ''}
@@ -161,15 +296,20 @@ function renderConversationMetadata(metadata, conversationId) { } // Participants Section - if (tagsByCategory.participant.length > 0) { + if (participantRecords.length > 0 || can_manage_members || can_accept_invite) { html += `
-
+
Participants
+ ${renderCollaborationActionButtons(conversationId, metadata)}
- ${renderParticipantsSection(tagsByCategory.participant)} + ${renderParticipantsSection(participantRecords, { + canManageMembers: can_manage_members, + canManageRoles: can_manage_roles, + conversationKind: conversation_kind, + })}
@@ -244,6 +384,70 @@ function renderConversationMetadata(metadata, conversationId) { return html; } +function renderCollaborationMembershipStatus(membershipStatus, canPostMessages, pendingInviteCount) { + if (!membershipStatus) { + return 'Normal'; + } + + const badges = []; + if (membershipStatus === 'accepted' || membershipStatus === 'group_member') { + badges.push('Active member'); + } + if (membershipStatus === 'pending') { + badges.push('Invite pending'); + } + if (!canPostMessages) { + badges.push('Read-only'); + } + if (pendingInviteCount > 0) { + badges.push(`${pendingInviteCount} pending`); + } + + return badges.join(' ') || 'Normal'; +} + +function formatCollaborationRole(currentUserRole, canDeleteConversation, canLeaveConversation) { + if (!currentUserRole) { + return 'Participant'; + } + + if (currentUserRole === 'owner') { + return `Owner${canDeleteConversation ? ' Can delete for everyone' : ''}`; + } + if (currentUserRole === 'admin') { + return 'Admin Can invite members'; + } + if (canLeaveConversation) { + return 'Member'; + } + return 'Participant'; +} + +function renderCollaborationActionButtons(conversationId, metadata) { + if (metadata.can_accept_invite) { + return ` +
+ + +
+ `; + } + + if (metadata.can_manage_members) { + return ` + + `; + } + + return ''; +} + /** * Render context section */ @@ -255,7 +459,7 @@ function renderContextSection(context) { if (primary) { const displayName = primary.name || primary.id; - const isGroupChat = primary.scope === 'group'; + const groupContextBadge = primary.scope === 'group' ? 'group' : ''; html += `
@@ -263,7 +467,7 @@ function renderContextSection(context) {
${primary.scope} - ${isGroupChat ? 'single-user' : ''} + ${groupContextBadge} ${displayName}
${primary.name ? `
ID: ${primary.id}
` : ''} @@ -299,22 +503,68 @@ function renderContextSection(context) { /** * Render participants section */ -function renderParticipantsSection(participants) { +function renderParticipantsSection(participants, options = {}) { let html = ''; participants.forEach(participant => { - const initials = (participant.name || 'U').slice(0, 2).toUpperCase(); + const displayName = participant.display_name || participant.name || 'Unknown User'; + const participantStatus = participant.status || null; + const participantRole = participant.role || null; + const initials = displayName.slice(0, 2).toUpperCase(); const avatarId = `participant-avatar-${participant.user_id}`; + const canRemoveParticipant = Boolean(options.canManageMembers) + && options.conversationKind === 'collaborative' + && participantRole !== 'owner'; + const canToggleAdmin = Boolean(options.canManageRoles) + && options.conversationKind === 'collaborative' + && participantRole !== 'owner' + && participantStatus === 'accepted'; + + let statusBadgesHtml = ''; + if (participantRole === 'owner') { + statusBadgesHtml += 'Owner'; + } + if (participantRole === 'admin') { + statusBadgesHtml += 'Admin'; + } + if (participantStatus === 'pending') { + statusBadgesHtml += 'Pending'; + } + if (participantStatus === 'removed') { + statusBadgesHtml += 'Removed'; + } + if (participantStatus === 'declined') { + statusBadgesHtml += 'Declined'; + } + + const participantActions = []; + if (canToggleAdmin) { + const nextRole = participantRole === 'admin' ? 'member' : 'admin'; + const roleActionLabel = participantRole === 'admin' ? 'Remove admin' : 'Make admin'; + participantActions.push(` + + `); + } + if (canRemoveParticipant) { + participantActions.push(` + + `); + } html += ` -
+
${initials}
-
-
${participant.name || 'Unknown User'}
+
+
${displayName}${statusBadgesHtml}
${participant.email || ''}
+ ${participantActions.length > 0 ? `
${participantActions.join('')}
` : ''}
`; }); @@ -329,6 +579,67 @@ function renderParticipantsSection(participants) { return html; } +function attachConversationDetailActions(metadata, conversationId) { + const addParticipantBtn = document.querySelector('[data-collaboration-action="add-participant"]'); + const acceptInviteBtn = document.querySelector('[data-collaboration-action="accept-invite"]'); + const declineInviteBtn = document.querySelector('[data-collaboration-action="decline-invite"]'); + const removeParticipantButtons = document.querySelectorAll('[data-collaboration-action="remove-participant"]'); + const roleButtons = document.querySelectorAll('[data-collaboration-action="toggle-participant-role"]'); + const exportConversationBtn = document.querySelector('[data-conversation-action="export"]'); + const deleteConversationBtn = document.querySelector('[data-conversation-action="delete"]'); + + if (addParticipantBtn) { + addParticipantBtn.addEventListener('click', () => { + window.chatCollaboration?.openParticipantPicker?.({ conversationId }); + }); + } + + if (acceptInviteBtn) { + acceptInviteBtn.addEventListener('click', () => { + window.chatCollaboration?.respondToInvite?.(conversationId, 'accept'); + }); + } + + if (declineInviteBtn) { + declineInviteBtn.addEventListener('click', () => { + window.chatCollaboration?.respondToInvite?.(conversationId, 'decline'); + }); + } + + removeParticipantButtons.forEach(button => { + button.addEventListener('click', () => { + const memberUserId = button.getAttribute('data-member-user-id'); + if (!memberUserId) { + return; + } + window.chatCollaboration?.removeParticipant?.(conversationId, memberUserId); + }); + }); + + roleButtons.forEach(button => { + button.addEventListener('click', () => { + const memberUserId = button.getAttribute('data-member-user-id'); + const nextRole = button.getAttribute('data-next-role'); + if (!memberUserId || !nextRole) { + return; + } + window.chatCollaboration?.updateParticipantRole?.(conversationId, memberUserId, nextRole); + }); + }); + + if (exportConversationBtn) { + exportConversationBtn.addEventListener('click', () => { + window.chatExport?.openExportWizard?.([conversationId], true); + }); + } + + if (deleteConversationBtn) { + deleteConversationBtn.addEventListener('click', () => { + window.chatConversations?.deleteConversation?.(conversationId); + }); + } +} + /** * Load profile image for a participant */ @@ -751,3 +1062,20 @@ document.addEventListener('click', function(e) { // Export functions for external use window.showConversationDetails = showConversationDetails; +window.hideConversationDetails = hideConversationDetails; + +function initializeConversationDetailsModal() { + const { modal } = getConversationDetailsModalElements(); + if (!modal || modal.dataset.initialized === 'true') { + return; + } + + modal.dataset.initialized = 'true'; + modal.addEventListener('hidden.bs.modal', cleanupConversationDetailsModalState); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeConversationDetailsModal); +} else { + initializeConversationDetailsModal(); +} diff --git a/application/single_app/static/js/chat/chat-conversations.js b/application/single_app/static/js/chat/chat-conversations.js index 560571e92..31eb28965 100644 --- a/application/single_app/static/js/chat/chat-conversations.js +++ b/application/single_app/static/js/chat/chat-conversations.js @@ -23,6 +23,15 @@ const conversationsList = document.getElementById("conversations-list"); const currentConversationTitleEl = document.getElementById("current-conversation-title"); const currentConversationClassificationsEl = document.getElementById("current-conversation-classifications"); const chatbox = document.getElementById("chatbox"); +const deleteConversationModalEl = document.getElementById("delete-conversation-modal"); +const deleteConversationMessageEl = document.getElementById("delete-conversation-message"); +const deleteConversationSharedWarningEl = document.getElementById("delete-conversation-shared-warning"); +const deleteConversationOwnerOptionsEl = document.getElementById("delete-conversation-owner-options"); +const deleteConversationTransferOptionEl = document.getElementById("delete-conversation-transfer-option"); +const deleteConversationOwnerSelectContainerEl = document.getElementById("delete-conversation-owner-select-container"); +const deleteConversationNewOwnerSelectEl = document.getElementById("delete-conversation-new-owner-select"); +const deleteConversationImpactNoteEl = document.getElementById("delete-conversation-impact-note"); +const confirmDeleteConversationBtn = document.getElementById("confirm-delete-conversation-btn"); // Track selected conversations let selectedConversations = new Set(); @@ -94,6 +103,7 @@ let showQuickSearch = false; // Track if quick search input is visible let quickSearchTerm = ""; // Current search term let pendingConversationCreation = null; // Reuse a single in-flight create request const markConversationReadRequests = new Map(); +let pendingDeleteConversationContext = null; function createUnreadDotElement() { const unreadDot = document.createElement("span"); @@ -226,16 +236,42 @@ export function applyConversationMetadataUpdate(conversationId, updates = {}) { convoItem.setAttribute('data-conversation-title', updates.title); const titleElement = convoItem.querySelector('.conversation-title'); if (titleElement) { - const pinIcon = titleElement.querySelector('.bi-pin-angle'); + const existingIcons = Array.from(titleElement.querySelectorAll('i')).map(icon => icon.cloneNode(true)); titleElement.innerHTML = ''; - if (pinIcon) { - titleElement.appendChild(pinIcon); - } + existingIcons.forEach(icon => titleElement.appendChild(icon)); titleElement.appendChild(document.createTextNode(updates.title)); titleElement.title = updates.title; } } + if (updates.conversation_kind) { + convoItem.dataset.conversationKind = updates.conversation_kind; + } + if (Object.prototype.hasOwnProperty.call(updates, 'membership_status') && updates.membership_status) { + convoItem.dataset.membershipStatus = updates.membership_status; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_manage_members')) { + convoItem.dataset.canManageMembers = updates.can_manage_members ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_manage_roles')) { + convoItem.dataset.canManageRoles = updates.can_manage_roles ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_accept_invite')) { + convoItem.dataset.canAcceptInvite = updates.can_accept_invite ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_post_messages')) { + convoItem.dataset.canPostMessages = updates.can_post_messages === false ? 'false' : 'true'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_delete_conversation')) { + convoItem.dataset.canDeleteConversation = updates.can_delete_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_leave_conversation')) { + convoItem.dataset.canLeaveConversation = updates.can_leave_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'current_user_role')) { + convoItem.dataset.currentUserRole = updates.current_user_role || ''; + } + if (Array.isArray(updates.classification)) { convoItem.dataset.classifications = JSON.stringify(updates.classification); } @@ -252,6 +288,15 @@ export function applyConversationMetadataUpdate(conversationId, updates = {}) { classification: updates.classification, context: updates.context, chat_type: updates.chat_type, + conversation_kind: updates.conversation_kind, + membership_status: updates.membership_status, + can_manage_members: updates.can_manage_members, + can_manage_roles: updates.can_manage_roles, + can_accept_invite: updates.can_accept_invite, + can_post_messages: updates.can_post_messages, + can_delete_conversation: updates.can_delete_conversation, + can_leave_conversation: updates.can_leave_conversation, + current_user_role: updates.current_user_role, }); applySidebarConversationMetadataUpdate(conversationId, updates); @@ -377,6 +422,21 @@ document.addEventListener('DOMContentLoaded', () => { if (deleteSelectedBtn) { deleteSelectedBtn.style.display = "none"; } + + if (confirmDeleteConversationBtn) { + confirmDeleteConversationBtn.addEventListener('click', () => { + void executeDeleteConversationAction(); + }); + } + + if (deleteConversationModalEl) { + deleteConversationModalEl.addEventListener('hidden.bs.modal', () => { + resetDeleteConversationModalState(); + }); + deleteConversationModalEl.querySelectorAll('input[name="delete-conversation-action"]').forEach(input => { + input.addEventListener('change', toggleDeleteConversationTransferInputs); + }); + } // Set up quick search event listeners const searchBtn = document.getElementById('sidebar-search-btn'); @@ -594,11 +654,24 @@ export function loadConversations() { isLoadingConversations = true; conversationsList.innerHTML = '
Loading conversations...
'; // Loading state - return fetch("/api/get_conversations") - .then(response => response.ok ? response.json() : response.json().then(err => Promise.reject(err))) - .then(data => { + const legacyConversationsRequest = fetch("/api/get_conversations") + .then(response => response.ok ? response.json() : response.json().then(err => Promise.reject(err))); + const collaborationConversationsRequest = window.chatCollaboration?.fetchCollaborationConversationList + ? window.chatCollaboration.fetchCollaborationConversationList().catch(error => { + console.warn('Failed to load collaborative conversations:', error); + return []; + }) + : Promise.resolve([]); + + return Promise.all([legacyConversationsRequest, collaborationConversationsRequest]) + .then(([data, collaborationConversations]) => { conversationsList.innerHTML = ""; // Clear loading state - if (!data.conversations || data.conversations.length === 0) { + const mergedConversations = [ + ...(Array.isArray(data.conversations) ? data.conversations : []), + ...(Array.isArray(collaborationConversations) ? collaborationConversations : []), + ]; + + if (mergedConversations.length === 0) { conversationsList.innerHTML = '
No conversations yet.
'; allConversations = []; updateHiddenToggleButton(); @@ -606,7 +679,7 @@ export function loadConversations() { } // Store all conversations for client-side operations - allConversations = data.conversations; + allConversations = mergedConversations; // Sort conversations: pinned first (by last_updated), then unpinned (by last_updated) const sortedConversations = [...allConversations].sort((a, b) => { @@ -671,18 +744,27 @@ export async function ensureConversationPresent(conversationId) { if (existing) return existing; // Fetch metadata to validate ownership and get details + let metadata = null; const res = await fetch(`/api/conversations/${conversationId}/metadata`); - if (!res.ok) { + if (res.ok) { + metadata = await res.json(); + } else if (window.chatCollaboration?.fetchConversationMetadata) { + try { + metadata = await window.chatCollaboration.fetchConversationMetadata(conversationId); + } catch (error) { + const err = await res.json().catch(() => ({})); + throw new Error(error.message || err.error || `Failed to load conversation ${conversationId}`); + } + } else { const err = await res.json().catch(() => ({})); throw new Error(err.error || `Failed to load conversation ${conversationId}`); } - const metadata = await res.json(); // Build a conversation object compatible with createConversationItem const convo = { id: conversationId, title: metadata.title || 'Conversation', - last_updated: metadata.last_updated || new Date().toISOString(), + last_updated: metadata.last_updated || metadata.updated_at || new Date().toISOString(), classification: metadata.classification || [], context: metadata.context || [], chat_type: metadata.chat_type || null, @@ -691,6 +773,15 @@ export async function ensureConversationPresent(conversationId) { has_unread_assistant_response: metadata.has_unread_assistant_response || false, last_unread_assistant_message_id: metadata.last_unread_assistant_message_id || null, last_unread_assistant_at: metadata.last_unread_assistant_at || null, + conversation_kind: metadata.conversation_kind || null, + membership_status: metadata.membership_status || null, + can_manage_members: metadata.can_manage_members || false, + can_manage_roles: metadata.can_manage_roles || false, + can_accept_invite: metadata.can_accept_invite || false, + can_post_messages: metadata.can_post_messages !== false, + can_delete_conversation: metadata.can_delete_conversation || false, + can_leave_conversation: metadata.can_leave_conversation || false, + current_user_role: metadata.current_user_role || '', }; // Keep allConversations in sync @@ -713,6 +804,32 @@ export function createConversationItem(convo) { convoItem.setAttribute("data-conversation-id", convo.id); convoItem.setAttribute("data-conversation-title", convo.title); // Store title too convoItem.dataset.hasUnreadAssistantResponse = convo.has_unread_assistant_response ? "true" : "false"; + const isCollaborativeConversation = convo.conversation_kind === 'collaborative'; + const conversationChatType = convo.chat_type === 'personal' ? 'personal_single_user' : convo.chat_type; + const canManageMembers = isCollaborativeConversation + ? Boolean(convo.can_manage_members) + : conversationChatType === 'personal_single_user'; + const canManageRoles = isCollaborativeConversation ? Boolean(convo.can_manage_roles) : false; + const canEditCollaborativeTitle = !isCollaborativeConversation || canManageRoles; + const canShowAddParticipants = ['personal_single_user', 'personal_multi_user'].includes(conversationChatType || '') + && canManageMembers; + const canDeleteCollaborativeConversation = Boolean(convo.can_delete_conversation); + const canLeaveCollaborativeConversation = Boolean(convo.can_leave_conversation); + const collaborativeDeleteLabel = canDeleteCollaborativeConversation ? 'Delete / Leave' : 'Leave'; + + if (isCollaborativeConversation) { + convoItem.dataset.conversationKind = 'collaborative'; + } + if (convo.membership_status) { + convoItem.dataset.membershipStatus = convo.membership_status; + } + convoItem.dataset.canManageMembers = canManageMembers ? 'true' : 'false'; + convoItem.dataset.canManageRoles = canManageRoles ? 'true' : 'false'; + convoItem.dataset.canAcceptInvite = convo.can_accept_invite ? 'true' : 'false'; + convoItem.dataset.canPostMessages = convo.can_post_messages === false ? 'false' : 'true'; + convoItem.dataset.canDeleteConversation = canDeleteCollaborativeConversation ? 'true' : 'false'; + convoItem.dataset.canLeaveConversation = canLeaveCollaborativeConversation ? 'true' : 'false'; + convoItem.dataset.currentUserRole = convo.current_user_role || ''; // *** Store classification data as stringified JSON *** convoItem.dataset.classifications = JSON.stringify(convo.classification || []); @@ -826,6 +943,13 @@ export function createConversationItem(convo) { pinIcon.classList.add("bi", "bi-pin-angle", "me-1"); titleSpan.appendChild(pinIcon); } + + if (isCollaborativeConversation) { + const collaborationIcon = document.createElement("i"); + collaborationIcon.classList.add("bi", "bi-people", "me-1"); + collaborationIcon.title = "Collaborative conversation"; + titleSpan.appendChild(collaborationIcon); + } titleSpan.appendChild(document.createTextNode(convo.title)); titleSpan.title = convo.title; // Tooltip for full title @@ -868,6 +992,17 @@ export function createConversationItem(convo) { detailsA.innerHTML = 'Details'; detailsLi.appendChild(detailsA); + let addParticipantsA = null; + let addParticipantsLi = null; + if (canShowAddParticipants) { + addParticipantsLi = document.createElement("li"); + addParticipantsA = document.createElement("a"); + addParticipantsA.classList.add("dropdown-item", "add-participants-btn"); + addParticipantsA.href = "#"; + addParticipantsA.innerHTML = 'Add participants'; + addParticipantsLi.appendChild(addParticipantsA); + } + // Add Pin option const pinLi = document.createElement("li"); const pinA = document.createElement("a"); @@ -915,17 +1050,32 @@ export function createConversationItem(convo) { const deleteA = document.createElement("a"); deleteA.classList.add("dropdown-item", "delete-btn", "text-danger"); deleteA.href = "#"; - deleteA.innerHTML = 'Delete'; + deleteA.innerHTML = `${isCollaborativeConversation ? collaborativeDeleteLabel : 'Delete'}`; deleteLi.appendChild(deleteA); dropdownMenu.appendChild(detailsLi); - dropdownMenu.appendChild(pinLi); - dropdownMenu.appendChild(hideLi); - dropdownMenu.appendChild(selectLi); - dropdownMenu.appendChild(exportLi); - - dropdownMenu.appendChild(editLi); - dropdownMenu.appendChild(deleteLi); + if (addParticipantsLi) { + dropdownMenu.appendChild(addParticipantsLi); + } + if (isCollaborativeConversation) { + dropdownMenu.appendChild(pinLi); + dropdownMenu.appendChild(hideLi); + dropdownMenu.appendChild(selectLi); + dropdownMenu.appendChild(exportLi); + if (canEditCollaborativeTitle) { + dropdownMenu.appendChild(editLi); + } + if (canDeleteCollaborativeConversation || canLeaveCollaborativeConversation) { + dropdownMenu.appendChild(deleteLi); + } + } else { + dropdownMenu.appendChild(pinLi); + dropdownMenu.appendChild(hideLi); + dropdownMenu.appendChild(selectLi); + dropdownMenu.appendChild(exportLi); + dropdownMenu.appendChild(editLi); + dropdownMenu.appendChild(deleteLi); + } rightDiv.appendChild(dropdownBtn); rightDiv.appendChild(dropdownMenu); @@ -951,6 +1101,15 @@ export function createConversationItem(convo) { selectConversation(convo.id); }); + if (addParticipantsA) { + addParticipantsA.addEventListener("click", event => { + event.preventDefault(); + event.stopPropagation(); + closeDropdownMenu(dropdownBtn); + window.chatCollaboration?.openParticipantPicker?.({ conversationId: convo.id }); + }); + } + editA.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); @@ -1124,7 +1283,13 @@ export function exitEditMode(convoItem, convo, dropdownBtn, rightDiv, dateSpan, const newSpan = document.createElement("span"); newSpan.classList.add("conversation-title", "text-truncate"); - newSpan.textContent = convo.title; + if (convoItem.dataset.conversationKind === 'collaborative') { + const collaborationIcon = document.createElement("i"); + collaborationIcon.classList.add("bi", "bi-people", "me-1"); + collaborationIcon.title = "Collaborative conversation"; + newSpan.appendChild(collaborationIcon); + } + newSpan.appendChild(document.createTextNode(convo.title)); newSpan.title = convo.title; // Add tooltip back input.replaceWith(newSpan); // Replace input with updated span @@ -1137,7 +1302,10 @@ export function exitEditMode(convoItem, convo, dropdownBtn, rightDiv, dateSpan, } export async function updateConversationTitle(conversationId, newTitle) { - const response = await fetch(`/api/conversations/${conversationId}`, { + const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); + const isCollaborativeConversation = convoItem?.dataset?.conversationKind === 'collaborative'; + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${conversationId}` : `/api/conversations/${conversationId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: newTitle }), @@ -1208,12 +1376,21 @@ export async function selectConversation(conversationId) { } const conversationTitle = convoItem.getAttribute("data-conversation-title") || "Conversation"; // Use stored title + const isCollaborativeConversation = convoItem.dataset.conversationKind === 'collaborative'; + let metadata = null; // Fetch the latest conversation metadata to get accurate chat_type, pin, and hide status try { - const response = await fetch(`/api/conversations/${conversationId}/metadata`); - if (response.ok) { - const metadata = await response.json(); + if (isCollaborativeConversation && window.chatCollaboration?.fetchConversationMetadata) { + metadata = await window.chatCollaboration.fetchConversationMetadata(conversationId); + } else { + const response = await fetch(`/api/conversations/${conversationId}/metadata`); + if (response.ok) { + metadata = await response.json(); + } + } + + if (metadata) { // Update Header Title with pin icon and hidden status if (currentConversationTitleEl) { @@ -1324,6 +1501,16 @@ export async function selectConversation(conversationId) { const metaScopeLocked = metadata.scope_locked !== undefined ? metadata.scope_locked : null; const metaLockedContexts = metadata.locked_contexts || []; restoreScopeLockState(metaScopeLocked, metaLockedContexts); + + convoItem.dataset.canManageMembers = metadata.can_manage_members ? 'true' : 'false'; + convoItem.dataset.canAcceptInvite = metadata.can_accept_invite ? 'true' : 'false'; + convoItem.dataset.canPostMessages = metadata.can_post_messages === false ? 'false' : 'true'; + if (metadata.membership_status) { + convoItem.dataset.membershipStatus = metadata.membership_status; + } + if (metadata.conversation_kind) { + convoItem.dataset.conversationKind = metadata.conversation_kind; + } } } catch (error) { console.warn('Failed to fetch conversation metadata:', error); @@ -1335,16 +1522,21 @@ export async function selectConversation(conversationId) { renderConversationHeaderBadges(convoItem); } - await loadMessages(conversationId); - try { - const streamingModule = await import('./chat-streaming.js'); - await streamingModule.reattachStreamingConversation(conversationId); - } catch (error) { - console.warn('Failed to reattach active stream for conversation:', error); + if (isCollaborativeConversation && window.chatCollaboration?.activateConversation) { + await window.chatCollaboration.activateConversation(conversationId, metadata); + } else { + window.chatCollaboration?.deactivateConversation?.(); + await loadMessages(conversationId); + try { + const streamingModule = await import('./chat-streaming.js'); + await streamingModule.reattachStreamingConversation(conversationId); + } catch (error) { + console.warn('Failed to reattach active stream for conversation:', error); + } + markConversationRead(conversationId, { force: true, suppressErrorToast: true }).catch(error => { + console.warn('Failed to clear unread state for conversation:', error); + }); } - markConversationRead(conversationId, { force: true, suppressErrorToast: true }).catch(error => { - console.warn('Failed to clear unread state for conversation:', error); - }); highlightSelectedConversation(conversationId); // Show the conversation info button since we have an active conversation @@ -1388,45 +1580,260 @@ export function highlightSelectedConversation(conversationId) { }); } -// Delete a conversation -export function deleteConversation(conversationId) { - if (!confirm("Are you sure you want to delete this conversation? This action cannot be undone.")) { +function getConversationFromCache(conversationId) { + return allConversations.find(conversation => conversation.id === conversationId) || null; +} + +function toggleDeleteConversationTransferInputs() { + if (!deleteConversationOwnerSelectContainerEl || !deleteConversationModalEl || !confirmDeleteConversationBtn) { return; } - // Optionally show loading state on the item being deleted + const selectedAction = deleteConversationModalEl.querySelector('input[name="delete-conversation-action"]:checked')?.value; + deleteConversationOwnerSelectContainerEl.classList.toggle('d-none', selectedAction !== 'leave'); - fetch(`/api/conversations/${conversationId}`, { method: "DELETE" }) - .then(response => { - if (response.ok) { - const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`); - if (convoItem) convoItem.remove(); - - // If the deleted conversation was the current one, reset the chat view - if (currentConversationId === conversationId) { - currentConversationId = null; - if (currentConversationTitleEl) currentConversationTitleEl.textContent = "Select or start a conversation"; - if (currentConversationClassificationsEl) currentConversationClassificationsEl.innerHTML = ""; // Clear classifications - if (chatbox) chatbox.innerHTML = '
Select a conversation to view messages.
'; // Reset chatbox - highlightSelectedConversation(null); // Deselect all - toggleConversationInfoButton(false); // Hide the info button - } - - // Also reload sidebar conversations if the sidebar exists - if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) { - window.chatSidebarConversations.loadSidebarConversations(); + if (selectedAction === 'leave') { + confirmDeleteConversationBtn.classList.remove('btn-danger'); + confirmDeleteConversationBtn.classList.add('btn-warning'); + confirmDeleteConversationBtn.innerHTML = 'Assign Owner & Leave'; + return; + } + + confirmDeleteConversationBtn.classList.remove('btn-warning'); + confirmDeleteConversationBtn.classList.add('btn-danger'); + confirmDeleteConversationBtn.innerHTML = 'Delete Conversation'; +} + +function resetDeleteConversationModalState() { + pendingDeleteConversationContext = null; + + if (deleteConversationMessageEl) { + deleteConversationMessageEl.textContent = 'Are you sure you want to delete this conversation?'; + } + if (deleteConversationSharedWarningEl) { + deleteConversationSharedWarningEl.classList.add('d-none'); + } + if (deleteConversationOwnerOptionsEl) { + deleteConversationOwnerOptionsEl.classList.add('d-none'); + } + if (deleteConversationTransferOptionEl) { + deleteConversationTransferOptionEl.classList.add('d-none'); + } + if (deleteConversationOwnerSelectContainerEl) { + deleteConversationOwnerSelectContainerEl.classList.add('d-none'); + } + if (deleteConversationNewOwnerSelectEl) { + deleteConversationNewOwnerSelectEl.innerHTML = ''; + } + if (deleteConversationImpactNoteEl) { + deleteConversationImpactNoteEl.textContent = 'This action cannot be undone.'; + } + if (confirmDeleteConversationBtn) { + confirmDeleteConversationBtn.disabled = false; + confirmDeleteConversationBtn.innerHTML = 'Delete Conversation'; + confirmDeleteConversationBtn.classList.remove('btn-warning'); + confirmDeleteConversationBtn.classList.add('btn-danger'); + } + + const deleteRadio = document.getElementById('delete-conversation-action-delete'); + if (deleteRadio) { + deleteRadio.checked = true; + } +} + +async function buildDeleteConversationContext(conversationId) { + const cachedConversation = getConversationFromCache(conversationId) || {}; + const isCollaborativeConversation = cachedConversation.conversation_kind === 'collaborative' + || document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`)?.dataset?.conversationKind === 'collaborative' + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`)?.dataset?.conversationKind === 'collaborative'; + + if (isCollaborativeConversation && window.chatCollaboration?.fetchConversationMetadata) { + return window.chatCollaboration.fetchConversationMetadata(conversationId); + } + + const response = await fetch(`/api/conversations/${conversationId}/metadata`); + if (!response.ok) { + const errorPayload = await response.json().catch(() => ({})); + throw new Error(errorPayload.error || 'Failed to load conversation metadata'); + } + return response.json(); +} + +function configureDeleteConversationModal(conversationId, metadata = {}) { + resetDeleteConversationModalState(); + + const isCollaborativeConversation = metadata.conversation_kind === 'collaborative'; + const currentUserId = String(window.currentUser?.id || window.currentUser?.user_id || '').trim(); + const activeParticipants = Array.isArray(metadata.participants) + ? metadata.participants.filter(participant => participant?.status === 'accepted') + : []; + const transferableParticipants = activeParticipants.filter(participant => participant?.user_id && participant.user_id !== currentUserId); + + pendingDeleteConversationContext = { + conversationId, + isCollaborativeConversation, + metadata, + transferableParticipants, + }; + + if (!isCollaborativeConversation) { + if (deleteConversationMessageEl) { + deleteConversationMessageEl.textContent = 'Are you sure you want to delete this conversation?'; + } + if (deleteConversationImpactNoteEl) { + deleteConversationImpactNoteEl.textContent = 'This action cannot be undone.'; + } + if (confirmDeleteConversationBtn) { + confirmDeleteConversationBtn.innerHTML = 'Delete Conversation'; + } + return; + } + + if (deleteConversationSharedWarningEl) { + deleteConversationSharedWarningEl.classList.remove('d-none'); + } + + if (metadata.can_delete_conversation) { + if (deleteConversationMessageEl) { + deleteConversationMessageEl.textContent = 'This is a multi-user conversation. Do you want to delete it for everyone, or assign another owner and leave the conversation?'; + } + if (deleteConversationOwnerOptionsEl) { + deleteConversationOwnerOptionsEl.classList.remove('d-none'); + } + if (deleteConversationImpactNoteEl) { + deleteConversationImpactNoteEl.textContent = 'Deleting removes the shared conversation for every participant.'; + } + if (deleteConversationTransferOptionEl && transferableParticipants.length > 0) { + deleteConversationTransferOptionEl.classList.remove('d-none'); + } + if (deleteConversationNewOwnerSelectEl) { + deleteConversationNewOwnerSelectEl.innerHTML = transferableParticipants + .map(participant => ``) + .join(''); + } + return; + } + + if (deleteConversationMessageEl) { + deleteConversationMessageEl.textContent = 'Are you sure you want to leave this shared conversation?'; + } + if (deleteConversationImpactNoteEl) { + deleteConversationImpactNoteEl.textContent = 'Leaving removes this conversation from your list, but other participants will keep it.'; + } + if (confirmDeleteConversationBtn) { + confirmDeleteConversationBtn.classList.remove('btn-danger'); + confirmDeleteConversationBtn.classList.add('btn-warning'); + confirmDeleteConversationBtn.innerHTML = 'Leave Conversation'; + } +} + +export function removeConversationFromUi(conversationId, options = {}) { + if (!conversationId) { + return; + } + + allConversations = allConversations.filter(conversation => conversation.id !== conversationId); + document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`)?.remove(); + document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`)?.remove(); + + if (currentConversationId === conversationId || window.currentConversationId === conversationId) { + currentConversationId = null; + window.currentConversationId = null; + if (currentConversationTitleEl) currentConversationTitleEl.textContent = "Select or start a conversation"; + if (currentConversationClassificationsEl) currentConversationClassificationsEl.innerHTML = ""; + if (chatbox) { + chatbox.innerHTML = '
Select a conversation to view messages.
'; + } + highlightSelectedConversation(null); + toggleConversationInfoButton(false); + window.chatCollaboration?.deactivateConversation?.(); + setSidebarActiveConversation(null); + } + + window.hideConversationDetails?.(); + + if (!options.skipToast) { + showToast(options.toastMessage || 'Conversation removed.', 'success'); + } + + if (options.refreshList !== false) { + loadConversations(); + } +} + +async function executeDeleteConversationAction() { + if (!pendingDeleteConversationContext || !confirmDeleteConversationBtn) { + return; + } + + const { conversationId, isCollaborativeConversation, metadata } = pendingDeleteConversationContext; + confirmDeleteConversationBtn.disabled = true; + + try { + if (!isCollaborativeConversation) { + const response = await fetch(`/api/conversations/${conversationId}`, { method: 'DELETE' }); + if (!response.ok) { + const errorPayload = await response.json().catch(() => ({})); + throw new Error(errorPayload.error || 'Failed to delete conversation'); + } + + bootstrap.Modal.getOrCreateInstance(deleteConversationModalEl)?.hide(); + removeConversationFromUi(conversationId, { toastMessage: 'Conversation deleted.' }); + return; + } + + let action = metadata.can_delete_conversation ? 'delete' : 'leave'; + let newOwnerUserId = null; + if (metadata.can_delete_conversation) { + action = deleteConversationModalEl?.querySelector('input[name="delete-conversation-action"]:checked')?.value || 'delete'; + if (action === 'leave') { + newOwnerUserId = deleteConversationNewOwnerSelectEl?.value || null; + if (!newOwnerUserId) { + throw new Error('Choose a new owner before leaving the shared conversation.'); } - - showToast("Conversation deleted.", "success"); - } else { - return response.json().then(err => Promise.reject(err)); // Pass error details } - }) - .catch(error => { - console.error("Error deleting conversation:", error); - showToast(`Error deleting conversation: ${error.error || 'Unknown error'}`, "danger"); - // Re-enable button if loading state was shown + } + + const response = await fetch(`/api/collaboration/conversations/${conversationId}/delete-action`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify({ + action, + new_owner_user_id: newOwnerUserId, + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error || 'Failed to update the shared conversation'); + } + + bootstrap.Modal.getOrCreateInstance(deleteConversationModalEl)?.hide(); + removeConversationFromUi(conversationId, { + toastMessage: action === 'delete' ? 'Shared conversation deleted for all participants.' : 'You left the shared conversation.', }); + } catch (error) { + showToast(error.message || 'Failed to update the conversation.', 'danger'); + } finally { + confirmDeleteConversationBtn.disabled = false; + } +} + +// Delete a conversation +export async function deleteConversation(conversationId) { + if (!conversationId || !deleteConversationModalEl) { + return; + } + + try { + const metadata = await buildDeleteConversationContext(conversationId); + configureDeleteConversationModal(conversationId, metadata); + bootstrap.Modal.getOrCreateInstance(deleteConversationModalEl).show(); + } catch (error) { + showToast(error.message || 'Failed to prepare the delete dialog.', 'danger'); + } } // Create a new conversation via API @@ -1711,7 +2118,10 @@ async function deleteSelectedConversations() { // Toggle conversation pin status async function toggleConversationPin(conversationId) { try { - const response = await fetch(`/api/conversations/${conversationId}/pin`, { + const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); + const isCollaborativeConversation = convoItem?.dataset?.conversationKind === 'collaborative'; + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${conversationId}/pin` : `/api/conversations/${conversationId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1743,7 +2153,10 @@ async function toggleConversationPin(conversationId) { // Toggle conversation hide status async function toggleConversationHide(conversationId) { try { - const response = await fetch(`/api/conversations/${conversationId}/hide`, { + const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); + const isCollaborativeConversation = convoItem?.dataset?.conversationKind === 'collaborative'; + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${conversationId}/hide` : `/api/conversations/${conversationId}/hide`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1900,6 +2313,7 @@ window.chatConversations = { markConversationRead, setConversationUnreadState, deleteConversation, + removeConversationFromUi, toggleConversationSelection, deleteSelectedConversations, bulkPinConversations, @@ -2016,6 +2430,13 @@ function addChatTypeBadges(convoItem, classificationsEl) { // Don't show badges for Model-only conversations if (chatType === 'personal' || chatType === 'personal_single_user') { return; + } else if (chatType === 'personal_multi_user') { + const sharedBadge = document.createElement("span"); + sharedBadge.classList.add("badge", "bg-primary-subtle", "text-primary-emphasis"); + sharedBadge.textContent = 'shared'; + + appendBadgeSpacer(); + classificationsEl.appendChild(sharedBadge); } else if (chatType && chatType.startsWith('group')) { // Group workspace was used const groupBadge = document.createElement("span"); diff --git a/application/single_app/static/js/chat/chat-export.js b/application/single_app/static/js/chat/chat-export.js index f42e0123f..03d5f20af 100644 --- a/application/single_app/static/js/chat/chat-export.js +++ b/application/single_app/static/js/chat/chat-export.js @@ -114,10 +114,25 @@ function prevStep() { async function _loadConversationTitles() { try { - const response = await fetch('/api/get_conversations'); - if (!response.ok) throw new Error('Failed to fetch conversations'); - const data = await response.json(); - const conversations = data.conversations || []; + const legacyConversationsRequest = fetch('/api/get_conversations') + .then(response => { + if (!response.ok) { + throw new Error('Failed to fetch conversations'); + } + return response.json(); + }); + const collaborationConversationsRequest = window.chatCollaboration?.fetchCollaborationConversationList + ? window.chatCollaboration.fetchCollaborationConversationList().catch(() => []) + : Promise.resolve([]); + + const [legacyData, collaborationConversations] = await Promise.all([ + legacyConversationsRequest, + collaborationConversationsRequest, + ]); + const conversations = [ + ...(legacyData?.conversations || []), + ...(Array.isArray(collaborationConversations) ? collaborationConversations : []), + ]; exportConversationTitles = {}; conversations.forEach(c => { if (exportConversationIds.includes(c.id)) { diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index d350eb3ae..dda637952 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -1040,6 +1040,17 @@ export function appendMessage( marked.parse(escapeHtml(messageContent)) ); messageContentHtml = addTargetBlankToExternalLinks(sanitizedUserHtml); + } else if (sender === "Collaborator") { + messageClass = "collaborator-message"; + senderLabel = fullMessageObject?.sender?.display_name + || fullMessageObject?.metadata?.sender?.display_name + || "Participant"; + avatarAltText = `${senderLabel} Avatar`; + avatarImg = "/static/images/user-avatar.png"; + const sanitizedCollaboratorHtml = DOMPurify.sanitize( + marked.parse(escapeHtml(messageContent)) + ); + messageContentHtml = addTargetBlankToExternalLinks(sanitizedCollaboratorHtml); } else if (sender === "File") { messageClass = "file-message"; senderLabel = "File Added"; @@ -1408,6 +1419,28 @@ export function sendMessage() { } export function actuallySendMessage(finalMessageToSend) { + const isCollaborativeConversation = Boolean( + currentConversationId + && window.chatCollaboration?.isCollaborationConversation?.(currentConversationId) + ); + + if (isCollaborativeConversation) { + const tempUserMessageId = `temp_user_${Date.now()}`; + appendMessage("You", finalMessageToSend, null, tempUserMessageId); + userInput.value = ""; + userInput.style.height = ""; + updateSendButtonVisibility(); + + window.chatCollaboration.sendCollaborativeMessage(finalMessageToSend, tempUserMessageId).catch(error => { + const tempMessage = document.querySelector(`[data-message-id="${tempUserMessageId}"]`); + if (tempMessage) { + tempMessage.remove(); + } + showToast(error.message || 'Failed to send shared message.', 'danger'); + }); + return; + } + // Generate a temporary message ID for the user message const tempUserMessageId = `temp_user_${Date.now()}`; @@ -1636,6 +1669,10 @@ if (sendBtn) { if (userInput) { userInput.addEventListener("keydown", function (e) { + if (window.chatCollaboration?.handleComposerKeydown?.(e)) { + return; + } + // Check if Enter key is pressed if (e.key === "Enter") { // Check if Shift key is NOT pressed @@ -1650,9 +1687,18 @@ if (userInput) { }); // Monitor input changes for send button visibility - userInput.addEventListener("input", updateSendButtonVisibility); - userInput.addEventListener("focus", updateSendButtonVisibility); - userInput.addEventListener("blur", updateSendButtonVisibility); + userInput.addEventListener("input", () => { + updateSendButtonVisibility(); + window.chatCollaboration?.handleComposerInput?.(); + }); + userInput.addEventListener("focus", () => { + updateSendButtonVisibility(); + window.chatCollaboration?.handleComposerInput?.(); + }); + userInput.addEventListener("blur", () => { + updateSendButtonVisibility(); + window.chatCollaboration?.handleComposerBlur?.(); + }); } // Monitor prompt selection changes diff --git a/application/single_app/static/js/chat/chat-sidebar-conversations.js b/application/single_app/static/js/chat/chat-sidebar-conversations.js index 02d401640..a97c9a319 100644 --- a/application/single_app/static/js/chat/chat-sidebar-conversations.js +++ b/application/single_app/static/js/chat/chat-sidebar-conversations.js @@ -205,11 +205,24 @@ export function loadSidebarConversations() { pendingSidebarReload = false; // Clear any pending reload flag sidebarConversationsList.innerHTML = '
Loading conversations...
'; - fetch("/api/get_conversations") - .then(response => response.ok ? response.json() : response.json().then(err => Promise.reject(err))) - .then(data => { + const legacyConversationsRequest = fetch("/api/get_conversations") + .then(response => response.ok ? response.json() : response.json().then(err => Promise.reject(err))); + const collaborationConversationsRequest = window.chatCollaboration?.fetchCollaborationConversationList + ? window.chatCollaboration.fetchCollaborationConversationList().catch(error => { + console.warn('Failed to load collaborative sidebar conversations:', error); + return []; + }) + : Promise.resolve([]); + + Promise.all([legacyConversationsRequest, collaborationConversationsRequest]) + .then(([data, collaborationConversations]) => { sidebarConversationsList.innerHTML = ""; - if (!data.conversations || data.conversations.length === 0) { + const mergedConversations = [ + ...(Array.isArray(data.conversations) ? data.conversations : []), + ...(Array.isArray(collaborationConversations) ? collaborationConversations : []), + ]; + + if (mergedConversations.length === 0) { sidebarConversationsList.innerHTML = '
No conversations yet.
'; dispatchSidebarConversationsLoaded({ loaded: true, count: 0, hasVisibleConversations: false, isError: false }); @@ -225,7 +238,7 @@ export function loadSidebarConversations() { } // Sort conversations: pinned first (by last_updated), then unpinned (by last_updated) - const sortedConversations = [...data.conversations].sort((a, b) => { + const sortedConversations = [...mergedConversations].sort((a, b) => { const aPinned = a.is_pinned || false; const bPinned = b.is_pinned || false; @@ -266,7 +279,7 @@ export function loadSidebarConversations() { dispatchSidebarConversationsLoaded({ loaded: true, - count: data.conversations.length, + count: mergedConversations.length, hasVisibleConversations: visibleConversations.length > 0, isError: false }); @@ -313,28 +326,74 @@ function createSidebarConversationItem(convo) { convoItem.classList.add("sidebar-conversation-item"); convoItem.setAttribute("data-conversation-id", convo.id); convoItem.dataset.hasUnreadAssistantResponse = convo.has_unread_assistant_response ? 'true' : 'false'; + const isCollaborativeConversation = convo.conversation_kind === 'collaborative'; + const normalizedChatType = normalizeSidebarChatType(convo.chat_type || '', convo.context || []); + const canManageMembers = isCollaborativeConversation + ? Boolean(convo.can_manage_members) + : normalizedChatType === 'personal_single_user'; + const canManageRoles = isCollaborativeConversation ? Boolean(convo.can_manage_roles) : false; + const canEditCollaborativeTitle = !isCollaborativeConversation || canManageRoles; + const canShowAddParticipants = ['personal_single_user', 'personal_multi_user'].includes(normalizedChatType) + && canManageMembers; + const canDeleteCollaborativeConversation = Boolean(convo.can_delete_conversation); + const canLeaveCollaborativeConversation = Boolean(convo.can_leave_conversation); + const collaborativeDeleteLabel = canDeleteCollaborativeConversation ? 'Delete / Leave' : 'Leave'; + + if (isCollaborativeConversation) { + convoItem.dataset.conversationKind = 'collaborative'; + } + if (convo.membership_status) { + convoItem.dataset.membershipStatus = convo.membership_status; + } + convoItem.dataset.canManageMembers = canManageMembers ? 'true' : 'false'; + convoItem.dataset.canManageRoles = canManageRoles ? 'true' : 'false'; + convoItem.dataset.canAcceptInvite = convo.can_accept_invite ? 'true' : 'false'; + convoItem.dataset.canPostMessages = convo.can_post_messages === false ? 'false' : 'true'; + convoItem.dataset.canDeleteConversation = canDeleteCollaborativeConversation ? 'true' : 'false'; + convoItem.dataset.canLeaveConversation = canLeaveCollaborativeConversation ? 'true' : 'false'; + convoItem.dataset.currentUserRole = convo.current_user_role || ''; applySidebarConversationContextAttributes(convoItem, convo.chat_type || '', convo.context || []); const isPinned = convo.is_pinned || false; const isHidden = convo.is_hidden || false; const pinIcon = isPinned ? '' : ''; const hiddenIcon = isHidden ? '' : ''; + const collaborationIcon = isCollaborativeConversation ? '' : ''; + const titleTooltip = isCollaborativeConversation + ? convo.title + : `${convo.title} (Double-click to edit)`; + const addParticipantsItemHtml = canShowAddParticipants + ? '
  • Add participants
  • ' + : ''; + const legacyActionsHtml = isCollaborativeConversation + ? ` +
  • ${isPinned ? 'Unpin' : 'Pin'}
  • +
  • ${isHidden ? 'Unhide' : 'Hide'}
  • +
  • Select
  • +
  • Export
  • + ${canEditCollaborativeTitle ? '
  • Edit title
  • ' : ''} + ${(canDeleteCollaborativeConversation || canLeaveCollaborativeConversation) ? `
  • ${collaborativeDeleteLabel}
  • ` : ''} + ` + : ` +
  • ${isPinned ? 'Unpin' : 'Pin'}
  • +
  • ${isHidden ? 'Unhide' : 'Hide'}
  • +
  • Select
  • +
  • Export
  • +
  • Edit title
  • +
  • Delete
  • + `; convoItem.innerHTML = `
    - +
    @@ -395,6 +454,9 @@ function createSidebarConversationItem(convo) { const titleElement = convoItem.querySelector('.sidebar-conversation-title'); if (titleElement) { titleElement.addEventListener('dblclick', (e) => { + if (!canEditCollaborativeTitle) { + return; + } e.preventDefault(); e.stopPropagation(); enableSidebarTitleEdit(convo.id); @@ -457,6 +519,7 @@ function createSidebarConversationItem(convo) { // Add dropdown menu event handlers const detailsBtn = convoItem.querySelector('.details-btn'); + const addParticipantsBtn = convoItem.querySelector('.add-participants-btn'); const pinBtn = convoItem.querySelector('.pin-btn'); const hideBtn = convoItem.querySelector('.hide-btn'); const selectBtn = convoItem.querySelector('.select-btn'); @@ -478,6 +541,15 @@ function createSidebarConversationItem(convo) { } }); } + + if (addParticipantsBtn) { + addParticipantsBtn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + closeSidebarConversationDropdown(dropdownBtn, dropdownInstance); + window.chatCollaboration?.openParticipantPicker?.({ conversationId: convo.id }); + }); + } if (pinBtn) { pinBtn.addEventListener('click', async (e) => { @@ -487,7 +559,7 @@ function createSidebarConversationItem(convo) { closeSidebarConversationDropdown(dropdownBtn, dropdownInstance); // Toggle pin status try { - const response = await fetch(`/api/conversations/${convo.id}/pin`, { + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${convo.id}/pin` : `/api/conversations/${convo.id}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); @@ -518,7 +590,7 @@ function createSidebarConversationItem(convo) { closeSidebarConversationDropdown(dropdownBtn, dropdownInstance); // Toggle hide status try { - const response = await fetch(`/api/conversations/${convo.id}/hide`, { + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${convo.id}/hide` : `/api/conversations/${convo.id}/hide`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); @@ -797,8 +869,12 @@ export function updateSidebarConversationTitle(conversationId, newTitle) { if (sidebarItem) { const titleElement = sidebarItem.querySelector('.sidebar-conversation-title'); if (titleElement) { - titleElement.textContent = newTitle; - titleElement.title = `${newTitle} (Double-click to edit)`; + const existingIcons = Array.from(titleElement.querySelectorAll('i')).map(icon => icon.cloneNode(true)); + titleElement.innerHTML = ''; + existingIcons.forEach(icon => titleElement.appendChild(icon)); + titleElement.appendChild(document.createTextNode(newTitle)); + const isCollaborativeConversation = sidebarItem.dataset.conversationKind === 'collaborative'; + titleElement.title = isCollaborativeConversation ? newTitle : `${newTitle} (Double-click to edit)`; } } @@ -818,6 +894,34 @@ export function applySidebarConversationMetadataUpdate(conversationId, updates = updateSidebarConversationTitle(conversationId, updates.title); } + if (updates.conversation_kind) { + sidebarItem.dataset.conversationKind = updates.conversation_kind; + } + if (Object.prototype.hasOwnProperty.call(updates, 'membership_status') && updates.membership_status) { + sidebarItem.dataset.membershipStatus = updates.membership_status; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_manage_members')) { + sidebarItem.dataset.canManageMembers = updates.can_manage_members ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_manage_roles')) { + sidebarItem.dataset.canManageRoles = updates.can_manage_roles ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_accept_invite')) { + sidebarItem.dataset.canAcceptInvite = updates.can_accept_invite ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_post_messages')) { + sidebarItem.dataset.canPostMessages = updates.can_post_messages === false ? 'false' : 'true'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_delete_conversation')) { + sidebarItem.dataset.canDeleteConversation = updates.can_delete_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_leave_conversation')) { + sidebarItem.dataset.canLeaveConversation = updates.can_leave_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'current_user_role')) { + sidebarItem.dataset.currentUserRole = updates.current_user_role || ''; + } + if (Object.prototype.hasOwnProperty.call(updates, 'chat_type') || Array.isArray(updates.context)) { applySidebarConversationContextAttributes(sidebarItem, updates.chat_type || '', updates.context || []); renderSidebarConversationScopeBadge(sidebarItem, updates.chat_type || '', updates.context || []); @@ -871,7 +975,8 @@ export function enableSidebarTitleEdit(conversationId) { try { // Call the update function from main module - const response = await fetch(`/api/conversations/${conversationId}`, { + const isCollaborativeConversation = sidebarItem.dataset.conversationKind === 'collaborative'; + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${conversationId}` : `/api/conversations/${conversationId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index f47936a83..298bff087 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -635,6 +635,7 @@
    +
    {% if app_settings.enable_speech_to_text_input %} @@ -874,6 +875,77 @@
    + +
    +
    +
    + + + + `; metadataContainerHtml = ``; + } else if (sender === "Collaborator") { + messageFooterHtml = ` + `; } else if (sender === "image" || sender === "File") { // Image and file messages get mask button on left, metadata button on right side const metadataContainerId = `metadata-${messageId || Date.now()}`; @@ -1224,9 +1459,11 @@ export function appendMessage( sender === "You" || sender === "File" ? "flex-row-reverse" : "" }"> ${ - avatarImg - ? `${avatarAltText}` - : "" + avatarHtml + ? avatarHtml + : avatarImg + ? `${avatarAltText}` + : "" }
    @@ -1234,12 +1471,18 @@ export function appendMessage( ${fullMessageObject?.metadata?.edited ? 'Edited' : ''} ${fullMessageObject?.metadata?.retried ? 'Retried' : ''}
    + ${replyQuoteHtml}
    ${messageContentHtml}
    ${metadataContainerHtml} ${messageFooterHtml}
    `; + messageDiv.dataset.replySenderName = stripHtmlTags(senderLabel).replace(/\s+/g, " ").trim() || "Participant"; + if (typeof messageContent === "string") { + messageDiv.dataset.replyPreviewText = buildPlainTextPreview(messageContent); + } + // Append and scroll (common actions for non-AI) chatbox.appendChild(messageDiv); @@ -1266,6 +1509,11 @@ export function appendMessage( } } + if (sender === "Collaborator") { + attachCollaboratorMessageEventListeners(messageDiv, fullMessageObject, messageContent); + hydrateCollaboratorAvatar(messageDiv, getMessageSenderUserId(fullMessageObject), senderLabel); + } + // Add event listener for image info button (uploaded images) if (sender === "image" && fullMessageObject?.metadata?.is_user_upload) { const imageInfoBtn = messageDiv.querySelector('.image-info-btn'); @@ -1426,7 +1674,8 @@ export function actuallySendMessage(finalMessageToSend) { if (isCollaborativeConversation) { const tempUserMessageId = `temp_user_${Date.now()}`; - appendMessage("You", finalMessageToSend, null, tempUserMessageId); + const pendingCollaborativeContext = window.chatCollaboration?.getPendingMessageContext?.() || null; + appendMessage("You", finalMessageToSend, null, tempUserMessageId, false, [], [], [], null, null, pendingCollaborativeContext); userInput.value = ""; userInput.style.height = ""; updateSendButtonVisibility(); @@ -1938,6 +2187,51 @@ function attachUserMessageEventListeners(messageDiv, messageId, messageContent) } } +function attachCollaboratorMessageEventListeners(messageDiv, fullMessageObject, messageContent) { + const dropdownReplyBtn = messageDiv.querySelector(".dropdown-reply-btn"); + if (dropdownReplyBtn) { + dropdownReplyBtn.addEventListener("click", e => { + e.preventDefault(); + const currentMessageId = messageDiv.getAttribute("data-message-id"); + window.chatCollaboration?.replyToMessage?.({ + ...(fullMessageObject || {}), + id: currentMessageId, + content: messageContent, + sender: fullMessageObject?.sender || fullMessageObject?.metadata?.sender || { + display_name: messageDiv.dataset.replySenderName || "Participant", + }, + }); + }); + } + + const dropdownToggle = messageDiv.querySelector(".message-footer .dropdown button[data-bs-toggle='dropdown']"); + const dropdownMenu = messageDiv.querySelector(".message-footer .dropdown-menu"); + if (dropdownToggle && dropdownMenu) { + dropdownToggle.addEventListener("show.bs.dropdown", () => { + const localChatbox = document.getElementById("chatbox"); + if (localChatbox) { + dropdownMenu.remove(); + localChatbox.appendChild(dropdownMenu); + + const rect = dropdownToggle.getBoundingClientRect(); + const chatboxRect = localChatbox.getBoundingClientRect(); + dropdownMenu.style.position = "absolute"; + dropdownMenu.style.top = `${rect.bottom - chatboxRect.top + localChatbox.scrollTop + 2}px`; + dropdownMenu.style.left = `${rect.left - chatboxRect.left}px`; + dropdownMenu.style.zIndex = "9999"; + } + }); + + dropdownToggle.addEventListener("hidden.bs.dropdown", () => { + const dropdown = messageDiv.querySelector(".message-footer .dropdown"); + if (dropdown && dropdownMenu.parentElement !== dropdown) { + dropdownMenu.remove(); + dropdown.appendChild(dropdownMenu); + } + }); + } +} + // Function to toggle user message metadata drawer function toggleUserMessageMetadata(messageDiv, messageId) { console.log(`šŸ”€ Toggling metadata for message: ${messageId}`); diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index 298bff087..e4ff9a300 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -632,6 +632,15 @@
    {% endif %}
    +
    +
    +
    +
    +
    + +
    diff --git a/docs/explanation/fixes/COLLABORATION_REPLY_AND_AVATAR_FIX.md b/docs/explanation/fixes/COLLABORATION_REPLY_AND_AVATAR_FIX.md new file mode 100644 index 000000000..1e5ad863e --- /dev/null +++ b/docs/explanation/fixes/COLLABORATION_REPLY_AND_AVATAR_FIX.md @@ -0,0 +1,64 @@ +# Collaboration Reply And Avatar Fix (v0.241.014) + +Fixed/Implemented in version: **0.241.014** + +## Issue Description + +Shared conversations still felt incomplete in three visible ways: + +1. Other participants could show a broken or collapsed avatar inside shared message bubbles, even when a profile image existed. +2. Shared join, removal, and role-change toasts could replay when a user re-opened the same shared conversation because the event stream cached historical events. +3. Shared chats did not expose a reply workflow, so users could not quote and answer a specific participant message from the conversation timeline. + +## Root Cause Analysis + +The collaboration UI already loaded participant avatars in the conversation details modal and persisted `reply_to_message_id` in the collaboration route, but the chat transcript and composer did not reuse either capability. + +- Shared message rendering used a custom collaborator avatar wrapper instead of the same top-level avatar image styling used elsewhere in chat. +- The collaborator avatar flex item could shrink under the shared-message layout, which caused profile images to collapse into a thin strip on the left edge of the row. +- The collaboration event client reattached to a replayable event cache without suppressing historical events, so stale toasts were shown again on re-entry. +- The compose area had no reply target state, no reply preview container, and no user-message action for selecting a reply target. + +## Files Modified + +1. `application/single_app/static/js/chat/chat-collaboration.js` +2. `application/single_app/static/js/chat/chat-messages.js` +3. `application/single_app/static/css/chats.css` +4. `application/single_app/templates/chats.html` +5. `ui_tests/test_chat_collaboration_ui_scaffolding.py` +6. `functional_tests/test_collaboration_reply_and_avatar_fix.py` +7. `application/single_app/config.py` + +## Code Changes Summary + +1. Added composer-level reply state for collaborative conversations, including a dismissible reply preview panel above the chat input. +2. Added a Reply action to collaborator message menus and rendered quoted reply context inside shared user and collaborator bubbles. +3. Reused the existing per-user profile image endpoint to hydrate collaborator avatars in shared message bubbles, with initials as the fallback state. +4. Switched hydrated collaborator avatars onto the same top-level avatar image element used by the standard chat rows and locked the avatar slot to a fixed flex width so it cannot collapse. +5. Added replay-event suppression in the collaboration event client so cached invite, removal, and role-change events do not re-toast each time a shared conversation is reselected. +6. Updated the collaboration UI regression and added a functional regression file that locks in the reply-preview, avatar, and replay-suppression hooks. + +## Testing Approach + +1. Updated `ui_tests/test_chat_collaboration_ui_scaffolding.py` to validate the reply preview container and reply selection behavior. +2. Added `functional_tests/test_collaboration_reply_and_avatar_fix.py` to verify the relevant collaboration UI hooks remain present. +3. Ran file diagnostics on the modified JavaScript, CSS, HTML, and Python files. + +## Validation + +### Before + +1. Collaborator bubbles showed the generic avatar even when profile images were available. +2. Reopening a shared conversation could replay old green collaboration toasts from the cached SSE session. +3. Users had no targeted reply workflow in shared chats. + +### After + +1. Shared collaborator bubbles can show the participant profile image when one exists and otherwise fall back to initials, without collapsing the avatar width. +2. Historical collaboration events are ignored when reconnecting to a shared conversation, which prevents repeated join or role-change toasts. +3. Users can reply to another participant, see the quoted reply target above the composer, and keep the quoted context in the resulting shared message bubble. + +## Related Tests + +1. `functional_tests/test_collaboration_reply_and_avatar_fix.py` +2. `ui_tests/test_chat_collaboration_ui_scaffolding.py` \ No newline at end of file diff --git a/functional_tests/test_collaboration_reply_and_avatar_fix.py b/functional_tests/test_collaboration_reply_and_avatar_fix.py new file mode 100644 index 000000000..30ebdfba4 --- /dev/null +++ b/functional_tests/test_collaboration_reply_and_avatar_fix.py @@ -0,0 +1,61 @@ +# test_collaboration_reply_and_avatar_fix.py +""" +Functional test for collaboration reply, avatar, and replay suppression fixes. +Version: 0.241.014 +Implemented in: 0.241.014 + +This test ensures shared conversations expose the reply preview scaffold, +persist reply linkage when posting shared messages, hydrate collaborator +avatars when profile images exist, and suppress replayed collaboration events +from showing the same join or role toasts each time a shared conversation is + reopened. +""" + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CHAT_COLLABORATION_FILE = REPO_ROOT / 'application' / 'single_app' / 'static' / 'js' / 'chat' / 'chat-collaboration.js' +CHAT_MESSAGES_FILE = REPO_ROOT / 'application' / 'single_app' / 'static' / 'js' / 'chat' / 'chat-messages.js' +CHATS_TEMPLATE_FILE = REPO_ROOT / 'application' / 'single_app' / 'templates' / 'chats.html' +CHATS_CSS_FILE = REPO_ROOT / 'application' / 'single_app' / 'static' / 'css' / 'chats.css' + + +def read_text(path): + return path.read_text(encoding='utf-8') + + +def test_collaboration_reply_and_avatar_fix(): + collaboration_source = read_text(CHAT_COLLABORATION_FILE) + messages_source = read_text(CHAT_MESSAGES_FILE) + template_source = read_text(CHATS_TEMPLATE_FILE) + css_source = read_text(CHATS_CSS_FILE) + + assert 'collaboration-reply-preview' in template_source + assert 'collaboration-reply-cancel-btn' in template_source + + assert 'activeReplyContext' in collaboration_source + assert 'replyToMessage' in collaboration_source + assert 'getPendingMessageContext' in collaboration_source + assert 'reply_to_message_id: activeReplyContext?.message_id || null' in collaboration_source + assert 'seenCollaborationEventKeys' in collaboration_source + assert 'isReplayEvent' in collaboration_source + + assert 'renderReplyQuoteHtml' in messages_source + assert 'createCollaboratorAvatarHtml' in messages_source + assert 'hydrateCollaboratorAvatar' in messages_source + assert 'class="avatar collaborator-avatar"' in messages_source + assert 'avatarElement.replaceWith(imageElement)' in messages_source + assert 'dropdown-reply-btn' in messages_source + assert 'window.chatCollaboration?.replyToMessage?.' in messages_source + + assert '.collaboration-reply-preview' in css_source + assert '.collaboration-quote-block' in css_source + assert '.avatar.avatar-initials' in css_source + assert 'flex: 0 0 30px;' in css_source + assert '.collaborator-message .message-content' in css_source + + +if __name__ == '__main__': + test_collaboration_reply_and_avatar_fix() + print('collaboration reply and avatar regression checks passed') \ No newline at end of file diff --git a/ui_tests/test_chat_collaboration_ui_scaffolding.py b/ui_tests/test_chat_collaboration_ui_scaffolding.py index be7dd81a5..014573b83 100644 --- a/ui_tests/test_chat_collaboration_ui_scaffolding.py +++ b/ui_tests/test_chat_collaboration_ui_scaffolding.py @@ -1,8 +1,8 @@ # test_chat_collaboration_ui_scaffolding.py """ UI test for chat collaboration scaffolding. -Version: 0.241.012 -Implemented in: 0.241.012 +Version: 0.241.014 +Implemented in: 0.241.014 This test ensures the authenticated chats page loads the collaboration UI containers needed for participant management and @-mention suggestions without @@ -59,6 +59,7 @@ def track_console(message): expect(page.locator('#chatbox')).to_be_visible() expect(page.locator('#user-input')).to_be_visible() + expect(page.locator('#collaboration-reply-preview')).to_have_count(1) expect(page.locator('#collaboration-mention-menu')).to_have_count(1) expect(page.locator('#collaboration-mention-menu')).to_have_class('list-group collaboration-mention-menu d-none') expect(page.locator('#collaboration-participant-modal')).to_have_count(1) @@ -116,6 +117,54 @@ def track_console(message): } """) + page.evaluate(""" + () => { + const replyPreview = document.getElementById('collaboration-reply-preview'); + window.chatCollaboration.replyToMessage({ + id: 'mock-reply-message', + content: 'Please look at the updated shared reply workflow.', + sender: { + user_id: 'member-user-002', + display_name: 'Member User' + } + }); + + if (!replyPreview || replyPreview.classList.contains('d-none')) { + throw new Error('Reply preview did not become visible after selecting Reply.'); + } + } + """) + expect(page.locator('#collaboration-reply-preview')).not_to_have_class('collaboration-reply-preview d-none') + expect(page.locator('#collaboration-reply-preview-label')).to_contain_text('Replying to Member User') + expect(page.locator('#collaboration-reply-preview-text')).to_contain_text('updated shared reply workflow') + page.click('#collaboration-reply-cancel-btn') + expect(page.locator('#collaboration-reply-preview')).to_have_class('collaboration-reply-preview d-none') + + page.evaluate(""" + () => { + const message = document.createElement('div'); + message.className = 'message collaborator-message'; + message.innerHTML = ` +
    + Collaborator Avatar +
    Shared message
    +
    + `; + document.body.appendChild(message); + + const avatar = message.querySelector('.avatar'); + const styles = window.getComputedStyle(avatar); + if (styles.flexShrink !== '0') { + throw new Error(`Expected collaborator avatar flex-shrink to be 0 but received ${styles.flexShrink}.`); + } + if (styles.minWidth !== '30px') { + throw new Error(`Expected collaborator avatar min-width to be 30px but received ${styles.minWidth}.`); + } + + message.remove(); + } + """) + page.evaluate(""" () => { window.currentUser = { id: 'owner-user-001' }; From 228083c342d9fa571d9c900170457935d397d887 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 16 Apr 2026 13:54:11 -0400 Subject: [PATCH 4/7] +1 --- .../static/js/chat/chat-collaboration.js | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js index f4c525e5d..739000713 100644 --- a/application/single_app/static/js/chat/chat-collaboration.js +++ b/application/single_app/static/js/chat/chat-collaboration.js @@ -787,7 +787,7 @@ function handleConversationEvent(eventEnvelope = {}) { const payload = eventEnvelope.payload || {}; if (payload.conversation) { - const normalizedConversation = normalizeCollaborationConversation(payload.conversation); + const normalizedConversation = cacheCollaborationConversation(payload.conversation); setConversationDataset(normalizedConversation.id, normalizedConversation); applyConversationMetadataUpdate(normalizedConversation.id, normalizedConversation); if (!['collaboration.message.created', 'collaboration.typing.updated'].includes(eventEnvelope.event_type)) { @@ -796,6 +796,12 @@ function handleConversationEvent(eventEnvelope = {}) { } if (eventEnvelope.event_type === 'collaboration.message.created' && payload.message) { + const senderUserId = String(payload.message?.sender?.user_id || payload.message?.metadata?.sender?.user_id || '').trim(); + if (senderUserId && senderUserId !== getCurrentUserId() && isCurrentUserMentioned(payload.message)) { + const senderName = normalizeCollaborator(payload.message.sender || payload.message.metadata?.sender || {})?.display_name || 'A participant'; + showToast(`${senderName} tagged you in a shared message.`, 'info'); + } + const decoratedMessage = decorateReplyMessage(payload.message); cacheCollaborationMessage(payload.message); if (reconcilePendingCollaborativeUserMessage(payload.message)) { @@ -874,7 +880,7 @@ function subscribeToConversationEvents(conversationId) { async function fetchConversationMetadata(conversationId) { const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}`); - const normalizedConversation = normalizeCollaborationConversation(payload.conversation || {}); + const normalizedConversation = cacheCollaborationConversation(payload.conversation || {}); setConversationDataset(conversationId, normalizedConversation); applyConversationMetadataUpdate(conversationId, normalizedConversation); return normalizedConversation; @@ -929,7 +935,7 @@ async function fetchCollaborationConversationList() { const payload = await fetchJson('/api/collaboration/conversations?include_pending=true'); const conversations = Array.isArray(payload.conversations) ? payload.conversations : []; - const normalizedConversations = conversations.map(conversation => normalizeCollaborationConversation(conversation)); + const normalizedConversations = conversations.map(conversation => cacheCollaborationConversation(conversation)); notifyPendingInvites(normalizedConversations); return normalizedConversations; } @@ -967,6 +973,8 @@ async function sendCollaborativeMessage(messageText, tempMessageId = null) { return null; } + const mentionedParticipants = extractMentionedParticipantsFromMessage(messageText, conversationId); + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/messages`, { method: 'POST', headers: { @@ -975,11 +983,12 @@ async function sendCollaborativeMessage(messageText, tempMessageId = null) { body: JSON.stringify({ content: messageText, reply_to_message_id: activeReplyContext?.message_id || null, + mentioned_participants: mentionedParticipants, }), }); if (payload.conversation) { - const normalizedConversation = normalizeCollaborationConversation(payload.conversation); + const normalizedConversation = cacheCollaborationConversation(payload.conversation); setConversationDataset(conversationId, normalizedConversation); applyConversationMetadataUpdate(conversationId, normalizedConversation); } @@ -1061,9 +1070,11 @@ function buildSuggestionItemHtml(suggestion) { const subtitle = suggestion.email ? `
    ${suggestion.email}
    ` : '
    No email recorded
    '; - const sourceLabel = suggestion.source === 'recent' + const sourceLabel = suggestion.action === 'tag' + ? 'Tag' + : suggestion.source === 'recent' ? 'Recent' - : 'Local'; + : 'Invite'; return `
    @@ -1117,6 +1128,11 @@ function renderMentionMenu(results, mentionState) { button.setAttribute('data-index', String(index)); button.addEventListener('mousedown', event => { event.preventDefault(); + if (result.action === 'tag') { + insertParticipantMention(result, mentionState); + return; + } + openParticipantConfirmation(result, { conversationId: window.chatConversations?.getCurrentConversationId?.(), source: 'mention', @@ -1141,7 +1157,7 @@ function updateMentionMenuActiveItem() { async function refreshMentionSuggestions() { const conversationId = window.chatConversations?.getCurrentConversationId?.(); - if (!conversationId || !canUseParticipantFlow(conversationId)) { + if (!conversationId) { hideMentionMenu(); return; } @@ -1154,7 +1170,7 @@ async function refreshMentionSuggestions() { const searchToken = ++mentionSearchToken; try { - const results = await searchLocalCollaborators(mentionState.query, { recentOnly: false, limit: DEFAULT_SUGGESTION_LIMIT }); + const results = await loadMentionSuggestions(conversationId, mentionState.query); if (searchToken !== mentionSearchToken) { return; } @@ -1457,11 +1473,15 @@ function handleComposerKeydown(event) { event.preventDefault(); const collaborator = activeMentionState.results[activeMentionState.activeIndex]; if (collaborator) { - openParticipantConfirmation(collaborator, { - conversationId: window.chatConversations?.getCurrentConversationId?.(), - source: 'mention', - mentionState: activeMentionState, - }); + if (collaborator.action === 'tag') { + insertParticipantMention(collaborator, activeMentionState); + } else { + openParticipantConfirmation(collaborator, { + conversationId: window.chatConversations?.getCurrentConversationId?.(), + source: 'mention', + mentionState: activeMentionState, + }); + } } return true; } From 2aa973beaaa22e48ba9d1db6c0bcfc4f64d36f3e Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 16 Apr 2026 15:45:57 -0400 Subject: [PATCH 5/7] Implemented the collaboration shared AI workflow end to end --- application/single_app/config.py | 2 +- .../single_app/functions_collaboration.py | 319 ++++++++- .../single_app/functions_notifications.py | 92 +++ .../single_app/route_backend_collaboration.py | 447 +++++++++++- application/single_app/static/css/chats.css | 39 ++ .../static/js/chat/chat-collaboration.js | 172 ++++- .../static/js/chat/chat-messages.js | 641 +++++++++++++++--- .../static/js/chat/chat-streaming.js | 16 +- .../fixes/COLLABORATION_MESSAGE_DELETE_FIX.md | 41 ++ .../COLLABORATION_MESSAGE_NOTIFICATION_FIX.md | 43 ++ ...ON_METADATA_AND_PARTICIPANT_MENTION_FIX.md | 53 ++ .../COLLABORATION_SHARED_AI_WORKFLOW_FIX.md | 39 ++ .../test_collaboration_message_delete_fix.py | 336 +++++++++ ...est_collaboration_message_notifications.py | 379 +++++++++++ ...collaboration_metadata_and_mentions_fix.py | 67 ++ .../test_collaboration_shared_ai_workflow.py | 64 ++ .../test_chat_collaboration_ui_scaffolding.py | 239 ++++++- 17 files changed, 2860 insertions(+), 129 deletions(-) create mode 100644 docs/explanation/fixes/COLLABORATION_MESSAGE_DELETE_FIX.md create mode 100644 docs/explanation/fixes/COLLABORATION_MESSAGE_NOTIFICATION_FIX.md create mode 100644 docs/explanation/fixes/COLLABORATION_METADATA_AND_PARTICIPANT_MENTION_FIX.md create mode 100644 docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md create mode 100644 functional_tests/test_collaboration_message_delete_fix.py create mode 100644 functional_tests/test_collaboration_message_notifications.py create mode 100644 functional_tests/test_collaboration_metadata_and_mentions_fix.py create mode 100644 functional_tests/test_collaboration_shared_ai_workflow.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 4445d839f..70a9865a8 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.241.014" +VERSION = "0.241.019" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_collaboration.py b/application/single_app/functions_collaboration.py index b944f9f3c..4e399b010 100644 --- a/application/single_app/functions_collaboration.py +++ b/application/single_app/functions_collaboration.py @@ -3,6 +3,7 @@ """Persistence, authorization, and serialization helpers for collaborative conversations.""" from copy import deepcopy +import uuid from config import * from collaboration_models import ( @@ -15,6 +16,7 @@ MEMBERSHIP_STATUS_DECLINED, MEMBERSHIP_STATUS_PENDING, MEMBERSHIP_STATUS_REMOVED, + MESSAGE_KIND_AI_REQUEST, MESSAGE_KIND_HUMAN, PERSONAL_MULTI_USER_CHAT_TYPE, add_personal_pending_participants, @@ -26,6 +28,7 @@ build_personal_collaboration_conversation, ensure_group_participant_record, get_collaboration_user_state_doc_id, + normalize_collaboration_user, refresh_personal_participant_indexes, remove_personal_participant, utc_now_iso, @@ -38,6 +41,7 @@ get_user_groups, ) from functions_message_artifacts import filter_assistant_artifact_items +from functions_notifications import create_collaboration_message_notification from functions_thoughts import delete_thoughts_for_conversation, get_thoughts_for_message @@ -85,6 +89,27 @@ def get_collaboration_message(message_id): return items[0] +def get_collaboration_message_by_source_message(conversation_id, source_message_id): + normalized_conversation_id = str(conversation_id or '').strip() + normalized_source_message_id = str(source_message_id or '').strip() + if not normalized_conversation_id or not normalized_source_message_id: + return None + + query = ( + 'SELECT TOP 1 * FROM c WHERE c.conversation_id = @conversation_id ' + 'AND c.metadata.source_message_id = @source_message_id' + ) + items = list(cosmos_collaboration_messages_container.query_items( + query=query, + parameters=[ + {'name': '@conversation_id', 'value': normalized_conversation_id}, + {'name': '@source_message_id', 'value': normalized_source_message_id}, + ], + partition_key=normalized_conversation_id, + )) + return items[0] if items else None + + def get_personal_collaboration_participant(conversation_doc, participant_user_id): normalized_user_id = str(participant_user_id or '').strip() for participant in list((conversation_doc or {}).get('participants', []) or []): @@ -276,6 +301,91 @@ def resolve_collaboration_mentions(conversation_doc, raw_mentions): return normalized_mentions +def _get_group_collaboration_notification_recipient_ids(conversation_doc, sender_user_id): + scope = conversation_doc.get('scope', {}) if isinstance(conversation_doc, dict) else {} + group_id = str(scope.get('group_id') or '').strip() + if not group_id: + return [] + + group_doc = find_group_by_id(group_id) + if not group_doc: + return [] + + recipient_ids = set() + owner_user_id = str((group_doc.get('owner') or {}).get('id') or '').strip() + if owner_user_id: + recipient_ids.add(owner_user_id) + + for member in list(group_doc.get('users', []) or []): + member_user_id = str(member.get('userId') or '').strip() + if member_user_id: + recipient_ids.add(member_user_id) + + normalized_sender_user_id = str(sender_user_id or '').strip() + if normalized_sender_user_id: + recipient_ids.discard(normalized_sender_user_id) + + return sorted(recipient_ids) + + +def list_collaboration_notification_recipient_ids(conversation_doc, sender_user_id): + if is_group_collaboration_conversation(conversation_doc): + return _get_group_collaboration_notification_recipient_ids(conversation_doc, sender_user_id) + + accepted_participant_ids = set(conversation_doc.get('accepted_participant_ids', []) or []) + normalized_sender_user_id = str(sender_user_id or '').strip() + if normalized_sender_user_id: + accepted_participant_ids.discard(normalized_sender_user_id) + + return sorted(user_id for user_id in accepted_participant_ids if str(user_id or '').strip()) + + +def create_collaboration_message_notifications(conversation_doc, message_doc): + """Fan out personal inbox notifications for recipients of a shared message.""" + if not conversation_doc or not message_doc: + return [] + + metadata = message_doc.get('metadata', {}) if isinstance(message_doc, dict) else {} + sender = normalize_collaboration_user(metadata.get('sender') or {}) or {} + sender_user_id = str(sender.get('user_id') or '').strip() + recipient_ids = list_collaboration_notification_recipient_ids(conversation_doc, sender_user_id) + if not recipient_ids: + return [] + + mentioned_user_ids = { + str(participant.get('user_id') or '').strip() + for participant in list(metadata.get('mentioned_participants', []) or []) + if str(participant.get('user_id') or '').strip() + } + scope = conversation_doc.get('scope', {}) if isinstance(conversation_doc, dict) else {} + created_notifications = [] + + for recipient_user_id in recipient_ids: + try: + notification_doc = create_collaboration_message_notification( + user_id=recipient_user_id, + conversation_id=message_doc.get('conversation_id'), + message_id=message_doc.get('id'), + conversation_title=conversation_doc.get('title'), + sender_display_name=sender.get('display_name'), + message_preview=message_doc.get('content'), + chat_type=conversation_doc.get('chat_type'), + group_id=scope.get('group_id'), + mentioned_user=recipient_user_id in mentioned_user_ids, + ) + if notification_doc: + created_notifications.append(notification_doc) + except Exception as exc: + log_event( + f'[Collaboration Notifications] Failed to create notification for conversation {message_doc.get("conversation_id")}: {exc}', + level=logging.WARNING, + exceptionTraceback=True, + debug_only=True, + ) + + return created_notifications + + def build_collaboration_message_metadata_payload(message_doc, conversation_doc): source_message_doc = _get_collaboration_source_message(message_doc) message_metadata = deepcopy(message_doc.get('metadata', {}) if isinstance(message_doc.get('metadata'), dict) else {}) @@ -980,6 +1090,8 @@ def persist_collaboration_message( content, reply_to_message_id=None, mentioned_participants=None, + message_kind=MESSAGE_KIND_HUMAN, + extra_metadata=None, ): conversation_id = conversation_doc.get('id') message_doc = build_collaboration_message_doc( @@ -988,12 +1100,32 @@ def persist_collaboration_message( content=content, reply_to_message_id=reply_to_message_id, mentioned_participants=mentioned_participants, - message_kind=MESSAGE_KIND_HUMAN, + message_kind=message_kind, timestamp=utc_now_iso(), ) + if isinstance(extra_metadata, dict) and extra_metadata: + message_doc['metadata'] = { + **dict(message_doc.get('metadata', {}) or {}), + **extra_metadata, + } + + return _save_collaboration_message_doc(conversation_doc, message_doc) + + +def _save_collaboration_message_doc(conversation_doc, message_doc): + sender_summary = normalize_collaboration_user( + ((message_doc or {}).get('metadata', {}) or {}).get('sender') or {}, + ) + if is_group_collaboration_conversation(conversation_doc): - ensure_group_participant_record(conversation_doc, sender_user, joined_at=message_doc.get('timestamp')) + sender_user_id = str((sender_summary or {}).get('user_id') or '').strip() + if sender_user_id and sender_user_id != 'assistant' and str(message_doc.get('role') or '').strip().lower() == 'user': + ensure_group_participant_record( + conversation_doc, + sender_summary, + joined_at=message_doc.get('timestamp'), + ) cosmos_collaboration_messages_container.upsert_item(message_doc) @@ -1011,6 +1143,189 @@ def persist_collaboration_message( return message_doc, conversation_doc +def ensure_collaboration_source_conversation(conversation_doc, current_user): + normalized_current_user = normalize_collaboration_user(current_user) + if not normalized_current_user: + raise PermissionError('User not authenticated') + + source_conversation_id = str((conversation_doc or {}).get('source_conversation_id') or '').strip() + source_conversation_doc = None + source_updated = False + + if source_conversation_id: + try: + source_conversation_doc = cosmos_conversations_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + except CosmosResourceNotFoundError: + source_conversation_doc = None + source_conversation_id = '' + + timestamp = utc_now_iso() + if source_conversation_doc is None: + source_conversation_id = str(uuid.uuid4()) + source_conversation_doc = { + 'id': source_conversation_id, + 'user_id': str((conversation_doc or {}).get('created_by_user_id') or normalized_current_user.get('user_id') or '').strip(), + 'last_updated': timestamp, + 'title': str((conversation_doc or {}).get('title') or 'Collaborative Conversation').strip() or 'Collaborative Conversation', + 'context': list((conversation_doc or {}).get('context', []) or []), + 'tags': list((conversation_doc or {}).get('tags', []) or []), + 'strict': bool((conversation_doc or {}).get('strict', False)), + 'chat_type': 'group' if is_group_collaboration_conversation(conversation_doc) else 'personal_single_user', + 'scope_locked': bool((conversation_doc or {}).get('scope_locked', False)), + 'locked_contexts': list((conversation_doc or {}).get('locked_contexts', []) or []), + 'classification': list((conversation_doc or {}).get('classification', []) or []), + 'summary': (conversation_doc or {}).get('summary'), + 'conversation_kind': 'collaboration_source', + 'collaboration_conversation_id': (conversation_doc or {}).get('id'), + 'is_hidden': True, + } + source_updated = True + else: + synchronized_values = { + 'title': str((conversation_doc or {}).get('title') or source_conversation_doc.get('title') or 'Collaborative Conversation').strip() or 'Collaborative Conversation', + 'context': list((conversation_doc or {}).get('context', []) or source_conversation_doc.get('context', []) or []), + 'tags': list((conversation_doc or {}).get('tags', []) or source_conversation_doc.get('tags', []) or []), + 'strict': bool((conversation_doc or {}).get('strict', source_conversation_doc.get('strict', False))), + 'scope_locked': bool((conversation_doc or {}).get('scope_locked', source_conversation_doc.get('scope_locked', False))), + 'locked_contexts': list((conversation_doc or {}).get('locked_contexts', []) or source_conversation_doc.get('locked_contexts', []) or []), + 'classification': list((conversation_doc or {}).get('classification', []) or source_conversation_doc.get('classification', []) or []), + 'summary': (conversation_doc or {}).get('summary', source_conversation_doc.get('summary')), + 'conversation_kind': 'collaboration_source', + 'collaboration_conversation_id': (conversation_doc or {}).get('id'), + 'is_hidden': True, + } + for field_name, field_value in synchronized_values.items(): + if source_conversation_doc.get(field_name) != field_value: + source_conversation_doc[field_name] = field_value + source_updated = True + + if source_updated: + source_conversation_doc['last_updated'] = timestamp + cosmos_conversations_container.upsert_item(source_conversation_doc) + + if str((conversation_doc or {}).get('source_conversation_id') or '').strip() != source_conversation_id: + conversation_doc['source_conversation_id'] = source_conversation_id + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + return source_conversation_doc, conversation_doc + + +def mirror_source_message_to_collaboration( + conversation_doc, + source_message_doc, + default_sender_user, + reply_to_message_id=None, + extra_metadata=None, +): + source_message_id = str((source_message_doc or {}).get('id') or '').strip() + if not source_message_id: + raise ValueError('source_message_doc.id is required') + + existing_message = get_collaboration_message_by_source_message( + (conversation_doc or {}).get('id'), + source_message_id, + ) + if existing_message: + return existing_message, conversation_doc, False + + collaboration_message = build_collaboration_message_doc_from_legacy( + (conversation_doc or {}).get('id'), + source_message_doc, + default_sender_user, + ) + if not collaboration_message: + return None, conversation_doc, False + + source_role = str((source_message_doc or {}).get('role') or '').strip().lower() + source_metadata = (source_message_doc or {}).get('metadata', {}) if isinstance((source_message_doc or {}).get('metadata'), dict) else {} + message_metadata = collaboration_message.setdefault('metadata', {}) + message_metadata.setdefault('source_message_id', source_message_id) + message_metadata.setdefault('source_conversation_id', str((conversation_doc or {}).get('source_conversation_id') or '').strip() or None) + message_metadata.setdefault('source_thought_user_id', str((default_sender_user or {}).get('user_id') or (conversation_doc or {}).get('created_by_user_id') or '').strip()) + + if isinstance(extra_metadata, dict) and extra_metadata: + message_metadata.update(extra_metadata) + + if reply_to_message_id: + collaboration_message['reply_to_message_id'] = str(reply_to_message_id or '').strip() or None + + if source_role == 'image' and not bool(source_metadata.get('is_user_upload')): + collaboration_message['role'] = 'image' + collaboration_message['content'] = str((source_message_doc or {}).get('content') or '') + message_metadata['last_message_preview'] = '[Generated image]' + + return (*_save_collaboration_message_doc(conversation_doc, collaboration_message), True) + + +def _refresh_collaboration_conversation_message_summary(conversation_doc): + conversation_id = str((conversation_doc or {}).get('id') or '').strip() + if not conversation_id: + raise ValueError('conversation_id is required') + + remaining_messages = list_collaboration_messages(conversation_id) + conversation_doc['message_count'] = len(remaining_messages) + + if remaining_messages: + last_message_doc = remaining_messages[-1] + last_message_timestamp = last_message_doc.get('timestamp') or utc_now_iso() + conversation_doc['last_message_at'] = last_message_timestamp + conversation_doc['last_message_preview'] = ( + (last_message_doc.get('metadata') or {}).get('last_message_preview') or '' + ) + conversation_doc['updated_at'] = last_message_timestamp + else: + conversation_doc['last_message_at'] = None + conversation_doc['last_message_preview'] = '' + conversation_doc['updated_at'] = utc_now_iso() + + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + return conversation_doc + + +def delete_collaboration_message(conversation_id, message_id, current_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + access_context = assert_user_can_participate_in_collaboration_conversation( + current_user_id, + conversation_doc, + ) + message_doc = get_collaboration_message(message_id) + + if str(message_doc.get('conversation_id') or '').strip() != str(conversation_id or '').strip(): + raise LookupError('Collaborative message not found in this conversation') + + metadata = message_doc.get('metadata', {}) if isinstance(message_doc, dict) else {} + sender_user_id = str( + ((metadata.get('sender') or {}).get('user_id')) + or ((metadata.get('user_info') or {}).get('user_id')) + or '' + ).strip() + normalized_current_user_id = str(current_user_id or '').strip() + + can_delete_message = sender_user_id == normalized_current_user_id + if not can_delete_message and is_personal_collaboration_conversation(conversation_doc): + actor_role = get_personal_collaboration_role( + conversation_doc, + normalized_current_user_id, + user_state=access_context.get('user_state'), + ) + can_delete_message = actor_role in PERSONAL_COLLABORATION_MANAGER_ROLES + elif not can_delete_message and is_group_collaboration_conversation(conversation_doc): + can_delete_message = access_context.get('group_role') in ('Owner', 'Admin', 'DocumentManager') + + if not can_delete_message: + raise PermissionError('You can only delete your own shared messages') + + cosmos_collaboration_messages_container.delete_item( + item=message_id, + partition_key=conversation_id, + ) + updated_conversation_doc = _refresh_collaboration_conversation_message_summary(conversation_doc) + return message_doc, updated_conversation_doc + + def update_personal_collaboration_title(conversation_id, current_user_id, new_title): conversation_doc = get_collaboration_conversation(conversation_id) if not is_personal_collaboration_conversation(conversation_doc): diff --git a/application/single_app/functions_notifications.py b/application/single_app/functions_notifications.py index 80574160f..a3c7e98b4 100644 --- a/application/single_app/functions_notifications.py +++ b/application/single_app/functions_notifications.py @@ -32,6 +32,10 @@ 'icon': 'bi-file-earmark-check', 'color': 'success' }, + 'collaboration_message_received': { + 'icon': 'bi-people-fill', + 'color': 'info' + }, 'chat_response_complete': { 'icon': 'bi-chat-dots', 'color': 'success' @@ -395,6 +399,54 @@ def create_chat_response_notification( ) +def create_collaboration_message_notification( + user_id, + conversation_id, + message_id, + conversation_title='', + sender_display_name='', + message_preview='', + chat_type='', + group_id=None, + mentioned_user=False, +): + """Create a personal notification when another participant posts in a shared conversation.""" + normalized_title = str(conversation_title or '').strip() or 'Shared Conversation' + normalized_sender = str(sender_display_name or '').strip() or 'A participant' + normalized_preview = str(message_preview or '').strip() + if len(normalized_preview) > 160: + normalized_preview = f"{normalized_preview[:157]}..." + + notification_title = f"New shared message in {normalized_title}" + if mentioned_user: + notification_title = f"{normalized_sender} tagged you in {normalized_title}" + + notification_message = normalized_preview or f"{normalized_sender} posted in {normalized_title}." + + return create_notification( + user_id=user_id, + notification_type='collaboration_message_received', + title=notification_title, + message=notification_message, + link_url=f'/chats?conversationId={conversation_id}', + link_context={ + 'workspace_type': 'group' if str(chat_type or '').strip().lower().startswith('group') else 'personal', + 'conversation_id': conversation_id, + 'group_id': group_id, + 'conversation_kind': 'collaborative', + }, + metadata={ + 'conversation_id': conversation_id, + 'message_id': message_id, + 'sender_display_name': normalized_sender, + 'mentioned_user': bool(mentioned_user), + 'conversation_kind': 'collaborative', + 'chat_type': chat_type, + 'group_id': group_id, + } + ) + + def get_user_notifications(user_id, page=1, per_page=20, include_read=True, include_dismissed=False, user_roles=None): """ Fetch notifications visible to a user from personal, group, and public workspace scopes. @@ -674,6 +726,46 @@ def mark_chat_response_notifications_read_for_conversation(user_id, conversation return 0 +def mark_collaboration_message_notifications_read_for_conversation(user_id, conversation_id): + """Mark personal collaboration-message notifications read for a conversation.""" + try: + query = """ + SELECT * FROM c + WHERE c.user_id = @user_id + AND c.notification_type = @notification_type + AND c.metadata.conversation_id = @conversation_id + """ + params = [ + {'name': '@user_id', 'value': user_id}, + {'name': '@notification_type', 'value': 'collaboration_message_received'}, + {'name': '@conversation_id', 'value': conversation_id}, + ] + + notifications = list(cosmos_notifications_container.query_items( + query=query, + parameters=params, + partition_key=user_id + )) + + marked_count = 0 + for notification in notifications: + read_by = notification.get('read_by', []) + if user_id in read_by: + continue + + read_by.append(user_id) + notification['read_by'] = read_by + cosmos_notifications_container.upsert_item(notification) + marked_count += 1 + + return marked_count + except Exception as e: + debug_print( + f"Error marking collaboration notifications as read for conversation {conversation_id}: {e}" + ) + return 0 + + def dismiss_notification(notification_id, user_id): """ Dismiss a notification for a specific user (adds to dismissed_by). diff --git a/application/single_app/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py index c801c25c1..611d728bc 100644 --- a/application/single_app/route_backend_collaboration.py +++ b/application/single_app/route_backend_collaboration.py @@ -5,19 +5,21 @@ import time import app_settings_cache -from flask import Response, jsonify, request, stream_with_context +from flask import Response, current_app, jsonify, request, session, stream_with_context from config import * -from collaboration_models import MEMBERSHIP_STATUS_PENDING, add_seconds_to_iso, normalize_collaboration_user, utc_now_iso +from collaboration_models import MEMBERSHIP_STATUS_PENDING, MESSAGE_KIND_AI_REQUEST, add_seconds_to_iso, normalize_collaboration_user, utc_now_iso from functions_appinsights import log_event from functions_authentication import * from functions_collaboration import ( assert_user_can_participate_in_collaboration_conversation, assert_user_can_view_collaboration_conversation, - build_collaboration_message_metadata_payload, + create_collaboration_message_notifications, create_group_collaboration_conversation_record, create_personal_collaboration_conversation_record, + delete_collaboration_message, delete_personal_collaboration_conversation, + ensure_collaboration_source_conversation, ensure_personal_collaboration_for_legacy_conversation, get_collaboration_conversation, get_collaboration_user_state, @@ -26,6 +28,7 @@ list_collaboration_messages, list_group_collaboration_conversations_for_user, list_personal_collaboration_conversations_for_user, + mirror_source_message_to_collaboration, persist_collaboration_message, record_personal_invite_response, remove_personal_collaboration_member, @@ -38,6 +41,7 @@ update_personal_collaboration_title, ) from functions_group import assert_group_role, check_group_status_allows_operation, find_group_by_id +from functions_notifications import mark_collaboration_message_notifications_read_for_conversation from functions_settings import get_settings, get_user_settings from swagger_wrapper import swagger_route, get_auth_security @@ -207,6 +211,62 @@ def _normalize_participant_payload(raw_payload): return normalized_participants +def _read_source_message_doc(source_conversation_id, source_message_id): + normalized_conversation_id = str(source_conversation_id or '').strip() + normalized_message_id = str(source_message_id or '').strip() + if not normalized_conversation_id or not normalized_message_id: + raise CosmosResourceNotFoundError(message='Source message not found') + + try: + return cosmos_messages_container.read_item( + item=normalized_message_id, + partition_key=normalized_conversation_id, + ) + except CosmosResourceNotFoundError: + query = 'SELECT TOP 1 * FROM c WHERE c.id = @message_id' + items = list(cosmos_messages_container.query_items( + query=query, + parameters=[{'name': '@message_id', 'value': normalized_message_id}], + enable_cross_partition_query=True, + )) + if not items: + raise + return items[0] + + +def _serialize_stream_error(error_message, **extra_fields): + payload = {'error': str(error_message or 'Streaming request failed')} + payload.update({key: value for key, value in extra_fields.items() if value is not None}) + return f'data: {json.dumps(payload)}\n\n' + + +def _build_collaboration_stream_request_payload(data, source_conversation_id, message_content): + return { + 'message': message_content, + 'conversation_id': source_conversation_id, + 'hybrid_search': bool(data.get('hybrid_search')), + 'web_search_enabled': bool(data.get('web_search_enabled')), + 'selected_document_id': data.get('selected_document_id'), + 'selected_document_ids': data.get('selected_document_ids') or [], + 'classifications': data.get('classifications'), + 'tags': data.get('tags') or [], + 'image_generation': bool(data.get('image_generation')), + 'doc_scope': data.get('doc_scope'), + 'chat_type': data.get('chat_type', 'user'), + 'active_group_ids': data.get('active_group_ids') or [], + 'active_group_id': data.get('active_group_id'), + 'active_public_workspace_ids': data.get('active_public_workspace_ids') or [], + 'active_public_workspace_id': data.get('active_public_workspace_id'), + 'model_deployment': data.get('model_deployment'), + 'model_id': data.get('model_id'), + 'model_endpoint_id': data.get('model_endpoint_id'), + 'model_provider': data.get('model_provider'), + 'prompt_info': data.get('prompt_info'), + 'agent_info': data.get('agent_info'), + 'reasoning_effort': data.get('reasoning_effort'), + } + + def register_route_backend_collaboration(app): @app.route('/api/collaboration/conversations', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -915,6 +975,7 @@ def post_collaboration_message_api(conversation_id): reply_to_message_id=reply_to_message_id, mentioned_participants=mentioned_participants, ) + create_collaboration_message_notifications(updated_conversation_doc, message_doc) serialized_message = serialize_collaboration_message(message_doc) serialized_conversation = serialize_collaboration_conversation( updated_conversation_doc, @@ -944,6 +1005,386 @@ def post_collaboration_message_api(conversation_id): ) return jsonify({'error': 'Failed to post collaborative conversation message'}), 500 + @app.route('/api/collaboration/conversations//stream', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def stream_collaboration_message_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + message_content = str(data.get('content') or data.get('message') or '').strip() + reply_to_message_id = str(data.get('reply_to_message_id') or '').strip() or None + if not message_content: + return jsonify({'error': 'content is required'}), 400 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_participate_in_collaboration_conversation(current_user['user_id'], conversation_doc) + source_conversation_doc, conversation_doc = ensure_collaboration_source_conversation( + conversation_doc, + current_user, + ) + source_conversation_id = str((source_conversation_doc or {}).get('id') or '').strip() + if not source_conversation_id: + return jsonify({'error': 'Failed to initialize collaboration AI context'}), 500 + + mentioned_participants = resolve_collaboration_mentions( + conversation_doc, + data.get('mentioned_participants'), + ) + invocation_target = data.get('invocation_target') if isinstance(data.get('invocation_target'), dict) else None + extra_metadata = {} + if invocation_target: + extra_metadata['ai_invocation_target'] = invocation_target + + user_message_doc, updated_conversation_doc = persist_collaboration_message( + conversation_doc, + current_user, + message_content, + reply_to_message_id=reply_to_message_id, + mentioned_participants=mentioned_participants, + message_kind=MESSAGE_KIND_AI_REQUEST, + extra_metadata=extra_metadata, + ) + user_message_doc.setdefault('metadata', {})['source_conversation_id'] = source_conversation_id + cosmos_collaboration_messages_container.upsert_item(user_message_doc) + + create_collaboration_message_notifications(updated_conversation_doc, user_message_doc) + serialized_user_message = serialize_collaboration_message(user_message_doc) + serialized_user_conversation = serialize_collaboration_conversation( + updated_conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.message.created', + { + 'conversation': serialized_user_conversation, + 'message': serialized_user_message, + }, + ), + ) + + session_snapshot = dict(session) + source_owner_user = normalize_collaboration_user({ + 'user_id': updated_conversation_doc.get('created_by_user_id'), + 'display_name': updated_conversation_doc.get('created_by_display_name'), + }) or current_user + stream_request_payload = _build_collaboration_stream_request_payload( + data, + source_conversation_id, + message_content, + ) + + def generate_stream(): + try: + internal_stream_view = current_app.view_functions.get('chat_stream_api') + if not callable(internal_stream_view): + yield _serialize_stream_error( + 'Chat streaming endpoint is unavailable', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + return + + buffer = '' + with current_app.test_request_context('/api/chat/stream', method='POST', json=stream_request_payload): + session.clear() + session.update(session_snapshot) + internal_response = current_app.make_response(internal_stream_view()) + + if int(internal_response.status_code or 500) >= 400: + try: + error_payload = internal_response.get_json(silent=True) or {} + except Exception: + error_payload = {} + yield _serialize_stream_error( + error_payload.get('error') or error_payload.get('message') or 'Failed to start collaboration AI workflow', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + return + + def transform_event_block(event_block): + normalized_event_block = str(event_block or '') + if not normalized_event_block.strip(): + return None + + if normalized_event_block.lstrip().startswith(':'): + return normalized_event_block + '\n\n' + + data_lines = [ + line for line in normalized_event_block.split('\n') + if line.startswith('data:') + ] + if not data_lines: + return normalized_event_block + '\n\n' + + json_text = '\n'.join(line[5:].lstrip() for line in data_lines) + try: + stream_payload = json.loads(json_text) + except json.JSONDecodeError: + return normalized_event_block + '\n\n' + + if stream_payload.get('error'): + return _serialize_stream_error( + stream_payload.get('error'), + partial_content=stream_payload.get('partial_content'), + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + if not stream_payload.get('done'): + return normalized_event_block + '\n\n' + + source_message_id = str(stream_payload.get('message_id') or '').strip() + if not source_message_id: + return _serialize_stream_error( + 'AI workflow completed without a source assistant message', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + source_user_message_id = str(stream_payload.get('user_message_id') or '').strip() + if source_user_message_id: + try: + saved_user_message_doc = cosmos_collaboration_messages_container.read_item( + item=serialized_user_message.get('id'), + partition_key=conversation_id, + ) + saved_user_message_doc['metadata'] = { + **dict(saved_user_message_doc.get('metadata', {}) or {}), + 'source_message_id': source_user_message_id, + 'source_conversation_id': source_conversation_id, + 'source_thought_user_id': current_user['user_id'], + } + cosmos_collaboration_messages_container.upsert_item(saved_user_message_doc) + except Exception: + pass + + try: + source_message_doc = _read_source_message_doc(source_conversation_id, source_message_id) + except CosmosResourceNotFoundError: + return _serialize_stream_error( + 'Failed to load the generated assistant response', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + mirrored_message_doc, final_conversation_doc, _ = mirror_source_message_to_collaboration( + updated_conversation_doc, + source_message_doc, + source_owner_user, + reply_to_message_id=serialized_user_message.get('id'), + extra_metadata={ + 'source_conversation_id': source_conversation_id, + 'source_thought_user_id': current_user['user_id'], + }, + ) + if not mirrored_message_doc: + return _serialize_stream_error( + 'Failed to mirror the assistant response into the collaboration conversation', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + create_collaboration_message_notifications(final_conversation_doc, mirrored_message_doc) + serialized_assistant_message = serialize_collaboration_message(mirrored_message_doc) + serialized_final_conversation = serialize_collaboration_conversation( + final_conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.message.created', + { + 'conversation': serialized_final_conversation, + 'message': serialized_assistant_message, + }, + ), + ) + + transformed_payload = { + **stream_payload, + 'conversation_id': conversation_id, + 'conversation_title': serialized_final_conversation.get('title'), + 'chat_type': serialized_final_conversation.get('chat_type'), + 'classification': serialized_final_conversation.get('classification', []), + 'context': serialized_final_conversation.get('context', []), + 'scope_locked': serialized_final_conversation.get('scope_locked'), + 'locked_contexts': serialized_final_conversation.get('locked_contexts', []), + 'message_id': serialized_assistant_message.get('id'), + 'user_message_id': serialized_user_message.get('id'), + 'model_deployment_name': serialized_assistant_message.get('model_deployment_name') or stream_payload.get('model_deployment_name'), + 'augmented': serialized_assistant_message.get('augmented', False), + 'hybrid_citations': serialized_assistant_message.get('hybrid_citations', []), + 'web_search_citations': serialized_assistant_message.get('web_search_citations', []), + 'agent_citations': serialized_assistant_message.get('agent_citations', []), + 'agent_display_name': serialized_assistant_message.get('agent_display_name'), + 'agent_name': serialized_assistant_message.get('agent_name'), + 'full_content': mirrored_message_doc.get('content') if serialized_assistant_message.get('role') != 'image' else stream_payload.get('full_content', ''), + 'image_url': mirrored_message_doc.get('content') if serialized_assistant_message.get('role') == 'image' else stream_payload.get('image_url'), + 'reload_messages': False, + } + return f'data: {json.dumps(transformed_payload)}\n\n' + + for chunk in internal_response.response: + if chunk is None: + continue + + chunk_text = chunk.decode('utf-8') if isinstance(chunk, (bytes, bytearray)) else str(chunk) + buffer += chunk_text.replace('\r', '') + + while '\n\n' in buffer: + event_block, buffer = buffer.split('\n\n', 1) + transformed_block = transform_event_block(event_block) + if transformed_block: + yield transformed_block + + if buffer.strip(): + transformed_block = transform_event_block(buffer.strip()) + if transformed_block: + yield transformed_block + except Exception as exc: + log_event( + f'[Collaboration] Failed to stream AI message for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + yield _serialize_stream_error( + 'Failed to stream collaborative AI response', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + return Response(stream_with_context(generate_stream()), mimetype='text/event-stream') + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to start AI stream for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to start collaborative AI workflow'}), 500 + + @app.route('/api/collaboration/conversations//messages/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def delete_collaboration_message_api(conversation_id, message_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + deleted_message_doc, updated_conversation_doc = delete_collaboration_message( + conversation_id, + message_id, + current_user['user_id'], + ) + serialized_conversation = serialize_collaboration_conversation( + updated_conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.message.deleted', + { + 'conversation': serialized_conversation, + 'message_id': message_id, + 'deleted_by_user_id': current_user['user_id'], + 'deleted_message': { + 'id': deleted_message_doc.get('id'), + 'sender_user_id': ( + ((deleted_message_doc.get('metadata') or {}).get('sender') or {}).get('user_id') + ), + }, + }, + ), + ) + return jsonify({ + 'success': True, + 'deleted_message_ids': [message_id], + 'archived': False, + 'conversation': serialized_conversation, + }), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative message not found'}), 404 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to delete message {message_id} for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to delete collaborative conversation message'}), 500 + + @app.route('/api/collaboration/conversations//mark-read', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def mark_collaboration_conversation_read_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + current_user['user_id'], + conversation_doc, + allow_pending=True, + ) + notifications_marked_read = mark_collaboration_message_notifications_read_for_conversation( + current_user['user_id'], + conversation_id, + ) + + return jsonify({ + 'success': True, + 'conversation_id': conversation_id, + 'notifications_marked_read': notifications_marked_read, + }), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to mark conversation {conversation_id} read: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to mark collaborative conversation read'}), 500 + @app.route('/api/collaboration/conversations//typing', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index dd7f5fe42..2ae4f31d0 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -1609,6 +1609,34 @@ a.citation-link:hover { font-size: 0.85rem; color: #495057; } +.collaboration-mentions-block { + margin-bottom: 0.5rem; +} +.collaboration-mentions-label { + margin-bottom: 0.25rem; + font-size: 0.78rem; + font-weight: 600; + color: #146c43; +} +.collaboration-mentions-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.collaboration-mention-chip { + display: inline-flex; + align-items: center; + padding: 0.18rem 0.55rem; + border-radius: 999px; + background-color: rgba(25, 135, 84, 0.12); + color: #146c43; + font-size: 0.78rem; + font-weight: 600; +} +.collaboration-mention-chip-current-user { + background-color: rgba(13, 110, 253, 0.15); + color: #0a58ca; +} .collaboration-mention-menu { position: absolute; @@ -1625,6 +1653,17 @@ a.citation-link:hover { .collaboration-mention-menu .list-group-item.active { background-color: #0d6efd; border-color: #0d6efd; +[data-bs-theme="dark"] .collaboration-mentions-label { + color: #8dd7a3; +} +[data-bs-theme="dark"] .collaboration-mention-chip { + background-color: rgba(73, 167, 97, 0.2); + color: #d9f3e2; +} +[data-bs-theme="dark"] .collaboration-mention-chip-current-user { + background-color: rgba(84, 141, 255, 0.25); + color: #d7e4ff; +} } .collaboration-participant-results { diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js index 739000713..50fc18019 100644 --- a/application/single_app/static/js/chat/chat-collaboration.js +++ b/application/single_app/static/js/chat/chat-collaboration.js @@ -4,6 +4,7 @@ import { appendMessage, updateSendButtonVisibility, updateUserMessageId, userInp import { applyConversationMetadataUpdate } from './chat-conversations.js'; import { loadUserSettings, saveUserSetting } from './chat-layout.js'; import { showToast } from './chat-toast.js'; +import { sendMessageWithStreaming } from './chat-streaming.js'; const RECENT_COLLABORATORS_KEY = 'recentCollaborators'; const MAX_RECENT_COLLABORATORS = 12; @@ -39,6 +40,7 @@ const promptedPendingInviteConversationIds = new Set(); const seenCollaborationEventKeys = new Set(); const collaborationMessageCache = new Map(); const collaborationConversationCache = new Map(); +const collaborationMarkReadRequests = new Map(); function isCollaborationEnabled() { return Boolean(window.appSettings?.enable_collaborative_conversations); @@ -67,6 +69,43 @@ function getConversationChatType(conversationId) { return item?.getAttribute('data-chat-type') || null; } +function markCollaborationConversationRead(conversationId, options = {}) { + const { suppressErrorToast = false } = options; + if (!conversationId) { + return Promise.resolve(null); + } + + if (collaborationMarkReadRequests.has(conversationId)) { + return collaborationMarkReadRequests.get(conversationId); + } + + const request = fetch(`/api/collaboration/conversations/${conversationId}/mark-read`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(async response => { + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.success === false) { + throw new Error(payload.error || 'Failed to clear shared conversation notifications'); + } + return payload; + }) + .catch(error => { + if (!suppressErrorToast) { + showToast(`Failed to clear shared conversation notifications: ${error.message}`, 'danger'); + } + throw error; + }) + .finally(() => { + collaborationMarkReadRequests.delete(conversationId); + }); + + collaborationMarkReadRequests.set(conversationId, request); + return request; +} + function setConversationDataset(conversationId, metadata = {}) { const conversationSelectors = [ `.conversation-item[data-conversation-id="${conversationId}"]`, @@ -233,13 +272,22 @@ async function loadMentionSuggestions(conversationId, query = '') { limit: DEFAULT_SUGGESTION_LIMIT, }); const inviteSuggestions = collaboratorSuggestions - .map(collaborator => normalizeCollaborator(collaborator)) + .map(collaborator => { + const normalizedCollaborator = normalizeCollaborator(collaborator); + if (!normalizedCollaborator) { + return null; + } + + return { + ...normalizedCollaborator, + source: collaborator.source || 'local', + }; + }) .filter(Boolean) .filter(collaborator => !seenUserIds.has(collaborator.user_id)) .map(collaborator => ({ ...collaborator, action: 'invite', - source: collaborator.source || 'local', })); return [...participantSuggestions, ...inviteSuggestions]; @@ -388,10 +436,13 @@ function replyToMessage(message = {}) { userInput?.focus(); } -function getPendingMessageContext() { +function getPendingMessageContext(options = {}) { const conversationId = window.chatConversations?.getCurrentConversationId?.(); const mentionedParticipants = extractMentionedParticipantsFromMessage(userInput?.value || '', conversationId); - if (!activeReplyContext && mentionedParticipants.length === 0) { + const invocationTarget = options.invocationTarget && typeof options.invocationTarget === 'object' + ? options.invocationTarget + : null; + if (!activeReplyContext && mentionedParticipants.length === 0 && !invocationTarget) { return null; } @@ -405,6 +456,10 @@ function getPendingMessageContext() { metadata.mentioned_participants = mentionedParticipants; metadata.mentioned_user_ids = mentionedParticipants.map(participant => participant.user_id); } + if (invocationTarget) { + metadata.ai_invocation_target = { ...invocationTarget }; + metadata.explicit_ai_invocation = true; + } return { reply_to_message_id: activeReplyContext?.message_id || null, @@ -425,6 +480,24 @@ function cacheCollaborationMessage(message = {}) { }); } +function removeCollaborationMessage(messageId) { + const normalizedMessageId = String(messageId || '').trim(); + if (!normalizedMessageId) { + return; + } + + collaborationMessageCache.delete(normalizedMessageId); + + const messageElement = document.querySelector(`[data-message-id="${normalizedMessageId}"]`); + if (messageElement) { + messageElement.remove(); + } + + if (activeReplyContext?.message_id === normalizedMessageId) { + clearReplyTarget({ focusComposer: false }); + } +} + function clearMessageCache() { collaborationMessageCache.clear(); } @@ -451,7 +524,7 @@ function buildEventKey(eventEnvelope = {}) { return [ eventEnvelope.conversation_id || payload.conversation?.id || '', eventEnvelope.event_type || '', - payload.message?.id || payload.participant?.user_id || payload.user?.user_id || payload.deleted_by_user_id || '', + payload.message?.id || payload.message_id || payload.participant?.user_id || payload.user?.user_id || payload.deleted_by_user_id || '', eventEnvelope.occurred_at || '', ].join('|'); } @@ -622,6 +695,18 @@ function resolveMessageSenderType(message) { return 'AI'; } + if (message.role === 'image') { + return 'image'; + } + + if (message.role === 'file') { + return 'File'; + } + + if (message.role === 'safety') { + return 'safety'; + } + const senderUserId = message.sender?.user_id || message.metadata?.sender?.user_id || null; if (senderUserId && senderUserId === getCurrentUserId()) { return 'You'; @@ -797,6 +882,7 @@ function handleConversationEvent(eventEnvelope = {}) { if (eventEnvelope.event_type === 'collaboration.message.created' && payload.message) { const senderUserId = String(payload.message?.sender?.user_id || payload.message?.metadata?.sender?.user_id || '').trim(); + const shouldClearNotifications = Boolean(senderUserId && senderUserId !== getCurrentUserId()); if (senderUserId && senderUserId !== getCurrentUserId() && isCurrentUserMentioned(payload.message)) { const senderName = normalizeCollaborator(payload.message.sender || payload.message.metadata?.sender || {})?.display_name || 'A participant'; showToast(`${senderName} tagged you in a shared message.`, 'info'); @@ -805,9 +891,19 @@ function handleConversationEvent(eventEnvelope = {}) { const decoratedMessage = decorateReplyMessage(payload.message); cacheCollaborationMessage(payload.message); if (reconcilePendingCollaborativeUserMessage(payload.message)) { + if (shouldClearNotifications) { + void markCollaborationConversationRead(eventEnvelope.conversation_id || payload.message.conversation_id, { + suppressErrorToast: true, + }).catch(() => {}); + } return; } renderCollaborationMessage(decoratedMessage, { isNewMessage: true }); + if (shouldClearNotifications) { + void markCollaborationConversationRead(eventEnvelope.conversation_id || payload.message.conversation_id, { + suppressErrorToast: true, + }).catch(() => {}); + } return; } @@ -816,6 +912,14 @@ function handleConversationEvent(eventEnvelope = {}) { return; } + if (eventEnvelope.event_type === 'collaboration.message.deleted' && payload.message_id) { + removeCollaborationMessage(payload.message_id); + if (payload.deleted_by_user_id && payload.deleted_by_user_id !== getCurrentUserId()) { + showToast('A shared message was deleted.', 'info'); + } + return; + } + if (eventEnvelope.event_type === 'collaboration.member.invited' && Array.isArray(payload.participants)) { return; } @@ -941,10 +1045,15 @@ async function fetchCollaborationConversationList() { } async function activateConversation(conversationId, metadata = null) { - const conversationMetadata = metadata || await fetchConversationMetadata(conversationId); + const conversationMetadata = metadata + ? cacheCollaborationConversation(metadata) + : await fetchConversationMetadata(conversationId); updateComposerAvailability(conversationMetadata); clearReplyTarget({ focusComposer: false }); await loadConversationMessages(conversationId); + markCollaborationConversationRead(conversationId, { suppressErrorToast: true }).catch(error => { + console.warn('Failed to clear shared conversation notifications:', error); + }); subscribeToConversationEvents(conversationId); if (conversationMetadata.can_accept_invite && !promptedPendingInviteConversationIds.has(conversationId)) { @@ -1005,6 +1114,51 @@ async function sendCollaborativeMessage(messageText, tempMessageId = null) { return payload; } +async function sendCollaborativeAiMessage(messageText, tempMessageId = null, messageData = {}, pendingContext = null) { + const conversationId = window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId) { + throw new Error('No collaborative conversation is active.'); + } + + const mentionedParticipants = extractMentionedParticipantsFromMessage(messageText, conversationId); + const invocationTarget = pendingContext?.metadata?.ai_invocation_target || null; + const requestBody = { + ...messageData, + content: messageText, + reply_to_message_id: activeReplyContext?.message_id || null, + mentioned_participants: mentionedParticipants, + invocation_target: invocationTarget, + }; + + sendMessageWithStreaming( + requestBody, + tempMessageId, + conversationId, + { + endpoint: `/api/collaboration/conversations/${encodeURIComponent(conversationId)}/stream`, + allowRecovery: false, + onError: (errorMessage, errorData = null) => { + if (errorData?.user_message_id && tempMessageId) { + updateUserMessageId(tempMessageId, errorData.user_message_id); + } + + if (errorData?.message_persisted === true) { + return; + } + + const tempMessage = document.querySelector(`[data-message-id="${tempMessageId}"]`); + if (tempMessage) { + tempMessage.remove(); + } + }, + }, + ); + + setTypingState(false, { force: true }); + clearReplyTarget(); + return { started: true }; +} + function setTypingState(isTyping, options = {}) { const conversationId = options.conversationId || window.chatConversations?.getCurrentConversationId?.(); if (!conversationId || !isCollaborationConversation(conversationId) || !canPostMessages(conversationId)) { @@ -1103,7 +1257,7 @@ function renderMentionMenu(results, mentionState) { } if (!Array.isArray(results) || results.length === 0) { - mentionMenu.innerHTML = '
    No local collaborators found.
    '; + mentionMenu.innerHTML = '
    No participants or collaborators found.
    '; mentionMenu.classList.remove('d-none'); activeMentionState = { ...mentionState, @@ -1309,7 +1463,7 @@ async function addParticipantToConversation(conversationId, collaborator) { }), }); - const normalizedConversation = normalizeCollaborationConversation(payload.conversation || {}); + const normalizedConversation = cacheCollaborationConversation(payload.conversation || {}); await rememberRecentCollaborator(collaborator); if (window.chatConversations?.loadConversations) { @@ -1556,9 +1710,11 @@ window.chatCollaboration = { removeParticipant, replyToMessage, respondToInvite, + sendCollaborativeAiMessage, sendCollaborativeMessage, updateParticipantRole, canUseParticipantFlow, + markConversationRead: markCollaborationConversationRead, }; if (document.readyState === 'loading') { diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 848e1f055..7c8ae9206 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -766,6 +766,117 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
    `; } + function escapeMentionPattern(value) { + return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + function getMentionedParticipants(fullMessageObject = null) { + const rawMentions = Array.isArray(fullMessageObject?.metadata?.mentioned_participants) + ? fullMessageObject.metadata.mentioned_participants + : []; + + return rawMentions + .map(participant => ({ + user_id: String(participant?.user_id || "").trim(), + display_name: String(participant?.display_name || participant?.name || participant?.email || "").trim(), + email: String(participant?.email || "").trim(), + })) + .filter(participant => participant.user_id && participant.display_name); + } + + function stripMentionTextFromMessageContent(messageContent, fullMessageObject = null) { + let normalizedMessageContent = String(messageContent ?? ""); + if (!normalizedMessageContent.trim()) { + return normalizedMessageContent; + } + + const mentions = getMentionedParticipants(fullMessageObject) + .slice() + .sort((left, right) => right.display_name.length - left.display_name.length); + if (mentions.length === 0) { + return normalizedMessageContent; + } + + mentions.forEach(participant => { + const displayName = String(participant.display_name || "").trim(); + if (!displayName) { + return; + } + + const mentionPattern = new RegExp( + `(^|\\s)@${escapeMentionPattern(displayName)}(?=$|\\s|[.,!?;:])`, + "gi" + ); + normalizedMessageContent = normalizedMessageContent.replace( + mentionPattern, + (match, leadingWhitespace) => leadingWhitespace || "" + ); + }); + + return normalizedMessageContent + .replace(/[ \t]{2,}/g, " ") + .replace(/\s+\n/g, "\n") + .replace(/\n\s+/g, "\n") + .replace(/\s+([.,!?;:])/g, "$1") + .trim(); + } + + function renderMentionTagsHtml(fullMessageObject = null) { + const mentions = getMentionedParticipants(fullMessageObject); + if (mentions.length === 0) { + return ""; + } + + const currentUserId = String(window.currentUser?.id || window.currentUser?.user_id || "").trim(); + const mentionChipsHtml = mentions.map(participant => { + const isCurrentUser = currentUserId && participant.user_id === currentUserId; + const currentUserClass = isCurrentUser ? " collaboration-mention-chip-current-user" : ""; + return `@${escapeHtml(participant.display_name)}`; + }).join(""); + + return ` +
    +
    Tagged
    +
    ${mentionChipsHtml}
    +
    `; + } + + function getInvocationTarget(fullMessageObject = null) { + const target = fullMessageObject?.metadata?.ai_invocation_target; + if (!target || typeof target !== "object") { + return null; + } + + const displayName = String(target.display_name || target.label || "").trim(); + if (!displayName) { + return null; + } + + const targetType = String(target.target_type || target.type || "model").trim() || "model"; + const sourceMode = String(target.source_mode || target.mode || "").trim() || null; + return { + target_type: targetType, + display_name: displayName, + mention_text: String(target.mention_text || `@${displayName}`).trim() || `@${displayName}`, + source_mode: sourceMode, + }; + } + + function renderInvocationTargetHtml(fullMessageObject = null) { + const invocationTarget = getInvocationTarget(fullMessageObject); + if (!invocationTarget) { + return ""; + } + + return ` +
    +
    Invoking
    +
    + ${escapeHtml(invocationTarget.mention_text)} +
    +
    `; + } + export function appendMessage( sender, messageContent, @@ -1251,9 +1362,10 @@ export function appendMessage( } else { avatarImg = "/static/images/user-avatar.png"; } - + + const renderedMessageContent = stripMentionTextFromMessageContent(messageContent, fullMessageObject); const sanitizedUserHtml = DOMPurify.sanitize( - marked.parse(escapeHtml(messageContent)) + marked.parse(escapeHtml(renderedMessageContent)) ); messageContentHtml = addTargetBlankToExternalLinks(sanitizedUserHtml); } else if (sender === "Collaborator") { @@ -1263,8 +1375,9 @@ export function appendMessage( || "Participant"; avatarAltText = `${senderLabel} Avatar`; avatarHtml = createCollaboratorAvatarHtml(fullMessageObject, senderLabel); + const renderedMessageContent = stripMentionTextFromMessageContent(messageContent, fullMessageObject); const sanitizedCollaboratorHtml = DOMPurify.sanitize( - marked.parse(escapeHtml(messageContent)) + marked.parse(escapeHtml(renderedMessageContent)) ); messageContentHtml = addTargetBlankToExternalLinks(sanitizedCollaboratorHtml); } else if (sender === "File") { @@ -1351,6 +1464,13 @@ export function appendMessage( const replyQuoteHtml = (sender === "You" || sender === "Collaborator") ? renderReplyQuoteHtml(fullMessageObject) : ""; + const invocationTargetHtml = (sender === "You" || sender === "Collaborator") + ? renderInvocationTargetHtml(fullMessageObject) + : ""; + const mentionTagsHtml = (sender === "You" || sender === "Collaborator") + ? renderMentionTagsHtml(fullMessageObject) + : ""; + const hasVisibleMessageText = Boolean(stripHtmlTags(messageContentHtml || "").replace(/\s+/g, " ").trim()); if (sender === "You") { const metadataContainerId = `metadata-${messageId || Date.now()}`; const isMasked = fullMessageObject?.metadata?.masked || (fullMessageObject?.metadata?.masked_ranges && fullMessageObject.metadata.masked_ranges.length > 0); @@ -1397,6 +1517,7 @@ export function appendMessage(
    `; metadataContainerHtml = ``; } else if (sender === "Collaborator") { + const metadataContainerId = `metadata-${messageId || Date.now()}`; messageFooterHtml = `
    -
    +
    + +
    `; + metadataContainerHtml = ``; } else if (sender === "image" || sender === "File") { // Image and file messages get mask button on left, metadata button on right side const metadataContainerId = `metadata-${messageId || Date.now()}`; @@ -1472,7 +1598,9 @@ export function appendMessage( ${fullMessageObject?.metadata?.retried ? 'Retried' : ''} ${replyQuoteHtml} -
    ${messageContentHtml}
    + ${invocationTargetHtml} + ${mentionTagsHtml} + ${hasVisibleMessageText ? `
    ${messageContentHtml}
    ` : ""} ${metadataContainerHtml} ${messageFooterHtml} @@ -1666,44 +1794,12 @@ export function sendMessage() { userInput.focus(); } -export function actuallySendMessage(finalMessageToSend) { - const isCollaborativeConversation = Boolean( - currentConversationId - && window.chatCollaboration?.isCollaborationConversation?.(currentConversationId) - ); - - if (isCollaborativeConversation) { - const tempUserMessageId = `temp_user_${Date.now()}`; - const pendingCollaborativeContext = window.chatCollaboration?.getPendingMessageContext?.() || null; - appendMessage("You", finalMessageToSend, null, tempUserMessageId, false, [], [], [], null, null, pendingCollaborativeContext); - userInput.value = ""; - userInput.style.height = ""; - updateSendButtonVisibility(); - - window.chatCollaboration.sendCollaborativeMessage(finalMessageToSend, tempUserMessageId).catch(error => { - const tempMessage = document.querySelector(`[data-message-id="${tempUserMessageId}"]`); - if (tempMessage) { - tempMessage.remove(); - } - showToast(error.message || 'Failed to send shared message.', 'danger'); - }); - return; - } - - // Generate a temporary message ID for the user message - const tempUserMessageId = `temp_user_${Date.now()}`; - - // Append user message first with temporary ID - appendMessage("You", finalMessageToSend, null, tempUserMessageId); - userInput.value = ""; - userInput.style.height = ""; - // Update send button visibility after clearing input - updateSendButtonVisibility(); - +function getCurrentModelSelection() { let modelDeployment = modelSelect?.value; let modelId = null; let modelEndpointId = null; let modelProvider = null; + if (window.appSettings?.enable_multi_model_endpoints && modelSelect) { const selectedOption = modelSelect.options[modelSelect.selectedIndex]; modelId = selectedOption?.dataset?.modelId || selectedOption?.value || null; @@ -1712,35 +1808,73 @@ export function actuallySendMessage(finalMessageToSend) { modelDeployment = selectedOption?.dataset?.deploymentName || null; } - // ... (keep existing logic for hybridSearchEnabled, selectedDocumentId, classificationsToSend, imageGenEnabled) + return { + modelDeployment, + modelId, + modelEndpointId, + modelProvider, + modelDisplayName: String( + modelSelect?.options?.[modelSelect.selectedIndex]?.dataset?.displayName + || modelSelect?.options?.[modelSelect.selectedIndex]?.textContent + || modelDeployment + || 'Model' + ).trim() || 'Model', + }; +} + +function getCurrentAgentSelection() { + const agentSelectContainer = document.getElementById('agent-select-container'); + const agentSelect = document.getElementById('agent-select'); + if (!areAgentsEnabled() || !agentSelectContainer || agentSelectContainer.style.display === 'none' || !agentSelect) { + return null; + } + + const selectedAgentOption = agentSelect.options[agentSelect.selectedIndex]; + if (!selectedAgentOption) { + return null; + } + + return { + id: selectedAgentOption.dataset.agentId || null, + name: selectedAgentOption.dataset.name || selectedAgentOption.value || '', + display_name: selectedAgentOption.dataset.displayName || selectedAgentOption.textContent, + is_global: selectedAgentOption.dataset.isGlobal === 'true', + is_group: selectedAgentOption.dataset.isGroup === 'true', + group_id: selectedAgentOption.dataset.groupId || null, + group_name: selectedAgentOption.dataset.groupName || null, + }; +} + +export function buildChatRequestPayload(finalMessageToSend, conversationId = currentConversationId) { + const { + modelDeployment, + modelId, + modelEndpointId, + modelProvider, + } = getCurrentModelSelection(); + let hybridSearchEnabled = false; - const sdbtn = document.getElementById("search-documents-btn"); - if (sdbtn && sdbtn.classList.contains("active")) { + const sdbtn = document.getElementById('search-documents-btn'); + if (sdbtn && sdbtn.classList.contains('active')) { hybridSearchEnabled = true; } let selectedDocumentId = null; let selectedDocumentIds = []; - const docSel = document.getElementById("document-select"); - - // Read all selected document IDs (multi-select support) + const docSel = document.getElementById('document-select'); if (docSel) { selectedDocumentIds = Array.from(docSel.selectedOptions) - .map(o => o.value) - .filter(v => v); // Filter out empty strings - // For backwards compat, set single ID to first selected or null + .map(option => option.value) + .filter(value => value); selectedDocumentId = selectedDocumentIds.length > 0 ? selectedDocumentIds[0] : null; } let imageGenEnabled = false; - const igbtn = document.getElementById("image-generate-btn"); - if (igbtn && igbtn.classList.contains("active")) { + const igbtn = document.getElementById('image-generate-btn'); + if (igbtn && igbtn.classList.contains('active')) { imageGenEnabled = true; } - // --- Robust chat_type/group_id logic --- - // Assume: window.activeChatTabType = 'user' | 'group', window.activeGroupId = group id if group tab - // If you add a group chat tab, set window.activeChatTabType and window.activeGroupId accordingly when switching tabs let chat_type = 'user'; let group_id = null; if (window.activeChatTabType === 'group' && window.activeGroupId) { @@ -1748,95 +1882,66 @@ export function actuallySendMessage(finalMessageToSend) { group_id = window.activeGroupId; } - // Collect prompt information if a prompt is selected let promptInfo = null; if ( - promptSelectionContainer && - promptSelectionContainer.style.display !== "none" && - promptSelect && - promptSelect.selectedIndex > 0 + promptSelectionContainer + && promptSelectionContainer.style.display !== 'none' + && promptSelect + && promptSelect.selectedIndex > 0 ) { const selectedOpt = promptSelect.options[promptSelect.selectedIndex]; if (selectedOpt) { promptInfo = { name: selectedOpt.textContent, id: selectedOpt.value, - content: selectedOpt.dataset?.promptContent || "" - }; - } - } - - // Collect agent information if agents are enabled - let agentInfo = null; - const agentSelectContainer = document.getElementById("agent-select-container"); - const agentSelect = document.getElementById("agent-select"); - if (agentSelectContainer && agentSelectContainer.style.display !== "none" && agentSelect) { - const selectedAgentOption = agentSelect.options[agentSelect.selectedIndex]; - if (selectedAgentOption) { - agentInfo = { - id: selectedAgentOption.dataset.agentId || null, - name: selectedAgentOption.dataset.name || selectedAgentOption.value || '', - display_name: selectedAgentOption.dataset.displayName || selectedAgentOption.textContent, - is_global: selectedAgentOption.dataset.isGlobal === 'true', - is_group: selectedAgentOption.dataset.isGroup === 'true', - group_id: selectedAgentOption.dataset.groupId || null, - group_name: selectedAgentOption.dataset.groupName || null + content: selectedOpt.dataset?.promptContent || '', }; } } - // Get effective scopes from multi-select scope dropdown + const agentInfo = getCurrentAgentSelection(); const scopes = getEffectiveScopes(); - // Determine the correct doc_scope based on selected scopes - let effectiveDocScope = "all"; + let effectiveDocScope = 'all'; if (scopes.personal && scopes.groupIds.length === 0 && scopes.publicWorkspaceIds.length === 0) { - effectiveDocScope = "personal"; + effectiveDocScope = 'personal'; } else if (!scopes.personal && scopes.groupIds.length > 0 && scopes.publicWorkspaceIds.length === 0) { - effectiveDocScope = "group"; + effectiveDocScope = 'group'; } else if (!scopes.personal && scopes.groupIds.length === 0 && scopes.publicWorkspaceIds.length > 0) { - effectiveDocScope = "public"; + effectiveDocScope = 'public'; } - // If documents are selected, determine the actual scope from the documents themselves if (selectedDocumentIds.length > 0) { const docScopes = new Set(); selectedDocumentIds.forEach(docId => { if (personalDocs.find(doc => doc.id === docId || doc.document_id === docId)) { - docScopes.add("personal"); + docScopes.add('personal'); } else if (groupDocs.find(doc => doc.id === docId || doc.document_id === docId)) { - docScopes.add("group"); + docScopes.add('group'); } else if (publicDocs.find(doc => doc.id === docId || doc.document_id === docId)) { - docScopes.add("public"); + docScopes.add('public'); } }); - // Only narrow scope if ALL selected docs are from the same scope if (docScopes.size === 1) { effectiveDocScope = docScopes.values().next().value; console.log(`All selected documents are from scope: ${effectiveDocScope}`); } else if (docScopes.size > 1) { - effectiveDocScope = "all"; + effectiveDocScope = 'all'; console.log(`Selected documents span ${docScopes.size} scopes (${[...docScopes].join(', ')}), keeping scope as "all"`); } } - // Use group IDs from scope selector; fall back to window.activeGroupId for backwards compat const finalGroupIds = scopes.groupIds.length > 0 ? scopes.groupIds : (window.activeGroupId ? [window.activeGroupId] : []); - const finalGroupId = finalGroupIds[0] || window.activeGroupId || null; - const webSearchToggle = document.getElementById("search-web-btn"); - const webSearchEnabled = webSearchToggle ? webSearchToggle.classList.contains("active") : false; - - // Prepare message data object - // Get public workspace IDs from scope selector; fall back to window.activePublicWorkspaceId + const finalGroupId = finalGroupIds[0] || group_id || null; + const webSearchToggle = document.getElementById('search-web-btn'); + const webSearchEnabled = webSearchToggle ? webSearchToggle.classList.contains('active') : false; const finalPublicWorkspaceId = scopes.publicWorkspaceIds[0] || window.activePublicWorkspaceId || null; - - // Get selected tags from chat-documents module const selectedTags = getSelectedTags(); - const messageData = { + return { message: finalMessageToSend, - conversation_id: currentConversationId, + conversation_id: conversationId, hybrid_search: hybridSearchEnabled, web_search_enabled: webSearchEnabled, selected_document_id: selectedDocumentId, @@ -1845,7 +1950,7 @@ export function actuallySendMessage(finalMessageToSend) { tags: selectedTags, image_generation: imageGenEnabled, doc_scope: effectiveDocScope, - chat_type: chat_type, + chat_type, active_group_ids: finalGroupIds, active_group_id: finalGroupId, active_public_workspace_ids: scopes.publicWorkspaceIds, @@ -1856,8 +1961,117 @@ export function actuallySendMessage(finalMessageToSend) { model_provider: modelProvider, prompt_info: promptInfo, agent_info: agentInfo, - reasoning_effort: getCurrentReasoningEffort() + reasoning_effort: getCurrentReasoningEffort(), + }; +} + +export function buildCollaborativeInvocationTarget(messageData = {}) { + if (!messageData || typeof messageData !== 'object') { + return null; + } + + const hasAgentTarget = Boolean( + messageData.agent_info + && (messageData.agent_info.id || messageData.agent_info.name || messageData.agent_info.display_name) + ); + const sourceMode = messageData.image_generation + ? 'image_generation' + : hasAgentTarget + ? 'agent' + : messageData.web_search_enabled + ? 'web_search' + : messageData.hybrid_search + ? 'workspace' + : messageData.prompt_info + ? 'prompt' + : null; + + if (!sourceMode) { + return null; + } + + if (messageData.image_generation) { + return { + target_type: 'image', + display_name: 'Image', + mention_text: '@Image', + source_mode: sourceMode, + }; + } + + if (hasAgentTarget) { + const agentLabel = String( + messageData.agent_info.display_name + || messageData.agent_info.name + || messageData.agent_info.id + || 'Agent' + ).trim() || 'Agent'; + return { + target_type: 'agent', + display_name: agentLabel, + mention_text: `@${agentLabel}`, + source_mode: sourceMode, + }; + } + + const { modelDisplayName } = getCurrentModelSelection(); + return { + target_type: 'model', + display_name: modelDisplayName, + mention_text: `@${modelDisplayName}`, + source_mode: sourceMode, }; +} + +export function shouldUseCollaborativeAiWorkflow(messageData = {}) { + return Boolean(buildCollaborativeInvocationTarget(messageData)); +} + +export function actuallySendMessage(finalMessageToSend) { + const isCollaborativeConversation = Boolean( + currentConversationId + && window.chatCollaboration?.isCollaborationConversation?.(currentConversationId) + ); + + if (isCollaborativeConversation) { + const tempUserMessageId = `temp_user_${Date.now()}`; + const collaborativeMessageData = buildChatRequestPayload(finalMessageToSend, currentConversationId); + const invocationTarget = buildCollaborativeInvocationTarget(collaborativeMessageData); + const pendingCollaborativeContext = window.chatCollaboration?.getPendingMessageContext?.({ invocationTarget }) || null; + appendMessage("You", finalMessageToSend, null, tempUserMessageId, false, [], [], [], null, null, pendingCollaborativeContext); + userInput.value = ""; + userInput.style.height = ""; + updateSendButtonVisibility(); + + const collaborativeSendOperation = shouldUseCollaborativeAiWorkflow(collaborativeMessageData) + ? window.chatCollaboration.sendCollaborativeAiMessage?.( + finalMessageToSend, + tempUserMessageId, + collaborativeMessageData, + pendingCollaborativeContext, + ) + : window.chatCollaboration.sendCollaborativeMessage(finalMessageToSend, tempUserMessageId); + + Promise.resolve(collaborativeSendOperation).catch(error => { + const tempMessage = document.querySelector(`[data-message-id="${tempUserMessageId}"]`); + if (tempMessage) { + tempMessage.remove(); + } + showToast(error.message || 'Failed to send shared message.', 'danger'); + }); + return; + } + + // Generate a temporary message ID for the user message + const tempUserMessageId = `temp_user_${Date.now()}`; + + // Append user message first with temporary ID + appendMessage("You", finalMessageToSend, null, tempUserMessageId); + userInput.value = ""; + userInput.style.height = ""; + // Update send button visibility after clearing input + updateSendButtonVisibility(); + const messageData = buildChatRequestPayload(finalMessageToSend, currentConversationId); sendMessageWithStreaming( messageData, tempUserMessageId, @@ -2204,6 +2418,14 @@ function attachCollaboratorMessageEventListeners(messageDiv, fullMessageObject, }); } + const metadataToggleBtn = messageDiv.querySelector(".metadata-toggle-btn"); + if (metadataToggleBtn) { + metadataToggleBtn.addEventListener("click", () => { + const currentMessageId = messageDiv.getAttribute("data-message-id"); + toggleUserMessageMetadata(messageDiv, currentMessageId); + }); + } + const dropdownToggle = messageDiv.querySelector(".message-footer .dropdown button[data-bs-toggle='dropdown']"); const dropdownMenu = messageDiv.querySelector(".message-footer .dropdown-menu"); if (dropdownToggle && dropdownMenu) { @@ -2423,6 +2645,144 @@ function formatMetadataForDrawer(metadata) { return `${escapeHtml(classification)} (?)`; } } + + if (metadata.message_details) { + content += '
    '; + content += '
    Message Details
    '; + content += '
    '; + + if (metadata.message_details.message_id) { + content += `
    Message ID: ${escapeHtml(metadata.message_details.message_id)}
    `; + } + if (metadata.message_details.conversation_id) { + content += `
    Conversation ID: ${escapeHtml(metadata.message_details.conversation_id)}
    `; + } + if (metadata.message_details.role) { + content += `
    Stored Role: ${createInfoBadge(metadata.message_details.role, 'primary')}
    `; + } + if (metadata.message_details.display_role) { + content += `
    Display Role: ${createInfoBadge(metadata.message_details.display_role, 'info')}
    `; + } + if (metadata.message_details.message_kind) { + content += `
    Message Kind: ${createInfoBadge(metadata.message_details.message_kind, 'secondary')}
    `; + } + if (metadata.message_details.source_role) { + content += `
    Original Role: ${createInfoBadge(metadata.message_details.source_role, 'warning')}
    `; + } + if (metadata.message_details.timestamp) { + content += `
    Timestamp: ${escapeHtml(new Date(metadata.message_details.timestamp).toLocaleString())}
    `; + } + if (metadata.message_details.explicit_ai_invocation !== undefined) { + content += `
    Explicit AI Invocation: ${createStatusBadge(Boolean(metadata.message_details.explicit_ai_invocation))}
    `; + } + + content += '
    '; + } + + if (metadata.reply_context) { + content += '
    '; + content += '
    Reply Context
    '; + content += '
    '; + if (metadata.reply_context.message_id) { + content += `
    Reply Message ID: ${escapeHtml(metadata.reply_context.message_id)}
    `; + } + if (metadata.reply_context.sender_display_name) { + content += `
    Replying To: ${escapeHtml(metadata.reply_context.sender_display_name)}
    `; + } + if (metadata.reply_context.content_preview) { + content += `
    Preview:
    ${escapeHtml(metadata.reply_context.content_preview)}
    `; + } + content += '
    '; + } + + if (Array.isArray(metadata.mentions) && metadata.mentions.length > 0) { + content += '
    '; + content += '
    Tagged Participants
    '; + content += '
    '; + metadata.mentions.forEach(participant => { + content += `@${escapeHtml(participant.display_name || participant.email || participant.user_id || 'Participant')}`; + }); + content += '
    '; + } + + if (metadata.collaboration) { + content += '
    '; + content += '
    Shared Conversation
    '; + content += '
    '; + if (metadata.collaboration.conversation_title) { + content += `
    Conversation: ${escapeHtml(metadata.collaboration.conversation_title)}
    `; + } + if (metadata.collaboration.chat_type) { + content += `
    Collaboration Type: ${createInfoBadge(metadata.collaboration.chat_type, 'success')}
    `; + } + if (metadata.collaboration.participant_count !== undefined) { + content += `
    Participants: ${escapeHtml(metadata.collaboration.participant_count)}
    `; + } + content += '
    '; + } + + if (metadata.file_details) { + content += '
    '; + content += '
    File Details
    '; + content += '
    '; + if (metadata.file_details.filename) { + content += `
    Filename: ${escapeHtml(metadata.file_details.filename)}
    `; + } + if (metadata.file_details.source_message_id) { + content += `
    Source Message ID: ${escapeHtml(metadata.file_details.source_message_id)}
    `; + } + if (metadata.file_details.is_table !== undefined && metadata.file_details.is_table !== null) { + content += `
    Table Data: ${createStatusBadge(Boolean(metadata.file_details.is_table))}
    `; + } + content += '
    '; + } + + if (metadata.image_details) { + content += '
    '; + content += '
    Image Details
    '; + content += '
    '; + if (metadata.image_details.filename) { + content += `
    Filename: ${escapeHtml(metadata.image_details.filename)}
    `; + } + if (metadata.image_details.image_url) { + content += `
    Image URL: ${escapeHtml(metadata.image_details.image_url)}
    `; + } + if (metadata.image_details.is_user_upload !== undefined) { + content += `
    User Upload: ${createStatusBadge(Boolean(metadata.image_details.is_user_upload))}
    `; + } + if (metadata.image_details.extracted_text) { + content += `
    Extracted Text:
    ${escapeHtml(metadata.image_details.extracted_text)}
    `; + } + if (metadata.image_details.vision_analysis) { + content += `
    Vision Analysis:
    ${escapeHtml(metadata.image_details.vision_analysis)}
    `; + } + content += '
    '; + } + + if (metadata.generation_details) { + content += '
    '; + content += '
    Generation Details
    '; + content += '
    '; + if (metadata.generation_details.selected_model) { + content += `
    Model: ${escapeHtml(metadata.generation_details.selected_model)}
    `; + } + if (metadata.generation_details.agent_display_name || metadata.generation_details.agent_name) { + content += `
    Agent: ${escapeHtml(metadata.generation_details.agent_display_name || metadata.generation_details.agent_name)}
    `; + } + if (metadata.generation_details.augmented !== undefined) { + content += `
    Augmented: ${createStatusBadge(Boolean(metadata.generation_details.augmented))}
    `; + } + if (metadata.generation_details.document_citation_count !== undefined) { + content += `
    Document Citations: ${escapeHtml(metadata.generation_details.document_citation_count)}
    `; + } + if (metadata.generation_details.web_citation_count !== undefined) { + content += `
    Web Citations: ${escapeHtml(metadata.generation_details.web_citation_count)}
    `; + } + if (metadata.generation_details.agent_citation_count !== undefined) { + content += `
    Agent Citations: ${escapeHtml(metadata.generation_details.agent_citation_count)}
    `; + } + content += '
    '; + } // User Information Section if (metadata.user_info) { @@ -2596,7 +2956,7 @@ function formatMetadataForDrawer(metadata) { content += '
    '; metadata.uploaded_images.forEach((image, index) => { - const imageId = `image-${messageId || Date.now()}-${index}`; + const imageId = `image-${metadata.message_details?.message_id || Date.now()}-${index}`; content += `
    + +
    +
    +
    + Simple Chat Action Capabilities +
    +
    +

    Enable only the Simple Chat operations this agent should expose. These settings are saved per agent in additional settings.

    +
    +
    +
    +
    diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index 0a5b6f35e..a325352d2 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -443,6 +443,103 @@
    API Information
    + + +
    + + +
    + + +
    + + +
    Use the Azure Cosmos DB account endpoint for the API for NoSQL account.
    +
    + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    Enter the container partition key path exactly as configured in Cosmos DB.
    +
    +
    + +
    + + +
    + + +
    Managed identity avoids stored secrets. Account keys can be stored in Key Vault when app secret storage is enabled.
    +
    + +
    + + +
    If this action already uses a Key Vault-backed key, leave the stored value unchanged to preserve the existing secret.
    +
    + +
    + Managed Identity uses Azure AD authentication without storing credentials. Assign an Azure Cosmos DB built-in data reader role to the application identity for the target account. +
    +
    + +
    + + +
    + + +
    Optional. Add one field name per line so the model knows which document properties are relevant.
    +
    + +
    +
    + + +
    Hard cap for documents returned per query.
    +
    +
    + + +
    Absolute timeout for Cosmos client requests.
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    @@ -615,6 +712,61 @@
    + + + + +
    +
    +
    + Microsoft Graph Action Capabilities +
    +
    +

    Enable only the Microsoft Graph operations this agent should expose. These settings are saved per agent in additional settings.

    +
    +
    +
    +
    diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index a325352d2..fb83ca2ce 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -189,6 +189,119 @@
    API Information
    + +
    +
    + Built-in action: SimpleChat does not require a URL or external credentials. + The capabilities below become the default operations exposed by this action. Agents can narrow them further per assignment. +
    + +
    + +
    +
    +
    + +
    +
    + Built-in action: Microsoft Graph uses the signed-in user's delegated permissions and the standard Graph endpoint. + The capabilities below become the default operations exposed by this action. Agents can narrow them further per assignment. +
    + +
    + +
    +
    +
    + +
    +
    + + This action uses SimpleChat's internal document search and the current user's access. No external URL or stored credentials are required. +
    + +
    +
    + +
    +
    + + +
    Used when the action is called without an explicit scope filter.
    +
    +
    + + +
    Maximum number of ranked results returned when a caller does not set top_n.
    +
    +
    +
    +
    + +
    +
    + +

    Chunk retrieval returns all chunks by default. Configure preferred windowing so downstream tools can plan comparisons and summaries consistently.

    +
    +
    + + +
    +
    + + +
    Optional fixed number of pages or chunks per window. Takes precedence over percent.
    +
    +
    + + +
    Optional percent of the document per window when size is not set.
    +
    +
    +
    +
    + +
    +
    + +
    + + +
    Applied when a summarization call does not include its own focus instructions.
    +
    +
    +
    + + +
    Target length for first-pass summaries over each chunk window.
    +
    +
    + + +
    Target length for the final summary output.
    +
    +
    +
    +
    +
    @@ -767,6 +880,108 @@
    + + + + + + diff --git a/application/single_app/templates/base.html b/application/single_app/templates/base.html index 16028a639..c29d41b6e 100644 --- a/application/single_app/templates/base.html +++ b/application/single_app/templates/base.html @@ -283,6 +283,333 @@
    + {% if session.get('user') %} + + + {% endif %} diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index e4ff9a300..e91d705b5 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -266,7 +266,11 @@
    Conversations
    diff --git a/application/single_app/templates/workflow_activity.html b/application/single_app/templates/workflow_activity.html new file mode 100644 index 000000000..239059d0b --- /dev/null +++ b/application/single_app/templates/workflow_activity.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} + +{% block title %}Workflow Activity - {{ app_settings.app_title }}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
    +
    +
    +
    +
    +
    Workflow Activity
    +
    +

    Loading workflow activity...

    + Loading +
    +

    + Resolving the selected workflow run. +

    +
    + +
    +
    +
    +
    +
    Run
    +
    --
    +
    +
    +
    +
    +
    Activities
    +
    0
    +
    +
    +
    +
    +
    Tool Calls
    +
    0
    +
    +
    +
    +
    +
    Started
    +
    --
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    Timeline

    +

    Live events roll in here while a workflow run is active.

    +
    +
    +
    +
    +

    No activity yet

    +

    This conversation does not have a captured workflow run yet, or the run completed before activity tracking was available.

    +
    +
    +
    +
    + + +
    +
    + + +{% endblock %} diff --git a/application/single_app/templates/workspace.html b/application/single_app/templates/workspace.html index d79cae144..dc823cc0b 100644 --- a/application/single_app/templates/workspace.html +++ b/application/single_app/templates/workspace.html @@ -114,6 +114,46 @@ white-space: nowrap; } + /* --- Workflows Table Styles --- */ + #workflows-table th:nth-child(1), + #workflows-table td:nth-child(1) { + width: 24%; + min-width: 180px; + } + #workflows-table th:nth-child(2), + #workflows-table td:nth-child(2) { + width: 24%; + min-width: 180px; + } + #workflows-table th:nth-child(3), + #workflows-table td:nth-child(3) { + width: 15%; + min-width: 130px; + } + #workflows-table th:nth-child(4), + #workflows-table td:nth-child(4) { + width: 19%; + min-width: 180px; + } + #workflows-table th:nth-child(5), + #workflows-table td:nth-child(5) { + width: 18%; + min-width: 220px; + white-space: nowrap; + } + + .workflow-meta { + color: #6c757d; + font-size: 0.85rem; + } + + .workflow-response-preview { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + /* Style for the classification badge */ .classification-badge { display: inline-block; @@ -528,6 +568,22 @@

    Personal Workspace

    {% endif %} {% endif %} {% endif %} + {% if settings.allow_user_workflows %} + + {% endif %} {% if settings.allow_user_custom_endpoints and settings.enable_multi_model_endpoints %}