From f45b7f8473f0591454e6b149bfc2bb12bb60c4b1 Mon Sep 17 00:00:00 2001 From: Khush Patel Date: Mon, 5 Oct 2026 23:38:41 -0700 Subject: [PATCH 1/4] studio: lead the menu with the Playground Where you talk to what you own comes first, then the shadowing loop: data and a base model, training, the runs. --- frontend/src/App.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4818439..e0f5752 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -45,11 +45,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,7 +70,6 @@ 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]; From c712f77a6d6ef8abfa356210493d6ebb15ff2cff Mon Sep 17 00:00:00 2001 From: Khush Patel Date: Mon, 5 Oct 2026 23:38:54 -0700 Subject: [PATCH 2/4] studio: in opencontroller, its roles, its errors and the studio's menu Embedded, the studio now: - shows only the controls the host's role allows (allows() in lib/embed.ts): viewers read; operators add datasets and models, train, cancel, take the weights and use the playground; administrators mint machine tokens. The host's proxy decides; this only hides what it would refuse. - reads the host proxy's errors (RFC 9457 detail and title, beside its own error), and says a refusal as a toast in the host, where a page dropped it before; a framed page cannot alert(). - sends the host its menu over oc-embed/1 ("menu"): its sections, the island's actions the role allows and its links, so the host shows the studio's own menu, not a copy. An action chosen there comes back ("action") and runs here, where its dialog is. The island's Hugging Face token and VRAM rows are hooks now, shared by the island and the host's menu. On its own, the studio is as before. Co-Authored-By: Claude Opus 5.5 --- frontend/src/App.tsx | 111 ++++++++++++++++++++++-------- frontend/src/api.ts | 18 +++-- frontend/src/lib/embed.ts | 49 +++++++++++++ frontend/src/pages/Datasets.tsx | 14 ++-- frontend/src/pages/Machines.tsx | 16 +++-- frontend/src/pages/Models.tsx | 8 ++- frontend/src/pages/Playground.tsx | 12 +++- frontend/src/pages/Runs.tsx | 29 +++++--- frontend/src/pages/Train.tsx | 11 ++- 9 files changed, 204 insertions(+), 64 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e0f5752..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"; @@ -74,6 +76,13 @@ const sections: Section[] = [ 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"; @@ -263,6 +272,7 @@ function Studio({ onSignOut, embeddedIn }: { onSignOut?: () => void; embeddedIn? return (
{page} +
); } @@ -487,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(""); @@ -515,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`; @@ -566,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") && (