feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars

- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 17:21:33 +07:00
parent 8ed5018a2f
commit 7a88b74016
63 changed files with 3833 additions and 354 deletions
+92 -1
View File
@@ -1,7 +1,9 @@
/**
* @file artifacts.js (routes)
* @description `/api/artifacts` — the Artifacts hub (server/lib/artifacts.js):
* list, add (a claude.ai page, a pinned workspace file, a skill, a plugin),
* list, add (a claude.ai page, a pinned workspace file or an uploaded one, a
* skill — picked, uploaded (file, folder, zip) or written —, a plugin, after
* reading its marketplace's plugin list from a link),
* rename/share/unshare, delete, read a snapshot file (preview/download, under
* a `sandbox` CSP with `nosniff`, so nothing shared runs on the dashboard's
* origin), and install a shared skill or plugin into the caller's own
@@ -32,6 +34,8 @@ const STATUS = {
EUPGRADE: 409,
ENOTSHARED: 409,
EPLUGIN: 502,
EMARKETFETCH: 502,
ENEEDLOGIN: 502,
ETOOBIG: 413,
};
@@ -152,6 +156,93 @@ router.post(
})
);
// Read a marketplace (a git link, owner/repo, or a marketplace.json link)
// and list its plugins to pick from. A POST: it reaches the network.
router.post(
"/plugin/inspect",
sameOriginGuard,
wrap(async (req, res) => res.json(await artifacts.inspectMarket(req.body?.market)))
);
// A skill written in the dialog: {name, description, content, title?}.
router.post(
"/skill/new",
sameOriginGuard,
wrap((req, res) => {
const item = artifacts.writeSkill(req, req.body || {});
changed(item);
res.status(201).json({ item, items: [item] });
})
);
/** multer, in memory, for one upload route; errors answered like ours. */
function upload(limits) {
return (req, res, next) => {
const multer = require("multer");
multer({ storage: multer.memoryStorage(), limits }).array("files")(req, res, (err) => {
if (!err) return next();
const big = err.code === "LIMIT_FILE_SIZE" || err.code === "LIMIT_FILE_COUNT";
return res.status(big ? 413 : 400).json({
error: { code: big ? "ETOOBIG" : "EBADUPLOAD", message: err.message },
});
});
};
}
/** Each uploaded file with the relative path the browser sent in `paths` (a folder pick keeps it). */
function uploads(req) {
let paths = [];
try {
paths = JSON.parse(req.body?.paths || "[]");
} catch {
paths = [];
}
return (req.files || []).map((f, i) => ({
path: typeof paths[i] === "string" && paths[i] ? paths[i] : f.originalname,
data: f.buffer,
}));
}
// Skills from files: one Markdown file, a folder, several files, a .zip/.skill,
// or several skills at once (server/lib/artifacts.js `skillsFromUpload`).
router.post(
"/skill/upload",
sameOriginGuard,
upload({ files: 400, fileSize: 10 * 1024 * 1024, fields: 10 }),
wrap((req, res) => {
const b = req.body || {};
const items = artifacts.uploadSkills(req, {
uploads: uploads(req),
name: b.name,
title: b.title,
description: b.description,
});
for (const item of items) changed(item);
res.status(201).json({ items });
})
);
// One file from the user's computer, kept like a pinned workspace file.
router.post(
"/file/upload",
sameOriginGuard,
upload({ files: 1, fileSize: 25 * 1024 * 1024, fields: 10 }),
wrap((req, res) => {
const f = (req.files || [])[0];
if (!f) {
return res.status(400).json({ error: { code: "ENOFILE", message: "attach the file as `files`" } });
}
const item = artifacts.uploadFile(req, {
name: f.originalname,
data: f.buffer,
title: req.body?.title,
description: req.body?.description,
});
changed(item);
res.status(201).json({ item });
})
);
router.post(
"/plugin",
sameOriginGuard,
+50 -1
View File
@@ -22,13 +22,18 @@
* `POST /sign-out-others` ends every login but this one. These writes take the
* account from the login COOKIE only — a pane token (an agent acting as its
* owner) may not change the owner's login. Each change is audited.
* `/avatar` (server mode) is the account's own picture (server/lib/avatars.js):
* GET serves it (`nosniff`, `sandbox` CSP), PUT takes the raw image bytes
* (PNG/JPEG/GIF/WebP, at most 1 MB) and DELETE removes it — both writes are
* account writes like the profile's. The sidebar shows it beside the name.
*
* S3 (one Linux user per account): an account's dark/light choice also becomes
* the theme in their own Claude Code settings, written as them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const express = require("express");
const { Router } = express;
const { db } = require("../db");
const auth = require("../lib/auth");
const { sameOriginGuard } = require("../lib/same-origin");
@@ -162,6 +167,8 @@ const ACCOUNT_STATUS = {
EBADFIELD: 400,
EBADEMAIL: 400,
EBADPASSWORD: 400,
EBADIMAGE: 400,
ETOOBIG: 413,
EREAUTH: 403,
ETOOMANY: 429,
};
@@ -237,6 +244,48 @@ router.post(
})
);
const avatars = require("../lib/avatars");
router.get("/avatar", (req, res) => {
if (!auth.isServerMode()) return res.status(404).json({ error: { code: "ENOAVATAR" } });
const user = req.user || auth.userFromRequest(req);
if (!user) return unauth(res);
const file = avatars.avatarFile(user.id);
if (!file) return res.status(404).json({ error: { code: "ENOAVATAR", message: "no avatar" } });
res.set("Content-Type", file.type);
res.set("X-Content-Type-Options", "nosniff");
res.set("Content-Security-Policy", "sandbox; default-src 'none'");
// The client versions the URL with `avatarAt`, so a new picture is a new URL.
res.set("Cache-Control", "private, max-age=86400");
res.sendFile(file.path);
});
router.put(
"/avatar",
sameOriginGuard,
express.raw({ type: () => true, limit: avatars.MAX_AVATAR_BYTES }),
accountWrite(async (req, user) => {
avatars.setAvatar(user.id, req.body);
return { out: accountView(user.id), action: "own avatar set" };
})
);
router.delete(
"/avatar",
sameOriginGuard,
accountWrite(async (_req, user) => {
avatars.removeAvatar(user.id);
return { out: accountView(user.id), action: "own avatar removed" };
})
);
// An avatar past its limit reaches here as express.raw's 413.
router.use((e, _req, res, next) => {
if (e && e.type === "entity.too.large")
return res.status(413).json({ error: { code: "ETOOBIG", message: "the image is too large" } });
return next(e);
});
/** S3: an account's dark/light mode becomes their Claude Code theme (best effort). */
function followClaudeTheme(req, mode) {
if (mode !== "dark" && mode !== "light") return;
+35 -5
View File
@@ -287,9 +287,32 @@ const BUCKET_COLS = ["model", "speed", "inference_geo", "service_tier"];
* its session's `tool`, for the per-tool split.
*/
function dailyTokenRows(tzModifier, whereClause, params) {
return bookedTokenRows(
{ total: "DATE(s.started_at, ?)", hour: "DATE(h.hour || ':00:00', ?)", args: [tzModifier] },
whereClause,
params
);
}
/**
* The same booking per UTC hour (`YYYY-MM-DDTHH` in `date`) and per session
* (`session_id`): what the account pool sums over a run's own hours. The rest
* of a total goes to the session's start hour.
*/
function hourlyTokenRows(whereClause, params) {
return bookedTokenRows(
{ total: "substr(s.started_at, 1, 13)", hour: "h.hour", args: [] },
whereClause,
params,
{ bySession: true }
);
}
/** `slot.total`/`slot.hour` name the period of a total and of an hourly row (`slot.args` bound once each). */
function bookedTokenRows(slot, whereClause, params, { bySession = false } = {}) {
const totals = db
.prepare(
`SELECT tu.session_id, DATE(s.started_at, ?) as date, s.tool,
`SELECT tu.session_id, ${slot.total} as date, s.tool,
${BUCKET_COLS.map((c) => `tu.${c}`).join(", ")},
tu.input_tokens + tu.baseline_input as input_tokens,
tu.output_tokens + tu.baseline_output as output_tokens,
@@ -304,10 +327,10 @@ function dailyTokenRows(tzModifier, whereClause, params) {
JOIN sessions s ON s.id = tu.session_id
${whereClause}`
)
.all(tzModifier, ...params);
.all(...slot.args, ...params);
const hourly = db
.prepare(
`SELECT h.session_id, DATE(h.hour || ':00:00', ?) as date,
`SELECT h.session_id, ${slot.hour} as date,
${BUCKET_COLS.map((c) => `h.${c}`).join(", ")},
${USAGE_COLS.map((c) => `SUM(h.${c}) as ${c}`).join(", ")}
FROM token_usage_hourly h
@@ -315,7 +338,7 @@ function dailyTokenRows(tzModifier, whereClause, params) {
${whereClause}
GROUP BY h.session_id, 2, ${BUCKET_COLS.map((c) => `h.${c}`).join(", ")}`
)
.all(tzModifier, ...params);
.all(...slot.args, ...params);
const keyOf = (r) => [r.session_id, ...BUCKET_COLS.map((c) => r[c])].join("\u0001");
const hoursByBucket = new Map();
@@ -327,12 +350,18 @@ function dailyTokenRows(tzModifier, whereClause, params) {
const out = new Map();
const add = (date, bucket, values) => {
const k = [date, bucket.tool, ...BUCKET_COLS.map((c) => bucket[c])].join("\u0001");
const k = [
date,
bucket.tool,
...(bySession ? [bucket.session_id] : []),
...BUCKET_COLS.map((c) => bucket[c]),
].join("\u0001");
let row = out.get(k);
if (!row) {
row = {
date,
tool: bucket.tool,
...(bySession ? { session_id: bucket.session_id } : {}),
...Object.fromEntries(BUCKET_COLS.map((c) => [c, bucket[c]])),
};
for (const c of USAGE_COLS) row[c] = 0;
@@ -697,6 +726,7 @@ function attachAgentCosts(agents) {
module.exports = router;
module.exports.calculateCost = calculateCost;
module.exports.dailyTokenRows = dailyTokenRows;
module.exports.hourlyTokenRows = hourlyTokenRows;
module.exports.costWhere = costWhere;
module.exports.computeCost = computeCost;
module.exports.agentOwnCost = agentOwnCost;
+22 -2
View File
@@ -5,9 +5,12 @@
* `GET /` lists every account with its workspaces, sessions and $ spent in the
* last 7 and 30 days (from `token_usage_hourly`, sessions owned through their
* workspace — server/lib/scope.js); `GET /audit` the newest audit entries
* (server/lib/audit.js).
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
*
* `GET /:id` is one account with the workspaces it owns, for the Edit user form.
* `GET /:id` is one account with the workspaces it owns, for the Edit user form;
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
* owner groups — staff only like the rest of `/users`.
*
* The Users page's account changes — `POST /` (create), `PATCH /:id` (the Edit
* user form: display name, email, role, disabled, a new password, workspaces
@@ -84,6 +87,11 @@ router.get("/", (_req, res) => {
res.json({ users });
});
router.get("/storage", (req, res) => {
const { storageFor } = require("../lib/user-storage");
res.json(storageFor(listUsers(), { refresh: req.query.refresh === "1" }));
});
router.get("/audit", (req, res) => {
res.json({ entries: listAudit(req.query.limit) });
});
@@ -149,6 +157,18 @@ router.get("/:id", (req, res) => {
});
});
router.get("/:id/avatar", (req, res) => {
const id = Number(req.params.id);
const file = Number.isSafeInteger(id) && id > 0 ? require("../lib/avatars").avatarFile(id) : null;
if (!file) return res.status(404).json({ error: { code: "ENOAVATAR", message: "no avatar" } });
res.set("Content-Type", file.type);
res.set("X-Content-Type-Options", "nosniff");
res.set("Content-Security-Policy", "sandbox; default-src 'none'");
// Versioned by the client with the owner's `avatarAt`.
res.set("Cache-Control", "private, max-age=86400");
res.sendFile(file.path);
});
/** A list of workspace ids from the body, or [] when absent. */
function laneIds(value, field) {
if (value === undefined) return [];