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 = `

${ep.taskClassification || '—'} · ${ep.pathType || '—'} · ${ep.outcome}

provenance: ${provLine}

hard-floor: ${ep.hardFloor.invoked ? (ep.hardFloor.rules || []).join(', ') : 'нет'}

окружение: economy=${env.economy_level ?? '—'} · ${env.model || '—'} · turn ${env.session_turn ?? '—'}${env.post_compaction ? ' · post-compaction' : ''}${env.parallel_session ? ' · parallel' : ''}

атрибутировано узлов: ${attr.attributed} из ${attr.signals} сигналов

События

    ${ep.events.map((e) => `
  1. ${eventLine(e)}
  2. `).join('')}
`; } views.feed = function renderFeedView() { const groups = groupBySession(episodes); const root = document.getElementById('feed-stream'); root.innerHTML = groups.map((g) => `

сессия ${g.taskRef.slice(0, 8)} · ${g.episodes.length} ходов

${g.episodes.map(feedCard).join('')}
`).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 = `

Эпизодов

${a.count}

Ошибки / ретраи

${a.totalErrors} / ${a.totalRetries}

Доля перенаправлений

${(a.redirectRate * 100).toFixed(0)}%

path_type

${dist(a.pathType)}

outcome

${dist(a.outcome)}

классы задач

${dist(a.classification)}

economy-уровни

${dist(a.economy)}

Топ узлов

${topNodes.map(([k, v]) => `${k}×${v}`).join(' · ') || '—'}

`; 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 = `

Конфликты — три слоя

Дизайн-конфликты (факт): ${c.design.length} размеченных рёбер

Трение (инференс): ${top(c.friction)}

Корреляция (эвристика): ${c.correlation.length} ходов с ошибкой на паре конфликтующих узлов

`; }; 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 `
${ep.startedAt} · ${ep.taskClassification || '—'} · ${ep.pathType || '—'} · ${ep.nodeChosen || '—'} · ${dur}${ep.errorCount ? ' · ⚠' + ep.errorCount : ''}${ep.retryCount ? ' · ↻' + ep.retryCount : ''}${redirect}
`; } 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();