feat: remove chat section from dashboard

- Remove Chat from sidebar navigation
- Remove /chat route from App.tsx
- Delete ChatPage component, gateway.ts client lib
- Delete backend chat routes and gateway-relay WebSocket code
- No other features depended on removed code
This commit is contained in:
2026-01-30 04:40:51 +00:00
parent 504215439e
commit b5066a0d33
8 changed files with 200 additions and 1521 deletions

View File

@@ -3,9 +3,10 @@ import { cors } from "@elysiajs/cors";
import { taskRoutes } from "./routes/tasks";
import { adminRoutes } from "./routes/admin";
import { projectRoutes } from "./routes/projects";
import { chatRoutes } from "./routes/chat";
import { commentRoutes } from "./routes/comments";
import { activityRoutes } from "./routes/activity";
import { summaryRoutes } from "./routes/summaries";
import { securityRoutes } from "./routes/security";
import { auth } from "./lib/auth";
import { db } from "./db";
import { tasks, users } from "./db/schema";
@@ -121,7 +122,8 @@ const app = new Elysia()
.use(activityRoutes)
.use(projectRoutes)
.use(adminRoutes)
.use(chatRoutes)
.use(securityRoutes)
.use(summaryRoutes)
// Current user info (role, etc.)
.get("/api/me", async ({ request }) => {

View File

@@ -1,283 +0,0 @@
/**
* Gateway WebSocket Relay
*
* Maintains a single persistent WebSocket connection to the Clawdbot gateway.
* Dashboard clients connect through the backend (authenticated via BetterAuth),
* and messages are relayed bidirectionally.
*
* Architecture:
* Browser ←WSS→ Dashboard Backend ←WSS→ Clawdbot Gateway
* (BetterAuth) (relay) (token auth)
*/
const GATEWAY_URL = process.env.GATEWAY_WS_URL || "wss://ws.hammer.donovankelly.xyz";
const GATEWAY_TOKEN = process.env.GATEWAY_WS_TOKEN || "";
type GatewayState = "disconnected" | "connecting" | "connected";
type MessageHandler = (msg: any) => void;
let reqCounter = 0;
function nextReqId() {
return `relay-${++reqCounter}`;
}
class GatewayConnection {
private ws: WebSocket | null = null;
private state: GatewayState = "disconnected";
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void; timer: ReturnType<typeof setTimeout> }>();
private eventListeners = new Set<MessageHandler>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private shouldReconnect = true;
private connectSent = false;
private tickTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
this.connect();
}
private connect() {
if (this.state === "connecting") return;
this.state = "connecting";
this.connectSent = false;
if (!GATEWAY_TOKEN) {
console.warn("[gateway-relay] No GATEWAY_WS_TOKEN set, chat relay disabled");
this.state = "disconnected";
return;
}
console.log(`[gateway-relay] Connecting to ${GATEWAY_URL}...`);
try {
this.ws = new WebSocket(GATEWAY_URL);
} catch (e) {
console.error("[gateway-relay] Failed to create WebSocket:", e);
this.state = "disconnected";
this.scheduleReconnect();
return;
}
this.ws.addEventListener("open", () => {
console.log("[gateway-relay] WebSocket open, sending handshake...");
this.sendConnect();
});
this.ws.addEventListener("message", (event) => {
try {
const msg = JSON.parse(String(event.data));
// Handle connect.challenge — gateway may send this before we connect
if (msg.type === "event" && msg.event === "connect.challenge") {
console.log("[gateway-relay] Received connect challenge");
// Token auth doesn't need signing; send connect if not yet sent
if (!this.connectSent) {
this.sendConnect();
}
return;
}
this.handleMessage(msg);
} catch (e) {
console.error("[gateway-relay] Failed to parse message:", e);
}
});
this.ws.addEventListener("close", () => {
console.log("[gateway-relay] Disconnected");
this.state = "disconnected";
this.ws = null;
this.connectSent = false;
if (this.tickTimer) {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
// Reject all pending requests
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error("Connection closed"));
this.pendingRequests.delete(id);
}
if (this.shouldReconnect) {
this.scheduleReconnect();
}
});
this.ws.addEventListener("error", () => {
console.error("[gateway-relay] WebSocket error");
});
}
private sendConnect() {
if (this.connectSent) return;
this.connectSent = true;
const connectId = nextReqId();
this.sendRaw({
type: "req",
id: connectId,
method: "connect",
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: "dashboard-relay",
displayName: "Hammer Dashboard",
version: "1.0.0",
platform: "server",
mode: "webchat",
instanceId: `relay-${process.pid}-${Date.now()}`,
},
role: "operator",
scopes: ["operator.read", "operator.write"],
caps: [],
commands: [],
permissions: {},
auth: {
token: GATEWAY_TOKEN,
},
},
});
// Wait for handshake response
this.pendingRequests.set(connectId, {
resolve: (payload) => {
console.log("[gateway-relay] Connected to gateway, protocol:", payload?.protocol);
this.state = "connected";
// Start tick keepalive (gateway expects periodic ticks)
const tickInterval = payload?.policy?.tickIntervalMs || 15000;
this.tickTimer = setInterval(() => {
this.sendRaw({ type: "tick" });
}, tickInterval);
},
reject: (err) => {
console.error("[gateway-relay] Handshake failed:", err);
this.state = "disconnected";
this.ws?.close();
},
timer: setTimeout(() => {
if (this.pendingRequests.has(connectId)) {
this.pendingRequests.delete(connectId);
console.error("[gateway-relay] Handshake timeout");
this.state = "disconnected";
this.ws?.close();
}
}, 15000),
});
}
private scheduleReconnect() {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (this.shouldReconnect && this.state === "disconnected") {
this.connect();
}
}, 5000);
}
private sendRaw(msg: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
private handleMessage(msg: any) {
if (msg.type === "res") {
const pending = this.pendingRequests.get(msg.id);
if (pending) {
clearTimeout(pending.timer);
this.pendingRequests.delete(msg.id);
if (msg.ok !== false) {
pending.resolve(msg.payload ?? msg.result ?? {});
} else {
pending.reject(new Error(msg.error?.message || msg.error || "Request failed"));
}
}
} else if (msg.type === "event") {
// Forward events to all listeners
for (const listener of this.eventListeners) {
try {
listener(msg);
} catch (e) {
console.error("[gateway-relay] Event listener error:", e);
}
}
}
// Ignore tick responses and other frame types
}
isConnected(): boolean {
return this.state === "connected";
}
async request(method: string, params?: any): Promise<any> {
if (!this.isConnected()) {
throw new Error("Gateway not connected");
}
return new Promise((resolve, reject) => {
const id = nextReqId();
const timer = setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error("Request timeout"));
}
}, 120000);
this.pendingRequests.set(id, { resolve, reject, timer });
this.sendRaw({ type: "req", id, method, params });
});
}
onEvent(handler: MessageHandler): () => void {
this.eventListeners.add(handler);
return () => this.eventListeners.delete(handler);
}
destroy() {
this.shouldReconnect = false;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.tickTimer) clearInterval(this.tickTimer);
if (this.ws) {
try { this.ws.close(); } catch {}
}
this.ws = null;
this.state = "disconnected";
}
}
// Singleton gateway connection
export const gateway = new GatewayConnection();
/**
* Send a chat message to the gateway
*/
export async function chatSend(sessionKey: string, message: string): Promise<any> {
return gateway.request("chat.send", {
sessionKey,
message,
idempotencyKey: `dash-${Date.now()}-${Math.random().toString(36).slice(2)}`,
});
}
/**
* Get chat history from the gateway
*/
export async function chatHistory(sessionKey: string, limit = 50): Promise<any> {
return gateway.request("chat.history", { sessionKey, limit });
}
/**
* Abort an in-progress chat response
*/
export async function chatAbort(sessionKey: string): Promise<any> {
return gateway.request("chat.abort", { sessionKey });
}
/**
* List sessions from the gateway
*/
export async function sessionsList(limit = 50): Promise<any> {
return gateway.request("sessions.list", { limit });
}

View File

@@ -1,269 +0,0 @@
/**
* Chat routes - WebSocket relay + REST fallback for dashboard chat
*
* WebSocket: /api/chat/ws - Real-time bidirectional relay to gateway
* REST: /api/chat/send, /api/chat/history, /api/chat/sessions - Fallback endpoints
*/
import { Elysia } from "elysia";
import { auth } from "../lib/auth";
import { gateway, chatSend, chatHistory, chatAbort, sessionsList } from "../lib/gateway-relay";
// Track active WebSocket client connections
const activeClients = new Map<string, {
ws: any;
userId: string;
sessionKeys: Set<string>;
}>();
export const chatRoutes = new Elysia()
// WebSocket endpoint for real-time chat relay
.ws("/api/chat/ws", {
open(ws) {
const clientId = `client-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
(ws.data as any).__clientId = clientId;
(ws.data as any).__authenticated = false;
console.log(`[chat-ws] Client connected: ${clientId}`);
},
async message(ws, rawMsg) {
const clientId = (ws.data as any).__clientId || "unknown";
let msg: any;
try {
msg = typeof rawMsg === "string" ? JSON.parse(rawMsg) : rawMsg;
} catch {
ws.send(JSON.stringify({ type: "error", error: "Invalid JSON" }));
return;
}
// First message must be auth
if (!(ws.data as any).__authenticated) {
if (msg.type !== "auth") {
ws.send(JSON.stringify({ type: "error", error: "Must authenticate first" }));
ws.close();
return;
}
// Validate session cookie or token
const session = await validateAuth(msg);
if (!session) {
ws.send(JSON.stringify({ type: "error", error: "Authentication failed" }));
ws.close();
return;
}
(ws.data as any).__authenticated = true;
(ws.data as any).__userId = session.user.id;
(ws.data as any).__userName = session.user.name || session.user.email;
// Register client
activeClients.set(clientId, {
ws,
userId: session.user.id,
sessionKeys: new Set(),
});
ws.send(JSON.stringify({
type: "auth_ok",
user: { id: session.user.id, name: session.user.name },
gatewayConnected: gateway.isConnected(),
}));
console.log(`[chat-ws] Client authenticated: ${clientId} (${session.user.name || session.user.email})`);
return;
}
// Handle authenticated messages
try {
await handleClientMessage(clientId, ws, msg);
} catch (e: any) {
ws.send(JSON.stringify({
type: "error",
id: msg.id,
error: e.message || "Internal error",
}));
}
},
close(ws) {
const clientId = (ws.data as any).__clientId || "unknown";
activeClients.delete(clientId);
console.log(`[chat-ws] Client disconnected: ${clientId}`);
},
})
// REST: Send a chat message
.post("/api/chat/send", async ({ request, body }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
}
const { sessionKey, message } = body as { sessionKey: string; message: string };
if (!sessionKey || !message) {
return new Response(JSON.stringify({ error: "sessionKey and message required" }), { status: 400 });
}
if (!gateway.isConnected()) {
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
}
try {
const result = await chatSend(sessionKey, message);
return { ok: true, result };
} catch (e: any) {
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
}
})
// REST: Get chat history
.get("/api/chat/history/:sessionKey", async ({ request, params }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
}
if (!gateway.isConnected()) {
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
}
try {
const result = await chatHistory(params.sessionKey);
return { ok: true, ...result };
} catch (e: any) {
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
}
})
// REST: List sessions
.get("/api/chat/sessions", async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
}
if (!gateway.isConnected()) {
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
}
try {
const result = await sessionsList();
return { ok: true, ...result };
} catch (e: any) {
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
}
})
// REST: Gateway connection status
.get("/api/chat/status", async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
}
return {
gatewayConnected: gateway.isConnected(),
activeClients: activeClients.size,
};
});
// Validate auth from WebSocket auth message
async function validateAuth(msg: any): Promise<any> {
// Support cookie-based auth (pass cookie string)
if (msg.cookie) {
try {
// Create a fake request with the cookie header for BetterAuth
const headers = new Headers();
headers.set("cookie", msg.cookie);
const session = await auth.api.getSession({ headers });
return session;
} catch {
return null;
}
}
// Support bearer token auth
if (msg.token) {
try {
const headers = new Headers();
headers.set("authorization", `Bearer ${msg.token}`);
const session = await auth.api.getSession({ headers });
return session;
} catch {
return null;
}
}
return null;
}
// Handle messages from authenticated WebSocket clients
async function handleClientMessage(clientId: string, ws: any, msg: any) {
const client = activeClients.get(clientId);
if (!client) return;
switch (msg.type) {
case "chat.send": {
const { sessionKey, message } = msg;
if (!sessionKey || !message) {
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey and message required" }));
return;
}
client.sessionKeys.add(sessionKey);
const result = await chatSend(sessionKey, message);
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
break;
}
case "chat.history": {
const { sessionKey, limit } = msg;
if (!sessionKey) {
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey required" }));
return;
}
client.sessionKeys.add(sessionKey);
const result = await chatHistory(sessionKey, limit);
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
break;
}
case "chat.abort": {
const { sessionKey } = msg;
if (!sessionKey) {
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey required" }));
return;
}
const result = await chatAbort(sessionKey);
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
break;
}
case "sessions.list": {
const result = await sessionsList(msg.limit);
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
break;
}
default:
ws.send(JSON.stringify({ type: "error", id: msg.id, error: `Unknown message type: ${msg.type}` }));
}
}
// Forward gateway events to relevant WebSocket clients
gateway.onEvent((msg: any) => {
if (msg.type !== "event") return;
const payload = msg.payload || {};
const sessionKey = payload.sessionKey;
for (const [, client] of activeClients) {
// Forward to clients subscribed to this session key, or broadcast if no key
if (!sessionKey || client.sessionKeys.has(sessionKey)) {
try {
client.ws.send(JSON.stringify(msg));
} catch {
// Client disconnected, will be cleaned up
}
}
}
});