'use strict'; const $ = (sel) => document.querySelector(sel); const state = { project: null, session: null, sessions: [], detail: null, relative: false }; // ---------------------------------------------------------------- 表示ヘルパ function fmtDur(ms) { if (ms === null || ms === undefined) return '–'; if (ms < 0) return '-' + fmtDur(-ms); const s = ms / 1000; if (s < 1) return `${ms}ms`; if (s < 60) return `${s.toFixed(1)}秒`; const m = Math.floor(s / 60); if (m < 60) return `${m}分${String(Math.round(s - m * 60)).padStart(2, '0')}秒`; const h = Math.floor(m / 60); return `${h}時間${String(m - h * 60).padStart(2, '0')}分`; } function fmtTime(iso) { if (!iso) return '–'; const d = new Date(iso); const p = (n) => String(n).padStart(2, '0'); return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')[0]}`; } function fmtDayTime(iso) { if (!iso) return '–'; const d = new Date(iso); const day = `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`; return `${day} ${fmtTime(iso)}`; } function sameDay(a, b) { if (!a || !b) return false; const x = new Date(a); const y = new Date(b); return x.getFullYear() === y.getFullYear() && x.getMonth() === y.getMonth() && x.getDate() === y.getDate(); } function fmtDateTime(iso) { if (!iso) return '–'; const d = new Date(iso); return d.toLocaleString('ja-JP', { hour12: false, year: '2-digit', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } function fmtBytes(n) { if (n < 1024) return `${n}B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}KB`; return `${(n / 1024 / 1024).toFixed(1)}MB`; } function ago(iso) { if (!iso) return ''; const diff = (Date.now() - new Date(iso).getTime()) / 1000; if (diff < 3600) return `${Math.max(1, Math.round(diff / 60))}分前`; if (diff < 86400) return `${Math.round(diff / 3600)}時間前`; return `${Math.round(diff / 86400)}日前`; } const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); async function api(path) { const res = await fetch(path); const data = await res.json(); if (data.error) throw new Error(data.error); return data; } // ---------------------------------------------------------------- プロジェクト async function loadProjects() { const data = await api('/api/projects'); $('#root').textContent = data.root; const ul = $('#project-list'); ul.innerHTML = ''; data.projects.forEach((p) => { const li = document.createElement('li'); li.dataset.id = p.id; li.innerHTML = `
${esc(p.path)}
${p.sessions} タスク · ${fmtBytes(p.bytes)} · ${ago(p.lastActivity)}
`; li.onclick = () => selectProject(p.id); ul.appendChild(li); }); if (data.projects.length) selectProject(state.project && data.projects.some((p) => p.id === state.project) ? state.project : data.projects[0].id); } async function selectProject(id) { state.project = id; document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === id)); $('#csv-sessions').href = `/api/sessions?project=${encodeURIComponent(id)}&format=csv`; const tbody = $('#session-table tbody'); tbody.innerHTML = '解析中…'; try { const data = await api(`/api/sessions?project=${encodeURIComponent(id)}`); state.sessions = data.sessions; renderSessions(); } catch (e) { tbody.innerHTML = `${esc(e.message)}`; } } function renderSessions() { const q = $('#filter').value.trim().toLowerCase(); const rows = state.sessions.filter( (s) => !q || (s.title + ' ' + s.firstPrompt + ' ' + s.lastPrompt).toLowerCase().includes(q) ); $('#sessions-title').textContent = `タスク ${rows.length}/${state.sessions.length}`; const tbody = $('#session-table tbody'); tbody.innerHTML = ''; if (!rows.length) { tbody.innerHTML = '該当なし'; return; } rows.forEach((s) => { const tr = document.createElement('tr'); tr.dataset.id = s.id; tr.className = s.id === state.session ? 'sel' : ''; tr.innerHTML = `
${esc(s.title)}
${fmtDateTime(s.start)} ${s.turnCount} 往復 全体 ${fmtDur(s.wallMs)} 作業 ${fmtDur(s.activeMs)} ${s.gitBranch ? `⑂ ${esc(s.gitBranch)}` : ''}
`; tr.onclick = () => selectSession(s.id); tbody.appendChild(tr); }); } // ---------------------------------------------------------------- 詳細 async function selectSession(id) { state.session = id; document.querySelectorAll('#session-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.id === id)); $('#csv-turns').href = `/api/session?project=${encodeURIComponent(state.project)}&id=${encodeURIComponent(id)}&format=csv`; $('#detail-body').innerHTML = '

読み込み中…

'; try { state.detail = await api(`/api/session?project=${encodeURIComponent(state.project)}&id=${encodeURIComponent(id)}`); renderDetail(); } catch (e) { $('#detail-body').innerHTML = `

${esc(e.message)}

`; } } function renderDetail() { const d = state.detail; if (!d) return; $('#detail-title').textContent = d.title; const maxSpan = Math.max(1, ...d.turns.map((t) => (t.idleMs || 0) + (t.durMs || 0))); const scale = (ms) => { const v = Math.max(0, ms || 0); return v ? Math.max(2, Math.round((v / maxSpan) * 95)) : 0; }; const t0 = d.turns.length ? new Date(d.turns[0].tsIso).getTime() : 0; const head = `
開始${fmtDateTime(d.start)}
終了${fmtDateTime(d.end)}
全体${fmtDur(d.wallMs)}
作業時間合計${fmtDur(d.activeMs)}
往復${d.turnCount}
応答メッセージ${d.assistantMsgs}
ツール呼び出し${d.toolCalls}
サブエージェント${d.sidechainMsgs}
ログ${fmtBytes(d.bytes)} · ${esc(d.id.slice(0, 8))}
`; const rows = d.turns .map((t) => { const rel = (iso) => '+' + fmtDur(new Date(iso).getTime() - t0); const when = state.relative ? rel(t.tsIso) : fmtDayTime(t.tsIso); // 完了時刻は日をまたいだ時だけ日付を出す const done = !t.endIso ? '–' : state.relative ? rel(t.endIso) : sameDay(t.endIso, t.tsIso) ? fmtTime(t.endIso) : fmtDayTime(t.endIso); const tools = t.toolList.map(([n, c]) => (c > 1 ? `${n}×${c}` : n)).join(', '); return ` ${t.i} ${when}${t.kind !== 'prompt' ? ` ${t.kind}` : ''} ${t.idleMs !== null && t.idleMs < 0 ? `並行` : fmtDur(t.idleMs)} ${fmtDur(t.waitMs)} ${fmtDur(t.durMs)} ${done}
${t.assistant}msg${t.toolCalls ? ` ${t.toolCalls}tool` : ''}
${esc(t.prompt)}${t.truncated ? ' …' : ''}
${tools ? `
${esc(tools)}
` : ''} ${t.interrupted ? '
⚠ 中断あり
' : ''} `; }) .join(''); $('#detail-body').innerHTML = head + (d.turns.length ? `${rows}
#${state.relative ? '経過' : '発言時刻'} 空き初回応答応答時間 ${state.relative ? '完了(経過)' : '完了時刻'}タイムライン内訳やりとり
` : '

やりとりが記録されていません。

'); document.querySelectorAll('#turns .msg').forEach((td) => { td.onclick = () => td.parentElement.classList.toggle('open'); }); } // ---------------------------------------------------------------- 起動 $('#filter').oninput = renderSessions; $('#relative').onchange = (e) => { state.relative = e.target.checked; renderDetail(); }; $('#reload').onclick = async () => { await fetch('/api/reload'); const keepSession = state.session; await loadProjects(); if (keepSession) selectSession(keepSession).catch(() => {}); }; loadProjects().catch((e) => { $('#project-list').innerHTML = `
  • ${esc(e.message)}
  • `; });