Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 92 additions & 34 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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: [
Expand All @@ -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<LucideIcon, string>([
[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";

Expand Down Expand Up @@ -258,6 +272,7 @@ function Studio({ onSignOut, embeddedIn }: { onSignOut?: () => void; embeddedIn?
return (
<main className="@container flex h-full min-h-0 flex-col overflow-y-auto bg-canvas px-6 pt-6 pb-8 *:shrink-0">
{page}
<HostMenu />
</main>
);
}
Expand Down Expand Up @@ -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("");
Expand All @@ -510,38 +526,47 @@ function HfTokenButton({ open: islandOpen }: { open: boolean }) {
}
}

const dialog = (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<form onSubmit={save} className="grid gap-4">
<DialogHeader>
<DialogTitle>Hugging Face token</DialogTitle>
<DialogDescription>
For gated and private models. It is stored on this server, never in the browser.
{isSet && " A token is set; saving replaces it."}
</DialogDescription>
</DialogHeader>
<Input type="password" autoFocus placeholder="hf_…" value={value} onChange={(e) => setValue(e.target.value)} />
{err && <p className="text-sm text-destructive">{err}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={!value.trim() || busy}>{busy ? "Saving…" : "Save token"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
return { isSet, setOpen, dialog };
}

function HfTokenButton({ open: islandOpen }: { open: boolean }) {
const hf = useHfToken();
return (
<>
<ActionRow open={islandOpen} icon={KeyRound} label="Hugging Face token"
hint={isSet ? "set" : "not set"}
hint={hf.isSet ? "set" : "not set"}
title="The token the server uses for gated and private models"
onClick={() => setOpen(true)} />
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<form onSubmit={save} className="grid gap-4">
<DialogHeader>
<DialogTitle>Hugging Face token</DialogTitle>
<DialogDescription>
For gated and private models. It is stored on this server, never in the browser.
{isSet && " A token is set; saving replaces it."}
</DialogDescription>
</DialogHeader>
<Input type="password" autoFocus placeholder="hf_…" value={value} onChange={(e) => setValue(e.target.value)} />
{err && <p className="text-sm text-destructive">{err}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={!value.trim() || busy}>{busy ? "Saving…" : "Save token"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
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`;
Expand All @@ -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 (
<ActionRow open={open} icon={busy ? LoaderCircle : Zap} label="Clean VRAM"
hint={busy ? "clearing…" : hint}
<ActionRow open={open} icon={vram.busy ? LoaderCircle : Zap} label="Clean VRAM"
hint={vram.hint}
title="Unload cached models and free GPU memory"
onClick={clean} />
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;
}
18 changes: 13 additions & 5 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -112,14 +112,22 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Re
return r;
}

// failure is why a call failed, as its answer says: the studio's own
// {error}, or the problem a host's proxy answers ({detail}, {title}, RFC
// 9457). Embedded, a change the host refuses (its roles, not the studio's)
// is also said in the host, so it is seen even where a page drops errors.
export async function failure(r: Response): Promise<Error> {
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<T>(path: string, opts: RequestInit = {}): Promise<T> {
const r = await apiFetch(path, {
...opts, headers: { "Content-Type": "application/json", ...(opts.headers as Record<string, string> | 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<T>;
}

Expand Down
49 changes: 49 additions & 0 deletions frontend/src/lib/embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -126,6 +128,9 @@ export function startEmbed(navigate: (path: string) => void): Promise<string> {
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
Expand All @@ -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<void> {
Expand All @@ -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) });
Expand Down
14 changes: 9 additions & 5 deletions frontend/src/pages/Datasets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
chat: "border-primary/20 bg-primary/10 text-primary",
Expand Down Expand Up @@ -82,7 +83,7 @@ export default function Datasets() {
<PageHeader
title="Datasets"
description="Upload JSONL, or reference a Hugging Face dataset. Chat, instruction, preference, or raw text — the format is auto-detected."
actions={
actions={allows("operator") && (
<>
<Button variant="outline" onClick={() => setTab("hf")}>
<Database /> Hugging Face
Expand All @@ -91,6 +92,7 @@ export default function Datasets() {
<Upload /> Upload
</Button>
</>
)
}
/>

Expand Down Expand Up @@ -183,10 +185,12 @@ export default function Datasets() {
onClick={() => previewRow(d)}>
{previewing === d.dataset_id ? <><LoaderCircle className="animate-spin" /> Loading…</> : "Preview"}
</Button>
<Button size="sm" variant="ghost" className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => deleteDataset(d.dataset_id).then(refresh)}>
Delete
</Button>
{allows("operator") && (
<Button size="sm" variant="ghost" className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => deleteDataset(d.dataset_id).then(refresh, () => {})}>
Delete
</Button>
)}
</div>
</TableCell>
</TableRow>
Expand Down
Loading
Loading