import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { createServer as createHttpServer, type IncomingMessage, type Server as HttpServer } from "node:http"; import express from "express"; import { createServer as createViteServer, type ViteDevServer } from "vite"; import { WebSocketServer, type RawData, type WebSocket } from "ws"; import type { DashboardServer, DashboardServerOptions } from "./types.js"; import { buildOverviewResponse } from "./api/overview.js"; import { readChannelsResponse, saveChannelsRequest } from "./api/channels.js"; import { ValidationError, readModelsResponse, saveModelsRequest } from "./api/models.js"; import type { ChannelName } from "../core.js"; import { createBotMessage, getSessionKey } from "../core.js"; const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_PORT = <%= port %>; function readPort(explicitPort: number | undefined): number { if (explicitPort !== undefined) { return explicitPort; } const rawPort = process.env.PI_BOT_DASHBOARD_PORT; if (!rawPort) return DEFAULT_PORT; const parsed = Number.parseInt(rawPort, 10); if (!Number.isInteger(parsed) || parsed <= 0) { throw new Error(`Invalid PI_BOT_DASHBOARD_PORT value: ${rawPort}`); } return parsed; } function readHost(explicitHost: string | undefined): string { return explicitHost ?? process.env.PI_BOT_DASHBOARD_HOST ?? DEFAULT_HOST; } const DASHBOARD_DIR = dirname(fileURLToPath(import.meta.url)); const WEB_ROOT = resolve(DASHBOARD_DIR, "web"); const WEB_DIST = resolve(WEB_ROOT, "dist"); type ChatSessionIdentity = { channel?: ChannelName; isDirectMessage?: boolean; senderId?: string; conversationId?: string; threadId?: string; }; type WsClientFrame = | { type: "chat.send"; runId: string; session?: ChatSessionIdentity; sessionKey?: string; text?: string } | { type: "chat.abort"; runId?: string; session?: ChatSessionIdentity; sessionKey?: string } | { type: "ping" }; type WsServerFrame = | { type: "ack"; runId: string; status: "started" | "in_flight" } | { type: "meta"; runId: string; sessionKey: string } | { type: "delta"; runId: string; sessionKey: string; delta: string } | { type: "done"; runId: string; sessionKey: string; text: string } | { type: "error"; runId: string; sessionKey?: string; error: string }; function writeSse(res: express.Response, event: string, data: unknown) { res.write(`event: ${event}\n`); res.write(`data: ${JSON.stringify(data)}\n\n`); } function parseBoolQuery(value: unknown, defaultValue: boolean): boolean { if (value === undefined) return defaultValue; if (typeof value === "string") { const v = value.trim().toLowerCase(); if (v === "true" || v === "1" || v === "yes") return true; if (v === "false" || v === "0" || v === "no") return false; } return Boolean(value); } function parseSessionKey(sessionKey: string): ChatSessionIdentity { const raw = sessionKey.trim(); const parts = raw.split(":"); const channel = parts[0] as ChannelName | undefined; const kind = parts[1]; if (!channel || (channel !== "dashboard" && channel !== "feishu" && channel !== "wechat")) { throw new Error(`Invalid sessionKey (channel): ${sessionKey}`); } if (kind === "dm") { const senderId = parts.slice(2).join(":"); if (!senderId) throw new Error(`Invalid sessionKey (dm senderId): ${sessionKey}`); return { channel, isDirectMessage: true, senderId, conversationId: "dashboard" }; } if (kind === "group") { const conversationId = parts.slice(2).join(":"); if (!conversationId) throw new Error(`Invalid sessionKey (group conversationId): ${sessionKey}`); return { channel, isDirectMessage: false, senderId: "unknown", conversationId }; } if (kind === "thread") { // format: channel:thread:: if (parts.length < 4) throw new Error(`Invalid sessionKey (thread): ${sessionKey}`); const conversationId = parts[2] ?? ""; const threadId = parts.slice(3).join(":"); if (!conversationId || !threadId) throw new Error(`Invalid sessionKey (thread ids): ${sessionKey}`); return { channel, isDirectMessage: false, senderId: "unknown", conversationId, threadId }; } throw new Error(`Invalid sessionKey (kind): ${sessionKey}`); } function resolveSessionIdentity(req: express.Request): { identity: ChatSessionIdentity; sessionKey: string } { const sessionKeyRaw = typeof req.query.sessionKey === "string" ? req.query.sessionKey : undefined; if (sessionKeyRaw && sessionKeyRaw.trim()) { const identity = parseSessionKey(sessionKeyRaw); // Prefer the provided sessionKey verbatim for lookups; it matches getSessionKey() format. return { identity, sessionKey: sessionKeyRaw.trim() }; } const channel = (typeof req.query.channel === "string" ? req.query.channel : "dashboard") as ChannelName; const isDirectMessage = parseBoolQuery(req.query.isDirectMessage, true); const senderId = typeof req.query.senderId === "string" ? req.query.senderId : "dashboard-user"; const conversationId = typeof req.query.conversationId === "string" ? req.query.conversationId : "dashboard"; const threadId = typeof req.query.threadId === "string" ? req.query.threadId : undefined; const identity: ChatSessionIdentity = { channel, isDirectMessage, senderId, conversationId, threadId, }; const keyMsg = createBotMessage({ channel: identity.channel ?? "dashboard", isDirectMessage: Boolean(identity.isDirectMessage ?? true), senderId: identity.senderId ?? "dashboard-user", conversationId: identity.conversationId ?? "dashboard", threadId: identity.threadId, text: "", mentions: [], }); const sessionKey = getSessionKey(keyMsg); return { identity, sessionKey }; } function extractTextFromAgentMessage(message: unknown): string { if (!message || typeof message !== "object") return ""; const content = (message as { content?: unknown }).content; if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .flatMap((part) => { if (!part || typeof part !== "object") return []; const typedPart = part as { type?: unknown; text?: unknown }; return typedPart.type === "text" && typeof typedPart.text === "string" ? [typedPart.text] : []; }) .join(""); } export function createDashboardServer(options: DashboardServerOptions): DashboardServer { const host = readHost(options.host); const port = readPort(options.port); const url = `http://localhost:${port}`; const isProd = process.env.NODE_ENV === "production"; let started = false; let httpServer: HttpServer | null = null; let vite: ViteDevServer | null = null; let wss: WebSocketServer | null = null; async function start() { if (started) return; const app = express(); app.disable("x-powered-by"); app.use(express.json({ limit: "1mb" })); app.get("/api/overview", (_req, res) => { res.json( buildOverviewResponse({ dashboardUrl: url, dashboardStarted: started, appName: options.runtime.appName, agentMode: options.runtime.agentMode, workspaceDir: options.runtime.workspaceDir, agentDir: options.runtime.agentDir, enabledChannels: options.runtime.enabledChannels, }), ); }); app.get("/api/channels", (_req, res) => { try { res.json(readChannelsResponse({ configStore: options.configStore })); } catch (err) { res.status(500).json({ ok: false, error: String(err) }); } }); app.post("/api/channels", (req, res) => { try { saveChannelsRequest({ configStore: options.configStore, body: req.body as Parameters[0]["body"], }); res.json({ ok: true }); } catch (err) { res.status(500).json({ ok: false, error: String(err) }); } }); app.get("/api/models", (_req, res) => { try { res.json(readModelsResponse({ configStore: options.configStore })); } catch (err) { res.status(500).json({ ok: false, error: String(err) }); } }); app.post("/api/models", (req, res) => { try { saveModelsRequest({ configStore: options.configStore, body: req.body as Parameters[0]["body"], }); res.json({ ok: true }); } catch (err) { const status = err instanceof ValidationError ? err.statusCode : 500; res.status(status).json({ ok: false, error: String(err) }); } }); app.get("/api/chat/sessions", (_req, res) => { try { const sessions = options.agentRuntime.listSessionKeys(); res.json({ ok: true, sessions }); } catch (err) { res.status(500).json({ ok: false, error: String(err) }); } }); app.get("/api/chat/history", async (req, res) => { try { const { sessionKey } = resolveSessionIdentity(req); const session = await options.agentRuntime.ensureSessionLoaded(sessionKey); if (!session) { res.json({ ok: true, sessionKey, messages: [] }); return; } const messages = session.state.messages .filter((m) => { const role = (m as { role?: unknown }).role; return role === "user" || role === "assistant"; }) .map((m) => { const role = (m as { role?: unknown }).role as "user" | "assistant"; const text = extractTextFromAgentMessage(m); const timestamp = typeof (m as { timestamp?: unknown }).timestamp === "number" ? ((m as { timestamp?: number }).timestamp as number) : undefined; return { role, text, timestamp }; }) .filter((m) => m.text.trim().length > 0); res.json({ ok: true, sessionKey, messages }); } catch (err) { res.status(500).json({ ok: false, error: String(err) }); } }); app.post("/api/chat/reset", async (req, res) => { try { const body = (req.body ?? {}) as { session?: ChatSessionIdentity; sessionKey?: string }; const explicitKey = typeof body.sessionKey === "string" ? body.sessionKey.trim() : ""; const sessionKey = explicitKey ? explicitKey : getSessionKey( createBotMessage({ channel: body.session?.channel ?? "dashboard", isDirectMessage: Boolean(body.session?.isDirectMessage ?? true), senderId: body.session?.senderId ?? "dashboard-user", conversationId: body.session?.conversationId ?? "dashboard", threadId: body.session?.threadId, text: "", mentions: [], }), ); await options.agentRuntime.resetSession(sessionKey); res.json({ ok: true, sessionKey }); } catch (err) { res.status(500).json({ ok: false, error: String(err) }); } }); app.post("/api/chat/stream", async (req, res) => { const body = (req.body ?? {}) as { session?: ChatSessionIdentity; text?: string }; const sessionIdentity = body.session ?? {}; const text = typeof body.text === "string" ? body.text : ""; const msg = createBotMessage({ channel: sessionIdentity.channel ?? "dashboard", isDirectMessage: Boolean(sessionIdentity.isDirectMessage ?? true), senderId: sessionIdentity.senderId ?? "dashboard-user", conversationId: sessionIdentity.conversationId ?? "dashboard", threadId: sessionIdentity.threadId, text, mentions: [], }); const sessionKey = getSessionKey(msg); // Enforce one in-flight stream per session. const existing = options.agentRuntime.getSessionIfExists(sessionKey); if (existing?.isStreaming) { res.status(409).json({ ok: false, error: `Session is busy: ${sessionKey}` }); return; } res.status(200); res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); res.setHeader("Cache-Control", "no-cache, no-transform"); res.setHeader("Connection", "keep-alive"); (res as unknown as { flushHeaders?: () => void }).flushHeaders?.(); // Best-effort keepalive so browsers/proxies keep the connection open. const keepalive = setInterval(() => { res.write(":keepalive\n\n"); }, 15000); let closed = false; req.on("close", () => { closed = true; clearInterval(keepalive); void options.agentRuntime.abortSession(sessionKey); }); try { await options.agentRuntime.stream(msg, { onMeta(meta) { writeSse(res, "meta", meta); }, onDelta(delta) { if (closed) return; writeSse(res, "delta", { delta }); }, onError(error) { if (closed) return; writeSse(res, "error", { error }); }, }); if (!closed) { // Final snapshot for clients that only render on done. const session = options.agentRuntime.getSessionIfExists(sessionKey); const lastAssistant = session?.getLastAssistantText?.() ?? undefined; writeSse(res, "done", { text: lastAssistant ?? "" }); } } catch (err) { if (!closed) { writeSse(res, "error", { error: String(err) }); } } finally { clearInterval(keepalive); if (!closed) { res.end(); } } }); // WebSocket chat streaming (preferred). // Client connects to ws(s):///api/chat/ws and sends { type:"chat.send", session, text }. const wsPath = "/api/chat/ws"; if (!isProd) { vite = await createViteServer({ root: WEB_ROOT, server: { middlewareMode: true }, appType: "custom", }); app.use(vite.middlewares); app.use("*", async (req, res) => { try { const template = readFileSync(resolve(WEB_ROOT, "index.html"), "utf-8"); const html = await vite!.transformIndexHtml(req.originalUrl, template); res.status(200).setHeader("Content-Type", "text/html").end(html); } catch (e) { vite?.ssrFixStacktrace(e as Error); res.status(500).end(String(e)); } }); } else { app.use(express.static(WEB_DIST)); app.use("*", (_req, res) => { res.sendFile(resolve(WEB_DIST, "index.html")); }); } await new Promise((resolveListen, rejectListen) => { const server = createHttpServer(app); server.once("error", rejectListen); server.listen(port, host, () => resolveListen()); httpServer = server; }); // Attach WS server after HTTP server exists. wss = new WebSocketServer({ server: httpServer!, path: wsPath }); wss.on("connection", (socket: WebSocket, req: IncomingMessage) => { console.log("[dashboard][ws] connection", { url: req.url, remoteAddress: req.socket.remoteAddress }); let activeSessionKey: string | null = null; let activeRunId: string | null = null; let closed = false; const queue: Array<{ runId: string; msg: ReturnType; sessionKey: string }> = []; let draining = false; const send = (frame: WsServerFrame) => { if (closed) return; try { socket.send(JSON.stringify(frame)); } catch (err) { console.warn("[dashboard][ws] send failed", { activeRunId, activeSessionKey, frameType: frame.type, error: String(err) }); } }; const resolveSessionKeyFromIdentity = (sessionIdentity: ChatSessionIdentity | undefined) => { const keyMsg = createBotMessage({ channel: sessionIdentity?.channel ?? "dashboard", isDirectMessage: Boolean(sessionIdentity?.isDirectMessage ?? true), senderId: sessionIdentity?.senderId ?? "dashboard-user", conversationId: sessionIdentity?.conversationId ?? "dashboard", threadId: sessionIdentity?.threadId, text: "", mentions: [], }); return getSessionKey(keyMsg); }; const abortActive = async (sessionKey?: string) => { const key = sessionKey ?? activeSessionKey; if (!key) return; await options.agentRuntime.abortSession(key); }; socket.on("close", () => { closed = true; console.log("[dashboard][ws] close", { activeRunId, activeSessionKey }); void abortActive(); }); // Keep WS alive across proxies. Browser will ignore ping frames; ws lib handles pong. const pingTimer = setInterval(() => { try { socket.ping(); } catch { // ignore } }, 15000); pingTimer.unref?.(); const drainQueue = async () => { if (draining || closed) return; draining = true; try { while (!closed) { const next = queue.shift(); if (!next) break; const { runId, msg, sessionKey } = next; activeRunId = runId; activeSessionKey = sessionKey; const existing = options.agentRuntime.getSessionIfExists(sessionKey); if (existing?.isStreaming) { send({ type: "ack", runId, status: "in_flight" }); send({ type: "error", runId, sessionKey, error: `Session is busy: ${sessionKey}` }); activeRunId = null; activeSessionKey = null; continue; } send({ type: "ack", runId, status: "started" }); send({ type: "meta", runId, sessionKey }); try { const res = await options.agentRuntime.stream(msg, { onMeta(meta) { // repeat meta for robustness send({ type: "meta", runId, sessionKey: meta.sessionKey }); }, onDelta(delta) { send({ type: "delta", runId, sessionKey, delta }); }, onError(error) { send({ type: "error", runId, sessionKey, error }); }, }); send({ type: "done", runId, sessionKey, text: res.finalText }); } catch (err) { send({ type: "error", runId, sessionKey, error: String(err) }); } finally { activeRunId = null; activeSessionKey = null; } } } finally { draining = false; } }; socket.on("message", async (raw: RawData) => { let parsed: WsClientFrame | null = null; try { parsed = JSON.parse(String(raw)) as WsClientFrame; } catch { send({ type: "error", runId: "unknown", error: "invalid json" }); return; } if (!parsed || typeof parsed !== "object" || typeof (parsed as { type?: unknown }).type !== "string") { send({ type: "error", runId: "unknown", error: "invalid frame" }); return; } if (parsed.type === "ping") { return; } if (parsed.type === "chat.abort") { const key = typeof parsed.sessionKey === "string" ? parsed.sessionKey : resolveSessionKeyFromIdentity(parsed.session); // If abort targets current run, abort immediately; otherwise best-effort abort by sessionKey. await abortActive(key); return; } if (parsed.type !== "chat.send") { send({ type: "error", runId: "unknown", error: `unknown frame type: ${(parsed as { type: string }).type}` }); return; } const runId = typeof parsed.runId === "string" ? parsed.runId : ""; if (!runId.trim()) { send({ type: "error", runId: "unknown", error: "missing runId" }); return; } const text = typeof parsed.text === "string" ? parsed.text : ""; const sessionIdentity = typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() ? parseSessionKey(parsed.sessionKey) : (parsed.session ?? {}); const msg = createBotMessage({ channel: sessionIdentity.channel ?? "dashboard", isDirectMessage: Boolean(sessionIdentity.isDirectMessage ?? true), senderId: sessionIdentity.senderId ?? "dashboard-user", conversationId: sessionIdentity.conversationId ?? "dashboard", threadId: sessionIdentity.threadId, text, mentions: [], }); const sessionKey = getSessionKey(msg); queue.push({ runId, msg, sessionKey }); void drainQueue(); }); socket.once("close", () => clearInterval(pingTimer)); }); started = true; } async function stop() { if (!started) return; if (wss) { await new Promise((resolveClose) => { wss!.close(() => resolveClose()); }); wss = null; } await new Promise((resolveClose, rejectClose) => { httpServer?.close((err) => (err ? rejectClose(err) : resolveClose())); }); httpServer = null; if (vite) { await vite.close(); vite = null; } started = false; } return { start, stop, isStarted: () => started, getUrl: () => url, }; }