From c136d377534d90927a267615b06ddc99963556de Mon Sep 17 00:00:00 2001 From: Kasikrit Chantharuang Date: Wed, 4 Mar 2026 07:36:37 +0000 Subject: [PATCH 1/8] Added first draft of UI extension --- brev/nemoclaw-ui-extension/.env.example | 7 + brev/nemoclaw-ui-extension/.gitignore | 1 + .../extension/deploy-modal.ts | 192 +++++ .../extension/gateway-bridge.ts | 114 +++ brev/nemoclaw-ui-extension/extension/icons.ts | 30 + brev/nemoclaw-ui-extension/extension/index.ts | 47 ++ .../extension/model-registry.ts | 195 +++++ .../extension/model-selector.ts | 314 ++++++++ .../extension/nav-group.ts | 216 ++++++ .../extension/styles.css | 719 ++++++++++++++++++ brev/nemoclaw-ui-extension/install.sh | 118 +++ brev/nemoclaw-ui-extension/uninstall.sh | 66 ++ 12 files changed, 2019 insertions(+) create mode 100644 brev/nemoclaw-ui-extension/.env.example create mode 100644 brev/nemoclaw-ui-extension/.gitignore create mode 100644 brev/nemoclaw-ui-extension/extension/deploy-modal.ts create mode 100644 brev/nemoclaw-ui-extension/extension/gateway-bridge.ts create mode 100644 brev/nemoclaw-ui-extension/extension/icons.ts create mode 100644 brev/nemoclaw-ui-extension/extension/index.ts create mode 100644 brev/nemoclaw-ui-extension/extension/model-registry.ts create mode 100644 brev/nemoclaw-ui-extension/extension/model-selector.ts create mode 100644 brev/nemoclaw-ui-extension/extension/nav-group.ts create mode 100644 brev/nemoclaw-ui-extension/extension/styles.css create mode 100755 brev/nemoclaw-ui-extension/install.sh create mode 100755 brev/nemoclaw-ui-extension/uninstall.sh diff --git a/brev/nemoclaw-ui-extension/.env.example b/brev/nemoclaw-ui-extension/.env.example new file mode 100644 index 0000000..88d2735 --- /dev/null +++ b/brev/nemoclaw-ui-extension/.env.example @@ -0,0 +1,7 @@ +# NeMoClaw DevX Extension — Environment Variables +# +# Copy this file to .env and replace the placeholder values +# with your actual NVIDIA API keys. + +NVIDIA_INFERENCE_API_KEY=your-key-here +NVIDIA_INTEGRATE_API_KEY=your-key-here diff --git a/brev/nemoclaw-ui-extension/.gitignore b/brev/nemoclaw-ui-extension/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/brev/nemoclaw-ui-extension/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/brev/nemoclaw-ui-extension/extension/deploy-modal.ts b/brev/nemoclaw-ui-extension/extension/deploy-modal.ts new file mode 100644 index 0000000..aa03156 --- /dev/null +++ b/brev/nemoclaw-ui-extension/extension/deploy-modal.ts @@ -0,0 +1,192 @@ +/** + * NeMoClaw DevX — Deploy DGX Modal + * + * Topbar button + modal dialog for deploying to NVIDIA DGX Spark/Station. + */ + +import { + ICON_ROCKET, + ICON_CLOSE, + ICON_ARROW_RIGHT, + ICON_LOADER, + ICON_CHECK, + ICON_CHIP, + TARGET_ICONS, +} from "./icons.ts"; +import { DEPLOY_TARGETS, getApiKey, type DeployTarget } from "./model-registry.ts"; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let modalRoot: HTMLElement | null = null; +let buttonEl: HTMLElement | null = null; + +// --------------------------------------------------------------------------- +// Button injection (topbar) +// --------------------------------------------------------------------------- + +function createButton(): HTMLButtonElement { + const btn = document.createElement("button"); + btn.className = "nemoclaw-deploy-btn"; + btn.setAttribute("aria-label", "Deploy DGX Spark/Station"); + btn.setAttribute("title", "Deploy DGX Spark/Station"); + btn.innerHTML = `${ICON_ROCKET}Deploy DGX Spark/Station`; + btn.addEventListener("click", openModal); + return btn; +} + +export function injectButton(): boolean { + if (buttonEl && document.contains(buttonEl)) return true; + + const topbarStatus = document.querySelector(".topbar-status"); + if (!topbarStatus) return false; + + const btn = createButton(); + topbarStatus.appendChild(btn); + buttonEl = btn; + return true; +} + +// --------------------------------------------------------------------------- +// Modal +// --------------------------------------------------------------------------- + +function buildModal(): HTMLElement { + const overlay = document.createElement("div"); + overlay.className = "nemoclaw-overlay"; + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + overlay.setAttribute("aria-label", "Deploy to DGX"); + + const targetsHtml = DEPLOY_TARGETS.map( + (t) => ` + `, + ).join(""); + + overlay.innerHTML = ` +
+
+ Deploy to NVIDIA DGX + +
+
+

+ Choose a deployment target to provision your OpenClaw agent on NVIDIA DGX hardware. +

+
${targetsHtml}
+ +
+
`; + + overlay.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + if (target === overlay) { + closeModal(); + return; + } + const closeBtn = target.closest("[data-action='close']"); + if (closeBtn) { + closeModal(); + return; + } + const targetBtn = target.closest("[data-target-id]"); + if (targetBtn) { + const id = targetBtn.dataset.targetId!; + const dt = DEPLOY_TARGETS.find((t) => t.id === id); + if (dt) handleDeploy(dt, overlay); + } + }); + + overlay.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key === "Escape") closeModal(); + }); + + return overlay; +} + +function openModal() { + if (modalRoot && document.contains(modalRoot)) return; + modalRoot = buildModal(); + document.body.appendChild(modalRoot); + const closeBtn = modalRoot.querySelector("[data-action='close']"); + closeBtn?.focus(); +} + +function closeModal() { + if (!modalRoot) return; + modalRoot.remove(); + modalRoot = null; + buttonEl?.focus(); +} + +// --------------------------------------------------------------------------- +// Deploy action +// --------------------------------------------------------------------------- + +function setStatus(overlay: HTMLElement, type: string, message: string) { + const el = overlay.querySelector(".nemoclaw-modal__status"); + if (!el) return; + el.style.display = ""; + const iconMap: Record = { + loading: ICON_LOADER, + success: ICON_CHECK, + error: ICON_CLOSE, + }; + el.className = `nemoclaw-modal__status nemoclaw-status nemoclaw-status--${type}`; + el.innerHTML = `${iconMap[type] ?? ""}${message}`; +} + +function disableTargets(overlay: HTMLElement, disabled: boolean) { + overlay.querySelectorAll(".nemoclaw-target").forEach((btn) => { + btn.style.pointerEvents = disabled ? "none" : ""; + btn.style.opacity = disabled ? "0.5" : ""; + }); +} + +async function handleDeploy(target: DeployTarget, overlay: HTMLElement) { + const apiKey = getApiKey(target); + if (!apiKey || apiKey.startsWith("__")) { + setStatus(overlay, "error", "API key not configured. Re-run install.sh with a valid .env file."); + return; + } + + disableTargets(overlay, true); + setStatus(overlay, "loading", `Initiating deployment to ${target.name}…`); + + try { + const res = await fetch(target.endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + [target.apiKeyHeader]: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + action: "deploy", + target: target.id, + timestamp: new Date().toISOString(), + }), + }); + + if (res.ok) { + const data = await res.json().catch(() => null); + const id = data?.deploymentId ?? data?.id ?? "—"; + setStatus(overlay, "success", `Deployment queued on ${target.name} (ID: ${id})`); + } else { + const text = await res.text().catch(() => ""); + setStatus(overlay, "error", `${target.name} returned ${res.status}: ${text || "unknown error"}`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setStatus(overlay, "error", `Network error: ${msg}`); + } finally { + disableTargets(overlay, false); + } +} diff --git a/brev/nemoclaw-ui-extension/extension/gateway-bridge.ts b/brev/nemoclaw-ui-extension/extension/gateway-bridge.ts new file mode 100644 index 0000000..8da56c0 --- /dev/null +++ b/brev/nemoclaw-ui-extension/extension/gateway-bridge.ts @@ -0,0 +1,114 @@ +/** + * NeMoClaw DevX — Gateway Bridge + * + * Discovers the OpenClaw app element's GatewayBrowserClient and exposes + * helpers for sending config.patch RPCs without importing any openclaw internals. + */ + +interface GatewayClient { + request(method: string, params?: unknown): Promise; +} + +interface ConfigSnapshot { + hash?: string; + [key: string]: unknown; +} + +/** + * Returns the live GatewayBrowserClient from the element, + * or null if the app hasn't connected yet. + */ +export function getClient(): GatewayClient | null { + const app = document.querySelector("openclaw-app") as + | (HTMLElement & { client?: GatewayClient | null }) + | null; + return app?.client ?? null; +} + +/** + * Wait until the gateway client is available (polls every 500ms, up to timeoutMs). + */ +export function waitForClient(timeoutMs = 15_000): Promise { + return new Promise((resolve, reject) => { + const existing = getClient(); + if (existing) { + resolve(existing); + return; + } + + const start = Date.now(); + const interval = setInterval(() => { + const client = getClient(); + if (client) { + clearInterval(interval); + resolve(client); + } else if (Date.now() - start > timeoutMs) { + clearInterval(interval); + reject(new Error("Timed out waiting for OpenClaw gateway client")); + } + }, 500); + }); +} + +/** + * Send a config.patch RPC to merge a partial config into the running configuration. + * + * 1. Calls config.get to obtain the current baseHash + * 2. Calls config.patch with the serialised patch + baseHash + * + * Throws on gateway errors so callers can surface them to the user. + */ +export async function patchConfig(patch: Record): Promise { + const client = getClient(); + if (!client) { + throw new Error("OpenClaw gateway not connected"); + } + + const snapshot = await client.request("config.get", {}); + const baseHash = snapshot?.hash; + + const result = await client.request<{ ok?: boolean }>("config.patch", { + raw: JSON.stringify(patch), + ...(baseHash ? { baseHash } : {}), + }); + + if (result && typeof result === "object" && "ok" in result && !result.ok) { + throw new Error("config.patch was rejected by the server"); + } +} + +/** + * Check whether the element reports `connected === true`. + */ +export function isAppConnected(): boolean { + const app = document.querySelector("openclaw-app") as + | (HTMLElement & { connected?: boolean }) + | null; + return app?.connected === true; +} + +/** + * Wait for the gateway to reconnect after a restart (e.g. after config.patch). + * + * Polls .connected every 500ms. Resolves when the app is + * connected again, or rejects after timeoutMs. + */ +export function waitForReconnect(timeoutMs = 15_000): Promise { + return new Promise((resolve, reject) => { + if (isAppConnected()) { + resolve(); + return; + } + + const start = Date.now(); + const interval = setInterval(() => { + if (isAppConnected()) { + clearInterval(interval); + resolve(); + } else if (Date.now() - start > timeoutMs) { + clearInterval(interval); + reject(new Error("Timed out waiting for gateway to reconnect")); + } + }, 500); + }); +} diff --git a/brev/nemoclaw-ui-extension/extension/icons.ts b/brev/nemoclaw-ui-extension/extension/icons.ts new file mode 100644 index 0000000..cc40cc3 --- /dev/null +++ b/brev/nemoclaw-ui-extension/extension/icons.ts @@ -0,0 +1,30 @@ +/** + * NeMoClaw DevX — Inline SVG icon fragments. + * + * Kept as raw strings to avoid extra asset requests. + */ + +export const ICON_ROCKET = ``; + +export const ICON_CLOSE = ``; + +export const ICON_CHIP = ``; + +export const ICON_SERVER = ``; + +export const ICON_ARROW_RIGHT = ``; + +export const ICON_LOADER = ``; + +export const ICON_CHECK = ``; + +export const ICON_SHIELD = ``; + +export const ICON_ROUTE = ``; + +export const ICON_CHEVRON_DOWN = ``; + +export const TARGET_ICONS: Record = { + "dgx-spark": ICON_CHIP, + "dgx-station": ICON_SERVER, +}; diff --git a/brev/nemoclaw-ui-extension/extension/index.ts b/brev/nemoclaw-ui-extension/extension/index.ts new file mode 100644 index 0000000..877d3c2 --- /dev/null +++ b/brev/nemoclaw-ui-extension/extension/index.ts @@ -0,0 +1,47 @@ +/** + * NeMoClaw DevX Extension + * + * Injects into the OpenClaw UI: + * 1. A green "Deploy DGX Spark/Station" CTA button in the topbar + * 2. A "NeMoClaw" collapsible nav group with Policy and Inference Routes pages + * 3. A model selector wired to NVIDIA endpoints via config.patch + * + * Operates purely as an overlay — no original OpenClaw source files are modified. + */ + +import "./styles.css"; +import { injectButton } from "./deploy-modal.ts"; +import { injectNavGroup, watchOpenClawNavClicks } from "./nav-group.ts"; +import { injectModelSelector, watchChatCompose } from "./model-selector.ts"; + +function inject(): boolean { + const hasButton = injectButton(); + const hasNav = injectNavGroup(); + return hasButton && hasNav; +} + +function bootstrap() { + watchOpenClawNavClicks(); + watchChatCompose(); + + if (inject()) { + injectModelSelector(); + return; + } + + const observer = new MutationObserver(() => { + if (inject()) { + injectModelSelector(); + observer.disconnect(); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + setTimeout(() => observer.disconnect(), 30_000); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bootstrap); +} else { + bootstrap(); +} diff --git a/brev/nemoclaw-ui-extension/extension/model-registry.ts b/brev/nemoclaw-ui-extension/extension/model-registry.ts new file mode 100644 index 0000000..324b9cb --- /dev/null +++ b/brev/nemoclaw-ui-extension/extension/model-registry.ts @@ -0,0 +1,195 @@ +/** + * NeMoClaw DevX — NVIDIA Model & Deploy Registry + * + * Static registry of available NVIDIA model endpoints. + * + * Each entry carries enough information to: + * 1. Render the dropdown option + * 2. Build a `config.patch` payload that adds the provider and switches + * `agents.defaults.model.primary` + * + * The two NVIDIA API platforms use separate API keys: + * - inference-api.nvidia.com — NVIDIA_INFERENCE_API_KEY (injected at install time) + * - integrate.api.nvidia.com — NVIDIA_INTEGRATE_API_KEY (injected at install time) + * + * API keys are replaced by install.sh from .env placeholders. + */ + +// --------------------------------------------------------------------------- +// API key placeholders — replaced at install time by install.sh +// --------------------------------------------------------------------------- + +export const NVIDIA_INFERENCE_API_KEY = "__NVIDIA_INFERENCE_API_KEY__"; +export const NVIDIA_INTEGRATE_API_KEY = "__NVIDIA_INTEGRATE_API_KEY__"; + +// --------------------------------------------------------------------------- +// Model provider / entry types +// --------------------------------------------------------------------------- + +export interface ModelProviderConfig { + baseUrl: string; + api: string; + apiKey?: string; + models: Array<{ + id: string; + name: string; + reasoning: boolean; + input: string[]; + cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + contextWindow: number; + maxTokens: number; + }>; +} + +export interface ModelEntry { + id: string; + name: string; + isDefault: boolean; + providerKey: string; + modelRef: string; + apiKey: string | null; + providerConfig: ModelProviderConfig; +} + +// --------------------------------------------------------------------------- +// Model registry +// --------------------------------------------------------------------------- + +const DEFAULT_PROVIDER_KEY = "custom-inference-api-nvidia-com"; + +export const MODEL_REGISTRY: readonly ModelEntry[] = [ + { + id: "nvidia-claude-opus-4-6", + name: "NVIDIA Claude Opus 4.6", + isDefault: true, + providerKey: DEFAULT_PROVIDER_KEY, + modelRef: `${DEFAULT_PROVIDER_KEY}/aws/anthropic/bedrock-claude-opus-4-6`, + apiKey: NVIDIA_INFERENCE_API_KEY, + providerConfig: { + baseUrl: "https://inference-api.nvidia.com/v1", + api: "openai-completions", + models: [ + { + id: "aws/anthropic/bedrock-claude-opus-4-6", + name: "aws/anthropic/bedrock-claude-opus-4-6 (Custom Provider)", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8192, + }, + ], + }, + }, + { + id: "kimi-k2.5", + name: "Kimi K2.5", + isDefault: false, + providerKey: "custom-nvidia-kimi-k2-5", + modelRef: "custom-nvidia-kimi-k2-5/moonshotai/kimi-k2.5", + apiKey: NVIDIA_INTEGRATE_API_KEY, + providerConfig: { + baseUrl: "https://integrate.api.nvidia.com/v1", + api: "openai-completions", + models: [ + { + id: "moonshotai/kimi-k2.5", + name: "Kimi K2.5 (NVIDIA)", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131_072, + maxTokens: 16_384, + }, + ], + }, + }, + { + id: "nemotron-ultra-253b", + name: "Nemotron Ultra 253B", + isDefault: false, + providerKey: "custom-nvidia-nemotron-ultra", + modelRef: "custom-nvidia-nemotron-ultra/nvidia/llama-3.1-nemotron-ultra-253b-v1", + apiKey: NVIDIA_INTEGRATE_API_KEY, + providerConfig: { + baseUrl: "https://integrate.api.nvidia.com/v1", + api: "openai-completions", + models: [ + { + id: "nvidia/llama-3.1-nemotron-ultra-253b-v1", + name: "Nemotron Ultra 253B (NVIDIA)", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131_072, + maxTokens: 8192, + }, + ], + }, + }, + { + id: "deepseek-v3.2", + name: "DeepSeek V3.2", + isDefault: false, + providerKey: "custom-nvidia-deepseek-v3-2", + modelRef: "custom-nvidia-deepseek-v3-2/deepseek-ai/deepseek-r1", + apiKey: NVIDIA_INTEGRATE_API_KEY, + providerConfig: { + baseUrl: "https://integrate.api.nvidia.com/v1", + api: "openai-completions", + models: [ + { + id: "deepseek-ai/deepseek-r1", + name: "DeepSeek V3.2 (NVIDIA)", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131_072, + maxTokens: 16_384, + }, + ], + }, + }, +] as const; + +export const DEFAULT_MODEL = MODEL_REGISTRY.find((m) => m.isDefault)!; + +export function getModelById(id: string): ModelEntry | undefined { + return MODEL_REGISTRY.find((m) => m.id === id); +} + +// --------------------------------------------------------------------------- +// Deploy targets (used by deploy-modal.ts) +// --------------------------------------------------------------------------- + +export interface DeployTarget { + id: string; + name: string; + description: string; + endpoint: string; + apiKeyHeader: string; +} + +export const DEPLOY_TARGETS: DeployTarget[] = [ + { + id: "dgx-spark", + name: "DGX Spark", + description: "Personal AI computing with up to 128 GB unified memory", + endpoint: "https://integrate.api.nvidia.com/v1/deployments/spark", + apiKeyHeader: "Authorization", + }, + { + id: "dgx-station", + name: "DGX Station", + description: "Workgroup AI workstation with multi-GPU performance", + endpoint: "https://integrate.api.nvidia.com/v1/deployments/station", + apiKeyHeader: "Authorization", + }, +]; + +export function getApiKey(target: DeployTarget): string { + if (target.endpoint.includes("integrate.api.nvidia.com")) { + return NVIDIA_INTEGRATE_API_KEY; + } + return NVIDIA_INFERENCE_API_KEY; +} diff --git a/brev/nemoclaw-ui-extension/extension/model-selector.ts b/brev/nemoclaw-ui-extension/extension/model-selector.ts new file mode 100644 index 0000000..420282a --- /dev/null +++ b/brev/nemoclaw-ui-extension/extension/model-selector.ts @@ -0,0 +1,314 @@ +/** + * NeMoClaw DevX — Model Selector + * + * Dropdown injected into the chat compose area that lets users pick an + * NVIDIA model. On selection, sends a config.patch RPC through the + * gateway bridge to register the provider and switch the primary model. + */ + +import { ICON_CHEVRON_DOWN, ICON_LOADER, ICON_CHECK, ICON_CLOSE } from "./icons.ts"; +import { + MODEL_REGISTRY, + DEFAULT_MODEL, + getModelById, + type ModelEntry, +} from "./model-registry.ts"; +import { patchConfig, waitForReconnect } from "./gateway-bridge.ts"; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let selectedModelId = DEFAULT_MODEL.id; +let modelSelectorObserver: MutationObserver | null = null; +let applyInFlight = false; + +// --------------------------------------------------------------------------- +// Build the config.patch payload for a given model entry +// --------------------------------------------------------------------------- + +function buildModelPatch(entry: ModelEntry): Record { + const providerDef: Record = { + baseUrl: entry.providerConfig.baseUrl, + api: entry.providerConfig.api, + models: entry.providerConfig.models, + }; + + if (entry.apiKey && !entry.apiKey.startsWith("__")) { + providerDef.apiKey = entry.apiKey; + } + + return { + models: { + providers: { + [entry.providerKey]: providerDef, + }, + }, + agents: { + defaults: { + model: { primary: entry.modelRef }, + }, + }, + }; +} + +// --------------------------------------------------------------------------- +// Transition banner lifecycle +// --------------------------------------------------------------------------- + +let activeBanner: HTMLElement | null = null; + +function showTransitionBanner(modelName: string): void { + dismissTransitionBanner(); + + document.body.classList.add("nemoclaw-switching"); + + const chatCompose = document.querySelector(".chat-compose"); + if (!chatCompose) return; + + const banner = document.createElement("div"); + banner.className = "nemoclaw-switching-banner nemoclaw-switching-banner--loading"; + banner.innerHTML = `${ICON_LOADER}Switching to ${modelName}`; + + chatCompose.insertBefore(banner, chatCompose.firstChild); + activeBanner = banner; +} + +function updateTransitionBannerSuccess(modelName: string): void { + if (!activeBanner) return; + + activeBanner.className = "nemoclaw-switching-banner nemoclaw-switching-banner--success"; + activeBanner.innerHTML = `${ICON_CHECK}Now using ${modelName}`; + + document.body.classList.remove("nemoclaw-switching"); + + setTimeout(() => { + if (!activeBanner) return; + activeBanner.classList.add("nemoclaw-switching-banner--dismiss"); + activeBanner.addEventListener("animationend", () => { + dismissTransitionBanner(); + }, { once: true }); + }, 2000); +} + +function updateTransitionBannerError(message: string): void { + if (!activeBanner) return; + + activeBanner.className = "nemoclaw-switching-banner nemoclaw-switching-banner--error"; + activeBanner.innerHTML = `${ICON_CLOSE}${message}`; + + document.body.classList.remove("nemoclaw-switching"); + + setTimeout(() => dismissTransitionBanner(), 6000); +} + +function dismissTransitionBanner(): void { + if (activeBanner) { + activeBanner.remove(); + activeBanner = null; + } + document.body.classList.remove("nemoclaw-switching"); +} + +// --------------------------------------------------------------------------- +// Apply model selection to backend +// --------------------------------------------------------------------------- + +async function applyModelSelection( + entry: ModelEntry, + wrapper: HTMLElement, + trigger: HTMLElement, + previousModelId: string, +) { + if (applyInFlight) return; + applyInFlight = true; + + const valueEl = trigger.querySelector(".nemoclaw-model-trigger__value"); + const chevronEl = trigger.querySelector(".nemoclaw-model-trigger__chevron"); + const originalChevron = chevronEl?.innerHTML ?? ""; + + if (chevronEl) { + chevronEl.innerHTML = ICON_LOADER; + chevronEl.classList.add("nemoclaw-model-trigger__chevron--loading"); + } + trigger.style.pointerEvents = "none"; + + showTransitionBanner(entry.name); + + try { + const patch = buildModelPatch(entry); + await patchConfig(patch); + + if (valueEl) valueEl.textContent = entry.name; + + try { + await waitForReconnect(15_000); + updateTransitionBannerSuccess(entry.name); + } catch { + updateTransitionBannerError("Model applied but gateway reconnection timed out"); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[NeMoClaw] Failed to apply model:", msg); + + selectedModelId = previousModelId; + const prev = getModelById(previousModelId) ?? DEFAULT_MODEL; + if (valueEl) valueEl.textContent = prev.name; + + updateDropdownSelection(wrapper, previousModelId); + updateTransitionBannerError(`Failed to switch model: ${msg}`); + } finally { + if (chevronEl) { + chevronEl.innerHTML = originalChevron; + chevronEl.classList.remove("nemoclaw-model-trigger__chevron--loading"); + } + trigger.style.pointerEvents = ""; + applyInFlight = false; + } +} + +// --------------------------------------------------------------------------- +// Dropdown selection helpers +// --------------------------------------------------------------------------- + +function updateDropdownSelection(wrapper: HTMLElement, modelId: string) { + wrapper.querySelectorAll(".nemoclaw-model-option").forEach((el) => { + const isSelected = el.dataset.modelId === modelId; + el.classList.toggle("nemoclaw-model-option--selected", isSelected); + el.setAttribute("aria-selected", String(isSelected)); + }); +} + +// --------------------------------------------------------------------------- +// Build selector DOM +// --------------------------------------------------------------------------- + +function buildModelSelector(): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "nemoclaw-model-selector"; + wrapper.dataset.nemoclawModelSelector = "true"; + + const current = getModelById(selectedModelId) ?? DEFAULT_MODEL; + + const trigger = document.createElement("button"); + trigger.className = "nemoclaw-model-trigger"; + trigger.type = "button"; + trigger.setAttribute("aria-haspopup", "listbox"); + trigger.setAttribute("aria-expanded", "false"); + trigger.innerHTML = `Model${current.name}${ICON_CHEVRON_DOWN}`; + + const dropdown = document.createElement("div"); + dropdown.className = "nemoclaw-model-dropdown"; + dropdown.setAttribute("role", "listbox"); + dropdown.style.display = "none"; + + for (const model of MODEL_REGISTRY) { + const option = document.createElement("button"); + option.className = `nemoclaw-model-option${model.id === selectedModelId ? " nemoclaw-model-option--selected" : ""}`; + option.type = "button"; + option.setAttribute("role", "option"); + option.setAttribute("aria-selected", String(model.id === selectedModelId)); + option.dataset.modelId = model.id; + option.textContent = model.name; + dropdown.appendChild(option); + } + + const poweredBy = document.createElement("a"); + poweredBy.className = "nemoclaw-model-powered"; + poweredBy.href = "https://build.nvidia.com/models"; + poweredBy.target = "_blank"; + poweredBy.rel = "noopener noreferrer"; + poweredBy.textContent = "Powered by NVIDIA endpoints from build.nvidia.com"; + + wrapper.appendChild(poweredBy); + wrapper.appendChild(trigger); + wrapper.appendChild(dropdown); + + // Toggle dropdown + trigger.addEventListener("click", (e) => { + e.stopPropagation(); + if (applyInFlight) return; + const open = dropdown.style.display !== "none"; + dropdown.style.display = open ? "none" : ""; + trigger.setAttribute("aria-expanded", String(!open)); + wrapper.classList.toggle("nemoclaw-model-selector--open", !open); + }); + + // Handle model selection + dropdown.addEventListener("click", (e) => { + const opt = (e.target as HTMLElement).closest("[data-model-id]"); + if (!opt) return; + e.stopPropagation(); + + const newModelId = opt.dataset.modelId!; + if (newModelId === selectedModelId) { + dropdown.style.display = "none"; + trigger.setAttribute("aria-expanded", "false"); + wrapper.classList.remove("nemoclaw-model-selector--open"); + return; + } + + const entry = getModelById(newModelId); + if (!entry) return; + + const previousModelId = selectedModelId; + selectedModelId = newModelId; + + updateDropdownSelection(wrapper, newModelId); + const valueEl = trigger.querySelector(".nemoclaw-model-trigger__value"); + if (valueEl) valueEl.textContent = entry.name; + + dropdown.style.display = "none"; + trigger.setAttribute("aria-expanded", "false"); + wrapper.classList.remove("nemoclaw-model-selector--open"); + + applyModelSelection(entry, wrapper, trigger, previousModelId); + }); + + // Close on outside click + const closeOnOutsideClick = (e: MouseEvent) => { + if (!wrapper.contains(e.target as Node)) { + dropdown.style.display = "none"; + trigger.setAttribute("aria-expanded", "false"); + wrapper.classList.remove("nemoclaw-model-selector--open"); + } + }; + document.addEventListener("click", closeOnOutsideClick, true); + + return wrapper; +} + +// --------------------------------------------------------------------------- +// Injection into .chat-compose__actions +// --------------------------------------------------------------------------- + +export function injectModelSelector() { + const actionsEl = document.querySelector(".chat-compose__actions"); + if (!actionsEl) return; + + if (actionsEl.parentElement?.classList.contains("nemoclaw-actions-column")) return; + + const row = actionsEl.parentElement; + if (!row) return; + + const column = document.createElement("div"); + column.className = "nemoclaw-actions-column"; + + const selector = buildModelSelector(); + row.insertBefore(column, actionsEl); + column.appendChild(selector); + column.appendChild(actionsEl); +} + +export function watchChatCompose() { + if (modelSelectorObserver) return; + + modelSelectorObserver = new MutationObserver(() => { + const actionsEl = document.querySelector(".chat-compose__actions"); + if (actionsEl && !actionsEl.parentElement?.classList.contains("nemoclaw-actions-column")) { + injectModelSelector(); + } + }); + + modelSelectorObserver.observe(document.body, { childList: true, subtree: true }); +} diff --git a/brev/nemoclaw-ui-extension/extension/nav-group.ts b/brev/nemoclaw-ui-extension/extension/nav-group.ts new file mode 100644 index 0000000..fbbc9cc --- /dev/null +++ b/brev/nemoclaw-ui-extension/extension/nav-group.ts @@ -0,0 +1,216 @@ +/** + * NeMoClaw DevX — Sidebar Nav Group + * + * Collapsible "NeMoClaw" nav group with Policy and Inference Routes pages. + * Renders page overlays on top of . + */ + +import { ICON_SHIELD, ICON_ROUTE } from "./icons.ts"; + +// --------------------------------------------------------------------------- +// Page definitions +// --------------------------------------------------------------------------- + +interface NemoClawPage { + id: string; + label: string; + icon: string; + title: string; + subtitle: string; + emptyMessage: string; +} + +const NEMOCLAW_PAGES: NemoClawPage[] = [ + { + id: "nemoclaw-policy", + label: "Policy", + icon: ICON_SHIELD, + title: "Policy", + subtitle: "Manage deployment policies and guardrails", + emptyMessage: + "Policy configuration is coming soon. You'll be able to define safety policies, rate limits, and access controls for your NeMoClaw deployments here.", + }, + { + id: "nemoclaw-inference-routes", + label: "Inference Routes", + icon: ICON_ROUTE, + title: "Inference Routes", + subtitle: "Configure model routing and endpoint mappings", + emptyMessage: + "Inference route management is coming soon. You'll be able to configure model routing, load balancing, and failover strategies here.", + }, +]; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let navGroupEl: HTMLElement | null = null; +let activeNemoPage: string | null = null; +let pageOverlayEl: HTMLElement | null = null; +let navGroupCollapsed = false; + +// --------------------------------------------------------------------------- +// Nav group injection +// --------------------------------------------------------------------------- + +function buildNavGroup(): HTMLElement { + const group = document.createElement("div"); + group.className = "nav-group nemoclaw-nav-group"; + group.dataset.nemoclawNav = "true"; + + const label = document.createElement("button"); + label.className = "nav-label"; + label.setAttribute("aria-expanded", "true"); + label.innerHTML = `NeMoClaw`; + label.addEventListener("click", () => { + navGroupCollapsed = !navGroupCollapsed; + applyNavGroupCollapsed(group); + }); + + const items = document.createElement("div"); + items.className = "nav-group__items"; + + for (const page of NEMOCLAW_PAGES) { + const item = document.createElement("a"); + item.href = "#"; + item.className = "nav-item"; + item.dataset.nemoclawPage = page.id; + item.innerHTML = `${page.label}`; + item.addEventListener("click", (e) => { + e.preventDefault(); + activateNemoPage(page.id); + }); + items.appendChild(item); + } + + group.appendChild(label); + group.appendChild(items); + return group; +} + +function applyNavGroupCollapsed(group: HTMLElement) { + const chevron = group.querySelector(".nav-label__chevron"); + const label = group.querySelector(".nav-label"); + if (navGroupCollapsed && !activeNemoPage) { + group.classList.add("nav-group--collapsed"); + if (chevron) chevron.textContent = "+"; + label?.setAttribute("aria-expanded", "false"); + } else { + group.classList.remove("nav-group--collapsed"); + if (chevron) chevron.textContent = "−"; + label?.setAttribute("aria-expanded", "true"); + } +} + +export function injectNavGroup(): boolean { + if (navGroupEl && document.contains(navGroupEl)) return true; + + const nav = document.querySelector("aside.nav"); + if (!nav) return false; + + const allGroups = nav.querySelectorAll(":scope > .nav-group"); + const group = buildNavGroup(); + + if (allGroups.length >= 1) { + allGroups[0].after(group); + } else { + nav.prepend(group); + } + + navGroupEl = group; + return true; +} + +// --------------------------------------------------------------------------- +// Page activation / deactivation +// --------------------------------------------------------------------------- + +function activateNemoPage(pageId: string) { + activeNemoPage = pageId; + clearAllActiveNavItems(); + + document.querySelectorAll("[data-nemoclaw-page]").forEach((el) => { + el.classList.toggle("active", el.dataset.nemoclawPage === pageId); + }); + + if (navGroupEl) applyNavGroupCollapsed(navGroupEl); + showPageOverlay(pageId); +} + +function deactivateNemoPages() { + activeNemoPage = null; + document.querySelectorAll("[data-nemoclaw-page]").forEach((el) => { + el.classList.remove("active"); + }); + hidePageOverlay(); +} + +function clearAllActiveNavItems() { + document.querySelectorAll("aside.nav .nav-item.active").forEach((el) => { + if (!el.dataset.nemoclawPage) { + el.classList.remove("active"); + } + }); +} + +// --------------------------------------------------------------------------- +// Page overlay (renders on top of .content) +// --------------------------------------------------------------------------- + +function showPageOverlay(pageId: string) { + const page = NEMOCLAW_PAGES.find((p) => p.id === pageId); + if (!page) return; + + const content = document.querySelector("main.content"); + if (!content) return; + + hidePageOverlay(); + + const overlay = document.createElement("div"); + overlay.className = "nemoclaw-page-overlay"; + overlay.dataset.nemoclawOverlay = "true"; + + overlay.innerHTML = ` +
+
+
${page.title}
+
${page.subtitle}
+
+
+
+
${page.icon}
+
${page.label}
+

${page.emptyMessage}

+
`; + + content.appendChild(overlay); + pageOverlayEl = overlay; +} + +function hidePageOverlay() { + if (pageOverlayEl) { + pageOverlayEl.remove(); + pageOverlayEl = null; + } +} + +// --------------------------------------------------------------------------- +// Intercept OpenClaw's own nav clicks to deactivate NeMoClaw pages +// --------------------------------------------------------------------------- + +export function watchOpenClawNavClicks() { + document.addEventListener( + "click", + (e) => { + const target = e.target as HTMLElement; + const navItem = target.closest("aside.nav .nav-item"); + if (!navItem) return; + if (navItem.dataset.nemoclawPage) return; + if (activeNemoPage) { + deactivateNemoPages(); + } + }, + true, + ); +} diff --git a/brev/nemoclaw-ui-extension/extension/styles.css b/brev/nemoclaw-ui-extension/extension/styles.css new file mode 100644 index 0000000..cb585eb --- /dev/null +++ b/brev/nemoclaw-ui-extension/extension/styles.css @@ -0,0 +1,719 @@ +/* =========================================== + NeMoClaw DevX — NVIDIA Green: #76B900 + =========================================== */ + +/* =========================================== + Deploy DGX Button + =========================================== */ + +.nemoclaw-deploy-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 14px; + height: 32px; + box-sizing: border-box; + border: 1px solid #76B900; + border-radius: var(--radius-full, 9999px); + background: #76B900; + color: #fff; + font-size: 12px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; + cursor: pointer; + transition: + background 180ms ease, + border-color 180ms ease, + box-shadow 180ms ease, + transform 180ms ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.nemoclaw-deploy-btn:hover { + background: #6aa300; + border-color: #6aa300; + box-shadow: + 0 4px 12px rgba(118, 185, 0, 0.35), + 0 0 20px rgba(118, 185, 0, 0.15); + transform: translateY(-1px); +} + +.nemoclaw-deploy-btn:active { + background: #5a8500; + border-color: #5a8500; + transform: translateY(0); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.nemoclaw-deploy-btn:focus-visible { + outline: 2px solid #76B900; + outline-offset: 2px; +} + +.nemoclaw-deploy-btn__icon { + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.nemoclaw-deploy-btn__icon svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* =========================================== + Deploy Modal Overlay + =========================================== */ + +.nemoclaw-overlay { + position: fixed; + inset: 0; + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + animation: nemoclaw-fade-in 150ms ease; +} + +.nemoclaw-overlay[aria-hidden="true"] { + display: none; +} + +@keyframes nemoclaw-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.nemoclaw-modal { + width: min(520px, 100%); + background: var(--card, #181b22); + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-lg, 12px); + padding: 24px; + animation: nemoclaw-scale-in 200ms cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: + 0 24px 48px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(255, 255, 255, 0.04); +} + +@keyframes nemoclaw-scale-in { + from { opacity: 0; transform: scale(0.96) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.nemoclaw-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} + +.nemoclaw-modal__title { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--text-strong, #fafafa); +} + +.nemoclaw-modal__close { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: var(--radius-md, 8px); + background: transparent; + color: var(--muted, #71717a); + cursor: pointer; + transition: + background 120ms ease, + color 120ms ease; +} + +.nemoclaw-modal__close:hover { + background: var(--bg-hover, #262a35); + color: var(--text, #e4e4e7); +} + +.nemoclaw-modal__close svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-modal__body { + display: grid; + gap: 16px; +} + +.nemoclaw-modal__desc { + color: var(--muted, #71717a); + font-size: 14px; + line-height: 1.55; +} + +.nemoclaw-target-list { + display: grid; + gap: 10px; +} + +.nemoclaw-target { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + padding: 14px 16px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--bg-elevated, #1a1d25); + cursor: pointer; + transition: + border-color 150ms ease, + background 150ms ease, + box-shadow 150ms ease; +} + +.nemoclaw-target:hover { + border-color: #76B900; + background: rgba(118, 185, 0, 0.06); + box-shadow: 0 0 0 1px rgba(118, 185, 0, 0.15); +} + +.nemoclaw-target__icon { + width: 36px; + height: 36px; + border-radius: var(--radius-md, 8px); + background: rgba(118, 185, 0, 0.12); + display: grid; + place-items: center; + color: #76B900; +} + +.nemoclaw-target__icon svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-target__info { + display: grid; + gap: 2px; +} + +.nemoclaw-target__name { + font-size: 14px; + font-weight: 600; + color: var(--text-strong, #fafafa); +} + +.nemoclaw-target__desc { + font-size: 12px; + color: var(--muted, #71717a); +} + +.nemoclaw-target__arrow { + color: var(--muted, #71717a); + transition: color 150ms ease, transform 150ms ease; +} + +.nemoclaw-target:hover .nemoclaw-target__arrow { + color: #76B900; + transform: translateX(2px); +} + +.nemoclaw-target__arrow svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* =========================================== + Status / feedback inside modal + =========================================== */ + +.nemoclaw-status { + padding: 12px 14px; + border-radius: var(--radius-md, 8px); + font-size: 13px; + line-height: 1.5; + display: flex; + align-items: flex-start; + gap: 10px; +} + +.nemoclaw-status--info { + border: 1px solid rgba(59, 130, 246, 0.25); + background: rgba(59, 130, 246, 0.08); + color: var(--info, #3b82f6); +} + +.nemoclaw-status--success { + border: 1px solid rgba(118, 185, 0, 0.25); + background: rgba(118, 185, 0, 0.08); + color: #76B900; +} + +.nemoclaw-status--error { + border: 1px solid rgba(239, 68, 68, 0.25); + background: rgba(239, 68, 68, 0.08); + color: var(--danger, #ef4444); +} + +.nemoclaw-status--loading { + border: 1px solid rgba(118, 185, 0, 0.25); + background: rgba(118, 185, 0, 0.08); + color: #76B900; +} + +.nemoclaw-status svg { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-top: 1px; +} + +.nemoclaw-status--loading svg { + animation: nemoclaw-spin 1s linear infinite; +} + +@keyframes nemoclaw-spin { + to { transform: rotate(360deg); } +} + +/* =========================================== + NeMoClaw Nav Group — NVIDIA Green text + =========================================== */ + +.nemoclaw-nav-group .nav-label__text { + color: #76B900; +} + +.nemoclaw-nav-group .nav-label:hover .nav-label__text { + color: #76B900; +} + +.nemoclaw-nav-group .nav-label__chevron { + color: #76B900; + opacity: 0.7; +} + +.nemoclaw-nav-group .nav-item { + color: #76B900; +} + +.nemoclaw-nav-group .nav-item:hover { + color: #8ad400; +} + +.nemoclaw-nav-group .nav-item .nav-item__icon { + color: #76B900; + opacity: 0.85; +} + +.nemoclaw-nav-group .nav-item:hover .nav-item__icon { + color: #8ad400; + opacity: 1; +} + +.nemoclaw-nav-group .nav-item.active { + color: #76B900; + background: rgba(118, 185, 0, 0.12); +} + +.nemoclaw-nav-group .nav-item.active .nav-item__icon { + color: #76B900; + opacity: 1; +} + +.nemoclaw-nav-group .nav-item__icon svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* =========================================== + NeMoClaw Page Overlay + =========================================== */ + +.nemoclaw-page-overlay { + position: absolute; + inset: 0; + z-index: 20; + background: var(--bg, #12141a); + padding: 12px 16px 32px; + overflow-y: auto; +} + +:root[data-theme="light"] .nemoclaw-page-overlay { + background: var(--bg-content, #f8f8fa); +} + +main.content { + position: relative; +} + +/* =========================================== + Empty State Placeholder + =========================================== */ + +.nemoclaw-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 80px 24px; + max-width: 480px; + margin: 0 auto; + animation: nemoclaw-fade-in 250ms ease; +} + +.nemoclaw-empty-state__icon { + width: 56px; + height: 56px; + border-radius: var(--radius-lg, 12px); + background: rgba(118, 185, 0, 0.10); + display: grid; + place-items: center; + margin-bottom: 20px; + color: #76B900; +} + +.nemoclaw-empty-state__icon svg { + width: 28px; + height: 28px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-empty-state__title { + font-size: 20px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--text-strong, #fafafa); + margin-bottom: 8px; +} + +.nemoclaw-empty-state__message { + font-size: 14px; + line-height: 1.6; + color: var(--muted, #71717a); + margin: 0; +} + +/* =========================================== + Model Selector (Chat Compose) + =========================================== */ + +.nemoclaw-actions-column { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; +} + +.nemoclaw-model-selector { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +/* "Powered by" attribution line */ +.nemoclaw-model-powered { + font-size: 9px; + font-weight: 500; + color: #76B900; + text-decoration: none; + white-space: nowrap; + transition: color 150ms ease; +} + +.nemoclaw-model-powered:hover { + color: #76B900; + text-decoration: underline; + text-underline-offset: 2px; +} + +/* Trigger button — larger sizing */ +.nemoclaw-model-trigger { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + height: 34px; + box-sizing: border-box; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-full, 9999px); + background: var(--secondary, #1e2028); + color: var(--text, #e4e4e7); + font-size: 13px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: + border-color 150ms ease, + background 150ms ease; +} + +.nemoclaw-model-trigger:hover { + border-color: var(--border-strong, #3f3f46); + background: var(--bg-hover, #262a35); +} + +.nemoclaw-model-selector--open .nemoclaw-model-trigger { + border-color: #76B900; +} + +.nemoclaw-model-trigger__label { + color: var(--muted, #71717a); + font-size: 12px; + font-weight: 500; +} + +.nemoclaw-model-trigger__value { + color: #76B900; + font-size: 13px; + font-weight: 600; +} + +.nemoclaw-model-trigger__chevron { + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + color: var(--muted, #71717a); + transition: transform 150ms ease; +} + +.nemoclaw-model-selector--open .nemoclaw-model-trigger__chevron { + transform: rotate(180deg); + color: #76B900; +} + +.nemoclaw-model-trigger__chevron svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-model-trigger__chevron--loading svg { + animation: nemoclaw-spin 1s linear infinite; +} + +/* Dropdown panel — larger */ +.nemoclaw-model-dropdown { + position: absolute; + bottom: calc(100% + 6px); + right: 0; + z-index: 50; + min-width: 230px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--card, #181b22); + padding: 5px; + box-shadow: + 0 8px 24px rgba(0, 0, 0, 0.35), + 0 0 0 1px rgba(255, 255, 255, 0.04); + animation: nemoclaw-scale-in 120ms cubic-bezier(0.16, 1, 0.3, 1); +} + +:root[data-theme="light"] .nemoclaw-model-dropdown { + background: var(--bg, #fff); + box-shadow: + 0 8px 24px rgba(0, 0, 0, 0.12), + 0 0 0 1px rgba(0, 0, 0, 0.06); +} + +/* Dropdown options — larger */ +.nemoclaw-model-option { + display: flex; + align-items: center; + width: 100%; + padding: 10px 14px; + border: none; + border-radius: var(--radius-sm, 6px); + background: transparent; + color: var(--text, #e4e4e7); + font-size: 14px; + font-weight: 500; + cursor: pointer; + text-align: left; + transition: + background 100ms ease, + color 100ms ease; +} + +.nemoclaw-model-option:hover { + background: var(--bg-hover, #262a35); +} + +.nemoclaw-model-option--selected { + color: #76B900; + background: rgba(118, 185, 0, 0.10); +} + +.nemoclaw-model-option--selected:hover { + background: rgba(118, 185, 0, 0.15); +} + +/* =========================================== + Model Switching — Suppress OpenClaw disconnect artifacts + =========================================== */ + +body.nemoclaw-switching .card.chat > .callout { + display: none !important; +} + +body.nemoclaw-switching .statusDot:not(.ok) { + visibility: hidden; +} + +body.nemoclaw-switching .topbar-status .pill .mono { + visibility: hidden; +} + +body.nemoclaw-switching .chat-compose textarea[disabled] { + opacity: 1 !important; + color: var(--text, #e4e4e7) !important; + cursor: text; +} + +body.nemoclaw-switching .chat-compose__actions button[disabled] { + opacity: 0.6; +} + +/* =========================================== + Model Switching — Transition Banner + =========================================== */ + +.nemoclaw-switching-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + margin-bottom: 8px; + border-radius: var(--radius-md, 8px); + font-size: 13px; + font-weight: 500; + line-height: 1.4; + animation: nemoclaw-slide-down 200ms cubic-bezier(0.16, 1, 0.3, 1); + border: 1px solid rgba(118, 185, 0, 0.25); + background: rgba(118, 185, 0, 0.08); + color: #76B900; +} + +.nemoclaw-switching-banner svg { + width: 15px; + height: 15px; + flex-shrink: 0; +} + +.nemoclaw-switching-banner--loading svg { + animation: nemoclaw-spin 1s linear infinite; +} + +.nemoclaw-switching-banner--success { + border-color: rgba(118, 185, 0, 0.3); + background: rgba(118, 185, 0, 0.1); + color: #76B900; +} + +.nemoclaw-switching-banner--error { + border-color: rgba(239, 68, 68, 0.25); + background: rgba(239, 68, 68, 0.08); + color: var(--danger, #ef4444); +} + +.nemoclaw-switching-banner strong { + font-weight: 700; +} + +.nemoclaw-switching-banner--dismiss { + animation: nemoclaw-banner-fade-out 300ms ease forwards; +} + +@keyframes nemoclaw-slide-down { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes nemoclaw-banner-fade-out { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(-4px); } +} + +/* =========================================== + Responsive + =========================================== */ + +@media (max-width: 1100px) { + .nemoclaw-deploy-btn span:not(.nemoclaw-deploy-btn__icon) { + display: none; + } + + .nemoclaw-deploy-btn { + padding: 6px 8px; + gap: 0; + } + + .nemoclaw-actions-column { + flex-direction: column; + align-items: stretch; + width: 100%; + } + + .nemoclaw-model-selector { + width: 100%; + } + + .nemoclaw-model-trigger { + width: 100%; + justify-content: center; + } + + .nemoclaw-model-dropdown { + right: auto; + left: 0; + width: 100%; + } +} diff --git a/brev/nemoclaw-ui-extension/install.sh b/brev/nemoclaw-ui-extension/install.sh new file mode 100755 index 0000000..c456f45 --- /dev/null +++ b/brev/nemoclaw-ui-extension/install.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# +# install.sh — Install the NeMoClaw DevX extension into an OpenClaw UI tree. +# +# Usage: +# bash install.sh /path/to/openclaw/ui +# bash install.sh # uses ../openclaw/ui relative to repo +# +set -euo pipefail + +GREEN='\033[0;32m' +RED='\033[0;31m' +DIM='\033[0;90m' +RESET='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +EXT_SRC="$SCRIPT_DIR/extension" +ENV_FILE="$SCRIPT_DIR/.env" + +UI_DIR="${1:-}" +if [ -z "$UI_DIR" ]; then + # Try common sibling location + if [ -f "$SCRIPT_DIR/../openclaw/ui/src/main.ts" ]; then + UI_DIR="$(cd "$SCRIPT_DIR/../openclaw/ui" && pwd)" + else + echo -e "${RED}Error:${RESET} No UI directory specified." + echo " Usage: bash install.sh /path/to/openclaw/ui" + exit 1 + fi +fi + +MAIN_TS="$UI_DIR/src/main.ts" +TARGET_EXT="$UI_DIR/src/extensions/nemoclaw-devx" +IMPORT_LINE='import "./extensions/nemoclaw-devx/index.ts";' + +echo -e "${GREEN}NeMoClaw DevX Extension Installer${RESET}" +echo -e "${DIM}─────────────────────────────${RESET}" + +# --- Verify targets --- +if [ ! -f "$MAIN_TS" ]; then + echo -e "${RED}Error:${RESET} Cannot find $MAIN_TS" + echo " Make sure the path points to the openclaw/ui directory." + exit 1 +fi + +if [ ! -f "$EXT_SRC/index.ts" ]; then + echo -e "${RED}Error:${RESET} Extension source files not found at $EXT_SRC/" + echo " The repo appears incomplete." + exit 1 +fi + +# --- Load .env --- +if [ ! -f "$ENV_FILE" ]; then + echo -e "${RED}Error:${RESET} .env file not found at $ENV_FILE" + echo " Copy .env.example to .env and fill in your API keys." + exit 1 +fi + +set -a +source "$ENV_FILE" +set +a + +if [ -z "${NVIDIA_INFERENCE_API_KEY:-}" ] || [ "$NVIDIA_INFERENCE_API_KEY" = "your-key-here" ]; then + echo -e "${RED}Error:${RESET} NVIDIA_INFERENCE_API_KEY is not set in .env" + echo " Edit $ENV_FILE and provide your inference-api.nvidia.com key." + exit 1 +fi + +if [ -z "${NVIDIA_INTEGRATE_API_KEY:-}" ] || [ "$NVIDIA_INTEGRATE_API_KEY" = "your-key-here" ]; then + echo -e "${RED}Error:${RESET} NVIDIA_INTEGRATE_API_KEY is not set in .env" + echo " Edit $ENV_FILE and provide your integrate.api.nvidia.com key." + exit 1 +fi + +echo -e " Repo: ${DIM}$SCRIPT_DIR${RESET}" +echo -e " UI directory: ${DIM}$UI_DIR${RESET}" + +# --- Copy extension files --- +mkdir -p "$TARGET_EXT" +cp "$EXT_SRC"/* "$TARGET_EXT/" + +FILE_COUNT=$(find "$TARGET_EXT" -type f | wc -l | tr -d ' ') +echo -e " Copied files: ${GREEN}$FILE_COUNT${RESET} -> $TARGET_EXT/" + +# --- Substitute API key placeholders --- +REGISTRY="$TARGET_EXT/model-registry.ts" +KEYS_INJECTED=0 + +if grep -q '__NVIDIA_INFERENCE_API_KEY__' "$REGISTRY" 2>/dev/null; then + sed -i "s|__NVIDIA_INFERENCE_API_KEY__|${NVIDIA_INFERENCE_API_KEY}|g" "$REGISTRY" + KEYS_INJECTED=$((KEYS_INJECTED + 1)) +fi + +if grep -q '__NVIDIA_INTEGRATE_API_KEY__' "$REGISTRY" 2>/dev/null; then + sed -i "s|__NVIDIA_INTEGRATE_API_KEY__|${NVIDIA_INTEGRATE_API_KEY}|g" "$REGISTRY" + KEYS_INJECTED=$((KEYS_INJECTED + 1)) +fi + +if [ "$KEYS_INJECTED" -gt 0 ]; then + echo -e " API keys: ${GREEN}${KEYS_INJECTED} injected${RESET}" +else + echo -e " API keys: ${DIM}no placeholders found (already set?)${RESET}" +fi + +# --- Patch main.ts --- +if grep -qF "$IMPORT_LINE" "$MAIN_TS" 2>/dev/null; then + echo -e " main.ts: ${DIM}already patched (skipping)${RESET}" +else + echo "$IMPORT_LINE" >> "$MAIN_TS" + echo -e " main.ts: ${GREEN}patched${RESET} — added import line" +fi + +echo "" +echo -e "${GREEN}Done!${RESET} NeMoClaw DevX extension is installed." +echo "" +echo " To build: cd $UI_DIR && pnpm build" +echo " To dev: cd $UI_DIR && pnpm dev" +echo " To uninstall: bash $SCRIPT_DIR/uninstall.sh $UI_DIR" diff --git a/brev/nemoclaw-ui-extension/uninstall.sh b/brev/nemoclaw-ui-extension/uninstall.sh new file mode 100755 index 0000000..40c3084 --- /dev/null +++ b/brev/nemoclaw-ui-extension/uninstall.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# uninstall.sh — Remove the NeMoClaw DevX extension from an OpenClaw UI tree. +# +# Usage: +# bash uninstall.sh /path/to/openclaw/ui +# bash uninstall.sh # uses ../openclaw/ui relative to repo +# +set -euo pipefail + +GREEN='\033[0;32m' +RED='\033[0;31m' +DIM='\033[0;90m' +RESET='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +UI_DIR="${1:-}" +if [ -z "$UI_DIR" ]; then + if [ -f "$SCRIPT_DIR/../openclaw/ui/src/main.ts" ]; then + UI_DIR="$(cd "$SCRIPT_DIR/../openclaw/ui" && pwd)" + else + echo -e "${RED}Error:${RESET} No UI directory specified." + echo " Usage: bash uninstall.sh /path/to/openclaw/ui" + exit 1 + fi +fi + +MAIN_TS="$UI_DIR/src/main.ts" +TARGET_EXT="$UI_DIR/src/extensions/nemoclaw-devx" +IMPORT_LINE='import "./extensions/nemoclaw-devx/index.ts";' + +echo -e "${GREEN}NeMoClaw DevX Extension Uninstaller${RESET}" +echo -e "${DIM}───────────────────────────────${RESET}" + +# --- Remove extension directory --- +if [ -d "$TARGET_EXT" ]; then + rm -rf "$TARGET_EXT" + echo -e " Extension dir: ${GREEN}removed${RESET}" +else + echo -e " Extension dir: ${DIM}not found (already removed?)${RESET}" +fi + +# Clean up empty parent if it exists +EXTENSIONS_DIR="$UI_DIR/src/extensions" +if [ -d "$EXTENSIONS_DIR" ] && [ -z "$(ls -A "$EXTENSIONS_DIR" 2>/dev/null)" ]; then + rmdir "$EXTENSIONS_DIR" 2>/dev/null || true + echo -e " extensions/: ${DIM}removed empty directory${RESET}" +fi + +# --- Remove import from main.ts --- +if [ -f "$MAIN_TS" ]; then + if grep -qF "$IMPORT_LINE" "$MAIN_TS" 2>/dev/null; then + grep -vF "$IMPORT_LINE" "$MAIN_TS" > "$MAIN_TS.tmp" && mv "$MAIN_TS.tmp" "$MAIN_TS" + echo -e " main.ts: ${GREEN}cleaned${RESET} — removed import line" + else + echo -e " main.ts: ${DIM}no import line found (already clean?)${RESET}" + fi +else + echo -e " main.ts: ${DIM}not found${RESET}" +fi + +echo "" +echo -e "${GREEN}Done!${RESET} NeMoClaw DevX extension has been uninstalled." +echo "" +echo " Rebuild with: cd $UI_DIR && pnpm build" From 27009d9440e1dfd64a15a59c7077cae730caaadd Mon Sep 17 00:00:00 2001 From: Kasikrit Chantharuang Date: Fri, 6 Mar 2026 22:47:34 +0000 Subject: [PATCH 2/8] Refactored and reorganized code for NemoClaw --- sandboxes/nemoclaw/.gitignore | 2 + sandboxes/nemoclaw/Dockerfile | 43 +++ sandboxes/nemoclaw/README.md | 94 +++++++ sandboxes/nemoclaw/build.sh | 20 ++ sandboxes/nemoclaw/nemoclaw-start.sh | 58 ++++ .../nemoclaw-ui-extension/.env.example | 0 .../nemoclaw-ui-extension/.gitignore | 0 .../extension/api-keys-page.ts | 190 +++++++++++++ .../extension/deploy-modal.ts | 6 +- .../extension/gateway-bridge.ts | 0 .../nemoclaw-ui-extension/extension/icons.ts | 6 + .../nemoclaw-ui-extension/extension/index.ts | 21 +- .../extension/model-registry.ts | 65 ++++- .../extension/model-selector.ts | 25 +- .../extension/nav-group.ts | 65 +++-- .../extension/styles.css | 261 ++++++++++++++++++ .../nemoclaw-ui-extension/install.sh | 0 .../nemoclaw-ui-extension/uninstall.sh | 0 sandboxes/nemoclaw/policy.yaml | 167 +++++++++++ sandboxes/nemoclaw/skills/.gitkeep | 0 20 files changed, 981 insertions(+), 42 deletions(-) create mode 100644 sandboxes/nemoclaw/.gitignore create mode 100644 sandboxes/nemoclaw/Dockerfile create mode 100644 sandboxes/nemoclaw/README.md create mode 100755 sandboxes/nemoclaw/build.sh create mode 100644 sandboxes/nemoclaw/nemoclaw-start.sh rename {brev => sandboxes/nemoclaw}/nemoclaw-ui-extension/.env.example (100%) rename {brev => sandboxes/nemoclaw}/nemoclaw-ui-extension/.gitignore (100%) create mode 100644 sandboxes/nemoclaw/nemoclaw-ui-extension/extension/api-keys-page.ts rename {brev => sandboxes/nemoclaw}/nemoclaw-ui-extension/extension/deploy-modal.ts (95%) rename {brev => sandboxes/nemoclaw}/nemoclaw-ui-extension/extension/gateway-bridge.ts (100%) rename {brev => sandboxes/nemoclaw}/nemoclaw-ui-extension/extension/icons.ts (77%) rename {brev => sandboxes/nemoclaw}/nemoclaw-ui-extension/extension/index.ts (60%) rename {brev => sandboxes/nemoclaw}/nemoclaw-ui-extension/extension/model-registry.ts (73%) rename {brev => sandboxes/nemoclaw}/nemoclaw-ui-extension/extension/model-selector.ts (94%) rename {brev => sandboxes/nemoclaw}/nemoclaw-ui-extension/extension/nav-group.ts (77%) rename {brev => sandboxes/nemoclaw}/nemoclaw-ui-extension/extension/styles.css (74%) rename {brev => sandboxes/nemoclaw}/nemoclaw-ui-extension/install.sh (100%) rename {brev => sandboxes/nemoclaw}/nemoclaw-ui-extension/uninstall.sh (100%) create mode 100644 sandboxes/nemoclaw/policy.yaml create mode 100644 sandboxes/nemoclaw/skills/.gitkeep diff --git a/sandboxes/nemoclaw/.gitignore b/sandboxes/nemoclaw/.gitignore new file mode 100644 index 0000000..4c2fcb0 --- /dev/null +++ b/sandboxes/nemoclaw/.gitignore @@ -0,0 +1,2 @@ +# Synced from brev/nemoclaw-ui-extension/extension/ at build time — do not edit here. +nemoclaw-devx/ diff --git a/sandboxes/nemoclaw/Dockerfile b/sandboxes/nemoclaw/Dockerfile new file mode 100644 index 0000000..17e375e --- /dev/null +++ b/sandboxes/nemoclaw/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.4 + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# NeMoClaw sandbox image +# +# Builds on the OpenClaw sandbox and adds the NeMoClaw DevX UI extension +# (model selector, deploy modal, API keys page, nav group). +# +# Build: docker build -t nemoclaw . +# Run: nemoclaw sandbox create --from nemoclaw --forward 18789 -- nemoclaw-start + +ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw-community/sandboxes/openclaw:latest +FROM ${BASE_IMAGE} + +USER root + +# Override the startup script with our version (adds runtime API key injection) +COPY nemoclaw-start.sh /usr/local/bin/nemoclaw-start +RUN chmod +x /usr/local/bin/nemoclaw-start + +# Stage the NeMoClaw DevX extension source +COPY nemoclaw-ui-extension/extension/ /opt/nemoclaw-devx/ + +# Bundle the extension with esbuild and inject into the pre-built OpenClaw UI. +# The openclaw npm package ships a pre-built SPA at dist/control-ui/; there is +# no TypeScript source tree to patch, so we bundle the extension separately and +# add \n|' "$UI_DIR/index.html"; \ + npm uninstall -g esbuild + +ENTRYPOINT ["/bin/bash"] diff --git a/sandboxes/nemoclaw/README.md b/sandboxes/nemoclaw/README.md new file mode 100644 index 0000000..b540b4b --- /dev/null +++ b/sandboxes/nemoclaw/README.md @@ -0,0 +1,94 @@ +# NeMoClaw Sandbox + +NemoClaw sandbox image that layers the **NeMoClaw DevX UI extension** on top of the [OpenClaw](https://github.com/openclaw) sandbox. + +## What's Included + +Everything from the `openclaw` sandbox (OpenClaw CLI, gateway, Node.js 22, developer tools), plus: + +- **NVIDIA Model Selector** — switch between NVIDIA-hosted models (Kimi K2.5, Nemotron 3 Super, DeepSeek V3.2) directly from the OpenClaw UI +- **Deploy Modal** — one-click deploy to DGX Spark / DGX Station from any conversation +- **API Keys Page** — settings page to enter and manage NVIDIA API keys, persisted in browser `localStorage` +- **NeMoClaw Nav Group** — sidebar navigation with status indicators for key configuration +- **Contextual Nudges** — inline links in error states that guide users to configure missing API keys +- **nemoclaw-start** — startup script that injects API keys, onboards, and starts the gateway + +## Build + +Build from the sandbox directory: + +```bash +docker build -t nemoclaw sandboxes/nemoclaw/ +``` + +## Usage + +### Create a sandbox + +```bash +nemoclaw sandbox create --from sandboxes/nemoclaw \ + --forward 18789 \ + -- nemoclaw-start +``` + +The `--from ` flag builds the image and imports it into the cluster automatically. + +`nemoclaw-start` then: + +1. Substitutes `__NVIDIA_*_API_KEY__` placeholders in the bundled JS with runtime environment variables (if provided) +2. Runs `openclaw onboard` to configure the environment +3. Starts the OpenClaw gateway in the background +4. Prints the gateway URL with auth token + +Access the UI at `http://127.0.0.1:18789/`. + +### API Keys + +API keys can be provided in two ways (in order of precedence): + +1. **Browser `localStorage`** — enter keys via the API Keys page in the UI sidebar (persists across page reloads) +2. **Environment variables** — baked into the JS bundle at container startup by `nemoclaw-start` + +| Variable | Description | +|---|---| +| `NVIDIA_INTEGRATE_API_KEY` | Key for `integrate.api.nvidia.com` (Kimi K2.5, Nemotron Ultra, DeepSeek V3.2) | + +Keys are optional at sandbox creation time. If omitted, the UI will prompt users to enter them via the API Keys page. + +### Manual startup + +If you prefer to start OpenClaw manually inside the sandbox: + +```bash +nemoclaw sandbox connect +openclaw onboard +openclaw gateway run +``` + +Note: without running `nemoclaw-start`, the API key placeholders will remain as literals and model endpoints will not work unless keys are entered via the UI. + +## How the Extension Works + +The extension source lives in `nemoclaw-ui-extension/extension/` within this directory. At Docker build time: + +1. The TypeScript + CSS source is staged at `/opt/nemoclaw-devx/` +2. `esbuild` bundles it into `nemoclaw-devx.js` and `nemoclaw-devx.css` +3. The bundles are placed in the OpenClaw SPA assets directory (`dist/control-ui/assets/`) +4. ` + + diff --git a/brev/welcome-ui/server.py b/brev/welcome-ui/server.py new file mode 100644 index 0000000..5d1ff7d --- /dev/null +++ b/brev/welcome-ui/server.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""NemoClaw Welcome UI — HTTP server with sandbox lifecycle APIs.""" + +import http.server +import json +import os +import re +import socket +import subprocess +import sys +import threading +import time + +PORT = int(os.environ.get("PORT", 8081)) +ROOT = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.environ.get("REPO_ROOT", os.path.join(ROOT, "..", "..")) +SANDBOX_DIR = os.path.join(REPO_ROOT, "sandboxes", "nemoclaw") + +LOG_FILE = "/tmp/nemoclaw-sandbox-create.log" + +_sandbox_lock = threading.Lock() +_sandbox_state = { + "status": "idle", # idle | creating | running | error + "pid": None, + "url": None, + "error": None, +} + + +def _port_open(host: str, port: int, timeout: float = 1.0) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +def _read_openclaw_token() -> str | None: + """Try to extract the auth token from the sandbox's openclaw config via logs.""" + try: + with open(LOG_FILE) as f: + content = f.read() + match = re.search(r"token=([A-Za-z0-9_\-]+)", content) + if match: + return match.group(1) + except FileNotFoundError: + pass + return None + + +def _cleanup_existing_sandbox(): + """Delete any leftover sandbox named 'nemoclaw' from a previous attempt.""" + try: + subprocess.run( + ["nemoclaw", "sandbox", "delete", "nemoclaw"], + capture_output=True, timeout=30, + ) + except Exception: + pass + + +def _run_sandbox_create(api_key: str, brev_ui_url: str): + """Background thread: runs nemoclaw sandbox create and monitors until ready.""" + global _sandbox_state + + with _sandbox_lock: + _sandbox_state["status"] = "creating" + _sandbox_state["error"] = None + _sandbox_state["url"] = None + + _cleanup_existing_sandbox() + + env = os.environ.copy() + # Use `env` to inject vars into the sandbox command. Avoids the + # nemoclaw -e flag which has a quoting bug that causes SSH to + # misinterpret the export string as a cipher type. + cmd = [ + "nemoclaw", "sandbox", "create", + "--name", "nemoclaw", + "--from", SANDBOX_DIR, + "--forward", "18789", + "--", + "env", + f"NVIDIA_INFERENCE_API_KEY={api_key}", + f"NVIDIA_INTEGRATE_API_KEY={api_key}", + f"BREV_UI_URL={brev_ui_url}", + "nemoclaw-start", + ] + + cmd_display = " ".join(cmd[:8]) + " -- ..." + sys.stderr.write(f"[welcome-ui] Running: {cmd_display}\n") + sys.stderr.flush() + + try: + log_fh = open(LOG_FILE, "w") + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + + def _stream_output(): + for line in proc.stdout: + log_fh.write(line.decode("utf-8", errors="replace")) + log_fh.flush() + sys.stderr.write(f"[sandbox] {line.decode('utf-8', errors='replace')}") + sys.stderr.flush() + log_fh.close() + + streamer = threading.Thread(target=_stream_output, daemon=True) + streamer.start() + + with _sandbox_lock: + _sandbox_state["pid"] = proc.pid + + proc.wait() + streamer.join(timeout=5) + + if proc.returncode != 0: + with _sandbox_lock: + _sandbox_state["status"] = "error" + try: + with open(LOG_FILE) as f: + _sandbox_state["error"] = f.read()[-2000:] + except Exception: + _sandbox_state["error"] = f"Process exited with code {proc.returncode}" + return + + deadline = time.time() + 120 + while time.time() < deadline: + if _port_open("127.0.0.1", 18789): + token = _read_openclaw_token() + url = "http://127.0.0.1:18789/" + if token: + url += f"?token={token}" + with _sandbox_lock: + _sandbox_state["status"] = "running" + _sandbox_state["url"] = url + return + time.sleep(3) + + with _sandbox_lock: + _sandbox_state["status"] = "error" + _sandbox_state["error"] = "Timed out waiting for OpenClaw gateway on port 18789" + + except Exception as exc: + with _sandbox_lock: + _sandbox_state["status"] = "error" + _sandbox_state["error"] = str(exc) + + +def _get_hostname() -> str: + """Best-effort external hostname for connection details.""" + try: + result = subprocess.run( + ["hostname", "-f"], capture_output=True, text=True, timeout=5 + ) + hostname = result.stdout.strip() + if hostname: + return hostname + except Exception: + pass + return socket.getfqdn() + + +class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=ROOT, **kwargs) + + def end_headers(self): + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + super().end_headers() + + # -- Routing -------------------------------------------------------- + + def do_POST(self): + if self.path == "/api/install-openclaw": + return self._handle_install_openclaw() + self.send_error(404) + + def do_GET(self): + if self.path == "/api/sandbox-status": + return self._handle_sandbox_status() + if self.path == "/api/connection-details": + return self._handle_connection_details() + return super().do_GET() + + # -- POST /api/install-openclaw ------------------------------------ + + def _handle_install_openclaw(self): + content_length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(content_length) if content_length else b"{}" + try: + data = json.loads(raw) + except json.JSONDecodeError: + return self._json_response(400, {"ok": False, "error": "Invalid JSON"}) + + api_key = data.get("apiKey", "").strip() + if not api_key: + return self._json_response(400, {"ok": False, "error": "apiKey is required"}) + + with _sandbox_lock: + if _sandbox_state["status"] == "creating": + return self._json_response(409, { + "ok": False, + "error": "Sandbox is already being created", + }) + if _sandbox_state["status"] == "running": + return self._json_response(409, { + "ok": False, + "error": "Sandbox is already running", + }) + + brev_ui_url = f"http://{self.headers.get('Host', 'localhost:8080')}" + + thread = threading.Thread( + target=_run_sandbox_create, + args=(api_key, brev_ui_url), + daemon=True, + ) + thread.start() + + return self._json_response(200, {"ok": True}) + + # -- GET /api/sandbox-status ---------------------------------------- + + def _handle_sandbox_status(self): + with _sandbox_lock: + state = dict(_sandbox_state) + + if state["status"] == "creating" and _port_open("127.0.0.1", 18789): + token = _read_openclaw_token() + url = "http://127.0.0.1:18789/" + if token: + url += f"?token={token}" + with _sandbox_lock: + _sandbox_state["status"] = "running" + _sandbox_state["url"] = url + state["status"] = "running" + state["url"] = url + + return self._json_response(200, { + "status": state["status"], + "url": state.get("url"), + "error": state.get("error"), + }) + + # -- GET /api/connection-details ------------------------------------ + + def _handle_connection_details(self): + hostname = _get_hostname() + return self._json_response(200, { + "hostname": hostname, + "gatewayPort": 8080, + "instructions": { + "install": "pip install nemoclaw", + "connect": f"nemoclaw cluster connect {hostname}", + "createSandbox": "nemoclaw sandbox create -- claude", + "tui": "nemoclaw term", + }, + }) + + # -- Helpers -------------------------------------------------------- + + def _json_response(self, status: int, body: dict): + raw = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def log_message(self, fmt, *args): + sys.stderr.write(f"[welcome-ui] {fmt % args}\n") + + +if __name__ == "__main__": + server = http.server.ThreadingHTTPServer(("", PORT), Handler) + print(f"NemoClaw Welcome UI → http://localhost:{PORT}") + server.serve_forever() diff --git a/brev/welcome-ui/styles.css b/brev/welcome-ui/styles.css new file mode 100644 index 0000000..6c0a9e9 --- /dev/null +++ b/brev/welcome-ui/styles.css @@ -0,0 +1,880 @@ +/* ============================================ + NemoClaw Welcome UI — NVIDIA build.nvidia.com style + Primary green: #76B900 + ============================================ */ + +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --nv-green: #76b900; + --nv-green-hover: #6aa300; + --nv-green-active: #5a8500; + --nv-green-glow: rgba(118, 185, 0, 0.35); + --nv-green-subtle: rgba(118, 185, 0, 0.08); + --nv-green-border: rgba(118, 185, 0, 0.25); + + --bg: #0a0a0a; + --bg-card: #141414; + --bg-card-hover: #1a1a1a; + --bg-elevated: #1e1e1e; + + --border: #262626; + --border-hover: #404040; + + --text: #fafafa; + --text-secondary: #a3a3a3; + --text-muted: #737373; + + --red: #ef4444; + --red-subtle: rgba(239, 68, 68, 0.08); + --red-border: rgba(239, 68, 68, 0.25); + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ── Top bar ─────────────────────────────── */ + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 32px; + border-bottom: 1px solid var(--border); + background: rgba(10, 10, 10, 0.8); + backdrop-filter: blur(12px); + position: sticky; + top: 0; + z-index: 100; +} + +.topbar__brand { + display: flex; + align-items: center; + gap: 14px; +} + +.topbar__logo { + height: 26px; + width: auto; +} + +.topbar__divider { + width: 1px; + height: 20px; + background: var(--border); +} + +.topbar__title { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--text); +} + +.topbar__badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 3px 8px; + border-radius: 9999px; + background: var(--nv-green-subtle); + color: var(--nv-green); + border: 1px solid var(--nv-green-border); +} + +/* ── Main content ────────────────────────── */ + +.main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px 80px; + text-align: center; +} + +/* ── Hero ─────────────────────────────────── */ + +.hero { + max-width: 640px; + margin-bottom: 48px; +} + +.hero__icon { + width: 64px; + height: 64px; + border-radius: var(--radius-lg); + background: var(--nv-green-subtle); + border: 1px solid var(--nv-green-border); + display: grid; + place-items: center; + margin: 0 auto 24px; + color: var(--nv-green); +} + +.hero__icon svg { + width: 32px; + height: 32px; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.hero__heading { + font-size: 36px; + font-weight: 800; + letter-spacing: -0.04em; + line-height: 1.15; + margin-bottom: 12px; +} + +.hero__heading span { + color: var(--nv-green); +} + +.hero__sub { + font-size: 16px; + line-height: 1.6; + color: var(--text-secondary); + max-width: 500px; + margin: 0 auto; +} + +/* ── Cards grid ──────────────────────────── */ + +.cards { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + max-width: 720px; + width: 100%; +} + +.card { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + padding: 28px 24px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + cursor: pointer; + text-align: left; + transition: + border-color 200ms ease, + background 200ms ease, + box-shadow 200ms ease, + transform 200ms ease; +} + +.card:hover { + border-color: var(--nv-green); + background: var(--bg-card-hover); + box-shadow: + 0 0 0 1px rgba(118, 185, 0, 0.12), + 0 8px 32px rgba(118, 185, 0, 0.08); + transform: translateY(-2px); +} + +.card:active { + transform: translateY(0); +} + +.card__icon { + width: 44px; + height: 44px; + border-radius: var(--radius-md); + background: var(--nv-green-subtle); + display: grid; + place-items: center; + color: var(--nv-green); + flex-shrink: 0; +} + +.card__icon svg { + width: 22px; + height: 22px; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.card__body { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.card__title { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); +} + +.card__desc { + font-size: 14px; + line-height: 1.55; + color: var(--text-muted); +} + +.card__footer { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: var(--nv-green); + margin-top: auto; + transition: gap 200ms ease; +} + +.card:hover .card__footer { + gap: 10px; +} + +.card__footer svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* ── Overlay / Modal ─────────────────────── */ + +.overlay { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(6px); + animation: fade-in 150ms ease; +} + +.overlay[hidden] { + display: none; +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal { + width: min(560px, 100%); + max-height: 85vh; + overflow-y: auto; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 28px; + animation: scale-in 200ms cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: + 0 24px 64px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(255, 255, 255, 0.04); +} + +.modal--wide { + width: min(620px, 100%); +} + +@keyframes scale-in { + from { opacity: 0; transform: scale(0.96) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} + +.modal__title { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); +} + +.modal__close { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.modal__close:hover { + background: var(--bg-elevated); + color: var(--text); +} + +.modal__close svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.modal__body { + display: flex; + flex-direction: column; + gap: 16px; +} + +.modal__text { + font-size: 14px; + line-height: 1.6; + color: var(--text-secondary); +} + +.modal__text em { + color: var(--nv-green); + font-style: italic; +} + +/* ── Form fields ─────────────────────────── */ + +.form-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-field__label { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +.form-field__row { + display: flex; + gap: 0; +} + +.form-field__input { + flex: 1; + padding: 10px 14px; + font-family: var(--font-mono); + font-size: 14px; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm) 0 0 var(--radius-sm); + outline: none; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.form-field__input:focus { + border-color: var(--nv-green); + box-shadow: 0 0 0 2px var(--nv-green-glow); +} + +.form-field__input--error { + border-color: var(--red); + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); +} + +.form-field__input::placeholder { + color: var(--text-muted); +} + +.form-field__toggle { + width: 42px; + display: grid; + place-items: center; + border: 1px solid var(--border); + border-left: none; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + background: var(--bg-elevated); + color: var(--text-muted); + cursor: pointer; + transition: color 120ms ease, background 120ms ease; +} + +.form-field__toggle:hover { + color: var(--text); + background: var(--border); +} + +.form-field__toggle svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.form-field__help { + font-size: 12px; + color: var(--nv-green); + text-decoration: none; + transition: color 120ms ease; +} + +.form-field__help:hover { + color: var(--nv-green-hover); + text-decoration: underline; +} + +/* ── Buttons ─────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 20px; + font-family: var(--font); + font-size: 14px; + font-weight: 600; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + text-decoration: none; + transition: background 150ms ease, box-shadow 150ms ease, transform 100ms ease; +} + +.btn svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.btn--primary { + background: var(--nv-green); + color: #000; +} + +.btn--primary:hover { + background: var(--nv-green-hover); + box-shadow: 0 4px 16px var(--nv-green-glow); +} + +.btn--primary:active { + transform: translateY(1px); + background: var(--nv-green-active); +} + +.btn--secondary { + background: var(--bg-elevated); + color: var(--text); + border: 1px solid var(--border); +} + +.btn--secondary:hover { + background: var(--border); +} + +/* ── Progress steps ──────────────────────── */ + +.progress-steps { + display: flex; + flex-direction: column; + gap: 0; +} + +.progress-step { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 16px 0; + position: relative; +} + +.progress-step:not(:last-child)::after { + content: ""; + position: absolute; + left: 13px; + top: 42px; + bottom: 0; + width: 2px; + background: var(--border); +} + +.progress-step--done:not(:last-child)::after { + background: var(--nv-green); +} + +.progress-step__icon { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid var(--border); + background: var(--bg); + flex-shrink: 0; + display: grid; + place-items: center; + position: relative; + z-index: 1; + transition: border-color 200ms ease, background 200ms ease; +} + +.progress-step--active .progress-step__icon { + border-color: var(--nv-green); + background: var(--nv-green-subtle); +} + +.progress-step--active .progress-step__icon::after { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--nv-green); + animation: pulse 1.5s ease-in-out infinite; +} + +.progress-step--done .progress-step__icon { + border-color: var(--nv-green); + background: var(--nv-green); +} + +.progress-step--done .progress-step__icon::after { + content: ""; + width: 12px; + height: 12px; + background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") center/contain no-repeat; +} + +.progress-step--error .progress-step__icon { + border-color: var(--red); + background: var(--red-subtle); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.8); } +} + +.progress-step__content { + padding-top: 2px; +} + +.progress-step__title { + font-size: 14px; + font-weight: 600; + color: var(--text); + margin-bottom: 2px; +} + +.progress-step--active .progress-step__title { + color: var(--nv-green); +} + +.progress-step__desc { + font-size: 13px; + color: var(--text-muted); + line-height: 1.4; +} + +/* ── Success card ────────────────────────── */ + +.success-card { + text-align: center; + padding: 12px 0; +} + +.success-card__icon { + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--nv-green); + display: grid; + place-items: center; + margin: 0 auto 16px; +} + +.success-card__icon svg { + width: 28px; + height: 28px; + stroke: #000; + fill: none; + stroke-width: 2.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.success-card__title { + font-size: 18px; + font-weight: 700; + color: var(--text); + margin-bottom: 8px; +} + +.success-card__desc { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; + max-width: 420px; + margin: 0 auto 20px; +} + +/* ── Error card ──────────────────────────── */ + +.error-card { + text-align: center; + padding: 12px 0; +} + +.error-card__icon { + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--red-subtle); + border: 2px solid var(--red-border); + display: grid; + place-items: center; + margin: 0 auto 16px; +} + +.error-card__icon svg { + width: 28px; + height: 28px; + stroke: var(--red); + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.error-card__title { + font-size: 18px; + font-weight: 700; + color: var(--text); + margin-bottom: 8px; +} + +.error-card__desc { + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; + max-width: 420px; + margin: 0 auto 20px; + word-break: break-word; +} + +/* ── Code block ──────────────────────────── */ + +.code-block { + position: relative; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 16px; + padding-right: 48px; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.7; + color: var(--text-secondary); + overflow-x: auto; + white-space: pre; +} + +.code-block .comment { + color: var(--text-muted); +} + +.code-block .cmd { + color: var(--nv-green); +} + +/* ── Copy button ─────────────────────────── */ + +.copy-btn { + position: absolute; + top: 8px; + right: 8px; + width: 32px; + height: 32px; + display: grid; + place-items: center; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + color: var(--text-muted); + cursor: pointer; + transition: color 120ms ease, background 120ms ease, border-color 120ms ease; +} + +.copy-btn:hover { + color: var(--text); + background: var(--bg-elevated); + border-color: var(--border-hover); +} + +.copy-btn--done { + color: var(--nv-green); + border-color: var(--nv-green-border); +} + +.copy-btn svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* ── Instructions sections ───────────────── */ + +.instructions-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.instructions-section__title { + font-size: 13px; + font-weight: 600; + color: var(--text); + letter-spacing: -0.01em; +} + +/* ── Status banner (legacy compat) ───────── */ + +.status-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-radius: var(--radius-sm); + font-size: 14px; + font-weight: 500; + animation: slide-down 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.status-banner--loading { + border: 1px solid var(--nv-green-border); + background: var(--nv-green-subtle); + color: var(--nv-green); +} + +.status-banner--success { + border: 1px solid rgba(118, 185, 0, 0.3); + background: rgba(118, 185, 0, 0.1); + color: var(--nv-green); +} + +.status-banner--error { + border: 1px solid var(--red-border); + background: var(--red-subtle); + color: var(--red); +} + +.status-banner svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.status-banner--loading svg { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes slide-down { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Footer ──────────────────────────────── */ + +.footer { + padding: 16px 32px; + border-top: 1px solid var(--border); + text-align: center; + font-size: 12px; + color: var(--text-muted); +} + +/* ── Responsive ──────────────────────────── */ + +@media (max-width: 640px) { + .cards { + grid-template-columns: 1fr; + } + + .hero__heading { + font-size: 28px; + } + + .topbar { + padding: 14px 16px; + } + + .main { + padding: 32px 16px 64px; + } + + .modal { + padding: 20px; + } + + .modal--wide { + width: 100%; + } +} From 6af56ba1ace1851eadcc9c8924c1d0daa048b3ae Mon Sep 17 00:00:00 2001 From: "Kasikrit (Gus) Chantharuang" Date: Sat, 7 Mar 2026 00:59:33 +0000 Subject: [PATCH 4/8] Edited nemoclaw-start.sh --- sandboxes/nemoclaw/nemoclaw-start.sh | 62 +++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index c42d63a..4ba6df2 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -14,12 +14,12 @@ # NVIDIA_INFERENCE_API_KEY — key for inference-api.nvidia.com # NVIDIA_INTEGRATE_API_KEY — key for integrate.api.nvidia.com # -# Usage: -# nemoclaw sandbox create --from nemoclaw-launchable-ui \ +# Usage (env vars inlined via env command to avoid nemoclaw -e quoting bug): +# nemoclaw sandbox create --name nemoclaw --from sandboxes/nemoclaw/ \ # --forward 18789 \ -# -e NVIDIA_INFERENCE_API_KEY= \ -# -e NVIDIA_INTEGRATE_API_KEY= \ -# -- openclaw-start +# -- env NVIDIA_INFERENCE_API_KEY= \ +# NVIDIA_INTEGRATE_API_KEY= \ +# nemoclaw-start set -euo pipefail # -------------------------------------------------------------------------- @@ -27,22 +27,62 @@ set -euo pipefail # # The build bakes __NVIDIA_*_API_KEY__ placeholders into the bundled JS. # Replace them with the real values supplied via environment variables. +# +# /usr is read-only under Landlock, so sed -i (which creates a temp file +# in the same directory) fails. Instead we sed to /tmp and write back +# via shell redirection (truncate-write to the existing inode). If even +# that is blocked, we skip gracefully — users can still enter keys via +# the API Keys page in the OpenClaw UI. # -------------------------------------------------------------------------- BUNDLE="$(npm root -g)/openclaw/dist/control-ui/assets/nemoclaw-devx.js" if [ -f "$BUNDLE" ]; then - [ -n "${NVIDIA_INFERENCE_API_KEY:-}" ] && \ - sed -i "s|__NVIDIA_INFERENCE_API_KEY__|${NVIDIA_INFERENCE_API_KEY}|g" "$BUNDLE" - [ -n "${NVIDIA_INTEGRATE_API_KEY:-}" ] && \ - sed -i "s|__NVIDIA_INTEGRATE_API_KEY__|${NVIDIA_INTEGRATE_API_KEY}|g" "$BUNDLE" + ( + set +e + tmp="/tmp/_nemoclaw_bundle_$$" + cp "$BUNDLE" "$tmp" 2>/dev/null + if [ $? -ne 0 ]; then exit 0; fi + [ -n "${NVIDIA_INFERENCE_API_KEY:-}" ] && \ + sed -i "s|__NVIDIA_INFERENCE_API_KEY__|${NVIDIA_INFERENCE_API_KEY}|g" "$tmp" + [ -n "${NVIDIA_INTEGRATE_API_KEY:-}" ] && \ + sed -i "s|__NVIDIA_INTEGRATE_API_KEY__|${NVIDIA_INTEGRATE_API_KEY}|g" "$tmp" + cp "$tmp" "$BUNDLE" 2>/dev/null + rm -f "$tmp" 2>/dev/null + ) || echo "Note: API key injection into UI bundle skipped (read-only /usr). Keys can be set via the API Keys page." fi # -------------------------------------------------------------------------- # Onboard and start the gateway # -------------------------------------------------------------------------- -openclaw onboard +export NVIDIA_API_KEY="${NVIDIA_INFERENCE_API_KEY:- }" +openclaw onboard \ + --non-interactive \ + --accept-risk \ + --mode local \ + --no-install-daemon \ + --skip-skills \ + --skip-health \ + --auth-choice custom-api-key \ + --custom-base-url "https://inference-api.nvidia.com/v1" \ + --custom-model-id "aws/anthropic/bedrock-claude-opus-4-6" \ + --custom-api-key "${NVIDIA_API_KEY}" \ + --secret-input-mode plaintext \ + --custom-compatibility openai \ + --gateway-port 18789 \ + --gateway-bind loopback + +export NVIDIA_API_KEY=" " +python3 -c " +import json, os +cfg = json.load(open(os.environ['HOME'] + '/.openclaw/openclaw.json')) +cfg['gateway']['controlUi'] = { + 'allowInsecureAuth': True, + 'allowedOrigins': [os.environ['BREV_UI_URL']] +} +json.dump(cfg, open(os.environ['HOME'] + '/.openclaw/openclaw.json', 'w'), indent=2) +" -nohup openclaw gateway run > /tmp/gateway.log 2>&1 & +nohup openclaw gateway > /tmp/gateway.log 2>&1 & CONFIG_FILE="${HOME}/.openclaw/openclaw.json" token=$(grep -o '"token"\s*:\s*"[^"]*"' "${CONFIG_FILE}" 2>/dev/null | head -1 | cut -d'"' -f4 || true) From f9404381a28b884cb0d94e1c0f5e815ff6589849 Mon Sep 17 00:00:00 2001 From: "Kasikrit (Gus) Chantharuang" Date: Sat, 7 Mar 2026 01:10:37 +0000 Subject: [PATCH 5/8] Edited files to pass CI pipeline --- .github/workflows/build-sandboxes.yml | 30 +++++++++++++++++-- brev/.gitignore | 1 + brev/.gitkeep | 0 brev/welcome-ui/server.py | 4 +++ sandboxes/nemoclaw/build.sh | 4 +++ .../nemoclaw/nemoclaw-ui-extension/.gitignore | 1 - .../nemoclaw/nemoclaw-ui-extension/install.sh | 4 +++ .../nemoclaw-ui-extension/uninstall.sh | 4 +++ 8 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 brev/.gitignore delete mode 100644 brev/.gitkeep delete mode 100644 sandboxes/nemoclaw/nemoclaw-ui-extension/.gitignore diff --git a/.github/workflows/build-sandboxes.yml b/.github/workflows/build-sandboxes.yml index 6768a04..ffc85b3 100644 --- a/.github/workflows/build-sandboxes.yml +++ b/.github/workflows/build-sandboxes.yml @@ -187,13 +187,39 @@ jobs: tags: localhost:5000/sandboxes/base:latest cache-from: type=gha,scope=base + - name: Determine parent sandbox + id: parent + run: | + set -euo pipefail + DEFAULT_BASE=$(grep '^ARG BASE_IMAGE=' "sandboxes/${{ matrix.sandbox }}/Dockerfile" | head -1 | cut -d= -f2-) + PARENT=$(echo "$DEFAULT_BASE" | sed -n 's|.*/sandboxes/\([^:]*\).*|\1|p') + if [ -z "$PARENT" ]; then + PARENT="base" + fi + echo "sandbox=$PARENT" >> "$GITHUB_OUTPUT" + echo "Parent for ${{ matrix.sandbox }}: $PARENT" + + # When a sandbox depends on another sandbox (not base), build that + # intermediate parent locally so it is available to the buildx build. + - name: Build parent sandbox locally (PR only) + if: github.ref != 'refs/heads/main' && steps.parent.outputs.sandbox != 'base' + uses: docker/build-push-action@v6 + with: + context: sandboxes/${{ steps.parent.outputs.sandbox }} + push: true + tags: localhost:5000/sandboxes/${{ steps.parent.outputs.sandbox }}:latest + build-args: | + BASE_IMAGE=localhost:5000/sandboxes/base:latest + cache-from: type=gha,scope=${{ steps.parent.outputs.sandbox }} + - name: Set BASE_IMAGE id: base run: | + PARENT="${{ steps.parent.outputs.sandbox }}" if [ "${{ github.ref }}" = "refs/heads/main" ]; then - echo "image=${{ env.REGISTRY }}/${{ steps.repo.outputs.image_prefix }}/sandboxes/base:latest" >> "$GITHUB_OUTPUT" + echo "image=${{ env.REGISTRY }}/${{ steps.repo.outputs.image_prefix }}/sandboxes/${PARENT}:latest" >> "$GITHUB_OUTPUT" else - echo "image=localhost:5000/sandboxes/base:latest" >> "$GITHUB_OUTPUT" + echo "image=localhost:5000/sandboxes/${PARENT}:latest" >> "$GITHUB_OUTPUT" fi - name: Generate image metadata diff --git a/brev/.gitignore b/brev/.gitignore new file mode 100644 index 0000000..c26c3f6 --- /dev/null +++ b/brev/.gitignore @@ -0,0 +1 @@ +brev-start-vm.sh \ No newline at end of file diff --git a/brev/.gitkeep b/brev/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/brev/welcome-ui/server.py b/brev/welcome-ui/server.py index 5d1ff7d..309b411 100644 --- a/brev/welcome-ui/server.py +++ b/brev/welcome-ui/server.py @@ -1,4 +1,8 @@ #!/usr/bin/env python3 + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + """NemoClaw Welcome UI — HTTP server with sandbox lifecycle APIs.""" import http.server diff --git a/sandboxes/nemoclaw/build.sh b/sandboxes/nemoclaw/build.sh index bd2f38d..c0ba202 100755 --- a/sandboxes/nemoclaw/build.sh +++ b/sandboxes/nemoclaw/build.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + # Sync the UI extension source and launch a NemoClaw sandbox. # # Usage (from repo root): diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/.gitignore b/sandboxes/nemoclaw/nemoclaw-ui-extension/.gitignore deleted file mode 100644 index 2eea525..0000000 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.env \ No newline at end of file diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/install.sh b/sandboxes/nemoclaw/nemoclaw-ui-extension/install.sh index c456f45..86c5312 100755 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/install.sh +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/install.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + # # install.sh — Install the NeMoClaw DevX extension into an OpenClaw UI tree. # diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/uninstall.sh b/sandboxes/nemoclaw/nemoclaw-ui-extension/uninstall.sh index 40c3084..2c73ae7 100755 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/uninstall.sh +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/uninstall.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + # # uninstall.sh — Remove the NeMoClaw DevX extension from an OpenClaw UI tree. # From 09e207d2e64c1c9e6a92bc0b6987178132864cfe Mon Sep 17 00:00:00 2001 From: nv-kasikritc Date: Sat, 7 Mar 2026 22:07:52 +0000 Subject: [PATCH 6/8] feat(welcome-ui): parallel sandbox provisioning with inline API key flow --- brev/welcome-ui/app.js | 233 ++++++++++---- brev/welcome-ui/index.html | 120 ++++---- brev/welcome-ui/server.py | 48 +-- brev/welcome-ui/styles.css | 290 ++++++++++++------ sandboxes/nemoclaw/nemoclaw-start.sh | 11 +- .../nemoclaw-ui-extension/extension/index.ts | 36 +++ .../extension/model-registry.ts | 25 +- sandboxes/nemoclaw/policy.yaml | 9 +- 8 files changed, 519 insertions(+), 253 deletions(-) diff --git a/brev/welcome-ui/app.js b/brev/welcome-ui/app.js index 31979c8..645eb7a 100644 --- a/brev/welcome-ui/app.js +++ b/brev/welcome-ui/app.js @@ -12,22 +12,24 @@ const closeInstall = $("#close-install"); const closeInstr = $("#close-instructions"); - // Path 1 elements - const stepKey = $("#install-step-key"); - const stepProgress = $("#install-step-progress"); - const stepSuccess = $("#install-step-success"); + // Install modal elements + const installMain = $("#install-main"); const stepError = $("#install-step-error"); const apiKeyInput = $("#api-key-input"); const toggleKeyVis = $("#toggle-key-vis"); - const btnInstall = $("#btn-install"); + const keyHint = $("#key-hint"); + const btnLaunch = $("#btn-launch"); + const btnLaunchLabel = $("#btn-launch-label"); + const btnSpinner = $("#btn-spinner"); const btnRetry = $("#btn-retry"); - const btnOpenOpenclaw = $("#btn-open-openclaw"); const errorMessage = $("#error-message"); - // Progress steps - const pstepSandbox = $("#pstep-sandbox"); - const pstepGateway = $("#pstep-gateway"); - const pstepReady = $("#pstep-ready"); + // Console log lines + const logSandbox = $("#log-sandbox"); + const logSandboxIcon = $("#log-sandbox-icon"); + const logGateway = $("#log-gateway"); + const logGatewayIcon = $("#log-gateway-icon"); + const logReady = $("#log-ready"); // Path 2 elements const connectCmd = $("#connect-cmd"); @@ -38,6 +40,9 @@ const iconEye = ``; const iconEyeOff = ``; + const SPINNER_CHAR = "↻"; + const CHECK_CHAR = "✓"; + // -- Modal helpers --------------------------------------------------- function showOverlay(el) { @@ -83,22 +88,33 @@ } }); - // -- Progress step state machine ------------------------------------ + // -- API key validation --------------------------------------------- - function setStepState(el, state) { - el.classList.remove("progress-step--active", "progress-step--done", "progress-step--error"); - if (state) el.classList.add(`progress-step--${state}`); + function isApiKeyValid() { + const v = apiKeyInput.value.trim(); + return v.startsWith("nvapi-") || v.startsWith("sk-"); } - // -- Path 1: Install flow ------------------------------------------- - - function showInstallStep(step) { - stepKey.hidden = step !== "key"; - stepProgress.hidden = step !== "progress"; - stepSuccess.hidden = step !== "success"; - stepError.hidden = step !== "error"; + // -- Console log helpers -------------------------------------------- + + function setLogIcon(iconEl, state) { + if (state === "spin") { + iconEl.textContent = SPINNER_CHAR; + iconEl.className = "console__icon console__icon--spin"; + } else if (state === "done") { + iconEl.textContent = CHECK_CHAR; + iconEl.className = "console__icon console__icon--done"; + } else { + iconEl.textContent = ""; + iconEl.className = "console__icon"; + } } + // -- Install state --------------------------------------------------- + + let sandboxReady = false; + let sandboxUrl = null; + let installTriggered = false; let pollTimer = null; function stopPolling() { @@ -108,37 +124,100 @@ } } - async function startInstall() { - const apiKey = apiKeyInput.value.trim(); - if (!apiKey) { - apiKeyInput.focus(); - apiKeyInput.classList.add("form-field__input--error"); - setTimeout(() => apiKeyInput.classList.remove("form-field__input--error"), 1500); - return; + /** + * Four-state CTA button: + * 1. API empty + tasks running -> "Waiting for API key…" (disabled) + * 2. API valid + tasks running -> "Provisioning Sandbox…" (disabled, spinner) + * 3. API empty + tasks complete -> "Waiting for API key…" (disabled) + * 4. API valid + tasks complete -> "Open NemoClaw" (enabled) + */ + function updateButtonState() { + const keyValid = isApiKeyValid(); + const keyRaw = apiKeyInput.value.trim(); + + // Hint feedback below input + if (keyRaw.length === 0) { + keyHint.textContent = ""; + keyHint.className = "form-field__hint"; + } else if (keyValid) { + keyHint.textContent = "Valid key format"; + keyHint.className = "form-field__hint form-field__hint--ok"; + } else { + keyHint.textContent = "Key must start with nvapi- or sk-"; + keyHint.className = "form-field__hint form-field__hint--warn"; + } + + // Console "ready" line + if (sandboxReady && keyValid) { + logReady.hidden = false; + logReady.querySelector(".console__icon").textContent = CHECK_CHAR; + logReady.querySelector(".console__icon").className = "console__icon console__icon--done"; + } else { + logReady.hidden = true; + } + + if (sandboxReady && keyValid) { + btnLaunch.disabled = false; + btnLaunch.classList.add("btn--ready"); + btnSpinner.hidden = true; + btnSpinner.style.display = "none"; + btnLaunchLabel.textContent = "Open NemoClaw"; + } else if (!sandboxReady && keyValid) { + btnLaunch.disabled = true; + btnLaunch.classList.remove("btn--ready"); + btnSpinner.hidden = false; + btnSpinner.style.display = ""; + btnLaunchLabel.textContent = "Provisioning Sandbox\u2026"; + } else { + btnLaunch.disabled = true; + btnLaunch.classList.remove("btn--ready"); + btnSpinner.hidden = true; + btnSpinner.style.display = "none"; + btnLaunchLabel.textContent = "Waiting for API key\u2026"; } + } + + function showMainView() { + installMain.hidden = false; + stepError.hidden = true; + } + + function showError(msg) { + stopPolling(); + installMain.hidden = true; + stepError.hidden = false; + errorMessage.textContent = msg; + } + + async function triggerInstall() { + if (installTriggered) return; + installTriggered = true; - showInstallStep("progress"); - setStepState(pstepSandbox, "active"); - setStepState(pstepGateway, null); - setStepState(pstepReady, null); + setLogIcon(logSandboxIcon, "spin"); + setLogIcon(logGatewayIcon, null); + logReady.hidden = true; + updateButtonState(); try { const res = await fetch("/api/install-openclaw", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ apiKey }), }); const data = await res.json(); if (!data.ok) { + installTriggered = false; showError(data.error || "Failed to start sandbox creation"); return; } - setStepState(pstepSandbox, "done"); - setStepState(pstepGateway, "active"); + setLogIcon(logSandboxIcon, "done"); + logSandbox.querySelector(".console__text").textContent = + "Secure NemoClaw sandbox created."; + setLogIcon(logGatewayIcon, "spin"); startPolling(); - } catch (err) { + } catch { + installTriggered = false; showError("Could not reach the server. Please try again."); } } @@ -152,13 +231,16 @@ if (data.status === "running") { stopPolling(); - setStepState(pstepGateway, "done"); - setStepState(pstepReady, "done"); + sandboxReady = true; + sandboxUrl = data.url || null; - btnOpenOpenclaw.href = data.url || "http://127.0.0.1:18789/"; - showInstallStep("success"); + setLogIcon(logGatewayIcon, "done"); + logGateway.querySelector(".console__text").textContent = + "OpenClaw agent gateway online."; + updateButtonState(); } else if (data.status === "error") { stopPolling(); + installTriggered = false; showError(data.error || "Sandbox creation failed"); } } catch { @@ -167,39 +249,68 @@ }, 3000); } - function showError(msg) { - stopPolling(); - errorMessage.textContent = msg; - showInstallStep("error"); + function openOpenClaw() { + if (!sandboxReady || !isApiKeyValid() || !sandboxUrl) return; + + const apiKey = apiKeyInput.value.trim(); + const url = new URL(sandboxUrl); + url.searchParams.set("nvapi", apiKey); + window.open(url.toString(), "_blank", "noopener,noreferrer"); } function resetInstall() { - showInstallStep("key"); - setStepState(pstepSandbox, null); - setStepState(pstepGateway, null); - setStepState(pstepReady, null); + sandboxReady = false; + sandboxUrl = null; + installTriggered = false; + stopPolling(); + + setLogIcon(logSandboxIcon, null); + setLogIcon(logGatewayIcon, null); + logSandbox.querySelector(".console__text").textContent = + "Initializing secure NemoClaw sandbox..."; + logGateway.querySelector(".console__text").textContent = + "Launching OpenClaw agent gateway..."; + logReady.hidden = true; + + showMainView(); + updateButtonState(); + triggerInstall(); } - btnInstall.addEventListener("click", startInstall); - apiKeyInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") startInstall(); - }); + apiKeyInput.addEventListener("input", updateButtonState); + btnLaunch.addEventListener("click", openOpenClaw); btnRetry.addEventListener("click", resetInstall); - // -- Path 1: Check if sandbox already running on load --------------- + // -- Check if sandbox already running on load ----------------------- async function checkExistingSandbox() { try { const res = await fetch("/api/sandbox-status"); const data = await res.json(); + if (data.status === "running" && data.url) { - btnOpenOpenclaw.href = data.url; - showInstallStep("success"); + sandboxReady = true; + sandboxUrl = data.url; + installTriggered = true; + + setLogIcon(logSandboxIcon, "done"); + logSandbox.querySelector(".console__text").textContent = + "Secure NemoClaw sandbox created."; + setLogIcon(logGatewayIcon, "done"); + logGateway.querySelector(".console__text").textContent = + "OpenClaw agent gateway online."; + updateButtonState(); + showOverlay(overlayInstall); } else if (data.status === "creating") { - showInstallStep("progress"); - setStepState(pstepSandbox, "done"); - setStepState(pstepGateway, "active"); + installTriggered = true; + + setLogIcon(logSandboxIcon, "done"); + logSandbox.querySelector(".console__text").textContent = + "Secure NemoClaw sandbox created."; + setLogIcon(logGatewayIcon, "spin"); + updateButtonState(); + showOverlay(overlayInstall); startPolling(); } @@ -226,6 +337,12 @@ cardOpenclaw.addEventListener("click", () => { showOverlay(overlayInstall); + showMainView(); + if (!installTriggered) { + triggerInstall(); + } + apiKeyInput.focus(); + updateButtonState(); }); cardOther.addEventListener("click", () => { diff --git a/brev/welcome-ui/index.html b/brev/welcome-ui/index.html index c891641..7a7ab28 100644 --- a/brev/welcome-ui/index.html +++ b/brev/welcome-ui/index.html @@ -4,7 +4,7 @@ NemoClaw — Agent Sandbox - + @@ -82,87 +82,71 @@