Last active
July 17, 2025 04:01
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# /// 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