import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from './api.js';
// Native amerc chat — speaks plain HTTP to amerc-api (long-poll + streamed
// file relay). No iframe, no external relay: works whenever the API is up.
const FILE_TTL_MS = 5 * 60 * 1000;
const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${(n / 1024).toFixed(0)} KB` : `${n} B`);
const fmtTime = (t) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
function FileMsg({ m, mine }) {
const [now, setNow] = useState(Date.now());
useEffect(() => { const t = setInterval(() => setNow(Date.now()), 10000); return () => clearInterval(t); }, []);
const left = m.created_at + FILE_TTL_MS - now;
const isImage = (m.file_mime || '').startsWith('image/');
return (
);
}
export default function ChatPanel({ instance, accesskey, sessionId: resumeId, onClose }) {
const [sid, setSid] = useState(resumeId || null);
const [msgs, setMsgs] = useState([]);
const [skills, setSkills] = useState([]);
const [instStatus, setInstStatus] = useState(instance?.status || '');
const [sessStatus, setSessStatus] = useState('active');
const [input, setInput] = useState('');
const [err, setErr] = useState('');
const [up, setUp] = useState(null); // { name, pct, phase }
const seqRef = useRef(0);
const bodyRef = useRef(null);
const aliveRef = useRef(true);
// open (or resume) the session
useEffect(() => {
if (sid) return;
api(`/agents/instances/${instance.id}/sessions`, { method: 'POST', body: { connector: 'chat', accesskey: accesskey || undefined } })
.then((r) => setSid(r.sessionId))
.catch((e) => setErr(e.message));
}, [sid, instance, accesskey]);
// long-poll loop
useEffect(() => {
if (!sid) return undefined;
aliveRef.current = true;
let timer;
const loop = async () => {
while (aliveRef.current) {
try {
const d = await api(`/agents/sessions/${sid}/messages?after=${seqRef.current}&wait=1`);
if (!aliveRef.current) return;
if (d.messages?.length) {
seqRef.current = d.messages[d.messages.length - 1].seq;
setMsgs((prev) => [...prev, ...d.messages]);
}
if (d.skills) setSkills(d.skills);
if (d.instanceStatus) setInstStatus(d.instanceStatus);
if (d.sessionStatus) setSessStatus(d.sessionStatus);
} catch {
await new Promise((r) => { timer = setTimeout(r, 3000); });
}
}
};
loop();
return () => { aliveRef.current = false; clearTimeout(timer); };
}, [sid]);
useEffect(() => { bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight, behavior: 'smooth' }); }, [msgs]);
const send = async (e) => {
e?.preventDefault();
const text = input.trim();
if (!text || !sid) return;
setInput(''); setErr('');
try { await api(`/agents/sessions/${sid}/messages`, { method: 'POST', body: { content: text } }); }
catch (e2) { setErr(e2.message); setInput(text); }
};
// file/image send: stream straight through the relay; never stored server-side
const sendFile = (file) => {
if (!file || !sid) return;
setErr('');
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/agents/sessions/${sid}/files?name=${encodeURIComponent(file.name)}`);
if (file.type) xhr.setRequestHeader('Content-Type', file.type);
xhr.upload.onprogress = (ev) => {
const pct = ev.total ? Math.round((ev.loaded / ev.total) * 100) : 0;
setUp({ name: file.name, pct, phase: pct >= 100 ? 'waiting for the agent to take it…' : 'streaming…' });
};
xhr.onload = () => {
setUp(null);
if (xhr.status === 408) setErr('file not picked up within 5 minutes — try again while the agent is responsive');
else if (xhr.status >= 400) setErr(`file send failed (${xhr.status})`);
};
xhr.onerror = () => { setUp(null); setErr('file send failed — network error'); };
setUp({ name: file.name, pct: 0, phase: 'streaming…' });
xhr.send(file);
};
const closeSession = async () => {
if (sid && sessStatus === 'active') { try { await api(`/agents/sessions/${sid}/close`, { method: 'POST' }); } catch { /* already gone */ } }
onClose?.();
};
const live = sessStatus === 'active';
return (
{instance.name || `instance #${instance.id}`}
{live ? `${instStatus} · session #${sid ?? '…'}` : `session #${sid} · closed (chatlog)`}
{skills.length > 0 && (
skills loaded
{skills.map((s) => {s})}
)}
{msgs.map((m) => {
if (m.kind === 'event') return
— {m.content} · {fmtTime(m.created_at)} —
;
const mine = m.role === 'user';
return (
{m.kind === 'file' ?
:
{m.content}
}
{fmtTime(m.created_at)}
);
})}
{!msgs.length &&
{sid ? 'no messages yet — say something' : 'opening session…'}
}
{up && (
📤 {up.name}
{up.phase}
)}
{err &&
{err}
}
{live ? (
) : (
this session is closed — its chatlog is kept here
)}
);
}
// Chatlog: every session you started (or that ran on your instances)
export function SessionLog({ onOpen }) {
const [sessions, setSessions] = useState(null);
const [err, setErr] = useState('');
useEffect(() => { api('/agents/sessions').then((d) => setSessions(d.sessions || [])).catch((e) => { setErr(e.message); setSessions([]); }); }, []);
if (err) return {err}
;
if (!sessions) return loading chatlog…
;
if (!sessions.length) return No sessions yet — open an agent and start one.
;
return (
{sessions.map((s) => (
))}
);
}