Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added /export comment to export self-contained HTML file built from webui #235

Merged
merged 1 commit into from
Oct 30, 2024
Merged
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
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:
ErikBjare marked this conversation as resolved.
Show resolved Hide resolved
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using DOMPurify.sanitize to sanitize HTML content before rendering to prevent XSS vulnerabilities.

Suggested change
// TODO: Use DOMPurify.sanitize
return DOMPurify.sanitize(html);

// 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
Loading