diff --git a/application/single_app/app.py b/application/single_app/app.py
index cd04ff67..2354b1b5 100644
--- a/application/single_app/app.py
+++ b/application/single_app/app.py
@@ -74,6 +74,7 @@
from route_backend_public_documents import *
from route_backend_public_prompts import *
from route_backend_user_agreement import register_route_backend_user_agreement
+from route_backend_conversation_export import register_route_backend_conversation_export
from route_backend_speech import register_route_backend_speech
from route_backend_tts import register_route_backend_tts
from route_enhanced_citations import register_enhanced_citations_routes
@@ -628,6 +629,9 @@ def list_semantic_kernel_plugins():
# ------------------- API Public Workspaces Routes -------
register_route_backend_public_workspaces(app)
+# ------------------- API Conversation Export Routes -----
+register_route_backend_conversation_export(app)
+
# ------------------- API Public Documents Routes --------
register_route_backend_public_documents(app)
diff --git a/application/single_app/config.py b/application/single_app/config.py
index 6d548c0a..c68a32f2 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -15,6 +15,12 @@
import fitz # PyMuPDF
import math
import mimetypes
+# Register font MIME types so Flask serves them correctly (required for
+# X-Content-Type-Options: nosniff to not block Bootstrap Icons)
+mimetypes.add_type('font/woff', '.woff')
+mimetypes.add_type('font/woff2', '.woff2')
+mimetypes.add_type('font/ttf', '.ttf')
+mimetypes.add_type('font/otf', '.otf')
import openpyxl
import xlrd
import traceback
diff --git a/application/single_app/route_backend_conversation_export.py b/application/single_app/route_backend_conversation_export.py
new file mode 100644
index 00000000..aad750e4
--- /dev/null
+++ b/application/single_app/route_backend_conversation_export.py
@@ -0,0 +1,288 @@
+# route_backend_conversation_export.py
+
+import io
+import json
+import zipfile
+from datetime import datetime
+
+from config import *
+from functions_authentication import *
+from functions_settings import *
+from flask import Response, jsonify, request, make_response
+from functions_debug import debug_print
+from swagger_wrapper import swagger_route, get_auth_security
+
+
+def register_route_backend_conversation_export(app):
+ """Register conversation export API routes."""
+
+ @app.route('/api/conversations/export', methods=['POST'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @user_required
+ def api_export_conversations():
+ """
+ Export one or more conversations in JSON or Markdown format.
+ Supports single-file or ZIP packaging.
+
+ Request body:
+ conversation_ids (list): List of conversation IDs to export.
+ format (str): Export format — "json" or "markdown".
+ packaging (str): Output packaging — "single" or "zip".
+ """
+ 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
+
+ conversation_ids = data.get('conversation_ids', [])
+ export_format = data.get('format', 'json').lower()
+ packaging = data.get('packaging', 'single').lower()
+
+ if not conversation_ids or not isinstance(conversation_ids, list):
+ return jsonify({'error': 'At least one conversation_id is required'}), 400
+
+ if export_format not in ('json', 'markdown'):
+ return jsonify({'error': 'Format must be "json" or "markdown"'}), 400
+
+ if packaging not in ('single', 'zip'):
+ return jsonify({'error': 'Packaging must be "single" or "zip"'}), 400
+
+ try:
+ exported = []
+ for conv_id in conversation_ids:
+ # Verify ownership and fetch conversation
+ try:
+ conversation = cosmos_conversations_container.read_item(
+ item=conv_id,
+ partition_key=conv_id
+ )
+ except Exception:
+ debug_print(f"Export: conversation {conv_id} not found or access denied")
+ continue
+
+ # Verify user owns this conversation
+ if conversation.get('user_id') != user_id:
+ debug_print(f"Export: user {user_id} does not own conversation {conv_id}")
+ continue
+
+ # Fetch messages ordered by timestamp
+ message_query = f"""
+ SELECT * FROM c
+ WHERE c.conversation_id = '{conv_id}'
+ ORDER BY c.timestamp ASC
+ """
+ messages = list(cosmos_messages_container.query_items(
+ query=message_query,
+ partition_key=conv_id
+ ))
+
+ # Filter for active thread messages only
+ filtered_messages = []
+ for msg in messages:
+ thread_info = msg.get('metadata', {}).get('thread_info', {})
+ active = thread_info.get('active_thread')
+ if active is True or active is None or 'active_thread' not in thread_info:
+ filtered_messages.append(msg)
+
+ exported.append({
+ 'conversation': _sanitize_conversation(conversation),
+ 'messages': [_sanitize_message(m) for m in filtered_messages]
+ })
+
+ if not exported:
+ return jsonify({'error': 'No accessible conversations found'}), 404
+
+ # Generate export content
+ timestamp_str = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
+
+ if packaging == 'zip':
+ return _build_zip_response(exported, export_format, timestamp_str)
+ else:
+ return _build_single_file_response(exported, export_format, timestamp_str)
+
+ except Exception as e:
+ debug_print(f"Export error: {str(e)}")
+ return jsonify({'error': f'Export failed: {str(e)}'}), 500
+
+ def _sanitize_conversation(conv):
+ """Return only user-facing conversation fields."""
+ return {
+ 'id': conv.get('id'),
+ 'title': conv.get('title', 'Untitled'),
+ 'last_updated': conv.get('last_updated', ''),
+ 'chat_type': conv.get('chat_type', 'personal'),
+ 'tags': conv.get('tags', []),
+ 'is_pinned': conv.get('is_pinned', False),
+ 'context': conv.get('context', [])
+ }
+
+ def _sanitize_message(msg):
+ """Return only user-facing message fields."""
+ result = {
+ 'role': msg.get('role', ''),
+ 'content': msg.get('content', ''),
+ 'timestamp': msg.get('timestamp', ''),
+ }
+ # Include citations if present
+ if msg.get('citations'):
+ result['citations'] = msg['citations']
+ # Include context/tool info if present
+ if msg.get('context'):
+ result['context'] = msg['context']
+ return result
+
+ def _build_single_file_response(exported, export_format, timestamp_str):
+ """Build a single-file download response."""
+ if export_format == 'json':
+ content = json.dumps(exported, indent=2, ensure_ascii=False, default=str)
+ filename = f"conversations_export_{timestamp_str}.json"
+ content_type = 'application/json; charset=utf-8'
+ else:
+ parts = []
+ for entry in exported:
+ parts.append(_conversation_to_markdown(entry))
+ content = '\n\n---\n\n'.join(parts)
+ filename = f"conversations_export_{timestamp_str}.md"
+ content_type = 'text/markdown; charset=utf-8'
+
+ response = make_response(content)
+ response.headers['Content-Type'] = content_type
+ response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
+ return response
+
+ def _build_zip_response(exported, export_format, timestamp_str):
+ """Build a ZIP archive containing one file per conversation."""
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
+ for entry in exported:
+ conv = entry['conversation']
+ safe_title = _safe_filename(conv.get('title', 'Untitled'))
+ conv_id_short = conv.get('id', 'unknown')[:8]
+
+ if export_format == 'json':
+ file_content = json.dumps(entry, indent=2, ensure_ascii=False, default=str)
+ ext = 'json'
+ else:
+ file_content = _conversation_to_markdown(entry)
+ ext = 'md'
+
+ file_name = f"{safe_title}_{conv_id_short}.{ext}"
+ zf.writestr(file_name, file_content)
+
+ buffer.seek(0)
+ filename = f"conversations_export_{timestamp_str}.zip"
+
+ response = make_response(buffer.read())
+ response.headers['Content-Type'] = 'application/zip'
+ response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
+ return response
+
+ def _conversation_to_markdown(entry):
+ """Convert a conversation + messages entry to Markdown format."""
+ conv = entry['conversation']
+ messages = entry['messages']
+
+ lines = []
+ title = conv.get('title', 'Untitled')
+ lines.append(f"# {title}")
+ lines.append('')
+
+ # Metadata
+ last_updated = conv.get('last_updated', '')
+ chat_type = conv.get('chat_type', 'personal')
+ tags = conv.get('tags', [])
+
+ lines.append(f"**Last Updated:** {last_updated} ")
+ lines.append(f"**Chat Type:** {chat_type} ")
+ if tags:
+ tag_strs = [str(t) for t in tags]
+ lines.append(f"**Tags:** {', '.join(tag_strs)} ")
+ lines.append(f"**Messages:** {len(messages)} ")
+ lines.append('')
+ lines.append('---')
+ lines.append('')
+
+ # Messages
+ for msg in messages:
+ role = msg.get('role', 'unknown')
+ timestamp = msg.get('timestamp', '')
+ raw_content = msg.get('content', '')
+ content = _normalize_content(raw_content)
+
+ role_label = role.capitalize()
+ if role == 'assistant':
+ role_label = 'Assistant'
+ elif role == 'user':
+ role_label = 'User'
+ elif role == 'system':
+ role_label = 'System'
+ elif role == 'tool':
+ role_label = 'Tool'
+
+ lines.append(f"### {role_label}")
+ if timestamp:
+ lines.append(f"*{timestamp}*")
+ lines.append('')
+ lines.append(content)
+ lines.append('')
+
+ # Citations
+ citations = msg.get('citations')
+ if citations:
+ lines.append('**Citations:**')
+ if isinstance(citations, list):
+ for cit in citations:
+ if isinstance(cit, dict):
+ source = cit.get('title') or cit.get('filepath') or cit.get('url', 'Unknown')
+ lines.append(f"- {source}")
+ else:
+ lines.append(f"- {cit}")
+ lines.append('')
+
+ lines.append('---')
+ lines.append('')
+
+ return '\n'.join(lines)
+
+ def _normalize_content(content):
+ """Normalize message content to a plain string.
+
+ Content may be a string, a list of content-part dicts
+ (e.g. [{"type": "text", "text": "..."}, ...]), or a dict.
+ """
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ parts = []
+ for item in content:
+ if isinstance(item, dict):
+ if item.get('type') == 'text':
+ parts.append(item.get('text', ''))
+ elif item.get('type') == 'image_url':
+ parts.append('[Image]')
+ else:
+ parts.append(str(item))
+ else:
+ parts.append(str(item))
+ return '\n'.join(parts)
+ if isinstance(content, dict):
+ if content.get('type') == 'text':
+ return content.get('text', '')
+ return str(content)
+ return str(content) if content else ''
+
+ def _safe_filename(title):
+ """Create a filesystem-safe filename from a conversation title."""
+ import re
+ # Remove or replace unsafe characters
+ safe = re.sub(r'[<>:"/\\|?*]', '_', title)
+ safe = re.sub(r'\s+', '_', safe)
+ safe = safe.strip('_. ')
+ # Truncate to reasonable length
+ if len(safe) > 50:
+ safe = safe[:50]
+ return safe or 'Untitled'
diff --git a/application/single_app/static/css/sidebar.css b/application/single_app/static/css/sidebar.css
index 999b44c7..ebc40910 100644
--- a/application/single_app/static/css/sidebar.css
+++ b/application/single_app/static/css/sidebar.css
@@ -304,6 +304,22 @@ body.sidebar-nav-enabled.has-classification-banner .container-fluid {
#conversations-actions {
opacity: 1;
transition: opacity 0.2s ease;
+ flex-shrink: 0;
+ gap: 2px;
+}
+
+/* Compact action buttons in selection mode */
+#conversations-actions .btn {
+ padding: 2px 4px !important;
+ font-size: 0.7rem !important;
+ margin-right: 0 !important;
+ line-height: 1;
+}
+
+/* Reduce toggle row padding when selection actions are visible */
+#conversations-toggle.selection-active {
+ padding-left: 0.5rem !important;
+ padding-right: 0.25rem !important;
}
#sidebar-delete-selected-btn {
diff --git a/application/single_app/static/images/custom_logo.png b/application/single_app/static/images/custom_logo.png
deleted file mode 100644
index ecf6e652..00000000
Binary files a/application/single_app/static/images/custom_logo.png and /dev/null differ
diff --git a/application/single_app/static/images/custom_logo_dark.png b/application/single_app/static/images/custom_logo_dark.png
deleted file mode 100644
index 4f281945..00000000
Binary files a/application/single_app/static/images/custom_logo_dark.png and /dev/null differ
diff --git a/application/single_app/static/js/chat/chat-conversations.js b/application/single_app/static/js/chat/chat-conversations.js
index 221b5aa5..c4b7345b 100644
--- a/application/single_app/static/js/chat/chat-conversations.js
+++ b/application/single_app/static/js/chat/chat-conversations.js
@@ -11,6 +11,7 @@ const newConversationBtn = document.getElementById("new-conversation-btn");
const deleteSelectedBtn = document.getElementById("delete-selected-btn");
const pinSelectedBtn = document.getElementById("pin-selected-btn");
const hideSelectedBtn = document.getElementById("hide-selected-btn");
+const exportSelectedBtn = document.getElementById("export-selected-btn");
const conversationsList = document.getElementById("conversations-list");
const currentConversationTitleEl = document.getElementById("current-conversation-title");
const currentConversationClassificationsEl = document.getElementById("current-conversation-classifications");
@@ -96,6 +97,9 @@ function enterSelectionMode() {
if (hideSelectedBtn) {
hideSelectedBtn.style.display = "block";
}
+ if (exportSelectedBtn) {
+ exportSelectedBtn.style.display = "block";
+ }
// Only reload conversations if we're transitioning from inactive to active
// This shows hidden conversations in selection mode
@@ -129,6 +133,9 @@ function exitSelectionMode() {
if (hideSelectedBtn) {
hideSelectedBtn.style.display = "none";
}
+ if (exportSelectedBtn) {
+ exportSelectedBtn.style.display = "none";
+ }
// Clear any selections
selectedConversations.clear();
@@ -503,6 +510,14 @@ export function createConversationItem(convo) {
selectA.href = "#";
selectA.innerHTML = 'Select';
selectLi.appendChild(selectA);
+
+ // Add Export option
+ const exportLi = document.createElement("li");
+ const exportA = document.createElement("a");
+ exportA.classList.add("dropdown-item", "export-btn");
+ exportA.href = "#";
+ exportA.innerHTML = 'Export';
+ exportLi.appendChild(exportA);
const editLi = document.createElement("li");
const editA = document.createElement("a");
@@ -522,6 +537,8 @@ export function createConversationItem(convo) {
dropdownMenu.appendChild(pinLi);
dropdownMenu.appendChild(hideLi);
dropdownMenu.appendChild(selectLi);
+ dropdownMenu.appendChild(exportLi);
+
dropdownMenu.appendChild(editLi);
dropdownMenu.appendChild(deleteLi);
rightDiv.appendChild(dropdownBtn);
@@ -571,6 +588,16 @@ export function createConversationItem(convo) {
enterSelectionMode();
});
+ // Add event listener for the Export button
+ exportA.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ closeDropdownMenu(dropdownBtn);
+ if (window.chatExport && window.chatExport.openExportWizard) {
+ window.chatExport.openExportWizard([convo.id], true);
+ }
+ });
+
// Add event listener for the Pin button
pinA.addEventListener("click", (event) => {
event.preventDefault();
@@ -1423,6 +1450,17 @@ if (hideSelectedBtn) {
hideSelectedBtn.addEventListener("click", bulkHideConversations);
}
+if (exportSelectedBtn) {
+ exportSelectedBtn.addEventListener("click", () => {
+ if (window.chatExport && window.chatExport.openExportWizard) {
+ const selectedIds = Array.from(selectedConversations);
+ if (selectedIds.length > 0) {
+ window.chatExport.openExportWizard(selectedIds, false);
+ }
+ }
+ });
+}
+
// Helper function to set show hidden conversations state and return a promise
export function setShowHiddenConversations(value) {
showHiddenConversations = value;
diff --git a/application/single_app/static/js/chat/chat-export.js b/application/single_app/static/js/chat/chat-export.js
new file mode 100644
index 00000000..269cbfe0
--- /dev/null
+++ b/application/single_app/static/js/chat/chat-export.js
@@ -0,0 +1,517 @@
+// chat-export.js
+import { showToast } from "./chat-toast.js";
+
+'use strict';
+
+/**
+ * Conversation Export Wizard Module
+ *
+ * Provides a multi-step modal wizard for exporting conversations
+ * in JSON or Markdown format with single-file or ZIP packaging.
+ */
+
+// --- Wizard State ---
+let exportConversationIds = [];
+let exportConversationTitles = {};
+let exportFormat = 'json';
+let exportPackaging = 'single';
+let currentStep = 1;
+let totalSteps = 3;
+let skipSelectionStep = false;
+
+// Modal reference
+let exportModal = null;
+
+// --- DOM Helpers ---
+function getEl(id) {
+ return document.getElementById(id);
+}
+
+// --- Initialize ---
+document.addEventListener('DOMContentLoaded', () => {
+ const modalEl = getEl('export-wizard-modal');
+ if (modalEl) {
+ exportModal = new bootstrap.Modal(modalEl);
+ }
+});
+
+// --- Public Entry Point ---
+
+/**
+ * Open the export wizard.
+ * @param {string[]} conversationIds - Array of conversation IDs to export.
+ * @param {boolean} skipSelection - If true, skip step 1 (review) and start at format choice.
+ */
+function openExportWizard(conversationIds, skipSelection) {
+ if (!conversationIds || conversationIds.length === 0) {
+ showToast('No conversations selected for export.', 'warning');
+ return;
+ }
+
+ // Reset state
+ exportConversationIds = [...conversationIds];
+ exportConversationTitles = {};
+ exportFormat = 'json';
+ exportPackaging = conversationIds.length > 1 ? 'zip' : 'single';
+ skipSelectionStep = !!skipSelection;
+
+ // Determine step configuration
+ if (skipSelectionStep) {
+ totalSteps = 3;
+ currentStep = 1; // Format step (mapped to visual step)
+ } else {
+ totalSteps = 4;
+ currentStep = 1; // Selection review step
+ }
+
+ // Initialize the modal if not already
+ if (!exportModal) {
+ const modalEl = getEl('export-wizard-modal');
+ if (modalEl) {
+ exportModal = new bootstrap.Modal(modalEl);
+ }
+ }
+
+ if (!exportModal) {
+ showToast('Export wizard not available.', 'danger');
+ return;
+ }
+
+ // Load conversation titles, then show the modal
+ _loadConversationTitles().then(() => {
+ _renderCurrentStep();
+ _updateStepIndicators();
+ _updateNavigationButtons();
+ exportModal.show();
+ });
+}
+
+// --- Step Navigation ---
+
+function nextStep() {
+ if (currentStep < totalSteps) {
+ currentStep++;
+ _renderCurrentStep();
+ _updateStepIndicators();
+ _updateNavigationButtons();
+ }
+}
+
+function prevStep() {
+ if (currentStep > 1) {
+ currentStep--;
+ _renderCurrentStep();
+ _updateStepIndicators();
+ _updateNavigationButtons();
+ }
+}
+
+// --- Data Loading ---
+
+async function _loadConversationTitles() {
+ try {
+ const response = await fetch('/api/get_conversations');
+ if (!response.ok) throw new Error('Failed to fetch conversations');
+ const data = await response.json();
+ const conversations = data.conversations || [];
+ exportConversationTitles = {};
+ conversations.forEach(c => {
+ if (exportConversationIds.includes(c.id)) {
+ exportConversationTitles[c.id] = c.title || 'Untitled';
+ }
+ });
+ // Fill in any missing titles
+ exportConversationIds.forEach(id => {
+ if (!exportConversationTitles[id]) {
+ exportConversationTitles[id] = 'Untitled Conversation';
+ }
+ });
+ } catch (err) {
+ console.error('Error loading conversation titles for export:', err);
+ // Use placeholder titles
+ exportConversationIds.forEach(id => {
+ exportConversationTitles[id] = exportConversationTitles[id] || 'Conversation';
+ });
+ }
+}
+
+// --- Step Rendering ---
+
+function _renderCurrentStep() {
+ const stepBody = getEl('export-wizard-body');
+ if (!stepBody) return;
+
+ if (skipSelectionStep) {
+ // Steps: 1=Format, 2=Packaging, 3=Download
+ switch (currentStep) {
+ case 1: _renderFormatStep(stepBody); break;
+ case 2: _renderPackagingStep(stepBody); break;
+ case 3: _renderDownloadStep(stepBody); break;
+ }
+ } else {
+ // Steps: 1=Selection, 2=Format, 3=Packaging, 4=Download
+ switch (currentStep) {
+ case 1: _renderSelectionStep(stepBody); break;
+ case 2: _renderFormatStep(stepBody); break;
+ case 3: _renderPackagingStep(stepBody); break;
+ case 4: _renderDownloadStep(stepBody); break;
+ }
+ }
+}
+
+function _renderSelectionStep(container) {
+ const count = exportConversationIds.length;
+ let listHtml = '';
+ exportConversationIds.forEach(id => {
+ const title = _escapeHtml(exportConversationTitles[id] || 'Untitled');
+ listHtml += `
+
`;
+ });
+
+ container.innerHTML = `
+
+
Review Conversations
+
You have ${count} conversation${count !== 1 ? 's' : ''} selected for export. Remove any you don't want to include.
+
+
+ ${listHtml || '
No conversations selected
'}
+
`;
+
+ // Wire remove buttons
+ container.querySelectorAll('.export-remove-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.preventDefault();
+ const removeId = btn.dataset.id;
+ exportConversationIds = exportConversationIds.filter(id => id !== removeId);
+ delete exportConversationTitles[removeId];
+ if (exportConversationIds.length === 0) {
+ showToast('All conversations removed. Closing export wizard.', 'warning');
+ exportModal.hide();
+ return;
+ }
+ _renderSelectionStep(container);
+ _updateNavigationButtons();
+ });
+ });
+}
+
+function _renderFormatStep(container) {
+ container.innerHTML = `
+
+
Choose Export Format
+
Select the format for your exported conversations.
+
+
+
+
+
+
+
JSON
+
Structured data format. Ideal for programmatic analysis or re-import.
+
+
+
+
+
+
+
+
Markdown
+
Human-readable format. Great for documentation and sharing.
+
+
+
+
`;
+
+ // Wire card clicks
+ container.querySelectorAll('.action-type-card[data-format]').forEach(card => {
+ card.addEventListener('click', () => {
+ exportFormat = card.dataset.format;
+ container.querySelectorAll('.action-type-card[data-format]').forEach(c => c.classList.remove('selected'));
+ card.classList.add('selected');
+ });
+ card.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ card.click();
+ }
+ });
+ });
+}
+
+function _renderPackagingStep(container) {
+ const count = exportConversationIds.length;
+ const singleDesc = count > 1
+ ? 'All conversations combined into one file.'
+ : 'Export as a single file.';
+ const zipDesc = count > 1
+ ? 'Each conversation in a separate file, bundled in a ZIP archive.'
+ : 'Single conversation wrapped in a ZIP archive.';
+
+ container.innerHTML = `
+
+
Choose Output Packaging
+
Select how the exported file(s) should be packaged.
+
+
+
+
+
+
+
Single File
+
${singleDesc}
+
+
+
+
+
+
+
+
ZIP Archive
+
${zipDesc}
+
+
+
+
`;
+
+ // Wire card clicks
+ container.querySelectorAll('.action-type-card[data-packaging]').forEach(card => {
+ card.addEventListener('click', () => {
+ exportPackaging = card.dataset.packaging;
+ container.querySelectorAll('.action-type-card[data-packaging]').forEach(c => c.classList.remove('selected'));
+ card.classList.add('selected');
+ });
+ card.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ card.click();
+ }
+ });
+ });
+}
+
+function _renderDownloadStep(container) {
+ const count = exportConversationIds.length;
+ const formatLabel = exportFormat === 'json' ? 'JSON' : 'Markdown';
+ const packagingLabel = exportPackaging === 'zip' ? 'ZIP Archive' : 'Single File';
+ const ext = exportPackaging === 'zip' ? '.zip' : (exportFormat === 'json' ? '.json' : '.md');
+
+ let conversationsList = '';
+ exportConversationIds.forEach(id => {
+ const title = _escapeHtml(exportConversationTitles[id] || 'Untitled');
+ conversationsList += `${title}`;
+ });
+
+ container.innerHTML = `
+
+
Ready to Export
+
Review your export settings and click Download.
+
+
+
+
+
Conversations:
+
${count} conversation${count !== 1 ? 's' : ''}
+
+
+
Format:
+
${formatLabel}
+
+
+
Packaging:
+
${packagingLabel}
+
+
+
+
+
+
+
+
+ `;
+
+ // Wire download button
+ const downloadBtn = getEl('export-download-btn');
+ if (downloadBtn) {
+ downloadBtn.addEventListener('click', _executeExport);
+ }
+}
+
+// --- Step Indicator & Navigation ---
+
+function _updateStepIndicators() {
+ const stepsContainer = getEl('export-steps-container');
+ if (!stepsContainer) return;
+
+ let steps;
+ if (skipSelectionStep) {
+ steps = [
+ { label: 'Format', icon: 'bi-filetype-json' },
+ { label: 'Packaging', icon: 'bi-box' },
+ { label: 'Download', icon: 'bi-download' }
+ ];
+ } else {
+ steps = [
+ { label: 'Select', icon: 'bi-list-check' },
+ { label: 'Format', icon: 'bi-filetype-json' },
+ { label: 'Packaging', icon: 'bi-box' },
+ { label: 'Download', icon: 'bi-download' }
+ ];
+ }
+
+ let html = '';
+ steps.forEach((step, index) => {
+ const stepNum = index + 1;
+ let circleClass = 'step-circle';
+ let indicatorClass = 'step-indicator';
+ if (stepNum < currentStep) {
+ circleClass += ' completed';
+ indicatorClass += ' completed';
+ } else if (stepNum === currentStep) {
+ circleClass += ' active';
+ indicatorClass += ' active';
+ }
+
+ // Add connector line between steps
+ const connector = index < steps.length - 1
+ ? ''
+ : '';
+
+ html += `
+
+
${stepNum < currentStep ? '' : stepNum}
+
${step.label}
+ ${connector}
+
`;
+ });
+
+ stepsContainer.innerHTML = html;
+}
+
+function _updateNavigationButtons() {
+ const prevBtn = getEl('export-prev-btn');
+ const nextBtn = getEl('export-next-btn');
+
+ if (prevBtn) {
+ prevBtn.style.display = currentStep > 1 ? 'inline-block' : 'none';
+ prevBtn.onclick = prevStep;
+ }
+
+ if (nextBtn) {
+ const isLastStep = currentStep === totalSteps;
+ nextBtn.style.display = isLastStep ? 'none' : 'inline-block';
+ nextBtn.onclick = nextStep;
+
+ // Validate selection step — need at least 1 conversation
+ if (!skipSelectionStep && currentStep === 1 && exportConversationIds.length === 0) {
+ nextBtn.disabled = true;
+ } else {
+ nextBtn.disabled = false;
+ }
+ }
+}
+
+// --- Export Execution ---
+
+async function _executeExport() {
+ const downloadBtn = getEl('export-download-btn');
+ const statusDiv = getEl('export-download-status');
+
+ if (downloadBtn) {
+ downloadBtn.disabled = true;
+ downloadBtn.innerHTML = 'Generating export...';
+ }
+ if (statusDiv) {
+ statusDiv.innerHTML = 'This may take a moment for large conversations...';
+ }
+
+ try {
+ const response = await fetch('/api/conversations/export', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ conversation_ids: exportConversationIds,
+ format: exportFormat,
+ packaging: exportPackaging
+ })
+ });
+
+ if (!response.ok) {
+ const errData = await response.json().catch(() => ({}));
+ throw new Error(errData.error || `Server responded with status ${response.status}`);
+ }
+
+ // Get filename from Content-Disposition header
+ const disposition = response.headers.get('Content-Disposition') || '';
+ const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
+ const filename = filenameMatch ? filenameMatch[1] : `conversations_export.${exportPackaging === 'zip' ? 'zip' : (exportFormat === 'json' ? 'json' : 'md')}`;
+
+ // Download the blob
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ if (downloadBtn) {
+ downloadBtn.disabled = false;
+ downloadBtn.innerHTML = 'Downloaded!';
+ downloadBtn.classList.remove('btn-primary');
+ downloadBtn.classList.add('btn-success');
+ }
+ if (statusDiv) {
+ statusDiv.innerHTML = 'Export downloaded successfully.';
+ }
+
+ showToast('Conversations exported successfully.', 'success');
+
+ // Auto-close modal after a short delay
+ setTimeout(() => {
+ if (exportModal) exportModal.hide();
+ }, 1500);
+
+ } catch (err) {
+ console.error('Export error:', err);
+ if (downloadBtn) {
+ downloadBtn.disabled = false;
+ downloadBtn.innerHTML = 'Retry Download';
+ }
+ if (statusDiv) {
+ statusDiv.innerHTML = `Error: ${_escapeHtml(err.message)}`;
+ }
+ showToast(`Export failed: ${err.message}`, 'danger');
+ }
+}
+
+// --- Utility ---
+
+function _escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+// --- Expose Globally ---
+window.chatExport = {
+ openExportWizard
+};
diff --git a/application/single_app/static/js/chat/chat-sidebar-conversations.js b/application/single_app/static/js/chat/chat-sidebar-conversations.js
index c8bd3729..4e89144f 100644
--- a/application/single_app/static/js/chat/chat-sidebar-conversations.js
+++ b/application/single_app/static/js/chat/chat-sidebar-conversations.js
@@ -155,6 +155,7 @@ function createSidebarConversationItem(convo) {
${isPinned ? 'Unpin' : 'Pin'}
${isHidden ? 'Unhide' : 'Hide'}
Select
+ Export
Edit title
Delete
@@ -285,6 +286,7 @@ function createSidebarConversationItem(convo) {
const pinBtn = convoItem.querySelector('.pin-btn');
const hideBtn = convoItem.querySelector('.hide-btn');
const selectBtn = convoItem.querySelector('.select-btn');
+ const exportBtn = convoItem.querySelector('.export-btn');
const editBtn = convoItem.querySelector('.edit-btn');
const deleteBtn = convoItem.querySelector('.delete-btn');
@@ -400,6 +402,25 @@ function createSidebarConversationItem(convo) {
});
}
+ if (exportBtn) {
+ exportBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ // Close dropdown after action
+ const dropdownBtn = convoItem.querySelector('[data-bs-toggle="dropdown"]');
+ if (dropdownBtn) {
+ const dropdownInstance = bootstrap.Dropdown.getInstance(dropdownBtn);
+ if (dropdownInstance) {
+ dropdownInstance.hide();
+ }
+ }
+ // Open export wizard for this single conversation
+ if (window.chatExport && window.chatExport.openExportWizard) {
+ window.chatExport.openExportWizard([convo.id], true);
+ }
+ });
+ }
+
if (editBtn) {
editBtn.addEventListener('click', (e) => {
e.preventDefault();
@@ -523,6 +544,7 @@ export function setSidebarSelectionMode(isActive) {
if (isActive) {
conversationsToggle.style.color = '#856404';
conversationsToggle.style.fontWeight = '600';
+ conversationsToggle.classList.add('selection-active');
conversationsActions.style.display = 'flex !important';
conversationsActions.style.setProperty('display', 'flex', 'important');
// Hide the search and eye buttons in selection mode
@@ -564,6 +586,7 @@ export function setSidebarSelectionMode(isActive) {
} else {
conversationsToggle.style.color = '';
conversationsToggle.style.fontWeight = '';
+ conversationsToggle.classList.remove('selection-active');
conversationsActions.style.display = 'none !important';
conversationsActions.style.setProperty('display', 'none', 'important');
if (sidebarDeleteBtn) {
@@ -575,6 +598,10 @@ export function setSidebarSelectionMode(isActive) {
if (sidebarHideBtn) {
sidebarHideBtn.style.display = 'none';
}
+ const sidebarExportBtn = document.getElementById('sidebar-export-selected-btn');
+ if (sidebarExportBtn) {
+ sidebarExportBtn.style.display = 'none';
+ }
// Show the search and eye buttons again when exiting selection mode
if (sidebarSettingsBtn) {
sidebarSettingsBtn.style.display = 'inline-block';
@@ -596,6 +623,7 @@ export function updateSidebarDeleteButton(selectedCount) {
const sidebarDeleteBtn = document.getElementById('sidebar-delete-selected-btn');
const sidebarPinBtn = document.getElementById('sidebar-pin-selected-btn');
const sidebarHideBtn = document.getElementById('sidebar-hide-selected-btn');
+ const sidebarExportBtn = document.getElementById('sidebar-export-selected-btn');
if (selectedCount > 0) {
if (sidebarDeleteBtn) {
@@ -610,6 +638,10 @@ export function updateSidebarDeleteButton(selectedCount) {
sidebarHideBtn.style.display = 'inline-flex';
sidebarHideBtn.title = `Hide ${selectedCount} selected conversation${selectedCount > 1 ? 's' : ''}`;
}
+ if (sidebarExportBtn) {
+ sidebarExportBtn.style.display = 'inline-flex';
+ sidebarExportBtn.title = `Export ${selectedCount} selected conversation${selectedCount > 1 ? 's' : ''}`;
+ }
} else {
if (sidebarDeleteBtn) {
sidebarDeleteBtn.style.display = 'none';
@@ -620,6 +652,9 @@ export function updateSidebarDeleteButton(selectedCount) {
if (sidebarHideBtn) {
sidebarHideBtn.style.display = 'none';
}
+ if (sidebarExportBtn) {
+ sidebarExportBtn.style.display = 'none';
+ }
}
}
@@ -821,6 +856,22 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
+ // Handle sidebar export selected button click
+ const sidebarExportBtn = document.getElementById('sidebar-export-selected-btn');
+ if (sidebarExportBtn) {
+ sidebarExportBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ // Open export wizard for selected conversations
+ if (window.chatExport && window.chatExport.openExportWizard && window.chatConversations && window.chatConversations.getSelectedConversations) {
+ const selectedIds = window.chatConversations.getSelectedConversations();
+ if (selectedIds && selectedIds.length > 0) {
+ window.chatExport.openExportWizard(Array.from(selectedIds), false);
+ }
+ }
+ });
+ }
+
// Handle sidebar settings button click (toggle show/hide hidden conversations)
const sidebarSettingsBtn = document.getElementById('sidebar-conversations-settings-btn');
if (sidebarSettingsBtn) {
diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html
index 2f169573..a0bceee8 100644
--- a/application/single_app/templates/_sidebar_nav.html
+++ b/application/single_app/templates/_sidebar_nav.html
@@ -573,15 +573,18 @@
-
diff --git a/application/single_app/templates/_sidebar_short_nav.html b/application/single_app/templates/_sidebar_short_nav.html
index 954eab25..34413abc 100644
--- a/application/single_app/templates/_sidebar_short_nav.html
+++ b/application/single_app/templates/_sidebar_short_nav.html
@@ -43,6 +43,9 @@
+
diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html
index 37c8c27d..b6c212cc 100644
--- a/application/single_app/templates/chats.html
+++ b/application/single_app/templates/chats.html
@@ -246,6 +246,9 @@ Conversations
+