diff --git a/brev/welcome-ui/server.py b/brev/welcome-ui/server.py index 80891d0..a93509f 100644 --- a/brev/welcome-ui/server.py +++ b/brev/welcome-ui/server.py @@ -31,11 +31,45 @@ POLICY_FILE = os.path.join(SANDBOX_DIR, "policy.yaml") LOG_FILE = "/tmp/nemoclaw-sandbox-create.log" +PROVIDER_CONFIG_CACHE = "/tmp/nemoclaw-provider-config-cache.json" BREV_ENV_ID = os.environ.get("BREV_ENV_ID", "") _detected_brev_id = "" SANDBOX_PORT = 18789 +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]") + +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) + +def _read_config_cache() -> dict: + try: + with open(PROVIDER_CONFIG_CACHE) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _write_config_cache(cache: dict) -> None: + try: + with open(PROVIDER_CONFIG_CACHE, "w") as f: + json.dump(cache, f) + except OSError: + pass + + +def _cache_provider_config(name: str, config: dict) -> None: + cache = _read_config_cache() + cache[name] = config + _write_config_cache(cache) + + +def _remove_cached_provider(name: str) -> None: + cache = _read_config_cache() + cache.pop(name, None) + _write_config_cache(cache) + + _sandbox_lock = threading.Lock() _sandbox_state = { "status": "idle", # idle | creating | running | error @@ -93,6 +127,9 @@ def _run_inject_key(key: str, key_hash: str) -> None: return _inject_log(f"step 3/3: SUCCESS — provider nvidia-inference updated") + _cache_provider_config("nvidia-inference", { + "OPENAI_BASE_URL": "https://inference-api.nvidia.com/v1", + }) with _inject_key_lock: _inject_key_state["status"] = "done" _inject_key_state["error"] = None @@ -455,6 +492,20 @@ def _route(self): if path == "/api/inject-key" and self.command == "POST": return self._handle_inject_key() + if path == "/api/providers" and self.command == "GET": + return self._handle_providers_list() + if path == "/api/providers" and self.command == "POST": + return self._handle_provider_create() + if re.match(r"^/api/providers/[\w-]+$", path) and self.command == "PUT": + return self._handle_provider_update(path.split("/")[-1]) + if re.match(r"^/api/providers/[\w-]+$", path) and self.command == "DELETE": + return self._handle_provider_delete(path.split("/")[-1]) + + if path == "/api/cluster-inference" and self.command == "GET": + return self._handle_cluster_inference_get() + if path == "/api/cluster-inference" and self.command == "POST": + return self._handle_cluster_inference_set() + if _sandbox_ready(): return self._proxy_to_sandbox() @@ -658,6 +709,234 @@ def _handle_inject_key(self): return self._json_response(202, {"ok": True, "started": True}) + # -- Provider CRUD -------------------------------------------------- + + @staticmethod + def _parse_provider_detail(stdout: str) -> dict | None: + """Parse the text output of ``nemoclaw provider get ``.""" + info: dict = {} + for line in stdout.splitlines(): + line = _strip_ansi(line).strip() + if line.startswith("Id:"): + info["id"] = line.split(":", 1)[1].strip() + elif line.startswith("Name:"): + info["name"] = line.split(":", 1)[1].strip() + elif line.startswith("Type:"): + info["type"] = line.split(":", 1)[1].strip() + elif line.startswith("Credential keys:"): + raw = line.split(":", 1)[1].strip() + info["credentialKeys"] = ( + [k.strip() for k in raw.split(",") if k.strip()] + if raw and raw != "" else [] + ) + elif line.startswith("Config keys:"): + raw = line.split(":", 1)[1].strip() + info["configKeys"] = ( + [k.strip() for k in raw.split(",") if k.strip()] + if raw and raw != "" else [] + ) + return info if "name" in info else None + + def _handle_providers_list(self): + try: + result = subprocess.run( + ["nemoclaw", "provider", "list", "--names"], + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + return self._json_response(502, { + "ok": False, + "error": (result.stderr or result.stdout or "provider list failed").strip(), + }) + names = [n.strip() for n in result.stdout.strip().splitlines() if n.strip()] + except Exception as exc: + return self._json_response(502, {"ok": False, "error": str(exc)}) + + providers = [] + config_cache = _read_config_cache() + for name in names: + try: + detail = subprocess.run( + ["nemoclaw", "provider", "get", name], + capture_output=True, text=True, timeout=30, + ) + if detail.returncode == 0: + parsed = self._parse_provider_detail(detail.stdout) + if parsed: + cached = config_cache.get(name, {}) + if cached: + parsed["configValues"] = cached + providers.append(parsed) + except Exception: + pass + + return self._json_response(200, {"ok": True, "providers": providers}) + + def _read_json_body(self) -> dict | None: + content_length = int(self.headers.get("Content-Length", 0)) + if content_length == 0: + return None + raw = self.rfile.read(content_length).decode("utf-8", errors="replace") + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + + def _handle_provider_create(self): + data = self._read_json_body() + if not data: + return self._json_response(400, {"ok": False, "error": "invalid or empty JSON body"}) + + name = data.get("name", "").strip() + ptype = data.get("type", "").strip() + if not name or not ptype: + return self._json_response(400, {"ok": False, "error": "name and type are required"}) + + cmd = ["nemoclaw", "provider", "create", "--name", name, "--type", ptype] + creds = data.get("credentials", {}) + configs = data.get("config", {}) + if not creds: + cmd += ["--credential", "PLACEHOLDER=unused"] + for k, v in creds.items(): + cmd += ["--credential", f"{k}={v}"] + for k, v in configs.items(): + cmd += ["--config", f"{k}={v}"] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode != 0: + err = (result.stderr or result.stdout or "create failed").strip() + return self._json_response(400, {"ok": False, "error": err}) + if configs: + _cache_provider_config(name, configs) + return self._json_response(200, {"ok": True}) + except Exception as exc: + return self._json_response(502, {"ok": False, "error": str(exc)}) + + def _handle_provider_update(self, name: str): + data = self._read_json_body() + if not data: + return self._json_response(400, {"ok": False, "error": "invalid or empty JSON body"}) + + ptype = data.get("type", "").strip() + if not ptype: + return self._json_response(400, {"ok": False, "error": "type is required"}) + + cmd = ["nemoclaw", "provider", "update", name, "--type", ptype] + for k, v in data.get("credentials", {}).items(): + cmd += ["--credential", f"{k}={v}"] + configs = data.get("config", {}) + for k, v in configs.items(): + cmd += ["--config", f"{k}={v}"] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode != 0: + err = (result.stderr or result.stdout or "update failed").strip() + return self._json_response(400, {"ok": False, "error": err}) + if configs: + _cache_provider_config(name, configs) + return self._json_response(200, {"ok": True}) + except Exception as exc: + return self._json_response(502, {"ok": False, "error": str(exc)}) + + def _handle_provider_delete(self, name: str): + try: + result = subprocess.run( + ["nemoclaw", "provider", "delete", name], + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + err = (result.stderr or result.stdout or "delete failed").strip() + return self._json_response(400, {"ok": False, "error": err}) + _remove_cached_provider(name) + return self._json_response(200, {"ok": True}) + except Exception as exc: + return self._json_response(502, {"ok": False, "error": str(exc)}) + + # -- GET /api/cluster-inference ------------------------------------ + + @staticmethod + def _parse_cluster_inference(stdout: str) -> dict | None: + """Parse ``nemoclaw cluster inference get/set`` output.""" + fields: dict[str, str] = {} + for line in stdout.splitlines(): + stripped = _strip_ansi(line).strip() + for key in ("Provider:", "Model:", "Version:"): + if stripped.startswith(key): + fields[key.rstrip(":")] = stripped[len(key):].strip() + if "Provider" not in fields: + return None + version = 0 + try: + version = int(fields.get("Version", "0")) + except ValueError: + pass + return { + "providerName": fields["Provider"], + "modelId": fields.get("Model", ""), + "version": version, + } + + def _handle_cluster_inference_get(self): + try: + result = subprocess.run( + ["nemoclaw", "cluster", "inference", "get"], + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + stderr = (result.stderr or "").strip() + if "not configured" in stderr.lower() or "not found" in stderr.lower(): + return self._json_response(200, { + "ok": True, + "providerName": None, + "modelId": "", + "version": 0, + }) + err = stderr or (result.stdout or "get failed").strip() + return self._json_response(400, {"ok": False, "error": err}) + parsed = self._parse_cluster_inference(result.stdout) + if not parsed: + return self._json_response(200, { + "ok": True, + "providerName": None, + "modelId": "", + "version": 0, + }) + return self._json_response(200, {"ok": True, **parsed}) + except Exception as exc: + return self._json_response(502, {"ok": False, "error": str(exc)}) + + # -- POST /api/cluster-inference ----------------------------------- + + def _handle_cluster_inference_set(self): + body = self._read_json_body() + if body is None: + return self._json_response(400, {"ok": False, "error": "invalid JSON body"}) + provider_name = (body.get("providerName") or "").strip() + model_id = (body.get("modelId") or "").strip() + if not provider_name: + return self._json_response(400, {"ok": False, "error": "providerName is required"}) + if not model_id: + return self._json_response(400, {"ok": False, "error": "modelId is required"}) + try: + result = subprocess.run( + ["nemoclaw", "cluster", "inference", "set", + "--provider", provider_name, + "--model", model_id], + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + err = (result.stderr or result.stdout or "set failed").strip() + return self._json_response(400, {"ok": False, "error": err}) + parsed = self._parse_cluster_inference(result.stdout) + resp = {"ok": True} + if parsed: + resp.update(parsed) + return self._json_response(200, resp) + except Exception as exc: + return self._json_response(502, {"ok": False, "error": str(exc)}) + # -- GET /api/sandbox-status ---------------------------------------- def _handle_sandbox_status(self): @@ -716,7 +995,20 @@ def log_message(self, fmt, *args): sys.stderr.write(f"[welcome-ui] {fmt % args}\n") +def _bootstrap_config_cache() -> None: + """Seed the config cache for providers created before caching existed.""" + if os.path.isfile(PROVIDER_CONFIG_CACHE): + return + _write_config_cache({ + "nvidia-inference": { + "OPENAI_BASE_URL": "https://inference-api.nvidia.com/v1", + }, + }) + sys.stderr.write("[welcome-ui] Bootstrapped provider config cache\n") + + if __name__ == "__main__": + _bootstrap_config_cache() server = http.server.ThreadingHTTPServer(("", PORT), Handler) print(f"NemoClaw Welcome UI → http://localhost:{PORT}") server.serve_forever() diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/inference-page.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/inference-page.ts new file mode 100644 index 0000000..d1fd47e --- /dev/null +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/inference-page.ts @@ -0,0 +1,1330 @@ +/** + * NeMoClaw DevX — Inference Page + * + * Model-first design with four sections: + * [1] Gateway Status Strip — immutable info about inference.local + * [2] Quick Model Picker — 3 curated presets for one-click switching + * [3] Active Configuration — current provider + model + endpoint + * [4] Providers (Advanced) — collapsible CRUD for power users + * Save Bar — persists changes, then refreshes model selector + */ + +import { + ICON_LOCK, + ICON_INFO, + ICON_PLUS, + ICON_TRASH, + ICON_CHECK, + ICON_CHEVRON_RIGHT, + ICON_LOADER, + ICON_CLOSE, + ICON_CHEVRON_DOWN, + ICON_EYE, + ICON_EYE_OFF, +} from "./icons.ts"; +import { refreshModelSelector, setActiveModelFromExternal } from "./model-selector.ts"; +import { + CURATED_MODELS, + getCuratedByModelId, +} from "./model-registry.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface InferenceProvider { + id: string; + name: string; + type: string; + credentialKeys: string[]; + configKeys: string[]; + configValues?: Record; + _draft?: ProviderDraft; + _isNew?: boolean; + _modelId?: string; +} + +interface ClusterInferenceRoute { + providerName: string | null; + modelId: string; + version: number; +} + +interface ProviderDraft { + type: string; + credentials: Record; + config: Record; +} + +interface ProviderProfile { + defaultUrl: string; + credentialKey: string; + configUrlKey: string; + authStyle: string; +} + +// --------------------------------------------------------------------------- +// Provider profiles — mirrors InferenceProviderProfile from navigator-core +// --------------------------------------------------------------------------- + +const PROVIDER_PROFILES: Record = { + openai: { + defaultUrl: "https://api.openai.com/v1", + credentialKey: "OPENAI_API_KEY", + configUrlKey: "OPENAI_BASE_URL", + authStyle: "Bearer", + }, + anthropic: { + defaultUrl: "https://api.anthropic.com/v1", + credentialKey: "ANTHROPIC_API_KEY", + configUrlKey: "ANTHROPIC_BASE_URL", + authStyle: "x-api-key", + }, + nvidia: { + defaultUrl: "https://integrate.api.nvidia.com/v1", + credentialKey: "NVIDIA_API_KEY", + configUrlKey: "NVIDIA_BASE_URL", + authStyle: "Bearer", + }, + generic: { + defaultUrl: "", + credentialKey: "API_KEY", + configUrlKey: "BASE_URL", + authStyle: "Bearer", + }, +}; + +const PROVIDER_TEMPLATES: { label: string; name: string; type: string; config: Record }[] = [ + { label: "NVIDIA NIM", name: "nvidia_nim", type: "nvidia", config: { NVIDIA_BASE_URL: "https://integrate.api.nvidia.com/v1" } }, + { label: "OpenAI", name: "openai", type: "openai", config: { OPENAI_BASE_URL: "https://api.openai.com/v1" } }, + { label: "Anthropic", name: "anthropic", type: "anthropic", config: { ANTHROPIC_BASE_URL: "https://api.anthropic.com/v1" } }, + { label: "Local (LM Studio)", name: "local_lmstudio", type: "openai", config: { OPENAI_BASE_URL: "http://localhost:1234/v1" } }, +]; + +const PROVIDER_TYPE_OPTIONS = ["openai", "anthropic", "nvidia"]; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let providers: InferenceProvider[] = []; +let activeRoute: ClusterInferenceRoute | null = null; +let pendingActivation: { providerName: string; modelId: string } | null = null; +const changeTracker = { + modified: new Set(), + added: new Set(), + deleted: new Set(), +}; +let deletedProviders: string[] = []; +let pageContainer: HTMLElement | null = null; +let saveBarEl: HTMLElement | null = null; +let providersExpanded = true; + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- + +async function fetchProviders(): Promise { + const res = await fetch("/api/providers"); + if (!res.ok) throw new Error(`Failed to load providers: ${res.status}`); + const body = await res.json(); + if (!body.ok) throw new Error(body.error || "Failed to load providers"); + return body.providers || []; +} + +async function apiCreateProvider(draft: { name: string; type: string; credentials: Record; config: Record }): Promise { + const res = await fetch("/api/providers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(draft), + }); + const body = await res.json(); + if (!body.ok) throw new Error(body.error || "Create failed"); +} + +async function apiUpdateProvider(name: string, draft: { type: string; credentials: Record; config: Record }): Promise { + const res = await fetch(`/api/providers/${encodeURIComponent(name)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(draft), + }); + const body = await res.json(); + if (!body.ok) throw new Error(body.error || "Update failed"); +} + +async function apiDeleteProvider(name: string): Promise { + const res = await fetch(`/api/providers/${encodeURIComponent(name)}`, { method: "DELETE" }); + const body = await res.json(); + if (!body.ok) throw new Error(body.error || "Delete failed"); +} + +async function fetchClusterInference(): Promise { + const res = await fetch("/api/cluster-inference"); + if (!res.ok) return null; + const body = await res.json(); + if (!body.ok || body.providerName == null) return null; + return { providerName: body.providerName, modelId: body.modelId || "", version: body.version || 0 }; +} + +async function apiSetClusterInference(providerName: string, modelId: string): Promise { + const res = await fetch("/api/cluster-inference", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerName, modelId }), + }); + const body = await res.json(); + if (!body.ok) throw new Error(body.error || "Activation failed"); +} + +// --------------------------------------------------------------------------- +// Render entry point +// --------------------------------------------------------------------------- + +export function renderInferencePage(container: HTMLElement): void { + container.innerHTML = ` +
+
+
Inference
+
Configure which model handles AI requests
+
+
+
+
+ ${ICON_LOADER} + Loading… +
+
`; + + pageContainer = container; + loadAndRender(container); +} + +async function loadAndRender(container: HTMLElement): Promise { + const page = container.querySelector(".nemoclaw-inference-page")!; + try { + const [providerList, route] = await Promise.all([fetchProviders(), fetchClusterInference()]); + providers = providerList; + activeRoute = route; + pendingActivation = null; + providers.forEach((p) => { + p._draft = undefined; + p._isNew = false; + if (activeRoute && p.name === activeRoute.providerName) { + p._modelId = activeRoute.modelId; + } + }); + changeTracker.modified.clear(); + changeTracker.added.clear(); + changeTracker.deleted.clear(); + deletedProviders = []; + renderPageContent(page); + } catch (err) { + page.innerHTML = ` +
+

Could not load inference configuration.

+

${escapeHtml(String(err))}

+ +
`; + page.querySelector(".nemoclaw-policy-retry-btn")?.addEventListener("click", () => { + page.innerHTML = ` +
+ ${ICON_LOADER} + Loading… +
`; + loadAndRender(container); + }); + } +} + +// --------------------------------------------------------------------------- +// Main page layout +// --------------------------------------------------------------------------- + +function renderPageContent(page: HTMLElement): void { + page.innerHTML = ""; + page.appendChild(buildGatewayStrip()); + page.appendChild(buildQuickPicker()); + page.appendChild(buildActiveConfig()); + page.appendChild(buildProviderSection()); + saveBarEl = buildSaveBar(); + page.appendChild(saveBarEl); +} + +// --------------------------------------------------------------------------- +// Section 1 — Gateway Status Strip +// --------------------------------------------------------------------------- + +function buildGatewayStrip(): HTMLElement { + const strip = document.createElement("div"); + strip.className = "nc-gateway-strip"; + + const left = document.createElement("div"); + left.className = "nc-gateway-strip__left"; + left.innerHTML = `${ICON_LOCK} inference.local`; + + const center = document.createElement("span"); + center.className = "nc-gateway-strip__desc"; + center.textContent = "All AI requests from this sandbox route here"; + + const helpBtn = document.createElement("button"); + helpBtn.type = "button"; + helpBtn.className = "nc-gateway-strip__help"; + helpBtn.innerHTML = ICON_INFO; + helpBtn.title = "How inference routing works"; + + const tooltip = document.createElement("div"); + tooltip.className = "nc-gateway-strip__tooltip"; + tooltip.innerHTML = ` +
Your Code sends requests to inference.local
+
+
NemoClaw Proxy intercepts, injects credentials
+
+
Provider API receives authenticated request
+ `; + tooltip.style.display = "none"; + + let tooltipOpen = false; + helpBtn.addEventListener("click", (e) => { + e.stopPropagation(); + tooltipOpen = !tooltipOpen; + tooltip.style.display = tooltipOpen ? "" : "none"; + helpBtn.classList.toggle("nc-gateway-strip__help--active", tooltipOpen); + }); + document.addEventListener("click", () => { + if (tooltipOpen) { + tooltipOpen = false; + tooltip.style.display = "none"; + helpBtn.classList.remove("nc-gateway-strip__help--active"); + } + }); + + strip.appendChild(left); + strip.appendChild(center); + strip.appendChild(helpBtn); + strip.appendChild(tooltip); + return strip; +} + +// --------------------------------------------------------------------------- +// Section 2 — Quick Model Picker +// --------------------------------------------------------------------------- + +function getCustomQuickSelects(): { modelId: string; name: string; providerName: string }[] { + try { + const raw = localStorage.getItem("nemoclaw:custom-quick-selects"); + if (raw) return JSON.parse(raw); + } catch { /* ignore */ } + return []; +} + +function saveCustomQuickSelects(items: { modelId: string; name: string; providerName: string }[]): void { + localStorage.setItem("nemoclaw:custom-quick-selects", JSON.stringify(items)); +} + +function buildQuickPicker(): HTMLElement { + const section = document.createElement("div"); + section.className = "nc-quick-picker"; + + const label = document.createElement("div"); + label.className = "nc-quick-picker__label"; + label.textContent = "Quick Select"; + section.appendChild(label); + + const strip = document.createElement("div"); + strip.className = "nc-quick-picker__strip"; + + const currentModelId = pendingActivation?.modelId ?? activeRoute?.modelId ?? ""; + + for (const curated of CURATED_MODELS) { + strip.appendChild(buildQuickChip(curated.modelId, curated.name, curated.providerName, currentModelId, section, false)); + } + + const custom = getCustomQuickSelects(); + const curatedIds = new Set(CURATED_MODELS.map((c) => c.modelId)); + for (const item of custom) { + if (curatedIds.has(item.modelId)) continue; + strip.appendChild(buildQuickChip(item.modelId, item.name, item.providerName, currentModelId, section, true)); + } + + section.appendChild(strip); + + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "nc-quick-picker__add-btn"; + addBtn.innerHTML = `${ICON_PLUS} Add`; + addBtn.addEventListener("click", () => showAddQuickSelectForm(section)); + section.appendChild(addBtn); + + return section; +} + +function buildQuickChip(modelId: string, name: string, providerName: string, currentModelId: string, section: HTMLElement, removable: boolean): HTMLElement { + const chip = document.createElement("button"); + chip.type = "button"; + const isActive = modelId === currentModelId; + chip.className = "nc-quick-chip" + (isActive ? " nc-quick-chip--active" : ""); + chip.dataset.modelId = modelId; + + const nameSpan = document.createElement("span"); + nameSpan.className = "nc-quick-chip__name"; + nameSpan.textContent = name; + chip.appendChild(nameSpan); + + if (removable) { + const removeBtn = document.createElement("span"); + removeBtn.className = "nc-quick-chip__remove"; + removeBtn.innerHTML = ICON_CLOSE; + removeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const items = getCustomQuickSelects().filter((i) => i.modelId !== modelId); + saveCustomQuickSelects(items); + chip.remove(); + }); + chip.appendChild(removeBtn); + } + + chip.addEventListener("click", () => { + pendingActivation = { providerName, modelId }; + markDirty(); + rerenderQuickPicker(section); + rerenderActiveConfig(); + }); + + return chip; +} + +function showAddQuickSelectForm(section: HTMLElement): void { + const existing = section.querySelector(".nc-quick-picker__add-form"); + if (existing) { existing.remove(); return; } + + const form = document.createElement("div"); + form.className = "nc-quick-picker__add-form"; + + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.className = "nemoclaw-policy-input nc-quick-picker__add-input"; + nameInput.placeholder = "Display name"; + + const modelInput = document.createElement("input"); + modelInput.type = "text"; + modelInput.className = "nemoclaw-policy-input nc-quick-picker__add-input"; + modelInput.placeholder = "Model ID (e.g. nvidia/meta/llama-3.3-70b-instruct)"; + + const provInput = document.createElement("input"); + provInput.type = "text"; + provInput.className = "nemoclaw-policy-input nc-quick-picker__add-input"; + provInput.placeholder = "Provider name (e.g. nvidia-inference)"; + provInput.value = "nvidia-inference"; + + const btns = document.createElement("div"); + btns.className = "nc-quick-picker__add-actions"; + const addConfirm = document.createElement("button"); + addConfirm.type = "button"; + addConfirm.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--create"; + addConfirm.textContent = "Add"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--cancel"; + cancelBtn.textContent = "Cancel"; + + cancelBtn.addEventListener("click", () => form.remove()); + addConfirm.addEventListener("click", () => { + const name = nameInput.value.trim(); + const mid = modelInput.value.trim(); + const prov = provInput.value.trim(); + if (!name || !mid || !prov) return; + const items = getCustomQuickSelects(); + if (items.some((i) => i.modelId === mid)) { form.remove(); return; } + items.push({ modelId: mid, name, providerName: prov }); + saveCustomQuickSelects(items); + form.remove(); + rerenderQuickPicker(section); + }); + + btns.appendChild(addConfirm); + btns.appendChild(cancelBtn); + form.appendChild(nameInput); + form.appendChild(modelInput); + form.appendChild(provInput); + form.appendChild(btns); + section.appendChild(form); + requestAnimationFrame(() => nameInput.focus()); +} + +function rerenderQuickPicker(section: HTMLElement): void { + const fresh = buildQuickPicker(); + section.replaceWith(fresh); +} + +// --------------------------------------------------------------------------- +// Section 3 — Active Configuration +// --------------------------------------------------------------------------- + +function buildActiveConfig(): HTMLElement { + const card = document.createElement("div"); + card.className = "nc-active-config"; + + const title = document.createElement("div"); + title.className = "nc-active-config__title"; + title.textContent = "Active Configuration"; + card.appendChild(title); + + const routeProviderName = pendingActivation?.providerName ?? activeRoute?.providerName ?? ""; + const routeModelId = pendingActivation?.modelId ?? activeRoute?.modelId ?? ""; + + // Provider row + const provRow = document.createElement("div"); + provRow.className = "nc-active-config__row"; + const provLabel = document.createElement("label"); + provLabel.className = "nc-active-config__label"; + provLabel.textContent = "Provider"; + const provSelect = document.createElement("select"); + provSelect.className = "nemoclaw-policy-select nc-active-config__provider-select"; + for (const p of providers) { + if (p._isNew) continue; + const opt = document.createElement("option"); + opt.value = p.name; + opt.textContent = p.name; + if (p.name === routeProviderName) opt.selected = true; + provSelect.appendChild(opt); + } + const activeProvider = providers.find((p) => p.name === routeProviderName); + const activeType = activeProvider?._draft?.type || activeProvider?.type || ""; + const typePill = document.createElement("span"); + typePill.className = `nemoclaw-inference-type-pill nemoclaw-inference-type-pill--${PROVIDER_PROFILES[activeType] ? activeType : "generic"}`; + typePill.textContent = activeType || "—"; + provRow.appendChild(provLabel); + provRow.appendChild(provSelect); + provRow.appendChild(typePill); + card.appendChild(provRow); + + provSelect.addEventListener("change", () => { + pendingActivation = { + providerName: provSelect.value, + modelId: modelInput.value || routeModelId, + }; + markDirty(); + rerenderActiveConfig(); + const pickerSection = pageContainer?.querySelector(".nc-quick-picker"); + if (pickerSection) rerenderQuickPicker(pickerSection as HTMLElement); + }); + + // Model row + const modelRow = document.createElement("div"); + modelRow.className = "nc-active-config__row"; + const modelLabel = document.createElement("label"); + modelLabel.className = "nc-active-config__label"; + modelLabel.textContent = "Model"; + const modelInput = document.createElement("input"); + modelInput.type = "text"; + modelInput.className = "nemoclaw-policy-input nc-active-config__model-input"; + modelInput.placeholder = "e.g. meta/llama-3.1-8b-instruct"; + modelInput.value = routeModelId; + modelInput.addEventListener("input", () => { + pendingActivation = { + providerName: provSelect.value || routeProviderName, + modelId: modelInput.value, + }; + markDirty(); + const pickerSection = pageContainer?.querySelector(".nc-quick-picker"); + if (pickerSection) rerenderQuickPicker(pickerSection as HTMLElement); + }); + modelRow.appendChild(modelLabel); + modelRow.appendChild(modelInput); + card.appendChild(modelRow); + + // Endpoint row (read-only, derived from provider config) + const endpointRow = document.createElement("div"); + endpointRow.className = "nc-active-config__row"; + const endpointLabel = document.createElement("label"); + endpointLabel.className = "nc-active-config__label"; + endpointLabel.textContent = "Endpoint"; + const endpointValue = document.createElement("code"); + endpointValue.className = "nc-active-config__endpoint-value"; + endpointValue.textContent = resolveEndpoint(activeProvider) || "Not configured"; + const endpointHint = document.createElement("span"); + endpointHint.className = "nc-active-config__hint"; + endpointHint.textContent = "Resolved from provider config"; + endpointRow.appendChild(endpointLabel); + const endpointWrap = document.createElement("div"); + endpointWrap.className = "nc-active-config__endpoint-wrap"; + endpointWrap.appendChild(endpointValue); + endpointWrap.appendChild(endpointHint); + endpointRow.appendChild(endpointWrap); + card.appendChild(endpointRow); + + // Status row + const statusRow = document.createElement("div"); + statusRow.className = "nc-active-config__row"; + const statusLabel = document.createElement("label"); + statusLabel.className = "nc-active-config__label"; + statusLabel.textContent = "Status"; + const hasCreds = activeProvider && (activeProvider.credentialKeys.length > 0 || Object.keys(activeProvider._draft?.credentials || {}).length > 0); + const statusValue = document.createElement("span"); + statusValue.className = "nc-active-config__status"; + statusValue.innerHTML = ` ${hasCreds ? "Credentials configured" : "No credentials"}`; + statusRow.appendChild(statusLabel); + statusRow.appendChild(statusValue); + card.appendChild(statusRow); + + return card; +} + +function rerenderActiveConfig(): void { + const existing = pageContainer?.querySelector(".nc-active-config"); + if (!existing) return; + const fresh = buildActiveConfig(); + existing.replaceWith(fresh); +} + +function resolveEndpoint(provider: InferenceProvider | undefined): string { + if (!provider) return ""; + const draft = provider._draft; + const profile = PROVIDER_PROFILES[draft?.type || provider.type]; + if (draft) { + const urlKey = profile?.configUrlKey || ""; + if (draft.config[urlKey]) return draft.config[urlKey]; + } + if (provider.configValues && profile) { + const val = provider.configValues[profile.configUrlKey]; + if (val) return val; + } + if (provider._isNew || provider.configKeys.length === 0) { + return profile?.defaultUrl || ""; + } + return ""; +} + +// --------------------------------------------------------------------------- +// Section 4 — Providers (Advanced, collapsible) +// --------------------------------------------------------------------------- + +function buildProviderSection(): HTMLElement { + const section = document.createElement("div"); + section.className = "nc-providers-section nc-providers-section--expanded"; + section.dataset.section = "providers"; + + const headerRow = document.createElement("div"); + headerRow.className = "nc-providers-section__header nc-providers-section__header--static"; + headerRow.innerHTML = ` + Providers + ${providers.length} + Configure backend endpoints and credentials`; + + const body = document.createElement("div"); + body.className = "nc-providers-section__body"; + + section.appendChild(headerRow); + + // Provider list + const list = document.createElement("div"); + list.className = "nemoclaw-policy-netpolicies nemoclaw-inference-provider-list"; + if (providers.length === 0) { + list.appendChild(buildProviderEmptyState(list)); + } else { + for (const provider of providers) { + list.appendChild(buildProviderCard(provider, list)); + } + } + body.appendChild(list); + + // Add provider button + const addWrap = document.createElement("div"); + addWrap.className = "nemoclaw-policy-add-wrap"; + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "nemoclaw-policy-add-btn"; + addBtn.innerHTML = `${ICON_PLUS} Add Provider ${ICON_CHEVRON_DOWN}`; + + let dropdownOpen = false; + let dropdownEl: HTMLElement | null = null; + + function closeDropdown() { + dropdownOpen = false; + dropdownEl?.remove(); + dropdownEl = null; + } + + addBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (dropdownOpen) { closeDropdown(); return; } + dropdownOpen = true; + dropdownEl = document.createElement("div"); + dropdownEl.className = "nemoclaw-policy-templates"; + + const blankOpt = document.createElement("button"); + blankOpt.type = "button"; + blankOpt.className = "nemoclaw-policy-template-option nemoclaw-policy-template-option--blank"; + blankOpt.innerHTML = `Blank + Start from scratch`; + blankOpt.addEventListener("click", (ev) => { + ev.stopPropagation(); closeDropdown(); + showInlineNewProviderForm(list); + }); + dropdownEl.appendChild(blankOpt); + + for (const tmpl of PROVIDER_TEMPLATES) { + const profile = PROVIDER_PROFILES[tmpl.type]; + const urlPreview = Object.values(tmpl.config)[0] || profile?.defaultUrl || ""; + const opt = document.createElement("button"); + opt.type = "button"; + opt.className = "nemoclaw-policy-template-option"; + opt.innerHTML = `${escapeHtml(tmpl.label)} + ${escapeHtml(tmpl.type)} — ${escapeHtml(urlPreview)}`; + opt.addEventListener("click", (ev) => { + ev.stopPropagation(); closeDropdown(); + showInlineNewProviderForm(list, tmpl); + }); + dropdownEl.appendChild(opt); + } + addWrap.appendChild(dropdownEl); + }); + + document.addEventListener("click", () => { if (dropdownOpen) closeDropdown(); }); + addWrap.appendChild(addBtn); + body.appendChild(addWrap); + section.appendChild(body); + return section; +} + +function buildProviderEmptyState(list: HTMLElement): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "nemoclaw-inference-empty-tiles"; + for (const tmpl of PROVIDER_TEMPLATES) { + const profile = PROVIDER_PROFILES[tmpl.type]; + const tile = document.createElement("button"); + tile.type = "button"; + tile.className = "nemoclaw-inference-empty-tile"; + tile.innerHTML = ` + ${escapeHtml(tmpl.label)} + ${escapeHtml(tmpl.type)} + ${escapeHtml(profile?.defaultUrl || "")}`; + tile.addEventListener("click", () => { + wrap.remove(); + showInlineNewProviderForm(list, tmpl); + }); + wrap.appendChild(tile); + } + return wrap; +} + +// --------------------------------------------------------------------------- +// Provider card +// --------------------------------------------------------------------------- + +function getProviderDraft(p: InferenceProvider): ProviderDraft { + if (!p._draft) { + p._draft = { type: p.type, credentials: {}, config: { ...(p.configValues || {}) } }; + } + return p._draft; +} + +function getUrlPreview(p: InferenceProvider): string { + const draft = p._draft; + const profile = PROVIDER_PROFILES[draft?.type || p.type]; + if (draft) { + const urlKey = profile?.configUrlKey || ""; + if (draft.config[urlKey]) return draft.config[urlKey]; + } + if (p.configValues && profile) { + const val = p.configValues[profile.configUrlKey]; + if (val) return val; + } + if (p._isNew || p.configKeys.length === 0) { + return profile?.defaultUrl || ""; + } + return ""; +} + +function buildProviderCard(provider: InferenceProvider, list: HTMLElement): HTMLElement { + const isActive = !provider._isNew && activeRoute?.providerName === provider.name; + const card = document.createElement("div"); + card.className = "nemoclaw-policy-netcard" + (isActive ? " nemoclaw-policy-netcard--active" : ""); + card.dataset.providerName = provider.name; + card.dataset.providerType = provider._draft?.type || provider.type; + + const header = document.createElement("div"); + header.className = "nemoclaw-policy-netcard__header"; + + const effectiveType = provider._draft?.type || provider.type; + const hasCreds = provider.credentialKeys.length > 0 || Object.keys(provider._draft?.credentials || {}).length > 0; + const typePill = buildTypePill(effectiveType); + const statusDot = ``; + const urlPreview = getUrlPreview(provider); + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "nemoclaw-policy-netcard__toggle"; + toggle.innerHTML = `${ICON_CHEVRON_RIGHT} + ${escapeHtml(provider.name)} + ${typePill} + ${escapeHtml(urlPreview)} + ${statusDot}`; + + const actions = document.createElement("div"); + actions.className = "nemoclaw-policy-netcard__actions"; + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger"; + deleteBtn.title = "Delete provider"; + deleteBtn.innerHTML = ICON_TRASH; + deleteBtn.addEventListener("click", (e) => { + e.stopPropagation(); + showDeleteConfirmation(actions, deleteBtn, provider, card, list); + }); + actions.appendChild(deleteBtn); + + header.appendChild(toggle); + header.appendChild(actions); + + const body = document.createElement("div"); + body.className = "nemoclaw-policy-netcard__body"; + body.style.display = "none"; + renderProviderBody(body, provider); + + let expanded = provider._isNew || false; + if (expanded) { + body.style.display = ""; + card.classList.add("nemoclaw-policy-netcard--expanded"); + } + + toggle.addEventListener("click", () => { + expanded = !expanded; + body.style.display = expanded ? "" : "none"; + card.classList.toggle("nemoclaw-policy-netcard--expanded", expanded); + }); + + card.appendChild(header); + card.appendChild(body); + return card; +} + +function buildTypePill(type: string): string { + const cls = PROVIDER_PROFILES[type] ? type : "generic"; + return `${escapeHtml(type)}`; +} + +// --------------------------------------------------------------------------- +// Delete confirmation +// --------------------------------------------------------------------------- + +function showDeleteConfirmation(actions: HTMLElement, deleteBtn: HTMLElement, provider: InferenceProvider, card: HTMLElement, list: HTMLElement): void { + const isDeletingActive = !provider._isNew && activeRoute?.providerName === provider.name; + + deleteBtn.style.display = "none"; + const confirmWrap = document.createElement("div"); + confirmWrap.className = "nemoclaw-policy-confirm-actions"; + + if (isDeletingActive) { + const warning = document.createElement("span"); + warning.className = "nemoclaw-inference-delete-warning"; + warning.textContent = "Active provider \u2014 deleting will break inference."; + confirmWrap.appendChild(warning); + } + + const confirmBtn = document.createElement("button"); + confirmBtn.type = "button"; + confirmBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--delete"; + confirmBtn.textContent = isDeletingActive ? "Delete anyway" : "Delete"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--cancel"; + cancelBtn.textContent = "Cancel"; + + confirmWrap.appendChild(confirmBtn); + confirmWrap.appendChild(cancelBtn); + actions.appendChild(confirmWrap); + card.classList.add("nemoclaw-policy-netcard--confirming"); + if (isDeletingActive) card.classList.add("nemoclaw-policy-netcard--confirming-danger"); + + const revert = () => { + confirmWrap.remove(); + deleteBtn.style.display = ""; + card.classList.remove("nemoclaw-policy-netcard--confirming", "nemoclaw-policy-netcard--confirming-danger"); + }; + const timeout = setTimeout(revert, 5000); + + cancelBtn.addEventListener("click", (e) => { e.stopPropagation(); clearTimeout(timeout); revert(); }); + confirmBtn.addEventListener("click", (e) => { + e.stopPropagation(); clearTimeout(timeout); + const idx = providers.indexOf(provider); + if (idx >= 0) providers.splice(idx, 1); + if (!provider._isNew) deletedProviders.push(provider.name); + changeTracker.added.delete(provider.name); + changeTracker.modified.delete(provider.name); + changeTracker.deleted.add(provider.name); + markDirty(); + card.remove(); + updateProviderCount(); + if (providers.length === 0) { + const listEl = pageContainer?.querySelector(".nemoclaw-inference-provider-list"); + if (listEl) listEl.appendChild(buildProviderEmptyState(listEl)); + } + }); +} + +// --------------------------------------------------------------------------- +// Inline new-provider form +// --------------------------------------------------------------------------- + +function showInlineNewProviderForm( + list: HTMLElement, + template?: { name: string; type: string; config: Record }, +): void { + const existing = list.querySelector(".nemoclaw-policy-newcard"); + if (existing) existing.remove(); + const emptyState = list.querySelector(".nemoclaw-policy-net-empty, .nemoclaw-inference-empty-tiles"); + if (emptyState) emptyState.remove(); + + const form = document.createElement("div"); + form.className = "nemoclaw-policy-newcard"; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "nemoclaw-policy-input"; + input.placeholder = "e.g. my_openai_provider"; + input.value = template ? template.name : ""; + + const createBtn = document.createElement("button"); + createBtn.type = "button"; + createBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--create"; + createBtn.textContent = "Create"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--cancel"; + cancelBtn.textContent = "Cancel"; + + const hint = document.createElement("div"); + hint.className = "nemoclaw-policy-newcard__hint"; + hint.textContent = "Use snake_case. Only letters, numbers, _ and - allowed."; + const error = document.createElement("div"); + error.className = "nemoclaw-policy-newcard__error"; + + form.appendChild(input); + form.appendChild(createBtn); + form.appendChild(cancelBtn); + form.appendChild(hint); + form.appendChild(error); + list.prepend(form); + requestAnimationFrame(() => input.focus()); + + const cancel = () => { + form.remove(); + if (providers.length === 0) list.appendChild(buildProviderEmptyState(list)); + }; + cancelBtn.addEventListener("click", cancel); + input.addEventListener("keydown", (e) => { + if (e.key === "Escape") cancel(); + if (e.key === "Enter") doCreate(); + }); + + function doCreate() { + const raw = input.value.trim(); + if (!raw) { error.textContent = "Name is required."; return; } + const key = raw.replace(/[^a-zA-Z0-9_-]/g, "_"); + if (providers.some((p) => p.name === key)) { + error.textContent = `A provider named "${key}" already exists.`; + input.classList.add("nemoclaw-policy-input--error"); + return; + } + const type = template?.type || "openai"; + const profile = PROVIDER_PROFILES[type] || PROVIDER_PROFILES.openai; + const newProvider: InferenceProvider = { + id: "(pending)", name: key, type, + credentialKeys: [], configKeys: Object.keys(template?.config || {}), + _isNew: true, + _draft: { + type, credentials: {}, + config: template?.config ? { ...template.config } : { [profile.configUrlKey]: profile.defaultUrl }, + }, + }; + providers.push(newProvider); + changeTracker.added.add(key); + markDirty(); + form.remove(); + list.appendChild(buildProviderCard(newProvider, list)); + updateProviderCount(); + } + createBtn.addEventListener("click", doCreate); +} + +// --------------------------------------------------------------------------- +// Provider body (expanded card) +// --------------------------------------------------------------------------- + +function renderProviderBody(body: HTMLElement, provider: InferenceProvider): void { + body.innerHTML = ""; + const draft = getProviderDraft(provider); + const profile = PROVIDER_PROFILES[draft.type] || PROVIDER_PROFILES.generic; + + // Type selector + auth chip + const typeRow = document.createElement("div"); + typeRow.className = "nemoclaw-inference-flat-row"; + + const typeField = document.createElement("label"); + typeField.className = "nemoclaw-policy-field"; + typeField.innerHTML = `Type`; + const typeSelect = document.createElement("select"); + typeSelect.className = "nemoclaw-policy-select"; + for (const t of PROVIDER_TYPE_OPTIONS) { + const o = document.createElement("option"); + o.value = t; o.textContent = t; + if (t === draft.type) o.selected = true; + typeSelect.appendChild(o); + } + typeSelect.addEventListener("change", () => { + draft.type = typeSelect.value; + if (!provider._isNew) changeTracker.modified.add(provider.name); + markDirty(); + renderProviderBody(body, provider); + }); + typeField.appendChild(typeSelect); + const authChip = document.createElement("span"); + authChip.className = "nc-auth-chip"; + authChip.textContent = `Auth: ${profile.authStyle}`; + typeField.appendChild(authChip); + typeRow.appendChild(typeField); + body.appendChild(typeRow); + + // Credentials + const credRow = document.createElement("div"); + credRow.className = "nemoclaw-inference-flat-row"; + if (provider._isNew) { + credRow.appendChild(buildCredentialInput(provider, profile.credentialKey)); + } else if (provider.credentialKeys.length > 0) { + const chipRow = document.createElement("div"); + chipRow.className = "nemoclaw-inference-cred-chips"; + for (const key of provider.credentialKeys) { + const chip = document.createElement("span"); + chip.className = "nemoclaw-inference-cred-chip"; + chip.innerHTML = `${escapeHtml(key)} configured`; + chipRow.appendChild(chip); + } + credRow.appendChild(chipRow); + + const rotateToggle = document.createElement("button"); + rotateToggle.type = "button"; + rotateToggle.className = "nemoclaw-policy-ep-advanced-toggle"; + rotateToggle.innerHTML = `${ICON_CHEVRON_RIGHT} Rotate`; + let rotateOpen = Object.keys(draft.credentials).length > 0; + const rotatePanel = document.createElement("div"); + rotatePanel.style.display = rotateOpen ? "" : "none"; + if (rotateOpen) rotateToggle.classList.add("nemoclaw-policy-ep-advanced-toggle--open"); + for (const key of provider.credentialKeys) { + rotatePanel.appendChild(buildCredentialInput(provider, key)); + } + rotateToggle.addEventListener("click", () => { + rotateOpen = !rotateOpen; + rotatePanel.style.display = rotateOpen ? "" : "none"; + rotateToggle.classList.toggle("nemoclaw-policy-ep-advanced-toggle--open", rotateOpen); + }); + credRow.appendChild(rotateToggle); + credRow.appendChild(rotatePanel); + } else { + credRow.appendChild(buildCredentialInput(provider, profile.credentialKey)); + } + body.appendChild(credRow); + + // Config key-value pairs (label "Endpoint" for *_BASE_URL keys) + const configRow = document.createElement("div"); + configRow.className = "nemoclaw-inference-flat-row"; + const configList = document.createElement("div"); + configList.className = "nemoclaw-inference-config-list"; + const configKeys = new Set([...provider.configKeys, ...Object.keys(draft.config)]); + if (configKeys.size === 0 && profile.configUrlKey) configKeys.add(profile.configUrlKey); + for (const key of configKeys) { + configList.appendChild(buildConfigRow(provider, key, configList)); + } + configRow.appendChild(configList); + const addConfigBtn = document.createElement("button"); + addConfigBtn.type = "button"; + addConfigBtn.className = "nemoclaw-policy-add-small-btn"; + addConfigBtn.innerHTML = `${ICON_PLUS} Add Config Entry`; + addConfigBtn.addEventListener("click", () => { + configList.appendChild(buildConfigRow(provider, "", configList, true)); + if (!provider._isNew) changeTracker.modified.add(provider.name); + markDirty(); + }); + configRow.appendChild(addConfigBtn); + body.appendChild(configRow); +} + +// --------------------------------------------------------------------------- +// Credential input +// --------------------------------------------------------------------------- + +function buildCredentialInput(provider: InferenceProvider, keyName: string): HTMLElement { + const draft = getProviderDraft(provider); + const row = document.createElement("div"); + row.className = "nemoclaw-inference-cred-input-row"; + const label = document.createElement("label"); + label.className = "nemoclaw-policy-field"; + label.innerHTML = `${escapeHtml(keyName)}`; + const inputWrap = document.createElement("div"); + inputWrap.className = "nemoclaw-key-field__input-row"; + const input = document.createElement("input"); + input.type = "password"; + input.className = "nemoclaw-policy-input"; + input.placeholder = provider._isNew ? "sk-... or nvapi-..." : "Enter new value to rotate"; + input.value = draft.credentials[keyName] || ""; + input.addEventListener("input", () => { + if (input.value.trim()) { draft.credentials[keyName] = input.value; } + else { delete draft.credentials[keyName]; } + if (!provider._isNew) changeTracker.modified.add(provider.name); + markDirty(); + }); + const toggleBtn = document.createElement("button"); + toggleBtn.type = "button"; + toggleBtn.className = "nemoclaw-key-field__toggle"; + toggleBtn.innerHTML = ICON_EYE; + toggleBtn.addEventListener("click", () => { + const isHidden = input.type === "password"; + input.type = isHidden ? "text" : "password"; + toggleBtn.innerHTML = isHidden ? ICON_EYE_OFF : ICON_EYE; + }); + inputWrap.appendChild(input); + inputWrap.appendChild(toggleBtn); + label.appendChild(inputWrap); + row.appendChild(label); + return row; +} + +// --------------------------------------------------------------------------- +// Config row +// --------------------------------------------------------------------------- + +function buildConfigRow(provider: InferenceProvider, key: string, configList: HTMLElement, isNew = false): HTMLElement { + const draft = getProviderDraft(provider); + const row = document.createElement("div"); + row.className = "nemoclaw-inference-config-row"; + + const isUrlKey = key.endsWith("_BASE_URL") || key === "BASE_URL"; + const keyInput = document.createElement("input"); + keyInput.type = "text"; + keyInput.className = "nemoclaw-policy-input nemoclaw-inference-config-row__key"; + keyInput.placeholder = isUrlKey ? "Endpoint" : "KEY"; + keyInput.value = key; + keyInput.readOnly = !isNew && !!key; + if (keyInput.readOnly) keyInput.classList.add("nemoclaw-inference-config-row__key--readonly"); + + const valInput = document.createElement("input"); + valInput.type = "text"; + valInput.className = "nemoclaw-policy-input nemoclaw-inference-config-row__value"; + valInput.placeholder = isUrlKey ? "https://api.example.com/v1" : "value"; + valInput.value = draft.config[key] || ""; + + const update = () => { + const k = keyInput.value.trim(); + if (k && valInput.value) { + if (k !== key && key) delete draft.config[key]; + draft.config[k] = valInput.value; + } + if (!provider._isNew) changeTracker.modified.add(provider.name); + markDirty(); + }; + keyInput.addEventListener("input", update); + valInput.addEventListener("input", update); + + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger"; + delBtn.title = "Remove config entry"; + delBtn.innerHTML = ICON_TRASH; + delBtn.addEventListener("click", () => { + const k = keyInput.value.trim() || key; + if (k) delete draft.config[k]; + if (!provider._isNew) changeTracker.modified.add(provider.name); + markDirty(); + row.remove(); + }); + + row.appendChild(keyInput); + row.appendChild(valInput); + row.appendChild(delBtn); + return row; +} + +// --------------------------------------------------------------------------- +// Save bar +// --------------------------------------------------------------------------- + +function buildSaveBar(): HTMLElement { + const bar = document.createElement("div"); + bar.className = "nemoclaw-policy-savebar nemoclaw-policy-savebar--hidden"; + const info = document.createElement("div"); + info.className = "nemoclaw-policy-savebar__info"; + info.innerHTML = `
+ Unsaved changes +
`; + const actions = document.createElement("div"); + actions.className = "nemoclaw-policy-savebar__actions"; + const feedback = document.createElement("div"); + feedback.className = "nemoclaw-policy-savebar__feedback"; + feedback.setAttribute("role", "status"); + + const discardBtn = document.createElement("button"); + discardBtn.type = "button"; + discardBtn.className = "nemoclaw-policy-discard-btn"; + discardBtn.textContent = "Discard"; + discardBtn.addEventListener("click", () => handleDiscard(bar, discardBtn)); + const saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.className = "nemoclaw-policy-save-btn"; + saveBtn.textContent = "Save & Apply"; + saveBtn.addEventListener("click", () => handleSave(saveBtn, feedback, bar)); + + actions.appendChild(feedback); + actions.appendChild(discardBtn); + actions.appendChild(saveBtn); + bar.appendChild(info); + bar.appendChild(actions); + return bar; +} + +function handleDiscard(bar: HTMLElement, discardBtn: HTMLButtonElement): void { + if (discardBtn.dataset.confirming === "true") return; + discardBtn.dataset.confirming = "true"; + const origText = discardBtn.textContent; + discardBtn.textContent = "Discard all changes?"; + discardBtn.classList.add("nemoclaw-policy-discard-btn--confirming"); + const timer = setTimeout(() => { + discardBtn.textContent = origText; + discardBtn.classList.remove("nemoclaw-policy-discard-btn--confirming"); + delete discardBtn.dataset.confirming; + }, 3000); + discardBtn.addEventListener("click", function onConfirm() { + discardBtn.removeEventListener("click", onConfirm); + clearTimeout(timer); + delete discardBtn.dataset.confirming; + if (!pageContainer) return; + bar.classList.remove("nemoclaw-policy-savebar--visible"); + bar.classList.add("nemoclaw-policy-savebar--hidden"); + loadAndRender(pageContainer); + }, { once: true }); +} + +async function handleSave(btn: HTMLButtonElement, feedback: HTMLElement, bar: HTMLElement): Promise { + btn.disabled = true; + feedback.className = "nemoclaw-policy-savebar__feedback nemoclaw-policy-savebar__feedback--saving"; + feedback.innerHTML = `${ICON_LOADER} Applying\u2026`; + + const errors: string[] = []; + try { + // Step 1: Delete removed providers + if (deletedProviders.length > 0) { + for (const name of deletedProviders) { + try { await apiDeleteProvider(name); } + catch (err) { errors.push(`Delete ${name}: ${err}`); } + } + } + + // Step 2: Create new providers + const newProviders = providers.filter((p) => p._isNew && changeTracker.added.has(p.name)); + if (newProviders.length > 0) { + for (const provider of newProviders) { + const draft = provider._draft; + if (!draft) continue; + try { + await apiCreateProvider({ name: provider.name, type: draft.type, credentials: draft.credentials, config: draft.config }); + } catch (err) { + const msg = String(err); + if (msg.includes("AlreadyExists") || msg.includes("already exists")) { + try { + await apiUpdateProvider(provider.name, { type: draft.type, credentials: draft.credentials, config: draft.config }); + } catch (updateErr) { errors.push(`Update ${provider.name}: ${updateErr}`); } + } else { + errors.push(`Create ${provider.name}: ${err}`); + } + } + } + } + + // Step 3: Update modified providers + const modifiedProviders = providers.filter((p) => !p._isNew && changeTracker.modified.has(p.name)); + if (modifiedProviders.length > 0) { + for (const provider of modifiedProviders) { + const draft = provider._draft; + if (!draft) continue; + try { + await apiUpdateProvider(provider.name, { type: draft.type, credentials: draft.credentials, config: draft.config }); + } catch (err) { errors.push(`Update ${provider.name}: ${err}`); } + } + } + + // Step 4: Activate route + if (pendingActivation && errors.length === 0) { + try { + await apiSetClusterInference(pendingActivation.providerName, pendingActivation.modelId); + } catch (err) { errors.push(`Activate ${pendingActivation.providerName}: ${err}`); } + } + + if (errors.length > 0) { + feedback.className = "nemoclaw-policy-savebar__feedback nemoclaw-policy-savebar__feedback--error"; + feedback.innerHTML = `${ICON_CLOSE} ${escapeHtml(errors.join("; "))}`; + } else { + feedback.className = "nemoclaw-policy-savebar__feedback nemoclaw-policy-savebar__feedback--success"; + feedback.innerHTML = `${ICON_CHECK} Route configured — propagating to sandbox…`; + changeTracker.modified.clear(); + changeTracker.added.clear(); + changeTracker.deleted.clear(); + deletedProviders = []; + + const savedModelId = pendingActivation?.modelId || activeRoute?.modelId || ""; + pendingActivation = null; + refreshModelSelector().then(() => { + if (savedModelId) setActiveModelFromExternal(savedModelId); + }).catch(() => {}); + + setTimeout(() => { + feedback.className = "nemoclaw-policy-savebar__feedback"; + feedback.textContent = ""; + bar.classList.remove("nemoclaw-policy-savebar--visible"); + bar.classList.add("nemoclaw-policy-savebar--hidden"); + if (pageContainer) loadAndRender(pageContainer); + }, 3000); + } + } catch (err) { + feedback.className = "nemoclaw-policy-savebar__feedback nemoclaw-policy-savebar__feedback--error"; + feedback.innerHTML = `${ICON_CLOSE} ${escapeHtml(String(err))}`; + } finally { + btn.disabled = false; + } +} + +// --------------------------------------------------------------------------- +// Change tracking helpers +// --------------------------------------------------------------------------- + +function markDirty(): void { + if (saveBarEl) { + saveBarEl.classList.remove("nemoclaw-policy-savebar--hidden"); + saveBarEl.classList.add("nemoclaw-policy-savebar--visible"); + updateSaveBarSummary(); + } +} + +function updateSaveBarSummary(): void { + if (!saveBarEl) return; + const summaryEl = saveBarEl.querySelector(".nemoclaw-policy-savebar__summary"); + if (!summaryEl) return; + const parts: string[] = []; + if (changeTracker.modified.size > 0) parts.push(`${changeTracker.modified.size} modified`); + if (changeTracker.added.size > 0) parts.push(`${changeTracker.added.size} added`); + if (changeTracker.deleted.size > 0) parts.push(`${changeTracker.deleted.size} deleted`); + if (pendingActivation) { + const curated = getCuratedByModelId(pendingActivation.modelId); + const label = curated ? curated.name : pendingActivation.modelId; + parts.push(`switch to ${label}`); + } + summaryEl.textContent = parts.length > 0 ? `Unsaved: ${parts.join(", ")}` : "Unsaved changes"; +} + +function updateProviderCount(): void { + const countEl = document.querySelector(".nemoclaw-inference-provider-count"); + if (countEl) countEl.textContent = String(providers.length); +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts index 81181ec..885fb49 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts @@ -114,113 +114,189 @@ export interface ModelEntry { modelRef: string; keyType: ApiKeyType; providerConfig: ModelProviderConfig; + isDynamic?: boolean; } // --------------------------------------------------------------------------- -// Model registry +// Curated models — hardcoded presets backed by the nvidia-inference provider. +// All route through inference.local; the NemoClaw proxy injects credentials. // --------------------------------------------------------------------------- -const DEFAULT_PROVIDER_KEY = "custom-inference-api-nvidia-com"; +export interface CuratedModel { + id: string; + name: string; + modelId: string; + providerName: string; +} -export const MODEL_REGISTRY: readonly ModelEntry[] = [ +export const CURATED_MODELS: readonly CuratedModel[] = [ { - 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`, + id: "curated-claude-opus", + name: "Claude Opus 4.6", + modelId: "aws/anthropic/bedrock-claude-opus-4-6", + providerName: "nvidia-inference", + }, + { + id: "curated-gpt-oss", + name: "GPT-OSS 20B", + modelId: "nvidia/openai/gpt-oss-20b", + providerName: "nvidia-inference", + }, + { + id: "curated-nemotron-super", + name: "Nemotron 3 Super", + modelId: "nvidia/nvidia/nemotron-3-super-preview", + providerName: "nvidia-inference", + }, + { + id: "curated-qwen3", + name: "Qwen3 Next 80B", + modelId: "nvidia/qwen/qwen3-next-80b-a3b-instruct", + providerName: "nvidia-inference", + }, + { + id: "curated-llama-70b", + name: "Llama 3.3 70B", + modelId: "nvidia/meta/llama-3.3-70b-instruct", + providerName: "nvidia-inference", + }, +]; + +export function curatedToModelEntry(c: CuratedModel): ModelEntry { + const key = `curated-${c.providerName}`; + return { + id: c.id, + name: c.name, + isDefault: c.id === "curated-claude-opus", + providerKey: key, + modelRef: `${key}/${c.modelId}`, keyType: "inference", + isDynamic: true, providerConfig: { baseUrl: "https://inference.local/v1", api: "openai-completions", models: [ { - id: "aws/anthropic/bedrock-claude-opus-4-6", - name: "aws/anthropic/bedrock-claude-opus-4-6 (Custom Provider)", + id: c.modelId, + name: c.name, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, + contextWindow: 128_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", - keyType: "integrate", - 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, - }, - ], - }, - }, + }; +} + +export function getCuratedByModelId(modelId: string): CuratedModel | undefined { + return CURATED_MODELS.find((c) => c.modelId === modelId); +} + +// --------------------------------------------------------------------------- +// Legacy MODEL_REGISTRY — kept as the default model reference for bootstrap +// --------------------------------------------------------------------------- + +const DEFAULT_PROVIDER_KEY = "custom-inference-api-nvidia-com"; + +export const MODEL_REGISTRY: readonly ModelEntry[] = [ { - 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", - keyType: "integrate", + id: "nvidia-claude-opus-4-6", + name: "Claude Opus 4.6", + isDefault: true, + providerKey: DEFAULT_PROVIDER_KEY, + modelRef: `${DEFAULT_PROVIDER_KEY}/aws/anthropic/bedrock-claude-opus-4-6`, + keyType: "inference", providerConfig: { - baseUrl: "https://integrate.api.nvidia.com/v1", + baseUrl: "https://inference.local/v1", api: "openai-completions", models: [ { - id: "nvidia/llama-3.1-nemotron-ultra-253b-v1", - name: "Nemotron Ultra 253B (NVIDIA)", + id: "aws/anthropic/bedrock-claude-opus-4-6", + name: "Claude Opus 4.6", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 131_072, + contextWindow: 200_000, maxTokens: 8192, }, ], }, }, - { - id: "deepseek-v3.2", - name: "DeepSeek V3.2", +]; + +export const DEFAULT_MODEL = MODEL_REGISTRY.find((m) => m.isDefault)!; + +// --------------------------------------------------------------------------- +// Dynamic models — populated at runtime from configured providers +// --------------------------------------------------------------------------- + +let dynamicModels: ModelEntry[] = []; + +export function getDynamicModels(): readonly ModelEntry[] { + return dynamicModels; +} + +export function setDynamicModels(models: ModelEntry[]): void { + dynamicModels = models; +} + +export function getAllModels(): ModelEntry[] { + const curated = CURATED_MODELS.map(curatedToModelEntry); + return [...curated, ...dynamicModels]; +} + +export function getModelById(id: string): ModelEntry | undefined { + const curated = CURATED_MODELS.find((c) => c.id === id); + if (curated) return curatedToModelEntry(curated); + return dynamicModels.find((m) => m.id === id) ?? MODEL_REGISTRY.find((m) => m.id === id); +} + +export function getModelByCuratedModelId(modelId: string): ModelEntry | undefined { + const curated = getCuratedByModelId(modelId); + if (curated) return curatedToModelEntry(curated); + return undefined; +} + +/** + * Build a ModelEntry for a provider managed through the inference tab. + * These route through inference.local where the proxy injects credentials, + * so no client-side API key is needed. + */ +export function buildDynamicEntry( + providerName: string, + modelId: string, + providerType: string, +): ModelEntry { + const curated = getCuratedByModelId(modelId); + if (curated) return curatedToModelEntry(curated); + + const key = `dynamic-${providerName}`; + return { + id: key, + name: `${modelId} (via ${providerName})`, isDefault: false, - providerKey: "custom-nvidia-deepseek-v3-2", - modelRef: "custom-nvidia-deepseek-v3-2/deepseek-ai/deepseek-r1", - keyType: "integrate", + providerKey: key, + modelRef: `${key}/${modelId}`, + keyType: "inference", + isDynamic: true, providerConfig: { - baseUrl: "https://integrate.api.nvidia.com/v1", + baseUrl: "https://inference.local/v1", api: "openai-completions", models: [ { - id: "deepseek-ai/deepseek-r1", - name: "DeepSeek V3.2 (NVIDIA)", + id: modelId, + name: `${modelId} (${providerType})`, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 131_072, - maxTokens: 16_384, + contextWindow: 128_000, + maxTokens: 8192, }, ], }, - }, -]; - -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); + }; } // --------------------------------------------------------------------------- diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts index b0da379..da3bf4d 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts @@ -1,18 +1,29 @@ /** * 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. + * Dropdown injected into the chat compose area that lets users pick a + * model. For models routed through inference.local (curated + dynamic), + * switching only updates the NemoClaw cluster-inference route — no + * OpenClaw config.patch is needed because the NemoClaw proxy rewrites + * the model field in every request body. This avoids the gateway + * disconnect that config.patch causes. + * + * Models are fetched dynamically from the NemoClaw runtime (providers + * and active route configured in the Inference tab). */ import { ICON_CHEVRON_DOWN, ICON_LOADER, ICON_CHECK, ICON_CLOSE } from "./icons.ts"; import { - MODEL_REGISTRY, DEFAULT_MODEL, getModelById, resolveApiKey, isKeyConfigured, + buildDynamicEntry, + setDynamicModels, + getDynamicModels, + CURATED_MODELS, + curatedToModelEntry, + getCuratedByModelId, type ModelEntry, } from "./model-registry.ts"; import { patchConfig, waitForReconnect } from "./gateway-bridge.ts"; @@ -24,16 +35,22 @@ import { patchConfig, waitForReconnect } from "./gateway-bridge.ts"; let selectedModelId = DEFAULT_MODEL.id; let modelSelectorObserver: MutationObserver | null = null; let applyInFlight = false; +let currentWrapper: HTMLElement | null = null; // --------------------------------------------------------------------------- // Build the config.patch payload for a given model entry // --------------------------------------------------------------------------- export function buildModelPatch(entry: ModelEntry): Record | null { - const apiKey = resolveApiKey(entry.keyType); - - if (!isKeyConfigured(apiKey)) { - return null; + let apiKey: string; + + if (entry.isDynamic) { + apiKey = "proxy-managed"; + } else { + apiKey = resolveApiKey(entry.keyType); + if (!isKeyConfigured(apiKey)) { + return null; + } } const providerDef: Record = { @@ -57,11 +74,66 @@ export function buildModelPatch(entry: ModelEntry): Record | nu }; } +// --------------------------------------------------------------------------- +// Fetch dynamic models from the inference tab's provider API +// --------------------------------------------------------------------------- + +interface ProviderInfo { + name: string; + type: string; + credentialKeys: string[]; +} + +interface ClusterRoute { + providerName: string | null; + modelId: string; + version: number; +} + +async function fetchDynamic(): Promise { + try { + const [provRes, routeRes] = await Promise.all([ + fetch("/api/providers"), + fetch("/api/cluster-inference"), + ]); + + let providers: ProviderInfo[] = []; + if (provRes.ok) { + const body = await provRes.json(); + if (body.ok) providers = body.providers || []; + } + + let route: ClusterRoute | null = null; + if (routeRes.ok) { + const body = await routeRes.json(); + if (body.ok && body.providerName != null) { + route = { providerName: body.providerName, modelId: body.modelId || "", version: body.version || 0 }; + } + } + + const entries: ModelEntry[] = []; + + if (route && route.providerName && route.modelId) { + const prov = providers.find((p) => p.name === route!.providerName); + const provType = prov?.type || "generic"; + entries.push(buildDynamicEntry(route.providerName, route.modelId, provType)); + } + + setDynamicModels(entries); + } catch { + // Non-fatal -- static models still work + } +} + // --------------------------------------------------------------------------- // Transition banner lifecycle // --------------------------------------------------------------------------- let activeBanner: HTMLElement | null = null; +let propagationTimer: ReturnType | null = null; + +/** Sandbox polls the gateway every 30s for route updates. */ +const ROUTE_PROPAGATION_SECS = 30; function showTransitionBanner(modelName: string): void { dismissTransitionBanner(); @@ -73,17 +145,67 @@ function showTransitionBanner(modelName: string): void { const banner = document.createElement("div"); banner.className = "nemoclaw-switching-banner nemoclaw-switching-banner--loading"; - banner.innerHTML = `${ICON_LOADER}Switching to ${modelName}`; + banner.innerHTML = `${ICON_LOADER}Switching to ${escapeHtml(modelName)}`; chatCompose.insertBefore(banner, chatCompose.firstChild); activeBanner = banner; } +/** Like showTransitionBanner but without dimming the app (no gateway disconnect). */ +function showTransitionBannerLight(modelName: string): void { + dismissTransitionBanner(); + + 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 ${escapeHtml(modelName)}`; + + chatCompose.insertBefore(banner, chatCompose.firstChild); + activeBanner = banner; +} + +/** + * Show an honest propagation banner for proxy-managed models. + * The NemoClaw sandbox polls for route updates every 30 seconds, so the + * switch isn't truly instant. This banner shows a progress bar that + * counts down from ROUTE_PROPAGATION_SECS and transitions to a success + * state when the propagation window has elapsed. + */ +function showPropagationBanner(modelName: string): void { + if (!activeBanner) return; + + activeBanner.className = "nemoclaw-switching-banner nemoclaw-switching-banner--propagating"; + activeBanner.innerHTML = [ + `${ICON_LOADER}`, + `
`, + `Activating ${escapeHtml(modelName)} — route configured, propagating to sandbox…`, + `
`, + `
`, + ].join(""); + + document.body.classList.remove("nemoclaw-switching"); + + const fill = activeBanner.querySelector(".nemoclaw-propagation-bar__fill"); + if (fill) { + fill.style.transition = `width ${ROUTE_PROPAGATION_SECS}s linear`; + requestAnimationFrame(() => { + requestAnimationFrame(() => { fill.style.width = "100%"; }); + }); + } + + propagationTimer = setTimeout(() => { + propagationTimer = null; + updateTransitionBannerSuccess(modelName); + }, ROUTE_PROPAGATION_SECS * 1000); +} + function updateTransitionBannerSuccess(modelName: string): void { if (!activeBanner) return; activeBanner.className = "nemoclaw-switching-banner nemoclaw-switching-banner--success"; - activeBanner.innerHTML = `${ICON_CHECK}Now using ${modelName}`; + activeBanner.innerHTML = `${ICON_CHECK}Now using ${escapeHtml(modelName)}`; document.body.classList.remove("nemoclaw-switching"); @@ -108,6 +230,10 @@ function updateTransitionBannerError(message: string): void { } function dismissTransitionBanner(): void { + if (propagationTimer) { + clearTimeout(propagationTimer); + propagationTimer = null; + } if (activeBanner) { activeBanner.remove(); activeBanner = null; @@ -119,6 +245,17 @@ function dismissTransitionBanner(): void { // Apply model selection to backend // --------------------------------------------------------------------------- +/** + * Returns true if the model routes through inference.local, meaning the + * NemoClaw proxy manages credential injection and model rewriting. + * For these models we only need to update the cluster-inference route — + * no OpenClaw config.patch (and therefore no gateway disconnect). + */ +function isProxyManaged(entry: ModelEntry): boolean { + return entry.isDynamic === true || + entry.providerConfig.baseUrl === "https://inference.local/v1"; +} + async function applyModelSelection( entry: ModelEntry, wrapper: HTMLElement, @@ -138,29 +275,60 @@ async function applyModelSelection( } trigger.style.pointerEvents = "none"; - showTransitionBanner(entry.name); - try { - const patch = buildModelPatch(entry); - if (!patch) { - selectedModelId = previousModelId; - const prev = getModelById(previousModelId) ?? DEFAULT_MODEL; - if (valueEl) valueEl.textContent = prev.name; - updateDropdownSelection(wrapper, previousModelId); - updateTransitionBannerError( - `API key not configured. Add your keys to switch models.`, - ); - return; - } - 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"); + if (isProxyManaged(entry)) { + // Proxy-managed models route through inference.local. We update the + // NemoClaw cluster-inference route (no OpenClaw config.patch, no + // gateway disconnect). The sandbox polls every ~30s for route + // updates, so we show an honest propagation countdown. + const curated = getCuratedByModelId(entry.providerConfig.models[0]?.id || ""); + const provName = curated?.providerName || entry.providerKey.replace(/^dynamic-/, ""); + const modelId = entry.providerConfig.models[0]?.id || ""; + + if (!provName || !modelId) { + throw new Error("Missing provider or model ID"); + } + + showTransitionBannerLight(entry.name); + + const res = await fetch("/api/cluster-inference", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerName: provName, modelId }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error || `HTTP ${res.status}`); + } + + if (valueEl) valueEl.textContent = entry.name; + showPropagationBanner(entry.name); + } else { + // Slow path: non-proxy models (direct API keys, custom baseUrls). + // Must use config.patch which causes a brief gateway restart. + showTransitionBanner(entry.name); + + const patch = buildModelPatch(entry); + if (!patch) { + selectedModelId = previousModelId; + const prev = getModelById(previousModelId) ?? DEFAULT_MODEL; + if (valueEl) valueEl.textContent = prev.name; + updateDropdownSelection(wrapper, previousModelId); + updateTransitionBannerError( + `API key not configured. Add your keys to switch models.`, + ); + return; + } + 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); @@ -194,6 +362,63 @@ function updateDropdownSelection(wrapper: HTMLElement, modelId: string) { }); } +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +// --------------------------------------------------------------------------- +// Populate dropdown with grouped entries +// --------------------------------------------------------------------------- + +function populateDropdown(dropdown: HTMLElement): void { + dropdown.innerHTML = ""; + + const curatedModelIds = new Set(CURATED_MODELS.map((c) => c.modelId)); + + for (const curated of CURATED_MODELS) { + const entry = curatedToModelEntry(curated); + dropdown.appendChild(buildOption(entry)); + } + + const dynamic = getDynamicModels(); + const customDynamic = dynamic.filter((m) => { + const mid = m.providerConfig.models[0]?.id || ""; + return !curatedModelIds.has(mid); + }); + + if (customDynamic.length > 0) { + const divider = document.createElement("div"); + divider.className = "nemoclaw-model-dropdown__divider"; + dropdown.appendChild(divider); + + for (const model of customDynamic) { + dropdown.appendChild(buildOption(model)); + } + } + + const divider2 = document.createElement("div"); + divider2.className = "nemoclaw-model-dropdown__divider"; + dropdown.appendChild(divider2); + + const routeLink = document.createElement("button"); + routeLink.className = "nemoclaw-model-dropdown__route-link"; + routeLink.type = "button"; + routeLink.textContent = "Configure inference \u2192"; + routeLink.dataset.nemoclawGoto = "nemoclaw-inference-routes"; + dropdown.appendChild(routeLink); +} + +function buildOption(model: ModelEntry): HTMLElement { + 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; + return option; +} + // --------------------------------------------------------------------------- // Build selector DOM // --------------------------------------------------------------------------- @@ -203,30 +428,19 @@ function buildModelSelector(): HTMLElement { 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}`; + trigger.innerHTML = `ModelLoading\u2026${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); - } + populateDropdown(dropdown); const poweredBy = document.createElement("a"); poweredBy.className = "nemoclaw-model-powered"; @@ -290,9 +504,81 @@ function buildModelSelector(): HTMLElement { }; document.addEventListener("click", closeOnOutsideClick, true); + currentWrapper = wrapper; + + // Fetch dynamic models, sync selection, and refresh dropdown + fetchDynamic().then(() => { + populateDropdown(dropdown); + syncSelectionToActiveRoute(); + const current = getModelById(selectedModelId); + const valueEl = trigger.querySelector(".nemoclaw-model-trigger__value"); + if (valueEl) { + valueEl.textContent = current ? current.name : "No model"; + } + }); + return wrapper; } +// --------------------------------------------------------------------------- +// Selection sync helpers +// --------------------------------------------------------------------------- + +function syncSelectionToActiveRoute(): void { + const dynamic = getDynamicModels(); + if (dynamic.length > 0) { + const activeModelId = dynamic[0]?.providerConfig.models[0]?.id || ""; + const curated = getCuratedByModelId(activeModelId); + if (curated) { + selectedModelId = curated.id; + } else if (!dynamic.find((m) => m.id === selectedModelId)) { + selectedModelId = dynamic[0].id; + } + } +} + +// --------------------------------------------------------------------------- +// Public: set active model from inference tab or external callers +// --------------------------------------------------------------------------- + +export function setActiveModelFromExternal(modelId: string): void { + const curated = getCuratedByModelId(modelId); + if (curated) { + selectedModelId = curated.id; + } else { + const dynamic = getDynamicModels(); + const match = dynamic.find((m) => m.providerConfig.models[0]?.id === modelId); + if (match) selectedModelId = match.id; + } + if (!currentWrapper) return; + const dropdown = currentWrapper.querySelector(".nemoclaw-model-dropdown"); + if (dropdown) populateDropdown(dropdown); + const current = getModelById(selectedModelId); + const valueEl = currentWrapper.querySelector(".nemoclaw-model-trigger__value"); + if (valueEl) { + valueEl.textContent = current ? current.name : "No model"; + } + updateDropdownSelection(currentWrapper, selectedModelId); +} + +// --------------------------------------------------------------------------- +// Public refresh — called by inference-page after save +// --------------------------------------------------------------------------- + +export async function refreshModelSelector(): Promise { + await fetchDynamic(); + if (!currentWrapper) return; + const dropdown = currentWrapper.querySelector(".nemoclaw-model-dropdown"); + if (dropdown) populateDropdown(dropdown); + + syncSelectionToActiveRoute(); + const current = getModelById(selectedModelId); + const valueEl = currentWrapper.querySelector(".nemoclaw-model-trigger__value"); + if (valueEl) { + valueEl.textContent = current ? current.name : "No model"; + } +} + // --------------------------------------------------------------------------- // Injection into .chat-compose__actions // --------------------------------------------------------------------------- diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts index c8a7c16..7218fa1 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts @@ -8,6 +8,7 @@ import { ICON_SHIELD, ICON_ROUTE, ICON_KEY } from "./icons.ts"; import { renderApiKeysPage, areAllKeysConfigured, updateStatusDots } from "./api-keys-page.ts"; import { renderPolicyPage } from "./policy-page.ts"; +import { renderInferencePage } from "./inference-page.ts"; // --------------------------------------------------------------------------- // Page definitions @@ -40,8 +41,8 @@ const NEMOCLAW_PAGES: NemoClawPage[] = [ 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.", + emptyMessage: "", + customRender: renderInferencePage, }, { id: "nemoclaw-api-keys", diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css index 1f212d3..432598d 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css @@ -694,6 +694,37 @@ body.nemoclaw-switching openclaw-app { animation: nemoclaw-spin 1s linear infinite; } +.nemoclaw-switching-banner--propagating { + border-color: rgba(118, 185, 0, 0.2); + background: rgba(118, 185, 0, 0.06); + color: #76B900; + flex-wrap: wrap; +} + +.nemoclaw-switching-banner--propagating svg { + animation: nemoclaw-spin 2s linear infinite; +} + +.nemoclaw-switching-banner__content { + flex: 1; + min-width: 0; +} + +.nemoclaw-propagation-bar { + margin-top: 6px; + height: 3px; + border-radius: 2px; + background: rgba(118, 185, 0, 0.15); + overflow: hidden; +} + +.nemoclaw-propagation-bar__fill { + height: 100%; + width: 0%; + border-radius: 2px; + background: #76B900; +} + .nemoclaw-switching-banner--success { border-color: rgba(118, 185, 0, 0.3); background: rgba(118, 185, 0, 0.1); @@ -2687,3 +2718,990 @@ body.nemoclaw-switching openclaw-app { color: var(--danger, #ef4444); background: rgba(239, 68, 68, 0.08); } + +/* =================================================================== + INFERENCE PROVIDERS PAGE + =================================================================== */ + +.nemoclaw-inference-page { + display: flex; + flex-direction: column; + gap: 24px; + padding-bottom: 100px; +} + +/* =================================================================== + Gateway Status Strip (Section 1) + =================================================================== */ + +.nc-gateway-strip { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--bg-elevated, #1a1d25); + position: relative; +} + +:root[data-theme="light"] .nc-gateway-strip { + background: #fff; +} + +.nc-gateway-strip__left { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.nc-gateway-strip__endpoint { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + padding: 4px 10px; + border-radius: 6px; + background: rgba(118, 185, 0, 0.08); + border: 1px solid rgba(118, 185, 0, 0.2); + color: #76B900; + white-space: nowrap; +} + +.nc-gateway-strip__endpoint svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.7; +} + +.nc-gateway-strip__desc { + font-size: 12px; + color: var(--muted, #71717a); + flex: 1; + min-width: 0; +} + +.nc-gateway-strip__help { + width: 24px; + height: 24px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: var(--radius-sm, 6px); + background: transparent; + color: var(--muted, #71717a); + cursor: pointer; + flex-shrink: 0; + transition: background 120ms ease, color 120ms ease; +} + +.nc-gateway-strip__help:hover, +.nc-gateway-strip__help--active { + background: var(--bg-hover, #262a35); + color: #76B900; +} + +.nc-gateway-strip__help svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nc-gateway-strip__tooltip { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 50; + width: 300px; + padding: 14px 16px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--card, #181b22); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + animation: nemoclaw-scale-in 120ms cubic-bezier(0.16, 1, 0.3, 1); + font-size: 12px; + line-height: 1.5; + color: var(--text, #e4e4e7); +} + +:root[data-theme="light"] .nc-gateway-strip__tooltip { + background: #fff; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +} + +.nc-gateway-tooltip__row { + padding: 4px 0; +} + +.nc-gateway-tooltip__row strong { + color: var(--text-strong, #fafafa); +} + +.nc-gateway-tooltip__row code { + font-size: 11px; + padding: 1px 5px; + border-radius: 3px; + background: rgba(118, 185, 0, 0.08); + color: #76B900; +} + +.nc-gateway-tooltip__arrow { + text-align: center; + color: var(--muted, #71717a); + font-size: 14px; + padding: 2px 0; +} + +.nc-gateway-tooltip__footer { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border, #27272a); + font-size: 11px; + color: var(--muted, #71717a); + display: flex; + align-items: center; + gap: 6px; +} + +.nc-gateway-tooltip__footer svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; + opacity: 0.6; +} + +/* =================================================================== + Quick Model Picker (Section 2) — horizontal chip strip + =================================================================== */ + +.nc-quick-picker { + display: flex; + flex-direction: column; + gap: 8px; +} + +.nc-quick-picker__label { + font-size: 13px; + font-weight: 600; + color: var(--text-strong, #fafafa); +} + +.nc-quick-picker__strip { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.nc-quick-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border: 1px solid var(--border, #27272a); + border-radius: 999px; + background: var(--bg-elevated, #1a1d25); + cursor: pointer; + font-family: inherit; + font-size: 13px; + font-weight: 500; + color: var(--text, #e4e4e7); + white-space: nowrap; + transition: border-color 150ms ease, background 150ms ease, color 150ms ease; +} + +:root[data-theme="light"] .nc-quick-chip { + background: #fff; +} + +.nc-quick-chip:hover { + border-color: rgba(118, 185, 0, 0.5); + background: rgba(118, 185, 0, 0.05); +} + +.nc-quick-chip--active { + border-color: #22c55e; + background: rgba(34, 197, 94, 0.08); + color: #22c55e; + font-weight: 600; +} + +.nc-quick-chip--active:hover { + background: rgba(34, 197, 94, 0.12); +} + +.nc-quick-chip__name { + line-height: 1; +} + +.nc-quick-chip__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + opacity: 0.5; + transition: opacity 150ms ease; + cursor: pointer; +} + +.nc-quick-chip__remove svg { + width: 10px; + height: 10px; +} + +.nc-quick-chip__remove:hover { + opacity: 1; +} + +.nc-quick-picker__add-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + border: none; + background: none; + color: var(--muted, #71717a); + font-size: 12px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: color 150ms ease; +} + +.nc-quick-picker__add-btn svg { + width: 12px; + height: 12px; +} + +.nc-quick-picker__add-btn:hover { + color: #76B900; +} + +.nc-quick-picker__add-form { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--bg-elevated, #1a1d25); +} + +.nc-quick-picker__add-input { + flex: 1 1 180px; + min-width: 120px; +} + +.nc-quick-picker__add-actions { + display: flex; + gap: 6px; + align-items: center; + width: 100%; +} + +/* =================================================================== + Active Configuration (Section 3) + =================================================================== */ + +.nc-active-config { + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--bg-elevated, #1a1d25); + padding: 16px 20px; +} + +:root[data-theme="light"] .nc-active-config { + background: #fff; +} + +.nc-active-config__title { + font-size: 14px; + font-weight: 700; + color: var(--text-strong, #fafafa); + margin-bottom: 14px; +} + +.nc-active-config__row { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 0; +} + +.nc-active-config__label { + font-size: 12px; + font-weight: 600; + color: var(--muted, #a1a1aa); + min-width: 70px; + flex-shrink: 0; +} + +.nc-active-config__provider-select { + min-width: 160px; +} + +.nc-active-config__model-input { + flex: 1; + min-width: 200px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; +} + +.nc-active-config__endpoint-wrap { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.nc-active-config__endpoint-value { + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + color: var(--text, #e4e4e7); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nc-active-config__hint { + font-size: 10px; + color: var(--muted, #71717a); + font-style: italic; +} + +.nc-active-config__status { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text, #e4e4e7); +} + +/* Auth chip (inside provider body) */ + +.nc-auth-chip { + display: inline-block; + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + background: rgba(161, 161, 170, 0.08); + color: var(--muted, #a1a1aa); + border: 1px solid rgba(161, 161, 170, 0.15); + margin-left: 8px; + white-space: nowrap; +} + +/* =================================================================== + Providers Section (Section 4 — collapsible) + =================================================================== */ + +.nc-providers-section { + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--bg-elevated, #1a1d25); + overflow: hidden; +} + +:root[data-theme="light"] .nc-providers-section { + background: #fff; +} + +.nc-providers-section__header--static { + cursor: default; +} + +.nc-providers-section__header--static:hover { + background: transparent; +} + +.nc-providers-section__header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + width: 100%; + border: none; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + text-align: left; + transition: background 120ms ease; +} + +.nc-providers-section__header:hover { + background: rgba(118, 185, 0, 0.03); +} + +.nc-providers-section__chevron { + display: flex; + width: 16px; + height: 16px; + color: var(--muted, #71717a); + transition: transform 200ms ease; +} + +.nc-providers-section__chevron svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nc-providers-section--expanded .nc-providers-section__chevron { + transform: rotate(90deg); + color: #76B900; +} + +.nc-providers-section__title { + font-size: 14px; + font-weight: 700; + color: var(--text-strong, #fafafa); +} + +.nc-providers-section__subtitle { + font-size: 12px; + color: var(--muted, #71717a); + margin-left: auto; +} + +.nc-providers-section__body { + padding: 0 16px 16px; +} + +/* --- Type Pills --- */ + +.nemoclaw-inference-type-pill { + display: inline-block; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 8px; + border-radius: 4px; + line-height: 1.5; + background: var(--bg-2, #252525); + color: var(--fg-muted, #888); + border: 1px solid var(--border, #333); + margin-left: 8px; +} +.nemoclaw-inference-type-pill--openai { border-color: #10a37f; color: #10a37f; } +.nemoclaw-inference-type-pill--anthropic { border-color: #d97706; color: #d97706; } +.nemoclaw-inference-type-pill--nvidia { border-color: #76b900; color: #76b900; } +.nemoclaw-inference-type-pill--generic { border-color: #6b7280; color: #6b7280; } + +/* --- Status Dots --- */ + +.nemoclaw-inference-status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-left: auto; + flex-shrink: 0; +} +.nemoclaw-inference-status-dot--ok { background: #22c55e; } +.nemoclaw-inference-status-dot--missing { background: #f59e0b; } + +/* --- Provider Card header layout --- */ + +.nemoclaw-policy-netcard__toggle { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + background: none; + border: none; + cursor: pointer; + color: inherit; + font: inherit; + padding: 0; + text-align: left; +} +.nemoclaw-policy-netcard__toggle:hover { + opacity: 0.85; +} +.nemoclaw-policy-netcard__chevron { + display: flex; + align-items: center; + transition: transform 0.15s ease; +} +.nemoclaw-policy-netcard__chevron svg { + width: 14px; + height: 14px; + stroke: var(--fg-muted, #888); + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} +.nemoclaw-policy-netcard--expanded .nemoclaw-policy-netcard__chevron { + transform: rotate(90deg); +} +.nemoclaw-policy-netcard__name { + font-weight: 600; + font-size: 0.85rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.nemoclaw-policy-netcard__summary { + font-size: 0.72rem; + color: var(--fg-muted, #666); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-left: auto; + flex-shrink: 1; + min-width: 0; +} +.nemoclaw-policy-netcard__actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-left: 8px; +} + +/* --- Immutable fields display --- */ + +.nemoclaw-inference-immutable { + background: var(--bg-2, #252525); + border: 1px solid var(--border, #333); + border-radius: 6px; + padding: 10px 14px; + margin-bottom: 12px; +} +.nemoclaw-inference-immutable__row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; +} +.nemoclaw-inference-immutable__lock { + display: flex; + align-items: center; + color: var(--fg-muted, #666); +} +.nemoclaw-inference-immutable__lock svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} +.nemoclaw-inference-immutable__label { + font-size: 0.72rem; + color: var(--fg-muted, #888); + min-width: 40px; +} +.nemoclaw-inference-immutable__value { + font-size: 0.78rem; + color: var(--fg, #ccc); + word-break: break-all; +} +.nemoclaw-inference-immutable__hint { + font-size: 0.68rem; + color: var(--fg-muted, #666); + margin-top: 6px; + padding-left: 20px; +} + +/* --- Credential chips --- */ + +.nemoclaw-inference-cred-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} +.nemoclaw-inference-cred-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--bg-2, #252525); + border: 1px solid var(--border, #333); + border-radius: 4px; + padding: 4px 10px; + font-size: 0.75rem; +} +.nemoclaw-inference-cred-chip code { + color: var(--fg, #ccc); + font-size: 0.72rem; +} +.nemoclaw-inference-cred-chip__status { + color: #22c55e; + font-size: 0.68rem; + font-weight: 500; +} + +.nemoclaw-inference-cred-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +.nemoclaw-inference-cred-input-row { + display: flex; + flex-direction: column; + gap: 2px; +} + +/* --- Config key/value rows --- */ + +.nemoclaw-inference-config-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.nemoclaw-inference-config-row { + display: flex; + align-items: center; + gap: 6px; +} +.nemoclaw-inference-config-row__key { + flex: 0 0 180px; + font-family: var(--font-mono, monospace); + font-size: 0.78rem; +} +.nemoclaw-inference-config-row__key--readonly { + opacity: 0.7; + cursor: default; +} +.nemoclaw-inference-config-row__value { + flex: 1; + font-size: 0.78rem; +} + +/* --- Protocol list (read-only) --- */ + +.nemoclaw-inference-proto-list { + display: flex; + flex-direction: column; + gap: 4px; +} +.nemoclaw-inference-proto-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + background: var(--bg-2, #252525); + border-radius: 4px; + font-size: 0.75rem; +} +.nemoclaw-inference-proto-row__method { + font-weight: 600; + color: var(--accent, #76b900); + min-width: 40px; +} +.nemoclaw-inference-proto-row__path { + color: var(--fg, #ccc); + flex: 1; +} +.nemoclaw-inference-proto-row__arrow { + color: var(--fg-muted, #555); + font-size: 0.7rem; +} +.nemoclaw-inference-proto-row__id { + color: var(--fg-muted, #888); + font-size: 0.68rem; +} + +/* --- Platform Behaviors footer --- */ + +.nemoclaw-inference-platform { + background: var(--bg-1, #1e1e1e); + border: 1px solid var(--border, #333); + border-radius: 10px; + padding: 16px 20px; + opacity: 0.85; +} +.nemoclaw-inference-platform__header { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 0.82rem; + color: var(--fg-muted, #999); + margin-bottom: 10px; +} +.nemoclaw-inference-platform__header svg, +.nemoclaw-inference-platform__lock svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} +.nemoclaw-inference-platform__row { + display: flex; + align-items: baseline; + gap: 10px; + padding: 4px 0; + font-size: 0.75rem; +} +.nemoclaw-inference-platform__row code { + font-size: 0.72rem; + background: var(--bg-2, #252525); + padding: 2px 6px; + border-radius: 3px; + color: var(--fg-muted, #999); + flex-shrink: 0; + min-width: 120px; +} +.nemoclaw-inference-platform__row span { + color: var(--fg-muted, #777); +} +.nemoclaw-inference-platform__footer { + margin-top: 10px; + font-size: 0.68rem; + color: var(--fg-muted, #555); + font-style: italic; +} + +/* --- Active provider badge --- */ + +.nemoclaw-inference-active-badge { + display: inline-block; + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 2px 8px; + border-radius: 4px; + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.3); + margin-left: 6px; + flex-shrink: 0; +} + +/* --- Active provider card border --- */ + +.nemoclaw-policy-netcard--active { + border-left: 3px solid #22c55e; +} + +/* --- Activate button --- */ + +.nemoclaw-inference-activate-btn { + font-size: 0.72rem; + font-weight: 600; + padding: 4px 12px; + border-radius: 4px; + border: 1px solid var(--accent, #76b900); + background: transparent; + color: var(--accent, #76b900); + cursor: pointer; + white-space: nowrap; + transition: background 0.15s ease, color 0.15s ease; +} +.nemoclaw-inference-activate-btn:hover { + background: var(--accent, #76b900); + color: #fff; +} +.nemoclaw-inference-activate-btn--current { + border-color: #22c55e; + color: #22c55e; + opacity: 0.7; + cursor: default; +} +.nemoclaw-inference-activate-btn--current:hover { + background: transparent; + color: #22c55e; +} + +/* --- Model input --- */ + +.nemoclaw-inference-model-input { + font-family: var(--font-mono, monospace); + font-size: 0.82rem; +} + +/* --- Delete-active-provider warning --- */ + +.nemoclaw-policy-netcard--confirming-danger { + border-color: #ef4444 !important; + box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.2) inset; +} + +.nemoclaw-inference-delete-warning { + display: block; + font-size: 0.72rem; + font-weight: 500; + color: #f59e0b; + background: rgba(245, 158, 11, 0.08); + border: 1px solid rgba(245, 158, 11, 0.2); + border-radius: 4px; + padding: 6px 10px; + margin-bottom: 6px; + line-height: 1.4; +} + +/* (Zone A and Zone C styles removed — replaced by nc-* sections above) */ + +/* =================================================================== + Empty State — Template Tiles (P4) + =================================================================== */ + +.nemoclaw-inference-empty-tiles { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + animation: nemoclaw-fade-in 200ms ease; +} + +@media (max-width: 640px) { + .nemoclaw-inference-empty-tiles { + grid-template-columns: 1fr; + } +} + +.nemoclaw-inference-empty-tile { + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px; + border: 1px dashed var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: transparent; + color: var(--text, #e4e4e7); + font-family: inherit; + text-align: left; + cursor: pointer; + transition: border-color 150ms ease, background 150ms ease, box-shadow 150ms ease; +} + +.nemoclaw-inference-empty-tile:hover { + border-color: #76B900; + background: rgba(118, 185, 0, 0.04); + box-shadow: 0 0 0 1px rgba(118, 185, 0, 0.15); +} + +.nemoclaw-inference-empty-tile__label { + font-size: 14px; + font-weight: 600; + color: var(--text-strong, #fafafa); +} + +.nemoclaw-inference-empty-tile__type { + display: inline-block; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 1px 6px; + border-radius: 3px; + background: rgba(118, 185, 0, 0.08); + color: #76B900; + width: fit-content; +} + +.nemoclaw-inference-empty-tile__url { + font-size: 11px; + color: var(--muted, #71717a); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* =================================================================== + Flattened Provider Body Rows + =================================================================== */ + +.nemoclaw-inference-flat-row { + margin-bottom: 12px; +} + +.nemoclaw-inference-flat-row:last-child { + margin-bottom: 0; +} + +.nemoclaw-inference-proto-flat { + display: flex; + align-items: baseline; + gap: 10px; + padding: 8px 0 0; + border-top: 1px solid var(--border, #27272a); +} + +.nemoclaw-inference-proto-inline { + font-size: 0.72rem; + color: var(--muted, #71717a); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; +} + +/* =================================================================== + Model Selector — Grouped Dropdown + =================================================================== */ + +.nemoclaw-model-group-header { + padding: 6px 14px 4px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted, #71717a); + pointer-events: none; +} + +.nemoclaw-model-dropdown__divider { + height: 1px; + background: var(--border, #27272a); + margin: 4px 8px; +} + +.nemoclaw-model-dropdown__empty { + padding: 12px 14px; + color: var(--muted, #71717a); + font-size: 12px; + font-style: italic; + text-align: center; + user-select: none; +} + +.nemoclaw-model-dropdown__route-link { + display: block; + width: 100%; + padding: 8px 14px; + border: none; + border-radius: var(--radius-sm, 6px); + background: transparent; + color: var(--muted, #71717a); + font-size: 12px; + font-weight: 500; + font-family: inherit; + text-align: left; + cursor: pointer; + transition: color 100ms ease, background 100ms ease; +} + +.nemoclaw-model-dropdown__route-link:hover { + color: #76B900; + background: rgba(118, 185, 0, 0.06); +}