From cb9bea66ba9d1167f5604b0f14159eccc1fff8a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Wed, 30 Oct 2024 13:17:51 +0100 Subject: [PATCH] feat: added /export comment to export self-contained HTML file built from webui --- gptme/commands.py | 13 ++++ gptme/export.py | 120 ++++++++++++++++++++++++++++++++++++ gptme/server/static/main.js | 28 +++++++-- 3 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 gptme/export.py diff --git a/gptme/commands.py b/gptme/commands.py index 65116a10..1fd035e9 100644 --- a/gptme/commands.py +++ b/gptme/commands.py @@ -2,10 +2,12 @@ import re import sys from collections.abc import Generator +from pathlib import Path from time import sleep from typing import Literal from . import llm +from .export import export_chat_to_html from .logmanager import LogManager, prepare_messages from .message import ( Message, @@ -35,6 +37,7 @@ "tokens", "help", "exit", + "export", ] action_descriptions: dict[Actions, str] = { @@ -50,6 +53,7 @@ "tools": "Show available tools", "help": "Show this help message", "exit": "Exit the program", + "export": "Export conversation as standalone HTML", } COMMANDS = list(action_descriptions.keys()) @@ -144,6 +148,15 @@ def handle_cmd( {tool.desc.rstrip(".")} tokens (example): {len_tokens(tool.examples)}""" ) + case "export": + manager.undo(1, quiet=True) + # Get output path from args or use default + output_path = ( + Path(args[0]) if args else Path(f"{manager.logfile.parent.name}.html") + ) + # Export the chat + export_chat_to_html(manager.log, output_path) + print(f"Exported conversation to {output_path}") case _: # the case for python, shell, and other block_types supported by tools tooluse = ToolUse(name, [], full_args) diff --git a/gptme/export.py b/gptme/export.py new file mode 100644 index 00000000..2b2f4b74 --- /dev/null +++ b/gptme/export.py @@ -0,0 +1,120 @@ +import json +import html +from pathlib import Path + +from .logmanager import Log + + +def replace_or_fail(html: str, old: str, new: str, desc: str = "") -> str: + """Replace a string and fail if nothing was replaced""" + result = html.replace(old, new) + if result == html: + raise ValueError(f"Failed to replace {desc or old!r}") + return result + + +def export_chat_to_html(chat_data: Log, output_path: Path) -> None: + """Export a chat log to a self-contained HTML file""" + + # Read the template files + current_dir = Path(__file__).parent + template_dir = current_dir / "server" / "static" + + with open(template_dir / "index.html") as f: + html_template = f.read() + with open(template_dir / "style.css") as f: + css = f.read() + with open(template_dir / "main.js") as f: + js = f.read() + + # No need to modify JavaScript since it now handles embedded data + + # Prepare the chat data, escaping any HTML in the content + chat_data_list = [] + for msg in chat_data.messages: + msg_dict = msg.to_dict() + # Escape HTML in the content, but preserve newlines + msg_dict["content"] = html.escape(msg_dict["content"], quote=False) + chat_data_list.append(msg_dict) + + chat_data_json = json.dumps(chat_data_list, indent=2) + + # Modify the template + standalone_html = replace_or_fail( + html_template, + '', + f""" + + + """, + "main.js script tag", + ) + + # Remove external resources + standalone_html = replace_or_fail( + standalone_html, + '', + "", + "favicon link", + ) + standalone_html = replace_or_fail( + standalone_html, + '', + f"", + "style.css link", + ) + + # Remove interactive elements + standalone_html = replace_or_fail( + standalone_html, + '
", + "", + "loader comment", + ) + standalone_html = replace_or_fail( + standalone_html, + '