diff --git a/package.json b/package.json
index b0f1705..bd07f17 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "amerc-site",
- "version": "1.0.0-legion",
+ "version": "1.1.0-chat",
"private": true,
"type": "module",
"scripts": {
diff --git a/src/AgentPlatform.jsx b/src/AgentPlatform.jsx
index 8c42b99..27ab2a3 100644
--- a/src/AgentPlatform.jsx
+++ b/src/AgentPlatform.jsx
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from './api.js';
import { copyToast } from './toast.js';
+import ChatPanel, { SessionLog } from './Chat.jsx';
const STATUS_COLOR = { online: '#36f0b0', pending: '#f2b85f', lost: '#ff9f5a', down: '#ff7a8c' };
const TAG_EMOJI = { backend: 'π οΈ', model: 'π§ ', frontend: 'π¨', ui: 'ποΈ', web: 'π', data: 'π', devops: 'βοΈ', ml: 'π€', security: 'π', api: 'π', bot: 'π€', chat: 'π¬', test: 'π§ͺ', docs: 'π', design: 'βοΈ', ops: 'π¦', research: 'π¬' };
@@ -174,12 +175,14 @@ function ClassModal({ id, auth, onClose }) {
);
}
-// ---- Use an instance: chatbox + web-pty ----------------------------------
+// ---- Use an instance: native chat + web-pty -------------------------------
export function UsePanel({ instance, onClose }) {
const [mode, setMode] = useState('chatbox');
const [sess, setSess] = useState(null);
const [accesskey, setAccesskey] = useState(instance.accesskey || '');
const [err, setErr] = useState('');
+ const needKey = !instance.accesskey && instance.visibility !== 'public';
+ const [keyOk, setKeyOk] = useState(!needKey);
const start = useCallback(async () => {
setErr('');
try {
@@ -187,7 +190,6 @@ export function UsePanel({ instance, onClose }) {
setSess(r);
} catch (e) { setErr(e.message); }
}, [instance.id, mode, accesskey]);
- useEffect(() => { if (instance.accesskey) start(); }, []); // owner: auto-start
return (
@@ -198,26 +200,23 @@ export function UsePanel({ instance, onClose }) {
- {!sess && (
+ {needKey && !keyOk && (
- {!instance.accesskey && instance.visibility !== 'public' && (
- setAccesskey(e.target.value)} />
- )}
-
+ setAccesskey(e.target.value)} />
+
+
+ )}
+ {mode === 'chatbox' && keyOk && }
+ {mode === 'webpty' && keyOk && !sess && (
+
+
{err &&
{err}
}
)}
- {sess && (
+ {mode === 'webpty' && sess && (
<>
-
- {mode === 'chatbox' && (
-
-
π Embed this agent on any website
-
{``}
-
Paste before </body> β a chat bubble appears bottom-right, and on connect your page's skills are injected into the agent.
-
- )}
- {mode === 'webpty' && π₯οΈ Switch to the εε° / Backstage tab above to watch and drive the agent's live terminal.
}
+
+ π₯οΈ The web terminal rides the webagent relay; chat no longer does.
>
)}
@@ -273,6 +272,7 @@ export function LegionDashboard({ auth, setRoute }) {
const [err, setErr] = useState('');
const [form, setForm] = useState({ name: '', tags: '', description: '', kind: 'codex', command: 'codex --yolo', visibility: 'public' });
const [newInst, setNewInst] = useState(null);
+ const [logOpen, setLogOpen] = useState(null); // chatlog session to reopen
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (!newInst) return;
@@ -393,7 +393,19 @@ export function LegionDashboard({ auth, setRoute }) {
)}
+
+
π¬ My chatlog
+
Every session keeps its log β reopen one to read it back, or continue it if still active.
+
+
+ {logOpen && (
+ setLogOpen(null)}>
+
e.stopPropagation()}>
+ setLogOpen(null)} />
+
+
+ )}
{newInst && (() => {
const hb = `while true; do curl -s -XPOST https://amerc.ai/api/agents/instances/${newInst.id}/heartbeat -H 'content-type: application/json' -d '{"accesskey":"${newInst.accesskey}","status":"online","broker":"my-broker"}' >/dev/null; sleep 10; done`;
return (
diff --git a/src/Chat.jsx b/src/Chat.jsx
new file mode 100644
index 0000000..32be991
--- /dev/null
+++ b/src/Chat.jsx
@@ -0,0 +1,194 @@
+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 (
+
+
{isImage ? 'πΌοΈ' : 'π¦'}
+
+ {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 ? (
+
+ ) : (
+
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) => (
+
+ ))}
+
+ );
+}
diff --git a/src/Scene2D.jsx b/src/Scene2D.jsx
index e7e7b04..0c2e7fd 100644
--- a/src/Scene2D.jsx
+++ b/src/Scene2D.jsx
@@ -10,7 +10,7 @@ import { api } from './api.js';
const Menu = ({ size = 20 }) => ();
const X = ({ size = 20 }) => ();
-const RELEASE = '1.0.0-legion';
+const RELEASE = '1.1.0-chat';
const NAV = [
{ id: 'home', label: 'Tavern' },
@@ -477,6 +477,7 @@ function StatusPage() {
}
const CHANGELOG = [
+ { v: '1.1', date: 'Jun 2026', title: 'Chat, rebuilt native', notes: ['Agent chat now runs on amerc itself β no external relay, so it works whenever the site is up', 'Send files and images both ways: relayed live with a progress bar, never stored on the server', 'The chat shows which skills the agent has loaded', 'Every session keeps its chatlog β reopen any conversation from My Legion'] },
{ v: '1.0', date: 'Jun 2026', title: 'Legion & My Mansion', notes: ['My Booth is now your Legion β your war-band of agent classes and the instances you field', 'The Mansion became My Mansion, your estate of live services'] },
{ v: '0.99', date: 'Jun 2026', title: 'Clearer publish form', notes: ['The Booth publish form now labels every field, so the pre-filled Kind and Launch command are no longer ambiguous'] },
{ v: '0.98', date: 'Jun 2026', title: 'Live API console', notes: ['The home-page API peek now shows the response status and timing, with a run button to re-fetch'] },
diff --git a/src/styles.css b/src/styles.css
index d435ffe..df163c8 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1570,3 +1570,61 @@ button {
.ap-pub-field { display: flex; flex-direction: column; gap: 4px; }
.ap-pub-field > span { font-size: 11px; color: #8a7bb0; letter-spacing: 0.3px; }
.ap-row .ap-pub-field { flex: 1; }
+
+/* native chat panel (1.1) β long-poll chat, file relay, chatlog */
+.ch { display: flex; flex-direction: column; background: #0b0f1a; border: 1px solid #2a2440; border-radius: 12px; overflow: hidden; min-height: 380px; max-height: 64vh; }
+.ch-head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: #131022; border-bottom: 1px solid #2a2440; }
+.ch-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; background: #555; }
+.ch-dot.online { background: #36f0b0; box-shadow: 0 0 8px #36f0b0; animation: chpulse 2s infinite; }
+.ch-dot.pending { background: #f2b85f; }
+.ch-dot.lost { background: #ff9f5a; }
+.ch-dot.down { background: #ff7a8c; }
+@keyframes chpulse { 0%, 100% { box-shadow: 0 0 4px #36f0b0; } 50% { box-shadow: 0 0 11px #36f0b0; } }
+.ch-head-t { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
+.ch-head-t strong { color: #eee6ff; font-size: 13.5px; }
+.ch-sub { color: #8a7bb0; font-size: 11px; }
+.ch-x { background: none; border: 1px solid #3a3354; color: #b6a7d4; border-radius: 7px; padding: 4px 10px; font-size: 11px; cursor: pointer; font-family: inherit; flex: 0 0 auto; }
+.ch-x:hover { border-color: #6a5a9a; color: #eee6ff; }
+.ch-skills { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 7px 12px; background: #0e1320; border-bottom: 1px solid #1c2438; }
+.ch-skills-label { color: #5b6b7d; font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; }
+.ch-skill { background: rgba(39, 216, 255, 0.09); border: 1px solid #1f4456; color: #7fd9ea; border-radius: 999px; padding: 2px 9px; font-size: 11px; }
+.ch-body { flex: 1; overflow-y: auto; padding: 14px 12px; display: flex; flex-direction: column; gap: 9px; }
+.ch-event { text-align: center; color: #5b6b7d; font-size: 11px; padding: 2px 0; }
+.ch-msg { max-width: 82%; padding: 8px 12px; border-radius: 13px; position: relative; }
+.ch-msg p { margin: 0; color: #e8eef8; font-size: 13px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
+.ch-msg.mine { align-self: flex-end; background: #2a2360; border: 1px solid #443a7e; border-bottom-right-radius: 4px; }
+.ch-msg.theirs { align-self: flex-start; background: #151c2c; border: 1px solid #243049; border-bottom-left-radius: 4px; }
+.ch-when { display: block; margin-top: 3px; color: #5e5876; font-size: 9.5px; text-align: right; }
+.ch-file { display: flex; align-items: center; gap: 10px; }
+.ch-file-ico { font-size: 22px; }
+.ch-file-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
+.ch-file-meta strong { color: #eee6ff; font-size: 12.5px; word-break: break-all; }
+.ch-file-meta span { color: #8a7bb0; font-size: 10.5px; }
+.ch-file-get { flex: 0 0 auto; text-decoration: none; font-size: 11px; }
+.ch-file-sent { color: #36f0b0; font-size: 11px; flex: 0 0 auto; }
+.ch-file-gone { color: #6e6788; font-size: 10.5px; font-style: italic; }
+.ch-upload { display: flex; align-items: center; gap: 9px; padding: 7px 12px; background: #10182a; border-top: 1px solid #1c2438; font-size: 11.5px; color: #b6c5da; }
+.ch-upload-bar { flex: 1; height: 6px; background: #1a2438; border-radius: 99px; overflow: hidden; }
+.ch-upload-bar i { display: block; height: 100%; background: linear-gradient(90deg, #46c8e0, #7fd9ea); border-radius: 99px; transition: width 0.2s; }
+.ch-upload-phase { color: #7fd9ea; flex: 0 0 auto; }
+.ch-err { margin: 4px 12px; }
+.ch-foot { display: flex; align-items: center; gap: 8px; padding: 9px 10px; background: #131022; border-top: 1px solid #2a2440; }
+.ch-attach { cursor: pointer; font-size: 17px; padding: 4px 7px; border-radius: 8px; border: 1px solid #3a3354; line-height: 1; }
+.ch-attach:hover { border-color: #6a5a9a; background: #1c1733; }
+.ch-input { flex: 1; background: #0b0f1a; border: 1px solid #3a3354; color: #e8eef8; border-radius: 9px; padding: 9px 12px; font-size: 13px; font-family: inherit; outline: none; }
+.ch-input:focus { border-color: #6a5a9a; }
+.ch-send { background: #46c8e0; color: #07111a; border: none; border-radius: 9px; padding: 8px 15px; font-size: 14px; cursor: pointer; }
+.ch-send:disabled { opacity: 0.35; cursor: default; }
+.ch-send:hover:not(:disabled) { background: #6ad6ea; }
+.ch-foot-closed { justify-content: center; color: #6e6788; font-size: 11.5px; font-style: italic; }
+/* chatlog list (Legion) */
+.ap-chatlog { margin-top: 20px; background: #11101e; border: 1px solid #2a2440; border-radius: 12px; padding: 14px 16px; }
+.ch-log { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
+.ch-log-row { display: flex; align-items: center; gap: 10px; background: #0b0f1a; border: 1px solid #243049; border-radius: 10px; padding: 9px 12px; cursor: pointer; text-align: left; font-family: inherit; }
+.ch-log-row:hover { border-color: #46c8e0; }
+.ch-log-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
+.ch-log-main strong { color: #eee6ff; font-size: 12.5px; }
+.ch-log-main em { color: #8a7bb0; font-style: normal; font-weight: 400; font-size: 11px; }
+.ch-log-last { color: #8595ab; font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.ch-log-meta { display: flex; flex-direction: column; gap: 2px; align-items: flex-end; color: #5b6b7d; font-size: 10.5px; flex: 0 0 auto; }
+.ap-modal-chat { width: min(660px, 94vw); padding: 10px; }