Adds an in-dashboard AI assistant that answers questions about live game
state. Designed reactively (no background loops) — every user message in
the chat window or via /api/agent/ask runs one `claude -p` invocation.
Architecture:
- New host-side FastAPI service (agent/) on 127.0.0.1:8767, OUTSIDE the
dereth-tracker Docker container because `claude` and ~/.claude
credentials live on the host.
- nginx routes /api/agent/* to the host service.
- The same browser session cookie the tracker issues authenticates
agent requests (shared SECRET_KEY).
- The agent shells out to `claude -p --session-id <uuid>` with
cwd=/home/erik/MosswartOverlord. Sessions persist as JSONL on disk
via Claude Code's built-in machinery.
- An MCP stdio server (agent/mcp_overlord.py) exposes tools to Claude:
get_live_players, get_recent_rares, query_telemetry_db (read-only,
parsed by sqlglot to reject DML/DDL), get_player_state, get_inventory,
get_inventory_search, get_combat_stats, get_equipment_cantrips,
get_quest_status, get_server_health, suitbuilder_search.
- Read-only PG role (overlord_agent_ro) is the second line of defense
on the SQL tool — even a parser bypass can't mutate.
Frontend:
- AgentWindow.tsx — draggable chat window with localStorage-pinned
session UUID, "New Chat" button, on-mount rehydration from
/agent/sessions/{id}/history (parses Claude Code's JSONL).
- Wired into WindowRenderer + Sidebar (🤖 Assistant button).
Operational:
- systemd unit (overlord-agent.service) + install.sh.
- agent/README.md documents env vars, deploy flow, smoke tests.
- nginx/overlord.conf gets a new /api/agent/ location with 180s timeout.
- CLAUDE.md gets an "Overlord Assistant Mode" section briefing the
agent on which tools to use and how to behave.
NOT YET DEPLOYED — server still needs:
1. Apply agent/sql/0001_overlord_agent_ro.sql + ALTER ROLE password
2. Add AGENT_DB_DSN to /home/erik/MosswartOverlord/.env
3. bash agent/install.sh (creates venv, installs unit, starts service)
4. sudo cp /home/erik/MosswartOverlord/nginx/overlord.conf to nginx + reload
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
180 lines
5.8 KiB
TypeScript
180 lines
5.8 KiB
TypeScript
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<Props> = ({ id, zIndex }) => {
|
|
const [sessionId, setSessionId] = useState<string>(() => loadSessionId());
|
|
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
|
const [input, setInput] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [hydrating, setHydrating] = useState(true);
|
|
const scrollRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
void send();
|
|
}
|
|
}, [send]);
|
|
|
|
return (
|
|
<DraggableWindow id={id} title="🤖 Overlord Assistant" zIndex={zIndex} width={520} height={620}>
|
|
<div className="ml-agent">
|
|
<div className="ml-agent-toolbar">
|
|
<button className="ml-agent-btn" onClick={newChat} disabled={loading}>+ New Chat</button>
|
|
<span className="ml-agent-session" title={sessionId}>{sessionId.slice(0, 8)}…</span>
|
|
</div>
|
|
|
|
<div className="ml-agent-messages" ref={scrollRef}>
|
|
{hydrating && messages.length === 0 && (
|
|
<div className="ml-agent-empty">Loading conversation…</div>
|
|
)}
|
|
{!hydrating && messages.length === 0 && (
|
|
<div className="ml-agent-empty">
|
|
Ask anything about the live game state — players, kills, inventory,
|
|
suitbuilder, recent rares, etc.
|
|
</div>
|
|
)}
|
|
{messages.map((m, i) => (
|
|
<div key={i} className={`ml-agent-msg ml-agent-${m.role}`}>
|
|
<div className="ml-agent-role">
|
|
{m.role === 'user' ? 'You' : m.role === 'assistant' ? 'Overlord' : 'Error'}
|
|
</div>
|
|
<div className="ml-agent-text">{m.text}</div>
|
|
</div>
|
|
))}
|
|
{loading && (
|
|
<div className="ml-agent-msg ml-agent-assistant">
|
|
<div className="ml-agent-role">Overlord</div>
|
|
<div className="ml-agent-text ml-agent-thinking">Thinking…</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<form
|
|
className="ml-agent-form"
|
|
onSubmit={e => { e.preventDefault(); void send(); }}
|
|
>
|
|
<textarea
|
|
className="ml-agent-input"
|
|
value={input}
|
|
onChange={e => setInput(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
placeholder={loading ? 'Waiting for response…' : 'Type a message — Enter to send, Shift+Enter for newline'}
|
|
disabled={loading}
|
|
rows={2}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="ml-agent-send"
|
|
disabled={loading || !input.trim()}
|
|
>
|
|
Send
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</DraggableWindow>
|
|
);
|
|
};
|