chat: native ChatPanel + chatlog + file relay UI (1.1.0)
- src/Chat.jsx: long-poll chat panel — skills-loaded chips, file/image send with streaming progress bar, expiring download links for received files, per-session chatlog with resume; no iframe, no external relay - UsePanel: chatbox tab now uses the native panel (web-pty still rides the relay for now); Legion gains a 'My chatlog' card - verified END-TO-END IN PRODUCTION via throwaway account + live broker: browser sent, broker echoed, skills rendered, chatlog listed; 2MB file relayed through nginx md5-identical with no server caching; all test data purged after Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
34fb174308
commit
2941f14583
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "amerc-site",
|
"name": "amerc-site",
|
||||||
"version": "1.0.0-legion",
|
"version": "1.1.0-chat",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { copyToast } from './toast.js';
|
import { copyToast } from './toast.js';
|
||||||
|
import ChatPanel, { SessionLog } from './Chat.jsx';
|
||||||
|
|
||||||
const STATUS_COLOR = { online: '#36f0b0', pending: '#f2b85f', lost: '#ff9f5a', down: '#ff7a8c' };
|
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: '🔬' };
|
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 }) {
|
export function UsePanel({ instance, onClose }) {
|
||||||
const [mode, setMode] = useState('chatbox');
|
const [mode, setMode] = useState('chatbox');
|
||||||
const [sess, setSess] = useState(null);
|
const [sess, setSess] = useState(null);
|
||||||
const [accesskey, setAccesskey] = useState(instance.accesskey || '');
|
const [accesskey, setAccesskey] = useState(instance.accesskey || '');
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
|
const needKey = !instance.accesskey && instance.visibility !== 'public';
|
||||||
|
const [keyOk, setKeyOk] = useState(!needKey);
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(async () => {
|
||||||
setErr('');
|
setErr('');
|
||||||
try {
|
try {
|
||||||
@ -187,7 +190,6 @@ export function UsePanel({ instance, onClose }) {
|
|||||||
setSess(r);
|
setSess(r);
|
||||||
} catch (e) { setErr(e.message); }
|
} catch (e) { setErr(e.message); }
|
||||||
}, [instance.id, mode, accesskey]);
|
}, [instance.id, mode, accesskey]);
|
||||||
useEffect(() => { if (instance.accesskey) start(); }, []); // owner: auto-start
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ap-use">
|
<div className="ap-use">
|
||||||
@ -198,26 +200,23 @@ export function UsePanel({ instance, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
<button className="ap-use-x" onClick={onClose}>close ✕</button>
|
<button className="ap-use-x" onClick={onClose}>close ✕</button>
|
||||||
</div>
|
</div>
|
||||||
{!sess && (
|
{needKey && !keyOk && (
|
||||||
<div className="ap-use-start">
|
<div className="ap-use-start">
|
||||||
{!instance.accesskey && instance.visibility !== 'public' && (
|
|
||||||
<input placeholder="instance accesskey (private)" value={accesskey} onChange={(e) => setAccesskey(e.target.value)} />
|
<input placeholder="instance accesskey (private)" value={accesskey} onChange={(e) => setAccesskey(e.target.value)} />
|
||||||
|
<button className="px-action" onClick={() => setKeyOk(true)}>Connect</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<button className="px-action" onClick={start}>Connect {mode}</button>
|
{mode === 'chatbox' && keyOk && <ChatPanel instance={instance} accesskey={accesskey || undefined} onClose={onClose} />}
|
||||||
|
{mode === 'webpty' && keyOk && !sess && (
|
||||||
|
<div className="ap-use-start">
|
||||||
|
<button className="px-action" onClick={start}>Connect webpty</button>
|
||||||
{err && <p className="px-auth-err">{err}</p>}
|
{err && <p className="px-auth-err">{err}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sess && (
|
{mode === 'webpty' && sess && (
|
||||||
<>
|
<>
|
||||||
<iframe title="agent" className="ap-iframe" src={`https://webagent.amerc.ai/chat_session?session=${encodeURIComponent(sess.relayToken)}`} />
|
<iframe title="agent terminal" className="ap-iframe" src={`https://webagent.amerc.ai/chat_session?session=${encodeURIComponent(sess.relayToken)}`} />
|
||||||
{mode === 'chatbox' && (
|
<p className="ap-hint ap-pty-note">🖥️ The web terminal rides the webagent relay; chat no longer does.</p>
|
||||||
<div className="ap-embed">
|
|
||||||
<div className="ap-embed-head"><span>📎 Embed this agent on any website</span><button className="px-action" onClick={() => copyToast(`<script src="${sess.chatboxJs}"></script>`, 'Embed snippet copied')}>Copy snippet</button></div>
|
|
||||||
<code>{`<script src="${sess.chatboxJs}"></script>`}</code>
|
|
||||||
<p className="ap-hint">Paste before <code></body></code> — a chat bubble appears bottom-right, and on connect your page's skills are injected into the agent.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mode === 'webpty' && <p className="ap-hint ap-pty-note">🖥️ Switch to the <b>后台 / Backstage</b> tab above to watch and drive the agent's live terminal.</p>}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -273,6 +272,7 @@ export function LegionDashboard({ auth, setRoute }) {
|
|||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
const [form, setForm] = useState({ name: '', tags: '', description: '', kind: 'codex', command: 'codex --yolo', visibility: 'public' });
|
const [form, setForm] = useState({ name: '', tags: '', description: '', kind: 'codex', command: 'codex --yolo', visibility: 'public' });
|
||||||
const [newInst, setNewInst] = useState(null);
|
const [newInst, setNewInst] = useState(null);
|
||||||
|
const [logOpen, setLogOpen] = useState(null); // chatlog session to reopen
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!newInst) return;
|
if (!newInst) return;
|
||||||
@ -393,7 +393,19 @@ export function LegionDashboard({ auth, setRoute }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ap-chatlog">
|
||||||
|
<h4 className="ap-h4">💬 My chatlog</h4>
|
||||||
|
<p className="ap-hint">Every session keeps its log — reopen one to read it back, or continue it if still active.</p>
|
||||||
|
<SessionLog onOpen={setLogOpen} />
|
||||||
|
</div>
|
||||||
<KeysCard />
|
<KeysCard />
|
||||||
|
{logOpen && (
|
||||||
|
<div className="ap-modal" onClick={() => setLogOpen(null)}>
|
||||||
|
<div className="ap-modal-box ap-modal-chat" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<ChatPanel instance={{ id: logOpen.instance_id, name: logOpen.inst_name || logOpen.class_name }} sessionId={logOpen.id} onClose={() => setLogOpen(null)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{newInst && (() => {
|
{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`;
|
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 (
|
return (
|
||||||
|
|||||||
194
src/Chat.jsx
Normal file
194
src/Chat.jsx
Normal file
@ -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 (
|
||||||
|
<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} /> : <p>{m.content}</p>}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,7 +10,7 @@ import { api } from './api.js';
|
|||||||
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
|
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
|
||||||
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
|
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
|
||||||
|
|
||||||
const RELEASE = '1.0.0-legion';
|
const RELEASE = '1.1.0-chat';
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ id: 'home', label: 'Tavern' },
|
{ id: 'home', label: 'Tavern' },
|
||||||
@ -477,6 +477,7 @@ function StatusPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CHANGELOG = [
|
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: '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.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'] },
|
{ 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'] },
|
||||||
|
|||||||
@ -1570,3 +1570,61 @@ button {
|
|||||||
.ap-pub-field { display: flex; flex-direction: column; gap: 4px; }
|
.ap-pub-field { display: flex; flex-direction: column; gap: 4px; }
|
||||||
.ap-pub-field > span { font-size: 11px; color: #8a7bb0; letter-spacing: 0.3px; }
|
.ap-pub-field > span { font-size: 11px; color: #8a7bb0; letter-spacing: 0.3px; }
|
||||||
.ap-row .ap-pub-field { flex: 1; }
|
.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; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user