Chat messages render markdown via marked (code blocks, lists, tables, links) with a DOMParser-based sanitizer that strips script/style/iframe/ event-handlers and javascript:/data: URLs; links open in a new tab. The agent owns its own links (netdisk, showcase, anywhere) as the user asked. Verified: formatted agent reply renders, injected <script> stays inert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
217 lines
10 KiB
JavaScript
217 lines
10 KiB
JavaScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { marked } from 'marked';
|
|
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.
|
|
|
|
// Render chat text as markdown. The agent (or user) owns its links — we only
|
|
// make them safe and open them in a new tab; the agent decides whether a link
|
|
// points at the netdisk, a showcase, or anywhere else.
|
|
marked.setOptions({ breaks: true, gfm: true });
|
|
function mdHtml(text) {
|
|
let raw;
|
|
try { raw = marked.parse(String(text || '')); } catch { return escapeText(text); }
|
|
if (typeof window === 'undefined' || !window.DOMParser) return escapeText(text);
|
|
const doc = new DOMParser().parseFromString(raw, 'text/html');
|
|
doc.querySelectorAll('script,style,iframe,object,embed,link,meta,form,input,button').forEach((n) => n.remove());
|
|
doc.querySelectorAll('*').forEach((el) => {
|
|
[...el.attributes].forEach((a) => {
|
|
if (/^on/i.test(a.name) || a.name === 'style') el.removeAttribute(a.name);
|
|
if ((a.name === 'href' || a.name === 'src') && /^\s*(javascript|data):/i.test(a.value)) el.removeAttribute(a.name);
|
|
});
|
|
if (el.tagName === 'A') { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); }
|
|
});
|
|
return doc.body.innerHTML;
|
|
}
|
|
function escapeText(s) { return String(s || '').replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
|
|
|
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 (
|
|
<div className="ch-file">
|
|
<span className="ch-file-ico" aria-hidden="true">{isImage ? '🖼️' : '📦'}</span>
|
|
<div className="ch-file-meta">
|
|
<strong>{m.file_name}</strong>
|
|
<span>{fmtBytes(m.file_size)} · {m.file_mime || 'file'}</span>
|
|
</div>
|
|
{mine
|
|
? <span className="ch-file-sent">sent ✓</span>
|
|
: left > 0
|
|
? <a className="px-action ch-file-get" href={`/api/agents/files/${m.file_token}`} download={m.file_name}>⬇ download · {Math.ceil(left / 60000)}m left</a>
|
|
: <span className="ch-file-gone">relay link expired — bytes were never stored</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="ch">
|
|
<div className="ch-head">
|
|
<span className={`ch-dot ${instStatus}`} title={`instance ${instStatus}`} />
|
|
<div className="ch-head-t">
|
|
<strong>{instance.name || `instance #${instance.id}`}</strong>
|
|
<span className="ch-sub">{live ? `${instStatus} · session #${sid ?? '…'}` : `session #${sid} · closed (chatlog)`}</span>
|
|
</div>
|
|
<button className="ch-x" onClick={closeSession} type="button">{live ? 'end session ✕' : 'close ✕'}</button>
|
|
</div>
|
|
{skills.length > 0 && (
|
|
<div className="ch-skills" title="skills the broker reports as loaded">
|
|
<span className="ch-skills-label">skills loaded</span>
|
|
{skills.map((s) => <span key={s} className="ch-skill">{s}</span>)}
|
|
</div>
|
|
)}
|
|
<div className="ch-body" ref={bodyRef}>
|
|
{msgs.map((m) => {
|
|
if (m.kind === 'event') return <div key={m.seq} className="ch-event">— {m.content} · {fmtTime(m.created_at)} —</div>;
|
|
const mine = m.role === 'user';
|
|
return (
|
|
<div key={m.seq} className={`ch-msg ${mine ? 'mine' : 'theirs'}`}>
|
|
{m.kind === 'file' ? <FileMsg m={m} mine={mine} /> : <div className="ch-md" dangerouslySetInnerHTML={{ __html: mdHtml(m.content) }} />}
|
|
<span className="ch-when">{fmtTime(m.created_at)}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{!msgs.length && <div className="ch-event">{sid ? 'no messages yet — say something' : 'opening session…'}</div>}
|
|
</div>
|
|
{up && (
|
|
<div className="ch-upload">
|
|
<span>📤 {up.name}</span>
|
|
<span className="ch-upload-bar"><i style={{ width: `${up.pct}%` }} /></span>
|
|
<span className="ch-upload-phase">{up.phase}</span>
|
|
</div>
|
|
)}
|
|
{err && <p className="px-auth-err ch-err">{err}</p>}
|
|
{live ? (
|
|
<form className="ch-foot" onSubmit={send}>
|
|
<label className="ch-attach" title="send a file or image — relayed live, never stored on the server">
|
|
📎<input type="file" hidden onChange={(e) => { sendFile(e.target.files?.[0]); e.target.value = ''; }} />
|
|
</label>
|
|
<input className="ch-input" placeholder={instStatus === 'online' ? 'message the agent…' : `agent is ${instStatus} — messages queue in the chatlog`} value={input} onChange={(e) => setInput(e.target.value)} />
|
|
<button className="ch-send" type="submit" disabled={!input.trim()}>➤</button>
|
|
</form>
|
|
) : (
|
|
<div className="ch-foot ch-foot-closed">this session is closed — its chatlog is kept here</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 <p className="px-auth-err">{err}</p>;
|
|
if (!sessions) return <p className="ap-hint">loading chatlog…</p>;
|
|
if (!sessions.length) return <p className="ap-hint">No sessions yet — open an agent and start one.</p>;
|
|
return (
|
|
<div className="ch-log">
|
|
{sessions.map((s) => (
|
|
<button key={s.id} className="ch-log-row" type="button" onClick={() => onOpen(s)}>
|
|
<span className={`ch-dot ${s.status === 'active' ? 'online' : 'down'}`} />
|
|
<div className="ch-log-main">
|
|
<strong>{s.class_name || 'agent'} <em>· session #{s.id}</em></strong>
|
|
<span className="ch-log-last">{s.last ? (s.last.kind === 'file' ? `📦 ${s.last.file_name}` : s.last.content) : 'no messages'}</span>
|
|
</div>
|
|
<div className="ch-log-meta">
|
|
<span>{s.messageCount} msgs</span>
|
|
<span>{new Date(s.started_at).toLocaleDateString()}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|