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:
parent
8fe6694a21
commit
6621c6b28b
5 changed files with 473 additions and 0 deletions
|
|
@ -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
166
bridge/src/routes/chat.ts
Normal 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;
|
||||
50
dashboard/src/app/api/chat/history/route.ts
Normal file
50
dashboard/src/app/api/chat/history/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
137
dashboard/src/app/api/chat/route.ts
Normal file
137
dashboard/src/app/api/chat/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
108
dashboard/src/contexts/chat-context.tsx
Normal file
108
dashboard/src/contexts/chat-context.tsx
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue