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:
parent
b4b5df91f0
commit
511bd25aa0
@ -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": {
|
||||||
|
|||||||
@ -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' });
|
||||||
|
|||||||
@ -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/<id>/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('');
|
||||||
|
|||||||
@ -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'] },
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user