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:
+117
-5
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user