feat(agent): Phase 1 — chat-window AI assistant via Claude Code subprocess
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>
This commit is contained in:
parent
aeddaf9925
commit
79cf88d3f7
35 changed files with 1763 additions and 25 deletions
|
|
@ -9,6 +9,26 @@ export async function apiFetch<T>(path: string): Promise<T> {
|
|||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST JSON to an authenticated API endpoint.
|
||||
* Sends `body` as JSON, includes session cookie, parses JSON response.
|
||||
* Throws Error with HTTP status on non-2xx.
|
||||
*/
|
||||
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try { detail = (await res.json())?.detail ?? ''; } catch { /* ignore */ }
|
||||
throw new Error(`API ${path}: ${res.status}${detail ? ` (${detail})` : ''}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function wsUrl(): string {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${location.host}/api/ws/live`;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { apiFetch } from './client';
|
||||
import { apiFetch, apiPost } from './client';
|
||||
import type { TelemetrySnapshot, CombatStatsMessage, ServerHealth } from '../types';
|
||||
|
||||
interface LiveResponse {
|
||||
|
|
@ -19,3 +19,30 @@ export const getServerHealth = () => apiFetch<ServerHealth>('/server-health');
|
|||
export const getTotalRares = () => apiFetch<RaresResponse>('/total-rares');
|
||||
export const getTotalKills = () => apiFetch<KillsResponse>('/total-kills');
|
||||
export const getCharacterStats = (name: string) => apiFetch<Record<string, unknown>>(`/character-stats/${encodeURIComponent(name)}`);
|
||||
|
||||
// ─── Agent endpoints (host-side service via /api/agent/*) ──────────────────
|
||||
|
||||
export interface AgentAskResponse {
|
||||
result: string;
|
||||
session_id: string;
|
||||
duration_ms: number;
|
||||
num_turns: number;
|
||||
is_error: boolean;
|
||||
}
|
||||
|
||||
export interface AgentHistoryMessage {
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export const agentAsk = (message: string, sessionId: string) =>
|
||||
apiPost<AgentAskResponse>('/agent/ask', { message, session_id: sessionId });
|
||||
|
||||
export const agentNewSession = () =>
|
||||
apiPost<{ session_id: string }>('/agent/sessions/new', {});
|
||||
|
||||
export const agentSessionHistory = (sessionId: string) =>
|
||||
apiFetch<{ messages: AgentHistoryMessage[] }>(
|
||||
`/agent/sessions/${encodeURIComponent(sessionId)}/history`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ export const SidebarWindowButtons: React.FC = () => {
|
|||
|
||||
return (
|
||||
<div className="ml-tool-links">
|
||||
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
|
||||
onClick={() => openWindow('agent', 'Overlord Assistant')}>🤖 Assistant</span>
|
||||
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
|
||||
onClick={() => openWindow('playerdash', 'Player Dashboard')}>👥 Dashboard</span>
|
||||
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
|
||||
|
|
|
|||
180
frontend/src/components/windows/AgentWindow.tsx
Normal file
180
frontend/src/components/windows/AgentWindow.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -11,6 +11,7 @@ const IssuesWindow = lazy(() => import('./IssuesWindow').then(m => ({ default: m
|
|||
const VitalSharingWindow = lazy(() => import('./VitalSharingWindow').then(m => ({ default: m.VitalSharingWindow })));
|
||||
const QuestStatusWindow = lazy(() => import('./QuestStatusWindow').then(m => ({ default: m.QuestStatusWindow })));
|
||||
const PlayerDashboardWindow = lazy(() => import('./PlayerDashboardWindow').then(m => ({ default: m.PlayerDashboardWindow })));
|
||||
const AgentWindow = lazy(() => import('./AgentWindow').then(m => ({ default: m.AgentWindow })));
|
||||
import type { CharacterState } from '../../types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -62,6 +63,8 @@ export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMes
|
|||
return <QuestStatusWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
|
||||
case 'playerdash':
|
||||
return <PlayerDashboardWindow key={w.id} id={w.id} zIndex={w.zIndex} characters={characters} />;
|
||||
case 'agent':
|
||||
return <AgentWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -389,6 +389,119 @@
|
|||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ── Agent (AI assistant) chat window ─────────────────── */
|
||||
.ml-agent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.ml-agent-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #333;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.ml-agent-btn {
|
||||
background: #2a2a3a;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
padding: 3px 8px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ml-agent-btn:hover:not(:disabled) { background: #353550; border-color: #88f; }
|
||||
.ml-agent-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.ml-agent-session {
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
color: #888;
|
||||
margin-left: auto;
|
||||
}
|
||||
.ml-agent-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.ml-agent-empty {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ml-agent-msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: 92%;
|
||||
}
|
||||
.ml-agent-user { align-self: flex-end; }
|
||||
.ml-agent-assistant, .ml-agent-error { align-self: flex-start; }
|
||||
.ml-agent-role {
|
||||
font-size: 0.65rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #888;
|
||||
}
|
||||
.ml-agent-user .ml-agent-role { color: #88f; text-align: right; }
|
||||
.ml-agent-assistant .ml-agent-role { color: #6fd07a; }
|
||||
.ml-agent-error .ml-agent-role { color: #d66; }
|
||||
.ml-agent-text {
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
background: #232333;
|
||||
color: #e8e8e8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ml-agent-user .ml-agent-text { background: #2a3a55; color: #fff; }
|
||||
.ml-agent-error .ml-agent-text { background: #3a1c1c; color: #ffaaaa; }
|
||||
.ml-agent-thinking {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
.ml-agent-form {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-top: 1px solid #333;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.ml-agent-input {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
padding: 5px 7px;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.ml-agent-input:focus { outline: 1px solid #88f; border-color: #88f; }
|
||||
.ml-agent-input:disabled { opacity: 0.6; }
|
||||
.ml-agent-send {
|
||||
background: #2a3a55;
|
||||
color: #fff;
|
||||
border: 1px solid #4466aa;
|
||||
border-radius: 3px;
|
||||
padding: 0 14px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ml-agent-send:hover:not(:disabled) { background: #34507a; }
|
||||
.ml-agent-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* ── Tooltip ──────────────────────────────────────────── */
|
||||
.ml-tooltip {
|
||||
position: absolute;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue