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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user