import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; import { InboxMixin } from '../lib/inbox-mixin.js'; const GUIDE = [ { icon: 'bi-chat-dots-fill', title: 'Copilot', desc: 'The chat panel on the right knows everything — ask it to run agents, enable plugins, write code, or search the web.', color: '#0d6efd', }, { icon: 'bi-inbox', title: 'Inbox', desc: 'Pending approvals and agent questions that need your input before background tasks can continue.', color: '#f59e0b', }, { icon: 'bi-people', title: 'Agents', desc: 'Specialized sub-agents (engineer, architect, QA…). Each has a focused system prompt, tool set, and model selection.', color: '#8b5cf6', }, { icon: 'bi-clock', title: 'Cron', desc: 'Scheduled tasks that run automatically at set intervals, even when the Copilot is idle.', color: '#f97316', }, { icon: 'bi-cpu', title: 'Models', desc: 'Manage LLM, transcription, and image generation models. Drag to reorder priority.', color: '#10b981', }, { icon: 'bi-plug', title: 'Providers', desc: 'Add API keys for LLM providers (Anthropic, OpenAI, OpenRouter, Ollama…).', color: '#06b6d4', }, { icon: 'bi-shield-check', title: 'Security', desc: 'Define rules to auto-approve or auto-reject tool calls — skip repetitive confirmation prompts.', color: '#ef4444', }, ]; export class HomePage extends InboxMixin(LightElement) { static get properties() { return { ...super.properties, _open: { state: true }, _models: { state: true }, _plugins: { state: true }, _debugMode: { state: true }, _debugLoading: { state: true }, _stats: { state: true }, _statsRange: { state: true }, }; } constructor() { super(); this._open = false; this._models = null; // null = loading, [] = no models configured this._plugins = null; this._pollTimer = null; this._debugMode = false; this._debugLoading = true; this._stats = null; // null = loading this._statsRange = 'week'; this._chartInstances = {}; this._statsTimer = null; } connectedCallback() { super.connectedCallback(); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'home'; this.style.display = this._open ? 'flex' : 'none'; if (this._open) { this._loadAll(); this._loadStats(); this._startPolling(); } else { this._stopPolling(); } }); } disconnectedCallback() { super.disconnectedCallback(); this._stopPolling(); this._destroyCharts(); } updated(changed) { super.updated?.(changed); if (changed.has('_stats') && this._stats !== null) { requestAnimationFrame(() => this._initCharts()); } } _startPolling() { this._stopPolling(); this._pollTimer = setInterval(() => this._loadAll(), 10_000); this._statsTimer = setInterval(() => this._loadStats(), 180_000); } _stopPolling() { if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; } if (this._statsTimer) { clearInterval(this._statsTimer); this._statsTimer = null; } } async _loadAll() { await Promise.all([ this._loadModels(), this._loadPlugins(), this._loadInbox(), this._loadDebugMode(), ]); } async _loadDebugMode() { try { const res = await fetch('/api/dev/debug_mode'); if (!res.ok) throw new Error(); const data = await res.json(); this._debugMode = data.enabled; } catch { // ignore, keep current value } finally { this._debugLoading = false; } } async _toggleDebugMode() { const next = !this._debugMode; this._debugMode = next; try { const res = await fetch('/api/dev/debug_mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: next }), }); if (!res.ok) throw new Error(); window.dispatchEvent(new CustomEvent('debug-mode-change', { detail: { enabled: next } })); } catch { this._debugMode = !next; } } async _loadModels() { try { const res = await fetch('/api/llm/models'); if (!res.ok) throw new Error(`HTTP ${res.status}`); this._models = await res.json(); } catch { this._models = []; } } async _loadPlugins() { try { const res = await fetch('/api/plugins'); if (!res.ok) throw new Error(`HTTP ${res.status}`); this._plugins = await res.json(); } catch { this._plugins = []; } } async _loadStats() { try { const res = await fetch(`/api/stats/llm?range=${this._statsRange}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); this._stats = await res.json(); } catch { this._stats = { daily: [], models: [] }; } } async _setRange(range) { if (range === this._statsRange) return; this._statsRange = range; this._stats = null; await this._loadStats(); } get _honchoActive() { return this._plugins?.some(p => p.id === 'honcho' && p.enabled && p.running) ?? false; } get _statusInfo() { if (this._models === null) return { cls: 'loading', dot: false, icon: null, text: 'Loading…' }; if (this._models.length === 0) return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: 'No LLM models' }; if (this._models.some(m => m.status === 'healthy')) return { cls: 'online', dot: true, icon: null, text: 'Online & ready' }; if (this._models.some(m => m.status === 'degraded')) return { cls: 'warn', dot: true, icon: 'bi-exclamation-triangle-fill', text: 'Degraded' }; return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: 'All models offline' }; } _nav(page) { const url = page === 'home' ? location.pathname : '#' + page; history.pushState({ page }, '', url); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } })); } // ── Charts ──────────────────────────────────────────────────────────────── _destroyCharts() { for (const c of Object.values(this._chartInstances)) { c.destroy(); } this._chartInstances = {}; } _shortModelName(name) { return name .replace(/^claude-/, '') .replace(/^gpt-/, '') .replace(/-\d{8}$/, ''); } get _periodLabel() { return { hour: '/ min', day: '/ hour', week: '/ day', month: '/ day' }[this._statsRange] ?? '/ day'; } // Generates the full sequence of expected slots for the current range and // merges with backend data, filling missing slots with zeros. _fillGaps(daily) { const now = new Date(); const pad = n => String(n).padStart(2, '0'); const slots = []; if (this._statsRange === 'hour') { for (let i = 59; i >= 0; i--) { const d = new Date(now - i * 60_000); slots.push(`${pad(d.getHours())}:${pad(d.getMinutes())}`); } } else if (this._statsRange === 'day') { for (let i = 23; i >= 0; i--) { const d = new Date(now - i * 3_600_000); slots.push(`${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:00`); } } else { const count = this._statsRange === 'week' ? 7 : 30; for (let i = count - 1; i >= 0; i--) { const d = new Date(now - i * 86_400_000); slots.push(`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`); } } const index = new Map(daily.map(d => [d.day, d])); const zero = { requests: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, avg_duration_ms: 0 }; return slots.map(s => ({ day: s, ...(index.get(s) ?? zero) })); } _initCharts() { if (!window.Chart || !this._stats) return; this._destroyCharts(); const dark = document.documentElement.getAttribute('data-bs-theme') === 'dark'; const gridColor = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.06)'; const textColor = dark ? '#adb5bd' : '#6c757d'; const isHour = this._statsRange === 'hour'; const filled = this._fillGaps(this._stats.daily); const days = filled.map(d => d.day); const req = filled.map(d => d.requests); const inp = filled.map(d => d.input_tokens); const out = filled.map(d => d.output_tokens); const cache = filled.map(d => d.cache_read_tokens); // null for empty slots so the latency line doesn't touch zero where there were no requests const lat = filled.map(d => d.requests > 0 ? Math.round(d.avg_duration_ms) : null); const models = this._stats.models; const axisDefaults = () => ({ ticks: { color: textColor, font: { size: 11 } }, grid: { color: gridColor }, border: { color: gridColor }, }); const xAxis = () => ({ ...axisDefaults(), ticks: { color: textColor, font: { size: 11 }, maxTicksLimit: isHour ? 7 : 15, maxRotation: 0, minRotation: 0, }, }); const baseOpts = (extraPlugins = {}) => ({ responsive: true, maintainAspectRatio: false, animation: { duration: 300 }, plugins: { legend: { display: false }, ...extraPlugins, }, scales: { x: xAxis(), y: { ...axisDefaults(), beginAtZero: true }, }, }); const barDs = (data, color) => ({ data, backgroundColor: color, borderRadius: 4, borderSkipped: false, }); const lineDs = (data, borderColor, bgColor, opts = {}) => ({ data, borderColor, backgroundColor: bgColor, fill: true, tension: 0.3, pointRadius: 0, borderWidth: 2, ...opts, }); const type = isHour ? 'line' : 'bar'; const get = id => this.querySelector(`#${id}`); // Requests const c1 = get('chart-requests'); if (c1) this._chartInstances.requests = new Chart(c1, { type, data: { labels: days, datasets: [isHour ? lineDs(req, '#3b82f6', 'rgba(59,130,246,0.12)') : barDs(req, '#3b82f6')], }, options: baseOpts(), }); // Tokens const c2 = get('chart-tokens'); if (c2) this._chartInstances.tokens = new Chart(c2, { type, data: { labels: days, datasets: isHour ? [ lineDs(inp, '#3b82f6', 'rgba(59,130,246,0)', { fill: false, label: 'Input' }), lineDs(out, '#10b981', 'rgba(16,185,129,0)', { fill: false, label: 'Output' }), lineDs(cache, '#f59e0b', 'rgba(245,158,11,0)', { fill: false, label: 'Cached' }), ] : (() => { const nonCached = inp.map((v, i) => Math.max(0, v - (cache[i] ?? 0))); return [ { label: 'Cached', data: cache, backgroundColor: '#f59e0b', stack: 'tok', borderSkipped: false }, { label: 'Non-cached', data: nonCached, backgroundColor: '#3b82f6', stack: 'tok', borderSkipped: false }, { label: 'Output', data: out, backgroundColor: '#10b981', stack: 'tok', borderRadius: 4, borderSkipped: false }, ]; })(), }, options: baseOpts({ legend: { display: true, labels: { color: textColor, boxWidth: 10, font: { size: 11 } }, }, tooltip: { callbacks: { footer(items) { const idx = items[0]?.dataIndex; const total = inp[idx] ?? 0; if (!total) return ''; const pct = Math.round((cache[idx] ?? 0) / total * 100); return `Cache hit: ${pct}%`; }, }, }, }), }); // Latency const c3 = get('chart-latency'); if (c3) this._chartInstances.latency = new Chart(c3, { type, data: { labels: days, datasets: [isHour ? lineDs(lat, '#8b5cf6', 'rgba(139,92,246,0.12)', { spanGaps: false }) : barDs(lat.map(v => v ?? 0), '#8b5cf6')], }, options: baseOpts(), }); // Models — always horizontal bar const c4 = get('chart-models'); if (c4) this._chartInstances.models = new Chart(c4, { type: 'bar', data: { labels: models.map(m => this._shortModelName(m.model_name)), datasets: [{ data: models.map(m => m.requests), backgroundColor: ['#3b82f6','#10b981','#f59e0b','#8b5cf6','#ef4444','#06b6d4'], borderRadius: 4, borderSkipped: false, }], }, options: { ...baseOpts(), indexAxis: 'y', scales: { x: { ...axisDefaults(), beginAtZero: true }, y: { ...axisDefaults(), ticks: { color: textColor, font: { size: 10 } } }, }, }, }); } // ── Render ──────────────────────────────────────────────────────────────── _renderStats() { if (this._stats === null) { return html`
Loading stats…
`; } const empty = this._stats.daily.length === 0 && this._stats.models.length === 0; if (empty) { return html`
No LLM requests in the selected range.
`; } return html`
Requests ${this._periodLabel}
Tokens ${this._periodLabel}
Avg latency (ms)
Models
`; } render() { const st = this._statusInfo; const noModels = this._models !== null && this._models.length === 0; const approvals = this._inboxData?.approvals ?? []; const clarifs = this._inboxData?.clarifications ?? []; const elicits = this._inboxData?.elicitations ?? []; const inboxTotal = approvals.length + clarifs.length + elicits.length; return html`
Skald

Skald

Your AI command centre — research, code, plan, and orchestrate. All in one place.

${st.dot ? html`` : nothing} ${st.icon ? html`` : nothing} ${st.text}
${noModels ? html`
No LLM models configured. Start by adding a provider (Anthropic, OpenAI, OpenRouter…), then add at least one model in the Models section.
` : nothing}
LLM Stats
${[['hour','1h'],['day','24h'],['week','7d'],['month','30d']].map(([r, label]) => html` `)}
${this._renderStats()}
Pending ${inboxTotal > 0 ? html`${inboxTotal}` : nothing}
${this._renderInboxSection()} ${!this._honchoActive ? html`
Enable Honcho Persistent long-term memory — the agent learns your preferences over time. Ask the Copilot to enable it.
` : nothing}
Quick guide
${GUIDE.map(s => html`
${s.title}

${s.desc}

`)}
`; } }