diff --git a/application/single_app/config.py b/application/single_app/config.py index 143f847b8..ef6a7d47c 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.301" +VERSION = "0.261.302" 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_generated_documents.py b/application/single_app/functions_collaboration_generated_documents.py index 2c397ebec..46a9c099a 100644 --- a/application/single_app/functions_collaboration_generated_documents.py +++ b/application/single_app/functions_collaboration_generated_documents.py @@ -1,11 +1,12 @@ # functions_collaboration_generated_documents.py -"""Documents that agents generated in a shared conversation, and who may download them. +"""Documents that agents generated in a 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. +the message in the agent citation. That is what lets a conversation list what was produced in it, +whether it is a shared conversation or a personal one. Listing reveals only file names; +downloading follows the workspace's own download rules, whichever group the reader currently has +active. """ import json diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index 8f13518b6..b1682541c 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -61,7 +61,13 @@ from flask import Response, current_app, request, stream_with_context from werkzeug.utils import secure_filename from functions_debug import debug_print +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, delete_chat_upload_workspace_documents_for_conversation, serialize_chat_upload_workspace_documents_for_conversation, ) @@ -969,6 +975,54 @@ def hydrate_m365_pending_action_cards(messages, viewer_user_id, conversation_id) ) +def _list_personal_conversation_generated_documents(conversation_item): + """Return the documents SimpleChat upload actions created in a personal conversation. + + ``conversation_item`` must already be authorized with ``_authorize_personal_conversation_read``. + Only messages the thread shows are read, filtered the way ``/api/get_messages`` filters them: + deleted messages, the assistant artifact store, hidden generated chat files and replaced + thread attempts are left out. A citation stored in compact form is rebuilt from its artifact + record first, because the created document's id is in the full tool result. + """ + conversation_id = conversation_item['id'] + raw_messages = list(cosmos_messages_container.query_items( + query='SELECT * FROM c WHERE c.conversation_id = @conversation_id', + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + partition_key=conversation_id, + )) + raw_messages.sort(key=lambda item: ( + str(item.get('timestamp') or ''), + int(item.get('fork_sequence')) if str(item.get('fork_sequence') or '').isdigit() else 0, + str(item.get('id') or ''), + )) + raw_messages = exclude_soft_deleted_messages(raw_messages) + artifact_payload_map = build_message_artifact_payload_map(raw_messages) + + visible_messages = [] + for item in filter_assistant_artifact_items(raw_messages): + metadata = item.get('metadata') if isinstance(item.get('metadata'), dict) else {} + if metadata.get('is_generated_chat_artifact', False): + continue + thread_info = metadata.get('thread_info') if isinstance(metadata.get('thread_info'), dict) else {} + active_thread = thread_info.get('active_thread') + if active_thread is True or active_thread is None: + visible_messages.append(item) + + documents = collect_generated_documents( + hydrate_agent_citations_from_artifacts(visible_messages, artifact_payload_map), + ) + log_event( + '[CONVERSATION_GENERATED_DOCUMENTS] Listed generated documents for a personal conversation.', + extra={ + 'conversation_id': conversation_id, + 'message_count': len(visible_messages), + 'document_count': len(documents), + }, + debug_only=True, + ) + return documents + + def _rebuild_authorized_personal_conversation_used_documents( user_id, conversation_id, @@ -2278,6 +2332,105 @@ def get_conversation_metadata_api(conversation_id): print(f"Error retrieving conversation metadata: {e}") return jsonify({'error': 'Failed to retrieve conversation metadata'}), 500 + @bp.route('/api/conversations//generated-documents', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def list_personal_conversation_generated_documents_api(conversation_id): + """List the documents agents created in this conversation, and which the reader may download. + + The personal counterpart of the shared conversation list: the same documents, read from the + SimpleChat upload actions' results, under the same workspace download rules. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + try: + conversation_item = _authorize_personal_conversation_read(user_id, conversation_id) + settings = get_settings() + documents = [ + { + '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(user_id, document, settings=settings), + } + for document in _list_personal_conversation_generated_documents(conversation_item) + ] + return jsonify({'documents': documents}), 200 + except LookupError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError: + return jsonify({'error': 'Forbidden'}), 403 + except Exception as exc: + log_event( + '[CONVERSATION_GENERATED_DOCUMENTS] Failed to list generated documents.', + extra={'conversation_id': conversation_id, 'exception_type': type(exc).__name__}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to list generated documents'}), 500 + + @bp.route( + '/api/conversations//generated-documents//download', + methods=['GET'], + ) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def download_personal_conversation_generated_document_api(conversation_id, document_id): + """Download a document an agent created in this conversation, under its workspace's rules.""" + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + try: + conversation_item = _authorize_personal_conversation_read(user_id, conversation_id) + except LookupError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError: + return jsonify({'error': 'Forbidden'}), 403 + + try: + # Only a document this conversation produced can be fetched through it. + document = next( + ( + candidate + for candidate in _list_personal_conversation_generated_documents(conversation_item) + 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(user_id, document) + except LookupError: + return jsonify({'error': 'Document not found or access denied'}), 404 + return build_document_download_response( + document_record, + user_id=user_id, + group_id=group_id, + ) + except FileNotFoundError: + return jsonify({'error': 'This document is not available yet.'}), 404 + except PermissionError: + return jsonify({'error': 'You do not have permission to download this document'}), 403 + except ScreeningError as error: + return jsonify({'error': error.public_message, 'error_code': error.code}), error.status_code + except Exception as exc: + log_event( + '[CONVERSATION_GENERATED_DOCUMENTS] Failed to download a generated document.', + extra={'conversation_id': conversation_id, 'exception_type': type(exc).__name__}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Unable to download document'}), 500 + @bp.route('/api/conversations//kind', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/v2_ui/src/components/chat/ConversationDrawer.tsx b/application/v2_ui/src/components/chat/ConversationDrawer.tsx index d16bb0aff..fcaf09166 100644 --- a/application/v2_ui/src/components/chat/ConversationDrawer.tsx +++ b/application/v2_ui/src/components/chat/ConversationDrawer.tsx @@ -2,9 +2,10 @@ // Right-hand drawer with three modes, mirroring the legacy offcanvas that hosts both a // table of contents and the documents used in the conversation, plus the orchestration plan. // -// Contents lists the user's turns so a long thread can be navigated; Documents lists the -// document-level citation aggregates the server records on the conversation; Plan hosts the -// orchestration plan surface when the feature is on. +// Contents lists the user's turns so a long thread can be navigated; Documents lists what the +// conversation produced (generated files and media) above the document-level citation +// aggregates the server records on the conversation; Plan hosts the orchestration plan surface +// when the feature is on. import { useEffect, useMemo } from 'react'; import { clsx } from 'clsx'; @@ -19,13 +20,17 @@ import { } from 'lucide-react'; import { useChatStore, type DrawerMode } from '../../stores/chatStore'; import { useBootstrapStore } from '../../stores/bootstrapStore'; +import { + useConversationGeneratedDocuments, + useConversationGeneratedFiles, +} from '../../stores/generatedDocumentsStore'; import { collectConversationMedia } from '../../lib/conversationMedia'; +import { mergeGeneratedEntries } from '../../lib/conversationGeneratedFiles'; import { EmptyState, Skeleton } from '../ui/primitives'; import { - GeneratedDocumentsSection, + GeneratedSection, MediaSection, SectionHeading, - useGeneratedDocuments, } from './DrawerAssets'; import { OrchestrationPlanPanel } from './OrchestrationPlanPanel'; import type { UsedDocument } from '../../lib/types'; @@ -157,10 +162,15 @@ function DocumentRow({ document }: { document: UsedDocument }) { function DocumentsMode() { const { metadata, metadataLoading, metadataError, activeConversationId, loadMetadata } = useChatStore(); - const collaborative = useChatStore((state) => state.activeConversationKind === 'collaborative'); + const kind = useChatStore((state) => state.activeConversationKind); const messages = useChatStore((state) => state.messages); const media = useMemo(() => collectConversationMedia(messages), [messages]); - const generated = useGeneratedDocuments(activeConversationId, collaborative); + const files = useConversationGeneratedFiles(); + const generated = useConversationGeneratedDocuments(); + const entries = useMemo( + () => mergeGeneratedEntries(files, generated.documents, messages), + [files, generated.documents, messages], + ); useEffect(() => { if (activeConversationId && !metadata && !metadataLoading && !metadataError) { @@ -191,13 +201,15 @@ function DocumentsMode() { return [...merged.values()]; }, [metadata]); - const hasAssets = generated.documents.length > 0 || Boolean(generated.error) || media.length > 0; + const hasAssets = entries.length > 0 || Boolean(generated.error) || media.length > 0; const assets = hasAssets && activeConversationId ? ( <> - @@ -232,8 +244,8 @@ function DocumentsMode() { return ( } - title="No documents used yet" - description="Documents referenced while answering will be listed here." + title="No documents yet" + description="Documents used or created while answering will be listed here." /> ); } diff --git a/application/v2_ui/src/components/chat/DrawerAssets.tsx b/application/v2_ui/src/components/chat/DrawerAssets.tsx index 56ec7c724..0aee50f51 100644 --- a/application/v2_ui/src/components/chat/DrawerAssets.tsx +++ b/application/v2_ui/src/components/chat/DrawerAssets.tsx @@ -1,28 +1,49 @@ // DrawerAssets.tsx // The Generated and Media sections of the conversation drawer's Documents tab. // -// Generated lists the documents agents created in a shared conversation, with Download where the -// workspace's own download rules allow it and a preview for Markdown. Media gathers every image, -// video and audio clip the thread shows, including signed links an action fetched from a remote -// service, which are otherwise hard to find again in a long thread. Images and clips are tiles, -// three to a row, that open one viewer stepping through them all; recordings are players, each -// with Download and a button that scrolls to its message. +// Generated lists every file the conversation produced, in conversation order, for both personal +// and shared conversations: the files a plan rendered and the exports and analysis files replies +// wrote, read off the thread itself, and the documents agents created with the SimpleChat upload +// actions, which the server lists along with whether the workspace's download rules let this +// reader have them. A file that is still being written, failed or waits for approval is listed +// with its status, and Download appears once it is ready; Markdown and files with an inline +// preview open in a preview, and every row can scroll to the reply that produced it. +// +// Media gathers every image, video and audio clip the thread shows, including signed links an +// action fetched from a remote service, which are otherwise hard to find again in a long thread. +// Images and clips are tiles, three to a row, that open one viewer stepping through them all; +// recordings are players, each with Download and a button that scrolls to its message. import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Download, Eye, FileText, Loader2 } from 'lucide-react'; -import { ApiError } from '../../lib/apiClient'; +import { clsx } from 'clsx'; import { - fetchCollaborationGeneratedDocument, - fetchCollaborationGeneratedDocuments, - type GeneratedDocument, -} from '../../lib/collaboration'; -import { saveBlob } from '../../lib/endpoints'; + Download, + Eye, + File as GenericFileIcon, + FileCode, + FileJson, + FileSpreadsheet, + FileText, + Loader2, + LocateFixed, + Presentation, + type LucideIcon, +} from 'lucide-react'; import type { ConversationMediaItem } from '../../lib/conversationMedia'; -import { useChatStore } from '../../stores/chatStore'; +import { + generatedFileDetails, + type ConversationGeneratedFile, + type GeneratedEntry, + type GeneratedFileStatus, +} from '../../lib/conversationGeneratedFiles'; +import { downloadGeneratedArtifact, saveBlob, type ConversationKind } from '../../lib/endpoints'; +import { hasArtifactPreview, type GeneratedArtifact } from '../../lib/generatedArtifacts'; +import { fetchGeneratedDocument, type GeneratedDocument } from '../../lib/generatedDocuments'; import { toast } from '../../stores/toastStore'; import { Modal } from '../ui/Modal'; import { PlainMarkdown } from '../ui/PlainMarkdown'; import { GlassButton } from '../ui/primitives'; +import { ArtifactPreviewDialog } from './GeneratedArtifactCard'; import { InlineAudioPlayer } from './InlineAudioPlayer'; import { MediaViewer, type MediaViewerItem } from './MediaViewer'; import { ImageTile, VideoTile } from './MediaTiles'; @@ -30,6 +51,43 @@ import { ImageTile, VideoTile } from './MediaTiles'; /** Largest Markdown file previewed in the app; anything bigger is better read downloaded. */ const PREVIEW_MAX_BYTES = 2 * 1024 * 1024; +const ROW_BUTTON = + 'shrink-0 rounded-lg p-1.5 text-text-3 transition-colors hover:bg-surface-2 hover:text-text-1 disabled:opacity-50'; + +/** Spreadsheets, documents, decks and structured data each get an icon a person recognises. */ +const FORMAT_ICONS: Readonly> = { + csv: FileSpreadsheet, + tsv: FileSpreadsheet, + xls: FileSpreadsheet, + xlsx: FileSpreadsheet, + docx: FileText, + pdf: FileText, + md: FileText, + txt: FileText, + pptx: Presentation, + json: FileJson, + xml: FileCode, + yaml: FileCode, +}; + +/** Status pills share the tones of the plan's own file cards. */ +const STATUS_TONES: Readonly, string>> = { + pending: 'bg-surface-3 text-text-2', + cancelled: 'bg-surface-3 text-text-2', + failed: 'bg-danger-soft text-danger', + withheld: 'bg-warn-soft text-warn', + unavailable: 'bg-warn-soft text-warn', +}; + +function FormatIcon({ format }: { format: string }) { + const Icon = FORMAT_ICONS[format] ?? GenericFileIcon; + return