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,