Spaces:
Configuration error
Configuration error
| <html lang="en" dir="rtl"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>🎮 AI Catan Viewer - Dynamic</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| background: #1e1e1e; | |
| color: #d4d4d4; | |
| height: 100vh; | |
| overflow: hidden; | |
| direction: ltr; | |
| } | |
| .container { | |
| display: flex; | |
| height: 100vh; | |
| } | |
| /* Sidebar */ | |
| .sidebar { | |
| width: 280px; | |
| background: #252526; | |
| border-right: 1px solid #3e3e42; | |
| display: flex; | |
| flex-direction: column; | |
| overflow-y: auto; | |
| } | |
| .sidebar-header { | |
| padding: 20px; | |
| background: #007acc; | |
| color: white; | |
| font-weight: bold; | |
| font-size: 18px; | |
| } | |
| .sidebar-section { | |
| padding: 15px; | |
| border-bottom: 1px solid #3e3e42; | |
| } | |
| .sidebar-section h3 { | |
| font-size: 12px; | |
| color: #858585; | |
| text-transform: uppercase; | |
| margin-bottom: 10px; | |
| font-weight: 600; | |
| } | |
| .nav-item { | |
| padding: 10px 15px; | |
| margin: 5px 0; | |
| border-radius: 5px; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| position: relative; | |
| } | |
| .nav-item:hover { | |
| background: #2a2d2e; | |
| } | |
| .nav-item.active { | |
| background: #37373d; | |
| border-left: 3px solid #007acc; | |
| } | |
| .nav-item .icon { | |
| font-size: 18px; | |
| } | |
| .badge { | |
| margin-left: auto; | |
| background: #007acc; | |
| padding: 2px 8px; | |
| border-radius: 10px; | |
| font-size: 11px; | |
| transition: all 0.3s; | |
| } | |
| .badge.new { | |
| background: #f48771; | |
| animation: pulse-badge 2s infinite; | |
| } | |
| @keyframes pulse-badge { | |
| 0%, 100% { opacity: 1; transform: scale(1); } | |
| 50% { opacity: 0.8; transform: scale(1.05); } | |
| } | |
| /* New item animations */ | |
| .fade-in { | |
| animation: fadeIn 0.5s ease-in; | |
| } | |
| @keyframes fadeIn { | |
| from { | |
| opacity: 0; | |
| transform: translateY(-10px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| .highlight-new { | |
| animation: highlightFlash 2s ease-in-out; | |
| } | |
| @keyframes highlightFlash { | |
| 0% { | |
| background: rgba(244, 135, 113, 0.3); | |
| box-shadow: 0 0 20px rgba(244, 135, 113, 0.4); | |
| } | |
| 100% { | |
| background: transparent; | |
| box-shadow: none; | |
| } | |
| } | |
| /* Update notification */ | |
| .update-notification { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| background: #0e8a0e; | |
| color: white; | |
| padding: 12px 20px; | |
| border-radius: 6px; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); | |
| display: none; | |
| align-items: center; | |
| gap: 10px; | |
| z-index: 1001; | |
| font-weight: 500; | |
| } | |
| .update-notification.show { | |
| display: flex; | |
| animation: slideInNotification 0.3s ease-out; | |
| } | |
| @keyframes slideInNotification { | |
| from { | |
| transform: translateX(400px); | |
| opacity: 0; | |
| } | |
| to { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| } | |
| /* Refreshing indicator */ | |
| .refreshing-indicator { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| height: 3px; | |
| background: linear-gradient(90deg, #007acc 0%, #4fc1ff 50%, #007acc 100%); | |
| background-size: 200% 100%; | |
| animation: shimmer 1.5s infinite; | |
| z-index: 1000; | |
| opacity: 0; | |
| transition: opacity 0.3s; | |
| } | |
| .refreshing-indicator.active { | |
| opacity: 1; | |
| } | |
| @keyframes shimmer { | |
| 0% { background-position: 200% 0; } | |
| 100% { background-position: -200% 0; } | |
| } | |
| /* Main content */ | |
| .content { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| .content-header { | |
| padding: 20px; | |
| background: #2d2d30; | |
| border-bottom: 1px solid #3e3e42; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .content-header h1 { | |
| font-size: 24px; | |
| color: #cccccc; | |
| } | |
| .header-actions { | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| } | |
| .btn { | |
| padding: 8px 16px; | |
| border-radius: 5px; | |
| border: none; | |
| cursor: pointer; | |
| font-size: 12px; | |
| font-weight: bold; | |
| transition: all 0.2s; | |
| } | |
| .btn-primary { | |
| background: #007acc; | |
| color: white; | |
| } | |
| .btn-primary:hover { | |
| background: #005a9e; | |
| } | |
| .status-badge { | |
| padding: 8px 16px; | |
| border-radius: 20px; | |
| font-size: 12px; | |
| font-weight: bold; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .status-badge.live { | |
| background: #0e8a0e; | |
| color: white; | |
| } | |
| .status-badge.manual { | |
| background: #858585; | |
| color: white; | |
| } | |
| .status-badge:hover { | |
| transform: scale(1.05); | |
| } | |
| .status-badge .pulse { | |
| width: 8px; | |
| height: 8px; | |
| background: white; | |
| border-radius: 50%; | |
| animation: pulse 2s infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.3; } | |
| } | |
| .content-body { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 20px; | |
| } | |
| /* Streaming Status Box */ | |
| .streaming-container { | |
| background: #1e1e1e; | |
| border: 2px solid #007acc; | |
| border-radius: 8px; | |
| margin-bottom: 15px; | |
| padding: 15px; | |
| animation: pulse-border 2s infinite; | |
| } | |
| .streaming-container.done { | |
| border-color: #4ec9b0; | |
| animation: none; | |
| } | |
| @keyframes pulse-border { | |
| 0%, 100% { border-color: #007acc; box-shadow: 0 0 5px rgba(0, 122, 204, 0.3); } | |
| 50% { border-color: #4fc1ff; box-shadow: 0 0 15px rgba(79, 193, 255, 0.5); } | |
| } | |
| .streaming-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-bottom: 12px; | |
| padding-bottom: 10px; | |
| border-bottom: 1px solid #3e3e42; | |
| } | |
| .streaming-header .player-name { | |
| font-weight: bold; | |
| color: #4fc1ff; | |
| font-size: 16px; | |
| } | |
| .streaming-header .status-indicator { | |
| display: inline-block; | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| background: #007acc; | |
| animation: blink-status 1.5s infinite; | |
| } | |
| .streaming-header .status-indicator.done { | |
| background: #4ec9b0; | |
| animation: none; | |
| } | |
| @keyframes blink-status { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.3; } | |
| } | |
| .streaming-content { | |
| max-height: 400px; | |
| overflow-y: auto; | |
| } | |
| .stream-chunk { | |
| margin-bottom: 10px; | |
| padding: 10px; | |
| border-radius: 4px; | |
| animation: fade-in-chunk 0.3s ease-out; | |
| } | |
| @keyframes fade-in-chunk { | |
| from { opacity: 0; transform: translateY(-5px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .stream-thought { | |
| background: #2d2d30; | |
| border-left: 3px solid #c586c0; | |
| color: #c586c0; | |
| font-style: italic; | |
| } | |
| .stream-text { | |
| background: #252526; | |
| border-left: 3px solid #4ec9b0; | |
| color: #d4d4d4; | |
| } | |
| .stream-function { | |
| background: #2d2d30; | |
| border-left: 3px solid #f48771; | |
| color: #f48771; | |
| font-family: 'Consolas', monospace; | |
| font-size: 13px; | |
| } | |
| .stream-status { | |
| background: #1e1e1e; | |
| border-left: 3px solid #4fc1ff; | |
| color: #4fc1ff; | |
| text-align: center; | |
| font-weight: bold; | |
| } | |
| /* Request Cards */ | |
| .request-card { | |
| background: #252526; | |
| border: 1px solid #3e3e42; | |
| border-radius: 8px; | |
| margin-bottom: 10px; | |
| overflow: hidden; | |
| transition: all 0.2s; | |
| } | |
| .request-card.new { | |
| border-left: 4px solid #f48771; | |
| box-shadow: 0 0 10px rgba(244, 135, 113, 0.2); | |
| } | |
| .request-header { | |
| padding: 15px 20px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| gap: 15px; | |
| background: #2d2d30; | |
| transition: background 0.2s; | |
| } | |
| .request-header:hover { | |
| background: #333336; | |
| } | |
| .request-card.expanded .request-header { | |
| background: #37373d; | |
| border-bottom: 1px solid #3e3e42; | |
| } | |
| .request-num { | |
| font-size: 16px; | |
| font-weight: bold; | |
| color: #4ec9b0; | |
| min-width: 60px; | |
| } | |
| .request-summary { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 5px; | |
| } | |
| .request-trigger { | |
| font-size: 14px; | |
| color: #d4d4d4; | |
| font-weight: 500; | |
| } | |
| .request-meta { | |
| font-size: 12px; | |
| color: #858585; | |
| display: flex; | |
| gap: 15px; | |
| } | |
| .request-expand-icon { | |
| font-size: 20px; | |
| color: #858585; | |
| transition: transform 0.2s; | |
| } | |
| .request-card.expanded .request-expand-icon { | |
| transform: rotate(90deg); | |
| } | |
| .request-body { | |
| max-height: 0; | |
| overflow: hidden; | |
| transition: max-height 0.3s ease-out; | |
| } | |
| .request-card.expanded .request-body { | |
| max-height: 5000px; | |
| transition: max-height 0.5s ease-in; | |
| } | |
| .request-content { | |
| padding: 20px; | |
| } | |
| .request-section { | |
| margin-bottom: 25px; | |
| } | |
| .request-section h4 { | |
| color: #4fc1ff; | |
| margin-bottom: 12px; | |
| font-size: 16px; | |
| padding-bottom: 8px; | |
| border-bottom: 2px solid #3e3e42; | |
| } | |
| .thinking-box, .note-box, .chat-box, .action-box { | |
| background: #1e1e1e; | |
| padding: 15px; | |
| border-radius: 6px; | |
| line-height: 1.6; | |
| } | |
| .thinking-box { | |
| border-left: 4px solid #007acc; | |
| color: #d4d4d4; | |
| } | |
| .note-box { | |
| border-left: 4px solid #ce9178; | |
| font-style: italic; | |
| color: #ce9178; | |
| } | |
| .chat-box { | |
| border-left: 4px solid #4ec9b0; | |
| color: #4ec9b0; | |
| font-weight: 500; | |
| } | |
| .action-box { | |
| border-left: 4px solid #dcdcaa; | |
| } | |
| .action-box .action-type { | |
| font-size: 18px; | |
| color: #dcdcaa; | |
| font-weight: bold; | |
| margin-bottom: 8px; | |
| } | |
| .action-box .action-params { | |
| color: #9cdcfe; | |
| font-family: 'Consolas', monospace; | |
| } | |
| /* Tool Iterations Section */ | |
| .tools-section { | |
| background: #252526; | |
| border-radius: 8px; | |
| padding: 15px; | |
| margin-top: 10px; | |
| } | |
| .tool-iteration { | |
| background: #1e1e1e; | |
| border-radius: 6px; | |
| padding: 15px; | |
| margin-bottom: 12px; | |
| border-left: 4px solid #c586c0; | |
| } | |
| .tool-iteration:last-child { | |
| margin-bottom: 0; | |
| } | |
| .tool-iteration-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-bottom: 10px; | |
| color: #d4d4d4; | |
| font-weight: bold; | |
| } | |
| .tool-call { | |
| background: #2d2d30; | |
| border-radius: 4px; | |
| padding: 12px; | |
| margin-top: 10px; | |
| } | |
| .tool-call-name { | |
| color: #dcdcaa; | |
| font-family: 'Consolas', monospace; | |
| font-weight: bold; | |
| margin-bottom: 8px; | |
| } | |
| .tool-params { | |
| background: #252526; | |
| border-radius: 4px; | |
| padding: 8px 12px; | |
| margin: 8px 0; | |
| font-size: 12px; | |
| } | |
| .tool-params code { | |
| color: #ce9178; | |
| font-family: 'Consolas', monospace; | |
| white-space: pre-wrap; | |
| } | |
| .tool-reasoning { | |
| background: #1a1a1a; | |
| border-left: 3px solid #569cd6; | |
| padding: 10px; | |
| margin: 8px 0; | |
| color: #9cdcfe; | |
| font-style: italic; | |
| } | |
| .tool-result { | |
| background: #1a1a1a; | |
| border-radius: 4px; | |
| padding: 10px; | |
| margin-top: 8px; | |
| font-family: 'Consolas', monospace; | |
| font-size: 12px; | |
| max-height: 200px; | |
| overflow-y: auto; | |
| color: #d4d4d4; | |
| } | |
| /* Statistics */ | |
| .stats-grid { | |
| display: flex; | |
| gap: 15px; | |
| margin-bottom: 15px; | |
| } | |
| .stat-card { | |
| flex: 1; | |
| background: #2d2d30; | |
| padding: 12px; | |
| border-radius: 6px; | |
| border: 1px solid #3e3e42; | |
| transition: all 0.3s; | |
| } | |
| .stat-card.updated { | |
| animation: statPulse 0.6s ease-out; | |
| } | |
| @keyframes statPulse { | |
| 0%, 100% { transform: scale(1); } | |
| 50% { transform: scale(1.05); background: #37373d; } | |
| } | |
| .stat-value { | |
| font-size: 24px; | |
| font-weight: bold; | |
| color: #4ec9b0; | |
| } | |
| .stat-label { | |
| font-size: 11px; | |
| color: #858585; | |
| text-transform: uppercase; | |
| margin-top: 4px; | |
| } | |
| .empty-state { | |
| text-align: center; | |
| padding: 60px 20px; | |
| color: #858585; | |
| } | |
| .empty-state .icon { | |
| font-size: 48px; | |
| margin-bottom: 20px; | |
| } | |
| /* Filter Bar */ | |
| .filter-bar { | |
| background: #2d2d30; | |
| padding: 15px 20px; | |
| border-bottom: 1px solid #3e3e42; | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| } | |
| .filter-btn { | |
| padding: 6px 12px; | |
| border-radius: 4px; | |
| border: 1px solid #3e3e42; | |
| background: #1e1e1e; | |
| color: #d4d4d4; | |
| cursor: pointer; | |
| font-size: 12px; | |
| transition: all 0.2s; | |
| } | |
| .filter-btn:hover { | |
| background: #2a2d2e; | |
| border-color: #007acc; | |
| } | |
| .filter-btn.active { | |
| background: #007acc; | |
| border-color: #007acc; | |
| color: white; | |
| } | |
| /* Scrollbar styling */ | |
| ::-webkit-scrollbar { | |
| width: 12px; | |
| height: 12px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #1e1e1e; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #424242; | |
| border-radius: 6px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: #4e4e4e; | |
| } | |
| /* Loading spinner */ | |
| .loading { | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| padding: 40px; | |
| } | |
| .spinner { | |
| width: 40px; | |
| height: 40px; | |
| border: 4px solid #3e3e42; | |
| border-top-color: #007acc; | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| /* Details/Summary styling */ | |
| details { | |
| background: #1e1e1e; | |
| border: 1px solid #3e3e42; | |
| border-radius: 6px; | |
| padding: 10px; | |
| margin: 15px 0; | |
| } | |
| details summary { | |
| cursor: pointer; | |
| font-weight: bold; | |
| color: #4fc1ff; | |
| padding: 8px; | |
| user-select: none; | |
| transition: color 0.2s, background 0.2s; | |
| border-radius: 4px; | |
| } | |
| details summary:hover { | |
| color: #9cdcfe; | |
| background: #2a2d2e; | |
| } | |
| details[open] summary { | |
| margin-bottom: 10px; | |
| background: #2a2d2e; | |
| } | |
| details pre { | |
| background: #2d2d30; | |
| padding: 15px; | |
| border-radius: 6px; | |
| overflow-x: auto; | |
| font-size: 12px; | |
| line-height: 1.4; | |
| margin: 0; | |
| border-left: 3px solid #007acc; | |
| } | |
| details pre code { | |
| font-family: 'Consolas', 'Monaco', 'Courier New', monospace; | |
| color: #d4d4d4; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Refreshing Indicator --> | |
| <div class="refreshing-indicator" id="refreshing-indicator"></div> | |
| <!-- Update Notification --> | |
| <div class="update-notification" id="update-notification"> | |
| <span>✨</span> | |
| <span id="notification-text">עדכונים חדשים זמינים</span> | |
| </div> | |
| <div class="container"> | |
| <!-- Sidebar --> | |
| <div class="sidebar"> | |
| <div class="sidebar-header"> | |
| 🎮 AI Catan Viewer | |
| </div> | |
| <div class="sidebar-section"> | |
| <h3>Players</h3> | |
| <div id="players-nav"></div> | |
| </div> | |
| <div class="sidebar-section"> | |
| <h3>Views</h3> | |
| <div class="nav-item active" onclick="showView('requests')"> | |
| <span class="icon">📊</span> | |
| <span>All Requests</span> | |
| <span class="badge" id="total-requests">0</span> | |
| </div> | |
| <div class="nav-item" onclick="showView('chat')"> | |
| <span class="icon">💬</span> | |
| <span>Chat History</span> | |
| <span class="badge" id="chat-count">0</span> | |
| </div> | |
| <div class="nav-item" onclick="showView('memories')"> | |
| <span class="icon">📝</span> | |
| <span>Agent Memories</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Main Content --> | |
| <div class="content"> | |
| <div class="content-header"> | |
| <h1 id="content-title">All Requests</h1> | |
| <div class="header-actions"> | |
| <button class="btn btn-primary" onclick="refreshData()" title="Refresh data manually">🔄 Refresh</button> | |
| <button class="btn btn-primary" onclick="markAllViewed()">Mark All as Read</button> | |
| <div class="status-badge live" id="auto-refresh-badge" onclick="toggleAutoRefresh()" title="Click to toggle auto-refresh"> | |
| <div class="pulse"></div> | |
| <span id="refresh-status">AUTO</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="filter-bar" id="filter-bar" style="display: none;"> | |
| <span style="font-size: 12px; color: #858585;">Filter:</span> | |
| <button class="filter-btn active" onclick="filterRequests('all')">All</button> | |
| <button class="filter-btn" onclick="filterRequests('new')">New Only</button> | |
| <button class="filter-btn" onclick="filterRequests('has_action')">Has Action</button> | |
| </div> | |
| <div class="content-body" id="content-body"> | |
| <div class="loading"> | |
| <div class="spinner"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let sessionData = null; | |
| let previousData = null; | |
| let currentView = 'requests'; | |
| let currentPlayer = null; | |
| let currentFilter = 'all'; | |
| let expandedRequests = new Set(); | |
| let expandedDetails = new Map(); // Track which details elements are open: requestId -> Set of detail indices | |
| let scrollPosition = 0; | |
| let autoRefreshEnabled = true; | |
| let refreshInterval = null; | |
| let newRequestIds = new Set(); | |
| let isRefreshing = false; | |
| // Streaming state | |
| let streamingSources = {}; // player_name -> EventSource | |
| let streamingContainers = {}; // player_name -> HTMLElement | |
| async function loadData() { | |
| const isInitialLoad = sessionData === null; | |
| // Show refreshing indicator | |
| if (!isInitialLoad && autoRefreshEnabled) { | |
| showRefreshingIndicator(); | |
| } | |
| // Save current scroll position | |
| const contentBody = document.getElementById('content-body'); | |
| if (contentBody && !isInitialLoad) { | |
| scrollPosition = contentBody.scrollTop; | |
| } | |
| try { | |
| const response = await fetch('/api/current'); | |
| if (!response.ok) { | |
| showEmptyState('No Active Session', 'Start a game to see AI activity'); | |
| return; | |
| } | |
| const newData = await response.json(); | |
| // Detect changes and show notification | |
| if (!isInitialLoad && previousData) { | |
| const changes = detectChanges(previousData, newData); | |
| if (changes && changes.hasChanges) { | |
| showNotification(changes); | |
| } | |
| } | |
| previousData = JSON.parse(JSON.stringify(newData)); | |
| sessionData = newData; | |
| updateSidebar(); | |
| updateView(); | |
| // Initialize streaming connections on first load | |
| if (isInitialLoad) { | |
| initStreaming(); | |
| } | |
| // Restore scroll position | |
| if (!isInitialLoad) { | |
| setTimeout(() => { | |
| const contentBody = document.getElementById('content-body'); | |
| if (contentBody) { | |
| contentBody.scrollTop = scrollPosition; | |
| } | |
| }, 50); | |
| } | |
| } catch (error) { | |
| console.error('Error loading data:', error); | |
| } finally { | |
| hideRefreshingIndicator(); | |
| } | |
| } | |
| function detectChanges(oldData, newData) { | |
| const changes = { | |
| hasChanges: false, | |
| newRequestIds: [], | |
| description: [] | |
| }; | |
| // Check for new requests | |
| const oldRequests = oldData.requests || []; | |
| const newRequests = newData.requests || []; | |
| const oldRequestIds = new Set(oldRequests.map(r => r.request_id)); | |
| const addedRequests = newRequests.filter(r => !oldRequestIds.has(r.request_id)); | |
| if (addedRequests.length > 0) { | |
| changes.hasChanges = true; | |
| changes.newRequestIds = addedRequests.map(r => r.request_id); | |
| changes.description.push(`${addedRequests.length} בקשות חדשות`); | |
| // Track new request IDs for animation | |
| addedRequests.forEach(req => newRequestIds.add(req.request_id)); | |
| } | |
| // Check for new chat messages | |
| const oldChatCount = oldData.chat?.length || 0; | |
| const newChatCount = newData.chat?.length || 0; | |
| if (newChatCount > oldChatCount) { | |
| changes.hasChanges = true; | |
| const newMessages = newChatCount - oldChatCount; | |
| changes.description.push(`${newMessages} הודעות חדשות`); | |
| } | |
| return changes.hasChanges ? changes : null; | |
| } | |
| function showNotification(changes) { | |
| const notification = document.getElementById('update-notification'); | |
| const notificationText = document.getElementById('notification-text'); | |
| notificationText.textContent = changes.description.join(', '); | |
| notification.classList.add('show'); | |
| // Hide after 3 seconds | |
| setTimeout(() => { | |
| notification.classList.remove('show'); | |
| }, 3000); | |
| } | |
| function showRefreshingIndicator() { | |
| document.getElementById('refreshing-indicator').classList.add('active'); | |
| } | |
| function hideRefreshingIndicator() { | |
| setTimeout(() => { | |
| document.getElementById('refreshing-indicator').classList.remove('active'); | |
| }, 300); | |
| } | |
| function updateSidebar() { | |
| // Update players nav | |
| const playersStats = sessionData.players_stats || {}; | |
| const playersHTML = Object.entries(playersStats).map(([player, stats]) => { | |
| const badge = stats.new > 0 | |
| ? `<span class="badge new">${stats.new}</span>` | |
| : `<span class="badge">${stats.total}</span>`; | |
| return ` | |
| <div class="nav-item ${currentPlayer === player ? 'active' : ''}" onclick="showPlayer('${player}')"> | |
| <span class="icon">🤖</span> | |
| <span>${player.toUpperCase()}</span> | |
| ${badge} | |
| </div> | |
| `; | |
| }).join(''); | |
| document.getElementById('players-nav').innerHTML = playersHTML || '<p style="color: #858585; font-size: 12px;">No players yet</p>'; | |
| // Update counts | |
| document.getElementById('total-requests').textContent = sessionData.requests ? sessionData.requests.length : 0; | |
| document.getElementById('chat-count').textContent = sessionData.chat ? sessionData.chat.length : 0; | |
| } | |
| function updateView() { | |
| if (!sessionData) return; | |
| if (currentView === 'requests') { | |
| showRequests(); | |
| } else if (currentView === 'chat') { | |
| showChat(); | |
| } else if (currentView === 'memories') { | |
| showMemories(); | |
| } | |
| } | |
| function showRequests() { | |
| document.getElementById('filter-bar').style.display = 'flex'; | |
| let requests = sessionData.requests || []; | |
| // Apply player filter | |
| if (currentPlayer) { | |
| requests = requests.filter(r => r.player_name === currentPlayer); | |
| document.getElementById('content-title').textContent = `${currentPlayer.toUpperCase()}'s Requests`; | |
| } else { | |
| document.getElementById('content-title').textContent = 'All Requests'; | |
| } | |
| // Apply status filter | |
| if (currentFilter === 'new') { | |
| requests = requests.filter(r => r.is_new); | |
| } else if (currentFilter === 'has_action') { | |
| requests = requests.filter(r => r.response && r.response.action); | |
| } | |
| if (requests.length === 0) { | |
| document.getElementById('content-body').innerHTML = ` | |
| <div class="empty-state"> | |
| <div class="icon">📊</div> | |
| <h3>No Requests Yet</h3> | |
| <p>AI requests will appear here during gameplay</p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| // Show statistics | |
| const stats = calculateStats(requests); | |
| const statsHTML = ` | |
| <div class="stats-grid"> | |
| <div class="stat-card"> | |
| <div class="stat-value">${stats.total}</div> | |
| <div class="stat-label">Total Requests</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value">${stats.new}</div> | |
| <div class="stat-label">New</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value">${stats.totalTokens.toLocaleString()}</div> | |
| <div class="stat-label">Total Tokens</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value" style="color: #4ec9b0;">$${stats.totalCost}</div> | |
| <div class="stat-label">💰 Total Cost</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value" style="color: #569cd6;">${stats.avgLatency}</div> | |
| <div class="stat-label">⏱️ Avg Response Time</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value" style="color: #c586c0;">${stats.toolIterations}</div> | |
| <div class="stat-label">🛠️ Tool Calls</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value">${stats.avgTokens}</div> | |
| <div class="stat-label">Avg Tokens</div> | |
| </div> | |
| </div> | |
| `; | |
| // Show requests (reverse to show newest first) | |
| const requestsHTML = requests.slice().reverse().map(req => generateRequestCard(req)).join(''); | |
| document.getElementById('content-body').innerHTML = statsHTML + requestsHTML; | |
| // Restore expanded state | |
| expandedRequests.forEach(requestId => { | |
| const card = document.getElementById(requestId); | |
| if (card) { | |
| card.classList.add('expanded'); | |
| // Restore details elements state | |
| if (expandedDetails.has(requestId)) { | |
| const detailsElements = card.querySelectorAll('details'); | |
| const openIndices = expandedDetails.get(requestId); | |
| detailsElements.forEach((detail, idx) => { | |
| if (openIndices.has(idx)) { | |
| detail.open = true; | |
| } | |
| }); | |
| } | |
| } | |
| }); | |
| // Add event listeners to all details elements to track their state | |
| document.querySelectorAll('.request-card').forEach(card => { | |
| const requestId = card.id; | |
| const detailsElements = card.querySelectorAll('details'); | |
| detailsElements.forEach((detail, idx) => { | |
| detail.addEventListener('toggle', () => { | |
| if (card.classList.contains('expanded')) { | |
| saveDetailsState(requestId, card); | |
| } | |
| }); | |
| }); | |
| }); | |
| } | |
| function generateRequestCard(req) { | |
| const requestId = `req_${req.player_name}_${req.request_number}`; | |
| const isNew = req.is_new ? ' new' : ''; | |
| const newBadge = req.is_new ? '<span class="badge new">NEW</span>' : ''; | |
| // Check if this is a freshly added request (for animation) | |
| const isJustAdded = newRequestIds.has(requestId); | |
| const fadeInClass = isJustAdded ? ' fade-in highlight-new' : ''; | |
| // Remove from newRequestIds set after first render | |
| if (isJustAdded) { | |
| setTimeout(() => newRequestIds.delete(requestId), 2000); | |
| } | |
| const timestamp = new Date(req.timestamp).toLocaleString(); | |
| const response = req.response || {}; | |
| const thinking = response.internal_thinking || ''; | |
| const note = response.note_to_self || ''; | |
| const chat = response.say_outloud || ''; | |
| // Handle both old (action object) and new (action_type + parameters) formats | |
| const actionType = response.action_type || (response.action ? response.action.type : null); | |
| const actionParams = response.parameters || (response.action ? response.action.parameters : null); | |
| const tokens = req.tokens || {}; | |
| const tokensDisplay = tokens.total ? `${tokens.total.toLocaleString()} tokens` : 'N/A'; | |
| // Calculate cost | |
| const cost = calculateGeminiCost(tokens); | |
| const costDisplay = cost ? cost.formatted : 'N/A'; | |
| // Get latency (response time) | |
| const latencySeconds = req.latency_seconds || req.tokens?.latency || 0; | |
| const latencyDisplay = latencySeconds ? `${latencySeconds.toFixed(2)}s` : 'N/A'; | |
| // Extract trigger from prompt | |
| const trigger = req.prompt?.task_context?.what_just_happened || 'No trigger info'; | |
| const phase = req.prompt?.game_state ? 'Active' : 'Unknown'; | |
| // Check for tool iterations | |
| const hasTools = req.tool_iterations && req.tool_iterations.length > 0; | |
| const toolCount = hasTools ? req.tool_iterations.length : 0; | |
| // Create content type icons | |
| const contentIcons = []; | |
| if (thinking) contentIcons.push('<span title="Has Internal Thinking">💭</span>'); | |
| if (note) contentIcons.push('<span title="Has Note to Self">📝</span>'); | |
| if (chat) contentIcons.push('<span title="Says Out Loud">💬</span>'); | |
| if (actionType) contentIcons.push('<span title="Has Action">🎮</span>'); | |
| if (hasTools) contentIcons.push(`<span title="Used ${toolCount} tool iteration(s)" style="color: #c586c0;">🛠️${toolCount}</span>`); | |
| const contentIconsHTML = contentIcons.length > 0 ? `<div style="display: inline-flex; gap: 6px; margin-left: 10px;">${contentIcons.join('')}</div>` : ''; | |
| return ` | |
| <div class="request-card${isNew}${fadeInClass}" id="${requestId}"> | |
| <div class="request-header" onclick="toggleRequest('${requestId}')"> | |
| <div class="request-num">#${req.request_number}</div> | |
| <div class="request-summary"> | |
| <div class="request-trigger"> | |
| ${newBadge} | |
| <strong>${req.player_name.toUpperCase()}:</strong> ${escapeHtml(trigger.substring(0, 100))}... | |
| ${contentIconsHTML} | |
| </div> | |
| <div class="request-meta"> | |
| <span>🔢 ${tokensDisplay}</span> | |
| <span>💰 ${costDisplay}</span> | |
| <span>⏱️ ${latencyDisplay}</span> | |
| <span>${req.success ? '✅ Success' : '❌ Failed'}</span> | |
| <span>📅 ${timestamp}</span> | |
| </div> | |
| </div> | |
| <div class="request-expand-icon">▶</div> | |
| </div> | |
| <div class="request-body"> | |
| <div class="request-content"> | |
| ${thinking ? ` | |
| <div class="request-section"> | |
| <h4>💭 Internal Thinking</h4> | |
| <div class="thinking-box">${escapeHtml(thinking)}</div> | |
| </div> | |
| ` : ''} | |
| ${note ? ` | |
| <div class="request-section"> | |
| <h4>📝 Note to Self</h4> | |
| <div class="note-box">${escapeHtml(note)}</div> | |
| </div> | |
| ` : ''} | |
| ${chat ? ` | |
| <div class="request-section"> | |
| <h4>💬 Says Out Loud</h4> | |
| <div class="chat-box">"${escapeHtml(chat)}"</div> | |
| </div> | |
| ` : ''} | |
| ${actionType ? ` | |
| <div class="request-section"> | |
| <h4>🎮 Action</h4> | |
| <div class="action-box"> | |
| <div class="action-type">${actionType}</div> | |
| ${actionParams ? ` | |
| <div class="action-params">Parameters: ${JSON.stringify(actionParams)}</div> | |
| ` : ''} | |
| </div> | |
| </div> | |
| ` : ''} | |
| ${generateToolIterationsSection(req)} | |
| ${tokens.total ? ` | |
| <div class="request-section"> | |
| <h4>📊 Token Usage & Cost</h4> | |
| <div style="font-size: 12px; color: #858585;"> | |
| <div>⏱️ Response Time: <strong>${latencyDisplay}</strong></div> | |
| <div>🔢 Total Tokens: <strong>${tokens.total.toLocaleString()}</strong></div> | |
| <div style="padding-right: 15px;">├─ Prompt: ${(tokens.prompt || 0).toLocaleString()}</div> | |
| <div style="padding-right: 15px;">└─ Completion: ${(tokens.completion || 0).toLocaleString()}</div> | |
| ${cost ? ` | |
| <div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #3e3e42;"> | |
| <div>💰 Total Cost: <strong style="color: #4ec9b0;">${cost.formatted}</strong></div> | |
| <div style="padding-right: 15px; font-size: 11px;">├─ Input: $${cost.input.toFixed(6)}</div> | |
| <div style="padding-right: 15px; font-size: 11px;">└─ Output: $${cost.output.toFixed(6)}</div> | |
| </div> | |
| ` : ''} | |
| ${tokens.thinking ? `<div style="margin-top: 5px;">🧠 Thinking Tokens: ${tokens.thinking.toLocaleString()}</div>` : ''} | |
| </div> | |
| </div> | |
| ` : ''} | |
| <div class="request-section"> | |
| <details> | |
| <summary>📤 Original Prompt (JSON)</summary> | |
| <pre><code>${escapeHtml(formatPromptInOrder(req.prompt))}</code></pre> | |
| </details> | |
| </div> | |
| ${req.raw_response ? ` | |
| <div class="request-section"> | |
| <details> | |
| <summary style="color: #f48771;">🔴 Raw LLM Response</summary> | |
| <pre style="border-left: 3px solid #f48771;"><code>${escapeHtml(req.raw_response)}</code></pre> | |
| </details> | |
| </div> | |
| ` : ''} | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| function generateToolIterationsSection(req) { | |
| const iterations = req.tool_iterations || []; | |
| if (iterations.length === 0) { | |
| return ''; | |
| } | |
| const iterationsHTML = iterations.map(iter => { | |
| // Parse tool results | |
| const toolCalls = parseToolResults(iter.tool_results); | |
| const toolCallsHTML = toolCalls.map(tool => ` | |
| <div class="tool-call"> | |
| <div class="tool-call-name">🔧 ${escapeHtml(tool.name)}</div> | |
| ${tool.parameters ? ` | |
| <div class="tool-params"> | |
| <span style="color: #858585;">Parameters:</span> | |
| <code>${escapeHtml(tool.parameters)}</code> | |
| </div> | |
| ` : ''} | |
| ${tool.reasoning ? ` | |
| <div class="tool-reasoning"> | |
| 💭 ${escapeHtml(tool.reasoning)} | |
| </div> | |
| ` : ''} | |
| <div class="tool-result"> | |
| <details> | |
| <summary>📊 Result (${tool.resultPreview})</summary> | |
| <pre>${escapeHtml(tool.result)}</pre> | |
| </details> | |
| </div> | |
| </div> | |
| `).join(''); | |
| return ` | |
| <div class="tool-iteration"> | |
| <div class="tool-iteration-header"> | |
| <span>🔄 Iteration ${iter.iteration}</span> | |
| <span style="font-size: 12px; color: #858585;">${iter.timestamp ? new Date(iter.timestamp).toLocaleTimeString() : ''}</span> | |
| </div> | |
| ${toolCallsHTML} | |
| </div> | |
| `; | |
| }).join(''); | |
| return ` | |
| <div class="request-section"> | |
| <h4>🛠️ Tool Calls (${iterations.length} iteration${iterations.length > 1 ? 's' : ''})</h4> | |
| <div class="tools-section"> | |
| ${iterationsHTML} | |
| </div> | |
| </div> | |
| `; | |
| } | |
| function parseToolResults(toolResultsStr) { | |
| const tools = []; | |
| if (!toolResultsStr) return tools; | |
| // Split by "Tool: " to get each tool call | |
| const parts = toolResultsStr.split(/Tool:\s+/); | |
| for (let i = 1; i < parts.length; i++) { | |
| const part = parts[i]; | |
| // Extract tool name (first line) | |
| const lines = part.split('\n'); | |
| const name = lines[0].trim(); | |
| // Extract parameters JSON | |
| let parameters = ''; | |
| let paramsParsed = null; | |
| const paramsMatch = part.match(/Parameters:\s*(\{[\s\S]*?\})\s*(?=Result:|$)/); | |
| if (paramsMatch) { | |
| try { | |
| // Parse and re-stringify for nice formatting | |
| paramsParsed = JSON.parse(paramsMatch[1]); | |
| // Remove reasoning from display params (it's shown separately) | |
| const displayParams = {...paramsParsed}; | |
| delete displayParams.reasoning; | |
| parameters = JSON.stringify(displayParams, null, 2); | |
| } catch (e) { | |
| parameters = paramsMatch[1].trim(); | |
| } | |
| } | |
| // Extract reasoning - first from parameters, then from result llm_reasoning | |
| let reasoning = ''; | |
| if (paramsParsed && paramsParsed.reasoning) { | |
| reasoning = paramsParsed.reasoning; | |
| } else { | |
| const reasoningMatch = part.match(/"(?:reasoning|llm_reasoning)":\s*"([^"]+)"/); | |
| if (reasoningMatch) { | |
| reasoning = reasoningMatch[1]; | |
| } | |
| } | |
| // Extract result JSON | |
| const resultMatch = part.match(/Result:\s*([\s\S]*?)(?=---|\Z)/); | |
| let result = resultMatch ? resultMatch[1].trim() : ''; | |
| // Create preview | |
| let resultPreview = 'click to expand'; | |
| try { | |
| const parsed = JSON.parse(result); | |
| if (parsed.total_found !== undefined) { | |
| resultPreview = `${parsed.total_found} nodes found`; | |
| } else if (parsed.node_id !== undefined) { | |
| resultPreview = `Node ${parsed.node_id}`; | |
| } else if (parsed.from_node !== undefined) { | |
| resultPreview = `Path from ${parsed.from_node}`; | |
| } | |
| } catch (e) {} | |
| tools.push({ name, parameters, reasoning, result, resultPreview }); | |
| } | |
| return tools; | |
| } | |
| function toggleRequest(requestId) { | |
| const card = document.getElementById(requestId); | |
| if (!card) return; | |
| const isExpanded = card.classList.contains('expanded'); | |
| if (isExpanded) { | |
| // Before collapsing, save which details elements are open | |
| saveDetailsState(requestId, card); | |
| card.classList.remove('expanded'); | |
| expandedRequests.delete(requestId); | |
| } else { | |
| card.classList.add('expanded'); | |
| expandedRequests.add(requestId); | |
| // Mark as viewed | |
| if (card.classList.contains('new')) { | |
| markAsViewed(requestId); | |
| card.classList.remove('new'); | |
| } | |
| // Restore details state after a short delay to ensure DOM is ready | |
| setTimeout(() => restoreDetailsState(requestId, card), 50); | |
| } | |
| } | |
| function saveDetailsState(requestId, card) { | |
| const detailsElements = card.querySelectorAll('details'); | |
| const openIndices = new Set(); | |
| detailsElements.forEach((detail, idx) => { | |
| if (detail.open) { | |
| openIndices.add(idx); | |
| } | |
| }); | |
| if (openIndices.size > 0) { | |
| expandedDetails.set(requestId, openIndices); | |
| } else { | |
| expandedDetails.delete(requestId); | |
| } | |
| } | |
| function restoreDetailsState(requestId, card) { | |
| if (expandedDetails.has(requestId)) { | |
| const detailsElements = card.querySelectorAll('details'); | |
| const openIndices = expandedDetails.get(requestId); | |
| detailsElements.forEach((detail, idx) => { | |
| if (openIndices.has(idx)) { | |
| detail.open = true; | |
| } | |
| }); | |
| } | |
| } | |
| async function markAsViewed(requestId) { | |
| try { | |
| await fetch(`/api/mark_viewed/${encodeURIComponent(sessionData.session_path)}/${requestId}`); | |
| setTimeout(loadData, 500); | |
| } catch (error) { | |
| console.error('Error marking as viewed:', error); | |
| } | |
| } | |
| async function markAllViewed() { | |
| if (!sessionData) return; | |
| try { | |
| await fetch(`/api/mark_all_viewed/${encodeURIComponent(sessionData.session_path)}`); | |
| document.querySelectorAll('.request-card.new').forEach(card => { | |
| card.classList.remove('new'); | |
| }); | |
| setTimeout(loadData, 500); | |
| } catch (error) { | |
| console.error('Error marking all as viewed:', error); | |
| } | |
| } | |
| function calculateStats(requests) { | |
| const total = requests.length; | |
| const newCount = requests.filter(r => r.is_new).length; | |
| // Use tokens object instead of metadata | |
| const tokenCounts = requests | |
| .map(r => r.tokens?.total) | |
| .filter(t => t != null && t > 0); | |
| const avgTokens = tokenCounts.length > 0 | |
| ? Math.round(tokenCounts.reduce((a, b) => a + b, 0) / tokenCounts.length) | |
| : 0; | |
| const totalTokens = tokenCounts.reduce((a, b) => a + b, 0); | |
| const promptTokens = requests | |
| .map(r => r.tokens?.prompt || 0) | |
| .reduce((a, b) => a + b, 0); | |
| const completionTokens = requests | |
| .map(r => r.tokens?.completion || 0) | |
| .reduce((a, b) => a + b, 0); | |
| // Gemini 2.0 Flash pricing (as of Jan 2025) | |
| // Input: $0.075 per 1M tokens (up to 128k) | |
| // Output: $0.30 per 1M tokens | |
| const inputCost = (promptTokens / 1000000) * 0.075; | |
| const outputCost = (completionTokens / 1000000) * 0.30; | |
| const totalCost = (inputCost + outputCost).toFixed(6); | |
| // Calculate average latency | |
| const latencies = requests | |
| .map(r => r.latency_seconds || r.tokens?.latency || 0) | |
| .filter(l => l > 0); | |
| const avgLatency = latencies.length > 0 | |
| ? (latencies.reduce((a, b) => a + b, 0) / latencies.length).toFixed(2) + 's' | |
| : 'N/A'; | |
| // Calculate success rate | |
| const successCount = requests.filter(r => r.success).length; | |
| const successRate = total > 0 ? Math.round((successCount / total) * 100) : 0; | |
| // Count tool iterations | |
| const toolIterations = requests.reduce((sum, r) => | |
| sum + (r.tool_iterations ? r.tool_iterations.length : 0), 0); | |
| return { | |
| total, | |
| new: newCount, | |
| avgLatency: 'N/A', | |
| avgTokens, | |
| totalTokens, | |
| totalCost, | |
| successRate, | |
| toolIterations | |
| }; | |
| } | |
| function showView(view) { | |
| currentView = view; | |
| currentPlayer = null; | |
| expandedRequests.clear(); | |
| expandedDetails.clear(); | |
| scrollPosition = 0; | |
| updateActiveNav(); | |
| updateView(); | |
| } | |
| function showPlayer(player) { | |
| currentPlayer = player; | |
| currentView = 'requests'; | |
| expandedRequests.clear(); | |
| expandedDetails.clear(); | |
| scrollPosition = 0; | |
| updateActiveNav(); | |
| updateView(); | |
| } | |
| function filterRequests(filter) { | |
| currentFilter = filter; | |
| document.querySelectorAll('.filter-btn').forEach(btn => { | |
| btn.classList.remove('active'); | |
| }); | |
| event.target.classList.add('active'); | |
| updateView(); | |
| } | |
| function showChat() { | |
| document.getElementById('filter-bar').style.display = 'none'; | |
| document.getElementById('content-title').textContent = 'Chat History'; | |
| if (!sessionData || !sessionData.chat || sessionData.chat.length === 0) { | |
| document.getElementById('content-body').innerHTML = ` | |
| <div class="empty-state"> | |
| <div class="icon">💬</div> | |
| <h3>No messages yet</h3> | |
| <p>Chat messages will appear here when players communicate</p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| // Filter out undefined/null messages and validate structure | |
| const validMessages = sessionData.chat.filter(msg => msg && msg.message); | |
| if (validMessages.length === 0) { | |
| document.getElementById('content-body').innerHTML = ` | |
| <div class="empty-state"> | |
| <div class="icon">💬</div> | |
| <h3>No valid messages</h3> | |
| <p>Chat data exists but contains no valid messages</p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| const chatHTML = validMessages.map(msg => { | |
| const sender = msg.from || msg.player_name || msg.player || 'Unknown'; | |
| const message = msg.message || msg.text || ''; | |
| const displayName = sender ? escapeHtml(sender) : 'Unknown'; | |
| return ` | |
| <div class="chat-box" style="margin-bottom: 10px; background: #2a2a2a; padding: 15px; border-radius: 8px;"> | |
| <strong style="color: #4a9eff;">${displayName}:</strong> "${escapeHtml(message)}" | |
| </div> | |
| `; | |
| }).join(''); | |
| document.getElementById('content-body').innerHTML = chatHTML; | |
| } | |
| function showMemories() { | |
| document.getElementById('filter-bar').style.display = 'none'; | |
| document.getElementById('content-title').textContent = 'Agent Memories'; | |
| if (!sessionData.memories || Object.keys(sessionData.memories).length === 0) { | |
| document.getElementById('content-body').innerHTML = ` | |
| <div class="empty-state"> | |
| <div class="icon">📝</div> | |
| <h3>No memories stored</h3> | |
| <p>AI agents haven't saved any notes yet</p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| const memoriesHTML = Object.entries(sessionData.memories).map(([player, memory]) => { | |
| // Handle both string format and object format {note_to_self: ...} | |
| const memoryText = typeof memory === 'string' ? memory : | |
| (memory.note_to_self || JSON.stringify(memory)); | |
| return ` | |
| <div class="note-box" style="margin-bottom: 15px;"> | |
| <strong>${player.toUpperCase()}:</strong><br> | |
| "${escapeHtml(memoryText)}" | |
| </div> | |
| `; | |
| }).join(''); | |
| document.getElementById('content-body').innerHTML = memoriesHTML; | |
| } | |
| function updateActiveNav() { | |
| document.querySelectorAll('.nav-item').forEach(item => { | |
| item.classList.remove('active'); | |
| }); | |
| if (currentPlayer) { | |
| const items = document.querySelectorAll('.nav-item'); | |
| items.forEach(item => { | |
| if (item.textContent.includes(currentPlayer.toUpperCase())) { | |
| item.classList.add('active'); | |
| } | |
| }); | |
| } else { | |
| const viewMap = { | |
| 'requests': 'All Requests', | |
| 'chat': 'Chat History', | |
| 'memories': 'Agent Memories' | |
| }; | |
| const items = document.querySelectorAll('.nav-item'); | |
| items.forEach(item => { | |
| if (item.textContent.includes(viewMap[currentView])) { | |
| item.classList.add('active'); | |
| } | |
| }); | |
| } | |
| } | |
| function showEmptyState(title, message) { | |
| document.getElementById('content-title').textContent = 'AI Catan Viewer'; | |
| document.getElementById('content-body').innerHTML = ` | |
| <div class="empty-state"> | |
| <div class="icon">🎮</div> | |
| <h3>${title}</h3> | |
| <p>${message}</p> | |
| </div> | |
| `; | |
| } | |
| // ============================================================================ | |
| // STREAMING FUNCTIONS | |
| // ============================================================================ | |
| function initStreaming() { | |
| // Connect to SSE streams for all known players | |
| if (!sessionData || !sessionData.requests) return; | |
| // Get unique player names | |
| const players = new Set(); | |
| sessionData.requests.forEach(req => players.add(req.player_name)); | |
| // Connect to each player's stream | |
| players.forEach(playerName => { | |
| connectPlayerStream(playerName); | |
| }); | |
| } | |
| function connectPlayerStream(playerName) { | |
| // Check if already connected | |
| if (streamingSources[playerName]) return; | |
| console.log(`🌊 Connecting to stream for ${playerName}...`); | |
| // Create EventSource for SSE | |
| const eventSource = new EventSource(`/api/stream/${playerName}`); | |
| streamingSources[playerName] = eventSource; | |
| eventSource.onmessage = (event) => { | |
| try { | |
| const data = JSON.parse(event.data); | |
| handleStreamChunk(playerName, data); | |
| } catch (e) { | |
| console.error('Stream parse error:', e); | |
| } | |
| }; | |
| eventSource.onerror = (error) => { | |
| console.error(`Stream error for ${playerName}:`, error); | |
| // Reconnect after delay | |
| setTimeout(() => { | |
| if (streamingSources[playerName]) { | |
| streamingSources[playerName].close(); | |
| delete streamingSources[playerName]; | |
| connectPlayerStream(playerName); | |
| } | |
| }, 5000); | |
| }; | |
| } | |
| function handleStreamChunk(playerName, chunk) { | |
| console.log('🌊 Stream chunk:', playerName, chunk); | |
| // Get or create streaming container | |
| let container = streamingContainers[playerName]; | |
| if (!container || !document.body.contains(container)) { | |
| container = createStreamingContainer(playerName); | |
| streamingContainers[playerName] = container; | |
| // Insert at top of content body | |
| const contentBody = document.getElementById('content-body'); | |
| if (contentBody) { | |
| contentBody.insertBefore(container, contentBody.firstChild); | |
| } | |
| } | |
| // Handle different chunk types | |
| const contentDiv = container.querySelector('.streaming-content'); | |
| switch (chunk.type) { | |
| case 'connected': | |
| console.log(`✅ Connected to ${chunk.player} stream`); | |
| break; | |
| case 'thought': | |
| addStreamChunk(contentDiv, 'thought', '💭 ' + chunk.content); | |
| break; | |
| case 'text': | |
| addStreamChunk(contentDiv, 'text', chunk.content); | |
| break; | |
| case 'function_call': | |
| const funcName = chunk.function_call.name; | |
| const funcParams = JSON.stringify(chunk.function_call.parameters, null, 2); | |
| addStreamChunk(contentDiv, 'function', `🔧 ${funcName}(\\n${funcParams}\\n)`); | |
| break; | |
| case 'done': | |
| container.classList.add('done'); | |
| container.querySelector('.status-indicator')?.classList.add('done'); | |
| addStreamChunk(contentDiv, 'status', '✅ Stream complete'); | |
| // Remove after delay | |
| setTimeout(() => { | |
| container.style.opacity = '0'; | |
| setTimeout(() => container.remove(), 500); | |
| delete streamingContainers[playerName]; | |
| }, 3000); | |
| break; | |
| } | |
| // Auto-scroll to bottom of streaming content | |
| contentDiv.scrollTop = contentDiv.scrollHeight; | |
| } | |
| function createStreamingContainer(playerName) { | |
| const div = document.createElement('div'); | |
| div.className = 'streaming-container'; | |
| div.style.transition = 'opacity 0.5s'; | |
| div.innerHTML = ` | |
| <div class="streaming-header"> | |
| <span class="status-indicator"></span> | |
| <span class="player-name">${playerName}</span> | |
| <span style="color: #858585; font-size: 12px;">• Streaming...</span> | |
| </div> | |
| <div class="streaming-content"></div> | |
| `; | |
| return div; | |
| } | |
| function addStreamChunk(container, type, content) { | |
| const chunk = document.createElement('div'); | |
| chunk.className = `stream-chunk stream-${type}`; | |
| chunk.textContent = content; | |
| container.appendChild(chunk); | |
| } | |
| function disconnectAllStreams() { | |
| Object.values(streamingSources).forEach(source => source.close()); | |
| streamingSources = {}; | |
| streamingContainers = {}; | |
| } | |
| function escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| // Calculate cost for Gemini models | |
| function calculateGeminiCost(tokens) { | |
| if (!tokens || !tokens.prompt || !tokens.completion) return null; | |
| // Gemini 2.0 Flash pricing (as of Jan 2025) | |
| // Input: $0.075 per 1M tokens (up to 128k) | |
| // Output: $0.30 per 1M tokens | |
| const inputCostPer1M = 0.075; | |
| const outputCostPer1M = 0.30; | |
| const inputCost = (tokens.prompt / 1000000) * inputCostPer1M; | |
| const outputCost = (tokens.completion / 1000000) * outputCostPer1M; | |
| const totalCost = inputCost + outputCost; | |
| return { | |
| input: inputCost, | |
| output: outputCost, | |
| total: totalCost, | |
| formatted: `$${totalCost.toFixed(6)}` | |
| }; | |
| } | |
| function formatPromptInOrder(prompt) { | |
| // Define the correct order for prompt keys | |
| const keyOrder = ['meta_data', 'task_context', 'game_state', 'social_context', 'memory', 'constraints']; | |
| const orderedPrompt = {}; | |
| // Add keys in the correct order | |
| for (const key of keyOrder) { | |
| if (prompt && prompt[key] !== undefined) { | |
| orderedPrompt[key] = prompt[key]; | |
| } | |
| } | |
| // Add any remaining keys that weren't in our order list | |
| if (prompt) { | |
| for (const key of Object.keys(prompt)) { | |
| if (!keyOrder.includes(key)) { | |
| orderedPrompt[key] = prompt[key]; | |
| } | |
| } | |
| } | |
| return JSON.stringify(orderedPrompt, null, 2); | |
| } | |
| function refreshData() { | |
| loadData(); | |
| } | |
| function toggleAutoRefresh() { | |
| autoRefreshEnabled = !autoRefreshEnabled; | |
| const badge = document.getElementById('auto-refresh-badge'); | |
| const statusText = document.getElementById('refresh-status'); | |
| if (autoRefreshEnabled) { | |
| badge.classList.remove('manual'); | |
| badge.classList.add('live'); | |
| statusText.textContent = 'AUTO'; | |
| if (refreshInterval) clearInterval(refreshInterval); | |
| refreshInterval = setInterval(loadData, 2000); | |
| } else { | |
| badge.classList.remove('live'); | |
| badge.classList.add('manual'); | |
| statusText.textContent = 'MANUAL'; | |
| if (refreshInterval) { | |
| clearInterval(refreshInterval); | |
| refreshInterval = null; | |
| } | |
| } | |
| } | |
| // Initial load | |
| loadData(); | |
| // Start auto-refresh | |
| if (autoRefreshEnabled) { | |
| refreshInterval = setInterval(loadData, 2000); | |
| } | |
| // Cleanup on page unload | |
| window.addEventListener('beforeunload', () => { | |
| disconnectAllStreams(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |