Skip to content

Commit 5b47122

Browse files
committed
Add export chat as Markdown feature
Adds a download button to the chat section toolbar and a command palette entry that exports the current chat conversation as a Markdown file. The export formats messages with sender names, timestamps, and proper Markdown structure. The file is downloaded directly to the user's machine. Changes: - Add `downloadChatAsMarkdown` and `formatChatAsMarkdown` utilities - Add export button to `ChatSection` toolbar (download icon) - Add `exportChat` command to the command palette - Export `downloadChatAsMarkdown` from `@jupyter/chat` public API Closes #328
1 parent f12702e commit 5b47122

5 files changed

Lines changed: 98 additions & 3 deletions

File tree

packages/jupyter-chat/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ export * from './registers';
1313
export * from './selection-watcher';
1414
export * from './tokens';
1515
export * from './types';
16+
export * from './utils';
1617
export * from './widgets';

packages/jupyter-chat/src/utils.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { FileEditor } from '@jupyterlab/fileeditor';
1010
import { Notebook } from '@jupyterlab/notebook';
1111
import { Widget } from '@lumino/widgets';
1212

13-
import { IUser } from './types';
13+
import { IMessage, IUser } from './types';
1414

1515
const MENTION_CLASS = 'jp-chat-mention';
1616

@@ -81,3 +81,56 @@ export function replaceSpanToMention(content: string, user: IUser): string {
8181
const regex = new RegExp(mentionEl, 'g');
8282
return content.replace(regex, mention);
8383
}
84+
85+
/**
86+
* Trigger a browser download of a Markdown-formatted chat export.
87+
*
88+
* @param chatName - Raw model name (e.g. "path/to/my.chat"). Used to derive the
89+
* filename and as the document title passed to formatChatAsMarkdown.
90+
* @param messages - Messages to export.
91+
*/
92+
export function downloadChatAsMarkdown(
93+
chatName: string,
94+
messages: ReadonlyArray<IMessage>
95+
): void {
96+
// Use || (not ??) so that empty string '' also falls back to 'chat'.
97+
const name = chatName || 'chat';
98+
const basename = name.split('/').pop()?.replace(/\.chat$/, '') || name;
99+
const markdown = formatChatAsMarkdown(basename, messages);
100+
const blob = new Blob([markdown], { type: 'text/markdown' });
101+
const url = URL.createObjectURL(blob);
102+
const a = document.createElement('a');
103+
a.href = url;
104+
a.download = `${basename}.md`;
105+
a.style.display = 'none';
106+
document.body.appendChild(a); // Firefox requires DOM attachment
107+
a.click();
108+
document.body.removeChild(a);
109+
setTimeout(() => URL.revokeObjectURL(url), 100); // delayed to avoid premature revoke
110+
}
111+
112+
/**
113+
* Format chat messages as a Markdown document for export.
114+
*
115+
* @param chatName - The display name of the chat, used as the document title.
116+
* @param messages - Messages to format. Deleted messages are skipped.
117+
* @returns A Markdown-formatted string ready for download.
118+
*/
119+
export function formatChatAsMarkdown(
120+
chatName: string,
121+
messages: ReadonlyArray<IMessage>
122+
): string {
123+
const lines: string[] = [`# ${chatName}`, ''];
124+
const visibleMessages = messages.filter(msg => !msg.deleted);
125+
if (visibleMessages.length === 0) {
126+
lines.push('*(No messages)*', '');
127+
return lines.join('\n');
128+
}
129+
for (const msg of visibleMessages) {
130+
const displayName = msg.sender.display_name || msg.sender.username || 'Unknown';
131+
const timestamp = new Date(msg.time * 1000).toLocaleString();
132+
const body = typeof msg.body === 'string' ? msg.body : '[Rich content]';
133+
lines.push(`**${displayName}** — ${timestamp}`, '', body, '', '---', '');
134+
}
135+
return lines.join('\n');
136+
}

packages/jupyter-chat/src/widgets/multichat-panel.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { nullTranslator, TranslationBundle } from '@jupyterlab/translation';
1313
import {
1414
addIcon,
1515
closeIcon,
16+
downloadIcon,
1617
launchIcon,
1718
PanelWithToolbar,
1819
ReactWidget,
@@ -36,6 +37,7 @@ import {
3637
import { TRANSLATION_DOMAIN } from '../context';
3738
import { chatIcon, readIcon } from '../icons';
3839
import { IChatModel } from '../model';
40+
import { downloadChatAsMarkdown } from '../utils';
3941

4042
const SIDEPANEL_CLASS = 'jp-chat-sidepanel';
4143
const ADD_BUTTON_CLASS = 'jp-chat-add';
@@ -534,6 +536,19 @@ class SidePanelWidget extends PanelWithToolbar {
534536
this.toolbar.addItem('rename', renameButton);
535537
}
536538

539+
const exportButton = new ToolbarButton({
540+
icon: downloadIcon,
541+
iconLabel: 'Export chat as Markdown',
542+
className: 'jp-mod-styled',
543+
onClick: () => {
544+
try {
545+
downloadChatAsMarkdown(this.model.name, this.model.messages);
546+
} catch (err) {
547+
console.error('Failed to export chat:', err);
548+
}
549+
}
550+
});
551+
this.toolbar.addItem('export', exportButton);
537552
if (options.openInMain) {
538553
const moveToMain = new ToolbarButton({
539554
icon: launchIcon,

packages/jupyterlab-chat-extension/src/index.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
5252
import { Contents } from '@jupyterlab/services';
5353
import { ISettingRegistry } from '@jupyterlab/settingregistry';
5454
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
55-
import { launchIcon } from '@jupyterlab/ui-components';
55+
import { downloadIcon, launchIcon } from '@jupyterlab/ui-components';
5656
import { PromiseDelegate } from '@lumino/coreutils';
5757
import {
5858
ChatWidgetFactory,
@@ -70,6 +70,7 @@ import {
7070
chatFileType,
7171
getDisplayName
7272
} from 'jupyterlab-chat';
73+
import { downloadChatAsMarkdown } from '@jupyter/chat';
7374
import { chatCommandRegistryPlugin } from './chat-commands/plugins';
7475
import { emojiCommandsPlugin } from './chat-commands/providers/emoji';
7576
import { mentionCommandsPlugin } from './chat-commands/providers/user-mention';
@@ -835,6 +836,23 @@ const chatCommands: JupyterFrontEndPlugin<void> = {
835836
}
836837
});
837838

839+
// Command to export the current chat as a Markdown file.
840+
commands.addCommand(CommandIDs.exportChat, {
841+
label: 'Export chat as Markdown',
842+
icon: downloadIcon,
843+
isEnabled: () => tracker.currentWidget !== null,
844+
execute: () => {
845+
const widget = tracker.currentWidget;
846+
if (!widget) {
847+
return;
848+
}
849+
try {
850+
downloadChatAsMarkdown(widget.model.name, widget.model.messages);
851+
} catch (err) {
852+
console.error('Failed to export chat:', err);
853+
}
854+
}
855+
});
838856
// The command to focus the input of the current chat widget.
839857
commands.addCommand(CommandIDs.focusInput, {
840858
caption: trans.__('Focus the input of the current chat widget'),
@@ -866,6 +884,10 @@ const chatCommands: JupyterFrontEndPlugin<void> = {
866884
category: trans.__('Chat'),
867885
command: CommandIDs.renameChat
868886
});
887+
commandPalette.addItem({
888+
category: 'Chat',
889+
command: CommandIDs.exportChat
890+
});
869891
}
870892

871893
// Add the create command to the launcher

packages/jupyterlab-chat/src/token.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,11 @@ export const CommandIDs = {
108108
/**
109109
* Open a chat and send a message into it.
110110
*/
111-
openWithMessage: 'jupyterlab-chat:openWithMessage'
111+
openWithMessage: 'jupyterlab-chat:openWithMessage',
112+
/**
113+
* Export the current chat as a Markdown file.
114+
*/
115+
exportChat: 'jupyterlab-chat:exportChat'
112116
};
113117

114118
/**

0 commit comments

Comments
 (0)