This commit is contained in:
2026-03-27 03:58:57 +08:00
commit 86eba27a24
38 changed files with 4074 additions and 0 deletions

43
static/js/api.js Normal file
View File

@@ -0,0 +1,43 @@
/**
* static/js/api.js
* Thin fetch wrappers for all HTTP API endpoints.
*/
const BASE = '';
async function request(method, path, body) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(BASE + path, opts);
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) {
const msg = data?.detail || `HTTP ${res.status}`;
throw new Error(msg);
}
return data;
}
export const api = {
// Health
health: () => request('GET', '/healthz'),
// Status
status: () => request('GET', '/api/status'),
// Instructions
listInstructions: (status='all') => request('GET', `/api/instructions?status=${status}`),
createInstruction: (content) => request('POST', '/api/instructions', { content }),
updateInstruction: (id, content) => request('PATCH', `/api/instructions/${id}`, { content }),
deleteInstruction: (id) => request('DELETE', `/api/instructions/${id}`),
// Config
getConfig: () => request('GET', '/api/config'),
updateConfig: (patch) => request('PATCH', '/api/config', patch),
};

108
static/js/app.js Normal file
View File

@@ -0,0 +1,108 @@
/**
* static/js/app.js
* Application bootstrap initialises all modules, kicks off data fetching,
* and exports shared utilities like `toast`.
*/
import { api } from './api.js';
import { state } from './state.js';
import { connectSSE } from './events.js';
import { initInstructions, initComposer, refreshTimestamps } from './instructions.js';
import { initStatus, initConfig, refreshStatusTimestamps } from './status.js';
import { initTheme } from './theme.js';
// ── Toast notification ────────────────────────────────────────────────────
const _toastContainer = document.getElementById('toast-container');
export function toast(message, type = 'info') {
const el = document.createElement('div');
el.className = `toast toast--${type}`;
el.textContent = message;
_toastContainer.appendChild(el);
setTimeout(() => {
el.style.animation = 'toast-out 240ms cubic-bezier(0.4,0,1,1) forwards';
el.addEventListener('animationend', () => el.remove());
}, 3000);
}
// ── Document title badge ──────────────────────────────────────────────────
function updateTitle(instructions) {
const pending = (instructions || []).filter(i => i.status === 'pending').length;
document.title = pending > 0 ? `(${pending}) local-mcp` : 'local-mcp';
}
// ── Reconnecting indicator ────────────────────────────────────────────────
function initReconnectingIndicator() {
const serverLed = document.getElementById('led-server');
state.subscribe('sseReconnecting', (reconnecting) => {
if (!serverLed) return;
if (reconnecting) {
serverLed.className = 'led led--amber led--pulse';
serverLed.querySelector('.led__label').textContent = 'Reconnecting…';
}
// The full status update (onopen) will re-set the correct class
});
}
// ── Startup data load ─────────────────────────────────────────────────────
async function loadInitialData() {
try {
const [instructions, status, config] = await Promise.all([
api.listInstructions('all'),
api.status(),
api.getConfig(),
]);
state.set('instructions', instructions.items);
state.set('status', status);
state.set('config', config);
state.set('serverOnline', true);
state.set('sseReconnecting', false);
} catch (err) {
console.error('Failed to load initial data:', err);
state.set('serverOnline', false);
}
}
// ── Periodic refresh (fallback for SSE gaps) ──────────────────────────────
function startPolling() {
setInterval(async () => {
try {
const s = await api.status();
state.set('status', s);
state.set('serverOnline', true);
} catch {
state.set('serverOnline', false);
}
}, 15_000);
// Refresh relative timestamps every 20 seconds
setInterval(() => { refreshTimestamps(); refreshStatusTimestamps(); }, 20_000);
}
// ── Subscribe to state changes ────────────────────────────────────────────
function initGlobalSubscriptions() {
state.subscribe('instructions', updateTitle);
}
// ── Init ──────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
initTheme(); // must run first sets button icon
initReconnectingIndicator();
initStatus();
initConfig();
initInstructions();
initComposer();
initGlobalSubscriptions();
await loadInitialData();
connectSSE();
startPolling();
});

119
static/js/events.js Normal file
View File

@@ -0,0 +1,119 @@
/**
* static/js/events.js
* Server-Sent Events client connects to /api/events and dispatches
* updates into the central state store.
* Uses full item payloads embedded in events to avoid extra re-fetch round-trips.
*/
import { state } from './state.js';
import { api } from './api.js';
let _es = null;
let _reconnectTimer = null;
let _reconnecting = false;
const RECONNECT_DELAY_MS = 3000;
export function connectSSE() {
if (_es) return;
_connect();
}
function _connect() {
_es = new EventSource('/api/events');
_es.onopen = () => {
console.debug('[SSE] connected');
if (_reconnecting) {
_reconnecting = false;
state.set('sseReconnecting', false);
// Full refresh after reconnect to catch anything we missed
_fullRefresh();
}
state.set('serverOnline', true);
};
_es.onmessage = (e) => {
try {
const event = JSON.parse(e.data);
_handleEvent(event);
} catch (err) {
console.warn('[SSE] parse error', err);
}
};
_es.onerror = () => {
console.warn('[SSE] connection lost reconnecting in', RECONNECT_DELAY_MS, 'ms');
state.set('serverOnline', false);
_reconnecting = true;
state.set('sseReconnecting', true);
_es.close();
_es = null;
clearTimeout(_reconnectTimer);
_reconnectTimer = setTimeout(_connect, RECONNECT_DELAY_MS);
};
}
async function _fullRefresh() {
try {
const [instructions, status, config] = await Promise.all([
api.listInstructions('all'),
api.status(),
api.getConfig(),
]);
state.set('instructions', instructions.items);
state.set('status', status);
state.set('config', config);
} catch (e) {
console.error('[SSE] full refresh failed', e);
}
}
function _applyInstructionPatch(item) {
const current = state.get('instructions') || [];
const idx = current.findIndex(i => i.id === item.id);
if (idx === -1) {
// New item append and re-sort by position
const next = [...current, item].sort((a, b) => a.position - b.position);
state.set('instructions', next);
} else {
// Replace in-place, preserving order
const next = [...current];
next[idx] = item;
state.set('instructions', next.sort((a, b) => a.position - b.position));
}
}
function _handleEvent(event) {
switch (event.type) {
case 'instruction.created':
case 'instruction.updated':
case 'instruction.consumed': {
if (event.data?.item) {
_applyInstructionPatch(event.data.item);
} else {
// Fallback: full refresh
api.listInstructions('all').then(d => state.set('instructions', d.items)).catch(console.error);
}
break;
}
case 'instruction.deleted': {
const id = event.data?.id;
if (id) {
const next = (state.get('instructions') || []).filter(i => i.id !== id);
state.set('instructions', next);
}
break;
}
case 'status.changed': {
api.status().then(s => state.set('status', s)).catch(console.error);
break;
}
case 'config.updated': {
api.getConfig().then(c => state.set('config', c)).catch(console.error);
break;
}
default:
console.debug('[SSE] unknown event type', event.type);
}
}

258
static/js/instructions.js Normal file
View File

@@ -0,0 +1,258 @@
/**
* static/js/instructions.js
* Renders the pending and consumed instruction panels.
* Handles add, edit, delete interactions.
*/
import { state } from './state.js';
import { api } from './api.js';
import { toast } from './app.js';
// ── SVG icon helpers ──────────────────────────────────────────────────────
function iconEdit() {
return `<svg class="icon" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`;
}
function iconDelete() {
return `<svg class="icon" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`;
}
function iconCheck() {
return `<svg class="icon" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>`;
}
function iconX() {
return `<svg class="icon" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
}
// ── Time formatters ───────────────────────────────────────────────────────
function fmtRelativeTime(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
const diff = Math.floor((Date.now() - d.getTime()) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return d.toLocaleDateString();
}
function fmtAbsTime(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
/** Refresh all relative-time spans in the lists (called by app.js on a timer). */
export function refreshTimestamps() {
document.querySelectorAll('[data-ts]').forEach(el => {
el.textContent = fmtRelativeTime(el.dataset.ts);
});
}
// ── Card renderer ─────────────────────────────────────────────────────────
function renderPendingCard(item, index) {
const card = document.createElement('div');
card.className = 'instruction-card';
card.dataset.id = item.id;
card.style.animationDelay = `${index * 30}ms`;
card.innerHTML = `
<div class="instruction-card__meta">
<span class="instruction-card__pos">#${item.position}</span>
<span class="instruction-card__time" data-ts="${item.created_at}">${fmtRelativeTime(item.created_at)}</span>
</div>
<div class="instruction-card__content">${escapeHtml(item.content)}</div>
<div class="instruction-card__actions">
<button class="btn btn--ghost btn--icon btn--edit" title="Edit instruction" aria-label="Edit">${iconEdit()}</button>
<button class="btn btn--danger btn--icon btn--delete" title="Delete instruction" aria-label="Delete">${iconDelete()}</button>
</div>
<div class="instruction-card__edit-area" style="display:none; grid-column:1/-1;">
<textarea class="textarea edit-textarea" rows="3">${escapeHtml(item.content)}</textarea>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
<button class="btn btn--primary btn--sm btn--save" title="Save">${iconCheck()}</button>
<button class="btn btn--ghost btn--sm btn--cancel" title="Cancel">${iconX()}</button>
</div>
</div>
`;
const editBtn = card.querySelector('.btn--edit');
const deleteBtn = card.querySelector('.btn--delete');
const cancelBtn = card.querySelector('.btn--cancel');
const saveBtn = card.querySelector('.btn--save');
const editArea = card.querySelector('.instruction-card__edit-area');
const content = card.querySelector('.instruction-card__content');
const actions = card.querySelector('.instruction-card__actions');
const editTA = card.querySelector('.edit-textarea');
function showEdit() {
editTA.value = item.content;
editArea.style.display = 'flex';
content.style.display = 'none';
actions.style.display = 'none';
editTA.focus();
editTA.setSelectionRange(editTA.value.length, editTA.value.length);
}
function hideEdit() {
editArea.style.display = 'none';
content.style.display = '';
actions.style.display = '';
}
editBtn.addEventListener('click', showEdit);
cancelBtn.addEventListener('click', hideEdit);
editTA.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { e.preventDefault(); hideEdit(); }
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); saveBtn.click(); }
});
saveBtn.addEventListener('click', async () => {
const newContent = editTA.value.trim();
if (!newContent) { toast('Content cannot be empty', 'error'); return; }
saveBtn.disabled = true;
try {
await api.updateInstruction(item.id, newContent);
toast('Instruction updated', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
saveBtn.disabled = false;
}
});
deleteBtn.addEventListener('click', async () => {
if (!confirm('Delete this instruction?')) return;
deleteBtn.disabled = true;
try {
await api.deleteInstruction(item.id);
toast('Instruction deleted', 'info');
} catch (e) {
toast(e.message, 'error');
deleteBtn.disabled = false;
}
});
return card;
}
function renderConsumedCard(item) {
const card = document.createElement('div');
card.className = 'instruction-card instruction-card--consumed';
card.dataset.id = item.id;
card.innerHTML = `
<div class="instruction-card__meta">
<span class="instruction-card__pos">#${item.position}</span>
<span class="instruction-card__time">${fmtAbsTime(item.consumed_at)}</span>
${item.consumed_by_agent_id
? `<span class="instruction-card__consumed-by">→ ${escapeHtml(item.consumed_by_agent_id)}</span>`
: ''}
</div>
<div class="instruction-card__content">${escapeHtml(item.content)}</div>
<div></div>
`;
return card;
}
// ── List renderers ────────────────────────────────────────────────────────
export function initInstructions() {
const pendingList = document.getElementById('pending-list');
const pendingBadge = document.getElementById('pending-badge');
const consumedList = document.getElementById('consumed-list');
const consumedBadge = document.getElementById('consumed-badge');
function render(instructions) {
if (!instructions) return;
const pending = instructions.filter(i => i.status === 'pending');
const consumed = instructions.filter(i => i.status === 'consumed').reverse();
// Pending
pendingList.innerHTML = '';
if (pending.length === 0) {
pendingList.innerHTML = `
<div class="empty-state">
<div class="empty-state__icon">◈</div>
Queue is empty add an instruction above
</div>`;
} else {
pending.forEach((item, i) => pendingList.appendChild(renderPendingCard(item, i)));
}
pendingBadge.textContent = pending.length;
pendingBadge.className = `badge ${pending.length > 0 ? 'badge--cyan' : 'badge--muted'}`;
// Consumed
consumedList.innerHTML = '';
if (consumed.length === 0) {
consumedList.innerHTML = `<div class="empty-state"><div class="empty-state__icon">◈</div>No consumed instructions yet</div>`;
} else {
consumed.forEach(item => consumedList.appendChild(renderConsumedCard(item)));
}
consumedBadge.textContent = consumed.length;
consumedBadge.className = `badge ${consumed.length > 0 ? 'badge--amber' : 'badge--muted'}`;
}
state.subscribe('instructions', render);
}
// ── Composer ──────────────────────────────────────────────────────────────
export function initComposer() {
const form = document.getElementById('composer-form');
const textarea = document.getElementById('composer-input');
const btn = document.getElementById('composer-submit');
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
// Plain Enter → submit (like a chat box)
e.preventDefault();
form.requestSubmit();
}
// Shift+Enter → default browser behaviour (newline)
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const content = textarea.value.trim();
if (!content) return;
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<span class="spinner"></span>';
try {
await api.createInstruction(content);
textarea.value = '';
textarea.style.height = '';
toast('Instruction queued', 'success');
} catch (err) {
toast(err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = original;
textarea.focus();
}
});
// Auto-resize textarea
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
});
}
// ── Utility ───────────────────────────────────────────────────────────────
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

34
static/js/state.js Normal file
View File

@@ -0,0 +1,34 @@
/**
* static/js/state.js
* Centralised reactive state store.
* Components subscribe to state slices and are notified on change.
*/
const _state = {
instructions: [], // InstructionItem[]
status: null, // StatusResponse | null
config: null, // ConfigResponse | null
serverOnline: false,
};
const _listeners = {}; // key -> Set<fn>
export const state = {
get(key) {
return _state[key];
},
set(key, value) {
_state[key] = value;
(_listeners[key] || new Set()).forEach(fn => fn(value));
},
subscribe(key, fn) {
if (!_listeners[key]) _listeners[key] = new Set();
_listeners[key].add(fn);
// Immediately call with current value
fn(_state[key]);
return () => _listeners[key].delete(fn); // unsubscribe
},
};

157
static/js/status.js Normal file
View File

@@ -0,0 +1,157 @@
/**
* static/js/status.js
* Renders the server status and agent activity panels,
* and the config settings panel.
*/
import { state } from './state.js';
import { api } from './api.js';
import { toast } from './app.js';
// ── Time helpers ──────────────────────────────────────────────────────────
function fmtTime(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function fmtRelative(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
const diff = Math.floor((Date.now() - d.getTime()) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
}
// ── Server online indicator (header) ─────────────────────────────────────
function updateHeaderLeds(serverOnline, status) {
const serverLed = document.getElementById('led-server');
const agentLed = document.getElementById('led-agent');
if (!serverLed || !agentLed) return;
// Don't overwrite reconnecting state events.js sets that
if (serverOnline && !state.get('sseReconnecting')) {
serverLed.className = 'led led--green led--pulse';
serverLed.querySelector('.led__label').textContent = 'Server Online';
} else if (!serverOnline) {
serverLed.className = 'led led--red';
serverLed.querySelector('.led__label').textContent = 'Server Offline';
}
if (status?.agent?.connected) {
agentLed.className = 'led led--cyan led--pulse';
agentLed.querySelector('.led__label').textContent = 'Agent Connected';
} else {
agentLed.className = 'led led--muted';
agentLed.querySelector('.led__label').textContent = 'Agent Idle';
}
}
// ── Status sidebar panel ──────────────────────────────────────────────────
function renderStatusPanel(status) {
const el = document.getElementById('status-panel-body');
if (!el || !status) return;
const agent = status.agent;
const queue = status.queue;
el.innerHTML = `
<div class="stat-row">
<span class="stat-label">Server Up</span>
<span class="stat-value">${fmtTime(status.server?.started_at)}</span>
</div>
<div class="stat-row">
<span class="stat-label">Pending</span>
<span class="stat-value stat-value--cyan">${queue.pending_count}</span>
</div>
<div class="stat-row">
<span class="stat-label">Consumed</span>
<span class="stat-value stat-value--amber">${queue.consumed_count}</span>
</div>
<div class="stat-row">
<span class="stat-label">Agent</span>
<span class="stat-value">${agent.agent_id ? escapeHtml(agent.agent_id) : ''}</span>
</div>
<div class="stat-row">
<span class="stat-label">Last Seen</span>
<span class="stat-value" data-ts-rel="${agent.last_seen_at || ''}">${fmtRelative(agent.last_seen_at)}</span>
</div>
<div class="stat-row">
<span class="stat-label">Last Fetch</span>
<span class="stat-value" data-ts-rel="${agent.last_fetch_at || ''}">${fmtRelative(agent.last_fetch_at)}</span>
</div>
`;
}
/** Called by app.js on a timer to keep relative times fresh. */
export function refreshStatusTimestamps() {
document.querySelectorAll('[data-ts-rel]').forEach(el => {
const iso = el.dataset.tsRel;
if (iso) el.textContent = fmtRelative(iso);
});
}
export function initStatus() {
state.subscribe('serverOnline', (online) => {
updateHeaderLeds(online, state.get('status'));
});
state.subscribe('status', (status) => {
updateHeaderLeds(state.get('serverOnline'), status);
renderStatusPanel(status);
});
}
// ── Config panel ──────────────────────────────────────────────────────────
export function initConfig() {
const form = document.getElementById('config-form');
const waitInput = document.getElementById('cfg-wait');
const emptyInput = document.getElementById('cfg-empty');
const staleInput = document.getElementById('cfg-stale');
const saveBtn = document.getElementById('cfg-save');
// Populate from state
state.subscribe('config', (cfg) => {
if (!cfg) return;
waitInput.value = cfg.default_wait_seconds;
emptyInput.value = cfg.default_empty_response;
staleInput.value = cfg.agent_stale_after_seconds;
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
saveBtn.disabled = true;
const original = saveBtn.innerHTML;
saveBtn.innerHTML = '<span class="spinner"></span>';
try {
const cfg = await api.updateConfig({
default_wait_seconds: parseInt(waitInput.value, 10) || 10,
default_empty_response: emptyInput.value,
agent_stale_after_seconds: parseInt(staleInput.value, 10) || 30,
});
state.set('config', cfg);
toast('Settings saved', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = original;
}
});
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

67
static/js/theme.js Normal file
View File

@@ -0,0 +1,67 @@
/**
* static/js/theme.js
* Dark / light theme toggle.
* - Defaults to the OS/browser colour-scheme preference.
* - User override is persisted in localStorage.
* - Applies via <html data-theme="dark|light">.
*/
const STORAGE_KEY = 'local-mcp-theme';
function systemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function storedTheme() {
return localStorage.getItem(STORAGE_KEY); // 'dark' | 'light' | null
}
function iconSun() {
return `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>`;
}
function iconMoon() {
return `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>`;
}
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
const btn = document.getElementById('theme-toggle');
if (!btn) return;
const isDark = theme === 'dark';
btn.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode');
btn.setAttribute('title', isDark ? 'Switch to light mode' : 'Switch to dark mode');
btn.innerHTML = isDark ? iconMoon() : iconSun();
}
function toggle() {
const current = document.documentElement.dataset.theme || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
localStorage.setItem(STORAGE_KEY, next);
applyTheme(next);
}
export function initTheme() {
// Apply immediately (before paint) based on stored or system preference
const theme = storedTheme() || systemTheme();
applyTheme(theme);
// Follow system changes only when the user hasn't manually overridden
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!storedTheme()) {
applyTheme(e.matches ? 'dark' : 'light');
}
});
document.getElementById('theme-toggle')?.addEventListener('click', toggle);
}