diff --git a/application/single_app/config.py b/application/single_app/config.py index 17903e612..77387246d 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.252" +VERSION = "0.261.255" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_collaboration_ai_activity.py b/application/single_app/functions_collaboration_ai_activity.py new file mode 100644 index 000000000..790ccb827 --- /dev/null +++ b/application/single_app/functions_collaboration_ai_activity.py @@ -0,0 +1,132 @@ +# functions_collaboration_ai_activity.py +"""Plain-language steps for the activity line of a shared conversation. + +Thoughts are written for whoever debugs a request: plugin and function names, model deployment +names, argument values and timings. The other participants only need to know what the assistant +is doing, so each stream event is described in plain words before it is broadcast. An event that +is not recognised describes as an empty string, which leaves the line on its previous step. +""" + +import re + + +THINKING_STEP = 'Thinking' +WRITING_STEP = 'Writing the answer' +REVIEWING_STEP = 'Reviewing what it found' +FINISHING_STEP = 'Finishing up' + +STEP_TYPE_PHRASES = { + 'history_context': 'Reading the conversation', + 'search': 'Searching documents', + 'web_search': 'Searching the web', + 'deep_research': 'Researching the web', + 'url_access': 'Reading a web page', + 'tabular_analysis': 'Analyzing the data', + 'fact_memory': 'Checking saved facts', + 'orchestration_triage': 'Planning the work', + 'orchestration_planning': 'Planning the work', + 'orchestration_step': 'Working through the plan', + 'orchestration_synthesis': 'Putting the answer together', +} + +PLUGIN_PHRASES = { + 'DocumentSearchPlugin': 'Searching documents', + 'SharedMapPlugin': 'Updating the map', + 'MathPlugin': 'Calculating', + 'WaitPlugin': 'Waiting', +} + +VERB_PHRASES = { + 'get': 'Looking up', 'fetch': 'Looking up', 'retrieve': 'Looking up', 'lookup': 'Looking up', + 'read': 'Reading', 'find': 'Finding', 'search': 'Searching', 'query': 'Searching', + 'list': 'Reviewing', 'show': 'Showing', 'view': 'Viewing', 'check': 'Checking', 'verify': 'Checking', + 'validate': 'Checking', 'create': 'Creating', 'new': 'Creating', 'add': 'Adding', 'open': 'Opening', + 'start': 'Starting', 'update': 'Updating', 'set': 'Updating', 'edit': 'Updating', 'patch': 'Updating', + 'submit': 'Submitting', 'send': 'Sending', 'post': 'Posting', 'file': 'Filing', 'upload': 'Saving', + 'save': 'Saving', 'draft': 'Drafting', 'generate': 'Generating', 'write': 'Writing', + 'delete': 'Removing', 'remove': 'Removing', 'close': 'Closing', 'cancel': 'Cancelling', + 'approve': 'Approving', 'sign': 'Signing', 'analyze': 'Analyzing', 'summarize': 'Summarizing', + 'calculate': 'Calculating', 'run': 'Running', 'schedule': 'Scheduling', 'assign': 'Assigning', + 'invite': 'Inviting', 'notify': 'Notifying', 'publish': 'Publishing', 'export': 'Exporting', + 'download': 'Downloading', 'claim': 'Claiming', 'record': 'Recording', 'link': 'Linking', +} + +PROPER_WORDS = { + 'word': 'Word', 'powerpoint': 'PowerPoint', 'excel': 'Excel', 'markdown': 'Markdown', + 'pdf': 'PDF', 'teams': 'Teams', 'outlook': 'Outlook', +} + +FUNCTION_WORD_PATTERN = re.compile(r'[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+') + + +def _text(value): + return str(value or '').strip() + + +def _display_word(word): + if len(word) > 1 and word.isupper(): + return word + return PROPER_WORDS.get(word.lower(), word.lower()) + + +def describe_function_name(function_name): + """'getOrderStatus' -> 'Looking up order status', 'search_documents' -> 'Searching documents'.""" + words = FUNCTION_WORD_PATTERN.findall(_text(function_name)) + if not words: + return '' + verb = VERB_PHRASES.get(words[0].lower()) + rest = [_display_word(word) for word in (words[1:] if verb else words)] + if not verb: + return f"Using {' '.join(rest)}" + return ' '.join([verb, *rest]) + + +def _describe_tool_invocation(activity): + status = _text(activity.get('status') or activity.get('state')).lower() + if status == 'failed': + return '' + plugin_name = _text(activity.get('plugin_name')) + if plugin_name == 'AgentPlugin': + delegation = activity.get('delegation') if isinstance(activity.get('delegation'), dict) else {} + target = _text(delegation.get('target_label')) + if status == 'completed': + return f"Reviewing {target}'s reply" if target else REVIEWING_STEP + return f'Asking {target}' if target else 'Asking another agent' + if status == 'completed': + return REVIEWING_STEP + return PLUGIN_PHRASES.get(plugin_name) or describe_function_name(activity.get('function_name')) + + +def _describe_generation(content): + lowered = content.lower() + if lowered.startswith('sending to'): + return THINKING_STEP + if 'responded' in lowered or lowered.startswith('image generated'): + return FINISHING_STEP + if 'image' in lowered: + return 'Creating the image' + return THINKING_STEP + + +def describe_ai_activity_step(payload): + """Describe one collaboration stream event for the activity line, or return '' to skip it.""" + if not isinstance(payload, dict) or payload.get('done') or payload.get('error'): + return '' + + if payload.get('type') is None: + content = payload.get('content') + return WRITING_STEP if isinstance(content, str) and content.strip() else '' + if payload.get('type') != 'thought': + return '' + + activity = payload.get('activity') + if isinstance(activity, dict) and _text(activity.get('kind')) == 'tool_invocation': + return _describe_tool_invocation(activity) + + step_type = _text(payload.get('step_type')).lower() + content = _text(payload.get('content')) + if step_type == 'generation': + return _describe_generation(content) + if step_type == 'agent_tool_call' and content.lower().startswith('sending to agent'): + return 'Getting started' + return STEP_TYPE_PHRASES.get(step_type, '') diff --git a/application/single_app/functions_collaboration_generated_documents.py b/application/single_app/functions_collaboration_generated_documents.py new file mode 100644 index 000000000..2c397ebec --- /dev/null +++ b/application/single_app/functions_collaboration_generated_documents.py @@ -0,0 +1,153 @@ +# functions_collaboration_generated_documents.py +"""Documents that agents generated in a shared conversation, and who may download them. + +An agent creates a document with the SimpleChat action's upload functions. Each stores the file +in the requester's personal workspace or in a group workspace and returns its id, which stays on +the message in the agent citation. That is what lets a shared conversation list what was produced +in it. Listing reveals only file names; downloading follows the workspace's own download rules, +whichever group the reader currently has active. +""" + +import json +import os +import re + +from functions_documents import get_document_record +from functions_group import assert_group_role, find_group_by_id +from functions_settings import ( + get_settings, + is_group_workspace_file_download_enabled, + is_personal_workspace_file_download_enabled, +) + +GENERATED_DOCUMENT_FUNCTIONS = frozenset({ + 'upload_markdown_document', + 'upload_word_document', + 'upload_powerpoint_document', +}) +# The roles the group workspace's own download route requires +# (GROUP_DOCUMENT_DOWNLOAD_MANAGER_ROLES in route_backend_group_documents.py). +GROUP_DOWNLOAD_ROLES = ('Owner', 'Admin', 'DocumentManager') +DOCUMENT_ID_PATTERN = re.compile(r'^[A-Za-z0-9-]{8,64}$') +MARKDOWN_EXTENSIONS = frozenset({'.md', '.markdown'}) +FILE_NAME_MAX_LENGTH = 255 + + +def _parse_result(value): + if isinstance(value, str): + try: + value = json.loads(value) + except ValueError: + return None + return value if isinstance(value, dict) else None + + +def read_generated_document(citation): + """Return the document an upload citation created, or None for any other citation.""" + if not isinstance(citation, dict): + return None + if str(citation.get('function_name') or '').strip() not in GENERATED_DOCUMENT_FUNCTIONS: + return None + result = _parse_result(citation.get('function_result')) + if not result or result.get('success') is False: + return None + document = result.get('document') + if not isinstance(document, dict): + return None + + document_id = str(document.get('id') or '').strip() + if not DOCUMENT_ID_PATTERN.match(document_id): + return None + scope = str(result.get('workspace_scope') or '').strip().lower() + group_id = str(result.get('group_id') or '').strip() + if scope == 'group': + if not group_id: + return None + elif scope == 'personal': + group_id = '' + else: + return None + + raw_name = str(document.get('file_name') or '').replace('\\', '/') + file_name = os.path.basename(raw_name).strip()[:FILE_NAME_MAX_LENGTH] or 'document' + extension = os.path.splitext(file_name)[1].lower() + return { + 'document_id': document_id, + 'file_name': file_name, + 'workspace_scope': scope, + 'group_id': group_id or None, + 'preview': 'markdown' if extension in MARKDOWN_EXTENSIONS else None, + } + + +def collect_generated_documents(messages): + """List every document generated in a conversation's messages, oldest first, once each. + + A fully masked message is skipped: masking it hid what it said, including what it produced. + """ + found = {} + for message in messages or []: + if not isinstance(message, dict): + continue + metadata = message.get('metadata') + if isinstance(metadata, dict) and metadata.get('masked') is True: + continue + citations = message.get('agent_citations') + if not isinstance(citations, list): + continue + for citation in citations: + document = read_generated_document(citation) + if not document or document['document_id'] in found: + continue + document['message_id'] = str(message.get('id') or '') + document['created_at'] = str( + (citation.get('timestamp') if isinstance(citation, dict) else '') + or message.get('timestamp') + or '' + ) + found[document['document_id']] = document + return list(found.values()) + + +def authorize_generated_document_download(user_id, document, settings=None): + """Return ``(document_record, group_id)`` when ``user_id`` may download ``document``. + + Raises ``PermissionError`` when the workspace's download rules refuse it and ``LookupError`` + when the document cannot be read by this user, which includes another person's personal + document. A group document needs a document-managing role in that group and downloads + allowed for it; a personal one needs to be the reader's own with personal downloads allowed. + """ + settings = settings if settings is not None else get_settings() + document_id = document['document_id'] + if document['workspace_scope'] == 'group': + if not settings.get('enable_group_workspaces', False): + raise PermissionError('Group workspaces are disabled') + group_id = document['group_id'] + group_doc = find_group_by_id(group_id) + if not group_doc: + raise LookupError('Document not found') + assert_group_role(user_id, group_id, allowed_roles=GROUP_DOWNLOAD_ROLES) + if not is_group_workspace_file_download_enabled(settings, group_doc): + raise PermissionError('File downloads are disabled for this group workspace') + record = get_document_record(user_id=user_id, document_id=document_id, group_id=group_id) + if not record: + raise LookupError('Document not found') + return record, group_id + + if not settings.get('enable_user_workspace', False): + raise PermissionError('Personal workspaces are disabled') + if not is_personal_workspace_file_download_enabled(settings): + raise PermissionError('File downloads are disabled for personal workspaces') + record = get_document_record(user_id=user_id, document_id=document_id) + if not record: + raise LookupError('Document not found') + return record, None + + +def can_download_generated_document(user_id, document, settings=None): + """Whether the download would be allowed, for the list's Download control.""" + try: + authorize_generated_document_download(user_id, document, settings=settings) + except (LookupError, PermissionError): + return False + return True diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index 6b93ddf57..204239f28 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -60,6 +60,7 @@ TABULAR_EXTENSIONS, VERSION, cognitive_services_scope, + cosmos_collaboration_messages_container, cosmos_conversations_container, cosmos_group_documents_container, cosmos_messages_container, @@ -105,6 +106,7 @@ get_collaboration_conversation, mirror_source_message_to_collaboration, ) +from functions_conversation_cache import bump_conversation_cache_version from functions_generated_file_exports import ( normalize_complete_xml_artifact_payload, normalize_xml_artifact_payload, @@ -4763,6 +4765,139 @@ def _filter_visualization_agent_citations(agent_citations): return [citation for citation in agent_citations or [] if _is_visualization_citation(citation)] +# Set on a message a run posted itself once the run's reply has been mirrored into the same +# conversation. The message stays stored, and in the conversation's AI history, but clients +# do not show it: the reply carries the same findings with the maps and sources the tools +# returned, and the posted copy reads as though the person had written it. +WORKFLOW_REPLY_SUPERSEDED_METADATA_KEY = 'superseded_by_workflow_reply' + + +def _extract_run_posted_message_ids_from_citations(agent_citations): + """Return the ids of the messages a run posted itself, keyed by conversation id. + + Covers the opening message a create call seeded and every add_conversation_message call. + The ids come from the tool results, so only messages this run posted can match. + """ + seeding_function_names = { + 'create_group_conversation', + 'create_personal_collaboration_conversation', + 'create_personal_conversation', + } + posted_message_ids = {} + + for citation in agent_citations or []: + if not isinstance(citation, dict) or citation.get('plugin_name') != 'SimpleChatPlugin': + continue + function_name = citation.get('function_name') + if function_name not in seeding_function_names and function_name != 'add_conversation_message': + continue + if citation.get('success') is False: + continue + + invocation_result = citation.get('function_result') if isinstance(citation.get('function_result'), dict) else {} + if invocation_result.get('success') is False: + continue + if function_name in seeding_function_names and invocation_result.get('seeded_initial_message') is not True: + continue + + message_doc = invocation_result.get('message') if isinstance(invocation_result.get('message'), dict) else {} + conversation_doc = invocation_result.get('conversation') if isinstance(invocation_result.get('conversation'), dict) else {} + message_id = str(message_doc.get('id') or '').strip() + conversation_id = str(conversation_doc.get('id') or message_doc.get('conversation_id') or '').strip() + if not message_id or not conversation_id: + continue + + conversation_message_ids = posted_message_ids.setdefault(conversation_id, []) + if message_id not in conversation_message_ids: + conversation_message_ids.append(message_id) + + return posted_message_ids + + +def _hide_run_posts_superseded_by_reply(workflow, conversation_id, message_ids, reply_message_doc, collaboration): + """Withdraw the run's own posts from view once its reply is mirrored into the conversation. + + Only messages posted through the agent action are marked (posted_via 'agent_action', set by + add_conversation_message_for_current_user). Best effort: a failure leaves the post visible + and never undoes the mirror. + """ + reply_message_id = str((reply_message_doc or {}).get('id') or '').strip() + if not conversation_id or not reply_message_id or not message_ids: + return [] + + container = cosmos_collaboration_messages_container if collaboration else cosmos_messages_container + marker = { + 'message_id': reply_message_id, + 'workflow_id': str(workflow.get('id') or '').strip(), + 'superseded_at': _utc_now_iso(), + } + hidden_message_ids = [] + for message_id in message_ids: + if message_id == reply_message_id: + continue + try: + message_doc = container.read_item(item=message_id, partition_key=conversation_id) + metadata = message_doc.get('metadata') if isinstance(message_doc.get('metadata'), dict) else {} + if metadata.get('posted_via') != 'agent_action': + continue + if not metadata.get(WORKFLOW_REPLY_SUPERSEDED_METADATA_KEY): + message_doc['metadata'] = {**metadata, WORKFLOW_REPLY_SUPERSEDED_METADATA_KEY: marker} + container.upsert_item(message_doc) + hidden_message_ids.append(message_id) + except CosmosResourceNotFoundError: + continue + except Exception as exc: + log_event( + f'[WORKFLOW_RUNNER] Failed to hide a run-posted message replaced by the mirrored reply: {exc}', + extra={ + 'workflow_id': str(workflow.get('id') or '').strip(), + 'conversation_id': conversation_id, + 'message_id': message_id, + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return hidden_message_ids + + +def _hide_workflow_conversation_after_delivery(conversation_id): + """Hide a workflow's own conversation the first time a run delivers into one it created. + + The created conversation is where people read the result; the workflow conversation is the + run's working record and stays reachable by link. The timestamp makes this a one-time + default, so a person who shows the conversation again is not overruled by later runs. + """ + if not conversation_id: + return False + try: + conversation_doc = cosmos_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, + ) + if str(conversation_doc.get('chat_type') or '').strip() != 'workflow': + return False + if conversation_doc.get('is_hidden') or conversation_doc.get('workflow_delivery_hidden_at'): + return False + conversation_doc['is_hidden'] = True + conversation_doc['workflow_delivery_hidden_at'] = _utc_now_iso() + cosmos_conversations_container.upsert_item(conversation_doc) + bump_conversation_cache_version( + str(conversation_doc.get('user_id') or '').strip(), + reason='workflow_conversation_hidden_after_delivery', + ) + return True + except CosmosResourceNotFoundError: + return False + except Exception as exc: + log_event( + f'[WORKFLOW_RUNNER] Failed to hide the workflow conversation after delivery: {exc}', + extra={'conversation_id': conversation_id}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return False + + def _is_collaboration_target_conversation(conversation_doc): chat_type = str((conversation_doc or {}).get('chat_type') or '').strip() conversation_kind = str((conversation_doc or {}).get('conversation_kind') or '').strip() @@ -4905,6 +5040,7 @@ def _mirror_workflow_visualizations_to_created_conversations(workflow, source_as 'hybrid_citations': hybrid_citations, 'web_search_citations': web_search_citations, } + run_posted_message_ids = _extract_run_posted_message_ids_from_citations(raw_agent_citations) mirrored_message_ids = [] for created_conversation in created_conversations: @@ -4928,6 +5064,14 @@ def _mirror_workflow_visualizations_to_created_conversations(workflow, source_as if created and mirrored_message_doc: create_collaboration_message_notifications(updated_conversation, mirrored_message_doc) mirrored_message_ids.append(mirrored_message_doc.get('id')) + if mirrored_message_doc: + _hide_run_posts_superseded_by_reply( + workflow, + conversation_id, + run_posted_message_ids.get(conversation_id), + mirrored_message_doc, + collaboration=True, + ) else: mirrored_message_doc = _mirror_assistant_message_to_personal_conversation( workflow, @@ -4937,6 +5081,13 @@ def _mirror_workflow_visualizations_to_created_conversations(workflow, source_as ) if mirrored_message_doc: mirrored_message_ids.append(mirrored_message_doc.get('id')) + _hide_run_posts_superseded_by_reply( + workflow, + conversation_id, + run_posted_message_ids.get(conversation_id), + mirrored_message_doc, + collaboration=False, + ) except Exception as exc: log_event( f'[WORKFLOW_RUNNER] Failed to mirror workflow visualizations into conversation {conversation_id}: {exc}', @@ -4949,6 +5100,9 @@ def _mirror_workflow_visualizations_to_created_conversations(workflow, source_as exceptionTraceback=True, ) + if mirrored_message_ids: + _hide_workflow_conversation_after_delivery(source_conversation_id) + return mirrored_message_ids diff --git a/application/single_app/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py index 1ec2cd189..d679d9395 100644 --- a/application/single_app/route_backend_collaboration.py +++ b/application/single_app/route_backend_collaboration.py @@ -3,6 +3,7 @@ import json import threading import time +import uuid from content_screening.access import build_available_document_response, public_history_messages from content_screening.contracts import ScreeningError @@ -54,6 +55,13 @@ update_personal_collaboration_title, ) from functions_conversation_cache import bump_conversation_cache_version +from functions_collaboration_ai_activity import describe_ai_activity_step +from functions_collaboration_generated_documents import ( + authorize_generated_document_download, + can_download_generated_document, + collect_generated_documents, +) +from functions_documents import build_document_download_response from functions_saved_analysis import sanitize_saved_analysis_messages from functions_chat_stream_events import ( USER_MESSAGE_PERSISTED_EVENT_TYPE, @@ -346,6 +354,83 @@ def _build_collaboration_event(conversation_id, event_type, payload): } +AI_ACTIVITY_STEP_MAX_LENGTH = 140 +AI_ACTIVITY_NAME_MAX_LENGTH = 120 + + +def _describe_collaboration_ai_target(invocation_target, agent_info): + """Return the display name and kind of what an AI request asked, for its activity line.""" + target = invocation_target if isinstance(invocation_target, dict) else {} + name = str(target.get('display_name') or '').strip() + target_type = str(target.get('target_type') or '').strip().lower() + if not name and isinstance(agent_info, dict): + name = str(agent_info.get('display_name') or agent_info.get('name') or '').strip() + target_type = target_type or 'agent' + if target_type not in ('agent', 'model', 'image'): + target_type = 'model' + return (name or 'Assistant')[:AI_ACTIVITY_NAME_MAX_LENGTH], target_type + + +class CollaborationAiActivity: + """Tell every participant that an AI request is running, what it is doing, and when it ends. + + One instance covers one request. ``started`` and ``finished`` bracket the run, and + ``step`` repeats the run's latest progress step in plain words + (``describe_ai_activity_step``), never a thought's technical text or detail. Publishing never + raises: an activity line is advisory and must not break the answer it describes. + """ + + def __init__(self, conversation_id, display_name, target_type, requested_by, request_message_id, publish=None): + self.conversation_id = conversation_id + self.run_id = uuid.uuid4().hex + self.display_name = display_name + self.target_type = target_type + self.requested_by = { + 'user_id': str((requested_by or {}).get('user_id') or '').strip(), + 'display_name': str((requested_by or {}).get('display_name') or '').strip(), + } + self.request_message_id = str(request_message_id or '').strip() + self.status = 'failed' + self._publish = publish or COLLABORATION_EVENT_REGISTRY.publish + self._last_step = '' + self._finished = False + + def _emit(self, event_type, run_payload): + try: + self._publish( + self.conversation_id, + _build_collaboration_event(self.conversation_id, event_type, {'run': run_payload}), + ) + except Exception as exc: + log_event( + f'[COLLABORATION] Could not publish {event_type} for {self.conversation_id}: {exc}', + level=logging.WARNING, + ) + + def started(self): + self._emit('collaboration.ai.started', { + 'run_id': self.run_id, + 'display_name': self.display_name, + 'target_type': self.target_type, + 'requested_by': self.requested_by, + 'request_message_id': self.request_message_id, + 'started_at': utc_now_iso(), + }) + + def step(self, content): + step = ' '.join(str(content or '').split())[:AI_ACTIVITY_STEP_MAX_LENGTH] + if not step or step == self._last_step or self._finished: + return + self._last_step = step + self._emit('collaboration.ai.progress', {'run_id': self.run_id, 'step': step}) + + def finished(self): + if self._finished: + return + self._finished = True + self._emit('collaboration.ai.finished', {'run_id': self.run_id, 'status': self.status}) + + def _hydrate_collaboration_stream_actions(payload, viewer_user_id, conversation_id): """Resolve only record IDs, then remap the UI to the visible shared conversation.""" creation = payload.get('type') == 'm365_pending_action' @@ -1733,6 +1818,116 @@ def get_collaboration_messages_api(conversation_id): ) return jsonify({'error': 'Failed to load collaborative conversation messages'}), 500 + @bp.route('/api/collaboration/conversations//generated-documents', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def list_collaboration_generated_documents_api(conversation_id): + """List the documents agents generated in this conversation, and which the reader may download.""" + 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=False, + ) + settings = get_settings() + documents = [] + for document in collect_generated_documents(list_collaboration_messages(conversation_id)): + documents.append({ + 'document_id': document['document_id'], + 'file_name': document['file_name'], + 'workspace_scope': document['workspace_scope'], + 'preview': document['preview'], + 'message_id': document['message_id'], + 'created_at': document['created_at'], + 'can_download': can_download_generated_document( + current_user['user_id'], document, settings=settings, + ), + }) + return jsonify({'documents': documents}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + log_event( + f'[COLLABORATION] Permission denied while listing generated documents for {conversation_id}: {exc}', + level=logging.WARNING, + ) + return jsonify({'error': 'You do not have access to this conversation'}), 403 + except Exception as exc: + log_event( + f'[COLLABORATION] Failed to list generated documents for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to list generated documents'}), 500 + + @bp.route( + '/api/collaboration/conversations//generated-documents//download', + methods=['GET'], + ) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def download_collaboration_generated_document_api(conversation_id, document_id): + """Download a document an agent generated in this conversation, under its workspace's rules.""" + 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=False, + ) + # Only a document this conversation produced can be fetched through it. + document = next( + ( + candidate + for candidate in collect_generated_documents(list_collaboration_messages(conversation_id)) + if candidate['document_id'] == document_id + ), + None, + ) + if not document: + return jsonify({'error': 'Document not found'}), 404 + try: + document_record, group_id = authorize_generated_document_download( + current_user['user_id'], document, + ) + except LookupError: + return jsonify({'error': 'Document not found or access denied'}), 404 + return build_document_download_response( + document_record, + user_id=current_user['user_id'], + group_id=group_id, + ) + except FileNotFoundError: + return jsonify({'error': 'This document is not available yet.'}), 404 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + log_event( + f'[COLLABORATION] Permission denied while downloading a generated document for {conversation_id}: {exc}', + level=logging.WARNING, + ) + return jsonify({'error': 'You do not have permission to download this document'}), 403 + except Exception as exc: + log_event( + f'[COLLABORATION] Failed to download generated document for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Unable to download document'}), 500 + @bp.route('/api/collaboration/conversations//messages//mask', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required @@ -2682,6 +2877,16 @@ def stream_collaboration_message_api(conversation_id): stream_request_payload['m365_collaboration_message_id'] = user_message_doc['id'] pending_cards = {} pending_request_id = None + target_name, target_type = _describe_collaboration_ai_target( + invocation_target, data.get('agent_info'), + ) + ai_activity = CollaborationAiActivity( + conversation_id, + target_name, + target_type, + requested_by=current_user, + request_message_id=serialized_user_message.get('id'), + ) def collaboration_stream_error(error_message, **extra_fields): """Serialize a stream error that stays attributed to this shared conversation. @@ -2703,6 +2908,7 @@ def collaboration_stream_error(error_message, **extra_fields): ) def generate_stream(): + ai_activity.started() try: yield build_user_message_persisted_stream_event( conversation_id, @@ -2761,6 +2967,8 @@ def transform_event_block(event_block): except json.JSONDecodeError: return normalized_event_block + '\n\n' + ai_activity.step(describe_ai_activity_step(stream_payload)) + if stream_payload.get('type') == 'm365_pending_action' or 'm365_pending_actions' in stream_payload: stream_payload = _hydrate_collaboration_stream_actions( stream_payload, current_user['user_id'], conversation_id, @@ -2821,6 +3029,7 @@ def transform_event_block(event_block): source_message_id = str(stream_payload.get('message_id') or '').strip() if stream_payload.get('cancelled') or stream_payload.get('canceled'): + ai_activity.status = 'cancelled' if not source_message_id: transformed_payload = { **stream_payload, @@ -2910,6 +3119,8 @@ def transform_event_block(event_block): }, ), ) + if ai_activity.status != 'cancelled': + ai_activity.status = 'completed' serialized_assistant_message = hydrate_m365_pending_action_cards( [serialized_assistant_message], current_user['user_id'], conversation_id, )[0] @@ -2969,6 +3180,10 @@ def transform_event_block(event_block): exceptionTraceback=True, ) yield collaboration_stream_error('Failed to stream collaborative AI response') + finally: + # Also reached when the requester disconnects: nothing more will arrive + # through this request, so its activity line must not outlive it. + ai_activity.finished() return Response(stream_with_context(generate_stream()), mimetype='text/event-stream') except CosmosResourceNotFoundError: diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js index 7d8b53dd7..0c71f3a8d 100644 --- a/application/single_app/static/js/chat/chat-collaboration.js +++ b/application/single_app/static/js/chat/chat-collaboration.js @@ -1015,6 +1015,12 @@ async function loadConversationMessages(conversationId) { .map(message => String(message.id)) ); messages.forEach(message => { + // A workflow run's own post is replaced by its mirrored reply, which says the same. + // It stays cached so a reply that quotes it still resolves. + if (message?.metadata?.superseded_by_workflow_reply) { + cacheCollaborationMessage(message); + return; + } const decoratedMessage = decorateReplyMessage(message); if (decoratedMessage.role === 'assistant' && generatedImageProposalMessages.has(decoratedMessage.id)) { decoratedMessage.generated_image_proposals = generatedImageProposalMessages.get(decoratedMessage.id); diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index d2f068690..5b6ee1cb9 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -2223,6 +2223,10 @@ export function loadMessages(conversationId) { console.log(`Skipping deleted message: ${msg.id}`); return; } + // A workflow run's own post is replaced by its mirrored reply, which says the same. + if (msg.metadata && msg.metadata.superseded_by_workflow_reply) { + return; + } console.log(`[loadMessages Loop] -------- START Message ID: ${msg.id} --------`); console.log(`[loadMessages Loop] Role: ${msg.role}`); if (msg.role === "user") { diff --git a/application/v2_ui/src/components/chat/Composer.tsx b/application/v2_ui/src/components/chat/Composer.tsx index 72fc1d071..9fe1e93cc 100644 --- a/application/v2_ui/src/components/chat/Composer.tsx +++ b/application/v2_ui/src/components/chat/Composer.tsx @@ -245,6 +245,8 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st const [draft, setDraft] = useState(createComposerDraft); const { text, attachedPrompt } = draft; + // A message made only of mention chips is a ping, which typing `@Name` alone always allowed. + const hasMentionChips = (draft.mentions?.length ?? 0) > 0; const promptInstance = draft.promptInstance ?? 0; const contextItems = composerDraftContextItems(draft); const imageCapability = useImageEditCapability(); @@ -620,10 +622,17 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st imageReferenceConversationRef.current = activeConversationId; setDraft((current) => { const clearReferences = conversationChanged && Boolean(current.imageReferences?.length); - if (!current.uploads.length && !clearReferences) { + // Mention chips name the people and agents of the conversation being left. + const clearMentions = conversationChanged && Boolean(current.mentions?.length); + if (!current.uploads.length && !clearReferences && !clearMentions) { return current; } - return { ...current, uploads: [], ...(clearReferences ? { imageReferences: [] } : {}) }; + return { + ...current, + uploads: [], + ...(clearReferences ? { imageReferences: [] } : {}), + ...(clearMentions ? { mentions: [] } : {}), + }; }); }, [activeConversationId]); @@ -1001,7 +1010,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st } // An attached prompt is a complete message on its own, so a turn carrying one may be // sent without anything typed under it. - if (!text.trim() && !attachedPrompt) { + if (!text.trim() && !attachedPrompt && !hasMentionChips) { return; } if (attachedPrompt && !allowUnfilled && composerDraftUnfilledVariables(draft, promptContext()).length > 0) { @@ -1935,7 +1944,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st + ) : ( +

+ {document.file_name} +

+ )} +

+ {scopeLabel(document)} + {!document.can_download && ' · Download not permitted'} +

+ + {canPreview && ( + + )} + {document.can_download && ( + + )} + + + ); + })} + + {previewing && ( + void download(previewing)} + onClose={() => setPreviewing(null)} + /> + )} + + ); +} + +function ImageTile({ item, onOpen }: { item: ConversationMediaItem; onOpen: () => void }) { + const [failed, setFailed] = useState(false); + return ( +
  • + +
  • + ); +} + +function PlayerDialog({ item, onClose }: { item: ConversationMediaItem; onClose: () => void }) { + return ( + + {item.kind === 'video' ? ( + + ) : ( + + )} + + ); +} + +export function MediaSection({ items }: { items: ConversationMediaItem[] }) { + const [viewing, setViewing] = useState(null); + if (items.length === 0) { + return null; + } + const images = items.filter((item) => item.kind === 'image'); + const recordings = items.filter((item) => item.kind !== 'image'); + const imageSource = viewing?.kind === 'image' ? resolveImageSource(viewing.src) : null; + + return ( +
    + Media + {images.length > 0 && ( +
      + {images.map((item) => ( + setViewing(item)} /> + ))} +
    + )} + {recordings.length > 0 && ( +
      + {recordings.map((item) => ( +
    • + +
    • + ))} +
    + )} + {viewing && imageSource && createPortal( + // Portalled: the drawer's backdrop filter would otherwise confine a fixed overlay to it. + setViewing(null)} />, + document.body, + )} + {viewing && viewing.kind !== 'image' && setViewing(null)} />} +
    + ); +} diff --git a/application/v2_ui/src/components/chat/MentionPills.tsx b/application/v2_ui/src/components/chat/MentionPills.tsx new file mode 100644 index 000000000..74e9906a5 --- /dev/null +++ b/application/v2_ui/src/components/chat/MentionPills.tsx @@ -0,0 +1,127 @@ +// MentionPills.tsx +// The people and the AI target a shared-conversation message is addressed to, drawn as pills. +// +// Two places use them. Above the message box they are the chips the `@` menu adds, which can be +// removed before sending. On a sent message they replace the `@Name` text, the way a reply shows +// its quote above the text rather than inside it. +// +// Each kind has its own tint and icon, and the name itself is always drawn in the body text +// colour so it stays readable on every tint. + +import { clsx } from 'clsx'; +import { Bot, Cpu, Image as ImageIcon, User, X } from 'lucide-react'; +import type { ComposerMention, InvocationTarget } from '../../lib/mentions'; +import type { MessageMentionPill } from '../../lib/sharedMessage'; + +type PillKind = 'person' | 'self' | InvocationTarget['target_type']; + +const PILL_TINT: Record = { + person: 'bg-ok-soft ring-ok/30', + self: 'bg-accent-soft ring-accent/40', + agent: 'bg-warn-soft ring-warn/40', + model: 'bg-info-soft ring-info/30', + image: 'bg-surface-sunken ring-edge', +}; + +const ICON_TINT: Record = { + person: 'text-ok', + self: 'text-accent', + agent: 'text-warn', + model: 'text-info', + image: 'text-text-3', +}; + +const KIND_LABEL: Record = { + person: 'Person', + self: 'You', + agent: 'Agent', + model: 'Model', + image: 'Image', +}; + +function PillIcon({ kind, className }: { kind: PillKind; className?: string }) { + const Icon = kind === 'agent' ? Bot : kind === 'model' ? Cpu : kind === 'image' ? ImageIcon : User; + return