c946b73d11
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.
151 lines
4.9 KiB
TypeScript
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);
|
|
}
|