diff --git a/code/backend/api/chat_history.py b/code/backend/api/chat_history.py index 8a86b8119..04556af36 100644 --- a/code/backend/api/chat_history.py +++ b/code/backend/api/chat_history.py @@ -1,8 +1,9 @@ import os +import json import logging from uuid import uuid4 from dotenv import load_dotenv -from flask import request, jsonify, Blueprint +from flask import request, jsonify, Blueprint, Response from openai import AsyncAzureOpenAI from backend.batch.utilities.chat_history.auth_utils import ( get_authenticated_user_details, @@ -508,3 +509,167 @@ async def generate_title(conversation_messages): logger.exception(f"Error generating title: {str(e)}") # Fallback: return the content of the second to last message if something goes wrong return messages[-2]["content"] if len(messages) > 1 else "Untitled" + + +def _format_as_json(conversation, messages): + """Format conversation and messages as a JSON string.""" + export_data = { + "conversation_id": conversation.get("id", ""), + "title": conversation.get("title", "Untitled"), + "created_at": conversation.get("createdAt", ""), + "updated_at": conversation.get("updatedAt", ""), + "messages": [ + { + "role": msg.get("role", ""), + "content": msg.get("content", ""), + "created_at": msg.get("createdAt", ""), + "id": msg.get("id", ""), + } + for msg in messages + if msg.get("role") != "tool" + ], + } + return json.dumps(export_data, indent=2, ensure_ascii=False) + + +def _format_as_markdown(conversation, messages): + """Format conversation and messages as Markdown.""" + title = conversation.get("title", "Untitled") + created = conversation.get("createdAt", "") + lines = [ + f"# {title}", + "", + f"**Date:** {created}", + "", + "---", + "", + ] + for msg in messages: + role = msg.get("role", "unknown") + if role == "tool": + continue + content = msg.get("content", "") + timestamp = msg.get("createdAt", "") + role_label = "User" if role == "user" else "Assistant" + lines.append(f"### {role_label}") + if timestamp: + lines.append(f"*{timestamp}*") + lines.append("") + lines.append(content) + lines.append("") + lines.append("---") + lines.append("") + return "\n".join(lines) + + +def _format_as_text(conversation, messages): + """Format conversation and messages as plain text.""" + title = conversation.get("title", "Untitled") + created = conversation.get("createdAt", "") + lines = [ + f"Conversation: {title}", + f"Date: {created}", + "=" * 50, + "", + ] + for msg in messages: + role = msg.get("role", "unknown") + if role == "tool": + continue + content = msg.get("content", "") + timestamp = msg.get("createdAt", "") + role_label = "User" if role == "user" else "Assistant" + lines.append(f"[{role_label}] {timestamp}") + lines.append(content) + lines.append("-" * 50) + lines.append("") + return "\n".join(lines) + + +EXPORT_FORMATTERS = { + "json": ("application/json", ".json", _format_as_json), + "markdown": ("text/markdown", ".md", _format_as_markdown), + "text": ("text/plain", ".txt", _format_as_text), +} + + +@bp_chat_history_response.route("/history/export", methods=["POST"]) +async def export_conversation(): + config = ConfigHelper.get_active_config_or_default() + if not config.enable_chat_history: + return jsonify({"error": "Chat history is not available"}), 400 + + try: + authenticated_user = get_authenticated_user_details( + request_headers=request.headers + ) + user_id = authenticated_user["user_principal_id"] + + request_json = request.get_json(silent=True) + if request_json is None: + return jsonify({"error": "A JSON request body is required"}), 400 + conversation_id = request_json.get("conversation_id", None) + if not conversation_id: + return jsonify({"error": "conversation_id is required"}), 400 + + export_format = request_json.get("format", "json").lower() + if export_format not in EXPORT_FORMATTERS: + return ( + jsonify( + { + "error": f"Invalid format '{export_format}'. Supported formats: json, markdown, text" + } + ), + 400, + ) + + conversation_client = init_database_client() + if not conversation_client: + return jsonify({"error": "Database not available"}), 500 + + await conversation_client.connect() + try: + conversation = await conversation_client.get_conversation( + user_id, conversation_id + ) + if not conversation: + return ( + jsonify( + { + "error": f"Conversation {conversation_id} was not found. It either does not exist or the logged in user does not have access to it." + } + ), + 400, + ) + + messages = await conversation_client.get_messages( + user_id, conversation_id + ) + + content_type, file_ext, formatter = EXPORT_FORMATTERS[export_format] + exported_content = formatter(conversation, messages) + + safe_title = "".join( + c if c.isalnum() or c in (" ", "-", "_") else "_" + for c in conversation.get("title", "conversation") + ).strip()[:50] + filename = f"{safe_title or 'conversation'}{file_ext}" + + return Response( + exported_content, + mimetype=content_type, + headers={ + "Content-Disposition": f'attachment; filename="{filename}"' + }, + ) + except Exception as e: + logger.exception( + f"Error exporting conversation: user_id={user_id}, conversation_id={conversation_id}, error={e}" + ) + raise + finally: + await conversation_client.close() + + except Exception as e: + logger.exception(f"Exception in /history/export: {e}") + return jsonify({"error": "Error while exporting conversation"}), 500 diff --git a/code/frontend/src/api/api.ts b/code/frontend/src/api/api.ts index 2f261db42..15708d8b0 100644 --- a/code/frontend/src/api/api.ts +++ b/code/frontend/src/api/api.ts @@ -268,6 +268,51 @@ export const historyDeleteAll = async (): Promise => { return response; }; +export const historyExport = async ( + convId: string, + format: "json" | "markdown" | "text" = "json" +): Promise => { + try { + const response = await fetch("/api/history/export", { + method: "POST", + body: JSON.stringify({ + conversation_id: convId, + format: format, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Export failed"); + } + + const blob = await response.blob(); + const contentDisposition = response.headers.get("Content-Disposition"); + let filename = `conversation.${format === "markdown" ? "md" : format === "text" ? "txt" : "json"}`; + if (contentDisposition) { + const match = contentDisposition.match(/filename="(.+)"/); + if (match) { + filename = match[1]; + } + } + + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (err) { + console.error("Error exporting conversation:", err); + throw err; + } +}; + export async function getFrontEndSettings(): Promise { try { const response = await fetch("/api/history/frontend_settings", { diff --git a/code/frontend/src/components/ChatHistoryListItemCell/ChatHistoryListItemCell.tsx b/code/frontend/src/components/ChatHistoryListItemCell/ChatHistoryListItemCell.tsx index fc47ef2c1..36811511c 100644 --- a/code/frontend/src/components/ChatHistoryListItemCell/ChatHistoryListItemCell.tsx +++ b/code/frontend/src/components/ChatHistoryListItemCell/ChatHistoryListItemCell.tsx @@ -1,10 +1,12 @@ import * as React from "react"; import { useEffect, useRef, useState } from "react"; import { + ContextualMenu, DefaultButton, Dialog, DialogFooter, DialogType, + IContextualMenuItem, IconButton, ITextField, ITooltipHostStyles, @@ -16,7 +18,7 @@ import { } from "@fluentui/react"; import { useBoolean } from "@fluentui/react-hooks"; -import { historyRename, historyDelete } from "../../api"; +import { historyRename, historyDelete, historyExport } from "../../api"; import { Conversation } from "../../api/models"; import _ from 'lodash'; @@ -56,6 +58,9 @@ export const ChatHistoryListItemCell: React.FC< const [renameLoading, setRenameLoading] = useState(false); const [errorRename, setErrorRename] = useState(undefined); const [textFieldFocused, setTextFieldFocused] = useState(false); + const [showExportMenu, setShowExportMenu] = useState(false); + const [errorExport, setErrorExport] = useState(false); + const exportButtonRef = useRef(null); const textFieldRef = useRef(null); const isSelected = item?.id === selectedConvId; const tooltipId = 'tooltip'+ item?.id; @@ -167,6 +172,44 @@ export const ChatHistoryListItemCell: React.FC< e.stopPropagation(); toggleDeleteDialog(); }; + + const onClickExport = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setShowExportMenu(true); + }; + + const onExport = async (format: "json" | "markdown" | "text") => { + setShowExportMenu(false); + try { + await historyExport(item.id, format); + } catch { + setErrorExport(true); + setTimeout(() => setErrorExport(false), 5000); + } + }; + + const exportMenuItems: IContextualMenuItem[] = [ + { + key: "json", + text: "Export as JSON", + iconProps: { iconName: "Code" }, + onClick: () => onExport("json"), + }, + { + key: "markdown", + text: "Export as Markdown", + iconProps: { iconName: "MarkDownLanguage" }, + onClick: () => onExport("markdown"), + }, + { + key: "text", + text: "Export as Text", + iconProps: { iconName: "TextDocument" }, + onClick: () => onExport("text"), + }, + ]; + const isButtonDisabled = isGenerating && isSelected; return ( {truncatedTitle} {(isSelected || isHovered) && ( +
+ + e.key === " " ? onClickExport(e as any) : null + } + aria-label="export conversation" + /> +
+
)} +
+ setShowExportMenu(true)} + /> +
+