"""
Chat history formatters.
Provides formatting and export functionality for ChatHistory in multiple formats:
Markdown, HTML, plain text, and JSON.
"""
from __future__ import annotations
import html
from pathlib import Path
from typing import Any
from lexilux.chat.history import ChatHistory
[docs]
class ChatHistoryFormatter:
"""
Chat history formatter.
Provides static methods to format ChatHistory into various output formats.
"""
[docs]
@staticmethod
def to_markdown(
history: ChatHistory,
*,
show_round_numbers: bool = True,
show_timestamps: bool = False,
highlight_system: bool = True,
) -> str:
"""
Format history as Markdown.
Args:
history: ChatHistory instance to format.
show_round_numbers: Whether to show round numbers. Default: True
highlight_system: Whether to highlight system message. Default: True
show_timestamps: Whether to show timestamps (if available). Default: False
Returns:
Markdown formatted string.
Examples:
>>> history = ChatHistory.from_chat_result("Hello", result)
>>> md = ChatHistoryFormatter.to_markdown(history)
>>> print(md)
"""
lines = []
messages = history.get_messages(include_system=True)
round_num = 0
for i, msg in enumerate(messages):
role = msg.get("role", "")
content = msg.get("content", "")
# System message
if role == "system":
if highlight_system:
lines.append("## System Message")
lines.append("")
lines.append(f"*{content}*")
else:
lines.append(f"**System:** {content}")
lines.append("")
continue
# User message - start new round
if role == "user":
round_num += 1
if show_round_numbers:
lines.append(f"### Round {round_num}")
lines.append("")
lines.append("**User:**")
lines.append("")
# Escape markdown special characters in content
content_escaped = content.replace("**", "\\*\\*").replace(
"__", "\\_\\_"
)
lines.append(content_escaped)
lines.append("")
# Assistant message
elif role == "assistant":
lines.append("**Assistant:**")
lines.append("")
content_escaped = content.replace("**", "\\*\\*").replace(
"__", "\\_\\_"
)
lines.append(content_escaped)
lines.append("")
return "\n".join(lines)
[docs]
@staticmethod
def to_html(
history: ChatHistory,
*,
theme: str = "default",
show_round_numbers: bool = True,
show_timestamps: bool = False,
) -> str:
"""
Format history as HTML (beautiful and clear).
Args:
history: ChatHistory instance to format.
theme: Theme name ("default", "dark", "minimal"). Default: "default"
show_round_numbers: Whether to show round numbers. Default: True
show_timestamps: Whether to show timestamps (if available). Default: False
Returns:
HTML formatted string with embedded CSS.
Examples:
>>> history = ChatHistory.from_chat_result("Hello", result)
>>> html = ChatHistoryFormatter.to_html(history, theme="dark")
"""
messages = history.get_messages(include_system=True)
# CSS styles based on theme
css_styles = {
"default": """
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6; margin: 0; padding: 20px; background: #f5f5f5; }
.container { max-width: 900px; margin: 0 auto; background: white;
padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.system { background: #e3f2fd; padding: 15px; border-radius: 6px;
margin-bottom: 20px; border-left: 4px solid #2196f3; }
.round { margin-bottom: 30px; border: 1px solid #e0e0e0;
border-radius: 6px; overflow: hidden; }
.round-header { background: #fafafa; padding: 10px 15px;
font-weight: 600; color: #666; border-bottom: 1px solid #e0e0e0; }
.message { padding: 15px; }
.user { background: #f5f5f5; border-left: 4px solid #4caf50; }
.assistant { background: #fff; border-left: 4px solid #2196f3; }
.role { font-weight: 600; margin-bottom: 8px; color: #333; }
.content { color: #444; white-space: pre-wrap; }
""",
"dark": """
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6; margin: 0; padding: 20px; background: #1e1e1e; color: #e0e0e0; }
.container { max-width: 900px; margin: 0 auto; background: #2d2d2d;
padding: 30px; border-radius: 8px; }
.system { background: #1a237e; padding: 15px; border-radius: 6px;
margin-bottom: 20px; border-left: 4px solid #3f51b5; }
.round { margin-bottom: 30px; border: 1px solid #404040;
border-radius: 6px; overflow: hidden; }
.round-header { background: #333; padding: 10px 15px;
font-weight: 600; color: #aaa; border-bottom: 1px solid #404040; }
.message { padding: 15px; }
.user { background: #2d2d2d; border-left: 4px solid #4caf50; }
.assistant { background: #252525; border-left: 4px solid #2196f3; }
.role { font-weight: 600; margin-bottom: 8px; color: #fff; }
.content { color: #e0e0e0; white-space: pre-wrap; }
""",
"minimal": """
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.8; margin: 0; padding: 20px; background: white; }
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
.system { padding: 10px 0; margin-bottom: 20px; border-bottom: 1px solid #eee;
font-style: italic; color: #666; }
.round { margin-bottom: 25px; }
.round-header { font-weight: 600; color: #999; margin-bottom: 10px; font-size: 0.9em; }
.message { margin-bottom: 15px; }
.user { padding-left: 15px; border-left: 2px solid #4caf50; }
.assistant { padding-left: 15px; border-left: 2px solid #2196f3; }
.role { font-weight: 600; margin-bottom: 5px; color: #333; }
.content { color: #444; white-space: pre-wrap; }
""",
}
css = css_styles.get(theme, css_styles["default"])
html_parts = [
"<!DOCTYPE html>",
"<html>",
"<head>",
"<meta charset='utf-8'>",
"<title>Chat History</title>",
f"<style>{css}</style>",
"</head>",
"<body>",
"<div class='container'>",
]
round_num = 0
in_round = False
for i, msg in enumerate(messages):
role = msg.get("role", "")
content = msg.get("content", "")
# System message
if role == "system":
html_parts.append("<div class='system'>")
html_parts.append(f"<strong>System:</strong> {html.escape(content)}")
html_parts.append("</div>")
continue
# User message - start new round
if role == "user":
if in_round:
html_parts.append("</div>") # Close previous round
round_num += 1
in_round = True
html_parts.append("<div class='round'>")
if show_round_numbers:
html_parts.append(
f"<div class='round-header'>Round {round_num}</div>"
)
html_parts.append("<div class='message user'>")
html_parts.append("<div class='role'>User</div>")
html_parts.append(f"<div class='content'>{html.escape(content)}</div>")
html_parts.append("</div>")
# Assistant message
elif role == "assistant":
html_parts.append("<div class='message assistant'>")
html_parts.append("<div class='role'>Assistant</div>")
html_parts.append(f"<div class='content'>{html.escape(content)}</div>")
html_parts.append("</div>")
html_parts.append("</div>") # Close round
in_round = False
if in_round:
html_parts.append("</div>") # Close last round if incomplete
html_parts.extend(["</div>", "</body>", "</html>"])
return "\n".join(html_parts)
[docs]
@staticmethod
def to_text(
history: ChatHistory,
*,
show_round_numbers: bool = True,
width: int = 80,
) -> str:
"""
Format history as plain text (console-friendly).
Args:
history: ChatHistory instance to format.
show_round_numbers: Whether to show round numbers. Default: True
width: Text width for wrapping. Default: 80
Returns:
Plain text formatted string.
Examples:
>>> history = ChatHistory.from_chat_result("Hello", result)
>>> text = ChatHistoryFormatter.to_text(history, width=100)
"""
lines = []
messages = history.get_messages(include_system=True)
round_num = 0
for i, msg in enumerate(messages):
role = msg.get("role", "")
content = msg.get("content", "")
# System message
if role == "system":
lines.append("=" * width)
lines.append("SYSTEM MESSAGE")
lines.append("=" * width)
lines.append(content)
lines.append("")
continue
# User message - start new round
if role == "user":
round_num += 1
if show_round_numbers:
lines.append("")
lines.append("-" * width)
lines.append(f"Round {round_num}")
lines.append("-" * width)
else:
lines.append("-" * width)
lines.append("User:")
lines.append("")
# Simple text wrapping (basic implementation)
words = content.split()
current_line = ""
for word in words:
if len(current_line) + len(word) + 1 <= width:
current_line += (word + " ") if current_line else word
else:
if current_line:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
lines.append("")
# Assistant message
elif role == "assistant":
lines.append("Assistant:")
lines.append("")
words = content.split()
current_line = ""
for word in words:
if len(current_line) + len(word) + 1 <= width:
current_line += (word + " ") if current_line else word
else:
if current_line:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
lines.append("")
return "\n".join(lines)
[docs]
@staticmethod
def to_json(history: ChatHistory, **kwargs) -> str:
"""
Format history as JSON (program-friendly).
Args:
history: ChatHistory instance to format.
**kwargs: Additional arguments for json.dumps (e.g., indent=2).
Returns:
JSON formatted string.
Examples:
>>> history = ChatHistory.from_chat_result("Hello", result)
>>> json_str = ChatHistoryFormatter.to_json(history, indent=2)
"""
return history.to_json(**kwargs)
[docs]
@staticmethod
def save(
history: ChatHistory,
filepath: str,
format: str = "auto",
**options: Any,
) -> None:
"""
Save history to file (automatically selects format based on extension).
Args:
history: ChatHistory instance to save.
filepath: Path to save file.
format: Format to use ("auto", "markdown", "html", "text", "json").
If "auto", format is determined by file extension.
**options: Additional options for formatters.
Examples:
>>> history = ChatHistory.from_chat_result("Hello", result)
>>> ChatHistoryFormatter.save(history, "conversation.md")
>>> ChatHistoryFormatter.save(history, "conversation.html", theme="dark")
>>> ChatHistoryFormatter.save(history, "conversation.txt", width=100)
"""
path = Path(filepath)
# Auto-detect format from extension
if format == "auto":
ext = path.suffix.lower()
if ext == ".md" or ext == ".markdown":
format = "markdown"
elif ext == ".html" or ext == ".htm":
format = "html"
elif ext == ".txt" or ext == ".text":
format = "text"
elif ext == ".json":
format = "json"
else:
# Default to markdown if unknown
format = "markdown"
# Format content
if format == "markdown":
content = ChatHistoryFormatter.to_markdown(history, **options)
elif format == "html":
content = ChatHistoryFormatter.to_html(history, **options)
elif format == "text":
content = ChatHistoryFormatter.to_text(history, **options)
elif format == "json":
content = ChatHistoryFormatter.to_json(history, **options)
else:
raise ValueError(f"Unknown format: {format}")
# Write to file
path.write_text(content, encoding="utf-8")