-
Notifications
You must be signed in to change notification settings - Fork 105
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
Merged
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
5004686
Add per-message export feature for Markdown and Word formats
eldongormsen bfea4a9
several imports are currently unused
eldong a216df2
'use strict'; isn’t needed in ES modules
eldong 2afc7e6
Initial plan
Copilot 104810c
Move docx import to top-level imports
Copilot a2a93dd
Merge pull request #784 from microsoft/copilot/sub-pr-783
eldong 1b5714a
Initial plan
Copilot 0c5bbfa
Initial plan
Copilot e6fe076
fix: update copyAsPrompt comment to match actual behavior
Copilot 06f55a0
Merge pull request #785 from microsoft/copilot/sub-pr-783
eldong 1a5890d
Add functional test script for per-message export (happy path + auth/…
Copilot 817aecd
Merge pull request #786 from microsoft/copilot/sub-pr-783-again
eldong 19304bb
Initial plan
Copilot f59fadf
fix: remove timestamp claims from per-message export feature
Copilot 01188ab
Merge pull request #787 from microsoft/copilot/sub-pr-783-another-one
eldong 0f7fd54
Initial plan
Copilot 8c0d943
Merge pull request #788 from microsoft/copilot/sub-pr-783-another-one
eldong 5652b53
Initial plan
Copilot 93eac3e
fix: return generic error message to client in export routes, keep st…
Copilot 94fffb1
Merge pull request #796 from microsoft/copilot/sub-pr-783-another-one
eldong File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
171 changes: 171 additions & 0 deletions
171
application/single_app/static/js/chat/chat-message-export.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 ''; | ||
| } | ||
paullizer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * 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'); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.