diff --git a/application/single_app/config.py b/application/single_app/config.py index 08c0adf1..6e88a0a5 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.239.005" +VERSION = "0.239.010" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/route_backend_conversation_export.py b/application/single_app/route_backend_conversation_export.py index aad750e4..ca4dcc0c 100644 --- a/application/single_app/route_backend_conversation_export.py +++ b/application/single_app/route_backend_conversation_export.py @@ -11,6 +11,7 @@ from flask import Response, jsonify, request, make_response from functions_debug import debug_print from swagger_wrapper import swagger_route, get_auth_security +from docx import Document as DocxDocument def register_route_backend_conversation_export(app): @@ -286,3 +287,195 @@ def _safe_filename(title): if len(safe) > 50: safe = safe[:50] return safe or 'Untitled' + + # ------------------------------------------------------------------ + # Single-message export to Word (.docx) + # ------------------------------------------------------------------ + + @app.route('/api/message/export-word', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_export_message_word(): + """ + Export a single message as a Word (.docx) document. + + Request body: + message_id (str): ID of the message to export. + conversation_id (str): ID of the conversation the message belongs to. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json() + if not data: + return jsonify({'error': 'Request body is required'}), 400 + + message_id = data.get('message_id') + conversation_id = data.get('conversation_id') + + if not message_id or not conversation_id: + return jsonify({'error': 'message_id and conversation_id are required'}), 400 + + try: + # Verify user owns the conversation + try: + conversation = cosmos_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id + ) + except Exception: + return jsonify({'error': 'Conversation not found'}), 404 + + if conversation.get('user_id') != user_id: + return jsonify({'error': 'Access denied'}), 403 + + # Fetch the specific message + try: + message = cosmos_messages_container.read_item( + item=message_id, + partition_key=conversation_id + ) + except Exception: + return jsonify({'error': 'Message not found'}), 404 + + # Build the Word document + doc = DocxDocument() + + role = message.get('role', 'unknown').capitalize() + if role == 'Assistant': + role_label = 'Assistant' + elif role == 'User': + role_label = 'User' + else: + role_label = role + + timestamp = message.get('timestamp', '') + + # Title + doc.add_heading('Message Export', level=1) + + # Metadata paragraph + meta_para = doc.add_paragraph() + meta_run = meta_para.add_run(f"Role: {role_label}") + meta_run.bold = True + if timestamp: + meta_para.add_run(f" {timestamp}") + + doc.add_paragraph('') # spacer + + # Message content + raw_content = message.get('content', '') + content = _normalize_content(raw_content) + _add_markdown_content_to_doc(doc, content) + + # Citations + citations = message.get('citations') + if citations and isinstance(citations, list) and len(citations) > 0: + doc.add_heading('Citations', level=2) + for cit in citations: + if isinstance(cit, dict): + source = cit.get('title') or cit.get('filepath') or cit.get('url', 'Unknown') + doc.add_paragraph(source, style='List Bullet') + else: + doc.add_paragraph(str(cit), style='List Bullet') + + # Write to buffer and return + buffer = io.BytesIO() + doc.save(buffer) + buffer.seek(0) + + timestamp_str = datetime.utcnow().strftime('%Y%m%d_%H%M%S') + filename = f"message_export_{timestamp_str}.docx" + + response = make_response(buffer.read()) + response.headers['Content-Type'] = ( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) + response.headers['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + except Exception as e: + debug_print(f"Message export error: {str(e)}") + return jsonify({'error': f'Export failed: {str(e)}'}), 500 + + def _add_markdown_content_to_doc(doc, content): + """Convert markdown content to Word document elements with basic formatting.""" + import re as _re + from docx.shared import Pt + + lines = content.split('\n') + i = 0 + while i < len(lines): + line = lines[i] + + # Headings + heading_match = _re.match(r'^(#{1,6})\s+(.*)', line) + if heading_match: + level = min(len(heading_match.group(1)), 4) + doc.add_heading(heading_match.group(2).strip(), level=level) + i += 1 + continue + + # Fenced code block + if line.strip().startswith('```'): + code_lines = [] + i += 1 + while i < len(lines) and not lines[i].strip().startswith('```'): + code_lines.append(lines[i]) + i += 1 + i += 1 # skip closing ``` + code_para = doc.add_paragraph() + code_run = code_para.add_run('\n'.join(code_lines)) + code_run.font.name = 'Consolas' + code_run.font.size = Pt(9) + continue + + # Unordered list item + list_match = _re.match(r'^(\s*)[*\-+]\s+(.*)', line) + if list_match: + doc.add_paragraph(list_match.group(2).strip(), style='List Bullet') + i += 1 + continue + + # Ordered list item + ol_match = _re.match(r'^(\s*)\d+[.)]\s+(.*)', line) + if ol_match: + doc.add_paragraph(ol_match.group(2).strip(), style='List Number') + i += 1 + continue + + # Blank line — skip + if line.strip() == '': + i += 1 + continue + + # Regular paragraph with inline formatting + para = doc.add_paragraph() + _add_inline_formatting(para, line) + i += 1 + + def _add_inline_formatting(paragraph, text): + """Apply bold and italic inline markdown formatting to a paragraph.""" + import re as _re + from docx.shared import Pt + + # Split on bold/italic markers and apply formatting + # Pattern matches **bold**, *italic*, `code` + pattern = _re.compile(r'(\*\*.*?\*\*|\*.*?\*|`[^`]+`)') + parts = pattern.split(text) + + for part in parts: + if part.startswith('**') and part.endswith('**'): + run = paragraph.add_run(part[2:-2]) + run.bold = True + elif part.startswith('*') and part.endswith('*') and len(part) > 2: + run = paragraph.add_run(part[1:-1]) + run.italic = True + elif part.startswith('`') and part.endswith('`'): + run = paragraph.add_run(part[1:-1]) + run.font.name = 'Consolas' + run.font.size = Pt(9) + elif part: + paragraph.add_run(part) diff --git a/application/single_app/static/js/chat/chat-message-export.js b/application/single_app/static/js/chat/chat-message-export.js new file mode 100644 index 00000000..8211c097 --- /dev/null +++ b/application/single_app/static/js/chat/chat-message-export.js @@ -0,0 +1,171 @@ +// chat-message-export.js +import { showToast } from "./chat-toast.js"; + +/** + * Per-message export module. + * + * Provides functions to export a single chat message as Markdown (.md) + * or Word (.docx) from the three-dots dropdown on each message bubble. + */ + +/** + * Get the markdown content for a message from the DOM. + * AI messages store their markdown in a hidden textarea; user messages + * use the visible text content. + */ +function getMessageMarkdown(messageDiv, role) { + if (role === 'assistant') { + // AI messages have a hidden textarea with the markdown content + const hiddenTextarea = messageDiv.querySelector('textarea[id^="copy-md-"]'); + if (hiddenTextarea) { + return hiddenTextarea.value; + } + } + // For user messages (or fallback), grab the text from the message bubble + const messageText = messageDiv.querySelector('.message-text'); + if (messageText) { + return messageText.innerText; + } + return ''; +} + +/** + * Get the sender label from a message div. + */ +function getMessageMeta(messageDiv, role) { + const senderEl = messageDiv.querySelector('.message-sender'); + const sender = senderEl ? senderEl.innerText.trim() : (role === 'assistant' ? 'Assistant' : 'User'); + + return { sender }; +} + +/** + * Trigger a browser file download from a Blob. + */ +function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** + * Build a formatted timestamp string for filenames. + */ +function filenameTimestamp() { + const now = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; +} + +/** + * Export a single message as a Markdown (.md) file download. + * This is entirely client-side — no backend call needed. + */ +export function exportMessageAsMarkdown(messageDiv, messageId, role) { + const content = getMessageMarkdown(messageDiv, role); + if (!content) { + showToast('No message content to export.', 'warning'); + return; + } + + const { sender } = getMessageMeta(messageDiv, role); + + const lines = []; + lines.push(`### ${sender}`); + lines.push(''); + lines.push(content); + lines.push(''); + + const markdown = lines.join('\n'); + const blob = new Blob([markdown], { type: 'text/markdown; charset=utf-8' }); + const filename = `message_export_${filenameTimestamp()}.md`; + downloadBlob(blob, filename); + showToast('Message exported as Markdown.', 'success'); +} + +/** + * Export a single message as a Word (.docx) file by calling the backend + * endpoint which uses python-docx to generate the document. + */ +export async function exportMessageAsWord(messageDiv, messageId, role) { + const conversationId = window.currentConversationId; + if (!conversationId || !messageId) { + showToast('Cannot export — no active conversation or message.', 'warning'); + return; + } + + try { + const response = await fetch('/api/message/export-word', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message_id: messageId, + conversation_id: conversationId + }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + const errorMsg = errorData?.error || `Export failed (${response.status})`; + showToast(errorMsg, 'danger'); + return; + } + + const blob = await response.blob(); + const filename = `message_export_${filenameTimestamp()}.docx`; + downloadBlob(blob, filename); + showToast('Message exported as Word document.', 'success'); + } catch (err) { + console.error('Error exporting message to Word:', err); + showToast('Failed to export message to Word.', 'danger'); + } +} + +/** + * Insert the message content as a formatted prompt directly into the chat + * input box so the user can review, edit, and send it. + * The raw message content is inserted unchanged for both user and AI messages. + */ +export function copyAsPrompt(messageDiv, messageId, role) { + const content = getMessageMarkdown(messageDiv, role); + if (!content) { + showToast('No message content to use.', 'warning'); + return; + } + + const userInput = document.getElementById('user-input'); + if (!userInput) { + showToast('Chat input not found.', 'warning'); + return; + } + + userInput.value = content; + userInput.focus(); + // Trigger input event so auto-resize and send button visibility update + userInput.dispatchEvent(new Event('input', { bubbles: true })); + showToast('Prompt inserted into chat input.', 'success'); +} + +/** + * Open the user's default email client with the message content + * pre-filled in the email body via a mailto: link. + */ +export function openInEmail(messageDiv, messageId, role) { + const content = getMessageMarkdown(messageDiv, role); + if (!content) { + showToast('No message content to email.', 'warning'); + return; + } + + const { sender } = getMessageMeta(messageDiv, role); + const subject = `Chat message from ${sender}`; + + // mailto: uses the body parameter for content + const mailtoUrl = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(content)}`; + window.open(mailtoUrl, '_blank'); +} diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index d4c54790..52b3642c 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -663,6 +663,11 @@ export function appendMessage(
  • Delete
  • Retry
  • ${feedbackHtml} +
  • +
  • Export to Markdown
  • +
  • Export to Word
  • +
  • Use as Prompt
  • +
  • Open in Email
  • `; @@ -851,6 +856,50 @@ export function appendMessage( handleRetryButtonClick(messageDiv, currentMessageId, 'assistant'); }); } + + const dropdownExportMdBtn = messageDiv.querySelector(".dropdown-export-md-btn"); + if (dropdownExportMdBtn) { + dropdownExportMdBtn.addEventListener("click", (e) => { + e.preventDefault(); + const currentMessageId = messageDiv.getAttribute('data-message-id'); + import('./chat-message-export.js').then(module => { + module.exportMessageAsMarkdown(messageDiv, currentMessageId, 'assistant'); + }).catch(err => console.error('Error loading message export module:', err)); + }); + } + + const dropdownExportWordBtn = messageDiv.querySelector(".dropdown-export-word-btn"); + if (dropdownExportWordBtn) { + dropdownExportWordBtn.addEventListener("click", (e) => { + e.preventDefault(); + const currentMessageId = messageDiv.getAttribute('data-message-id'); + import('./chat-message-export.js').then(module => { + module.exportMessageAsWord(messageDiv, currentMessageId, 'assistant'); + }).catch(err => console.error('Error loading message export module:', err)); + }); + } + + const dropdownCopyPromptBtn = messageDiv.querySelector(".dropdown-copy-prompt-btn"); + if (dropdownCopyPromptBtn) { + dropdownCopyPromptBtn.addEventListener("click", (e) => { + e.preventDefault(); + const currentMessageId = messageDiv.getAttribute('data-message-id'); + import('./chat-message-export.js').then(module => { + module.copyAsPrompt(messageDiv, currentMessageId, 'assistant'); + }).catch(err => console.error('Error loading message export module:', err)); + }); + } + + const dropdownOpenEmailBtn = messageDiv.querySelector(".dropdown-open-email-btn"); + if (dropdownOpenEmailBtn) { + dropdownOpenEmailBtn.addEventListener("click", (e) => { + e.preventDefault(); + const currentMessageId = messageDiv.getAttribute('data-message-id'); + import('./chat-message-export.js').then(module => { + module.openInEmail(messageDiv, currentMessageId, 'assistant'); + }).catch(err => console.error('Error loading message export module:', err)); + }); + } // Handle dropdown positioning manually - move to chatbox container const dropdownToggle = messageDiv.querySelector(".message-actions .dropdown button[data-bs-toggle='dropdown']"); @@ -1076,6 +1125,11 @@ export function appendMessage(
  • Edit
  • Delete
  • Retry
  • +
  • +
  • Export to Markdown
  • +
  • Export to Word
  • +
  • Use as Prompt
  • +
  • Open in Email