-
Notifications
You must be signed in to change notification settings - Fork 103
Add per-message export feature for Markdown and Word formats #783
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: Development
Are you sure you want to change the base?
Changes from all commits
5004686
bfea4a9
a216df2
2afc7e6
104810c
a2a93dd
1b5714a
0c5bbfa
e6fe076
06f55a0
1a5890d
817aecd
19304bb
f59fadf
01188ab
0f7fd54
8c0d943
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
| """ | ||
eldong marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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
|
||
|
|
||
| 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) | ||
| 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
|
||
|
|
||
| /** | ||
| * 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. | ||
| */ | ||
eldong marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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'); | ||
| } | ||
There was a problem hiding this comment.
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 existingfunctional_tests/test_conversation_export.pycoverage. Please add a functional test (e.g.,functional_tests/test_message_export.py) that validates permission checks (403), not-found cases (404), and successful.docxgeneration for a simple message.