Files
Skald-Circle/web/components/home-page.js
T
2026-07-10 15:02:09 +01:00

563 lines
19 KiB
JavaScript

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`<div class="home-stats-loading"><i class="bi bi-hourglass-split"></i> Loading stats…</div>`;
}
const empty = this._stats.daily.length === 0 && this._stats.models.length === 0;
if (empty) {
return html`
<div class="home-stats-empty">
<i class="bi bi-bar-chart"></i>
<span>No LLM requests in the selected range.</span>
</div>
`;
}
return html`
<div class="home-stats-grid">
<div class="home-stat-card">
<div class="home-stat-card-title">Requests ${this._periodLabel}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-requests"></canvas></div>
</div>
<div class="home-stat-card">
<div class="home-stat-card-title">Tokens ${this._periodLabel}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-tokens"></canvas></div>
</div>
<div class="home-stat-card">
<div class="home-stat-card-title">Avg latency (ms)</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-latency"></canvas></div>
</div>
<div class="home-stat-card">
<div class="home-stat-card-title">Models</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-models"></canvas></div>
</div>
</div>
`;
}
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`
<div class="home-page">
<!-- ── Debug toggle ── -->
<div class="home-debug-bar">
<label class="home-debug-toggle" title="${this._debugMode ? 'Debug mode on' : 'Debug mode off'}">
<i class="bi bi-bug-fill"></i>
<span>Debug</span>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox"
.checked=${this._debugMode}
@change=${this._toggleDebugMode}
?disabled=${this._debugLoading} />
</div>
</label>
</div>
<!-- ── Hero ── -->
<div class="home-hero">
<div class="home-hero-image">
<img src="/assets/icons/icon-1024.png" alt="Skald" />
</div>
<div class="home-hero-text">
<h1 class="home-hero-title">Skald</h1>
<p class="home-hero-desc">Your AI command centre — research, code, plan, and orchestrate. All in one place.</p>
<div class="home-hero-status home-hero-status--${st.cls}">
${st.dot ? html`<span class="home-hero-dot"></span>` : nothing}
${st.icon ? html`<i class="bi ${st.icon}"></i>` : nothing}
<span>${st.text}</span>
</div>
</div>
</div>
<!-- ── No-models banner ── -->
${noModels ? html`
<div class="home-banner home-banner--error">
<div class="home-banner-icon"><i class="bi bi-cpu-fill"></i></div>
<div class="home-banner-body">
<strong>No LLM models configured.</strong>
Start by adding a provider (Anthropic, OpenAI, OpenRouter…), then add at least one model in the Models section.
</div>
<button class="btn btn-sm btn-danger" @click=${() => this._nav('providers')}>
Add a provider
</button>
</div>
` : nothing}
<!-- ── LLM Stats ── -->
<div class="home-section-title">
<i class="bi bi-bar-chart-fill"></i>
<span>LLM Stats</span>
<div class="home-stats-range ms-auto">
${[['hour','1h'],['day','24h'],['week','7d'],['month','30d']].map(([r, label]) => html`
<button class="home-stats-range-btn ${this._statsRange === r ? 'active' : ''}"
@click=${() => this._setRange(r)}>${label}</button>
`)}
</div>
</div>
${this._renderStats()}
<!-- ── Pending inbox ── -->
<div class="home-section-title">
<i class="bi bi-inbox"></i>
<span>Pending</span>
${inboxTotal > 0 ? html`<span class="badge bg-danger">${inboxTotal}</span>` : nothing}
<button class="inbox-refresh-btn ms-auto" title="Refresh" @click=${() => this._loadInbox()}>
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
${this._renderInboxSection()}
<!-- ── Honcho tip ── -->
${!this._honchoActive ? html`
<div class="home-tip">
<div class="home-tip-icon"><i class="bi bi-lightbulb-fill"></i></div>
<div class="home-tip-body">
<strong>Enable Honcho</strong>
<span>Persistent long-term memory — the agent learns your preferences over time. Ask the Copilot to enable it.</span>
</div>
</div>
` : nothing}
<!-- ── Quick guide ── -->
<div class="home-section-title">
<i class="bi bi-map"></i>
<span>Quick guide</span>
</div>
<div class="home-guide">
${GUIDE.map(s => html`
<div class="home-card" style="--home-card-color: ${s.color}">
<div class="home-card-icon">
<i class="bi ${s.icon}"></i>
</div>
<div class="home-card-body">
<h6 class="home-card-title">${s.title}</h6>
<p class="home-card-desc">${s.desc}</p>
</div>
</div>
`)}
</div>
</div>
`;
}
}