From f4c7587eebc20ac7e7413c19c407e86c17002669 Mon Sep 17 00:00:00 2001 From: RyanAI Date: Wed, 11 Mar 2026 11:25:47 +0800 Subject: [PATCH] feat: Add Web UI for Email Inbox - Added static HTML/CSS/JS frontend for browsing emails - Implemented inbox view with message list - Added message detail view - Added compose/send email functionality - API key stored in localStorage - Dark mode theme - Responsive design for mobile Resolves: dmb4086/agentwork-infrastructure#1 --- app/main.py | 17 +++ app/static/app.js | 271 ++++++++++++++++++++++++++++++++++++++ app/static/index.html | 84 ++++++++++++ app/static/styles.css | 297 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 669 insertions(+) create mode 100644 app/static/app.js create mode 100644 app/static/index.html create mode 100644 app/static/styles.css diff --git a/app/main.py b/app/main.py index 5bd191b..85bdfb6 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ from fastapi import FastAPI, Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session from typing import List import boto3 @@ -9,6 +10,7 @@ from app.db.database import get_db, engine, Base from app.models import models from app.schemas import schemas +import os # Create tables Base.metadata.create_all(bind=engine) @@ -17,6 +19,21 @@ security = HTTPBearer() settings = get_settings() +# Mount static files for Web UI +static_dir = os.path.join(os.path.dirname(__file__), "static") +if os.path.exists(static_dir): + app.mount("/static", StaticFiles(directory=static_dir), name="static") + +# Serve index.html at root +@app.get("/") +def serve_index(): + """Serve the Web UI.""" + index_path = os.path.join(static_dir, "index.html") + if os.path.exists(index_path): + from fastapi.responses import FileResponse + return FileResponse(index_path) + return {"message": "Agent Suite API", "docs": "/docs"} + def get_inbox_by_api_key(api_key: str, db: Session): return db.query(models.Inbox).filter( diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..b0024c1 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,271 @@ +// Agent Suite - Web UI JavaScript + +const API_BASE = window.location.origin; + +// State +let apiKey = localStorage.getItem('agent_suite_api_key') || ''; +let messages = []; + +// DOM Elements +const messageListEl = document.getElementById('messageList'); +const messageDetailEl = document.getElementById('messageDetail'); +const apiKeyModal = document.getElementById('apiKeyModal'); +const composeModal = document.getElementById('composeModal'); +const emailAddressEl = document.getElementById('emailAddress'); + +// Initialize +document.addEventListener('DOMContentLoaded', () => { + if (!apiKey) { + showApiKeyModal(); + } else { + loadMessages(); + loadInboxInfo(); + } + setupEventListeners(); +}); + +function setupEventListeners() { + // API Key Modal + document.getElementById('saveApiKey').addEventListener('click', saveApiKey); + document.getElementById('cancelApiKey').addEventListener('click', () => { + if (!apiKey) { + showError('API key is required'); + } + hideModal(apiKeyModal); + }); + + // Settings Button + document.getElementById('settingsBtn').addEventListener('click', () => { + document.getElementById('apiKeyInput').value = apiKey; + showModal(apiKeyModal); + }); + + // Compose Button + document.getElementById('composeBtn').addEventListener('click', () => { + showModal(composeModal); + }); + + // Compose Form + document.getElementById('composeForm').addEventListener('submit', sendEmail); + document.getElementById('cancelCompose').addEventListener('click', () => { + hideModal(composeModal); + }); + + // Back Button + document.getElementById('backBtn').addEventListener('click', () => { + messageDetailEl.classList.add('hidden'); + messageListEl.classList.remove('hidden'); + }); +} + +function getHeaders() { + return { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }; +} + +async function loadInboxInfo() { + try { + const response = await fetch(`${API_BASE}/v1/inboxes/me`, { + headers: getHeaders() + }); + + if (response.status === 401) { + showApiKeyModal(); + return; + } + + const data = await response.json(); + emailAddressEl.textContent = data.email_address; + } catch (error) { + console.error('Failed to load inbox info:', error); + } +} + +async function loadMessages() { + messageListEl.innerHTML = '
Loading messages...
'; + + try { + const response = await fetch(`${API_BASE}/v1/inboxes/me/messages`, { + headers: getHeaders() + }); + + if (response.status === 401) { + showApiKeyModal(); + return; + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + messages = data.messages; + renderMessages(messages); + } catch (error) { + showError(`Failed to load messages: ${error.message}`); + } +} + +function renderMessages(messageList) { + if (!messageList || messageList.length === 0) { + messageListEl.innerHTML = ` +
+

📭 No messages yet

+

Your inbox is empty. Send a message to get started!

+
+ `; + return; + } + + messageListEl.innerHTML = messageList.map(msg => ` +
+
${escapeHtml(msg.subject || '(No Subject)')}
+
${escapeHtml(msg.body_text || '').substring(0, 100)}
+
+ ${escapeHtml(msg.sender)} + ${formatDate(msg.received_at)} +
+
+ `).join(''); + + // Add click handlers + document.querySelectorAll('.message-item').forEach(item => { + item.addEventListener('click', () => { + const msgId = item.dataset.id; + showMessageDetail(msgId); + }); + }); +} + +async function showMessageDetail(msgId) { + const msg = messages.find(m => m.id === msgId); + if (!msg) return; + + // Mark as read + if (!msg.is_read) { + msg.is_read = true; + // Optionally call API to mark as read + } + + document.getElementById('detailSubject').textContent = msg.subject || '(No Subject)'; + document.getElementById('detailFrom').textContent = msg.sender; + document.getElementById('detailTo').textContent = msg.recipient; + document.getElementById('detailDate').textContent = formatDate(msg.received_at); + document.getElementById('detailBody').textContent = msg.body_text || '(No content)'; + + messageListEl.classList.add('hidden'); + messageDetailEl.classList.remove('hidden'); +} + +async function sendEmail(e) { + e.preventDefault(); + + const to = document.getElementById('toInput').value; + const subject = document.getElementById('subjectInput').value; + const body = document.getElementById('bodyInput').value; + + const submitBtn = e.target.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.textContent = 'Sending...'; + + try { + const response = await fetch(`${API_BASE}/v1/inboxes/me/send`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify({ to, subject, body }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to send'); + } + + const result = await response.json(); + + // Show success and close modal + hideModal(composeModal); + e.target.reset(); + + // Show success message + const successEl = document.createElement('div'); + successEl.className = 'success'; + successEl.textContent = `✓ Message sent! Message ID: ${result.message_id}`; + messageListEl.insertBefore(successEl, messageListEl.firstChild); + + // Refresh messages + loadMessages(); + + } catch (error) { + showError(`Failed to send: ${error.message}`); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = 'Send'; + } +} + +function saveApiKey() { + const input = document.getElementById('apiKeyInput').value.trim(); + if (!input) { + showError('Please enter a valid API key'); + return; + } + + apiKey = input; + localStorage.setItem('agent_suite_api_key', apiKey); + hideModal(apiKeyModal); + loadMessages(); + loadInboxInfo(); +} + +function showApiKeyModal() { + document.getElementById('apiKeyInput').value = apiKey; + showModal(apiKeyModal); +} + +function showModal(modal) { + modal.classList.remove('hidden'); +} + +function hideModal(modal) { + modal.classList.add('hidden'); +} + +function showError(message) { + const errorEl = document.createElement('div'); + errorEl.className = 'error'; + errorEl.textContent = message; + messageListEl.insertBefore(errorEl, messageListEl.firstChild); + + // Auto-remove after 5 seconds + setTimeout(() => errorEl.remove(), 5000); +} + +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function formatDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + const now = new Date(); + const diff = now - date; + + // Less than 24 hours + if (diff < 86400000) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + // Less than 7 days + if (diff < 604800000) { + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + return days[date.getDay()] + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + // Older + return date.toLocaleDateString(); +} diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..cc2b169 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,84 @@ + + + + + + Agent Suite - Inbox + + + +
+
+

📧 Agent Suite

+
+ + +
+
+ +
+
+

Inbox

+ +
+ +
+
Loading messages...
+
+ + +
+
+ + + + + + + + + + diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..84bbf50 --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,297 @@ +/* Dark Mode Theme */ +:root { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --bg-tertiary: #0f3460; + --text-primary: #e8e8e8; + --text-secondary: #a0a0a0; + --accent: #e94560; + --accent-hover: #ff6b6b; + --border: #2a2a4a; + --success: #4caf50; + --error: #f44336; + --warning: #ff9800; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; +} + +.app-container { + max-width: 900px; + margin: 0 auto; + padding: 20px; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 0; + border-bottom: 1px solid var(--border); + margin-bottom: 20px; +} + +header h1 { + font-size: 1.5rem; +} + +.header-actions { + display: flex; + gap: 10px; +} + +.btn { + padding: 10px 20px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s ease; +} + +.btn-primary { + background: var(--accent); + color: white; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.btn-secondary:hover { + background: var(--border); +} + +.inbox-header { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 20px; +} + +.email-badge { + background: var(--bg-tertiary); + padding: 5px 12px; + border-radius: 20px; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.message-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.message-item { + background: var(--bg-secondary); + padding: 15px; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid transparent; +} + +.message-item:hover { + border-color: var(--accent); + transform: translateX(5px); +} + +.message-item.unread { + border-left: 3px solid var(--accent); +} + +.message-item .subject { + font-weight: 600; + margin-bottom: 5px; +} + +.message-item .preview { + color: var(--text-secondary); + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.message-item .meta { + display: flex; + justify-content: space-between; + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 8px; +} + +.message-detail { + background: var(--bg-secondary); + padding: 20px; + border-radius: 8px; +} + +.hidden { + display: none !important; +} + +.detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin: 20px 0; + padding-bottom: 15px; + border-bottom: 1px solid var(--border); +} + +.detail-header h3 { + font-size: 1.3rem; +} + +.detail-meta { + margin: 15px 0; + color: var(--text-secondary); +} + +.detail-meta div { + margin: 8px 0; +} + +.detail-body { + margin-top: 20px; + line-height: 1.6; + white-space: pre-wrap; +} + +.loading { + text-align: center; + padding: 40px; + color: var(--text-secondary); +} + +.error { + background: rgba(244, 67, 54, 0.1); + border: 1px solid var(--error); + padding: 15px; + border-radius: 8px; + color: var(--error); + margin: 10px 0; +} + +.success { + background: rgba(76, 175, 80, 0.1); + border: 1px solid var(--success); + padding: 15px; + border-radius: 8px; + color: var(--success); + margin: 10px 0; +} + +/* Modal */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: var(--bg-secondary); + padding: 30px; + border-radius: 12px; + width: 90%; + max-width: 500px; +} + +.modal-content h3 { + margin-bottom: 15px; +} + +.modal-content p { + color: var(--text-secondary); + margin-bottom: 20px; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 500; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 12px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-size: 1rem; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--accent); +} + +.modal-actions { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); +} + +.empty-state h3 { + margin-bottom: 10px; +} + +/* Mobile Responsive */ +@media (max-width: 600px) { + .app-container { + padding: 10px; + } + + header { + flex-direction: column; + gap: 15px; + align-items: flex-start; + } + + .header-actions { + width: 100%; + justify-content: flex-end; + } + + .detail-header { + flex-direction: column; + gap: 10px; + } +}