feat(gateway): add web UI with dashboard and chat interface
Refactor GatewayServer to serve HTTP and WebSocket on a shared http.Server. Add static file serving with path traversal protection, a dark-themed dashboard (system health, sessions, tools) and a WebSocket chat interface with streaming tool events and markdown rendering.
This commit is contained in:
@@ -6,6 +6,7 @@ export { SessionBridge } from './session-bridge.js';
|
|||||||
export type { SessionBridgeConfig } from './session-bridge.js';
|
export type { SessionBridgeConfig } from './session-bridge.js';
|
||||||
export { authenticateRequest } from './auth.js';
|
export { authenticateRequest } from './auth.js';
|
||||||
export type { AuthConfig, AuthResult } from './auth.js';
|
export type { AuthConfig, AuthResult } from './auth.js';
|
||||||
|
export { serveStatic } from './static.js';
|
||||||
export {
|
export {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
isValidRequest,
|
isValidRequest,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
|
import { resolve } from 'path';
|
||||||
import { GatewayServer } from './server.js';
|
import { GatewayServer } from './server.js';
|
||||||
import type { GatewayServerConfig } from './server.js';
|
import type { GatewayServerConfig } from './server.js';
|
||||||
import type { GatewayResponse, GatewayError, GatewayEvent } from './protocol.js';
|
import type { GatewayResponse, GatewayError, GatewayEvent } from './protocol.js';
|
||||||
@@ -87,6 +88,7 @@ describe('GatewayServer integration', () => {
|
|||||||
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
|
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
|
||||||
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
|
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
|
||||||
version: '0.1.0-test',
|
version: '0.1.0-test',
|
||||||
|
uiDir: resolve(import.meta.dirname, 'ui'),
|
||||||
});
|
});
|
||||||
await server.start();
|
await server.start();
|
||||||
});
|
});
|
||||||
@@ -185,4 +187,30 @@ describe('GatewayServer integration', () => {
|
|||||||
expect(methods).toContain('tools.list');
|
expect(methods).toContain('tools.list');
|
||||||
expect(methods).toContain('tools.invoke');
|
expect(methods).toContain('tools.invoke');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── HTTP static file serving tests ────────────────────────────
|
||||||
|
|
||||||
|
it('serves index.html on HTTP GET /', async () => {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${TEST_PORT}/`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toBe('text/html');
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain('Flynn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves style.css on HTTP GET /style.css', async () => {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${TEST_PORT}/style.css`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toBe('text/css');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for unknown HTTP path', async () => {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${TEST_PORT}/nonexistent`);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for path traversal attempt', async () => {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${TEST_PORT}/../../../etc/passwd`);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+59
-18
@@ -1,7 +1,8 @@
|
|||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import type { IncomingMessage } from 'http';
|
import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from 'http';
|
||||||
import { Router } from './router.js';
|
import { Router } from './router.js';
|
||||||
|
import { serveStatic } from './static.js';
|
||||||
import { SessionBridge } from './session-bridge.js';
|
import { SessionBridge } from './session-bridge.js';
|
||||||
import type { SessionBridgeConfig } from './session-bridge.js';
|
import type { SessionBridgeConfig } from './session-bridge.js';
|
||||||
import { authenticateRequest } from './auth.js';
|
import { authenticateRequest } from './auth.js';
|
||||||
@@ -32,10 +33,12 @@ export interface GatewayServerConfig {
|
|||||||
toolExecutor: ToolExecutor;
|
toolExecutor: ToolExecutor;
|
||||||
version?: string;
|
version?: string;
|
||||||
auth?: AuthConfig;
|
auth?: AuthConfig;
|
||||||
|
uiDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GatewayServer {
|
export class GatewayServer {
|
||||||
private wss: WebSocketServer | null = null;
|
private wss: WebSocketServer | null = null;
|
||||||
|
private httpServer: HttpServer | null = null;
|
||||||
private router: Router;
|
private router: Router;
|
||||||
private sessionBridge: SessionBridge;
|
private sessionBridge: SessionBridge;
|
||||||
private connectionMap: Map<WebSocket, string> = new Map();
|
private connectionMap: Map<WebSocket, string> = new Map();
|
||||||
@@ -95,14 +98,20 @@ export class GatewayServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
|
const host = this.config.host ?? '127.0.0.1';
|
||||||
|
const { port } = this.config;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.wss = new WebSocketServer({
|
// Create HTTP server first — handles static file requests
|
||||||
port: this.config.port,
|
this.httpServer = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||||
host: this.config.host ?? '127.0.0.1',
|
this.handleHttpRequest(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Attach WebSocket server to the shared HTTP server (no separate port)
|
||||||
|
this.wss = new WebSocketServer({ server: this.httpServer });
|
||||||
|
|
||||||
this.wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
|
this.wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
|
||||||
// Auth check on upgrade
|
// Auth check on upgrade — only WS connections require auth
|
||||||
const authResult = authenticateRequest(req, this.config.auth ?? {});
|
const authResult = authenticateRequest(req, this.config.auth ?? {});
|
||||||
if (!authResult.authenticated) {
|
if (!authResult.authenticated) {
|
||||||
ws.close(4001, authResult.error ?? 'Authentication failed');
|
ws.close(4001, authResult.error ?? 'Authentication failed');
|
||||||
@@ -111,34 +120,43 @@ export class GatewayServer {
|
|||||||
this.handleConnection(ws, authResult.identity);
|
this.handleConnection(ws, authResult.identity);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.wss.on('listening', () => {
|
this.httpServer.listen(port, host, () => {
|
||||||
const addr = this.wss?.address();
|
console.log(`Gateway server listening on ${host}:${port}`);
|
||||||
const portStr = typeof addr === 'object' && addr ? `:${addr.port}` : '';
|
|
||||||
console.log(`Gateway WebSocket server listening on ${this.config.host ?? '127.0.0.1'}${portStr}`);
|
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
// Close all WebSocket connections first
|
||||||
|
for (const [ws, connectionId] of this.connectionMap) {
|
||||||
|
this.sessionBridge.disconnect(connectionId);
|
||||||
|
ws.close(1001, 'Server shutting down');
|
||||||
|
}
|
||||||
|
this.connectionMap.clear();
|
||||||
|
|
||||||
|
// Close WSS first, then the underlying HTTP server
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
if (!this.wss) {
|
if (!this.wss) {
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close all connections
|
|
||||||
for (const [ws, connectionId] of this.connectionMap) {
|
|
||||||
this.sessionBridge.disconnect(connectionId);
|
|
||||||
ws.close(1001, 'Server shutting down');
|
|
||||||
}
|
|
||||||
this.connectionMap.clear();
|
|
||||||
|
|
||||||
this.wss.close(() => {
|
this.wss.close(() => {
|
||||||
this.wss = null;
|
this.wss = null;
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (!this.httpServer) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.httpServer.close(() => {
|
||||||
|
this.httpServer = null;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleConnection(ws: WebSocket, identity?: string): void {
|
private handleConnection(ws: WebSocket, identity?: string): void {
|
||||||
@@ -161,6 +179,24 @@ export class GatewayServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming HTTP requests.
|
||||||
|
* Delegates to serveStatic for UI files; returns 404 if no UI dir or file not found.
|
||||||
|
* Auth is NOT applied to HTTP requests — only to WS upgrade.
|
||||||
|
*/
|
||||||
|
private async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||||
|
const uiDir = this.config.uiDir;
|
||||||
|
|
||||||
|
if (uiDir) {
|
||||||
|
const served = await serveStatic(req, res, uiDir);
|
||||||
|
if (served) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No UI directory configured, or file not found
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Not Found');
|
||||||
|
}
|
||||||
|
|
||||||
private async handleMessage(ws: WebSocket, connectionId: string, raw: string): Promise<void> {
|
private async handleMessage(ws: WebSocket, connectionId: string, raw: string): Promise<void> {
|
||||||
const request = parseMessage(raw);
|
const request = parseMessage(raw);
|
||||||
|
|
||||||
@@ -192,6 +228,11 @@ export class GatewayServer {
|
|||||||
return this.wss;
|
return this.wss;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the underlying HTTP server (for testing). */
|
||||||
|
getHttpServer(): HttpServer | null {
|
||||||
|
return this.httpServer;
|
||||||
|
}
|
||||||
|
|
||||||
/** Get the session bridge (for testing/debugging). */
|
/** Get the session bridge (for testing/debugging). */
|
||||||
getSessionBridge(): SessionBridge {
|
getSessionBridge(): SessionBridge {
|
||||||
return this.sessionBridge;
|
return this.sessionBridge;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { resolve, extname } from 'path';
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'http';
|
||||||
|
|
||||||
|
/** Supported content types by file extension. */
|
||||||
|
const CONTENT_TYPES: Record<string, string> = {
|
||||||
|
'.html': 'text/html',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.js': 'application/javascript',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve static files from a directory.
|
||||||
|
*
|
||||||
|
* - Only handles GET requests
|
||||||
|
* - Maps `/` to `/index.html`
|
||||||
|
* - Protects against path traversal (resolved path must be within uiDir)
|
||||||
|
* - Returns true if a file was served, false otherwise (caller should 404)
|
||||||
|
*/
|
||||||
|
export async function serveStatic(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
uiDir: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Only serve GET requests
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the pathname from the request URL
|
||||||
|
const urlStr = req.url ?? '/';
|
||||||
|
let pathname: string;
|
||||||
|
try {
|
||||||
|
// Use URL constructor to safely parse; base doesn't matter for pathname extraction
|
||||||
|
const parsed = new URL(urlStr, 'http://localhost');
|
||||||
|
pathname = parsed.pathname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map root to index.html
|
||||||
|
if (pathname === '/') {
|
||||||
|
pathname = '/index.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the file extension is one we serve
|
||||||
|
const ext = extname(pathname);
|
||||||
|
const contentType = CONTENT_TYPES[ext];
|
||||||
|
if (!contentType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the absolute path to the requested file
|
||||||
|
// Strip leading slash to get a relative path for resolve()
|
||||||
|
const relativePath = pathname.slice(1);
|
||||||
|
const resolvedUiDir = resolve(uiDir);
|
||||||
|
const filePath = resolve(resolvedUiDir, relativePath);
|
||||||
|
|
||||||
|
// Path traversal protection: ensure the resolved path is within uiDir
|
||||||
|
if (!filePath.startsWith(resolvedUiDir + '/') && filePath !== resolvedUiDir) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read and serve the file
|
||||||
|
try {
|
||||||
|
const content = await readFile(filePath);
|
||||||
|
res.writeHead(200, { 'Content-Type': contentType });
|
||||||
|
res.end(content);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist or can't be read — let the caller handle it
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Flynn Chat</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="chat-container">
|
||||||
|
<header>
|
||||||
|
<h1>Flynn</h1>
|
||||||
|
<span id="status">Connecting...</span>
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
</header>
|
||||||
|
<div class="messages" id="messages"></div>
|
||||||
|
<div class="input-area">
|
||||||
|
<input type="text" id="input" placeholder="Type a message..." autofocus>
|
||||||
|
<button id="send">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── State ──────────────────────────────────────────────────────
|
||||||
|
let requestId = 0;
|
||||||
|
let ws = null;
|
||||||
|
let reconnectDelay = 1000; // Start at 1s, increase with backoff
|
||||||
|
const MAX_RECONNECT_DELAY = 30000;
|
||||||
|
const pendingTools = new Map(); // requestId -> Map(toolName -> element)
|
||||||
|
let inputDisabled = false;
|
||||||
|
|
||||||
|
// ── DOM references ─────────────────────────────────────────────
|
||||||
|
const messagesEl = document.getElementById('messages');
|
||||||
|
const inputEl = document.getElementById('input');
|
||||||
|
const sendBtn = document.getElementById('send');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
// ── WebSocket connection ───────────────────────────────────────
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
setStatus('connecting');
|
||||||
|
ws = new WebSocket(`ws://${location.host}`);
|
||||||
|
|
||||||
|
ws.addEventListener('open', () => {
|
||||||
|
setStatus('connected');
|
||||||
|
reconnectDelay = 1000; // Reset backoff on successful connection
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('message', (event) => {
|
||||||
|
handleMessage(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
setStatus('disconnected');
|
||||||
|
ws = null;
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
// Error event is always followed by close, so reconnect happens there
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
setTimeout(() => {
|
||||||
|
connect();
|
||||||
|
}, reconnectDelay);
|
||||||
|
// Exponential backoff with cap
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(state) {
|
||||||
|
statusEl.textContent =
|
||||||
|
state === 'connecting' ? 'Connecting...' :
|
||||||
|
state === 'connected' ? 'Connected' :
|
||||||
|
'Disconnected';
|
||||||
|
statusEl.className = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sending messages ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function sendMessage() {
|
||||||
|
const text = inputEl.value.trim();
|
||||||
|
if (!text || !ws || ws.readyState !== WebSocket.OPEN || inputDisabled) return;
|
||||||
|
|
||||||
|
requestId++;
|
||||||
|
const id = requestId;
|
||||||
|
|
||||||
|
// Display user message
|
||||||
|
appendUserMessage(text);
|
||||||
|
|
||||||
|
// Create an assistant message area for tool events + final response
|
||||||
|
createAssistantArea(id);
|
||||||
|
|
||||||
|
// Send JSON-RPC request
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
id: id,
|
||||||
|
method: 'agent.send',
|
||||||
|
params: { message: text },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear input and disable while waiting
|
||||||
|
inputEl.value = '';
|
||||||
|
setInputDisabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInputDisabled(disabled) {
|
||||||
|
inputDisabled = disabled;
|
||||||
|
inputEl.disabled = disabled;
|
||||||
|
sendBtn.disabled = disabled;
|
||||||
|
if (!disabled) {
|
||||||
|
inputEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handling incoming messages ─────────────────────────────────
|
||||||
|
|
||||||
|
function handleMessage(raw) {
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to parse message:', raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct responses (e.g., from system.health) — ignore for chat
|
||||||
|
if (msg.result !== undefined && !msg.event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error responses without an event field (protocol-level errors)
|
||||||
|
if (msg.error && !msg.event) {
|
||||||
|
appendErrorMessage(msg.error.message || 'Unknown error');
|
||||||
|
setInputDisabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streamed events from agent.send
|
||||||
|
if (msg.event) {
|
||||||
|
const id = msg.id;
|
||||||
|
const data = msg.data || {};
|
||||||
|
|
||||||
|
switch (msg.event) {
|
||||||
|
case 'tool_start':
|
||||||
|
handleToolStart(id, data);
|
||||||
|
break;
|
||||||
|
case 'tool_end':
|
||||||
|
handleToolEnd(id, data);
|
||||||
|
break;
|
||||||
|
case 'done':
|
||||||
|
handleDone(id, data);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
handleError(id, data);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn('Unknown event type:', msg.event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event handlers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleToolStart(id, data) {
|
||||||
|
const area = getAssistantArea(id);
|
||||||
|
if (!area) return;
|
||||||
|
|
||||||
|
const toolDiv = document.createElement('div');
|
||||||
|
toolDiv.className = 'tool-event';
|
||||||
|
toolDiv.dataset.tool = data.tool;
|
||||||
|
|
||||||
|
const spinner = document.createElement('span');
|
||||||
|
spinner.className = 'spinner';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = `Running ${data.tool}...`;
|
||||||
|
|
||||||
|
toolDiv.appendChild(spinner);
|
||||||
|
toolDiv.appendChild(label);
|
||||||
|
area.appendChild(toolDiv);
|
||||||
|
|
||||||
|
// Track this tool element so we can update it on tool_end
|
||||||
|
if (!pendingTools.has(id)) {
|
||||||
|
pendingTools.set(id, new Map());
|
||||||
|
}
|
||||||
|
pendingTools.get(id).set(data.tool, toolDiv);
|
||||||
|
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToolEnd(id, data) {
|
||||||
|
const toolMap = pendingTools.get(id);
|
||||||
|
if (!toolMap) return;
|
||||||
|
|
||||||
|
const toolDiv = toolMap.get(data.tool);
|
||||||
|
if (!toolDiv) return;
|
||||||
|
|
||||||
|
// Remove spinner, add checkmark
|
||||||
|
const spinner = toolDiv.querySelector('.spinner');
|
||||||
|
if (spinner) spinner.remove();
|
||||||
|
|
||||||
|
const label = toolDiv.querySelector('span');
|
||||||
|
if (label) {
|
||||||
|
label.textContent = `${data.tool} \u2714`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toolMap.delete(data.tool);
|
||||||
|
if (toolMap.size === 0) {
|
||||||
|
pendingTools.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDone(id, data) {
|
||||||
|
const area = getAssistantArea(id);
|
||||||
|
if (!area) {
|
||||||
|
// No area exists (edge case) — create a standalone message
|
||||||
|
appendAssistantMessage(data.content || '');
|
||||||
|
} else {
|
||||||
|
// Render final response as markdown inside the assistant area
|
||||||
|
const responseDiv = document.createElement('div');
|
||||||
|
responseDiv.className = 'message assistant';
|
||||||
|
responseDiv.innerHTML = marked.parse(data.content || '');
|
||||||
|
area.appendChild(responseDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInputDisabled(false);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(id, data) {
|
||||||
|
const area = getAssistantArea(id);
|
||||||
|
const errorDiv = document.createElement('div');
|
||||||
|
errorDiv.className = 'message error';
|
||||||
|
errorDiv.textContent = data.message || 'Unknown error';
|
||||||
|
|
||||||
|
if (area) {
|
||||||
|
area.appendChild(errorDiv);
|
||||||
|
} else {
|
||||||
|
messagesEl.appendChild(errorDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up any pending tools for this request
|
||||||
|
pendingTools.delete(id);
|
||||||
|
|
||||||
|
setInputDisabled(false);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DOM helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a user message bubble to the messages area.
|
||||||
|
*/
|
||||||
|
function appendUserMessage(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'message user';
|
||||||
|
div.textContent = text;
|
||||||
|
messagesEl.appendChild(div);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a standalone assistant message (used as fallback).
|
||||||
|
*/
|
||||||
|
function appendAssistantMessage(content) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'message assistant';
|
||||||
|
div.innerHTML = marked.parse(content);
|
||||||
|
messagesEl.appendChild(div);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append an error message to the messages area.
|
||||||
|
*/
|
||||||
|
function appendErrorMessage(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'message error';
|
||||||
|
div.textContent = text;
|
||||||
|
messagesEl.appendChild(div);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a container div for an assistant response (tool events + final message).
|
||||||
|
* Tagged with a data-request-id so we can find it later.
|
||||||
|
*/
|
||||||
|
function createAssistantArea(id) {
|
||||||
|
const area = document.createElement('div');
|
||||||
|
area.className = 'assistant-area';
|
||||||
|
area.dataset.requestId = id;
|
||||||
|
messagesEl.appendChild(area);
|
||||||
|
return area;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the assistant area for a given request ID.
|
||||||
|
*/
|
||||||
|
function getAssistantArea(id) {
|
||||||
|
return messagesEl.querySelector(`.assistant-area[data-request-id="${id}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll the messages container to the bottom.
|
||||||
|
*/
|
||||||
|
function scrollToBottom() {
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event listeners ────────────────────────────────────────────
|
||||||
|
|
||||||
|
sendBtn.addEventListener('click', sendMessage);
|
||||||
|
|
||||||
|
inputEl.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Initialize ─────────────────────────────────────────────────
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Flynn Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</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>
|
||||||
|
<script>
|
||||||
|
// --- Request ID counter and pending callback map ---
|
||||||
|
let requestId = 0;
|
||||||
|
const pending = new Map();
|
||||||
|
|
||||||
|
// --- WebSocket connection ---
|
||||||
|
const ws = new WebSocket(`ws://${location.host}`);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
Flynn Gateway — Shared Dark Theme
|
||||||
|
Terminal-aesthetic dark theme used by chat.html and index.html (dashboard).
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* ---------- CSS Custom Properties (Design Tokens) ---------- */
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0d1117;
|
||||||
|
--bg-secondary: #161b22;
|
||||||
|
--bg-tertiary: #1c2128;
|
||||||
|
--bg-input: #0d1117;
|
||||||
|
|
||||||
|
--text-primary: #c9d1d9;
|
||||||
|
--text-secondary: #8b949e;
|
||||||
|
--text-muted: #6e7681;
|
||||||
|
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--accent-muted: rgba(88, 166, 255, 0.15);
|
||||||
|
|
||||||
|
--error: #f85149;
|
||||||
|
--error-muted: rgba(248, 81, 73, 0.15);
|
||||||
|
|
||||||
|
--success: #3fb950;
|
||||||
|
--success-muted: rgba(63, 185, 80, 0.15);
|
||||||
|
|
||||||
|
--warning: #d29922;
|
||||||
|
|
||||||
|
--border: #30363d;
|
||||||
|
--border-light: #21262d;
|
||||||
|
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
--font-size-base: 14px;
|
||||||
|
--font-size-sm: 12px;
|
||||||
|
--font-size-lg: 18px;
|
||||||
|
--font-size-xl: 24px;
|
||||||
|
|
||||||
|
--line-height: 1.55;
|
||||||
|
--radius: 6px;
|
||||||
|
--radius-lg: 10px;
|
||||||
|
--container-max: 900px;
|
||||||
|
|
||||||
|
--transition: 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Reset ---------- */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Base ---------- */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden; /* Pages handle their own scrolling */
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: var(--line-height);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Links ---------- */
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Container ---------- */
|
||||||
|
.container {
|
||||||
|
max-width: var(--container-max);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Header ---------- */
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
header #status {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
header #status.connected {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
header #status.disconnected,
|
||||||
|
header #status.status-error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
header #status.status-ok {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Scrollbar (Webkit) ---------- */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Chat-Specific Styles
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* Chat layout — full viewport flex column */
|
||||||
|
.chat-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
max-width: var(--container-max);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollable message area */
|
||||||
|
.messages {
|
||||||
|
flex: 1 1 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual message bubble */
|
||||||
|
.message {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
max-width: 85%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: var(--line-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User messages — right-aligned with accent tint */
|
||||||
|
.message.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background-color: var(--accent-muted);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid rgba(88, 166, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Assistant messages — left-aligned with subtle bg */
|
||||||
|
.message.assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error messages */
|
||||||
|
.message.error {
|
||||||
|
align-self: flex-start;
|
||||||
|
background-color: var(--error-muted);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid rgba(248, 81, 73, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input area — fixed at the bottom of chat */
|
||||||
|
.input-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area input {
|
||||||
|
flex: 1 1 0;
|
||||||
|
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;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area button:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Dashboard-Specific Styles
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* Dashboard layout — grid of cards */
|
||||||
|
.dashboard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section headings in dashboard page */
|
||||||
|
.container > section {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container > section > h2 {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard header — spans full width */
|
||||||
|
.dashboard-header {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header h1 {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card component */
|
||||||
|
.card {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 16px;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .value {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicators */
|
||||||
|
.status-ok {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.ok {
|
||||||
|
background-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.error {
|
||||||
|
background-color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.warning {
|
||||||
|
background-color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Session list */
|
||||||
|
.session-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list li {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list li a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool list */
|
||||||
|
.tool-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-list li {
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Utility Classes
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* Tool event display — monospace block for tool_start / tool_end */
|
||||||
|
.tool-event {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin: 4px 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS spinner animation */
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden utility */
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text utilities */
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-accent {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-sm {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-lg {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.ok {
|
||||||
|
background-color: var(--success-muted);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.error {
|
||||||
|
background-color: var(--error-muted);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Responsive — Small Screens (<600px)
|
||||||
|
========================================================================== */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
padding: 10px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
max-width: 95%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area input {
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area button {
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 16px 8px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .value {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user