Ctrl K
ComunidadGuías…

src/ui/siwc-controls.js

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

Archivo original ↗Preguntar a KiBot
const RECOVERY = new Set(["none", "retry-later", "reauthorize", "enable-plan", "manage-usage",
  "fix-request", "fix-configuration", "review-again", "resolve-handoff"]);

export function prepareSiwcBrowser(browserWindow) {
  let popup;
  let launched = false;
  const closeUnused = () => {
    if (!launched) { try { popup?.close(); } catch { /* The user may already have closed it. */ } }
  };
  // Reserve the tab during the click, before any asynchronous provider/API work.
  // Sever the opener before navigating; never transfer the private wizard session.
  try {
    popup = browserWindow?.open?.("about:blank", "_blank");
    if (popup) popup.opener = null;
  } catch {
    closeUnused();
    popup = null;
  }
  return {
    launchMode: popup ? "same-browser" : "system-browser",
    closeUnused,
    launch(result) {
      if (result?.launchMode === "system-browser") { closeUnused(); return; }
      if (result?.launchMode !== "same-browser" || !popup || popup.closed ||
          !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(result.attemptId)) {
        throw new Error("The sign-in tab is unavailable. Return to Relmio and try again.");
      }
      popup.location.replace(`/__relmio/oauth/launch/${result.attemptId}`);
      launched = true;
    },
  };
}

export function siwcErrorFromResponse(result, status) {
  const error = new Error(typeof result?.error === "string" ? result.error : "The request did not complete.");
  error.status = Number.isInteger(result?.status) ? result.status
    : Number.isInteger(result?.upstreamStatus) ? result.upstreamStatus : status;
  error.recovery = RECOVERY.has(result?.recovery) ? result.recovery : "none";
  for (const key of ["code", "param", "requestId"]) {
    if (typeof result?.[key] === "string" && result[key].length <= 128) error[key] = result[key];
  }
  if (typeof result?.registrationId === "string" &&
      /^[A-Za-z0-9_-]{8,128}$/u.test(result.registrationId)) error.registrationId = result.registrationId;
  if (result?.upstream && typeof result.upstream === "object") error.upstream = result.upstream;
  for (const flag of ["remoteOutcomeUnknown", "retryBlocked"]) {
    if (result?.[flag] === true) error[flag] = true;
  }
  return error;
}

export function siwcErrorText(error) {
  const detail = error?.upstream?.body?.detail ?? error?.upstream?.body?.error?.message;
  const text = typeof detail === "string" && detail.length <= 240 ? detail : error?.message ?? "The request did not complete.";
  const fields = [error?.code && `Code: ${error.code}`, error?.param && `Field: ${error.param}`,
    error?.requestId && `Request ID: ${error.requestId}`].filter(Boolean);
  return fields.length ? `${text} ${fields.join(". ")}.` : text;
}

const USAGE_URL = "https://chatgpt.com/settings/usage";

export function accountUiState(account) {
  if (!account) return "new";
  if (account.ownership === "handoff-pending") return "handoff-pending";
  if (account.ownership === "transferred") return "transferred";
  if (account.session === "reauthorize") return "reauthorize";
  if (account.session !== "connected") return "signed-out";
  if (account.hostReady === false) return "host-unavailable";
  if (account.planPermission !== "granted") return "identity-only";
  return account.planEnabled ? "plan-active" : "plan-paused";
}

export function normalizeSiwcAccount(value) {
  if (!value || typeof value !== "object" || Array.isArray(value) ||
      typeof value.registrationId !== "string" || !/^[A-Za-z0-9_-]{8,128}$/u.test(value.registrationId) ||
      typeof value.label !== "string" || value.label.length > 160 ||
      (value.email !== undefined && (typeof value.email !== "string" || value.email.length > 254)) ||
      value.identity !== "verified" ||
      !["signed-out", "connected", "reauthorize"].includes(value.session) ||
      !["granted", "not-granted"].includes(value.planPermission) ||
      typeof value.planEnabled !== "boolean" ||
      !["owned", "handoff-pending", "transferred"].includes(value.ownership) ||
      typeof value.generation !== "string" || !/^[A-Za-z0-9_-]{8,128}$/u.test(value.generation) ||
      typeof value.ownerHostId !== "string" || !/^urn:uuid:[a-f0-9-]{36}$/iu.test(value.ownerHostId) ||
      typeof value.ownerRuntimeId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/u.test(value.ownerRuntimeId) ||
      typeof value.needsPlanWelcome !== "boolean" ||
      (value.hostReady !== undefined && typeof value.hostReady !== "boolean")) {
    throw new Error("The ChatGPT account status is invalid.");
  }
  return {
    registrationId: value.registrationId, label: value.label,
    ...(value.email !== undefined ? { email: value.email } : {}),
    identity: "verified", session: value.session, planPermission: value.planPermission,
    planEnabled: value.planEnabled, ownership: value.ownership, generation: value.generation,
    ownerHostId: value.ownerHostId, ownerRuntimeId: value.ownerRuntimeId,
    needsPlanWelcome: value.needsPlanWelcome,
    ...(value.hostReady !== undefined ? { hostReady: value.hostReady } : {}),
  };
}

export function createSiwcControls({ root, api, onChange, onLogin, onError }) {
  const field = (name) => root.querySelector(`[data-siwc="${name}"]`);
  const picker = field("account");
  const status = field("status");
  const dialog = field("welcome");
  let accounts = [];
  let pendingRegistrations = [];
  let selectedId = null;
  let models = [];
  let catalogGeneration = null;
  let loadVersion = 0;
  let loaded = false;
  let busy = false;
  let previewMode = false;
  let notice = "";
  let previewFixture = null;
  let welcomeDue = false;
  let welcomeTrigger = null;

  function selected() {
    return accounts.find((account) => account.registrationId === selectedId) ?? null;
  }

  function display(account) {
    return `${account.label}${account.email ? ` (${account.email})` : ""} · ${account.registrationId.slice(-8)}`;
  }

  function welcomePending() {
    const account = selected();
    return accountUiState(account) === "plan-active" && account.needsPlanWelcome === true;
  }

  function usageLimited() {
    return previewFixture === "usage-limit" && ["plan-active", "plan-paused"].includes(accountUiState(selected()));
  }

  function usable(control) {
    return Boolean(control) && root.contains(control) && !control.disabled && control.checkVisibility?.() !== false;
  }

  function render() {
    const account = selected();
    const mode = accountUiState(account);
    const limited = usageLimited();
    const welcome = welcomePending();
    // Help receives bounded states only, never account labels or credentials.
    if (root.dataset) Object.assign(root.dataset, {
      guideAccountState: mode, guideBusy: String(busy), guidePreview: String(previewMode),
      guidePlanNotice: String(welcome), guideUsageLimited: String(limited),
      guidePending: String(pendingRegistrations.length > 0),
    });
    picker.replaceChildren();
    if (!accounts.length) {
      picker.add(new Option("No saved ChatGPT accounts", ""));
    } else {
      picker.add(new Option("Choose an account", ""));
      for (const item of accounts) {
        const option = new Option(display(item), item.registrationId);
        option.disabled = item.ownership !== "owned" || item.hostReady === false;
        picker.add(option);
      }
    }
    picker.value = selectedId ?? "";
    picker.disabled = busy || previewMode || accounts.length === 0;
    const pending = field("pending");
    pending.replaceChildren();
    for (const item of pendingRegistrations) pending.add(new Option(
      `${item.label} · ${item.registrationId.slice(-8)}`, item.registrationId,
    ));
    field("pending-row").hidden = pendingRegistrations.length === 0;
    pending.disabled = busy || previewMode;
    field("finish").disabled = busy || previewMode;
    // The status and owner lines describe the selected account; this list names the others.
    const transferred = field("transferred");
    const inactiveOwners = accounts.filter((item) => item.ownership !== "owned" && item.registrationId !== selectedId);
    transferred.replaceChildren(...inactiveOwners.map((item) => {
      const listItem = document.createElement("li");
      listItem.textContent = `${display(item)}: ${item.ownership === "transferred"
        ? `managed by ${item.ownerRuntimeId}` : "transfer pending, plan requests frozen"}.`;
      return listItem;
    }));
    transferred.hidden = inactiveOwners.length === 0;
    status.textContent = [
      limited ? "ChatGPT usage limit reached. Plan requests are paused. Manage usage in ChatGPT; Relmio does not switch accounts or billing."
        : welcome ? "ChatGPT plan use is granted. Review the plan notice before using it."
          : {
            new: "Continue with ChatGPT to connect an account. A fresh Relmio sign-in is required; old Codex credentials are not imported.",
            "signed-out": "Signed out. The account label remains here; sign in again to reconnect.",
            reauthorize: "This account needs a fresh ChatGPT sign-in before it can be used.",
            "identity-only": "Connected for identity. ChatGPT plan use has not been granted.",
            "plan-paused": "Connected. ChatGPT plan permission is granted, but plan use is paused in Relmio.",
            "plan-active": "Model access and each request still depend on this account and its limits.",
            "handoff-pending": "Transfer is unresolved. Plan requests are frozen until ownership is checked at the destination.",
            transferred: "This account was transferred to its installation. Manage its plan and sign-out from the installed target.",
            "host-unavailable": "This registration belongs to another host or runtime. Start a fresh Relmio sign-in here.",
          }[mode],
      previewFixture === "staged" && "Staged installation: build interrupted. Review a resume before another write.",
      notice,
    ].filter(Boolean).join(" ");
    field("return").hidden = !account || mode === "transferred" || mode === "handoff-pending";
    field("grant").hidden = !["identity-only"].includes(mode);
    field("resume").hidden = mode !== "plan-paused";
    field("pause").hidden = mode !== "plan-active" || limited;
    field("logout").hidden = !["identity-only", "plan-paused", "plan-active", "reauthorize"].includes(mode);
    for (const name of ["new", "return", "grant", "resume", "pause", "logout"]) {
      field(name).disabled = busy || previewMode;
    }
    field("welcome-open").hidden = !welcome || dialog.open;
    field("welcome-open").disabled = busy;
    field("plan-badge").hidden = mode !== "plan-active" || welcome || limited;
    const usage = field("usage");
    usage.href = USAGE_URL;
    usage.hidden = !["plan-active", "plan-paused"].includes(mode);
    // A usage limit makes Manage usage the primary recovery.
    usage.className = limited ? "rm-button rm-button--primary" : "rm-link rm-link--standalone";
    field("owner").textContent = mode === "transferred" || mode === "handoff-pending"
      ? `Owner: ${account.ownerRuntimeId ?? "destination not confirmed"}` : "";
  }

  // A modal opened in a hidden view is an invisible dialog that makes the page inert, and an
  // operation lock disables its buttons, so a due welcome waits until both clear.
  function showWelcome(trigger = null) {
    welcomeDue = welcomePending() && !dialog.open;
    if (!welcomeDue || !root.checkVisibility?.() || document.body?.dataset.operationBusy === "true") return;
    welcomeDue = false;
    welcomeTrigger = trigger;
    dialog.showModal();
    render();
    field("welcome-accept").focus();
  }

  // Operation locks restore the control states captured before the operation started, which
  // undoes render() for loads that ran inside it. Draw again once the page lock lifts.
  if (typeof MutationObserver !== "undefined" && document.body) {
    new MutationObserver(() => {
      if (!loaded || document.body.dataset.operationBusy !== "false") return;
      render();
      if (welcomeDue) showWelcome();
    }).observe(document.body, { attributes: true, attributeFilter: ["data-operation-busy"] });
  }

  async function load({ welcome = true } = {}) {
    const version = ++loadVersion;
    const result = await api("/api/siwc/accounts");
    if (version !== loadVersion) return selected();
    if (!Array.isArray(result.accounts) || !Array.isArray(result.pendingRegistrations)) {
      throw new Error("The ChatGPT account list could not be verified.");
    }
    const previous = selected();
    accounts = result.accounts.map(normalizeSiwcAccount);
    pendingRegistrations = result.pendingRegistrations.map((item) => {
      if (!item || typeof item.registrationId !== "string" ||
          !/^[A-Za-z0-9_-]{8,128}$/u.test(item.registrationId) ||
          typeof item.label !== "string" || item.label.length > 160) {
        throw new Error("The unfinished ChatGPT sign-in could not be verified.");
      }
      return { registrationId: item.registrationId, label: item.label };
    });
    previewMode = result.previewMode === true;
    previewFixture = result.previewFixture ?? null;
    selectedId = accounts.some((account) => account.registrationId === result.selectedRegistrationId)
      ? result.selectedRegistrationId : null;
    loaded = true;
    if (previous?.registrationId !== selectedId || previous?.generation !== selected()?.generation) {
      models = [];
      catalogGeneration = null;
      onChange?.(selected());
    }
    render();
    if (welcome) showWelcome();
    else welcomeDue = false;
    return selected();
  }

  async function change(action) {
    if (busy) return;
    const trigger = document.activeElement;
    busy = true;
    render();
    try {
      await action();
      await load();
    } catch (error) {
      onError?.(error);
      await load({ welcome: false }).catch(onError);
    } finally {
      busy = false;
      render();
      // The trigger may now be hidden (Sign out, Pause) or was disabled while busy.
      const active = document.activeElement;
      if (!active || active === document.body || (root.contains(active) && !usable(active))) {
        (usable(trigger) ? trigger : field("state")).focus();
      }
    }
  }

  picker.addEventListener("change", () => change(async () => {
    if (!accounts.some((account) => account.registrationId === picker.value &&
        account.ownership === "owned" && account.hostReady !== false)) {
      throw new Error("Choose an account owned by this computer.");
    }
    await api("/api/siwc/select", { method: "POST", body: { registrationId: picker.value } });
  }));
  field("new").addEventListener("click", () => onLogin({ purpose: "sign-in" }));
  field("finish").addEventListener("click", () => {
    const registrationId = field("pending").value;
    if (pendingRegistrations.some((item) => item.registrationId === registrationId)) {
      onLogin({ purpose: "sign-in", registrationId });
    }
  });
  field("return").addEventListener("click", () => {
    const account = selected();
    if (account) onLogin({ purpose: "sign-in", registrationId: account.registrationId });
  });
  field("grant").addEventListener("click", () => {
    const account = selected();
    if (account) onLogin({ purpose: "enable-plan", registrationId: account.registrationId });
  });
  field("resume").addEventListener("click", () => change(async () => {
    const account = selected();
    await api("/api/siwc/plan", { method: "POST", body: {
      registrationId: account.registrationId, enabled: true, expectedGeneration: account.generation,
    } });
  }));
  field("pause").addEventListener("click", () => change(async () => {
    const account = selected();
    await api("/api/siwc/plan", { method: "POST", body: {
      registrationId: account.registrationId, enabled: false, expectedGeneration: account.generation,
    } });
  }));
  field("logout").addEventListener("click", () => change(async () => {
    const account = selected();
    const result = await api("/api/siwc/logout", { method: "POST", body: {
      registrationId: account.registrationId, expectedGeneration: account.generation,
    } });
    notice = result.revocation === "unconfirmed"
      ? "Local tokens cleared. Remote revocation was not confirmed. Disconnect Relmio in ChatGPT settings."
      : "Provider revocation finished where applicable. Old Codex data is separate.";
  }));
  field("welcome-open").addEventListener("click", (event) => showWelcome(event.currentTarget));
  field("welcome-accept").addEventListener("click", () => change(async () => {
    const account = selected();
    await api("/api/siwc/ack", { method: "POST", body: {
      registrationId: account.registrationId, expectedGeneration: account.generation,
    } });
    dialog.close();
  }));
  // Native dialog traversal can leave the final control for browser chrome.
  // Wrap only the modal's boundaries; preserve native Escape and interior traversal.
  dialog.addEventListener("keydown", (event) => {
    if (!dialog.open || event.key !== "Tab") return;
    const focusable = [...dialog.querySelectorAll("a[href], button, input, select, textarea, [tabindex]")]
      .filter((control) => control.tabIndex >= 0 && !control.disabled && control.checkVisibility?.() !== false);
    const first = focusable[0];
    const last = focusable.at(-1);
    if (!first || !(event.shiftKey ? document.activeElement === first : document.activeElement === last)) return;
    event.preventDefault();
    (event.shiftKey ? last : first).focus();
  });
  // Escape closes without an acknowledgment: the plan stays unconfirmed and Review plan notice
  // reopens it. Focus returns to the control that opened it, or to the account state.
  dialog.addEventListener("close", () => {
    render();
    (usable(welcomeTrigger) ? welcomeTrigger : field("state")).focus();
    welcomeTrigger = null;
  });

  return {
    load, selected, showWelcome, isPreview: () => previewMode, isUsageLimited: usageLimited,
    async authorized({ purpose } = {}) {
      const account = await load({ welcome: false });
      if (purpose === "enable-plan" && account?.planPermission === "granted" && !account.planEnabled) {
        await api("/api/siwc/plan", { method: "POST", body: {
          registrationId: account.registrationId, enabled: true, expectedGeneration: account.generation,
        } });
      }
      return load();
    },
    async catalog() {
      const account = selected();
      if (accountUiState(account) !== "plan-active" || account.needsPlanWelcome) {
        models = [];
        catalogGeneration = null;
        return [];
      }
      const id = account.registrationId;
      const result = await api("/api/siwc/models");
      if (selected()?.registrationId !== id || result.account?.registrationId !== id ||
          !Array.isArray(result.models)) throw new Error("The ChatGPT account changed during model discovery.");
      models = result.models;
      catalogGeneration = result.account.generation;
      return [...models];
    },
    catalogIsCurrent() { return selected()?.generation === catalogGeneration; },
    clear() { ++loadVersion; models = []; catalogGeneration = null; },
  };
}

export function createSiwcRecovery({ root, api, vps = false, getTarget, onReview, onResult, onError }) {
  const node = (tag, text, className) => {
    const item = document.createElement(tag);
    if (text) item.textContent = text;
    if (className) item.className = className;
    return item;
  };
  const details = node("details", "", "rm-disclosure");
  details.append(node("summary", "Recover a transfer or staged installation"));
  const body = node("div", "", "rm-disclosure__body");
  const label = node("label", "Recovery account", "rm-field");
  const account = node("select", "", "rm-select");
  label.append(account);
  const targetLabel = node("label", "Installation", "rm-field");
  const target = node("select", "", "rm-select");
  for (const [value, text] of [["codex-chatgpt", "Codex App Server"],
    ["codex-chat", "Codex Chat Adapter"], ["n8n-openai-oauth", "n8n ChatGPT sidecar"]]) {
    const option = node("option", text); option.value = value; target.append(option);
  }
  targetLabel.append(target);
  targetLabel.hidden = vps;
  const status = node("p", "Review is read-only. Confirm separately before recovery writes.", "rm-small");
  status.setAttribute("role", "status"); status.setAttribute("aria-live", "polite"); status.tabIndex = -1;
  const check = node("button", "Review pending transfer", "rm-button rm-button--sm");
  const resume = node("button", "Review staged resume", "rm-button rm-button--sm");
  const confirmLabel = node("label", "", "rm-check");
  const confirm = node("input"); confirm.type = "checkbox";
  confirmLabel.append(confirm, node("span", "I approve checking this reviewed destination and finishing its transfer acknowledgment. No credential export is repeated."));
  confirmLabel.hidden = true;
  const apply = node("button", "Reconcile reviewed transfer", "rm-button rm-button--primary");
  for (const button of [check, resume, apply]) button.type = "button";
  apply.hidden = true; apply.disabled = true;
  body.append(label, targetLabel, status, check, resume, confirmLabel, apply);
  details.append(body); root.append(details);
  let reviewId = null;
  let busy = false;
  let preview = false;
  function clear() {
    reviewId = null; confirm.checked = false; confirmLabel.hidden = true; apply.hidden = true; apply.disabled = true;
  }
  async function load({ target: selectedTarget, registrationId } = {}) {
    clear();
    const result = await api("/api/siwc/accounts");
    preview = result.previewMode === true;
    account.replaceChildren();
    for (const value of result.accounts.map(normalizeSiwcAccount)) {
      const ownership = { owned: "owned here", "handoff-pending": "transfer pending", transferred: "transferred" }[value.ownership];
      const option = node("option", `${value.label} · ${value.registrationId.slice(-8)} (${ownership})`);
      option.value = value.registrationId; account.append(option);
    }
    if (selectedTarget) target.value = selectedTarget;
    if (registrationId) account.value = registrationId;
    check.disabled = resume.disabled = preview || !account.value;
    if (result.previewFixture === "staged") status.textContent = "Staged installation. Files remain owned; resume needs a fresh read-only review and final approval.";
    if (result.previewFixture === "handoff-pending") status.textContent = "Transfer acknowledgment is pending. Requests stay frozen; reconciliation reads the destination's original receipt.";
  }
  details.addEventListener("toggle", () => { if (details.open) void load().catch(onError); });
  account.addEventListener("change", clear); target.addEventListener("change", clear);
  confirm.addEventListener("change", () => { apply.disabled = !confirm.checked || !reviewId || busy; });
  async function perform(action) {
    if (busy || preview) return;
    busy = true;
    check.disabled = resume.disabled = apply.disabled = true;
    try {
      if (action !== "apply") {
        clear();
        const result = await api(`${vps ? "/api/siwc/vps" : "/api/local/siwc"}/recovery/review`, {
          method: "POST", body: { ...(vps ? getTarget() : { target: target.value,
            ...getTarget?.({ target: target.value, action }) }),
            registrationId: account.value, action },
        });
        if (action === "resume") {
          onReview(result);
        } else {
          if (typeof result.reviewId !== "string") throw new Error("Transfer review was not confirmed.");
          reviewId = result.reviewId;
          const ids = result.destination;
          status.textContent = `Reviewed ${result.account.label} at ${vps
            ? `n8n ${ids.n8nContainerId.slice(0, 12)}, network ${ids.networkId.slice(0, 12)}`
            : target.options[target.selectedIndex].textContent}. Confirm to reconcile its acknowledgment.`;
          confirmLabel.hidden = apply.hidden = false; confirm.focus();
        }
      } else {
        if (!confirm.checked || !reviewId) throw new Error("Review and confirm this transfer first.");
        const id = reviewId; clear();
        const result = await api(`${vps ? "/api/siwc/vps" : "/api/local/siwc"}/recovery/reconcile`, {
          method: "POST", body: { reviewId: id, confirmed: true },
        });
        status.textContent = result.outcome === "finished"
          ? "Original receipt confirmed. The destination owns this registration. Review its staged resume if installation is incomplete."
          : "Destination did not accept this transfer. The old session stays frozen. Start a fresh sign-in before reviewing recovery.";
        status.focus(); await onResult?.(result);
      }
    } catch (error) {
      clear(); onError(error);
    } finally {
      busy = false;
      check.disabled = resume.disabled = preview || !account.value;
      apply.disabled = !confirm.checked || !reviewId;
    }
  }
  check.addEventListener("click", () => { void perform("reconcile"); });
  resume.addEventListener("click", () => { void perform("resume"); });
  apply.addEventListener("click", () => { void perform("apply"); });
  return { clear, load };
}