From 6621c6b28b1666147520098075928bd91a4ae611 Mon Sep 17 00:00:00 2001 From: Manohar Gupta Date: Sat, 18 Apr 2026 19:10:47 +0000 Subject: [PATCH] feat(chat): server-side persistence via SQLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat history now survives hard refresh, tab close, and multi-device use. Schema: chat_messages(id, session_id, role, content, meta, created_at) + index on (session_id, created_at DESC) Bridge endpoints: POST /tiger/chat — unchanged externally, now persists user + agent messages alongside the existing LLM dispatch GET /tiger/chat/history — ?sessionId=X&limit=200 → ordered messages DELETE /tiger/chat/history — ?sessionId=X → wipe history Dashboard: /api/chat/history — proxy route, bridge token stays server-side contexts/chat-context.tsx — ChatProvider hydrates messages from the history endpoint on mount; clearChat() now also hits DELETE /api/chat/history Design: single-session model for now (DEFAULT_SESSION_ID constant matches the openclaw agent --session-id used by the dispatch call). Multi-session support would require session UI + session-aware routing — deferred to a later feature sprint. Tradeoff noted: message data is duplicated between our SQLite and whatever state OpenClaw keeps internally. Chose duplication over coupling — if OpenClaw session semantics change, dashboard history remains intact. --- bridge/src/db.ts | 12 ++ bridge/src/routes/chat.ts | 166 ++++++++++++++++++++ dashboard/src/app/api/chat/history/route.ts | 50 ++++++ dashboard/src/app/api/chat/route.ts | 137 ++++++++++++++++ dashboard/src/contexts/chat-context.tsx | 108 +++++++++++++ 5 files changed, 473 insertions(+) create mode 100644 bridge/src/routes/chat.ts create mode 100644 dashboard/src/app/api/chat/history/route.ts create mode 100644 dashboard/src/app/api/chat/route.ts create mode 100644 dashboard/src/contexts/chat-context.tsx diff --git a/bridge/src/db.ts b/bridge/src/db.ts index 2457140..b87d54a 100644 --- a/bridge/src/db.ts +++ b/bridge/src/db.ts @@ -56,6 +56,18 @@ db.exec(` updated_at TEXT DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('user', 'agent', 'system')), + content TEXT NOT NULL, + -- 'meta' is optional JSON for things like model used, tokens, duration. + meta TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_chat_messages_session_created + ON chat_messages (session_id, created_at DESC); + CREATE TABLE IF NOT EXISTS executions ( id TEXT PRIMARY KEY, task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE, diff --git a/bridge/src/routes/chat.ts b/bridge/src/routes/chat.ts new file mode 100644 index 0000000..e81a7ef --- /dev/null +++ b/bridge/src/routes/chat.ts @@ -0,0 +1,166 @@ +/** + * routes/chat.ts — Chat via OpenClaw CLI + persistence + * + * POST /tiger/chat — send a message; response includes reply + * GET /tiger/chat/history — ?sessionId=X&limit=50 → past messages + * DELETE /tiger/chat/history — ?sessionId=X → clear history for a session + * + * Persistence rationale (see phase1b-patches.py): + * Chat history is duplicated into our SQLite so it survives: + * - browser hard refresh + * - close/reopen tab + * - use from a different device + * - OpenClaw restarts (session state may or may not persist internally) + * We own the read path; OpenClaw owns the reasoning context. + */ + +import { Router } from "express"; +import db from "../db.js"; + +// The main Tiger session — matches the hardcoded session in chat.send below. +// Keep this constant in sync with the --session-id used by openclaw agent. +const DEFAULT_SESSION_ID = "c1e6a067-7ca5-423b-9506-105db0702997"; + +const insertMessage = db.prepare(` + INSERT INTO chat_messages (session_id, role, content, meta) + VALUES (?, ?, ?, ?) +`); +const getHistory = db.prepare(` + SELECT id, role, content, meta, created_at + FROM chat_messages + WHERE session_id = ? + ORDER BY created_at ASC, id ASC + LIMIT ? +`); +const deleteHistory = db.prepare(` + DELETE FROM chat_messages WHERE session_id = ? +`); + +const router = Router(); + +// ─── GET /tiger/chat/history ───────────────────────────────────────────── +router.get("/history", (req, res) => { + const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION_ID; + const limit = Math.min(parseInt(req.query.limit as string) || 200, 500); + const rows = getHistory.all(sessionId, limit) as any[]; + res.json({ + ok: true, + sessionId, + count: rows.length, + messages: rows.map((r) => ({ + id: String(r.id), + role: r.role, + content: r.content, + timestamp: new Date(r.created_at + "Z").getTime(), + meta: r.meta ? JSON.parse(r.meta) : {}, + })), + }); +}); + +// ─── DELETE /tiger/chat/history ────────────────────────────────────────── +router.delete("/history", (req, res) => { + const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION_ID; + const result = deleteHistory.run(sessionId); + res.json({ ok: true, deleted: result.changes }); +}); + +// ─── POST /tiger/chat ──────────────────────────────────────────────────── +router.post("/", async (req, res) => { + const { message } = req.body; + + if (!message) { + return res.status(400).json({ ok: false, error: "message is required" }); + } + + // Persist the user's message BEFORE calling the LLM so history is intact + // even if the LLM call fails. + try { + insertMessage.run(DEFAULT_SESSION_ID, "user", message, "{}"); + } catch (e: any) { + console.warn("[chat] failed to persist user message:", e.message); + } + + // ── Timing instrumentation ────────────────────────────────────── + // Label each phase so we can see where latency goes. Format in logs: + // [chat.timing] spawn=120ms exec=2834ms parse=3ms total=2957ms + const tStart = Date.now(); + let tSpawn = 0; + let tExec = 0; + let tParse = 0; + + try { + const { exec } = await import("child_process"); + const { promisify } = await import("util"); + const execAsync = promisify(exec); + + // Escape the message for shell + const escapedMessage = message.replace(/'/g, "'\\''"); + + // Use openclaw agent to send a message to the main session + // Session ID: c1e6a067-7ca5-423b-9506-105db0702997 (agent:main:main) + const cmd = `docker exec tiger-openclaw openclaw agent --session-id c1e6a067-7ca5-423b-9506-105db0702997 -m '${escapedMessage}' --json --timeout 120`; + + const tBeforeSpawn = Date.now(); + tSpawn = tBeforeSpawn - tStart; + console.log("[chat] Executing:", cmd.substring(0, 100) + "..."); + + const { stdout, stderr } = await execAsync(cmd, { + timeout: 130000, + maxBuffer: 10 * 1024 * 1024, + }); + + tExec = Date.now() - tBeforeSpawn; + console.log("[chat] Response:", stdout.substring(0, 500)); + + // Parse the JSON response + const tBeforeParse = Date.now(); + let result; + try { + result = JSON.parse(stdout); + } catch { + result = { output: stdout, error: stderr }; + } + tParse = Date.now() - tBeforeParse; + + const tTotal = Date.now() - tStart; + console.log( + `[chat.timing] spawn=${tSpawn}ms exec=${tExec}ms parse=${tParse}ms total=${tTotal}ms` + ); + + // Persist the agent's reply. Extract text using the same fallback chain + // as the dashboard so we store whatever the user actually sees. + try { + const agentText = + result?.result?.payloads?.[0]?.text || + result?.payloads?.[0]?.text || + result?.summary || + result?.text || + ""; + if (agentText) { + const meta = { + runId: result?.runId, + model: result?.result?.meta?.agentMeta?.model || result?.meta?.agentMeta?.model, + durationMs: tTotal, + }; + insertMessage.run(DEFAULT_SESSION_ID, "agent", agentText, JSON.stringify(meta)); + } + } catch (e: any) { + console.warn("[chat] failed to persist agent reply:", e.message); + } + + res.json({ + ok: true, + timing: { spawn: tSpawn, exec: tExec, parse: tParse, total: tTotal }, + response: result, + }); + } catch (err: any) { + const tTotal = Date.now() - tStart; + console.error(`[chat] Error after ${tTotal}ms:`, err.message); + res.status(500).json({ + ok: false, + error: err.message || "Failed to send chat message", + }); + } +}); + +export default router; \ No newline at end of file diff --git a/dashboard/src/app/api/chat/history/route.ts b/dashboard/src/app/api/chat/history/route.ts new file mode 100644 index 0000000..41914d0 --- /dev/null +++ b/dashboard/src/app/api/chat/history/route.ts @@ -0,0 +1,50 @@ +/** + * /api/chat/history — proxy for bridge's chat history. + * GET — list persisted messages for the default session + * DELETE — clear them + * + * Why a proxy and not a direct bridge call from the client? + * - Keeps the bridge auth token on the server side (never leaks to browser) + * - Matches the pattern used by /api/chat (POST) and /api/tiger/status + */ + +import { NextRequest, NextResponse } from "next/server"; + +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"; +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""; + +export async function GET(request: NextRequest) { + const sessionId = request.nextUrl.searchParams.get("sessionId") || ""; + const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : ""; + try { + const r = await fetch(`${BRIDGE_URL}/tiger/chat/history${qs}`, { + headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` }, + cache: "no-store", + }); + const data = await r.json(); + return NextResponse.json(data, { status: r.status }); + } catch (err: any) { + return NextResponse.json( + { ok: false, error: "Bridge unreachable", details: err.message }, + { status: 502 } + ); + } +} + +export async function DELETE(request: NextRequest) { + const sessionId = request.nextUrl.searchParams.get("sessionId") || ""; + const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : ""; + try { + const r = await fetch(`${BRIDGE_URL}/tiger/chat/history${qs}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` }, + }); + const data = await r.json(); + return NextResponse.json(data, { status: r.status }); + } catch (err: any) { + return NextResponse.json( + { ok: false, error: "Bridge unreachable", details: err.message }, + { status: 502 } + ); + } +} diff --git a/dashboard/src/app/api/chat/route.ts b/dashboard/src/app/api/chat/route.ts new file mode 100644 index 0000000..b28c25c --- /dev/null +++ b/dashboard/src/app/api/chat/route.ts @@ -0,0 +1,137 @@ +/** + * API route: POST /api/chat + * Sends chat messages via Tiger Bridge -> OpenClaw CLI + */ + +import { NextRequest, NextResponse } from "next/server"; + +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"; +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""; + +export const maxDuration = 120; + +export async function POST(request: NextRequest) { + const { message } = await request.json(); + + if (!message) { + return NextResponse.json({ error: "message is required" }, { status: 400 }); + } + + // End-to-end timing: measure the full /api/chat call so we can compare + // against the bridge's own timing (data.timing) to find overhead. + const t0 = Date.now(); + + try { + // Call the bridge + const response = await fetch(`${BRIDGE_URL}/tiger/chat`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${BRIDGE_TOKEN}`, + }, + body: JSON.stringify({ message }), + }); + + const tBridgeDone = Date.now(); + const data = await response.json(); + + if (data?.timing) { + console.log( + `[chat.timing] bridge: ${JSON.stringify(data.timing)} | dashboard: bridge_call=${tBridgeDone - t0}ms` + ); + } + + console.log("[chat] Bridge response:", JSON.stringify(data).substring(0, 500)); + + if (!response.ok) { + return NextResponse.json( + { error: data.error || "Chat failed" }, + { status: response.status } + ); + } + + // Extract the text response - OpenClaw returns in several possible formats + let text = ""; + + if (data.response?.result?.payloads?.[0]?.text) { + text = data.response.result.payloads[0].text; + } else if (data.response?.payloads?.[0]?.text) { + text = data.response.payloads[0].text; + } else if (data.response?.summary) { + text = data.response.summary; + } else if (data.response?.text) { + text = data.response.text; + } else if (data.text) { + text = data.text; + } else { + // Fallback: stringify the whole response for debugging + text = JSON.stringify(data); + } + + console.log("[chat] Extracted text:", text.substring(0, 200)); + + // Return as SSE with word-by-word streaming. + // + // WHY SIMULATE STREAMING? + // The bridge gives us the entire reply in one shot (LLM call completes + // before the process returns). That means without this code the whole + // answer pops in at once — feels sluggish even though the infra is fine. + // Splitting on whitespace and drip-feeding gives the UI a "typing" feel + // without changing the backend. Total time until done is identical. + // + // When true token-level streaming is wired in the bridge (Phase 3), we + // can swap this out for real chunks from openclaw's event stream. + const encoder = new TextEncoder(); + const words = text.split(/(\s+)/); // keep whitespace tokens → smooth flow + // ~60 words-per-second cadence ≈ 16ms per word. Tune to taste. + const WORD_DELAY_MS = 25; // 40 wps — smooth typing feel with frame headroom + + const stream = new ReadableStream({ + async start(controller) { + // Send status marker first so UI can show the thinking indicator. + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "status", content: "" })}\n\n` + ) + ); + + // Drip-feed word tokens. Each is a "chunk" that appends to the + // streaming message bubble on the client. + for (const word of words) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "chunk", content: word })}\n\n` + ) + ); + if (WORD_DELAY_MS > 0) { + await new Promise((resolve) => setTimeout(resolve, WORD_DELAY_MS)); + } + } + + // Final done event carries the full text as a safety fallback + // (see the Bug D fix in chat-interface.tsx). + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "done", content: text })}\n\n` + ) + ); + + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } catch (err: any) { + console.error("[chat] Error:", err.message); + return NextResponse.json( + { error: "Failed to communicate with Tiger Bridge" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/dashboard/src/contexts/chat-context.tsx b/dashboard/src/contexts/chat-context.tsx new file mode 100644 index 0000000..608365d --- /dev/null +++ b/dashboard/src/contexts/chat-context.tsx @@ -0,0 +1,108 @@ +"use client" + +/** + * ChatContext — chat state that persists across route changes AND across + * hard refreshes (via the server-side /api/chat/history endpoint). + * + * TWO LAYERS OF PERSISTENCE: + * 1. React Context → survives client-side navigation between /chat, /workspace + * 2. Server-side history (SQLite in bridge) → survives refresh, tab close, + * device change. On mount we fetch history and hydrate the messages. + * + * FLOW: + * Mount → GET /api/chat/history → merge with default welcome message + * Send message → optimistic local update → /api/chat → server persists + * Clear → DELETE /api/chat/history → reset to just the welcome + */ +import * as React from "react" + +export type ChatMessage = { + id: string + role: "user" | "agent" | "system" + content: string + streaming?: boolean + timestamp: number +} + +const DEFAULT_WELCOME: ChatMessage = { + id: "welcome", + role: "agent", + content: "Hey! I am Tiger, your AI assistant. Send me a message to get started.", + timestamp: 0, // sentinel — always sorted to the top +} + +type ChatContextValue = { + messages: ChatMessage[] + setMessages: React.Dispatch> + clearChat: () => Promise + loading: boolean +} + +const ChatContext = React.createContext(null) + +export function ChatProvider({ children }: { children: React.ReactNode }) { + const [messages, setMessages] = React.useState([DEFAULT_WELCOME]) + const [loading, setLoading] = React.useState(true) + + // Hydrate from server on mount. This is what makes persistence actually + // work across hard refresh. + React.useEffect(() => { + let cancelled = false + async function load() { + try { + const r = await fetch("/api/chat/history", { cache: "no-store" }) + if (!r.ok) throw new Error(`history ${r.status}`) + const data = await r.json() + if (cancelled || !data?.ok || !Array.isArray(data.messages)) return + + // Combine welcome + history, no duplicates. Sort by timestamp so + // it renders in conversational order. + const hydrated: ChatMessage[] = [ + DEFAULT_WELCOME, + ...data.messages.map((m: any) => ({ + id: String(m.id), + role: m.role, + content: m.content, + timestamp: m.timestamp, + })), + ] + setMessages(hydrated) + } catch (err) { + console.warn("[chat] could not load history:", err) + } finally { + if (!cancelled) setLoading(false) + } + } + load() + return () => { + cancelled = true + } + }, []) + + const clearChat = React.useCallback(async () => { + // Optimistic: clear UI first, then ask server to clear. + setMessages([DEFAULT_WELCOME]) + try { + await fetch("/api/chat/history", { method: "DELETE" }) + } catch (err) { + console.warn("[chat] clear on server failed (local cleared):", err) + } + }, []) + + return ( + + {children} + + ) +} + +export function useChatContext(): ChatContextValue { + const ctx = React.useContext(ChatContext) + if (!ctx) { + throw new Error( + "useChatContext must be used inside . " + + "Make sure app/layout.tsx wraps children with ." + ) + } + return ctx +}