ソースを参照

Merge branch 'main' into feat-i18n

Lloyd Zhou 1 年間 前
コミット
e033bef544
93 ファイル変更5014 行追加400 行削除
  1. 2 0
      .gitignore
  2. 36 5
      README.md
  3. 23 0
      README_CN.md
  4. 66 0
      app/api/[provider]/[...path]/route.ts
  5. 1 25
      app/api/alibaba.ts
  6. 2 26
      app/api/anthropic.ts
  7. 73 0
      app/api/artifacts/route.ts
  8. 10 0
      app/api/auth.ts
  9. 3 27
      app/api/azure.ts
  10. 1 25
      app/api/baidu.ts
  11. 1 25
      app/api/bytedance.ts
  12. 3 3
      app/api/google.ts
  13. 131 0
      app/api/iflytek.ts
  14. 130 0
      app/api/moonshot.ts
  15. 4 28
      app/api/openai.ts
  16. 99 0
      app/api/stability.ts
  17. 124 0
      app/api/tencent/route.ts
  18. 17 8
      app/api/webdav/[...path]/route.ts
  19. 47 10
      app/client/api.ts
  20. 13 5
      app/client/platforms/baidu.ts
  21. 6 4
      app/client/platforms/google.ts
  22. 240 0
      app/client/platforms/iflytek.ts
  23. 251 0
      app/client/platforms/moonshot.ts
  24. 92 31
      app/client/platforms/openai.ts
  25. 268 0
      app/client/platforms/tencent.ts
  26. 8 5
      app/command.ts
  27. 31 0
      app/components/artifacts.module.scss
  28. 234 0
      app/components/artifacts.tsx
  29. 12 1
      app/components/button.tsx
  30. 6 0
      app/components/chat.module.scss
  31. 146 2
      app/components/chat.tsx
  32. 2 0
      app/components/error.tsx
  33. 2 1
      app/components/exporter.tsx
  34. 9 3
      app/components/home.module.scss
  35. 51 25
      app/components/home.tsx
  36. 3 0
      app/components/input-range.tsx
  37. 67 8
      app/components/markdown.tsx
  38. 6 0
      app/components/mask.tsx
  39. 11 0
      app/components/model-config.tsx
  40. 2 0
      app/components/sd/index.tsx
  41. 45 0
      app/components/sd/sd-panel.module.scss
  42. 320 0
      app/components/sd/sd-panel.tsx
  43. 140 0
      app/components/sd/sd-sidebar.tsx
  44. 53 0
      app/components/sd/sd.module.scss
  45. 336 0
      app/components/sd/sd.tsx
  46. 259 0
      app/components/settings.tsx
  47. 171 85
      app/components/sidebar.tsx
  48. 18 0
      app/components/ui-lib.module.scss
  49. 103 16
      app/components/ui-lib.tsx
  50. 1 1
      app/config/client.ts
  51. 54 2
      app/config/server.ts
  52. 124 2
      app/constant.ts
  53. 7 0
      app/icons/discovery.svg
  54. 4 0
      app/icons/hd.svg
  55. 10 0
      app/icons/history.svg
  56. 4 0
      app/icons/palette.svg
  57. 12 0
      app/icons/sd.svg
  58. 1 0
      app/icons/size.svg
  59. 5 0
      app/locales/ar.ts
  60. 6 0
      app/locales/bn.ts
  61. 124 0
      app/locales/cn.ts
  62. 6 0
      app/locales/cs.ts
  63. 6 0
      app/locales/de.ts
  64. 125 1
      app/locales/en.ts
  65. 6 0
      app/locales/es.ts
  66. 6 0
      app/locales/fr.ts
  67. 6 0
      app/locales/id.ts
  68. 6 0
      app/locales/it.ts
  69. 6 0
      app/locales/jp.ts
  70. 5 0
      app/locales/ko.ts
  71. 6 0
      app/locales/no.ts
  72. 6 0
      app/locales/pt.ts
  73. 6 0
      app/locales/ru.ts
  74. 6 0
      app/locales/sk.ts
  75. 6 0
      app/locales/tr.ts
  76. 5 0
      app/locales/tw.ts
  77. 6 0
      app/locales/vi.ts
  78. 47 1
      app/store/access.ts
  79. 8 1
      app/store/chat.ts
  80. 5 0
      app/store/config.ts
  81. 3 1
      app/store/mask.ts
  82. 163 0
      app/store/sd.ts
  83. 4 0
      app/typing.ts
  84. 5 0
      app/utils.ts
  85. 1 1
      app/utils/chat.ts
  86. 11 3
      app/utils/cloud/webdav.ts
  87. 246 0
      app/utils/hmac.ts
  88. 54 8
      app/utils/model.ts
  89. 102 0
      app/utils/tencent.ts
  90. 7 4
      package.json
  91. 6 3
      public/serviceWorker.js
  92. 1 1
      src-tauri/tauri.conf.json
  93. 108 3
      yarn.lock

+ 2 - 0
.gitignore

@@ -44,3 +44,5 @@ dev
 
 *.key
 *.key.pub
+
+masks.json

+ 36 - 5
README.md

@@ -88,10 +88,15 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
 - [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
 - [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.
-- [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
+- [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)
+- [ ] local knowledge base
 
 ## What's New
 
+- 🚀 v2.14.0 Now supports  Artifacts & SD 
 - 🚀 v2.10.1 support Google Gemini Pro model.
 - 🚀 v2.9.11 you can use azure endpoint now.
 - 🚀 v2.8 now we have a client that runs across all platforms!
@@ -120,15 +125,21 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
 - [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
 - [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)
-- [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
+- [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)
+ - [ ] 本地知识库
 
 ## 最新动态
 
+- 🚀 v2.14.0 现在支持 Artifacts & SD 了。
+- 🚀 v2.10.1 现在支持 Gemini Pro 模型。
+- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
+- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
+- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
 - 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
 - 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
-- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
-- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
-- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
 
 ## Get Started
 
@@ -271,6 +282,18 @@ Alibaba Cloud Api Key.
 
 Alibaba Cloud Api Url.
 
+### `IFLYTEK_URL` (Optional)
+
+iflytek Api Url.
+
+### `IFLYTEK_API_KEY` (Optional)
+
+iflytek Api Key.
+
+### `IFLYTEK_API_SECRET` (Optional)
+
+iflytek Api Secret.
+
 ### `HIDE_USER_API_KEY` (optional)
 
 > Default: Empty
@@ -326,6 +349,14 @@ You can use this option if you want to increase the number of webdav service add
 
 Customize the default template used to initialize the User Input Preprocessing configuration item in Settings.
 
+### `STABILITY_API_KEY` (optional)
+
+Stability API key.
+
+### `STABILITY_URL` (optional)
+
+Customize Stability API url.
+
 ## Requirements
 
 NodeJS >= 18, Docker >= 20

+ 23 - 0
README_CN.md

@@ -172,6 +172,20 @@ ByteDance Api Url.
 
 阿里云(千问)Api Url.
 
+### `IFLYTEK_URL` (可选)
+
+讯飞星火Api Url.
+
+### `IFLYTEK_API_KEY` (可选)
+
+讯飞星火Api Key.
+
+### `IFLYTEK_API_SECRET` (可选)
+
+讯飞星火Api Secret.
+
+
+
 ### `HIDE_USER_API_KEY` (可选)
 
 如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
@@ -218,6 +232,15 @@ ByteDance Api Url.
 
 自定义默认的 template,用于初始化『设置』中的『用户输入预处理』配置项
 
+### `STABILITY_API_KEY` (optional)
+
+Stability API密钥
+
+### `STABILITY_URL` (optional)
+
+自定义的Stability API请求地址
+
+
 ## 开发
 
 点击下方按钮,开始二次开发:

+ 66 - 0
app/api/[provider]/[...path]/route.ts

@@ -0,0 +1,66 @@
+import { ApiPath } from "@/app/constant";
+import { NextRequest, NextResponse } from "next/server";
+import { handle as openaiHandler } from "../../openai";
+import { handle as azureHandler } from "../../azure";
+import { handle as googleHandler } from "../../google";
+import { handle as anthropicHandler } from "../../anthropic";
+import { handle as baiduHandler } from "../../baidu";
+import { handle as bytedanceHandler } from "../../bytedance";
+import { handle as alibabaHandler } from "../../alibaba";
+import { handle as moonshotHandler } from "../../moonshot";
+import { handle as stabilityHandler } from "../../stability";
+import { handle as iflytekHandler } from "../../iflytek";
+async function handle(
+  req: NextRequest,
+  { params }: { params: { provider: string; path: string[] } },
+) {
+  const apiPath = `/api/${params.provider}`;
+  console.log(`[${params.provider} Route] params `, params);
+  switch (apiPath) {
+    case ApiPath.Azure:
+      return azureHandler(req, { params });
+    case ApiPath.Google:
+      return googleHandler(req, { params });
+    case ApiPath.Anthropic:
+      return anthropicHandler(req, { params });
+    case ApiPath.Baidu:
+      return baiduHandler(req, { params });
+    case ApiPath.ByteDance:
+      return bytedanceHandler(req, { params });
+    case ApiPath.Alibaba:
+      return alibabaHandler(req, { params });
+    // case ApiPath.Tencent: using "/api/tencent"
+    case ApiPath.Moonshot:
+      return moonshotHandler(req, { params });
+    case ApiPath.Stability:
+      return stabilityHandler(req, { params });
+    case ApiPath.Iflytek:
+      return iflytekHandler(req, { params });
+    default:
+      return openaiHandler(req, { params });
+  }
+}
+
+export const GET = handle;
+export const POST = handle;
+
+export const runtime = "edge";
+export const preferredRegion = [
+  "arn1",
+  "bom1",
+  "cdg1",
+  "cle1",
+  "cpt1",
+  "dub1",
+  "fra1",
+  "gru1",
+  "hnd1",
+  "iad1",
+  "icn1",
+  "kix1",
+  "lhr1",
+  "pdx1",
+  "sfo1",
+  "sin1",
+  "syd1",
+];

+ 1 - 25
app/api/alibaba/[...path]/route.ts → app/api/alibaba.ts

@@ -14,7 +14,7 @@ import type { RequestPayload } from "@/app/client/platforms/openai";
 
 const serverConfig = getServerSideConfig();
 
-async function handle(
+export async function handle(
   req: NextRequest,
   { params }: { params: { path: string[] } },
 ) {
@@ -40,30 +40,6 @@ async function handle(
   }
 }
 
-export const GET = handle;
-export const POST = handle;
-
-export const runtime = "edge";
-export const preferredRegion = [
-  "arn1",
-  "bom1",
-  "cdg1",
-  "cle1",
-  "cpt1",
-  "dub1",
-  "fra1",
-  "gru1",
-  "hnd1",
-  "iad1",
-  "icn1",
-  "kix1",
-  "lhr1",
-  "pdx1",
-  "sfo1",
-  "sin1",
-  "syd1",
-];
-
 async function request(req: NextRequest) {
   const controller = new AbortController();
 

+ 2 - 26
app/api/anthropic/[...path]/route.ts → app/api/anthropic.ts

@@ -9,13 +9,13 @@ import {
 } from "@/app/constant";
 import { prettyObject } from "@/app/utils/format";
 import { NextRequest, NextResponse } from "next/server";
-import { auth } from "../../auth";
+import { auth } from "./auth";
 import { isModelAvailableInServer } from "@/app/utils/model";
 import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
 
 const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
 
-async function handle(
+export async function handle(
   req: NextRequest,
   { params }: { params: { path: string[] } },
 ) {
@@ -56,30 +56,6 @@ async function handle(
   }
 }
 
-export const GET = handle;
-export const POST = handle;
-
-export const runtime = "edge";
-export const preferredRegion = [
-  "arn1",
-  "bom1",
-  "cdg1",
-  "cle1",
-  "cpt1",
-  "dub1",
-  "fra1",
-  "gru1",
-  "hnd1",
-  "iad1",
-  "icn1",
-  "kix1",
-  "lhr1",
-  "pdx1",
-  "sfo1",
-  "sin1",
-  "syd1",
-];
-
 const serverConfig = getServerSideConfig();
 
 async function request(req: NextRequest) {

+ 73 - 0
app/api/artifacts/route.ts

@@ -0,0 +1,73 @@
+import md5 from "spark-md5";
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSideConfig } from "@/app/config/server";
+
+async function handle(req: NextRequest, res: NextResponse) {
+  const serverConfig = getServerSideConfig();
+  const storeUrl = () =>
+    `https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}`;
+  const storeHeaders = () => ({
+    Authorization: `Bearer ${serverConfig.cloudflareKVApiKey}`,
+  });
+  if (req.method === "POST") {
+    const clonedBody = await req.text();
+    const hashedCode = md5.hash(clonedBody).trim();
+    const body: {
+      key: string;
+      value: string;
+      expiration_ttl?: number;
+    } = {
+      key: hashedCode,
+      value: clonedBody,
+    };
+    try {
+      const ttl = parseInt(serverConfig.cloudflareKVTTL as string);
+      if (ttl > 60) {
+        body["expiration_ttl"] = ttl;
+      }
+    } catch (e) {
+      console.error(e);
+    }
+    const res = await fetch(`${storeUrl()}/bulk`, {
+      headers: {
+        ...storeHeaders(),
+        "Content-Type": "application/json",
+      },
+      method: "PUT",
+      body: JSON.stringify([body]),
+    });
+    const result = await res.json();
+    console.log("save data", result);
+    if (result?.success) {
+      return NextResponse.json(
+        { code: 0, id: hashedCode, result },
+        { status: res.status },
+      );
+    }
+    return NextResponse.json(
+      { error: true, msg: "Save data error" },
+      { status: 400 },
+    );
+  }
+  if (req.method === "GET") {
+    const id = req?.nextUrl?.searchParams?.get("id");
+    const res = await fetch(`${storeUrl()}/values/${id}`, {
+      headers: storeHeaders(),
+      method: "GET",
+    });
+    return new Response(res.body, {
+      status: res.status,
+      statusText: res.statusText,
+      headers: res.headers,
+    });
+  }
+  return NextResponse.json(
+    { error: true, msg: "Invalid request" },
+    { status: 400 },
+  );
+}
+
+export const POST = handle;
+export const GET = handle;
+
+export const runtime = "edge";

+ 10 - 0
app/api/auth.ts

@@ -67,6 +67,9 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
     let systemApiKey: string | undefined;
 
     switch (modelProvider) {
+      case ModelProvider.Stability:
+        systemApiKey = serverConfig.stabilityApiKey;
+        break;
       case ModelProvider.GeminiPro:
         systemApiKey = serverConfig.googleApiKey;
         break;
@@ -82,6 +85,13 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
       case ModelProvider.Qwen:
         systemApiKey = serverConfig.alibabaApiKey;
         break;
+      case ModelProvider.Moonshot:
+        systemApiKey = serverConfig.moonshotApiKey;
+        break;
+      case ModelProvider.Iflytek:
+        systemApiKey =
+          serverConfig.iflytekApiKey + ":" + serverConfig.iflytekApiSecret;
+        break;
       case ModelProvider.GPT:
       default:
         if (req.nextUrl.pathname.includes("azure/deployments")) {

+ 3 - 27
app/api/azure/[...path]/route.ts → app/api/azure.ts

@@ -2,10 +2,10 @@ import { getServerSideConfig } from "@/app/config/server";
 import { ModelProvider } from "@/app/constant";
 import { prettyObject } from "@/app/utils/format";
 import { NextRequest, NextResponse } from "next/server";
-import { auth } from "../../auth";
-import { requestOpenai } from "../../common";
+import { auth } from "./auth";
+import { requestOpenai } from "./common";
 
-async function handle(
+export async function handle(
   req: NextRequest,
   { params }: { params: { path: string[] } },
 ) {
@@ -31,27 +31,3 @@ async function handle(
     return NextResponse.json(prettyObject(e));
   }
 }
-
-export const GET = handle;
-export const POST = handle;
-
-export const runtime = "edge";
-export const preferredRegion = [
-  "arn1",
-  "bom1",
-  "cdg1",
-  "cle1",
-  "cpt1",
-  "dub1",
-  "fra1",
-  "gru1",
-  "hnd1",
-  "iad1",
-  "icn1",
-  "kix1",
-  "lhr1",
-  "pdx1",
-  "sfo1",
-  "sin1",
-  "syd1",
-];

+ 1 - 25
app/api/baidu/[...path]/route.ts → app/api/baidu.ts

@@ -14,7 +14,7 @@ import { getAccessToken } from "@/app/utils/baidu";
 
 const serverConfig = getServerSideConfig();
 
-async function handle(
+export async function handle(
   req: NextRequest,
   { params }: { params: { path: string[] } },
 ) {
@@ -52,30 +52,6 @@ async function handle(
   }
 }
 
-export const GET = handle;
-export const POST = handle;
-
-export const runtime = "edge";
-export const preferredRegion = [
-  "arn1",
-  "bom1",
-  "cdg1",
-  "cle1",
-  "cpt1",
-  "dub1",
-  "fra1",
-  "gru1",
-  "hnd1",
-  "iad1",
-  "icn1",
-  "kix1",
-  "lhr1",
-  "pdx1",
-  "sfo1",
-  "sin1",
-  "syd1",
-];
-
 async function request(req: NextRequest) {
   const controller = new AbortController();
 

+ 1 - 25
app/api/bytedance/[...path]/route.ts → app/api/bytedance.ts

@@ -12,7 +12,7 @@ import { isModelAvailableInServer } from "@/app/utils/model";
 
 const serverConfig = getServerSideConfig();
 
-async function handle(
+export async function handle(
   req: NextRequest,
   { params }: { params: { path: string[] } },
 ) {
@@ -38,30 +38,6 @@ async function handle(
   }
 }
 
-export const GET = handle;
-export const POST = handle;
-
-export const runtime = "edge";
-export const preferredRegion = [
-  "arn1",
-  "bom1",
-  "cdg1",
-  "cle1",
-  "cpt1",
-  "dub1",
-  "fra1",
-  "gru1",
-  "hnd1",
-  "iad1",
-  "icn1",
-  "kix1",
-  "lhr1",
-  "pdx1",
-  "sfo1",
-  "sin1",
-  "syd1",
-];
-
 async function request(req: NextRequest) {
   const controller = new AbortController();
 

+ 3 - 3
app/api/google/[...path]/route.ts → app/api/google.ts

@@ -1,5 +1,5 @@
 import { NextRequest, NextResponse } from "next/server";
-import { auth } from "../../auth";
+import { auth } from "./auth";
 import { getServerSideConfig } from "@/app/config/server";
 import {
   ApiPath,
@@ -11,9 +11,9 @@ import { prettyObject } from "@/app/utils/format";
 
 const serverConfig = getServerSideConfig();
 
-async function handle(
+export async function handle(
   req: NextRequest,
-  { params }: { params: { path: string[] } },
+  { params }: { params: { provider: string; path: string[] } },
 ) {
   console.log("[Google Route] params ", params);
 

+ 131 - 0
app/api/iflytek.ts

@@ -0,0 +1,131 @@
+import { getServerSideConfig } from "@/app/config/server";
+import {
+  Iflytek,
+  IFLYTEK_BASE_URL,
+  ApiPath,
+  ModelProvider,
+  ServiceProvider,
+} from "@/app/constant";
+import { prettyObject } from "@/app/utils/format";
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/app/api/auth";
+import { isModelAvailableInServer } from "@/app/utils/model";
+import type { RequestPayload } from "@/app/client/platforms/openai";
+// iflytek
+
+const serverConfig = getServerSideConfig();
+
+export async function handle(
+  req: NextRequest,
+  { params }: { params: { path: string[] } },
+) {
+  console.log("[Iflytek Route] params ", params);
+
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+
+  const authResult = auth(req, ModelProvider.Iflytek);
+  if (authResult.error) {
+    return NextResponse.json(authResult, {
+      status: 401,
+    });
+  }
+
+  try {
+    const response = await request(req);
+    return response;
+  } catch (e) {
+    console.error("[Iflytek] ", e);
+    return NextResponse.json(prettyObject(e));
+  }
+}
+
+async function request(req: NextRequest) {
+  const controller = new AbortController();
+
+  // iflytek use base url or just remove the path
+  let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Iflytek, "");
+
+  let baseUrl = serverConfig.iflytekUrl || IFLYTEK_BASE_URL;
+
+  if (!baseUrl.startsWith("http")) {
+    baseUrl = `https://${baseUrl}`;
+  }
+
+  if (baseUrl.endsWith("/")) {
+    baseUrl = baseUrl.slice(0, -1);
+  }
+
+  console.log("[Proxy] ", path);
+  console.log("[Base Url]", baseUrl);
+
+  const timeoutId = setTimeout(
+    () => {
+      controller.abort();
+    },
+    10 * 60 * 1000,
+  );
+
+  const fetchUrl = `${baseUrl}${path}`;
+  const fetchOptions: RequestInit = {
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: req.headers.get("Authorization") ?? "",
+    },
+    method: req.method,
+    body: req.body,
+    redirect: "manual",
+    // @ts-ignore
+    duplex: "half",
+    signal: controller.signal,
+  };
+
+  // try to refuse some request to some models
+  if (serverConfig.customModels && req.body) {
+    try {
+      const clonedBody = await req.text();
+      fetchOptions.body = clonedBody;
+
+      const jsonBody = JSON.parse(clonedBody) as { model?: string };
+
+      // not undefined and is false
+      if (
+        isModelAvailableInServer(
+          serverConfig.customModels,
+          jsonBody?.model as string,
+          ServiceProvider.Iflytek as string,
+        )
+      ) {
+        return NextResponse.json(
+          {
+            error: true,
+            message: `you are not allowed to use ${jsonBody?.model} model`,
+          },
+          {
+            status: 403,
+          },
+        );
+      }
+    } catch (e) {
+      console.error(`[Iflytek] filter`, e);
+    }
+  }
+  try {
+    const res = await fetch(fetchUrl, fetchOptions);
+
+    // to prevent browser prompt for credentials
+    const newHeaders = new Headers(res.headers);
+    newHeaders.delete("www-authenticate");
+    // to disable nginx buffering
+    newHeaders.set("X-Accel-Buffering", "no");
+
+    return new Response(res.body, {
+      status: res.status,
+      statusText: res.statusText,
+      headers: newHeaders,
+    });
+  } finally {
+    clearTimeout(timeoutId);
+  }
+}

+ 130 - 0
app/api/moonshot.ts

@@ -0,0 +1,130 @@
+import { getServerSideConfig } from "@/app/config/server";
+import {
+  Moonshot,
+  MOONSHOT_BASE_URL,
+  ApiPath,
+  ModelProvider,
+  ServiceProvider,
+} from "@/app/constant";
+import { prettyObject } from "@/app/utils/format";
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/app/api/auth";
+import { isModelAvailableInServer } from "@/app/utils/model";
+import type { RequestPayload } from "@/app/client/platforms/openai";
+
+const serverConfig = getServerSideConfig();
+
+export async function handle(
+  req: NextRequest,
+  { params }: { params: { path: string[] } },
+) {
+  console.log("[Moonshot Route] params ", params);
+
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+
+  const authResult = auth(req, ModelProvider.Moonshot);
+  if (authResult.error) {
+    return NextResponse.json(authResult, {
+      status: 401,
+    });
+  }
+
+  try {
+    const response = await request(req);
+    return response;
+  } catch (e) {
+    console.error("[Moonshot] ", e);
+    return NextResponse.json(prettyObject(e));
+  }
+}
+
+async function request(req: NextRequest) {
+  const controller = new AbortController();
+
+  // alibaba use base url or just remove the path
+  let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Moonshot, "");
+
+  let baseUrl = serverConfig.moonshotUrl || MOONSHOT_BASE_URL;
+
+  if (!baseUrl.startsWith("http")) {
+    baseUrl = `https://${baseUrl}`;
+  }
+
+  if (baseUrl.endsWith("/")) {
+    baseUrl = baseUrl.slice(0, -1);
+  }
+
+  console.log("[Proxy] ", path);
+  console.log("[Base Url]", baseUrl);
+
+  const timeoutId = setTimeout(
+    () => {
+      controller.abort();
+    },
+    10 * 60 * 1000,
+  );
+
+  const fetchUrl = `${baseUrl}${path}`;
+  const fetchOptions: RequestInit = {
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: req.headers.get("Authorization") ?? "",
+    },
+    method: req.method,
+    body: req.body,
+    redirect: "manual",
+    // @ts-ignore
+    duplex: "half",
+    signal: controller.signal,
+  };
+
+  // #1815 try to refuse some request to some models
+  if (serverConfig.customModels && req.body) {
+    try {
+      const clonedBody = await req.text();
+      fetchOptions.body = clonedBody;
+
+      const jsonBody = JSON.parse(clonedBody) as { model?: string };
+
+      // not undefined and is false
+      if (
+        isModelAvailableInServer(
+          serverConfig.customModels,
+          jsonBody?.model as string,
+          ServiceProvider.Moonshot as string,
+        )
+      ) {
+        return NextResponse.json(
+          {
+            error: true,
+            message: `you are not allowed to use ${jsonBody?.model} model`,
+          },
+          {
+            status: 403,
+          },
+        );
+      }
+    } catch (e) {
+      console.error(`[Moonshot] filter`, e);
+    }
+  }
+  try {
+    const res = await fetch(fetchUrl, fetchOptions);
+
+    // to prevent browser prompt for credentials
+    const newHeaders = new Headers(res.headers);
+    newHeaders.delete("www-authenticate");
+    // to disable nginx buffering
+    newHeaders.set("X-Accel-Buffering", "no");
+
+    return new Response(res.body, {
+      status: res.status,
+      statusText: res.statusText,
+      headers: newHeaders,
+    });
+  } finally {
+    clearTimeout(timeoutId);
+  }
+}

+ 4 - 28
app/api/openai/[...path]/route.ts → app/api/openai.ts

@@ -3,8 +3,8 @@ import { getServerSideConfig } from "@/app/config/server";
 import { ModelProvider, OpenaiPath } from "@/app/constant";
 import { prettyObject } from "@/app/utils/format";
 import { NextRequest, NextResponse } from "next/server";
-import { auth } from "../../auth";
-import { requestOpenai } from "../../common";
+import { auth } from "./auth";
+import { requestOpenai } from "./common";
 
 const ALLOWD_PATH = new Set(Object.values(OpenaiPath));
 
@@ -13,14 +13,14 @@ function getModels(remoteModelRes: OpenAIListModelResponse) {
 
   if (config.disableGPT4) {
     remoteModelRes.data = remoteModelRes.data.filter(
-      (m) => !m.id.startsWith("gpt-4"),
+      (m) => !m.id.startsWith("gpt-4") || m.id.startsWith("gpt-4o-mini"),
     );
   }
 
   return remoteModelRes;
 }
 
-async function handle(
+export async function handle(
   req: NextRequest,
   { params }: { params: { path: string[] } },
 ) {
@@ -70,27 +70,3 @@ async function handle(
     return NextResponse.json(prettyObject(e));
   }
 }
-
-export const GET = handle;
-export const POST = handle;
-
-export const runtime = "edge";
-export const preferredRegion = [
-  "arn1",
-  "bom1",
-  "cdg1",
-  "cle1",
-  "cpt1",
-  "dub1",
-  "fra1",
-  "gru1",
-  "hnd1",
-  "iad1",
-  "icn1",
-  "kix1",
-  "lhr1",
-  "pdx1",
-  "sfo1",
-  "sin1",
-  "syd1",
-];

+ 99 - 0
app/api/stability.ts

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

+ 124 - 0
app/api/tencent/route.ts

@@ -0,0 +1,124 @@
+import { getServerSideConfig } from "@/app/config/server";
+import {
+  TENCENT_BASE_URL,
+  ApiPath,
+  ModelProvider,
+  ServiceProvider,
+  Tencent,
+} from "@/app/constant";
+import { prettyObject } from "@/app/utils/format";
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/app/api/auth";
+import { isModelAvailableInServer } from "@/app/utils/model";
+import { getHeader } from "@/app/utils/tencent";
+
+const serverConfig = getServerSideConfig();
+
+async function handle(
+  req: NextRequest,
+  { params }: { params: { path: string[] } },
+) {
+  console.log("[Tencent Route] params ", params);
+
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+
+  const authResult = auth(req, ModelProvider.Hunyuan);
+  if (authResult.error) {
+    return NextResponse.json(authResult, {
+      status: 401,
+    });
+  }
+
+  try {
+    const response = await request(req);
+    return response;
+  } catch (e) {
+    console.error("[Tencent] ", e);
+    return NextResponse.json(prettyObject(e));
+  }
+}
+
+export const GET = handle;
+export const POST = handle;
+
+export const runtime = "edge";
+export const preferredRegion = [
+  "arn1",
+  "bom1",
+  "cdg1",
+  "cle1",
+  "cpt1",
+  "dub1",
+  "fra1",
+  "gru1",
+  "hnd1",
+  "iad1",
+  "icn1",
+  "kix1",
+  "lhr1",
+  "pdx1",
+  "sfo1",
+  "sin1",
+  "syd1",
+];
+
+async function request(req: NextRequest) {
+  const controller = new AbortController();
+
+  let baseUrl = serverConfig.tencentUrl || TENCENT_BASE_URL;
+
+  if (!baseUrl.startsWith("http")) {
+    baseUrl = `https://${baseUrl}`;
+  }
+
+  if (baseUrl.endsWith("/")) {
+    baseUrl = baseUrl.slice(0, -1);
+  }
+
+  console.log("[Base Url]", baseUrl);
+
+  const timeoutId = setTimeout(
+    () => {
+      controller.abort();
+    },
+    10 * 60 * 1000,
+  );
+
+  const fetchUrl = baseUrl;
+
+  const body = await req.text();
+  const headers = await getHeader(
+    body,
+    serverConfig.tencentSecretId as string,
+    serverConfig.tencentSecretKey as string,
+  );
+  const fetchOptions: RequestInit = {
+    headers,
+    method: req.method,
+    body,
+    redirect: "manual",
+    // @ts-ignore
+    duplex: "half",
+    signal: controller.signal,
+  };
+
+  try {
+    const res = await fetch(fetchUrl, fetchOptions);
+
+    // to prevent browser prompt for credentials
+    const newHeaders = new Headers(res.headers);
+    newHeaders.delete("www-authenticate");
+    // to disable nginx buffering
+    newHeaders.set("X-Accel-Buffering", "no");
+
+    return new Response(res.body, {
+      status: res.status,
+      statusText: res.statusText,
+      headers: newHeaders,
+    });
+  } finally {
+    clearTimeout(timeoutId);
+  }
+}

+ 17 - 8
app/api/webdav/[...path]/route.ts

@@ -29,6 +29,7 @@ async function handle(
 
   const requestUrl = new URL(req.url);
   let endpoint = requestUrl.searchParams.get("endpoint");
+  let proxy_method = requestUrl.searchParams.get("proxy_method") || req.method;
 
   // Validate the endpoint to prevent potential SSRF attacks
   if (
@@ -37,9 +38,13 @@ async function handle(
       const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint);
       const normalizedEndpoint = normalizeUrl(endpoint as string);
 
-      return normalizedEndpoint &&
+      return (
+        normalizedEndpoint &&
         normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname &&
-        normalizedEndpoint.pathname.startsWith(normalizedAllowedEndpoint.pathname);
+        normalizedEndpoint.pathname.startsWith(
+          normalizedAllowedEndpoint.pathname,
+        )
+      );
     })
   ) {
     return NextResponse.json(
@@ -61,7 +66,11 @@ async function handle(
   const targetPath = `${endpoint}${endpointPath}`;
 
   // only allow MKCOL, GET, PUT
-  if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") {
+  if (
+    proxy_method !== "MKCOL" &&
+    proxy_method !== "GET" &&
+    proxy_method !== "PUT"
+  ) {
     return NextResponse.json(
       {
         error: true,
@@ -74,7 +83,7 @@ async function handle(
   }
 
   // for MKCOL request, only allow request ${folder}
-  if (req.method === "MKCOL" && !targetPath.endsWith(folder)) {
+  if (proxy_method === "MKCOL" && !targetPath.endsWith(folder)) {
     return NextResponse.json(
       {
         error: true,
@@ -87,7 +96,7 @@ async function handle(
   }
 
   // for GET request, only allow request ending with fileName
-  if (req.method === "GET" && !targetPath.endsWith(fileName)) {
+  if (proxy_method === "GET" && !targetPath.endsWith(fileName)) {
     return NextResponse.json(
       {
         error: true,
@@ -100,7 +109,7 @@ async function handle(
   }
 
   //   for PUT request, only allow request ending with fileName
-  if (req.method === "PUT" && !targetPath.endsWith(fileName)) {
+  if (proxy_method === "PUT" && !targetPath.endsWith(fileName)) {
     return NextResponse.json(
       {
         error: true,
@@ -114,7 +123,7 @@ async function handle(
 
   const targetUrl = targetPath;
 
-  const method = req.method;
+  const method = proxy_method || req.method;
   const shouldNotHaveBody = ["get", "head"].includes(
     method?.toLowerCase() ?? "",
   );
@@ -139,7 +148,7 @@ async function handle(
       "[Any Proxy]",
       targetUrl,
       {
-        method: req.method,
+        method: method,
       },
       {
         status: fetchResult?.status,

+ 47 - 10
app/client/api.ts

@@ -6,12 +6,15 @@ import {
   ServiceProvider,
 } from "../constant";
 import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store";
-import { ChatGPTApi } from "./platforms/openai";
+import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai";
 import { GeminiProApi } from "./platforms/google";
 import { ClaudeApi } from "./platforms/anthropic";
 import { ErnieApi } from "./platforms/baidu";
 import { DoubaoApi } from "./platforms/bytedance";
 import { QwenApi } from "./platforms/alibaba";
+import { HunyuanApi } from "./platforms/tencent";
+import { MoonshotApi } from "./platforms/moonshot";
+import { SparkApi } from "./platforms/iflytek";
 
 export const ROLES = ["system", "user", "assistant"] as const;
 export type MessageRole = (typeof ROLES)[number];
@@ -40,6 +43,9 @@ export interface LLMConfig {
   stream?: boolean;
   presence_penalty?: number;
   frequency_penalty?: number;
+  size?: DalleRequestPayload["size"];
+  quality?: DalleRequestPayload["quality"];
+  style?: DalleRequestPayload["style"];
 }
 
 export interface ChatOptions {
@@ -62,12 +68,14 @@ export interface LLMModel {
   displayName?: string;
   available: boolean;
   provider: LLMModelProvider;
+  sorted: number;
 }
 
 export interface LLMModelProvider {
   id: string;
   providerName: string;
   providerType: string;
+  sorted: number;
 }
 
 export abstract class LLMApi {
@@ -117,6 +125,15 @@ export class ClientApi {
       case ModelProvider.Qwen:
         this.llm = new QwenApi();
         break;
+      case ModelProvider.Hunyuan:
+        this.llm = new HunyuanApi();
+        break;
+      case ModelProvider.Moonshot:
+        this.llm = new MoonshotApi();
+        break;
+      case ModelProvider.Iflytek:
+        this.llm = new SparkApi();
+        break;
       default:
         this.llm = new ChatGPTApi();
     }
@@ -168,6 +185,19 @@ export class ClientApi {
   }
 }
 
+export function getBearerToken(
+  apiKey: string,
+  noBearer: boolean = false,
+): string {
+  return validString(apiKey)
+    ? `${noBearer ? "" : "Bearer "}${apiKey.trim()}`
+    : "";
+}
+
+export function validString(x: string): boolean {
+  return x?.length > 0;
+}
+
 export function getHeaders() {
   const accessStore = useAccessStore.getState();
   const chatStore = useChatStore.getState();
@@ -186,6 +216,8 @@ export function getHeaders() {
     const isBaidu = modelConfig.providerName == ServiceProvider.Baidu;
     const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance;
     const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba;
+    const isMoonshot = modelConfig.providerName === ServiceProvider.Moonshot;
+    const isIflytek = modelConfig.providerName === ServiceProvider.Iflytek;
     const isEnabledAccessControl = accessStore.enabledAccessControl();
     const apiKey = isGoogle
       ? accessStore.googleApiKey
@@ -197,6 +229,12 @@ export function getHeaders() {
       ? accessStore.bytedanceApiKey
       : isAlibaba
       ? accessStore.alibabaApiKey
+      : isMoonshot
+      ? accessStore.moonshotApiKey
+      : isIflytek
+      ? accessStore.iflytekApiKey && accessStore.iflytekApiSecret
+        ? accessStore.iflytekApiKey + ":" + accessStore.iflytekApiSecret
+        : ""
       : accessStore.openaiApiKey;
     return {
       isGoogle,
@@ -205,6 +243,8 @@ export function getHeaders() {
       isBaidu,
       isByteDance,
       isAlibaba,
+      isMoonshot,
+      isIflytek,
       apiKey,
       isEnabledAccessControl,
     };
@@ -214,15 +254,6 @@ export function getHeaders() {
     return isAzure ? "api-key" : isAnthropic ? "x-api-key" : "Authorization";
   }
 
-  function getBearerToken(apiKey: string, noBearer: boolean = false): string {
-    return validString(apiKey)
-      ? `${noBearer ? "" : "Bearer "}${apiKey.trim()}`
-      : "";
-  }
-
-  function validString(x: string): boolean {
-    return x?.length > 0;
-  }
   const {
     isGoogle,
     isAzure,
@@ -263,6 +294,12 @@ export function getClientApi(provider: ServiceProvider): ClientApi {
       return new ClientApi(ModelProvider.Doubao);
     case ServiceProvider.Alibaba:
       return new ClientApi(ModelProvider.Qwen);
+    case ServiceProvider.Tencent:
+      return new ClientApi(ModelProvider.Hunyuan);
+    case ServiceProvider.Moonshot:
+      return new ClientApi(ModelProvider.Moonshot);
+    case ServiceProvider.Iflytek:
+      return new ClientApi(ModelProvider.Iflytek);
     default:
       return new ClientApi(ModelProvider.GPT);
   }

+ 13 - 5
app/client/platforms/baidu.ts

@@ -77,16 +77,24 @@ export class ErnieApi implements LLMApi {
 
   async chat(options: ChatOptions) {
     const messages = options.messages.map((v) => ({
-      role: v.role,
+      // "error_code": 336006, "error_msg": "the role of message with even index in the messages must be user or function",
+      role: v.role === "system" ? "user" : v.role,
       content: getMessageTextContent(v),
     }));
 
     // "error_code": 336006, "error_msg": "the length of messages must be an odd number",
     if (messages.length % 2 === 0) {
-      messages.unshift({
-        role: "user",
-        content: " ",
-      });
+      if (messages.at(0)?.role === "user") {
+        messages.splice(1, 0, {
+          role: "assistant",
+          content: " ",
+        });
+      } else {
+        messages.unshift({
+          role: "user",
+          content: " ",
+        });
+      }
     }
 
     const modelConfig = {

+ 6 - 4
app/client/platforms/google.ts

@@ -25,11 +25,9 @@ export class GeminiProApi implements LLMApi {
       baseUrl = accessStore.googleUrl;
     }
 
+    const isApp = !!getClientConfig()?.isApp;
     if (baseUrl.length === 0) {
-      const isApp = !!getClientConfig()?.isApp;
-      baseUrl = isApp
-        ? DEFAULT_API_HOST + `/api/proxy/google?key=${accessStore.googleApiKey}`
-        : ApiPath.Google;
+      baseUrl = isApp ? DEFAULT_API_HOST + `/api/proxy/google` : ApiPath.Google;
     }
     if (baseUrl.endsWith("/")) {
       baseUrl = baseUrl.slice(0, baseUrl.length - 1);
@@ -43,6 +41,10 @@ export class GeminiProApi implements LLMApi {
     let chatPath = [baseUrl, path].join("/");
 
     chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
+    // if chatPath.startsWith('http') then add key in query string
+    if (chatPath.startsWith("http") && accessStore.googleApiKey) {
+      chatPath += `&key=${accessStore.googleApiKey}`;
+    }
     return chatPath;
   }
   extractMessage(res: any) {

+ 240 - 0
app/client/platforms/iflytek.ts

@@ -0,0 +1,240 @@
+"use client";
+import {
+  ApiPath,
+  DEFAULT_API_HOST,
+  Iflytek,
+  REQUEST_TIMEOUT_MS,
+} from "@/app/constant";
+import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
+
+import { ChatOptions, getHeaders, LLMApi, LLMModel } from "../api";
+import Locale from "../../locales";
+import {
+  EventStreamContentType,
+  fetchEventSource,
+} from "@fortaine/fetch-event-source";
+import { prettyObject } from "@/app/utils/format";
+import { getClientConfig } from "@/app/config/client";
+import { getMessageTextContent } from "@/app/utils";
+
+import { OpenAIListModelResponse, RequestPayload } from "./openai";
+
+export class SparkApi implements LLMApi {
+  private disableListModels = true;
+
+  path(path: string): string {
+    const accessStore = useAccessStore.getState();
+
+    let baseUrl = "";
+
+    if (accessStore.useCustomConfig) {
+      baseUrl = accessStore.iflytekUrl;
+    }
+
+    if (baseUrl.length === 0) {
+      const isApp = !!getClientConfig()?.isApp;
+      const apiPath = ApiPath.Iflytek;
+      baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath;
+    }
+
+    if (baseUrl.endsWith("/")) {
+      baseUrl = baseUrl.slice(0, baseUrl.length - 1);
+    }
+    if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Iflytek)) {
+      baseUrl = "https://" + baseUrl;
+    }
+
+    console.log("[Proxy Endpoint] ", baseUrl, path);
+
+    return [baseUrl, path].join("/");
+  }
+
+  extractMessage(res: any) {
+    return res.choices?.at(0)?.message?.content ?? "";
+  }
+
+  async chat(options: ChatOptions) {
+    const messages: ChatOptions["messages"] = [];
+    for (const v of options.messages) {
+      const content = getMessageTextContent(v);
+      messages.push({ role: v.role, content });
+    }
+
+    const modelConfig = {
+      ...useAppConfig.getState().modelConfig,
+      ...useChatStore.getState().currentSession().mask.modelConfig,
+      ...{
+        model: options.config.model,
+        providerName: options.config.providerName,
+      },
+    };
+
+    const requestPayload: RequestPayload = {
+      messages,
+      stream: options.config.stream,
+      model: modelConfig.model,
+      temperature: modelConfig.temperature,
+      presence_penalty: modelConfig.presence_penalty,
+      frequency_penalty: modelConfig.frequency_penalty,
+      top_p: modelConfig.top_p,
+      // 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.
+    };
+
+    console.log("[Request] Spark payload: ", requestPayload);
+
+    const shouldStream = !!options.config.stream;
+    const controller = new AbortController();
+    options.onController?.(controller);
+
+    try {
+      const chatPath = this.path(Iflytek.ChatPath);
+      const chatPayload = {
+        method: "POST",
+        body: JSON.stringify(requestPayload),
+        signal: controller.signal,
+        headers: getHeaders(),
+      };
+
+      // Make a fetch request
+      const requestTimeoutId = setTimeout(
+        () => controller.abort(),
+        REQUEST_TIMEOUT_MS,
+      );
+
+      if (shouldStream) {
+        let responseText = "";
+        let remainText = "";
+        let finished = false;
+
+        // Animate response text to make it look smooth
+        function animateResponseText() {
+          if (finished || controller.signal.aborted) {
+            responseText += remainText;
+            console.log("[Response Animation] finished");
+            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 animation
+        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("[Spark] request response content type: ", contentType);
+
+            if (contentType?.startsWith("text/plain")) {
+              responseText = await res.clone().text();
+              return finish();
+            }
+
+            // Handle different error scenarios
+            if (
+              !res.ok ||
+              !res.headers
+                .get("content-type")
+                ?.startsWith(EventStreamContentType) ||
+              res.status !== 200
+            ) {
+              let extraInfo = await res.clone().text();
+              try {
+                const resJson = await res.clone().json();
+                extraInfo = prettyObject(resJson);
+              } catch {}
+
+              if (res.status === 401) {
+                extraInfo = Locale.Error.Unauthorized;
+              }
+
+              options.onError?.(
+                new Error(
+                  `Request failed with status ${res.status}: ${extraInfo}`,
+                ),
+              );
+              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;
+
+              if (delta) {
+                remainText += delta;
+              }
+            } catch (e) {
+              console.error("[Request] parse error", text);
+              options.onError?.(new Error(`Failed to parse response: ${text}`));
+            }
+          },
+          onclose() {
+            finish();
+          },
+          onerror(e) {
+            options.onError?.(e);
+            throw e;
+          },
+          openWhenHidden: true,
+        });
+      } else {
+        const res = await fetch(chatPath, chatPayload);
+        clearTimeout(requestTimeoutId);
+
+        if (!res.ok) {
+          const errorText = await res.text();
+          options.onError?.(
+            new Error(`Request failed with status ${res.status}: ${errorText}`),
+          );
+          return;
+        }
+
+        const resJson = await res.json();
+        const message = this.extractMessage(resJson);
+        options.onFinish(message);
+      }
+    } catch (e) {
+      console.log("[Request] failed to make a chat request", e);
+      options.onError?.(e as Error);
+    }
+  }
+
+  async usage() {
+    return {
+      used: 0,
+      total: 0,
+    };
+  }
+
+  async models(): Promise<LLMModel[]> {
+    return [];
+  }
+}

+ 251 - 0
app/client/platforms/moonshot.ts

@@ -0,0 +1,251 @@
+"use client";
+// azure and openai, using same models. so using same LLMApi.
+import {
+  ApiPath,
+  DEFAULT_API_HOST,
+  DEFAULT_MODELS,
+  Moonshot,
+  REQUEST_TIMEOUT_MS,
+  ServiceProvider,
+} from "@/app/constant";
+import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
+import { collectModelsWithDefaultModel } from "@/app/utils/model";
+import { preProcessImageContent } from "@/app/utils/chat";
+import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
+
+import {
+  ChatOptions,
+  getHeaders,
+  LLMApi,
+  LLMModel,
+  LLMUsage,
+  MultimodalContent,
+} from "../api";
+import Locale from "../../locales";
+import {
+  EventStreamContentType,
+  fetchEventSource,
+} from "@fortaine/fetch-event-source";
+import { prettyObject } from "@/app/utils/format";
+import { getClientConfig } from "@/app/config/client";
+import { getMessageTextContent } from "@/app/utils";
+
+import { OpenAIListModelResponse, RequestPayload } from "./openai";
+
+export class MoonshotApi implements LLMApi {
+  private disableListModels = true;
+
+  path(path: string): string {
+    const accessStore = useAccessStore.getState();
+
+    let baseUrl = "";
+
+    if (accessStore.useCustomConfig) {
+      baseUrl = accessStore.moonshotUrl;
+    }
+
+    if (baseUrl.length === 0) {
+      const isApp = !!getClientConfig()?.isApp;
+      const apiPath = ApiPath.Moonshot;
+      baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath;
+    }
+
+    if (baseUrl.endsWith("/")) {
+      baseUrl = baseUrl.slice(0, baseUrl.length - 1);
+    }
+    if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Moonshot)) {
+      baseUrl = "https://" + baseUrl;
+    }
+
+    console.log("[Proxy Endpoint] ", baseUrl, path);
+
+    return [baseUrl, path].join("/");
+  }
+
+  extractMessage(res: any) {
+    return res.choices?.at(0)?.message?.content ?? "";
+  }
+
+  async chat(options: ChatOptions) {
+    const messages: ChatOptions["messages"] = [];
+    for (const v of options.messages) {
+      const content = getMessageTextContent(v);
+      messages.push({ role: v.role, content });
+    }
+
+    const modelConfig = {
+      ...useAppConfig.getState().modelConfig,
+      ...useChatStore.getState().currentSession().mask.modelConfig,
+      ...{
+        model: options.config.model,
+        providerName: options.config.providerName,
+      },
+    };
+
+    const requestPayload: RequestPayload = {
+      messages,
+      stream: options.config.stream,
+      model: modelConfig.model,
+      temperature: modelConfig.temperature,
+      presence_penalty: modelConfig.presence_penalty,
+      frequency_penalty: modelConfig.frequency_penalty,
+      top_p: modelConfig.top_p,
+      // 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.
+    };
+
+    console.log("[Request] openai payload: ", requestPayload);
+
+    const shouldStream = !!options.config.stream;
+    const controller = new AbortController();
+    options.onController?.(controller);
+
+    try {
+      const chatPath = this.path(Moonshot.ChatPath);
+      const chatPayload = {
+        method: "POST",
+        body: JSON.stringify(requestPayload),
+        signal: controller.signal,
+        headers: getHeaders(),
+      };
+
+      // make a fetch request
+      const requestTimeoutId = setTimeout(
+        () => controller.abort(),
+        REQUEST_TIMEOUT_MS,
+      );
+
+      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;
+              }
+            } catch (e) {
+              console.error("[Request] parse error", text, msg);
+            }
+          },
+          onclose() {
+            finish();
+          },
+          onerror(e) {
+            options.onError?.(e);
+            throw e;
+          },
+          openWhenHidden: true,
+        });
+      } else {
+        const res = await fetch(chatPath, chatPayload);
+        clearTimeout(requestTimeoutId);
+
+        const resJson = await res.json();
+        const message = this.extractMessage(resJson);
+        options.onFinish(message);
+      }
+    } catch (e) {
+      console.log("[Request] failed to make a chat request", e);
+      options.onError?.(e as Error);
+    }
+  }
+  async usage() {
+    return {
+      used: 0,
+      total: 0,
+    };
+  }
+
+  async models(): Promise<LLMModel[]> {
+    return [];
+  }
+}

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

@@ -11,8 +11,13 @@ import {
 } from "@/app/constant";
 import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
 import { collectModelsWithDefaultModel } from "@/app/utils/model";
-import { preProcessImageContent } from "@/app/utils/chat";
+import {
+  preProcessImageContent,
+  uploadImage,
+  base64Image2Blob,
+} from "@/app/utils/chat";
 import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
+import { DalleSize, DalleQuality, DalleStyle } from "@/app/typing";
 
 import {
   ChatOptions,
@@ -33,6 +38,7 @@ import {
   getMessageTextContent,
   getMessageImages,
   isVisionModel,
+  isDalle3 as _isDalle3,
 } from "@/app/utils";
 
 export interface OpenAIListModelResponse {
@@ -58,6 +64,16 @@ export interface RequestPayload {
   max_tokens?: number;
 }
 
+export interface DalleRequestPayload {
+  model: string;
+  prompt: string;
+  response_format: "url" | "b64_json";
+  n: number;
+  size: DalleSize;
+  quality: DalleQuality;
+  style: DalleStyle;
+}
+
 export class ChatGPTApi implements LLMApi {
   private disableListModels = true;
 
@@ -100,20 +116,31 @@ export class ChatGPTApi implements LLMApi {
     return cloudflareAIGatewayUrl([baseUrl, path].join("/"));
   }
 
-  extractMessage(res: any) {
-    return res.choices?.at(0)?.message?.content ?? "";
+  async extractMessage(res: any) {
+    if (res.error) {
+      return "```\n" + JSON.stringify(res, null, 4) + "\n```";
+    }
+    // dalle3 model return url, using url create image message
+    if (res.data) {
+      let url = res.data?.at(0)?.url ?? "";
+      const b64_json = res.data?.at(0)?.b64_json ?? "";
+      if (!url && b64_json) {
+        // uploadImage
+        url = await uploadImage(base64Image2Blob(b64_json, "image/png"));
+      }
+      return [
+        {
+          type: "image_url",
+          image_url: {
+            url,
+          },
+        },
+      ];
+    }
+    return res.choices?.at(0)?.message?.content ?? res;
   }
 
   async chat(options: ChatOptions) {
-    const visionModel = isVisionModel(options.config.model);
-    const messages: ChatOptions["messages"] = [];
-    for (const v of options.messages) {
-      const content = visionModel
-        ? await preProcessImageContent(v.content)
-        : getMessageTextContent(v);
-      messages.push({ role: v.role, content });
-    }
-
     const modelConfig = {
       ...useAppConfig.getState().modelConfig,
       ...useChatStore.getState().currentSession().mask.modelConfig,
@@ -123,26 +150,54 @@ export class ChatGPTApi implements LLMApi {
       },
     };
 
-    const requestPayload: RequestPayload = {
-      messages,
-      stream: options.config.stream,
-      model: modelConfig.model,
-      temperature: modelConfig.temperature,
-      presence_penalty: modelConfig.presence_penalty,
-      frequency_penalty: modelConfig.frequency_penalty,
-      top_p: modelConfig.top_p,
-      // 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.
-    };
+    let requestPayload: RequestPayload | DalleRequestPayload;
+
+    const isDalle3 = _isDalle3(options.config.model);
+    if (isDalle3) {
+      const prompt = getMessageTextContent(
+        options.messages.slice(-1)?.pop() as any,
+      );
+      requestPayload = {
+        model: options.config.model,
+        prompt,
+        // URLs are only valid for 60 minutes after the image has been generated.
+        response_format: "b64_json", // using b64_json, and save image in CacheStorage
+        n: 1,
+        size: options.config?.size ?? "1024x1024",
+        quality: options.config?.quality ?? "standard",
+        style: options.config?.style ?? "vivid",
+      };
+    } else {
+      const visionModel = isVisionModel(options.config.model);
+      const messages: ChatOptions["messages"] = [];
+      for (const v of options.messages) {
+        const content = visionModel
+          ? await preProcessImageContent(v.content)
+          : getMessageTextContent(v);
+        messages.push({ role: v.role, content });
+      }
 
-    // add max_tokens to vision model
-    if (visionModel && modelConfig.model.includes("preview")) {
-      requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
+      requestPayload = {
+        messages,
+        stream: options.config.stream,
+        model: modelConfig.model,
+        temperature: modelConfig.temperature,
+        presence_penalty: modelConfig.presence_penalty,
+        frequency_penalty: modelConfig.frequency_penalty,
+        top_p: modelConfig.top_p,
+        // 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")) {
+        requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
+      }
     }
 
     console.log("[Request] openai payload: ", requestPayload);
 
-    const shouldStream = !!options.config.stream;
+    const shouldStream = !isDalle3 && !!options.config.stream;
     const controller = new AbortController();
     options.onController?.(controller);
 
@@ -168,13 +223,15 @@ export class ChatGPTApi implements LLMApi {
             model?.provider?.providerName === ServiceProvider.Azure,
         );
         chatPath = this.path(
-          Azure.ChatPath(
+          (isDalle3 ? Azure.ImagePath : Azure.ChatPath)(
             (model?.displayName ?? model?.name) as string,
             useCustomConfig ? useAccessStore.getState().azureApiVersion : "",
           ),
         );
       } else {
-        chatPath = this.path(OpenaiPath.ChatPath);
+        chatPath = this.path(
+          isDalle3 ? OpenaiPath.ImagePath : OpenaiPath.ChatPath,
+        );
       }
       const chatPayload = {
         method: "POST",
@@ -186,7 +243,7 @@ export class ChatGPTApi implements LLMApi {
       // make a fetch request
       const requestTimeoutId = setTimeout(
         () => controller.abort(),
-        REQUEST_TIMEOUT_MS,
+        isDalle3 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow.
       );
 
       if (shouldStream) {
@@ -317,7 +374,7 @@ export class ChatGPTApi implements LLMApi {
         clearTimeout(requestTimeoutId);
 
         const resJson = await res.json();
-        const message = this.extractMessage(resJson);
+        const message = await this.extractMessage(resJson);
         options.onFinish(message);
       }
     } catch (e) {
@@ -411,13 +468,17 @@ export class ChatGPTApi implements LLMApi {
       return [];
     }
 
+    //由于目前 OpenAI 的 disableListModels 默认为 true,所以当前实际不会运行到这场
+    let seq = 1000; //同 Constant.ts 中的排序保持一致
     return chatModels.map((m) => ({
       name: m.id,
       available: true,
+      sorted: seq++,
       provider: {
         id: "openai",
         providerName: "OpenAI",
         providerType: "openai",
+        sorted: 1,
       },
     }));
   }

+ 268 - 0
app/client/platforms/tencent.ts

@@ -0,0 +1,268 @@
+"use client";
+import { ApiPath, DEFAULT_API_HOST, REQUEST_TIMEOUT_MS } from "@/app/constant";
+import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
+
+import {
+  ChatOptions,
+  getHeaders,
+  LLMApi,
+  LLMModel,
+  MultimodalContent,
+} from "../api";
+import Locale from "../../locales";
+import {
+  EventStreamContentType,
+  fetchEventSource,
+} from "@fortaine/fetch-event-source";
+import { prettyObject } from "@/app/utils/format";
+import { getClientConfig } from "@/app/config/client";
+import { getMessageTextContent, isVisionModel } from "@/app/utils";
+import mapKeys from "lodash-es/mapKeys";
+import mapValues from "lodash-es/mapValues";
+import isArray from "lodash-es/isArray";
+import isObject from "lodash-es/isObject";
+
+export interface OpenAIListModelResponse {
+  object: string;
+  data: Array<{
+    id: string;
+    object: string;
+    root: string;
+  }>;
+}
+
+interface RequestPayload {
+  Messages: {
+    Role: "system" | "user" | "assistant";
+    Content: string | MultimodalContent[];
+  }[];
+  Stream?: boolean;
+  Model: string;
+  Temperature: number;
+  TopP: number;
+}
+
+function capitalizeKeys(obj: any): any {
+  if (isArray(obj)) {
+    return obj.map(capitalizeKeys);
+  } else if (isObject(obj)) {
+    return mapValues(
+      mapKeys(obj, (value: any, key: string) =>
+        key.replace(/(^|_)(\w)/g, (m, $1, $2) => $2.toUpperCase()),
+      ),
+      capitalizeKeys,
+    );
+  } else {
+    return obj;
+  }
+}
+
+export class HunyuanApi implements LLMApi {
+  path(): string {
+    const accessStore = useAccessStore.getState();
+
+    let baseUrl = "";
+
+    if (accessStore.useCustomConfig) {
+      baseUrl = accessStore.tencentUrl;
+    }
+
+    if (baseUrl.length === 0) {
+      const isApp = !!getClientConfig()?.isApp;
+      baseUrl = isApp
+        ? DEFAULT_API_HOST + "/api/proxy/tencent"
+        : ApiPath.Tencent;
+    }
+
+    if (baseUrl.endsWith("/")) {
+      baseUrl = baseUrl.slice(0, baseUrl.length - 1);
+    }
+    if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Tencent)) {
+      baseUrl = "https://" + baseUrl;
+    }
+
+    console.log("[Proxy Endpoint] ", baseUrl);
+    return baseUrl;
+  }
+
+  extractMessage(res: any) {
+    return res.Choices?.at(0)?.Message?.Content ?? "";
+  }
+
+  async chat(options: ChatOptions) {
+    const visionModel = isVisionModel(options.config.model);
+    const messages = options.messages.map((v, index) => ({
+      // "Messages 中 system 角色必须位于列表的最开始"
+      role: index !== 0 && v.role === "system" ? "user" : v.role,
+      content: visionModel ? v.content : getMessageTextContent(v),
+    }));
+
+    const modelConfig = {
+      ...useAppConfig.getState().modelConfig,
+      ...useChatStore.getState().currentSession().mask.modelConfig,
+      ...{
+        model: options.config.model,
+      },
+    };
+
+    const requestPayload: RequestPayload = capitalizeKeys({
+      model: modelConfig.model,
+      messages,
+      temperature: modelConfig.temperature,
+      top_p: modelConfig.top_p,
+      stream: options.config.stream,
+    });
+
+    console.log("[Request] Tencent payload: ", requestPayload);
+
+    const shouldStream = !!options.config.stream;
+    const controller = new AbortController();
+    options.onController?.(controller);
+
+    try {
+      const chatPath = this.path();
+      const chatPayload = {
+        method: "POST",
+        body: JSON.stringify(requestPayload),
+        signal: controller.signal,
+        headers: getHeaders(),
+      };
+
+      // make a fetch request
+      const requestTimeoutId = setTimeout(
+        () => controller.abort(),
+        REQUEST_TIMEOUT_MS,
+      );
+
+      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(
+              "[Tencent] 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;
+              if (delta) {
+                remainText += delta;
+              }
+            } catch (e) {
+              console.error("[Request] parse error", text, msg);
+            }
+          },
+          onclose() {
+            finish();
+          },
+          onerror(e) {
+            options.onError?.(e);
+            throw e;
+          },
+          openWhenHidden: true,
+        });
+      } else {
+        const res = await fetch(chatPath, chatPayload);
+        clearTimeout(requestTimeoutId);
+
+        const resJson = await res.json();
+        const message = this.extractMessage(resJson);
+        options.onFinish(message);
+      }
+    } catch (e) {
+      console.log("[Request] failed to make a chat request", e);
+      options.onError?.(e as Error);
+    }
+  }
+  async usage() {
+    return {
+      used: 0,
+      total: 0,
+    };
+  }
+
+  async models(): Promise<LLMModel[]> {
+    return [];
+  }
+}

+ 8 - 5
app/command.ts

@@ -41,13 +41,16 @@ interface ChatCommands {
   del?: Command;
 }
 
-export const ChatCommandPrefix = ":";
+// Compatible with Chinese colon character ":"
+export const ChatCommandPrefix = /^[::]/;
 
 export function useChatCommand(commands: ChatCommands = {}) {
   function extract(userInput: string) {
-    return (
-      userInput.startsWith(ChatCommandPrefix) ? userInput.slice(1) : userInput
-    ) as keyof ChatCommands;
+    const match = userInput.match(ChatCommandPrefix);
+    if (match) {
+      return userInput.slice(1) as keyof ChatCommands;
+    }
+    return userInput as keyof ChatCommands;
   }
 
   function search(userInput: string) {
@@ -57,7 +60,7 @@ export function useChatCommand(commands: ChatCommands = {}) {
       .filter((c) => c.startsWith(input))
       .map((c) => ({
         title: desc[c as keyof ChatCommands],
-        content: ChatCommandPrefix + c,
+        content: ":" + c,
       }));
   }
 

+ 31 - 0
app/components/artifacts.module.scss

@@ -0,0 +1,31 @@
+.artifacts {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  flex-direction: column;
+  &-header {
+    display: flex;
+    align-items: center;
+    height: 36px;
+    padding: 20px;
+    background: var(--second);
+  }
+  &-title {
+    flex: 1;
+    text-align: center;
+    font-weight: bold;
+    font-size: 24px;
+  }
+  &-content {
+    flex-grow: 1;
+    padding: 0 20px 20px 20px;
+    background-color: var(--second);
+  }
+}
+
+.artifacts-iframe {
+  width: 100%;
+  border: var(--border-in-light);
+  border-radius: 6px;
+  background-color: var(--gray);
+}

+ 234 - 0
app/components/artifacts.tsx

@@ -0,0 +1,234 @@
+import { useEffect, useState, useRef, useMemo } from "react";
+import { useParams } from "react-router";
+import { useWindowSize } from "@/app/utils";
+import { IconButton } from "./button";
+import { nanoid } from "nanoid";
+import ExportIcon from "../icons/share.svg";
+import CopyIcon from "../icons/copy.svg";
+import DownloadIcon from "../icons/download.svg";
+import GithubIcon from "../icons/github.svg";
+import LoadingButtonIcon from "../icons/loading.svg";
+import Locale from "../locales";
+import { Modal, showToast } from "./ui-lib";
+import { copyToClipboard, downloadAs } from "../utils";
+import { Path, ApiPath, REPO_URL } from "@/app/constant";
+import { Loading } from "./home";
+import styles from "./artifacts.module.scss";
+
+export function HTMLPreview(props: {
+  code: string;
+  autoHeight?: boolean;
+  height?: number | string;
+  onLoad?: (title?: string) => void;
+}) {
+  const ref = useRef<HTMLIFrameElement>(null);
+  const frameId = useRef<string>(nanoid());
+  const [iframeHeight, setIframeHeight] = useState(600);
+  const [title, setTitle] = useState("");
+  /*
+   * https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an
+   * 1. using srcdoc
+   * 2. using src with dataurl:
+   *    easy to share
+   *    length limit (Data URIs cannot be larger than 32,768 characters.)
+   */
+
+  useEffect(() => {
+    const handleMessage = (e: any) => {
+      const { id, height, title } = e.data;
+      setTitle(title);
+      if (id == frameId.current) {
+        setIframeHeight(height);
+      }
+    };
+    window.addEventListener("message", handleMessage);
+    return () => {
+      window.removeEventListener("message", handleMessage);
+    };
+  }, []);
+
+  const height = useMemo(() => {
+    if (!props.autoHeight) return props.height || 600;
+    if (typeof props.height === "string") {
+      return props.height;
+    }
+    const parentHeight = props.height || 600;
+    return iframeHeight + 40 > parentHeight ? parentHeight : iframeHeight + 40;
+  }, [props.autoHeight, props.height, iframeHeight]);
+
+  const srcDoc = useMemo(() => {
+    const script = `<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId.current}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`;
+    if (props.code.includes("</head>")) {
+      props.code.replace("</head>", "</head>" + script);
+    }
+    return props.code + script;
+  }, [props.code]);
+
+  const handleOnLoad = () => {
+    if (props?.onLoad) {
+      props.onLoad(title);
+    }
+  };
+
+  return (
+    <iframe
+      className={styles["artifacts-iframe"]}
+      id={frameId.current}
+      ref={ref}
+      sandbox="allow-forms allow-modals allow-scripts"
+      style={{ height }}
+      srcDoc={srcDoc}
+      onLoad={handleOnLoad}
+    />
+  );
+}
+
+export function ArtifactsShareButton({
+  getCode,
+  id,
+  style,
+  fileName,
+}: {
+  getCode: () => string;
+  id?: string;
+  style?: any;
+  fileName?: string;
+}) {
+  const [loading, setLoading] = useState(false);
+  const [name, setName] = useState(id);
+  const [show, setShow] = useState(false);
+  const shareUrl = useMemo(
+    () => [location.origin, "#", Path.Artifacts, "/", name].join(""),
+    [name],
+  );
+  const upload = (code: string) =>
+    id
+      ? Promise.resolve({ id })
+      : fetch(ApiPath.Artifacts, {
+          method: "POST",
+          body: code,
+        })
+          .then((res) => res.json())
+          .then(({ id }) => {
+            if (id) {
+              return { id };
+            }
+            throw Error();
+          })
+          .catch((e) => {
+            showToast(Locale.Export.Artifacts.Error);
+          });
+  return (
+    <>
+      <div className="window-action-button" style={style}>
+        <IconButton
+          icon={loading ? <LoadingButtonIcon /> : <ExportIcon />}
+          bordered
+          title={Locale.Export.Artifacts.Title}
+          onClick={() => {
+            if (loading) return;
+            setLoading(true);
+            upload(getCode())
+              .then((res) => {
+                if (res?.id) {
+                  setShow(true);
+                  setName(res?.id);
+                }
+              })
+              .finally(() => setLoading(false));
+          }}
+        />
+      </div>
+      {show && (
+        <div className="modal-mask">
+          <Modal
+            title={Locale.Export.Artifacts.Title}
+            onClose={() => setShow(false)}
+            actions={[
+              <IconButton
+                key="download"
+                icon={<DownloadIcon />}
+                bordered
+                text={Locale.Export.Download}
+                onClick={() => {
+                  downloadAs(getCode(), `${fileName || name}.html`).then(() =>
+                    setShow(false),
+                  );
+                }}
+              />,
+              <IconButton
+                key="copy"
+                icon={<CopyIcon />}
+                bordered
+                text={Locale.Chat.Actions.Copy}
+                onClick={() => {
+                  copyToClipboard(shareUrl).then(() => setShow(false));
+                }}
+              />,
+            ]}
+          >
+            <div>
+              <a target="_blank" href={shareUrl}>
+                {shareUrl}
+              </a>
+            </div>
+          </Modal>
+        </div>
+      )}
+    </>
+  );
+}
+
+export function Artifacts() {
+  const { id } = useParams();
+  const [code, setCode] = useState("");
+  const [loading, setLoading] = useState(true);
+  const [fileName, setFileName] = useState("");
+
+  useEffect(() => {
+    if (id) {
+      fetch(`${ApiPath.Artifacts}?id=${id}`)
+        .then((res) => {
+          if (res.status > 300) {
+            throw Error("can not get content");
+          }
+          return res;
+        })
+        .then((res) => res.text())
+        .then(setCode)
+        .catch((e) => {
+          showToast(Locale.Export.Artifacts.Error);
+        });
+    }
+  }, [id]);
+
+  return (
+    <div className={styles["artifacts"]}>
+      <div className={styles["artifacts-header"]}>
+        <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
+          <IconButton bordered icon={<GithubIcon />} shadow />
+        </a>
+        <div className={styles["artifacts-title"]}>NextChat Artifacts</div>
+        <ArtifactsShareButton
+          id={id}
+          getCode={() => code}
+          fileName={fileName}
+        />
+      </div>
+      <div className={styles["artifacts-content"]}>
+        {loading && <Loading />}
+        {code && (
+          <HTMLPreview
+            code={code}
+            autoHeight={false}
+            height={"100%"}
+            onLoad={(title) => {
+              setFileName(title as string);
+              setLoading(false);
+            }}
+          />
+        )}
+      </div>
+    </div>
+  );
+}

+ 12 - 1
app/components/button.tsx

@@ -1,6 +1,7 @@
 import * as React from "react";
 
 import styles from "./button.module.scss";
+import { CSSProperties } from "react";
 
 export type ButtonType = "primary" | "danger" | null;
 
@@ -16,6 +17,8 @@ export function IconButton(props: {
   disabled?: boolean;
   tabIndex?: number;
   autoFocus?: boolean;
+  style?: CSSProperties;
+  aria?: string;
 }) {
   return (
     <button
@@ -31,9 +34,12 @@ export function IconButton(props: {
       role="button"
       tabIndex={props.tabIndex}
       autoFocus={props.autoFocus}
+      style={props.style}
+      aria-label={props.aria}
     >
       {props.icon && (
         <div
+          aria-label={props.text || props.title}
           className={
             styles["icon-button-icon"] +
             ` ${props.type === "primary" && "no-dark"}`
@@ -44,7 +50,12 @@ export function IconButton(props: {
       )}
 
       {props.text && (
-        <div className={styles["icon-button-text"]}>{props.text}</div>
+        <div
+          aria-label={props.text || props.title}
+          className={styles["icon-button-text"]}
+        >
+          {props.text}
+        </div>
       )}
     </button>
   );

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

@@ -346,6 +346,12 @@
       flex-wrap: nowrap;
     }
   }
+
+  .chat-model-name {
+    font-size: 12px;
+    color: var(--black);
+    margin-left: 6px;
+  }
 }
 
 .chat-message-container {

+ 146 - 2
app/components/chat.tsx

@@ -37,6 +37,10 @@ import AutoIcon from "../icons/auto.svg";
 import BottomIcon from "../icons/bottom.svg";
 import StopIcon from "../icons/pause.svg";
 import RobotIcon from "../icons/robot.svg";
+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 {
   ChatMessage,
@@ -59,6 +63,7 @@ import {
   getMessageTextContent,
   getMessageImages,
   isVisionModel,
+  isDalle3,
 } from "../utils";
 
 import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
@@ -66,6 +71,7 @@ import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
 import dynamic from "next/dynamic";
 
 import { ChatControllerPool } from "../client/controller";
+import { DalleSize, DalleQuality, DalleStyle } from "../typing";
 import { Prompt, usePromptStore } from "../store/prompt";
 import Locale from "../locales";
 
@@ -89,6 +95,7 @@ import {
   REQUEST_TIMEOUT_MS,
   UNFINISHED_INPUT,
   ServiceProvider,
+  Plugin,
 } from "../constant";
 import { Avatar } from "./emoji";
 import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
@@ -338,7 +345,7 @@ function ClearContextDivider() {
   );
 }
 
-function ChatAction(props: {
+export function ChatAction(props: {
   text: string;
   icon: JSX.Element;
   onClick: () => void;
@@ -476,8 +483,22 @@ export function ChatActions(props: {
     return model?.displayName ?? "";
   }, [models, currentModel, currentProviderName]);
   const [showModelSelector, setShowModelSelector] = useState(false);
+  const [showPluginSelector, setShowPluginSelector] = useState(false);
   const [showUploadImage, setShowUploadImage] = useState(false);
 
+  const [showSizeSelector, setShowSizeSelector] = useState(false);
+  const [showQualitySelector, setShowQualitySelector] = useState(false);
+  const [showStyleSelector, setShowStyleSelector] = useState(false);
+  const dalle3Sizes: DalleSize[] = ["1024x1024", "1792x1024", "1024x1792"];
+  const dalle3Qualitys: DalleQuality[] = ["standard", "hd"];
+  const dalle3Styles: DalleStyle[] = ["vivid", "natural"];
+  const currentSize =
+    chatStore.currentSession().mask.modelConfig?.size ?? "1024x1024";
+  const currentQuality =
+    chatStore.currentSession().mask.modelConfig?.quality ?? "standard";
+  const currentStyle =
+    chatStore.currentSession().mask.modelConfig?.style ?? "vivid";
+
   useEffect(() => {
     const show = isVisionModel(currentModel);
     setShowUploadImage(show);
@@ -620,6 +641,115 @@ export function ChatActions(props: {
           }}
         />
       )}
+
+      {isDalle3(currentModel) && (
+        <ChatAction
+          onClick={() => setShowSizeSelector(true)}
+          text={currentSize}
+          icon={<SizeIcon />}
+        />
+      )}
+
+      {showSizeSelector && (
+        <Selector
+          defaultSelectedValue={currentSize}
+          items={dalle3Sizes.map((m) => ({
+            title: m,
+            value: m,
+          }))}
+          onClose={() => setShowSizeSelector(false)}
+          onSelection={(s) => {
+            if (s.length === 0) return;
+            const size = s[0];
+            chatStore.updateCurrentSession((session) => {
+              session.mask.modelConfig.size = size;
+            });
+            showToast(size);
+          }}
+        />
+      )}
+
+      {isDalle3(currentModel) && (
+        <ChatAction
+          onClick={() => setShowQualitySelector(true)}
+          text={currentQuality}
+          icon={<QualityIcon />}
+        />
+      )}
+
+      {showQualitySelector && (
+        <Selector
+          defaultSelectedValue={currentQuality}
+          items={dalle3Qualitys.map((m) => ({
+            title: m,
+            value: m,
+          }))}
+          onClose={() => setShowQualitySelector(false)}
+          onSelection={(q) => {
+            if (q.length === 0) return;
+            const quality = q[0];
+            chatStore.updateCurrentSession((session) => {
+              session.mask.modelConfig.quality = quality;
+            });
+            showToast(quality);
+          }}
+        />
+      )}
+
+      {isDalle3(currentModel) && (
+        <ChatAction
+          onClick={() => setShowStyleSelector(true)}
+          text={currentStyle}
+          icon={<StyleIcon />}
+        />
+      )}
+
+      {showStyleSelector && (
+        <Selector
+          defaultSelectedValue={currentStyle}
+          items={dalle3Styles.map((m) => ({
+            title: m,
+            value: m,
+          }))}
+          onClose={() => setShowStyleSelector(false)}
+          onSelection={(s) => {
+            if (s.length === 0) return;
+            const style = s[0];
+            chatStore.updateCurrentSession((session) => {
+              session.mask.modelConfig.style = style;
+            });
+            showToast(style);
+          }}
+        />
+      )}
+
+      <ChatAction
+        onClick={() => setShowPluginSelector(true)}
+        text={Locale.Plugin.Name}
+        icon={<PluginIcon />}
+      />
+      {showPluginSelector && (
+        <Selector
+          multiple
+          defaultSelectedValue={chatStore.currentSession().mask?.plugin}
+          items={[
+            {
+              title: Locale.Plugin.Artifacts,
+              value: Plugin.Artifacts,
+            },
+          ]}
+          onClose={() => setShowPluginSelector(false)}
+          onSelection={(s) => {
+            const plugin = s[0];
+            chatStore.updateCurrentSession((session) => {
+              session.mask.plugin = s;
+            });
+            if (plugin) {
+              showToast(plugin);
+            }
+          }}
+        />
+      )}
     </div>
   );
 }
@@ -701,6 +831,7 @@ function _Chat() {
   const session = chatStore.currentSession();
   const config = useAppConfig();
   const fontSize = config.fontSize;
+  const fontFamily = config.fontFamily;
 
   const [showExport, setShowExport] = useState(false);
 
@@ -780,7 +911,7 @@ function _Chat() {
     // clear search results
     if (n === 0) {
       setPromptHints([]);
-    } else if (text.startsWith(ChatCommandPrefix)) {
+    } else if (text.match(ChatCommandPrefix)) {
       setPromptHints(chatCommands.search(text));
     } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
       // check if need to trigger auto completion
@@ -1270,6 +1401,8 @@ function _Chat() {
               <IconButton
                 icon={<RenameIcon />}
                 bordered
+                title={Locale.Chat.EditMessage.Title}
+                aria={Locale.Chat.EditMessage.Title}
                 onClick={() => setIsEditingMessage(true)}
               />
             </div>
@@ -1289,6 +1422,8 @@ function _Chat() {
               <IconButton
                 icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
                 bordered
+                title={Locale.Chat.Actions.FullScreen}
+                aria={Locale.Chat.Actions.FullScreen}
                 onClick={() => {
                   config.update(
                     (config) => (config.tightBorder = !config.tightBorder),
@@ -1340,6 +1475,7 @@ function _Chat() {
                       <div className={styles["chat-message-edit"]}>
                         <IconButton
                           icon={<EditIcon />}
+                          aria={Locale.Chat.Actions.Edit}
                           onClick={async () => {
                             const newMessage = await showPrompt(
                               Locale.Chat.Actions.Edit,
@@ -1388,6 +1524,11 @@ function _Chat() {
                         </>
                       )}
                     </div>
+                    {!isUser && (
+                      <div className={styles["chat-model-name"]}>
+                        {message.model}
+                      </div>
+                    )}
 
                     {showActions && (
                       <div className={styles["chat-message-actions"]}>
@@ -1439,6 +1580,7 @@ function _Chat() {
                   )}
                   <div className={styles["chat-message-item"]}>
                     <Markdown
+                      key={message.streaming ? "loading" : "done"}
                       content={getMessageTextContent(message)}
                       loading={
                         (message.preview || message.streaming) &&
@@ -1451,6 +1593,7 @@ function _Chat() {
                         setUserInput(getMessageTextContent(message));
                       }}
                       fontSize={fontSize}
+                      fontFamily={fontFamily}
                       parentRef={scrollRef}
                       defaultShow={i >= messages.length - 6}
                     />
@@ -1545,6 +1688,7 @@ function _Chat() {
             autoFocus={autoFocus}
             style={{
               fontSize: config.fontSize,
+              fontFamily: config.fontFamily,
             }}
           />
           {attachImages.length != 0 && (

+ 2 - 0
app/components/error.tsx

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

+ 2 - 1
app/components/exporter.tsx

@@ -541,7 +541,7 @@ export function ImagePreviewer(props: {
           <div>
             <div className={styles["main-title"]}>NextChat</div>
             <div className={styles["sub-title"]}>
-              github.com/Yidadaa/ChatGPT-Next-Web
+              github.com/ChatGPTNextWeb/ChatGPT-Next-Web
             </div>
             <div className={styles["icons"]}>
               <ExportAvatar avatar={config.avatar} />
@@ -583,6 +583,7 @@ export function ImagePreviewer(props: {
                 <Markdown
                   content={getMessageTextContent(m)}
                   fontSize={config.fontSize}
+                  fontFamily={config.fontFamily}
                   defaultShow
                 />
                 {getMessageImages(m).length == 1 && (

+ 9 - 3
app/components/home.module.scss

@@ -137,12 +137,18 @@
   position: relative;
   padding-top: 20px;
   padding-bottom: 20px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
 }
 
 .sidebar-logo {
-  position: absolute;
-  right: 0;
-  bottom: 18px;
+  display: inline-flex;
+}
+
+.sidebar-title-container {
+  display: inline-flex;
+  flex-direction: column;
 }
 
 .sidebar-title {

+ 51 - 25
app/components/home.tsx

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

+ 3 - 0
app/components/input-range.tsx

@@ -9,6 +9,7 @@ interface InputRangeProps {
   min: string;
   max: string;
   step: string;
+  aria: string;
 }
 
 export function InputRange({
@@ -19,11 +20,13 @@ export function InputRange({
   min,
   max,
   step,
+  aria,
 }: InputRangeProps) {
   return (
     <div className={styles["input-range"] + ` ${className ?? ""}`}>
       {title || value}
       <input
+        aria-label={aria}
         type="range"
         title={title}
         value={value}

+ 67 - 8
app/components/markdown.tsx

@@ -6,14 +6,16 @@ import RehypeKatex from "rehype-katex";
 import RemarkGfm from "remark-gfm";
 import RehypeHighlight from "rehype-highlight";
 import { useRef, useState, RefObject, useEffect, useMemo } from "react";
-import { copyToClipboard } from "../utils";
+import { copyToClipboard, useWindowSize } from "../utils";
 import mermaid from "mermaid";
 
 import LoadingIcon from "../icons/three-dots.svg";
 import React from "react";
 import { useDebouncedCallback } from "use-debounce";
-import { showImageModal } from "./ui-lib";
-
+import { showImageModal, FullScreen } from "./ui-lib";
+import { ArtifactsShareButton, HTMLPreview } from "./artifacts";
+import { Plugin } from "../constant";
+import { useChatStore } from "../store";
 export function Mermaid(props: { code: string }) {
   const ref = useRef<HTMLDivElement>(null);
   const [hasError, setHasError] = useState(false);
@@ -64,25 +66,64 @@ export function PreCode(props: { children: any }) {
   const ref = useRef<HTMLPreElement>(null);
   const refText = ref.current?.innerText;
   const [mermaidCode, setMermaidCode] = useState("");
+  const [htmlCode, setHtmlCode] = useState("");
+  const { height } = useWindowSize();
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const plugins = session.mask?.plugin;
 
-  const renderMermaid = useDebouncedCallback(() => {
+  const renderArtifacts = useDebouncedCallback(() => {
     if (!ref.current) return;
     const mermaidDom = ref.current.querySelector("code.language-mermaid");
     if (mermaidDom) {
       setMermaidCode((mermaidDom as HTMLElement).innerText);
     }
+    const htmlDom = ref.current.querySelector("code.language-html");
+    if (htmlDom) {
+      setHtmlCode((htmlDom as HTMLElement).innerText);
+    } else if (refText?.startsWith("<!DOCTYPE")) {
+      setHtmlCode(refText);
+    }
   }, 600);
 
   useEffect(() => {
-    setTimeout(renderMermaid, 1);
+    setTimeout(renderArtifacts, 1);
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [refText]);
 
+  const enableArtifacts = useMemo(
+    () => plugins?.includes(Plugin.Artifacts),
+    [plugins],
+  );
+
+  //Wrap the paragraph for plain-text
+  useEffect(() => {
+    if (ref.current) {
+      const codeElements = ref.current.querySelectorAll(
+        "code",
+      ) as NodeListOf<HTMLElement>;
+      const wrapLanguages = [
+        "",
+        "md",
+        "markdown",
+        "text",
+        "txt",
+        "plaintext",
+        "tex",
+        "latex",
+      ];
+      codeElements.forEach((codeElement) => {
+        let languageClass = codeElement.className.match(/language-(\w+)/);
+        let name = languageClass ? languageClass[1] : "";
+        if (wrapLanguages.includes(name)) {
+          codeElement.style.whiteSpace = "pre-wrap";
+        }
+      });
+    }
+  }, []);
+
   return (
     <>
-      {mermaidCode.length > 0 && (
-        <Mermaid code={mermaidCode} key={mermaidCode} />
-      )}
       <pre ref={ref}>
         <span
           className="copy-code-button"
@@ -95,6 +136,22 @@ export function PreCode(props: { children: any }) {
         ></span>
         {props.children}
       </pre>
+      {mermaidCode.length > 0 && (
+        <Mermaid code={mermaidCode} key={mermaidCode} />
+      )}
+      {htmlCode.length > 0 && enableArtifacts && (
+        <FullScreen className="no-dark html" right={70}>
+          <ArtifactsShareButton
+            style={{ position: "absolute", right: 20, top: 10 }}
+            getCode={() => htmlCode}
+          />
+          <HTMLPreview
+            code={htmlCode}
+            autoHeight={!document.fullscreenElement}
+            height={!document.fullscreenElement ? 600 : height}
+          />
+        </FullScreen>
+      )}
     </>
   );
 }
@@ -175,6 +232,7 @@ export function Markdown(
     content: string;
     loading?: boolean;
     fontSize?: number;
+    fontFamily?: string;
     parentRef?: RefObject<HTMLDivElement>;
     defaultShow?: boolean;
   } & React.DOMAttributes<HTMLDivElement>,
@@ -186,6 +244,7 @@ export function Markdown(
       className="markdown-body"
       style={{
         fontSize: `${props.fontSize ?? 14}px`,
+        fontFamily: props.fontFamily || "inherit",
       }}
       ref={mdRef}
       onContextMenu={props.onContextMenu}

+ 6 - 0
app/components/mask.tsx

@@ -127,6 +127,8 @@ export function MaskConfig(props: {
             onClose={() => setShowPicker(false)}
           >
             <div
+              tabIndex={0}
+              aria-label={Locale.Mask.Config.Avatar}
               onClick={() => setShowPicker(true)}
               style={{ cursor: "pointer" }}
             >
@@ -139,6 +141,7 @@ export function MaskConfig(props: {
         </ListItem>
         <ListItem title={Locale.Mask.Config.Name}>
           <input
+            aria-label={Locale.Mask.Config.Name}
             type="text"
             value={props.mask.name}
             onInput={(e) =>
@@ -153,6 +156,7 @@ export function MaskConfig(props: {
           subTitle={Locale.Mask.Config.HideContext.SubTitle}
         >
           <input
+            aria-label={Locale.Mask.Config.HideContext.Title}
             type="checkbox"
             checked={props.mask.hideContext}
             onChange={(e) => {
@@ -169,6 +173,7 @@ export function MaskConfig(props: {
             subTitle={Locale.Mask.Config.Share.SubTitle}
           >
             <IconButton
+              aria={Locale.Mask.Config.Share.Title}
               icon={<CopyIcon />}
               text={Locale.Mask.Config.Share.Action}
               onClick={copyMaskLink}
@@ -182,6 +187,7 @@ export function MaskConfig(props: {
             subTitle={Locale.Mask.Config.Sync.SubTitle}
           >
             <input
+              aria-label={Locale.Mask.Config.Sync.Title}
               type="checkbox"
               checked={props.mask.syncGlobalConfig}
               onChange={async (e) => {

+ 11 - 0
app/components/model-config.tsx

@@ -17,6 +17,7 @@ export function ModelConfigList(props: {
     <>
       <ListItem title={Locale.Settings.Model}>
         <Select
+          aria-label={Locale.Settings.Model}
           value={value}
           onChange={(e) => {
             const [model, providerName] = e.currentTarget.value.split("@");
@@ -40,6 +41,7 @@ export function ModelConfigList(props: {
         subTitle={Locale.Settings.Temperature.SubTitle}
       >
         <InputRange
+          aria={Locale.Settings.Temperature.Title}
           value={props.modelConfig.temperature?.toFixed(1)}
           min="0"
           max="1" // lets limit it to 0-1
@@ -59,6 +61,7 @@ export function ModelConfigList(props: {
         subTitle={Locale.Settings.TopP.SubTitle}
       >
         <InputRange
+          aria={Locale.Settings.TopP.Title}
           value={(props.modelConfig.top_p ?? 1).toFixed(1)}
           min="0"
           max="1"
@@ -78,6 +81,7 @@ export function ModelConfigList(props: {
         subTitle={Locale.Settings.MaxTokens.SubTitle}
       >
         <input
+          aria-label={Locale.Settings.MaxTokens.Title}
           type="number"
           min={1024}
           max={512000}
@@ -100,6 +104,7 @@ export function ModelConfigList(props: {
             subTitle={Locale.Settings.PresencePenalty.SubTitle}
           >
             <InputRange
+              aria={Locale.Settings.PresencePenalty.Title}
               value={props.modelConfig.presence_penalty?.toFixed(1)}
               min="-2"
               max="2"
@@ -121,6 +126,7 @@ export function ModelConfigList(props: {
             subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
           >
             <InputRange
+              aria={Locale.Settings.FrequencyPenalty.Title}
               value={props.modelConfig.frequency_penalty?.toFixed(1)}
               min="-2"
               max="2"
@@ -142,6 +148,7 @@ export function ModelConfigList(props: {
             subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
           >
             <input
+              aria-label={Locale.Settings.InjectSystemPrompts.Title}
               type="checkbox"
               checked={props.modelConfig.enableInjectSystemPrompts}
               onChange={(e) =>
@@ -159,6 +166,7 @@ export function ModelConfigList(props: {
             subTitle={Locale.Settings.InputTemplate.SubTitle}
           >
             <input
+              aria-label={Locale.Settings.InputTemplate.Title}
               type="text"
               value={props.modelConfig.template}
               onChange={(e) =>
@@ -175,6 +183,7 @@ export function ModelConfigList(props: {
         subTitle={Locale.Settings.HistoryCount.SubTitle}
       >
         <InputRange
+          aria={Locale.Settings.HistoryCount.Title}
           title={props.modelConfig.historyMessageCount.toString()}
           value={props.modelConfig.historyMessageCount}
           min="0"
@@ -193,6 +202,7 @@ export function ModelConfigList(props: {
         subTitle={Locale.Settings.CompressThreshold.SubTitle}
       >
         <input
+          aria-label={Locale.Settings.CompressThreshold.Title}
           type="number"
           min={500}
           max={4000}
@@ -208,6 +218,7 @@ export function ModelConfigList(props: {
       </ListItem>
       <ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
         <input
+          aria-label={Locale.Memory.Title}
           type="checkbox"
           checked={props.modelConfig.sendMemory}
           onChange={(e) =>

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,336 @@
+import chatStyles from "@/app/components/chat.module.scss";
+import styles from "@/app/components/sd/sd.module.scss";
+import homeStyles from "@/app/components/home.module.scss";
+
+import { IconButton } from "@/app/components/button";
+import ReturnIcon from "@/app/icons/return.svg";
+import Locale from "@/app/locales";
+import { Path } from "@/app/constant";
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import {
+  copyToClipboard,
+  getMessageTextContent,
+  useMobileScreen,
+} from "@/app/utils";
+import { useNavigate, useLocation } from "react-router-dom";
+import { useAppConfig } from "@/app/store";
+import MinIcon from "@/app/icons/min.svg";
+import MaxIcon from "@/app/icons/max.svg";
+import { getClientConfig } from "@/app/config/client";
+import { ChatAction } from "@/app/components/chat";
+import DeleteIcon from "@/app/icons/clear.svg";
+import CopyIcon from "@/app/icons/copy.svg";
+import PromptIcon from "@/app/icons/prompt.svg";
+import ResetIcon from "@/app/icons/reload.svg";
+import { useSdStore } from "@/app/store/sd";
+import LoadingIcon from "@/app/icons/three-dots.svg";
+import ErrorIcon from "@/app/icons/delete.svg";
+import SDIcon from "@/app/icons/sd.svg";
+import { Property } from "csstype";
+import {
+  showConfirm,
+  showImageModal,
+  showModal,
+} from "@/app/components/ui-lib";
+import { removeImage } from "@/app/utils/chat";
+import { SideBar } from "./sd-sidebar";
+import { WindowContent } from "@/app/components/home";
+import { params } from "./sd-panel";
+
+function getSdTaskStatus(item: any) {
+  let s: string;
+  let color: Property.Color | undefined = undefined;
+  switch (item.status) {
+    case "success":
+      s = Locale.Sd.Status.Success;
+      color = "green";
+      break;
+    case "error":
+      s = Locale.Sd.Status.Error;
+      color = "red";
+      break;
+    case "wait":
+      s = Locale.Sd.Status.Wait;
+      color = "yellow";
+      break;
+    case "running":
+      s = Locale.Sd.Status.Running;
+      color = "blue";
+      break;
+    default:
+      s = item.status.toUpperCase();
+  }
+  return (
+    <p className={styles["line-1"]} title={item.error} style={{ color: color }}>
+      <span>
+        {Locale.Sd.Status.Name}: {s}
+      </span>
+      {item.status === "error" && (
+        <span
+          className="clickable"
+          onClick={() => {
+            showModal({
+              title: Locale.Sd.Detail,
+              children: (
+                <div style={{ color: color, userSelect: "text" }}>
+                  {item.error}
+                </div>
+              ),
+            });
+          }}
+        >
+          - {item.error}
+        </span>
+      )}
+    </p>
+  );
+}
+
+export function Sd() {
+  const isMobileScreen = useMobileScreen();
+  const navigate = useNavigate();
+  const location = useLocation();
+  const clientConfig = useMemo(() => getClientConfig(), []);
+  const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
+  const config = useAppConfig();
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const sdStore = useSdStore();
+  const [sdImages, setSdImages] = useState(sdStore.draw);
+  const isSd = location.pathname === Path.Sd;
+
+  useEffect(() => {
+    setSdImages(sdStore.draw);
+  }, [sdStore.currentId]);
+
+  return (
+    <>
+      <SideBar className={isSd ? homeStyles["sidebar-show"] : ""} />
+      <WindowContent>
+        <div className={chatStyles.chat} key={"1"}>
+          <div className="window-header" data-tauri-drag-region>
+            {isMobileScreen && (
+              <div className="window-actions">
+                <div className={"window-action-button"}>
+                  <IconButton
+                    icon={<ReturnIcon />}
+                    bordered
+                    title={Locale.Chat.Actions.ChatList}
+                    onClick={() => navigate(Path.Sd)}
+                  />
+                </div>
+              </div>
+            )}
+            <div
+              className={`window-header-title ${chatStyles["chat-body-title"]}`}
+            >
+              <div className={`window-header-main-title`}>Stability AI</div>
+              <div className="window-header-sub-title">
+                {Locale.Sd.SubTitle(sdImages.length || 0)}
+              </div>
+            </div>
+
+            <div className="window-actions">
+              {showMaxIcon && (
+                <div className="window-action-button">
+                  <IconButton
+                    aria={Locale.Chat.Actions.FullScreen}
+                    icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
+                    bordered
+                    onClick={() => {
+                      config.update(
+                        (config) => (config.tightBorder = !config.tightBorder),
+                      );
+                    }}
+                  />
+                </div>
+              )}
+              {isMobileScreen && <SDIcon width={50} height={50} />}
+            </div>
+          </div>
+          <div className={chatStyles["chat-body"]} ref={scrollRef}>
+            <div className={styles["sd-img-list"]}>
+              {sdImages.length > 0 ? (
+                sdImages.map((item: any) => {
+                  return (
+                    <div
+                      key={item.id}
+                      style={{ display: "flex" }}
+                      className={styles["sd-img-item"]}
+                    >
+                      {item.status === "success" ? (
+                        <img
+                          className={styles["img"]}
+                          src={item.img_data}
+                          alt={item.id}
+                          onClick={(e) =>
+                            showImageModal(
+                              item.img_data,
+                              true,
+                              isMobileScreen
+                                ? { width: "100%", height: "fit-content" }
+                                : { maxWidth: "100%", maxHeight: "100%" },
+                              isMobileScreen
+                                ? { width: "100%", height: "fit-content" }
+                                : { width: "100%", height: "100%" },
+                            )
+                          }
+                        />
+                      ) : item.status === "error" ? (
+                        <div className={styles["pre-img"]}>
+                          <ErrorIcon />
+                        </div>
+                      ) : (
+                        <div className={styles["pre-img"]}>
+                          <LoadingIcon />
+                        </div>
+                      )}
+                      <div
+                        style={{ marginLeft: "10px" }}
+                        className={styles["sd-img-item-info"]}
+                      >
+                        <p className={styles["line-1"]}>
+                          {Locale.SdPanel.Prompt}:{" "}
+                          <span
+                            className="clickable"
+                            title={item.params.prompt}
+                            onClick={() => {
+                              showModal({
+                                title: Locale.Sd.Detail,
+                                children: (
+                                  <div style={{ userSelect: "text" }}>
+                                    {item.params.prompt}
+                                  </div>
+                                ),
+                              });
+                            }}
+                          >
+                            {item.params.prompt}
+                          </span>
+                        </p>
+                        <p>
+                          {Locale.SdPanel.AIModel}: {item.model_name}
+                        </p>
+                        {getSdTaskStatus(item)}
+                        <p>{item.created_at}</p>
+                        <div className={chatStyles["chat-message-actions"]}>
+                          <div className={chatStyles["chat-input-actions"]}>
+                            <ChatAction
+                              text={Locale.Sd.Actions.Params}
+                              icon={<PromptIcon />}
+                              onClick={() => {
+                                showModal({
+                                  title: Locale.Sd.GenerateParams,
+                                  children: (
+                                    <div style={{ userSelect: "text" }}>
+                                      {Object.keys(item.params).map((key) => {
+                                        let label = key;
+                                        let value = item.params[key];
+                                        switch (label) {
+                                          case "prompt":
+                                            label = Locale.SdPanel.Prompt;
+                                            break;
+                                          case "negative_prompt":
+                                            label =
+                                              Locale.SdPanel.NegativePrompt;
+                                            break;
+                                          case "aspect_ratio":
+                                            label = Locale.SdPanel.AspectRatio;
+                                            break;
+                                          case "seed":
+                                            label = "Seed";
+                                            value = value || 0;
+                                            break;
+                                          case "output_format":
+                                            label = Locale.SdPanel.OutFormat;
+                                            value = value?.toUpperCase();
+                                            break;
+                                          case "style":
+                                            label = Locale.SdPanel.ImageStyle;
+                                            value = params
+                                              .find(
+                                                (item) =>
+                                                  item.value === "style",
+                                              )
+                                              ?.options?.find(
+                                                (item) => item.value === value,
+                                              )?.name;
+                                            break;
+                                          default:
+                                            break;
+                                        }
+
+                                        return (
+                                          <div
+                                            key={key}
+                                            style={{ margin: "10px" }}
+                                          >
+                                            <strong>{label}: </strong>
+                                            {value}
+                                          </div>
+                                        );
+                                      })}
+                                    </div>
+                                  ),
+                                });
+                              }}
+                            />
+                            <ChatAction
+                              text={Locale.Sd.Actions.Copy}
+                              icon={<CopyIcon />}
+                              onClick={() =>
+                                copyToClipboard(
+                                  getMessageTextContent({
+                                    role: "user",
+                                    content: item.params.prompt,
+                                  }),
+                                )
+                              }
+                            />
+                            <ChatAction
+                              text={Locale.Sd.Actions.Retry}
+                              icon={<ResetIcon />}
+                              onClick={() => {
+                                const reqData = {
+                                  model: item.model,
+                                  model_name: item.model_name,
+                                  status: "wait",
+                                  params: { ...item.params },
+                                  created_at: new Date().toLocaleString(),
+                                  img_data: "",
+                                };
+                                sdStore.sendTask(reqData);
+                              }}
+                            />
+                            <ChatAction
+                              text={Locale.Sd.Actions.Delete}
+                              icon={<DeleteIcon />}
+                              onClick={async () => {
+                                if (
+                                  await showConfirm(Locale.Sd.Danger.Delete)
+                                ) {
+                                  // remove img_data + remove item in list
+                                  removeImage(item.img_data).finally(() => {
+                                    sdStore.draw = sdImages.filter(
+                                      (i: any) => i.id !== item.id,
+                                    );
+                                    sdStore.getNextId();
+                                  });
+                                }
+                              }}
+                            />
+                          </div>
+                        </div>
+                      </div>
+                    </div>
+                  );
+                })
+              ) : (
+                <div>{Locale.Sd.EmptyRecord}</div>
+              )}
+            </div>
+          </div>
+        </div>
+      </WindowContent>
+    </>
+  );
+}

+ 259 - 0
app/components/settings.tsx

@@ -54,8 +54,10 @@ import {
   Anthropic,
   Azure,
   Baidu,
+  Tencent,
   ByteDance,
   Alibaba,
+  Moonshot,
   Google,
   GoogleSafetySettingsThreshold,
   OPENAI_BASE_URL,
@@ -65,6 +67,8 @@ import {
   ServiceProvider,
   SlotID,
   UPDATE_URL,
+  Stability,
+  Iflytek,
 } from "../constant";
 import { Prompt, SearchService, usePromptStore } from "../store/prompt";
 import { ErrorBoundary } from "./error";
@@ -242,6 +246,7 @@ function DangerItems() {
         subTitle={Locale.Settings.Danger.Reset.SubTitle}
       >
         <IconButton
+          aria={Locale.Settings.Danger.Reset.Title}
           text={Locale.Settings.Danger.Reset.Action}
           onClick={async () => {
             if (await showConfirm(Locale.Settings.Danger.Reset.Confirm)) {
@@ -256,6 +261,7 @@ function DangerItems() {
         subTitle={Locale.Settings.Danger.Clear.SubTitle}
       >
         <IconButton
+          aria={Locale.Settings.Danger.Clear.Title}
           text={Locale.Settings.Danger.Clear.Action}
           onClick={async () => {
             if (await showConfirm(Locale.Settings.Danger.Clear.Confirm)) {
@@ -509,6 +515,7 @@ function SyncItems() {
         >
           <div style={{ display: "flex" }}>
             <IconButton
+              aria={Locale.Settings.Sync.CloudState + Locale.UI.Config}
               icon={<ConfigIcon />}
               text={Locale.UI.Config}
               onClick={() => {
@@ -539,6 +546,7 @@ function SyncItems() {
         >
           <div style={{ display: "flex" }}>
             <IconButton
+              aria={Locale.Settings.Sync.LocalState + Locale.UI.Export}
               icon={<UploadIcon />}
               text={Locale.UI.Export}
               onClick={() => {
@@ -546,6 +554,7 @@ function SyncItems() {
               }}
             />
             <IconButton
+              aria={Locale.Settings.Sync.LocalState + Locale.UI.Import}
               icon={<DownloadIcon />}
               text={Locale.UI.Import}
               onClick={() => {
@@ -683,6 +692,7 @@ export function Settings() {
         subTitle={Locale.Settings.Access.CustomEndpoint.SubTitle}
       >
         <input
+          aria-label={Locale.Settings.Access.CustomEndpoint.Title}
           type="checkbox"
           checked={accessStore.useCustomConfig}
           onChange={(e) =>
@@ -702,6 +712,7 @@ export function Settings() {
         subTitle={Locale.Settings.Access.OpenAI.Endpoint.SubTitle}
       >
         <input
+          aria-label={Locale.Settings.Access.OpenAI.Endpoint.Title}
           type="text"
           value={accessStore.openaiUrl}
           placeholder={OPENAI_BASE_URL}
@@ -717,6 +728,8 @@ export function Settings() {
         subTitle={Locale.Settings.Access.OpenAI.ApiKey.SubTitle}
       >
         <PasswordInput
+          aria={Locale.Settings.ShowPassword}
+          aria-label={Locale.Settings.Access.OpenAI.ApiKey.Title}
           value={accessStore.openaiApiKey}
           type="text"
           placeholder={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
@@ -740,6 +753,7 @@ export function Settings() {
         }
       >
         <input
+          aria-label={Locale.Settings.Access.Azure.Endpoint.Title}
           type="text"
           value={accessStore.azureUrl}
           placeholder={Azure.ExampleEndpoint}
@@ -755,6 +769,7 @@ export function Settings() {
         subTitle={Locale.Settings.Access.Azure.ApiKey.SubTitle}
       >
         <PasswordInput
+          aria-label={Locale.Settings.Access.Azure.ApiKey.Title}
           value={accessStore.azureApiKey}
           type="text"
           placeholder={Locale.Settings.Access.Azure.ApiKey.Placeholder}
@@ -770,6 +785,7 @@ export function Settings() {
         subTitle={Locale.Settings.Access.Azure.ApiVerion.SubTitle}
       >
         <input
+          aria-label={Locale.Settings.Access.Azure.ApiVerion.Title}
           type="text"
           value={accessStore.azureApiVersion}
           placeholder="2023-08-01-preview"
@@ -794,6 +810,7 @@ export function Settings() {
         }
       >
         <input
+          aria-label={Locale.Settings.Access.Google.Endpoint.Title}
           type="text"
           value={accessStore.googleUrl}
           placeholder={Google.ExampleEndpoint}
@@ -809,6 +826,7 @@ export function Settings() {
         subTitle={Locale.Settings.Access.Google.ApiKey.SubTitle}
       >
         <PasswordInput
+          aria-label={Locale.Settings.Access.Google.ApiKey.Title}
           value={accessStore.googleApiKey}
           type="text"
           placeholder={Locale.Settings.Access.Google.ApiKey.Placeholder}
@@ -824,6 +842,7 @@ export function Settings() {
         subTitle={Locale.Settings.Access.Google.ApiVersion.SubTitle}
       >
         <input
+          aria-label={Locale.Settings.Access.Google.ApiVersion.Title}
           type="text"
           value={accessStore.googleApiVersion}
           placeholder="2023-08-01-preview"
@@ -839,6 +858,7 @@ export function Settings() {
         subTitle={Locale.Settings.Access.Google.GoogleSafetySettings.SubTitle}
       >
         <Select
+          aria-label={Locale.Settings.Access.Google.GoogleSafetySettings.Title}
           value={accessStore.googleSafetySettings}
           onChange={(e) => {
             accessStore.update(
@@ -869,6 +889,7 @@ export function Settings() {
         }
       >
         <input
+          aria-label={Locale.Settings.Access.Anthropic.Endpoint.Title}
           type="text"
           value={accessStore.anthropicUrl}
           placeholder={Anthropic.ExampleEndpoint}
@@ -884,6 +905,7 @@ export function Settings() {
         subTitle={Locale.Settings.Access.Anthropic.ApiKey.SubTitle}
       >
         <PasswordInput
+          aria-label={Locale.Settings.Access.Anthropic.ApiKey.Title}
           value={accessStore.anthropicApiKey}
           type="text"
           placeholder={Locale.Settings.Access.Anthropic.ApiKey.Placeholder}
@@ -899,6 +921,7 @@ export function Settings() {
         subTitle={Locale.Settings.Access.Anthropic.ApiVerion.SubTitle}
       >
         <input
+          aria-label={Locale.Settings.Access.Anthropic.ApiVerion.Title}
           type="text"
           value={accessStore.anthropicApiVersion}
           placeholder={Anthropic.Vision}
@@ -920,6 +943,7 @@ export function Settings() {
         subTitle={Locale.Settings.Access.Baidu.Endpoint.SubTitle}
       >
         <input
+          aria-label={Locale.Settings.Access.Baidu.Endpoint.Title}
           type="text"
           value={accessStore.baiduUrl}
           placeholder={Baidu.ExampleEndpoint}
@@ -935,6 +959,7 @@ export function Settings() {
         subTitle={Locale.Settings.Access.Baidu.ApiKey.SubTitle}
       >
         <PasswordInput
+          aria-label={Locale.Settings.Access.Baidu.ApiKey.Title}
           value={accessStore.baiduApiKey}
           type="text"
           placeholder={Locale.Settings.Access.Baidu.ApiKey.Placeholder}
@@ -950,6 +975,7 @@ export function Settings() {
         subTitle={Locale.Settings.Access.Baidu.SecretKey.SubTitle}
       >
         <PasswordInput
+          aria-label={Locale.Settings.Access.Baidu.SecretKey.Title}
           value={accessStore.baiduSecretKey}
           type="text"
           placeholder={Locale.Settings.Access.Baidu.SecretKey.Placeholder}
@@ -963,6 +989,60 @@ export function Settings() {
     </>
   );
 
+  const tencentConfigComponent = accessStore.provider ===
+    ServiceProvider.Tencent && (
+    <>
+      <ListItem
+        title={Locale.Settings.Access.Tencent.Endpoint.Title}
+        subTitle={Locale.Settings.Access.Tencent.Endpoint.SubTitle}
+      >
+        <input
+          aria-label={Locale.Settings.Access.Tencent.Endpoint.Title}
+          type="text"
+          value={accessStore.tencentUrl}
+          placeholder={Tencent.ExampleEndpoint}
+          onChange={(e) =>
+            accessStore.update(
+              (access) => (access.tencentUrl = e.currentTarget.value),
+            )
+          }
+        ></input>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Access.Tencent.ApiKey.Title}
+        subTitle={Locale.Settings.Access.Tencent.ApiKey.SubTitle}
+      >
+        <PasswordInput
+          aria-label={Locale.Settings.Access.Tencent.ApiKey.Title}
+          value={accessStore.tencentSecretId}
+          type="text"
+          placeholder={Locale.Settings.Access.Tencent.ApiKey.Placeholder}
+          onChange={(e) => {
+            accessStore.update(
+              (access) => (access.tencentSecretId = e.currentTarget.value),
+            );
+          }}
+        />
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Access.Tencent.SecretKey.Title}
+        subTitle={Locale.Settings.Access.Tencent.SecretKey.SubTitle}
+      >
+        <PasswordInput
+          aria-label={Locale.Settings.Access.Tencent.SecretKey.Title}
+          value={accessStore.tencentSecretKey}
+          type="text"
+          placeholder={Locale.Settings.Access.Tencent.SecretKey.Placeholder}
+          onChange={(e) => {
+            accessStore.update(
+              (access) => (access.tencentSecretKey = e.currentTarget.value),
+            );
+          }}
+        />
+      </ListItem>
+    </>
+  );
+
   const byteDanceConfigComponent = accessStore.provider ===
     ServiceProvider.ByteDance && (
     <>
@@ -974,6 +1054,7 @@ export function Settings() {
         }
       >
         <input
+          aria-label={Locale.Settings.Access.ByteDance.Endpoint.Title}
           type="text"
           value={accessStore.bytedanceUrl}
           placeholder={ByteDance.ExampleEndpoint}
@@ -989,6 +1070,7 @@ export function Settings() {
         subTitle={Locale.Settings.Access.ByteDance.ApiKey.SubTitle}
       >
         <PasswordInput
+          aria-label={Locale.Settings.Access.ByteDance.ApiKey.Title}
           value={accessStore.bytedanceApiKey}
           type="text"
           placeholder={Locale.Settings.Access.ByteDance.ApiKey.Placeholder}
@@ -1013,6 +1095,7 @@ export function Settings() {
         }
       >
         <input
+          aria-label={Locale.Settings.Access.Alibaba.Endpoint.Title}
           type="text"
           value={accessStore.alibabaUrl}
           placeholder={Alibaba.ExampleEndpoint}
@@ -1028,6 +1111,7 @@ export function Settings() {
         subTitle={Locale.Settings.Access.Alibaba.ApiKey.SubTitle}
       >
         <PasswordInput
+          aria-label={Locale.Settings.Access.Alibaba.ApiKey.Title}
           value={accessStore.alibabaApiKey}
           type="text"
           placeholder={Locale.Settings.Access.Alibaba.ApiKey.Placeholder}
@@ -1041,6 +1125,145 @@ export function Settings() {
     </>
   );
 
+  const moonshotConfigComponent = accessStore.provider ===
+    ServiceProvider.Moonshot && (
+    <>
+      <ListItem
+        title={Locale.Settings.Access.Moonshot.Endpoint.Title}
+        subTitle={
+          Locale.Settings.Access.Moonshot.Endpoint.SubTitle +
+          Moonshot.ExampleEndpoint
+        }
+      >
+        <input
+          aria-label={Locale.Settings.Access.Moonshot.Endpoint.Title}
+          type="text"
+          value={accessStore.moonshotUrl}
+          placeholder={Moonshot.ExampleEndpoint}
+          onChange={(e) =>
+            accessStore.update(
+              (access) => (access.moonshotUrl = e.currentTarget.value),
+            )
+          }
+        ></input>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Access.Moonshot.ApiKey.Title}
+        subTitle={Locale.Settings.Access.Moonshot.ApiKey.SubTitle}
+      >
+        <PasswordInput
+          aria-label={Locale.Settings.Access.Moonshot.ApiKey.Title}
+          value={accessStore.moonshotApiKey}
+          type="text"
+          placeholder={Locale.Settings.Access.Moonshot.ApiKey.Placeholder}
+          onChange={(e) => {
+            accessStore.update(
+              (access) => (access.moonshotApiKey = e.currentTarget.value),
+            );
+          }}
+        />
+      </ListItem>
+    </>
+  );
+
+  const stabilityConfigComponent = accessStore.provider ===
+    ServiceProvider.Stability && (
+    <>
+      <ListItem
+        title={Locale.Settings.Access.Stability.Endpoint.Title}
+        subTitle={
+          Locale.Settings.Access.Stability.Endpoint.SubTitle +
+          Stability.ExampleEndpoint
+        }
+      >
+        <input
+          aria-label={Locale.Settings.Access.Stability.Endpoint.Title}
+          type="text"
+          value={accessStore.stabilityUrl}
+          placeholder={Stability.ExampleEndpoint}
+          onChange={(e) =>
+            accessStore.update(
+              (access) => (access.stabilityUrl = e.currentTarget.value),
+            )
+          }
+        ></input>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Access.Stability.ApiKey.Title}
+        subTitle={Locale.Settings.Access.Stability.ApiKey.SubTitle}
+      >
+        <PasswordInput
+          aria-label={Locale.Settings.Access.Stability.ApiKey.Title}
+          value={accessStore.stabilityApiKey}
+          type="text"
+          placeholder={Locale.Settings.Access.Stability.ApiKey.Placeholder}
+          onChange={(e) => {
+            accessStore.update(
+              (access) => (access.stabilityApiKey = e.currentTarget.value),
+            );
+          }}
+        />
+      </ListItem>
+    </>
+  );
+  const lflytekConfigComponent = accessStore.provider ===
+    ServiceProvider.Iflytek && (
+    <>
+      <ListItem
+        title={Locale.Settings.Access.Iflytek.Endpoint.Title}
+        subTitle={
+          Locale.Settings.Access.Iflytek.Endpoint.SubTitle +
+          Iflytek.ExampleEndpoint
+        }
+      >
+        <input
+          aria-label={Locale.Settings.Access.Iflytek.Endpoint.Title}
+          type="text"
+          value={accessStore.iflytekUrl}
+          placeholder={Iflytek.ExampleEndpoint}
+          onChange={(e) =>
+            accessStore.update(
+              (access) => (access.iflytekUrl = e.currentTarget.value),
+            )
+          }
+        ></input>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Access.Iflytek.ApiKey.Title}
+        subTitle={Locale.Settings.Access.Iflytek.ApiKey.SubTitle}
+      >
+        <PasswordInput
+          aria-label={Locale.Settings.Access.Iflytek.ApiKey.Title}
+          value={accessStore.iflytekApiKey}
+          type="text"
+          placeholder={Locale.Settings.Access.Iflytek.ApiKey.Placeholder}
+          onChange={(e) => {
+            accessStore.update(
+              (access) => (access.iflytekApiKey = e.currentTarget.value),
+            );
+          }}
+        />
+      </ListItem>
+
+      <ListItem
+        title={Locale.Settings.Access.Iflytek.ApiSecret.Title}
+        subTitle={Locale.Settings.Access.Iflytek.ApiSecret.SubTitle}
+      >
+        <PasswordInput
+          aria-label={Locale.Settings.Access.Iflytek.ApiSecret.Title}
+          value={accessStore.iflytekApiSecret}
+          type="text"
+          placeholder={Locale.Settings.Access.Iflytek.ApiSecret.Placeholder}
+          onChange={(e) => {
+            accessStore.update(
+              (access) => (access.iflytekApiSecret = e.currentTarget.value),
+            );
+          }}
+        />
+      </ListItem>
+    </>
+  );
+
   return (
     <ErrorBoundary>
       <div className="window-header" data-tauri-drag-region>
@@ -1057,6 +1280,7 @@ export function Settings() {
           <div className="window-action-button"></div>
           <div className="window-action-button">
             <IconButton
+              aria={Locale.UI.Close}
               icon={<CloseIcon />}
               onClick={() => navigate(Path.Home)}
               bordered
@@ -1080,6 +1304,8 @@ export function Settings() {
               open={showEmojiPicker}
             >
               <div
+                aria-label={Locale.Settings.Avatar}
+                tabIndex={0}
                 className={styles.avatar}
                 onClick={() => {
                   setShowEmojiPicker(!showEmojiPicker);
@@ -1117,6 +1343,7 @@ export function Settings() {
 
           <ListItem title={Locale.Settings.SendKey}>
             <Select
+              aria-label={Locale.Settings.SendKey}
               value={config.submitKey}
               onChange={(e) => {
                 updateConfig(
@@ -1135,6 +1362,7 @@ export function Settings() {
 
           <ListItem title={Locale.Settings.Theme}>
             <Select
+              aria-label={Locale.Settings.Theme}
               value={config.theme}
               onChange={(e) => {
                 updateConfig(
@@ -1152,6 +1380,7 @@ export function Settings() {
 
           <ListItem title={Locale.Settings.Lang.Name}>
             <Select
+              aria-label={Locale.Settings.Lang.Name}
               value={getLang()}
               onChange={(e) => {
                 changeLang(e.target.value as any);
@@ -1170,6 +1399,7 @@ export function Settings() {
             subTitle={Locale.Settings.FontSize.SubTitle}
           >
             <InputRange
+              aria={Locale.Settings.FontSize.Title}
               title={`${config.fontSize ?? 14}px`}
               value={config.fontSize}
               min="12"
@@ -1184,11 +1414,29 @@ export function Settings() {
             ></InputRange>
           </ListItem>
 
+          <ListItem
+            title={Locale.Settings.FontFamily.Title}
+            subTitle={Locale.Settings.FontFamily.SubTitle}
+          >
+            <input
+              aria-label={Locale.Settings.FontFamily.Title}
+              type="text"
+              value={config.fontFamily}
+              placeholder={Locale.Settings.FontFamily.Placeholder}
+              onChange={(e) =>
+                updateConfig(
+                  (config) => (config.fontFamily = e.currentTarget.value),
+                )
+              }
+            ></input>
+          </ListItem>
+
           <ListItem
             title={Locale.Settings.AutoGenerateTitle.Title}
             subTitle={Locale.Settings.AutoGenerateTitle.SubTitle}
           >
             <input
+              aria-label={Locale.Settings.AutoGenerateTitle.Title}
               type="checkbox"
               checked={config.enableAutoGenerateTitle}
               onChange={(e) =>
@@ -1205,6 +1453,7 @@ export function Settings() {
             subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
           >
             <input
+              aria-label={Locale.Settings.SendPreviewBubble.Title}
               type="checkbox"
               checked={config.sendPreviewBubble}
               onChange={(e) =>
@@ -1225,6 +1474,7 @@ export function Settings() {
             subTitle={Locale.Settings.Mask.Splash.SubTitle}
           >
             <input
+              aria-label={Locale.Settings.Mask.Splash.Title}
               type="checkbox"
               checked={!config.dontShowMaskSplashScreen}
               onChange={(e) =>
@@ -1242,6 +1492,7 @@ export function Settings() {
             subTitle={Locale.Settings.Mask.Builtin.SubTitle}
           >
             <input
+              aria-label={Locale.Settings.Mask.Builtin.Title}
               type="checkbox"
               checked={config.hideBuiltinMasks}
               onChange={(e) =>
@@ -1260,6 +1511,7 @@ export function Settings() {
             subTitle={Locale.Settings.Prompt.Disable.SubTitle}
           >
             <input
+              aria-label={Locale.Settings.Prompt.Disable.Title}
               type="checkbox"
               checked={config.disablePromptHint}
               onChange={(e) =>
@@ -1279,6 +1531,7 @@ export function Settings() {
             )}
           >
             <IconButton
+              aria={Locale.Settings.Prompt.List + Locale.Settings.Prompt.Edit}
               icon={<EditIcon />}
               text={Locale.Settings.Prompt.Edit}
               onClick={() => setShowPromptModal(true)}
@@ -1300,6 +1553,7 @@ export function Settings() {
                     subTitle={Locale.Settings.Access.Provider.SubTitle}
                   >
                     <Select
+                      aria-label={Locale.Settings.Access.Provider.Title}
                       value={accessStore.provider}
                       onChange={(e) => {
                         accessStore.update(
@@ -1324,6 +1578,10 @@ export function Settings() {
                   {baiduConfigComponent}
                   {byteDanceConfigComponent}
                   {alibabaConfigComponent}
+                  {tencentConfigComponent}
+                  {moonshotConfigComponent}
+                  {stabilityConfigComponent}
+                  {lflytekConfigComponent}
                 </>
               )}
             </>
@@ -1360,6 +1618,7 @@ export function Settings() {
             subTitle={Locale.Settings.Access.CustomModel.SubTitle}
           >
             <input
+              aria-label={Locale.Settings.Access.CustomModel.Title}
               type="text"
               value={config.customModels}
               placeholder="model1,model2,model3"

+ 171 - 85
app/components/sidebar.tsx

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

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

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

+ 103 - 16
app/components/ui-lib.tsx

@@ -13,7 +13,15 @@ import MinIcon from "../icons/min.svg";
 import Locale from "../locales";
 
 import { createRoot } from "react-dom/client";
-import React, { HTMLProps, useEffect, useState } from "react";
+import React, {
+  CSSProperties,
+  HTMLProps,
+  MouseEvent,
+  useEffect,
+  useState,
+  useCallback,
+  useRef,
+} from "react";
 import { IconButton } from "./button";
 
 export function Popover(props: {
@@ -47,11 +55,16 @@ export function ListItem(props: {
   children?: JSX.Element | JSX.Element[];
   icon?: JSX.Element;
   className?: string;
-  onClick?: () => void;
+  onClick?: (e: MouseEvent) => void;
+  vertical?: boolean;
 }) {
   return (
     <div
-      className={styles["list-item"] + ` ${props.className || ""}`}
+      className={
+        styles["list-item"] +
+        ` ${props.vertical ? styles["vertical"] : ""} ` +
+        ` ${props.className || ""}`
+      }
       onClick={props.onClick}
     >
       <div className={styles["list-header"]}>
@@ -252,9 +265,10 @@ export function Input(props: InputProps) {
   );
 }
 
-export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
+export function PasswordInput(
+  props: HTMLProps<HTMLInputElement> & { aria?: string },
+) {
   const [visible, setVisible] = useState(false);
-
   function changeVisibility() {
     setVisible(!visible);
   }
@@ -262,6 +276,7 @@ export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
   return (
     <div className={"password-input-container"}>
       <IconButton
+        aria={props.aria}
         icon={visible ? <EyeIcon /> : <EyeOffIcon />}
         onClick={changeVisibility}
         className={"password-eye"}
@@ -420,17 +435,25 @@ export function showPrompt(content: any, value = "", rows = 3) {
   });
 }
 
-export function showImageModal(img: string) {
+export function showImageModal(
+  img: string,
+  defaultMax?: boolean,
+  style?: CSSProperties,
+  boxStyle?: CSSProperties,
+) {
   showModal({
     title: Locale.Export.Image.Modal,
+    defaultMax: defaultMax,
     children: (
-      <div>
+      <div style={{ display: "flex", justifyContent: "center", ...boxStyle }}>
         <img
           src={img}
           alt="preview"
-          style={{
-            maxWidth: "100%",
-          }}
+          style={
+            style ?? {
+              maxWidth: "100%",
+            }
+          }
         ></img>
       </div>
     ),
@@ -442,27 +465,56 @@ export function Selector<T>(props: {
     title: string;
     subTitle?: string;
     value: T;
+    disable?: boolean;
   }>;
-  defaultSelectedValue?: T;
+  defaultSelectedValue?: T[] | T;
   onSelection?: (selection: T[]) => void;
   onClose?: () => void;
   multiple?: boolean;
 }) {
+  const [selectedValues, setSelectedValues] = useState<T[]>(
+    Array.isArray(props.defaultSelectedValue)
+      ? props.defaultSelectedValue
+      : props.defaultSelectedValue !== undefined
+      ? [props.defaultSelectedValue]
+      : [],
+  );
+
+  const handleSelection = (e: MouseEvent, value: T) => {
+    if (props.multiple) {
+      e.stopPropagation();
+      const newSelectedValues = selectedValues.includes(value)
+        ? selectedValues.filter((v) => v !== value)
+        : [...selectedValues, value];
+      setSelectedValues(newSelectedValues);
+      props.onSelection?.(newSelectedValues);
+    } else {
+      setSelectedValues([value]);
+      props.onSelection?.([value]);
+      props.onClose?.();
+    }
+  };
+
   return (
     <div className={styles["selector"]} onClick={() => props.onClose?.()}>
       <div className={styles["selector-content"]}>
         <List>
           {props.items.map((item, i) => {
-            const selected = props.defaultSelectedValue === item.value;
+            const selected = selectedValues.includes(item.value);
             return (
               <ListItem
-                className={styles["selector-item"]}
+                className={`${styles["selector-item"]} ${
+                  item.disable && styles["selector-item-disabled"]
+                }`}
                 key={i}
                 title={item.title}
                 subTitle={item.subTitle}
-                onClick={() => {
-                  props.onSelection?.([item.value]);
-                  props.onClose?.();
+                onClick={(e) => {
+                  if (item.disable) {
+                    e.stopPropagation();
+                  } else {
+                    handleSelection(e, item.value);
+                  }
                 }}
               >
                 {selected ? (
@@ -485,3 +537,38 @@ export function Selector<T>(props: {
     </div>
   );
 }
+export function FullScreen(props: any) {
+  const { children, right = 10, top = 10, ...rest } = props;
+  const ref = useRef<HTMLDivElement>();
+  const [fullScreen, setFullScreen] = useState(false);
+  const toggleFullscreen = useCallback(() => {
+    if (!document.fullscreenElement) {
+      ref.current?.requestFullscreen();
+    } else {
+      document.exitFullscreen();
+    }
+  }, []);
+  useEffect(() => {
+    const handleScreenChange = (e: any) => {
+      if (e.target === ref.current) {
+        setFullScreen(!!document.fullscreenElement);
+      }
+    };
+    document.addEventListener("fullscreenchange", handleScreenChange);
+    return () => {
+      document.removeEventListener("fullscreenchange", handleScreenChange);
+    };
+  }, []);
+  return (
+    <div ref={ref} style={{ position: "relative" }} {...rest}>
+      <div style={{ position: "absolute", right, top }}>
+        <IconButton
+          icon={fullScreen ? <MinIcon /> : <MaxIcon />}
+          onClick={toggleFullscreen}
+          bordered
+        />
+      </div>
+      {children}
+    </div>
+  );
+}

+ 1 - 1
app/config/client.ts

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

+ 54 - 2
app/config/server.ts

@@ -23,6 +23,10 @@ declare global {
       CUSTOM_MODELS?: string; // to control custom models
       DEFAULT_MODEL?: string; // to control default model in every new chat window
 
+      // stability only
+      STABILITY_URL?: string;
+      STABILITY_API_KEY?: string;
+
       // azure only
       AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
       AZURE_API_KEY?: string;
@@ -53,6 +57,20 @@ declare global {
       ALIBABA_URL?: string;
       ALIBABA_API_KEY?: string;
 
+      // tencent only
+      TENCENT_URL?: string;
+      TENCENT_SECRET_KEY?: string;
+      TENCENT_SECRET_ID?: string;
+
+      // moonshot only
+      MOONSHOT_URL?: string;
+      MOONSHOT_API_KEY?: string;
+
+      // iflytek only
+      IFLYTEK_URL?: string;
+      IFLYTEK_API_KEY?: string;
+      IFLYTEK_API_SECRET?: string;
+
       // custom template for preprocessing user input
       DEFAULT_INPUT_TEMPLATE?: string;
     }
@@ -101,19 +119,30 @@ export const getServerSideConfig = () => {
 
   if (disableGPT4) {
     if (customModels) customModels += ",";
-    customModels += DEFAULT_MODELS.filter((m) => m.name.startsWith("gpt-4"))
+    customModels += DEFAULT_MODELS.filter(
+      (m) => m.name.startsWith("gpt-4") && !m.name.startsWith("gpt-4o-mini"),
+    )
       .map((m) => "-" + m.name)
       .join(",");
-    if (defaultModel.startsWith("gpt-4")) defaultModel = "";
+    if (
+      defaultModel.startsWith("gpt-4") &&
+      !defaultModel.startsWith("gpt-4o-mini")
+    )
+      defaultModel = "";
   }
 
+  const isStability = !!process.env.STABILITY_API_KEY;
+
   const isAzure = !!process.env.AZURE_URL;
   const isGoogle = !!process.env.GOOGLE_API_KEY;
   const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
+  const isTencent = !!process.env.TENCENT_API_KEY;
 
   const isBaidu = !!process.env.BAIDU_API_KEY;
   const isBytedance = !!process.env.BYTEDANCE_API_KEY;
   const isAlibaba = !!process.env.ALIBABA_API_KEY;
+  const isMoonshot = !!process.env.MOONSHOT_API_KEY;
+  const isIflytek = !!process.env.IFLYTEK_API_KEY;
   // const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
   // const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
   // const randomIndex = Math.floor(Math.random() * apiKeys.length);
@@ -131,6 +160,10 @@ export const getServerSideConfig = () => {
     apiKey: getApiKey(process.env.OPENAI_API_KEY),
     openaiOrgId: process.env.OPENAI_ORG_ID,
 
+    isStability,
+    stabilityUrl: process.env.STABILITY_URL,
+    stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY),
+
     isAzure,
     azureUrl: process.env.AZURE_URL,
     azureApiKey: getApiKey(process.env.AZURE_API_KEY),
@@ -158,6 +191,25 @@ export const getServerSideConfig = () => {
     alibabaUrl: process.env.ALIBABA_URL,
     alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY),
 
+    isTencent,
+    tencentUrl: process.env.TENCENT_URL,
+    tencentSecretKey: getApiKey(process.env.TENCENT_SECRET_KEY),
+    tencentSecretId: process.env.TENCENT_SECRET_ID,
+
+    isMoonshot,
+    moonshotUrl: process.env.MOONSHOT_URL,
+    moonshotApiKey: getApiKey(process.env.MOONSHOT_API_KEY),
+
+    isIflytek,
+    iflytekUrl: process.env.IFLYTEK_URL,
+    iflytekApiKey: process.env.IFLYTEK_API_KEY,
+    iflytekApiSecret: process.env.IFLYTEK_API_SECRET,
+
+    cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID,
+    cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID,
+    cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY),
+    cloudflareKVTTL: process.env.CLOUDFLARE_KV_TTL,
+
     gtmId: process.env.GTM_ID,
     gaId: process.env.GA_ID || DEFAULT_GA_ID,
 

+ 124 - 2
app/constant.ts

@@ -1,4 +1,4 @@
-export const OWNER = "Yidadaa";
+export const OWNER = "ChatGPTNextWeb";
 export const REPO = "ChatGPT-Next-Web";
 export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
 export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
@@ -8,6 +8,8 @@ export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/c
 export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
 export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
 
+export const STABILITY_BASE_URL = "https://api.stability.ai";
+
 export const DEFAULT_API_HOST = "https://api.nextchat.dev";
 export const OPENAI_BASE_URL = "https://api.openai.com";
 export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
@@ -21,6 +23,11 @@ export const BYTEDANCE_BASE_URL = "https://ark.cn-beijing.volces.com";
 
 export const ALIBABA_BASE_URL = "https://dashscope.aliyuncs.com/api/";
 
+export const TENCENT_BASE_URL = "https://hunyuan.tencentcloudapi.com";
+
+export const MOONSHOT_BASE_URL = "https://api.moonshot.cn";
+export const IFLYTEK_BASE_URL = "https://spark-api-open.xf-yun.com";
+
 export const CACHE_URL_PREFIX = "/api/cache";
 export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`;
 
@@ -31,6 +38,9 @@ export enum Path {
   NewChat = "/new-chat",
   Masks = "/masks",
   Auth = "/auth",
+  Sd = "/sd",
+  SdNew = "/sd-new",
+  Artifacts = "/artifacts",
 }
 
 export enum ApiPath {
@@ -42,6 +52,11 @@ export enum ApiPath {
   Baidu = "/api/baidu",
   ByteDance = "/api/bytedance",
   Alibaba = "/api/alibaba",
+  Tencent = "/api/tencent",
+  Moonshot = "/api/moonshot",
+  Iflytek = "/api/iflytek",
+  Stability = "/api/stability",
+  Artifacts = "/api/artifacts",
 }
 
 export enum SlotID {
@@ -54,6 +69,10 @@ export enum FileName {
   Prompts = "prompts.json",
 }
 
+export enum Plugin {
+  Artifacts = "artifacts",
+}
+
 export enum StoreKey {
   Chat = "chat-next-web-store",
   Access = "access-control",
@@ -62,6 +81,7 @@ export enum StoreKey {
   Prompt = "prompt-store",
   Update = "chat-update",
   Sync = "sync",
+  SdList = "sd-list",
 }
 
 export const DEFAULT_SIDEBAR_WIDTH = 300;
@@ -88,6 +108,10 @@ export enum ServiceProvider {
   Baidu = "Baidu",
   ByteDance = "ByteDance",
   Alibaba = "Alibaba",
+  Tencent = "Tencent",
+  Moonshot = "Moonshot",
+  Stability = "Stability",
+  Iflytek = "Iflytek",
 }
 
 // Google API safety settings, see https://ai.google.dev/gemini-api/docs/safety-settings
@@ -100,14 +124,23 @@ export enum GoogleSafetySettingsThreshold {
 }
 
 export enum ModelProvider {
+  Stability = "Stability",
   GPT = "GPT",
   GeminiPro = "GeminiPro",
   Claude = "Claude",
   Ernie = "Ernie",
   Doubao = "Doubao",
   Qwen = "Qwen",
+  Hunyuan = "Hunyuan",
+  Moonshot = "Moonshot",
+  Iflytek = "Iflytek",
 }
 
+export const Stability = {
+  GeneratePath: "v2beta/stable-image/generate",
+  ExampleEndpoint: "https://api.stability.ai",
+};
+
 export const Anthropic = {
   ChatPath: "v1/messages",
   ChatPath1: "v1/complete",
@@ -117,6 +150,7 @@ export const Anthropic = {
 
 export const OpenaiPath = {
   ChatPath: "v1/chat/completions",
+  ImagePath: "v1/images/generations",
   UsagePath: "dashboard/billing/usage",
   SubsPath: "dashboard/billing/subscription",
   ListModelPath: "v1/models",
@@ -125,7 +159,10 @@ export const OpenaiPath = {
 export const Azure = {
   ChatPath: (deployName: string, apiVersion: string) =>
     `deployments/${deployName}/chat/completions?api-version=${apiVersion}`,
-  ExampleEndpoint: "https://{resource-url}/openai/deployments/{deploy-id}",
+  // https://<your_resource_name>.openai.azure.com/openai/deployments/<your_deployment_name>/images/generations?api-version=<api_version>
+  ImagePath: (deployName: string, apiVersion: string) =>
+    `deployments/${deployName}/images/generations?api-version=${apiVersion}`,
+  ExampleEndpoint: "https://{resource-url}/openai",
 };
 
 export const Google = {
@@ -164,6 +201,20 @@ export const Alibaba = {
   ChatPath: "v1/services/aigc/text-generation/generation",
 };
 
+export const Tencent = {
+  ExampleEndpoint: TENCENT_BASE_URL,
+};
+
+export const Moonshot = {
+  ExampleEndpoint: MOONSHOT_BASE_URL,
+  ChatPath: "v1/chat/completions",
+};
+
+export const Iflytek = {
+  ExampleEndpoint: IFLYTEK_BASE_URL,
+  ChatPath: "v1/chat/completions",
+};
+
 export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
 // export const DEFAULT_SYSTEM_TEMPLATE = `
 // You are ChatGPT, a large language model trained by {{ServiceProvider}}.
@@ -192,6 +243,7 @@ export const KnowledgeCutOffDate: Record<string, string> = {
   "gpt-4-turbo-preview": "2023-12",
   "gpt-4o": "2023-10",
   "gpt-4o-2024-05-13": "2023-10",
+  "gpt-4o-2024-08-06": "2023-10",
   "gpt-4o-mini": "2023-10",
   "gpt-4o-mini-2024-07-18": "2023-10",
   "gpt-4-vision-preview": "2023-04",
@@ -213,11 +265,13 @@ const openaiModels = [
   "gpt-4-turbo-preview",
   "gpt-4o",
   "gpt-4o-2024-05-13",
+  "gpt-4o-2024-08-06",
   "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",
 ];
 
 const googleModels = [
@@ -270,68 +324,136 @@ const alibabaModes = [
   "qwen-max-longcontext",
 ];
 
+const tencentModels = [
+  "hunyuan-pro",
+  "hunyuan-standard",
+  "hunyuan-lite",
+  "hunyuan-role",
+  "hunyuan-functioncall",
+  "hunyuan-code",
+  "hunyuan-vision",
+];
+
+const moonshotModes = ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"];
+
+const iflytekModels = [
+  "general",
+  "generalv3",
+  "pro-128k",
+  "generalv3.5",
+  "4.0Ultra",
+];
+
+let seq = 1000; // 内置的模型序号生成器从1000开始
 export const DEFAULT_MODELS = [
   ...openaiModels.map((name) => ({
     name,
     available: true,
+    sorted: seq++, // Global sequence sort(index)
     provider: {
       id: "openai",
       providerName: "OpenAI",
       providerType: "openai",
+      sorted: 1, // 这里是固定的,确保顺序与之前内置的版本一致
     },
   })),
   ...openaiModels.map((name) => ({
     name,
     available: true,
+    sorted: seq++,
     provider: {
       id: "azure",
       providerName: "Azure",
       providerType: "azure",
+      sorted: 2,
     },
   })),
   ...googleModels.map((name) => ({
     name,
     available: true,
+    sorted: seq++,
     provider: {
       id: "google",
       providerName: "Google",
       providerType: "google",
+      sorted: 3,
     },
   })),
   ...anthropicModels.map((name) => ({
     name,
     available: true,
+    sorted: seq++,
     provider: {
       id: "anthropic",
       providerName: "Anthropic",
       providerType: "anthropic",
+      sorted: 4,
     },
   })),
   ...baiduModels.map((name) => ({
     name,
     available: true,
+    sorted: seq++,
     provider: {
       id: "baidu",
       providerName: "Baidu",
       providerType: "baidu",
+      sorted: 5,
     },
   })),
   ...bytedanceModels.map((name) => ({
     name,
     available: true,
+    sorted: seq++,
     provider: {
       id: "bytedance",
       providerName: "ByteDance",
       providerType: "bytedance",
+      sorted: 6,
     },
   })),
   ...alibabaModes.map((name) => ({
     name,
     available: true,
+    sorted: seq++,
     provider: {
       id: "alibaba",
       providerName: "Alibaba",
       providerType: "alibaba",
+      sorted: 7,
+    },
+  })),
+  ...tencentModels.map((name) => ({
+    name,
+    available: true,
+    sorted: seq++,
+    provider: {
+      id: "tencent",
+      providerName: "Tencent",
+      providerType: "tencent",
+      sorted: 8,
+    },
+  })),
+  ...moonshotModes.map((name) => ({
+    name,
+    available: true,
+    sorted: seq++,
+    provider: {
+      id: "moonshot",
+      providerName: "Moonshot",
+      providerType: "moonshot",
+      sorted: 9,
+    },
+  })),
+  ...iflytekModels.map((name) => ({
+    name,
+    available: true,
+    sorted: seq++,
+    provider: {
+      id: "iflytek",
+      providerName: "Iflytek",
+      providerType: "iflytek",
+      sorted: 10,
     },
   })),
 ] as const;

+ 7 - 0
app/icons/discovery.svg

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

+ 4 - 0
app/icons/hd.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#333" class="bi bi-badge-hd" viewBox="0 0 16 16">
+  <path d="M7.396 11V5.001H6.209v2.44H3.687V5H2.5v6h1.187V8.43h2.522V11zM8.5 5.001V11h2.188c1.811 0 2.685-1.107 2.685-3.015 0-1.894-.86-2.984-2.684-2.984zm1.187.967h.843c1.112 0 1.622.686 1.622 2.04 0 1.353-.505 2.02-1.622 2.02h-.843z"/>
+  <path d="M14 3a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM2 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/>
+</svg>

+ 10 - 0
app/icons/history.svg

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

+ 4 - 0
app/icons/palette.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#333" class="bi bi-palette" viewBox="0 0 16 16">
+  <path d="M8 5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3m4 3a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3M5.5 7a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m.5 6a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"/>
+  <path d="M16 8c0 3.15-1.866 2.585-3.567 2.07C11.42 9.763 10.465 9.473 10 10c-.603.683-.475 1.819-.351 2.92C9.826 14.495 9.996 16 8 16a8 8 0 1 1 8-8m-8 7c.611 0 .654-.171.655-.176.078-.146.124-.464.07-1.119-.014-.168-.037-.37-.061-.591-.052-.464-.112-1.005-.118-1.462-.01-.707.083-1.61.704-2.314.369-.417.845-.578 1.272-.618.404-.038.812.026 1.16.104.343.077.702.186 1.025.284l.028.008c.346.105.658.199.953.266.653.148.904.083.991.024C14.717 9.38 15 9.161 15 8a7 7 0 1 0-7 7"/>
+</svg>

+ 12 - 0
app/icons/sd.svg

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

+ 1 - 0
app/icons/size.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 9V39C4 40.1046 4.89543 41 6 41H42C43.1046 41 44 40.1046 44 39V9C44 7.89543 43.1046 7 42 7Z" fill="none" stroke="#333" stroke-width="4"/><path d="M30 30V18L38 30V18" stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 30V18L18 30V18" stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><path d="M24 20V21" stroke="#333" stroke-width="4" stroke-linecap="round"/><path d="M24 27V28" stroke="#333" stroke-width="4" stroke-linecap="round"/></svg>

+ 5 - 0
app/locales/ar.ts

@@ -153,6 +153,11 @@ const ar: PartialLocaleType = {
       Title: "حجم الخط",
       SubTitle: "حجم الخط في محتوى الدردشة",
     },
+    FontFamily: {
+      Title: "خط الدردشة",
+      SubTitle: "خط محتوى الدردشة، اتركه فارغًا لتطبيق الخط الافتراضي العالمي",
+      Placeholder: "اسم الخط",
+    },
     InjectSystemPrompts: {
       Title: "حقن الرسائل النصية النظامية",
       SubTitle:

+ 6 - 0
app/locales/bn.ts

@@ -153,6 +153,12 @@ const bn: PartialLocaleType = {
       Title: "ফন্ট সাইজ",
       SubTitle: "চ্যাট কনটেন্টের ফন্ট সাইজ",
     },
+    FontFamily: {
+      Title: "চ্যাট ফন্ট",
+      SubTitle:
+        "চ্যাট সামগ্রীর ফন্ট, বিশ্বব্যাপী ডিফল্ট ফন্ট প্রয়োগ করতে খালি রাখুন",
+      Placeholder: "ফন্টের নাম",
+    },
     InjectSystemPrompts: {
       Title: "সিস্টেম-লেভেল প্রম্পট যোগ করুন",
       SubTitle:

+ 124 - 0
app/locales/cn.ts

@@ -42,6 +42,7 @@ const cn = {
       PinToastAction: "查看",
       Delete: "删除",
       Edit: "编辑",
+      FullScreen: "全屏",
     },
     Commands: {
       new: "新建聊天",
@@ -104,6 +105,10 @@ const cn = {
       Toast: "正在生成截图",
       Modal: "长按或右键保存图片",
     },
+    Artifacts: {
+      Title: "分享页面",
+      Error: "分享失败",
+    },
   },
   Select: {
     Search: "搜索消息",
@@ -128,6 +133,7 @@ const cn = {
   Settings: {
     Title: "设置",
     SubTitle: "所有设置选项",
+    ShowPassword: "显示密码",
 
     Danger: {
       Reset: {
@@ -152,6 +158,11 @@ const cn = {
       Title: "字体大小",
       SubTitle: "聊天内容的字体大小",
     },
+    FontFamily: {
+      Title: "聊天字体",
+      SubTitle: "聊天内容的字体,若置空则应用全局默认字体",
+      Placeholder: "字体名称",
+    },
     InjectSystemPrompts: {
       Title: "注入系统级提示信息",
       SubTitle: "强制给每次请求的消息列表开头添加一个模拟 ChatGPT 的系统提示",
@@ -367,6 +378,22 @@ const cn = {
           SubTitle: "不支持自定义前往.env配置",
         },
       },
+      Tencent: {
+        ApiKey: {
+          Title: "API Key",
+          SubTitle: "使用自定义腾讯云API Key",
+          Placeholder: "Tencent API Key",
+        },
+        SecretKey: {
+          Title: "Secret Key",
+          SubTitle: "使用自定义腾讯云Secret Key",
+          Placeholder: "Tencent Secret Key",
+        },
+        Endpoint: {
+          Title: "接口地址",
+          SubTitle: "不支持自定义前往.env配置",
+        },
+      },
       ByteDance: {
         ApiKey: {
           Title: "接口密钥",
@@ -389,6 +416,44 @@ const cn = {
           SubTitle: "样例:",
         },
       },
+      Moonshot: {
+        ApiKey: {
+          Title: "接口密钥",
+          SubTitle: "使用自定义月之暗面API Key",
+          Placeholder: "Moonshot API Key",
+        },
+        Endpoint: {
+          Title: "接口地址",
+          SubTitle: "样例:",
+        },
+      },
+      Stability: {
+        ApiKey: {
+          Title: "接口密钥",
+          SubTitle: "使用自定义 Stability API Key",
+          Placeholder: "Stability API Key",
+        },
+        Endpoint: {
+          Title: "接口地址",
+          SubTitle: "样例:",
+        },
+      },
+      Iflytek: {
+        ApiKey: {
+          Title: "ApiKey",
+          SubTitle: "从讯飞星火控制台获取的 APIKey",
+          Placeholder: "APIKey",
+        },
+        ApiSecret: {
+          Title: "ApiSecret",
+          SubTitle: "从讯飞星火控制台获取的 APISecret",
+          Placeholder: "APISecret",
+        },
+        Endpoint: {
+          Title: "接口地址",
+          SubTitle: "样例:",
+        },
+      },
       CustomModel: {
         Title: "自定义模型名",
         SubTitle: "增加自定义模型可选项,使用英文逗号隔开",
@@ -446,6 +511,10 @@ const cn = {
   },
   Plugin: {
     Name: "插件",
+    Artifacts: "Artifacts",
+  },
+  Discovery: {
+    Name: "发现",
   },
   FineTuned: {
     Sysmessage: "你是一个助手",
@@ -526,6 +595,61 @@ const cn = {
     Topic: "主题",
     Time: "时间",
   },
+  SdPanel: {
+    Prompt: "画面提示",
+    NegativePrompt: "否定提示",
+    PleaseInput: (name: string) => `请输入${name}`,
+    AspectRatio: "横纵比",
+    ImageStyle: "图像风格",
+    OutFormat: "输出格式",
+    AIModel: "AI模型",
+    ModelVersion: "模型版本",
+    Submit: "提交生成",
+    ParamIsRequired: (name: string) => `${name}不能为空`,
+    Styles: {
+      D3Model: "3D模型",
+      AnalogFilm: "模拟电影",
+      Anime: "动漫",
+      Cinematic: "电影风格",
+      ComicBook: "漫画书",
+      DigitalArt: "数字艺术",
+      Enhance: "增强",
+      FantasyArt: "幻想艺术",
+      Isometric: "等角",
+      LineArt: "线描",
+      LowPoly: "低多边形",
+      ModelingCompound: "建模材料",
+      NeonPunk: "霓虹朋克",
+      Origami: "折纸",
+      Photographic: "摄影",
+      PixelArt: "像素艺术",
+      TileTexture: "贴图",
+    },
+  },
+  Sd: {
+    SubTitle: (count: number) => `共 ${count} 条绘画`,
+    Actions: {
+      Params: "查看参数",
+      Copy: "复制提示词",
+      Delete: "删除",
+      Retry: "重试",
+      ReturnHome: "返回首页",
+      History: "查看历史",
+    },
+    EmptyRecord: "暂无绘画记录",
+    Status: {
+      Name: "状态",
+      Success: "成功",
+      Error: "失败",
+      Wait: "等待中",
+      Running: "运行中",
+    },
+    Danger: {
+      Delete: "确认删除?",
+    },
+    GenerateParams: "生成参数",
+    Detail: "详情",
+  },
 };
 
 type DeepPartial<T> = T extends object

+ 6 - 0
app/locales/cs.ts

@@ -153,6 +153,12 @@ const cs: PartialLocaleType = {
       Title: "Velikost písma",
       SubTitle: "Velikost písma pro obsah chatu",
     },
+    FontFamily: {
+      Title: "Chatové Písmo",
+      SubTitle:
+        "Písmo obsahu chatu, ponechejte prázdné pro použití globálního výchozího písma",
+      Placeholder: "Název Písma",
+    },
     InjectSystemPrompts: {
       Title: "Vložit systémové výzvy",
       SubTitle:

+ 6 - 0
app/locales/de.ts

@@ -155,6 +155,12 @@ const de: PartialLocaleType = {
       Title: "Schriftgröße",
       SubTitle: "Schriftgröße des Chat-Inhalts",
     },
+    FontFamily: {
+      Title: "Chat-Schriftart",
+      SubTitle:
+        "Schriftart des Chat-Inhalts, leer lassen, um die globale Standardschriftart anzuwenden",
+      Placeholder: "Schriftartname",
+    },
     InjectSystemPrompts: {
       Title: "Systemweite Eingabeaufforderungen einfügen",
       SubTitle:

+ 125 - 1
app/locales/en.ts

@@ -44,6 +44,7 @@ const en: LocaleType = {
       PinToastAction: "View",
       Delete: "Delete",
       Edit: "Edit",
+      FullScreen: "FullScreen",
     },
     Commands: {
       new: "Start a new chat",
@@ -106,6 +107,10 @@ const en: LocaleType = {
       Toast: "Capturing Image...",
       Modal: "Long press or right click to save image",
     },
+    Artifacts: {
+      Title: "Share Artifacts",
+      Error: "Share Error",
+    },
   },
   Select: {
     Search: "Search",
@@ -131,6 +136,7 @@ const en: LocaleType = {
   Settings: {
     Title: "Settings",
     SubTitle: "All Settings",
+    ShowPassword: "ShowPassword",
     Danger: {
       Reset: {
         Title: "Reset All Settings",
@@ -154,6 +160,12 @@ const en: LocaleType = {
       Title: "Font Size",
       SubTitle: "Adjust font size of chat content",
     },
+    FontFamily: {
+      Title: "Chat Font Family",
+      SubTitle:
+        "Font Family of the chat content, leave empty to apply global default font",
+      Placeholder: "Font Family Name",
+    },
     InjectSystemPrompts: {
       Title: "Inject System Prompts",
       SubTitle: "Inject a global system prompt for every request",
@@ -350,6 +362,22 @@ const en: LocaleType = {
           SubTitle: "not supported, configure in .env",
         },
       },
+      Tencent: {
+        ApiKey: {
+          Title: "Tencent API Key",
+          SubTitle: "Use a custom Tencent API Key",
+          Placeholder: "Tencent API Key",
+        },
+        SecretKey: {
+          Title: "Tencent Secret Key",
+          SubTitle: "Use a custom Tencent Secret Key",
+          Placeholder: "Tencent Secret Key",
+        },
+        Endpoint: {
+          Title: "Endpoint Address",
+          SubTitle: "not supported, configure in .env",
+        },
+      },
       ByteDance: {
         ApiKey: {
           Title: "ByteDance API Key",
@@ -372,6 +400,44 @@ const en: LocaleType = {
           SubTitle: "Example: ",
         },
       },
+      Moonshot: {
+        ApiKey: {
+          Title: "Moonshot API Key",
+          SubTitle: "Use a custom Moonshot API Key",
+          Placeholder: "Moonshot API Key",
+        },
+        Endpoint: {
+          Title: "Endpoint Address",
+          SubTitle: "Example: ",
+        },
+      },
+      Stability: {
+        ApiKey: {
+          Title: "Stability API Key",
+          SubTitle: "Use a custom Stability API Key",
+          Placeholder: "Stability API Key",
+        },
+        Endpoint: {
+          Title: "Endpoint Address",
+          SubTitle: "Example: ",
+        },
+      },
+      Iflytek: {
+        ApiKey: {
+          Title: "Iflytek API Key",
+          SubTitle: "Use a Iflytek API Key",
+          Placeholder: "Iflytek API Key",
+        },
+        ApiSecret: {
+          Title: "Iflytek API Secret",
+          SubTitle: "Use a Iflytek API Secret",
+          Placeholder: "Iflytek API Secret",
+        },
+        Endpoint: {
+          Title: "Endpoint Address",
+          SubTitle: "Example: ",
+        },
+      },
       CustomModel: {
         Title: "Custom Models",
         SubTitle: "Custom model options, seperated by comma",
@@ -453,6 +519,10 @@ const en: LocaleType = {
   },
   Plugin: {
     Name: "Plugin",
+    Artifacts: "Artifacts",
+  },
+  Discovery: {
+    Name: "Discovery",
   },
   FineTuned: {
     Sysmessage: "You are an assistant that",
@@ -528,11 +598,65 @@ const en: LocaleType = {
     Topic: "Topic",
     Time: "Time",
   },
-
   URLCommand: {
     Code: "Detected access code from url, confirm to apply? ",
     Settings: "Detected settings from url, confirm to apply?",
   },
+  SdPanel: {
+    Prompt: "Prompt",
+    NegativePrompt: "Negative Prompt",
+    PleaseInput: (name: string) => `Please input ${name}`,
+    AspectRatio: "Aspect Ratio",
+    ImageStyle: "Image Style",
+    OutFormat: "Output Format",
+    AIModel: "AI Model",
+    ModelVersion: "Model Version",
+    Submit: "Submit",
+    ParamIsRequired: (name: string) => `${name} is required`,
+    Styles: {
+      D3Model: "3d-model",
+      AnalogFilm: "analog-film",
+      Anime: "anime",
+      Cinematic: "cinematic",
+      ComicBook: "comic-book",
+      DigitalArt: "digital-art",
+      Enhance: "enhance",
+      FantasyArt: "fantasy-art",
+      Isometric: "isometric",
+      LineArt: "line-art",
+      LowPoly: "low-poly",
+      ModelingCompound: "modeling-compound",
+      NeonPunk: "neon-punk",
+      Origami: "origami",
+      Photographic: "photographic",
+      PixelArt: "pixel-art",
+      TileTexture: "tile-texture",
+    },
+  },
+  Sd: {
+    SubTitle: (count: number) => `${count} images`,
+    Actions: {
+      Params: "See Params",
+      Copy: "Copy Prompt",
+      Delete: "Delete",
+      Retry: "Retry",
+      ReturnHome: "Return Home",
+      History: "History",
+    },
+    EmptyRecord: "No images yet",
+    Status: {
+      Name: "Status",
+      Success: "Success",
+      Error: "Error",
+      Wait: "Waiting",
+      Running: "Running",
+    },
+    Danger: {
+      Delete: "Confirm to delete?",
+    },
+    GenerateParams: "Generate Params",
+    Detail: "Detail",
+  },
 };
 
 export default en;

+ 6 - 0
app/locales/es.ts

@@ -159,6 +159,12 @@ const es: PartialLocaleType = {
       Title: "Tamaño de fuente",
       SubTitle: "Tamaño de la fuente del contenido del chat",
     },
+    FontFamily: {
+      Title: "Fuente del Chat",
+      SubTitle:
+        "Fuente del contenido del chat, dejar vacío para aplicar la fuente predeterminada global",
+      Placeholder: "Nombre de la Fuente",
+    },
     InjectSystemPrompts: {
       Title: "Inyectar mensajes del sistema",
       SubTitle:

+ 6 - 0
app/locales/fr.ts

@@ -157,6 +157,12 @@ const fr: PartialLocaleType = {
       Title: "Taille de la police",
       SubTitle: "Taille de la police pour le contenu des discussions",
     },
+    FontFamily: {
+      Title: "Police de Chat",
+      SubTitle:
+        "Police du contenu du chat, laissez vide pour appliquer la police par défaut globale",
+      Placeholder: "Nom de la Police",
+    },
     InjectSystemPrompts: {
       Title: "Injecter des invites système",
       SubTitle:

+ 6 - 0
app/locales/id.ts

@@ -154,6 +154,12 @@ const id: PartialLocaleType = {
       Title: "Ukuran Font",
       SubTitle: "Ukuran font untuk konten obrolan",
     },
+    FontFamily: {
+      Title: "Font Obrolan",
+      SubTitle:
+        "Font dari konten obrolan, biarkan kosong untuk menerapkan font default global",
+      Placeholder: "Nama Font",
+    },
     InjectSystemPrompts: {
       Title: "Suntikkan Pesan Sistem",
       SubTitle:

+ 6 - 0
app/locales/it.ts

@@ -159,6 +159,12 @@ const it: PartialLocaleType = {
       Title: "Dimensione del carattere",
       SubTitle: "Dimensione del carattere per il contenuto della chat",
     },
+    FontFamily: {
+      Title: "Font della Chat",
+      SubTitle:
+        "Carattere del contenuto della chat, lascia vuoto per applicare il carattere predefinito globale",
+      Placeholder: "Nome del Font",
+    },
     InjectSystemPrompts: {
       Title: "Inserisci suggerimenti di sistema",
       SubTitle:

+ 6 - 0
app/locales/jp.ts

@@ -153,6 +153,12 @@ const jp: PartialLocaleType = {
       Title: "フォントサイズ",
       SubTitle: "チャット内容のフォントサイズ",
     },
+    FontFamily: {
+      Title: "チャットフォント",
+      SubTitle:
+        "チャットコンテンツのフォント、空白の場合はグローバルデフォルトフォントを適用します",
+      Placeholder: "フォント名",
+    },
     InjectSystemPrompts: {
       Title: "システムプロンプトの注入",
       SubTitle:

+ 5 - 0
app/locales/ko.ts

@@ -153,6 +153,11 @@ const ko: PartialLocaleType = {
       Title: "글꼴 크기",
       SubTitle: "채팅 내용의 글꼴 크기",
     },
+    FontFamily: {
+      Title: "채팅 폰트",
+      SubTitle: "채팅 내용의 폰트, 비워 두면 글로벌 기본 폰트를 적용",
+      Placeholder: "폰트 이름",
+    },
     InjectSystemPrompts: {
       Title: "시스템 수준 프롬프트 삽입",
       SubTitle:

+ 6 - 0
app/locales/no.ts

@@ -157,6 +157,12 @@ const no: PartialLocaleType = {
       Title: "Skriftstørrelse",
       SubTitle: "Skriftstørrelse for samtaleinnhold",
     },
+    FontFamily: {
+      Title: "Chat-skrifttype",
+      SubTitle:
+        "Skrifttypen for chatinnhold, la stå tom for å bruke global standardskrifttype",
+      Placeholder: "Skriftnavn",
+    },
     InjectSystemPrompts: {
       Title: "Injiser systemprompter",
       SubTitle:

+ 6 - 0
app/locales/pt.ts

@@ -153,6 +153,12 @@ const pt: PartialLocaleType = {
       Title: "Tamanho da Fonte",
       SubTitle: "Ajustar o tamanho da fonte do conteúdo do chat",
     },
+    FontFamily: {
+      Title: "Fonte do Chat",
+      SubTitle:
+        "Fonte do conteúdo do chat, deixe vazio para aplicar a fonte padrão global",
+      Placeholder: "Nome da Fonte",
+    },
     InjectSystemPrompts: {
       Title: "Inserir Prompts de Sistema",
       SubTitle: "Inserir um prompt de sistema global para cada requisição",

+ 6 - 0
app/locales/ru.ts

@@ -153,6 +153,12 @@ const ru: PartialLocaleType = {
       Title: "Размер шрифта",
       SubTitle: "Размер шрифта в чате",
     },
+    FontFamily: {
+      Title: "Шрифт чата",
+      SubTitle:
+        "Шрифт содержимого чата, оставьте пустым для применения глобального шрифта по умолчанию",
+      Placeholder: "Название шрифта",
+    },
     InjectSystemPrompts: {
       Title: "Вставить системные подсказки",
       SubTitle:

+ 6 - 0
app/locales/sk.ts

@@ -155,6 +155,12 @@ const sk: PartialLocaleType = {
       Title: "Veľkosť písma",
       SubTitle: "Nastaviť veľkosť písma obsahu chatu",
     },
+    FontFamily: {
+      Title: "Chatové Písmo",
+      SubTitle:
+        "Písmo obsahu chatu, ponechajte prázdne pre použitie globálneho predvoleného písma",
+      Placeholder: "Názov Písma",
+    },
     InjectSystemPrompts: {
       Title: "Vložiť systémové výzvy",
       SubTitle: "Vložiť globálnu systémovú výzvu pre každú požiadavku",

+ 6 - 0
app/locales/tr.ts

@@ -154,6 +154,12 @@ const tr: PartialLocaleType = {
       Title: "Yazı Boyutu",
       SubTitle: "Sohbet içeriğinin yazı boyutu",
     },
+    FontFamily: {
+      Title: "Sohbet Yazı Tipi",
+      SubTitle:
+        "Sohbet içeriğinin yazı tipi, boş bırakıldığında küresel varsayılan yazı tipi uygulanır",
+      Placeholder: "Yazı Tipi Adı",
+    },
     InjectSystemPrompts: {
       Title: "Sistem Seviyesi İpucu Enjeksiyonu",
       SubTitle: "Her isteğin başına ChatGPT benzeri bir sistem ipucu ekle",

+ 5 - 0
app/locales/tw.ts

@@ -153,6 +153,11 @@ const tw = {
       Title: "字型大小",
       SubTitle: "聊天內容的字型大小",
     },
+    FontFamily: {
+      Title: "聊天字體",
+      SubTitle: "聊天內容的字體,若置空則應用全局默認字體",
+      Placeholder: "字體名稱",
+    },
     InjectSystemPrompts: {
       Title: "匯入系統提示",
       SubTitle: "強制在每個請求的訊息列表開頭新增一個模擬 ChatGPT 的系統提示",

+ 6 - 0
app/locales/vi.ts

@@ -153,6 +153,12 @@ const vi: PartialLocaleType = {
       Title: "Kích thước chữ",
       SubTitle: "Kích thước chữ của nội dung trò chuyện",
     },
+    FontFamily: {
+      Title: "Phông Chữ Trò Chuyện",
+      SubTitle:
+        "Phông chữ của nội dung trò chuyện, để trống để áp dụng phông chữ mặc định toàn cầu",
+      Placeholder: "Tên Phông Chữ",
+    },
     InjectSystemPrompts: {
       Title: "Tiêm thông báo hệ thống",
       SubTitle:

+ 47 - 1
app/store/access.ts

@@ -39,7 +39,21 @@ const DEFAULT_ALIBABA_URL = isApp
   ? DEFAULT_API_HOST + "/api/proxy/alibaba"
   : ApiPath.Alibaba;
 
-console.log("DEFAULT_ANTHROPIC_URL", DEFAULT_ANTHROPIC_URL);
+const DEFAULT_TENCENT_URL = isApp
+  ? DEFAULT_API_HOST + "/api/proxy/tencent"
+  : ApiPath.Tencent;
+
+const DEFAULT_MOONSHOT_URL = isApp
+  ? DEFAULT_API_HOST + "/api/proxy/moonshot"
+  : ApiPath.Moonshot;
+
+const DEFAULT_STABILITY_URL = isApp
+  ? DEFAULT_API_HOST + "/api/proxy/stability"
+  : ApiPath.Stability;
+
+const DEFAULT_IFLYTEK_URL = isApp
+  ? DEFAULT_API_HOST + "/api/proxy/iflytek"
+  : ApiPath.Iflytek;
 
 const DEFAULT_ACCESS_STATE = {
   accessCode: "",
@@ -80,6 +94,24 @@ const DEFAULT_ACCESS_STATE = {
   alibabaUrl: DEFAULT_ALIBABA_URL,
   alibabaApiKey: "",
 
+  // moonshot
+  moonshotUrl: DEFAULT_MOONSHOT_URL,
+  moonshotApiKey: "",
+
+  //stability
+  stabilityUrl: DEFAULT_STABILITY_URL,
+  stabilityApiKey: "",
+
+  // tencent
+  tencentUrl: DEFAULT_TENCENT_URL,
+  tencentSecretKey: "",
+  tencentSecretId: "",
+
+  // iflytek
+  iflytekUrl: DEFAULT_IFLYTEK_URL,
+  iflytekApiKey: "",
+  iflytekApiSecret: "",
+
   // server config
   needCode: true,
   hideUserApiKey: false,
@@ -128,6 +160,17 @@ export const useAccessStore = createPersistStore(
       return ensure(get(), ["alibabaApiKey"]);
     },
 
+    isValidTencent() {
+      return ensure(get(), ["tencentSecretKey", "tencentSecretId"]);
+    },
+
+    isValidMoonshot() {
+      return ensure(get(), ["moonshotApiKey"]);
+    },
+    isValidIflytek() {
+      return ensure(get(), ["iflytekApiKey"]);
+    },
+
     isAuthorized() {
       this.fetch();
 
@@ -140,6 +183,9 @@ export const useAccessStore = createPersistStore(
         this.isValidBaidu() ||
         this.isValidByteDance() ||
         this.isValidAlibaba() ||
+        this.isValidTencent ||
+        this.isValidMoonshot() ||
+        this.isValidIflytek() ||
         !this.enabledAccessControl() ||
         (this.enabledAccessControl() && ensure(get(), ["accessCode"]))
       );

+ 8 - 1
app/store/chat.ts

@@ -26,6 +26,7 @@ import { nanoid } from "nanoid";
 import { createPersistStore } from "../utils/store";
 import { collectModelsWithDefaultModel } from "../utils/model";
 import { useAccessStore } from "./access";
+import { isDalle3 } from "../utils";
 
 export type ChatMessage = RequestMessage & {
   date: string;
@@ -541,8 +542,13 @@ export const useChatStore = createPersistStore(
         const config = useAppConfig.getState();
         const session = get().currentSession();
         const modelConfig = session.mask.modelConfig;
+        // skip summarize when using dalle3?
+        if (isDalle3(modelConfig.model)) {
+          return;
+        }
 
-        const api: ClientApi = getClientApi(modelConfig.providerName);
+        const providerName = modelConfig.providerName;
+        const api: ClientApi = getClientApi(providerName);
 
         // remove error messages if any
         const messages = session.messages;
@@ -565,6 +571,7 @@ export const useChatStore = createPersistStore(
             config: {
               model: getSummarizeModel(session.mask.modelConfig.model),
               stream: false,
+              providerName,
             },
             onFinish(message) {
               get().updateCurrentSession(

+ 5 - 0
app/store/config.ts

@@ -1,4 +1,5 @@
 import { LLMModel } from "../client/api";
+import { DalleSize, DalleQuality, DalleStyle } from "../typing";
 import { getClientConfig } from "../config/client";
 import {
   DEFAULT_INPUT_TEMPLATE,
@@ -33,6 +34,7 @@ export const DEFAULT_CONFIG = {
   submitKey: SubmitKey.Enter,
   avatar: "1f603",
   fontSize: 14,
+  fontFamily: "",
   theme: Theme.Auto as Theme,
   tightBorder: !!config?.isApp,
   sendPreviewBubble: true,
@@ -60,6 +62,9 @@ export const DEFAULT_CONFIG = {
     compressMessageLengthThreshold: 1000,
     enableInjectSystemPrompts: true,
     template: config?.template ?? DEFAULT_INPUT_TEMPLATE,
+    size: "1024x1024" as DalleSize,
+    quality: "standard" as DalleQuality,
+    style: "vivid" as DalleStyle,
   },
 };
 

+ 3 - 1
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 } from "../constant";
+import { StoreKey, Plugin } from "../constant";
 import { nanoid } from "nanoid";
 import { createPersistStore } from "../utils/store";
 
@@ -17,6 +17,7 @@ export type Mask = {
   modelConfig: ModelConfig;
   lang: Lang;
   builtin: boolean;
+  plugin?: Plugin[];
 };
 
 export const DEFAULT_MASK_STATE = {
@@ -37,6 +38,7 @@ export const createEmptyMask = () =>
     lang: getLang(),
     builtin: false,
     createdAt: Date.now(),
+    plugin: [Plugin.Artifacts],
   }) as Mask;
 
 export const useMaskStore = createPersistStore(

+ 163 - 0
app/store/sd.ts

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

+ 4 - 0
app/typing.ts

@@ -7,3 +7,7 @@ export interface RequestMessage {
   role: MessageRole;
   content: string;
 }
+
+export type DalleSize = "1024x1024" | "1792x1024" | "1024x1792";
+export type DalleQuality = "standard" | "hd";
+export type DalleStyle = "vivid" | "natural";

+ 5 - 0
app/utils.ts

@@ -194,6 +194,7 @@ export function autoGrowTextArea(dom: HTMLTextAreaElement) {
   measureDom.style.width = width + "px";
   measureDom.innerText = dom.value !== "" ? dom.value : "1";
   measureDom.style.fontSize = dom.style.fontSize;
+  measureDom.style.fontFamily = dom.style.fontFamily;
   const endWithEmptyLine = dom.value.endsWith("\n");
   const height = parseFloat(window.getComputedStyle(measureDom).height);
   const singleLineHeight = parseFloat(
@@ -265,3 +266,7 @@ export function isVisionModel(model: string) {
     visionKeywords.some((keyword) => model.includes(keyword)) || isGpt4Turbo
   );
 }
+
+export function isDalle3(model: string) {
+  return "dall-e-3" === model;
+}

+ 1 - 1
app/utils/chat.ts

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

+ 11 - 3
app/utils/cloud/webdav.ts

@@ -14,8 +14,8 @@ export function createWebDavClient(store: SyncStore) {
   return {
     async check() {
       try {
-        const res = await fetch(this.path(folder, proxyUrl), {
-          method: "MKCOL",
+        const res = await fetch(this.path(folder, proxyUrl, "MKCOL"), {
+          method: "GET",
           headers: this.headers(),
         });
         const success = [201, 200, 404, 405, 301, 302, 307, 308].includes(
@@ -42,6 +42,10 @@ export function createWebDavClient(store: SyncStore) {
 
       console.log("[WebDav] get key = ", key, res.status, res.statusText);
 
+      if (404 == res.status) {
+        return "";
+      }
+
       return await res.text();
     },
 
@@ -62,7 +66,7 @@ export function createWebDavClient(store: SyncStore) {
         authorization: `Basic ${auth}`,
       };
     },
-    path(path: string, proxyUrl: string = "") {
+    path(path: string, proxyUrl: string = "", proxyMethod: string = "") {
       if (path.startsWith("/")) {
         path = path.slice(1);
       }
@@ -78,9 +82,13 @@ export function createWebDavClient(store: SyncStore) {
         let u = new URL(proxyUrl + pathPrefix + path);
         // add query params
         u.searchParams.append("endpoint", config.endpoint);
+        proxyMethod && u.searchParams.append("proxy_method", proxyMethod);
         url = u.toString();
       } catch (e) {
         url = pathPrefix + path + "?endpoint=" + config.endpoint;
+        if (proxyMethod) {
+          url += "&proxy_method=" + proxyMethod;
+        }
       }
 
       return url;

+ 246 - 0
app/utils/hmac.ts

@@ -0,0 +1,246 @@
+// From https://gist.github.com/guillermodlpa/f6d955f838e9b10d1ef95b8e259b2c58
+// From https://gist.github.com/stevendesu/2d52f7b5e1f1184af3b667c0b5e054b8
+
+// To ensure cross-browser support even without a proper SubtleCrypto
+// impelmentation (or without access to the impelmentation, as is the case with
+// Chrome loaded over HTTP instead of HTTPS), this library can create SHA-256
+// HMAC signatures using nothing but raw JavaScript
+
+/* eslint-disable no-magic-numbers, id-length, no-param-reassign, new-cap */
+
+// By giving internal functions names that we can mangle, future calls to
+// them are reduced to a single byte (minor space savings in minified file)
+const uint8Array = Uint8Array;
+const uint32Array = Uint32Array;
+const pow = Math.pow;
+
+// Will be initialized below
+// Using a Uint32Array instead of a simple array makes the minified code
+// a bit bigger (we lose our `unshift()` hack), but comes with huge
+// performance gains
+const DEFAULT_STATE = new uint32Array(8);
+const ROUND_CONSTANTS: number[] = [];
+
+// Reusable object for expanded message
+// Using a Uint32Array instead of a simple array makes the minified code
+// 7 bytes larger, but comes with huge performance gains
+const M = new uint32Array(64);
+
+// After minification the code to compute the default state and round
+// constants is smaller than the output. More importantly, this serves as a
+// good educational aide for anyone wondering where the magic numbers come
+// from. No magic numbers FTW!
+function getFractionalBits(n: number) {
+  return ((n - (n | 0)) * pow(2, 32)) | 0;
+}
+
+let n = 2;
+let nPrime = 0;
+while (nPrime < 64) {
+  // isPrime() was in-lined from its original function form to save
+  // a few bytes
+  let isPrime = true;
+  // Math.sqrt() was replaced with pow(n, 1/2) to save a few bytes
+  // var sqrtN = pow(n, 1 / 2);
+  // So technically to determine if a number is prime you only need to
+  // check numbers up to the square root. However this function only runs
+  // once and we're only computing the first 64 primes (up to 311), so on
+  // any modern CPU this whole function runs in a couple milliseconds.
+  // By going to n / 2 instead of sqrt(n) we net 8 byte savings and no
+  // scaling performance cost
+  for (let factor = 2; factor <= n / 2; factor++) {
+    if (n % factor === 0) {
+      isPrime = false;
+    }
+  }
+  if (isPrime) {
+    if (nPrime < 8) {
+      DEFAULT_STATE[nPrime] = getFractionalBits(pow(n, 1 / 2));
+    }
+    ROUND_CONSTANTS[nPrime] = getFractionalBits(pow(n, 1 / 3));
+
+    nPrime++;
+  }
+
+  n++;
+}
+
+// For cross-platform support we need to ensure that all 32-bit words are
+// in the same endianness. A UTF-8 TextEncoder will return BigEndian data,
+// so upon reading or writing to our ArrayBuffer we'll only swap the bytes
+// if our system is LittleEndian (which is about 99% of CPUs)
+const LittleEndian = !!new uint8Array(new uint32Array([1]).buffer)[0];
+
+function convertEndian(word: number) {
+  if (LittleEndian) {
+    return (
+      // byte 1 -> byte 4
+      (word >>> 24) |
+      // byte 2 -> byte 3
+      (((word >>> 16) & 0xff) << 8) |
+      // byte 3 -> byte 2
+      ((word & 0xff00) << 8) |
+      // byte 4 -> byte 1
+      (word << 24)
+    );
+  } else {
+    return word;
+  }
+}
+
+function rightRotate(word: number, bits: number) {
+  return (word >>> bits) | (word << (32 - bits));
+}
+
+function sha256(data: Uint8Array) {
+  // Copy default state
+  const STATE = DEFAULT_STATE.slice();
+
+  // Caching this reduces occurrences of ".length" in minified JavaScript
+  // 3 more byte savings! :D
+  const legth = data.length;
+
+  // Pad data
+  const bitLength = legth * 8;
+  const newBitLength = 512 - ((bitLength + 64) % 512) - 1 + bitLength + 65;
+
+  // "bytes" and "words" are stored BigEndian
+  const bytes = new uint8Array(newBitLength / 8);
+  const words = new uint32Array(bytes.buffer);
+
+  bytes.set(data, 0);
+  // Append a 1
+  bytes[legth] = 0b10000000;
+  // Store length in BigEndian
+  words[words.length - 1] = convertEndian(bitLength);
+
+  // Loop iterator (avoid two instances of "var") -- saves 2 bytes
+  let round;
+
+  // Process blocks (512 bits / 64 bytes / 16 words at a time)
+  for (let block = 0; block < newBitLength / 32; block += 16) {
+    const workingState = STATE.slice();
+
+    // Rounds
+    for (round = 0; round < 64; round++) {
+      let MRound;
+      // Expand message
+      if (round < 16) {
+        // Convert to platform Endianness for later math
+        MRound = convertEndian(words[block + round]);
+      } else {
+        const gamma0x = M[round - 15];
+        const gamma1x = M[round - 2];
+        MRound =
+          M[round - 7] +
+          M[round - 16] +
+          (rightRotate(gamma0x, 7) ^
+            rightRotate(gamma0x, 18) ^
+            (gamma0x >>> 3)) +
+          (rightRotate(gamma1x, 17) ^
+            rightRotate(gamma1x, 19) ^
+            (gamma1x >>> 10));
+      }
+
+      // M array matches platform endianness
+      M[round] = MRound |= 0;
+
+      // Computation
+      const t1 =
+        (rightRotate(workingState[4], 6) ^
+          rightRotate(workingState[4], 11) ^
+          rightRotate(workingState[4], 25)) +
+        ((workingState[4] & workingState[5]) ^
+          (~workingState[4] & workingState[6])) +
+        workingState[7] +
+        MRound +
+        ROUND_CONSTANTS[round];
+      const t2 =
+        (rightRotate(workingState[0], 2) ^
+          rightRotate(workingState[0], 13) ^
+          rightRotate(workingState[0], 22)) +
+        ((workingState[0] & workingState[1]) ^
+          (workingState[2] & (workingState[0] ^ workingState[1])));
+      for (let i = 7; i > 0; i--) {
+        workingState[i] = workingState[i - 1];
+      }
+      workingState[0] = (t1 + t2) | 0;
+      workingState[4] = (workingState[4] + t1) | 0;
+    }
+
+    // Update state
+    for (round = 0; round < 8; round++) {
+      STATE[round] = (STATE[round] + workingState[round]) | 0;
+    }
+  }
+
+  // Finally the state needs to be converted to BigEndian for output
+  // And we want to return a Uint8Array, not a Uint32Array
+  return new uint8Array(
+    new uint32Array(
+      STATE.map(function (val) {
+        return convertEndian(val);
+      }),
+    ).buffer,
+  );
+}
+
+function hmac(key: Uint8Array, data: ArrayLike<number>) {
+  if (key.length > 64) key = sha256(key);
+
+  if (key.length < 64) {
+    const tmp = new Uint8Array(64);
+    tmp.set(key, 0);
+    key = tmp;
+  }
+
+  // Generate inner and outer keys
+  const innerKey = new Uint8Array(64);
+  const outerKey = new Uint8Array(64);
+  for (let i = 0; i < 64; i++) {
+    innerKey[i] = 0x36 ^ key[i];
+    outerKey[i] = 0x5c ^ key[i];
+  }
+
+  // Append the innerKey
+  const msg = new Uint8Array(data.length + 64);
+  msg.set(innerKey, 0);
+  msg.set(data, 64);
+
+  // Has the previous message and append the outerKey
+  const result = new Uint8Array(64 + 32);
+  result.set(outerKey, 0);
+  result.set(sha256(msg), 64);
+
+  // Hash the previous message
+  return sha256(result);
+}
+
+// Convert a string to a Uint8Array, SHA-256 it, and convert back to string
+const encoder = new TextEncoder();
+
+export function sign(
+  inputKey: string | Uint8Array,
+  inputData: string | Uint8Array,
+) {
+  const key =
+    typeof inputKey === "string" ? encoder.encode(inputKey) : inputKey;
+  const data =
+    typeof inputData === "string" ? encoder.encode(inputData) : inputData;
+  return hmac(key, data);
+}
+
+export function hex(bin: Uint8Array) {
+  return bin.reduce((acc, val) => {
+    const hexVal = "00" + val.toString(16);
+    return acc + hexVal.substring(hexVal.length - 2);
+  }, "");
+}
+
+export function hash(str: string) {
+  return hex(sha256(encoder.encode(str)));
+}
+
+export function hashWithSecret(str: string, secret: string) {
+  return hex(sign(secret, str)).toString();
+}

+ 54 - 8
app/utils/model.ts

@@ -1,12 +1,42 @@
 import { DEFAULT_MODELS } from "../constant";
 import { LLMModel } from "../client/api";
 
+const CustomSeq = {
+  val: -1000, //To ensure the custom model located at front, start from -1000, refer to constant.ts
+  cache: new Map<string, number>(),
+  next: (id: string) => {
+    if (CustomSeq.cache.has(id)) {
+      return CustomSeq.cache.get(id) as number;
+    } else {
+      let seq = CustomSeq.val++;
+      CustomSeq.cache.set(id, seq);
+      return seq;
+    }
+  },
+};
+
 const customProvider = (providerName: string) => ({
   id: providerName.toLowerCase(),
   providerName: providerName,
   providerType: "custom",
+  sorted: CustomSeq.next(providerName),
 });
 
+/**
+ * Sorts an array of models based on specified rules.
+ *
+ * First, sorted by provider; if the same, sorted by model
+ */
+const sortModelTable = (models: ReturnType<typeof collectModels>) =>
+  models.sort((a, b) => {
+    if (a.provider && b.provider) {
+      let cmp = a.provider.sorted - b.provider.sorted;
+      return cmp === 0 ? a.sorted - b.sorted : cmp;
+    } else {
+      return a.sorted - b.sorted;
+    }
+  });
+
 export function collectModelTable(
   models: readonly LLMModel[],
   customModels: string,
@@ -17,6 +47,7 @@ export function collectModelTable(
       available: boolean;
       name: string;
       displayName: string;
+      sorted: number;
       provider?: LLMModel["provider"]; // Marked as optional
       isDefault?: boolean;
     }
@@ -84,6 +115,7 @@ export function collectModelTable(
             displayName: displayName || customModelName,
             available,
             provider, // Use optional chaining
+            sorted: CustomSeq.next(`${customModelName}@${provider?.id}`),
           };
         }
       }
@@ -99,12 +131,21 @@ export function collectModelTableWithDefaultModel(
 ) {
   let modelTable = collectModelTable(models, customModels);
   if (defaultModel && defaultModel !== "") {
-    modelTable[defaultModel] = {
-      ...modelTable[defaultModel],
-      name: defaultModel,
-      available: true,
-      isDefault: true,
-    };
+    if (defaultModel.includes("@")) {
+      if (defaultModel in modelTable) {
+        modelTable[defaultModel].isDefault = true;
+      }
+    } else {
+      for (const key of Object.keys(modelTable)) {
+        if (
+          modelTable[key].available &&
+          key.split("@").shift() == defaultModel
+        ) {
+          modelTable[key].isDefault = true;
+          break;
+        }
+      }
+    }
   }
   return modelTable;
 }
@@ -117,7 +158,9 @@ export function collectModels(
   customModels: string,
 ) {
   const modelTable = collectModelTable(models, customModels);
-  const allModels = Object.values(modelTable);
+  let allModels = Object.values(modelTable);
+
+  allModels = sortModelTable(allModels);
 
   return allModels;
 }
@@ -132,7 +175,10 @@ export function collectModelsWithDefaultModel(
     customModels,
     defaultModel,
   );
-  const allModels = Object.values(modelTable);
+  let allModels = Object.values(modelTable);
+
+  allModels = sortModelTable(allModels);
+
   return allModels;
 }
 

+ 102 - 0
app/utils/tencent.ts

@@ -0,0 +1,102 @@
+import { sign, hash as getHash, hex } from "./hmac";
+
+// 使用 SHA-256 和 secret 进行 HMAC 加密
+function sha256(message: any, secret: any, encoding?: string) {
+  const result = sign(secret, message);
+  return encoding == "hex" ? hex(result).toString() : result;
+}
+
+function getDate(timestamp: number) {
+  const date = new Date(timestamp * 1000);
+  const year = date.getUTCFullYear();
+  const month = ("0" + (date.getUTCMonth() + 1)).slice(-2);
+  const day = ("0" + date.getUTCDate()).slice(-2);
+  return `${year}-${month}-${day}`;
+}
+
+export async function getHeader(
+  payload: any,
+  SECRET_ID: string,
+  SECRET_KEY: string,
+) {
+  // https://cloud.tencent.com/document/api/1729/105701
+
+  const endpoint = "hunyuan.tencentcloudapi.com";
+  const service = "hunyuan";
+  const region = ""; // optional
+  const action = "ChatCompletions";
+  const version = "2023-09-01";
+  const timestamp = Math.floor(Date.now() / 1000);
+  //时间处理, 获取世界时间日期
+  const date = getDate(timestamp);
+
+  // ************* 步骤 1:拼接规范请求串 *************
+
+  const hashedRequestPayload = getHash(payload);
+  const httpRequestMethod = "POST";
+  const contentType = "application/json";
+  const canonicalUri = "/";
+  const canonicalQueryString = "";
+  const canonicalHeaders =
+    `content-type:${contentType}\n` +
+    "host:" +
+    endpoint +
+    "\n" +
+    "x-tc-action:" +
+    action.toLowerCase() +
+    "\n";
+  const signedHeaders = "content-type;host;x-tc-action";
+
+  const canonicalRequest = [
+    httpRequestMethod,
+    canonicalUri,
+    canonicalQueryString,
+    canonicalHeaders,
+    signedHeaders,
+    hashedRequestPayload,
+  ].join("\n");
+
+  // ************* 步骤 2:拼接待签名字符串 *************
+  const algorithm = "TC3-HMAC-SHA256";
+  const hashedCanonicalRequest = getHash(canonicalRequest);
+  const credentialScope = date + "/" + service + "/" + "tc3_request";
+  const stringToSign =
+    algorithm +
+    "\n" +
+    timestamp +
+    "\n" +
+    credentialScope +
+    "\n" +
+    hashedCanonicalRequest;
+
+  // ************* 步骤 3:计算签名 *************
+  const kDate = sha256(date, "TC3" + SECRET_KEY);
+  const kService = sha256(service, kDate);
+  const kSigning = sha256("tc3_request", kService);
+  const signature = sha256(stringToSign, kSigning, "hex");
+
+  // ************* 步骤 4:拼接 Authorization *************
+  const authorization =
+    algorithm +
+    " " +
+    "Credential=" +
+    SECRET_ID +
+    "/" +
+    credentialScope +
+    ", " +
+    "SignedHeaders=" +
+    signedHeaders +
+    ", " +
+    "Signature=" +
+    signature;
+
+  return {
+    Authorization: authorization,
+    "Content-Type": contentType,
+    Host: endpoint,
+    "X-TC-Action": action,
+    "X-TC-Timestamp": timestamp.toString(),
+    "X-TC-Version": version,
+    "X-TC-Region": region,
+  };
+}

+ 7 - 4
package.json

@@ -4,14 +4,14 @@
   "license": "mit",
   "scripts": {
     "mask": "npx tsx app/masks/build.ts",
-    "mask:watch": "npx watch 'yarn mask' app/masks",
-    "dev": "yarn run mask:watch & next dev",
+    "mask:watch": "npx watch \"yarn mask\" app/masks",
+    "dev": "concurrently -r \"yarn run mask:watch\" \"next dev\"",
     "build": "yarn mask && cross-env BUILD_MODE=standalone next build",
     "start": "next start",
     "lint": "next lint",
     "export": "yarn mask && cross-env BUILD_MODE=export BUILD_APP=1 next build",
-    "export:dev": "yarn mask:watch & cross-env BUILD_MODE=export BUILD_APP=1 next dev",
-    "app:dev": "yarn mask:watch & yarn tauri dev",
+    "export:dev": "concurrently -r \"yarn mask:watch\"  \"cross-env BUILD_MODE=export BUILD_APP=1 next dev\"",
+    "app:dev": "concurrently -r \"yarn mask:watch\" \"yarn tauri dev\"",
     "app:build": "yarn mask && yarn tauri build",
     "prompts": "node ./scripts/fetch-prompts.mjs",
     "prepare": "husky install",
@@ -28,6 +28,7 @@
     "fuse.js": "^7.0.0",
     "heic2any": "^0.0.4",
     "html-to-image": "^1.11.11",
+    "lodash-es": "^4.17.21",
     "mermaid": "^10.6.1",
     "nanoid": "^5.0.3",
     "next": "^14.1.1",
@@ -48,11 +49,13 @@
   },
   "devDependencies": {
     "@tauri-apps/cli": "1.5.11",
+    "@types/lodash-es": "^4.17.12",
     "@types/node": "^20.11.30",
     "@types/react": "^18.2.70",
     "@types/react-dom": "^18.2.7",
     "@types/react-katex": "^3.0.0",
     "@types/spark-md5": "^3.0.4",
+    "concurrently": "^8.2.2",
     "cross-env": "^7.0.3",
     "eslint": "^8.49.0",
     "eslint-config-next": "13.4.19",

+ 6 - 3
public/serviceWorker.js

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

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

@@ -9,7 +9,7 @@
   },
   "package": {
     "productName": "NextChat",
-    "version": "2.13.1"
+    "version": "2.14.1"
   },
   "tauri": {
     "allowlist": {

+ 108 - 3
yarn.lock

@@ -1035,6 +1035,13 @@
   dependencies:
     regenerator-runtime "^0.14.0"
 
+"@babel/runtime@^7.21.0":
+  version "7.25.0"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.0.tgz#3af9a91c1b739c569d5d80cc917280919c544ecb"
+  integrity sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==
+  dependencies:
+    regenerator-runtime "^0.14.0"
+
 "@babel/template@^7.18.10", "@babel/template@^7.20.7":
   version "7.20.7"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8"
@@ -1697,6 +1704,18 @@
   resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.14.0.tgz#b84c0afc3218069a5ad64fe2a95321881021b5fe"
   integrity sha512-+2FW2CcT0K3P+JMR8YG846bmDwplKUTsWgT2ENwdQ1UdVfRk3GQrh6Mi4sTopy30gI8Uau5CEqHTDZ6YvWIUPA==
 
+"@types/lodash-es@^4.17.12":
+  version "4.17.12"
+  resolved "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b"
+  integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==
+  dependencies:
+    "@types/lodash" "*"
+
+"@types/lodash@*":
+  version "4.17.7"
+  resolved "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.7.tgz#2f776bcb53adc9e13b2c0dfd493dfcbd7de43612"
+  integrity sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==
+
 "@types/mdast@^3.0.0":
   version "3.0.11"
   resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.11.tgz#dc130f7e7d9306124286f6d6cee40cf4d14a3dc0"
@@ -2269,7 +2288,7 @@ chalk@^2.0.0, chalk@^2.4.2:
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
 
-chalk@^4.0.0:
+chalk@^4.0.0, chalk@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
   integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@@ -2335,6 +2354,15 @@ client-only@0.0.1:
   resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
   integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
 
+cliui@^8.0.1:
+  version "8.0.1"
+  resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa"
+  integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==
+  dependencies:
+    string-width "^4.2.0"
+    strip-ansi "^6.0.1"
+    wrap-ansi "^7.0.0"
+
 color-convert@^1.9.0:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@@ -2394,6 +2422,21 @@ concat-map@0.0.1:
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
   integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
 
+concurrently@^8.2.2:
+  version "8.2.2"
+  resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-8.2.2.tgz#353141985c198cfa5e4a3ef90082c336b5851784"
+  integrity sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==
+  dependencies:
+    chalk "^4.1.2"
+    date-fns "^2.30.0"
+    lodash "^4.17.21"
+    rxjs "^7.8.1"
+    shell-quote "^1.8.1"
+    spawn-command "0.0.2"
+    supports-color "^8.1.1"
+    tree-kill "^1.2.2"
+    yargs "^17.7.2"
+
 convert-source-map@^1.7.0:
   version "1.9.0"
   resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
@@ -2801,6 +2844,13 @@ data-uri-to-buffer@^4.0.0:
   resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e"
   integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==
 
+date-fns@^2.30.0:
+  version "2.30.0"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0"
+  integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==
+  dependencies:
+    "@babel/runtime" "^7.21.0"
+
 dayjs@^1.11.7:
   version "1.11.7"
   resolved "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.7.tgz#4b296922642f70999544d1144a2c25730fce63e2"
@@ -3562,6 +3612,11 @@ gensync@^1.0.0-beta.2:
   resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
   integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
 
+get-caller-file@^2.0.5:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
+  integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
+
 get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.0.tgz#7ad1dc0535f3a2904bba075772763e5051f6d05f"
@@ -5480,6 +5535,11 @@ remark-rehype@^10.0.0:
     mdast-util-to-hast "^12.1.0"
     unified "^10.0.0"
 
+require-directory@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
+  integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
+
 resolve-from@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
@@ -5557,6 +5617,13 @@ rxjs@^7.8.0:
   dependencies:
     tslib "^2.1.0"
 
+rxjs@^7.8.1:
+  version "7.8.1"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543"
+  integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==
+  dependencies:
+    tslib "^2.1.0"
+
 sade@^1.7.3:
   version "1.8.1"
   resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701"
@@ -5639,6 +5706,11 @@ shebang-regex@^3.0.0:
   resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
   integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
 
+shell-quote@^1.8.1:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680"
+  integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==
+
 side-channel@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
@@ -5717,6 +5789,11 @@ spark-md5@^3.0.2:
   resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc"
   integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==
 
+spawn-command@0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2.tgz#9544e1a43ca045f8531aac1a48cb29bdae62338e"
+  integrity sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==
+
 stable@^0.1.8:
   version "0.1.8"
   resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
@@ -5739,7 +5816,7 @@ string-argv@^0.3.1:
   resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"
   integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==
 
-string-width@^4.1.0, string-width@^4.2.0:
+string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -5860,7 +5937,7 @@ supports-color@^7.1.0:
   dependencies:
     has-flag "^4.0.0"
 
-supports-color@^8.0.0:
+supports-color@^8.0.0, supports-color@^8.1.1:
   version "8.1.1"
   resolved "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c"
   integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==
@@ -5956,6 +6033,11 @@ to-regex-range@^5.0.1:
   dependencies:
     is-number "^7.0.0"
 
+tree-kill@^1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
+  integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
+
 trim-lines@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338"
@@ -6355,6 +6437,11 @@ wrappy@1:
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
 
+y18n@^5.0.5:
+  version "5.0.8"
+  resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
+  integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
+
 yallist@^3.0.2:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
@@ -6375,6 +6462,24 @@ yaml@^2.2.2:
   resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b"
   integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==
 
+yargs-parser@^21.1.1:
+  version "21.1.1"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
+  integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
+
+yargs@^17.7.2:
+  version "17.7.2"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269"
+  integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==
+  dependencies:
+    cliui "^8.0.1"
+    escalade "^3.1.1"
+    get-caller-file "^2.0.5"
+    require-directory "^2.1.1"
+    string-width "^4.2.3"
+    y18n "^5.0.5"
+    yargs-parser "^21.1.1"
+
 yocto-queue@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"