Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.239.005"
VERSION = "0.239.011"

SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')

Expand Down
195 changes: 194 additions & 1 deletion application/single_app/route_backend_conversation_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -106,7 +107,7 @@ def api_export_conversations():

except Exception as e:
debug_print(f"Export error: {str(e)}")
return jsonify({'error': f'Export failed: {str(e)}'}), 500
return jsonify({'error': 'Export failed due to a server error. Please try again later.'}), 500

def _sanitize_conversation(conv):
"""Return only user-facing conversation fields."""
Expand Down Expand Up @@ -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': 'Export failed due to a server error. Please try again later.'}), 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)
171 changes: 171 additions & 0 deletions application/single_app/static/js/chat/chat-message-export.js
Original file line number Diff line number Diff line change
@@ -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');
}
Loading
Loading