diff --git a/CLAUDE.md b/CLAUDE.md index 0a21571..4339fd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,13 @@ routing; its run progress is one step per unit (see `resolve_total_steps`). run **embedded** in a host console over the `oc-embed/1` postMessage bridge (`frontend/src/lib/embed.ts`): framed by an origin `serve.py`'s `frame_ancestors()` lists, it drops its shell, follows the host's theme, and - sends every call to the host's proxy with the host's pass. + sends every call to the host's proxy with the host's pass. It shows only the + controls the host's role allows (`allows()` in `lib/embed.ts`: operators + change and train, administrators mint machine tokens), and says a refusal + from the host's proxy as a toast in the host (`failure()` in `api.ts`). The + host shows the studio's own menu in place of the island: `HostMenu` in + `App.tsx` sends it (`menu`), and runs the island's actions the host says + were chosen (`action`), so a change to the menu needs no change in the host. ### The shadow accelerator (`accel.py`) diff --git a/README.md b/README.md index 3252f1b..4dbbb51 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,15 @@ frame holds no session of its own. Only listed origins may frame it: yours with `SHADOWLM_FRAME_ANCESTORS="https://console.example.com"` (space- or comma-separated). Opened on its own, the studio is unchanged. +To connect it to opencontroller, open **Settings → Fine-tuning** there and give +the studio's address and a bearer token: its `SHADOWLM_API_KEY`, or better a +machine token minted for opencontroller (**Machines → Create machine token**), +which you can revoke on its own. opencontroller then shows the studio under +**Fine-tuning**, with its own menu in place of the studio's. Its roles decide +what each person may do, and the studio hides what theirs would be refused: +viewers read, operators add datasets and models, train and use the +playground, and administrators mint machine tokens. + ## The shadow accelerator `accelerator="shadow"` turns on the optimizations that are safe for your model diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4818439..8e982c2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,7 +22,9 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { embedded, hashToPage, reportRoute, reportTitle, useEmbedTheme } from "@/lib/embed"; +import { + allows, embedded, hashToPage, type MenuItem, onHostAction, reportMenu, reportRoute, reportTitle, useEmbedTheme, +} from "@/lib/embed"; import { cn } from "@/lib/utils"; import Dashboard from "@/pages/Dashboard"; import Datasets from "@/pages/Datasets"; @@ -45,11 +47,17 @@ function useHash(): string { interface NavItem { hash: string; label: string; icon: LucideIcon } type Section = { title?: string; items: NavItem[] }; -// The navigation follows the shadowing loop: bring data and a base model, -// train, watch the run, then talk to what you own. Machines, where training -// runs, is setup, so it sits apart at the foot. +// The navigation leads with the Playground, where you talk to what you own, +// then follows the shadowing loop: bring data and a base model, train, watch +// the run. Machines, where training runs, is setup, so it sits apart at the +// foot. const sections: Section[] = [ - { items: [{ hash: "", label: "Overview", icon: LayoutDashboard }] }, + { + items: [ + { hash: "playground", label: "Playground", icon: MessagesSquare }, + { hash: "", label: "Overview", icon: LayoutDashboard }, + ], + }, { title: "Build", items: [ @@ -64,11 +72,17 @@ const sections: Section[] = [ { hash: "runs", label: "Runs", icon: History }, ], }, - { title: "Use", items: [{ hash: "playground", label: "Playground", icon: MessagesSquare }] }, ]; const machinesItem: NavItem = { hash: "machines", label: "Machines", icon: MonitorSmartphone }; const allItems = [...sections.flatMap((s) => s.items), machinesItem]; +// glyphs are the menu's icons by name, for a host that draws the menu itself +// (HostMenu): lucide's names, which the host knows. +const glyphs = new Map([ + [LayoutDashboard, "layout-dashboard"], [MessagesSquare, "messages-square"], [Database, "database"], + [Box, "box"], [Cpu, "cpu"], [History, "history"], [MonitorSmartphone, "monitor-smartphone"], +]); + // The repository; its README is the documentation. const repo = "https://github.com/open-gitagent/shadowLM"; @@ -258,6 +272,7 @@ function Studio({ onSignOut, embeddedIn }: { onSignOut?: () => void; embeddedIn? return (
{page} +
); } @@ -482,9 +497,10 @@ function ThemeToggle() { ); } -// HfTokenButton sets the Hugging Face token the server uses for gated and -// private models. -function HfTokenButton({ open: islandOpen }: { open: boolean }) { +// useHfToken is the Hugging Face token the server uses for gated and private +// models: whether one is set, and its dialog, opened from the island +// (HfTokenButton) or, embedded, from the host's menu (HostMenu). +function useHfToken() { const [open, setOpen] = useState(false); const [isSet, setIsSet] = useState(false); const [value, setValue] = useState(""); @@ -510,38 +526,47 @@ function HfTokenButton({ open: islandOpen }: { open: boolean }) { } } + const dialog = ( + + +
+ + Hugging Face token + + For gated and private models. It is stored on this server, never in the browser. + {isSet && " A token is set; saving replaces it."} + + + setValue(e.target.value)} /> + {err &&

{err}

} + + + + +
+
+
+ ); + return { isSet, setOpen, dialog }; +} + +function HfTokenButton({ open: islandOpen }: { open: boolean }) { + const hf = useHfToken(); return ( <> setOpen(true)} /> - - -
- - Hugging Face token - - For gated and private models. It is stored on this server, never in the browser. - {isSet && " A token is set; saving replaces it."} - - - setValue(e.target.value)} /> - {err &&

{err}

} - - - - -
-
-
+ onClick={() => hf.setOpen(true)} /> + {hf.dialog} ); } // VramButton unloads cached models and frees GPU memory; beside it, how // much is in use, and after a clean, what it came down to. -function VramButton({ open }: { open: boolean }) { +// useVram is the GPU memory cached models hold, and freeing it. +function useVram() { const [hint, setHint] = useState(""); const [busy, setBusy] = useState(false); const gb = (mb: number) => `${(mb / 1024).toFixed(1)} GB`; @@ -561,11 +586,44 @@ function VramButton({ open }: { open: boolean }) { setBusy(false); } } + return { hint: busy ? "clearing…" : hint, busy, clean }; +} +function VramButton({ open }: { open: boolean }) { + const vram = useVram(); return ( - + onClick={vram.clean} /> ); } + +// HostMenu is the studio's menu for a host console that shows its own in +// place of the island (lib/embed.ts): the same sections, the island's +// actions the person's role in the host allows, and its links. The host +// says when an action is chosen; it runs here, where its dialog is. +function HostMenu() { + const hf = useHfToken(); + const vram = useVram(); + const admin = allows("admin"); + const operator = allows("operator"); + const { setOpen } = hf; + const { clean } = vram; + + useEffect(() => { + const item = (i: NavItem): MenuItem => ({ label: i.label, icon: glyphs.get(i.icon) ?? "", path: hashToPage(i.hash) }); + const foot: MenuItem[] = [item(machinesItem)]; + if (admin) foot.push({ label: "Hugging Face token", icon: "key-round", hint: hf.isSet ? "set" : "not set", action: "hf-token" }); + if (operator) foot.push({ label: "Clean VRAM", icon: vram.busy ? "loader-circle" : "zap", hint: vram.hint || undefined, action: "clear-vram" }); + foot.push({ label: "Docs", icon: "book-open", href: `${repo}#readme` }, { label: "GitHub", icon: "external-link", href: repo }); + reportMenu({ groups: sections.map((s) => ({ title: s.title, items: s.items.map(item) })), foot }); + }, [admin, operator, hf.isSet, vram.busy, vram.hint]); + + useEffect(() => onHostAction((id) => { + if (id === "hf-token" && admin) setOpen(true); + if (id === "clear-vram" && operator) void clean(); + }), [admin, operator, setOpen, clean]); + + return hf.dialog; +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b11d5f6..ac9dd2c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,5 +1,5 @@ // The ShadowLM remote protocol, typed. Same endpoints the SDK speaks. -import { embedApiBase, embedded, embedToken, renewPass } from "@/lib/embed"; +import { embedApiBase, embedded, embedToken, hostToast, renewPass } from "@/lib/embed"; export interface DatasetMeta { dataset_id: string; @@ -112,14 +112,22 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise { + const b = await r.json().catch(() => ({} as { error?: string; detail?: string; title?: string })); + const message = b.error || b.detail || b.title || r.statusText || `HTTP ${r.status}`; + if (embedded && r.status === 403) hostToast("error", message); + return new Error(message); +} + export async function api(path: string, opts: RequestInit = {}): Promise { const r = await apiFetch(path, { ...opts, headers: { "Content-Type": "application/json", ...(opts.headers as Record | undefined) }, }); - if (!r.ok) { - const detail = await r.json().catch(() => ({} as { error?: string })); - throw new Error(detail.error || r.statusText); - } + if (!r.ok) throw await failure(r); return r.json() as Promise; } diff --git a/frontend/src/lib/embed.ts b/frontend/src/lib/embed.ts index 52488fc..4742350 100644 --- a/frontend/src/lib/embed.ts +++ b/frontend/src/lib/embed.ts @@ -12,7 +12,9 @@ // call. The host's menu replaces the studio's sidebar. // // host → frame init {pass, role, path, api_base, api_url} · pass {pass} · theme {theme} · navigate {path} +// · action {id}, taken from the menu the studio sent // frame → host ready · pass (one now, please) · route {path} · title {title} · toast {level, message} +// · menu {menu}, the studio's menu for the host to show in place of its own import { useSyncExternalStore } from "react"; @@ -126,6 +128,9 @@ export function startEmbed(navigate: (path: string) => void): Promise { case "navigate": if (isPage(m.path)) navigate(m.path); break; + case "action": + if (typeof m.id === "string") actions.forEach((a) => a(m.id as string)); + break; } }); // The parent's origin is not known yet, and may be any port of a listed @@ -146,6 +151,19 @@ export const embedApiBase = () => state.apiBase; // host's proxy decides what is allowed. export const embedRole = () => state.role; +const ranks: EmbedRole[] = ["viewer", "operator", "admin"]; + +// allows reports whether to show a control whose change needs the role +// need: on its own, the studio's sign-in decides, so always; embedded, by +// the person's role in the host. Through opencontroller, operators add +// datasets and models, train and use the playground, and administrators +// mint machine tokens. Showing only: the host's proxy decides. +export const allows = (need: EmbedRole) => !embedded || ranks.indexOf(state.role) >= ranks.indexOf(need); + +// needs says why a control is not there, for a page that would be empty +// without it. +export const needs = (need: EmbedRole) => `This needs the ${need} role in the console the studio is shown in.`; + // renewPass asks the host for a pass now, after a refusal, and waits for // it, or five seconds. export function renewPass(): Promise { @@ -165,6 +183,37 @@ export const reportRoute = (path: string) => isPage(path) && post({ type: "route // reportTitle tells the host what the page shows, as its subtitle. export const reportTitle = (title: string) => post({ type: "title", title: title.slice(0, 120) }); +// A menu item, as the host shows it: a page of the studio, an action the +// studio takes when the host says it was chosen, or a link elsewhere (https +// only). Icons are lucide's, by name. +export interface MenuItem { + label: string; + icon: string; + hint?: string; + path?: string; + action?: string; + href?: string; +} + +export interface Menu { + groups: { title?: string; items: MenuItem[] }[]; + foot: MenuItem[]; +} + +// reportMenu gives the host the studio's menu, to show in place of its own. +export const reportMenu = (menu: Menu) => post({ type: "menu", menu }); + +const actions = new Set<(id: string) => void>(); + +// onHostAction runs a when the host says an action of the studio's menu was +// chosen; it returns how to stop. +export function onHostAction(a: (id: string) => void): () => void { + actions.add(a); + return () => { + actions.delete(a); + }; +} + // toast asks the host to show a notification. export const hostToast = (level: "success" | "error" | "info", message: string) => post({ type: "toast", level, message: message.slice(0, 300) }); diff --git a/frontend/src/pages/Datasets.tsx b/frontend/src/pages/Datasets.tsx index 6647567..6681d2c 100644 --- a/frontend/src/pages/Datasets.tsx +++ b/frontend/src/pages/Datasets.tsx @@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; +import { allows } from "@/lib/embed"; const FORMAT_COLORS: Record = { chat: "border-primary/20 bg-primary/10 text-primary", @@ -82,7 +83,7 @@ export default function Datasets() { + ) } /> @@ -183,10 +185,12 @@ export default function Datasets() { onClick={() => previewRow(d)}> {previewing === d.dataset_id ? <> Loading… : "Preview"} - + {allows("operator") && ( + + )} diff --git a/frontend/src/pages/Machines.tsx b/frontend/src/pages/Machines.tsx index a8088c6..a8b883c 100644 --- a/frontend/src/pages/Machines.tsx +++ b/frontend/src/pages/Machines.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { cn } from "@/lib/utils"; +import { allows, needs } from "@/lib/embed"; function CopyBtn({ text }: { text: string }) { const [copied, setCopied] = useState(false); @@ -49,7 +50,8 @@ function ConnectCmd() { return (
-
+ {!allows("admin") &&

{needs("admin")}

} +
setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && mint()} placeholder="Machine name, e.g. macbook" @@ -83,11 +85,13 @@ function ConnectCmd() { created {new Date(t.created * 1000).toLocaleDateString()} - + {allows("admin") && ( + + )} ))} diff --git a/frontend/src/pages/Models.tsx b/frontend/src/pages/Models.tsx index e021a1d..f6d6233 100644 --- a/frontend/src/pages/Models.tsx +++ b/frontend/src/pages/Models.tsx @@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { allows } from "@/lib/embed"; const fmtGB = (b?: number) => (b ? `${(b / 1e9).toFixed(b < 1e9 ? 2 : 1)} GB` : ""); @@ -84,12 +85,13 @@ export default function Models() { { e.preventDefault(); addModel(free); }}> setFree(e.target.value)} placeholder="org/model, any HF id" className="w-56 font-mono" /> + ) } /> @@ -137,7 +139,7 @@ export default function Models() { HF token )} - {m.custom && ( + {m.custom && allows("operator") && ( - {!onDisk && !downloading && ( + {!onDisk && !downloading && allows("operator") && (