Преглед на файлове

Merge branch 'feature-artifacts' of https://github.com/ConnectAI-E/ChatGPT-Next-Web into feature/artifacts-style

Dogtiti преди 1 година
родител
ревизия
51e8f0440d

+ 8 - 0
README.md

@@ -326,6 +326,14 @@ You can use this option if you want to increase the number of webdav service add
 
 Customize the default template used to initialize the User Input Preprocessing configuration item in Settings.
 
+### `STABILITY_API_KEY` (optional)
+
+Stability API key.
+
+### `STABILITY_URL` (optional)
+
+Customize Stability API url.
+
 ## Requirements
 
 NodeJS >= 18, Docker >= 20

+ 9 - 0
README_CN.md

@@ -218,6 +218,15 @@ ByteDance Api Url.
 
 自定义默认的 template,用于初始化『设置』中的『用户输入预处理』配置项
 
+### `STABILITY_API_KEY` (optional)
+
+Stability API密钥
+
+### `STABILITY_URL` (optional)
+
+自定义的Stability API请求地址
+
+
 ## 开发
 
 点击下方按钮,开始二次开发:

+ 25 - 6
app/api/artifact/route.ts

@@ -4,18 +4,37 @@ import { getServerSideConfig } from "@/app/config/server";
 
 async function handle(req: NextRequest, res: NextResponse) {
   const serverConfig = getServerSideConfig();
-  const storeUrl = (key: string) =>
-    `https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}/values/${key}`;
+  const storeUrl = () =>
+    `https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}`;
   const storeHeaders = () => ({
     Authorization: `Bearer ${serverConfig.cloudflareKVApiKey}`,
   });
   if (req.method === "POST") {
     const clonedBody = await req.text();
     const hashedCode = md5.hash(clonedBody).trim();
-    const res = await fetch(storeUrl(hashedCode), {
-      headers: storeHeaders(),
+    const body: {
+      key: string;
+      value: string;
+      expiration_ttl?: number;
+    } = {
+      key: hashedCode,
+      value: clonedBody,
+    };
+    try {
+      const ttl = parseInt(serverConfig.cloudflareKVTTL as string);
+      if (ttl > 60) {
+        body["expiration_ttl"] = ttl;
+      }
+    } catch (e) {
+      console.error(e);
+    }
+    const res = await fetch(`${storeUrl()}/bulk`, {
+      headers: {
+        ...storeHeaders(),
+        "Content-Type": "application/json",
+      },
       method: "PUT",
-      body: clonedBody,
+      body: JSON.stringify([body]),
     });
     const result = await res.json();
     console.log("save data", result);
@@ -32,7 +51,7 @@ async function handle(req: NextRequest, res: NextResponse) {
   }
   if (req.method === "GET") {
     const id = req?.nextUrl?.searchParams?.get("id");
-    const res = await fetch(storeUrl(id as string), {
+    const res = await fetch(`${storeUrl()}/values/${id}`, {
       headers: storeHeaders(),
       method: "GET",
     });

+ 3 - 0
app/api/auth.ts

@@ -67,6 +67,9 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
     let systemApiKey: string | undefined;
 
     switch (modelProvider) {
+      case ModelProvider.Stability:
+        systemApiKey = serverConfig.stabilityApiKey;
+        break;
       case ModelProvider.GeminiPro:
         systemApiKey = serverConfig.googleApiKey;
         break;

+ 104 - 0
app/api/stability/[...path]/route.ts

@@ -0,0 +1,104 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSideConfig } from "@/app/config/server";
+import { ModelProvider, STABILITY_BASE_URL } from "@/app/constant";
+import { auth } from "@/app/api/auth";
+
+async function handle(
+  req: NextRequest,
+  { params }: { params: { path: string[] } },
+) {
+  console.log("[Stability] params ", params);
+
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+
+  const controller = new AbortController();
+
+  const serverConfig = getServerSideConfig();
+
+  let baseUrl = serverConfig.stabilityUrl || STABILITY_BASE_URL;
+
+  if (!baseUrl.startsWith("http")) {
+    baseUrl = `https://${baseUrl}`;
+  }
+
+  if (baseUrl.endsWith("/")) {
+    baseUrl = baseUrl.slice(0, -1);
+  }
+
+  let path = `${req.nextUrl.pathname}`.replaceAll("/api/stability/", "");
+
+  console.log("[Stability Proxy] ", path);
+  console.log("[Stability Base Url]", baseUrl);
+
+  const timeoutId = setTimeout(
+    () => {
+      controller.abort();
+    },
+    10 * 60 * 1000,
+  );
+
+  const authResult = auth(req, ModelProvider.Stability);
+
+  if (authResult.error) {
+    return NextResponse.json(authResult, {
+      status: 401,
+    });
+  }
+
+  const bearToken = req.headers.get("Authorization") ?? "";
+  const token = bearToken.trim().replaceAll("Bearer ", "").trim();
+
+  const key = token ? token : serverConfig.stabilityApiKey;
+
+  if (!key) {
+    return NextResponse.json(
+      {
+        error: true,
+        message: `missing STABILITY_API_KEY in server env vars`,
+      },
+      {
+        status: 401,
+      },
+    );
+  }
+
+  const fetchUrl = `${baseUrl}/${path}`;
+  console.log("[Stability Url] ", fetchUrl);
+  const fetchOptions: RequestInit = {
+    headers: {
+      "Content-Type": req.headers.get("Content-Type") || "multipart/form-data",
+      Accept: req.headers.get("Accept") || "application/json",
+      Authorization: `Bearer ${key}`,
+    },
+    method: req.method,
+    body: req.body,
+    // to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
+    redirect: "manual",
+    // @ts-ignore
+    duplex: "half",
+    signal: controller.signal,
+  };
+
+  try {
+    const res = await fetch(fetchUrl, fetchOptions);
+    // to prevent browser prompt for credentials
+    const newHeaders = new Headers(res.headers);
+    newHeaders.delete("www-authenticate");
+    // to disable nginx buffering
+    newHeaders.set("X-Accel-Buffering", "no");
+    return new Response(res.body, {
+      status: res.status,
+      statusText: res.statusText,
+      headers: newHeaders,
+    });
+  } finally {
+    clearTimeout(timeoutId);
+  }
+}
+
+export const GET = handle;
+export const POST = handle;
+
+export const runtime = "edge";

+ 6 - 2
app/api/webdav/[...path]/route.ts

@@ -37,9 +37,13 @@ async function handle(
       const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint);
       const normalizedEndpoint = normalizeUrl(endpoint as string);
 
-      return normalizedEndpoint &&
+      return (
+        normalizedEndpoint &&
         normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname &&
-        normalizedEndpoint.pathname.startsWith(normalizedAllowedEndpoint.pathname);
+        normalizedEndpoint.pathname.startsWith(
+          normalizedAllowedEndpoint.pathname,
+        )
+      );
     })
   ) {
     return NextResponse.json(

+ 13 - 9
app/client/api.ts

@@ -168,6 +168,19 @@ export class ClientApi {
   }
 }
 
+export function getBearerToken(
+  apiKey: string,
+  noBearer: boolean = false,
+): string {
+  return validString(apiKey)
+    ? `${noBearer ? "" : "Bearer "}${apiKey.trim()}`
+    : "";
+}
+
+export function validString(x: string): boolean {
+  return x?.length > 0;
+}
+
 export function getHeaders() {
   const accessStore = useAccessStore.getState();
   const chatStore = useChatStore.getState();
@@ -214,15 +227,6 @@ export function getHeaders() {
     return isAzure ? "api-key" : isAnthropic ? "x-api-key" : "Authorization";
   }
 
-  function getBearerToken(apiKey: string, noBearer: boolean = false): string {
-    return validString(apiKey)
-      ? `${noBearer ? "" : "Bearer "}${apiKey.trim()}`
-      : "";
-  }
-
-  function validString(x: string): boolean {
-    return x?.length > 0;
-  }
   const {
     isGoogle,
     isAzure,

+ 17 - 4
app/components/artifact.tsx

@@ -34,14 +34,18 @@ export function HTMLPreview(props: {
    */
 
   useEffect(() => {
-    window.addEventListener("message", (e) => {
+    const handleMessage = (e: any) => {
       const { id, height, title } = e.data;
       setTitle(title);
       if (id == frameId.current) {
         setIframeHeight(height);
       }
-    });
-  }, [iframeHeight]);
+    };
+    window.addEventListener("message", handleMessage);
+    return () => {
+      window.removeEventListener("message", handleMessage);
+    };
+  }, []);
 
   const height = useMemo(() => {
     const parentHeight = props.height || 600;
@@ -186,8 +190,17 @@ export function Artifact() {
   useEffect(() => {
     if (id) {
       fetch(`${ApiPath.Artifact}?id=${id}`)
+        .then((res) => {
+          if (res.status > 300) {
+            throw Error("can not get content");
+          }
+          return res;
+        })
         .then((res) => res.text())
-        .then(setCode);
+        .then(setCode)
+        .catch((e) => {
+          showToast(Locale.Export.Artifact.Error);
+        });
     }
   }, [id]);
 

+ 3 - 0
app/components/button.tsx

@@ -1,6 +1,7 @@
 import * as React from "react";
 
 import styles from "./button.module.scss";
+import { CSSProperties } from "react";
 
 export type ButtonType = "primary" | "danger" | null;
 
@@ -16,6 +17,7 @@ export function IconButton(props: {
   disabled?: boolean;
   tabIndex?: number;
   autoFocus?: boolean;
+  style?: CSSProperties;
 }) {
   return (
     <button
@@ -31,6 +33,7 @@ export function IconButton(props: {
       role="button"
       tabIndex={props.tabIndex}
       autoFocus={props.autoFocus}
+      style={props.style}
     >
       {props.icon && (
         <div

+ 1 - 1
app/components/chat.tsx

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

+ 2 - 0
app/components/error.tsx

@@ -1,3 +1,5 @@
+"use client";
+
 import React from "react";
 import { IconButton } from "./button";
 import GithubIcon from "../icons/github.svg";

+ 38 - 25
app/components/home.tsx

@@ -59,6 +59,10 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
   loading: () => <Loading noLogo />,
 });
 
+const Sd = dynamic(async () => (await import("./sd")).Sd, {
+  loading: () => <Loading noLogo />,
+});
+
 export function useSwitchTheme() {
   const config = useAppConfig();
 
@@ -126,12 +130,23 @@ const loadAsyncGoogleFont = () => {
   document.head.appendChild(linkEl);
 };
 
+export function WindowContent(props: { children: React.ReactNode }) {
+  return (
+    <div className={styles["window-content"]} id={SlotID.AppBody}>
+      {props?.children}
+    </div>
+  );
+}
+
 function Screen() {
   const config = useAppConfig();
   const location = useLocation();
   const isArtifact = location.pathname.includes(Path.Artifact);
   const isHome = location.pathname === Path.Home;
   const isAuth = location.pathname === Path.Auth;
+  const isSd = location.pathname === Path.Sd;
+  const isSdNew = location.pathname === Path.SdNew;
+
   const isMobileScreen = useMobileScreen();
   const shouldTightBorder =
     getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
@@ -147,35 +162,33 @@ function Screen() {
       </Routes>
     );
   }
+  const renderContent = () => {
+    if (isAuth) return <AuthPage />;
+    if (isSd) return <Sd />;
+    if (isSdNew) return <Sd />;
+    return (
+      <>
+        <SideBar className={isHome ? styles["sidebar-show"] : ""} />
+        <WindowContent>
+          <Routes>
+            <Route path={Path.Home} element={<Chat />} />
+            <Route path={Path.NewChat} element={<NewChat />} />
+            <Route path={Path.Masks} element={<MaskPage />} />
+            <Route path={Path.Chat} element={<Chat />} />
+            <Route path={Path.Settings} element={<Settings />} />
+          </Routes>
+        </WindowContent>
+      </>
+    );
+  };
 
   return (
     <div
-      className={
-        styles.container +
-        ` ${shouldTightBorder ? styles["tight-container"] : styles.container} ${
-          getLang() === "ar" ? styles["rtl-screen"] : ""
-        }`
-      }
+      className={`${styles.container} ${
+        shouldTightBorder ? styles["tight-container"] : styles.container
+      } ${getLang() === "ar" ? styles["rtl-screen"] : ""}`}
     >
-      {isAuth ? (
-        <>
-          <AuthPage />
-        </>
-      ) : (
-        <>
-          <SideBar className={isHome ? styles["sidebar-show"] : ""} />
-
-          <div className={styles["window-content"]} id={SlotID.AppBody}>
-            <Routes>
-              <Route path={Path.Home} element={<Chat />} />
-              <Route path={Path.NewChat} element={<NewChat />} />
-              <Route path={Path.Masks} element={<MaskPage />} />
-              <Route path={Path.Chat} element={<Chat />} />
-              <Route path={Path.Settings} element={<Settings />} />
-            </Routes>
-          </div>
-        </>
-      )}
+      {renderContent()}
     </div>
   );
 }

+ 2 - 0
app/components/sd/index.tsx

@@ -0,0 +1,2 @@
+export * from "./sd";
+export * from "./sd-panel";

+ 45 - 0
app/components/sd/sd-panel.module.scss

@@ -0,0 +1,45 @@
+.ctrl-param-item {
+  display: flex;
+  justify-content: space-between;
+  min-height: 40px;
+  padding: 10px 0;
+  animation: slide-in ease 0.6s;
+  flex-direction: column;
+
+  .ctrl-param-item-header {
+    display: flex;
+    align-items: center;
+
+    .ctrl-param-item-title {
+      font-size: 14px;
+      font-weight: bolder;
+      margin-bottom: 5px;
+    }
+  }
+
+  .ctrl-param-item-sub-title {
+    font-size: 12px;
+    font-weight: normal;
+    margin-top: 3px;
+  }
+  textarea {
+    appearance: none;
+    border-radius: 10px;
+    border: var(--border-in-light);
+    min-height: 36px;
+    box-sizing: border-box;
+    background: var(--white);
+    color: var(--black);
+    padding: 0 10px;
+    max-width: 50%;
+    font-family: inherit;
+  }
+}
+
+.ai-models {
+  button {
+    margin-bottom: 10px;
+    padding: 10px;
+    width: 100%;
+  }
+}

+ 317 - 0
app/components/sd/sd-panel.tsx

@@ -0,0 +1,317 @@
+import styles from "./sd-panel.module.scss";
+import React from "react";
+import { Select } from "@/app/components/ui-lib";
+import { IconButton } from "@/app/components/button";
+import Locale from "@/app/locales";
+import { useSdStore } from "@/app/store/sd";
+
+export const params = [
+  {
+    name: Locale.SdPanel.Prompt,
+    value: "prompt",
+    type: "textarea",
+    placeholder: Locale.SdPanel.PleaseInput(Locale.SdPanel.Prompt),
+    required: true,
+  },
+  {
+    name: Locale.SdPanel.ModelVersion,
+    value: "model",
+    type: "select",
+    default: "sd3-medium",
+    support: ["sd3"],
+    options: [
+      { name: "SD3 Medium", value: "sd3-medium" },
+      { name: "SD3 Large", value: "sd3-large" },
+      { name: "SD3 Large Turbo", value: "sd3-large-turbo" },
+    ],
+  },
+  {
+    name: Locale.SdPanel.NegativePrompt,
+    value: "negative_prompt",
+    type: "textarea",
+    placeholder: Locale.SdPanel.PleaseInput(Locale.SdPanel.NegativePrompt),
+  },
+  {
+    name: Locale.SdPanel.AspectRatio,
+    value: "aspect_ratio",
+    type: "select",
+    default: "1:1",
+    options: [
+      { name: "1:1", value: "1:1" },
+      { name: "16:9", value: "16:9" },
+      { name: "21:9", value: "21:9" },
+      { name: "2:3", value: "2:3" },
+      { name: "3:2", value: "3:2" },
+      { name: "4:5", value: "4:5" },
+      { name: "5:4", value: "5:4" },
+      { name: "9:16", value: "9:16" },
+      { name: "9:21", value: "9:21" },
+    ],
+  },
+  {
+    name: Locale.SdPanel.ImageStyle,
+    value: "style",
+    type: "select",
+    default: "3d-model",
+    support: ["core"],
+    options: [
+      { name: Locale.SdPanel.Styles.D3Model, value: "3d-model" },
+      { name: Locale.SdPanel.Styles.AnalogFilm, value: "analog-film" },
+      { name: Locale.SdPanel.Styles.Anime, value: "anime" },
+      { name: Locale.SdPanel.Styles.Cinematic, value: "cinematic" },
+      { name: Locale.SdPanel.Styles.ComicBook, value: "comic-book" },
+      { name: Locale.SdPanel.Styles.DigitalArt, value: "digital-art" },
+      { name: Locale.SdPanel.Styles.Enhance, value: "enhance" },
+      { name: Locale.SdPanel.Styles.FantasyArt, value: "fantasy-art" },
+      { name: Locale.SdPanel.Styles.Isometric, value: "isometric" },
+      { name: Locale.SdPanel.Styles.LineArt, value: "line-art" },
+      { name: Locale.SdPanel.Styles.LowPoly, value: "low-poly" },
+      {
+        name: Locale.SdPanel.Styles.ModelingCompound,
+        value: "modeling-compound",
+      },
+      { name: Locale.SdPanel.Styles.NeonPunk, value: "neon-punk" },
+      { name: Locale.SdPanel.Styles.Origami, value: "origami" },
+      { name: Locale.SdPanel.Styles.Photographic, value: "photographic" },
+      { name: Locale.SdPanel.Styles.PixelArt, value: "pixel-art" },
+      { name: Locale.SdPanel.Styles.TileTexture, value: "tile-texture" },
+    ],
+  },
+  {
+    name: "Seed",
+    value: "seed",
+    type: "number",
+    default: 0,
+    min: 0,
+    max: 4294967294,
+  },
+  {
+    name: Locale.SdPanel.OutFormat,
+    value: "output_format",
+    type: "select",
+    default: "png",
+    options: [
+      { name: "PNG", value: "png" },
+      { name: "JPEG", value: "jpeg" },
+      { name: "WebP", value: "webp" },
+    ],
+  },
+];
+
+const sdCommonParams = (model: string, data: any) => {
+  return params.filter((item) => {
+    return !(item.support && !item.support.includes(model));
+  });
+};
+
+export const models = [
+  {
+    name: "Stable Image Ultra",
+    value: "ultra",
+    params: (data: any) => sdCommonParams("ultra", data),
+  },
+  {
+    name: "Stable Image Core",
+    value: "core",
+    params: (data: any) => sdCommonParams("core", data),
+  },
+  {
+    name: "Stable Diffusion 3",
+    value: "sd3",
+    params: (data: any) => {
+      return sdCommonParams("sd3", data).filter((item) => {
+        return !(
+          data.model === "sd3-large-turbo" && item.value == "negative_prompt"
+        );
+      });
+    },
+  },
+];
+
+export function ControlParamItem(props: {
+  title: string;
+  subTitle?: string;
+  required?: boolean;
+  children?: JSX.Element | JSX.Element[];
+  className?: string;
+}) {
+  return (
+    <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}
+            {props.required && <span style={{ color: "red" }}>*</span>}
+          </div>
+        </div>
+      </div>
+      {props.children}
+      {props.subTitle && (
+        <div className={styles["ctrl-param-item-sub-title"]}>
+          {props.subTitle}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export function ControlParam(props: {
+  columns: any[];
+  data: any;
+  onChange: (field: string, val: any) => void;
+}) {
+  return (
+    <>
+      {props.columns?.map((item) => {
+        let element: null | JSX.Element;
+        switch (item.type) {
+          case "textarea":
+            element = (
+              <ControlParamItem
+                title={item.name}
+                subTitle={item.sub}
+                required={item.required}
+              >
+                <textarea
+                  rows={item.rows || 3}
+                  style={{ maxWidth: "100%", width: "100%", padding: "10px" }}
+                  placeholder={item.placeholder}
+                  onChange={(e) => {
+                    props.onChange(item.value, e.currentTarget.value);
+                  }}
+                  value={props.data[item.value]}
+                ></textarea>
+              </ControlParamItem>
+            );
+            break;
+          case "select":
+            element = (
+              <ControlParamItem
+                title={item.name}
+                subTitle={item.sub}
+                required={item.required}
+              >
+                <Select
+                  value={props.data[item.value]}
+                  onChange={(e) => {
+                    props.onChange(item.value, e.currentTarget.value);
+                  }}
+                >
+                  {item.options.map((opt: any) => {
+                    return (
+                      <option value={opt.value} key={opt.value}>
+                        {opt.name}
+                      </option>
+                    );
+                  })}
+                </Select>
+              </ControlParamItem>
+            );
+            break;
+          case "number":
+            element = (
+              <ControlParamItem
+                title={item.name}
+                subTitle={item.sub}
+                required={item.required}
+              >
+                <input
+                  type="number"
+                  min={item.min}
+                  max={item.max}
+                  value={props.data[item.value] || 0}
+                  onChange={(e) => {
+                    props.onChange(item.value, parseInt(e.currentTarget.value));
+                  }}
+                />
+              </ControlParamItem>
+            );
+            break;
+          default:
+            element = (
+              <ControlParamItem
+                title={item.name}
+                subTitle={item.sub}
+                required={item.required}
+              >
+                <input
+                  type="text"
+                  value={props.data[item.value]}
+                  style={{ maxWidth: "100%", width: "100%" }}
+                  onChange={(e) => {
+                    props.onChange(item.value, e.currentTarget.value);
+                  }}
+                />
+              </ControlParamItem>
+            );
+        }
+        return <div key={item.value}>{element}</div>;
+      })}
+    </>
+  );
+}
+
+export const getModelParamBasicData = (
+  columns: any[],
+  data: any,
+  clearText?: boolean,
+) => {
+  const newParams: any = {};
+  columns.forEach((item: any) => {
+    if (clearText && ["text", "textarea", "number"].includes(item.type)) {
+      newParams[item.value] = item.default || "";
+    } else {
+      // @ts-ignore
+      newParams[item.value] = data[item.value] || item.default || "";
+    }
+  });
+  return newParams;
+};
+
+export const getParams = (model: any, params: any) => {
+  return models.find((m) => m.value === model.value)?.params(params) || [];
+};
+
+export function SdPanel() {
+  const sdStore = useSdStore();
+  const currentModel = sdStore.currentModel;
+  const setCurrentModel = sdStore.setCurrentModel;
+  const params = sdStore.currentParams;
+  const setParams = sdStore.setCurrentParams;
+
+  const handleValueChange = (field: string, val: any) => {
+    setParams({
+      ...params,
+      [field]: val,
+    });
+  };
+  const handleModelChange = (model: any) => {
+    setCurrentModel(model);
+    setParams(getModelParamBasicData(model.params({}), params));
+  };
+
+  return (
+    <>
+      <ControlParamItem title={Locale.SdPanel.AIModel}>
+        <div className={styles["ai-models"]}>
+          {models.map((item) => {
+            return (
+              <IconButton
+                text={item.name}
+                key={item.value}
+                type={currentModel.value == item.value ? "primary" : null}
+                shadow
+                onClick={() => handleModelChange(item)}
+              />
+            );
+          })}
+        </div>
+      </ControlParamItem>
+      <ControlParam
+        columns={getParams?.(currentModel, params) as any[]}
+        data={params}
+        onChange={handleValueChange}
+      ></ControlParam>
+    </>
+  );
+}

+ 140 - 0
app/components/sd/sd-sidebar.tsx

@@ -0,0 +1,140 @@
+import { IconButton } from "@/app/components/button";
+import GithubIcon from "@/app/icons/github.svg";
+import SDIcon from "@/app/icons/sd.svg";
+import ReturnIcon from "@/app/icons/return.svg";
+import HistoryIcon from "@/app/icons/history.svg";
+import Locale from "@/app/locales";
+
+import { Path, REPO_URL } from "@/app/constant";
+
+import { useNavigate } from "react-router-dom";
+import dynamic from "next/dynamic";
+import {
+  SideBarContainer,
+  SideBarBody,
+  SideBarHeader,
+  SideBarTail,
+  useDragSideBar,
+  useHotKey,
+} from "@/app/components/sidebar";
+
+import { getParams, getModelParamBasicData } from "./sd-panel";
+import { useSdStore } from "@/app/store/sd";
+import { showToast } from "@/app/components/ui-lib";
+import { useMobileScreen } from "@/app/utils";
+
+const SdPanel = dynamic(
+  async () => (await import("@/app/components/sd")).SdPanel,
+  {
+    loading: () => null,
+  },
+);
+
+export function SideBar(props: { className?: string }) {
+  useHotKey();
+  const isMobileScreen = useMobileScreen();
+  const { onDragStart, shouldNarrow } = useDragSideBar();
+  const navigate = useNavigate();
+  const sdStore = useSdStore();
+  const currentModel = sdStore.currentModel;
+  const params = sdStore.currentParams;
+  const setParams = sdStore.setCurrentParams;
+
+  const handleSubmit = () => {
+    const columns = getParams?.(currentModel, params);
+    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(Locale.SdPanel.ParamIsRequired(item.name));
+          return;
+        }
+      }
+    }
+    let data: any = {
+      model: currentModel.value,
+      model_name: currentModel.name,
+      status: "wait",
+      params: reqParams,
+      created_at: new Date().toLocaleString(),
+      img_data: "",
+    };
+    sdStore.sendTask(data, () => {
+      setParams(getModelParamBasicData(columns, params, true));
+      navigate(Path.SdNew);
+    });
+  };
+
+  return (
+    <SideBarContainer
+      onDragStart={onDragStart}
+      shouldNarrow={shouldNarrow}
+      {...props}
+    >
+      {isMobileScreen ? (
+        <div
+          className="window-header"
+          data-tauri-drag-region
+          style={{
+            paddingLeft: 0,
+            paddingRight: 0,
+          }}
+        >
+          <div className="window-actions">
+            <div className="window-action-button">
+              <IconButton
+                icon={<ReturnIcon />}
+                bordered
+                title={Locale.Sd.Actions.ReturnHome}
+                onClick={() => navigate(Path.Home)}
+              />
+            </div>
+          </div>
+          <SDIcon width={50} height={50} />
+          <div className="window-actions">
+            <div className="window-action-button">
+              <IconButton
+                icon={<HistoryIcon />}
+                bordered
+                title={Locale.Sd.Actions.History}
+                onClick={() => navigate(Path.SdNew)}
+              />
+            </div>
+          </div>
+        </div>
+      ) : (
+        <SideBarHeader
+          title={
+            <IconButton
+              icon={<ReturnIcon />}
+              bordered
+              title={Locale.Sd.Actions.ReturnHome}
+              onClick={() => navigate(Path.Home)}
+            />
+          }
+          logo={<SDIcon width={38} height={"100%"} />}
+        ></SideBarHeader>
+      )}
+      <SideBarBody>
+        <SdPanel />
+      </SideBarBody>
+      <SideBarTail
+        primaryAction={
+          <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
+            <IconButton icon={<GithubIcon />} shadow />
+          </a>
+        }
+        secondaryAction={
+          <IconButton
+            text={Locale.SdPanel.Submit}
+            type="primary"
+            shadow
+            onClick={handleSubmit}
+          ></IconButton>
+        }
+      />
+    </SideBarContainer>
+  );
+}

+ 53 - 0
app/components/sd/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%;
+    }
+  }
+}

+ 336 - 0
app/components/sd/sd.tsx

@@ -0,0 +1,336 @@
+import chatStyles from "@/app/components/chat.module.scss";
+import styles from "@/app/components/sd/sd.module.scss";
+import homeStyles from "@/app/components/home.module.scss";
+
+import { IconButton } from "@/app/components/button";
+import ReturnIcon from "@/app/icons/return.svg";
+import Locale from "@/app/locales";
+import { Path } from "@/app/constant";
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import {
+  copyToClipboard,
+  getMessageTextContent,
+  useMobileScreen,
+} from "@/app/utils";
+import { useNavigate, useLocation } 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 { useSdStore } from "@/app/store/sd";
+import locales from "@/app/locales";
+import LoadingIcon from "@/app/icons/three-dots.svg";
+import ErrorIcon from "@/app/icons/delete.svg";
+import SDIcon from "@/app/icons/sd.svg";
+import { Property } from "csstype";
+import {
+  showConfirm,
+  showImageModal,
+  showModal,
+} from "@/app/components/ui-lib";
+import { removeImage } from "@/app/utils/chat";
+import { SideBar } from "./sd-sidebar";
+import { WindowContent } from "@/app/components/home";
+import { params } from "./sd-panel";
+
+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
+          className="clickable"
+          onClick={() => {
+            showModal({
+              title: locales.Sd.Detail,
+              children: (
+                <div style={{ color: color, userSelect: "text" }}>
+                  {item.error}
+                </div>
+              ),
+            });
+          }}
+        >
+          - {item.error}
+        </span>
+      )}
+    </p>
+  );
+}
+
+export function Sd() {
+  const isMobileScreen = useMobileScreen();
+  const navigate = useNavigate();
+  const location = useLocation();
+  const clientConfig = useMemo(() => getClientConfig(), []);
+  const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
+  const config = useAppConfig();
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const sdStore = useSdStore();
+  const [sdImages, setSdImages] = useState(sdStore.draw);
+  const isSd = location.pathname === Path.Sd;
+
+  useEffect(() => {
+    setSdImages(sdStore.draw);
+  }, [sdStore.currentId]);
+
+  return (
+    <>
+      <SideBar className={isSd ? homeStyles["sidebar-show"] : ""} />
+      <WindowContent>
+        <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.Sd)}
+                  />
+                </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>
+              )}
+              {isMobileScreen && <SDIcon width={50} height={50} />}
+            </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={item.img_data}
+                          alt={item.id}
+                          onClick={(e) =>
+                            showImageModal(
+                              item.img_data,
+                              true,
+                              isMobileScreen
+                                ? { width: "100%", height: "fit-content" }
+                                : { maxWidth: "100%", maxHeight: "100%" },
+                              isMobileScreen
+                                ? { width: "100%", height: "fit-content" }
+                                : { width: "100%", height: "100%" },
+                            )
+                          }
+                        />
+                      ) : 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
+                            className="clickable"
+                            title={item.params.prompt}
+                            onClick={() => {
+                              showModal({
+                                title: locales.Sd.Detail,
+                                children: (
+                                  <div style={{ userSelect: "text" }}>
+                                    {item.params.prompt}
+                                  </div>
+                                ),
+                              });
+                            }}
+                          >
+                            {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={() => {
+                                showModal({
+                                  title: locales.Sd.GenerateParams,
+                                  children: (
+                                    <div style={{ userSelect: "text" }}>
+                                      {Object.keys(item.params).map((key) => {
+                                        let label = key;
+                                        let value = item.params[key];
+                                        switch (label) {
+                                          case "prompt":
+                                            label = Locale.SdPanel.Prompt;
+                                            break;
+                                          case "negative_prompt":
+                                            label =
+                                              Locale.SdPanel.NegativePrompt;
+                                            break;
+                                          case "aspect_ratio":
+                                            label = Locale.SdPanel.AspectRatio;
+                                            break;
+                                          case "seed":
+                                            label = "Seed";
+                                            value = value || 0;
+                                            break;
+                                          case "output_format":
+                                            label = Locale.SdPanel.OutFormat;
+                                            value = value?.toUpperCase();
+                                            break;
+                                          case "style":
+                                            label = Locale.SdPanel.ImageStyle;
+                                            value = params
+                                              .find(
+                                                (item) =>
+                                                  item.value === "style",
+                                              )
+                                              ?.options?.find(
+                                                (item) => item.value === value,
+                                              )?.name;
+                                            break;
+                                          default:
+                                            break;
+                                        }
+
+                                        return (
+                                          <div
+                                            key={key}
+                                            style={{ margin: "10px" }}
+                                          >
+                                            <strong>{label}: </strong>
+                                            {value}
+                                          </div>
+                                        );
+                                      })}
+                                    </div>
+                                  ),
+                                });
+                              }}
+                            />
+                            <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={() => {
+                                const reqData = {
+                                  model: item.model,
+                                  model_name: item.model_name,
+                                  status: "wait",
+                                  params: { ...item.params },
+                                  created_at: new Date().toLocaleString(),
+                                  img_data: "",
+                                };
+                                sdStore.sendTask(reqData);
+                              }}
+                            />
+                            <ChatAction
+                              text={Locale.Sd.Actions.Delete}
+                              icon={<DeleteIcon />}
+                              onClick={async () => {
+                                if (
+                                  await showConfirm(Locale.Sd.Danger.Delete)
+                                ) {
+                                  // remove img_data + remove item in list
+                                  removeImage(item.img_data).finally(() => {
+                                    sdStore.draw = sdImages.filter(
+                                      (i: any) => i.id !== item.id,
+                                    );
+                                    sdStore.getNextId();
+                                  });
+                                }
+                              }}
+                            />
+                          </div>
+                        </div>
+                      </div>
+                    </div>
+                  );
+                })
+              ) : (
+                <div>{locales.Sd.EmptyRecord}</div>
+              )}
+            </div>
+          </div>
+        </div>
+      </WindowContent>
+    </>
+  );
+}

+ 41 - 0
app/components/settings.tsx

@@ -65,6 +65,7 @@ import {
   ServiceProvider,
   SlotID,
   UPDATE_URL,
+  Stability,
 } from "../constant";
 import { Prompt, SearchService, usePromptStore } from "../store/prompt";
 import { ErrorBoundary } from "./error";
@@ -1041,6 +1042,45 @@ export function Settings() {
     </>
   );
 
+  const stabilityConfigComponent = accessStore.provider ===
+    ServiceProvider.Stability && (
+    <>
+      <ListItem
+        title={Locale.Settings.Access.Stability.Endpoint.Title}
+        subTitle={
+          Locale.Settings.Access.Stability.Endpoint.SubTitle +
+          Stability.ExampleEndpoint
+        }
+      >
+        <input
+          type="text"
+          value={accessStore.stabilityUrl}
+          placeholder={Stability.ExampleEndpoint}
+          onChange={(e) =>
+            accessStore.update(
+              (access) => (access.stabilityUrl = e.currentTarget.value),
+            )
+          }
+        ></input>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Access.Stability.ApiKey.Title}
+        subTitle={Locale.Settings.Access.Stability.ApiKey.SubTitle}
+      >
+        <PasswordInput
+          value={accessStore.stabilityApiKey}
+          type="text"
+          placeholder={Locale.Settings.Access.Stability.ApiKey.Placeholder}
+          onChange={(e) => {
+            accessStore.update(
+              (access) => (access.stabilityApiKey = e.currentTarget.value),
+            );
+          }}
+        />
+      </ListItem>
+    </>
+  );
+
   return (
     <ErrorBoundary>
       <div className="window-header" data-tauri-drag-region>
@@ -1324,6 +1364,7 @@ export function Settings() {
                   {baiduConfigComponent}
                   {byteDanceConfigComponent}
                   {alibabaConfigComponent}
+                  {stabilityConfigComponent}
                 </>
               )}
             </>

+ 160 - 84
app/components/sidebar.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useRef, useMemo } from "react";
+import React, { useEffect, useRef, useMemo, useState, Fragment } from "react";
 
 import styles from "./home.module.scss";
 
@@ -10,8 +10,8 @@ import AddIcon from "../icons/add.svg";
 import CloseIcon from "../icons/close.svg";
 import DeleteIcon from "../icons/delete.svg";
 import MaskIcon from "../icons/mask.svg";
-import PluginIcon from "../icons/plugin.svg";
 import DragIcon from "../icons/drag.svg";
+import DiscoveryIcon from "../icons/discovery.svg";
 
 import Locale from "../locales";
 
@@ -23,19 +23,20 @@ import {
   MIN_SIDEBAR_WIDTH,
   NARROW_SIDEBAR_WIDTH,
   Path,
+  PLUGINS,
   REPO_URL,
 } from "../constant";
 
 import { Link, useNavigate } from "react-router-dom";
 import { isIOS, useMobileScreen } from "../utils";
 import dynamic from "next/dynamic";
-import { showConfirm, showToast } from "./ui-lib";
+import { showConfirm, Selector } from "./ui-lib";
 
 const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
   loading: () => null,
 });
 
-function useHotKey() {
+export function useHotKey() {
   const chatStore = useChatStore();
 
   useEffect(() => {
@@ -54,7 +55,7 @@ function useHotKey() {
   });
 }
 
-function useDragSideBar() {
+export function useDragSideBar() {
   const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
 
   const config = useAppConfig();
@@ -127,25 +128,21 @@ function useDragSideBar() {
     shouldNarrow,
   };
 }
-
-export function SideBar(props: { className?: string }) {
-  const chatStore = useChatStore();
-
-  // drag side bar
-  const { onDragStart, shouldNarrow } = useDragSideBar();
-  const navigate = useNavigate();
-  const config = useAppConfig();
+export function SideBarContainer(props: {
+  children: React.ReactNode;
+  onDragStart: (e: MouseEvent) => void;
+  shouldNarrow: boolean;
+  className?: string;
+}) {
   const isMobileScreen = useMobileScreen();
   const isIOSMobile = useMemo(
     () => isIOS() && isMobileScreen,
     [isMobileScreen],
   );
-
-  useHotKey();
-
+  const { children, className, onDragStart, shouldNarrow } = props;
   return (
     <div
-      className={`${styles.sidebar} ${props.className} ${
+      className={`${styles.sidebar} ${className} ${
         shouldNarrow && styles["narrow-sidebar"]
       }`}
       style={{
@@ -153,43 +150,128 @@ export function SideBar(props: { className?: string }) {
         transition: isMobileScreen && isIOSMobile ? "none" : undefined,
       }}
     >
+      {children}
+      <div
+        className={styles["sidebar-drag"]}
+        onPointerDown={(e) => onDragStart(e as any)}
+      >
+        <DragIcon />
+      </div>
+    </div>
+  );
+}
+
+export function SideBarHeader(props: {
+  title?: string | React.ReactNode;
+  subTitle?: string | React.ReactNode;
+  logo?: React.ReactNode;
+  children?: React.ReactNode;
+}) {
+  const { title, subTitle, logo, children } = props;
+  return (
+    <Fragment>
       <div className={styles["sidebar-header"]} data-tauri-drag-region>
         <div className={styles["sidebar-title"]} data-tauri-drag-region>
-          NextChat
-        </div>
-        <div className={styles["sidebar-sub-title"]}>
-          Build your own AI assistant.
-        </div>
-        <div className={styles["sidebar-logo"] + " no-dark"}>
-          <ChatGptIcon />
+          {title}
         </div>
+        <div className={styles["sidebar-sub-title"]}>{subTitle}</div>
+        <div className={styles["sidebar-logo"] + " no-dark"}>{logo}</div>
       </div>
+      {children}
+    </Fragment>
+  );
+}
 
-      <div className={styles["sidebar-header-bar"]}>
-        <IconButton
-          icon={<MaskIcon />}
-          text={shouldNarrow ? undefined : Locale.Mask.Name}
-          className={styles["sidebar-bar-button"]}
-          onClick={() => {
-            if (config.dontShowMaskSplashScreen !== true) {
-              navigate(Path.NewChat, { state: { fromHome: true } });
-            } else {
-              navigate(Path.Masks, { state: { fromHome: true } });
-            }
-          }}
-          shadow
-        />
-        <IconButton
-          icon={<PluginIcon />}
-          text={shouldNarrow ? undefined : Locale.Plugin.Name}
-          className={styles["sidebar-bar-button"]}
-          onClick={() => showToast(Locale.WIP)}
-          shadow
-        />
-      </div>
+export function SideBarBody(props: {
+  children: React.ReactNode;
+  onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
+}) {
+  const { onClick, children } = props;
+  return (
+    <div className={styles["sidebar-body"]} onClick={onClick}>
+      {children}
+    </div>
+  );
+}
 
-      <div
-        className={styles["sidebar-body"]}
+export function SideBarTail(props: {
+  primaryAction?: React.ReactNode;
+  secondaryAction?: React.ReactNode;
+}) {
+  const { primaryAction, secondaryAction } = props;
+
+  return (
+    <div className={styles["sidebar-tail"]}>
+      <div className={styles["sidebar-actions"]}>{primaryAction}</div>
+      <div className={styles["sidebar-actions"]}>{secondaryAction}</div>
+    </div>
+  );
+}
+
+export function SideBar(props: { className?: string }) {
+  useHotKey();
+  const { onDragStart, shouldNarrow } = useDragSideBar();
+  const [showPluginSelector, setShowPluginSelector] = useState(false);
+  const navigate = useNavigate();
+  const config = useAppConfig();
+  const chatStore = useChatStore();
+
+  return (
+    <SideBarContainer
+      onDragStart={onDragStart}
+      shouldNarrow={shouldNarrow}
+      {...props}
+    >
+      <SideBarHeader
+        title="NextChat"
+        subTitle="Build your own AI assistant."
+        logo={<ChatGptIcon />}
+      >
+        <div className={styles["sidebar-header-bar"]}>
+          <IconButton
+            icon={<MaskIcon />}
+            text={shouldNarrow ? undefined : Locale.Mask.Name}
+            className={styles["sidebar-bar-button"]}
+            onClick={() => {
+              if (config.dontShowMaskSplashScreen !== true) {
+                navigate(Path.NewChat, { state: { fromHome: true } });
+              } else {
+                navigate(Path.Masks, { state: { fromHome: true } });
+              }
+            }}
+            shadow
+          />
+          <IconButton
+            icon={<DiscoveryIcon />}
+            text={shouldNarrow ? undefined : Locale.Discovery.Name}
+            className={styles["sidebar-bar-button"]}
+            onClick={() => setShowPluginSelector(true)}
+            shadow
+          />
+        </div>
+        {showPluginSelector && (
+          <Selector
+            items={[
+              {
+                title: "👇 Please select the plugin you need to use",
+                value: "-",
+                disable: true,
+              },
+              ...PLUGINS.map((item) => {
+                return {
+                  title: item.name,
+                  value: item.path,
+                };
+              }),
+            ]}
+            onClose={() => setShowPluginSelector(false)}
+            onSelection={(s) => {
+              navigate(s[0], { state: { fromHome: true } });
+            }}
+          />
+        )}
+      </SideBarHeader>
+      <SideBarBody
         onClick={(e) => {
           if (e.target === e.currentTarget) {
             navigate(Path.Home);
@@ -197,32 +279,33 @@ export function SideBar(props: { className?: string }) {
         }}
       >
         <ChatList narrow={shouldNarrow} />
-      </div>
-
-      <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>
-          <div className={styles["sidebar-action"]}>
-            <Link to={Path.Settings}>
-              <IconButton icon={<SettingsIcon />} shadow />
-            </Link>
-          </div>
-          <div className={styles["sidebar-action"]}>
-            <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
-              <IconButton icon={<GithubIcon />} shadow />
-            </a>
-          </div>
-        </div>
-        <div>
+      </SideBarBody>
+      <SideBarTail
+        primaryAction={
+          <>
+            <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 />
+              </Link>
+            </div>
+            <div className={styles["sidebar-action"]}>
+              <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
+                <IconButton icon={<GithubIcon />} shadow />
+              </a>
+            </div>
+          </>
+        }
+        secondaryAction={
           <IconButton
             icon={<AddIcon />}
             text={shouldNarrow ? undefined : Locale.Home.NewChat}
@@ -236,15 +319,8 @@ export function SideBar(props: { className?: string }) {
             }}
             shadow
           />
-        </div>
-      </div>
-
-      <div
-        className={styles["sidebar-drag"]}
-        onPointerDown={(e) => onDragStart(e as any)}
-      >
-        <DragIcon />
-      </div>
-    </div>
+        }
+      />
+    </SideBarContainer>
   );
 }

+ 17 - 0
app/components/ui-lib.module.scss

@@ -61,6 +61,19 @@
       font-weight: normal;
     }
   }
+
+  &.vertical{
+    flex-direction: column;
+    align-items: start;
+    .list-header{
+      .list-item-title{
+        margin-bottom: 5px;
+      }
+      .list-item-sub-title{
+        margin-bottom: 2px;
+      }
+    }
+  }
 }
 
 .list {
@@ -291,6 +304,10 @@
   justify-content: center;
   z-index: 999;
 
+  .selector-item-disabled{
+    opacity: 0.6;
+  }
+
   &-content {
     min-width: 300px;
     .list {

+ 40 - 15
app/components/ui-lib.tsx

@@ -14,7 +14,9 @@ import Locale from "../locales";
 
 import { createRoot } from "react-dom/client";
 import React, {
+  CSSProperties,
   HTMLProps,
+  MouseEvent,
   useEffect,
   useState,
   useCallback,
@@ -53,11 +55,16 @@ export function ListItem(props: {
   children?: JSX.Element | JSX.Element[];
   icon?: JSX.Element;
   className?: string;
-  onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
+  onClick?: (e: MouseEvent) => void;
+  vertical?: boolean;
 }) {
   return (
     <div
-      className={styles["list-item"] + ` ${props.className || ""}`}
+      className={
+        styles["list-item"] +
+        ` ${props.vertical ? styles["vertical"] : ""} ` +
+        ` ${props.className || ""}`
+      }
       onClick={props.onClick}
     >
       <div className={styles["list-header"]}>
@@ -426,17 +433,25 @@ export function showPrompt(content: any, value = "", rows = 3) {
   });
 }
 
-export function showImageModal(img: string) {
+export function showImageModal(
+  img: string,
+  defaultMax?: boolean,
+  style?: CSSProperties,
+  boxStyle?: CSSProperties,
+) {
   showModal({
     title: Locale.Export.Image.Modal,
+    defaultMax: defaultMax,
     children: (
-      <div>
+      <div style={{ display: "flex", justifyContent: "center", ...boxStyle }}>
         <img
           src={img}
           alt="preview"
-          style={{
-            maxWidth: "100%",
-          }}
+          style={
+            style ?? {
+              maxWidth: "100%",
+            }
+          }
         ></img>
       </div>
     ),
@@ -448,6 +463,7 @@ export function Selector<T>(props: {
     title: string;
     subTitle?: string;
     value: T;
+    disable?: boolean;
   }>;
   defaultSelectedValue?: T[] | T;
   onSelection?: (selection: T[]) => void;
@@ -462,10 +478,7 @@ export function Selector<T>(props: {
       : [],
   );
 
-  const handleSelection = (
-    e: React.MouseEvent<HTMLDivElement, MouseEvent>,
-    value: T,
-  ) => {
+  const handleSelection = (e: MouseEvent, value: T) => {
     if (props.multiple) {
       e.stopPropagation();
       const newSelectedValues = selectedValues.includes(value)
@@ -488,11 +501,19 @@ export function Selector<T>(props: {
             const selected = selectedValues.includes(item.value);
             return (
               <ListItem
-                className={styles["selector-item"]}
+                className={`${styles["selector-item"]} ${
+                  item.disable && styles["selector-item-disabled"]
+                }`}
                 key={i}
                 title={item.title}
                 subTitle={item.subTitle}
-                onClick={(e) => handleSelection(e, item.value)}
+                onClick={(e) => {
+                  if (item.disable) {
+                    e.stopPropagation();
+                  } else {
+                    handleSelection(e, item.value);
+                  }
+                }}
               >
                 {selected ? (
                   <div
@@ -526,11 +547,15 @@ export function FullScreen(props: any) {
     }
   }, []);
   useEffect(() => {
-    document.addEventListener("fullscreenchange", (e) => {
+    const handleScreenChange = (e: any) => {
       if (e.target === ref.current) {
         setFullScreen(!!document.fullscreenElement);
       }
-    });
+    };
+    document.addEventListener("fullscreenchange", handleScreenChange);
+    return () => {
+      document.removeEventListener("fullscreenchange", handleScreenChange);
+    };
   }, []);
   return (
     <div ref={ref} style={{ position: "relative" }} {...rest}>

+ 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") {

+ 11 - 0
app/config/server.ts

@@ -23,6 +23,10 @@ declare global {
       CUSTOM_MODELS?: string; // to control custom models
       DEFAULT_MODEL?: string; // to control default model in every new chat window
 
+      // stability only
+      STABILITY_URL?: string;
+      STABILITY_API_KEY?: string;
+
       // azure only
       AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
       AZURE_API_KEY?: string;
@@ -107,6 +111,8 @@ export const getServerSideConfig = () => {
     if (defaultModel.startsWith("gpt-4")) defaultModel = "";
   }
 
+  const isStability = !!process.env.STABILITY_API_KEY;
+
   const isAzure = !!process.env.AZURE_URL;
   const isGoogle = !!process.env.GOOGLE_API_KEY;
   const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
@@ -131,6 +137,10 @@ export const getServerSideConfig = () => {
     apiKey: getApiKey(process.env.OPENAI_API_KEY),
     openaiOrgId: process.env.OPENAI_ORG_ID,
 
+    isStability,
+    stabilityUrl: process.env.STABILITY_URL,
+    stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY),
+
     isAzure,
     azureUrl: process.env.AZURE_URL,
     azureApiKey: getApiKey(process.env.AZURE_API_KEY),
@@ -161,6 +171,7 @@ export const getServerSideConfig = () => {
     cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID,
     cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID,
     cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY),
+    cloudflareKVTTL: process.env.CLOUDFLARE_KV_TTL,
 
     gtmId: process.env.GTM_ID,
 

+ 15 - 1
app/constant.ts

@@ -8,7 +8,8 @@ export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/c
 export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
 export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
 
-export const PREVIEW_URL = "https://app.nextchat.dev";
+export const STABILITY_BASE_URL = "https://api.stability.ai";
+
 export const DEFAULT_API_HOST = "https://api.nextchat.dev";
 export const OPENAI_BASE_URL = "https://api.openai.com";
 export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
@@ -32,6 +33,8 @@ export enum Path {
   NewChat = "/new-chat",
   Masks = "/masks",
   Auth = "/auth",
+  Sd = "/sd",
+  SdNew = "/sd-new",
   Artifact = "/artifact",
 }
 
@@ -44,6 +47,7 @@ export enum ApiPath {
   Baidu = "/api/baidu",
   ByteDance = "/api/bytedance",
   Alibaba = "/api/alibaba",
+  Stability = "/api/stability",
   Artifact = "/api/artifact",
 }
 
@@ -69,6 +73,7 @@ export enum StoreKey {
   Prompt = "prompt-store",
   Update = "chat-update",
   Sync = "sync",
+  SdList = "sd-list",
 }
 
 export const DEFAULT_SIDEBAR_WIDTH = 300;
@@ -95,6 +100,7 @@ export enum ServiceProvider {
   Baidu = "Baidu",
   ByteDance = "ByteDance",
   Alibaba = "Alibaba",
+  Stability = "Stability",
 }
 
 // Google API safety settings, see https://ai.google.dev/gemini-api/docs/safety-settings
@@ -107,6 +113,7 @@ export enum GoogleSafetySettingsThreshold {
 }
 
 export enum ModelProvider {
+  Stability = "Stability",
   GPT = "GPT",
   GeminiPro = "GeminiPro",
   Claude = "Claude",
@@ -115,6 +122,11 @@ export enum ModelProvider {
   Qwen = "Qwen",
 }
 
+export const Stability = {
+  GeneratePath: "v2beta/stable-image/generate",
+  ExampleEndpoint: "https://api.stability.ai",
+};
+
 export const Anthropic = {
   ChatPath: "v1/messages",
   ChatPath1: "v1/complete",
@@ -358,3 +370,5 @@ export const internalAllowedWebDavEndpoints = [
   "https://webdav.yandex.com",
   "https://app.koofr.net/dav/Koofr",
 ];
+
+export const PLUGINS = [{ name: "Stable Diffusion", path: Path.Sd }];

+ 7 - 0
app/icons/discovery.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1.2rem" height="1.2rem" viewBox="0 0 24 24">
+    <g fill="none" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
+        <circle cx="12" cy="12" r="9" />
+        <path
+            d="M11.307 9.739L15 9l-.739 3.693a2 2 0 0 1-1.568 1.569L9 15l.739-3.693a2 2 0 0 1 1.568-1.568" />
+    </g>
+</svg>

+ 10 - 0
app/icons/history.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16" height="16" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path d="M5.81836 6.72729V14H13.0911" stroke="#333" stroke-width="4" stroke-linecap="round"
+        stroke-linejoin="round" />
+    <path
+        d="M4 24C4 35.0457 12.9543 44 24 44V44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C16.598 4 10.1351 8.02111 6.67677 13.9981"
+        stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
+    <path d="M24.005 12L24.0038 24.0088L32.4832 32.4882" stroke="#333" stroke-width="4"
+        stroke-linecap="round" stroke-linejoin="round" />
+</svg>

+ 12 - 0
app/icons/sd.svg

@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1.21em" height="1em" viewBox="0 0 256 213">
+    <defs>
+        <linearGradient id="logosStabilityAiIcon0" x1="50%" x2="50%" y1="0%" y2="100%">
+            <stop offset="0%" stop-color="#9d39ff" />
+            <stop offset="100%" stop-color="#a380ff" />
+        </linearGradient>
+    </defs>
+    <path fill="url(#logosStabilityAiIcon0)"
+        d="M72.418 212.45c49.478 0 81.658-26.205 81.658-65.626c0-30.572-19.572-49.998-54.569-58.043l-22.469-6.74c-19.71-4.424-31.215-9.738-28.505-23.312c2.255-11.292 9.002-17.667 24.69-17.667c49.872 0 68.35 17.667 68.35 17.667V16.237S123.583 0 73.223 0C25.757 0 0 24.424 0 62.236c0 30.571 17.85 48.35 54.052 56.798q3.802.95 3.885.976q8.26 2.556 22.293 6.755c18.504 4.425 23.262 9.121 23.262 23.2c0 12.872-13.374 20.19-31.074 20.19C21.432 170.154 0 144.36 0 144.36v47.078s13.402 21.01 72.418 21.01" />
+    <path fill="#e80000"
+        d="M225.442 209.266c17.515 0 30.558-12.67 30.558-29.812c0-17.515-12.67-29.813-30.558-29.813c-17.515 0-30.185 12.298-30.185 29.813s12.67 29.812 30.185 29.812" />
+</svg>

+ 4 - 1
app/layout.tsx

@@ -37,7 +37,10 @@ export default function RootLayout({
     <html lang="en">
       <head>
         <meta name="config" content={JSON.stringify(getClientConfig())} />
-        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+        <meta
+          name="viewport"
+          content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
+        />
         <link rel="manifest" href="/site.webmanifest"></link>
         <script src="/serviceWorkerRegister.js" defer></script>
       </head>

+ 69 - 0
app/locales/cn.ts

@@ -393,6 +393,17 @@ const cn = {
           SubTitle: "样例:",
         },
       },
+      Stability: {
+        ApiKey: {
+          Title: "接口密钥",
+          SubTitle: "使用自定义 Stability API Key",
+          Placeholder: "Stability API Key",
+        },
+        Endpoint: {
+          Title: "接口地址",
+          SubTitle: "样例:",
+        },
+      },
       CustomModel: {
         Title: "自定义模型名",
         SubTitle: "增加自定义模型可选项,使用英文逗号隔开",
@@ -452,6 +463,9 @@ const cn = {
     Name: "插件",
     Artifact: "Artifact",
   },
+  Discovery: {
+    Name: "发现",
+  },
   FineTuned: {
     Sysmessage: "你是一个助手",
   },
@@ -531,6 +545,61 @@ const cn = {
     Topic: "主题",
     Time: "时间",
   },
+  SdPanel: {
+    Prompt: "画面提示",
+    NegativePrompt: "否定提示",
+    PleaseInput: (name: string) => `请输入${name}`,
+    AspectRatio: "横纵比",
+    ImageStyle: "图像风格",
+    OutFormat: "输出格式",
+    AIModel: "AI模型",
+    ModelVersion: "模型版本",
+    Submit: "提交生成",
+    ParamIsRequired: (name: string) => `${name}不能为空`,
+    Styles: {
+      D3Model: "3D模型",
+      AnalogFilm: "模拟电影",
+      Anime: "动漫",
+      Cinematic: "电影风格",
+      ComicBook: "漫画书",
+      DigitalArt: "数字艺术",
+      Enhance: "增强",
+      FantasyArt: "幻想艺术",
+      Isometric: "等角",
+      LineArt: "线描",
+      LowPoly: "低多边形",
+      ModelingCompound: "建模材料",
+      NeonPunk: "霓虹朋克",
+      Origami: "折纸",
+      Photographic: "摄影",
+      PixelArt: "像素艺术",
+      TileTexture: "贴图",
+    },
+  },
+  Sd: {
+    SubTitle: (count: number) => `共 ${count} 条绘画`,
+    Actions: {
+      Params: "查看参数",
+      Copy: "复制提示词",
+      Delete: "删除",
+      Retry: "重试",
+      ReturnHome: "返回首页",
+      History: "查看历史",
+    },
+    EmptyRecord: "暂无绘画记录",
+    Status: {
+      Name: "状态",
+      Success: "成功",
+      Error: "失败",
+      Wait: "等待中",
+      Running: "运行中",
+    },
+    Danger: {
+      Delete: "确认删除?",
+    },
+    GenerateParams: "生成参数",
+    Detail: "详情",
+  },
 };
 
 type DeepPartial<T> = T extends object

+ 69 - 1
app/locales/en.ts

@@ -376,6 +376,17 @@ const en: LocaleType = {
           SubTitle: "Example: ",
         },
       },
+      Stability: {
+        ApiKey: {
+          Title: "Stability API Key",
+          SubTitle: "Use a custom Stability API Key",
+          Placeholder: "Stability API Key",
+        },
+        Endpoint: {
+          Title: "Endpoint Address",
+          SubTitle: "Example: ",
+        },
+      },
       CustomModel: {
         Title: "Custom Models",
         SubTitle: "Custom model options, seperated by comma",
@@ -459,6 +470,9 @@ const en: LocaleType = {
     Name: "Plugin",
     Artifact: "Artifact",
   },
+  Discovery: {
+    Name: "Discovery",
+  },
   FineTuned: {
     Sysmessage: "You are an assistant that",
   },
@@ -533,11 +547,65 @@ const en: LocaleType = {
     Topic: "Topic",
     Time: "Time",
   },
-
   URLCommand: {
     Code: "Detected access code from url, confirm to apply? ",
     Settings: "Detected settings from url, confirm to apply?",
   },
+  SdPanel: {
+    Prompt: "Prompt",
+    NegativePrompt: "Negative Prompt",
+    PleaseInput: (name: string) => `Please input ${name}`,
+    AspectRatio: "Aspect Ratio",
+    ImageStyle: "Image Style",
+    OutFormat: "Output Format",
+    AIModel: "AI Model",
+    ModelVersion: "Model Version",
+    Submit: "Submit",
+    ParamIsRequired: (name: string) => `${name} is required`,
+    Styles: {
+      D3Model: "3d-model",
+      AnalogFilm: "analog-film",
+      Anime: "anime",
+      Cinematic: "cinematic",
+      ComicBook: "comic-book",
+      DigitalArt: "digital-art",
+      Enhance: "enhance",
+      FantasyArt: "fantasy-art",
+      Isometric: "isometric",
+      LineArt: "line-art",
+      LowPoly: "low-poly",
+      ModelingCompound: "modeling-compound",
+      NeonPunk: "neon-punk",
+      Origami: "origami",
+      Photographic: "photographic",
+      PixelArt: "pixel-art",
+      TileTexture: "tile-texture",
+    },
+  },
+  Sd: {
+    SubTitle: (count: number) => `${count} images`,
+    Actions: {
+      Params: "See Params",
+      Copy: "Copy Prompt",
+      Delete: "Delete",
+      Retry: "Retry",
+      ReturnHome: "Return Home",
+      History: "History",
+    },
+    EmptyRecord: "No images yet",
+    Status: {
+      Name: "Status",
+      Success: "Success",
+      Error: "Error",
+      Wait: "Waiting",
+      Running: "Running",
+    },
+    Danger: {
+      Delete: "Confirm to delete?",
+    },
+    GenerateParams: "Generate Params",
+    Detail: "Detail",
+  },
 };
 
 export default en;

+ 1 - 1
app/locales/tw.ts

@@ -241,7 +241,7 @@ const tw = {
       },
       List: "自訂提示詞列表",
       ListCount: (builtin: number, custom: number) =>
-      `內建 ${builtin} 條,使用者自訂 ${custom} 條`,
+        `內建 ${builtin} 條,使用者自訂 ${custom} 條`,
       Edit: "編輯",
       Modal: {
         Title: "提示詞列表",

+ 7 - 1
app/store/access.ts

@@ -39,7 +39,9 @@ const DEFAULT_ALIBABA_URL = isApp
   ? DEFAULT_API_HOST + "/api/proxy/alibaba"
   : ApiPath.Alibaba;
 
-console.log("DEFAULT_ANTHROPIC_URL", DEFAULT_ANTHROPIC_URL);
+const DEFAULT_STABILITY_URL = isApp
+  ? DEFAULT_API_HOST + "/api/proxy/stability"
+  : ApiPath.Stability;
 
 const DEFAULT_ACCESS_STATE = {
   accessCode: "",
@@ -80,6 +82,10 @@ const DEFAULT_ACCESS_STATE = {
   alibabaUrl: DEFAULT_ALIBABA_URL,
   alibabaApiKey: "",
 
+  //stability
+  stabilityUrl: DEFAULT_STABILITY_URL,
+  stabilityApiKey: "",
+
   // server config
   needCode: true,
   hideUserApiKey: false,

+ 163 - 0
app/store/sd.ts

@@ -0,0 +1,163 @@
+import {
+  Stability,
+  StoreKey,
+  ACCESS_CODE_PREFIX,
+  ApiPath,
+} from "@/app/constant";
+import { getBearerToken } from "@/app/client/api";
+import { createPersistStore } from "@/app/utils/store";
+import { nanoid } from "nanoid";
+import { uploadImage, base64Image2Blob } from "@/app/utils/chat";
+import { models, getModelParamBasicData } from "@/app/components/sd/sd-panel";
+import { useAccessStore } from "./access";
+
+const defaultModel = {
+  name: models[0].name,
+  value: models[0].value,
+};
+
+const defaultParams = getModelParamBasicData(models[0].params({}), {});
+
+const DEFAULT_SD_STATE = {
+  currentId: 0,
+  draw: [],
+  currentModel: defaultModel,
+  currentParams: defaultParams,
+};
+
+export const useSdStore = createPersistStore<
+  {
+    currentId: number;
+    draw: any[];
+    currentModel: typeof defaultModel;
+    currentParams: any;
+  },
+  {
+    getNextId: () => number;
+    sendTask: (data: any, okCall?: Function) => void;
+    updateDraw: (draw: any) => void;
+    setCurrentModel: (model: any) => void;
+    setCurrentParams: (data: any) => void;
+  }
+>(
+  DEFAULT_SD_STATE,
+  (set, _get) => {
+    function get() {
+      return {
+        ..._get(),
+        ...methods,
+      };
+    }
+
+    const methods = {
+      getNextId() {
+        const id = ++_get().currentId;
+        set({ currentId: id });
+        return id;
+      },
+      sendTask(data: any, okCall?: Function) {
+        data = { ...data, id: nanoid(), status: "running" };
+        set({ draw: [data, ..._get().draw] });
+        this.getNextId();
+        this.stabilityRequestCall(data);
+        okCall?.();
+      },
+      stabilityRequestCall(data: any) {
+        const accessStore = useAccessStore.getState();
+        let prefix: string = ApiPath.Stability as string;
+        let bearerToken = "";
+        if (accessStore.useCustomConfig) {
+          prefix = accessStore.stabilityUrl || (ApiPath.Stability as string);
+          bearerToken = getBearerToken(accessStore.stabilityApiKey);
+        }
+        if (!bearerToken && accessStore.enabledAccessControl()) {
+          bearerToken = getBearerToken(
+            ACCESS_CODE_PREFIX + accessStore.accessCode,
+          );
+        }
+        const headers = {
+          Accept: "application/json",
+          Authorization: bearerToken,
+        };
+        const path = `${prefix}/${Stability.GeneratePath}/${data.model}`;
+        const formData = new FormData();
+        for (let paramsKey in data.params) {
+          formData.append(paramsKey, data.params[paramsKey]);
+        }
+        fetch(path, {
+          method: "POST",
+          headers,
+          body: formData,
+        })
+          .then((response) => response.json())
+          .then((resData) => {
+            if (resData.errors && resData.errors.length > 0) {
+              this.updateDraw({
+                ...data,
+                status: "error",
+                error: resData.errors[0],
+              });
+              this.getNextId();
+              return;
+            }
+            const self = this;
+            if (resData.finish_reason === "SUCCESS") {
+              uploadImage(base64Image2Blob(resData.image, "image/png"))
+                .then((img_data) => {
+                  console.debug("uploadImage success", img_data, self);
+                  self.updateDraw({
+                    ...data,
+                    status: "success",
+                    img_data,
+                  });
+                })
+                .catch((e) => {
+                  console.error("uploadImage error", e);
+                  self.updateDraw({
+                    ...data,
+                    status: "error",
+                    error: JSON.stringify(e),
+                  });
+                });
+            } else {
+              self.updateDraw({
+                ...data,
+                status: "error",
+                error: JSON.stringify(resData),
+              });
+            }
+            this.getNextId();
+          })
+          .catch((error) => {
+            this.updateDraw({ ...data, status: "error", error: error.message });
+            console.error("Error:", error);
+            this.getNextId();
+          });
+      },
+      updateDraw(_draw: any) {
+        const draw = _get().draw || [];
+        draw.some((item, index) => {
+          if (item.id === _draw.id) {
+            draw[index] = _draw;
+            set(() => ({ draw }));
+            return true;
+          }
+        });
+      },
+      setCurrentModel(model: any) {
+        set({ currentModel: model });
+      },
+      setCurrentParams(data: any) {
+        set({
+          currentParams: data,
+        });
+      },
+    };
+
+    return methods;
+  },
+  {
+    name: StoreKey.SdList,
+    version: 1.0,
+  },
+);

+ 1 - 1
app/utils/chat.ts

@@ -112,7 +112,7 @@ export function base64Image2Blob(base64Data: string, contentType: string) {
   return new Blob([byteArray], { type: contentType });
 }
 
-export function uploadImage(file: File): Promise<string> {
+export function uploadImage(file: Blob): Promise<string> {
   if (!window._SW_ENABLED) {
     // if serviceWorker register error, using compressImage
     return compressImage(file, 256 * 1024);

+ 6 - 3
public/serviceWorker.js

@@ -15,6 +15,10 @@ self.addEventListener("install", function (event) {
   );
 });
 
+function jsonify(data) {
+  return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } })
+}
+
 async function upload(request, url) {
   const formData = await request.formData()
   const file = formData.getAll('file')[0]
@@ -33,13 +37,13 @@ async function upload(request, url) {
       'server': 'ServiceWorker',
     }
   }))
-  return Response.json({ code: 0, data: fileUrl })
+  return jsonify({ code: 0, data: fileUrl })
 }
 
 async function remove(request, url) {
   const cache = await caches.open(CHATGPT_NEXT_WEB_FILE_CACHE)
   const res = await cache.delete(request.url)
-  return Response.json({ code: 0 })
+  return jsonify({ code: 0 })
 }
 
 self.addEventListener("fetch", (e) => {
@@ -56,4 +60,3 @@ self.addEventListener("fetch", (e) => {
     }
   }
 });
-