feat: add web UI dashboard SPA with dashboard, chat, sessions, and settings pages
- Add SPA shell with hash-based router, sidebar navigation, and WebSocket RPC client - Add dashboard page with system health cards, channel status, and auto-refresh - Add chat page with session selector, streaming tool events, and markdown rendering - Add sessions page with list, history viewer, and delete functionality - Add settings page with hook pattern editor, tool list, and config viewer - Add backend handlers: sessions.delete, sessions.switch, system.channels, system.usage - Wire channelRegistry into gateway server for channel status reporting - Extend static file server with .mjs, .png, .ico, .woff2 content types
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
|
||||
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
|
||||
import type { SessionManager } from '../../session/manager.js';
|
||||
import type { SessionBridge } from '../session-bridge.js';
|
||||
|
||||
export interface SessionHandlerDeps {
|
||||
sessionManager: SessionManager;
|
||||
sessionBridge?: SessionBridge;
|
||||
}
|
||||
|
||||
export function createSessionHandlers(deps: SessionHandlerDeps) {
|
||||
@@ -55,5 +57,44 @@ export function createSessionHandlers(deps: SessionHandlerDeps) {
|
||||
|
||||
return makeResponse(request.id, { sessionId });
|
||||
},
|
||||
|
||||
'sessions.delete': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
const params = request.params as { sessionId?: string } | undefined;
|
||||
if (!params?.sessionId) {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
|
||||
}
|
||||
|
||||
const { sessionId } = params;
|
||||
const parts = sessionId.split(':');
|
||||
const frontend = parts[0];
|
||||
const userId = parts.slice(1).join(':');
|
||||
const session = deps.sessionManager.getSession(frontend, userId);
|
||||
session.clear();
|
||||
|
||||
return makeResponse(request.id, { deleted: true, sessionId });
|
||||
},
|
||||
|
||||
'sessions.switch': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
const params = request.params as { sessionId?: string; connectionId?: string } | undefined;
|
||||
if (!params?.sessionId) {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
|
||||
}
|
||||
if (!deps.sessionBridge) {
|
||||
return makeError(request.id, ErrorCode.InternalError, 'Session switching not available');
|
||||
}
|
||||
|
||||
const connectionId = params.connectionId as string;
|
||||
if (!connectionId) {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'connectionId is required');
|
||||
}
|
||||
|
||||
try {
|
||||
deps.sessionBridge.switchSession(connectionId, params.sessionId);
|
||||
return makeResponse(request.id, { switched: true, sessionId: params.sessionId });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to switch session';
|
||||
return makeError(request.id, ErrorCode.InternalError, message);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface SystemHandlerDeps {
|
||||
getConnectionCount: () => number;
|
||||
/** Optional callback to trigger a graceful restart. If not provided, system.restart returns an error. */
|
||||
restart?: () => Promise<void>;
|
||||
getChannels?: () => Array<{ name: string; status: string }>;
|
||||
getUsage?: () => { totalSessions: number; activeConnections: number };
|
||||
}
|
||||
|
||||
export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
@@ -41,5 +43,22 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
'system.channels': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
if (!deps.getChannels) {
|
||||
return makeResponse(request.id, { channels: [] });
|
||||
}
|
||||
return makeResponse(request.id, { channels: deps.getChannels() });
|
||||
},
|
||||
|
||||
'system.usage': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
const uptime = Math.floor((Date.now() - deps.startTime) / 1000);
|
||||
const usage = deps.getUsage?.() ?? { totalSessions: 0, activeConnections: 0 };
|
||||
return makeResponse(request.id, {
|
||||
uptime,
|
||||
...usage,
|
||||
tools: deps.getToolCount(),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface GatewayServerConfig {
|
||||
config?: Config;
|
||||
/** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */
|
||||
restart?: () => Promise<void>;
|
||||
channelRegistry?: { list(): Array<{ readonly name: string; readonly status: string }> };
|
||||
}
|
||||
|
||||
export class GatewayServer {
|
||||
@@ -75,10 +76,18 @@ export class GatewayServer {
|
||||
getToolCount: () => this.config.toolRegistry.list().length,
|
||||
getConnectionCount: () => this.sessionBridge.connectionCount,
|
||||
restart: this.config.restart,
|
||||
getChannels: this.config.channelRegistry
|
||||
? () => this.config.channelRegistry!.list().map(a => ({ name: a.name, status: a.status }))
|
||||
: undefined,
|
||||
getUsage: () => ({
|
||||
totalSessions: this.config.sessionManager.listSessions().length,
|
||||
activeConnections: this.sessionBridge.connectionCount,
|
||||
}),
|
||||
});
|
||||
|
||||
const sessionHandlers = createSessionHandlers({
|
||||
sessionManager: this.config.sessionManager,
|
||||
sessionBridge: this.sessionBridge,
|
||||
});
|
||||
|
||||
const toolHandlers = createToolHandlers({
|
||||
|
||||
@@ -7,8 +7,12 @@ const CONTENT_TYPES: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.mjs': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Flynn SPA Router
|
||||
*
|
||||
* Hash-based routing with page lifecycle management.
|
||||
*/
|
||||
import { getClient } from './lib/ws-client.js';
|
||||
|
||||
const routes = new Map();
|
||||
let currentPage = null;
|
||||
let contentEl = null;
|
||||
|
||||
export function registerPage(path, page) {
|
||||
routes.set(path, page);
|
||||
}
|
||||
|
||||
export function navigate(path) {
|
||||
window.location.hash = path;
|
||||
}
|
||||
|
||||
function getPath() {
|
||||
const hash = window.location.hash.slice(1) || '/';
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const path = getPath();
|
||||
const page = routes.get(path);
|
||||
|
||||
if (!page) {
|
||||
contentEl.innerHTML = '<div class="page-error"><h2>Page not found</h2></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Teardown previous page
|
||||
if (currentPage?.teardown) {
|
||||
currentPage.teardown();
|
||||
}
|
||||
|
||||
// Update nav
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.classList.toggle('active', link.getAttribute('href') === `#${path}`);
|
||||
});
|
||||
|
||||
// Render new page
|
||||
currentPage = page;
|
||||
contentEl.innerHTML = '';
|
||||
|
||||
const pageEl = document.createElement('div');
|
||||
pageEl.className = 'page';
|
||||
contentEl.appendChild(pageEl);
|
||||
|
||||
await page.render(pageEl, getClient());
|
||||
}
|
||||
|
||||
export function initRouter() {
|
||||
contentEl = document.getElementById('content');
|
||||
window.addEventListener('hashchange', render);
|
||||
render();
|
||||
}
|
||||
|
||||
// Connection status indicator
|
||||
export function initStatusIndicator() {
|
||||
const statusEl = document.getElementById('conn-status');
|
||||
const client = getClient();
|
||||
|
||||
client.onStatusChange((status) => {
|
||||
statusEl.textContent = status === 'connected' ? 'Connected' :
|
||||
status === 'connecting' ? 'Connecting...' : 'Disconnected';
|
||||
statusEl.className = `conn-status ${status}`;
|
||||
});
|
||||
|
||||
// Set initial status
|
||||
statusEl.textContent = client.status === 'connected' ? 'Connected' :
|
||||
client.status === 'connecting' ? 'Connecting...' : 'Disconnected';
|
||||
statusEl.className = `conn-status ${client.status}`;
|
||||
}
|
||||
+42
-205
@@ -3,217 +3,54 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Flynn Dashboard</title>
|
||||
<title>Flynn</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container" style="height:100vh;overflow-y:auto">
|
||||
<header>
|
||||
<h1>Flynn Dashboard</h1>
|
||||
<span id="status">Connecting...</span>
|
||||
<a href="/chat.html">Chat</a>
|
||||
</header>
|
||||
<section class="dashboard" id="health">
|
||||
<!-- Health cards rendered by JS -->
|
||||
</section>
|
||||
<section>
|
||||
<h2>Sessions</h2>
|
||||
<div id="sessions" class="session-list">Loading...</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Tools</h2>
|
||||
<div id="tools" class="tool-list">Loading...</div>
|
||||
</section>
|
||||
<div class="app-shell">
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1 class="logo">Flynn</h1>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="#/" class="nav-link active" data-page="dashboard">
|
||||
<span class="nav-icon">■</span> Dashboard
|
||||
</a>
|
||||
<a href="#/chat" class="nav-link" data-page="chat">
|
||||
<span class="nav-icon">✉</span> Chat
|
||||
</a>
|
||||
<a href="#/sessions" class="nav-link" data-page="sessions">
|
||||
<span class="nav-icon">☰</span> Sessions
|
||||
</a>
|
||||
<a href="#/settings" class="nav-link" data-page="settings">
|
||||
<span class="nav-icon">⚙</span> Settings
|
||||
</a>
|
||||
</div>
|
||||
<div class="sidebar-footer">
|
||||
<span id="conn-status" class="conn-status disconnected">Disconnected</span>
|
||||
</div>
|
||||
</nav>
|
||||
<main id="content" class="content">
|
||||
<!-- Pages rendered here by router -->
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
// --- Request ID counter and pending callback map ---
|
||||
let requestId = 0;
|
||||
const pending = new Map();
|
||||
<script type="module">
|
||||
import { registerPage, initRouter, initStatusIndicator } from './app.js';
|
||||
import { DashboardPage } from './pages/dashboard.js';
|
||||
import { ChatPage } from './pages/chat.js';
|
||||
import { SessionsPage } from './pages/sessions.js';
|
||||
import { SettingsPage } from './pages/settings.js';
|
||||
|
||||
// --- WebSocket connection ---
|
||||
const ws = new WebSocket(`ws://${location.host}`);
|
||||
registerPage('/', DashboardPage);
|
||||
registerPage('/chat', ChatPage);
|
||||
registerPage('/sessions', SessionsPage);
|
||||
registerPage('/settings', SettingsPage);
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
const healthEl = document.getElementById('health');
|
||||
const sessionsEl = document.getElementById('sessions');
|
||||
const toolsEl = document.getElementById('tools');
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
statusEl.textContent = 'Connected';
|
||||
statusEl.className = 'status-ok';
|
||||
// Fetch all data on connect
|
||||
fetchHealth();
|
||||
fetchSessions();
|
||||
fetchTools();
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
statusEl.textContent = 'Disconnected';
|
||||
statusEl.className = 'status-error';
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
statusEl.textContent = 'Connection error';
|
||||
statusEl.className = 'status-error';
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
// Match response to pending request by ID
|
||||
if (msg.id != null && pending.has(msg.id)) {
|
||||
const callback = pending.get(msg.id);
|
||||
pending.delete(msg.id);
|
||||
if (msg.result) {
|
||||
callback(null, msg.result);
|
||||
} else if (msg.error) {
|
||||
callback(msg.error, null);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
});
|
||||
|
||||
// --- Auto-refresh health every 10 seconds ---
|
||||
setInterval(fetchHealth, 10000);
|
||||
|
||||
// --- RPC helper: send a JSON-RPC request and register a callback ---
|
||||
function rpcCall(method, params, callback) {
|
||||
requestId++;
|
||||
const id = requestId;
|
||||
pending.set(id, callback);
|
||||
const message = { id, method };
|
||||
if (params) {
|
||||
message.params = params;
|
||||
}
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
// --- Uptime formatting: converts seconds to human-readable string ---
|
||||
function formatUptime(totalSeconds) {
|
||||
const s = Math.floor(totalSeconds);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
return `${h}h ${m}m ${sec}s`;
|
||||
}
|
||||
|
||||
// --- Fetch and render system health ---
|
||||
function fetchHealth() {
|
||||
rpcCall('system.health', null, (err, result) => {
|
||||
if (err) {
|
||||
healthEl.innerHTML = '<div class="card"><h2>Error</h2><div class="value">' +
|
||||
escapeHtml(err.message || 'Failed to fetch health') + '</div></div>';
|
||||
return;
|
||||
}
|
||||
renderHealth(result);
|
||||
});
|
||||
}
|
||||
|
||||
function renderHealth(data) {
|
||||
const isOk = data.status === 'ok';
|
||||
const statusClass = isOk ? 'status-ok' : 'status-error';
|
||||
const statusLabel = isOk ? '● Healthy' : '● Unhealthy';
|
||||
|
||||
healthEl.innerHTML = `
|
||||
<div class="card">
|
||||
<h2>Status</h2>
|
||||
<div class="value ${statusClass}">${statusLabel}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Version</h2>
|
||||
<div class="value">${escapeHtml(data.version || 'unknown')}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Uptime</h2>
|
||||
<div class="value">${formatUptime(data.uptime || 0)}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Connections</h2>
|
||||
<div class="value">${data.connections ?? 0}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Sessions</h2>
|
||||
<div class="value">${data.sessions ?? 0}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Tools</h2>
|
||||
<div class="value">${data.tools ?? 0}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Fetch and render sessions list ---
|
||||
function fetchSessions() {
|
||||
rpcCall('sessions.list', null, (err, result) => {
|
||||
if (err) {
|
||||
sessionsEl.textContent = 'Error: ' + (err.message || 'Failed to fetch sessions');
|
||||
return;
|
||||
}
|
||||
renderSessions(result.sessions || []);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSessions(sessions) {
|
||||
if (sessions.length === 0) {
|
||||
sessionsEl.textContent = 'No active sessions';
|
||||
return;
|
||||
}
|
||||
const rows = sessions.map(s => `
|
||||
<tr>
|
||||
<td>${escapeHtml(s.id)}</td>
|
||||
<td>${s.messageCount ?? 0}</td>
|
||||
<td>${s.lastActivity ? new Date(s.lastActivity).toLocaleString() : '—'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
sessionsEl.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session ID</th>
|
||||
<th>Messages</th>
|
||||
<th>Last Activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Fetch and render tools list ---
|
||||
function fetchTools() {
|
||||
rpcCall('tools.list', null, (err, result) => {
|
||||
if (err) {
|
||||
toolsEl.textContent = 'Error: ' + (err.message || 'Failed to fetch tools');
|
||||
return;
|
||||
}
|
||||
renderTools(result.tools || []);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTools(tools) {
|
||||
if (tools.length === 0) {
|
||||
toolsEl.textContent = 'No tools registered';
|
||||
return;
|
||||
}
|
||||
const items = tools.map(t => `
|
||||
<div class="tool-item">
|
||||
<strong>${escapeHtml(t.name)}</strong>
|
||||
${t.description ? '<span> — ' + escapeHtml(t.description) + '</span>' : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
toolsEl.innerHTML = items;
|
||||
}
|
||||
|
||||
// --- XSS protection: escape HTML entities ---
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(str);
|
||||
return div.innerHTML;
|
||||
}
|
||||
initStatusIndicator();
|
||||
initRouter();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Flynn WebSocket RPC Client
|
||||
*
|
||||
* Promise-based JSON-RPC client with auto-reconnect, event streaming,
|
||||
* and connection lifecycle management.
|
||||
*/
|
||||
export class FlynnClient {
|
||||
constructor(url) {
|
||||
this._url = url || `ws://${location.host}`;
|
||||
this._ws = null;
|
||||
this._requestId = 0;
|
||||
this._pending = new Map(); // id -> { resolve, reject }
|
||||
this._listeners = new Map(); // id -> { events: Map<event, callback[]> }
|
||||
this._reconnectDelay = 1000;
|
||||
this._maxReconnectDelay = 30000;
|
||||
this._onStatusChange = null;
|
||||
this._status = 'disconnected';
|
||||
this._autoReconnect = true;
|
||||
}
|
||||
|
||||
get status() { return this._status; }
|
||||
|
||||
onStatusChange(callback) {
|
||||
this._onStatusChange = callback;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this._autoReconnect = true;
|
||||
this._doConnect();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._autoReconnect = false;
|
||||
if (this._ws) {
|
||||
this._ws.close();
|
||||
this._ws = null;
|
||||
}
|
||||
this._setStatus('disconnected');
|
||||
}
|
||||
|
||||
_doConnect() {
|
||||
this._setStatus('connecting');
|
||||
|
||||
// Build URL with token from URL search params if present
|
||||
let wsUrl = this._url;
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const token = urlParams.get('token');
|
||||
if (token) {
|
||||
const sep = wsUrl.includes('?') ? '&' : '?';
|
||||
wsUrl = `${wsUrl}${sep}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
this._ws = new WebSocket(wsUrl);
|
||||
|
||||
this._ws.onopen = () => {
|
||||
this._setStatus('connected');
|
||||
this._reconnectDelay = 1000;
|
||||
};
|
||||
|
||||
this._ws.onmessage = (event) => {
|
||||
this._handleMessage(event.data);
|
||||
};
|
||||
|
||||
this._ws.onclose = () => {
|
||||
this._ws = null;
|
||||
this._setStatus('disconnected');
|
||||
// Reject all pending requests
|
||||
for (const [id, pending] of this._pending) {
|
||||
pending.reject(new Error('WebSocket closed'));
|
||||
}
|
||||
this._pending.clear();
|
||||
this._listeners.clear();
|
||||
|
||||
if (this._autoReconnect) {
|
||||
setTimeout(() => this._doConnect(), this._reconnectDelay);
|
||||
this._reconnectDelay = Math.min(this._reconnectDelay * 2, this._maxReconnectDelay);
|
||||
}
|
||||
};
|
||||
|
||||
this._ws.onerror = () => {
|
||||
// Error is always followed by close
|
||||
};
|
||||
}
|
||||
|
||||
_setStatus(status) {
|
||||
if (this._status !== status) {
|
||||
this._status = status;
|
||||
this._onStatusChange?.(status);
|
||||
}
|
||||
}
|
||||
|
||||
_handleMessage(raw) {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Streamed event (has 'event' field)
|
||||
if (msg.event && msg.id != null) {
|
||||
const listener = this._listeners.get(msg.id);
|
||||
if (listener) {
|
||||
const callbacks = listener.events.get(msg.event) || [];
|
||||
for (const cb of callbacks) {
|
||||
cb(msg.data);
|
||||
}
|
||||
// Also fire wildcard listeners
|
||||
const wildcards = listener.events.get('*') || [];
|
||||
for (const cb of wildcards) {
|
||||
cb(msg.event, msg.data);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Response or error (matches pending request)
|
||||
if (msg.id != null && this._pending.has(msg.id)) {
|
||||
const pending = this._pending.get(msg.id);
|
||||
this._pending.delete(msg.id);
|
||||
|
||||
if (msg.error) {
|
||||
pending.reject(new Error(msg.error.message));
|
||||
} else {
|
||||
pending.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an RPC call and return a promise for the result.
|
||||
* For streaming methods (like agent.send), use stream() instead.
|
||||
*/
|
||||
async call(method, params) {
|
||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const id = ++this._requestId;
|
||||
return new Promise((resolve, reject) => {
|
||||
this._pending.set(id, { resolve, reject });
|
||||
this._ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a streaming RPC call. Returns an object with:
|
||||
* - on(event, callback): listen for streaming events
|
||||
* - result: promise that resolves when 'done' event fires or rejects on 'error'
|
||||
*/
|
||||
stream(method, params) {
|
||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const id = ++this._requestId;
|
||||
const events = new Map();
|
||||
this._listeners.set(id, { events });
|
||||
|
||||
const handle = {
|
||||
on(event, callback) {
|
||||
if (!events.has(event)) events.set(event, []);
|
||||
events.get(event).push(callback);
|
||||
return handle;
|
||||
},
|
||||
result: new Promise((resolve, reject) => {
|
||||
// Auto-wire done/error to resolve/reject the promise
|
||||
if (!events.has('done')) events.set('done', []);
|
||||
events.get('done').push((data) => {
|
||||
this._listeners.delete(id);
|
||||
resolve(data);
|
||||
});
|
||||
if (!events.has('error')) events.set('error', []);
|
||||
events.get('error').push((data) => {
|
||||
this._listeners.delete(id);
|
||||
reject(new Error(data.message || 'Agent error'));
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
this._ws.send(JSON.stringify({ id, method, params }));
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let _instance = null;
|
||||
|
||||
export function getClient() {
|
||||
if (!_instance) {
|
||||
_instance = new FlynnClient();
|
||||
_instance.connect();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Flynn Chat Page
|
||||
*
|
||||
* Session selector, message input, streaming tool events,
|
||||
* and markdown-rendered responses.
|
||||
*/
|
||||
|
||||
/* global marked, hljs */
|
||||
|
||||
let _currentSession = null;
|
||||
let _sending = false;
|
||||
let _elements = {};
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
try {
|
||||
if (typeof marked !== 'undefined') {
|
||||
return marked.parse(text);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to plain text
|
||||
}
|
||||
return `<p>${escapeHtml(text)}</p>`;
|
||||
}
|
||||
|
||||
function highlightCode() {
|
||||
if (typeof hljs !== 'undefined') {
|
||||
document.querySelectorAll('.chat-messages pre code').forEach(block => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createMessageEl(role, content) {
|
||||
const div = document.createElement('div');
|
||||
div.className = `message ${role}`;
|
||||
|
||||
if (role === 'assistant') {
|
||||
div.innerHTML = renderMarkdown(content);
|
||||
setTimeout(highlightCode, 0);
|
||||
} else {
|
||||
div.textContent = content;
|
||||
}
|
||||
return div;
|
||||
}
|
||||
|
||||
function createToolEventEl(event, data) {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'tool-event-group';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'tool-event-header';
|
||||
|
||||
if (event === 'tool_start') {
|
||||
header.innerHTML = `<span class="spinner"></span> <strong>${escapeHtml(data.tool)}</strong>`;
|
||||
} else if (event === 'tool_end') {
|
||||
const icon = data.result?.success ? '✓' : '✗';
|
||||
const cls = data.result?.success ? 'status-ok' : 'status-error';
|
||||
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
|
||||
}
|
||||
|
||||
header.addEventListener('click', () => {
|
||||
body.classList.toggle('open');
|
||||
});
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'tool-event-body';
|
||||
|
||||
if (event === 'tool_start' && data.args) {
|
||||
body.textContent = JSON.stringify(data.args, null, 2);
|
||||
} else if (event === 'tool_end' && data.result) {
|
||||
body.textContent = data.result.output || data.result.error || '(no output)';
|
||||
}
|
||||
|
||||
group.appendChild(header);
|
||||
group.appendChild(body);
|
||||
return group;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const msgs = _elements.messages;
|
||||
if (msgs) {
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessions(client) {
|
||||
const select = _elements.sessionSelect;
|
||||
if (!select) return;
|
||||
|
||||
try {
|
||||
const result = await client.call('sessions.list');
|
||||
const sessions = result.sessions ?? [];
|
||||
|
||||
// Preserve current selection
|
||||
const current = _currentSession;
|
||||
select.innerHTML = '';
|
||||
|
||||
if (sessions.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = 'No sessions';
|
||||
select.appendChild(opt);
|
||||
} else {
|
||||
for (const s of sessions) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.textContent = `${s.id} (${s.messageCount} msgs)`;
|
||||
if (s.id === current) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
// Update current session
|
||||
_currentSession = select.value || null;
|
||||
} catch {
|
||||
// Ignore — sessions may not be available
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory(client) {
|
||||
const msgs = _elements.messages;
|
||||
if (!msgs || !_currentSession) return;
|
||||
|
||||
msgs.innerHTML = '';
|
||||
|
||||
try {
|
||||
const result = await client.call('sessions.history', { sessionId: _currentSession });
|
||||
const messages = result.messages ?? [];
|
||||
|
||||
for (const msg of messages) {
|
||||
const role = msg.role ?? 'assistant';
|
||||
const content = msg.content ?? msg.text ?? '';
|
||||
msgs.appendChild(createMessageEl(role, content));
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
} catch {
|
||||
msgs.innerHTML = '<div class="empty-state">Could not load history</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(client) {
|
||||
const input = _elements.input;
|
||||
const text = input?.value?.trim();
|
||||
if (!text || _sending) return;
|
||||
|
||||
_sending = true;
|
||||
_elements.sendBtn.disabled = true;
|
||||
input.value = '';
|
||||
|
||||
// Show user message
|
||||
_elements.messages.appendChild(createMessageEl('user', text));
|
||||
scrollToBottom();
|
||||
|
||||
// Create placeholder for assistant response
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'message assistant streaming-cursor';
|
||||
placeholder.innerHTML = '<span class="text-muted">Thinking...</span>';
|
||||
_elements.messages.appendChild(placeholder);
|
||||
scrollToBottom();
|
||||
|
||||
try {
|
||||
const stream = client.stream('agent.send', { message: text });
|
||||
|
||||
stream.on('tool_start', (data) => {
|
||||
const el = createToolEventEl('tool_start', data);
|
||||
_elements.messages.insertBefore(el, placeholder);
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
stream.on('tool_end', (data) => {
|
||||
// Replace the last tool_start spinner with completion marker
|
||||
const events = _elements.messages.querySelectorAll('.tool-event-group');
|
||||
const last = events[events.length - 1];
|
||||
if (last) {
|
||||
const header = last.querySelector('.tool-event-header');
|
||||
if (header && data.tool) {
|
||||
const icon = data.result?.success !== false ? '✓' : '✗';
|
||||
const cls = data.result?.success !== false ? 'status-ok' : 'status-error';
|
||||
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
|
||||
}
|
||||
// Add result body
|
||||
const body = last.querySelector('.tool-event-body');
|
||||
if (body && data.result) {
|
||||
body.textContent = data.result.output || data.result.error || '(no output)';
|
||||
}
|
||||
}
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
const done = await stream.result;
|
||||
// Replace placeholder with actual response
|
||||
placeholder.classList.remove('streaming-cursor');
|
||||
const content = done?.content ?? done?.text ?? '(no response)';
|
||||
placeholder.innerHTML = renderMarkdown(content);
|
||||
setTimeout(highlightCode, 0);
|
||||
} catch (err) {
|
||||
placeholder.classList.remove('streaming-cursor');
|
||||
placeholder.className = 'message error';
|
||||
placeholder.textContent = `Error: ${err.message}`;
|
||||
} finally {
|
||||
_sending = false;
|
||||
if (_elements.sendBtn) _elements.sendBtn.disabled = false;
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
export const ChatPage = {
|
||||
async render(el, client) {
|
||||
el.innerHTML = `
|
||||
<div class="chat-layout">
|
||||
<div class="chat-header">
|
||||
<select id="chat-session-select"></select>
|
||||
<button id="chat-new-session" class="btn btn-secondary">+ New Session</button>
|
||||
<button id="chat-load-history" class="btn btn-secondary">Load History</button>
|
||||
</div>
|
||||
<div class="chat-messages" id="chat-messages"></div>
|
||||
<div class="chat-input">
|
||||
<textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
|
||||
<button id="chat-send" class="btn btn-primary">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
_elements = {
|
||||
sessionSelect: el.querySelector('#chat-session-select'),
|
||||
messages: el.querySelector('#chat-messages'),
|
||||
input: el.querySelector('#chat-input'),
|
||||
sendBtn: el.querySelector('#chat-send'),
|
||||
};
|
||||
|
||||
// Load sessions into dropdown
|
||||
await loadSessions(client);
|
||||
|
||||
// Event: session change
|
||||
_elements.sessionSelect.addEventListener('change', () => {
|
||||
_currentSession = _elements.sessionSelect.value || null;
|
||||
});
|
||||
|
||||
// Event: new session
|
||||
el.querySelector('#chat-new-session').addEventListener('click', async () => {
|
||||
try {
|
||||
const result = await client.call('sessions.create');
|
||||
_currentSession = result.sessionId;
|
||||
await loadSessions(client);
|
||||
_elements.messages.innerHTML = '';
|
||||
} catch (err) {
|
||||
_elements.messages.innerHTML = `<div class="empty-state">Failed to create session: ${err.message}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Event: load history
|
||||
el.querySelector('#chat-load-history').addEventListener('click', () => {
|
||||
loadHistory(client);
|
||||
});
|
||||
|
||||
// Event: send message
|
||||
_elements.sendBtn.addEventListener('click', () => sendMessage(client));
|
||||
|
||||
// Event: Enter to send (Shift+Enter for newline)
|
||||
_elements.input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage(client);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-resize textarea
|
||||
_elements.input.addEventListener('input', () => {
|
||||
const ta = _elements.input;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = Math.min(ta.scrollHeight, 150) + 'px';
|
||||
});
|
||||
|
||||
// If there's a current session, show welcome
|
||||
if (!_currentSession) {
|
||||
_elements.messages.innerHTML = '<div class="empty-state">Select a session or create a new one to start chatting</div>';
|
||||
}
|
||||
},
|
||||
|
||||
teardown() {
|
||||
_currentSession = null;
|
||||
_sending = false;
|
||||
_elements = {};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Flynn Dashboard Page
|
||||
*
|
||||
* Shows system health cards, channel status, and usage stats.
|
||||
* Auto-refreshes every 10 seconds.
|
||||
*/
|
||||
|
||||
let _timer = null;
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
const parts = [];
|
||||
if (d > 0) parts.push(`${d}d`);
|
||||
if (h > 0) parts.push(`${h}h`);
|
||||
if (m > 0) parts.push(`${m}m`);
|
||||
parts.push(`${s}s`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
async function loadDashboard(el, client) {
|
||||
let health, channels, usage;
|
||||
|
||||
try {
|
||||
[health, channels, usage] = await Promise.all([
|
||||
client.call('system.health'),
|
||||
client.call('system.channels'),
|
||||
client.call('system.usage'),
|
||||
]);
|
||||
} catch (err) {
|
||||
el.innerHTML = `<div class="empty-state">Failed to load dashboard: ${err.message}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build stats grid
|
||||
const stats = [
|
||||
{ label: 'Status', value: health.status?.toUpperCase() ?? 'UNKNOWN', cls: health.status === 'ok' ? 'ok' : 'error' },
|
||||
{ label: 'Version', value: health.version ?? '-', cls: '' },
|
||||
{ label: 'Uptime', value: formatUptime(health.uptime ?? 0), cls: '' },
|
||||
{ label: 'Connections', value: String(health.connections ?? 0), cls: '' },
|
||||
{ label: 'Sessions', value: String(health.sessions ?? 0), cls: '' },
|
||||
{ label: 'Tools', value: String(health.tools ?? 0), cls: '' },
|
||||
];
|
||||
|
||||
const statsHtml = stats.map(s =>
|
||||
`<div class="stat-card">
|
||||
<div class="stat-label">${s.label}</div>
|
||||
<div class="stat-value ${s.cls}">${s.value}</div>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
// Build channels grid
|
||||
const channelList = channels?.channels ?? [];
|
||||
let channelsHtml = '';
|
||||
if (channelList.length > 0) {
|
||||
channelsHtml = channelList.map(ch =>
|
||||
`<div class="channel-card">
|
||||
<span class="channel-dot ${ch.status}"></span>
|
||||
<span class="channel-name">${ch.name}</span>
|
||||
</div>`
|
||||
).join('');
|
||||
} else {
|
||||
channelsHtml = '<div class="text-muted text-sm">No channels registered</div>';
|
||||
}
|
||||
|
||||
// Build usage section
|
||||
const usageItems = [
|
||||
{ label: 'Total Sessions', value: String(usage?.totalSessions ?? 0) },
|
||||
{ label: 'Active Connections', value: String(usage?.activeConnections ?? 0) },
|
||||
{ label: 'Available Tools', value: String(usage?.tools ?? 0) },
|
||||
{ label: 'Uptime', value: formatUptime(usage?.uptime ?? 0) },
|
||||
];
|
||||
|
||||
const usageHtml = usageItems.map(u =>
|
||||
`<div class="stat-card">
|
||||
<div class="stat-label">${u.label}</div>
|
||||
<div class="stat-value">${u.value}</div>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<h1 class="page-title">Dashboard</h1>
|
||||
<h2 class="section-title">System Health</h2>
|
||||
<div class="stats-grid">${statsHtml}</div>
|
||||
<h2 class="section-title">Channels</h2>
|
||||
<div class="channels-grid">${channelsHtml}</div>
|
||||
<h2 class="section-title">Usage</h2>
|
||||
<div class="stats-grid">${usageHtml}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export const DashboardPage = {
|
||||
async render(el, client) {
|
||||
await loadDashboard(el, client);
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
_timer = setInterval(() => {
|
||||
loadDashboard(el, client).catch(() => {});
|
||||
}, 10000);
|
||||
},
|
||||
|
||||
teardown() {
|
||||
if (_timer) {
|
||||
clearInterval(_timer);
|
||||
_timer = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Flynn Sessions Page
|
||||
*
|
||||
* Lists all sessions, allows viewing history and deleting sessions.
|
||||
*/
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
let _client = null;
|
||||
let _el = null;
|
||||
|
||||
async function loadSessionList() {
|
||||
if (!_client || !_el) return;
|
||||
|
||||
const listContainer = _el.querySelector('#sessions-list');
|
||||
const detailContainer = _el.querySelector('#session-detail');
|
||||
if (detailContainer) detailContainer.innerHTML = '';
|
||||
|
||||
try {
|
||||
const result = await _client.call('sessions.list');
|
||||
const sessions = result.sessions ?? [];
|
||||
|
||||
if (sessions.length === 0) {
|
||||
listContainer.innerHTML = '<div class="empty-state">No sessions found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session ID</th>
|
||||
<th>Messages</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
for (const s of sessions) {
|
||||
html += `
|
||||
<tr>
|
||||
<td><a href="#" class="session-view-link" data-id="${escapeHtml(s.id)}">${escapeHtml(s.id)}</a></td>
|
||||
<td>${s.messageCount ?? 0}</td>
|
||||
<td class="session-actions">
|
||||
<button class="btn btn-secondary session-view-btn" data-id="${escapeHtml(s.id)}">View</button>
|
||||
<button class="btn btn-danger session-delete-btn" data-id="${escapeHtml(s.id)}">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
listContainer.innerHTML = html;
|
||||
|
||||
// Bind view buttons
|
||||
listContainer.querySelectorAll('.session-view-btn, .session-view-link').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
viewSession(btn.dataset.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Bind delete buttons
|
||||
listContainer.querySelectorAll('.session-delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
deleteSession(btn.dataset.id);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
listContainer.innerHTML = `<div class="empty-state">Failed to load sessions: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function viewSession(sessionId) {
|
||||
const detailContainer = _el.querySelector('#session-detail');
|
||||
if (!detailContainer) return;
|
||||
|
||||
detailContainer.innerHTML = '<div class="empty-state"><span class="spinner"></span> Loading...</div>';
|
||||
|
||||
try {
|
||||
const result = await _client.call('sessions.history', { sessionId });
|
||||
const messages = result.messages ?? [];
|
||||
|
||||
let html = `
|
||||
<div class="session-detail">
|
||||
<div class="session-detail-header">
|
||||
<h2 class="section-title">${escapeHtml(sessionId)}</h2>
|
||||
<span class="text-muted text-sm">${messages.length} messages</span>
|
||||
</div>
|
||||
<div class="message-history">
|
||||
`;
|
||||
|
||||
if (messages.length === 0) {
|
||||
html += '<div class="empty-state">No messages in this session</div>';
|
||||
} else {
|
||||
for (const msg of messages) {
|
||||
const role = msg.role ?? 'system';
|
||||
const content = msg.content ?? msg.text ?? '';
|
||||
html += `<div class="message ${escapeHtml(role)}">${escapeHtml(content)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
detailContainer.innerHTML = html;
|
||||
} catch (err) {
|
||||
detailContainer.innerHTML = `<div class="empty-state">Failed to load session: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId) {
|
||||
if (!confirm(`Delete session "${sessionId}"? This will clear all message history.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _client.call('sessions.delete', { sessionId });
|
||||
await loadSessionList();
|
||||
} catch (err) {
|
||||
alert(`Failed to delete session: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const SessionsPage = {
|
||||
async render(el, client) {
|
||||
_client = client;
|
||||
_el = el;
|
||||
|
||||
el.innerHTML = `
|
||||
<h1 class="page-title">Sessions</h1>
|
||||
<div id="sessions-list"></div>
|
||||
<div id="session-detail"></div>
|
||||
`;
|
||||
|
||||
await loadSessionList();
|
||||
},
|
||||
|
||||
teardown() {
|
||||
_client = null;
|
||||
_el = null;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Flynn Settings Page
|
||||
*
|
||||
* Read-only config view (redacted), editable hook patterns,
|
||||
* tool list, and channel overview.
|
||||
*/
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
let _client = null;
|
||||
let _el = null;
|
||||
|
||||
async function loadSettings() {
|
||||
if (!_client || !_el) return;
|
||||
|
||||
let config, tools, channels;
|
||||
|
||||
try {
|
||||
[config, tools, channels] = await Promise.all([
|
||||
_client.call('config.get'),
|
||||
_client.call('tools.list'),
|
||||
_client.call('system.channels'),
|
||||
]);
|
||||
} catch (err) {
|
||||
_el.innerHTML = `
|
||||
<h1 class="page-title">Settings</h1>
|
||||
<div class="empty-state">Failed to load settings: ${err.message}</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract hooks from config
|
||||
const hooks = config?.hooks ?? {};
|
||||
const confirmPatterns = hooks.confirm ?? [];
|
||||
const logPatterns = hooks.log ?? [];
|
||||
const silentPatterns = hooks.silent ?? [];
|
||||
|
||||
// Build config view (redacted JSON)
|
||||
const configJson = JSON.stringify(config, null, 2);
|
||||
|
||||
// Build tool list
|
||||
const toolList = tools?.tools ?? [];
|
||||
|
||||
// Build channel list
|
||||
const channelList = channels?.channels ?? [];
|
||||
|
||||
_el.innerHTML = `
|
||||
<h1 class="page-title">Settings</h1>
|
||||
|
||||
<h2 class="section-title">Hook Patterns</h2>
|
||||
<div class="settings-section">
|
||||
<div class="hook-editor">
|
||||
<div class="hook-group">
|
||||
<label>Confirm (require approval)</label>
|
||||
<textarea id="hooks-confirm" rows="3">${escapeHtml(confirmPatterns.join('\n'))}</textarea>
|
||||
</div>
|
||||
<div class="hook-group">
|
||||
<label>Log (allow + log)</label>
|
||||
<textarea id="hooks-log" rows="3">${escapeHtml(logPatterns.join('\n'))}</textarea>
|
||||
</div>
|
||||
<div class="hook-group">
|
||||
<label>Silent (allow silently)</label>
|
||||
<textarea id="hooks-silent" rows="3">${escapeHtml(silentPatterns.join('\n'))}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<button id="hooks-save" class="btn btn-primary">Save Hook Patterns</button>
|
||||
<span id="hooks-status" class="text-sm text-muted" style="margin-left: 12px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Tools (${toolList.length})</h2>
|
||||
<div class="settings-section">
|
||||
${toolList.length > 0 ? `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${toolList.map(t => `
|
||||
<tr>
|
||||
<td><code>${escapeHtml(t.name)}</code></td>
|
||||
<td class="text-secondary">${escapeHtml(t.description ?? '')}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
` : '<div class="empty-state">No tools available</div>'}
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Channels</h2>
|
||||
<div class="settings-section">
|
||||
${channelList.length > 0 ? `
|
||||
<div class="channels-grid">
|
||||
${channelList.map(ch => `
|
||||
<div class="channel-card">
|
||||
<span class="channel-dot ${ch.status}"></span>
|
||||
<span class="channel-name">${escapeHtml(ch.name)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '<div class="text-muted text-sm">No channels registered</div>'}
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Configuration (read-only)</h2>
|
||||
<div class="settings-section">
|
||||
<div class="config-view"><code>${escapeHtml(configJson)}</code></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Bind save hooks
|
||||
_el.querySelector('#hooks-save').addEventListener('click', saveHooks);
|
||||
}
|
||||
|
||||
async function saveHooks() {
|
||||
const status = _el.querySelector('#hooks-status');
|
||||
status.textContent = 'Saving...';
|
||||
status.className = 'text-sm text-muted';
|
||||
|
||||
try {
|
||||
const confirm = _el.querySelector('#hooks-confirm').value.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
const log = _el.querySelector('#hooks-log').value.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
const silent = _el.querySelector('#hooks-silent').value.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
const result = await _client.call('config.patch', {
|
||||
patches: {
|
||||
'hooks.confirm': confirm,
|
||||
'hooks.log': log,
|
||||
'hooks.silent': silent,
|
||||
},
|
||||
});
|
||||
|
||||
const applied = result.applied ?? [];
|
||||
const rejected = result.rejected ?? [];
|
||||
|
||||
if (rejected.length > 0) {
|
||||
status.textContent = `Partially saved. Rejected: ${rejected.join(', ')}`;
|
||||
status.className = 'text-sm text-error';
|
||||
} else {
|
||||
status.textContent = `Saved (${applied.length} updated)`;
|
||||
status.className = 'text-sm text-success';
|
||||
}
|
||||
} catch (err) {
|
||||
status.textContent = `Error: ${err.message}`;
|
||||
status.className = 'text-sm text-error';
|
||||
}
|
||||
|
||||
// Clear status after 5s
|
||||
setTimeout(() => {
|
||||
if (status) status.textContent = '';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
export const SettingsPage = {
|
||||
async render(el, client) {
|
||||
_client = client;
|
||||
_el = el;
|
||||
await loadSettings();
|
||||
},
|
||||
|
||||
teardown() {
|
||||
_client = null;
|
||||
_el = null;
|
||||
},
|
||||
};
|
||||
@@ -530,3 +530,621 @@ header #status.status-ok {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
SPA Shell Layout
|
||||
========================================================================== */
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar navigation */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-base);
|
||||
transition: all var(--transition);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-tertiary);
|
||||
text-decoration: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--accent);
|
||||
background-color: var(--accent-muted);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: var(--font-size-base);
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.conn-status {
|
||||
font-size: var(--font-size-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.conn-status::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.conn-status.connected::before {
|
||||
background-color: var(--success);
|
||||
}
|
||||
|
||||
.conn-status.connecting::before {
|
||||
background-color: var(--warning);
|
||||
}
|
||||
|
||||
.conn-status.disconnected::before {
|
||||
background-color: var(--error);
|
||||
}
|
||||
|
||||
/* Main content area */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Page titles */
|
||||
.page-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Dashboard Cards ────────────────────────────────────────── */
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-value.ok { color: var(--success); }
|
||||
.stat-value.error { color: var(--error); }
|
||||
.stat-value.warning { color: var(--warning); }
|
||||
|
||||
/* ── Channels Grid ──────────────────────────────────────────── */
|
||||
|
||||
.channels-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.channel-card {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.channel-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.channel-dot.connected { background-color: var(--success); }
|
||||
.channel-dot.connecting { background-color: var(--warning); }
|
||||
.channel-dot.disconnected { background-color: var(--text-muted); }
|
||||
.channel-dot.error { background-color: var(--error); }
|
||||
|
||||
.channel-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* ── Section Headers ────────────────────────────────────────── */
|
||||
|
||||
.section-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* ── Data Tables ────────────────────────────────────────────── */
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* ── Chat Page ──────────────────────────────────────────────── */
|
||||
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 48px);
|
||||
max-width: var(--container-max);
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chat-header select {
|
||||
background-color: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-header select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-header button {
|
||||
padding: 6px 12px;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.chat-header button:hover {
|
||||
background-color: var(--accent-muted);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
background-color: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
outline: none;
|
||||
resize: none;
|
||||
min-height: 42px;
|
||||
max-height: 150px;
|
||||
line-height: var(--line-height);
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.chat-input textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chat-input textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
padding: 10px 18px;
|
||||
background-color: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition);
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.chat-input button:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.chat-input button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Streaming text cursor */
|
||||
.streaming-cursor::after {
|
||||
content: '|';
|
||||
animation: blink 1s step-end infinite;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Tool event in chat (collapsible) */
|
||||
.tool-event-group {
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
margin: 4px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-event-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background-color: var(--bg-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tool-event-header:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-event-body {
|
||||
padding: 8px 10px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-secondary);
|
||||
display: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-event-body.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Code blocks in assistant messages */
|
||||
.message.assistant pre {
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.message.assistant code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.message.assistant p code {
|
||||
background-color: var(--bg-tertiary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ── Settings Page ──────────────────────────────────────────── */
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.config-view {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
white-space: pre-wrap;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.hook-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hook-group {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.hook-group label {
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hook-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background-color: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.hook-group textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--error-muted);
|
||||
color: var(--error);
|
||||
border-color: rgba(248, 81, 73, 0.35);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--accent-muted);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Sessions Page ──────────────────────────────────────────── */
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.session-actions button {
|
||||
padding: 4px 10px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.session-detail {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.session-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.message-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* ── Empty States ───────────────────────────────────────────── */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
/* ── Responsive: Mobile ─────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-link span:not(.nav-icon) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.conn-status {
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user