amerc/src/Chat.jsx
artheru 8f00e7668a chat: render markdown inline (2.5.0)
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>
2026-06-11 13:41:46 +08:00

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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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>
);
}