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:
artheru 2026-06-11 02:51:22 +08:00
parent 34fb174308
commit 2941f14583
5 changed files with 284 additions and 19 deletions

View File

@ -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": {

View File

@ -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>&lt;/body&gt;</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
View 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>
);
}

View File

@ -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'] },

View File

@ -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; }