First Version
This commit is contained in:
@@ -0,0 +1,562 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user