import React, { useCallback, useEffect, useRef, useState } from 'react'; import { DraggableWindow } from './DraggableWindow'; import { agentAsk, agentNewSession, agentSessionHistory, type AgentHistoryMessage, } from '../../api/endpoints'; interface Props { id: string; zIndex: number; } interface ChatMsg { role: 'user' | 'assistant' | 'error'; text: string; } const SESSION_KEY = 'overlord_agent_session_id'; /** UUID is preferred but crypto.randomUUID is only available in secure contexts. */ function newUuid(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } // RFC4122-ish fallback const r = (n: number) => Math.floor(Math.random() * n); return `${r(0x100000000).toString(16).padStart(8, '0')}-${r(0x10000).toString(16).padStart(4, '0')}-4${r(0x1000).toString(16).padStart(3, '0')}-${(8 + r(4)).toString(16)}${r(0x1000).toString(16).padStart(3, '0')}-${r(0x1000000000000).toString(16).padStart(12, '0')}`; } function loadSessionId(): string { try { const stored = localStorage.getItem(SESSION_KEY); if (stored) return stored; } catch { /* ignore */ } const fresh = newUuid(); try { localStorage.setItem(SESSION_KEY, fresh); } catch { /* ignore */ } return fresh; } export const AgentWindow: React.FC = ({ id, zIndex }) => { const [sessionId, setSessionId] = useState(() => loadSessionId()); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [loading, setLoading] = useState(false); const [hydrating, setHydrating] = useState(true); const scrollRef = useRef(null); // Rehydrate from server-side session JSONL on mount / session change. useEffect(() => { let cancelled = false; setHydrating(true); agentSessionHistory(sessionId) .then(res => { if (cancelled) return; const msgs: ChatMsg[] = (res.messages ?? []).map((m: AgentHistoryMessage) => ({ role: m.role, text: m.text, })); setMessages(msgs); }) .catch(() => { if (!cancelled) setMessages([]); }) .finally(() => { if (!cancelled) setHydrating(false); }); return () => { cancelled = true; }; }, [sessionId]); // Auto-scroll to bottom on new messages. useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [messages.length, loading]); const send = useCallback(async () => { const text = input.trim(); if (!text || loading) return; setInput(''); setMessages(prev => [...prev, { role: 'user', text }]); setLoading(true); try { const res = await agentAsk(text, sessionId); setMessages(prev => [ ...prev, { role: res.is_error ? 'error' : 'assistant', text: res.result || '(no response)' }, ]); } catch (err) { setMessages(prev => [ ...prev, { role: 'error', text: `Request failed: ${String(err)}` }, ]); } finally { setLoading(false); } }, [input, loading, sessionId]); const newChat = useCallback(async () => { if (loading) return; let fresh = ''; try { const res = await agentNewSession(); fresh = res.session_id; } catch { fresh = newUuid(); } try { localStorage.setItem(SESSION_KEY, fresh); } catch { /* ignore */ } setSessionId(fresh); setMessages([]); setInput(''); }, [loading]); const onKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(); } }, [send]); return (
{sessionId.slice(0, 8)}…
{hydrating && messages.length === 0 && (
Loading conversation…
)} {!hydrating && messages.length === 0 && (
Ask anything about the live game state — players, kills, inventory, suitbuilder, recent rares, etc.
)} {messages.map((m, i) => (
{m.role === 'user' ? 'You' : m.role === 'assistant' ? 'Overlord' : 'Error'}
{m.text}
))} {loading && (
Overlord
Thinking…
)}
{ e.preventDefault(); void send(); }} >