Skip to content

Instantly share code, notes, and snippets.

@ericmjl
Last active July 17, 2025 04:01
Show Gist options
  • Save ericmjl/7d23c116a37db9357cbf2143ad27e1a2 to your computer and use it in GitHub Desktop.
Save ericmjl/7d23c116a37db9357cbf2143ad27e1a2 to your computer and use it in GitHub Desktop.
GitHub Copilot Chat Viewer - FastAPI app with HTMX for viewing GitHub Copilot chat sessions
# /// script
# dependencies = [
# "fastapi",
# "uvicorn",
# "jinja2",
# "python-multipart"
# ]
# ///
import json
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Any, Optional
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import Template
app = FastAPI(title="GitHub Copilot Chat Viewer")
# Default chat file - will be overridden by user selection
DEFAULT_CHAT_FILE = "chat.json"
def get_json_files() -> List[str]:
"""Get all JSON files in the current directory."""
current_dir = Path(".")
json_files = [f.name for f in current_dir.glob("*.json")]
return sorted(json_files)
def load_chat_data(filename: Optional[str] = None) -> Dict[str, Any]:
"""Load chat data from specified file or default."""
if filename is None:
filename = DEFAULT_CHAT_FILE
chat_file = Path(filename)
if not chat_file.exists():
return {}
try:
with open(chat_file, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
def format_timestamp(timestamp: int) -> str:
return datetime.fromtimestamp(timestamp / 1000).strftime("%Y-%m-%d %H:%M:%S")
def extract_text_from_message(message: Dict[str, Any]) -> str:
if "text" in message:
return message["text"]
if "parts" in message:
return " ".join(part.get("text", "") for part in message["parts"] if part.get("kind") == "text")
return ""
def extract_response_text(response_items: List[Dict[str, Any]]) -> str:
text_parts = []
for item in response_items:
if item.get("kind") == "textChunk" and "text" in item:
text_parts.append(item["text"])
elif item.get("kind") == "confirmation" and "message" in item:
text_parts.append(f"[Confirmation] {item['message']}")
return "".join(text_parts)
BASE_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GitHub Copilot Chat Viewer</title>
<script src="https://unpkg.com/[email protected]"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background-color: #f6f8fa;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.header {
padding: 20px;
border-bottom: 1px solid #e1e4e8;
background: #24292e;
color: white;
border-radius: 8px 8px 0 0;
}
.chat-list {
padding: 20px;
}
.chat-header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e1e4e8;
}
.messages-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.message-item {
border: 1px solid #e1e4e8;
border-radius: 6px;
transition: all 0.2s ease;
}
.message-item.expanded {
border-color: #0366d6;
box-shadow: 0 2px 8px rgba(3, 102, 214, 0.1);
}
.message-header-row {
padding: 16px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background-color 0.2s;
}
.message-header-row:hover {
background-color: #f6f8fa;
}
.message-summary {
flex: 1;
}
.message-text {
font-weight: 500;
color: #24292e;
margin-bottom: 4px;
}
.message-meta {
color: #6a737d;
font-size: 14px;
}
.expand-icon {
font-size: 14px;
color: #6a737d;
margin-left: 12px;
}
.message-detail {
border-top: 1px solid #e1e4e8;
padding: 16px;
background-color: #fafbfc;
}
.message-block {
margin-bottom: 24px;
padding: 16px;
border-radius: 6px;
}
.user-message {
background-color: #f1f8ff;
border-left: 4px solid #0366d6;
}
.assistant-message {
background-color: #f6f8fa;
border-left: 4px solid #28a745;
}
.message-header {
font-weight: 600;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.message-content {
white-space: pre-wrap;
word-wrap: break-word;
}
.back-button {
background: #0366d6;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
margin-bottom: 16px;
}
.back-button:hover {
background: #0056b3;
}
.model-info {
font-size: 12px;
color: #6a737d;
background: #f6f8fa;
padding: 4px 8px;
border-radius: 4px;
}
.file-selector {
margin-top: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.file-selector label {
color: #e1e4e8;
font-weight: 500;
}
.file-selector select {
padding: 6px 12px;
border: 1px solid #444d56;
border-radius: 4px;
background: #2f363d;
color: #e1e4e8;
font-size: 14px;
min-width: 200px;
}
.file-selector select:focus {
outline: none;
border-color: #0366d6;
box-shadow: 0 0 0 2px rgba(3, 102, 214, 0.3);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>πŸ€– GitHub Copilot Chat Viewer</h1>
<p>Browse and view your GitHub Copilot chat sessions</p>
<div class="file-selector">
<label for="chat-file-select">πŸ“ Chat File:</label>
<select id="chat-file-select"
hx-get="/chat-list"
hx-target="#content"
hx-trigger="change"
name="selected-file">
<option value="">Loading files...</option>
</select>
</div>
</div>
<div id="content" hx-get="/file-selector" hx-trigger="load">
<div style="padding: 40px; text-align: center; color: #6a737d;">
Loading chat sessions...
</div>
</div>
</div>
</body>
</html>
"""
@app.get("/", response_class=HTMLResponse)
async def root():
return BASE_TEMPLATE
@app.get("/file-selector")
async def get_file_selector():
"""Populate the file selector dropdown with available JSON files."""
json_files = get_json_files()
if not json_files:
options = '<option value="">No JSON files found</option>'
else:
# Set default selection to chat.json if it exists, otherwise first file
default_file = DEFAULT_CHAT_FILE if DEFAULT_CHAT_FILE in json_files else json_files[0]
options = ""
for file in json_files:
selected = "selected" if file == default_file else ""
options += f'<option value="{file}" {selected}>{file}</option>'
return HTMLResponse(f'''
<script>
document.getElementById('chat-file-select').innerHTML = `{options}`;
// Trigger initial load with default file
if (document.getElementById('chat-file-select').value) {{
htmx.ajax('GET', '/chat-list?selected-file=' + document.getElementById('chat-file-select').value, '#content');
}}
</script>
''')
@app.get("/chat-list")
async def get_chat_list(request: Request):
# Get selected file from query parameters
selected_file = request.query_params.get("selected-file", DEFAULT_CHAT_FILE)
data = load_chat_data(selected_file)
if not data or "requests" not in data:
return HTMLResponse(f"""
<div class="chat-list">
<p style="color: #6a737d; text-align: center; padding: 40px;">
No chat session found in "{selected_file}". Make sure the file exists and contains valid chat data.
</p>
</div>
""")
requests = data["requests"]
requester = data.get("requesterUsername", "User")
responder = data.get("responderUsername", "GitHub Copilot")
message_items = []
for i, request in enumerate(requests):
timestamp = format_timestamp(request.get("timestamp", 0))
message_text = extract_text_from_message(request.get("message", {}))
model_id = request.get("modelId", "Unknown")
preview = message_text[:80] + "..." if len(message_text) > 80 else message_text
message_items.append(f"""
<div class="message-item" id="message-{i}">
<div class="message-header-row"
hx-get="/message/{i}?selected-file={selected_file}"
hx-target="#message-{i}"
hx-swap="outerHTML">
<div class="message-summary">
<div class="message-text">πŸ’¬ {preview}</div>
<div class="message-meta">πŸ“… {timestamp} β€’ πŸ€– {model_id.split('/')[-1] if '/' in model_id else model_id}</div>
</div>
<div class="expand-icon">▢️</div>
</div>
</div>
""")
html = f"""
<div class="chat-list">
<div class="chat-header">
<h2>πŸ’¬ Conversation with {responder}</h2>
<p style="color: #6a737d; margin: 0;">User: {requester} β€’ {len(requests)} messages</p>
</div>
<div class="messages-container">
{"".join(message_items)}
</div>
</div>
"""
return HTMLResponse(html)
@app.get("/message/{message_id}")
async def get_message_detail(message_id: int, request: Request):
selected_file = request.query_params.get("selected-file", DEFAULT_CHAT_FILE)
data = load_chat_data(selected_file)
if not data or "requests" not in data or message_id >= len(data["requests"]):
raise HTTPException(status_code=404, detail="Message not found")
request = data["requests"][message_id]
user_message = extract_text_from_message(request.get("message", {}))
response_text = extract_response_text(request.get("response", []))
timestamp = format_timestamp(request.get("timestamp", 0))
model_id = request.get("modelId", "Unknown")
html = f"""
<div class="message-item expanded" id="message-{message_id}">
<div class="message-header-row"
hx-get="/message/{message_id}/collapse?selected-file={selected_file}"
hx-target="#message-{message_id}"
hx-swap="outerHTML">
<div class="message-summary">
<div class="message-text">πŸ’¬ {user_message[:80]}{"..." if len(user_message) > 80 else ""}</div>
<div class="message-meta">πŸ“… {timestamp} β€’ πŸ€– {model_id.split('/')[-1] if '/' in model_id else model_id}</div>
</div>
<div class="expand-icon">πŸ”½</div>
</div>
<div class="message-detail">
<div class="message-block user-message">
<div class="message-header">
πŸ‘€ User Message
</div>
<div class="message-content">{user_message}</div>
</div>
<div class="message-block assistant-message">
<div class="message-header">
πŸ€– Copilot Response
</div>
<div class="message-content">{response_text if response_text else "No response content available"}</div>
</div>
</div>
</div>
"""
return HTMLResponse(html)
@app.get("/message/{message_id}/collapse")
async def collapse_message(message_id: int, request: Request):
selected_file = request.query_params.get("selected-file", DEFAULT_CHAT_FILE)
data = load_chat_data(selected_file)
if not data or "requests" not in data or message_id >= len(data["requests"]):
raise HTTPException(status_code=404, detail="Message not found")
request = data["requests"][message_id]
message_text = extract_text_from_message(request.get("message", {}))
timestamp = format_timestamp(request.get("timestamp", 0))
model_id = request.get("modelId", "Unknown")
preview = message_text[:80] + "..." if len(message_text) > 80 else message_text
html = f"""
<div class="message-item" id="message-{message_id}">
<div class="message-header-row"
hx-get="/message/{message_id}?selected-file={selected_file}"
hx-target="#message-{message_id}"
hx-swap="outerHTML">
<div class="message-summary">
<div class="message-text">πŸ’¬ {preview}</div>
<div class="message-meta">πŸ“… {timestamp} β€’ πŸ€– {model_id.split('/')[-1] if '/' in model_id else model_id}</div>
</div>
<div class="expand-icon">▢️</div>
</div>
</div>
"""
return HTMLResponse(html)
if __name__ == "__main__":
import uvicorn
import random
port = random.randint(8000, 9000)
print(f"πŸš€ Starting server on http://127.0.0.1:{port}")
uvicorn.run("app:app", host="127.0.0.1", port=port, reload=True)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment