| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"> |
| <meta http-equiv="Pragma" content="no-cache"> |
| <meta http-equiv="Expires" content="0"> |
| <title>AI Crime Investigation β Live Dashboard</title> |
| <meta name="description" content="Real-time dashboard for the AI Crime Investigation World multi-agent RL environment"> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> |
| <style> |
| |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| :root { |
| --bg-primary: #FDFCF9; |
| --bg-secondary: #FFFFFF; |
| --bg-card: #FFFFFF; |
| --bg-card-hover: #F8F7F4; |
| --bg-panel: #FDFCF9; |
| --border: #000000; |
| --border-glow: #000000; |
| --text-primary: #000000; |
| --text-secondary: #000000; |
| --text-muted: #444444; |
| |
| --accent-teal: #65A3BB; |
| --accent-yellow: #F4CC64; |
| --accent-orange: #F5A972; |
| --accent-red: #F25D68; |
| |
| --accent-blue: #65A3BB; |
| --accent-cyan: #F4CC64; |
| --accent-amber: #F5A972; |
| --accent-green: #F4CC64; |
| --accent-purple: #F5A972; |
| |
| --gradient-reward: linear-gradient(90deg, #F25D68, #F5A972, #65A3BB); |
| --shadow-glow: 4px 4px 0px #000000; |
| --font-mono: 'JetBrains Mono', monospace; |
| --font-sans: 'Inter', system-ui, sans-serif; |
| --radius: 0px; |
| --radius-sm: 0px; |
| } |
| |
| html { font-size: 15px; } |
| |
| body { |
| font-family: var(--font-sans); |
| background: var(--bg-primary); |
| color: var(--text-primary); |
| min-height: 100vh; |
| overflow-x: hidden; |
| background-image: radial-gradient(rgba(0, 0, 0, 0.2) 2px, transparent 2px); |
| background-size: 20px 20px; |
| } |
| |
| |
| .app { position: relative; z-index: 1; } |
| |
| .topbar { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 14px 28px; |
| background: var(--accent-teal); |
| border-bottom: 3px solid #000; |
| position: sticky; |
| top: 0; |
| z-index: 100; |
| } |
| |
| .topbar-left { |
| display: flex; |
| align-items: center; |
| gap: 14px; |
| } |
| |
| .logo { |
| font-size: 1.15rem; |
| font-weight: 800; |
| color: var(--text-primary); |
| letter-spacing: -0.02em; |
| text-transform: uppercase; |
| } |
| |
| .logo-icon { font-size: 1.3rem; } |
| |
| .status-badge { |
| background: var(--bg-card); border: 2px solid #000; color: #000 !important; box-shadow: 2px 2px 0px #000; |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| padding: 4px 12px; |
| background: var(--bg-card); |
| border: 3px solid #000; |
| font-size: 0.72rem; |
| font-weight: 800; |
| color: var(--text-primary) !important; |
| text-transform: uppercase; |
| letter-spacing: 0.05em; |
| box-shadow: 2px 2px 0px #000; |
| } |
| |
| .status-dot { |
| box-shadow: 0 0 8px var(--accent-green); border: 3px solid #000; border-radius: 50%; |
| width: 8px; |
| height: 8px; |
| border-radius: 0px; |
| border: 3px solid #000; |
| background: var(--accent-green); |
| } |
| |
| .topbar-right { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| |
| .btn { |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| padding: 8px 16px; |
| border: 3px solid #000; |
| background: var(--bg-card); |
| color: var(--text-primary); |
| font-family: var(--font-sans); |
| font-size: 0.8rem; |
| font-weight: 800; |
| border-radius: 0px; |
| cursor: pointer; |
| box-shadow: 3px 3px 0px #000; |
| transition: all 0.1s; |
| } |
| |
| .btn:hover { |
| transform: translate(-2px, -2px); |
| box-shadow: 5px 5px 0px #000; |
| } |
| |
| .btn:active { |
| transform: translate(2px, 2px); |
| box-shadow: 1px 1px 0px #000; |
| } |
| |
| .btn-primary { |
| background: var(--accent-blue); |
| color: #000; |
| } |
| |
| .btn-danger { |
| background: var(--accent-red); |
| color: #000; |
| } |
| |
| |
| .stats-strip { |
| display: grid; |
| grid-template-columns: repeat(5, 1fr); |
| gap: 12px; |
| padding: 16px 28px; |
| background: var(--bg-primary); |
| border-bottom: 3px solid #000; |
| } |
| |
| .stat-card { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| padding: 12px 16px; |
| background: var(--bg-card); |
| border: 3px solid #000; |
| border-radius: 0px; |
| box-shadow: 4px 4px 0px #000; |
| transition: all 0.1s; |
| } |
| |
| .stat-card:hover { |
| transform: translate(-2px, -2px); |
| box-shadow: 5px 5px 0px #000; |
| } |
| |
| .stat-icon { |
| width: 38px; |
| height: 38px; |
| border-radius: 0px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 1.1rem; |
| border: 3px solid #000; |
| background: var(--bg-primary); |
| } |
| |
| .stat-icon.turns { background: var(--accent-blue); } |
| .stat-icon.evidence { background: var(--accent-cyan); } |
| .stat-icon.contradictions { background: var(--accent-amber); } |
| .stat-icon.reward { background: var(--accent-green); } |
| .stat-icon.result { background: var(--accent-teal); } |
| |
| .stat-info { display: flex; flex-direction: column; } |
| |
| .stat-label { |
| font-size: 0.72rem; |
| color: var(--text-primary); |
| text-transform: uppercase; |
| letter-spacing: 0.06em; |
| font-weight: 800; |
| } |
| |
| .stat-value { |
| font-family: var(--font-mono); |
| font-size: 1.25rem; |
| font-weight: 800; |
| color: var(--text-primary); |
| } |
| |
| .stat-value.positive { color: #16a34a; } |
| .stat-value.negative { color: #dc2626; } |
| .stat-value.neutral { color: var(--text-primary); } |
| |
| |
| .main-grid { |
| display: grid; |
| grid-template-columns: 1fr 340px; |
| gap: 16px; |
| padding: 16px 28px 28px; |
| min-height: calc(100vh - 160px); |
| } |
| |
| |
| .panel { |
| background: var(--bg-panel); |
| border: 3px solid #000; |
| border-radius: 0px; |
| box-shadow: 5px 5px 0px #000; |
| overflow: hidden; |
| display: flex; |
| flex-direction: column; |
| margin-bottom: 4px; |
| } |
| |
| .panel-header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 14px 18px; |
| border-bottom: 3px solid #000; |
| background: var(--bg-card); |
| } |
| |
| .panel-title { |
| font-size: 0.82rem; |
| font-weight: 700; |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| text-transform: uppercase; |
| letter-spacing: 0.04em; |
| color: var(--text-secondary); |
| } |
| |
| .panel-body { |
| padding: 14px 18px; |
| background: var(--bg-panel); |
| flex: 1; |
| overflow-y: auto; |
| } |
| |
| |
| .conversation-panel { |
| max-height: calc(100vh - 230px); |
| } |
| |
| .conversation-panel .panel-body { |
| display: flex; |
| flex-direction: column; |
| gap: 10px; |
| padding-bottom: 16px; |
| } |
| |
| .msg { |
| display: flex; |
| gap: 10px; |
| padding: 12px 14px; |
| border-radius: 0px; |
| border: 2px solid transparent; |
| background: transparent; |
| border-bottom: 2px solid rgba(0,0,0,0.05); |
| animation: msg-in 0.35s ease-out; |
| transition: all 0.2s; |
| } |
| |
| .msg:hover { |
| background: rgba(0,0,0,0.02); |
| } |
| |
| @keyframes msg-in { |
| from { opacity: 0; transform: translateY(8px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| |
| .msg-avatar { |
| width: 38px; |
| height: 38px; |
| border-radius: 0px; |
| border: 3px solid #000; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 1.1rem; |
| flex-shrink: 0; |
| font-weight: 800; |
| color: #000; |
| box-shadow: 2px 2px 0px #000; |
| } |
| |
| .msg-avatar.detective { background: var(--accent-blue); } |
| .msg-avatar.suspect-a { background: var(--accent-red); } |
| .msg-avatar.suspect-b { background: var(--accent-orange); } |
| .msg-avatar.witness { background: var(--accent-purple); } |
| .msg-avatar.system { background: #e2e8f0; } |
| |
| .msg-content { flex: 1; min-width: 0; } |
| |
| .msg-header { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| margin-bottom: 6px; |
| } |
| |
| .msg-name { |
| font-weight: 800; |
| font-size: 0.8rem; |
| text-transform: uppercase; |
| letter-spacing: 0.05em; |
| } |
| |
| .msg-name.detective { color: var(--accent-blue); } |
| .msg-name.suspect-a { color: var(--accent-red); } |
| .msg-name.suspect-b { color: var(--accent-orange); } |
| .msg-name.witness { color: var(--accent-purple); } |
| .msg-name.system { color: var(--text-muted); } |
| |
| .msg-turn { |
| font-size: 0.65rem; |
| color: var(--text-muted); |
| font-weight: 600; |
| } |
| |
| .msg-text { |
| font-size: 1rem; |
| line-height: 1.5; |
| color: var(--text-secondary); |
| } |
| |
| .msg-tag { |
| display: inline-block; |
| padding: 4px 10px; |
| border-radius: 0px; |
| border: 2px solid #000; |
| font-size: 0.65rem; |
| font-weight: 800; |
| text-transform: uppercase; |
| letter-spacing: 0.04em; |
| margin-top: 8px; |
| box-shadow: 2px 2px 0px #000; |
| } |
| |
| .msg-tag.evidence { background: var(--accent-cyan); color: #000; } |
| .msg-tag.contradiction { background: var(--accent-red); color: #000; } |
| .msg-tag.accusation { background: var(--accent-green); color: #000; } |
| |
| |
| .sidebar { |
| display: flex; |
| flex-direction: column; |
| gap: 16px; |
| } |
| |
| |
| .reward-meter { |
| padding: 18px; |
| } |
| |
| .reward-bar-track { |
| width: 100%; |
| height: 12px; |
| background: rgba(0, 0, 0, 0.05); |
| border: 2px solid #000; |
| border-radius: 0px; |
| overflow: hidden; |
| margin: 12px 0 8px; |
| } |
| |
| .reward-bar-fill { |
| height: 100%; |
| border-radius: 0px; |
| background: var(--gradient-reward); |
| width: 0%; |
| transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); |
| } |
| |
| .reward-labels { |
| display: flex; |
| justify-content: space-between; |
| font-size: 0.72rem; |
| color: var(--text-muted); |
| font-weight: 700; |
| } |
| |
| .reward-detail { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| padding: 8px 0; |
| border-bottom: 2px solid rgba(0, 0, 0, 0.05); |
| font-size: 0.8rem; |
| } |
| |
| .reward-detail:last-child { border-bottom: none; } |
| |
| .reward-detail-label { color: var(--text-muted); font-weight: 600; } |
| |
| .reward-detail-value { |
| font-family: var(--font-mono); |
| font-weight: 700; |
| } |
| |
| |
| .evidence-list { |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| } |
| |
| .evidence-item { |
| display: flex; |
| align-items: flex-start; |
| gap: 12px; |
| padding: 12px 14px; |
| background: var(--bg-card); |
| border: 3px solid #000; |
| border-radius: 0px; |
| box-shadow: 4px 4px 0px #000; |
| transition: all 0.2s; |
| position: relative; |
| } |
| |
| .evidence-item::before { |
| border-top-left-radius: var(--radius-sm); border-bottom-left-radius: var(--radius-sm); |
| content: ''; |
| position: absolute; |
| left: -3px; top: -3px; bottom: -3px; |
| width: 10px; |
| background: var(--text-muted); |
| border-right: 3px solid #000; |
| } |
| |
| .evidence-item.revealed { background: var(--accent-yellow); border: 3px solid #000; box-shadow: 4px 4px 0px #000; color: #000; } |
| |
| .evidence-item.revealed::before { background: var(--bg-primary); border-right: 3px solid #000; } |
| |
| .evidence-item.locked { opacity: 0.5; background: var(--bg-card); } |
| |
| .evidence-item.locked::before { |
| background: #94a3b8; |
| } |
| |
| .evidence-icon { |
| font-size: 1.1rem; |
| flex-shrink: 0; |
| margin-top: 2px; |
| } |
| |
| .evidence-info { flex: 1; } |
| |
| .evidence-name { |
| font-size: 0.85rem; |
| font-weight: 600; |
| color: var(--text-primary); |
| margin-bottom: 3px; |
| } |
| |
| .evidence-desc { |
| font-size: 0.72rem; |
| color: var(--text-muted); |
| line-height: 1.4; |
| } |
| |
| |
| .alert-list { |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| } |
| |
| .alert-item { |
| padding: 12px 14px; |
| background: var(--bg-card); |
| border: 3px solid #000; |
| border-left: 10px solid var(--accent-red); |
| border-radius: 0px; |
| box-shadow: 4px 4px 0px #000; |
| animation: alert-flash 0.5s ease-out; |
| } |
| |
| @keyframes alert-flash { |
| 0% { transform: translateX(10px); } |
| 100% { transform: translateX(0); } |
| } |
| |
| .alert-suspect { |
| font-size: 0.72rem; |
| font-weight: 700; |
| color: var(--accent-red); |
| margin-bottom: 4px; |
| } |
| |
| .alert-detail { |
| font-size: 0.72rem; |
| color: var(--text-muted); |
| line-height: 1.4; |
| } |
| |
| .alert-turns { |
| font-size: 0.65rem; |
| color: var(--text-muted); |
| margin-top: 4px; |
| font-weight: 600; |
| } |
| |
| |
| .chart-container { |
| height: 200px; |
| position: relative; |
| } |
| |
| .training-metrics { |
| display: grid; |
| grid-template-columns: repeat(2, 1fr); |
| gap: 8px; |
| margin-bottom: 10px; |
| } |
| |
| .training-metric { |
| border: 2px solid #000; |
| background: var(--bg-card); |
| padding: 8px; |
| box-shadow: 2px 2px 0px #000; |
| } |
| |
| .training-metric-label { |
| font-size: 0.66rem; |
| text-transform: uppercase; |
| letter-spacing: 0.05em; |
| font-weight: 800; |
| color: var(--text-muted); |
| } |
| |
| .training-metric-value { |
| font-family: var(--font-mono); |
| font-weight: 800; |
| margin-top: 2px; |
| font-size: 0.9rem; |
| } |
| |
| .chart-canvas { |
| width: 100%; |
| height: 100%; |
| } |
| |
| |
| .empty-state { |
| text-align: center; |
| padding: 30px 16px; |
| color: var(--text-muted); |
| } |
| |
| .empty-icon { font-size: 2rem; margin-bottom: 8px; } |
| |
| .empty-text { |
| font-size: 0.78rem; |
| line-height: 1.5; |
| } |
| |
| |
| ::-webkit-scrollbar { width: 5px; } |
| ::-webkit-scrollbar-track { background: transparent; } |
| ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } |
| ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } |
| |
| @keyframes live-pulse { |
| 0%, 100% { opacity: 1; } |
| 50% { opacity: 0.4; } |
| } |
| |
| |
| @media (max-width: 1100px) { |
| .main-grid { grid-template-columns: 1fr; } |
| .stats-strip { grid-template-columns: repeat(3, 1fr); } |
| } |
| |
| @media (max-width: 700px) { |
| .stats-strip { grid-template-columns: 1fr 1fr; } |
| .topbar { padding: 12px 16px; } |
| .main-grid { padding: 12px 16px; } |
| } |
| |
| |
| .outcome-overlay { |
| position: fixed; |
| inset: 0; |
| background: rgba(253, 252, 249, 0.85); |
| backdrop-filter: blur(8px); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| z-index: 200; |
| opacity: 0; |
| pointer-events: none; |
| transition: opacity 0.4s; |
| } |
| |
| .outcome-overlay.visible { |
| opacity: 1; |
| pointer-events: auto; |
| } |
| |
| .outcome-card { |
| background: var(--bg-card); |
| border: 3px solid #000; |
| border-radius: 0px; |
| padding: 40px 50px; |
| text-align: center; |
| transform: scale(0.9); |
| box-shadow: 8px 8px 0px #000; |
| transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); |
| max-width: 420px; |
| } |
| |
| .outcome-overlay.visible .outcome-card { |
| transform: scale(1); |
| } |
| |
| .outcome-icon { font-size: 3.5rem; margin-bottom: 12px; } |
| |
| .outcome-title { |
| font-size: 1.6rem; |
| font-weight: 700; |
| margin-bottom: 6px; |
| } |
| |
| .outcome-subtitle { |
| font-size: 0.9rem; |
| color: var(--text-muted); |
| margin-bottom: 20px; |
| } |
| |
| .outcome-stat { |
| font-family: var(--font-mono); |
| font-size: 2rem; |
| font-weight: 700; |
| margin-bottom: 20px; |
| } |
| |
| .outcome-stat.win { color: var(--accent-green); } |
| .outcome-stat.lose { color: var(--accent-red); } |
| </style> |
| </head> |
| <body> |
| <div class="app" id="app"> |
|
|
| |
| <header class="topbar"> |
| <div class="topbar-left"> |
| <span class="logo-icon">π</span> |
| <span class="logo">Crime Investigation World</span> |
| <div class="status-badge" id="statusBadge"> |
| <span class="status-dot"></span> |
| <span id="statusText">Ready</span> |
| </div> |
| </div> |
| <div class="topbar-right"> |
| <span id="episodeLabel" style="font-size:0.75rem;color:var(--text-muted);font-family:var(--font-mono);">Episode β</span> |
| <button class="btn btn-primary" id="btnRunLive" onclick="runLiveAgent()" style="background: linear-gradient(135deg, #10b981, #059669);">π Run Live Server</button> |
| <button class="btn btn-danger" id="btnReset" onclick="resetBoard()" style="display:none;">βΊ Reset</button> |
| </div> |
| </header> |
|
|
| |
| <div class="stats-strip"> |
| <div class="stat-card"> |
| <div class="stat-icon turns">π</div> |
| <div class="stat-info"> |
| <span class="stat-label">Turn</span> |
| <span class="stat-value" id="statTurn">0 / 15</span> |
| </div> |
| </div> |
| <div class="stat-card"> |
| <div class="stat-icon evidence">π¬</div> |
| <div class="stat-info"> |
| <span class="stat-label">Evidence</span> |
| <span class="stat-value" id="statEvidence">0 / 3</span> |
| </div> |
| </div> |
| <div class="stat-card"> |
| <div class="stat-icon contradictions">β‘</div> |
| <div class="stat-info"> |
| <span class="stat-label">Contradictions</span> |
| <span class="stat-value" id="statContra">0</span> |
| </div> |
| </div> |
| <div class="stat-card"> |
| <div class="stat-icon reward">π</div> |
| <div class="stat-info"> |
| <span class="stat-label">Reward</span> |
| <span class="stat-value" id="statReward">0.00</span> |
| </div> |
| </div> |
| <div class="stat-card"> |
| <div class="stat-icon result">π―</div> |
| <div class="stat-info"> |
| <span class="stat-label">Outcome</span> |
| <span class="stat-value neutral" id="statOutcome">β</span> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="main-grid"> |
|
|
| |
| <div class="panel conversation-panel"> |
| <div class="panel-header"> |
| <span class="panel-title">π¬ Interrogation Log</span> |
| <span id="msgCount" style="font-size:0.7rem;color:var(--text-muted);font-family:var(--font-mono);">0 messages</span> |
| </div> |
| <div class="panel-body" id="conversationLog"> |
| <div class="empty-state"> |
| <div class="empty-icon">π΅οΈ</div> |
| <div class="empty-text">Click <strong>Run Live Server</strong> to watch a full<br>investigation episode unfold live.</div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="sidebar"> |
|
|
| |
| <div class="panel"> |
| <div class="panel-header"> |
| <span class="panel-title">π Reward Breakdown</span> |
| </div> |
| <div class="panel-body reward-meter"> |
| <div class="reward-bar-track"> |
| <div class="reward-bar-fill" id="rewardBar"></div> |
| </div> |
| <div class="reward-labels"> |
| <span>-10</span> |
| <span>0</span> |
| <span>+10</span> |
| </div> |
| <div style="margin-top:12px;" id="rewardBreakdown"> |
| <div class="reward-detail"> |
| <span class="reward-detail-label">Base</span> |
| <span class="reward-detail-value" id="rwBase">0.00</span> |
| </div> |
| <div class="reward-detail"> |
| <span class="reward-detail-label">Turn Cost</span> |
| <span class="reward-detail-value" id="rwTurnCost" style="color:var(--accent-red);">0.00</span> |
| </div> |
| <div class="reward-detail"> |
| <span class="reward-detail-label">Contradiction Bonus</span> |
| <span class="reward-detail-value" id="rwContra" style="color:var(--accent-amber);">0.00</span> |
| </div> |
| <div class="reward-detail"> |
| <span class="reward-detail-label">Evidence Bonus</span> |
| <span class="reward-detail-value" id="rwEvidence" style="color:var(--accent-cyan);">0.00</span> |
| </div> |
| <div class="reward-detail"> |
| <span class="reward-detail-label"><strong>Total</strong></span> |
| <span class="reward-detail-value" id="rwTotal" style="font-size:1.05rem;">0.00</span> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="panel"> |
| <div class="panel-header"> |
| <span class="panel-title">π¬ Evidence</span> |
| </div> |
| <div class="panel-body"> |
| <div class="evidence-list" id="evidenceList"> |
| <div class="empty-state" id="evidenceEmptyState" style="padding:14px;"> |
| <div class="empty-text" style="font-size:0.72rem;">No evidence revealed yet.</div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="panel"> |
| <div class="panel-header"> |
| <span class="panel-title">β‘ Contradiction Alerts</span> |
| </div> |
| <div class="panel-body"> |
| <div class="alert-list" id="alertList"> |
| <div class="empty-state" style="padding:14px;"> |
| <div class="empty-text" style="font-size:0.72rem;">No contradictions detected yet.</div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="panel" id="trainingPanel"> |
| <div class="panel-header"> |
| <span class="panel-title">π Training Progress</span> |
| <span id="trainLiveBadge" style="display:none;font-size:0.66rem;font-weight:800;text-transform:uppercase;letter-spacing:0.06em;background:#16a34a;color:#fff;padding:3px 8px;border:2px solid #000;box-shadow:2px 2px 0px #000;animation:live-pulse 1.5s ease-in-out infinite;">β LIVE</span> |
| </div> |
| <div class="panel-body"> |
| <div class="training-metrics"> |
| <div class="training-metric"> |
| <div class="training-metric-label">Episodes</div> |
| <div class="training-metric-value" id="trainEpisodes">-</div> |
| </div> |
| <div class="training-metric"> |
| <div class="training-metric-label">Model</div> |
| <div class="training-metric-value" id="trainModel" style="font-size:0.72rem;">-</div> |
| </div> |
| <div class="training-metric"> |
| <div class="training-metric-label">Accuracy</div> |
| <div class="training-metric-value" id="trainAccuracy">-</div> |
| </div> |
| <div class="training-metric"> |
| <div class="training-metric-label">Difficulty</div> |
| <div class="training-metric-value" id="trainDifficulty" style="text-transform:capitalize;">-</div> |
| </div> |
| <div class="training-metric"> |
| <div class="training-metric-label">First-50 Mean</div> |
| <div class="training-metric-value" id="trainFirst">-</div> |
| </div> |
| <div class="training-metric"> |
| <div class="training-metric-label">Last-50 Mean</div> |
| <div class="training-metric-value" id="trainLast">-</div> |
| </div> |
| </div> |
| <div style="font-size:0.72rem; font-weight:800; margin-bottom:6px;" id="trainImprovement">Improvement: -</div> |
| <div style="font-size:0.7rem; margin-bottom:8px; color:#666;" id="trainStatus">Checking training artifacts...</div> |
| <div id="trainResultDots" style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px;min-height:16px;"></div> |
| <div class="chart-container"> |
| <canvas class="chart-canvas" id="trainingRewardChart"></canvas> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="panel" id="historyPanel" style="display:none;"> |
| <div class="panel-header"> |
| <span class="panel-title">π Reward History</span> |
| </div> |
| <div class="panel-body"> |
| <div class="chart-container"> |
| <canvas class="chart-canvas" id="rewardChart"></canvas> |
| </div> |
| </div> |
| </div> |
|
|
| </div> |
| </div> |
|
|
| |
| <div class="outcome-overlay" id="outcomeOverlay"> |
| <div class="outcome-card"> |
| <div class="outcome-icon" id="outcomeIcon">π</div> |
| <div class="outcome-title" id="outcomeTitle">Case Solved!</div> |
| <div class="outcome-subtitle" id="outcomeSub">The detective correctly identified the criminal.</div> |
| <div class="outcome-stat win" id="outcomeStat">+8.50</div> |
| <button class="btn btn-primary" onclick="dismissOutcome()">Continue</button> |
| </div> |
| </div> |
|
|
| </div> |
|
|
| <script> |
| |
| const EVIDENCE_ICONS = { |
| keycard_log: "π", |
| cctv_footage: "πΉ", |
| forensic_report: "π¬", |
| }; |
| |
| const EVIDENCE_NAMES = { |
| keycard_log: "Keycard Log", |
| cctv_footage: "CCTV Footage", |
| forensic_report: "Forensic Report", |
| }; |
| |
| |
| |
| let demoRunning = false; |
| let currentTurn = 0; |
| let evidenceCount = 0; |
| let contradictionCount = 0; |
| let totalReward = 0; |
| let rewardHistory = []; |
| let messageCount = 0; |
| let revealedEvidenceKeys = new Set(); |
| let trainingRewards = []; |
| let trainingSmoothed = []; |
| let lastTrainEpisodeCount = 0; |
| |
| const rewardComponents = { |
| base: 0, |
| turnCost: 0, |
| contradictions: 0, |
| evidence: 0, |
| total: 0, |
| }; |
| |
| |
| |
| function getAvatarClass(speaker) { |
| if (speaker === "Detective") return "detective"; |
| if (speaker === "Suspect_A") return "suspect-a"; |
| if (speaker === "Suspect_B") return "suspect-b"; |
| if (speaker === "Witness_1") return "witness"; |
| return "system"; |
| } |
| |
| function getAvatarEmoji(speaker) { |
| if (speaker === "Detective") return "π"; |
| if (speaker === "Suspect_A") return "π
°οΈ"; |
| if (speaker === "Suspect_B") return "π
±οΈ"; |
| if (speaker === "Witness_1") return "ποΈ"; |
| return "βοΈ"; |
| } |
| |
| function addMessage(speaker, text, turn, tag) { |
| const log = document.getElementById("conversationLog"); |
| |
| if (messageCount === 0) log.innerHTML = ""; |
| |
| const msgEl = document.createElement("div"); |
| msgEl.className = "msg"; |
| |
| const cls = getAvatarClass(speaker); |
| let tagHTML = ""; |
| if (tag) { |
| tagHTML = `<div class="msg-tag ${tag}">${tag}</div>`; |
| } |
| |
| msgEl.innerHTML = ` |
| <div class="msg-avatar ${cls}">${getAvatarEmoji(speaker)}</div> |
| <div class="msg-content"> |
| <div class="msg-header"> |
| <span class="msg-name ${cls}">${speaker}</span> |
| <span class="msg-turn">Turn ${turn}</span> |
| </div> |
| <div class="msg-text">${text}</div> |
| ${tagHTML} |
| </div> |
| `; |
| |
| log.appendChild(msgEl); |
| log.scrollTop = log.scrollHeight; |
| messageCount++; |
| document.getElementById("msgCount").textContent = `${messageCount} messages`; |
| } |
| |
| function revealEvidence(itemKey, nameOverride, descOverride) { |
| let keyNorm = itemKey.toLowerCase().replace(" ", "_"); |
| |
| if (keyNorm.includes("keycard")) keyNorm = "keycard_log"; |
| if (keyNorm.includes("cctv")) keyNorm = "cctv_footage"; |
| if (keyNorm.includes("forensic")) keyNorm = "forensic_report"; |
| |
| if (revealedEvidenceKeys.has(keyNorm)) return; |
| revealedEvidenceKeys.add(keyNorm); |
| |
| |
| const name = nameOverride || EVIDENCE_NAMES[keyNorm] || itemKey; |
| const description = descOverride || ""; |
| const icon = EVIDENCE_ICONS[keyNorm] || "π"; |
| |
| evidenceCount++; |
| document.getElementById("statEvidence").textContent = `${evidenceCount} / 3`; |
| |
| |
| const list = document.getElementById("evidenceList"); |
| const emptyState = document.getElementById("evidenceEmptyState"); |
| if (emptyState) emptyState.remove(); |
| |
| const el = document.createElement("div"); |
| el.className = "evidence-item revealed"; |
| el.innerHTML = ` |
| <span class="evidence-icon">${icon}</span> |
| <div class="evidence-info"> |
| <div class="evidence-name">${name}</div> |
| <div class="evidence-desc">${description}</div> |
| </div> |
| `; |
| list.appendChild(el); |
| |
| addMessage("System", `π Evidence revealed: <strong>${name}</strong> β ${description}`, currentTurn, "evidence"); |
| |
| |
| rewardComponents.evidence += 1.0; |
| updateReward(); |
| } |
| |
| function addContradiction(suspect, claim1, t1, claim2, t2) { |
| contradictionCount++; |
| document.getElementById("statContra").textContent = contradictionCount; |
| |
| const list = document.getElementById("alertList"); |
| if (contradictionCount === 1) list.innerHTML = ""; |
| |
| const el = document.createElement("div"); |
| el.className = "alert-item"; |
| el.innerHTML = ` |
| <div class="alert-suspect">β ${suspect} β Contradiction #${contradictionCount}</div> |
| <div class="alert-detail">"${claim1}" β "${claim2}"</div> |
| <div class="alert-turns">Turns ${t1} β ${t2}</div> |
| `; |
| list.appendChild(el); |
| |
| rewardComponents.contradictions += 2.0; |
| updateReward(); |
| } |
| |
| function updateReward() { |
| const total = rewardComponents.base + rewardComponents.turnCost + rewardComponents.contradictions + rewardComponents.evidence; |
| rewardComponents.total = total; |
| totalReward = total; |
| |
| document.getElementById("statReward").textContent = total.toFixed(2); |
| const statEl = document.getElementById("statReward"); |
| statEl.className = "stat-value " + (total >= 0 ? "positive" : "negative"); |
| |
| |
| const pct = Math.max(0, Math.min(100, ((total + 10) / 20) * 100)); |
| document.getElementById("rewardBar").style.width = `${pct}%`; |
| |
| document.getElementById("rwBase").textContent = rewardComponents.base.toFixed(2); |
| document.getElementById("rwTurnCost").textContent = rewardComponents.turnCost.toFixed(2); |
| document.getElementById("rwContra").textContent = `+${rewardComponents.contradictions.toFixed(2)}`; |
| document.getElementById("rwEvidence").textContent = `+${rewardComponents.evidence.toFixed(2)}`; |
| document.getElementById("rwTotal").textContent = total.toFixed(2); |
| document.getElementById("rwTotal").style.color = total >= 0 ? "var(--accent-green)" : "var(--accent-red)"; |
| } |
| |
| function updateTurn(t) { |
| currentTurn = t; |
| document.getElementById("statTurn").textContent = `${t} / 15`; |
| rewardComponents.turnCost = -(t * 0.3); |
| updateReward(); |
| } |
| |
| function showOutcome(correct) { |
| const overlay = document.getElementById("outcomeOverlay"); |
| document.getElementById("outcomeIcon").textContent = correct ? "π" : "β"; |
| document.getElementById("outcomeTitle").textContent = correct ? "Case Solved!" : "Wrong Accusation"; |
| document.getElementById("outcomeSub").textContent = correct |
| ? "The detective correctly identified the true criminal." |
| : "The detective accused the wrong suspect."; |
| document.getElementById("outcomeStat").textContent = (totalReward >= 0 ? "+" : "") + totalReward.toFixed(2); |
| document.getElementById("outcomeStat").className = "outcome-stat " + (correct ? "win" : "lose"); |
| document.getElementById("statOutcome").textContent = correct ? "β
Correct" : "β Wrong"; |
| document.getElementById("statOutcome").className = "stat-value " + (correct ? "positive" : "negative"); |
| overlay.classList.add("visible"); |
| } |
| |
| function dismissOutcome() { |
| document.getElementById("outcomeOverlay").classList.remove("visible"); |
| document.getElementById("btnReset").style.display = "inline-flex"; |
| updateRewardChart(); |
| } |
| |
| |
| |
| function updateRewardChart() { |
| rewardHistory.push(totalReward); |
| const panel = document.getElementById("historyPanel"); |
| panel.style.display = "block"; |
| |
| const canvas = document.getElementById("rewardChart"); |
| const ctx = canvas.getContext("2d"); |
| const rect = canvas.parentElement.getBoundingClientRect(); |
| canvas.width = rect.width * 2; |
| canvas.height = rect.height * 2; |
| ctx.scale(2, 2); |
| |
| const w = rect.width; |
| const h = rect.height; |
| const data = rewardHistory; |
| |
| |
| const maxVal = data.length > 0 ? Math.max(...data) : 0; |
| const minVal = data.length > 0 ? Math.min(...data) : 0; |
| const maxR = Math.max(10, Math.ceil(maxVal / 5) * 5 + 5); |
| const minR = Math.min(-10, Math.floor(minVal / 5) * 5 - 5); |
| const stepV = Math.max(5, Math.floor((maxR - minR) / 6)); |
| |
| const pad = { t: 15, b: 25, l: 40, r: 15 }; |
| |
| ctx.clearRect(0, 0, w, h); |
| |
| |
| ctx.strokeStyle = "rgba(0,0,0,0.1)"; |
| ctx.lineWidth = 1; |
| ctx.fillStyle = "rgba(0,0,0,0.6)"; |
| ctx.font = "11px 'JetBrains Mono'"; |
| ctx.textAlign = "right"; |
| |
| for (let v = minR; v <= maxR; v += stepV) { |
| const y = pad.t + ((maxR - v) / (maxR - minR)) * (h - pad.t - pad.b); |
| ctx.beginPath(); |
| ctx.moveTo(pad.l, y); |
| ctx.lineTo(w - pad.r, y); |
| ctx.stroke(); |
| |
| ctx.fillText(v.toString(), pad.l - 8, y + 4); |
| } |
| |
| |
| const zeroY = pad.t + ((maxR - 0) / (maxR - minR)) * (h - pad.t - pad.b); |
| ctx.strokeStyle = "rgba(0,0,0,0.2)"; |
| ctx.beginPath(); |
| ctx.moveTo(pad.l, zeroY); |
| ctx.lineTo(w - pad.r, zeroY); |
| ctx.stroke(); |
| |
| if (data.length < 2) return; |
| |
| |
| const stepX = (w - pad.l - pad.r) / (Math.max(data.length - 1, 1)); |
| ctx.beginPath(); |
| data.forEach((v, i) => { |
| const x = pad.l + i * stepX; |
| const y = pad.t + ((maxR - v) / (maxR - minR)) * (h - pad.t - pad.b); |
| i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); |
| }); |
| ctx.strokeStyle = "#3b82f6"; |
| ctx.lineWidth = 2; |
| ctx.stroke(); |
| |
| |
| const grad = ctx.createLinearGradient(0, 0, 0, h); |
| grad.addColorStop(0, "rgba(59, 130, 246, 0.2)"); |
| grad.addColorStop(1, "rgba(59, 130, 246, 0)"); |
| ctx.lineTo(pad.l + (data.length - 1) * stepX, h - pad.b); |
| ctx.lineTo(pad.l, h - pad.b); |
| ctx.closePath(); |
| ctx.fillStyle = grad; |
| ctx.fill(); |
| |
| |
| data.forEach((v, i) => { |
| const x = pad.l + i * stepX; |
| const y = pad.t + ((maxR - v) / (maxR - minR)) * (h - pad.t - pad.b); |
| ctx.beginPath(); |
| ctx.arc(x, y, 4, 0, Math.PI * 2); |
| ctx.fillStyle = v >= 0 ? "#10b981" : "#ef4444"; |
| ctx.fill(); |
| ctx.strokeStyle = "#ffffff"; |
| ctx.lineWidth = 2; |
| ctx.stroke(); |
| }); |
| |
| |
| ctx.fillStyle = "rgba(0,0,0,0.5)"; |
| ctx.font = "9px 'JetBrains Mono'"; |
| ctx.textAlign = "center"; |
| data.forEach((_, i) => { |
| ctx.fillText(`E${i + 1}`, pad.l + i * stepX, h - 4); |
| }); |
| } |
| |
| function drawTrainingChart(rewards, smoothed) { |
| const canvas = document.getElementById("trainingRewardChart"); |
| const ctx = canvas.getContext("2d"); |
| const rect = canvas.parentElement.getBoundingClientRect(); |
| canvas.width = rect.width * 2; |
| canvas.height = rect.height * 2; |
| ctx.scale(2, 2); |
| |
| const w = rect.width; |
| const h = rect.height; |
| const pad = { t: 15, b: 25, l: 40, r: 10 }; |
| const data = rewards || []; |
| const avg = smoothed || []; |
| |
| ctx.clearRect(0, 0, w, h); |
| |
| if (data.length === 0) { |
| ctx.fillStyle = "rgba(0,0,0,0.6)"; |
| ctx.font = "12px 'JetBrains Mono'"; |
| ctx.fillText("No training data found yet.", 12, 24); |
| return; |
| } |
| |
| const maxVal = Math.max(...data, ...(avg.length ? avg : data), 10); |
| const minVal = Math.min(...data, ...(avg.length ? avg : data), -10); |
| const span = Math.max(1, maxVal - minVal); |
| |
| ctx.strokeStyle = "rgba(0,0,0,0.12)"; |
| ctx.lineWidth = 1; |
| for (let i = 0; i <= 5; i++) { |
| const y = pad.t + (i / 5) * (h - pad.t - pad.b); |
| ctx.beginPath(); |
| ctx.moveTo(pad.l, y); |
| ctx.lineTo(w - pad.r, y); |
| ctx.stroke(); |
| } |
| |
| const valueToY = (v) => pad.t + ((maxVal - v) / span) * (h - pad.t - pad.b); |
| const xStep = (w - pad.l - pad.r) / Math.max(1, data.length - 1); |
| |
| ctx.beginPath(); |
| data.forEach((v, i) => { |
| const x = pad.l + i * xStep; |
| const y = valueToY(v); |
| i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); |
| }); |
| ctx.strokeStyle = "#65A3BB"; |
| ctx.lineWidth = 2; |
| ctx.stroke(); |
| |
| if (avg.length === data.length && avg.length > 1) { |
| ctx.beginPath(); |
| avg.forEach((v, i) => { |
| const x = pad.l + i * xStep; |
| const y = valueToY(v); |
| i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); |
| }); |
| ctx.strokeStyle = "#F25D68"; |
| ctx.lineWidth = 2.5; |
| ctx.stroke(); |
| } |
| } |
| |
| async function loadTrainingProgress() { |
| try { |
| const res = await fetch("/api/reward_curve"); |
| if (!res.ok) return; |
| const data = await res.json(); |
| trainingRewards = data.rewards || []; |
| trainingSmoothed = data.smoothed || []; |
| |
| document.getElementById("trainEpisodes").textContent = String(data.num_episodes || trainingRewards.length || 0); |
| document.getElementById("trainModel").textContent = (data.model || "-").replace("Qwen/", ""); |
| document.getElementById("trainFirst").textContent = Number(data.mean_first_50 || 0).toFixed(2); |
| document.getElementById("trainLast").textContent = Number(data.mean_last_50 || 0).toFixed(2); |
| |
| const improvement = Number(data.improvement || 0); |
| const sign = improvement >= 0 ? "+" : ""; |
| const improveEl = document.getElementById("trainImprovement"); |
| improveEl.textContent = `Improvement: ${sign}${improvement.toFixed(2)}`; |
| improveEl.style.color = improvement >= 0 ? "#16a34a" : "#dc2626"; |
| |
| const statusEl = document.getElementById("trainStatus"); |
| if (data.has_data) { |
| statusEl.textContent = "Live: training metrics loaded."; |
| statusEl.style.color = "#16a34a"; |
| } else { |
| statusEl.textContent = data.message || "No training data found yet."; |
| statusEl.style.color = "#666"; |
| } |
| |
| drawTrainingChart(trainingRewards, trainingSmoothed); |
| } catch (err) { |
| const statusEl = document.getElementById("trainStatus"); |
| statusEl.textContent = "Could not load training metrics from server."; |
| statusEl.style.color = "#dc2626"; |
| } |
| } |
| |
| |
| function resetBoard() { |
| currentTurn = 0; |
| evidenceCount = 0; |
| contradictionCount = 0; |
| totalReward = 0; |
| messageCount = 0; |
| revealedEvidenceKeys.clear(); |
| if (window.revealedContradictionKeys) window.revealedContradictionKeys.clear(); |
| Object.keys(rewardComponents).forEach(k => rewardComponents[k] = 0); |
| |
| document.getElementById("statTurn").textContent = "0 / 15"; |
| document.getElementById("statEvidence").textContent = "0 / 3"; |
| document.getElementById("statContra").textContent = "0"; |
| document.getElementById("statReward").textContent = "0.00"; |
| document.getElementById("statReward").className = "stat-value"; |
| document.getElementById("statOutcome").textContent = "β"; |
| document.getElementById("statOutcome").className = "stat-value neutral"; |
| document.getElementById("rewardBar").style.width = "50%"; |
| document.getElementById("msgCount").textContent = "0 messages"; |
| |
| document.getElementById("conversationLog").innerHTML = ` |
| <div class="empty-state"> |
| <div class="empty-icon">π΅οΈ</div> |
| <div class="empty-text">Click <strong>Run Live Server</strong> to watch a full<br>investigation episode unfold live.</div> |
| </div>`; |
| |
| document.getElementById("evidenceList").innerHTML = ` |
| <div class="empty-state" id="evidenceEmptyState" style="padding:14px;"> |
| <div class="empty-text" style="font-size:0.72rem;">No evidence revealed yet.</div> |
| </div>`; |
| |
| document.getElementById("alertList").innerHTML = ` |
| <div class="empty-state" style="padding:14px;"> |
| <div class="empty-text" style="font-size:0.72rem;">No contradictions detected yet.</div> |
| </div>`; |
| |
| document.getElementById("btnReset").style.display = "none"; |
| document.getElementById("outcomeOverlay").classList.remove("visible"); |
| |
| updateReward(); |
| } |
| |
| function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } |
| |
| |
| |
| async function runLiveAgent() { |
| if (demoRunning) return; |
| demoRunning = true; |
| |
| resetBoard(); |
| |
| document.getElementById("btnRunLive").disabled = true; |
| document.getElementById("btnRunLive").textContent = "β³ Waiting for AI..."; |
| document.getElementById("statusText").textContent = "Live Investigating"; |
| document.getElementById("statusBadge").style.borderColor = "rgba(16, 185, 129, 0.4)"; |
| document.getElementById("statusBadge").style.background = "rgba(16, 185, 129, 0.1)"; |
| document.getElementById("statusBadge").querySelector(".status-dot").style.background = "var(--accent-green)"; |
| document.getElementById("statusBadge").querySelector("span:last-child").style.color = "var(--accent-green)"; |
| |
| const epNum = rewardHistory.length + 1; |
| document.getElementById("episodeLabel").textContent = `Episode ${epNum}`; |
| |
| const DELAY_SHORT = 600; |
| const DELAY_LONG = 1200; |
| |
| try { |
| const response = await fetch("/api/run_episode"); |
| if (!response.ok) throw new Error("Server not responding"); |
| const data = await response.json(); |
| |
| if (data.status !== "ok") { |
| throw new Error("Simulation failed"); |
| } |
| |
| const script = data.trace; |
| document.getElementById("btnRunLive").textContent = "βοΈ Executing Trace..."; |
| |
| for (const event of script) { |
| if (!demoRunning) break; |
| |
| if (event.type === "detective") { |
| updateTurn(event.turn); |
| |
| const actionDesc = event.action.includes("ask") |
| ? `Asking ${event.target}: "${event.text}"` |
| : event.action.includes("evid") |
| ? `Requesting evidence: ${event.item}` |
| : `Accusing ${event.target}`; |
| addMessage("Detective", actionDesc, event.turn); |
| |
| |
| |
| await sleep(DELAY_LONG); |
| |
| } else if (event.type === "response") { |
| addMessage(event.speaker, event.text, event.turn); |
| await sleep(DELAY_SHORT); |
| |
| } else if (event.type === "contradiction") { |
| addContradiction(event.suspect, event.claim1 || "Previous claim", event.claim1Turn || "?", event.claim2 || ("New claim: " + event.detail), event.turn); |
| |
| |
| await sleep(DELAY_SHORT); |
| |
| } else if (event.type === "evidence") { |
| revealEvidence(event.item, event.item, event.desc); |
| |
| |
| await sleep(DELAY_SHORT); |
| |
| } else if (event.type === "outcome") { |
| |
| |
| let serverTotal = data.rewards.detective; |
| rewardComponents.base = serverTotal - rewardComponents.turnCost - rewardComponents.contradictions - rewardComponents.evidence; |
| totalReward = serverTotal; |
| updateReward(); |
| |
| await sleep(800); |
| showOutcome(event.correct || false); |
| } |
| } |
| |
| |
| totalReward = data.rewards.detective; |
| rewardComponents.base = totalReward - rewardComponents.turnCost - rewardComponents.contradictions - rewardComponents.evidence; |
| rewardComponents.total = totalReward; |
| updateReward(); |
| document.getElementById("statReward").textContent = totalReward.toFixed(2); |
| |
| } catch (e) { |
| addMessage("System", `β οΈ Error connecting to live server: ${e.message}. Note: you must run 'uvicorn server.app:app' and open the app from localhost to use Live Mode!`, currentTurn || 0); |
| } |
| |
| document.getElementById("statusText").textContent = "Complete"; |
| document.getElementById("statusBadge").style.borderColor = "rgba(16, 185, 129, 0.25)"; |
| document.getElementById("btnRunLive").disabled = false; |
| document.getElementById("btnRunLive").textContent = "π Run Live Server"; |
| demoRunning = false; |
| } |
| |
| window.addEventListener("load", () => { |
| loadTrainingProgress(); |
| |
| setInterval(loadTrainingProgress, 15000); |
| }); |
| </script> |
| </body> |
| </html> |
|
|