mansion: file-mapper + net-disk rooms; 100MB quota; broker jobs (1.3.0)

- file_maps table + agent fm register/list/delete; public GET /fm/:iid/:name
  relays the download live through the broker (consumer-first rendezvous,
  60s window, zero server storage); broker long-polls /instances/:id/jobs
- netdisk: 100MB per-account quota enforced on upload + /files/usage meter
- Mansion: File-mapper and Net-disk are live rooms (usage bar, embedded
  Netdisk browser); Netdisk relay soon-room retired
- kebab's real instance is ONLINE and replied 'pong' over native chat —
  gate path proven; jobs protocol spec sent to kebab

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
artheru 2026-06-11 03:12:56 +08:00
parent b4b5df91f0
commit 511bd25aa0
5 changed files with 198 additions and 8 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "amerc-site", "name": "amerc-site",
"version": "1.2.0-legioncards", "version": "1.3.0-mansionfiles",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -72,6 +72,12 @@ db.exec(`
CREATE TABLE IF NOT EXISTS showcases ( CREATE TABLE IF NOT EXISTS showcases (
id INTEGER PRIMARY KEY AUTOINCREMENT, owner TEXT NOT NULL, name TEXT NOT NULL, host TEXT UNIQUE NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, owner TEXT NOT NULL, name TEXT NOT NULL, host TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL); created_at INTEGER NOT NULL);
-- file-mapper: agent-published named files; each download is fetched live
-- from the broker and piped through the server stores no bytes.
CREATE TABLE IF NOT EXISTS file_maps (
id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id INTEGER NOT NULL, owner TEXT NOT NULL,
name TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER DEFAULT 0, mime TEXT DEFAULT 'application/octet-stream',
created_at INTEGER NOT NULL, UNIQUE(instance_id, name));
`); `);
try { db.exec("ALTER TABLE api_keys ADD COLUMN owner TEXT DEFAULT ''"); } catch { /* column exists */ } try { db.exec("ALTER TABLE api_keys ADD COLUMN owner TEXT DEFAULT ''"); } catch { /* column exists */ }
@ -135,6 +141,55 @@ function claimFile(token, consumerRes) {
prodReq.resume(); prodReq.resume();
return true; return true;
} }
// broker fetch-jobs (file-mapper): the browser arrives FIRST and waits while
// the broker is told to push the bytes. Consumer-first rendezvous.
const JOB_WAIT_MS = 60 * 1000;
const jobWaiters = new Map(); // instanceId -> Set<fn> (broker long-polls /jobs)
const pendingJobs = new Map(); // instanceId -> [{token, name, file_name}]
const jobConsumers = new Map(); // token -> { res, meta, timer }
function wakeJobs(instId) {
const set = jobWaiters.get(instId); if (!set) return;
jobWaiters.delete(instId);
for (const fn of set) { try { fn(); } catch { /* gone */ } }
}
function waitJobs(instId, ms) {
return new Promise((resolve) => {
let set = jobWaiters.get(instId);
if (!set) { set = new Set(); jobWaiters.set(instId, set); }
const fn = () => { clearTimeout(t); set.delete(fn); resolve(); };
set.add(fn);
const t = setTimeout(fn, ms);
});
}
function enqueueJob(instId, job, consumerRes, meta) {
const entry = { res: consumerRes, meta, timer: null };
entry.timer = setTimeout(() => {
if (jobConsumers.get(job.token) === entry) {
jobConsumers.delete(job.token);
send(consumerRes, 504, { ok: false, error: 'broker did not deliver within 60s — is the instance online?' });
}
}, JOB_WAIT_MS);
jobConsumers.set(job.token, entry);
consumerRes.on('close', () => { if (jobConsumers.get(job.token) === entry) { clearTimeout(entry.timer); jobConsumers.delete(job.token); } });
if (!pendingJobs.has(instId)) pendingJobs.set(instId, []);
pendingJobs.get(instId).push(job);
wakeJobs(instId);
}
function fulfillJob(token, brokerReq, brokerRes) {
const entry = jobConsumers.get(token);
if (!entry) return false;
clearTimeout(entry.timer);
jobConsumers.delete(token);
const headers = { 'Content-Type': entry.meta.mime || 'application/octet-stream', 'Content-Disposition': `attachment; filename="${encodeURIComponent(entry.meta.name || 'file')}"`, 'Cache-Control': 'no-store' };
const len = Number(brokerReq.headers['content-length']) || entry.meta.size;
if (len) headers['Content-Length'] = len;
entry.res.writeHead(200, headers);
brokerReq.pipe(entry.res);
brokerReq.on('end', () => send(brokerRes, 200, { ok: true, delivered: true }));
brokerReq.on('error', () => { try { entry.res.destroy(); } catch { /* gone */ } });
entry.res.on('close', () => { if (!entry.res.writableEnded) try { brokerReq.destroy(); send(brokerRes, 499, { ok: false, error: 'receiver disconnected' }); } catch { /* gone */ } });
return true;
}
const SERVER_IP = process.env.AMERC_SERVER_IP || '104.168.145.233'; const SERVER_IP = process.env.AMERC_SERVER_IP || '104.168.145.233';
// edge token: signed {host} so the showcase-edge can verify a broker may bind that host // edge token: signed {host} so the showcase-edge can verify a broker may bind that host
@ -362,10 +417,15 @@ const server = http.createServer(async (req, res) => {
db.prepare('UPDATE documents SET title=?,folder=?,body=?,updated_by=?,updated_at=? WHERE id=?').run(b.title ?? d.title, b.folder ?? d.folder, b.body ?? d.body, who(id), Date.now(), d.id); return send(res, 200, { ok: true }); } db.prepare('UPDATE documents SET title=?,folder=?,body=?,updated_by=?,updated_at=? WHERE id=?').run(b.title ?? d.title, b.folder ?? d.folder, b.body ?? d.body, who(id), Date.now(), d.id); return send(res, 200, { ok: true }); }
if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'DELETE') { if (id?.kind !== 'user') return send(res, 403, { ok: false, error: 'human login required to delete' }); db.prepare('DELETE FROM documents WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); } if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'DELETE') { if (id?.kind !== 'user') return send(res, 403, { ok: false, error: 'human login required to delete' }); db.prepare('DELETE FROM documents WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); }
// FILES (netdisk) // FILES (netdisk) — each identity gets a 100 MB quota
const NETDISK_QUOTA = 100 * 1024 * 1024;
const netdiskUsed = (owner) => db.prepare('SELECT COALESCE(SUM(size),0) s FROM files WHERE created_by=?').get(owner).s;
if (path === '/files/usage' && method === 'GET') { if (!id) return needAuth(); return send(res, 200, { ok: true, used: netdiskUsed(who(id)), quota: NETDISK_QUOTA }); }
if (path === '/files' && method === 'GET') { if (!id) return needAuth(); const folder = url.searchParams.get('folder'); const rows = folder != null ? db.prepare('SELECT id,name,folder,mime,size,kind,created_by,created_at,updated_at FROM files WHERE folder=? ORDER BY name').all(folder) : db.prepare('SELECT id,name,folder,mime,size,kind,created_by,created_at,updated_at FROM files ORDER BY folder,name').all(); return send(res, 200, { ok: true, files: rows }); } if (path === '/files' && method === 'GET') { if (!id) return needAuth(); const folder = url.searchParams.get('folder'); const rows = folder != null ? db.prepare('SELECT id,name,folder,mime,size,kind,created_by,created_at,updated_at FROM files WHERE folder=? ORDER BY name').all(folder) : db.prepare('SELECT id,name,folder,mime,size,kind,created_by,created_at,updated_at FROM files ORDER BY folder,name').all(); return send(res, 200, { ok: true, files: rows }); }
if (path === '/files' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req, MAX_UPLOAD); if (!b) return send(res, 413, { ok: false, error: 'file too large (max 16MB)' }); if (path === '/files' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req, MAX_UPLOAD); if (!b) return send(res, 413, { ok: false, error: 'file too large (max 16MB)' });
const now = Date.now(); const name = String(b.name || 'file').replace(/[/\\]/g, '_'); const now = Date.now(); const name = String(b.name || 'file').replace(/[/\\]/g, '_');
const incoming = b.kind === 'text' || (typeof b.text === 'string' && b.data == null) ? Buffer.byteLength(b.text || '') : Math.floor(String(b.data || '').length * 0.75);
if (netdiskUsed(who(id)) + incoming > NETDISK_QUOTA) return send(res, 413, { ok: false, error: `net-disk quota exceeded — you have ${((NETDISK_QUOTA - netdiskUsed(who(id))) / 1048576).toFixed(1)} MB of 100 MB left` });
if (b.kind === 'text' || (typeof b.text === 'string' && b.data == null)) { if (b.kind === 'text' || (typeof b.text === 'string' && b.data == null)) {
const text = b.text || ''; const info = db.prepare('INSERT INTO files(name,folder,mime,size,store,kind,text_body,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)').run(name, b.folder || '', b.mime || 'text/plain', Buffer.byteLength(text), '', 'text', text, who(id), now, now); const text = b.text || ''; const info = db.prepare('INSERT INTO files(name,folder,mime,size,store,kind,text_body,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)').run(name, b.folder || '', b.mime || 'text/plain', Buffer.byteLength(text), '', 'text', text, who(id), now, now);
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) }); return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) });
@ -624,6 +684,62 @@ const server = http.createServer(async (req, res) => {
return send(res, 410, { ok: false, error: 'file gone — relay links are single-use and expire in 5 minutes' }); return send(res, 410, { ok: false, error: 'file gone — relay links are single-use and expire in 5 minutes' });
} }
// ---------- FILE-MAPPER: stable agent-published file links, broker-relayed ----------
// agent registers/lists/unregisters named files on its instance
if ((m = path.match(/^\/agents\/instances\/(\d+)\/fm$/))) {
const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' });
if (method === 'GET') {
const akey = url.searchParams.get('accesskey');
if (!(owns(inst) || (akey && akey === inst.accesskey))) return send(res, 403, { ok: false, error: 'not authorized' });
return send(res, 200, { ok: true, maps: db.prepare('SELECT * FROM file_maps WHERE instance_id=? ORDER BY name').all(inst.id) });
}
const b = await readBody(req) || {};
if (!(owns(inst) || (b.accesskey && b.accesskey === inst.accesskey))) return send(res, 403, { ok: false, error: 'not authorized' });
if (method === 'POST') {
const name = String(b.name || '').toLowerCase().replace(/[^a-z0-9._-]/g, '').slice(0, 80);
if (!name) return send(res, 400, { ok: false, error: 'name required (letters/numbers/._-)' });
db.prepare('INSERT INTO file_maps(instance_id,owner,name,file_name,size,mime,created_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(instance_id,name) DO UPDATE SET file_name=excluded.file_name,size=excluded.size,mime=excluded.mime')
.run(inst.id, inst.owner, name, String(b.fileName || name).slice(0, 200), Number(b.size) || 0, String(b.mime || 'application/octet-stream').slice(0, 100), Date.now());
return send(res, 200, { ok: true, url: `https://amerc.ai/api/fm/${inst.id}/${name}` });
}
if (method === 'DELETE') {
db.prepare('DELETE FROM file_maps WHERE instance_id=? AND name=?').run(inst.id, String(b.name || ''));
return send(res, 200, { ok: true });
}
}
// my mapped files across my instances (Mansion room)
if (path === '/fm' && method === 'GET') {
if (!id) return needAuth();
const rows = db.prepare(`SELECT f.*, i.status inst_status, i.last_heartbeat, i.created_at inst_created, c.name class_name
FROM file_maps f JOIN agent_instances i ON i.id=f.instance_id LEFT JOIN agent_classes c ON c.id=i.class_id
WHERE f.owner=? ORDER BY f.created_at DESC`).all(who(id));
return send(res, 200, { ok: true, maps: rows.map((r) => ({ ...r, inst_status: liveStatus({ status: r.inst_status, last_heartbeat: r.last_heartbeat, created_at: r.inst_created }), url: `https://amerc.ai/api/fm/${r.instance_id}/${r.name}` })) });
}
// public download: relayed live through the broker, nothing cached
if ((m = path.match(/^\/fm\/(\d+)\/([a-z0-9._-]+)$/)) && method === 'GET') {
const map = db.prepare('SELECT * FROM file_maps WHERE instance_id=? AND name=?').get(Number(m[1]), m[2]);
if (!map) return send(res, 404, { ok: false, error: 'no such mapped file' });
const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(map.instance_id);
if (!inst || liveStatus(inst) !== 'online') return send(res, 503, { ok: false, error: 'the instance serving this file is offline' });
const token = 'j_' + crypto.randomBytes(18).toString('base64url');
enqueueJob(inst.id, { token, kind: 'file', name: map.name, file_name: map.file_name }, res, { name: map.file_name, size: map.size, mime: map.mime });
return; // responds when the broker delivers (or 504)
}
// broker long-polls its fetch jobs
if ((m = path.match(/^\/agents\/instances\/(\d+)\/jobs$/)) && method === 'GET') {
const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' });
if (url.searchParams.get('accesskey') !== inst.accesskey) return send(res, 401, { ok: false, error: 'bad accesskey' });
let jobs = pendingJobs.get(inst.id) || [];
if (!jobs.length && url.searchParams.get('wait')) { await waitJobs(inst.id, 25000); jobs = pendingJobs.get(inst.id) || []; }
pendingJobs.delete(inst.id);
return send(res, 200, { ok: true, jobs });
}
// broker pushes the bytes for a job (raw body) -> piped to the waiting browser
if ((m = path.match(/^\/agents\/jobs\/([A-Za-z0-9_-]+)$/)) && method === 'POST') {
if (fulfillJob(m[1], req, res)) return;
return send(res, 410, { ok: false, error: 'job gone — the requester disconnected or timed out' });
}
// ---------- self-service agent keys (any logged-in user) ---------- // ---------- self-service agent keys (any logged-in user) ----------
if (path === '/keys' && method === 'GET') { if (path === '/keys' && method === 'GET') {
if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' }); if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });

View File

@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { api } from './api.js'; import { api } from './api.js';
import { copyToast } from './toast.js'; import { copyToast } from './toast.js';
import Netdisk from './Netdisk.jsx';
// amerc Mansion a hall of amerc services. First live service: Showcase (reverse proxy). // amerc Mansion a hall of amerc services. First live service: Showcase (reverse proxy).
export default function Mansion({ auth, setRoute }) { export default function Mansion({ auth, setRoute }) {
@ -22,11 +23,8 @@ export default function Mansion({ auth, setRoute }) {
<div className="mn-rooms"> <div className="mn-rooms">
<ShowcaseRoom loggedIn={loggedIn} handle={handle} setRoute={setRoute} /> <ShowcaseRoom loggedIn={loggedIn} handle={handle} setRoute={setRoute} />
<SoonRoom <FileMapperRoom loggedIn={loggedIn} setRoute={setRoute} />
ico="🗄️" name="Netdisk relay" tag="storage" <NetdiskRoom loggedIn={loggedIn} setRoute={setRoute} />
desc="Mount your amerc netdisk and stream files straight through the edge — share large artifacts by link without uploading them twice."
feats={['link-shareable files', 'resumable streaming', 'agent-readable via API key']}
/>
<SoonRoom <SoonRoom
ico="🤖" name="Hosted webagent" tag="runtime" ico="🤖" name="Hosted webagent" tag="runtime"
desc="Let amerc host the broker for you — keep an agent class always-on without leaving your own machine running." desc="Let amerc host the broker for you — keep an agent class always-on without leaving your own machine running."
@ -63,6 +61,73 @@ function SoonRoom(props) {
); );
} }
// File-mapper: agents publish named files; each download is fetched live from
// the broker and piped through nothing is ever stored on amerc.
function FileMapperRoom({ loggedIn, setRoute }) {
const [maps, setMaps] = useState(null);
const [err, setErr] = useState('');
useEffect(() => {
if (!loggedIn) return;
api('/fm').then((d) => setMaps(d.maps || [])).catch((e) => { setErr(e.message); setMaps([]); });
}, [loggedIn]);
return (
<RoomShell
ico="🗺️" name="File-mapper" tag="live file links" status="live"
desc="Your agent maps a file to a stable link; every download streams live through your broker — the server keeps no copy. Perfect for big or fast-changing artifacts."
feats={['stable public URLs', 'zero server storage', 'served by your own broker']}
>
{!loggedIn ? (
<div className="mn-room-cta"><button className="px-action" onClick={() => setRoute('signin')}>Log in to see your mapped files </button></div>
) : (
<>
{err && <p className="px-auth-err">{err}</p>}
<div className="mn-mine">
<h4 className="ap-h4">My mapped files</h4>
{maps === null ? <p className="ap-hint">loading</p> : maps.length ? maps.map((f) => (
<div key={f.id} className="mn-row">
<span className={`ch-dot ${f.inst_status}`} title={`instance ${f.inst_status}`} />
<a href={f.url} target="_blank" rel="noreferrer">{f.name}</a>
<span className="mn-fm-meta">{f.class_name || `instance #${f.instance_id}`}</span>
<button className="px-action" onClick={() => copyToast(f.url, 'Link copied')}>copy link</button>
</div>
)) : <p className="ap-hint">No mapped files yet. Your broker registers one with <code>POST /api/agents/instances/&lt;id&gt;/fm</code> {'{accesskey, name, fileName, size, mime}'} it becomes a stable link instantly.</p>}
</div>
</>
)}
</RoomShell>
);
}
// Net-disk: real storage on amerc, 100 MB per account.
function NetdiskRoom({ loggedIn, setRoute }) {
const [usage, setUsage] = useState(null);
const [open, setOpen] = useState(false);
useEffect(() => { if (loggedIn) api('/files/usage').then(setUsage).catch(() => {}); }, [loggedIn, open]);
const pct = usage ? Math.min(100, Math.round((usage.used / usage.quota) * 100)) : 0;
return (
<RoomShell
ico="🗄️" name="Net-disk" tag="storage" status="live"
desc="Safe keeping on amerc itself — upload files and notes, edit text in place, share by link. Each account gets 100 MB."
feats={['100 MB per account', 'stored on amerc (the safe option)', 'agents read/write it via API key']}
>
{!loggedIn ? (
<div className="mn-room-cta"><button className="px-action" onClick={() => setRoute('signin')}>Log in to open your net-disk </button></div>
) : (
<>
{usage && (
<div className="mn-quota" title={`${(usage.used / 1048576).toFixed(1)} MB of 100 MB used`}>
<span className="mn-quota-bar"><i style={{ width: `${pct}%`, background: pct > 90 ? '#ff7a8c' : undefined }} /></span>
<span className="mn-quota-label">{(usage.used / 1048576).toFixed(1)} / 100 MB</span>
</div>
)}
<button className="px-action" onClick={() => setOpen((v) => !v)}>{open ? 'Close net-disk' : 'Open net-disk'}</button>
{open && <div className="mn-nd"><Netdisk /></div>}
</>
)}
</RoomShell>
);
}
function ShowcaseRoom({ loggedIn, handle, setRoute }) { function ShowcaseRoom({ loggedIn, handle, setRoute }) {
const [list, setList] = useState([]); const [list, setList] = useState([]);
const [name, setName] = useState(''); const [name, setName] = useState('');

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.2.0-legioncards'; const RELEASE = '1.3.0-mansionfiles';
const NAV = [ const NAV = [
{ id: 'home', label: 'Tavern' }, { id: 'home', label: 'Tavern' },
@ -477,6 +477,7 @@ function StatusPage() {
} }
const CHANGELOG = [ const CHANGELOG = [
{ v: '1.3', date: 'Jun 2026', title: 'Mansion learns to handle files', notes: ['File-mapper: your agent maps a file to a stable link — every download streams live through your broker, nothing stored on amerc', 'Net-disk opened as a Mansion room: 100 MB per account, with a live usage meter', 'And the roster has a real agent online again — say hello to Kebab Webagent'] },
{ v: '1.2', date: 'Jun 2026', title: 'Legion fields agent cards', notes: ['Your classes in My Legion are now the same agent cards as Browse Agents — open one to see and use its instances right there', 'New class properties at publish time: amerc-managed (loads amerc skills), terminal-per-session vs shared terminal, and skills-reload on shared terminals'] }, { v: '1.2', date: 'Jun 2026', title: 'Legion fields agent cards', notes: ['Your classes in My Legion are now the same agent cards as Browse Agents — open one to see and use its instances right there', 'New class properties at publish time: amerc-managed (loads amerc skills), terminal-per-session vs shared terminal, and skills-reload on shared terminals'] },
{ 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.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'] },

View File

@ -1640,3 +1640,11 @@ button {
.ap-mine-cardwrap { display: flex; flex-direction: column; gap: 7px; } .ap-mine-cardwrap { display: flex; flex-direction: column; gap: 7px; }
.ap-mine-cardwrap .ap-card { width: 100%; } .ap-mine-cardwrap .ap-card { width: 100%; }
.ap-mine-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .ap-mine-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
/* mansion file rooms (1.3): file-mapper + net-disk quota */
.mn-quota { display: flex; align-items: center; gap: 10px; margin: 8px 0 10px; }
.mn-quota-bar { flex: 1; height: 8px; background: #1a2438; border-radius: 99px; overflow: hidden; }
.mn-quota-bar i { display: block; height: 100%; background: linear-gradient(90deg, #46c8e0, #7fd9ea); border-radius: 99px; transition: width 0.3s; }
.mn-quota-label { color: #8fa3b8; font-size: 11.5px; flex: 0 0 auto; }
.mn-fm-meta { color: #6e7e94; font-size: 11px; }
.mn-nd { margin-top: 10px; background: #0b0f1a; border: 1px solid #20283c; border-radius: 10px; padding: 8px; }