OpenClawDashboard/dashboard/src/contexts/chat-context.tsx

232 lines
8 KiB
TypeScript

"use client"
/**
* ChatContext — chat state that persists across route changes AND across
* hard refreshes (via the server-side /api/chat/history endpoint).
*
* THREE LAYERS OF STATE:
* 1. React Context — survives client-side navigation between routes
* 2. localStorage — remembers which sessionKey was active across reloads
* 3. Server-side history — sqlite in bridge; survives device change, tab close
*
* SESSION MODEL (post WS-migration):
* Each chat is keyed by a `sessionKey` like:
* - "agent:main:main" → the default Tiger conversation
* - "agent:main:webchat-<8hex>" → a fresh session created via "+ New"
* The sessionKey is passed to /api/chat so the gateway routes to the right
* conversation memory. It's also passed to /api/chat/history?sessionId=<key>.
*
* FLOW:
* Mount → load sessions list → load history for current sessionKey
* New → POST /api/chat/sessions → set as current, clear messages
* Switch → set current → load history
* Send → optimistic UI → POST /api/chat with current sessionKey
* Clear → DELETE /api/chat/history?sessionId=<current>
* Delete → DELETE /api/chat/sessions?key=<x> → drop, switch to Main
*/
import * as React from "react"
const DEFAULT_SESSION_KEY = "agent:main:main"
const STORAGE_KEY = "tiger.currentSessionKey"
export type ChatMessage = {
id: string
role: "user" | "agent" | "system"
content: string
streaming?: boolean
timestamp: number
}
export type ChatSession = {
key: string
label: string
updatedAt: number | null
messageCount: number
isDefault: boolean
}
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,
}
type ChatContextValue = {
messages: ChatMessage[]
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
loading: boolean
/** The sessionKey currently being chatted with — sent to /api/chat. */
currentSessionKey: string
/** All webchat-visible sessions (from gateway sessions.list). */
sessions: ChatSession[]
/** Switch to an existing session — loads its history. */
selectSession: (key: string) => Promise<void>
/** Mint and switch to a brand-new session — clears messages. */
newSession: () => Promise<void>
/** Delete a non-default session — switches to Main if the active one is removed. */
deleteSession: (key: string) => Promise<void>
/** Wipe the *current* session's history (keeps the session itself). */
clearChat: () => Promise<void>
/** Force-refresh the sessions list (e.g., after a send so updatedAt updates). */
refreshSessions: () => Promise<void>
}
const ChatContext = React.createContext<ChatContextValue | null>(null)
/** Read the saved sessionKey from localStorage (SSR-safe). */
function readPersistedKey(): string {
if (typeof window === "undefined") return DEFAULT_SESSION_KEY
try {
return localStorage.getItem(STORAGE_KEY) || DEFAULT_SESSION_KEY
} catch {
return DEFAULT_SESSION_KEY
}
}
function writePersistedKey(key: string): void {
if (typeof window === "undefined") return
try { localStorage.setItem(STORAGE_KEY, key) } catch { /* quota / private mode */ }
}
/** Fetch history for a sessionKey, returning hydrated messages (welcome first). */
async function loadHistoryFor(sessionKey: string): Promise<ChatMessage[]> {
try {
const r = await fetch(`/api/chat/history?sessionId=${encodeURIComponent(sessionKey)}`, { cache: "no-store" })
if (!r.ok) throw new Error(`history ${r.status}`)
const data = await r.json()
if (!data?.ok || !Array.isArray(data.messages)) return [DEFAULT_WELCOME]
return [
DEFAULT_WELCOME,
...data.messages.map((m: any) => ({
id: String(m.id),
role: m.role,
content: m.content,
timestamp: m.timestamp,
})),
]
} catch (err) {
console.warn("[chat] could not load history for", sessionKey, err)
return [DEFAULT_WELCOME]
}
}
export function ChatProvider({ children }: { children: React.ReactNode }) {
const [messages, setMessages] = React.useState<ChatMessage[]>([DEFAULT_WELCOME])
const [loading, setLoading] = React.useState(true)
const [currentSessionKey, setCurrentSessionKey] = React.useState<string>(DEFAULT_SESSION_KEY)
const [sessions, setSessions] = React.useState<ChatSession[]>([])
const refreshSessions = React.useCallback(async () => {
try {
const r = await fetch("/api/chat/sessions", { cache: "no-store" })
if (!r.ok) return
const data = await r.json()
if (data?.ok && Array.isArray(data.sessions)) setSessions(data.sessions)
} catch (err) {
console.warn("[chat] sessions list failed:", err)
}
}, [])
// Initial mount: pick stored sessionKey, load its history, fetch sessions list.
React.useEffect(() => {
let cancelled = false
// Check URL for session param
const urlParams = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '')
const sessionParam = urlParams.get('session')
let initialKey = sessionParam ? `agent:main:${sessionParam}` : readPersistedKey()
async function init() {
if (cancelled) return
setCurrentSessionKey(initialKey)
const [hist] = await Promise.all([loadHistoryFor(initialKey), refreshSessions()])
if (!cancelled) {
setMessages(hist)
setLoading(false)
}
}
init()
return () => { cancelled = true }
}, [refreshSessions])
const selectSession = React.useCallback(async (key: string) => {
if (key === currentSessionKey) return
setLoading(true)
setCurrentSessionKey(key)
writePersistedKey(key)
const hist = await loadHistoryFor(key)
setMessages(hist)
setLoading(false)
}, [currentSessionKey])
const newSession = React.useCallback(async () => {
try {
const r = await fetch("/api/chat/sessions", { method: "POST" })
const data = await r.json()
if (!r.ok || !data?.ok || !data.session?.key) {
console.warn("[chat] new session failed:", data)
return
}
const key = data.session.key as string
setCurrentSessionKey(key)
writePersistedKey(key)
setMessages([DEFAULT_WELCOME])
// Add to local list optimistically; full refresh happens after first send
setSessions(prev => {
if (prev.find(s => s.key === key)) return prev
return [...prev, data.session as ChatSession]
})
} catch (err) {
console.warn("[chat] new session error:", err)
}
}, [])
const deleteSession = React.useCallback(async (key: string) => {
if (key === DEFAULT_SESSION_KEY) return
try {
await fetch(`/api/chat/sessions?key=${encodeURIComponent(key)}`, { method: "DELETE" })
} catch (err) {
console.warn("[chat] delete session failed:", err)
}
setSessions(prev => prev.filter(s => s.key !== key))
// If the deleted session was active, fall back to Main.
if (key === currentSessionKey) {
setCurrentSessionKey(DEFAULT_SESSION_KEY)
writePersistedKey(DEFAULT_SESSION_KEY)
const hist = await loadHistoryFor(DEFAULT_SESSION_KEY)
setMessages(hist)
}
}, [currentSessionKey])
const clearChat = React.useCallback(async () => {
setMessages([DEFAULT_WELCOME])
try {
await fetch(`/api/chat/history?sessionId=${encodeURIComponent(currentSessionKey)}`, { method: "DELETE" })
} catch (err) {
console.warn("[chat] clear server failed (local cleared):", err)
}
}, [currentSessionKey])
return (
<ChatContext.Provider value={{
messages, setMessages, loading,
currentSessionKey, sessions,
selectSession, newSession, deleteSession,
clearChat, refreshSessions,
}}>
{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
}