add viewer

This commit is contained in:
2026-01-30 11:44:06 +08:00
parent 552380172c
commit 6384b6bd9d
2 changed files with 524 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import List, Dict, Optional
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
# Ensure we can import from project root
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lang_agent.components.conv_store import ConversationStore
app = FastAPI(
title="Conversation Viewer",
description="Web UI to view conversations from the database",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize conversation store
try:
conv_store = ConversationStore()
except ValueError as e:
print(f"Warning: {e}. Make sure CONN_STR environment variable is set.")
conv_store = None
class MessageResponse(BaseModel):
message_type: str
content: str
sequence_number: int
created_at: str
class ConversationListItem(BaseModel):
conversation_id: str
message_count: int
last_updated: Optional[str] = None
@app.get("/", response_class=HTMLResponse)
async def root():
"""Serve the main HTML page"""
html_path = Path(__file__).parent.parent / "static" / "viewer.html"
if html_path.exists():
return HTMLResponse(content=html_path.read_text(encoding="utf-8"))
else:
return HTMLResponse(content="<h1>Viewer HTML not found. Please create static/viewer.html</h1>")
@app.get("/api/conversations", response_model=List[ConversationListItem])
async def list_conversations():
"""Get list of all conversations"""
if conv_store is None:
raise HTTPException(status_code=500, detail="Database connection not configured")
import psycopg
conn_str = os.environ.get("CONN_STR")
if not conn_str:
raise HTTPException(status_code=500, detail="CONN_STR not set")
with psycopg.connect(conn_str) as conn:
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
# Get all unique conversation IDs with message counts and last updated time
cur.execute("""
SELECT
conversation_id,
COUNT(*) as message_count,
MAX(created_at) as last_updated
FROM messages
GROUP BY conversation_id
ORDER BY last_updated DESC
""")
results = cur.fetchall()
return [
ConversationListItem(
conversation_id=row["conversation_id"],
message_count=row["message_count"],
last_updated=row["last_updated"].isoformat() if row["last_updated"] else None
)
for row in results
]
@app.get("/api/conversations/{conversation_id}/messages", response_model=List[MessageResponse])
async def get_conversation_messages(conversation_id: str):
"""Get all messages for a specific conversation"""
if conv_store is None:
raise HTTPException(status_code=500, detail="Database connection not configured")
messages = conv_store.get_conversation(conversation_id)
return [
MessageResponse(
message_type=msg["message_type"],
content=msg["content"],
sequence_number=msg["sequence_number"],
created_at=msg["created_at"].isoformat() if msg["created_at"] else ""
)
for msg in messages
]
@app.get("/health")
async def health():
return {"status": "healthy", "db_connected": conv_store is not None}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"server_viewer:app",
host="0.0.0.0",
port=8590,
reload=True,
)

394
static/viewer.html Normal file
View File

@@ -0,0 +1,394 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Conversation Viewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
height: 100vh;
display: flex;
background-color: #f5f5f5;
}
/* Left Sidebar - Conversation List */
.sidebar {
width: 300px;
background-color: #2c3e50;
color: white;
display: flex;
flex-direction: column;
border-right: 1px solid #34495e;
}
.sidebar-header {
padding: 20px;
background-color: #34495e;
border-bottom: 1px solid #2c3e50;
}
.sidebar-header h1 {
font-size: 20px;
font-weight: 600;
}
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.conversation-item {
padding: 12px 15px;
margin-bottom: 8px;
background-color: #34495e;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.conversation-item:hover {
background-color: #3d566e;
transform: translateX(2px);
}
.conversation-item.active {
background-color: #3498db;
border-color: #2980b9;
}
.conversation-id {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
word-break: break-all;
}
.conversation-meta {
font-size: 12px;
opacity: 0.8;
display: flex;
justify-content: space-between;
align-items: center;
}
.message-count {
background-color: rgba(255, 255, 255, 0.2);
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
}
/* Main Content Area */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background-color: #ffffff;
}
.chat-header {
padding: 20px;
background-color: #ecf0f1;
border-bottom: 1px solid #bdc3c7;
}
.chat-header h2 {
font-size: 18px;
color: #2c3e50;
}
.chat-header .conversation-id-display {
font-size: 14px;
color: #7f8c8d;
margin-top: 4px;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 20px;
background: linear-gradient(to bottom, #f8f9fa, #ffffff);
}
.message {
margin-bottom: 16px;
display: flex;
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message.human {
justify-content: flex-end;
}
.message.ai,
.message.tool {
justify-content: flex-start;
}
.message-bubble {
max-width: 70%;
padding: 12px 16px;
border-radius: 18px;
word-wrap: break-word;
position: relative;
}
.message.human .message-bubble {
background-color: #3498db;
color: white;
border-bottom-right-radius: 4px;
}
.message.ai .message-bubble {
background-color: #e8f5e9;
color: #2c3e50;
border-bottom-left-radius: 4px;
}
.message.tool .message-bubble {
background-color: #fff3e0;
color: #2c3e50;
border-bottom-left-radius: 4px;
border-left: 3px solid #ff9800;
}
.message-type-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 4px;
opacity: 0.7;
}
.message-content {
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
}
.message-time {
font-size: 11px;
opacity: 0.6;
margin-top: 4px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #95a5a6;
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 16px;
}
.loading {
text-align: center;
padding: 40px;
color: #7f8c8d;
}
.error {
background-color: #fee;
color: #c33;
padding: 12px;
margin: 10px;
border-radius: 4px;
border-left: 4px solid #c33;
}
/* Scrollbar styling */
.conversation-list::-webkit-scrollbar,
.messages-container::-webkit-scrollbar {
width: 8px;
}
.conversation-list::-webkit-scrollbar-track,
.messages-container::-webkit-scrollbar-track {
background: #2c3e50;
}
.conversation-list::-webkit-scrollbar-thumb {
background: #34495e;
border-radius: 4px;
}
.messages-container::-webkit-scrollbar-track {
background: #f8f9fa;
}
.messages-container::-webkit-scrollbar-thumb {
background: #bdc3c7;
border-radius: 4px;
}
</style>
</head>
<body>
<!-- Left Sidebar -->
<div class="sidebar">
<div class="sidebar-header">
<h1>💬 Conversations</h1>
</div>
<div class="conversation-list" id="conversationList">
<div class="loading">Loading conversations...</div>
</div>
</div>
<!-- Main Content -->
<div class="main-content">
<div class="chat-header">
<h2>Conversation Messages</h2>
<div class="conversation-id-display" id="conversationIdDisplay">Select a conversation to view messages</div>
</div>
<div class="messages-container" id="messagesContainer">
<div class="empty-state">
<div class="empty-state-icon">👈</div>
<div>Select a conversation from the left to view messages</div>
</div>
</div>
</div>
<script>
const API_BASE = window.location.origin;
let currentConversationId = null;
// Load conversations on page load
async function loadConversations() {
const listEl = document.getElementById('conversationList');
try {
const response = await fetch(`${API_BASE}/api/conversations`);
if (!response.ok) throw new Error('Failed to load conversations');
const conversations = await response.json();
if (conversations.length === 0) {
listEl.innerHTML = '<div class="loading">No conversations found</div>';
return;
}
listEl.innerHTML = conversations.map(conv => `
<div class="conversation-item" data-id="${conv.conversation_id}">
<div class="conversation-id">${conv.conversation_id}</div>
<div class="conversation-meta">
<span>${formatDate(conv.last_updated)}</span>
<span class="message-count">${conv.message_count} msgs</span>
</div>
</div>
`).join('');
// Add click handlers
listEl.querySelectorAll('.conversation-item').forEach(item => {
item.addEventListener('click', () => {
const convId = item.dataset.id;
selectConversation(convId);
});
});
// Select first conversation by default
if (conversations.length > 0) {
selectConversation(conversations[0].conversation_id);
}
} catch (error) {
listEl.innerHTML = `<div class="error">Error loading conversations: ${error.message}</div>`;
console.error('Error loading conversations:', error);
}
}
// Select a conversation and load its messages
async function selectConversation(conversationId) {
currentConversationId = conversationId;
// Update UI
document.querySelectorAll('.conversation-item').forEach(item => {
item.classList.toggle('active', item.dataset.id === conversationId);
});
document.getElementById('conversationIdDisplay').textContent = conversationId;
const container = document.getElementById('messagesContainer');
container.innerHTML = '<div class="loading">Loading messages...</div>';
try {
const response = await fetch(`${API_BASE}/api/conversations/${conversationId}/messages`);
if (!response.ok) throw new Error('Failed to load messages');
const messages = await response.json();
if (messages.length === 0) {
container.innerHTML = '<div class="empty-state"><div>No messages in this conversation</div></div>';
return;
}
container.innerHTML = messages.map(msg => {
const isHuman = msg.message_type === 'human';
const isTool = msg.message_type === 'tool';
const alignClass = isHuman ? 'human' : (isTool ? 'tool' : 'ai');
const typeLabel = isTool ? 'Tool' : (isHuman ? 'You' : 'AI');
return `
<div class="message ${alignClass}">
<div class="message-bubble">
<div class="message-type-label">${typeLabel}</div>
<div class="message-content">${escapeHtml(msg.content)}</div>
<div class="message-time">${formatDate(msg.created_at)}</div>
</div>
</div>
`;
}).join('');
// Scroll to bottom
container.scrollTop = container.scrollHeight;
} catch (error) {
container.innerHTML = `<div class="error">Error loading messages: ${error.message}</div>`;
console.error('Error loading messages:', error);
}
}
// Utility functions
function formatDate(dateString) {
if (!dateString) return 'Unknown';
const date = new Date(dateString);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize on page load
loadConversations();
</script>
</body>
</html>