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
+117 -5
View File
@@ -544,6 +544,14 @@ export type ServerUserUsage = AuthUser & {
logins?: number;
lastLoginAt?: string | null;
};
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
export type UserStorage = {
bytes: number;
files: number;
/** The walk timed out or met an unreadable folder: a lower bound. */
partial: boolean;
measuredAt: string;
} | null;
/** GET /api/users/:id — one account plus the workspaces it owns. */
export type UserDetail = {
user: AuthUser & { disabled: boolean; logins?: number; lastLoginAt?: string | null };
@@ -585,6 +593,8 @@ export type PoolAccount = {
livePanes: number;
spend5h: number;
spend7d: number;
tokens5h: number;
tokens7d: number;
quotaPercent: number | null;
full: boolean;
};
@@ -596,6 +606,8 @@ export type CodexPoolAccount = {
livePanes: number;
spend5h: number;
spend7d: number;
tokens5h: number;
tokens7d: number;
quotaPercent: number | null;
full: boolean;
};
@@ -644,6 +656,8 @@ export type AuthUser = {
/** Optional profile fields — absent from an older server's answers. */
displayName?: string | null;
email?: string | null;
/** When the account's avatar was last set (null = none) — versions its URL. */
avatarAt?: string | null;
createdAt?: string;
};
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
@@ -679,6 +693,10 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
...(token ? { "x-dashboard-token": token } : {}),
...((options?.headers as Record<string, string>) || {}),
};
// A multipart upload: the browser writes Content-Type with its boundary.
if (typeof FormData !== "undefined" && options?.body instanceof FormData) {
delete headers["Content-Type"];
}
const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (!res.ok) {
// Try to recover a structured error message from the JSON body; if the body
@@ -857,14 +875,35 @@ export const api = {
}),
/** POST /api/me/sign-out-others — end every login of this account but this one. */
signOutOthers: () => request<{ ended: number }>("/me/sign-out-others", { method: "POST" }),
/** URL of this account's avatar, versioned by `avatarAt` (null = none). */
avatarUrl: (avatarAt?: string | null) =>
avatarAt ? `${BASE}/me/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** PUT /api/me/avatar — one PNG, JPEG, GIF or WebP image, at most 1 MB. */
setAvatar: (file: Blob) =>
request<MyAccount>("/me/avatar", {
method: "PUT",
body: file,
headers: { "Content-Type": "application/octet-stream" },
}),
/** DELETE /api/me/avatar — back to the initials. */
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
avatarUrl: (id: number, avatarAt?: string | null) =>
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () => request<{ users: ServerUserUsage[] }>("/users"),
/** GET /api/users/audit — staff reads of members' sessions and admins'
* account changes, newest first. */
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
/** GET /api/users/storage — each user's home size, measured in the background
* (`measuring` = more numbers are coming); `refresh` re-measures every home. */
storage: (refresh = false) =>
request<{ storage: Record<string, UserStorage>; measuring: boolean }>(
`/users/storage${refresh ? "?refresh=1" : ""}`
),
// Account changes (admin only). Each carries the admin's OWN password
// again (`currentPassword`): the server refuses without it (EREAUTH).
/** POST /api/users — a new account. */
@@ -2683,18 +2722,75 @@ export const api = {
`/artifacts/skills${laneId ? `?laneId=${laneId}` : ""}`
),
addPage: (body: { url: string; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/page", { method: "POST", body: JSON.stringify(body) }),
request<{ item: Artifact }>("/artifacts/page", {
method: "POST",
body: JSON.stringify(body),
}),
pinFile: (body: { laneId: number; path: string; title?: string; sessionId?: string }) =>
request<{ item: Artifact }>("/artifacts/file", { method: "POST", body: JSON.stringify(body) }),
request<{ item: Artifact }>("/artifacts/file", {
method: "POST",
body: JSON.stringify(body),
}),
addSkill: (body: { name: string; laneId?: number; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/skill", { method: "POST", body: JSON.stringify(body) }),
request<{ item: Artifact }>("/artifacts/skill", {
method: "POST",
body: JSON.stringify(body),
}),
addPlugin: (body: { key: string; market: string; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/plugin", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: number, body: { title?: string; description?: string; shared?: boolean }) =>
request<{ item: Artifact }>(`/artifacts/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
/** Read a marketplace (git link, owner/repo or marketplace.json link) and list its plugins. */
inspectMarket: (market: string) =>
request<MarketplaceInfo>("/artifacts/plugin/inspect", {
method: "POST",
body: JSON.stringify({ market }),
}),
/** A skill written in the dialog: SKILL.md gets `name`/`description` in its frontmatter. */
writeSkill: (body: { name: string; description: string; content: string; title?: string }) =>
request<{ item: Artifact; items: Artifact[] }>("/artifacts/skill/new", {
method: "POST",
body: JSON.stringify(body),
}),
/** Skills from files: one Markdown file, a folder, several files, a .zip/.skill, or
* several skills at once (one artifact each). `path` keeps a folder's layout. */
uploadSkills: (
files: { file: File; path: string }[],
fields: { name?: string; title?: string; description?: string } = {}
) => {
const form = new FormData();
for (const f of files) form.append("files", f.file, f.file.name);
form.append("paths", JSON.stringify(files.map((f) => f.path)));
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
return request<{ items: Artifact[] }>("/artifacts/skill/upload", {
method: "POST",
body: form,
});
},
/** One file from the user's computer, kept like a pinned one (≤ 25 MB). */
uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => {
const form = new FormData();
form.append("files", file, file.name);
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
},
update: (
id: number,
body: {
title?: string;
description?: string;
shared?: boolean;
/** plugin only: `name@marketplace`. */
source?: string;
/** plugin only: the marketplace source. */
market?: string;
}
) =>
request<{ item: Artifact }>(`/artifacts/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
install: (id: number, replace = false) =>
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
@@ -3929,6 +4025,22 @@ export interface Artifact {
updatedAt: string;
}
/** POST /api/artifacts/plugin/inspect — a marketplace and the plugins it lists. */
export interface MarketplaceInfo {
/** The source as stored (owner/repo, a git URL or a marketplace.json link). */
market: string;
name: string;
description: string;
plugins: {
name: string;
/** `name@marketplace`, what `claude plugin install` takes. */
key: string;
description: string;
version: string;
category: string;
}[];
}
export interface ArtifactCandidate {
path: string;
sessionId: string;
+9
View File
@@ -251,6 +251,15 @@ export const SETTINGS_INDEX: {
descKey: "aiTools.description",
keywords: ["install", "update", "default"],
},
{
section: "agents",
titleKey: "artifacts:installsSwitch",
descKey: "artifacts:installsSwitchHint",
keywords: ["artifacts", "hub", "skill", "plugin", "cài đặt"],
to: "#artifacts-hub",
adminOnly: true,
serverOnly: true,
},
{ section: "agents/claude-code", titleKey: "hooks.title" },
{ section: "agents/claude-code", titleKey: "claudeHome.title" },
{ section: "agents/claude-code", titleKey: "ccConfig:tabs.skills", to: "?tab=skills" },
+2
View File
@@ -2673,6 +2673,8 @@ export interface Lane {
owner_name?: string | null;
/** The owner's display name (`users.display_name`); null when none is set. */
owner_display_name?: string | null;
/** When the owner's avatar was last set (`users.avatar_at`); null = none. */
owner_avatar_at?: string | null;
branch: string | null;
kind: "managed" | "adopted";
/** The CLI this lane's runs spawn. Immutable once the lane exists. */