diff --git a/client/src/i18n/locales/en/artifacts.json b/client/src/i18n/locales/en/artifacts.json index d7ca5ff8..1f7bf35e 100644 --- a/client/src/i18n/locales/en/artifacts.json +++ b/client/src/i18n/locales/en/artifacts.json @@ -183,5 +183,19 @@ "stat.updated": "Updated", "detail.runAs": "Run it as", "detail.market": "Marketplace", - "detail.added": "Added {{date}}" + "detail.added": "Added {{date}}", + "tab.unowned": "Unowned", + "tab.suggested": "Is this yours?", + "auto": "Auto", + "autoHint": "Added when its session stopped: a document the session created or changed", + "unowned.hint": "Items no workspace owns that were made in your folders — confirm whether they are yours", + "unowned.hintAdmin": "Items with no owner: sessions outside any workspace, run as the host account", + "unowned.mine": "It's mine", + "unowned.takeIt": "Take it", + "unowned.notMine": "Not mine", + "unowned.assign": "Assign to…", + "unowned.claimed": "Added to your library", + "unowned.declined": "Removed from your list", + "unowned.assigned": "Owner assigned", + "unowned.empty": "Nothing without an owner." } diff --git a/client/src/i18n/locales/vi/artifacts.json b/client/src/i18n/locales/vi/artifacts.json index db4e89e0..4e7f0a50 100644 --- a/client/src/i18n/locales/vi/artifacts.json +++ b/client/src/i18n/locales/vi/artifacts.json @@ -183,5 +183,19 @@ "stat.updated": "Cập nhật", "detail.runAs": "Gọi bằng", "detail.market": "Marketplace", - "detail.added": "Thêm lúc {{date}}" + "detail.added": "Thêm lúc {{date}}", + "tab.unowned": "Vô chủ", + "tab.suggested": "Có phải của bạn?", + "auto": "Tự động", + "autoHint": "Tự thêm khi phiên làm việc dừng: file tài liệu phiên đã tạo hoặc sửa", + "unowned.hint": "Những mục không thuộc workspace nào nhưng được tạo trong thư mục của bạn — xác nhận có phải của bạn không", + "unowned.hintAdmin": "Những mục chưa có chủ: phiên không thuộc workspace nào, chạy bằng tài khoản host", + "unowned.mine": "Của tôi", + "unowned.takeIt": "Nhận về tôi", + "unowned.notMine": "Không phải của tôi", + "unowned.assign": "Gán cho…", + "unowned.claimed": "Đã nhận về thư viện của bạn", + "unowned.declined": "Đã bỏ khỏi danh sách của bạn", + "unowned.assigned": "Đã gán chủ", + "unowned.empty": "Không có mục nào chưa có chủ." } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 4d49ce0a..448db960 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -3016,7 +3016,9 @@ export const api = { }, /** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */ artifacts: { - list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => { + list: ( + params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {} + ) => { const qs = new URLSearchParams(); if (params.scope) qs.set("scope", params.scope); if (params.kind) qs.set("kind", params.kind); @@ -3092,6 +3094,18 @@ export const api = { 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 }); }, + /** An unowned row: its suggested user (or an admin) takes it. */ + claim: (id: number) => + request<{ item: Artifact }>(`/artifacts/${id}/claim`, { method: "POST" }), + /** An unowned row offered to the caller is not theirs. */ + decline: (id: number) => + request<{ id: number; declined: true }>(`/artifacts/${id}/decline`, { method: "POST" }), + /** Admin: give an unowned row to a user. */ + assign: (id: number, userId: number) => + request<{ item: Artifact }>(`/artifacts/${id}/assign`, { + method: "POST", + body: JSON.stringify({ userId }), + }), update: ( id: number, body: { @@ -4382,7 +4396,7 @@ export interface Artifact { market: string | null; /** page: the claude.ai link. */ url: string | null; - files: { path: string; size: number }[]; + files: { path: string; size: number; mtime?: number }[]; size: number | null; shared: boolean; sharedAt: string | null; @@ -4391,6 +4405,12 @@ export interface Artifact { inLibrary: boolean; /** List rows only: a skill/plugin already in every account the viewer's sessions run as. */ installed?: boolean; + /** file: captured when its session stopped, not pinned by hand. */ + auto?: boolean; + /** Server mode: nobody owns it yet. */ + unowned?: boolean; + /** Unowned and offered to the caller to claim or decline. */ + suggestedForMe?: boolean; createdAt: string; updatedAt: string; } diff --git a/client/src/pages/Artifacts.tsx b/client/src/pages/Artifacts.tsx index 90f0dff6..ed00e09c 100644 --- a/client/src/pages/Artifacts.tsx +++ b/client/src/pages/Artifacts.tsx @@ -49,7 +49,9 @@ import { type ArtifactInstalls, type ArtifactKind, type ArtifactSettings, + type ServerUserUsage, } from "../lib/api"; +import { usePermissions } from "../components/AuthGate"; import type { Lane } from "../lib/types"; import { eventBus } from "../lib/eventBus"; import { toast } from "../lib/toast"; @@ -114,7 +116,11 @@ const footAction = export function Artifacts() { const { t } = useTranslation("artifacts"); const [settings, setSettings] = useState(null); - const [tab, setTab] = useState<"all" | "mine" | "hub">("all"); + const [tab, setTab] = useState<"all" | "mine" | "hub" | "unowned">("all"); + const { isAdmin } = usePermissions(); + // Server mode: rows nobody owns — every one for an admin, those offered to you otherwise. + const [unownedCount, setUnownedCount] = useState(0); + const [users, setUsers] = useState([]); const [kind, setKind] = useState(""); const [q, setQ] = useState(""); const [items, setItems] = useState(null); @@ -147,14 +153,45 @@ export function Artifacts() { return () => window.clearTimeout(id); }, [load, q]); + const serverMode = !!settings?.serverMode; + const countUnowned = useCallback(() => { + if (!serverMode) return; + api.artifacts + .list({ scope: "unowned" }) + .then((r) => setUnownedCount(r.items.length)) + .catch(() => setUnownedCount(0)); + }, [serverMode]); + useEffect(countUnowned, [countUnowned]); + useEffect(() => { + if (tab !== "unowned" || !isAdmin || users.length) return; + api.users + .list() + .then((r) => setUsers(r.users.filter((u) => !u.disabled))) + .catch(() => setUsers([])); + }, [tab, isAdmin, users.length]); + useEffect( () => eventBus.subscribe((msg) => { - if (msg.type === "artifact_changed") load(); + if (msg.type === "artifact_changed") { + load(); + countUnowned(); + } }), - [load] + [load, countUnowned] ); + const settle = async (run: () => Promise, done: string) => { + try { + await run(); + toast.success(t(done)); + load(); + countUnowned(); + } catch (err) { + toast.error(err); + } + }; + // The Add menu closes on Escape or a click anywhere outside it. useEffect(() => { if (!addMenu) return; @@ -297,15 +334,63 @@ export function Artifacts() { }, }); - const card = (a: Artifact) => ( - - ); + const card = (a: Artifact) => + tab === "unowned" ? ( +
+ +
+ {(a.suggestedForMe || isAdmin) && ( + + )} + {a.suggestedForMe && ( + + )} + {isAdmin && users.length > 0 && ( + + )} +
+
+ ) : ( + + ); // Local mode (everything is yours): the unfiltered page is shelved by // kind; a category or a search is one flat grid of results. @@ -320,7 +405,7 @@ export function Artifacts() { // what you shared, My library into what you installed from others (the // server adds those to `mine`) and your own. const halves = - server && items + server && items && tab !== "unowned" ? (["others", "own"] as const).map((who) => ({ key: `${tab}.${who}`, rows: items.filter((a) => (who === "own" ? a.mine : !a.mine)), @@ -424,12 +509,29 @@ export function Artifacts() { {t(`tab.${v}`)} ))} + {(isAdmin || unownedCount > 0 || tab === "unowned") && ( + + )} )} {items === null ? ( + ) : items.length === 0 && tab === "unowned" ? ( +

+ {t("unowned.empty")} +

) : items.length === 0 ? ( )} + {a.auto && ( + + {t("auto")} + + )} {a.description && (

{a.description}

)} diff --git a/docs/API.md b/docs/API.md index fdb4fde6..f17c3fe4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3087,7 +3087,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session ### Artifacts hub ```http -GET /api/artifacts?scope=all|mine|hub&kind=page|file|skill|plugin&q= {items} — each with `installed` (default scope `all`) +GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` (default scope `all`) GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage} PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]} @@ -3107,17 +3107,21 @@ GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, n POST /api/artifacts/:id/install {replace?} → {installed, replaced?} POST /api/artifacts/:id/uninstall → {uninstalled, paths} GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded} +POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin +POST /api/artifacts/:id/decline → {id, declined} — the suggested user +POST /api/artifacts/:id/assign {userId} → {item} — admin ``` What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`): -- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned. -- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `/artifacts//`. +- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is). +- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `/artifacts//`. - **skill** — the caller's own `~/.claude/skills/` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`. - **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept). - **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way. - **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it. - **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. `scope=mine` is the caller's own library rows plus the shared skills/plugins of OTHER users that the caller has installed (`installed: true`, `mine: false`); in server mode the page splits EVERY tab by `mine`, others' section first — **All** into *Shared by others* / *Mine*, **Hub** into *Shared by others* / *Shared by me*, **My library** into *Installed from others* / *Mine* — and each section pages 12 cards at a time (client-side). Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright. +- **Unowned** — server mode: a row with no owner (`unowned: true`). `scope=unowned` lists every one for an admin and, for anyone else, those suggested to them (`suggestedForMe: true`; such a row is also visible to them, `canSeeId`). **claim** makes the suggested user — or an admin — its owner; **decline** drops the suggestion (the row stays for an admin); **assign** `{userId}` is an admin giving it to an enabled user (`400 EBADFIELD` otherwise). All three answer `409 EOWNED` once it has an owner, `400 ELOCALMODE` in local mode, and are written to `audit_log`. The page shows the tab **Unowned** to an admin and **Is this yours?** to a user with suggestions. - **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment. - **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills//` AND `~/.agents/skills//` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/[-agents]-