Skip to content

Commit

Permalink
feat: added /export comment to export self-contained HTML file built …
Browse files Browse the repository at this point in the history
…from webui (#235)
  • Loading branch information
ErikBjare authored Oct 30, 2024
1 parent 16f8254 commit 0ca914f
Show file tree
Hide file tree
Showing 3 changed files with 157 additions and 4 deletions.
13 changes: 13 additions & 0 deletions gptme/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -35,6 +37,7 @@
"tokens",
"help",
"exit",
"export",
]

action_descriptions: dict[Actions, str] = {
Expand All @@ -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())

Expand Down Expand Up @@ -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)
Expand Down
120 changes: 120 additions & 0 deletions gptme/export.py
Original file line number Diff line number Diff line change
@@ -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,
'<script type="module" src="/static/main.js"></script>',
f"""
<script>
const CHAT_DATA = {chat_data_json};
window.CHAT_DATA = CHAT_DATA;
</script>
<script>
window.addEventListener('load', function() {{
{js}
// Remove hidden class after Vue is mounted
document.getElementById('app').classList.remove('hidden');
}});
</script>
""",
"main.js script tag",
)

# Remove external resources
standalone_html = replace_or_fail(
standalone_html,
'<link rel="icon" type="image/png" href="/favicon.png">',
"",
"favicon link",
)
standalone_html = replace_or_fail(
standalone_html,
'<link rel="stylesheet" href="/static/style.css">',
f"<style>{css}</style>",
"style.css link",
)

# Remove interactive elements
standalone_html = replace_or_fail(
standalone_html,
'<div class="chat-input',
'<div style="display: none;" class="chat-input',
"chat input",
)
standalone_html = replace_or_fail(
standalone_html,
'<button\n class="bg-green-500',
'<button style="display: none;" class="bg-green-500',
"generate button",
)

# Remove the loader since it's not needed in exported version
standalone_html = replace_or_fail(
standalone_html,
"<!-- Loader -->",
"<!-- Loader removed in export -->",
"loader comment",
)
standalone_html = replace_or_fail(
standalone_html,
'<div id="loader"',
'<div id="loader" style="display: none;"',
"loader div",
)

# Remove the sidebar since we don't need conversation selection in export
standalone_html = replace_or_fail(
standalone_html,
"<!-- Sidebar -->",
"<!-- Sidebar removed in export -->",
"sidebar comment",
)
standalone_html = replace_or_fail(
standalone_html,
'<div class="sidebar',
'<div style="display: none;" class="sidebar',
"sidebar div",
)

# Write the file
with open(output_path, "w") as f:
f.write(standalone_html)
28 changes: 24 additions & 4 deletions gptme/server/static/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,24 @@ new Vue({
conversationsLimit: 20,
},
async mounted() {
this.getConversations();
// if the hash is set, select that conversation
if (window.location.hash) {
this.selectConversation(window.location.hash.slice(1));
// Check for embedded data first
if (window.CHAT_DATA) {
this.conversations = [{
name: "Exported Chat",
messages: CHAT_DATA.length,
modified: new Date(CHAT_DATA[CHAT_DATA.length - 1].timestamp).getTime() / 1000,
}];
this.selectedConversation = "Exported Chat";
this.chatLog = CHAT_DATA;
this.branch = "main";
this.branches = {"main": CHAT_DATA};
} else {
// Normal API mode
await this.getConversations();
// if the hash is set, select that conversation
if (window.location.hash) {
await this.selectConversation(window.location.hash.slice(1));
}
}
// remove display-none class from app
document.getElementById("app").classList.remove("hidden");
Expand Down Expand Up @@ -244,6 +258,12 @@ new Vue({
},
mdToHtml(md) {
// TODO: Use DOMPurify.sanitize
// First unescape any HTML entities in the markdown
md = md.replace(/&([^;]+);/g, (match, entity) => {
const textarea = document.createElement('textarea');
textarea.innerHTML = match;
return textarea.value;
});
md = this.wrapThinkingInDetails(md);
let html = marked.parse(md);
html = this.wrapBlockInDetails(html);
Expand Down

0 comments on commit 0ca914f

Please sign in to comment.