tmp/webapp-docs/frontend/components/ProvidersMenu.js

/**
 * The header **Providers** dropdown — one trigger that opens a small panel holding BOTH provider
 * choices stacked vertically, each a rich grouped picker with its BYOK key field on the right:
 *   • Image — what renders the picture, grouped Local (incl. Plain text) / Online.
 *   • Text  — the AI that rewrites the prompt & keywords (auto-fix); Off or one of the rewrite AIs.
 * This keeps the header lean (just this dropdown + the settings gear + the NSFW switch). The
 * image-provider's own knobs live in the gear popover next door (`ProviderGear`); the auto-fix /
 * keyword toggles stay on the prompt box. Keys are stored per provider id, so the same provider
 * chosen for both rows shares one key (shown once).
 * @module gui/components/ProvidersMenu
 */
import { useState } from "react";
import { useIntl, defineMessages } from "react-intl";
import { providers, getProvider, rewriteProviders } from "../lib/providers/index.js";
import { softLockedForNsfw } from "../lib/contentPolicy.js";
import { ONLINE } from "../lib/online.js";
import { providerMode } from "../lib/useProvider.js";
import { metaFor } from "../lib/providerMeta.js";
import ProviderPicker from "./ProviderPicker.jsx";
import ApiKeyField from "./ApiKeyField.jsx";
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "react/jsx-dev-runtime";
const msgs = defineMessages({
  reasonProxy: {
    id: "providersMenu.reason.proxy",
    defaultMessage: "This provider can't be called directly from a browser, so it isn't available online."
  },
  reasonLocal: {
    id: "providersMenu.reason.local",
    defaultMessage: "It runs on your own machine."
  },
  providers: {
    id: "providersMenu.providers",
    defaultMessage: "Providers"
  },
  image: {
    id: "providersMenu.image",
    defaultMessage: "Image"
  },
  text: {
    id: "providersMenu.text",
    defaultMessage: "Text"
  },
  groupLocal: {
    id: "providersMenu.group.local",
    defaultMessage: "Local"
  },
  groupOnline: {
    id: "providersMenu.group.online",
    defaultMessage: "Online"
  },
  groupRewrite: {
    id: "providersMenu.group.rewrite",
    defaultMessage: "Prompt & keyword rewrite"
  },
  off: {
    id: "providersMenu.off",
    defaultMessage: "Off"
  },
  offDesc: {
    id: "providersMenu.offDesc",
    defaultMessage: "No prompt or keyword rewriting."
  },
  unset: {
    id: "providersMenu.unset",
    defaultMessage: "Unset"
  },
  unsetDesc: {
    id: "providersMenu.unsetDesc",
    defaultMessage: "No text AI selected — prompt & keyword rewriting stays off."
  },
  unsetUpscaleDesc: {
    id: "providersMenu.unsetUpscaleDesc",
    defaultMessage: "No upscaler selected."
  },
  sharesKey: {
    id: "providersMenu.sharesKey",
    defaultMessage: "shares the image key"
  },
  hintOn: {
    id: "providersMenu.hintOn",
    defaultMessage: "Text AI rewrites your prompt & keywords — turn it on per run with the wand / tag buttons on the prompt box."
  },
  hintOff: {
    id: "providersMenu.hintOff",
    defaultMessage: "Pick a Text AI to enable auto-fix / keyword rewriting (toggled on the prompt box)."
  },
  upscale: {
    id: "providersMenu.upscale",
    defaultMessage: "Upscaler / Enhancer"
  },
  groupUpscale: {
    id: "providersMenu.group.upscale",
    defaultMessage: "Upscale / enhance"
  },
  groupUpscaleLocal: {
    id: "providersMenu.group.upscaleLocal",
    defaultMessage: "Local"
  },
  groupUpscaleOnline: {
    id: "providersMenu.group.upscaleOnline",
    defaultMessage: "Online"
  },
  upscaleHint: {
    id: "providersMenu.upscaleHint",
    defaultMessage: "Used in the single-image view to upscale a saved image. Pick a provider and add its key."
  },
  upscaleLockedReason: {
    id: "providersMenu.upscaleLockedReason",
    defaultMessage: "Upscaling happens in the single-image view, which needs the full desktop app (it isn't available online)."
  }
});

/**
 * @param {object} props
 * @param {object} props.settings The current settings.
 * @param {Function} props.setSettings Update the settings.
 * @returns {JSX.Element}
 */
export default function ProvidersMenu({
  settings,
  setSettings
}) {
  const intl = useIntl();
  const [open, setOpen] = useState(false);
  // Show every provider; local ones are rendered disabled (locked) in the online build rather
  // than hidden, so visitors can see what the full desktop version adds.
  const provs = providers;

  // Shape a provider config into a ProviderPicker option (with its description + key/lock badge).
  // Online is a static site: local-direct providers need the user's machine, and hosted-proxy
  // providers (Replicate / BFL / Ideogram) can't be called straight from a browser (no CORS).
  const toOption = p => {
    const lockedLocal = ONLINE && p.local;
    const lockedProxy = ONLINE && p.transport === "hosted-proxy";
    return {
      id: p.id,
      label: p.label,
      needsKey: p.needsKey,
      description: metaFor(p.id).description,
      // SFW-only provider while NSFW mode is on → a soft lock (icon + tooltip), still selectable.
      softLock: softLockedForNsfw(p, settings.includeAdult),
      locked: lockedLocal || lockedProxy,
      lockReason: lockedProxy ? intl.formatMessage(msgs.reasonProxy) : lockedLocal ? intl.formatMessage(msgs.reasonLocal) : undefined
    };
  };
  const imageId = settings.provider;
  const image = getProvider(imageId);
  const textId = settings.rewriteProvider && settings.rewriteProvider !== "none" ? settings.rewriteProvider : "none";
  const text = textId !== "none" ? getProvider(textId) : null;

  // Image providers grouped Local / Online. Plain text needs no machine or network, so it lists
  // under Local alongside the local Stable Diffusion engines. Upscale-only providers (enhancers like
  // DeepAI) can't generate, so they're excluded here — they live in the Upscaler / Enhancer row.
  const notImageRole = p => p.upscaleOnly || p.textOnly; // enhancers + text-only providers
  // Plain text needs no machine/key/network, so it's the universal default — pin it to the very top
  // of the Local group, above the local Stable Diffusion engines.
  const localImage = provs.filter(p => (p.local || p.id === "plain") && !notImageRole(p)).sort((a, b) => a.id === "plain" ? -1 : b.id === "plain" ? 1 : 0);
  const imageGroups = [{
    title: intl.formatMessage(msgs.groupLocal),
    // Plain text is the universal default and is labeled "Unset" (its config) — pinned to the top.
    items: localImage.map(toOption)
  }, {
    title: intl.formatMessage(msgs.groupOnline),
    items: provs.filter(p => !p.local && p.id !== "plain" && !notImageRole(p)).map(toOption)
  }];

  // Upscaler / Enhancer providers: anything that ships an upscale adapter (the in-repo image
  // providers that also upscale + the upscale-only enhancers). Local-only feature (the single view),
  // so this row is hidden in the online build. Selecting one is just for key entry + a default.
  const upscaleId = settings.upscaleProvider && settings.upscaleProvider !== "none" ? settings.upscaleProvider : "none";
  // Grouped Local / Online like the image picker. "Off" leads the Local group (no machine needed).
  const upscaleCapable = provs.filter(p => p.capabilities?.upscale && p.loadUpscale);
  const upscaleGroups = [{
    title: intl.formatMessage(msgs.groupUpscaleLocal),
    items: [{
      id: "none",
      label: intl.formatMessage(msgs.unset),
      description: intl.formatMessage(msgs.unsetUpscaleDesc)
    }, ...upscaleCapable.filter(p => p.local).map(toOption)]
  }, {
    title: intl.formatMessage(msgs.groupUpscaleOnline),
    items: upscaleCapable.filter(p => !p.local).map(toOption)
  }];
  // Text providers: Off, then the rewrite-capable AIs. A provider in the text role uses its chat
  // model, so show its `rewriteLabel` (e.g. "OpenAI (GPT-4o mini)") instead of the image label.
  const toTextOption = p => ({
    ...toOption(p),
    label: p.rewriteLabel || p.label
  });
  const textGroups = [{
    title: intl.formatMessage(msgs.groupRewrite),
    items: [{
      id: "none",
      label: intl.formatMessage(msgs.unset),
      description: intl.formatMessage(msgs.unsetDesc)
    }, ...rewriteProviders().map(toTextOption)]
  }];
  const pickImage = id => setSettings(s => ({
    ...s,
    provider: id,
    mode: providerMode(id)
  }));
  const pickText = id => setSettings(s => ({
    ...s,
    rewriteProvider: id
  }));
  const pickUpscale = id => setSettings(s => ({
    ...s,
    upscaleProvider: id
  }));
  return /*#__PURE__*/_jsxDEV("div", {
    className: "provider-select providers-menu",
    children: [/*#__PURE__*/_jsxDEV("button", {
      className: "ps-trigger",
      onClick: () => setOpen(o => !o),
      title: intl.formatMessage(msgs.providers),
      "aria-haspopup": "dialog",
      "aria-expanded": open,
      children: [/*#__PURE__*/_jsxDEV("span", {
        className: "provider-select-label",
        children: intl.formatMessage(msgs.providers)
      }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
        className: "ps-current",
        children: image?.label
      }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
        className: "ps-caret",
        children: "▾"
      }, void 0, false)]
    }, void 0, true), open && /*#__PURE__*/_jsxDEV(_Fragment, {
      children: [/*#__PURE__*/_jsxDEV("div", {
        className: "ps-scrim",
        onClick: () => setOpen(false)
      }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
        className: "pm-pop",
        role: "dialog",
        "aria-label": intl.formatMessage(msgs.providers),
        children: [/*#__PURE__*/_jsxDEV("div", {
          className: "pm-row",
          children: [/*#__PURE__*/_jsxDEV(ProviderPicker, {
            label: intl.formatMessage(msgs.image),
            value: imageId,
            groups: imageGroups,
            onPick: pickImage
          }, void 0, false), /*#__PURE__*/_jsxDEV(ApiKeyField, {
            settings: settings,
            setSettings: setSettings,
            providerId: imageId
          }, void 0, false)]
        }, void 0, true), /*#__PURE__*/_jsxDEV("div", {
          className: "pm-row",
          children: [/*#__PURE__*/_jsxDEV(ProviderPicker, {
            label: intl.formatMessage(msgs.text),
            value: textId,
            groups: textGroups,
            onPick: pickText,
            hint: text ? intl.formatMessage(msgs.hintOn) : intl.formatMessage(msgs.hintOff)
          }, void 0, false), textId !== imageId ? /*#__PURE__*/_jsxDEV(ApiKeyField, {
            settings: settings,
            setSettings: setSettings,
            providerId: textId
          }, void 0, false) : text?.needsKey && /*#__PURE__*/_jsxDEV("span", {
            className: "pm-shared",
            children: intl.formatMessage(msgs.sharesKey)
          }, void 0, false)]
        }, void 0, true), /*#__PURE__*/_jsxDEV("div", {
          className: "pm-row",
          children: [/*#__PURE__*/_jsxDEV(ProviderPicker, {
            label: intl.formatMessage(msgs.upscale),
            value: upscaleId,
            groups: upscaleGroups,
            onPick: pickUpscale,
            locked: ONLINE,
            lockReason: intl.formatMessage(msgs.upscaleLockedReason),
            hint: intl.formatMessage(msgs.upscaleHint)
          }, void 0, false), !ONLINE && (upscaleId !== "none" && upscaleId !== imageId && upscaleId !== textId ? /*#__PURE__*/_jsxDEV(ApiKeyField, {
            settings: settings,
            setSettings: setSettings,
            providerId: upscaleId
          }, void 0, false) : upscaleId !== "none" && getProvider(upscaleId)?.needsKey && /*#__PURE__*/_jsxDEV("span", {
            className: "pm-shared",
            children: intl.formatMessage(msgs.sharesKey)
          }, void 0, false))]
        }, void 0, true)]
      }, void 0, true)]
    }, void 0, true)]
  }, void 0, true);
}