Files
local-mcp/static/js/app.js
2026-03-27 18:51:34 +08:00

257 lines
9.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* static/js/app.js
* Application bootstrap initialises all modules, kicks off data fetching,
* and exports shared utilities like `toast`.
*/
import { api, setUnauthorizedHandler, getStoredToken, setStoredToken } 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';
import { initShortcuts } from './shortcuts.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);
}
export function confirmDialog({
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmClass = 'btn--danger',
}) {
document.getElementById('confirm-modal')?.remove();
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.id = 'confirm-modal';
overlay.className = 'auth-overlay';
overlay.tabIndex = -1;
overlay.innerHTML = `
<div class="auth-card confirm-card fade-in" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<div class="auth-card__icon confirm-card__icon confirm-card__icon--danger">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 9v4"/>
<path d="M12 17h.01"/>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
</svg>
</div>
<h2 id="confirm-title" class="auth-card__title">${title}</h2>
<p class="auth-card__desc">${message}</p>
<div class="confirm-card__actions">
<button type="button" class="btn btn--ghost" data-action="cancel">${cancelText}</button>
<button type="button" class="btn ${confirmClass}" data-action="confirm">${confirmText}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const cancelBtn = overlay.querySelector('[data-action="cancel"]');
const confirmBtn = overlay.querySelector('[data-action="confirm"]');
const close = (result) => {
overlay.remove();
resolve(result);
};
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close(false);
});
cancelBtn.addEventListener('click', () => close(false));
confirmBtn.addEventListener('click', () => close(true));
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') close(false);
});
overlay.focus();
confirmBtn.focus();
});
}
// ── Token auth modal ──────────────────────────────────────────────────────
function showTokenModal(onSuccess) {
// Remove any existing modal
document.getElementById('auth-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'auth-modal';
overlay.className = 'auth-overlay';
overlay.innerHTML = `
<div class="auth-card fade-in">
<div class="auth-card__icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--cyan)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<h2 class="auth-card__title">Authentication Required</h2>
<p class="auth-card__desc">This server requires a Bearer token.<br>Enter the value of <code>API_TOKEN</code> to continue.</p>
<form id="auth-form" class="auth-card__form">
<input
id="auth-token-input"
type="password"
class="input"
placeholder="Enter your API token…"
autocomplete="current-password"
spellcheck="false"
autofocus
/>
<button type="submit" class="btn btn--primary" style="width:100%">
<svg class="icon" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
Unlock
</button>
</form>
<p id="auth-error" class="auth-card__error" style="display:none">Incorrect token — try again.</p>
</div>
`;
document.body.appendChild(overlay);
const form = overlay.querySelector('#auth-form');
const input = overlay.querySelector('#auth-token-input');
const errorMsg = overlay.querySelector('#auth-error');
input.focus();
form.addEventListener('submit', async (e) => {
e.preventDefault();
const token = input.value.trim();
if (!token) return;
// Optimistically store and try a quick auth ping
setStoredToken(token);
try {
await api.status();
overlay.remove();
onSuccess();
} catch (err) {
// 401 means wrong token
setStoredToken('');
input.value = '';
input.focus();
errorMsg.style.display = '';
}
});
}
// ── 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…';
}
});
}
// ── 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 ──────────────────────────────────────────────────────────────────
async function bootstrap() {
initTheme();
initReconnectingIndicator();
initStatus();
initConfig();
initInstructions();
initComposer();
initShortcuts();
initGlobalSubscriptions();
// Hook 401 handler so mid-session token expiry shows the modal again
setUnauthorizedHandler(() => {
showTokenModal(async () => {
await loadInitialData();
connectSSE();
});
});
// Check whether auth is required before doing any authenticated calls
const authInfo = await api.authCheck().catch(() => ({ auth_required: false }));
if (authInfo.auth_required && !getStoredToken()) {
// Show modal before loading data
showTokenModal(async () => {
await loadInitialData();
connectSSE();
startPolling();
});
} else {
// No auth, or token already stored — proceed normally
await loadInitialData();
connectSSE();
startPolling();
}
}
document.addEventListener('DOMContentLoaded', bootstrap);