Files
AgentHub/client/src/lib/artifactFolders.ts
T
nntrivi2001 c946b73d11 chore: v2.8.71 — remote clients (company hub), artifacts shared with chosen people, folders and viewable copies, fixes
Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool):
- server/hub/ holds the hub-only code (devices, remote sessions, upload ingest,
  LAN gateway), required only in server mode; hub-boundary.test.js keeps local
  code from importing it.
- A local install enrolls with `agenthub remote login` or Settings → AI tools →
  Company hub; the "Company account" AI Proxy (reserved name agenthub.company)
  sends a session through the hub's gateway, which adds a pool account's token —
  no credential reaches the machine. Only those sessions are uploaded, hourly
  and at their end; secret-shaped strings are redacted on the client.
- Gateway refuses: unknown device (401), a message without a session id, an
  unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota
  (EQUOTA), a purged session (410). Device tokens 90 days, conversations 30.
- New alert rule type remote_unmonitored: a company-account session its device
  never registered; a late registration clears the flag instead.
- The hub imports a session once per upload round (chunks carry `more`),
  not once per chunk.

Artifacts:
- Share an artifact with chosen people (artifact_shares, PATCH sharedWith,
  GET /api/artifacts/people) besides everyone; a "Shared with you" tab.
  Only the owner or an admin sees or edits who it is shared with; audited.
- Folders: each account's own arrangement (agenthub.artifacts.folders pref).
- A claude.ai page keeps a copy viewable without the publishing account: its
  HTML file (and supporting files) snapshotted at publish, or the copy Claude
  saves for a Doc / Slides / Design in artifacts-export/ (a synchronous
  PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch),
  attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or
  pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF).

Fixes:
- tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's
  tmux (stop the last pane, start again within 10 s failed with ENOENT).
- shelves: a shelf restores beside changes in other files.
- chat: a finished reply ends the turn even with no hook at all.
- Settings → AI tools: profile list display tweaks.
2026-11-04 15:16:49 +07:00

151 lines
4.9 KiB
TypeScript

/**
* @file artifactFolders — the Artifacts page's user-made folders ("Slides",
* "Client A", …): their names and order, and which folder each artifact sits
* in. One JSON value under `agenthub.artifacts.folders`, an account preference
* key (`lib/userPrefs.ts`, `server/routes/me.js`), so the folders are kept per
* ACCOUNT on the server and follow it to any browser. A viewer's arrangement,
* not an artifact fact: nothing about an artifact changes, other users never
* see these folders, and deleting a folder only takes its artifacts out of it.
* Same store shape as `workspaceGroups.ts`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useSyncExternalStore } from "react";
import { PREFS_EVENT } from "./workspaceGroups";
export const ARTIFACT_FOLDERS_KEY = "agenthub.artifacts.folders";
export const MAX_FOLDER_NAME = 60;
export interface ArtifactFolder {
id: string;
name: string;
}
export interface ArtifactFolders {
/** In display order. */
folders: ArtifactFolder[];
/** Artifact id → folder id. An artifact absent here is in no folder. */
of: Record<string, string>;
}
const EMPTY: ArtifactFolders = { folders: [], of: {} };
const listeners = new Set<() => void>();
let cache: { raw: string | null; value: ArtifactFolders } = { raw: null, value: EMPTY };
function readRaw(): string | null {
try {
return localStorage.getItem(ARTIFACT_FOLDERS_KEY);
} catch {
return null;
}
}
function parse(raw: string | null): ArtifactFolders {
if (!raw) return EMPTY;
try {
const v = JSON.parse(raw) as Partial<ArtifactFolders>;
const folders = (Array.isArray(v.folders) ? v.folders : []).filter(
(f): f is ArtifactFolder =>
!!f && typeof f.id === "string" && !!f.id && typeof f.name === "string"
);
const ids = new Set(folders.map((f) => f.id));
const of: Record<string, string> = {};
if (v.of && typeof v.of === "object") {
for (const [item, fid] of Object.entries(v.of)) {
if (typeof fid === "string" && ids.has(fid)) of[item] = fid;
}
}
return { folders, of };
} catch {
return EMPTY;
}
}
export function readArtifactFolders(): ArtifactFolders {
const raw = readRaw();
if (raw === cache.raw) return cache.value;
cache = { raw, value: parse(raw) };
return cache.value;
}
function write(next: ArtifactFolders): void {
try {
localStorage.setItem(ARTIFACT_FOLDERS_KEY, JSON.stringify(next));
} catch {
/* disabled storage: the folders live until the page closes */
cache = { raw: JSON.stringify(next), value: next };
}
listeners.forEach((l) => l());
}
function update(fn: (cur: ArtifactFolders) => ArtifactFolders): void {
write(fn(readArtifactFolders()));
}
const cleanName = (name: string) => name.trim().slice(0, MAX_FOLDER_NAME);
/** A new folder at the end, holding `itemIds` (moved out of their old folder). Returns its id, or null for a blank name. */
export function createFolder(name: string, itemIds: number[] = []): string | null {
const clean = cleanName(name);
if (!clean) return null;
const id = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
update((cur) => {
const of = { ...cur.of };
for (const item of itemIds) of[item] = id;
return { folders: [...cur.folders, { id, name: clean }], of };
});
return id;
}
/** Rename; a blank name keeps the old one. */
export function renameFolder(id: string, name: string): void {
const clean = cleanName(name);
if (!clean) return;
update((cur) => ({
...cur,
folders: cur.folders.map((f) => (f.id === id ? { ...f, name: clean } : f)),
}));
}
/** Delete the folder; its artifacts are only taken out of it. */
export function deleteFolder(id: string): void {
update((cur) => ({
folders: cur.folders.filter((f) => f.id !== id),
of: Object.fromEntries(Object.entries(cur.of).filter(([, fid]) => fid !== id)),
}));
}
/** Put an artifact in a folder, or (null) take it out of every folder. */
export function moveToFolder(itemId: number, folderId: string | null): void {
update((cur) => {
const of = { ...cur.of };
if (folderId && cur.folders.some((f) => f.id === folderId)) of[itemId] = folderId;
else delete of[itemId];
return { ...cur, of };
});
}
/** The folder an artifact is in, or null. */
export function folderOf(value: ArtifactFolders, itemId: number): ArtifactFolder | null {
const fid = value.of[itemId];
return (fid && value.folders.find((f) => f.id === fid)) || null;
}
function subscribe(l: () => void) {
listeners.add(l);
const onStorage = (e: StorageEvent) => {
if (e.key === ARTIFACT_FOLDERS_KEY || e.key === null) l();
};
window.addEventListener("storage", onStorage);
window.addEventListener(PREFS_EVENT, l);
return () => {
listeners.delete(l);
window.removeEventListener("storage", onStorage);
window.removeEventListener(PREFS_EVENT, l);
};
}
export function useArtifactFolders(): ArtifactFolders {
return useSyncExternalStore(subscribe, readArtifactFolders);
}