Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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.010"

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

Expand Down
193 changes: 193 additions & 0 deletions 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 @@ -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():
"""
Comment on lines +295 to +300
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a new user-facing export endpoint (/api/message/export-word) and Markdown-to-DOCX conversion logic, but there’s no corresponding functional test alongside the existing functional_tests/test_conversation_export.py coverage. Please add a functional test (e.g., functional_tests/test_message_export.py) that validates permission checks (403), not-found cases (404), and successful .docx generation for a simple message.

Copilot generated this review using guidance from repository custom instructions.
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
Comment on lines +399 to +401
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error response includes the raw exception string (Export failed: {str(e)}), which can leak internal details to the client. Consider logging the exception server-side and returning a generic message (optionally with a correlation id) to the caller.

Copilot uses AI. Check for mistakes.
Comment on lines +399 to +401
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 500 error response includes the raw exception text (Export failed: {str(e)}), which can leak internal details to the browser. Log the exception server-side and return a generic error message to the client instead.

Copilot uses AI. Check for mistakes.
Comment on lines +399 to +401
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 500 handler returns the raw exception string to the client (Export failed: {str(e)}), which can leak internal details (Cosmos errors, stack context, etc.). Consider logging the exception server-side and returning a generic error message to the browser while keeping the status code 500.

Copilot uses AI. Check for mistakes.

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 '';
}
Comment on lines +16 to +30
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new module uses 4-space indentation, but the surrounding JS code in this repo (e.g., chat-toast.js and chat-messages.js) consistently uses 2 spaces. For consistency/readability, please reformat this file to match the existing indentation style used in the static/js codebase.

Copilot uses AI. Check for mistakes.

/**
* 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