238 lines
10 KiB
JavaScript
238 lines
10 KiB
JavaScript
|
|
import { parseEpisodes, filterEpisodes, attributeNodes, groupBySession, aggregate, inferConflicts } from './dashboard-core.js';
|
|||
|
|
|
|||
|
|
const AGD = window.AGD;
|
|||
|
|
let episodes = [];
|
|||
|
|
let skipped = 0;
|
|||
|
|
let network = null;
|
|||
|
|
|
|||
|
|
// ── data loading ──────────────────────────────────────────────
|
|||
|
|
async function loadEpisodes() {
|
|||
|
|
const files = await fetch('/api/episodes').then((r) => r.json());
|
|||
|
|
const all = [];
|
|||
|
|
let skip = 0;
|
|||
|
|
for (const f of files) {
|
|||
|
|
const url = '/docs/observer/' + f;
|
|||
|
|
const text = await fetch(url).then((r) => (r.ok ? r.text() : ''));
|
|||
|
|
const r = parseEpisodes(text);
|
|||
|
|
all.push(...r.episodes);
|
|||
|
|
skip += r.skipped;
|
|||
|
|
}
|
|||
|
|
all.sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt)));
|
|||
|
|
episodes = all;
|
|||
|
|
skipped = skip;
|
|||
|
|
document.getElementById('status').textContent =
|
|||
|
|
`${episodes.length} эпизодов · ${skipped} пропущено`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── graph banner ──────────────────────────────────────────────
|
|||
|
|
function renderGraph() {
|
|||
|
|
const nodes = new vis.DataSet(AGD.NODES);
|
|||
|
|
const edges = new vis.DataSet(AGD.EDGES);
|
|||
|
|
network = new vis.Network(
|
|||
|
|
document.getElementById('network'),
|
|||
|
|
{ nodes, edges },
|
|||
|
|
{
|
|||
|
|
groups: AGD.GROUPS,
|
|||
|
|
nodes: { shape: 'dot', borderWidth: 2, font: { multi: 'html' } },
|
|||
|
|
edges: { smooth: { type: 'continuous', roundness: 0.5 } },
|
|||
|
|
physics: { enabled: false },
|
|||
|
|
interaction: { hover: true, tooltipDelay: 400 },
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
network.once('afterDrawing', () => network.fit());
|
|||
|
|
return { nodes, edges };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── view switching ────────────────────────────────────────────
|
|||
|
|
const views = {};
|
|||
|
|
let activeView = 'map';
|
|||
|
|
|
|||
|
|
views.map = function renderMapView() {
|
|||
|
|
// Plain mode: clear any overlay coloring applied by other views.
|
|||
|
|
window.__graph.nodes.update(AGD.NODES.map((n) => ({ id: n.id, color: undefined })));
|
|||
|
|
// List the design-time conflict edges (dashed edges carry an emoji label).
|
|||
|
|
const conflicts = AGD.EDGES.filter((e) => e.dashes === true);
|
|||
|
|
const ul = document.getElementById('map-conflicts');
|
|||
|
|
ul.innerHTML = '';
|
|||
|
|
for (const c of conflicts) {
|
|||
|
|
const li = document.createElement('li');
|
|||
|
|
li.textContent = `${c.label || '•'} ${c.from} ↔ ${c.to}: ${c.title || ''}`;
|
|||
|
|
ul.appendChild(li);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
views.replay = function renderReplayView() {
|
|||
|
|
const filter = {
|
|||
|
|
classification: document.getElementById('f-classification').value || undefined,
|
|||
|
|
outcome: document.getElementById('f-outcome').value || undefined,
|
|||
|
|
withErrors: document.getElementById('f-errors').checked || undefined,
|
|||
|
|
};
|
|||
|
|
const list = filterEpisodes(episodes, filter);
|
|||
|
|
const ul = document.getElementById('replay-episodes');
|
|||
|
|
ul.innerHTML = '';
|
|||
|
|
list.forEach((ep) => {
|
|||
|
|
const li = document.createElement('li');
|
|||
|
|
li.textContent = `${ep.startedAt} · ${ep.taskClassification || '—'} · ${ep.outcome}`
|
|||
|
|
+ (ep.errorCount ? ` · ⚠${ep.errorCount}` : '');
|
|||
|
|
li.addEventListener('click', () => selectEpisode(ep));
|
|||
|
|
ul.appendChild(li);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function selectEpisode(ep) {
|
|||
|
|
const attr = attributeNodes(ep);
|
|||
|
|
window.__graph.nodes.update(
|
|||
|
|
AGD.NODES.map((n) => ({
|
|||
|
|
id: n.id,
|
|||
|
|
color: attr.nodeIds.includes(n.id)
|
|||
|
|
? { background: '#268bd2', border: '#93a1a1' }
|
|||
|
|
: { background: '#2a2a3a', border: '#444' },
|
|||
|
|
}))
|
|||
|
|
);
|
|||
|
|
const d = document.getElementById('replay-detail');
|
|||
|
|
const prov = ep.decisionProvenance;
|
|||
|
|
const provLine = prov && prov.kind === 'user_directed_method'
|
|||
|
|
? `перенаправление: выбран ${prov.node || '?'}, автономно был бы ${prov.claude_would_have_chosen || '?'}`
|
|||
|
|
: prov ? prov.kind : '—';
|
|||
|
|
const env = ep.environment || {};
|
|||
|
|
d.innerHTML = `
|
|||
|
|
<h3>${ep.taskClassification || '—'} · ${ep.pathType || '—'} · ${ep.outcome}</h3>
|
|||
|
|
<p>provenance: ${provLine}</p>
|
|||
|
|
<p>hard-floor: ${ep.hardFloor.invoked ? (ep.hardFloor.rules || []).join(', ') : 'нет'}</p>
|
|||
|
|
<p>окружение: economy=${env.economy_level ?? '—'} · ${env.model || '—'} · turn ${env.session_turn ?? '—'}${env.post_compaction ? ' · post-compaction' : ''}${env.parallel_session ? ' · parallel' : ''}</p>
|
|||
|
|
<p>атрибутировано узлов: ${attr.attributed} из ${attr.signals} сигналов</p>
|
|||
|
|
<h4>События</h4>
|
|||
|
|
<ol>${ep.events.map((e) => `<li>${eventLine(e)}</li>`).join('')}</ol>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
views.feed = function renderFeedView() {
|
|||
|
|
const groups = groupBySession(episodes);
|
|||
|
|
const root = document.getElementById('feed-stream');
|
|||
|
|
root.innerHTML = groups.map((g) => `
|
|||
|
|
<section class="feed-group">
|
|||
|
|
<h4>сессия ${g.taskRef.slice(0, 8)} · ${g.episodes.length} ходов</h4>
|
|||
|
|
${g.episodes.map(feedCard).join('')}
|
|||
|
|
</section>`).join('');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
views.aggregate = function renderAggregateView() {
|
|||
|
|
const a = aggregate(episodes);
|
|||
|
|
applyHeat(a.nodeHeat);
|
|||
|
|
const dist = (obj) => Object.entries(obj).map(([k, v]) => `${k}: ${v}`).join(' · ') || '—';
|
|||
|
|
const topNodes = Object.entries(a.nodeHeat).sort((x, y) => y[1] - x[1]).slice(0, 10);
|
|||
|
|
document.getElementById('agg-tiles').innerHTML = `
|
|||
|
|
<div class="tile"><h4>Эпизодов</h4><p>${a.count}</p></div>
|
|||
|
|
<div class="tile"><h4>Ошибки / ретраи</h4><p>${a.totalErrors} / ${a.totalRetries}</p></div>
|
|||
|
|
<div class="tile"><h4>Доля перенаправлений</h4><p>${(a.redirectRate * 100).toFixed(0)}%</p></div>
|
|||
|
|
<div class="tile"><h4>path_type</h4><p>${dist(a.pathType)}</p></div>
|
|||
|
|
<div class="tile"><h4>outcome</h4><p>${dist(a.outcome)}</p></div>
|
|||
|
|
<div class="tile"><h4>классы задач</h4><p>${dist(a.classification)}</p></div>
|
|||
|
|
<div class="tile"><h4>economy-уровни</h4><p>${dist(a.economy)}</p></div>
|
|||
|
|
<div class="tile"><h4>Топ узлов</h4><p>${topNodes.map(([k, v]) => `${k}×${v}`).join(' · ') || '—'}</p></div>`;
|
|||
|
|
const c = inferConflicts(episodes, AGD.EDGES);
|
|||
|
|
const top = (obj) => Object.entries(obj).sort((x, y) => y[1] - x[1]).map(([k, v]) => `${k}×${v}`).join(' · ') || '—';
|
|||
|
|
document.getElementById('agg-conflicts').innerHTML = `
|
|||
|
|
<h4>Конфликты — три слоя</h4>
|
|||
|
|
<p><b>Дизайн-конфликты (факт):</b> ${c.design.length} размеченных рёбер</p>
|
|||
|
|
<p><b>Трение (инференс):</b> ${top(c.friction)}</p>
|
|||
|
|
<p><b>Корреляция (эвристика):</b> ${c.correlation.length} ходов с ошибкой на паре конфликтующих узлов</p>`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function applyHeat(nodeHeat) {
|
|||
|
|
const max = Math.max(1, ...Object.values(nodeHeat));
|
|||
|
|
window.__graph.nodes.update(
|
|||
|
|
AGD.NODES.map((n) => {
|
|||
|
|
const h = nodeHeat[n.id] || 0;
|
|||
|
|
const t = h / max;
|
|||
|
|
return {
|
|||
|
|
id: n.id,
|
|||
|
|
color: h
|
|||
|
|
? { background: `rgba(38,139,210,${0.25 + 0.6 * t})`, border: '#93a1a1' }
|
|||
|
|
: { background: '#2a2a3a', border: '#444' },
|
|||
|
|
};
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function feedCard(ep) {
|
|||
|
|
const dur = ep.durationMs != null ? Math.round(ep.durationMs / 1000) + 's' : '—';
|
|||
|
|
const redirect = ep.decisionProvenance && ep.decisionProvenance.kind === 'user_directed_method' ? ' ↪' : '';
|
|||
|
|
return `<div class="feed-card">
|
|||
|
|
${ep.startedAt} · ${ep.taskClassification || '—'} · ${ep.pathType || '—'} · ${ep.nodeChosen || '—'}
|
|||
|
|
· ${dur}${ep.errorCount ? ' · ⚠' + ep.errorCount : ''}${ep.retryCount ? ' · ↻' + ep.retryCount : ''}${redirect}
|
|||
|
|
</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function eventLine(e) {
|
|||
|
|
switch (e.kind) {
|
|||
|
|
case 'skill_invoked': return `skill: ${e.skill}`;
|
|||
|
|
case 'error': return `error: ${e.message || ''}`;
|
|||
|
|
case 'retry': return 'retry';
|
|||
|
|
case 'interrupt': return 'interrupt';
|
|||
|
|
case 'hook_fired': return `hooks (${Object.keys(e.counts || {}).length} типов, errors ${e.errors || 0})`;
|
|||
|
|
case 'tool_summary': return `инструменты: ${Object.entries(e.counts || {}).map(([k, v]) => `${k}×${v}`).join(', ')}`;
|
|||
|
|
case 'time_burn': return `time_burn: ${e.duration_ms} ms`;
|
|||
|
|
case 'parse_gap': return `parse_gap: ${e.broken}/${e.total}`;
|
|||
|
|
default: return e.kind;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function switchView(name) {
|
|||
|
|
activeView = name;
|
|||
|
|
for (const v of ['map', 'replay', 'feed', 'aggregate']) {
|
|||
|
|
document.getElementById('view-' + v).style.display = v === name ? 'block' : 'none';
|
|||
|
|
}
|
|||
|
|
document.querySelectorAll('#tabbar button').forEach((b) => {
|
|||
|
|
b.classList.toggle('active', b.dataset.view === name);
|
|||
|
|
});
|
|||
|
|
if (views[name]) views[name]();
|
|||
|
|
if (name === 'feed') startPolling(); else stopPolling();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── boot ──────────────────────────────────────────────────────
|
|||
|
|
async function boot() {
|
|||
|
|
const gds = renderGraph();
|
|||
|
|
window.__graph = { network, ...gds };
|
|||
|
|
document.querySelectorAll('#tabbar button').forEach((b) => {
|
|||
|
|
b.addEventListener('click', () => switchView(b.dataset.view));
|
|||
|
|
});
|
|||
|
|
['f-classification', 'f-outcome', 'f-errors'].forEach((id) => {
|
|||
|
|
document.getElementById(id).addEventListener('change', () => {
|
|||
|
|
if (activeView === 'replay') views.replay();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
document.getElementById('feed-pause').addEventListener('click', () => {
|
|||
|
|
if (pollTimer) stopPolling(); else startPolling();
|
|||
|
|
});
|
|||
|
|
await loadEpisodes();
|
|||
|
|
switchView('map');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── live polling for the Лента view ───────────────────────────
|
|||
|
|
const POLL_MS = 5000;
|
|||
|
|
let pollTimer = null;
|
|||
|
|
|
|||
|
|
async function pollTick() {
|
|||
|
|
const before = episodes.length;
|
|||
|
|
await loadEpisodes();
|
|||
|
|
if (episodes.length !== before && activeView === 'feed') views.feed();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startPolling() {
|
|||
|
|
if (pollTimer) return;
|
|||
|
|
pollTimer = setInterval(pollTick, POLL_MS);
|
|||
|
|
const el = document.getElementById('feed-poll-state');
|
|||
|
|
if (el) el.textContent = `автоопрос каждые ${POLL_MS / 1000}s`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function stopPolling() {
|
|||
|
|
clearInterval(pollTimer);
|
|||
|
|
pollTimer = null;
|
|||
|
|
const el = document.getElementById('feed-poll-state');
|
|||
|
|
if (el) el.textContent = 'опрос на паузе';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getEpisodes() { return episodes; }
|
|||
|
|
export { views, switchView };
|
|||
|
|
boot();
|