villsim/web_frontend/goap_debug.html
Снесарев Максим 308f738c37 [new] add goap agents
2026-01-19 20:45:35 +03:00

821 lines
27 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GOAP Debug Visualizer - VillSim</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--border-color: #30363d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent-blue: #58a6ff;
--accent-green: #3fb950;
--accent-orange: #d29922;
--accent-red: #f85149;
--accent-purple: #a371f7;
--accent-cyan: #39c5cf;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
.header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 {
font-size: 20px;
font-weight: 600;
color: var(--accent-cyan);
}
.header-controls {
display: flex;
gap: 12px;
align-items: center;
}
.btn {
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
}
.btn:hover {
background: var(--border-color);
border-color: var(--text-muted);
}
.btn-primary {
background: var(--accent-blue);
border-color: var(--accent-blue);
color: #fff;
}
.btn-primary:hover {
background: #4c9aff;
}
.status-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.status-connected {
background: rgba(63, 185, 80, 0.2);
color: var(--accent-green);
}
.status-disconnected {
background: rgba(248, 81, 73, 0.2);
color: var(--accent-red);
}
.main-content {
display: grid;
grid-template-columns: 280px 1fr 400px;
height: calc(100vh - 65px);
}
.panel {
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
overflow-y: auto;
}
.panel:last-child {
border-right: none;
border-left: 1px solid var(--border-color);
}
.panel-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
position: sticky;
top: 0;
z-index: 10;
}
.panel-header h2 {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
.agent-list {
padding: 8px;
}
.agent-item {
padding: 12px;
border-radius: 8px;
cursor: pointer;
margin-bottom: 4px;
transition: background 0.15s ease;
}
.agent-item:hover {
background: var(--bg-tertiary);
}
.agent-item.selected {
background: rgba(88, 166, 255, 0.15);
border: 1px solid var(--accent-blue);
}
.agent-item .agent-name {
font-weight: 500;
margin-bottom: 4px;
}
.agent-item .agent-action {
font-size: 12px;
color: var(--text-secondary);
font-family: 'IBM Plex Mono', monospace;
}
.agent-item .agent-goal {
font-size: 11px;
color: var(--accent-cyan);
margin-top: 4px;
}
.center-panel {
display: flex;
flex-direction: column;
background: var(--bg-primary);
}
.plan-view {
padding: 24px;
flex: 1;
overflow-y: auto;
}
.plan-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.plan-header h2 {
font-size: 24px;
font-weight: 600;
}
.plan-goal-badge {
padding: 6px 16px;
background: rgba(163, 113, 247, 0.2);
color: var(--accent-purple);
border-radius: 20px;
font-size: 14px;
font-weight: 500;
}
.world-state-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 32px;
}
.state-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
}
.state-card .label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.state-card .value {
font-size: 24px;
font-weight: 600;
font-family: 'IBM Plex Mono', monospace;
}
.state-card .bar {
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
margin-top: 8px;
overflow: hidden;
}
.state-card .bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.bar-thirst .bar-fill { background: var(--accent-blue); }
.bar-hunger .bar-fill { background: var(--accent-orange); }
.bar-heat .bar-fill { background: var(--accent-red); }
.bar-energy .bar-fill { background: var(--accent-green); }
.plan-visualization {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
.plan-visualization h3 {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 16px;
}
.plan-steps {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.plan-step {
display: flex;
align-items: center;
gap: 8px;
}
.step-node {
padding: 12px 20px;
background: var(--bg-tertiary);
border: 2px solid var(--border-color);
border-radius: 8px;
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
font-weight: 500;
}
.step-node.current {
border-color: var(--accent-green);
background: rgba(63, 185, 80, 0.15);
color: var(--accent-green);
}
.step-arrow {
color: var(--text-muted);
font-size: 20px;
}
.goal-result {
padding: 12px 20px;
background: rgba(163, 113, 247, 0.15);
border: 2px solid var(--accent-purple);
border-radius: 8px;
color: var(--accent-purple);
font-weight: 500;
}
.no-plan {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
.goals-chart-container {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
}
.goals-chart-container h3 {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 16px;
}
.chart-wrapper {
height: 300px;
}
.detail-section {
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.detail-section h3 {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.detail-item {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 13px;
}
.detail-item .label {
color: var(--text-secondary);
}
.detail-item .value {
font-family: 'IBM Plex Mono', monospace;
color: var(--text-primary);
}
.action-list {
max-height: 300px;
overflow-y: auto;
}
.action-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 6px;
margin-bottom: 4px;
font-size: 13px;
}
.action-item.valid {
background: var(--bg-tertiary);
}
.action-item.invalid {
background: transparent;
opacity: 0.5;
}
.action-item.in-plan {
background: rgba(63, 185, 80, 0.15);
border: 1px solid var(--accent-green);
}
.action-item .action-name {
flex: 1;
font-family: 'IBM Plex Mono', monospace;
}
.action-item .action-cost {
font-size: 11px;
color: var(--text-muted);
margin-left: 8px;
}
.action-item .action-order {
width: 20px;
height: 20px;
background: var(--accent-green);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: #000;
margin-right: 8px;
}
.inventory-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.inv-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: var(--bg-tertiary);
border-radius: 6px;
font-size: 13px;
}
.inv-item .icon {
font-size: 16px;
}
.inv-item .count {
margin-left: auto;
font-family: 'IBM Plex Mono', monospace;
font-weight: 500;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
}
.urgency-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-left: 8px;
}
.urgency-none { background: var(--accent-green); }
.urgency-low { background: var(--accent-orange); }
.urgency-high { background: var(--accent-red); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.updating {
animation: pulse 1s infinite;
}
</style>
</head>
<body>
<header class="header">
<h1>🧠 GOAP Debug Visualizer</h1>
<div class="header-controls">
<span id="turn-display">Turn 0</span>
<span id="status-badge" class="status-badge status-disconnected">Disconnected</span>
<button class="btn" onclick="refreshData()">↻ Refresh</button>
<button class="btn btn-primary" id="auto-refresh-btn" onclick="toggleAutoRefresh()">▶ Auto</button>
</div>
</header>
<main class="main-content">
<!-- Left Panel: Agent List -->
<div class="panel">
<div class="panel-header">
<h2>Agents</h2>
</div>
<div id="agent-list" class="agent-list">
<div class="loading">Loading...</div>
</div>
</div>
<!-- Center Panel: Plan Visualization -->
<div class="center-panel">
<div class="plan-view" id="plan-view">
<div class="loading">Select an agent to view GOAP details</div>
</div>
</div>
<!-- Right Panel: Details -->
<div class="panel">
<div class="panel-header">
<h2>Details</h2>
</div>
<div id="details-panel">
<div class="loading">Select an agent</div>
</div>
</div>
</main>
<script>
const API_BASE = 'http://localhost:8000/api';
let selectedAgentId = null;
let allAgentsData = [];
let autoRefreshInterval = null;
let goalsChart = null;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
refreshData();
});
async function refreshData() {
try {
const response = await fetch(`${API_BASE}/goap/debug`);
if (!response.ok) throw new Error('API error');
const data = await response.json();
allAgentsData = data.agents;
document.getElementById('turn-display').textContent = `Turn ${data.current_turn}`;
document.getElementById('status-badge').className = 'status-badge status-connected';
document.getElementById('status-badge').textContent = data.is_night ? '🌙 Night' : '☀️ Connected';
renderAgentList();
if (selectedAgentId) {
const agent = allAgentsData.find(a => a.agent_id === selectedAgentId);
if (agent) {
renderAgentDetails(agent);
}
}
} catch (error) {
console.error('Failed to fetch data:', error);
document.getElementById('status-badge').className = 'status-badge status-disconnected';
document.getElementById('status-badge').textContent = 'Disconnected';
}
}
function renderAgentList() {
const container = document.getElementById('agent-list');
if (allAgentsData.length === 0) {
container.innerHTML = '<div class="loading">No agents found</div>';
return;
}
container.innerHTML = allAgentsData.map(agent => `
<div class="agent-item ${agent.agent_id === selectedAgentId ? 'selected' : ''}"
onclick="selectAgent('${agent.agent_id}')">
<div class="agent-name">${agent.agent_name}</div>
<div class="agent-action">${agent.selected_action || 'No action'}</div>
<div class="agent-goal">${agent.current_plan ? '🎯 ' + agent.current_plan.goal_name : '(reactive)'}</div>
</div>
`).join('');
}
function selectAgent(agentId) {
selectedAgentId = agentId;
renderAgentList();
const agent = allAgentsData.find(a => a.agent_id === agentId);
if (agent) {
renderAgentDetails(agent);
}
}
function renderAgentDetails(agent) {
renderPlanView(agent);
renderDetailsPanel(agent);
}
function getUrgencyClass(urgency) {
if (urgency <= 0) return 'urgency-none';
if (urgency <= 1) return 'urgency-low';
return 'urgency-high';
}
function renderPlanView(agent) {
const container = document.getElementById('plan-view');
const ws = agent.world_state;
const plan = agent.current_plan;
container.innerHTML = `
<div class="plan-header">
<h2>${agent.agent_name}</h2>
${plan ? `<span class="plan-goal-badge">🎯 ${plan.goal_name}</span>` : ''}
</div>
<div class="world-state-grid">
<div class="state-card bar-thirst">
<div class="label">Thirst</div>
<div class="value">${Math.round(ws.vitals.thirst * 100)}%
<span class="urgency-indicator ${getUrgencyClass(ws.urgencies.thirst)}"></span>
</div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.thirst * 100}%"></div></div>
</div>
<div class="state-card bar-hunger">
<div class="label">Hunger</div>
<div class="value">${Math.round(ws.vitals.hunger * 100)}%
<span class="urgency-indicator ${getUrgencyClass(ws.urgencies.hunger)}"></span>
</div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.hunger * 100}%"></div></div>
</div>
<div class="state-card bar-heat">
<div class="label">Heat</div>
<div class="value">${Math.round(ws.vitals.heat * 100)}%
<span class="urgency-indicator ${getUrgencyClass(ws.urgencies.heat)}"></span>
</div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.heat * 100}%"></div></div>
</div>
<div class="state-card bar-energy">
<div class="label">Energy</div>
<div class="value">${Math.round(ws.vitals.energy * 100)}%
<span class="urgency-indicator ${getUrgencyClass(ws.urgencies.energy)}"></span>
</div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.energy * 100}%"></div></div>
</div>
</div>
<div class="plan-visualization">
<h3>Current Plan</h3>
${plan && plan.actions.length > 0 ? `
<div class="plan-steps">
${plan.actions.map((action, i) => `
<div class="plan-step">
<div class="step-node ${i === 0 ? 'current' : ''}">${action}</div>
${i < plan.actions.length - 1 ? '<span class="step-arrow">→</span>' : ''}
</div>
`).join('')}
<span class="step-arrow">→</span>
<div class="goal-result">✓ ${plan.goal_name}</div>
</div>
<div style="margin-top: 12px; font-size: 13px; color: var(--text-muted);">
Total Cost: ${plan.total_cost.toFixed(1)} | Steps: ${plan.plan_length}
</div>
` : `
<div class="no-plan">
<p style="font-size: 16px; margin-bottom: 8px;">No plan - using reactive selection</p>
<p>Selected: <strong>${agent.selected_action || 'None'}</strong></p>
</div>
`}
</div>
<div class="goals-chart-container">
<h3>Goal Priorities</h3>
<div class="chart-wrapper">
<canvas id="goals-chart"></canvas>
</div>
</div>
`;
renderGoalsChart(agent);
}
function renderGoalsChart(agent) {
const ctx = document.getElementById('goals-chart');
if (!ctx) return;
// Sort goals by priority
const sortedGoals = [...agent.goals].sort((a, b) => b.priority - a.priority);
const topGoals = sortedGoals.slice(0, 10);
if (goalsChart) {
goalsChart.destroy();
}
goalsChart = new Chart(ctx, {
type: 'bar',
data: {
labels: topGoals.map(g => g.name),
datasets: [{
label: 'Priority',
data: topGoals.map(g => g.priority),
backgroundColor: topGoals.map(g => {
if (g.is_selected) return 'rgba(163, 113, 247, 0.8)';
if (g.is_satisfied) return 'rgba(63, 185, 80, 0.5)';
if (g.priority > 0) return 'rgba(88, 166, 255, 0.7)';
return 'rgba(110, 118, 129, 0.3)';
}),
borderColor: topGoals.map(g => {
if (g.is_selected) return '#a371f7';
if (g.is_satisfied) return '#3fb950';
if (g.priority > 0) return '#58a6ff';
return '#6e7681';
}),
borderWidth: 2,
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
beginAtZero: true,
grid: { color: '#30363d' },
ticks: { color: '#8b949e' },
},
y: {
grid: { display: false },
ticks: {
color: '#e6edf3',
font: { family: 'IBM Plex Mono', size: 11 }
},
}
}
}
});
}
function renderDetailsPanel(agent) {
const container = document.getElementById('details-panel');
const ws = agent.world_state;
const validActions = agent.actions.filter(a => a.is_valid);
const inPlanActions = agent.actions.filter(a => a.is_in_plan).sort((a, b) => a.plan_order - b.plan_order);
container.innerHTML = `
<div class="detail-section">
<h3>Inventory</h3>
<div class="inventory-grid">
<div class="inv-item"><span class="icon">💧</span> Water <span class="count">${ws.inventory.water}</span></div>
<div class="inv-item"><span class="icon">🍖</span> Meat <span class="count">${ws.inventory.meat}</span></div>
<div class="inv-item"><span class="icon">🫐</span> Berries <span class="count">${ws.inventory.berries}</span></div>
<div class="inv-item"><span class="icon">🪵</span> Wood <span class="count">${ws.inventory.wood}</span></div>
<div class="inv-item"><span class="icon">🥩</span> Hide <span class="count">${ws.inventory.hide}</span></div>
<div class="inv-item"><span class="icon">📦</span> Space <span class="count">${ws.inventory.space}</span></div>
</div>
</div>
<div class="detail-section">
<h3>Economy</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="label">Money</span>
<span class="value" style="color: var(--accent-orange)">${ws.economy.money}c</span>
</div>
<div class="detail-item">
<span class="label">Wealthy</span>
<span class="value">${ws.economy.is_wealthy ? '✓' : '✗'}</span>
</div>
</div>
</div>
<div class="detail-section">
<h3>Market Access</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="label">Buy Water</span>
<span class="value">${ws.market.can_buy_water ? '✓' : '✗'}</span>
</div>
<div class="detail-item">
<span class="label">Buy Food</span>
<span class="value">${ws.market.can_buy_food ? '✓' : '✗'}</span>
</div>
<div class="detail-item">
<span class="label">Buy Wood</span>
<span class="value">${ws.market.can_buy_wood ? '✓' : '✗'}</span>
</div>
</div>
</div>
<div class="detail-section">
<h3>Actions (${validActions.length} valid)</h3>
<div class="action-list">
${agent.actions.map(action => `
<div class="action-item ${action.is_valid ? 'valid' : 'invalid'} ${action.is_in_plan ? 'in-plan' : ''}">
${action.is_in_plan ? `<span class="action-order">${action.plan_order + 1}</span>` : ''}
<span class="action-name">${action.name}</span>
<span class="action-cost">${action.cost >= 0 ? action.cost.toFixed(1) : '∞'}</span>
</div>
`).join('')}
</div>
</div>
`;
}
function toggleAutoRefresh() {
const btn = document.getElementById('auto-refresh-btn');
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
btn.textContent = '▶ Auto';
btn.classList.remove('btn-primary');
} else {
autoRefreshInterval = setInterval(refreshData, 500);
btn.textContent = '⏸ Stop';
btn.classList.add('btn-primary');
}
}
</script>
</body>
</html>