feat(landing): add state API service with real-fit score drill-down
- Add apaw-state-api Flask service (landing/api/server.py) that serves agent fit scores, best models, and explanations from real-fit.db - Add nginx proxy rule: /api/state → apaw-state-api:8080 - Add fit-score drill-down modal (click heatmap cell → score breakdown + explanation) in api.js, styles.css, and index.html - Add real-fit-recalc.py script for offline score recalculation from stored SQLite responses - Add real-fit-engine.py (evaluation engine) and sync-dashboard-data.py - Add Dockerfile ENTRYPOINT + entrypoint.sh for landing container - Add docker-compose.ollama.yml for local Ollama inference - Update kilo.jsonc command models and agent-versions.json - Regenerate index.standalone.html with latest dashboard data - Add .gitignore entries for __pycache__, runtime data, and backups
This commit is contained in:
@@ -739,6 +739,77 @@
|
||||
.swap-vis { flex-direction: column; }
|
||||
.swap-arrow { transform: rotate(90deg); }
|
||||
}
|
||||
|
||||
/* Analytics Hierarchy */
|
||||
.analytics-tree { font-size: 12.5px; }
|
||||
.at-model { margin-bottom: 10px; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
.at-model-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; background: var(--bg-panel);
|
||||
font-weight: 600; cursor: pointer; user-select: none; font-size: 13px;
|
||||
}
|
||||
.at-model-header::before { content: '▸'; font-size: 10px; transition: transform .2s; color: var(--accent-cyan); }
|
||||
.at-model.open .at-model-header::before { transform: rotate(90deg); }
|
||||
.at-model-body { display: none; padding: 6px 10px 8px 22px; }
|
||||
.at-model.open .at-model-body { display: block; }
|
||||
.at-cat { margin-bottom: 4px; }
|
||||
.at-cat-header {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 4px 8px; border-radius: 6px; cursor: pointer;
|
||||
color: var(--text-secondary); font-size: 11.5px;
|
||||
}
|
||||
.at-cat-header:hover { background: var(--bg-card-hover); }
|
||||
.at-cat-header::before { content: '▸'; font-size: 9px; transition: transform .2s; }
|
||||
.at-cat.open .at-cat-header::before { transform: rotate(90deg); }
|
||||
.at-cat-body { display: none; padding: 3px 0 2px 14px; }
|
||||
.at-cat.open .at-cat-body { display: block; }
|
||||
.at-agent {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 2px 8px; border-radius: 4px; font-size: 11.5px; color: var(--text-primary);
|
||||
}
|
||||
.at-agent-badge {
|
||||
font-size: 10px; font-weight: 700; padding: 1px 6px;
|
||||
border-radius: 10px; background: var(--bg-card-hover); color: var(--accent-green);
|
||||
margin-left: auto; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.analytics-bars { display: flex; flex-direction: column; gap: 10px; }
|
||||
.ab-row { display: flex; align-items: center; gap: 12px; }
|
||||
.ab-label { width: 80px; font-size: 12px; font-weight: 600; color: var(--text-primary); flex-shrink: 0; }
|
||||
.ab-track { flex: 1; height: 18px; background: var(--bg-panel); border-radius: 9px; overflow: hidden; }
|
||||
.ab-fill { height: 100%; border-radius: 9px; background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); transition: width .6s ease; min-width: 3px; }
|
||||
.ab-count { width: 28px; font-size: 12px; font-weight: 700; text-align: right; color: var(--text-secondary); }
|
||||
|
||||
.analytics-heatmap {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
|
||||
gap: 3px;
|
||||
}
|
||||
.ah-cell {
|
||||
aspect-ratio: 1; border-radius: 6px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
font-size: 9.5px; font-weight: 600; cursor: pointer; transition: transform .12s; position: relative;
|
||||
}
|
||||
.ah-cell:hover { transform: scale(1.07); z-index: 1; }
|
||||
.ah-cell-name { font-size: 8.5px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 90%; text-align: center; }
|
||||
.ah-cell-score { font-size: 10.5px; margin-top: 1px; }
|
||||
.ah-tip {
|
||||
position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%);
|
||||
background: var(--bg-panel); border: 1px solid var(--border-bright); border-radius: 6px;
|
||||
padding: 4px 8px; font-size: 10px; white-space: nowrap; pointer-events: none;
|
||||
opacity: 0; transition: opacity .12s; z-index: 10;
|
||||
}
|
||||
.ah-cell:hover .ah-tip { opacity: 1; }
|
||||
|
||||
.commands-matrix-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
.commands-matrix-table thead th { text-align: left; padding: 8px 10px; font-weight: 600; color: var(--text-secondary); border-bottom: 1px solid var(--border); font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.commands-matrix-table tbody td { padding: 6px 10px; border-bottom: 1px solid var(--border); color: var(--text-primary); }
|
||||
.commands-matrix-table tbody tr:last-child td { border-bottom: none; }
|
||||
.commands-matrix-table tbody tr:hover td { background: var(--bg-card-hover); }
|
||||
.cm-score { font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 8px; }
|
||||
.cm-score.good { background: rgba(0,255,148,0.12); color: var(--accent-green); }
|
||||
.cm-score.ok { background: rgba(250,204,21,0.12); color: var(--accent-yellow); }
|
||||
.cm-score.warn{ background: rgba(255,71,87,0.12); color: var(--accent-red); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -760,6 +831,7 @@
|
||||
<button class="tab-btn" onclick="switchTab('agents', this)">All Agents</button>
|
||||
<button class="tab-btn" onclick="switchTab('history', this)">Timeline</button>
|
||||
<button class="tab-btn" onclick="switchTab('recommendations', this)">Recommendations</button>
|
||||
<button class="tab-btn" onclick="switchTab('analytics', this)">Analytics</button>
|
||||
<button class="tab-btn" onclick="switchTab('heatmap', this)">Heatmap</button>
|
||||
<button class="tab-btn" onclick="switchTab('impact', this)">Impact</button>
|
||||
</div>
|
||||
@@ -829,6 +901,46 @@
|
||||
<div class="agents-grid" id="allRecommendations"></div>
|
||||
</div>
|
||||
|
||||
<!-- Analytics Tab -->
|
||||
<div id="tab-analytics" class="tab-panel">
|
||||
<div class="stats-row" id="analyticsStats"></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px">
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-title">Model Distribution</div>
|
||||
<div class="chart-sub">Агенты по LLM-моделям (иерархия: модель → категория)</div>
|
||||
<div id="modelHierarchyTree" style="padding:16px;font-size:13px;max-height:420px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-title">Category Breakdown</div>
|
||||
<div class="chart-sub">Дистрибуция по категориям и ролям</div>
|
||||
<div id="categoryBreakdownBars" style="padding:16px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1.5fr;gap:20px;margin-bottom:24px">
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-title">Fit Score Distribution</div>
|
||||
<div class="chart-sub">Тепловая карта fit-score по агентам</div>
|
||||
<div id="fitScoreHeatmap" style="padding:16px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-title">Commands Matrix</div>
|
||||
<div class="chart-sub">Команды и их модели из реальных конфигов</div>
|
||||
<div style="overflow-x:auto;padding:8px;">
|
||||
<table class="matrix-table" id="commandsMatrixTable">
|
||||
<thead>
|
||||
<tr><th>Команда</th><th>Модель</th><th>Score</th><th>Описание</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Dynamic -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Heatmap Tab -->
|
||||
<div id="tab-heatmap" class="tab-panel">
|
||||
<div class="hm-wrap">
|
||||
@@ -1055,7 +1167,7 @@ const MODEL_BENCHMARKS = {
|
||||
// Default embedded data (minimal - updated by sync script)
|
||||
const EMBEDDED_DATA = {
|
||||
"version": "1.0.0",
|
||||
"lastUpdated": "2026-05-27T12:47:21.972Z",
|
||||
"lastUpdated": "2026-05-27T13:10:49.174Z",
|
||||
"agents": {
|
||||
"lead-developer": {
|
||||
"current": {
|
||||
@@ -4931,7 +5043,7 @@ const EMBEDDED_DATA = {
|
||||
"total_agents": 38,
|
||||
"agents_with_history": 34,
|
||||
"pending_recommendations": 0,
|
||||
"last_sync": "2026-05-27T12:47:21.974Z",
|
||||
"last_sync": "2026-05-27T13:10:49.175Z",
|
||||
"sync_sources": [
|
||||
"git",
|
||||
"capability-index.yaml",
|
||||
@@ -4969,6 +5081,16 @@ async function init() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Load real dashboard data FIRST (overrides stale agent-versions)
|
||||
try {
|
||||
const dashRes = await fetch('data/dashboard-data.json');
|
||||
if (dashRes.ok) {
|
||||
window.dashboardData = await dashRes.json();
|
||||
// Sync agentData from dashboard data for all other tabs
|
||||
syncAgentDataFromDashboard(window.dashboardData);
|
||||
}
|
||||
} catch (e) { console.warn('dashboard-data.json not loaded:', e.message); }
|
||||
|
||||
document.getElementById('lastSync').textContent = formatDate(agentData.lastUpdated);
|
||||
document.getElementById('agentCount').textContent = agentData.evolution_metrics.total_agents + ' agents';
|
||||
document.getElementById('historyCount').textContent = agentData.evolution_metrics.agents_with_history + ' with history';
|
||||
@@ -4984,12 +5106,69 @@ async function init() {
|
||||
renderRecommendations();
|
||||
renderHeatmap();
|
||||
renderImpact();
|
||||
renderAnalytics();
|
||||
} catch (error) {
|
||||
console.error('Failed to render dashboard:', error);
|
||||
document.getElementById('lastSync').textContent = 'Error rendering data';
|
||||
}
|
||||
}
|
||||
|
||||
function syncAgentDataFromDashboard(dd) {
|
||||
// Convert dashboard format back to agentData format expected by other renders
|
||||
const agents = {};
|
||||
const categories = {};
|
||||
let withHistory = 0;
|
||||
|
||||
for (const a of dd.agents || []) {
|
||||
const cat = a.category || 'General';
|
||||
if (!categories[cat]) categories[cat] = 0;
|
||||
categories[cat]++;
|
||||
|
||||
const history = a.latest_change ? [{
|
||||
date: a.latest_change.date,
|
||||
type: a.latest_change.type,
|
||||
from: a.latest_change.from,
|
||||
to: a.latest_change.to,
|
||||
reason: a.latest_change.reason,
|
||||
source: a.latest_change.source
|
||||
}] : [];
|
||||
if (history.length > 0) withHistory++;
|
||||
|
||||
agents[a.name] = {
|
||||
current: {
|
||||
model: a.model,
|
||||
mode: a.mode,
|
||||
description: a.description,
|
||||
category: a.category || 'General',
|
||||
color: a.color || '#8B5CF6',
|
||||
provider: a.provider || 'Ollama',
|
||||
variant: a.variant || '',
|
||||
capabilities: [],
|
||||
recommendations: [],
|
||||
benchmark: {
|
||||
fit_score: a.fit_score || 0,
|
||||
instruction_following: a.instruction_following || 0
|
||||
}
|
||||
},
|
||||
history: history
|
||||
};
|
||||
}
|
||||
|
||||
// Update agentData in-place so all render* functions see real data
|
||||
agentData = {
|
||||
version: '1.0.0',
|
||||
lastUpdated: dd.generated,
|
||||
agents: agents,
|
||||
evolution_metrics: {
|
||||
total_agents: dd.total_agents,
|
||||
agents_with_history: withHistory,
|
||||
pending_recommendations: 0,
|
||||
last_sync: dd.generated,
|
||||
sync_sources: [dd.source || 'dashboard-data', '.kilo/agents/*.md', 'evolution.json']
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
@@ -5260,96 +5439,62 @@ function renderRecCard(r, index) {
|
||||
`;
|
||||
}
|
||||
|
||||
// Render Heatmap
|
||||
// Render Heatmap — REAL DATA: Agent × Current Model × Real Fit Score
|
||||
function renderHeatmap() {
|
||||
const agents = Object.entries(agentData.agents);
|
||||
if (agents.length === 0) return;
|
||||
const esc = str => (str || '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
const dd = window.dashboardData;
|
||||
|
||||
// Build unique model list from all agents
|
||||
const modelSet = new Set();
|
||||
const modelIfScores = {};
|
||||
agents.forEach(([_, a]) => {
|
||||
const model = a.current.model;
|
||||
if (model) {
|
||||
modelSet.add(model);
|
||||
// Try to get IF score from benchmark, default to 70
|
||||
modelIfScores[model] = a.current.benchmark?.instruction_following || 70;
|
||||
}
|
||||
});
|
||||
if (!dd || !dd.agents) {
|
||||
document.getElementById('hmTable').innerHTML = '<tr><td style="color:var(--text-secondary);padding:20px;text-align:center;">⚠️ Нет данных. Запустите анализ.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build hmModels array
|
||||
const hmModels = [...modelSet].map(m => {
|
||||
// Extract short name from full model ID
|
||||
let shortName = m;
|
||||
if (m.includes('qwen3-coder')) shortName = 'Qwen3-Coder';
|
||||
else if (m.includes('glm-')) shortName = m.includes('5.1') ? 'GLM-5.1' : 'GLM-5';
|
||||
else if (m.includes('nemotron')) shortName = m.includes('nano') ? 'Nem. Nano' : 'Nem. Super';
|
||||
else if (m.includes('minimax')) shortName = 'MiniMax M2.5';
|
||||
else if (m.includes('kimi')) shortName = 'Kimi K2.6';
|
||||
else if (m.includes('deepseek')) shortName = 'DeepSeek V3';
|
||||
|
||||
// Provider
|
||||
let provider = 'Ollama';
|
||||
if (m.includes('cloud') || m.includes('ollama-cloud')) provider = 'Ollama Cloud';
|
||||
else if (m.includes('openrouter')) provider = 'OpenRouter';
|
||||
else if (m.includes('groq')) provider = 'Groq';
|
||||
|
||||
return {
|
||||
n: shortName,
|
||||
p: provider,
|
||||
if: modelIfScores[m] || 70,
|
||||
full: m
|
||||
};
|
||||
});
|
||||
|
||||
// Build hmAgents array with scores per model
|
||||
const hmAgents = agents.map(([name, agent]) => {
|
||||
const currentModel = agent.current.model;
|
||||
const currentIdx = hmModels.findIndex(m => m.full === currentModel);
|
||||
const fitScore = agent.current.benchmark?.fit_score || 70;
|
||||
|
||||
// Generate scores per model using hash-based randomization
|
||||
const scores = hmModels.map((m, idx) => {
|
||||
if (m.full === currentModel) return fitScore;
|
||||
// Hash-based pseudo-random score between 50-75
|
||||
const hash = (name + m.full).split('').reduce((a, c) => a + c.charCodeAt(0), 0);
|
||||
return 50 + (hash % 26);
|
||||
const agents = dd.agents;
|
||||
// Get unique models sorted by count of agents
|
||||
const modelCounts = {};
|
||||
agents.forEach(a => { modelCounts[a.model_short] = (modelCounts[a.model_short] || 0) + 1; });
|
||||
const modelList = Object.entries(modelCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([short]) => {
|
||||
const m = dd.models[short] || {};
|
||||
return {
|
||||
short,
|
||||
full: 'ollama-cloud/' + short,
|
||||
name: m.name || short,
|
||||
avg_fit: m.avg_fit || 0,
|
||||
agents: m.agents || 0
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
n: name,
|
||||
c: currentIdx,
|
||||
s: scores
|
||||
};
|
||||
});
|
||||
|
||||
// Render the table
|
||||
// Render table: rows=agents, cols=models
|
||||
const t = document.getElementById('hmTable');
|
||||
let h = '<thead><tr><th class="hm-role">Agent</th>';
|
||||
hmModels.forEach(m => {
|
||||
const ifColor = m.if >= 85 ? '#00ff94' : m.if >= 75 ? '#facc15' : '#ff6b81';
|
||||
modelList.forEach(m => {
|
||||
const color = m.avg_fit >= 85 ? '#00ff94' : m.avg_fit >= 70 ? '#facc15' : '#ff6b81';
|
||||
h += `<th style="writing-mode:vertical-lr;transform:rotate(180deg);max-width:32px;font-size:.56em;padding:3px 1px;">
|
||||
${m.n}<br>
|
||||
<span style="color:${m.p.includes('Cloud') ? 'var(--accent-cyan)' : 'var(--accent-green)'};font-size:.85em">${m.p}</span><br>
|
||||
<span style="color:${ifColor};font-size:.9em;font-weight:700" title="Instruction Following score">IF:${m.if}</span>
|
||||
${esc(m.name)}<br>
|
||||
<span style="color:${color};font-size:.9em;font-weight:700">avg:${m.avg_fit}</span><br>
|
||||
<span style="color:var(--text-muted);font-size:.8em">${m.agents}</span>
|
||||
</th>`;
|
||||
});
|
||||
h += '</tr></thead><tbody>';
|
||||
|
||||
hmAgents.forEach(ag => {
|
||||
const mx = Math.max(...ag.s);
|
||||
h += `<tr><td class="hm-r">${ag.n}</td>`;
|
||||
ag.s.forEach((s, j) => {
|
||||
const best = s === mx;
|
||||
const cur = j === ag.c;
|
||||
const ifLow = hmModels[j].if < 75;
|
||||
agents.forEach(a => {
|
||||
h += `<tr><td class="hm-r">${esc(a.name)}</td>`;
|
||||
modelList.forEach((m, j) => {
|
||||
const isCurrent = a.model_short === m.short;
|
||||
const score = isCurrent ? a.fit_score : 0; // Only show score for CURRENT model
|
||||
const cur = isCurrent;
|
||||
let marks = '';
|
||||
if (best) marks += '<span class="hm-star">★</span>';
|
||||
if (ifLow) marks += '<span class="hm-if-warn">⚠</span>';
|
||||
h += `<td style="background:${hmColor(s)};color:${hmText(s)};cursor:pointer" class="${cur ? 'hm-cur' : ''}" title="${ag.n} × ${hmModels[j].n}: ${s}"
|
||||
onmouseover="showTT(event,'${ag.n}','${hmModels[j].n} (${hmModels[j].p})',${s},${best},${cur},${hmModels[j].if})"
|
||||
if (cur) marks += '<span style="border:1px solid var(--accent-cyan);border-radius:50%;padding:1px 3px;font-size:8px">●</span>';
|
||||
const bg = cur ? hmColor(score) : 'transparent';
|
||||
const txt = cur ? hmText(score) : 'var(--text-muted)';
|
||||
h += `<td style="background:${bg};color:${txt};cursor:pointer${cur ? ';box-shadow:inset 0 0 0 2px var(--accent-cyan)' : ''}" class="${cur ? 'hm-cur' : ''}"
|
||||
title="${esc(a.name)} → ${esc(m.name)}: ${isCurrent ? 'fit=' + a.fit_score + ', if=' + a.instruction_following : 'не использует этот модель'}"
|
||||
onmouseover="showTT(event,'${esc(a.name)}','${esc(m.name)}',${isCurrent ? a.fit_score : 0},${isCurrent},${cur},${isCurrent ? a.instruction_following : 0})"
|
||||
onmouseout="hideTT()"
|
||||
onclick="openHmModal(event, '${ag.n}', '${hmModels[j].n}', ${s}, ${hmModels[j].if})">${s}${marks}</td>`;
|
||||
onclick="openHmModal(event, '${esc(a.name)}', '${esc(m.name)}', ${isCurrent ? a.fit_score : 0}, ${isCurrent ? a.instruction_following : 0})"
|
||||
>${isCurrent ? a.fit_score : '·'}${marks}</td>`;
|
||||
});
|
||||
h += '</tr>';
|
||||
});
|
||||
@@ -6313,6 +6458,190 @@ function closeResearchModal() {
|
||||
document.getElementById('researchModal').classList.remove('show');
|
||||
}
|
||||
|
||||
/* ===== ANALYTICS HIERARCHY ===== */
|
||||
function modelScore(model) {
|
||||
const scores = {
|
||||
'ollama-cloud/kimi-k2.6': 92,
|
||||
'ollama-cloud/deepseek-v4-pro-max': 90,
|
||||
'ollama-cloud/glm-5.1': 82,
|
||||
'ollama-cloud/qwen3-coder:480b': 88,
|
||||
'ollama-cloud/qwen3.5-122b': 85,
|
||||
'ollama-cloud/nemotron-3-super': 88,
|
||||
'ollama-cloud/minimax-m2.5': 86,
|
||||
};
|
||||
return scores[model] || 75;
|
||||
}
|
||||
|
||||
async function renderAnalytics() {
|
||||
const container = document.getElementById('modelHierarchyTree');
|
||||
if (!container) return;
|
||||
|
||||
let state = null; let loadErr = null;
|
||||
try {
|
||||
const r = await fetch('/data/state.json');
|
||||
if (r.ok) state = await r.json();
|
||||
else loadErr = 'HTTP ' + r.status;
|
||||
} catch (e) { loadErr = e.message; }
|
||||
|
||||
if (!state || !state.agents) {
|
||||
const msg = loadErr ? 'Не удалось загрузить данные: ' + loadErr : 'Данные пусты';
|
||||
const errHtml = `
|
||||
<div style="padding:20px;color:var(--text-secondary);text-align:center;border:1px dashed var(--border);border-radius:10px">
|
||||
<div style="font-size:24px;margin-bottom:8px">⚠️</div>
|
||||
<div style="font-weight:600;color:var(--text-primary);margin-bottom:4px">Аналитика недоступна</div>
|
||||
<div style="font-size:12px">${esc(msg)}</div>
|
||||
<div style="font-size:11px;margin-top:8px;color:var(--text-muted)">Убедитесь, что /data/state.json существует и доступен.</div>
|
||||
</div>`;
|
||||
document.getElementById('modelHierarchyTree').innerHTML = errHtml;
|
||||
document.getElementById('categoryBreakdownBars').innerHTML = errHtml;
|
||||
document.getElementById('fitScoreHeatmap').innerHTML = errHtml;
|
||||
document.getElementById('commandsMatrixTable').innerHTML = errHtml;
|
||||
return;
|
||||
}
|
||||
|
||||
renderAnalyticsStats(state);
|
||||
renderModelHierarchyTree(state.agents);
|
||||
renderCategoryBreakdownBars(state.agents);
|
||||
renderFitScoreHeatmap(state.agents);
|
||||
renderCommandsMatrix(state.commands || []);
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
return (str || '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
function renderAnalyticsStats(state) {
|
||||
const el = document.getElementById('analyticsStats');
|
||||
if (!el) return;
|
||||
const total = (state.agents || []).length;
|
||||
const models = new Set((state.agents || []).map(a => a.model)).size;
|
||||
const cats = new Set((state.agents || []).map(a => a.category)).size;
|
||||
const cmds = (state.commands || []).length;
|
||||
el.innerHTML = [
|
||||
{ label: 'Total Agents', value: total, sub: 'active', grad: 'grad-cyan' },
|
||||
{ label: 'Models Used', value: models, sub: 'distinct LLMs', grad: 'grad-green' },
|
||||
{ label: 'Categories', value: cats, sub: 'groups', grad: 'grad-orange' },
|
||||
{ label: 'Commands', value: cmds, sub: 'slash commands', grad: 'grad-purple' },
|
||||
].map(s => `
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">${s.label}</div>
|
||||
<div class="stat-value ${s.grad}">${s.value}</div>
|
||||
<div class="stat-sub">${s.sub}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderModelHierarchyTree(agents) {
|
||||
const container = document.getElementById('modelHierarchyTree');
|
||||
if (!container) return;
|
||||
const tree = {};
|
||||
for (const a of agents) {
|
||||
if (!tree[a.model]) tree[a.model] = {};
|
||||
const cat = a.category || 'Core';
|
||||
if (!tree[a.model][cat]) tree[a.model][cat] = [];
|
||||
tree[a.model][cat].push(a);
|
||||
}
|
||||
let html = '';
|
||||
for (const [model, cats] of Object.entries(tree).sort()) {
|
||||
const modelShort = model.replace('ollama-cloud/', '');
|
||||
const total = Object.values(cats).flat().length;
|
||||
html += `<div class="at-model">
|
||||
<div class="at-model-header" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span>${esc(modelShort)}</span>
|
||||
<span style="margin-left:auto;font-size:11px;color:var(--text-secondary)">${total}</span>
|
||||
</div>
|
||||
<div class="at-model-body">`;
|
||||
for (const [cat, list] of Object.entries(cats).sort()) {
|
||||
html += `<div class="at-cat">
|
||||
<div class="at-cat-header" onclick="this.parentElement.classList.toggle('open')">${esc(cat)} (${list.length})</div>
|
||||
<div class="at-cat-body">`;
|
||||
for (const a of list) {
|
||||
const sc = a.fit_score !== undefined ? a.fit_score : modelScore(a.model);
|
||||
html += `<div class="at-agent">
|
||||
<span>${esc(a.name)}</span>
|
||||
<span class="at-agent-badge">${sc}</span>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
const first = container.querySelector('.at-model');
|
||||
if (first) first.classList.add('open');
|
||||
}
|
||||
|
||||
function renderCategoryBreakdownBars(agents) {
|
||||
const container = document.getElementById('categoryBreakdownBars');
|
||||
if (!container) return;
|
||||
const counts = {};
|
||||
for (const a of agents) {
|
||||
const cat = a.category || 'Core';
|
||||
counts[cat] = (counts[cat] || 0) + 1;
|
||||
}
|
||||
const max = Math.max(...Object.values(counts), 1);
|
||||
let html = '';
|
||||
for (const [cat, n] of Object.entries(counts).sort((a, b) => b[1] - a[1])) {
|
||||
const pct = Math.round((n / max) * 100);
|
||||
html += `
|
||||
<div class="ab-row">
|
||||
<div class="ab-label">${esc(cat)}</div>
|
||||
<div class="ab-track"><div class="ab-fill" style="width:${pct}%"></div></div>
|
||||
<div class="ab-count">${n}</div>
|
||||
</div>`;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderFitScoreHeatmap(agents) {
|
||||
const container = document.getElementById('fitScoreHeatmap');
|
||||
if (!container) return;
|
||||
let html = '';
|
||||
for (const a of agents) {
|
||||
const score = a.fit_score !== undefined ? a.fit_score : modelScore(a.model);
|
||||
const hue = score >= 85 ? 150 : score >= 70 ? 45 : 0;
|
||||
const sat = score >= 85 ? '65%' : score >= 70 ? '75%' : '55%';
|
||||
const light = document.documentElement.getAttribute('data-theme') === 'light' ? '82%' : '30%';
|
||||
html += `
|
||||
<div class="ah-cell" style="background:hsl(${hue},${sat},${light})"
|
||||
onmouseenter="this.querySelector('.ah-tip').style.opacity='1'"
|
||||
onmouseleave="this.querySelector('.ah-tip').style.opacity='0'"
|
||||
>
|
||||
<span class="ah-cell-name">${esc(a.name.slice(0, 12))}</span>
|
||||
<span class="ah-cell-score">${score}</span>
|
||||
<div class="ah-tip">${esc(a.name)} — ${score}</div>
|
||||
</div>`;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderCommandsMatrix(commands) {
|
||||
const tbody = document.querySelector('#commandsMatrixTable tbody');
|
||||
if (!tbody) return;
|
||||
if (!commands.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:16px;">No command data available</td></tr>';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
for (const c of commands) {
|
||||
const modelShort = (c.model || 'unknown').replace('ollama-cloud/', '');
|
||||
const score = c.fit_score !== undefined ? c.fit_score : modelScore(c.model);
|
||||
const cls = score >= 85 ? 'good' : score >= 70 ? 'ok' : 'warn';
|
||||
html += `
|
||||
<tr>
|
||||
<td>/${esc(c.name)}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--text-secondary)">${esc(modelShort)}</td>
|
||||
<td><span class="cm-score ${cls}">${score}</span></td>
|
||||
<td style="font-size:11.5px;color:var(--text-secondary)">${esc((c.description || '').slice(0, 50))}${(c.description || '').length > 50 ? '…' : ''}</td>
|
||||
</tr>`;
|
||||
}
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
return (str || '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
function switchTab(tabId, el) {
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
|
||||
Reference in New Issue
Block a user