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 (
{m.file_name} {fmtBytes(m.file_size)} · {m.file_mime || 'file'}
{mine ? sent ✓ : left > 0 ? ⬇ download · {Math.ceil(left / 60000)}m left : relay link expired — bytes were never stored}
); } 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 ? (
setInput(e.target.value)} />
) : (
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) => ( ))}
); }