Explorar o código

Merge branch 'main' into tts-stt

DDMeaqua hai 1 ano
pai
achega
212605a7e3
Modificáronse 62 ficheiros con 2253 adicións e 523 borrados
  1. 6 6
      README.md
  2. 5 1
      app/api/[provider]/[...path]/route.ts
  3. 1 0
      app/api/anthropic.ts
  4. 1 4
      app/api/common.ts
  5. 3 1
      app/api/openai.ts
  6. 75 0
      app/api/proxy.ts
  7. 9 1
      app/client/api.ts
  8. 126 105
      app/client/platforms/anthropic.ts
  9. 64 107
      app/client/platforms/moonshot.ts
  10. 92 141
      app/client/platforms/openai.ts
  11. 1 1
      app/components/artifacts.tsx
  12. 63 0
      app/components/chat.module.scss
  13. 207 25
      app/components/chat.tsx
  14. 2 1
      app/components/emoji.tsx
  15. 2 2
      app/components/error.tsx
  16. 5 0
      app/components/home.tsx
  17. 21 8
      app/components/markdown.tsx
  18. 19 12
      app/components/mask.tsx
  19. 40 7
      app/components/model-config.tsx
  20. 16 0
      app/components/plugin.module.scss
  21. 393 0
      app/components/plugin.tsx
  22. 6 0
      app/components/ui-lib.module.scss
  23. 11 5
      app/components/ui-lib.tsx
  24. 5 2
      app/config/server.ts
  25. 10 4
      app/constant.ts
  26. 7 1
      app/global.d.ts
  27. 1 0
      app/icons/shortcutkey.svg
  28. 5 1
      app/layout.tsx
  29. 6 0
      app/locales/ar.ts
  30. 6 0
      app/locales/bn.ts
  31. 61 6
      app/locales/cn.ts
  32. 6 0
      app/locales/cs.ts
  33. 6 0
      app/locales/de.ts
  34. 59 4
      app/locales/en.ts
  35. 6 0
      app/locales/es.ts
  36. 6 0
      app/locales/fr.ts
  37. 6 0
      app/locales/id.ts
  38. 5 8
      app/locales/index.ts
  39. 6 0
      app/locales/it.ts
  40. 6 0
      app/locales/jp.ts
  41. 6 0
      app/locales/ko.ts
  42. 6 0
      app/locales/no.ts
  43. 6 0
      app/locales/pt.ts
  44. 6 0
      app/locales/ru.ts
  45. 6 0
      app/locales/sk.ts
  46. 6 0
      app/locales/tr.ts
  47. 14 0
      app/locales/tw.ts
  48. 6 0
      app/locales/vi.ts
  49. 90 55
      app/store/chat.ts
  50. 11 2
      app/store/config.ts
  51. 1 0
      app/store/index.ts
  52. 13 4
      app/store/mask.ts
  53. 225 0
      app/store/plugin.ts
  54. 108 0
      app/utils.ts
  55. 211 1
      app/utils/chat.ts
  56. 47 0
      app/utils/indexedDB-storage.ts
  57. 15 1
      app/utils/store.ts
  58. 4 4
      next.config.mjs
  59. 5 0
      package.json
  60. 1 1
      src-tauri/Cargo.toml
  61. 6 1
      src-tauri/tauri.conf.json
  62. 85 1
      yarn.lock

+ 6 - 6
README.md

@@ -91,13 +91,13 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
 - [x] Desktop App with tauri
 - [x] Self-host Model: Fully compatible with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), as well as server deployment of [LocalAI](https://github.com/go-skynet/LocalAI): llama/gpt4all/rwkv/vicuna/koala/gpt4all-j/cerebras/falcon/dolly etc.
 - [x] Artifacts: Easily preview, copy and share generated content/webpages through a separate window [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
-- [x] Plugins: support artifacts, network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
-  - [x] artifacts
-  - [ ] network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
+- [x] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
+  - [x] network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
 - [ ] local knowledge base
 
 ## What's New
 
+- 🚀 v2.15.0 Now supports Plugins! Read this: [NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins)
 - 🚀 v2.14.0 Now supports  Artifacts & SD 
 - 🚀 v2.10.1 support Google Gemini Pro model.
 - 🚀 v2.9.11 you can use azure endpoint now.
@@ -128,13 +128,13 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
 - [x] 使用 tauri 打包桌面应用
 - [x] 支持自部署的大语言模型:开箱即用 [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) ,服务端部署 [LocalAI 项目](https://github.com/go-skynet/LocalAI) llama / gpt4all / rwkv / vicuna / koala / gpt4all-j / cerebras / falcon / dolly 等等,或者使用 [api-for-open-llm](https://github.com/xusenlinzy/api-for-open-llm)
 - [x] Artifacts: 通过独立窗口,轻松预览、复制和分享生成的内容/可交互网页 [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
-- [x] 插件机制,支持 artifacts,联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
-   - [x] artifacts
-   - [ ] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
+- [x] 插件机制,支持`联网搜索`、`计算器`、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
+   - [x] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
  - [ ] 本地知识库
 
 ## 最新动态
 
+- 🚀 v2.15.0 现在支持插件功能了!了解更多:[NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins)
 - 🚀 v2.14.0 现在支持 Artifacts & SD 了。
 - 🚀 v2.10.1 现在支持 Gemini Pro 模型。
 - 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。

+ 5 - 1
app/api/[provider]/[...path]/route.ts

@@ -10,6 +10,8 @@ import { handle as alibabaHandler } from "../../alibaba";
 import { handle as moonshotHandler } from "../../moonshot";
 import { handle as stabilityHandler } from "../../stability";
 import { handle as iflytekHandler } from "../../iflytek";
+import { handle as proxyHandler } from "../../proxy";
+
 async function handle(
   req: NextRequest,
   { params }: { params: { provider: string; path: string[] } },
@@ -36,8 +38,10 @@ async function handle(
       return stabilityHandler(req, { params });
     case ApiPath.Iflytek:
       return iflytekHandler(req, { params });
-    default:
+    case ApiPath.OpenAI:
       return openaiHandler(req, { params });
+    default:
+      return proxyHandler(req, { params });
   }
 }
 

+ 1 - 0
app/api/anthropic.ts

@@ -98,6 +98,7 @@ async function request(req: NextRequest) {
     headers: {
       "Content-Type": "application/json",
       "Cache-Control": "no-store",
+      "anthropic-dangerous-direct-browser-access": "true",
       [authHeaderName]: authValue,
       "anthropic-version":
         req.headers.get("anthropic-version") ||

+ 1 - 4
app/api/common.ts

@@ -32,10 +32,7 @@ export async function requestOpenai(req: NextRequest) {
     authHeaderName = "Authorization";
   }
 
-  let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
-    "/api/openai/",
-    "",
-  );
+  let path = `${req.nextUrl.pathname}`.replaceAll("/api/openai/", "");
 
   let baseUrl =
     (isAzure ? serverConfig.azureUrl : serverConfig.baseUrl) || OPENAI_BASE_URL;

+ 3 - 1
app/api/openai.ts

@@ -13,7 +13,9 @@ function getModels(remoteModelRes: OpenAIListModelResponse) {
 
   if (config.disableGPT4) {
     remoteModelRes.data = remoteModelRes.data.filter(
-      (m) => !m.id.startsWith("gpt-4") || m.id.startsWith("gpt-4o-mini"),
+      (m) =>
+        !(m.id.startsWith("gpt-4") || m.id.startsWith("chatgpt-4o")) ||
+        m.id.startsWith("gpt-4o-mini"),
     );
   }
 

+ 75 - 0
app/api/proxy.ts

@@ -0,0 +1,75 @@
+import { NextRequest, NextResponse } from "next/server";
+
+export async function handle(
+  req: NextRequest,
+  { params }: { params: { path: string[] } },
+) {
+  console.log("[Proxy Route] params ", params);
+
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+
+  // remove path params from searchParams
+  req.nextUrl.searchParams.delete("path");
+  req.nextUrl.searchParams.delete("provider");
+
+  const subpath = params.path.join("/");
+  const fetchUrl = `${req.headers.get(
+    "x-base-url",
+  )}/${subpath}?${req.nextUrl.searchParams.toString()}`;
+  const skipHeaders = ["connection", "host", "origin", "referer", "cookie"];
+  const headers = new Headers(
+    Array.from(req.headers.entries()).filter((item) => {
+      if (
+        item[0].indexOf("x-") > -1 ||
+        item[0].indexOf("sec-") > -1 ||
+        skipHeaders.includes(item[0])
+      ) {
+        return false;
+      }
+      return true;
+    }),
+  );
+  const controller = new AbortController();
+  const fetchOptions: RequestInit = {
+    headers,
+    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,
+  };
+
+  const timeoutId = setTimeout(
+    () => {
+      controller.abort();
+    },
+    10 * 60 * 1000,
+  );
+
+  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");
+
+    // The latest version of the OpenAI API forced the content-encoding to be "br" in json response
+    // So if the streaming is disabled, we need to remove the content-encoding header
+    // Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header
+    // The browser will try to decode the response with brotli and fail
+    newHeaders.delete("content-encoding");
+
+    return new Response(res.body, {
+      status: res.status,
+      statusText: res.statusText,
+      headers: newHeaders,
+    });
+  } finally {
+    clearTimeout(timeoutId);
+  }
+}

+ 9 - 1
app/client/api.ts

@@ -5,7 +5,13 @@ import {
   ModelProvider,
   ServiceProvider,
 } from "../constant";
-import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store";
+import {
+  ChatMessageTool,
+  ChatMessage,
+  ModelType,
+  useAccessStore,
+  useChatStore,
+} from "../store";
 import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai";
 import { GeminiProApi } from "./platforms/google";
 import { ClaudeApi } from "./platforms/anthropic";
@@ -76,6 +82,8 @@ export interface ChatOptions {
   onFinish: (message: string) => void;
   onError?: (err: Error) => void;
   onController?: (controller: AbortController) => void;
+  onBeforeTool?: (tool: ChatMessageTool) => void;
+  onAfterTool?: (tool: ChatMessageTool) => void;
 }
 
 export interface LLMUsage {

+ 126 - 105
app/client/platforms/anthropic.ts

@@ -7,7 +7,13 @@ import {
   SpeechOptions,
   TranscriptionOptions,
 } from "../api";
-import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
+import {
+  useAccessStore,
+  useAppConfig,
+  useChatStore,
+  usePluginStore,
+  ChatMessageTool,
+} from "@/app/store";
 import { getClientConfig } from "@/app/config/client";
 import { DEFAULT_API_HOST } from "@/app/constant";
 import {
@@ -18,8 +24,9 @@ import {
 import Locale from "../../locales";
 import { prettyObject } from "@/app/utils/format";
 import { getMessageTextContent, isVisionModel } from "@/app/utils";
-import { preProcessImageContent } from "@/app/utils/chat";
+import { preProcessImageContent, stream } from "@/app/utils/chat";
 import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
+import { RequestPayload } from "./openai";
 
 export type MultiBlockContent = {
   type: "image" | "text";
@@ -205,112 +212,126 @@ export class ClaudeApi implements LLMApi {
     const controller = new AbortController();
     options.onController?.(controller);
 
-    const payload = {
-      method: "POST",
-      body: JSON.stringify(requestBody),
-      signal: controller.signal,
-      headers: {
-        ...getHeaders(), // get common headers
-        "anthropic-version": accessStore.anthropicApiVersion,
-        // do not send `anthropicApiKey` in browser!!!
-        // Authorization: getAuthKey(accessStore.anthropicApiKey),
-      },
-    };
-
     if (shouldStream) {
-      try {
-        const context = {
-          text: "",
-          finished: false,
-        };
-
-        const finish = () => {
-          if (!context.finished) {
-            options.onFinish(context.text);
-            context.finished = true;
-          }
-        };
-
-        controller.signal.onabort = finish;
-        fetchEventSource(path, {
-          ...payload,
-          async onopen(res) {
-            const contentType = res.headers.get("content-type");
-            console.log("response content type: ", contentType);
-
-            if (contentType?.startsWith("text/plain")) {
-              context.text = await res.clone().text();
-              return finish();
-            }
-
-            if (
-              !res.ok ||
-              !res.headers
-                .get("content-type")
-                ?.startsWith(EventStreamContentType) ||
-              res.status !== 200
-            ) {
-              const responseTexts = [context.text];
-              let extraInfo = await res.clone().text();
-              try {
-                const resJson = await res.clone().json();
-                extraInfo = prettyObject(resJson);
-              } catch {}
-
-              if (res.status === 401) {
-                responseTexts.push(Locale.Error.Unauthorized);
-              }
-
-              if (extraInfo) {
-                responseTexts.push(extraInfo);
-              }
-
-              context.text = responseTexts.join("\n\n");
-
-              return finish();
-            }
-          },
-          onmessage(msg) {
-            let chunkJson:
-              | undefined
-              | {
-                  type: "content_block_delta" | "content_block_stop";
-                  delta?: {
-                    type: "text_delta";
-                    text: string;
-                  };
-                  index: number;
+      let index = -1;
+      const [tools, funcs] = usePluginStore
+        .getState()
+        .getAsTools(
+          useChatStore.getState().currentSession().mask?.plugin || [],
+        );
+      return stream(
+        path,
+        requestBody,
+        {
+          ...getHeaders(),
+          "anthropic-version": accessStore.anthropicApiVersion,
+        },
+        // @ts-ignore
+        tools.map((tool) => ({
+          name: tool?.function?.name,
+          description: tool?.function?.description,
+          input_schema: tool?.function?.parameters,
+        })),
+        funcs,
+        controller,
+        // parseSSE
+        (text: string, runTools: ChatMessageTool[]) => {
+          // console.log("parseSSE", text, runTools);
+          let chunkJson:
+            | undefined
+            | {
+                type: "content_block_delta" | "content_block_stop";
+                content_block?: {
+                  type: "tool_use";
+                  id: string;
+                  name: string;
                 };
-            try {
-              chunkJson = JSON.parse(msg.data);
-            } catch (e) {
-              console.error("[Response] parse error", msg.data);
-            }
-
-            if (!chunkJson || chunkJson.type === "content_block_stop") {
-              return finish();
-            }
-
-            const { delta } = chunkJson;
-            if (delta?.text) {
-              context.text += delta.text;
-              options.onUpdate?.(context.text, delta.text);
-            }
-          },
-          onclose() {
-            finish();
-          },
-          onerror(e) {
-            options.onError?.(e);
-            throw e;
-          },
-          openWhenHidden: true,
-        });
-      } catch (e) {
-        console.error("failed to chat", e);
-        options.onError?.(e as Error);
-      }
+                delta?: {
+                  type: "text_delta" | "input_json_delta";
+                  text?: string;
+                  partial_json?: string;
+                };
+                index: number;
+              };
+          chunkJson = JSON.parse(text);
+
+          if (chunkJson?.content_block?.type == "tool_use") {
+            index += 1;
+            const id = chunkJson?.content_block.id;
+            const name = chunkJson?.content_block.name;
+            runTools.push({
+              id,
+              type: "function",
+              function: {
+                name,
+                arguments: "",
+              },
+            });
+          }
+          if (
+            chunkJson?.delta?.type == "input_json_delta" &&
+            chunkJson?.delta?.partial_json
+          ) {
+            // @ts-ignore
+            runTools[index]["function"]["arguments"] +=
+              chunkJson?.delta?.partial_json;
+          }
+          return chunkJson?.delta?.text;
+        },
+        // processToolMessage, include tool_calls message and tool call results
+        (
+          requestPayload: RequestPayload,
+          toolCallMessage: any,
+          toolCallResult: any[],
+        ) => {
+          // reset index value
+          index = -1;
+          // @ts-ignore
+          requestPayload?.messages?.splice(
+            // @ts-ignore
+            requestPayload?.messages?.length,
+            0,
+            {
+              role: "assistant",
+              content: toolCallMessage.tool_calls.map(
+                (tool: ChatMessageTool) => ({
+                  type: "tool_use",
+                  id: tool.id,
+                  name: tool?.function?.name,
+                  input: tool?.function?.arguments
+                    ? JSON.parse(tool?.function?.arguments)
+                    : {},
+                }),
+              ),
+            },
+            // @ts-ignore
+            ...toolCallResult.map((result) => ({
+              role: "user",
+              content: [
+                {
+                  type: "tool_result",
+                  tool_use_id: result.tool_call_id,
+                  content: result.content,
+                },
+              ],
+            })),
+          );
+        },
+        options,
+      );
     } else {
+      const payload = {
+        method: "POST",
+        body: JSON.stringify(requestBody),
+        signal: controller.signal,
+        headers: {
+          ...getHeaders(), // get common headers
+          "anthropic-version": accessStore.anthropicApiVersion,
+          // do not send `anthropicApiKey` in browser!!!
+          // Authorization: getAuthKey(accessStore.anthropicApiKey),
+        },
+      };
+
       try {
         controller.signal.onabort = () => options.onFinish("");
 

+ 64 - 107
app/client/platforms/moonshot.ts

@@ -8,9 +8,15 @@ import {
   REQUEST_TIMEOUT_MS,
   ServiceProvider,
 } from "@/app/constant";
-import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
+import {
+  useAccessStore,
+  useAppConfig,
+  useChatStore,
+  ChatMessageTool,
+  usePluginStore,
+} from "@/app/store";
 import { collectModelsWithDefaultModel } from "@/app/utils/model";
-import { preProcessImageContent } from "@/app/utils/chat";
+import { preProcessImageContent, stream } from "@/app/utils/chat";
 import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
 
 import {
@@ -125,115 +131,66 @@ export class MoonshotApi implements LLMApi {
       );
 
       if (shouldStream) {
-        let responseText = "";
-        let remainText = "";
-        let finished = false;
-
-        // animate response to make it looks smooth
-        function animateResponseText() {
-          if (finished || controller.signal.aborted) {
-            responseText += remainText;
-            console.log("[Response Animation] finished");
-            if (responseText?.length === 0) {
-              options.onError?.(new Error("empty response from server"));
-            }
-            return;
-          }
-
-          if (remainText.length > 0) {
-            const fetchCount = Math.max(1, Math.round(remainText.length / 60));
-            const fetchText = remainText.slice(0, fetchCount);
-            responseText += fetchText;
-            remainText = remainText.slice(fetchCount);
-            options.onUpdate?.(responseText, fetchText);
-          }
-
-          requestAnimationFrame(animateResponseText);
-        }
-
-        // start animaion
-        animateResponseText();
-
-        const finish = () => {
-          if (!finished) {
-            finished = true;
-            options.onFinish(responseText + remainText);
-          }
-        };
-
-        controller.signal.onabort = finish;
-
-        fetchEventSource(chatPath, {
-          ...chatPayload,
-          async onopen(res) {
-            clearTimeout(requestTimeoutId);
-            const contentType = res.headers.get("content-type");
-            console.log(
-              "[OpenAI] request response content type: ",
-              contentType,
-            );
-
-            if (contentType?.startsWith("text/plain")) {
-              responseText = await res.clone().text();
-              return finish();
-            }
-
-            if (
-              !res.ok ||
-              !res.headers
-                .get("content-type")
-                ?.startsWith(EventStreamContentType) ||
-              res.status !== 200
-            ) {
-              const responseTexts = [responseText];
-              let extraInfo = await res.clone().text();
-              try {
-                const resJson = await res.clone().json();
-                extraInfo = prettyObject(resJson);
-              } catch {}
-
-              if (res.status === 401) {
-                responseTexts.push(Locale.Error.Unauthorized);
-              }
-
-              if (extraInfo) {
-                responseTexts.push(extraInfo);
-              }
-
-              responseText = responseTexts.join("\n\n");
-
-              return finish();
-            }
-          },
-          onmessage(msg) {
-            if (msg.data === "[DONE]" || finished) {
-              return finish();
-            }
-            const text = msg.data;
-            try {
-              const json = JSON.parse(text);
-              const choices = json.choices as Array<{
-                delta: { content: string };
-              }>;
-              const delta = choices[0]?.delta?.content;
-              const textmoderation = json?.prompt_filter_results;
-
-              if (delta) {
-                remainText += delta;
+        const [tools, funcs] = usePluginStore
+          .getState()
+          .getAsTools(
+            useChatStore.getState().currentSession().mask?.plugin || [],
+          );
+        return stream(
+          chatPath,
+          requestPayload,
+          getHeaders(),
+          tools as any,
+          funcs,
+          controller,
+          // parseSSE
+          (text: string, runTools: ChatMessageTool[]) => {
+            // console.log("parseSSE", text, runTools);
+            const json = JSON.parse(text);
+            const choices = json.choices as Array<{
+              delta: {
+                content: string;
+                tool_calls: ChatMessageTool[];
+              };
+            }>;
+            const tool_calls = choices[0]?.delta?.tool_calls;
+            if (tool_calls?.length > 0) {
+              const index = tool_calls[0]?.index;
+              const id = tool_calls[0]?.id;
+              const args = tool_calls[0]?.function?.arguments;
+              if (id) {
+                runTools.push({
+                  id,
+                  type: tool_calls[0]?.type,
+                  function: {
+                    name: tool_calls[0]?.function?.name as string,
+                    arguments: args,
+                  },
+                });
+              } else {
+                // @ts-ignore
+                runTools[index]["function"]["arguments"] += args;
               }
-            } catch (e) {
-              console.error("[Request] parse error", text, msg);
             }
+            return choices[0]?.delta?.content;
           },
-          onclose() {
-            finish();
-          },
-          onerror(e) {
-            options.onError?.(e);
-            throw e;
+          // processToolMessage, include tool_calls message and tool call results
+          (
+            requestPayload: RequestPayload,
+            toolCallMessage: any,
+            toolCallResult: any[],
+          ) => {
+            // @ts-ignore
+            requestPayload?.messages?.splice(
+              // @ts-ignore
+              requestPayload?.messages?.length,
+              0,
+              toolCallMessage,
+              ...toolCallResult,
+            );
           },
-          openWhenHidden: true,
-        });
+          options,
+        );
       } else {
         const res = await fetch(chatPath, chatPayload);
         clearTimeout(requestTimeoutId);

+ 92 - 141
app/client/platforms/openai.ts

@@ -9,12 +9,19 @@ import {
   REQUEST_TIMEOUT_MS,
   ServiceProvider,
 } from "@/app/constant";
-import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
+import {
+  ChatMessageTool,
+  useAccessStore,
+  useAppConfig,
+  useChatStore,
+  usePluginStore,
+} from "@/app/store";
 import { collectModelsWithDefaultModel } from "@/app/utils/model";
 import {
   preProcessImageContent,
   uploadImage,
   base64Image2Blob,
+  stream,
 } from "@/app/utils/chat";
 import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
 import { DalleSize, DalleQuality, DalleStyle } from "@/app/typing";
@@ -234,6 +241,7 @@ export class ChatGPTApi implements LLMApi {
     let requestPayload: RequestPayload | DalleRequestPayload;
 
     const isDalle3 = _isDalle3(options.config.model);
+    const isO1 = options.config.model.startsWith("o1");
     if (isDalle3) {
       const prompt = getMessageTextContent(
         options.messages.slice(-1)?.pop() as any,
@@ -255,30 +263,32 @@ export class ChatGPTApi implements LLMApi {
         const content = visionModel
           ? await preProcessImageContent(v.content)
           : getMessageTextContent(v);
-        messages.push({ role: v.role, content });
+        if (!(isO1 && v.role === "system"))
+          messages.push({ role: v.role, content });
       }
 
+      // O1 not support image, tools (plugin in ChatGPTNextWeb) and system, stream, logprobs, temperature, top_p, n, presence_penalty, frequency_penalty yet.
       requestPayload = {
         messages,
-        stream: options.config.stream,
+        stream: !isO1 ? options.config.stream : false,
         model: modelConfig.model,
-        temperature: modelConfig.temperature,
-        presence_penalty: modelConfig.presence_penalty,
-        frequency_penalty: modelConfig.frequency_penalty,
-        top_p: modelConfig.top_p,
+        temperature: !isO1 ? modelConfig.temperature : 1,
+        presence_penalty: !isO1 ? modelConfig.presence_penalty : 0,
+        frequency_penalty: !isO1 ? modelConfig.frequency_penalty : 0,
+        top_p: !isO1 ? modelConfig.top_p : 1,
         // max_tokens: Math.max(modelConfig.max_tokens, 1024),
         // Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
       };
 
       // add max_tokens to vision model
-      if (visionModel && modelConfig.model.includes("preview")) {
+      if (visionModel) {
         requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
       }
     }
 
     console.log("[Request] openai payload: ", requestPayload);
 
-    const shouldStream = !isDalle3 && !!options.config.stream;
+    const shouldStream = !isDalle3 && !!options.config.stream && !isO1;
     const controller = new AbortController();
     options.onController?.(controller);
 
@@ -314,143 +324,82 @@ export class ChatGPTApi implements LLMApi {
           isDalle3 ? OpenaiPath.ImagePath : OpenaiPath.ChatPath,
         );
       }
-      const chatPayload = {
-        method: "POST",
-        body: JSON.stringify(requestPayload),
-        signal: controller.signal,
-        headers: getHeaders(),
-      };
-
-      // make a fetch request
-      const requestTimeoutId = setTimeout(
-        () => controller.abort(),
-        isDalle3 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow.
-      );
-
       if (shouldStream) {
-        let responseText = "";
-        let remainText = "";
-        let finished = false;
-
-        // animate response to make it looks smooth
-        function animateResponseText() {
-          if (finished || controller.signal.aborted) {
-            responseText += remainText;
-            console.log("[Response Animation] finished");
-            if (responseText?.length === 0) {
-              options.onError?.(new Error("empty response from server"));
-            }
-            return;
-          }
-
-          if (remainText.length > 0) {
-            const fetchCount = Math.max(1, Math.round(remainText.length / 60));
-            const fetchText = remainText.slice(0, fetchCount);
-            responseText += fetchText;
-            remainText = remainText.slice(fetchCount);
-            options.onUpdate?.(responseText, fetchText);
-          }
-
-          requestAnimationFrame(animateResponseText);
-        }
-
-        // start animaion
-        animateResponseText();
-
-        const finish = () => {
-          if (!finished) {
-            finished = true;
-            options.onFinish(responseText + remainText);
-          }
-        };
-
-        controller.signal.onabort = finish;
-
-        fetchEventSource(chatPath, {
-          ...chatPayload,
-          async onopen(res) {
-            clearTimeout(requestTimeoutId);
-            const contentType = res.headers.get("content-type");
-            console.log(
-              "[OpenAI] request response content type: ",
-              contentType,
-            );
-
-            if (contentType?.startsWith("text/plain")) {
-              responseText = await res.clone().text();
-              return finish();
-            }
-
-            if (
-              !res.ok ||
-              !res.headers
-                .get("content-type")
-                ?.startsWith(EventStreamContentType) ||
-              res.status !== 200
-            ) {
-              const responseTexts = [responseText];
-              let extraInfo = await res.clone().text();
-              try {
-                const resJson = await res.clone().json();
-                extraInfo = prettyObject(resJson);
-              } catch {}
-
-              if (res.status === 401) {
-                responseTexts.push(Locale.Error.Unauthorized);
-              }
-
-              if (extraInfo) {
-                responseTexts.push(extraInfo);
+        const [tools, funcs] = usePluginStore
+          .getState()
+          .getAsTools(
+            useChatStore.getState().currentSession().mask?.plugin || [],
+          );
+        // console.log("getAsTools", tools, funcs);
+        stream(
+          chatPath,
+          requestPayload,
+          getHeaders(),
+          tools as any,
+          funcs,
+          controller,
+          // parseSSE
+          (text: string, runTools: ChatMessageTool[]) => {
+            // console.log("parseSSE", text, runTools);
+            const json = JSON.parse(text);
+            const choices = json.choices as Array<{
+              delta: {
+                content: string;
+                tool_calls: ChatMessageTool[];
+              };
+            }>;
+            const tool_calls = choices[0]?.delta?.tool_calls;
+            if (tool_calls?.length > 0) {
+              const index = tool_calls[0]?.index;
+              const id = tool_calls[0]?.id;
+              const args = tool_calls[0]?.function?.arguments;
+              if (id) {
+                runTools.push({
+                  id,
+                  type: tool_calls[0]?.type,
+                  function: {
+                    name: tool_calls[0]?.function?.name as string,
+                    arguments: args,
+                  },
+                });
+              } else {
+                // @ts-ignore
+                runTools[index]["function"]["arguments"] += args;
               }
-
-              responseText = responseTexts.join("\n\n");
-
-              return finish();
             }
+            return choices[0]?.delta?.content;
           },
-          onmessage(msg) {
-            if (msg.data === "[DONE]" || finished) {
-              return finish();
-            }
-            const text = msg.data;
-            try {
-              const json = JSON.parse(text);
-              const choices = json.choices as Array<{
-                delta: { content: string };
-              }>;
-              const delta = choices[0]?.delta?.content;
-              const textmoderation = json?.prompt_filter_results;
-
-              if (delta) {
-                remainText += delta;
-              }
-
-              if (
-                textmoderation &&
-                textmoderation.length > 0 &&
-                ServiceProvider.Azure
-              ) {
-                const contentFilterResults =
-                  textmoderation[0]?.content_filter_results;
-                console.log(
-                  `[${ServiceProvider.Azure}] [Text Moderation] flagged categories result:`,
-                  contentFilterResults,
-                );
-              }
-            } catch (e) {
-              console.error("[Request] parse error", text, msg);
-            }
-          },
-          onclose() {
-            finish();
-          },
-          onerror(e) {
-            options.onError?.(e);
-            throw e;
+          // processToolMessage, include tool_calls message and tool call results
+          (
+            requestPayload: RequestPayload,
+            toolCallMessage: any,
+            toolCallResult: any[],
+          ) => {
+            // @ts-ignore
+            requestPayload?.messages?.splice(
+              // @ts-ignore
+              requestPayload?.messages?.length,
+              0,
+              toolCallMessage,
+              ...toolCallResult,
+            );
           },
-          openWhenHidden: true,
-        });
+          options,
+        );
       } else {
+        const chatPayload = {
+          method: "POST",
+          body: JSON.stringify(requestPayload),
+          signal: controller.signal,
+          headers: getHeaders(),
+        };
+
+        // make a fetch request
+        const requestTimeoutId = setTimeout(
+          () => controller.abort(),
+          isDalle3 || isO1 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow.
+        );
+
         const res = await fetch(chatPath, chatPayload);
         clearTimeout(requestTimeoutId);
 
@@ -542,7 +491,9 @@ export class ChatGPTApi implements LLMApi {
     });
 
     const resJson = (await res.json()) as OpenAIListModelResponse;
-    const chatModels = resJson.data?.filter((m) => m.id.startsWith("gpt-"));
+    const chatModels = resJson.data?.filter(
+      (m) => m.id.startsWith("gpt-") || m.id.startsWith("chatgpt-"),
+    );
     console.log("[Models]", chatModels);
 
     if (!chatModels) {

+ 1 - 1
app/components/artifacts.tsx

@@ -80,7 +80,7 @@ export const HTMLPreview = forwardRef<HTMLPreviewHander, HTMLPreviewProps>(
     }, [props.autoHeight, props.height, iframeHeight]);
 
     const srcDoc = useMemo(() => {
-      const script = `<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`;
+      const script = `<script>window.addEventListener("DOMContentLoaded", () => new ResizeObserver((entries) => parent.postMessage({id: '${frameId}', height: entries[0].target.clientHeight}, '*')).observe(document.body))</script>`;
       if (props.code.includes("<!DOCTYPE html>")) {
         props.code.replace("<!DOCTYPE html>", "<!DOCTYPE html>" + script);
       }

+ 63 - 0
app/components/chat.module.scss

@@ -413,6 +413,21 @@
   margin-top: 5px;
 }
 
+.chat-message-tools {
+  font-size: 12px;
+  color: #aaa;
+  line-height: 1.5;
+  margin-top: 5px;
+  .chat-message-tool {
+    display: flex;
+    align-items: end;
+    svg {
+      margin-left: 5px;
+      margin-right: 5px;
+    }
+  }
+}
+
 .chat-message-item {
   box-sizing: border-box;
   max-width: 100%;
@@ -630,4 +645,52 @@
   .chat-input-send {
     bottom: 30px;
   }
+}
+
+.shortcut-key-container {
+  padding: 10px;
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+}
+
+.shortcut-key-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
+  gap: 16px;
+}
+
+.shortcut-key-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  overflow: hidden;
+  padding: 10px;
+  background-color: var(--white);
+}
+
+.shortcut-key-title {
+  font-size: 14px;
+  color: var(--black);
+}
+
+.shortcut-key-keys {
+  display: flex;
+  gap: 8px;
+}
+
+.shortcut-key {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: var(--border-in-light);
+  border-radius: 8px;
+  padding: 4px;
+  background-color: var(--gray);
+  min-width: 32px;
+}
+
+.shortcut-key span {
+  font-size: 12px;
+  color: var(--black);
 }

+ 207 - 25
app/components/chat.tsx

@@ -31,6 +31,7 @@ import DeleteIcon from "../icons/clear.svg";
 import PinIcon from "../icons/pin.svg";
 import EditIcon from "../icons/rename.svg";
 import ConfirmIcon from "../icons/confirm.svg";
+import CloseIcon from "../icons/close.svg";
 import CancelIcon from "../icons/cancel.svg";
 import ImageIcon from "../icons/image.svg";
 
@@ -44,6 +45,8 @@ import SizeIcon from "../icons/size.svg";
 import QualityIcon from "../icons/hd.svg";
 import StyleIcon from "../icons/palette.svg";
 import PluginIcon from "../icons/plugin.svg";
+import ShortcutkeyIcon from "../icons/shortcutkey.svg";
+import ReloadIcon from "../icons/reload.svg";
 
 import {
   ChatMessage,
@@ -56,6 +59,7 @@ import {
   useAppConfig,
   DEFAULT_TOPIC,
   ModelType,
+  usePluginStore,
 } from "../store";
 
 import {
@@ -67,6 +71,8 @@ import {
   getMessageImages,
   isVisionModel,
   isDalle3,
+  showPlugins,
+  safeLocalStorage,
   isFirefox,
 } from "../utils";
 
@@ -103,7 +109,6 @@ import {
   REQUEST_TIMEOUT_MS,
   UNFINISHED_INPUT,
   ServiceProvider,
-  Plugin,
 } from "../constant";
 import { Avatar } from "./emoji";
 import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
@@ -114,6 +119,8 @@ import { ExportMessageModal } from "./exporter";
 import { getClientConfig } from "../config/client";
 import { useAllModels } from "../utils/hooks";
 import { MultimodalContent } from "../client/api";
+
+const localStorage = safeLocalStorage();
 import { ClientApi } from "../client/api";
 import { createTTSPlayer } from "../utils/audio";
 import {
@@ -204,7 +211,7 @@ function PromptToast(props: {
 
   return (
     <div className={styles["prompt-toast"]} key="prompt-toast">
-      {props.showToast && (
+      {props.showToast && context.length > 0 && (
         <div
           className={styles["prompt-toast-inner"] + " clickable"}
           role="button"
@@ -453,11 +460,13 @@ export function ChatActions(props: {
   showPromptHints: () => void;
   hitBottom: boolean;
   uploading: boolean;
+  setShowShortcutKeyModal: React.Dispatch<React.SetStateAction<boolean>>;
   setUserInput: (input: string) => void;
 }) {
   const config = useAppConfig();
   const navigate = useNavigate();
   const chatStore = useChatStore();
+  const pluginStore = usePluginStore();
 
   // switch themes
   const theme = config.theme;
@@ -518,6 +527,8 @@ export function ChatActions(props: {
   const currentStyle =
     chatStore.currentSession().mask.modelConfig?.style ?? "vivid";
 
+  const isMobileScreen = useMobileScreen();
+
   useEffect(() => {
     const show = isVisionModel(currentModel);
     setShowUploadImage(show);
@@ -528,8 +539,8 @@ export function ChatActions(props: {
 
     // if current model is not available
     // switch to first available model
-    const isUnavaliableModel = !models.some((m) => m.name === currentModel);
-    if (isUnavaliableModel && models.length > 0) {
+    const isUnavailableModel = !models.some((m) => m.name === currentModel);
+    if (isUnavailableModel && models.length > 0) {
       // show next model to default model if exist
       let nextModel = models.find((model) => model.isDefault) || models[0];
       chatStore.updateCurrentSession((session) => {
@@ -671,7 +682,7 @@ export function ChatActions(props: {
           items={models.map((m) => ({
             title: `${m.displayName}${
               m?.provider?.providerName
-                ? "(" + m?.provider?.providerName + ")"
+                ? " (" + m?.provider?.providerName + ")"
                 : ""
             }`,
             value: `${m.name}@${m?.provider?.providerName}`,
@@ -780,34 +791,44 @@ export function ChatActions(props: {
         />
       )}
 
-      <ChatAction
-        onClick={() => setShowPluginSelector(true)}
-        text={Locale.Plugin.Name}
-        icon={<PluginIcon />}
-      />
+      {showPlugins(currentProviderName, currentModel) && (
+        <ChatAction
+          onClick={() => {
+            if (pluginStore.getAll().length == 0) {
+              navigate(Path.Plugins);
+            } else {
+              setShowPluginSelector(true);
+            }
+          }}
+          text={Locale.Plugin.Name}
+          icon={<PluginIcon />}
+        />
+      )}
       {showPluginSelector && (
         <Selector
           multiple
           defaultSelectedValue={chatStore.currentSession().mask?.plugin}
-          items={[
-            {
-              title: Locale.Plugin.Artifacts,
-              value: Plugin.Artifacts,
-            },
-          ]}
+          items={pluginStore.getAll().map((item) => ({
+            title: `${item?.title}@${item?.version}`,
+            value: item?.id,
+          }))}
           onClose={() => setShowPluginSelector(false)}
           onSelection={(s) => {
-            const plugin = s[0];
             chatStore.updateCurrentSession((session) => {
-              session.mask.plugin = s;
+              session.mask.plugin = s as string[];
             });
-            if (plugin) {
-              showToast(plugin);
-            }
           }}
         />
       )}
 
+      {!isMobileScreen && (
+        <ChatAction
+          onClick={() => props.setShowShortcutKeyModal(true)}
+          text={Locale.Chat.ShortcutKey.Title}
+          icon={<ShortcutkeyIcon />}
+        />
+      )}
+
       {config.sttConfig.enable && (
         <ChatAction
           onClick={async () =>
@@ -891,6 +912,67 @@ export function DeleteImageButton(props: { deleteImage: () => void }) {
   );
 }
 
+export function ShortcutKeyModal(props: { onClose: () => void }) {
+  const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
+  const shortcuts = [
+    {
+      title: Locale.Chat.ShortcutKey.newChat,
+      keys: isMac ? ["⌘", "Shift", "O"] : ["Ctrl", "Shift", "O"],
+    },
+    { title: Locale.Chat.ShortcutKey.focusInput, keys: ["Shift", "Esc"] },
+    {
+      title: Locale.Chat.ShortcutKey.copyLastCode,
+      keys: isMac ? ["⌘", "Shift", ";"] : ["Ctrl", "Shift", ";"],
+    },
+    {
+      title: Locale.Chat.ShortcutKey.copyLastMessage,
+      keys: isMac ? ["⌘", "Shift", "C"] : ["Ctrl", "Shift", "C"],
+    },
+    {
+      title: Locale.Chat.ShortcutKey.showShortcutKey,
+      keys: isMac ? ["⌘", "/"] : ["Ctrl", "/"],
+    },
+  ];
+  return (
+    <div className="modal-mask">
+      <Modal
+        title={Locale.Chat.ShortcutKey.Title}
+        onClose={props.onClose}
+        actions={[
+          <IconButton
+            type="primary"
+            text={Locale.UI.Confirm}
+            icon={<ConfirmIcon />}
+            key="ok"
+            onClick={() => {
+              props.onClose();
+            }}
+          />,
+        ]}
+      >
+        <div className={styles["shortcut-key-container"]}>
+          <div className={styles["shortcut-key-grid"]}>
+            {shortcuts.map((shortcut, index) => (
+              <div key={index} className={styles["shortcut-key-item"]}>
+                <div className={styles["shortcut-key-title"]}>
+                  {shortcut.title}
+                </div>
+                <div className={styles["shortcut-key-keys"]}>
+                  {shortcut.keys.map((key, i) => (
+                    <div key={i} className={styles["shortcut-key"]}>
+                      <span>{key}</span>
+                    </div>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      </Modal>
+    </div>
+  );
+}
+
 function _Chat() {
   type RenderMessage = ChatMessage & { preview?: boolean };
 
@@ -1003,7 +1085,7 @@ function _Chat() {
       .onUserInput(userInput, attachImages)
       .then(() => setIsLoading(false));
     setAttachImages([]);
-    localStorage.setItem(LAST_INPUT_KEY, userInput);
+    chatStore.setLastInput(userInput);
     setUserInput("");
     setPromptHints([]);
     if (!isMobileScreen) inputRef.current?.focus();
@@ -1069,7 +1151,7 @@ function _Chat() {
       userInput.length <= 0 &&
       !(e.metaKey || e.altKey || e.ctrlKey)
     ) {
-      setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
+      setUserInput(chatStore.lastInput ?? "");
       e.preventDefault();
       return;
     }
@@ -1480,6 +1562,70 @@ function _Chat() {
     setAttachImages(images);
   }
 
+  // 快捷键 shortcut keys
+  const [showShortcutKeyModal, setShowShortcutKeyModal] = useState(false);
+
+  useEffect(() => {
+    const handleKeyDown = (event: any) => {
+      // 打开新聊天 command + shift + o
+      if (
+        (event.metaKey || event.ctrlKey) &&
+        event.shiftKey &&
+        event.key.toLowerCase() === "o"
+      ) {
+        event.preventDefault();
+        setTimeout(() => {
+          chatStore.newSession();
+          navigate(Path.Chat);
+        }, 10);
+      }
+      // 聚焦聊天输入 shift + esc
+      else if (event.shiftKey && event.key.toLowerCase() === "escape") {
+        event.preventDefault();
+        inputRef.current?.focus();
+      }
+      // 复制最后一个代码块 command + shift + ;
+      else if (
+        (event.metaKey || event.ctrlKey) &&
+        event.shiftKey &&
+        event.code === "Semicolon"
+      ) {
+        event.preventDefault();
+        const copyCodeButton =
+          document.querySelectorAll<HTMLElement>(".copy-code-button");
+        if (copyCodeButton.length > 0) {
+          copyCodeButton[copyCodeButton.length - 1].click();
+        }
+      }
+      // 复制最后一个回复 command + shift + c
+      else if (
+        (event.metaKey || event.ctrlKey) &&
+        event.shiftKey &&
+        event.key.toLowerCase() === "c"
+      ) {
+        event.preventDefault();
+        const lastNonUserMessage = messages
+          .filter((message) => message.role !== "user")
+          .pop();
+        if (lastNonUserMessage) {
+          const lastMessageContent = getMessageTextContent(lastNonUserMessage);
+          copyToClipboard(lastMessageContent);
+        }
+      }
+      // 展示快捷键 command + /
+      else if ((event.metaKey || event.ctrlKey) && event.key === "/") {
+        event.preventDefault();
+        setShowShortcutKeyModal(true);
+      }
+    };
+
+    window.addEventListener("keydown", handleKeyDown);
+
+    return () => {
+      window.removeEventListener("keydown", handleKeyDown);
+    };
+  }, [messages, chatStore, navigate]);
+
   return (
     <div className={styles.chat} key={session.id}>
       <div className="window-header" data-tauri-drag-region>
@@ -1508,6 +1654,17 @@ function _Chat() {
           </div>
         </div>
         <div className="window-actions">
+          <div className="window-action-button">
+            <IconButton
+              icon={<ReloadIcon />}
+              bordered
+              title={Locale.Chat.Actions.RefreshTitle}
+              onClick={() => {
+                showToast(Locale.Chat.Actions.RefreshToast);
+                chatStore.summarizeSession(true);
+              }}
+            />
+          </div>
           {!isMobileScreen && (
             <div className="window-action-button">
               <IconButton
@@ -1704,11 +1861,31 @@ function _Chat() {
                       </div>
                     )}
                   </div>
-                  {showTyping && (
+                  {message?.tools?.length == 0 && showTyping && (
                     <div className={styles["chat-message-status"]}>
                       {Locale.Chat.Typing}
                     </div>
                   )}
+                  {/*@ts-ignore*/}
+                  {message?.tools?.length > 0 && (
+                    <div className={styles["chat-message-tools"]}>
+                      {message?.tools?.map((tool) => (
+                        <div
+                          key={tool.id}
+                          className={styles["chat-message-tool"]}
+                        >
+                          {tool.isError === false ? (
+                            <ConfirmIcon />
+                          ) : tool.isError === true ? (
+                            <CloseIcon />
+                          ) : (
+                            <LoadingButtonIcon />
+                          )}
+                          <span>{tool?.function?.name}</span>
+                        </div>
+                      ))}
+                    </div>
+                  )}
                   <div className={styles["chat-message-item"]}>
                     <Markdown
                       key={message.streaming ? "loading" : "done"}
@@ -1718,7 +1895,7 @@ function _Chat() {
                         message.content.length === 0 &&
                         !isUser
                       }
-                      onContextMenu={(e) => onRightClick(e, message)}
+                      //   onContextMenu={(e) => onRightClick(e, message)} // hard to use
                       onDoubleClickCapture={() => {
                         if (!isMobileScreen) return;
                         setUserInput(getMessageTextContent(message));
@@ -1795,6 +1972,7 @@ function _Chat() {
             setUserInput("/");
             onSearch("");
           }}
+          setShowShortcutKeyModal={setShowShortcutKeyModal}
           setUserInput={setUserInput}
         />
         <label
@@ -1867,6 +2045,10 @@ function _Chat() {
           }}
         />
       )}
+
+      {showShortcutKeyModal && (
+        <ShortcutKeyModal onClose={() => setShowShortcutKeyModal(false)} />
+      )}
     </div>
   );
 }

+ 2 - 1
app/components/emoji.tsx

@@ -36,7 +36,8 @@ export function Avatar(props: { model?: ModelType; avatar?: string }) {
   if (props.model) {
     return (
       <div className="no-dark">
-        {props.model?.startsWith("gpt-4") ? (
+        {props.model?.startsWith("gpt-4") ||
+        props.model?.startsWith("chatgpt-4o") ? (
           <BlackBotIcon className="user-avatar" />
         ) : (
           <BotIcon className="user-avatar" />

+ 2 - 2
app/components/error.tsx

@@ -8,6 +8,7 @@ import { ISSUE_URL } from "../constant";
 import Locale from "../locales";
 import { showConfirm } from "./ui-lib";
 import { useSyncStore } from "../store/sync";
+import { useChatStore } from "../store/chat";
 
 interface IErrorBoundaryState {
   hasError: boolean;
@@ -30,8 +31,7 @@ export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
     try {
       useSyncStore.getState().export();
     } finally {
-      localStorage.clear();
-      location.reload();
+      useChatStore.getState().clearAllData();
     }
   }
 

+ 5 - 0
app/components/home.tsx

@@ -59,6 +59,10 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
   loading: () => <Loading noLogo />,
 });
 
+const PluginPage = dynamic(async () => (await import("./plugin")).PluginPage, {
+  loading: () => <Loading noLogo />,
+});
+
 const SearchChat = dynamic(
   async () => (await import("./search-chat")).SearchChatPage,
   {
@@ -181,6 +185,7 @@ function Screen() {
             <Route path={Path.Home} element={<Chat />} />
             <Route path={Path.NewChat} element={<NewChat />} />
             <Route path={Path.Masks} element={<MaskPage />} />
+            <Route path={Path.Plugins} element={<PluginPage />} />
             <Route path={Path.SearchChat} element={<SearchChat />} />
             <Route path={Path.Chat} element={<Chat />} />
             <Route path={Path.Settings} element={<Settings />} />

+ 21 - 8
app/components/markdown.tsx

@@ -19,7 +19,6 @@ import {
   HTMLPreview,
   HTMLPreviewHander,
 } from "./artifacts";
-import { Plugin } from "../constant";
 import { useChatStore } from "../store";
 import { IconButton } from "./button";
 
@@ -77,7 +76,6 @@ export function PreCode(props: { children: any }) {
   const { height } = useWindowSize();
   const chatStore = useChatStore();
   const session = chatStore.currentSession();
-  const plugins = session.mask?.plugin;
 
   const renderArtifacts = useDebouncedCallback(() => {
     if (!ref.current) return;
@@ -94,10 +92,7 @@ export function PreCode(props: { children: any }) {
     }
   }, 600);
 
-  const enableArtifacts = useMemo(
-    () => plugins?.includes(Plugin.Artifacts),
-    [plugins],
-  );
+  const enableArtifacts = session.mask?.enableArtifacts !== false;
 
   //Wrap the paragraph for plain-text
   useEffect(() => {
@@ -168,7 +163,7 @@ export function PreCode(props: { children: any }) {
   );
 }
 
-function CustomCode(props: { children: any }) {
+function CustomCode(props: { children: any; className?: string }) {
   const ref = useRef<HTMLPreElement>(null);
   const [collapsed, setCollapsed] = useState(true);
   const [showToggle, setShowToggle] = useState(false);
@@ -187,6 +182,7 @@ function CustomCode(props: { children: any }) {
   return (
     <>
       <code
+        className={props?.className}
         ref={ref}
         style={{
           maxHeight: collapsed ? "400px" : "none",
@@ -241,9 +237,26 @@ function escapeBrackets(text: string) {
   );
 }
 
+function tryWrapHtmlCode(text: string) {
+  // try add wrap html code (fixed: html codeblock include 2 newline)
+  return text
+    .replace(
+      /([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,
+      (match, quoteStart, lang, newLine, doctype) => {
+        return !quoteStart ? "\n```html\n" + doctype : match;
+      },
+    )
+    .replace(
+      /(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*?)([`]*?)([\n\r]*?)/g,
+      (match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => {
+        return !quoteEnd ? bodyEnd + space + htmlEnd + "\n```\n" : match;
+      },
+    );
+}
+
 function _MarkDownContent(props: { content: string }) {
   const escapedContent = useMemo(() => {
-    return escapeBrackets(escapeDollarNumber(props.content));
+    return tryWrapHtmlCode(escapeBrackets(escapeDollarNumber(props.content)));
   }, [props.content]);
 
   return (

+ 19 - 12
app/components/mask.tsx

@@ -167,6 +167,22 @@ export function MaskConfig(props: {
           ></input>
         </ListItem>
 
+        <ListItem
+          title={Locale.Mask.Config.Artifacts.Title}
+          subTitle={Locale.Mask.Config.Artifacts.SubTitle}
+        >
+          <input
+            aria-label={Locale.Mask.Config.Artifacts.Title}
+            type="checkbox"
+            checked={props.mask.enableArtifacts !== false}
+            onChange={(e) => {
+              props.updateMask((mask) => {
+                mask.enableArtifacts = e.currentTarget.checked;
+              });
+            }}
+          ></input>
+        </ListItem>
+
         {!props.shouldSyncFromGlobal ? (
           <ListItem
             title={Locale.Mask.Config.Share.Title}
@@ -410,16 +426,7 @@ export function MaskPage() {
   const maskStore = useMaskStore();
   const chatStore = useChatStore();
 
-  const [filterLang, setFilterLang] = useState<Lang | undefined>(
-    () => localStorage.getItem("Mask-language") as Lang | undefined,
-  );
-  useEffect(() => {
-    if (filterLang) {
-      localStorage.setItem("Mask-language", filterLang);
-    } else {
-      localStorage.removeItem("Mask-language");
-    }
-  }, [filterLang]);
+  const filterLang = maskStore.language;
 
   const allMasks = maskStore
     .getAll()
@@ -526,9 +533,9 @@ export function MaskPage() {
               onChange={(e) => {
                 const value = e.currentTarget.value;
                 if (value === Locale.Settings.Lang.All) {
-                  setFilterLang(undefined);
+                  maskStore.setLanguage(undefined);
                 } else {
-                  setFilterLang(value as Lang);
+                  maskStore.setLanguage(value as Lang);
                 }
               }}
             >

+ 40 - 7
app/components/model-config.tsx

@@ -5,13 +5,19 @@ import Locale from "../locales";
 import { InputRange } from "./input-range";
 import { ListItem, Select } from "./ui-lib";
 import { useAllModels } from "../utils/hooks";
+import { groupBy } from "lodash-es";
 
 export function ModelConfigList(props: {
   modelConfig: ModelConfig;
   updateConfig: (updater: (config: ModelConfig) => void) => void;
 }) {
   const allModels = useAllModels();
+  const groupModels = groupBy(
+    allModels.filter((v) => v.available),
+    "provider.providerName",
+  );
   const value = `${props.modelConfig.model}@${props.modelConfig?.providerName}`;
+  const compressModelValue = `${props.modelConfig.compressModel}@${props.modelConfig?.compressProviderName}`;
 
   return (
     <>
@@ -19,6 +25,7 @@ export function ModelConfigList(props: {
         <Select
           aria-label={Locale.Settings.Model}
           value={value}
+          align="left"
           onChange={(e) => {
             const [model, providerName] = e.currentTarget.value.split("@");
             props.updateConfig((config) => {
@@ -27,13 +34,15 @@ export function ModelConfigList(props: {
             });
           }}
         >
-          {allModels
-            .filter((v) => v.available)
-            .map((v, i) => (
-              <option value={`${v.name}@${v.provider?.providerName}`} key={i}>
-                {v.displayName}({v.provider?.providerName})
-              </option>
-            ))}
+          {Object.keys(groupModels).map((providerName, index) => (
+            <optgroup label={providerName} key={index}>
+              {groupModels[providerName].map((v, i) => (
+                <option value={`${v.name}@${v.provider?.providerName}`} key={i}>
+                  {v.displayName}
+                </option>
+              ))}
+            </optgroup>
+          ))}
         </Select>
       </ListItem>
       <ListItem
@@ -228,6 +237,30 @@ export function ModelConfigList(props: {
           }
         ></input>
       </ListItem>
+      <ListItem
+        title={Locale.Settings.CompressModel.Title}
+        subTitle={Locale.Settings.CompressModel.SubTitle}
+      >
+        <Select
+          aria-label={Locale.Settings.CompressModel.Title}
+          value={compressModelValue}
+          onChange={(e) => {
+            const [model, providerName] = e.currentTarget.value.split("@");
+            props.updateConfig((config) => {
+              config.compressModel = ModalConfigValidator.model(model);
+              config.compressProviderName = providerName as ServiceProvider;
+            });
+          }}
+        >
+          {allModels
+            .filter((v) => v.available)
+            .map((v, i) => (
+              <option value={`${v.name}@${v.provider?.providerName}`} key={i}>
+                {v.displayName}({v.provider?.providerName})
+              </option>
+            ))}
+        </Select>
+      </ListItem>
     </>
   );
 }

+ 16 - 0
app/components/plugin.module.scss

@@ -0,0 +1,16 @@
+.plugin-title {
+  font-weight: bolder;
+  font-size: 16px;
+  margin: 10px 0;
+}
+.plugin-content {
+  font-size: 14px;
+  font-family: inherit;
+  pre code {
+    max-height: 240px;
+    overflow-y: auto;
+    white-space: pre-wrap;
+    min-width: 300px;
+  }
+}
+

+ 393 - 0
app/components/plugin.tsx

@@ -0,0 +1,393 @@
+import { useDebouncedCallback } from "use-debounce";
+import OpenAPIClientAxios from "openapi-client-axios";
+import yaml from "js-yaml";
+import { PLUGINS_REPO_URL } from "../constant";
+import { IconButton } from "./button";
+import { ErrorBoundary } from "./error";
+
+import styles from "./mask.module.scss";
+import pluginStyles from "./plugin.module.scss";
+
+import EditIcon from "../icons/edit.svg";
+import AddIcon from "../icons/add.svg";
+import CloseIcon from "../icons/close.svg";
+import DeleteIcon from "../icons/delete.svg";
+import EyeIcon from "../icons/eye.svg";
+import ConfirmIcon from "../icons/confirm.svg";
+import ReloadIcon from "../icons/reload.svg";
+import GithubIcon from "../icons/github.svg";
+
+import { Plugin, usePluginStore, FunctionToolService } from "../store/plugin";
+import {
+  PasswordInput,
+  List,
+  ListItem,
+  Modal,
+  showConfirm,
+  showToast,
+} from "./ui-lib";
+import Locale from "../locales";
+import { useNavigate } from "react-router-dom";
+import { useEffect, useState } from "react";
+import { getClientConfig } from "../config/client";
+
+export function PluginPage() {
+  const navigate = useNavigate();
+  const pluginStore = usePluginStore();
+
+  const allPlugins = pluginStore.getAll();
+  const [searchPlugins, setSearchPlugins] = useState<Plugin[]>([]);
+  const [searchText, setSearchText] = useState("");
+  const plugins = searchText.length > 0 ? searchPlugins : allPlugins;
+
+  // refactored already, now it accurate
+  const onSearch = (text: string) => {
+    setSearchText(text);
+    if (text.length > 0) {
+      const result = allPlugins.filter(
+        (m) => m?.title.toLowerCase().includes(text.toLowerCase()),
+      );
+      setSearchPlugins(result);
+    } else {
+      setSearchPlugins(allPlugins);
+    }
+  };
+
+  const [editingPluginId, setEditingPluginId] = useState<string | undefined>();
+  const editingPlugin = pluginStore.get(editingPluginId);
+  const editingPluginTool = FunctionToolService.get(editingPlugin?.id);
+  const closePluginModal = () => setEditingPluginId(undefined);
+
+  const onChangePlugin = useDebouncedCallback((editingPlugin, e) => {
+    const content = e.target.innerText;
+    try {
+      const api = new OpenAPIClientAxios({
+        definition: yaml.load(content) as any,
+      });
+      api
+        .init()
+        .then(() => {
+          if (content != editingPlugin.content) {
+            pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
+              plugin.content = content;
+              const tool = FunctionToolService.add(plugin, true);
+              plugin.title = tool.api.definition.info.title;
+              plugin.version = tool.api.definition.info.version;
+            });
+          }
+        })
+        .catch((e) => {
+          console.error(e);
+          showToast(Locale.Plugin.EditModal.Error);
+        });
+    } catch (e) {
+      console.error(e);
+      showToast(Locale.Plugin.EditModal.Error);
+    }
+  }, 100).bind(null, editingPlugin);
+
+  const [loadUrl, setLoadUrl] = useState<string>("");
+  const loadFromUrl = (loadUrl: string) =>
+    fetch(loadUrl)
+      .catch((e) => {
+        const p = new URL(loadUrl);
+        return fetch(`/api/proxy/${p.pathname}?${p.search}`, {
+          headers: {
+            "X-Base-URL": p.origin,
+          },
+        });
+      })
+      .then((res) => res.text())
+      .then((content) => {
+        try {
+          return JSON.stringify(JSON.parse(content), null, "  ");
+        } catch (e) {
+          return content;
+        }
+      })
+      .then((content) => {
+        pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
+          plugin.content = content;
+          const tool = FunctionToolService.add(plugin, true);
+          plugin.title = tool.api.definition.info.title;
+          plugin.version = tool.api.definition.info.version;
+        });
+      })
+      .catch((e) => {
+        showToast(Locale.Plugin.EditModal.Error);
+      });
+
+  return (
+    <ErrorBoundary>
+      <div className={styles["mask-page"]}>
+        <div className="window-header">
+          <div className="window-header-title">
+            <div className="window-header-main-title">
+              {Locale.Plugin.Page.Title}
+            </div>
+            <div className="window-header-submai-title">
+              {Locale.Plugin.Page.SubTitle(plugins.length)}
+            </div>
+          </div>
+
+          <div className="window-actions">
+            <div className="window-action-button">
+              <a
+                href={PLUGINS_REPO_URL}
+                target="_blank"
+                rel="noopener noreferrer"
+              >
+                <IconButton icon={<GithubIcon />} bordered />
+              </a>
+            </div>
+            <div className="window-action-button">
+              <IconButton
+                icon={<CloseIcon />}
+                bordered
+                onClick={() => navigate(-1)}
+              />
+            </div>
+          </div>
+        </div>
+
+        <div className={styles["mask-page-body"]}>
+          <div className={styles["mask-filter"]}>
+            <input
+              type="text"
+              className={styles["search-bar"]}
+              placeholder={Locale.Plugin.Page.Search}
+              autoFocus
+              onInput={(e) => onSearch(e.currentTarget.value)}
+            />
+
+            <IconButton
+              className={styles["mask-create"]}
+              icon={<AddIcon />}
+              text={Locale.Plugin.Page.Create}
+              bordered
+              onClick={() => {
+                const createdPlugin = pluginStore.create();
+                setEditingPluginId(createdPlugin.id);
+              }}
+            />
+          </div>
+
+          <div>
+            {plugins.length == 0 && (
+              <div
+                style={{
+                  display: "flex",
+                  margin: "60px auto",
+                  alignItems: "center",
+                  justifyContent: "center",
+                }}
+              >
+                {Locale.Plugin.Page.Find}
+                <a
+                  href={PLUGINS_REPO_URL}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  style={{ marginLeft: 16 }}
+                >
+                  <IconButton icon={<GithubIcon />} bordered />
+                </a>
+              </div>
+            )}
+            {plugins.map((m) => (
+              <div className={styles["mask-item"]} key={m.id}>
+                <div className={styles["mask-header"]}>
+                  <div className={styles["mask-icon"]}></div>
+                  <div className={styles["mask-title"]}>
+                    <div className={styles["mask-name"]}>
+                      {m.title}@<small>{m.version}</small>
+                    </div>
+                    <div className={styles["mask-info"] + " one-line"}>
+                      {Locale.Plugin.Item.Info(
+                        FunctionToolService.add(m).length,
+                      )}
+                    </div>
+                  </div>
+                </div>
+                <div className={styles["mask-actions"]}>
+                  {m.builtin ? (
+                    <IconButton
+                      icon={<EyeIcon />}
+                      text={Locale.Plugin.Item.View}
+                      onClick={() => setEditingPluginId(m.id)}
+                    />
+                  ) : (
+                    <IconButton
+                      icon={<EditIcon />}
+                      text={Locale.Plugin.Item.Edit}
+                      onClick={() => setEditingPluginId(m.id)}
+                    />
+                  )}
+                  {!m.builtin && (
+                    <IconButton
+                      icon={<DeleteIcon />}
+                      text={Locale.Plugin.Item.Delete}
+                      onClick={async () => {
+                        if (
+                          await showConfirm(Locale.Plugin.Item.DeleteConfirm)
+                        ) {
+                          pluginStore.delete(m.id);
+                        }
+                      }}
+                    />
+                  )}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      </div>
+
+      {editingPlugin && (
+        <div className="modal-mask">
+          <Modal
+            title={Locale.Plugin.EditModal.Title(editingPlugin?.builtin)}
+            onClose={closePluginModal}
+            actions={[
+              <IconButton
+                icon={<ConfirmIcon />}
+                text={Locale.UI.Confirm}
+                key="export"
+                bordered
+                onClick={() => setEditingPluginId("")}
+              />,
+            ]}
+          >
+            <List>
+              <ListItem title={Locale.Plugin.EditModal.Auth}>
+                <select
+                  value={editingPlugin?.authType}
+                  onChange={(e) => {
+                    pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
+                      plugin.authType = e.target.value;
+                    });
+                  }}
+                >
+                  <option value="">{Locale.Plugin.Auth.None}</option>
+                  <option value="bearer">{Locale.Plugin.Auth.Bearer}</option>
+                  <option value="basic">{Locale.Plugin.Auth.Basic}</option>
+                  <option value="custom">{Locale.Plugin.Auth.Custom}</option>
+                </select>
+              </ListItem>
+              {["bearer", "basic", "custom"].includes(
+                editingPlugin.authType as string,
+              ) && (
+                <ListItem title={Locale.Plugin.Auth.Location}>
+                  <select
+                    value={editingPlugin?.authLocation}
+                    onChange={(e) => {
+                      pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
+                        plugin.authLocation = e.target.value;
+                      });
+                    }}
+                  >
+                    <option value="header">
+                      {Locale.Plugin.Auth.LocationHeader}
+                    </option>
+                    <option value="query">
+                      {Locale.Plugin.Auth.LocationQuery}
+                    </option>
+                    <option value="body">
+                      {Locale.Plugin.Auth.LocationBody}
+                    </option>
+                  </select>
+                </ListItem>
+              )}
+              {editingPlugin.authType == "custom" && (
+                <ListItem title={Locale.Plugin.Auth.CustomHeader}>
+                  <input
+                    type="text"
+                    value={editingPlugin?.authHeader}
+                    onChange={(e) => {
+                      pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
+                        plugin.authHeader = e.target.value;
+                      });
+                    }}
+                  ></input>
+                </ListItem>
+              )}
+              {["bearer", "basic", "custom"].includes(
+                editingPlugin.authType as string,
+              ) && (
+                <ListItem title={Locale.Plugin.Auth.Token}>
+                  <PasswordInput
+                    type="text"
+                    value={editingPlugin?.authToken}
+                    onChange={(e) => {
+                      pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
+                        plugin.authToken = e.currentTarget.value;
+                      });
+                    }}
+                  ></PasswordInput>
+                </ListItem>
+              )}
+              {!getClientConfig()?.isApp && (
+                <ListItem
+                  title={Locale.Plugin.Auth.Proxy}
+                  subTitle={Locale.Plugin.Auth.ProxyDescription}
+                >
+                  <input
+                    type="checkbox"
+                    checked={editingPlugin?.usingProxy}
+                    style={{ minWidth: 16 }}
+                    onChange={(e) => {
+                      pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
+                        plugin.usingProxy = e.currentTarget.checked;
+                      });
+                    }}
+                  ></input>
+                </ListItem>
+              )}
+            </List>
+            <List>
+              <ListItem title={Locale.Plugin.EditModal.Content}>
+                <div style={{ display: "flex", justifyContent: "flex-end" }}>
+                  <input
+                    type="text"
+                    style={{ minWidth: 200, marginRight: 20 }}
+                    onInput={(e) => setLoadUrl(e.currentTarget.value)}
+                  ></input>
+                  <IconButton
+                    icon={<ReloadIcon />}
+                    text={Locale.Plugin.EditModal.Load}
+                    bordered
+                    onClick={() => loadFromUrl(loadUrl)}
+                  />
+                </div>
+              </ListItem>
+              <ListItem
+                subTitle={
+                  <div
+                    className={`markdown-body ${pluginStyles["plugin-content"]}`}
+                    dir="auto"
+                  >
+                    <pre>
+                      <code
+                        contentEditable={true}
+                        dangerouslySetInnerHTML={{
+                          __html: editingPlugin.content,
+                        }}
+                        onBlur={onChangePlugin}
+                      ></code>
+                    </pre>
+                  </div>
+                }
+              ></ListItem>
+              {editingPluginTool?.tools.map((tool, index) => (
+                <ListItem
+                  key={index}
+                  title={tool?.function?.name}
+                  subTitle={tool?.function?.description}
+                />
+              ))}
+            </List>
+          </Modal>
+        </div>
+      )}
+    </ErrorBoundary>
+  );
+}

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

@@ -252,6 +252,12 @@
   position: relative;
   max-width: fit-content;
 
+  &.left-align-option {
+    option {
+      text-align: left;
+    }
+  }
+
   .select-with-icon-select {
     height: 100%;
     border: var(--border-in-light);

+ 11 - 5
app/components/ui-lib.tsx

@@ -50,8 +50,8 @@ export function Card(props: { children: JSX.Element[]; className?: string }) {
 }
 
 export function ListItem(props: {
-  title: string;
-  subTitle?: string;
+  title?: string;
+  subTitle?: string | JSX.Element;
   children?: JSX.Element | JSX.Element[];
   icon?: JSX.Element;
   className?: string;
@@ -292,13 +292,19 @@ export function PasswordInput(
 
 export function Select(
   props: React.DetailedHTMLProps<
-    React.SelectHTMLAttributes<HTMLSelectElement>,
+    React.SelectHTMLAttributes<HTMLSelectElement> & {
+      align?: "left" | "center";
+    },
     HTMLSelectElement
   >,
 ) {
-  const { className, children, ...otherProps } = props;
+  const { className, children, align, ...otherProps } = props;
   return (
-    <div className={`${styles["select-with-icon"]} ${className}`}>
+    <div
+      className={`${styles["select-with-icon"]} ${
+        align === "left" ? styles["left-align-option"] : ""
+      } ${className}`}
+    >
       <select className={styles["select-with-icon-select"]} {...otherProps}>
         {children}
       </select>

+ 5 - 2
app/config/server.ts

@@ -120,12 +120,15 @@ export const getServerSideConfig = () => {
   if (disableGPT4) {
     if (customModels) customModels += ",";
     customModels += DEFAULT_MODELS.filter(
-      (m) => m.name.startsWith("gpt-4") && !m.name.startsWith("gpt-4o-mini"),
+      (m) =>
+        (m.name.startsWith("gpt-4") || m.name.startsWith("chatgpt-4o")) &&
+        !m.name.startsWith("gpt-4o-mini"),
     )
       .map((m) => "-" + m.name)
       .join(",");
     if (
-      defaultModel.startsWith("gpt-4") &&
+      (defaultModel.startsWith("gpt-4") ||
+        defaultModel.startsWith("chatgpt-4o")) &&
       !defaultModel.startsWith("gpt-4o-mini")
     )
       defaultModel = "";

+ 10 - 4
app/constant.ts

@@ -3,6 +3,7 @@ import path from "path";
 export const OWNER = "ChatGPTNextWeb";
 export const REPO = "ChatGPT-Next-Web";
 export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
+export const PLUGINS_REPO_URL = `https://github.com/${OWNER}/NextChat-Awesome-Plugins`;
 export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
 export const UPDATE_URL = `${REPO_URL}#keep-updated`;
 export const RELEASE_URL = `${REPO_URL}/releases`;
@@ -39,6 +40,7 @@ export enum Path {
   Settings = "/settings",
   NewChat = "/new-chat",
   Masks = "/masks",
+  Plugins = "/plugins",
   Auth = "/auth",
   Sd = "/sd",
   SdNew = "/sd-new",
@@ -72,12 +74,9 @@ export enum FileName {
   Prompts = "prompts.json",
 }
 
-export enum Plugin {
-  Artifacts = "artifacts",
-}
-
 export enum StoreKey {
   Chat = "chat-next-web-store",
+  Plugin = "chat-next-web-plugin",
   Access = "access-control",
   Config = "app-config",
   Mask = "mask-store",
@@ -249,9 +248,12 @@ export const KnowledgeCutOffDate: Record<string, string> = {
   "gpt-4o": "2023-10",
   "gpt-4o-2024-05-13": "2023-10",
   "gpt-4o-2024-08-06": "2023-10",
+  "chatgpt-4o-latest": "2023-10",
   "gpt-4o-mini": "2023-10",
   "gpt-4o-mini-2024-07-18": "2023-10",
   "gpt-4-vision-preview": "2023-04",
+  "o1-mini": "2023-10",
+  "o1-preview": "2023-10",
   // After improvements,
   // it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
   "gemini-pro": "2023-12",
@@ -289,12 +291,15 @@ const openaiModels = [
   "gpt-4o",
   "gpt-4o-2024-05-13",
   "gpt-4o-2024-08-06",
+  "chatgpt-4o-latest",
   "gpt-4o-mini",
   "gpt-4o-mini-2024-07-18",
   "gpt-4-vision-preview",
   "gpt-4-turbo-2024-04-09",
   "gpt-4-1106-preview",
   "dall-e-3",
+  "o1-mini",
+  "o1-preview",
 ];
 
 const googleModels = [
@@ -499,6 +504,7 @@ export const internalAllowedWebDavEndpoints = [
 
 export const DEFAULT_GA_ID = "G-89WN60ZK2E";
 export const PLUGINS = [
+  { name: "Plugins", path: Path.Plugins },
   { name: "Stable Diffusion", path: Path.Sd },
   { name: "Search Chat", path: Path.SearchChat },
 ];

+ 7 - 1
app/global.d.ts

@@ -21,10 +21,16 @@ declare interface Window {
       writeBinaryFile(path: string, data: Uint8Array): Promise<void>;
       writeTextFile(path: string, data: string): Promise<void>;
     };
-    notification:{
+    notification: {
       requestPermission(): Promise<Permission>;
       isPermissionGranted(): Promise<boolean>;
       sendNotification(options: string | Options): void;
     };
+    http: {
+      fetch<T>(
+        url: string,
+        options?: Record<string, unknown>,
+      ): Promise<Response<T>>;
+    };
   };
 }

+ 1 - 0
app/icons/shortcutkey.svg

@@ -0,0 +1 @@
+<?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="M42 7H6C4.89543 7 4 7.89543 4 9V37C4 38.1046 4.89543 39 6 39H42C43.1046 39 44 38.1046 44 37V9C44 7.89543 43.1046 7 42 7Z" fill="none" stroke="#000" stroke-width="3" stroke-linejoin="round"/><path d="M12 19H14" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M21 19H23" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M29 19H36" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 28H36" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>

+ 5 - 1
app/layout.tsx

@@ -41,7 +41,11 @@ export default function RootLayout({
           name="viewport"
           content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
         />
-        <link rel="manifest" href="/site.webmanifest"></link>
+        <link
+          rel="manifest"
+          href="/site.webmanifest"
+          crossOrigin="use-credentials"
+        ></link>
         <script src="/serviceWorkerRegister.js" defer></script>
       </head>
       <body>

+ 6 - 0
app/locales/ar.ts

@@ -43,6 +43,8 @@ const ar: PartialLocaleType = {
       PinToastAction: "عرض",
       Delete: "حذف",
       Edit: "تحرير",
+      RefreshTitle: "تحديث العنوان",
+      RefreshToast: "تم إرسال طلب تحديث العنوان",
     },
     Commands: {
       new: "دردشة جديدة",
@@ -404,6 +406,10 @@ const ar: PartialLocaleType = {
     },
 
     Model: "النموذج",
+    CompressModel: {
+      Title: "نموذج الضغط",
+      SubTitle: "النموذج المستخدم لضغط السجل التاريخي",
+    },
     Temperature: {
       Title: "العشوائية (temperature)",
       SubTitle: "كلما زادت القيمة، زادت العشوائية في الردود",

+ 6 - 0
app/locales/bn.ts

@@ -43,6 +43,8 @@ const bn: PartialLocaleType = {
       PinToastAction: "দেখুন",
       Delete: "মুছে ফেলুন",
       Edit: "সম্পাদনা করুন",
+      RefreshTitle: "শিরোনাম রিফ্রেশ করুন",
+      RefreshToast: "শিরোনাম রিফ্রেশ অনুরোধ পাঠানো হয়েছে",
     },
     Commands: {
       new: "নতুন চ্যাট",
@@ -411,6 +413,10 @@ const bn: PartialLocaleType = {
     },
 
     Model: "মডেল (model)",
+    CompressModel: {
+      Title: "সংকোচন মডেল",
+      SubTitle: "ইতিহাস সংকুচিত করার জন্য ব্যবহৃত মডেল",
+    },
     Temperature: {
       Title: "যাদুকরিতা (temperature)",
       SubTitle: "মান বাড়ালে উত্তর বেশি এলোমেলো হবে",

+ 61 - 6
app/locales/cn.ts

@@ -1,3 +1,4 @@
+import { ShortcutKeyModal } from "../components/chat";
 import { getClientConfig } from "../config/client";
 import { SubmitKey } from "../store/config";
 
@@ -43,6 +44,8 @@ const cn = {
       Delete: "删除",
       Edit: "编辑",
       FullScreen: "全屏",
+      RefreshTitle: "刷新标题",
+      RefreshToast: "已发送刷新标题请求",
       Speech: "朗读",
       StopSpeech: "停止",
     },
@@ -85,6 +88,14 @@ const cn = {
       SaveAs: "存为面具",
     },
     IsContext: "预设提示词",
+    ShortcutKey: {
+      Title: "键盘快捷方式",
+      newChat: "打开新聊天",
+      focusInput: "聚焦输入框",
+      copyLastMessage: "复制最后一个回复",
+      copyLastCode: "复制最后一个代码块",
+      showShortcutKey: "显示快捷方式",
+    },
   },
   Export: {
     Title: "分享聊天记录",
@@ -465,6 +476,10 @@ const cn = {
     },
 
     Model: "模型 (model)",
+    CompressModel: {
+      Title: "压缩模型",
+      SubTitle: "用于压缩历史记录的模型",
+    },
     Temperature: {
       Title: "随机性 (temperature)",
       SubTitle: "值越大,回复越随机",
@@ -529,8 +544,8 @@ const cn = {
     },
   },
   Copy: {
-    Success: "已写入剪板",
-    Failed: "复制失败,请赋予剪板权限",
+    Success: "已写入剪板",
+    Failed: "复制失败,请赋予剪板权限",
   },
   Download: {
     Success: "内容已下载到您的目录。",
@@ -543,10 +558,6 @@ const cn = {
     Clear: "上下文已清除",
     Revert: "恢复上下文",
   },
-  Plugin: {
-    Name: "插件",
-    Artifacts: "Artifacts",
-  },
   Discovery: {
     Name: "发现",
   },
@@ -568,6 +579,46 @@ const cn = {
       View: "查看",
     },
   },
+  Plugin: {
+    Name: "插件",
+    Page: {
+      Title: "插件",
+      SubTitle: (count: number) => `${count} 个插件`,
+      Search: "搜索插件",
+      Create: "新建",
+      Find: "您可以在Github上找到优秀的插件:",
+    },
+    Item: {
+      Info: (count: number) => `${count} 方法`,
+      View: "查看",
+      Edit: "编辑",
+      Delete: "删除",
+      DeleteConfirm: "确认删除?",
+    },
+    Auth: {
+      None: "不需要授权",
+      Basic: "Basic",
+      Bearer: "Bearer",
+      Custom: "自定义",
+      CustomHeader: "自定义参数名称",
+      Token: "Token",
+      Proxy: "使用代理",
+      ProxyDescription: "使用代理解决 CORS 错误",
+      Location: "位置",
+      LocationHeader: "Header",
+      LocationQuery: "Query",
+      LocationBody: "Body",
+    },
+    EditModal: {
+      Title: (readonly: boolean) => `编辑插件 ${readonly ? "(只读)" : ""}`,
+      Download: "下载",
+      Auth: "授权方式",
+      Content: "OpenAPI Schema",
+      Load: "从网页加载",
+      Method: "方法",
+      Error: "格式错误",
+    },
+  },
   Mask: {
     Name: "面具",
     Page: {
@@ -602,6 +653,10 @@ const cn = {
         Title: "隐藏预设对话",
         SubTitle: "隐藏后预设对话不会出现在聊天界面",
       },
+      Artifacts: {
+        Title: "启用Artifacts",
+        SubTitle: "启用之后可以直接渲染HTML页面",
+      },
       Share: {
         Title: "分享此面具",
         SubTitle: "生成此面具的直达链接",

+ 6 - 0
app/locales/cs.ts

@@ -43,6 +43,8 @@ const cs: PartialLocaleType = {
       PinToastAction: "Zobrazit",
       Delete: "Smazat",
       Edit: "Upravit",
+      RefreshTitle: "Obnovit název",
+      RefreshToast: "Požadavek na obnovení názvu byl odeslán",
     },
     Commands: {
       new: "Nová konverzace",
@@ -410,6 +412,10 @@ const cs: PartialLocaleType = {
     },
 
     Model: "Model (model)",
+    CompressModel: {
+      Title: "Kompresní model",
+      SubTitle: "Model používaný pro kompresi historie",
+    },
     Temperature: {
       Title: "Náhodnost (temperature)",
       SubTitle: "Čím vyšší hodnota, tím náhodnější odpovědi",

+ 6 - 0
app/locales/de.ts

@@ -43,6 +43,8 @@ const de: PartialLocaleType = {
       PinToastAction: "Ansehen",
       Delete: "Löschen",
       Edit: "Bearbeiten",
+      RefreshTitle: "Titel aktualisieren",
+      RefreshToast: "Anfrage zur Titelaktualisierung gesendet",
     },
     Commands: {
       new: "Neues Gespräch",
@@ -421,6 +423,10 @@ const de: PartialLocaleType = {
     },
 
     Model: "Modell",
+    CompressModel: {
+      Title: "Kompressionsmodell",
+      SubTitle: "Modell zur Komprimierung des Verlaufs",
+    },
     Temperature: {
       Title: "Zufälligkeit (temperature)",
       SubTitle: "Je höher der Wert, desto zufälliger die Antwort",

+ 59 - 4
app/locales/en.ts

@@ -45,6 +45,8 @@ const en: LocaleType = {
       Delete: "Delete",
       Edit: "Edit",
       FullScreen: "FullScreen",
+      RefreshTitle: "Refresh Title",
+      RefreshToast: "Title refresh request sent",
       Speech: "Play",
       StopSpeech: "Stop",
     },
@@ -87,6 +89,14 @@ const en: LocaleType = {
       SaveAs: "Save as Mask",
     },
     IsContext: "Contextual Prompt",
+    ShortcutKey: {
+      Title: "Keyboard Shortcuts",
+      newChat: "Open New Chat",
+      focusInput: "Focus Input Field",
+      copyLastMessage: "Copy Last Reply",
+      copyLastCode: "Copy Last Code Block",
+      showShortcutKey: "Show Shortcuts",
+    },
   },
   Export: {
     Title: "Export Messages",
@@ -470,6 +480,10 @@ const en: LocaleType = {
     },
 
     Model: "Model",
+    CompressModel: {
+      Title: "Compression Model",
+      SubTitle: "Model used to compress history",
+    },
     Temperature: {
       Title: "Temperature",
       SubTitle: "A larger value makes the more random output",
@@ -552,10 +566,6 @@ const en: LocaleType = {
     Clear: "Context Cleared",
     Revert: "Revert",
   },
-  Plugin: {
-    Name: "Plugin",
-    Artifacts: "Artifacts",
-  },
   Discovery: {
     Name: "Discovery",
   },
@@ -577,6 +587,47 @@ const en: LocaleType = {
       View: "View",
     },
   },
+  Plugin: {
+    Name: "Plugin",
+    Page: {
+      Title: "Plugins",
+      SubTitle: (count: number) => `${count} plugins`,
+      Search: "Search Plugin",
+      Create: "Create",
+      Find: "You can find awesome plugins on github: ",
+    },
+    Item: {
+      Info: (count: number) => `${count} method`,
+      View: "View",
+      Edit: "Edit",
+      Delete: "Delete",
+      DeleteConfirm: "Confirm to delete?",
+    },
+    Auth: {
+      None: "None",
+      Basic: "Basic",
+      Bearer: "Bearer",
+      Custom: "Custom",
+      CustomHeader: "Parameter Name",
+      Token: "Token",
+      Proxy: "Using Proxy",
+      ProxyDescription: "Using proxies to solve CORS error",
+      Location: "Location",
+      LocationHeader: "Header",
+      LocationQuery: "Query",
+      LocationBody: "Body",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `Edit Plugin ${readonly ? "(readonly)" : ""}`,
+      Download: "Download",
+      Auth: "Authentication Type",
+      Content: "OpenAPI Schema",
+      Load: "Load From URL",
+      Method: "Method",
+      Error: "OpenAPI Schema Error",
+    },
+  },
   Mask: {
     Name: "Mask",
     Page: {
@@ -611,6 +662,10 @@ const en: LocaleType = {
         Title: "Hide Context Prompts",
         SubTitle: "Do not show in-context prompts in chat",
       },
+      Artifacts: {
+        Title: "Enable Artifacts",
+        SubTitle: "Can render HTML page when enable artifacts.",
+      },
       Share: {
         Title: "Share This Mask",
         SubTitle: "Generate a link to this mask",

+ 6 - 0
app/locales/es.ts

@@ -44,6 +44,8 @@ const es: PartialLocaleType = {
       PinToastAction: "Ver",
       Delete: "Eliminar",
       Edit: "Editar",
+      RefreshTitle: "Actualizar título",
+      RefreshToast: "Se ha enviado la solicitud de actualización del título",
     },
     Commands: {
       new: "Nueva conversación",
@@ -423,6 +425,10 @@ const es: PartialLocaleType = {
     },
 
     Model: "Modelo (model)",
+    CompressModel: {
+      Title: "Modelo de compresión",
+      SubTitle: "Modelo utilizado para comprimir el historial",
+    },
     Temperature: {
       Title: "Aleatoriedad (temperature)",
       SubTitle: "Cuanto mayor sea el valor, más aleatorio será el resultado",

+ 6 - 0
app/locales/fr.ts

@@ -43,6 +43,8 @@ const fr: PartialLocaleType = {
       PinToastAction: "Voir",
       Delete: "Supprimer",
       Edit: "Modifier",
+      RefreshTitle: "Actualiser le titre",
+      RefreshToast: "Demande d'actualisation du titre envoyée",
     },
     Commands: {
       new: "Nouvelle discussion",
@@ -422,6 +424,10 @@ const fr: PartialLocaleType = {
     },
 
     Model: "Modèle",
+    CompressModel: {
+      Title: "Modèle de compression",
+      SubTitle: "Modèle utilisé pour compresser l'historique",
+    },
     Temperature: {
       Title: "Aléatoire (temperature)",
       SubTitle: "Plus la valeur est élevée, plus les réponses sont aléatoires",

+ 6 - 0
app/locales/id.ts

@@ -43,6 +43,8 @@ const id: PartialLocaleType = {
       PinToastAction: "Lihat",
       Delete: "Hapus",
       Edit: "Edit",
+      RefreshTitle: "Segarkan Judul",
+      RefreshToast: "Permintaan penyegaran judul telah dikirim",
     },
     Commands: {
       new: "Obrolan Baru",
@@ -411,6 +413,10 @@ const id: PartialLocaleType = {
     },
 
     Model: "Model",
+    CompressModel: {
+      Title: "Model Kompresi",
+      SubTitle: "Model yang digunakan untuk mengompres riwayat",
+    },
     Temperature: {
       Title: "Randomness (temperature)",
       SubTitle: "Semakin tinggi nilainya, semakin acak responsnya",

+ 5 - 8
app/locales/index.ts

@@ -18,10 +18,13 @@ import ar from "./ar";
 import bn from "./bn";
 import sk from "./sk";
 import { merge } from "../utils/merge";
+import { safeLocalStorage } from "@/app/utils";
 
 import type { LocaleType } from "./cn";
 export type { LocaleType, PartialLocaleType } from "./cn";
 
+const localStorage = safeLocalStorage();
+
 const ALL_LANGS = {
   cn,
   en,
@@ -82,17 +85,11 @@ merge(fallbackLang, targetLang);
 export default fallbackLang as LocaleType;
 
 function getItem(key: string) {
-  try {
-    return localStorage.getItem(key);
-  } catch {
-    return null;
-  }
+  return localStorage.getItem(key);
 }
 
 function setItem(key: string, value: string) {
-  try {
-    localStorage.setItem(key, value);
-  } catch {}
+  localStorage.setItem(key, value);
 }
 
 function getLanguage() {

+ 6 - 0
app/locales/it.ts

@@ -43,6 +43,8 @@ const it: PartialLocaleType = {
       PinToastAction: "Visualizza",
       Delete: "Elimina",
       Edit: "Modifica",
+      RefreshTitle: "Aggiorna titolo",
+      RefreshToast: "Richiesta di aggiornamento del titolo inviata",
     },
     Commands: {
       new: "Nuova chat",
@@ -423,6 +425,10 @@ const it: PartialLocaleType = {
     },
 
     Model: "Modello (model)",
+    CompressModel: {
+      Title: "Modello di compressione",
+      SubTitle: "Modello utilizzato per comprimere la cronologia",
+    },
     Temperature: {
       Title: "Casualità (temperature)",
       SubTitle: "Valore più alto, risposte più casuali",

+ 6 - 0
app/locales/jp.ts

@@ -43,6 +43,8 @@ const jp: PartialLocaleType = {
       PinToastAction: "見る",
       Delete: "削除",
       Edit: "編集",
+      RefreshTitle: "タイトルを更新",
+      RefreshToast: "タイトル更新リクエストが送信されました",
     },
     Commands: {
       new: "新しいチャット",
@@ -407,6 +409,10 @@ const jp: PartialLocaleType = {
     },
 
     Model: "モデル (model)",
+    CompressModel: {
+      Title: "圧縮モデル",
+      SubTitle: "履歴を圧縮するために使用されるモデル",
+    },
     Temperature: {
       Title: "ランダム性 (temperature)",
       SubTitle: "値が大きいほど応答がランダムになります",

+ 6 - 0
app/locales/ko.ts

@@ -43,6 +43,8 @@ const ko: PartialLocaleType = {
       PinToastAction: "보기",
       Delete: "삭제",
       Edit: "편집",
+      RefreshTitle: "제목 새로고침",
+      RefreshToast: "제목 새로고침 요청이 전송되었습니다",
     },
     Commands: {
       new: "새 채팅",
@@ -404,6 +406,10 @@ const ko: PartialLocaleType = {
     },
 
     Model: "모델 (model)",
+    CompressModel: {
+      Title: "압축 모델",
+      SubTitle: "기록을 압축하는 데 사용되는 모델",
+    },
     Temperature: {
       Title: "무작위성 (temperature)",
       SubTitle: "값이 클수록 응답이 더 무작위적",

+ 6 - 0
app/locales/no.ts

@@ -44,6 +44,8 @@ const no: PartialLocaleType = {
       PinToastAction: "Se",
       Delete: "Slett",
       Edit: "Rediger",
+      RefreshTitle: "Oppdater tittel",
+      RefreshToast: "Forespørsel om titteloppdatering sendt",
     },
     Commands: {
       new: "Ny samtale",
@@ -415,6 +417,10 @@ const no: PartialLocaleType = {
     },
 
     Model: "Modell",
+    CompressModel: {
+      Title: "Komprimeringsmodell",
+      SubTitle: "Modell brukt for å komprimere historikken",
+    },
     Temperature: {
       Title: "Tilfeldighet (temperature)",
       SubTitle: "Høyere verdi gir mer tilfeldige svar",

+ 6 - 0
app/locales/pt.ts

@@ -43,6 +43,8 @@ const pt: PartialLocaleType = {
       PinToastAction: "Visualizar",
       Delete: "Deletar",
       Edit: "Editar",
+      RefreshTitle: "Atualizar Título",
+      RefreshToast: "Solicitação de atualização de título enviada",
     },
     Commands: {
       new: "Iniciar um novo chat",
@@ -346,6 +348,10 @@ const pt: PartialLocaleType = {
     },
 
     Model: "Modelo",
+    CompressModel: {
+      Title: "Modelo de Compressão",
+      SubTitle: "Modelo usado para comprimir o histórico",
+    },
     Temperature: {
       Title: "Temperatura",
       SubTitle: "Um valor maior torna a saída mais aleatória",

+ 6 - 0
app/locales/ru.ts

@@ -43,6 +43,8 @@ const ru: PartialLocaleType = {
       PinToastAction: "Просмотреть",
       Delete: "Удалить",
       Edit: "Редактировать",
+      RefreshTitle: "Обновить заголовок",
+      RefreshToast: "Запрос на обновление заголовка отправлен",
     },
     Commands: {
       new: "Новый чат",
@@ -414,6 +416,10 @@ const ru: PartialLocaleType = {
     },
 
     Model: "Модель",
+    CompressModel: {
+      Title: "Модель сжатия",
+      SubTitle: "Модель, используемая для сжатия истории",
+    },
     Temperature: {
       Title: "Случайность (temperature)",
       SubTitle: "Чем больше значение, тем более случайные ответы",

+ 6 - 0
app/locales/sk.ts

@@ -45,6 +45,8 @@ const sk: PartialLocaleType = {
       PinToastAction: "Zobraziť",
       Delete: "Vymazať",
       Edit: "Upraviť",
+      RefreshTitle: "Obnoviť názov",
+      RefreshToast: "Požiadavka na obnovenie názvu bola odoslaná",
     },
     Commands: {
       new: "Začať nový chat",
@@ -365,6 +367,10 @@ const sk: PartialLocaleType = {
     },
 
     Model: "Model",
+    CompressModel: {
+      Title: "Kompresný model",
+      SubTitle: "Model používaný na kompresiu histórie",
+    },
     Temperature: {
       Title: "Teplota",
       SubTitle: "Vyššia hodnota robí výstup náhodnejším",

+ 6 - 0
app/locales/tr.ts

@@ -43,6 +43,8 @@ const tr: PartialLocaleType = {
       PinToastAction: "Görünüm",
       Delete: "Sil",
       Edit: "Düzenle",
+      RefreshTitle: "Başlığı Yenile",
+      RefreshToast: "Başlık yenileme isteği gönderildi",
     },
     Commands: {
       new: "Yeni sohbet",
@@ -414,6 +416,10 @@ const tr: PartialLocaleType = {
     },
 
     Model: "Model (model)",
+    CompressModel: {
+      Title: "Sıkıştırma Modeli",
+      SubTitle: "Geçmişi sıkıştırmak için kullanılan model",
+    },
     Temperature: {
       Title: "Rastgelelik (temperature)",
       SubTitle: "Değer arttıkça yanıt daha rastgele olur",

+ 14 - 0
app/locales/tw.ts

@@ -43,6 +43,8 @@ const tw = {
       PinToastAction: "檢視",
       Delete: "刪除",
       Edit: "編輯",
+      RefreshTitle: "刷新標題",
+      RefreshToast: "已發送刷新標題請求",
     },
     Commands: {
       new: "新建聊天",
@@ -81,6 +83,14 @@ const tw = {
       SaveAs: "另存新檔",
     },
     IsContext: "預設提示詞",
+    ShortcutKey: {
+      Title: "鍵盤快捷方式",
+      newChat: "打開新聊天",
+      focusInput: "聚焦輸入框",
+      copyLastMessage: "複製最後一個回覆",
+      copyLastCode: "複製最後一個代碼塊",
+      showShortcutKey: "顯示快捷方式",
+    },
   },
   Export: {
     Title: "將聊天記錄匯出為 Markdown",
@@ -360,6 +370,10 @@ const tw = {
     },
 
     Model: "模型 (model)",
+    CompressModel: {
+      Title: "壓縮模型",
+      SubTitle: "用於壓縮歷史記錄的模型",
+    },
     Temperature: {
       Title: "隨機性 (temperature)",
       SubTitle: "值越大,回應越隨機",

+ 6 - 0
app/locales/vi.ts

@@ -43,6 +43,8 @@ const vi: PartialLocaleType = {
       PinToastAction: "Xem",
       Delete: "Xóa",
       Edit: "Chỉnh sửa",
+      RefreshTitle: "Làm mới tiêu đề",
+      RefreshToast: "Đã gửi yêu cầu làm mới tiêu đề",
     },
     Commands: {
       new: "Tạo cuộc trò chuyện mới",
@@ -410,6 +412,10 @@ const vi: PartialLocaleType = {
     },
 
     Model: "Mô hình (model)",
+    CompressModel: {
+      Title: "Mô hình nén",
+      SubTitle: "Mô hình được sử dụng để nén lịch sử",
+    },
     Temperature: {
       Title: "Độ ngẫu nhiên (temperature)",
       SubTitle: "Giá trị càng lớn, câu trả lời càng ngẫu nhiên",

+ 90 - 55
app/store/chat.ts

@@ -1,32 +1,43 @@
-import { trimTopic, getMessageTextContent } from "../utils";
+import { getMessageTextContent, trimTopic } from "../utils";
 
-import Locale, { getLang } from "../locales";
+import { indexedDBStorage } from "@/app/utils/indexedDB-storage";
+import { nanoid } from "nanoid";
+import type {
+  ClientApi,
+  MultimodalContent,
+  RequestMessage,
+} from "../client/api";
+import { getClientApi } from "../client/api";
+import { ChatControllerPool } from "../client/controller";
 import { showToast } from "../components/ui-lib";
-import { ModelConfig, ModelType, useAppConfig } from "./config";
-import { createEmptyMask, Mask } from "./mask";
 import {
   DEFAULT_INPUT_TEMPLATE,
   DEFAULT_MODELS,
   DEFAULT_SYSTEM_TEMPLATE,
   KnowledgeCutOffDate,
   StoreKey,
-  SUMMARIZE_MODEL,
-  GEMINI_SUMMARIZE_MODEL,
 } from "../constant";
-import { getClientApi } from "../client/api";
-import type {
-  ClientApi,
-  RequestMessage,
-  MultimodalContent,
-} from "../client/api";
-import { ChatControllerPool } from "../client/controller";
+import Locale, { getLang } from "../locales";
+import { isDalle3, safeLocalStorage } from "../utils";
 import { prettyObject } from "../utils/format";
-import { estimateTokenLength } from "../utils/token";
-import { nanoid } from "nanoid";
 import { createPersistStore } from "../utils/store";
-import { collectModelsWithDefaultModel } from "../utils/model";
-import { useAccessStore } from "./access";
-import { isDalle3 } from "../utils";
+import { estimateTokenLength } from "../utils/token";
+import { ModelConfig, ModelType, useAppConfig } from "./config";
+import { createEmptyMask, Mask } from "./mask";
+
+const localStorage = safeLocalStorage();
+
+export type ChatMessageTool = {
+  id: string;
+  index?: number;
+  type?: string;
+  function?: {
+    name: string;
+    arguments?: string;
+  };
+  content?: string;
+  isError?: boolean;
+};
 
 export type ChatMessage = RequestMessage & {
   date: string;
@@ -34,6 +45,7 @@ export type ChatMessage = RequestMessage & {
   isError?: boolean;
   id: string;
   model?: ModelType;
+  tools?: ChatMessageTool[];
 };
 
 export function createMessage(override: Partial<ChatMessage>): ChatMessage {
@@ -90,27 +102,6 @@ function createEmptySession(): ChatSession {
   };
 }
 
-function getSummarizeModel(currentModel: string) {
-  // if it is using gpt-* models, force to use 4o-mini to summarize
-  if (currentModel.startsWith("gpt")) {
-    const configStore = useAppConfig.getState();
-    const accessStore = useAccessStore.getState();
-    const allModel = collectModelsWithDefaultModel(
-      configStore.models,
-      [configStore.customModels, accessStore.customModels].join(","),
-      accessStore.defaultModel,
-    );
-    const summarizeModel = allModel.find(
-      (m) => m.name === SUMMARIZE_MODEL && m.available,
-    );
-    return summarizeModel?.name ?? currentModel;
-  }
-  if (currentModel.startsWith("gemini")) {
-    return GEMINI_SUMMARIZE_MODEL;
-  }
-  return currentModel;
-}
-
 function countMessages(msgs: ChatMessage[]) {
   return msgs.reduce(
     (pre, cur) => pre + estimateTokenLength(getMessageTextContent(cur)),
@@ -165,6 +156,7 @@ function fillTemplateWith(input: string, modelConfig: ModelConfig) {
 const DEFAULT_CHAT_STATE = {
   sessions: [createEmptySession()],
   currentSessionIndex: 0,
+  lastInput: "",
 };
 
 export const useChatStore = createPersistStore(
@@ -389,8 +381,24 @@ export const useChatStore = createPersistStore(
             }
             ChatControllerPool.remove(session.id, botMessage.id);
           },
+          onBeforeTool(tool: ChatMessageTool) {
+            (botMessage.tools = botMessage?.tools || []).push(tool);
+            get().updateCurrentSession((session) => {
+              session.messages = session.messages.concat();
+            });
+          },
+          onAfterTool(tool: ChatMessageTool) {
+            botMessage?.tools?.forEach((t, i, tools) => {
+              if (tool.id == t.id) {
+                tools[i] = { ...tool };
+              }
+            });
+            get().updateCurrentSession((session) => {
+              session.messages = session.messages.concat();
+            });
+          },
           onError(error) {
-            const isAborted = error.message.includes("aborted");
+            const isAborted = error.message?.includes?.("aborted");
             botMessage.content +=
               "\n\n" +
               prettyObject({
@@ -446,7 +454,8 @@ export const useChatStore = createPersistStore(
         // system prompts, to get close to OpenAI Web ChatGPT
         const shouldInjectSystemPrompts =
           modelConfig.enableInjectSystemPrompts &&
-          session.mask.modelConfig.model.startsWith("gpt-");
+          (session.mask.modelConfig.model.startsWith("gpt-") ||
+            session.mask.modelConfig.model.startsWith("chatgpt-"));
 
         var systemPrompts: ChatMessage[] = [];
         systemPrompts = shouldInjectSystemPrompts
@@ -538,7 +547,7 @@ export const useChatStore = createPersistStore(
         });
       },
 
-      summarizeSession() {
+      summarizeSession(refreshTitle: boolean = false) {
         const config = useAppConfig.getState();
         const session = get().currentSession();
         const modelConfig = session.mask.modelConfig;
@@ -547,7 +556,7 @@ export const useChatStore = createPersistStore(
           return;
         }
 
-        const providerName = modelConfig.providerName;
+        const providerName = modelConfig.compressProviderName;
         const api: ClientApi = getClientApi(providerName);
 
         // remove error messages if any
@@ -556,20 +565,30 @@ export const useChatStore = createPersistStore(
         // should summarize topic after chating more than 50 words
         const SUMMARIZE_MIN_LEN = 50;
         if (
-          config.enableAutoGenerateTitle &&
-          session.topic === DEFAULT_TOPIC &&
-          countMessages(messages) >= SUMMARIZE_MIN_LEN
+          (config.enableAutoGenerateTitle &&
+            session.topic === DEFAULT_TOPIC &&
+            countMessages(messages) >= SUMMARIZE_MIN_LEN) ||
+          refreshTitle
         ) {
-          const topicMessages = messages.concat(
-            createMessage({
-              role: "user",
-              content: Locale.Store.Prompt.Topic,
-            }),
+          const startIndex = Math.max(
+            0,
+            messages.length - modelConfig.historyMessageCount,
           );
+          const topicMessages = messages
+            .slice(
+              startIndex < messages.length ? startIndex : messages.length - 1,
+              messages.length,
+            )
+            .concat(
+              createMessage({
+                role: "user",
+                content: Locale.Store.Prompt.Topic,
+              }),
+            );
           api.llm.chat({
             messages: topicMessages,
             config: {
-              model: getSummarizeModel(session.mask.modelConfig.model),
+              model: modelConfig.compressModel,
               stream: false,
               providerName,
             },
@@ -632,7 +651,7 @@ export const useChatStore = createPersistStore(
             config: {
               ...modelcfg,
               stream: true,
-              model: getSummarizeModel(session.mask.modelConfig.model),
+              model: modelConfig.compressModel,
             },
             onUpdate(message) {
               session.memoryPrompt = message;
@@ -665,17 +684,23 @@ export const useChatStore = createPersistStore(
         set(() => ({ sessions }));
       },
 
-      clearAllData() {
+      async clearAllData() {
+        await indexedDBStorage.clear();
         localStorage.clear();
         location.reload();
       },
+      setLastInput(lastInput: string) {
+        set({
+          lastInput,
+        });
+      },
     };
 
     return methods;
   },
   {
     name: StoreKey.Chat,
-    version: 3.1,
+    version: 3.2,
     migrate(persistedState, version) {
       const state = persistedState as any;
       const newState = JSON.parse(
@@ -722,6 +747,16 @@ export const useChatStore = createPersistStore(
         });
       }
 
+      // add default summarize model for every session
+      if (version < 3.2) {
+        newState.sessions.forEach((s) => {
+          const config = useAppConfig.getState();
+          s.mask.modelConfig.compressModel = config.modelConfig.compressModel;
+          s.mask.modelConfig.compressProviderName =
+            config.modelConfig.compressProviderName;
+        });
+      }
+
       return newState as any;
     },
   },

+ 11 - 2
app/store/config.ts

@@ -63,7 +63,7 @@ export const DEFAULT_CONFIG = {
   models: DEFAULT_MODELS as any as LLMModel[],
 
   modelConfig: {
-    model: "gpt-3.5-turbo" as ModelType,
+    model: "gpt-4o-mini" as ModelType,
     providerName: "OpenAI" as ServiceProvider,
     temperature: 0.5,
     top_p: 1,
@@ -73,6 +73,8 @@ export const DEFAULT_CONFIG = {
     sendMemory: true,
     historyMessageCount: 4,
     compressMessageLengthThreshold: 1000,
+    compressModel: "gpt-4o-mini" as ModelType,
+    compressProviderName: "OpenAI" as ServiceProvider,
     enableInjectSystemPrompts: true,
     template: config?.template ?? DEFAULT_INPUT_TEMPLATE,
     size: "1024x1024" as DalleSize,
@@ -189,7 +191,7 @@ export const useAppConfig = createPersistStore(
   }),
   {
     name: StoreKey.Config,
-    version: 3.9,
+    version: 4,
     migrate(persistedState, version) {
       const state = persistedState as ChatConfig;
 
@@ -227,6 +229,13 @@ export const useAppConfig = createPersistStore(
             : config?.template ?? DEFAULT_INPUT_TEMPLATE;
       }
 
+      if (version < 4) {
+        state.modelConfig.compressModel =
+          DEFAULT_CONFIG.modelConfig.compressModel;
+        state.modelConfig.compressProviderName =
+          DEFAULT_CONFIG.modelConfig.compressProviderName;
+      }
+
       return state as any;
     },
   },

+ 1 - 0
app/store/index.ts

@@ -2,3 +2,4 @@ export * from "./chat";
 export * from "./update";
 export * from "./access";
 export * from "./config";
+export * from "./plugin";

+ 13 - 4
app/store/mask.ts

@@ -2,7 +2,7 @@ import { BUILTIN_MASKS } from "../masks";
 import { getLang, Lang } from "../locales";
 import { DEFAULT_TOPIC, ChatMessage } from "./chat";
 import { ModelConfig, useAppConfig } from "./config";
-import { StoreKey, Plugin } from "../constant";
+import { StoreKey } from "../constant";
 import { nanoid } from "nanoid";
 import { createPersistStore } from "../utils/store";
 
@@ -17,14 +17,18 @@ export type Mask = {
   modelConfig: ModelConfig;
   lang: Lang;
   builtin: boolean;
-  plugin?: Plugin[];
+  plugin?: string[];
+  enableArtifacts?: boolean;
 };
 
 export const DEFAULT_MASK_STATE = {
   masks: {} as Record<string, Mask>,
+  language: undefined as Lang | undefined,
 };
 
-export type MaskState = typeof DEFAULT_MASK_STATE;
+export type MaskState = typeof DEFAULT_MASK_STATE & {
+  language?: Lang | undefined;
+};
 
 export const DEFAULT_MASK_AVATAR = "gpt-bot";
 export const createEmptyMask = () =>
@@ -38,7 +42,7 @@ export const createEmptyMask = () =>
     lang: getLang(),
     builtin: false,
     createdAt: Date.now(),
-    plugin: [Plugin.Artifacts],
+    plugin: [],
   }) as Mask;
 
 export const useMaskStore = createPersistStore(
@@ -101,6 +105,11 @@ export const useMaskStore = createPersistStore(
     search(text: string) {
       return Object.values(get().masks);
     },
+    setLanguage(language: Lang | undefined) {
+      set({
+        language,
+      });
+    },
   }),
   {
     name: StoreKey.Mask,

+ 225 - 0
app/store/plugin.ts

@@ -0,0 +1,225 @@
+import OpenAPIClientAxios from "openapi-client-axios";
+import { getLang, Lang } from "../locales";
+import { StoreKey } from "../constant";
+import { nanoid } from "nanoid";
+import { createPersistStore } from "../utils/store";
+import yaml from "js-yaml";
+import { adapter } from "../utils";
+
+export type Plugin = {
+  id: string;
+  createdAt: number;
+  title: string;
+  version: string;
+  content: string;
+  builtin: boolean;
+  authType?: string;
+  authLocation?: string;
+  authHeader?: string;
+  authToken?: string;
+  usingProxy?: boolean;
+};
+
+export type FunctionToolItem = {
+  type: string;
+  function: {
+    name: string;
+    description?: string;
+    parameters: Object;
+  };
+};
+
+type FunctionToolServiceItem = {
+  api: OpenAPIClientAxios;
+  length: number;
+  tools: FunctionToolItem[];
+  funcs: Record<string, Function>;
+};
+
+export const FunctionToolService = {
+  tools: {} as Record<string, FunctionToolServiceItem>,
+  add(plugin: Plugin, replace = false) {
+    if (!replace && this.tools[plugin.id]) return this.tools[plugin.id];
+    const headerName = (
+      plugin?.authType == "custom" ? plugin?.authHeader : "Authorization"
+    ) as string;
+    const tokenValue =
+      plugin?.authType == "basic"
+        ? `Basic ${plugin?.authToken}`
+        : plugin?.authType == "bearer"
+        ? ` Bearer ${plugin?.authToken}`
+        : plugin?.authToken;
+    const authLocation = plugin?.authLocation || "header";
+    const definition = yaml.load(plugin.content) as any;
+    const serverURL = definition?.servers?.[0]?.url;
+    const baseURL = !!plugin?.usingProxy ? "/api/proxy" : serverURL;
+    const headers: Record<string, string | undefined> = {
+      "X-Base-URL": !!plugin?.usingProxy ? serverURL : undefined,
+    };
+    if (authLocation == "header") {
+      headers[headerName] = tokenValue;
+    }
+    const api = new OpenAPIClientAxios({
+      definition: yaml.load(plugin.content) as any,
+      axiosConfigDefaults: {
+        adapter: (window.__TAURI__ ? adapter : ["xhr"]) as any,
+        baseURL,
+        headers,
+      },
+    });
+    try {
+      api.initSync();
+    } catch (e) {}
+    const operations = api.getOperations();
+    return (this.tools[plugin.id] = {
+      api,
+      length: operations.length,
+      tools: operations.map((o) => {
+        // @ts-ignore
+        const parameters = o?.requestBody?.content["application/json"]
+          ?.schema || {
+          type: "object",
+          properties: {},
+        };
+        if (!parameters["required"]) {
+          parameters["required"] = [];
+        }
+        if (o.parameters instanceof Array) {
+          o.parameters.forEach((p) => {
+            // @ts-ignore
+            if (p?.in == "query" || p?.in == "path") {
+              // const name = `${p.in}__${p.name}`
+              // @ts-ignore
+              const name = p?.name;
+              parameters["properties"][name] = {
+                // @ts-ignore
+                type: p.schema.type,
+                // @ts-ignore
+                description: p.description,
+              };
+              // @ts-ignore
+              if (p.required) {
+                parameters["required"].push(name);
+              }
+            }
+          });
+        }
+        return {
+          type: "function",
+          function: {
+            name: o.operationId,
+            description: o.description || o.summary,
+            parameters: parameters,
+          },
+        } as FunctionToolItem;
+      }),
+      funcs: operations.reduce((s, o) => {
+        // @ts-ignore
+        s[o.operationId] = function (args) {
+          const parameters: Record<string, any> = {};
+          if (o.parameters instanceof Array) {
+            o.parameters.forEach((p) => {
+              // @ts-ignore
+              parameters[p?.name] = args[p?.name];
+              // @ts-ignore
+              delete args[p?.name];
+            });
+          }
+          if (authLocation == "query") {
+            parameters[headerName] = tokenValue;
+          } else if (authLocation == "body") {
+            args[headerName] = tokenValue;
+          }
+          // @ts-ignore
+          return api.client[o.operationId](
+            parameters,
+            args,
+            api.axiosConfigDefaults,
+          );
+        };
+        return s;
+      }, {}),
+    });
+  },
+  get(id: string) {
+    return this.tools[id];
+  },
+};
+
+export const createEmptyPlugin = () =>
+  ({
+    id: nanoid(),
+    title: "",
+    version: "1.0.0",
+    content: "",
+    builtin: false,
+    createdAt: Date.now(),
+  }) as Plugin;
+
+export const DEFAULT_PLUGIN_STATE = {
+  plugins: {} as Record<string, Plugin>,
+};
+
+export const usePluginStore = createPersistStore(
+  { ...DEFAULT_PLUGIN_STATE },
+
+  (set, get) => ({
+    create(plugin?: Partial<Plugin>) {
+      const plugins = get().plugins;
+      const id = nanoid();
+      plugins[id] = {
+        ...createEmptyPlugin(),
+        ...plugin,
+        id,
+        builtin: false,
+      };
+
+      set(() => ({ plugins }));
+      get().markUpdate();
+
+      return plugins[id];
+    },
+    updatePlugin(id: string, updater: (plugin: Plugin) => void) {
+      const plugins = get().plugins;
+      const plugin = plugins[id];
+      if (!plugin) return;
+      const updatePlugin = { ...plugin };
+      updater(updatePlugin);
+      plugins[id] = updatePlugin;
+      FunctionToolService.add(updatePlugin, true);
+      set(() => ({ plugins }));
+      get().markUpdate();
+    },
+    delete(id: string) {
+      const plugins = get().plugins;
+      delete plugins[id];
+      set(() => ({ plugins }));
+      get().markUpdate();
+    },
+
+    getAsTools(ids: string[]) {
+      const plugins = get().plugins;
+      const selected = (ids || [])
+        .map((id) => plugins[id])
+        .filter((i) => i)
+        .map((p) => FunctionToolService.add(p));
+      return [
+        // @ts-ignore
+        selected.reduce((s, i) => s.concat(i.tools), []),
+        selected.reduce((s, i) => Object.assign(s, i.funcs), {}),
+      ];
+    },
+    get(id?: string) {
+      return get().plugins[id ?? 1145141919810];
+    },
+    getAll() {
+      return Object.values(get().plugins).sort(
+        (a, b) => b.createdAt - a.createdAt,
+      );
+    },
+  }),
+  {
+    name: StoreKey.Plugin,
+    version: 1,
+  },
+);

+ 108 - 0
app/utils.ts

@@ -2,6 +2,9 @@ import { useEffect, useState } from "react";
 import { showToast } from "./components/ui-lib";
 import Locale from "./locales";
 import { RequestMessage } from "./client/api";
+import { ServiceProvider, REQUEST_TIMEOUT_MS } from "./constant";
+import isObject from "lodash-es/isObject";
+import { fetch as tauriFetch, Body, ResponseType } from "@tauri-apps/api/http";
 
 export function trimTopic(topic: string) {
   // Fix an issue where double quotes still show in the Indonesian language
@@ -270,3 +273,108 @@ export function isVisionModel(model: string) {
 export function isDalle3(model: string) {
   return "dall-e-3" === model;
 }
+
+export function showPlugins(provider: ServiceProvider, model: string) {
+  if (
+    provider == ServiceProvider.OpenAI ||
+    provider == ServiceProvider.Azure ||
+    provider == ServiceProvider.Moonshot
+  ) {
+    return true;
+  }
+  if (provider == ServiceProvider.Anthropic && !model.includes("claude-2")) {
+    return true;
+  }
+  return false;
+}
+
+export function fetch(
+  url: string,
+  options?: Record<string, unknown>,
+): Promise<any> {
+  if (window.__TAURI__) {
+    const payload = options?.body || options?.data;
+    return tauriFetch(url, {
+      ...options,
+      body:
+        payload &&
+        ({
+          type: "Text",
+          payload,
+        } as any),
+      timeout: ((options?.timeout as number) || REQUEST_TIMEOUT_MS) / 1000,
+      responseType:
+        options?.responseType == "text" ? ResponseType.Text : ResponseType.JSON,
+    } as any);
+  }
+  return window.fetch(url, options);
+}
+
+export function adapter(config: Record<string, unknown>) {
+  const { baseURL, url, params, ...rest } = config;
+  const path = baseURL ? `${baseURL}${url}` : url;
+  const fetchUrl = params
+    ? `${path}?${new URLSearchParams(params as any).toString()}`
+    : path;
+  return fetch(fetchUrl as string, { ...rest, responseType: "text" });
+}
+
+export function safeLocalStorage(): {
+  getItem: (key: string) => string | null;
+  setItem: (key: string, value: string) => void;
+  removeItem: (key: string) => void;
+  clear: () => void;
+} {
+  let storage: Storage | null;
+
+  try {
+    if (typeof window !== "undefined" && window.localStorage) {
+      storage = window.localStorage;
+    } else {
+      storage = null;
+    }
+  } catch (e) {
+    console.error("localStorage is not available:", e);
+    storage = null;
+  }
+
+  return {
+    getItem(key: string): string | null {
+      if (storage) {
+        return storage.getItem(key);
+      } else {
+        console.warn(
+          `Attempted to get item "${key}" from localStorage, but localStorage is not available.`,
+        );
+        return null;
+      }
+    },
+    setItem(key: string, value: string): void {
+      if (storage) {
+        storage.setItem(key, value);
+      } else {
+        console.warn(
+          `Attempted to set item "${key}" in localStorage, but localStorage is not available.`,
+        );
+      }
+    },
+    removeItem(key: string): void {
+      if (storage) {
+        storage.removeItem(key);
+      } else {
+        console.warn(
+          `Attempted to remove item "${key}" from localStorage, but localStorage is not available.`,
+        );
+      }
+    },
+    clear(): void {
+      if (storage) {
+        storage.clear();
+      } else {
+        console.warn(
+          "Attempted to clear localStorage, but localStorage is not available.",
+        );
+      }
+    },
+  };
+}

+ 211 - 1
app/utils/chat.ts

@@ -1,5 +1,15 @@
-import { CACHE_URL_PREFIX, UPLOAD_URL } from "@/app/constant";
+import {
+  CACHE_URL_PREFIX,
+  UPLOAD_URL,
+  REQUEST_TIMEOUT_MS,
+} from "@/app/constant";
 import { RequestMessage } from "@/app/client/api";
+import Locale from "@/app/locales";
+import {
+  EventStreamContentType,
+  fetchEventSource,
+} from "@fortaine/fetch-event-source";
+import { prettyObject } from "./format";
 
 export function compressImage(file: Blob, maxSize: number): Promise<string> {
   return new Promise((resolve, reject) => {
@@ -142,3 +152,203 @@ export function removeImage(imageUrl: string) {
     credentials: "include",
   });
 }
+
+export function stream(
+  chatPath: string,
+  requestPayload: any,
+  headers: any,
+  tools: any[],
+  funcs: Record<string, Function>,
+  controller: AbortController,
+  parseSSE: (text: string, runTools: any[]) => string | undefined,
+  processToolMessage: (
+    requestPayload: any,
+    toolCallMessage: any,
+    toolCallResult: any[],
+  ) => void,
+  options: any,
+) {
+  let responseText = "";
+  let remainText = "";
+  let finished = false;
+  let running = false;
+  let runTools: any[] = [];
+
+  // animate response to make it looks smooth
+  function animateResponseText() {
+    if (finished || controller.signal.aborted) {
+      responseText += remainText;
+      console.log("[Response Animation] finished");
+      if (responseText?.length === 0) {
+        options.onError?.(new Error("empty response from server"));
+      }
+      return;
+    }
+
+    if (remainText.length > 0) {
+      const fetchCount = Math.max(1, Math.round(remainText.length / 60));
+      const fetchText = remainText.slice(0, fetchCount);
+      responseText += fetchText;
+      remainText = remainText.slice(fetchCount);
+      options.onUpdate?.(responseText, fetchText);
+    }
+
+    requestAnimationFrame(animateResponseText);
+  }
+
+  // start animaion
+  animateResponseText();
+
+  const finish = () => {
+    if (!finished) {
+      if (!running && runTools.length > 0) {
+        const toolCallMessage = {
+          role: "assistant",
+          tool_calls: [...runTools],
+        };
+        running = true;
+        runTools.splice(0, runTools.length); // empty runTools
+        return Promise.all(
+          toolCallMessage.tool_calls.map((tool) => {
+            options?.onBeforeTool?.(tool);
+            return Promise.resolve(
+              // @ts-ignore
+              funcs[tool.function.name](
+                // @ts-ignore
+                tool?.function?.arguments
+                  ? JSON.parse(tool?.function?.arguments)
+                  : {},
+              ),
+            )
+              .then((res) => {
+                const content = JSON.stringify(res.data);
+                if (res.status >= 300) {
+                  return Promise.reject(content);
+                }
+                return content;
+              })
+              .then((content) => {
+                options?.onAfterTool?.({
+                  ...tool,
+                  content,
+                  isError: false,
+                });
+                return content;
+              })
+              .catch((e) => {
+                options?.onAfterTool?.({ ...tool, isError: true });
+                return e.toString();
+              })
+              .then((content) => ({
+                role: "tool",
+                content,
+                tool_call_id: tool.id,
+              }));
+          }),
+        ).then((toolCallResult) => {
+          processToolMessage(requestPayload, toolCallMessage, toolCallResult);
+          setTimeout(() => {
+            // call again
+            console.debug("[ChatAPI] restart");
+            running = false;
+            chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource
+          }, 60);
+        });
+        return;
+      }
+      if (running) {
+        return;
+      }
+      console.debug("[ChatAPI] end");
+      finished = true;
+      options.onFinish(responseText + remainText);
+    }
+  };
+
+  controller.signal.onabort = finish;
+
+  function chatApi(
+    chatPath: string,
+    headers: any,
+    requestPayload: any,
+    tools: any,
+  ) {
+    const chatPayload = {
+      method: "POST",
+      body: JSON.stringify({
+        ...requestPayload,
+        tools: tools && tools.length ? tools : undefined,
+      }),
+      signal: controller.signal,
+      headers,
+    };
+    const requestTimeoutId = setTimeout(
+      () => controller.abort(),
+      REQUEST_TIMEOUT_MS,
+    );
+    fetchEventSource(chatPath, {
+      ...chatPayload,
+      async onopen(res) {
+        clearTimeout(requestTimeoutId);
+        const contentType = res.headers.get("content-type");
+        console.log("[Request] response content type: ", contentType);
+
+        if (contentType?.startsWith("text/plain")) {
+          responseText = await res.clone().text();
+          return finish();
+        }
+
+        if (
+          !res.ok ||
+          !res.headers
+            .get("content-type")
+            ?.startsWith(EventStreamContentType) ||
+          res.status !== 200
+        ) {
+          const responseTexts = [responseText];
+          let extraInfo = await res.clone().text();
+          try {
+            const resJson = await res.clone().json();
+            extraInfo = prettyObject(resJson);
+          } catch {}
+
+          if (res.status === 401) {
+            responseTexts.push(Locale.Error.Unauthorized);
+          }
+
+          if (extraInfo) {
+            responseTexts.push(extraInfo);
+          }
+
+          responseText = responseTexts.join("\n\n");
+
+          return finish();
+        }
+      },
+      onmessage(msg) {
+        if (msg.data === "[DONE]" || finished) {
+          return finish();
+        }
+        const text = msg.data;
+        try {
+          const chunk = parseSSE(msg.data, runTools);
+          if (chunk) {
+            remainText += chunk;
+          }
+        } catch (e) {
+          console.error("[Request] parse error", text, msg, e);
+        }
+      },
+      onclose() {
+        finish();
+      },
+      onerror(e) {
+        options?.onError?.(e);
+        throw e;
+      },
+      openWhenHidden: true,
+    });
+  }
+  console.debug("[ChatAPI] start");
+  chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource
+}

+ 47 - 0
app/utils/indexedDB-storage.ts

@@ -0,0 +1,47 @@
+import { StateStorage } from "zustand/middleware";
+import { get, set, del, clear } from "idb-keyval";
+import { safeLocalStorage } from "@/app/utils";
+
+const localStorage = safeLocalStorage();
+
+class IndexedDBStorage implements StateStorage {
+  public async getItem(name: string): Promise<string | null> {
+    try {
+      const value = (await get(name)) || localStorage.getItem(name);
+      return value;
+    } catch (error) {
+      return localStorage.getItem(name);
+    }
+  }
+
+  public async setItem(name: string, value: string): Promise<void> {
+    try {
+      const _value = JSON.parse(value);
+      if (!_value?.state?._hasHydrated) {
+        console.warn("skip setItem", name);
+        return;
+      }
+      await set(name, value);
+    } catch (error) {
+      localStorage.setItem(name, value);
+    }
+  }
+
+  public async removeItem(name: string): Promise<void> {
+    try {
+      await del(name);
+    } catch (error) {
+      localStorage.removeItem(name);
+    }
+  }
+
+  public async clear(): Promise<void> {
+    try {
+      await clear();
+    } catch (error) {
+      localStorage.clear();
+    }
+  }
+}
+
+export const indexedDBStorage = new IndexedDBStorage();

+ 15 - 1
app/utils/store.ts

@@ -1,7 +1,8 @@
 import { create } from "zustand";
-import { combine, persist } from "zustand/middleware";
+import { combine, persist, createJSONStorage } from "zustand/middleware";
 import { Updater } from "../typing";
 import { deepClone } from "./clone";
+import { indexedDBStorage } from "@/app/utils/indexedDB-storage";
 
 type SecondParam<T> = T extends (
   _f: infer _F,
@@ -13,9 +14,11 @@ type SecondParam<T> = T extends (
 
 type MakeUpdater<T> = {
   lastUpdateTime: number;
+  _hasHydrated: boolean;
 
   markUpdate: () => void;
   update: Updater<T>;
+  setHasHydrated: (state: boolean) => void;
 };
 
 type SetStoreState<T> = (
@@ -31,12 +34,20 @@ export function createPersistStore<T extends object, M>(
   ) => M,
   persistOptions: SecondParam<typeof persist<T & M & MakeUpdater<T>>>,
 ) {
+  persistOptions.storage = createJSONStorage(() => indexedDBStorage);
+  const oldOonRehydrateStorage = persistOptions?.onRehydrateStorage;
+  persistOptions.onRehydrateStorage = (state) => {
+    oldOonRehydrateStorage?.(state);
+    return () => state.setHasHydrated(true);
+  };
+
   return create(
     persist(
       combine(
         {
           ...state,
           lastUpdateTime: 0,
+          _hasHydrated: false,
         },
         (set, get) => {
           return {
@@ -55,6 +66,9 @@ export function createPersistStore<T extends object, M>(
                 lastUpdateTime: Date.now(),
               });
             },
+            setHasHydrated: (state: boolean) => {
+              set({ _hasHydrated: state } as Partial<T & M & MakeUpdater<T>>);
+            },
           } as M & MakeUpdater<T>;
         },
       ),

+ 4 - 4
next.config.mjs

@@ -65,10 +65,10 @@ if (mode !== "export") {
   nextConfig.rewrites = async () => {
     const ret = [
       // adjust for previous version directly using "/api/proxy/" as proxy base route
-      {
-        source: "/api/proxy/v1/:path*",
-        destination: "https://api.openai.com/v1/:path*",
-      },
+      // {
+      //   source: "/api/proxy/v1/:path*",
+      //   destination: "https://api.openai.com/v1/:path*",
+      // },
       {
         // https://{resource_name}.openai.azure.com/openai/deployments/{deploy_name}/chat/completions
         source: "/api/proxy/azure/:resource_name/deployments/:deploy_name/:path*",

+ 5 - 0
package.json

@@ -24,16 +24,19 @@
     "@svgr/webpack": "^6.5.1",
     "@vercel/analytics": "^0.1.11",
     "@vercel/speed-insights": "^1.0.2",
+    "axios": "^1.7.5",
     "emoji-picker-react": "^4.9.2",
     "fuse.js": "^7.0.0",
     "heic2any": "^0.0.4",
     "html-to-image": "^1.11.11",
+    "idb-keyval": "^6.2.1",
     "lodash-es": "^4.17.21",
     "mermaid": "^10.6.1",
     "markdown-to-txt": "^2.0.1",
     "nanoid": "^5.0.3",
     "next": "^14.1.1",
     "node-fetch": "^3.3.1",
+    "openapi-client-axios": "^7.5.5",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-markdown": "^8.0.7",
@@ -49,7 +52,9 @@
     "zustand": "^4.3.8"
   },
   "devDependencies": {
+    "@tauri-apps/api": "^1.6.0",
     "@tauri-apps/cli": "1.5.11",
+    "@types/js-yaml": "4.0.9",
     "@types/lodash-es": "^4.17.12",
     "@types/node": "^20.11.30",
     "@types/react": "^18.2.70",

+ 1 - 1
src-tauri/Cargo.toml

@@ -17,7 +17,7 @@ tauri-build = { version = "1.5.1", features = [] }
 [dependencies]
 serde_json = "1.0"
 serde = { version = "1.0", features = ["derive"] }
-tauri = { version = "1.5.4", features = [
+tauri = { version = "1.5.4", features = [ "http-all",
     "notification-all",
     "fs-all",
     "clipboard-all",

+ 6 - 1
src-tauri/tauri.conf.json

@@ -9,7 +9,7 @@
   },
   "package": {
     "productName": "NextChat",
-    "version": "2.14.2"
+    "version": "2.15.2"
   },
   "tauri": {
     "allowlist": {
@@ -50,6 +50,11 @@
       },
       "notification": {
         "all": true
+      },
+      "http": {
+        "all": true,
+        "request": true,
+        "scope": ["https://*", "http://*"]
       }
     },
     "bundle": {

+ 85 - 1
yarn.lock

@@ -1553,6 +1553,11 @@
   dependencies:
     tslib "^2.4.0"
 
+"@tauri-apps/api@^1.6.0":
+  version "1.6.0"
+  resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-1.6.0.tgz#745b7e4e26782c3b2ad9510d558fa5bb2cf29186"
+  integrity sha512-rqI++FWClU5I2UBp4HXFvl+sBWkdigBkxnpJDQUWttNyG7IZP4FwQGhTNL5EOw0vI8i6eSAJ5frLqO7n7jbJdg==
+
 "@tauri-apps/cli-darwin-arm64@1.5.11":
   version "1.5.11"
   resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-1.5.11.tgz#a831f98f685148e46e8050dbdddbf4bcdda9ddc6"
@@ -1684,6 +1689,11 @@
     "@types/react" "*"
     hoist-non-react-statics "^3.3.0"
 
+"@types/js-yaml@4.0.9":
+  version "4.0.9"
+  resolved "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2"
+  integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==
+
 "@types/json-schema@*", "@types/json-schema@^7.0.8":
   version "7.0.12"
   resolved "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb"
@@ -2138,6 +2148,11 @@ astral-regex@^2.0.0:
   resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
   integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
 
+asynckit@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
+  integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
+
 available-typed-arrays@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7"
@@ -2148,6 +2163,15 @@ axe-core@^4.6.2:
   resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.6.3.tgz#fc0db6fdb65cc7a80ccf85286d91d64ababa3ece"
   integrity sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg==
 
+axios@^1.7.5:
+  version "1.7.5"
+  resolved "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz#21eed340eb5daf47d29b6e002424b3e88c8c54b1"
+  integrity sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==
+  dependencies:
+    follow-redirects "^1.15.6"
+    form-data "^4.0.0"
+    proxy-from-env "^1.1.0"
+
 axobject-query@^3.1.1:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.1.1.tgz#3b6e5c6d4e43ca7ba51c5babf99d22a9c68485e1"
@@ -2189,6 +2213,11 @@ balanced-match@^1.0.0:
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
   integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
 
+bath-es5@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.npmjs.org/bath-es5/-/bath-es5-3.0.3.tgz#4e2808e8b33b4a5e3328ec1e9032f370f042193d"
+  integrity sha512-PdCioDToH3t84lP40kUFCKWCOCH389Dl1kbC8FGoqOwamxsmqxxnJSXdkTOsPoNHXjem4+sJ+bbNoQm5zeCqxg==
+
 binary-extensions@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
@@ -2392,6 +2421,13 @@ colorette@^2.0.19:
   resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798"
   integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==
 
+combined-stream@^1.0.8:
+  version "1.0.8"
+  resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
+  integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
+  dependencies:
+    delayed-stream "~1.0.0"
+
 comma-separated-tokens@^2.0.0:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee"
@@ -2925,11 +2961,21 @@ delaunator@5:
   dependencies:
     robust-predicates "^3.0.0"
 
+delayed-stream@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
+  integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
+
 dequal@^2.0.0:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
   integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
 
+dereference-json-schema@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.npmjs.org/dereference-json-schema/-/dereference-json-schema-0.2.1.tgz#fcad3c98e0116f7124b0989d39d947fa318cae09"
+  integrity sha512-uzJsrg225owJyRQ8FNTPHIuBOdSzIZlHhss9u6W8mp7jJldHqGuLv9cULagP/E26QVJDnjtG8U7Dw139mM1ydA==
+
 diff@^5.0.0:
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40"
@@ -3548,6 +3594,11 @@ flatted@^3.1.0:
   resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787"
   integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==
 
+follow-redirects@^1.15.6:
+  version "1.15.6"
+  resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b"
+  integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==
+
 for-each@^0.3.3:
   version "0.3.3"
   resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"
@@ -3555,6 +3606,15 @@ for-each@^0.3.3:
   dependencies:
     is-callable "^1.1.3"
 
+form-data@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
+  integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
+  dependencies:
+    asynckit "^0.4.0"
+    combined-stream "^1.0.8"
+    mime-types "^2.1.12"
+
 format@^0.2.0:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b"
@@ -3926,6 +3986,11 @@ iconv-lite@0.6:
   dependencies:
     safer-buffer ">= 2.1.2 < 3.0.0"
 
+idb-keyval@^6.2.1:
+  version "6.2.1"
+  resolved "https://registry.npmmirror.com/idb-keyval/-/idb-keyval-6.2.1.tgz#94516d625346d16f56f3b33855da11bfded2db33"
+  integrity sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==
+
 ignore@^5.2.0:
   version "5.2.4"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
@@ -4961,7 +5026,7 @@ mime-db@1.52.0:
   resolved "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
   integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
 
-mime-types@^2.1.27:
+mime-types@^2.1.12, mime-types@^2.1.27:
   version "2.1.35"
   resolved "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
   integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
@@ -5185,6 +5250,20 @@ onetime@^6.0.0:
   dependencies:
     mimic-fn "^4.0.0"
 
+openapi-client-axios@^7.5.5:
+  version "7.5.5"
+  resolved "https://registry.npmjs.org/openapi-client-axios/-/openapi-client-axios-7.5.5.tgz#4cb2bb7484ff9d1c92d9ff509db235cc35d64f38"
+  integrity sha512-pgCo1z+rxtYmGQXzB+N5DiXvRurTP6JqV+Ao/wtaGUMIIIM+znh3nTztps+FZS8mZgWnDHpdEzL9bWtZuWuvoA==
+  dependencies:
+    bath-es5 "^3.0.3"
+    dereference-json-schema "^0.2.1"
+    openapi-types "^12.1.3"
+
+openapi-types@^12.1.3:
+  version "12.1.3"
+  resolved "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz#471995eb26c4b97b7bd356aacf7b91b73e777dd3"
+  integrity sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==
+
 optionator@^0.9.3:
   version "0.9.3"
   resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64"
@@ -5327,6 +5406,11 @@ property-information@^6.0.0:
   resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.2.0.tgz#b74f522c31c097b5149e3c3cb8d7f3defd986a1d"
   integrity sha512-kma4U7AFCTwpqq5twzC1YVIDXSqg6qQK6JN0smOw8fgRy1OkMi0CYSzFmsy6dnqSenamAtj0CyXMUJ1Mf6oROg==
 
+proxy-from-env@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
+  integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
+
 punycode@^2.1.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"