Ctrl K
ComunidadGuías…

src/ui/assistant.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { bindWizardNavigation, readWizardSession, wizardApiError } from "./session.js";
import { bindSshAuthentication, clearFieldError, createCredentialSshGuard, markRejectedField } from "./ssh-form.js";
import { initWizardTopbar } from "./topbar.js";

const token = readWizardSession();

const state = {
  fingerprint: null,
  network: null,
  reviewedIncludeSearxng: null,
  installAttempted: false,
  planId: null,
  operationBusy: false,
  operationAllowedSelector: null,
  operationButton: null,
  operationButtonAriaBusy: null,
  operationButtonLabel: "",
  operationControlObserver: null,
  operationControlStates: [],
  operationFocusControl: null,
  operationLabel: "",
  operationMessageLive: null,
  operationOwner: 0,
  operationProgressStartedAt: 0,
  operationProgressTimer: null,
};

const element = (id) => document.getElementById(id);
const message = element("global-message-text");
const errorBox = element("global-error");
const errorMessage = element("global-error-text");
let sshIdentityDecision = 0;
const sshAuthentication = bindSshAuthentication({ token, trustId: "fingerprint-confirm", onChange: invalidateReviewedPlan, shouldApplyConnectionStatus: () => sshIdentityDecision === 0 });
const hostReport = () => ({ flow: "assistant", container: element("container-select").value, network: element("network-select").value });
const sshSession = createCredentialSshGuard({ token, hostReport, onIdentityDecision() { sshIdentityDecision++; }, onMismatch() {
  invalidateReviewedPlan();
  state.network = null;
  resetFingerprint();
  element("container-select").replaceChildren();
  element("network-select").replaceChildren();
  showStep(1);
} });

function focusVisible(target) {
  target.focus({ preventScroll: true });
  target.scrollIntoView?.({ block: "start", behavior: "instant" });
}

function setMessage(value) {
  message.textContent = value;
}

function clearError() {
  errorBox.hidden = true;
  errorMessage.textContent = "";
  globalThis.relmioGuide?.clearError?.();
}

function showError(error) {
  const text = markRejectedField(error?.message ?? "The request could not be completed.", element, "global-error-text");
  errorMessage.textContent = text;
  errorBox.hidden = false;
  errorBox.focus();
  globalThis.relmioGuide?.error?.(error);
}

function validatePlanId(value) {
  if (
    typeof value !== "string" ||
    value.length === 0 ||
    value.length > 512 ||
    /[\0\r\n]/u.test(value)
  ) {
    throw new Error("The wizard returned an unexpected plan reference.");
  }
  return value;
}

function invalidateReviewedPlan() {
  state.planId = null;
  state.reviewedIncludeSearxng = null;
  element("install-confirm").checked = false;
  element("install-button").disabled = true;
}

const OPERATION_INTERACTIVE_SELECTOR =
  'button, input, select, textarea, summary, a[href], [contenteditable]';
const OPERATION_BLOCKED_EVENTS = [
  "click",
  "pointerdown",
  "keydown",
  "beforeinput",
  "input",
  "change",
  "submit",
];
const OPERATION_DEFAULT_NOTE =
  "Timing varies with your remote server and network. Keep this page open until this step finishes.";

function readOperationAttribute(control, name) {
  return typeof control.getAttribute === "function"
    ? control.getAttribute(name)
    : control.attributes?.get?.(name) ?? null;
}

function restoreOperationAttribute(control, name, value) {
  if (value === null) {
    if (typeof control.removeAttribute === "function") {
      control.removeAttribute(name);
    } else {
      control.attributes?.delete?.(name);
    }
    return;
  }
  control.setAttribute?.(name, value);
}

function isOperationAllowedControl(control) {
  return Boolean(
    control?.closest?.(".rm-guide-layer button, .rm-guide-layer select, .rm-guide-layer summary, [data-guide-help]") ||
    state.operationAllowedSelector &&
    control?.closest?.(state.operationAllowedSelector),
  );
}

function operationControlCandidates() {
  return Array.from(
    document.querySelectorAll?.(OPERATION_INTERACTIVE_SELECTOR) ?? [],
  );
}

function lockOperationControl(control) {
  if (!control || isOperationAllowedControl(control)) return;
  const existingSnapshot = state.operationControlStates.find(
    (snapshot) => snapshot.control === control,
  );
  if (existingSnapshot) {
    if (existingSnapshot.disabled !== null && control.disabled !== true) {
      control.disabled = true;
    }
    if (existingSnapshot.readOnly !== null && control.readOnly !== true) {
      control.readOnly = true;
    }
    if (readOperationAttribute(control, "aria-disabled") !== "true") {
      control.setAttribute?.("aria-disabled", "true");
    }
    if (
      existingSnapshot.disabled === null &&
      readOperationAttribute(control, "tabindex") !== "-1"
    ) {
      control.setAttribute?.("tabindex", "-1");
    }
    if (
      existingSnapshot.contentEditable !== null &&
      readOperationAttribute(control, "contenteditable") !== "false"
    ) {
      control.setAttribute?.("contenteditable", "false");
    }
    return;
  }

  const snapshot = {
    control,
    disabled: typeof control.disabled === "boolean" ? control.disabled : null,
    readOnly: typeof control.readOnly === "boolean" ? control.readOnly : null,
    ariaDisabled: readOperationAttribute(control, "aria-disabled"),
    tabIndex: readOperationAttribute(control, "tabindex"),
    contentEditable: readOperationAttribute(control, "contenteditable"),
  };
  state.operationControlStates.push(snapshot);
  if (snapshot.disabled !== null) control.disabled = true;
  if (snapshot.readOnly !== null) control.readOnly = true;
  control.setAttribute?.("aria-disabled", "true");
  if (snapshot.disabled === null) control.setAttribute?.("tabindex", "-1");
  if (snapshot.contentEditable !== null) {
    control.setAttribute?.("contenteditable", "false");
  }
}

function lockAddedOperationControls(node) {
  if (!node || node.nodeType !== 1) return;
  if (node.matches?.(OPERATION_INTERACTIVE_SELECTOR)) {
    lockOperationControl(node);
  }
  for (const control of node.querySelectorAll?.(
    OPERATION_INTERACTIVE_SELECTOR,
  ) ?? []) {
    lockOperationControl(control);
  }
}

function formatOperationElapsed(elapsedSeconds) {
  const minutes = String(Math.floor(elapsedSeconds / 60)).padStart(2, "0");
  const seconds = String(elapsedSeconds % 60).padStart(2, "0");
  return `${minutes}:${seconds}`;
}

function updateOperationProgress() {
  const elapsedSeconds = Math.max(
    0,
    Math.floor((Date.now() - state.operationProgressStartedAt) / 1_000),
  );
  const elapsed = formatOperationElapsed(elapsedSeconds);
  const elapsedElement = element("operation-progress-elapsed");
  elapsedElement.textContent = elapsed;
  elapsedElement.setAttribute("datetime", `PT${elapsedSeconds}S`);
}

function updateOperationLabel(label) {
  state.operationLabel = label || "Working…";
  element("operation-progress-label").textContent = state.operationLabel;
  element("operation-progress-bar").setAttribute(
    "aria-valuetext",
    state.operationLabel,
  );
  if (state.operationButton) {
    state.operationButton.textContent = state.operationLabel;
  }
  updateOperationProgress();
}

function syncPanelBusy() {
  for (const panel of document.querySelectorAll(".assistant-step")) {
    panel.setAttribute("aria-busy", String(state.operationBusy && !panel.hidden));
  }
}

function startOperation(
  trigger,
  label,
  {
    allowedSelector = null,
    progressNote = OPERATION_DEFAULT_NOTE,
  } = {},
) {
  if (state.operationBusy) return false;

  state.operationBusy = true;
  state.operationAllowedSelector = allowedSelector;
  state.operationOwner += 1;
  state.operationFocusControl =
    trigger ??
    (document.activeElement && document.activeElement !== document.body
      ? document.activeElement
      : element("main-content"));
  state.operationButton = trigger?.tagName === "BUTTON" ? trigger : null;
  state.operationButtonLabel = state.operationButton?.textContent?.trim?.() ?? "";
  state.operationButtonAriaBusy = state.operationButton
    ? readOperationAttribute(state.operationButton, "aria-busy")
    : null;
  state.operationControlStates = [];
  state.operationLabel = label || "Working…";
  state.operationProgressStartedAt = Date.now();

  if (state.operationButton) {
    state.operationButton.setAttribute("aria-busy", "true");
    state.operationButton.textContent = state.operationLabel;
  }
  for (const control of operationControlCandidates()) {
    lockOperationControl(control);
  }

  document.body.dataset.operationBusy = "true";
  syncPanelBusy();
  const messageRegion = element("global-message");
  state.operationMessageLive = readOperationAttribute(
    messageRegion,
    "aria-live",
  );
  messageRegion.setAttribute("aria-live", "off");

  element("operation-progress-note").textContent = progressNote;
  const progress = element("operation-progress");
  progress.hidden = false;
  updateOperationLabel(state.operationLabel);
  focusVisible(progress);

  if (typeof MutationObserver !== "undefined" && document.body) {
    state.operationControlObserver = new MutationObserver((records) => {
      if (!state.operationBusy) return;
      for (const record of records) {
        if (record.type === "attributes") {
          lockAddedOperationControls(record.target);
        }
        for (const node of record.addedNodes ?? []) {
          lockAddedOperationControls(node);
        }
      }
    });
    state.operationControlObserver.observe(document.body, {
      attributeFilter: ["disabled", "readonly", "href", "contenteditable"],
      attributes: true,
      childList: true,
      subtree: true,
    });
  }

  state.operationProgressTimer = window.setInterval(
    updateOperationProgress,
    1_000,
  );
  return true;
}

function stopOperation(trigger, expectedOwner) {
  if (
    !state.operationBusy ||
    (expectedOwner !== undefined && expectedOwner !== state.operationOwner)
  ) {
    return false;
  }

  if (state.operationProgressTimer !== null) {
    window.clearInterval(state.operationProgressTimer);
  }
  state.operationProgressTimer = null;
  state.operationControlObserver?.disconnect?.();
  state.operationControlObserver = null;
  state.operationBusy = false;

  for (const snapshot of state.operationControlStates) {
    if (snapshot.disabled !== null) {
      snapshot.control.disabled = snapshot.disabled;
    }
    if (snapshot.readOnly !== null) {
      snapshot.control.readOnly = snapshot.readOnly;
    }
    restoreOperationAttribute(
      snapshot.control,
      "aria-disabled",
      snapshot.ariaDisabled,
    );
    restoreOperationAttribute(snapshot.control, "tabindex", snapshot.tabIndex);
    restoreOperationAttribute(
      snapshot.control,
      "contenteditable",
      snapshot.contentEditable,
    );
  }
  state.operationControlStates = [];

  document.body.dataset.operationBusy = "false";
  syncPanelBusy();
  restoreOperationAttribute(
    element("global-message"),
    "aria-live",
    state.operationMessageLive,
  );

  const progress = element("operation-progress");
  const restoreFocus =
    document.activeElement === progress ||
    progress.contains?.(document.activeElement) ||
    isOperationAllowedControl(document.activeElement);
  progress.hidden = true;
  element("operation-progress-label").textContent = "No operation is running.";
  const elapsedElement = element("operation-progress-elapsed");
  elapsedElement.textContent = "00:00";
  elapsedElement.setAttribute("datetime", "PT0S");
  element("operation-progress-bar").setAttribute(
    "aria-valuetext",
    "No operation is running.",
  );
  element("operation-progress-note").textContent = OPERATION_DEFAULT_NOTE;

  const activeButton = state.operationButton;
  if (activeButton) {
    restoreOperationAttribute(
      activeButton,
      "aria-busy",
      state.operationButtonAriaBusy,
    );
    activeButton.textContent = state.operationButtonLabel;
  }

  const focusControl = state.operationFocusControl ?? trigger ?? null;
  if (
    restoreFocus &&
    focusControl?.isConnected !== false &&
    !focusControl?.disabled &&
    !focusControl?.hidden &&
    !focusControl?.closest?.("[hidden]")
  ) {
    focusVisible(focusControl);
  }

  state.operationButton = null;
  state.operationAllowedSelector = null;
  state.operationButtonAriaBusy = null;
  state.operationButtonLabel = "";
  state.operationFocusControl = null;
  state.operationLabel = "";
  state.operationMessageLive = null;
  state.operationProgressStartedAt = 0;
  return true;
}

async function runOperation(trigger, label, work, options) {
  if (!startOperation(trigger, label, options)) return undefined;
  const operationOwner = state.operationOwner;
  try {
    return await work();
  } finally {
    stopOperation(trigger, operationOwner);
  }
}

async function runActiveOperationTask(button, label, work) {
  if (!state.operationBusy || !isOperationAllowedControl(button)) {
    return undefined;
  }
  const operationOwner = state.operationOwner;
  const previousLabel = state.operationLabel;
  const previousButtonLabel = button.textContent;
  const previousDisabled = button.disabled;
  const previousAriaBusy = readOperationAttribute(button, "aria-busy");
  updateOperationLabel(label);
  button.disabled = true;
  button.setAttribute("aria-busy", "true");
  button.textContent = label;
  try {
    return await work();
  } finally {
    button.disabled = previousDisabled;
    restoreOperationAttribute(button, "aria-busy", previousAriaBusy);
    button.textContent = previousButtonLabel;
    if (
      state.operationBusy &&
      state.operationOwner === operationOwner
    ) {
      updateOperationLabel(previousLabel);
    }
  }
}

function blockOperationInteraction(event) {
  if (
    !state.operationBusy ||
    (event.type === "keydown" && event.key === "Tab") ||
    (event.type === "keydown" && event.key === "Escape" && event.target?.closest?.(".rm-guide-layer")) ||
    event.target?.closest?.("#operation-progress") ||
    isOperationAllowedControl(event.target)
  ) {
    return;
  }
  event.preventDefault?.();
  event.stopImmediatePropagation?.();
}

for (const eventName of OPERATION_BLOCKED_EVENTS) {
  document.addEventListener(eventName, blockOperationInteraction, true);
}
function showStep(step) {
  document.body.dataset.currentStep = String(step);
  let activePanel = null;
  for (const panel of document.querySelectorAll("[data-step]")) {
    const active = Number(panel.dataset.step) === step;
    panel.hidden = !active;
    if (active) activePanel = panel;
  }
  syncPanelBusy();
  for (const marker of document.querySelectorAll("[data-step-marker]")) {
    const markerStep = Number(marker.dataset.stepMarker);
    if (markerStep === step) marker.setAttribute("aria-current", "step");
    else marker.removeAttribute("aria-current");
    if (markerStep < step) marker.dataset.state = "done";
    else delete marker.dataset.state;
  }
  if (activePanel && activePanel.getBoundingClientRect().top < 0) {
    activePanel.scrollIntoView({ block: "start" });
  }
  activePanel?.querySelector("h2")?.focus({ preventScroll: true });
}

function resetFingerprint() {
  invalidateReviewedPlan();
  state.fingerprint = null;
  element("fingerprint-box").hidden = true;
  element("fingerprint-confirm").checked = false;
  element("password").value = "";
  element("password").disabled = true;
  updateConnectState();
}

function updateConnectState() {
  element("connect-button").disabled = !(
    state.fingerprint &&
    element("fingerprint-confirm").checked &&
    element("privileged-confirm").checked
  );
}

function renderFingerprint(fingerprint) {
  resetFingerprint();
  state.fingerprint = fingerprint;
  element("fingerprint-value").textContent = fingerprint;
  element("fingerprint-box").hidden = false;
}

function fillSelect(select, items, selectedValue) {
  select.replaceChildren();
  for (const item of items) {
    const option = document.createElement("option");
    option.value = item.value;
    option.textContent = item.label;
    option.selected = item.value === selectedValue;
    select.append(option);
  }
}

function formatInstanceAiStatus(instanceAi) {
  switch (instanceAi?.status) {
    case "enabled":
      return "AI Assistant is enabled: N8N_ENABLED_MODULES includes instance-ai. Refresh discovery after n8n changes.";
    case "configured":
      return "instance-ai is missing from N8N_ENABLED_MODULES. Add it as its own comma-delimited value, keep existing values, then redeploy or restart n8n yourself.";
    case "missing":
      return "Add N8N_ENABLED_MODULES=instance-ai to existing n8n, then redeploy or restart n8n yourself.";
    default:
      return "AI Assistant prerequisite could not be verified for this n8n container.";
  }
}

async function api(path, { method = "GET", body } = {}) {
  if (!token) throw new Error("The setup session is missing. Start the assistant command again.");
  await sshSession.before(path);
  const response = await fetch(path, {
    method,
    headers: {
      "Content-Type": "application/json",
      "X-Setup-Token": token,
    },
    ...(body === undefined ? {} : { body: JSON.stringify(body) }),
  });
  let result;
  try {
    result = await response.json();
  } catch {
    throw new Error("The wizard returned an unreadable response. Start again.");
  }
  if (!response.ok) throw wizardApiError(result, response.status, "The request failed.");
  await sshSession.after(path, result);
  return result;
}

async function loadNetworks(
  containerName = element("container-select").value,
) {
  return api("/api/networks", {
    method: "POST",
    body: { containerName },
  });
}

function renderNetworks(result) {
  state.network = result;
  const instanceAiStatus = element("instance-ai-status");
  instanceAiStatus.textContent = formatInstanceAiStatus(result.instanceAi);
  instanceAiStatus.dataset.status = result.instanceAi?.status ?? "unknown";
  const prerequisiteReady = result.instanceAi?.status === "enabled";
  element("review-button").disabled = !prerequisiteReady;
  element("review-readiness").textContent = prerequisiteReady
    ? "Ready to review. Nothing has been written."
    : "Enable instance-ai, restart n8n yourself, then reconnect before you review.";
  fillSelect(
    element("network-select"),
    result.networks.map((network) => ({ value: network, label: network })),
    result.recommended,
  );
  sshSession.refreshHostReport();
}

async function discover() {
  const discovery = await api("/api/discover", { method: "POST", body: {} });
  if (
    !Array.isArray(discovery.containers) ||
    discovery.containers.length === 0
  ) {
    // The server says what it looked for when it has that sentence.
    throw new Error(typeof discovery.noneFoundMessage === "string" && discovery.noneFoundMessage.length <= 240
      ? discovery.noneFoundMessage : "No running official n8n container was found.");
  }
  const networks = await loadNetworks(discovery.containers[0].name);
  return { discovery, networks };
}

function renderDiscovery({ discovery, networks }) {
  fillSelect(
    element("container-select"),
    discovery.containers.map((container) => ({
      value: container.name,
      label: `${container.name} - ${container.image}`,
    })),
    discovery.containers[0].name,
  );
  renderNetworks(networks);
  showStep(2);
}

element("fingerprint-button").addEventListener("click", async (event) => {
  const button = event.currentTarget;
  clearError();
  invalidateReviewedPlan();
  for (const id of ["host", "port"]) {
    if (!element(id).checkValidity()) {
      showError(new Error(id === "host" ? "Hostname is invalid." : "Port is invalid."));
      return;
    }
  }
  try {
    const result = await runOperation(
      button,
      "Checking server identity…",
      () => api("/api/ssh/fingerprint", {
        method: "POST",
        body: { host: element("host").value, port: element("port").value },
      }),
    );
    if (!result) return;
    for (const id of ["host", "port"]) clearFieldError(element(id), "global-error-text");
    renderFingerprint(result.fingerprint);
    setMessage("Confirm the SSH host identity before authenticating.");
    // As on the main setup page, a finished check hands focus to the confirmation.
    if (document.activeElement === button) focusVisible(element("fingerprint-confirm"));
  } catch (error) {
    showError(error);
  }
});

element("fingerprint-confirm").addEventListener("change", (event) => {
  invalidateReviewedPlan();
  sshAuthentication.sync({ trusted: event.currentTarget.checked });
  if (!event.currentTarget.checked) element("password").value = "";
  updateConnectState();
});

element("privileged-confirm").addEventListener("change", () => {
  invalidateReviewedPlan();
  updateConnectState();
});
element("host").addEventListener("input", () => { clearFieldError(element("host"), "global-error-text"); clearError(); resetFingerprint(); });
element("port").addEventListener("input", () => { clearFieldError(element("port"), "global-error-text"); clearError(); resetFingerprint(); });
element("username").addEventListener("input", () => { clearFieldError(element("username"), "global-error-text"); clearError(); });
element("password").addEventListener("input", () => { clearFieldError(element("password"), "global-error-text"); clearError(); invalidateReviewedPlan(); });
element("vps-form").addEventListener("invalid", (event) => {
  const label = { host: "Hostname", port: "Port", username: "Username", password: "Password" }[event.target.id];
  if (!label) return;
  event.preventDefault();
  showError(new Error(`${label} is invalid.`));
}, true);

element("vps-form").addEventListener("submit", async (event) => {
  event.preventDefault();
  const button = element("connect-button");
  clearError();
  invalidateReviewedPlan();
  setMessage("Connecting, then inspecting Docker with read-only commands…");
  try {
    const discovered = await runOperation(
      button,
      "Wait…",
      async () => {
        await api("/api/ssh/connect", {
          method: "POST",
          body: sshAuthentication.request(state.fingerprint),
        });
        return discover();
      },
      {
        progressNote:
          "Relmio is opening the verified SSH connection and inspecting Docker with read-only commands. Timing varies with your remote server and network. Keep this page open.",
      },
    );
    if (!discovered) return;
    element("password").value = "";
    for (const id of ["username", "password"]) clearFieldError(element(id), "global-error-text");
    renderDiscovery(discovered);
    setMessage("n8n was found. Choose an existing Docker network.");
  } catch (error) {
    element("password").value = "";
    showError(error);
  }
});

element("container-select").addEventListener("change", async (event) => {
  const select = event.currentTarget;
  const containerName = select.value;
  clearError();
  invalidateReviewedPlan();
  try {
    const networks = await runOperation(
      select,
      "Refreshing Docker networks…",
      () => loadNetworks(containerName),
    );
    if (!networks) return;
    renderNetworks(networks);
    setMessage("Docker networks refreshed for the selected n8n container.");
  } catch (error) {
    showError(error);
  }
});

element("network-select").addEventListener("change", () => {
  invalidateReviewedPlan();
  sshSession.refreshHostReport();
  setMessage("Network changed. Review a fresh plan before installing.");
});

element("review-button").addEventListener("click", async (event) => {
  const button = event.currentTarget;
  clearError();
  invalidateReviewedPlan();
  try {
    const networkName = element("network-select").value;
    const includeSearxng = element("include-searxng").checked;
    const plan = await runOperation(
      button,
      "Preparing the exact plan…",
      () => api("/api/assistant/plan", {
        method: "POST",
        body: {
          containerName: element("container-select").value,
          networkName,
          includeSearxng,
        },
      }),
    );
    if (!plan) return;
    const planId = validatePlanId(plan.planId);
    state.reviewedIncludeSearxng = plan.includeSearxng;
    element("review-network").textContent = networkName;
    element("review-instance-ai").textContent = formatInstanceAiStatus(plan.instanceAi);
    element("review-web-search").textContent = plan.includeSearxng
      ? `Attach optional SearXNG web search to ${networkName} with its private alias`
      : "Web search is off. No SearXNG service, settings file, or URL will be installed.";
    // Plain sentences from the server, such as an n8n that already runs its own sandbox.
    const warnings = (Array.isArray(plan.warnings) ? plan.warnings : [])
      .filter((text) => typeof text === "string" && text && text.length <= 240);
    element("plan-warnings-list").replaceChildren(...warnings.map((text) => {
      const item = document.createElement("li");
      item.textContent = text;
      return item;
    }));
    element("plan-warnings").hidden = warnings.length === 0;
    state.planId = planId;
    element("install-confirm").checked = false;
    element("install-button").disabled = true;
    showStep(3);
    setMessage("Review this privileged companion plan. The SSH host has not changed.");
  } catch (error) {
    showError(error);
  }
});

element("install-confirm").addEventListener("change", (event) => {
  element("install-button").disabled = !(
    event.currentTarget.checked && state.planId
  );
});

element("include-searxng").addEventListener("change", () => {
  invalidateReviewedPlan();
  setMessage("Web search choice changed. Review a fresh plan before installing.");
});

element("install-button").addEventListener("click", async (event) => {
  const button = event.currentTarget;
  clearError();
  if (!state.planId || !element("install-confirm").checked) {
    invalidateReviewedPlan();
    showError(new Error("Review and confirm a fresh Assistant plan first."));
    return;
  }
  state.installAttempted = true;
  try {
    const result = await runOperation(
      button,
      "Starting the companion…",
      () => api("/api/assistant/install", {
        method: "POST",
        body: {
          containerName: element("container-select").value,
          networkName: element("network-select").value,
          includeSearxng: state.reviewedIncludeSearxng,
          confirmed: element("install-confirm").checked,
          planId: state.planId,
        },
      }),
      {
        progressNote:
          "Docker may be downloading images, building, or starting the reviewed companion. Timing varies with your remote server and network. Keep this page open until verification finishes.",
      },
    );
    if (!result) return;
    invalidateReviewedPlan();
    element("sandbox-url").textContent = result.sandboxUrl;
    element("sandbox-api-key").textContent = typeof result.sandboxApiKey === "string"
      ? result.sandboxApiKey
      : "Unchanged on this managed update; the key is not redisplayed.";
    if (result.includeSearxng === true && typeof result.searxngUrl === "string") {
      element("searxng-url").textContent = result.searxngUrl;
      element("searxng-note").textContent = "SearXNG keeps its server secret private. There is no SearXNG key for you to enter.";
    } else {
      element("searxng-url").textContent = "Web search disabled";
      element("searxng-note").textContent = "Web search is off, so no SearXNG service or settings file was installed. Its retained secret stays private.";
    }
    showStep(4);
    setMessage("Companion verified. Enter these values in n8n’s AI Assistant settings.");
  } catch (error) {
    invalidateReviewedPlan();
    showError(error);
  }
});

bindWizardNavigation(element("setup-another-assistant"), "/assistant", token);

for (const button of document.querySelectorAll(".back-button")) {
  button.addEventListener("click", () => {
    clearError();
    invalidateReviewedPlan();
    showStep(Number(button.dataset.back));
    setMessage("No new assistant installation has started.");
  });
}

initWizardTopbar({
  session: token,
  isBusy: () => state.operationBusy,
  loadProjectMeta: () => api("/api/local/project-meta"),
});