Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 166 additions & 1 deletion code/backend/api/chat_history.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
45 changes: 45 additions & 0 deletions code/frontend/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,51 @@ export const historyDeleteAll = async (): Promise<Response> => {
return response;
};

export const historyExport = async (
convId: string,
format: "json" | "markdown" | "text" = "json"
): Promise<void> => {
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<FrontEndSettings> {
try {
const response = await fetch("/api/history/frontend_settings", {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';

Expand Down Expand Up @@ -56,6 +58,9 @@ export const ChatHistoryListItemCell: React.FC<
const [renameLoading, setRenameLoading] = useState(false);
const [errorRename, setErrorRename] = useState<string | undefined>(undefined);
const [textFieldFocused, setTextFieldFocused] = useState(false);
const [showExportMenu, setShowExportMenu] = useState(false);
const [errorExport, setErrorExport] = useState(false);
const exportButtonRef = useRef<HTMLDivElement | null>(null);
const textFieldRef = useRef<ITextField | null>(null);
const isSelected = item?.id === selectedConvId;
const tooltipId = 'tooltip'+ item?.id;
Expand Down Expand Up @@ -167,6 +172,44 @@ export const ChatHistoryListItemCell: React.FC<
e.stopPropagation();
toggleDeleteDialog();
};

const onClickExport = (e: React.MouseEvent<HTMLButtonElement>) => {
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 (
<Stack
Expand Down Expand Up @@ -275,6 +318,24 @@ export const ChatHistoryListItemCell: React.FC<
>{truncatedTitle} </TooltipHost></div>
{(isSelected || isHovered) && (
<Stack horizontal horizontalAlign="end">
<div ref={exportButtonRef}>
<IconButton
className={styles.itemButton}
iconProps={{ iconName: "Download" }}
title="Export"
onClick={onClickExport}
onKeyDown={(e) =>
e.key === " " ? onClickExport(e as any) : null
}
aria-label="export conversation"
/>
</div>
<ContextualMenu
items={exportMenuItems}
hidden={!showExportMenu}
target={exportButtonRef.current}
onDismiss={() => setShowExportMenu(false)}
/>
<IconButton
className={styles.itemButton}
disabled={isButtonDisabled}
Expand Down Expand Up @@ -307,6 +368,15 @@ export const ChatHistoryListItemCell: React.FC<
Error: could not delete item
</Text>
)}
{errorExport && (
<Text
styles={{
root: { color: "red", marginTop: 5, fontSize: 14 },
}}
>
Error: could not export conversation
</Text>
)}
<Dialog
hidden={hideDeleteDialog}
onDismiss={toggleDeleteDialog}
Expand Down
Loading