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'))
|
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 (
|
CREATE TABLE IF NOT EXISTS executions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
|
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