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