feat(chat): server-side persistence via SQLite

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.
This commit is contained in:
Manohar Gupta 2026-04-18 19:10:47 +00:00
parent 8fe6694a21
commit 6621c6b28b
5 changed files with 473 additions and 0 deletions

View file

@ -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,

166
bridge/src/routes/chat.ts Normal file
View file

@ -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;

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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<React.SetStateAction<ChatMessage[]>>
clearChat: () => Promise<void>
loading: boolean
}
const ChatContext = React.createContext<ChatContextValue | null>(null)
export function ChatProvider({ children }: { children: React.ReactNode }) {
const [messages, setMessages] = React.useState<ChatMessage[]>([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 (
<ChatContext.Provider value={{ messages, setMessages, clearChat, loading }}>
{children}
</ChatContext.Provider>
)
}
export function useChatContext(): ChatContextValue {
const ctx = React.useContext(ChatContext)
if (!ctx) {
throw new Error(
"useChatContext must be used inside <ChatProvider>. " +
"Make sure app/layout.tsx wraps children with <ChatProvider>."
)
}
return ctx
}