conversation_browser.pyā¢41.2 kB
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "flask",
# ]
# ///
"""
Conversation Browser for Voice Mode
A simple web interface to browse voice mode conversations and play associated audio.
Usage:
uvx conversation_browser.py
# or
python conversation_browser.py
"""
import os
import re
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional, Tuple, Any
import json
import time
from collections import defaultdict
from flask import Flask, render_template_string, jsonify, send_file, request
# Import get_audio_path from voice_mode
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from voice_mode.core import get_audio_path
app = Flask(__name__)
# Configuration
BASE_DIR = Path(os.getenv("VOICEMODE_BASE_DIR", str(Path.home() / ".voicemode")))
TRANSCRIPTIONS_DIR = BASE_DIR / "transcriptions"
AUDIO_DIR = BASE_DIR / "audio"
LOGS_DIR = BASE_DIR / "logs"
# Simple cache
CACHE = {
'conversations': None,
'last_update': 0,
'cache_duration': 60 # Cache for 60 seconds
}
def parse_transcription_file(filepath: Path) -> Dict:
"""Parse a transcription file and extract metadata and content."""
try:
content = filepath.read_text(encoding="utf-8")
# Split metadata and transcript
parts = content.split("--- TRANSCRIPT ---")
metadata = {}
transcript = ""
if len(parts) == 2:
# Parse metadata
metadata_text = parts[0].replace("--- METADATA ---", "").strip()
for line in metadata_text.split("\n"):
if ": " in line:
key, value = line.split(": ", 1)
metadata[key.strip()] = value.strip()
transcript = parts[1].strip()
else:
# No metadata, just transcript
transcript = content.strip()
# Extract timestamp from filename
filename_parts = filepath.stem.split("_")
if len(filename_parts) >= 3:
timestamp_str = "_".join(filename_parts[1:3])
try:
# Parse timestamp (YYYYMMDD_HHMMSS)
timestamp = datetime.strptime(timestamp_str, "%Y%m%d_%H%M%S")
metadata["file_timestamp"] = timestamp.isoformat()
except:
pass
return {
"filepath": str(filepath),
"filename": filepath.name,
"metadata": metadata,
"transcript": transcript,
"type": filename_parts[0] if filename_parts else "unknown"
}
except Exception as e:
return {
"filepath": str(filepath),
"filename": filepath.name,
"metadata": {"error": str(e)},
"transcript": f"Error reading file: {e}",
"type": "error"
}
def find_matching_audio(transcription: Dict) -> Optional[str]:
"""Find audio file that matches the transcription timestamp."""
# Extract timestamp from transcription filename
filename = Path(transcription["filename"]).stem
parts = filename.split("_")
if len(parts) >= 3:
# Get the timestamp part (YYYYMMDD_HHMMSS)
timestamp = "_".join(parts[1:3])
# Look for audio files with similar timestamp
# Audio files are named like: tts_20250628_185848_123.mp3
for audio_file in AUDIO_DIR.glob("*"):
if timestamp in audio_file.name:
return str(audio_file)
return None
def read_jsonl_exchanges() -> List[Dict[str, Any]]:
"""Read exchanges from JSONL log files."""
exchanges = []
if not LOGS_DIR.exists():
return exchanges
# Read all JSONL files
for jsonl_file in sorted((LOGS_DIR / "conversations").glob("exchanges_*.jsonl")):
try:
with open(jsonl_file, 'r') as f:
for line in f:
if line.strip():
try:
entry = json.loads(line)
# Convert JSONL format to exchange format
exchange = {
"filepath": str(jsonl_file),
"filename": f"{entry['type']}_{entry['timestamp'].replace(':', '-')}.txt",
"metadata": {
"file_timestamp": entry.get("timestamp"),
"project_path": entry.get("project_path"),
"conversation_id": entry.get("conversation_id"),
"model": entry.get("metadata", {}).get("model"),
"voice": entry.get("metadata", {}).get("voice"),
"provider": entry.get("metadata", {}).get("provider"),
"timing": entry.get("metadata", {}).get("timing"),
},
"transcript": entry.get("text", ""),
"type": entry.get("type", "unknown"),
"audio_path": entry.get("audio_file")
}
exchanges.append(exchange)
except json.JSONDecodeError:
continue
except Exception as e:
print(f"Error reading JSONL file {jsonl_file}: {e}")
return exchanges
def get_all_exchanges() -> List[Dict]:
"""Get all exchanges from both transcription files and JSONL logs."""
# Check cache
current_time = time.time()
if (CACHE['conversations'] is not None and
current_time - CACHE['last_update'] < CACHE['cache_duration']):
return CACHE['conversations']
exchanges = []
# First, read from JSONL files (preferred source)
jsonl_exchanges = read_jsonl_exchanges()
exchanges.extend(jsonl_exchanges)
# Then, read from transcription files if they exist
if TRANSCRIPTIONS_DIR.exists():
# Get all transcription files
for filepath in sorted(TRANSCRIPTIONS_DIR.glob("*.txt"), reverse=True):
transcription = parse_transcription_file(filepath)
# Find matching audio
audio_path = find_matching_audio(transcription)
if audio_path:
transcription["audio_path"] = audio_path
exchanges.append(transcription)
# Update cache
CACHE['conversations'] = exchanges
CACHE['last_update'] = current_time
return exchanges
def group_by_project(exchanges: List[Dict]) -> Dict[str, List[Dict]]:
"""Group exchanges by project path."""
grouped = {}
for exchange in exchanges:
project = exchange["metadata"].get("project_path", "Unknown Project")
if project not in grouped:
grouped[project] = []
grouped[project].append(exchange)
# Sort exchanges within each project by timestamp (newest first)
for project_exchanges in grouped.values():
project_exchanges.sort(key=lambda x: x["metadata"].get("file_timestamp", ""), reverse=True)
return grouped
def group_exchanges_into_conversations(exchanges: List[Dict], gap_minutes: int = 5) -> List[Dict[str, any]]:
"""Group exchanges into conversations based on conversation_id or time gaps.
Args:
exchanges: List of exchange dictionaries
gap_minutes: Maximum minutes between exchanges to be considered same conversation
Returns:
List of conversation dictionaries containing grouped exchanges
"""
if not exchanges:
return []
# First, try to group by conversation_id
conversations_by_id = defaultdict(list)
exchanges_without_id = []
for exchange in exchanges:
conv_id = exchange["metadata"].get("conversation_id")
if conv_id:
conversations_by_id[conv_id].append(exchange)
else:
exchanges_without_id.append(exchange)
# Convert conversation_id groups to conversation format
conversations = []
for conv_id, conv_exchanges in conversations_by_id.items():
sorted_exch = sorted(conv_exchanges, key=lambda x: x["metadata"].get("file_timestamp", ""))
conversations.append({
"exchanges": sorted_exch,
"start_time": sorted_exch[0]["metadata"].get("file_timestamp", ""),
"end_time": sorted_exch[-1]["metadata"].get("file_timestamp", ""),
"project": sorted_exch[0]["metadata"].get("project_path", "Unknown Project"),
"conversation_id": conv_id
})
# Then handle exchanges without conversation_id using time-based grouping
if exchanges_without_id:
sorted_exchanges = sorted(exchanges_without_id, key=lambda x: x["metadata"].get("file_timestamp", ""))
current_conversation = {
"exchanges": [sorted_exchanges[0]],
"start_time": sorted_exchanges[0]["metadata"].get("file_timestamp", ""),
"project": sorted_exchanges[0]["metadata"].get("project_path", "Unknown Project")
}
for i in range(1, len(sorted_exchanges)):
exchange = sorted_exchanges[i]
prev_exchange = sorted_exchanges[i-1]
# Parse timestamps
try:
current_time = datetime.fromisoformat(exchange["metadata"].get("file_timestamp", ""))
prev_time = datetime.fromisoformat(prev_exchange["metadata"].get("file_timestamp", ""))
time_diff = (current_time - prev_time).total_seconds() / 60 # Convert to minutes
# Check if same project and within time gap
same_project = exchange["metadata"].get("project_path") == prev_exchange["metadata"].get("project_path")
if same_project and time_diff <= gap_minutes:
# Add to current conversation
current_conversation["exchanges"].append(exchange)
else:
# Start new conversation
current_conversation["end_time"] = prev_exchange["metadata"].get("file_timestamp", "")
conversations.append(current_conversation)
current_conversation = {
"exchanges": [exchange],
"start_time": exchange["metadata"].get("file_timestamp", ""),
"project": exchange["metadata"].get("project_path", "Unknown Project")
}
except:
# If timestamp parsing fails, start new conversation
current_conversation["end_time"] = prev_exchange["metadata"].get("file_timestamp", "")
conversations.append(current_conversation)
current_conversation = {
"exchanges": [exchange],
"start_time": exchange["metadata"].get("file_timestamp", ""),
"project": exchange["metadata"].get("project_path", "Unknown Project")
}
# Add last conversation
if current_conversation["exchanges"]:
current_conversation["end_time"] = current_conversation["exchanges"][-1]["metadata"].get("file_timestamp", "")
conversations.append(current_conversation)
# Calculate conversation summaries
for conv in conversations:
conv["exchange_count"] = len(conv["exchanges"])
# Get first few words from each exchange for summary
summaries = []
for ex in conv["exchanges"][:3]: # First 3 exchanges
transcript = ex.get("transcript", "").strip()
words = transcript.split()[:10] # First 10 words
if words:
summaries.append(" ".join(words) + "...")
conv["summary"] = " | ".join(summaries)
return conversations
def group_by_date(exchanges: List[Dict]) -> Dict[str, Dict[str, List[Dict]]]:
"""Group exchanges by date, then by project."""
grouped = {}
for exchange in exchanges:
# Extract date from timestamp
timestamp_str = exchange["metadata"].get("file_timestamp", "")
if timestamp_str:
try:
dt = datetime.fromisoformat(timestamp_str)
date_key = dt.strftime("%Y-%m-%d")
date_display = dt.strftime("%A, %B %d, %Y") # e.g., "Friday, June 28, 2024"
except:
date_key = "Unknown Date"
date_display = "Unknown Date"
else:
date_key = "Unknown Date"
date_display = "Unknown Date"
if date_key not in grouped:
grouped[date_key] = {
"display": date_display,
"exchanges": [],
"projects": set()
}
grouped[date_key]["exchanges"].append(exchange)
project = exchange["metadata"].get("project_path", "Unknown Project")
grouped[date_key]["projects"].add(project)
# Sort by date (newest first) - ensure proper date sorting
sorted_grouped = dict(sorted(grouped.items(), key=lambda x: x[0], reverse=True))
# Convert sets to lists for display
for date_data in sorted_grouped.values():
date_data["projects"] = sorted(list(date_data["projects"]))
return sorted_grouped
# HTML Template
HTML_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<title>Voice Mode Conversation Browser</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
h1 {
color: #333;
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
}
.date-group {
background: white;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.date-header {
background: #f8f9fa;
padding: 15px 20px;
cursor: pointer;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.2s;
}
.date-header:hover {
background: #e9ecef;
}
.date-header.expanded {
background: #007bff;
color: white;
}
.date-title {
font-size: 1.2em;
font-weight: bold;
}
.date-stats {
font-size: 0.9em;
opacity: 0.8;
}
.expand-hint {
font-size: 0.85em;
font-style: italic;
opacity: 0.7;
margin-left: 10px;
}
.date-content {
display: none;
padding: 20px;
}
.date-group.expanded .date-content {
display: block;
}
.project-section {
margin-bottom: 20px;
}
.project-title {
font-size: 1.1em;
font-weight: bold;
color: #007bff;
margin-bottom: 10px;
padding: 5px 0;
border-bottom: 1px solid #eee;
word-break: break-all;
}
.conversation {
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
margin-bottom: 10px;
background: #fafafa;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.conversation:hover {
background: #f0f0f0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.conversation.selected {
background: #e3f2fd;
border-color: #2196f3;
}
.play-button {
display: inline-block;
background: #4caf50;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
margin-right: 10px;
transition: background 0.2s;
}
.play-button:hover {
background: #45a049;
}
.play-button:disabled {
background: #ccc;
cursor: not-allowed;
}
.play-button.playing {
background: #ff5722;
}
.conversation-checkbox {
margin-right: 10px;
cursor: pointer;
}
.select-all-container {
margin-bottom: 10px;
padding: 10px;
background: #f0f0f0;
border-radius: 4px;
display: flex;
align-items: center;
gap: 15px;
}
.play-all-button {
background: #2196f3;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background 0.2s;
}
.play-all-button:hover {
background: #1976d2;
}
.play-all-button:disabled {
background: #ccc;
cursor: not-allowed;
}
.audio-controls {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.metadata {
font-size: 0.85em;
color: #666;
margin-bottom: 10px;
}
.transcript-preview {
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.full-transcript {
display: none;
margin-top: 15px;
padding: 15px;
background: white;
border-radius: 4px;
white-space: pre-wrap;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
line-height: 1.5;
}
.conversation.selected .full-transcript {
display: block;
}
.audio-player {
margin-top: 10px;
width: 100%;
}
.type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 0.8em;
font-weight: bold;
margin-right: 10px;
}
.type-conversation {
background: #4caf50;
color: white;
}
.type-stt {
background: #ff9800;
color: white;
}
.stats {
background: white;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.view-controls {
background: white;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
.view-button {
background: #f8f9fa;
border: 1px solid #ddd;
padding: 8px 16px;
margin: 0 5px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.view-button:hover {
background: #e9ecef;
}
.view-button.active {
background: #007bff;
color: white;
border-color: #007bff;
}
</style>
</head>
<body>
<h1>Voice Mode Conversation Browser</h1>
<div class="stats">
<strong>Total Conversations:</strong> {{ total_count }} |
<strong>Projects:</strong> {{ project_count }} |
<strong>Latest:</strong> {{ latest_date }}
</div>
<div class="view-controls">
<button class="view-button {% if view_mode == 'date' %}active{% endif %}"
onclick="window.location.href='/?view=date'">
Group by Date
</button>
<button class="view-button {% if view_mode == 'project' %}active{% endif %}"
onclick="window.location.href='/?view=project'">
Group by Project
</button>
<button class="view-button {% if view_mode == 'conversation' %}active{% endif %}"
onclick="window.location.href='/?view=conversation'">
Group by Conversation
</button>
</div>
{% if view_mode == 'date' %}
{% for date_key, date_data in grouped_exchanges.items() %}
<div class="date-group" id="date-{{ date_key }}">
<div class="date-header" onclick="toggleDateGroup(this)">
<div>
<div class="date-title">{{ date_data.display }}</div>
<div class="date-stats">
{{ date_data.conversations|length }} conversations |
{{ date_data.projects|length }} project{% if date_data.projects|length != 1 %}s{% endif %}
<span class="expand-hint">Click to expand</span>
</div>
</div>
</div>
<div class="date-content">
{% set conversations_by_project = {} %}
{% for conv in date_data.conversations %}
{% set project = conv.metadata.get('project_path', 'Unknown Project') %}
{% if project not in conversations_by_project %}
{% set _ = conversations_by_project.update({project: []}) %}
{% endif %}
{% set _ = conversations_by_project[project].append(conv) %}
{% endfor %}
{% for project, project_convs in conversations_by_project.items() %}
<div class="project-section">
<div class="project-title">{{ project }}</div>
{% for conv in project_exchanges %}
<div class="conversation exchange" onclick="toggleExchange(this)">
<div class="metadata">
<span class="type-badge type-{{ conv.type }}">{{ conv.type|upper }}</span>
{% if conv.metadata.file_timestamp %}
<strong>{{ conv.metadata.file_timestamp|format_timestamp }}</strong>
{% else %}
<strong>{{ conv.filename }}</strong>
{% endif %}
{% if conv.metadata.timing %}
| {{ conv.metadata.timing }}
{% endif %}
</div>
<div class="transcript-preview">
{{ conv.transcript[:200] }}{% if conv.transcript|length > 200 %}...{% endif %}
</div>
<div class="full-transcript">
{{ conv.transcript }}
{% if conv.audio_path %}
<audio class="audio-player" controls preload="none">
<source src="/audio/{{ conv.audio_path|basename }}" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% elif view_mode == 'project' %}
<!-- Project View -->
{% for project, project_exchanges in grouped_exchanges.items() %}
<div class="date-group expanded">
<div class="date-header expanded">
<div class="date-title">{{ project }}</div>
<div class="date-stats">{{ project_exchanges|length }} exchanges</div>
</div>
<div class="date-content" style="display: block;">
{% for conv in project_exchanges %}
<div class="conversation exchange" onclick="toggleExchange(this)">
<div class="metadata">
<span class="type-badge type-{{ conv.type }}">{{ conv.type|upper }}</span>
{% if conv.metadata.file_timestamp %}
<strong>{{ conv.metadata.file_timestamp|format_timestamp }}</strong>
{% else %}
<strong>{{ conv.filename }}</strong>
{% endif %}
{% if conv.metadata.timing %}
| {{ conv.metadata.timing }}
{% endif %}
</div>
<div class="transcript-preview">
{{ conv.transcript[:200] }}{% if conv.transcript|length > 200 %}...{% endif %}
</div>
<div class="full-transcript">
{{ conv.transcript }}
{% if conv.audio_path %}
<audio class="audio-player" controls>
<source src="/audio/{{ conv.audio_path|basename }}" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% elif view_mode == 'conversation' %}
<!-- Conversation View -->
{% for date_key, date_data in grouped_exchanges.items() %}
<div class="date-group" id="date-{{ date_key }}">
<div class="date-header" onclick="toggleDateGroup(this)">
<div>
<div class="date-title">{{ date_data.display }}</div>
<div class="date-stats">
{{ date_data.conversations|length }} conversation{% if date_data.conversations|length != 1 %}s{% endif %} |
{{ date_data.projects|length }} project{% if date_data.projects|length != 1 %}s{% endif %}
<span class="expand-hint">Click to expand</span>
</div>
</div>
</div>
<div class="date-content">
{% for conv in date_data.conversations %}
{% set conv_idx = loop.index0 %}
<div class="conversation-group" id="conv-{{ conv_idx }}" style="border: 2px solid #007bff; border-radius: 8px; padding: 15px; margin-bottom: 15px; background: #f0f8ff;">
<div class="select-all-container">
<input type="checkbox" class="select-all-checkbox" id="select-all-{{ conv_idx }}"
onchange="toggleSelectAll(this, {{ conv_idx }})" checked>
<label for="select-all-{{ conv_idx }}">Select All</label>
<button class="play-all-button" onclick="playConversation({{ conv_idx }})">
<span class="play-icon">ā¶</span> Play Conversation
</button>
</div>
<div style="font-weight: bold; color: #007bff; margin-bottom: 10px;">
Conversation ({{ conv.exchange_count }} exchanges) - {{ conv.project }}
<br>
<span style="font-size: 0.9em; color: #666;">
{{ conv.start_time|format_timestamp }} - {{ conv.end_time|format_timestamp }}
</span>
</div>
<div style="margin-bottom: 10px; font-style: italic; color: #555;">
{{ conv.summary }}
</div>
{% for exchange in conv.exchanges %}
{% set exchange_idx = loop.index0 %}
<div class="conversation exchange" data-audio-url="{% if exchange.audio_path %}/audio/{{ exchange.audio_path|basename }}{% endif %}"
data-conv-id="{{ conv_idx }}" data-exchange-id="{{ exchange_idx }}" style="margin-left: 20px;">
<div class="audio-controls">
<input type="checkbox" class="conversation-checkbox" id="checkbox-{{ conv_idx }}-{{ exchange_idx }}" checked
onclick="event.stopPropagation()">
{% if exchange.audio_path %}
<button class="play-button" onclick="event.stopPropagation(); playAudio(this, '/audio/{{ exchange.audio_path|basename }}')">
ā¶ Play
</button>
{% endif %}
</div>
<div class="metadata" onclick="toggleExchange(this.parentElement)">
<span class="type-badge type-{{ exchange.type }}">{{ exchange.type|upper }}</span>
<strong>{{ exchange.metadata.file_timestamp|format_timestamp }}</strong>
{% if exchange.metadata.timing %}
| {{ exchange.metadata.timing }}
{% endif %}
</div>
<div class="transcript-preview" onclick="toggleExchange(this.parentElement)">
{{ exchange.transcript[:200] }}{% if exchange.transcript|length > 200 %}...{% endif %}
</div>
<div class="full-transcript">
{{ exchange.transcript }}
{% if exchange.audio_path %}
<audio class="audio-player" controls preload="none">
<source src="/audio/{{ exchange.audio_path|basename }}" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% endif %}
<script>
let currentAudio = null;
let currentButton = null;
let playlistQueue = [];
let isPlayingPlaylist = false;
function toggleDateGroup(header) {
const dateGroup = header.parentElement;
dateGroup.classList.toggle('expanded');
header.classList.toggle('expanded');
}
function toggleConversation(element) {
// Remove selected class from all conversations
document.querySelectorAll('.conversation').forEach(el => {
if (el !== element) {
el.classList.remove('selected');
}
});
// Toggle selected class on clicked element
element.classList.toggle('selected');
}
function toggleExchange(element) {
// Remove selected class from all exchanges
document.querySelectorAll('.exchange').forEach(el => {
if (el !== element) {
el.classList.remove('selected');
}
});
// Toggle selected class on clicked element
element.classList.toggle('selected');
}
function toggleSelectAll(checkbox, convId) {
const conversationGroup = document.getElementById('conv-' + convId);
const checkboxes = conversationGroup.querySelectorAll('.conversation-checkbox');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
}
function playAudio(button, audioUrl) {
// If currently playing, stop it
if (currentAudio && currentButton === button) {
currentAudio.pause();
currentAudio = null;
currentButton = null;
button.innerHTML = 'ā¶ Play';
button.classList.remove('playing');
return;
}
// Stop any currently playing audio
if (currentAudio) {
currentAudio.pause();
if (currentButton) {
currentButton.innerHTML = 'ā¶ Play';
currentButton.classList.remove('playing');
}
}
// Create and play new audio
currentAudio = new Audio(audioUrl);
currentButton = button;
button.innerHTML = 'āø Pause';
button.classList.add('playing');
currentAudio.play().catch(error => {
console.error('Error playing audio:', error);
button.innerHTML = 'ā¶ Play';
button.classList.remove('playing');
});
currentAudio.onended = () => {
button.innerHTML = 'ā¶ Play';
button.classList.remove('playing');
currentButton = null;
currentAudio = null;
// If playing a playlist, play next
if (isPlayingPlaylist && playlistQueue.length > 0) {
playNextInPlaylist();
}
};
}
function playConversation(convId) {
const conversationGroup = document.getElementById('conv-' + convId);
const playButton = conversationGroup.querySelector('.play-all-button');
// Stop any current playback
if (currentAudio) {
currentAudio.pause();
if (currentButton) {
currentButton.innerHTML = 'ā¶ Play';
currentButton.classList.remove('playing');
}
currentAudio = null;
currentButton = null;
}
// Get all checked exchanges with audio
const exchanges = conversationGroup.querySelectorAll('.exchange');
playlistQueue = [];
exchanges.forEach(exchange => {
const checkbox = exchange.querySelector('.conversation-checkbox');
const audioUrl = exchange.getAttribute('data-audio-url');
if (checkbox && checkbox.checked && audioUrl) {
playlistQueue.push({
url: audioUrl,
button: exchange.querySelector('.play-button')
});
}
});
if (playlistQueue.length === 0) {
alert('No audio files selected to play');
return;
}
// Start playing playlist
isPlayingPlaylist = true;
playButton.innerHTML = 'āø Stop All';
playButton.onclick = () => stopPlaylist(convId);
playNextInPlaylist();
}
function playNextInPlaylist() {
if (playlistQueue.length === 0) {
isPlayingPlaylist = false;
// Reset all play buttons
document.querySelectorAll('.play-all-button').forEach(btn => {
btn.innerHTML = '<span class="play-icon">ā¶</span> Play Conversation';
btn.onclick = function() {
const convId = this.closest('.conversation-group').id.replace('conv-', '');
playConversation(convId);
};
});
return;
}
const next = playlistQueue.shift();
if (next.button) {
playAudio(next.button, next.url);
} else {
// Play without button animation
currentAudio = new Audio(next.url);
currentAudio.play().catch(error => {
console.error('Error playing audio:', error);
if (playlistQueue.length > 0) {
playNextInPlaylist();
}
});
currentAudio.onended = () => {
if (isPlayingPlaylist && playlistQueue.length > 0) {
playNextInPlaylist();
} else {
isPlayingPlaylist = false;
}
};
}
}
function stopPlaylist(convId) {
isPlayingPlaylist = false;
playlistQueue = [];
if (currentAudio) {
currentAudio.pause();
currentAudio = null;
}
if (currentButton) {
currentButton.innerHTML = 'ā¶ Play';
currentButton.classList.remove('playing');
currentButton = null;
}
const conversationGroup = document.getElementById('conv-' + convId);
const playButton = conversationGroup.querySelector('.play-all-button');
playButton.innerHTML = '<span class="play-icon">ā¶</span> Play Conversation';
playButton.onclick = () => playConversation(convId);
}
// Auto-expand today's conversations
window.onload = function() {
const today = new Date().toISOString().split('T')[0];
const todayGroup = document.getElementById('date-' + today);
if (todayGroup) {
todayGroup.classList.add('expanded');
todayGroup.querySelector('.date-header').classList.add('expanded');
}
};
</script>
</body>
</html>
"""
@app.template_filter('format_timestamp')
def format_timestamp(timestamp_str):
"""Format ISO timestamp to readable format."""
try:
dt = datetime.fromisoformat(timestamp_str)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except:
return timestamp_str
@app.template_filter('basename')
def basename(path):
"""Get basename of a path."""
return os.path.basename(path)
@app.route('/')
def index():
"""Main page showing all exchanges."""
view_mode = request.args.get('view', 'date') # Default to date view
exchanges = get_all_exchanges()
# Group based on view mode
if view_mode == 'project':
grouped = group_by_project(exchanges)
elif view_mode == 'conversation':
# Group exchanges into conversations
conversations = group_exchanges_into_conversations(exchanges)
# Then group conversations by date
grouped = {}
for conv in conversations:
# Extract date from start time
try:
dt = datetime.fromisoformat(conv["start_time"])
date_key = dt.strftime("%Y-%m-%d")
date_display = dt.strftime("%A, %B %d, %Y")
except:
date_key = "Unknown Date"
date_display = "Unknown Date"
if date_key not in grouped:
grouped[date_key] = {
"display": date_display,
"conversations": [],
"projects": set()
}
grouped[date_key]["conversations"].append(conv)
grouped[date_key]["projects"].add(conv["project"])
# Convert sets to lists
for date_data in grouped.values():
date_data["projects"] = sorted(list(date_data["projects"]))
# Sort by date (newest first)
grouped = dict(sorted(grouped.items(), key=lambda x: x[0], reverse=True))
else:
grouped = group_by_date(exchanges)
# Calculate stats
total_count = len(exchanges)
project_count = len(set(exchange["metadata"].get("project_path", "Unknown")
for exchange in exchanges))
latest_date = "N/A"
if exchanges:
latest = max(exchanges,
key=lambda x: x["metadata"].get("file_timestamp", ""))
if "file_timestamp" in latest["metadata"]:
latest_date = format_timestamp(latest["metadata"]["file_timestamp"])
return render_template_string(
HTML_TEMPLATE,
grouped_exchanges=grouped,
total_count=total_count,
project_count=project_count,
latest_date=latest_date,
view_mode=view_mode
)
@app.route('/audio/<filename>')
def serve_audio(filename):
"""Serve audio files."""
# Try new path structure first
audio_path = get_audio_path(filename, AUDIO_DIR)
if audio_path.exists():
return send_file(audio_path)
# Fall back to flat structure for older files
audio_path = AUDIO_DIR / filename
if audio_path.exists():
return send_file(audio_path)
return "Audio file not found", 404
@app.route('/api/conversations')
def api_conversations():
"""API endpoint to get all conversations as JSON."""
conversations = get_all_conversations()
return jsonify(conversations)
if __name__ == '__main__':
print(f"Starting Voice Mode Conversation Browser...")
print(f"Base directory: {BASE_DIR}")
print(f"Transcriptions: {TRANSCRIPTIONS_DIR}")
print(f"Audio files: {AUDIO_DIR}")
print(f"\nOpen http://localhost:5000 in your browser\n")
app.run(debug=True, host='0.0.0.0', port=5000)