Browse Source

feat: Improve SD list data and API integration

licoy 1 năm trước cách đây
mục cha
commit
a16725ac17

+ 1 - 1
app/components/chat.tsx

@@ -337,7 +337,7 @@ function ClearContextDivider() {
   );
 }
 
-function ChatAction(props: {
+export function ChatAction(props: {
   text: string;
   icon: JSX.Element;
   onClick: () => void;

+ 12 - 6
app/components/home.tsx

@@ -1,7 +1,5 @@
 "use client";
 
-import { Sd } from "@/app/components/sd";
-
 require("../polyfill");
 
 import { useState, useEffect } from "react";
@@ -32,6 +30,7 @@ import { getClientConfig } from "../config/client";
 import { ClientApi } from "../client/api";
 import { useAccessStore } from "../store";
 import { identifyDefaultClaudeModel } from "../utils/checkers";
+import { initDB } from "react-indexed-db-hook";
 
 export function Loading(props: { noLogo?: boolean }) {
   return (
@@ -58,6 +57,14 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
   loading: () => <Loading noLogo />,
 });
 
+const Sd = dynamic(async () => (await import("./sd")).Sd, {
+  loading: () => <Loading noLogo />,
+});
+
+const SdPanel = dynamic(async () => (await import("./sd-panel")).SdPanel, {
+  loading: () => <Loading noLogo />,
+});
+
 export function useSwitchTheme() {
   const config = useAppConfig();
 
@@ -128,7 +135,8 @@ const loadAsyncGoogleFont = () => {
 function Screen() {
   const config = useAppConfig();
   const location = useLocation();
-  const isHome = location.pathname === Path.Home;
+  const isHome =
+    location.pathname === Path.Home || location.pathname === Path.SdPanel;
   const isAuth = location.pathname === Path.Auth;
   const isMobileScreen = useMobileScreen();
   const shouldTightBorder =
@@ -137,7 +145,6 @@ function Screen() {
   useEffect(() => {
     loadAsyncGoogleFont();
   }, []);
-
   return (
     <div
       className={
@@ -154,7 +161,6 @@ function Screen() {
       ) : (
         <>
           <SideBar className={isHome ? styles["sidebar-show"] : ""} />
-
           <div className={styles["window-content"]} id={SlotID.AppBody}>
             <Routes>
               <Route path={Path.Home} element={<Chat />} />
@@ -162,6 +168,7 @@ function Screen() {
               <Route path={Path.Masks} element={<MaskPage />} />
               <Route path={Path.Chat} element={<Chat />} />
               <Route path={Path.Sd} element={<Sd />} />
+              <Route path={Path.SdPanel} element={<Sd />} />
               <Route path={Path.Settings} element={<Settings />} />
             </Routes>
           </div>
@@ -173,7 +180,6 @@ function Screen() {
 
 export function useLoadData() {
   const config = useAppConfig();
-
   var api: ClientApi;
   if (config.modelConfig.model.startsWith("gemini")) {
     api = new ClientApi(ModelProvider.GeminiPro);

+ 68 - 13
app/components/sd-panel.tsx

@@ -1,8 +1,14 @@
 import styles from "./sd-panel.module.scss";
 import React, { useState } from "react";
-import { Select } from "@/app/components/ui-lib";
+import { Select, showToast } from "@/app/components/ui-lib";
 import { IconButton } from "@/app/components/button";
 import locales from "@/app/locales";
+import { nanoid } from "nanoid";
+import { useIndexedDB } from "react-indexed-db-hook";
+import { StoreKey } from "@/app/constant";
+import { SdDbInit, sendSdTask, useSdStore } from "@/app/store/sd";
+
+SdDbInit();
 
 const sdCommonParams = (model: string, data: any) => {
   return [
@@ -89,7 +95,7 @@ const sdCommonParams = (model: string, data: any) => {
       name: locales.SdPanel.OutFormat,
       value: "output_format",
       type: "select",
-      default: 0,
+      default: "png",
       options: [
         { name: "PNG", value: "png" },
         { name: "JPEG", value: "jpeg" },
@@ -128,6 +134,7 @@ const models = [
 export function ControlParamItem(props: {
   title: string;
   subTitle?: string;
+  required?: boolean;
   children?: JSX.Element | JSX.Element[];
   className?: string;
 }) {
@@ -135,7 +142,10 @@ export function ControlParamItem(props: {
     <div className={styles["ctrl-param-item"] + ` ${props.className || ""}`}>
       <div className={styles["ctrl-param-item-header"]}>
         <div className={styles["ctrl-param-item-title"]}>
-          <div>{props.title}</div>
+          <div>
+            {props.title}
+            {props.required && <span style={{ color: "red" }}>*</span>}
+          </div>
         </div>
       </div>
       {props.children}
@@ -160,7 +170,11 @@ export function ControlParam(props: {
         switch (item.type) {
           case "textarea":
             element = (
-              <ControlParamItem title={item.name} subTitle={item.sub}>
+              <ControlParamItem
+                title={item.name}
+                subTitle={item.sub}
+                required={item.required}
+              >
                 <textarea
                   rows={item.rows || 3}
                   style={{ maxWidth: "100%", width: "100%", padding: "10px" }}
@@ -175,7 +189,11 @@ export function ControlParam(props: {
             break;
           case "select":
             element = (
-              <ControlParamItem title={item.name} subTitle={item.sub}>
+              <ControlParamItem
+                title={item.name}
+                subTitle={item.sub}
+                required={item.required}
+              >
                 <Select
                   value={props.data[item.value]}
                   onChange={(e) => {
@@ -195,7 +213,11 @@ export function ControlParam(props: {
             break;
           case "number":
             element = (
-              <ControlParamItem title={item.name} subTitle={item.sub}>
+              <ControlParamItem
+                title={item.name}
+                subTitle={item.sub}
+                required={item.required}
+              >
                 <input
                   type="number"
                   min={item.min}
@@ -210,7 +232,11 @@ export function ControlParam(props: {
             break;
           default:
             element = (
-              <ControlParamItem title={item.name} subTitle={item.sub}>
+              <ControlParamItem
+                title={item.name}
+                subTitle={item.sub}
+                required={item.required}
+              >
                 <input
                   type="text"
                   value={props.data[item.value]}
@@ -260,14 +286,43 @@ export function SdPanel() {
     setCurrentModel(model);
     setParams(getModelParamBasicData(model.params({}), params));
   };
+  const sdListDb = useIndexedDB(StoreKey.SdList);
+  const { execCountInc } = useSdStore();
   const handleSubmit = () => {
     const columns = currentModel.params(params);
-    const reqData: any = {};
-    columns.forEach((item: any) => {
-      reqData[item.value] = params[item.value] ?? null;
-    });
-    console.log(JSON.stringify(reqData, null, 4));
-    setParams(getModelParamBasicData(columns, params, true));
+    const reqParams: any = {};
+    for (let i = 0; i < columns.length; i++) {
+      const item = columns[i];
+      reqParams[item.value] = params[item.value] ?? null;
+      if (item.required) {
+        if (!reqParams[item.value]) {
+          showToast(locales.SdPanel.ParamIsRequired(item.name));
+          return;
+        }
+      }
+    }
+    // console.log(JSON.stringify(reqParams, null, 4));
+    let data: any = {
+      model: currentModel.value,
+      model_name: currentModel.name,
+      status: "wait",
+      params: reqParams,
+      created_at: new Date().toISOString(),
+      img_data: "",
+    };
+    sdListDb.add(data).then(
+      (id) => {
+        data = { ...data, id, status: "running" };
+        sdListDb.update(data);
+        execCountInc();
+        sendSdTask(data, sdListDb, execCountInc);
+        setParams(getModelParamBasicData(columns, params, true));
+      },
+      (error) => {
+        console.error(error);
+        showToast(`error: ` + error.message);
+      },
+    );
   };
   return (
     <>

+ 53 - 0
app/components/sd.module.scss

@@ -0,0 +1,53 @@
+.sd-img-list{
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  .sd-img-item{
+    width: 48%;
+    .sd-img-item-info{
+      flex:1;
+      width: 100%;
+      overflow: hidden;
+      user-select: text;
+      p{
+        margin: 6px;
+        font-size: 12px;
+      }
+      .line-1{
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+    }
+    .pre-img{
+      display: flex;
+      width: 130px;
+      justify-content: center;
+      align-items: center;
+      background-color: var(--second);
+      border-radius: 10px;
+    }
+    .img{
+      width: 130px;
+      height: 130px;
+      border-radius: 10px;
+      overflow: hidden;
+      cursor: pointer;
+      transition: all .3s;
+      &:hover{
+        opacity: .7;
+      }
+    }
+    &:not(:last-child){
+      margin-bottom: 20px;
+    }
+  }
+}
+
+@media only screen and (max-width: 600px) {
+  .sd-img-list{
+    .sd-img-item{
+      width: 100%;
+    }
+  }
+}

+ 230 - 1
app/components/sd.tsx

@@ -1,3 +1,232 @@
+import chatStyles from "@/app/components/chat.module.scss";
+import styles from "@/app/components/sd.module.scss";
+import { IconButton } from "@/app/components/button";
+import ReturnIcon from "@/app/icons/return.svg";
+import Locale from "@/app/locales";
+import { Path, StoreKey } from "@/app/constant";
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import {
+  copyToClipboard,
+  getMessageTextContent,
+  useMobileScreen,
+  useWindowSize,
+} from "@/app/utils";
+import { useNavigate } from "react-router-dom";
+import { useAppConfig } from "@/app/store";
+import MinIcon from "@/app/icons/min.svg";
+import MaxIcon from "@/app/icons/max.svg";
+import { getClientConfig } from "@/app/config/client";
+import { ChatAction } from "@/app/components/chat";
+import DeleteIcon from "@/app/icons/clear.svg";
+import CopyIcon from "@/app/icons/copy.svg";
+import PromptIcon from "@/app/icons/prompt.svg";
+import ResetIcon from "@/app/icons/reload.svg";
+import { useIndexedDB } from "react-indexed-db-hook";
+import { useSdStore } from "@/app/store/sd";
+import locales from "@/app/locales";
+import LoadingIcon from "../icons/three-dots.svg";
+import ErrorIcon from "../icons/delete.svg";
+import { Property } from "csstype";
+import { showConfirm } from "@/app/components/ui-lib";
+
+function openBase64ImgUrl(base64Data: string, contentType: string) {
+  const byteCharacters = atob(base64Data);
+  const byteNumbers = new Array(byteCharacters.length);
+  for (let i = 0; i < byteCharacters.length; i++) {
+    byteNumbers[i] = byteCharacters.charCodeAt(i);
+  }
+  const byteArray = new Uint8Array(byteNumbers);
+  const blob = new Blob([byteArray], { type: contentType });
+  const blobUrl = URL.createObjectURL(blob);
+  window.open(blobUrl);
+}
+
+function getSdTaskStatus(item: any) {
+  let s: string;
+  let color: Property.Color | undefined = undefined;
+  switch (item.status) {
+    case "success":
+      s = Locale.Sd.Status.Success;
+      color = "green";
+      break;
+    case "error":
+      s = Locale.Sd.Status.Error;
+      color = "red";
+      break;
+    case "wait":
+      s = Locale.Sd.Status.Wait;
+      color = "yellow";
+      break;
+    case "running":
+      s = Locale.Sd.Status.Running;
+      color = "blue";
+      break;
+    default:
+      s = item.status.toUpperCase();
+  }
+  return (
+    <p className={styles["line-1"]} title={item.error} style={{ color: color }}>
+      <span>
+        {locales.Sd.Status.Name}: {s}
+      </span>
+      {item.status === "error" && <span> - {item.error}</span>}
+    </p>
+  );
+}
+
 export function Sd() {
-  return <div>sd</div>;
+  const isMobileScreen = useMobileScreen();
+  const navigate = useNavigate();
+  const clientConfig = useMemo(() => getClientConfig(), []);
+  const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
+  const config = useAppConfig();
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const sdListDb = useIndexedDB(StoreKey.SdList);
+  const [sdImages, setSdImages] = useState([]);
+  const { execCount } = useSdStore();
+
+  useEffect(() => {
+    sdListDb.getAll().then((data) => {
+      setSdImages(((data as never[]) || []).reverse());
+    });
+  }, [execCount]);
+
+  return (
+    <div className={chatStyles.chat} key={"1"}>
+      <div className="window-header" data-tauri-drag-region>
+        {isMobileScreen && (
+          <div className="window-actions">
+            <div className={"window-action-button"}>
+              <IconButton
+                icon={<ReturnIcon />}
+                bordered
+                title={Locale.Chat.Actions.ChatList}
+                onClick={() => navigate(Path.SdPanel)}
+              />
+            </div>
+          </div>
+        )}
+        <div className={`window-header-title ${chatStyles["chat-body-title"]}`}>
+          <div className={`window-header-main-title`}>Stability AI</div>
+          <div className="window-header-sub-title">
+            {Locale.Sd.SubTitle(sdImages.length || 0)}
+          </div>
+        </div>
+
+        <div className="window-actions">
+          {showMaxIcon && (
+            <div className="window-action-button">
+              <IconButton
+                icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
+                bordered
+                onClick={() => {
+                  config.update(
+                    (config) => (config.tightBorder = !config.tightBorder),
+                  );
+                }}
+              />
+            </div>
+          )}
+        </div>
+      </div>
+      <div className={chatStyles["chat-body"]} ref={scrollRef}>
+        <div className={styles["sd-img-list"]}>
+          {sdImages.length > 0 ? (
+            sdImages.map((item: any) => {
+              return (
+                <div
+                  key={item.id}
+                  style={{ display: "flex" }}
+                  className={styles["sd-img-item"]}
+                >
+                  {item.status === "success" ? (
+                    <img
+                      className={styles["img"]}
+                      src={`data:image/png;base64,${item.img_data}`}
+                      alt={`${item.id}`}
+                      onClick={(e) => {
+                        openBase64ImgUrl(item.img_data, "image/png");
+                      }}
+                    />
+                  ) : item.status === "error" ? (
+                    <div className={styles["pre-img"]}>
+                      <ErrorIcon />
+                    </div>
+                  ) : (
+                    <div className={styles["pre-img"]}>
+                      <LoadingIcon />
+                    </div>
+                  )}
+                  <div
+                    style={{ marginLeft: "10px" }}
+                    className={styles["sd-img-item-info"]}
+                  >
+                    <p className={styles["line-1"]}>
+                      {locales.SdPanel.Prompt}:{" "}
+                      <span title={item.params.prompt}>
+                        {item.params.prompt}
+                      </span>
+                    </p>
+                    <p>
+                      {locales.SdPanel.AIModel}: {item.model_name}
+                    </p>
+                    {getSdTaskStatus(item)}
+                    <p>{item.created_at}</p>
+                    <div className={chatStyles["chat-message-actions"]}>
+                      <div className={chatStyles["chat-input-actions"]}>
+                        <ChatAction
+                          text={Locale.Sd.Actions.Params}
+                          icon={<PromptIcon />}
+                          onClick={() => console.log(1)}
+                        />
+                        <ChatAction
+                          text={Locale.Sd.Actions.Copy}
+                          icon={<CopyIcon />}
+                          onClick={() =>
+                            copyToClipboard(
+                              getMessageTextContent({
+                                role: "user",
+                                content: item.params.prompt,
+                              }),
+                            )
+                          }
+                        />
+                        <ChatAction
+                          text={Locale.Sd.Actions.Retry}
+                          icon={<ResetIcon />}
+                          onClick={() => console.log(1)}
+                        />
+                        <ChatAction
+                          text={Locale.Sd.Actions.Delete}
+                          icon={<DeleteIcon />}
+                          onClick={async () => {
+                            if (await showConfirm(Locale.Sd.Danger.Delete)) {
+                              sdListDb.deleteRecord(item.id).then(
+                                () => {
+                                  setSdImages(
+                                    sdImages.filter(
+                                      (i: any) => i.id !== item.id,
+                                    ),
+                                  );
+                                },
+                                (error) => {
+                                  console.error(error);
+                                },
+                              );
+                            }
+                          }}
+                        />
+                      </div>
+                    </div>
+                  </div>
+                </div>
+              );
+            })
+          ) : (
+            <div>{locales.Sd.EmptyRecord}</div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
 }

+ 13 - 10
app/components/sidebar.tsx

@@ -155,6 +155,7 @@ export function SideBar(props: { className?: string }) {
   let isChat: boolean = false;
   switch (location.pathname) {
     case Path.Sd:
+    case Path.SdPanel:
       bodyComponent = <SdPanel />;
       break;
     default:
@@ -220,16 +221,18 @@ export function SideBar(props: { className?: string }) {
 
       <div className={styles["sidebar-tail"]}>
         <div className={styles["sidebar-actions"]}>
-          <div className={styles["sidebar-action"] + " " + styles.mobile}>
-            <IconButton
-              icon={<DeleteIcon />}
-              onClick={async () => {
-                if (await showConfirm(Locale.Home.DeleteChat)) {
-                  chatStore.deleteSession(chatStore.currentSessionIndex);
-                }
-              }}
-            />
-          </div>
+          {isChat && (
+            <div className={styles["sidebar-action"] + " " + styles.mobile}>
+              <IconButton
+                icon={<DeleteIcon />}
+                onClick={async () => {
+                  if (await showConfirm(Locale.Home.DeleteChat)) {
+                    chatStore.deleteSession(chatStore.currentSessionIndex);
+                  }
+                }}
+              />
+            </div>
+          )}
           <div className={styles["sidebar-action"]}>
             <Link to={Path.Settings}>
               <IconButton icon={<SettingsIcon />} shadow />

+ 1 - 1
app/config/client.ts

@@ -3,7 +3,7 @@ import { BuildConfig, getBuildConfig } from "./build";
 export function getClientConfig() {
   if (typeof document !== "undefined") {
     // client side
-    return JSON.parse(queryMeta("config")) as BuildConfig;
+    return JSON.parse(queryMeta("config") || "{}") as BuildConfig;
   }
 
   if (typeof process !== "undefined") {

+ 2 - 0
app/constant.ts

@@ -22,6 +22,7 @@ export enum Path {
   Masks = "/masks",
   Auth = "/auth",
   Sd = "/sd",
+  SdPanel = "/sd-panel",
 }
 
 export enum ApiPath {
@@ -48,6 +49,7 @@ export enum StoreKey {
   Prompt = "prompt-store",
   Update = "chat-update",
   Sync = "sync",
+  SdList = "sd-list",
 }
 
 export const DEFAULT_SIDEBAR_WIDTH = 300;

+ 21 - 0
app/locales/cn.ts

@@ -494,6 +494,7 @@ const cn = {
     AIModel: "AI模型",
     ModelVersion: "模型版本",
     Submit: "提交生成",
+    ParamIsRequired: (name: string) => `${name}不能为空`,
     Styles: {
       D3Model: "3D模型",
       AnalogFilm: "模拟电影",
@@ -514,6 +515,26 @@ const cn = {
       TileTexture: "贴图",
     },
   },
+  Sd: {
+    SubTitle: (count: number) => `共 ${count} 条绘画`,
+    Actions: {
+      Params: "查看参数",
+      Copy: "复制提示词",
+      Delete: "删除",
+      Retry: "重试",
+    },
+    EmptyRecord: "暂无绘画记录",
+    Status: {
+      Name: "状态",
+      Success: "成功",
+      Error: "失败",
+      Wait: "等待中",
+      Running: "运行中",
+    },
+    Danger: {
+      Delete: "确认删除?",
+    },
+  },
 };
 
 type DeepPartial<T> = T extends object

+ 21 - 0
app/locales/en.ts

@@ -500,6 +500,7 @@ const en: LocaleType = {
     AIModel: "AI Model",
     ModelVersion: "Model Version",
     Submit: "Submit",
+    ParamIsRequired: (name: string) => `${name} is required`,
     Styles: {
       D3Model: "3d-model",
       AnalogFilm: "analog-film",
@@ -520,6 +521,26 @@ const en: LocaleType = {
       TileTexture: "tile-texture",
     },
   },
+  Sd: {
+    SubTitle: (count: number) => `${count} images`,
+    Actions: {
+      Params: "See Params",
+      Copy: "Copy Prompt",
+      Delete: "Delete",
+      Retry: "Retry",
+    },
+    EmptyRecord: "No images yet",
+    Status: {
+      Name: "Status",
+      Success: "Success",
+      Error: "Error",
+      Wait: "Waiting",
+      Running: "Running",
+    },
+    Danger: {
+      Delete: "Confirm to delete?",
+    },
+  },
 };
 
 export default en;

+ 1 - 0
app/page.tsx

@@ -3,6 +3,7 @@ import { Analytics } from "@vercel/analytics/react";
 import { Home } from "./components/home";
 
 import { getServerSideConfig } from "./config/server";
+import { SdDbInit } from "@/app/store/sd";
 
 const serverConfig = getServerSideConfig();
 

+ 78 - 0
app/store/sd.ts

@@ -0,0 +1,78 @@
+import { initDB, useIndexedDB } from "react-indexed-db-hook";
+import { StoreKey } from "@/app/constant";
+import { create, StoreApi } from "zustand";
+
+export const SdDbConfig = {
+  name: "@chatgpt-next-web/sd",
+  version: 1,
+  objectStoresMeta: [
+    {
+      store: StoreKey.SdList,
+      storeConfig: { keyPath: "id", autoIncrement: true },
+      storeSchema: [
+        { name: "model", keypath: "model", options: { unique: false } },
+        {
+          name: "model_name",
+          keypath: "model_name",
+          options: { unique: false },
+        },
+        { name: "status", keypath: "status", options: { unique: false } },
+        { name: "params", keypath: "params", options: { unique: false } },
+        { name: "img_data", keypath: "img_data", options: { unique: false } },
+        { name: "error", keypath: "error", options: { unique: false } },
+        {
+          name: "created_at",
+          keypath: "created_at",
+          options: { unique: false },
+        },
+      ],
+    },
+  ],
+};
+
+export function SdDbInit() {
+  initDB(SdDbConfig);
+}
+
+type SdStore = {
+  execCount: number;
+  execCountInc: () => void;
+};
+
+export const useSdStore = create<SdStore>()((set) => ({
+  execCount: 1,
+  execCountInc: () => set((state) => ({ execCount: state.execCount + 1 })),
+}));
+
+export function sendSdTask(data: any, db: any, inc: any) {
+  const formData = new FormData();
+  for (let paramsKey in data.params) {
+    formData.append(paramsKey, data.params[paramsKey]);
+  }
+  fetch("https://api.stability.ai/v2beta/stable-image/generate/" + data.model, {
+    method: "POST",
+    headers: {
+      Accept: "application/json",
+    },
+    body: formData,
+  })
+    .then((response) => response.json())
+    .then((resData) => {
+      if (resData.errors && resData.errors.length > 0) {
+        db.update({ ...data, status: "error", error: resData.errors[0] });
+        inc();
+        return;
+      }
+      if (resData.finish_reason === "SUCCESS") {
+        db.update({ ...data, status: "success", img_data: resData.image });
+      } else {
+        db.update({ ...data, status: "error", error: JSON.stringify(resData) });
+      }
+      inc();
+    })
+    .catch((error) => {
+      db.update({ ...data, status: "error", error: error.message });
+      console.error("Error:", error);
+      inc();
+    });
+}

+ 1 - 0
package.json

@@ -32,6 +32,7 @@
     "node-fetch": "^3.3.1",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
+    "react-indexed-db-hook": "^1.0.14",
     "react-markdown": "^8.0.7",
     "react-router-dom": "^6.15.0",
     "rehype-highlight": "^6.0.0",

+ 5 - 0
yarn.lock

@@ -5110,6 +5110,11 @@ react-dom@^18.2.0:
     loose-envify "^1.1.0"
     scheduler "^0.23.0"
 
+react-indexed-db-hook@^1.0.14:
+  version "1.0.14"
+  resolved "https://registry.npmmirror.com/react-indexed-db-hook/-/react-indexed-db-hook-1.0.14.tgz#a29cd732d592735b6a68dfc94316b7a4a091e6be"
+  integrity sha512-tQ6rWofgXUCBhZp9pRpWzthzPbjqcll5uXMo07lbQTKl47VyL9nw9wfVswRxxzS5yj5Sq/VHUkNUjamWbA/M/w==
+
 react-is@^16.13.1, react-is@^16.7.0:
   version "16.13.1"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"