ソースを参照

分支代码提交

Ryuiso 3 ヶ月 前
コミット
90d8b50730
100 ファイル変更19952 行追加0 行削除
  1. 14 0
      .babelrc
  2. 124 0
      README.md
  3. 54 0
      app/api/[provider]/[...path]/route.ts
  4. 131 0
      app/api/alibaba.ts
  5. 47 0
      app/api/api.ts
  6. 73 0
      app/api/artifacts/route.ts
  7. 105 0
      app/api/auth.ts
  8. 33 0
      app/api/azure.ts
  9. 145 0
      app/api/baidu.ts
  10. 129 0
      app/api/bytedance.ts
  11. 194 0
      app/api/common.ts
  12. 30 0
      app/api/config/route.ts
  13. 131 0
      app/api/iflytek.ts
  14. 72 0
      app/api/openai.ts
  15. 124 0
      app/api/tencent/route.ts
  16. 73 0
      app/api/upstash/[action]/[...key]/route.ts
  17. 167 0
      app/api/webdav/[...path]/route.ts
  18. 289 0
      app/client/api.ts
  19. 37 0
      app/client/controller.ts
  20. 268 0
      app/client/platforms/alibaba.ts
  21. 281 0
      app/client/platforms/baidu.ts
  22. 261 0
      app/client/platforms/bigModel.ts
  23. 255 0
      app/client/platforms/bytedance.ts
  24. 257 0
      app/client/platforms/deepSeek.ts
  25. 240 0
      app/client/platforms/iflytek.ts
  26. 482 0
      app/client/platforms/openai.ts
  27. 268 0
      app/client/platforms/tencent.ts
  28. 78 0
      app/command.ts
  29. 137 0
      app/components/DeekSeekHome.tsx
  30. 1954 0
      app/components/DeepSeekChat.tsx
  31. 1533 0
      app/components/DeepSeekHomeChat.tsx
  32. 120 0
      app/components/Record.tsx
  33. 31 0
      app/components/artifacts.module.scss
  34. 234 0
      app/components/artifacts.tsx
  35. 36 0
      app/components/auth.module.scss
  36. 87 0
      app/components/auth.tsx
  37. 83 0
      app/components/button.module.scss
  38. 62 0
      app/components/button.tsx
  39. 173 0
      app/components/chat-list.tsx
  40. 862 0
      app/components/chat.module.scss
  41. 2344 0
      app/components/chat.tsx
  42. 72 0
      app/components/deepSeekHome.scss
  43. 59 0
      app/components/error.tsx
  44. 271 0
      app/components/exporter.module.scss
  45. 721 0
      app/components/exporter.tsx
  46. 348 0
      app/components/home.module.scss
  47. 456 0
      app/components/home.tsx
  48. 13 0
      app/components/input-range.module.scss
  49. 40 0
      app/components/input-range.tsx
  50. 287 0
      app/components/markdown.tsx
  51. 125 0
      app/components/mask-chat.module.scss
  52. 195 0
      app/components/mask-chat.tsx
  53. 108 0
      app/components/mask.module.scss
  54. 705 0
      app/components/mask.tsx
  55. 82 0
      app/components/message-selector.module.scss
  56. 261 0
      app/components/message-selector.tsx
  57. 231 0
      app/components/model-config.tsx
  58. 74 0
      app/components/settings.module.scss
  59. 1399 0
      app/components/settings.tsx
  60. 764 0
      app/components/sidebar.tsx
  61. 332 0
      app/components/ui-lib.module.scss
  62. 574 0
      app/components/ui-lib.tsx
  63. 46 0
      app/config/build.ts
  64. 27 0
      app/config/client.ts
  65. 230 0
      app/config/server.ts
  66. 399 0
      app/constant.ts
  67. 17 0
      app/global.d.ts
  68. 11 0
      app/icons/add.svg
  69. 1 0
      app/icons/add_bk.svg
  70. BIN
      app/icons/aiIcon.png
  71. 1 0
      app/icons/auto.svg
  72. BIN
      app/icons/avatar.png
  73. 19 0
      app/icons/black-bot.svg
  74. 19 0
      app/icons/black-bot_bk.svg
  75. BIN
      app/icons/bot.png
  76. 11 0
      app/icons/bot.svg
  77. 19 0
      app/icons/bot_bk.svg
  78. 1 0
      app/icons/bottom.svg
  79. 1 0
      app/icons/brain.svg
  80. 0 0
      app/icons/break.svg
  81. 0 0
      app/icons/cancel.svg
  82. 0 0
      app/icons/chat-settings.svg
  83. 1 0
      app/icons/chat.svg
  84. BIN
      app/icons/chatgpt.png
  85. 0 0
      app/icons/chatgpt.svg
  86. 1 0
      app/icons/clear.svg
  87. 1 0
      app/icons/close.svg
  88. 0 0
      app/icons/cloud-fail.svg
  89. 0 0
      app/icons/cloud-success.svg
  90. 0 0
      app/icons/config.svg
  91. 0 0
      app/icons/confirm.svg
  92. 0 0
      app/icons/connection.svg
  93. 1 0
      app/icons/copy.svg
  94. 1 0
      app/icons/dark.svg
  95. BIN
      app/icons/deepSeek.png
  96. 0 0
      app/icons/delete.svg
  97. 7 0
      app/icons/discovery.svg
  98. 1 0
      app/icons/down.svg
  99. 1 0
      app/icons/download.svg
  100. 1 0
      app/icons/drag.svg

+ 14 - 0
.babelrc

@@ -0,0 +1,14 @@
+{
+  "presets": [
+    [
+      "next/babel",
+      {
+        "preset-env": {
+          "targets": {
+            "browsers": ["> 0.25%, not dead"]
+          }
+        }
+      }
+    ]
+  ]
+}

+ 124 - 0
README.md

@@ -0,0 +1,124 @@
+
+# 建科·小智客户端项目概述
+
+## 业务目标
+1. 提供统一的多AI平台接入能力
+2. 实现企业级智能问答解决方案
+3. 支持文档辅助分析和知识检索
+4. 优化招聘流程中的信息交互
+
+## 核心功能
+### 多AI平台集成
+- 支持BigModel和DeepSeek双引擎
+- 可扩展的AI服务提供商接入架构
+- 统一API代理层实现
+
+### 智能对话系统
+- 多轮对话上下文管理
+- 自动会话摘要生成
+- 智能问题推荐
+- 流式消息处理
+
+### 企业级特性
+- 招聘信息智能问答
+- 文档辅助分析
+- 多语言支持(20+语言)
+- 响应式设计适配多端
+
+## 技术架构
+### 前端架构
+```
+┌───────────────────────────────────────────────┐
+│                    UI层                      │
+│  ┌─────────────┐ ┌─────────────┐ ┌────────┐ │
+│  │  聊天组件   │ │  设置面板   │ │ 导出  │ │
+│  └─────────────┘ └─────────────┘ └────────┘ │
+├───────────────────────────────────────────────┤
+│                  状态管理层                   │
+│  ┌─────────────────────────────────────────┐ │
+│  │                Zustand Store            │ │
+│  │  ┌────────┐ ┌────────┐ ┌──────────────┐ │ │
+│  │  │ 聊天状态│ │ 配置状态│ │ 全局状态    │ │ │
+│  │  └────────┘ └────────┘ └──────────────┘ │ │
+│  └─────────────────────────────────────────┘ │
+├───────────────────────────────────────────────┤
+│                  服务层                      │
+│  ┌─────────────────────────────────────────┐ │
+│  │               API代理层                  │ │
+│  │  ┌───────┐ ┌───────┐ ┌────────────────┐ │ │
+│  │  │阿里云 │ │OpenAI │ │ 自定义大模型    │ │ │
+│  │  └───────┘ └───────┘ └────────────────┘ │ │
+│  └─────────────────────────────────────────┘ │
+└───────────────────────────────────────────────┘
+```
+
+### 技术栈
+- **前端框架**: Next.js 14 + React 18
+- **状态管理**: Zustand
+- **UI组件库**: Ant Design
+- **构建工具**: Webpack 5
+- **桌面端**: Tauri集成
+- **样式**: SCSS模块化
+
+## 数据实体
+### 核心数据模型
+```mermaid
+classDiagram
+    class ChatSession {
+        +String id
+        +String topic
+        +ChatMessage[] messages
+        +ChatStat stat
+        +Mask mask
+        +Date lastUpdate
+    }
+
+    class ChatMessage {
+        +String id
+        +String role
+        +String content
+        +Date date
+        +Boolean streaming
+        +String model
+    }
+
+    class Mask {
+        +String name
+        +String avatar
+        +ModelConfig modelConfig
+        +String[] context
+    }
+
+    ChatSession "1" *-- "many" ChatMessage
+    ChatSession "1" -- "1" Mask
+```
+
+## 业务流程
+### 典型用户交互流程
+```mermaid
+sequenceDiagram
+    participant 用户
+    participant UI组件
+    participant 状态管理
+    participant API服务
+
+    用户->>UI组件: 输入问题
+    UI组件->>状态管理: 更新输入状态
+    用户->>UI组件: 提交问题
+    UI组件->>API服务: 发送请求
+    API服务->>状态管理: 流式返回响应
+    状态管理->>UI组件: 实时更新消息
+    UI组件->>用户: 显示AI回复
+    状态管理->>状态管理: 自动生成会话摘要
+```
+
+## 部署架构
+- 支持三种构建模式:
+  1. **Standalone**: 独立部署模式
+  2. **Export**: 静态导出模式
+  3. **默认模式**: 完整服务端渲染
+
+## 扩展能力
+1. **插件系统**: 支持功能模块动态扩展
+2. **配置中心**: 运行时配置热更新
+3. **多模型路由**: 根据请求自动路由到最优AI服务

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

@@ -0,0 +1,54 @@
+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 baiduHandler } from "../../baidu";
+import { handle as bytedanceHandler } from "../../bytedance";
+import { handle as alibabaHandler } from "../../alibaba";
+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.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.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",
+];

+ 131 - 0
app/api/alibaba.ts

@@ -0,0 +1,131 @@
+import { getServerSideConfig } from "@/app/config/server";
+import {
+  Alibaba,
+  ALIBABA_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("[Alibaba Route] params ", params);
+
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+
+  const authResult = auth(req, ModelProvider.Qwen);
+  if (authResult.error) {
+    return NextResponse.json(authResult, {
+      status: 401,
+    });
+  }
+
+  try {
+    const response = await request(req);
+    return response;
+  } catch (e) {
+    console.error("[Alibaba] ", 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.Alibaba, "");
+
+  let baseUrl = serverConfig.alibabaUrl || ALIBABA_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") ?? "",
+      "X-DashScope-SSE": req.headers.get("X-DashScope-SSE") ?? "disable",
+    },
+    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.Alibaba as string,
+        )
+      ) {
+        return NextResponse.json(
+          {
+            error: true,
+            message: `you are not allowed to use ${jsonBody?.model} model`,
+          },
+          {
+            status: 403,
+          },
+        );
+      }
+    } catch (e) {
+      console.error(`[Alibaba] 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);
+  }
+}

+ 47 - 0
app/api/api.ts

@@ -0,0 +1,47 @@
+import axios, { AxiosResponse } from 'axios';
+
+// 创建axios实例
+const axiosInstance = axios.create({
+    baseURL: '/bigmodel-api',
+    timeout: 300000,// 请求超时5分钟
+});
+
+// 请求拦截器
+axiosInstance.interceptors.request.use(
+    (config) => {
+        const userInfoStr = localStorage.getItem('userInfo');
+        if (userInfoStr) {
+            const userInfo = JSON.parse(userInfoStr);
+            if (userInfo.token) {
+                config.headers['Authorization'] = userInfo.token;
+            }
+        }
+        return config;
+    }
+);
+
+// 响应拦截器
+axiosInstance.interceptors.response.use(
+    (response: AxiosResponse) => {// 成功信息
+        const { config, data } = response;
+        if (config.responseType === 'blob') {
+            return Promise.resolve(data);
+        } else {
+            if (data.code === 200) {// 成功
+                return Promise.resolve(data);
+            } else {// 失败
+                if (data.code === 401) {
+                    localStorage.removeItem('userInfo');
+                    const originUrl = window.location.origin;
+                    window.open(originUrl, '_self');
+                }
+                return Promise.reject(data);
+            }
+        }
+    },
+    (error) => {// 错误信息
+        return Promise.reject();
+    }
+);
+
+export default axiosInstance;

+ 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";

+ 105 - 0
app/api/auth.ts

@@ -0,0 +1,105 @@
+import { NextRequest } from "next/server";
+import { getServerSideConfig } from "../config/server";
+import md5 from "spark-md5";
+import { ACCESS_CODE_PREFIX, ModelProvider } from "../constant";
+
+function getIP(req: NextRequest) {
+  let ip = req.ip ?? req.headers.get("x-real-ip");
+  const forwardedFor = req.headers.get("x-forwarded-for");
+
+  if (!ip && forwardedFor) {
+    ip = forwardedFor.split(",").at(0) ?? "";
+  }
+
+  return ip;
+}
+
+function parseApiKey(bearToken: string) {
+  const token = bearToken.trim().replaceAll("Bearer ", "").trim();
+  const isApiKey = !token.startsWith(ACCESS_CODE_PREFIX);
+
+  return {
+    accessCode: isApiKey ? "" : token.slice(ACCESS_CODE_PREFIX.length),
+    apiKey: isApiKey ? token : "",
+  };
+}
+
+export function auth(req: NextRequest, modelProvider: ModelProvider) {
+  const authToken = req.headers.get("Authorization") ?? "";
+
+  // check if it is openai api key or user token
+  const { accessCode, apiKey } = parseApiKey(authToken);
+
+  const hashedCode = md5.hash(accessCode ?? "").trim();
+
+  const serverConfig = getServerSideConfig();
+  console.log("[Auth] allowed hashed codes: ", [...serverConfig.codes]);
+  console.log("[Auth] got access code:", accessCode);
+  console.log("[Auth] hashed access code:", hashedCode);
+  console.log("[User IP] ", getIP(req));
+  console.log("[Time] ", new Date().toLocaleString());
+
+  if (serverConfig.needCode && !serverConfig.codes.has(hashedCode) && !apiKey) {
+    return {
+      error: true,
+      msg: !accessCode ? "empty access code" : "wrong access code",
+    };
+  }
+
+  if (serverConfig.hideUserApiKey && !!apiKey) {
+    return {
+      error: true,
+      msg: "you are not allowed to access with your own api key",
+    };
+  }
+
+  // if user does not provide an api key, inject system api key
+  if (!apiKey) {
+    const serverConfig = getServerSideConfig();
+
+    // const systemApiKey =
+    //   modelProvider === ModelProvider.GeminiPro
+    //     ? serverConfig.googleApiKey
+    //     : serverConfig.isAzure
+    //     ? serverConfig.azureApiKey
+    //     : serverConfig.apiKey;
+
+    let systemApiKey: string | undefined;
+
+    switch (modelProvider) {
+      case ModelProvider.Doubao:
+        systemApiKey = serverConfig.bytedanceApiKey;
+        break;
+      case ModelProvider.Ernie:
+        systemApiKey = serverConfig.baiduApiKey;
+        break;
+      case ModelProvider.Qwen:
+        systemApiKey = serverConfig.alibabaApiKey;
+        break;
+      case ModelProvider.Iflytek:
+        systemApiKey =
+          serverConfig.iflytekApiKey + ":" + serverConfig.iflytekApiSecret;
+        break;
+      case ModelProvider.GPT:
+      default:
+        if (req.nextUrl.pathname.includes("azure/deployments")) {
+          systemApiKey = serverConfig.azureApiKey;
+        } else {
+          systemApiKey = serverConfig.apiKey;
+        }
+    }
+
+    if (systemApiKey) {
+      console.log("[Auth] use system api key");
+      req.headers.set("Authorization", `Bearer ${systemApiKey}`);
+    } else {
+      console.log("[Auth] admin did not provide an api key");
+    }
+  } else {
+    console.log("[Auth] use user api key");
+  }
+
+  return {
+    error: false,
+  };
+}

+ 33 - 0
app/api/azure.ts

@@ -0,0 +1,33 @@
+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";
+
+export async function handle(
+  req: NextRequest,
+  { params }: { params: { path: string[] } },
+) {
+  console.log("[Azure Route] params ", params);
+
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+
+  const subpath = params.path.join("/");
+
+  const authResult = auth(req, ModelProvider.GPT);
+  if (authResult.error) {
+    return NextResponse.json(authResult, {
+      status: 401,
+    });
+  }
+
+  try {
+    return await requestOpenai(req);
+  } catch (e) {
+    console.error("[Azure] ", e);
+    return NextResponse.json(prettyObject(e));
+  }
+}

+ 145 - 0
app/api/baidu.ts

@@ -0,0 +1,145 @@
+import { getServerSideConfig } from "@/app/config/server";
+import {
+  BAIDU_BASE_URL,
+  ApiPath,
+  ModelProvider,
+  BAIDU_OATUH_URL,
+  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 { getAccessToken } from "@/app/utils/baidu";
+
+const serverConfig = getServerSideConfig();
+
+export async function handle(
+  req: NextRequest,
+  { params }: { params: { path: string[] } },
+) {
+  console.log("[Baidu Route] params ", params);
+
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+
+  const authResult = auth(req, ModelProvider.Ernie);
+  if (authResult.error) {
+    return NextResponse.json(authResult, {
+      status: 401,
+    });
+  }
+
+  if (!serverConfig.baiduApiKey || !serverConfig.baiduSecretKey) {
+    return NextResponse.json(
+      {
+        error: true,
+        message: `missing BAIDU_API_KEY or BAIDU_SECRET_KEY in server env vars`,
+      },
+      {
+        status: 401,
+      },
+    );
+  }
+
+  try {
+    const response = await request(req);
+    return response;
+  } catch (e) {
+    console.error("[Baidu] ", e);
+    return NextResponse.json(prettyObject(e));
+  }
+}
+
+async function request(req: NextRequest) {
+  const controller = new AbortController();
+
+  let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Baidu, "");
+
+  let baseUrl = serverConfig.baiduUrl || BAIDU_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 { access_token } = await getAccessToken(
+    serverConfig.baiduApiKey as string,
+    serverConfig.baiduSecretKey as string,
+  );
+  const fetchUrl = `${baseUrl}${path}?access_token=${access_token}`;
+
+  const fetchOptions: RequestInit = {
+    headers: {
+      "Content-Type": "application/json",
+    },
+    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.Baidu as string,
+        )
+      ) {
+        return NextResponse.json(
+          {
+            error: true,
+            message: `you are not allowed to use ${jsonBody?.model} model`,
+          },
+          {
+            status: 403,
+          },
+        );
+      }
+    } catch (e) {
+      console.error(`[Baidu] 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);
+  }
+}

+ 129 - 0
app/api/bytedance.ts

@@ -0,0 +1,129 @@
+import { getServerSideConfig } from "@/app/config/server";
+import {
+  BYTEDANCE_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";
+
+const serverConfig = getServerSideConfig();
+
+export async function handle(
+  req: NextRequest,
+  { params }: { params: { path: string[] } },
+) {
+  console.log("[ByteDance Route] params ", params);
+
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+
+  const authResult = auth(req, ModelProvider.Doubao);
+  if (authResult.error) {
+    return NextResponse.json(authResult, {
+      status: 401,
+    });
+  }
+
+  try {
+    const response = await request(req);
+    return response;
+  } catch (e) {
+    console.error("[ByteDance] ", e);
+    return NextResponse.json(prettyObject(e));
+  }
+}
+
+async function request(req: NextRequest) {
+  const controller = new AbortController();
+
+  let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.ByteDance, "");
+
+  let baseUrl = serverConfig.bytedanceUrl || BYTEDANCE_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.ByteDance as string,
+        )
+      ) {
+        return NextResponse.json(
+          {
+            error: true,
+            message: `you are not allowed to use ${jsonBody?.model} model`,
+          },
+          {
+            status: 403,
+          },
+        );
+      }
+    } catch (e) {
+      console.error(`[ByteDance] 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);
+  }
+}

+ 194 - 0
app/api/common.ts

@@ -0,0 +1,194 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSideConfig } from "../config/server";
+import {
+  DEFAULT_MODELS,
+  OPENAI_BASE_URL,
+  ServiceProvider,
+} from "../constant";
+import { isModelAvailableInServer } from "../utils/model";
+import { cloudflareAIGatewayUrl } from "../utils/cloudflare";
+
+const serverConfig = getServerSideConfig();
+
+export async function requestOpenai(req: NextRequest) {
+  const controller = new AbortController();
+
+  const isAzure = req.nextUrl.pathname.includes("azure/deployments");
+
+  var authValue,
+    authHeaderName = "";
+  if (isAzure) {
+    authValue =
+      req.headers
+        .get("Authorization")
+        ?.trim()
+        .replaceAll("Bearer ", "")
+        .trim() ?? "";
+
+    authHeaderName = "api-key";
+  } else {
+    authValue = req.headers.get("Authorization") ?? "";
+    authHeaderName = "Authorization";
+  }
+
+  let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
+    "/api/openai/",
+    "",
+  );
+
+  let baseUrl =
+    (isAzure ? serverConfig.azureUrl : serverConfig.baseUrl) || OPENAI_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,
+  );
+
+  if (isAzure) {
+    const azureApiVersion =
+      req?.nextUrl?.searchParams?.get("api-version") ||
+      serverConfig.azureApiVersion;
+    baseUrl = baseUrl.split("/deployments").shift() as string;
+    path = `${req.nextUrl.pathname.replaceAll(
+      "/api/azure/",
+      "",
+    )}?api-version=${azureApiVersion}`;
+
+    // Forward compatibility:
+    // if display_name(deployment_name) not set, and '{deploy-id}' in AZURE_URL
+    // then using default '{deploy-id}'
+    if (serverConfig.customModels && serverConfig.azureUrl) {
+      const modelName = path.split("/")[1];
+      let realDeployName = "";
+      serverConfig.customModels
+        .split(",")
+        .filter((v) => !!v && !v.startsWith("-") && v.includes(modelName))
+        .forEach((m) => {
+          const [fullName, displayName] = m.split("=");
+          const [_, providerName] = fullName.split("@");
+          if (providerName === "azure" && !displayName) {
+            const [_, deployId] = (serverConfig?.azureUrl ?? "").split(
+              "deployments/",
+            );
+            if (deployId) {
+              realDeployName = deployId;
+            }
+          }
+        });
+      if (realDeployName) {
+        console.log("[Replace with DeployId", realDeployName);
+        path = path.replaceAll(modelName, realDeployName);
+      }
+    }
+  }
+
+  const fetchUrl = cloudflareAIGatewayUrl(`${baseUrl}/${path}`);
+  console.log("fetchUrl", fetchUrl);
+  const fetchOptions: RequestInit = {
+    headers: {
+      "Content-Type": "application/json",
+      "Cache-Control": "no-store",
+      [authHeaderName]: authValue,
+      ...(serverConfig.openaiOrgId && {
+        "OpenAI-Organization": serverConfig.openaiOrgId,
+      }),
+    },
+    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,
+  };
+
+  // #1815 try to refuse gpt4 request
+  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.OpenAI as string,
+        ) ||
+        isModelAvailableInServer(
+          serverConfig.customModels,
+          jsonBody?.model as string,
+          ServiceProvider.Azure as string,
+        )
+      ) {
+        return NextResponse.json(
+          {
+            error: true,
+            message: `you are not allowed to use ${jsonBody?.model} model`,
+          },
+          {
+            status: 403,
+          },
+        );
+      }
+    } catch (e) {
+      console.error("[OpenAI] gpt4 filter", e);
+    }
+  }
+
+  try {
+    const res = await fetch(fetchUrl, fetchOptions);
+
+    // Extract the OpenAI-Organization header from the response
+    const openaiOrganizationHeader = res.headers.get("OpenAI-Organization");
+
+    // Check if serverConfig.openaiOrgId is defined and not an empty string
+    if (serverConfig.openaiOrgId && serverConfig.openaiOrgId.trim() !== "") {
+      // If openaiOrganizationHeader is present, log it; otherwise, log that the header is not present
+      console.log("[Org ID]", openaiOrganizationHeader);
+    } else {
+      console.log("[Org ID] is not set up.");
+    }
+
+    // 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");
+
+    // Conditionally delete the OpenAI-Organization header from the response if [Org ID] is undefined or empty (not setup in ENV)
+    // Also, this is to prevent the header from being sent to the client
+    if (!serverConfig.openaiOrgId || serverConfig.openaiOrgId.trim() === "") {
+      newHeaders.delete("OpenAI-Organization");
+    }
+
+    // The latest version of the OpenAI API forced the content-encoding to be "br" in json response
+    // So if the streaming is disabled, we need to remove the content-encoding header
+    // Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header
+    // The browser will try to decode the response with brotli and fail
+    newHeaders.delete("content-encoding");
+
+    return new Response(res.body, {
+      status: res.status,
+      statusText: res.statusText,
+      headers: newHeaders,
+    });
+  } finally {
+    clearTimeout(timeoutId);
+  }
+}

+ 30 - 0
app/api/config/route.ts

@@ -0,0 +1,30 @@
+import { NextResponse } from "next/server";
+
+import { getServerSideConfig } from "../../config/server";
+
+const serverConfig = getServerSideConfig();
+
+// Danger! Do not hard code any secret value here!
+// 警告!不要在这里写入任何敏感信息!
+const DANGER_CONFIG = {
+  needCode: serverConfig.needCode,
+  hideUserApiKey: serverConfig.hideUserApiKey,
+  disableGPT4: serverConfig.disableGPT4,
+  hideBalanceQuery: serverConfig.hideBalanceQuery,
+  disableFastLink: serverConfig.disableFastLink,
+  customModels: serverConfig.customModels,
+  defaultModel: serverConfig.defaultModel,
+};
+
+declare global {
+  type DangerConfig = typeof DANGER_CONFIG;
+}
+
+async function handle() {
+  return NextResponse.json(DANGER_CONFIG);
+}
+
+export const GET = handle;
+export const POST = handle;
+
+export const runtime = "edge";

+ 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);
+  }
+}

+ 72 - 0
app/api/openai.ts

@@ -0,0 +1,72 @@
+import { type OpenAIListModelResponse } from "@/app/client/platforms/openai";
+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";
+
+const ALLOWD_PATH = new Set(Object.values(OpenaiPath));
+
+function getModels(remoteModelRes: OpenAIListModelResponse) {
+  const config = getServerSideConfig();
+
+  if (config.disableGPT4) {
+    remoteModelRes.data = remoteModelRes.data.filter(
+      (m) => !m.id.startsWith("gpt-4") || m.id.startsWith("gpt-4o-mini"),
+    );
+  }
+
+  return remoteModelRes;
+}
+
+export async function handle(
+  req: NextRequest,
+  { params }: { params: { path: string[] } },
+) {
+  console.log("[OpenAI Route] params ", params);
+
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+
+  const subpath = params.path.join("/");
+
+  if (!ALLOWD_PATH.has(subpath)) {
+    console.log("[OpenAI Route] forbidden path ", subpath);
+    return NextResponse.json(
+      {
+        error: true,
+        msg: "you are not allowed to request " + subpath,
+      },
+      {
+        status: 403,
+      },
+    );
+  }
+
+  const authResult = auth(req, ModelProvider.GPT);
+  if (authResult.error) {
+    return NextResponse.json(authResult, {
+      status: 401,
+    });
+  }
+
+  try {
+    const response = await requestOpenai(req);
+
+    // list models
+    if (subpath === OpenaiPath.ListModelPath && response.status === 200) {
+      const resJson = (await response.json()) as OpenAIListModelResponse;
+      const availableModels = getModels(resJson);
+      return NextResponse.json(availableModels, {
+        status: response.status,
+      });
+    }
+
+    return response;
+  } catch (e) {
+    console.error("[OpenAI] ", e);
+    return NextResponse.json(prettyObject(e));
+  }
+}

+ 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);
+  }
+}

+ 73 - 0
app/api/upstash/[action]/[...key]/route.ts

@@ -0,0 +1,73 @@
+import { NextRequest, NextResponse } from "next/server";
+
+async function handle(
+  req: NextRequest,
+  { params }: { params: { action: string; key: string[] } },
+) {
+  const requestUrl = new URL(req.url);
+  const endpoint = requestUrl.searchParams.get("endpoint");
+
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+  const [...key] = params.key;
+  // only allow to request to *.upstash.io
+  if (!endpoint || !new URL(endpoint).hostname.endsWith(".upstash.io")) {
+    return NextResponse.json(
+      {
+        error: true,
+        msg: "you are not allowed to request " + params.key.join("/"),
+      },
+      {
+        status: 403,
+      },
+    );
+  }
+
+  // only allow upstash get and set method
+  if (params.action !== "get" && params.action !== "set") {
+    console.log("[Upstash Route] forbidden action ", params.action);
+    return NextResponse.json(
+      {
+        error: true,
+        msg: "you are not allowed to request " + params.action,
+      },
+      {
+        status: 403,
+      },
+    );
+  }
+
+  const targetUrl = `${endpoint}/${params.action}/${params.key.join("/")}`;
+
+  const method = req.method;
+  const shouldNotHaveBody = ["get", "head"].includes(
+    method?.toLowerCase() ?? "",
+  );
+
+  const fetchOptions: RequestInit = {
+    headers: {
+      authorization: req.headers.get("authorization") ?? "",
+    },
+    body: shouldNotHaveBody ? null : req.body,
+    method,
+    // @ts-ignore
+    duplex: "half",
+  };
+
+  console.log("[Upstash Proxy]", targetUrl, fetchOptions);
+  const fetchResult = await fetch(targetUrl, fetchOptions);
+
+  console.log("[Any Proxy]", targetUrl, {
+    status: fetchResult.status,
+    statusText: fetchResult.statusText,
+  });
+
+  return fetchResult;
+}
+
+export const POST = handle;
+export const GET = handle;
+export const OPTIONS = handle;
+
+export const runtime = "edge";

+ 167 - 0
app/api/webdav/[...path]/route.ts

@@ -0,0 +1,167 @@
+import { NextRequest, NextResponse } from "next/server";
+import { STORAGE_KEY, internalAllowedWebDavEndpoints } from "../../../constant";
+import { getServerSideConfig } from "@/app/config/server";
+
+const config = getServerSideConfig();
+
+const mergedAllowedWebDavEndpoints = [
+  ...internalAllowedWebDavEndpoints,
+  ...config.allowedWebDevEndpoints,
+].filter((domain) => Boolean(domain.trim()));
+
+const normalizeUrl = (url: string) => {
+  try {
+    return new URL(url);
+  } catch (err) {
+    return null;
+  }
+};
+
+async function handle(
+  req: NextRequest,
+  { params }: { params: { path: string[] } },
+) {
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+  const folder = STORAGE_KEY;
+  const fileName = `${folder}/backup.json`;
+
+  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 (
+    !endpoint ||
+    !mergedAllowedWebDavEndpoints.some((allowedEndpoint) => {
+      const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint);
+      const normalizedEndpoint = normalizeUrl(endpoint as string);
+
+      return (
+        normalizedEndpoint &&
+        normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname &&
+        normalizedEndpoint.pathname.startsWith(
+          normalizedAllowedEndpoint.pathname,
+        )
+      );
+    })
+  ) {
+    return NextResponse.json(
+      {
+        error: true,
+        msg: "Invalid endpoint",
+      },
+      {
+        status: 400,
+      },
+    );
+  }
+
+  if (!endpoint?.endsWith("/")) {
+    endpoint += "/";
+  }
+
+  const endpointPath = params.path.join("/");
+  const targetPath = `${endpoint}${endpointPath}`;
+
+  // only allow MKCOL, GET, PUT
+  if (
+    proxy_method !== "MKCOL" &&
+    proxy_method !== "GET" &&
+    proxy_method !== "PUT"
+  ) {
+    return NextResponse.json(
+      {
+        error: true,
+        msg: "you are not allowed to request " + targetPath,
+      },
+      {
+        status: 403,
+      },
+    );
+  }
+
+  // for MKCOL request, only allow request ${folder}
+  if (proxy_method === "MKCOL" && !targetPath.endsWith(folder)) {
+    return NextResponse.json(
+      {
+        error: true,
+        msg: "you are not allowed to request " + targetPath,
+      },
+      {
+        status: 403,
+      },
+    );
+  }
+
+  // for GET request, only allow request ending with fileName
+  if (proxy_method === "GET" && !targetPath.endsWith(fileName)) {
+    return NextResponse.json(
+      {
+        error: true,
+        msg: "you are not allowed to request " + targetPath,
+      },
+      {
+        status: 403,
+      },
+    );
+  }
+
+  //   for PUT request, only allow request ending with fileName
+  if (proxy_method === "PUT" && !targetPath.endsWith(fileName)) {
+    return NextResponse.json(
+      {
+        error: true,
+        msg: "you are not allowed to request " + targetPath,
+      },
+      {
+        status: 403,
+      },
+    );
+  }
+
+  const targetUrl = targetPath;
+
+  const method = proxy_method || req.method;
+  const shouldNotHaveBody = ["get", "head"].includes(
+    method?.toLowerCase() ?? "",
+  );
+
+  const fetchOptions: RequestInit = {
+    headers: {
+      authorization: req.headers.get("authorization") ?? "",
+    },
+    body: shouldNotHaveBody ? null : req.body,
+    redirect: "manual",
+    method,
+    // @ts-ignore
+    duplex: "half",
+  };
+
+  let fetchResult;
+
+  try {
+    fetchResult = await fetch(targetUrl, fetchOptions);
+  } finally {
+    console.log(
+      "[Any Proxy]",
+      targetUrl,
+      {
+        method: method,
+      },
+      {
+        status: fetchResult?.status,
+        statusText: fetchResult?.statusText,
+      },
+    );
+  }
+
+  return fetchResult;
+}
+
+export const PUT = handle;
+export const GET = handle;
+export const OPTIONS = handle;
+
+export const runtime = "edge";

+ 289 - 0
app/client/api.ts

@@ -0,0 +1,289 @@
+import { getClientConfig } from "../config/client";
+import {
+  ACCESS_CODE_PREFIX,
+  ModelProvider,
+  ServiceProvider,
+} from "../constant";
+import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store";
+import { BigModelApi } from "./platforms/bigModel";
+import { DeepSeekApi } from "./platforms/deepSeek";
+import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai";
+import { ErnieApi } from "./platforms/baidu";
+import { DoubaoApi } from "./platforms/bytedance";
+import { QwenApi } from "./platforms/alibaba";
+import { HunyuanApi } from "./platforms/tencent";
+import { SparkApi } from "./platforms/iflytek";
+
+export const ROLES = ["system", "user", "assistant"] as const;
+export type MessageRole = (typeof ROLES)[number];
+
+export const Models = ["gpt-3.5-turbo", "gpt-4"] as const;
+export type ChatModel = ModelType;
+
+export interface MultimodalContent {
+  type: "text" | "image_url";
+  text?: string;
+  image_url?: {
+    url: string;
+  };
+}
+
+export interface RequestMessage {
+  role: MessageRole;
+  content: string | MultimodalContent[];
+  document?: {
+    id: string,
+    name: string,
+    url: string,
+  }
+}
+
+export interface LLMConfig {
+  appId?: string,
+  model: string;
+  providerName?: string;
+  temperature?: number;
+  top_p?: number;
+  stream?: boolean;
+  presence_penalty?: number;
+  frequency_penalty?: number;
+  size?: DalleRequestPayload["size"];
+  web_search?: boolean;
+}
+
+export interface ChatOptions {
+  messages: RequestMessage[];
+  config: LLMConfig;
+
+  onUpdate?: (message: string, chunk: string) => void;
+  onFinish: (message: string) => void;
+  onError?: (err: Error) => void;
+  onController?: (controller: AbortController) => void;
+}
+
+export interface LLMUsage {
+  used: number;
+  total: number;
+}
+
+export interface LLMModel {
+  name: string;
+  displayName?: string;
+  available: boolean;
+  provider: LLMModelProvider;
+  sorted: number;
+}
+
+export interface LLMModelProvider {
+  id: string;
+  providerName: string;
+  providerType: string;
+  sorted: number;
+}
+
+export abstract class LLMApi {
+  abstract chat(options: ChatOptions): Promise<void>;
+  abstract usage(): Promise<LLMUsage>;
+  abstract models(): Promise<LLMModel[]>;
+}
+
+type ProviderName = "openai" | "azure" | "claude" | "palm";
+
+interface Model {
+  name: string;
+  provider: ProviderName;
+  ctxlen: number;
+}
+
+interface ChatProvider {
+  name: ProviderName;
+  apiConfig: {
+    baseUrl: string;
+    apiKey: string;
+    summaryModel: Model;
+  };
+  models: Model[];
+
+  chat: () => void;
+  usage: () => void;
+}
+
+export class ClientApi {
+  public llm: LLMApi;
+
+  constructor(provider: ModelProvider = ModelProvider.GPT) {
+    switch (provider) {
+      case ModelProvider.BigModel:
+        this.llm = new BigModelApi();
+        break;
+      case ModelProvider.DeepSeek:
+        this.llm = new DeepSeekApi();
+        break;
+      case ModelProvider.Ernie:
+        this.llm = new ErnieApi();
+        break;
+      case ModelProvider.Doubao:
+        this.llm = new DoubaoApi();
+        break;
+      case ModelProvider.Qwen:
+        this.llm = new QwenApi();
+        break;
+      case ModelProvider.Hunyuan:
+        this.llm = new HunyuanApi();
+        break;
+      case ModelProvider.Iflytek:
+        this.llm = new SparkApi();
+        break;
+      default:
+        this.llm = new ChatGPTApi();
+    }
+  }
+
+  config() { }
+
+  prompts() { }
+
+  masks() { }
+
+  async share(messages: ChatMessage[], avatarUrl: string | null = null) {
+    const msgs = messages
+      .map((m) => ({
+        from: m.role === "user" ? "human" : "gpt",
+        value: m.content,
+      }))
+      .concat([
+        {
+          from: "human",
+          value:
+            "Share from [NextChat]: https://github.com/Yidadaa/ChatGPT-Next-Web",
+        },
+      ]);
+    // 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用
+    // Please do not modify this message
+
+    console.log("[Share]", messages, msgs);
+    const clientConfig = getClientConfig();
+    const proxyUrl = "/sharegpt";
+    const rawUrl = "https://sharegpt.com/api/conversations";
+    const shareUrl = clientConfig?.isApp ? rawUrl : proxyUrl;
+    const res = await fetch(shareUrl, {
+      body: JSON.stringify({
+        avatarUrl,
+        items: msgs,
+      }),
+      headers: {
+        "Content-Type": "application/json",
+      },
+      method: "POST",
+    });
+
+    const resJson = await res.json();
+    console.log("[Share]", resJson);
+    if (resJson.id) {
+      return `https://shareg.pt/${resJson.id}`;
+    }
+  }
+}
+
+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();
+  const headers: Record<string, string> = {
+    "Content-Type": "application/json",
+    Accept: "application/json",
+  };
+
+  const clientConfig = getClientConfig();
+
+  function getConfig() {
+    const modelConfig = chatStore.currentSession().mask.modelConfig;
+    const isAzure = modelConfig.providerName === ServiceProvider.Azure;
+    const isBaidu = modelConfig.providerName == ServiceProvider.Baidu;
+    const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance;
+    const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba;
+    const isIflytek = modelConfig.providerName === ServiceProvider.Iflytek;
+    const isEnabledAccessControl = accessStore.enabledAccessControl();
+    const apiKey = isAzure
+      ? accessStore.azureApiKey
+      : isByteDance
+        ? accessStore.bytedanceApiKey
+        : isAlibaba
+          ? accessStore.alibabaApiKey
+          : isIflytek
+            ? accessStore.iflytekApiKey && accessStore.iflytekApiSecret
+              ? accessStore.iflytekApiKey + ":" + accessStore.iflytekApiSecret
+              : ""
+            : accessStore.openaiApiKey;
+    return {
+      isAzure,
+      isBaidu,
+      isByteDance,
+      isAlibaba,
+      isIflytek,
+      apiKey,
+      isEnabledAccessControl,
+    };
+  }
+
+  const {
+    isAzure,
+    isBaidu,
+    apiKey,
+    isEnabledAccessControl,
+  } = getConfig();
+  
+  function getAuthHeader(): string {
+    return isAzure ? "api-key" : "Authorization";
+  }
+  
+  // when using baidu api in app, not set auth header
+  if (isBaidu && clientConfig?.isApp) return headers;
+
+  const authHeader = getAuthHeader();
+
+  const bearerToken = getBearerToken(apiKey, isAzure);
+
+  if (bearerToken) {
+    headers[authHeader] = bearerToken;
+  } else if (isEnabledAccessControl && validString(accessStore.accessCode)) {
+    headers["Authorization"] = getBearerToken(
+      ACCESS_CODE_PREFIX + accessStore.accessCode,
+    );
+  }
+
+  return headers;
+}
+
+export function getClientApi(provider: ServiceProvider): ClientApi {
+  switch (provider) {
+    case ServiceProvider.BigModel:
+      return new ClientApi(ModelProvider.BigModel);
+    case ServiceProvider.DeepSeek:
+      return new ClientApi(ModelProvider.DeepSeek);
+    case ServiceProvider.Baidu:
+      return new ClientApi(ModelProvider.Ernie);
+    case ServiceProvider.ByteDance:
+      return new ClientApi(ModelProvider.Doubao);
+    case ServiceProvider.Alibaba:
+      return new ClientApi(ModelProvider.Qwen);
+    case ServiceProvider.Tencent:
+      return new ClientApi(ModelProvider.Hunyuan);
+    case ServiceProvider.Iflytek:
+      return new ClientApi(ModelProvider.Iflytek);
+    default:
+      return new ClientApi(ModelProvider.GPT);
+  }
+}

+ 37 - 0
app/client/controller.ts

@@ -0,0 +1,37 @@
+// To store message streaming controller
+export const ChatControllerPool = {
+  controllers: {} as Record<string, AbortController>,
+
+  addController(
+    sessionId: string,
+    messageId: string,
+    controller: AbortController,
+  ) {
+    const key = this.key(sessionId, messageId);
+    this.controllers[key] = controller;
+    return key;
+  },
+
+  stop(sessionId: string, messageId: string) {
+    const key = this.key(sessionId, messageId);
+    const controller = this.controllers[key];
+    controller?.abort();
+  },
+
+  stopAll() {
+    Object.values(this.controllers).forEach((v) => v.abort());
+  },
+
+  hasPending() {
+    return Object.values(this.controllers).length > 0;
+  },
+
+  remove(sessionId: string, messageId: string) {
+    const key = this.key(sessionId, messageId);
+    delete this.controllers[key];
+  },
+
+  key(sessionId: string, messageIndex: string) {
+    return `${sessionId},${messageIndex}`;
+  },
+};

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

@@ -0,0 +1,268 @@
+"use client";
+import {
+  ApiPath,
+  Alibaba,
+  ALIBABA_BASE_URL,
+  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 } from "@/app/utils";
+
+export interface OpenAIListModelResponse {
+  object: string;
+  data: Array<{
+    id: string;
+    object: string;
+    root: string;
+  }>;
+}
+
+interface RequestInput {
+  messages: {
+    role: "system" | "user" | "assistant";
+    content: string | MultimodalContent[];
+  }[];
+}
+interface RequestParam {
+  result_format: string;
+  incremental_output?: boolean;
+  temperature: number;
+  repetition_penalty?: number;
+  top_p: number;
+  max_tokens?: number;
+}
+interface RequestPayload {
+  model: string;
+  input: RequestInput;
+  parameters: RequestParam;
+}
+
+export class QwenApi implements LLMApi {
+  path(path: string): string {
+    const accessStore = useAccessStore.getState();
+
+    let baseUrl = "";
+
+    if (accessStore.useCustomConfig) {
+      baseUrl = accessStore.alibabaUrl;
+    }
+
+    if (baseUrl.length === 0) {
+      const isApp = !!getClientConfig()?.isApp;
+      baseUrl = isApp ? ALIBABA_BASE_URL : ApiPath.Alibaba;
+    }
+
+    if (baseUrl.endsWith("/")) {
+      baseUrl = baseUrl.slice(0, baseUrl.length - 1);
+    }
+    if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Alibaba)) {
+      baseUrl = "https://" + baseUrl;
+    }
+
+    console.log("[Proxy Endpoint] ", baseUrl, path);
+
+    return [baseUrl, path].join("/");
+  }
+
+  extractMessage(res: any) {
+    return res?.output?.choices?.at(0)?.message?.content ?? "";
+  }
+
+  async chat(options: ChatOptions) {
+    const messages = options.messages.map((v) => ({
+      role: v.role,
+      content: getMessageTextContent(v),
+    }));
+
+    const modelConfig = {
+      ...useAppConfig.getState().modelConfig,
+      ...useChatStore.getState().currentSession().mask.modelConfig,
+      ...{
+        model: options.config.model,
+      },
+    };
+
+    const shouldStream = !!options.config.stream;
+    const requestPayload: RequestPayload = {
+      model: modelConfig.model,
+      input: {
+        messages,
+      },
+      parameters: {
+        result_format: "message",
+        incremental_output: shouldStream,
+        temperature: modelConfig.temperature,
+        // max_tokens: modelConfig.max_tokens,
+        top_p: modelConfig.top_p === 1 ? 0.99 : modelConfig.top_p, // qwen top_p is should be < 1
+      },
+    };
+
+    const controller = new AbortController();
+    options.onController?.(controller);
+
+    try {
+      const chatPath = this.path(Alibaba.ChatPath);
+      const chatPayload = {
+        method: "POST",
+        body: JSON.stringify(requestPayload),
+        signal: controller.signal,
+        headers: {
+          ...getHeaders(),
+          "X-DashScope-SSE": shouldStream ? "enable" : "disable",
+        },
+      };
+
+      // 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(
+              "[Alibaba] 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.output.choices as Array<{
+                message: { content: string };
+              }>;
+              const delta = choices[0]?.message?.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 [];
+  }
+}
+export { Alibaba };

+ 281 - 0
app/client/platforms/baidu.ts

@@ -0,0 +1,281 @@
+"use client";
+import {
+  ApiPath,
+  Baidu,
+  BAIDU_BASE_URL,
+  REQUEST_TIMEOUT_MS,
+} from "@/app/constant";
+import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
+import { getAccessToken } from "@/app/utils/baidu";
+
+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 } from "@/app/utils";
+
+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;
+  presence_penalty: number;
+  frequency_penalty: number;
+  top_p: number;
+  max_tokens?: number;
+}
+
+export class ErnieApi implements LLMApi {
+  path(path: string): string {
+    const accessStore = useAccessStore.getState();
+
+    let baseUrl = "";
+
+    if (accessStore.useCustomConfig) {
+      baseUrl = accessStore.baiduUrl;
+    }
+
+    if (baseUrl.length === 0) {
+      const isApp = !!getClientConfig()?.isApp;
+      // do not use proxy for baidubce api
+      baseUrl = isApp ? BAIDU_BASE_URL : ApiPath.Baidu;
+    }
+
+    if (baseUrl.endsWith("/")) {
+      baseUrl = baseUrl.slice(0, baseUrl.length - 1);
+    }
+    if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Baidu)) {
+      baseUrl = "https://" + baseUrl;
+    }
+
+    console.log("[Proxy Endpoint] ", baseUrl, path);
+
+    return [baseUrl, path].join("/");
+  }
+
+  async chat(options: ChatOptions) {
+    const messages = options.messages.map((v) => ({
+      // "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) {
+      if (messages.at(0)?.role === "user") {
+        messages.splice(1, 0, {
+          role: "assistant",
+          content: " ",
+        });
+      } else {
+        messages.unshift({
+          role: "user",
+          content: " ",
+        });
+      }
+    }
+
+    const modelConfig = {
+      ...useAppConfig.getState().modelConfig,
+      ...useChatStore.getState().currentSession().mask.modelConfig,
+      ...{
+        model: options.config.model,
+      },
+    };
+
+    const shouldStream = !!options.config.stream;
+    const requestPayload: RequestPayload = {
+      messages,
+      stream: shouldStream,
+      model: modelConfig.model,
+      temperature: modelConfig.temperature,
+      presence_penalty: modelConfig.presence_penalty,
+      frequency_penalty: modelConfig.frequency_penalty,
+      top_p: modelConfig.top_p,
+    };
+
+    console.log("[Request] Baidu payload: ", requestPayload);
+
+    const controller = new AbortController();
+    options.onController?.(controller);
+
+    try {
+      let chatPath = this.path(Baidu.ChatPath(modelConfig.model));
+
+      // getAccessToken can not run in browser, because cors error
+      if (!!getClientConfig()?.isApp) {
+        const accessStore = useAccessStore.getState();
+        if (accessStore.useCustomConfig) {
+          if (accessStore.isValidBaidu()) {
+            const { access_token } = await getAccessToken(
+              accessStore.baiduApiKey,
+              accessStore.baiduSecretKey,
+            );
+            chatPath = `${chatPath}${
+              chatPath.includes("?") ? "&" : "?"
+            }access_token=${access_token}`;
+          }
+        }
+      }
+      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("[Baidu] 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 delta = json?.result;
+              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 = resJson?.result;
+        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 [];
+  }
+}
+export { Baidu };

+ 261 - 0
app/client/platforms/bigModel.ts

@@ -0,0 +1,261 @@
+"use client";
+import { REQUEST_TIMEOUT_MS } from "@/app/constant";
+import { useChatStore } from "@/app/store";
+import {
+  ChatOptions,
+  LLMApi,
+  LLMModel,
+} from "../api";
+import Locale from "../../locales";
+import {
+  EventStreamContentType,
+  fetchEventSource,
+} from "@fortaine/fetch-event-source";
+import { prettyObject } from "@/app/utils/format";
+import { getMessageTextContent } from "@/app/utils";
+import api from "@/app/api/api";
+
+export class BigModelApi implements LLMApi {
+  public baseURL: string;
+  public apiPath: string;
+
+  constructor() {
+    const chatMode = useChatStore.getState().chatMode;
+    // this.baseURL = 'http://xia0miduo.gicp.net:8401';
+    this.baseURL = '/bigmodel-api';
+    if (chatMode === 'LOCAL') {
+      this.apiPath = this.baseURL + '/deepseek/api/chat';
+    } else {
+      this.apiPath = this.baseURL + '/bigmodel/api/model-api/sse-invoke';
+    }
+  }
+
+  async chat(options: ChatOptions) {
+    const messages = options.messages.map((item) => {
+      return {
+        role: item.role,
+        content: getMessageTextContent(item),
+      }
+    });
+
+    const userMessages = messages.filter(item => item.content);
+
+    if (userMessages.length % 2 === 0) {
+      userMessages.unshift({
+        role: "user",
+        content: "⠀",
+      });
+    }
+
+    // 参数
+    const params = {
+      appId: options.config.appId,// 应用id
+      prompt: userMessages,
+      // 进阶配置
+      request_id: 'jkec2024-knowledge-base',
+      returnType: undefined,
+      knowledge_ids: undefined,
+      document_ids: undefined,
+    };
+
+    const controller = new AbortController();
+
+    options.onController?.(controller);
+
+    try {
+      const chatPath = this.apiPath;
+      const chatPayload = {
+        method: "POST",
+        body: JSON.stringify(params),
+        signal: controller.signal,
+        headers: {
+          'Content-Type': 'application/json',
+        },
+      };
+
+      const requestTimeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
+
+      let responseText = "";
+      let remainText = "";
+      let finished = false;
+
+      function animateResponseText() {
+        if (finished || controller.signal.aborted) {
+          responseText += remainText;
+          if (responseText?.length === 0) {
+            options.onError?.(new Error("请求已中止,请检查网络环境。"));
+          }
+          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);
+      }
+
+      animateResponseText();
+
+      const finish = () => {
+        if (!finished) {
+          finished = true;
+          options.onFinish(responseText + remainText);
+        }
+      };
+
+      controller.signal.onabort = finish;
+      let sliceInfoPromise: Promise<void> | null = null;
+
+      fetchEventSource(chatPath, {
+        ...chatPayload,
+        async onopen(res: any) {
+          clearTimeout(requestTimeoutId);
+          const contentType = res.headers.get("content-type");
+
+          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: async (msg) => {
+          const info = JSON.parse(msg.data);
+          if (info.event === 'finish') {
+            const chatMode = useChatStore.getState().chatMode;
+            if (chatMode === 'LOCAL') {// 切片
+              useChatStore.getState().updateCurrentSession((se) => {
+                se.chat_id = info.id;
+              });
+              sliceInfoPromise = (async () => {
+                try {
+                  const res: any = await api.get(`deepseek/api/slice/search/${info.id}`);
+                  const sliceInfo = {
+                    ...res.data,
+                  };
+                  delete sliceInfo.code;
+                  useChatStore.getState().updateCurrentSession((session) => {
+                    session.messages = session.messages.map((item, index) => {
+                      if (index === session.messages.length - 1 && item.role !== 'user') {
+                        return {
+                          ...item,
+                          sliceInfo: sliceInfo,
+                        };
+                      } else {
+                        return {
+                          ...item,
+                        }
+                      }
+                    });
+                  });
+                } catch (error) {
+                  console.error(error);
+                }
+              })();
+            }
+            return finish();
+          }
+
+          // 获取当前的数据
+          const currentData = info.data;
+          const formatStart = '```think';
+          const formatEnd = 'think```';
+
+          if (currentData?.startsWith(formatStart)) {
+            remainText += currentData.replace(formatStart, '```think\n');
+          } else if (currentData?.startsWith(formatEnd)) {
+            remainText += currentData.replace(formatEnd, '```');
+          } else {
+            remainText += currentData;
+          }
+        },
+        async onclose() {
+          finish();
+          if (sliceInfoPromise) {
+            await sliceInfoPromise; // 等待 sliceInfo 加载完成
+          }
+          const session = useChatStore.getState().sessions[0];
+
+          const item = session.messages.find(item => item.role === 'user');
+          const dialogName = item ? item.content : '新的聊天';
+          const data = {
+            id: session.id,
+            appId: session.appId,
+            userId: undefined,
+            dialogName: dialogName,
+            messages: session.messages.map(item => ({
+              id: item.id,
+              date: item.date,
+              role: item.role,
+              content: item.content,
+              sliceInfo: item.sliceInfo,
+            })),
+          };
+          const messages = session.messages.slice();
+          const backList = messages.reverse();
+          const record = backList.find(item => item.content && item.role === 'assistant');
+          if (record) {
+            useChatStore.setState({
+              message: {
+                content: record.content as string,
+                role: record.role,
+              }
+            });
+          }
+          const chatMode = useChatStore.getState().chatMode;
+          if (chatMode === 'LOCAL') {
+            await api.post('deepseek/api/dialog/save', data);
+          } else {
+            await api.post('bigmodel/api/dialog/save', data);
+          }
+        },
+        onerror(e) {
+          options.onError?.(e);
+          throw e;
+        },
+        openWhenHidden: true,
+      });
+    } catch (e) {
+      options.onError?.(e as Error);
+    }
+  }
+
+  async usage() {
+    return {
+      used: 0,
+      total: 0,
+    };
+  }
+
+  async models(): Promise<LLMModel[]> {
+    return [];
+  }
+}

+ 255 - 0
app/client/platforms/bytedance.ts

@@ -0,0 +1,255 @@
+"use client";
+import {
+  ApiPath,
+  ByteDance,
+  BYTEDANCE_BASE_URL,
+  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 } from "@/app/utils";
+
+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;
+  presence_penalty: number;
+  frequency_penalty: number;
+  top_p: number;
+  max_tokens?: number;
+}
+
+export class DoubaoApi implements LLMApi {
+  path(path: string): string {
+    const accessStore = useAccessStore.getState();
+
+    let baseUrl = "";
+
+    if (accessStore.useCustomConfig) {
+      baseUrl = accessStore.bytedanceUrl;
+    }
+
+    if (baseUrl.length === 0) {
+      const isApp = !!getClientConfig()?.isApp;
+      baseUrl = isApp ? BYTEDANCE_BASE_URL : ApiPath.ByteDance;
+    }
+
+    if (baseUrl.endsWith("/")) {
+      baseUrl = baseUrl.slice(0, baseUrl.length - 1);
+    }
+    if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.ByteDance)) {
+      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 = options.messages.map((v) => ({
+      role: v.role,
+      content: getMessageTextContent(v),
+    }));
+
+    const modelConfig = {
+      ...useAppConfig.getState().modelConfig,
+      ...useChatStore.getState().currentSession().mask.modelConfig,
+      ...{
+        model: options.config.model,
+      },
+    };
+
+    const shouldStream = !!options.config.stream;
+    const requestPayload: RequestPayload = {
+      messages,
+      stream: shouldStream,
+      model: modelConfig.model,
+      temperature: modelConfig.temperature,
+      presence_penalty: modelConfig.presence_penalty,
+      frequency_penalty: modelConfig.frequency_penalty,
+      top_p: modelConfig.top_p,
+    };
+
+    const controller = new AbortController();
+    options.onController?.(controller);
+
+    try {
+      const chatPath = this.path(ByteDance.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(
+              "[ByteDance] 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 [];
+  }
+}
+export { ByteDance };

+ 257 - 0
app/client/platforms/deepSeek.ts

@@ -0,0 +1,257 @@
+"use client";
+import { REQUEST_TIMEOUT_MS } from "@/app/constant";
+import { useChatStore } from "@/app/store";
+import {
+  ChatOptions,
+  LLMApi,
+  LLMModel,
+} from "../api";
+import Locale from "../../locales";
+import {
+  EventStreamContentType,
+  fetchEventSource,
+} from "@fortaine/fetch-event-source";
+import { prettyObject } from "@/app/utils/format";
+import { getMessageTextContent } from "@/app/utils";
+import api from "@/app/api/api";
+
+export class DeepSeekApi implements LLMApi {
+  public baseURL: string;
+  public apiPath: string;
+
+  constructor() {
+    // this.baseURL = 'http://192.168.3.209:18078';
+    this.baseURL = '/deepseek-api';
+    this.apiPath = this.baseURL + '/vllm/ai/chat';
+  }
+
+  async chat(options: ChatOptions) {
+    const list: ChatOptions['messages'] = JSON.parse(JSON.stringify(options.messages)) || [];
+
+    const backList = list.reverse();
+    const item = backList.find((item) => {
+      if (item.document) {
+        if (item.document.id) {
+          return true;
+        } else {
+          return false;
+        }
+      } else {
+        return false;
+      }
+    });
+
+    const messages = options.messages.map((item) => {
+      return {
+        role: item.role,
+        content: getMessageTextContent(item),
+      }
+    });
+
+    const userMessages = messages.filter(item => item.content);
+
+    if (userMessages.length % 2 === 0) {
+      userMessages.unshift({
+        role: "user",
+        content: "⠀",
+      });
+    }
+
+    const isDeepThink = useChatStore.getState().isDeepThink;
+
+    // 参数
+    const params = {
+      // model: 'DeepSeek-R1-Distill-Qwen-14B',
+      model: 'Qwen3-30B',
+      enable_think: isDeepThink,
+      messages: userMessages,
+      stream: true,
+      document_id: (item && item.document) ? item.document.id : undefined,
+      // 进阶配置
+      max_tokens: undefined,
+      temperature: undefined,
+      web_search: options.config.web_search,
+    };
+
+    const controller = new AbortController();
+
+    options.onController?.(controller);
+
+    try {
+      const chatPath = this.apiPath;
+      const chatPayload = {
+        method: "POST",
+        body: JSON.stringify(params),
+        signal: controller.signal,
+        headers: {
+          'Content-Type': 'application/json',
+        },
+      };
+
+      const requestTimeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
+
+      let responseText = "";
+      let remainText = "";
+      let finished = false;
+
+      function animateResponseText() {
+        if (finished || controller.signal.aborted) {
+          responseText += remainText;
+          if (responseText?.length === 0) {
+            options.onError?.(new Error("请求已中止,请检查网络环境。"));
+          }
+          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);
+      }
+
+      animateResponseText();
+
+      const finish = () => {
+        if (!finished) {
+          finished = true;
+          let text = responseText + remainText;
+          options.onFinish(text);
+        }
+      };
+
+      controller.signal.onabort = finish;
+      let networkInfoPromise: Promise<void> | null = null;
+
+      fetchEventSource(chatPath, {
+        ...chatPayload,
+        async onopen(res: any) {
+          clearTimeout(requestTimeoutId);
+          const contentType = res.headers.get("content-type");
+
+          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) => {
+          const info = JSON.parse(msg.data);
+          if (info.event === 'finish') {
+            const isNetwork = useChatStore.getState().web_search;
+            if (isNetwork) {// 联网搜索结果
+              networkInfoPromise = (async () => {
+                try {
+                  const res: any = await api.get(`bigmodel/api/web/search/${info.id}`);
+                  const networkInfo = {
+                    list: res.data.search_result,
+                  };
+                  useChatStore.getState().updateCurrentSession((session) => {
+                    session.messages = session.messages.map((item, index) => {
+                      if (index === session.messages.length - 1 && item.role !== 'user') {
+                        return {
+                          ...item,
+                          networkInfo: networkInfo,
+                        };
+                      } else {
+                        return {
+                          ...item,
+                        }
+                      }
+                    });
+                  });
+                } catch (error) {
+                  console.error(error);
+                }
+              })();
+            }
+            return finish();
+          }
+
+          // 获取当前的数据
+          const currentData = info.data;
+          const formatStart = '```think';
+          const formatEnd = 'think```';
+
+          if (currentData?.startsWith(formatStart)) {
+            remainText += currentData.replace(formatStart, '```think\n');
+          } else if (currentData?.startsWith(formatEnd)) {
+            remainText += currentData.replace(formatEnd, '```');
+          } else {
+            remainText += currentData;
+          }
+        },
+        async onclose() {
+          finish();
+          if (networkInfoPromise) {
+            await networkInfoPromise; // 等待 networkInfo 加载完成
+          }
+          const session = useChatStore.getState().sessions[0];
+          const item = session.messages.find(item => item.role === 'user');
+          const dialogName = item ? item.content : '新的聊天';
+          const data = {
+            id: session.id,
+            appId: '1881269958412521255',
+            userId: undefined,
+            dialogName: dialogName,
+            messages: session.messages.map(item => ({
+              id: item.id,
+              date: item.date,
+              role: item.role,
+              content: item.content,
+              document: item.document,
+              networkInfo: item.networkInfo,
+            })),
+          };
+          await api.post('bigmodel/api/dialog/save', data);
+        },
+        onerror(e) {
+          options.onError?.(e);
+          throw e;
+        },
+        openWhenHidden: true,
+      });
+    } catch (e) {
+      options.onError?.(e as Error);
+    }
+  }
+
+  async usage() {
+    return {
+      used: 0,
+      total: 0,
+    };
+  }
+
+  async models(): Promise<LLMModel[]> {
+    return [];
+  }
+}

+ 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 [];
+  }
+}

+ 482 - 0
app/client/platforms/openai.ts

@@ -0,0 +1,482 @@
+"use client";
+// azure and openai, using same models. so using same LLMApi.
+import {
+  ApiPath,
+  DEFAULT_API_HOST,
+  DEFAULT_MODELS,
+  OpenaiPath,
+  Azure,
+  REQUEST_TIMEOUT_MS,
+  ServiceProvider,
+} from "@/app/constant";
+import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
+import { collectModelsWithDefaultModel } from "@/app/utils/model";
+import {
+  preProcessImageContent,
+  uploadImage,
+  base64Image2Blob,
+} from "@/app/utils/chat";
+import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
+import { DalleSize } from "@/app/typing";
+
+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,
+  getMessageImages,
+  isVisionModel,
+  isDalle3 as _isDalle3,
+} from "@/app/utils";
+
+export interface OpenAIListModelResponse {
+  object: string;
+  data: Array<{
+    id: string;
+    object: string;
+    root: string;
+  }>;
+}
+
+export interface RequestPayload {
+  messages: {
+    role: "system" | "user" | "assistant";
+    content: string | MultimodalContent[];
+  }[];
+  stream?: boolean;
+  model: string;
+  temperature: number;
+  presence_penalty: number;
+  frequency_penalty: number;
+  top_p: number;
+  max_tokens?: number;
+}
+
+export interface DalleRequestPayload {
+  model: string;
+  prompt: string;
+  response_format: "url" | "b64_json";
+  n: number;
+  size: DalleSize;
+}
+
+export class ChatGPTApi implements LLMApi {
+  private disableListModels = true;
+
+  path(path: string): string {
+    const accessStore = useAccessStore.getState();
+
+    let baseUrl = "";
+
+    const isAzure = path.includes("deployments");
+    if (accessStore.useCustomConfig) {
+      if (isAzure && !accessStore.isValidAzure()) {
+        throw Error(
+          "incomplete azure config, please check it in your settings page",
+        );
+      }
+
+      baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl;
+    }
+
+    if (baseUrl.length === 0) {
+      const isApp = !!getClientConfig()?.isApp;
+      const apiPath = isAzure ? ApiPath.Azure : ApiPath.OpenAI;
+      baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath;
+    }
+
+    if (baseUrl.endsWith("/")) {
+      baseUrl = baseUrl.slice(0, baseUrl.length - 1);
+    }
+    if (
+      !baseUrl.startsWith("http") &&
+      !isAzure &&
+      !baseUrl.startsWith(ApiPath.OpenAI)
+    ) {
+      baseUrl = "https://" + baseUrl;
+    }
+
+    console.log("[Proxy Endpoint] ", baseUrl, path);
+
+    // try rebuild url, when using cloudflare ai gateway in client
+    return cloudflareAIGatewayUrl([baseUrl, path].join("/"));
+  }
+
+  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 modelConfig = {
+      ...useAppConfig.getState().modelConfig,
+      ...useChatStore.getState().currentSession().mask.modelConfig,
+      ...{
+        model: options.config.model,
+        providerName: options.config.providerName,
+      },
+    };
+
+    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",
+      };
+    } 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 });
+      }
+
+      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 = !isDalle3 && !!options.config.stream;
+    const controller = new AbortController();
+    options.onController?.(controller);
+
+    try {
+      let chatPath = "";
+      if (modelConfig.providerName === ServiceProvider.Azure) {
+        // find model, and get displayName as deployName
+        const { models: configModels, customModels: configCustomModels } =
+          useAppConfig.getState();
+        const {
+          defaultModel,
+          customModels: accessCustomModels,
+          useCustomConfig,
+        } = useAccessStore.getState();
+        const models = collectModelsWithDefaultModel(
+          configModels,
+          [configCustomModels, accessCustomModels].join(","),
+          defaultModel,
+        );
+        const model = models.find(
+          (model) =>
+            model.name === modelConfig.model &&
+            model?.provider?.providerName === ServiceProvider.Azure,
+        );
+        chatPath = this.path(
+          (isDalle3 ? Azure.ImagePath : Azure.ChatPath)(
+            (model?.displayName ?? model?.name) as string,
+            useCustomConfig ? useAccessStore.getState().azureApiVersion : "",
+          ),
+        );
+      } else {
+        chatPath = this.path(
+          isDalle3 ? OpenaiPath.ImagePath : OpenaiPath.ChatPath,
+        );
+      }
+      const chatPayload = {
+        method: "POST",
+        body: JSON.stringify(requestPayload),
+        signal: controller.signal,
+        headers: getHeaders(),
+      };
+
+      // make a fetch request
+      const requestTimeoutId = setTimeout(
+        () => controller.abort(),
+        isDalle3 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow.
+      );
+
+      if (shouldStream) {
+        let responseText = "";
+        let remainText = "";
+        let finished = false;
+
+        // animate response to make it looks smooth
+        function animateResponseText() {
+          if (finished || controller.signal.aborted) {
+            responseText += remainText;
+            console.log("[Response Animation] finished");
+            if (responseText?.length === 0) {
+              options.onError?.(new Error("empty response from server"));
+            }
+            return;
+          }
+
+          if (remainText.length > 0) {
+            const fetchCount = Math.max(1, Math.round(remainText.length / 60));
+            const fetchText = remainText.slice(0, fetchCount);
+            responseText += fetchText;
+            remainText = remainText.slice(fetchCount);
+            options.onUpdate?.(responseText, fetchText);
+          }
+
+          requestAnimationFrame(animateResponseText);
+        }
+
+        // start animaion
+        animateResponseText();
+
+        const finish = () => {
+          if (!finished) {
+            finished = true;
+            options.onFinish(responseText + remainText);
+          }
+        };
+
+        controller.signal.onabort = finish;
+
+        fetchEventSource(chatPath, {
+          ...chatPayload,
+          async onopen(res) {
+            clearTimeout(requestTimeoutId);
+            const contentType = res.headers.get("content-type");
+            console.log(
+              "[OpenAI] request response content type: ",
+              contentType,
+            );
+
+            if (contentType?.startsWith("text/plain")) {
+              responseText = await res.clone().text();
+              return finish();
+            }
+
+            if (
+              !res.ok ||
+              !res.headers
+                .get("content-type")
+                ?.startsWith(EventStreamContentType) ||
+              res.status !== 200
+            ) {
+              const responseTexts = [responseText];
+              let extraInfo = await res.clone().text();
+              try {
+                const resJson = await res.clone().json();
+                extraInfo = prettyObject(resJson);
+              } catch {}
+
+              if (res.status === 401) {
+                responseTexts.push(Locale.Error.Unauthorized);
+              }
+
+              if (extraInfo) {
+                responseTexts.push(extraInfo);
+              }
+
+              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;
+              }
+
+              if (
+                textmoderation &&
+                textmoderation.length > 0 &&
+                ServiceProvider.Azure
+              ) {
+                const contentFilterResults =
+                  textmoderation[0]?.content_filter_results;
+                console.log(
+                  `[${ServiceProvider.Azure}] [Text Moderation] flagged categories result:`,
+                  contentFilterResults,
+                );
+              }
+            } catch (e) {
+              console.error("[Request] parse error", text, msg);
+            }
+          },
+          onclose() {
+            finish();
+          },
+          onerror(e) {
+            options.onError?.(e);
+            throw e;
+          },
+          openWhenHidden: true,
+        });
+      } else {
+        const res = await fetch(chatPath, chatPayload);
+        clearTimeout(requestTimeoutId);
+
+        const resJson = await res.json();
+        const message = await 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() {
+    const formatDate = (d: Date) =>
+      `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d
+        .getDate()
+        .toString()
+        .padStart(2, "0")}`;
+    const ONE_DAY = 1 * 24 * 60 * 60 * 1000;
+    const now = new Date();
+    const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
+    const startDate = formatDate(startOfMonth);
+    const endDate = formatDate(new Date(Date.now() + ONE_DAY));
+
+    const [used, subs] = await Promise.all([
+      fetch(
+        this.path(
+          `${OpenaiPath.UsagePath}?start_date=${startDate}&end_date=${endDate}`,
+        ),
+        {
+          method: "GET",
+          headers: getHeaders(),
+        },
+      ),
+      fetch(this.path(OpenaiPath.SubsPath), {
+        method: "GET",
+        headers: getHeaders(),
+      }),
+    ]);
+
+    if (used.status === 401) {
+      throw new Error(Locale.Error.Unauthorized);
+    }
+
+    if (!used.ok || !subs.ok) {
+      throw new Error("Failed to query usage from openai");
+    }
+
+    const response = (await used.json()) as {
+      total_usage?: number;
+      error?: {
+        type: string;
+        message: string;
+      };
+    };
+
+    const total = (await subs.json()) as {
+      hard_limit_usd?: number;
+    };
+
+    if (response.error && response.error.type) {
+      throw Error(response.error.message);
+    }
+
+    if (response.total_usage) {
+      response.total_usage = Math.round(response.total_usage) / 100;
+    }
+
+    if (total.hard_limit_usd) {
+      total.hard_limit_usd = Math.round(total.hard_limit_usd * 100) / 100;
+    }
+
+    return {
+      used: response.total_usage,
+      total: total.hard_limit_usd,
+    } as LLMUsage;
+  }
+
+  async models(): Promise<LLMModel[]> {
+    if (this.disableListModels) {
+      return DEFAULT_MODELS.slice();
+    }
+
+    const res = await fetch(this.path(OpenaiPath.ListModelPath), {
+      method: "GET",
+      headers: {
+        ...getHeaders(),
+      },
+    });
+
+    const resJson = (await res.json()) as OpenAIListModelResponse;
+    const chatModels = resJson.data?.filter((m) => m.id.startsWith("gpt-"));
+    console.log("[Models]", chatModels);
+
+    if (!chatModels) {
+      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,
+      },
+    }));
+  }
+}
+export { OpenaiPath };

+ 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 [];
+  }
+}

+ 78 - 0
app/command.ts

@@ -0,0 +1,78 @@
+import { useEffect } from "react";
+import { useSearchParams } from "react-router-dom";
+import Locale from "./locales";
+
+type Command = (param: string) => void;
+interface Commands {
+  fill?: Command;
+  submit?: Command;
+  mask?: Command;
+  code?: Command;
+  settings?: Command;
+}
+
+export function useCommand(commands: Commands = {}) {
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  useEffect(() => {
+    let shouldUpdate = false;
+    searchParams.forEach((param, name) => {
+      const commandName = name as keyof Commands;
+      if (typeof commands[commandName] === "function") {
+        commands[commandName]!(param);
+        searchParams.delete(name);
+        shouldUpdate = true;
+      }
+    });
+
+    if (shouldUpdate) {
+      setSearchParams(searchParams);
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [searchParams, commands]);
+}
+
+interface ChatCommands {
+  new?: Command;
+  newm?: Command;
+  next?: Command;
+  prev?: Command;
+  clear?: Command;
+  del?: Command;
+}
+
+// Compatible with Chinese colon character ":"
+export const ChatCommandPrefix = /^[::]/;
+
+export function useChatCommand(commands: ChatCommands = {}) {
+  function extract(userInput: string) {
+    const match = userInput.match(ChatCommandPrefix);
+    if (match) {
+      return userInput.slice(1) as keyof ChatCommands;
+    }
+    return userInput as keyof ChatCommands;
+  }
+
+  function search(userInput: string) {
+    const input = extract(userInput);
+    const desc = Locale.Chat.Commands;
+    return Object.keys(commands)
+      .filter((c) => c.startsWith(input))
+      .map((c) => ({
+        title: desc[c as keyof ChatCommands],
+        content: ":" + c,
+      }));
+  }
+
+  function match(userInput: string) {
+    const command = extract(userInput);
+    const matched = typeof commands[command] === "function";
+
+    return {
+      matched,
+      invoke: () => matched && commands[command]!(userInput),
+    };
+  }
+
+  return { match, search };
+}

+ 137 - 0
app/components/DeekSeekHome.tsx

@@ -0,0 +1,137 @@
+import * as React from 'react';
+import { useNavigate } from "react-router-dom";
+import { Dropdown, Spin } from 'antd';
+import { Chat } from './DeepSeekChat';
+import whiteLogo from "../icons/whiteLogo.png";
+import jkxz from "../icons/jkxz.png";
+import { useChatStore } from "../store";
+import { useMobileScreen } from '../utils';
+import api from "@/app/api/api";
+import './deepSeekHome.scss';
+
+const DeekSeek: React.FC = () => {
+    const chatStore = useChatStore();
+    const isMobileScreen = useMobileScreen();
+
+    const navigate = useNavigate();
+
+    const [listLoading, setListLoading] = React.useState(false);
+
+    type List = {
+        title: string,
+        children: {
+            title: string,
+            showMenu: string,
+            chatMode: string,
+            appId: string,
+            children: List[number]['children'],
+        }[],
+    }[];
+
+    const [list, setList] = React.useState<List>([]);
+
+    const init = async () => {
+        setListLoading(true);
+        try {
+            const res = await api.get('/deepseek/api/appType');
+            setList(res.data);
+        } catch (error) {
+            console.error(error);
+        } finally {
+            setListLoading(false);
+        }
+    }
+
+    React.useEffect(() => {
+        chatStore.clearSessions();
+        const userInfo = localStorage.getItem('userInfo');
+
+        if (userInfo) {
+            init();
+        }
+    }, []);
+
+    return (
+        <Spin spinning={listLoading}>
+            <div className='deekSeek'>
+                <div className='deekSeek-header' style={{ justifyContent: isMobileScreen ? 'flex-start' : 'center' }}>
+                    <div style={{ display: 'flex', alignItems: 'center', margin: '0 20px' }}>
+                        <img src={whiteLogo.src} style={{ width: 20, marginRight: 10 }} />
+                        <div style={{ whiteSpace: 'nowrap' }}>
+                            上海建科
+                        </div>
+                    </div>
+                    {
+                        list.map((item, index) => {
+                            return <Dropdown
+                                menu={{
+                                    items: item.children.map((child, i) => {
+                                        return {
+                                            key: 'child' + i,
+                                            label: <div
+                                                onClick={() => {
+                                                    const search = `?showMenu=${child.showMenu}&chatMode=${child.chatMode}&appId=${child.appId}`;
+                                                    if (child.appId) {
+                                                        navigate({
+                                                            pathname: '/knowledgeChat',
+                                                            search: search,
+                                                        })
+                                                    }
+                                                }}
+                                            >
+                                                {child.title}
+                                            </div>,
+                                            children: child.children ? child.children.map((record, ind) => {
+                                                return {
+                                                    key: 'record' + ind,
+                                                    label: <div
+                                                        onClick={() => {
+                                                            const search = `?showMenu=${record.showMenu}&chatMode=${record.chatMode}&appId=${record.appId}`;
+                                                            if (record.appId) {
+                                                                navigate({
+                                                                    pathname: '/knowledgeChat',
+                                                                    search: search,
+                                                                })
+                                                            }
+                                                        }}
+                                                    >
+                                                        {record.title}
+                                                    </div>
+                                                };
+                                            }) : undefined,
+                                        };
+                                    })
+                                }}
+                                key={index}
+                            >
+                                <div style={{ whiteSpace: 'nowrap', marginRight: 20, color: '#98b4fa', cursor: 'pointer' }}>
+                                    {item.title}
+                                </div>
+                            </Dropdown>
+                        })
+                    }
+                    {/*<div style={{ whiteSpace: 'nowrap', marginRight: 20, color: '#98b4fa', cursor: 'pointer' }} onClick={() => {*/}
+                    {/*    navigate({*/}
+                    {/*        pathname: '/deepseekChat',*/}
+                    {/*    })*/}
+                    {/*}}>*/}
+                    {/*    DeepSeek问答*/}
+                    {/*</div>*/}
+                </div>
+                <div className='deekSeek-content'>
+                    <div className='deekSeek-content-title'>
+                        <img src={jkxz.src} />
+                    </div>
+                    <div className='deekSeek-content-title-sm' style={{ marginBottom: isMobileScreen ? 14 : 20 }}>
+                        智能问答助手
+                    </div>
+                    <div className={isMobileScreen ? 'deekSeek-content-mobile' : 'deekSeek-content-pc'}>
+                        <Chat />
+                    </div>
+                </div>
+            </div>
+        </Spin>
+    );
+};
+
+export default DeekSeek;

+ 1954 - 0
app/components/DeepSeekChat.tsx

@@ -0,0 +1,1954 @@
+// 第三方库导入
+import { useDebouncedCallback } from "use-debounce";
+import React, {
+  useState,
+  useRef,
+  useEffect,
+  useMemo,
+  useCallback,
+  Fragment,
+  RefObject,
+} from "react";
+import dynamic from "next/dynamic";
+import { useNavigate, useLocation } from "react-router-dom";
+
+// 本地组件和工具导入
+import { IconButton } from "./button";
+import { MaskAvatar } from "./mask";
+import styles from "./chat.module.scss";
+
+// 图标资源导入
+import LeftIcon from "../icons/left.svg";
+import SendWhiteIcon from "../icons/send-white.svg";
+import BrainIcon from "../icons/brain.svg";
+import CopyIcon from "../icons/copy.svg";
+import LoadingIcon from "../icons/three-dots.svg";
+import ResetIcon from "../icons/reload.svg";
+import DeleteIcon from "../icons/clear.svg";
+import ConfirmIcon from "../icons/confirm.svg";
+import CancelIcon from "../icons/cancel.svg";
+import SizeIcon from "../icons/size.svg";
+import avatar from "../icons/aiIcon.png";
+import sdsk from "../icons/sdsk.png";
+import sdsk_selected from "../icons/sdsk_selected.png";
+import hlw from "../icons/hlw.png";
+import hlw_selected from "../icons/hlw_selected.png";
+import BotIcon from "../icons/bot.svg";
+import BlackBotIcon from "../icons/black-bot.svg";
+
+// 状态管理和类型导入
+import {
+  ChatMessage,
+  SubmitKey,
+  useChatStore,
+  useAccessStore,
+  Theme,
+  useAppConfig,
+  DEFAULT_TOPIC,
+  ModelType,
+  useGlobalStore,
+} from "../store";
+import { Prompt, usePromptStore } from "../store/prompt";
+
+// 工具函数导入
+import {
+  copyToClipboard,
+  selectOrCopy,
+  autoGrowTextArea,
+  useMobileScreen,
+  getMessageTextContent,
+  getMessageImages,
+  isVisionModel,
+  isDalle3,
+} from "../utils";
+import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
+
+// 客户端和类型导入
+import { ChatControllerPool } from "../client/controller";
+import { DalleSize } from "../typing";
+import type { RequestMessage } from "../client/api";
+
+// UI 组件导入
+import {
+  List,
+  ListItem,
+  Modal,
+  Selector,
+  showConfirm,
+  showToast,
+} from "./ui-lib";
+
+// 常量和本地化
+import Locale from "../locales";
+import {
+  CHAT_PAGE_SIZE,
+  LAST_INPUT_KEY,
+  Path,
+  REQUEST_TIMEOUT_MS,
+  UNFINISHED_INPUT,
+  ServiceProvider,
+  Plugin,
+} from "../constant";
+import { ContextPrompts, MaskConfig } from "./mask";
+import { useMaskStore } from "../store/mask";
+import { ChatCommandPrefix, useChatCommand, useCommand } from "../command";
+import { prettyObject } from "../utils/format";
+import { ExportMessageModal } from "./exporter";
+import { getClientConfig } from "../config/client";
+import { useAllModels } from "../utils/hooks";
+import { nanoid } from "nanoid";
+import { message, Upload, UploadProps, Tooltip, Drawer, Button } from "antd";
+import {
+  PaperClipOutlined,
+  SendOutlined,
+  FileOutlined,
+  FilePdfOutlined,
+  FileTextOutlined,
+  FileWordOutlined,
+  RightOutlined
+} from '@ant-design/icons';
+
+// Avatar组件替代实现
+function Avatar( props : { model? : string; avatar? : string } ) {
+  if ( props.model ) {
+    return (
+        <div className="no-dark">
+          { props.model?.startsWith( "gpt-4" ) ? (
+              <BlackBotIcon className="user-avatar" />
+          ) : (
+              <BotIcon className="user-avatar" />
+          ) }
+        </div>
+    );
+  }
+  
+  return (
+      <div className="user-avatar">
+        {/* 移除emoji头像,使用默认bot图标 */ }
+        <BotIcon className="user-avatar" />
+      </div>
+  );
+}
+
+export function createMessage( override : Partial<ChatMessage> ) : ChatMessage {
+  return {
+    id: nanoid(),
+    date: new Date().toLocaleString(),
+    role: "user",
+    content: "",
+    ...override,
+  };
+}
+
+export const BOT_HELLO : ChatMessage = createMessage( {
+  role: "assistant",
+  content: '你好,我是小智~\n' +
+      '我可以帮助你快速查询作业指导书、规范条文、公司信息等内容,如需获取上述内容,请点击上方导航栏中的「专业知识」或「职能管理」,选择相应的智能体进行提问。无论是现场技术,还是制度流程,我都会尽力为你解答!\n' +
+      '请注意:在这个对话框内,我只能请DeepSeek来帮忙回答常见通用问题哦!',
+} );
+
+const Markdown = dynamic( async () => ( await import("./markdown") ).Markdown, {
+  loading: () => <LoadingIcon />,
+} );
+
+export function SessionConfigModel( props : { onClose : () => void } ) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const maskStore = useMaskStore();
+  const navigate = useNavigate();
+  
+  return (
+      <div className="modal-mask">
+        <Modal
+            title={ Locale.Context.Edit }
+            onClose={ () => props.onClose() }
+            actions={ [
+              <IconButton
+                  key="reset"
+                  icon={ <ResetIcon /> }
+                  bordered
+                  text={ Locale.Chat.Config.Reset }
+                  onClick={ async () => {
+                    if ( await showConfirm( Locale.Memory.ResetConfirm ) ) {
+                      chatStore.updateCurrentSession(
+                          ( session ) => ( session.memoryPrompt = "" ),
+                      );
+                    }
+                  } }
+              />,
+              <IconButton
+                  key="copy"
+                  icon={ <CopyIcon /> }
+                  bordered
+                  text={ Locale.Chat.Config.SaveAs }
+                  onClick={ () => {
+                    navigate( Path.Masks );
+                    setTimeout( () => {
+                      maskStore.create( session.mask );
+                    }, 500 );
+                  } }
+              />,
+            ] }
+        >
+          <MaskConfig
+              mask={ session.mask }
+              updateMask={ ( updater ) => {
+                const mask = { ...session.mask };
+                updater( mask );
+                chatStore.updateCurrentSession( ( session ) => ( session.mask = mask ) );
+              } }
+              shouldSyncFromGlobal
+              extraListItems={
+                session.mask.modelConfig.sendMemory ? (
+                    <ListItem
+                        className="copyable"
+                        title={ `${ Locale.Memory.Title } (${ session.lastSummarizeIndex } of ${ session.messages.length })` }
+                        subTitle={ session.memoryPrompt || Locale.Memory.EmptyContent }
+                    ></ListItem>
+                ) : (
+                    <></>
+                )
+              }
+          ></MaskConfig>
+        </Modal>
+      </div>
+  );
+}
+
+// 提示词
+const CallWord = ( props : {
+  setUserInput : ( value : string ) => void,
+  doSubmit : ( userInput : string ) => void,
+} ) => {
+  const { setUserInput, doSubmit } = props
+  const list = [
+    {
+      title: '信息公布',
+      // text: '在哪里查看招聘信息?',
+      text: '今年上海建科工程咨询的校园招聘什么时候开始?如何查阅相关招聘信息?',
+    },
+    {
+      title: '招聘岗位',
+      // text: '今年招聘的岗位有哪些?',
+      text: '今年招聘的岗位有哪些?',
+    },
+    {
+      title: '专业要求',
+      // text: '招聘的岗位有什么专业要求?',
+      text: '招聘的岗位有什么专业要求?',
+    },
+    {
+      title: '工作地点',
+      // text: '全国都有工作地点吗?',
+      text: '工作地点是如何确定的?',
+    },
+    {
+      title: '薪资待遇',
+      // text: '企业可提供的薪资与福利待遇如何?',
+      text: '企业可提供的薪资与福利待遇如何?',
+    },
+    {
+      title: '职业发展',
+      // text: '我应聘贵单位,你们能提供怎样的职业发展规划?',
+      text: '公司有哪些职业发展通道?',
+    },
+    {
+      title: '落户政策',
+      // text: '公司是否能协助我落户?',
+      text: '关于落户支持?',
+    }
+  ]
+  
+  return (
+      <>
+        {
+          list.map( ( item, index ) => {
+            return <span
+                key={ index }
+                style={ {
+                  padding: '5px 10px',
+                  background: '#f6f7f8',
+                  color: '#5e5e66',
+                  borderRadius: 4,
+                  margin: '0 5px 10px 0',
+                  cursor: 'pointer',
+                  fontSize: 12
+                } }
+                onClick={ () => {
+                  const plan : string = '2';
+                  if ( plan === '1' ) {
+                    // 方案1.点击后出现在输入框内,用户自己点击发送
+                    setUserInput( item.text );
+                  } else {
+                    // 方案2.点击后直接发送
+                    doSubmit( item.text )
+                  }
+                } }
+            >
+            { item.title }
+          </span>
+          } )
+        }
+      </>
+  )
+}
+
+function PromptToast( props : {
+  showToast? : boolean;
+  showModal? : boolean;
+  setShowModal : ( _ : boolean ) => void;
+} ) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const context = session.mask.context;
+  
+  return (
+      <div className={ styles[ "prompt-toast" ] } key="prompt-toast">
+        { props.showToast && (
+            <div
+                className={ styles[ "prompt-toast-inner" ] + " clickable" }
+                role="button"
+                onClick={ () => props.setShowModal( true ) }
+            >
+              <BrainIcon />
+              <span className={ styles[ "prompt-toast-content" ] }>
+            { Locale.Context.Toast( context.length ) }
+          </span>
+            </div>
+        ) }
+        { props.showModal && (
+            <SessionConfigModel onClose={ () => props.setShowModal( false ) } />
+        ) }
+      </div>
+  );
+}
+
+function useSubmitHandler() {
+  const config = useAppConfig();
+  const submitKey = config.submitKey;
+  const isComposing = useRef( false );
+  
+  useEffect( () => {
+    const onCompositionStart = () => {
+      isComposing.current = true;
+    };
+    const onCompositionEnd = () => {
+      isComposing.current = false;
+    };
+    
+    window.addEventListener( "compositionstart", onCompositionStart );
+    window.addEventListener( "compositionend", onCompositionEnd );
+    
+    return () => {
+      window.removeEventListener( "compositionstart", onCompositionStart );
+      window.removeEventListener( "compositionend", onCompositionEnd );
+    };
+  }, [] );
+  
+  const shouldSubmit = ( e : React.KeyboardEvent<HTMLTextAreaElement> ) => {
+    // Fix Chinese input method "Enter" on Safari
+    if ( e.keyCode == 229 ) return false;
+    if ( e.key !== "Enter" ) return false;
+    if ( e.key === "Enter" && ( e.nativeEvent.isComposing || isComposing.current ) )
+      return false;
+    return (
+        ( config.submitKey === SubmitKey.AltEnter && e.altKey ) ||
+        ( config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey ) ||
+        ( config.submitKey === SubmitKey.ShiftEnter && e.shiftKey ) ||
+        ( config.submitKey === SubmitKey.MetaEnter && e.metaKey ) ||
+        ( config.submitKey === SubmitKey.Enter &&
+            !e.altKey &&
+            !e.ctrlKey &&
+            !e.shiftKey &&
+            !e.metaKey )
+    );
+  };
+  
+  return {
+    submitKey,
+    shouldSubmit,
+  };
+}
+
+export type RenderPrompt = Pick<Prompt, "title" | "content">;
+
+export function PromptHints( props : {
+  prompts : RenderPrompt[];
+  onPromptSelect : ( prompt : RenderPrompt ) => void;
+} ) {
+  const noPrompts = props.prompts.length === 0;
+  const [ selectIndex, setSelectIndex ] = useState( 0 );
+  const selectedRef = useRef<HTMLDivElement>( null );
+  
+  useEffect( () => {
+    setSelectIndex( 0 );
+  }, [ props.prompts.length ] );
+  
+  useEffect( () => {
+    const onKeyDown = ( e : KeyboardEvent ) => {
+      if ( noPrompts || e.metaKey || e.altKey || e.ctrlKey ) {
+        return;
+      }
+      // arrow up / down to select prompt
+      const changeIndex = ( delta : number ) => {
+        e.stopPropagation();
+        e.preventDefault();
+        const nextIndex = Math.max(
+            0,
+            Math.min( props.prompts.length - 1, selectIndex + delta ),
+        );
+        setSelectIndex( nextIndex );
+        selectedRef.current?.scrollIntoView( {
+          block: "center",
+        } );
+      };
+      
+      if ( e.key === "ArrowUp" ) {
+        changeIndex( 1 );
+      } else if ( e.key === "ArrowDown" ) {
+        changeIndex( - 1 );
+      } else if ( e.key === "Enter" ) {
+        const selectedPrompt = props.prompts.at( selectIndex );
+        if ( selectedPrompt ) {
+          props.onPromptSelect( selectedPrompt );
+        }
+      }
+    };
+    
+    window.addEventListener( "keydown", onKeyDown );
+    
+    return () => window.removeEventListener( "keydown", onKeyDown );
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [ props.prompts.length, selectIndex ] );
+  
+  if ( noPrompts ) return null;
+  return (
+      <div className={ styles[ "prompt-hints" ] }>
+        { props.prompts.map( ( prompt, i ) => (
+            <div
+                ref={ i === selectIndex ? selectedRef : null }
+                className={
+                    styles[ "prompt-hint" ] +
+                    ` ${ i === selectIndex ? styles[ "prompt-hint-selected" ] : "" }`
+                }
+                key={ prompt.title + i.toString() }
+                onClick={ () => props.onPromptSelect( prompt ) }
+                onMouseEnter={ () => setSelectIndex( i ) }
+            >
+              <div className={ styles[ "hint-title" ] }>{ prompt.title }</div>
+              <div className={ styles[ "hint-content" ] }>{ prompt.content }</div>
+            </div>
+        ) ) }
+      </div>
+  );
+}
+
+function ClearContextDivider() {
+  const chatStore = useChatStore();
+  
+  return (
+      <div
+          className={ styles[ "clear-context" ] }
+          onClick={ () =>
+              chatStore.updateCurrentSession(
+                  ( session ) => ( session.clearContextIndex = undefined ),
+              )
+          }
+      >
+        <div className={ styles[ "clear-context-tips" ] }>{ Locale.Context.Clear }</div>
+        <div className={ styles[ "clear-context-revert-btn" ] }>
+          { Locale.Context.Revert }
+        </div>
+      </div>
+  );
+}
+
+export function ChatAction( props : {
+  text : string;
+  icon : JSX.Element;
+  onClick : () => void;
+} ) {
+  const iconRef = useRef<HTMLDivElement>( null );
+  const textRef = useRef<HTMLDivElement>( null );
+  const [ width, setWidth ] = useState( {
+    full: 16,
+    icon: 16,
+  } );
+  
+  function updateWidth() {
+    if ( !iconRef.current || !textRef.current ) return;
+    const getWidth = ( dom : HTMLDivElement ) => dom.getBoundingClientRect().width;
+    const textWidth = getWidth( textRef.current );
+    const iconWidth = getWidth( iconRef.current );
+    setWidth( {
+      full: textWidth + iconWidth,
+      icon: iconWidth,
+    } );
+  }
+  
+  return (
+      <div
+          className={ `${ styles[ "chat-input-action" ] } clickable` }
+          onClick={ () => {
+            props.onClick();
+            setTimeout( updateWidth, 1 );
+          } }
+          onMouseEnter={ updateWidth }
+          onTouchStart={ updateWidth }
+          style={
+            {
+              "--icon-width": `${ width.icon }px`,
+              "--full-width": `${ width.full }px`,
+            } as React.CSSProperties
+          }
+      >
+        <div ref={ iconRef } className={ styles[ "icon" ] }>
+          { props.icon }
+        </div>
+        <div className={ styles[ "text" ] } ref={ textRef }>
+          { props.text }
+        </div>
+      </div>
+  );
+}
+
+function useScrollToBottom(
+    scrollRef : RefObject<HTMLDivElement>,
+    detach : boolean = false,
+) {
+  // for auto-scroll
+  
+  const [ autoScroll, setAutoScroll ] = useState( true );
+  
+  function scrollDomToBottom() {
+    const dom = scrollRef.current;
+    if ( dom ) {
+      requestAnimationFrame( () => {
+        setAutoScroll( true );
+        dom.scrollTo( 0, dom.scrollHeight );
+      } );
+    }
+  }
+  
+  // auto scroll
+  useEffect( () => {
+    if ( autoScroll && !detach ) {
+      scrollDomToBottom();
+    }
+  } );
+  
+  return {
+    scrollRef,
+    autoScroll,
+    setAutoScroll,
+    scrollDomToBottom,
+  };
+}
+
+export function ChatActions( props : {
+  setUserInput : ( value : string ) => void;
+  doSubmit : ( userInput : string ) => void;
+  uploadImage : () => void;
+  setAttachImages : ( images : string[] ) => void;
+  setUploading : ( uploading : boolean ) => void;
+  showPromptModal : () => void;
+  scrollToBottom : () => void;
+  showPromptHints : () => void;
+  hitBottom : boolean;
+  uploading : boolean;
+} ) {
+  const config = useAppConfig();
+  const navigate = useNavigate();
+  const chatStore = useChatStore();
+  
+  // switch themes
+  const theme = config.theme;
+  
+  function nextTheme() {
+    const themes = [ Theme.Auto, Theme.Light, Theme.Dark ];
+    const themeIndex = themes.indexOf( theme );
+    const nextIndex = ( themeIndex + 1 ) % themes.length;
+    const nextTheme = themes[ nextIndex ];
+    config.update( ( config ) => ( config.theme = nextTheme ) );
+  }
+  
+  // stop all responses
+  const couldStop = ChatControllerPool.hasPending();
+  const stopAll = () => ChatControllerPool.stopAll();
+  
+  // switch model
+  const currentModel = chatStore.currentSession().mask.modelConfig.model;
+  const currentProviderName =
+      chatStore.currentSession().mask.modelConfig?.providerName ||
+      ServiceProvider.OpenAI;
+  const allModels = useAllModels();
+  const models = useMemo( () => {
+    const filteredModels = allModels.filter( ( m ) => m.available );
+    const defaultModel = filteredModels.find( ( m ) => m.isDefault );
+    
+    if ( defaultModel ) {
+      const arr = [
+        defaultModel,
+        ...filteredModels.filter( ( m ) => m !== defaultModel ),
+      ];
+      return arr;
+    } else {
+      return filteredModels;
+    }
+  }, [ allModels ] );
+  const currentModelName = useMemo( () => {
+    const model = models.find(
+        ( m ) =>
+            m.name == currentModel &&
+            m?.provider?.providerName == currentProviderName,
+    );
+    return model?.displayName ?? "";
+  }, [ models, currentModel, currentProviderName ] );
+  const [ showModelSelector, setShowModelSelector ] = useState( false );
+  const [ showPluginSelector, setShowPluginSelector ] = useState( false );
+  const [ showUploadImage, setShowUploadImage ] = useState( false );
+  
+  type GuessList = string[]
+  const [ guessList, setGuessList ] = useState<GuessList>( [] );
+  
+  const [ showSizeSelector, setShowSizeSelector ] = useState( false );
+  const dalle3Sizes : DalleSize[] = [ "1024x1024", "1792x1024", "1024x1792" ];
+  const currentSize =
+      chatStore.currentSession().mask.modelConfig?.size ?? "1024x1024";
+  const session = chatStore.currentSession();
+  
+  useEffect( () => {
+    const show = isVisionModel( currentModel );
+    setShowUploadImage( show );
+    if ( !show ) {
+      props.setAttachImages( [] );
+      props.setUploading( false );
+    }
+    
+    // if current model is not available
+    // switch to first available model
+    const isUnavaliableModel = !models.some( ( m ) => m.name === currentModel );
+    if ( isUnavaliableModel && models.length > 0 ) {
+      // show next model to default model if exist
+      let nextModel = models.find( ( model ) => model.isDefault ) || models[ 0 ];
+      chatStore.updateCurrentSession( ( session ) => {
+        session.mask.modelConfig.model = nextModel.name;
+        session.mask.modelConfig.providerName = nextModel?.provider?.providerName as ServiceProvider;
+      } );
+      showToast(
+          nextModel?.provider?.providerName == "ByteDance"
+              ? nextModel.displayName
+              : nextModel.name,
+      );
+    }
+  }, [ chatStore, currentModel, models ] );
+  
+  return (
+      <div className={ styles[ "chat-input-actions" ] }>
+        { showModelSelector && (
+            <Selector
+                defaultSelectedValue={ `${ currentModel }@${ currentProviderName }` }
+                items={ models.map( ( m ) => ( {
+                  title: `${ m.displayName }${ m?.provider?.providerName
+                      ? "(" + m?.provider?.providerName + ")"
+                      : ""
+                  }`,
+                  value: `${ m.name }@${ m?.provider?.providerName }`,
+                } ) ) }
+                onClose={ () => setShowModelSelector( false ) }
+                onSelection={ ( s ) => {
+                  if ( s.length === 0 ) return;
+                  const [ model, providerName ] = s[ 0 ].split( "@" );
+                  chatStore.updateCurrentSession( ( session ) => {
+                    session.mask.modelConfig.model = model as ModelType;
+                    session.mask.modelConfig.providerName =
+                        providerName as ServiceProvider;
+                    session.mask.syncGlobalConfig = false;
+                  } );
+                  if ( providerName == "ByteDance" ) {
+                    const selectedModel = models.find(
+                        ( m ) =>
+                            m.name == model && m?.provider?.providerName == providerName,
+                    );
+                    showToast( selectedModel?.displayName ?? "" );
+                  } else {
+                    showToast( model );
+                  }
+                } }
+            />
+        ) }
+        
+        { 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 );
+                } }
+            />
+        ) }
+        
+        { 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>
+  );
+}
+
+export function EditMessageModal( props : { onClose : () => void } ) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const [ messages, setMessages ] = useState( session.messages.slice() );
+  
+  return (
+      <div className="modal-mask">
+        <Modal
+            title={ Locale.Chat.EditMessage.Title }
+            onClose={ props.onClose }
+            actions={ [
+              <IconButton
+                  text={ Locale.UI.Cancel }
+                  icon={ <CancelIcon /> }
+                  key="cancel"
+                  onClick={ () => {
+                    props.onClose();
+                  } }
+              />,
+              <IconButton
+                  type="primary"
+                  text={ Locale.UI.Confirm }
+                  icon={ <ConfirmIcon /> }
+                  key="ok"
+                  onClick={ () => {
+                    chatStore.updateCurrentSession(
+                        ( session ) => ( session.messages = messages ),
+                    );
+                    props.onClose();
+                  } }
+              />,
+            ] }
+        >
+          <List>
+            <ListItem
+                title={ Locale.Chat.EditMessage.Topic.Title }
+                subTitle={ Locale.Chat.EditMessage.Topic.SubTitle }
+            >
+              <input
+                  type="text"
+                  value={ session.topic }
+                  onInput={ ( e ) =>
+                      chatStore.updateCurrentSession(
+                          ( session ) => ( session.topic = e.currentTarget.value ),
+                      )
+                  }
+              ></input>
+            </ListItem>
+          </List>
+          <ContextPrompts
+              context={ messages }
+              updateContext={ ( updater ) => {
+                const newMessages = messages.slice();
+                updater( newMessages );
+                setMessages( newMessages );
+              } }
+          />
+        </Modal>
+      </div>
+  );
+}
+
+export function DeleteImageButton( props : { deleteImage : () => void } ) {
+  return (
+      <div className={ styles[ "delete-image" ] } onClick={ props.deleteImage }>
+        <DeleteIcon />
+      </div>
+  );
+}
+
+function _Chat() {
+  type RenderMessage = ChatMessage & { preview? : boolean };
+  
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const config = useAppConfig();
+  config.sendPreviewBubble = false;
+  const fontSize = config.fontSize;
+  const fontFamily = config.fontFamily;
+  
+  const [ showExport, setShowExport ] = useState( false );
+  
+  const inputRef = useRef<HTMLTextAreaElement>( null );
+  const [ userInput, setUserInput ] = useState( "" );
+  const [ isLoading, setIsLoading ] = useState( false );
+  const { submitKey, shouldSubmit } = useSubmitHandler();
+  const scrollRef = useRef<HTMLDivElement>( null );
+  const isScrolledToBottom = scrollRef?.current
+      ? Math.abs(
+      scrollRef.current.scrollHeight -
+      ( scrollRef.current.scrollTop + scrollRef.current.clientHeight ),
+  ) <= 1
+      : false;
+  const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(
+      scrollRef,
+      isScrolledToBottom,
+  );
+  const [ hitBottom, setHitBottom ] = useState( true );
+  const isMobileScreen = useMobileScreen();
+  const navigate = useNavigate();
+  const [ attachImages, setAttachImages ] = useState<string[]>( [] );
+  const [ uploading, setUploading ] = useState( false );
+  
+  // prompt hints
+  const promptStore = usePromptStore();
+  const [ promptHints, setPromptHints ] = useState<RenderPrompt[]>( [] );
+  const onSearch = useDebouncedCallback(
+      ( text : string ) => {
+        const matchedPrompts = promptStore.search( text );
+        setPromptHints( matchedPrompts );
+      },
+      100,
+      { leading: true, trailing: true },
+  );
+  
+  useEffect( () => {
+    chatStore.updateCurrentSession( ( session ) => {
+      session.appId = '1881269958412521255';
+    } );
+  }, [] )
+  
+  const [ inputRows, setInputRows ] = useState( 2 );
+  const measure = useDebouncedCallback(
+      () => {
+        const rows = inputRef.current ? autoGrowTextArea( inputRef.current ) : 1;
+        const inputRows = Math.min(
+            20,
+            Math.max( 2 + Number( !isMobileScreen ), rows ),
+        );
+        setInputRows( inputRows );
+      },
+      100,
+      {
+        leading: true,
+        trailing: true,
+      },
+  );
+  
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  useEffect( measure, [ userInput ] );
+  
+  // chat commands shortcuts
+  const chatCommands = useChatCommand( {
+    new: () => chatStore.newSession(),
+    // newm: () => navigate(Path.MaskChat),  // 关闭mask入口 ,后续有需求再二开
+    prev: () => chatStore.nextSession( - 1 ),
+    next: () => chatStore.nextSession( 1 ),
+    clear: () =>
+        chatStore.updateCurrentSession(
+            ( session ) => ( session.clearContextIndex = session.messages.length ),
+        ),
+    del: () => chatStore.deleteSession( chatStore.currentSessionIndex ),
+  } );
+  
+  // only search prompts when user input is short 
+  const SEARCH_TEXT_LIMIT = 30;
+  const onInput = ( text : string ) => {
+    setUserInput( text );
+    const n = text.trim().length;
+    
+    // clear search results
+    if ( n === 0 ) {
+      setPromptHints( [] );
+    } else if ( text.match( ChatCommandPrefix ) ) {
+      setPromptHints( chatCommands.search( text ) );
+    } else if ( !config.disablePromptHint && n < SEARCH_TEXT_LIMIT ) {
+      // check if need to trigger auto completion
+      if ( text.startsWith( "/" ) ) {
+        let searchText = text.slice( 1 );
+        onSearch( searchText );
+      }
+    }
+  };
+  
+  const doSubmit = ( userInput : string ) => {
+    if ( userInput.trim() === "" ) return;
+    const matchCommand = chatCommands.match( userInput );
+    if ( matchCommand.matched ) {
+      setUserInput( "" );
+      setPromptHints( [] );
+      matchCommand.invoke();
+      return;
+    }
+    setIsLoading( true );
+    chatStore.onUserInput( fileList, userInput, attachImages ).then( () => setIsLoading( false ) );
+    setAttachImages( [] );
+    localStorage.setItem( LAST_INPUT_KEY, userInput );
+    setUserInput( "" );
+    setPromptHints( [] );
+    if ( !isMobileScreen ) inputRef.current?.focus();
+    setAutoScroll( true );
+  };
+  
+  const onPromptSelect = ( prompt : RenderPrompt ) => {
+    setTimeout( () => {
+      setPromptHints( [] );
+      
+      const matchedChatCommand = chatCommands.match( prompt.content );
+      if ( matchedChatCommand.matched ) {
+        // if user is selecting a chat command, just trigger it
+        matchedChatCommand.invoke();
+        setUserInput( "" );
+      } else {
+        // or fill the prompt
+        setUserInput( prompt.content );
+      }
+      inputRef.current?.focus();
+    }, 30 );
+  };
+  
+  // stop response
+  const onUserStop = ( messageId : string ) => {
+    ChatControllerPool.stop( session.id, messageId );
+  };
+  
+  useEffect( () => {
+    chatStore.updateCurrentSession( ( session ) => {
+      const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
+      session.messages.forEach( ( m ) => {
+        // check if should stop all stale messages
+        if ( m.isError || new Date( m.date ).getTime() < stopTiming ) {
+          if ( m.streaming ) {
+            m.streaming = false;
+          }
+          
+          if ( m.content.length === 0 ) {
+            m.isError = true;
+            m.content = prettyObject( {
+              error: true,
+              message: "empty response",
+            } );
+          }
+        }
+      } );
+      
+      // auto sync mask config from global config
+      if ( session.mask.syncGlobalConfig ) {
+        console.log( "[Mask] syncing from global, name = ", session.mask.name );
+        session.mask.modelConfig = { ...config.modelConfig };
+      }
+    } );
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [] );
+  
+  // check if should send message
+  const onInputKeyDown = ( e : React.KeyboardEvent<HTMLTextAreaElement> ) => {
+    if (
+        e.key === "ArrowUp" &&
+        userInput.length <= 0 &&
+        !( e.metaKey || e.altKey || e.ctrlKey )
+    ) {
+      setUserInput( localStorage.getItem( LAST_INPUT_KEY ) ?? "" );
+      e.preventDefault();
+      return;
+    }
+    if ( shouldSubmit( e ) && promptHints.length === 0 ) {
+      doSubmit( userInput );
+      e.preventDefault();
+    }
+  };
+  
+  const onRightClick = ( e : any, message : ChatMessage ) => {
+    // copy to clipboard
+    if ( selectOrCopy( e.currentTarget, getMessageTextContent( message ) ) ) {
+      if ( userInput.length === 0 ) {
+        setUserInput( getMessageTextContent( message ) );
+      }
+      
+      e.preventDefault();
+    }
+  };
+  
+  const deleteMessage = ( msgId? : string ) => {
+    chatStore.updateCurrentSession(
+        ( session ) =>
+            ( session.messages = session.messages.filter( ( m ) => m.id !== msgId ) ),
+    );
+  };
+  
+  const onDelete = ( msgId : string ) => {
+    deleteMessage( msgId );
+  };
+  
+  const onResend = ( message : ChatMessage ) => {
+    // when it is resending a message
+    // 1. for a user's message, find the next bot response
+    // 2. for a bot's message, find the last user's input
+    // 3. delete original user input and bot's message
+    // 4. resend the user's input
+    
+    const resendingIndex = session.messages.findIndex(
+        ( m ) => m.id === message.id,
+    );
+    
+    if ( resendingIndex < 0 || resendingIndex >= session.messages.length ) {
+      console.error( "[Chat] failed to find resending message", message );
+      return;
+    }
+    
+    let userMessage : ChatMessage | undefined;
+    let botMessage : ChatMessage | undefined;
+    
+    if ( message.role === "assistant" ) {
+      // if it is resending a bot's message, find the user input for it
+      botMessage = message;
+      for ( let i = resendingIndex; i >= 0; i -= 1 ) {
+        if ( session.messages[ i ].role === "user" ) {
+          userMessage = session.messages[ i ];
+          break;
+        }
+      }
+    } else if ( message.role === "user" ) {
+      // if it is resending a user's input, find the bot's response
+      userMessage = message;
+      for ( let i = resendingIndex; i < session.messages.length; i += 1 ) {
+        if ( session.messages[ i ].role === "assistant" ) {
+          botMessage = session.messages[ i ];
+          break;
+        }
+      }
+    }
+    
+    if ( userMessage === undefined ) {
+      console.error( "[Chat] failed to resend", message );
+      return;
+    }
+    
+    // delete the original messages
+    deleteMessage( userMessage.id );
+    deleteMessage( botMessage?.id );
+    
+    // resend the message
+    setIsLoading( true );
+    const textContent = getMessageTextContent( userMessage );
+    const images = getMessageImages( userMessage );
+    chatStore.onUserInput( [], textContent, images ).then( () => setIsLoading( false ) );
+    inputRef.current?.focus();
+  };
+  
+  const onPinMessage = ( message : ChatMessage ) => {
+    chatStore.updateCurrentSession( ( session ) =>
+        session.mask.context.push( message ),
+    );
+    
+    showToast( Locale.Chat.Actions.PinToastContent, {
+      text: Locale.Chat.Actions.PinToastAction,
+      onClick: () => {
+        setShowPromptModal( true );
+      },
+    } );
+  };
+  
+  const context : RenderMessage[] = useMemo( () => {
+    return session.mask.hideContext ? [] : session.mask.context.slice();
+  }, [ session.mask.context, session.mask.hideContext ] );
+  const accessStore = useAccessStore();
+  
+  if (
+      context.length === 0 &&
+      session.messages.at( 0 )?.content !== BOT_HELLO.content
+  ) {
+    const copiedHello = Object.assign( {}, BOT_HELLO );
+    if ( !accessStore.isAuthorized() ) {
+      copiedHello.content = Locale.Error.Unauthorized;
+    }
+    context.push( copiedHello );
+  }
+  
+  // preview messages
+  const renderMessages = useMemo( () => {
+    return context.concat( session.messages as RenderMessage[] ).concat(
+        isLoading
+            ? [
+              {
+                ...createMessage( {
+                  role: "assistant",
+                  content: "……",
+                } ),
+                preview: true,
+              },
+            ]
+            : [],
+    ).concat(
+        userInput.length > 0 && config.sendPreviewBubble
+            ? [
+              {
+                ...createMessage( {
+                  role: "user",
+                  content: userInput,
+                } ),
+                preview: true,
+              },
+            ]
+            : [],
+    );
+  }, [
+    config.sendPreviewBubble,
+    context,
+    isLoading,
+    session.messages,
+    userInput,
+  ] );
+  
+  const [ msgRenderIndex, _setMsgRenderIndex ] = useState(
+      Math.max( 0, renderMessages.length - CHAT_PAGE_SIZE ),
+  );
+  
+  function setMsgRenderIndex( newIndex : number ) {
+    newIndex = Math.min( renderMessages.length - CHAT_PAGE_SIZE, newIndex );
+    newIndex = Math.max( 0, newIndex );
+    _setMsgRenderIndex( newIndex );
+  }
+  
+  const messages = useMemo( () => {
+    const endRenderIndex = Math.min(
+        msgRenderIndex + 3 * CHAT_PAGE_SIZE,
+        renderMessages.length,
+    );
+    return renderMessages.slice( msgRenderIndex, endRenderIndex );
+  }, [ msgRenderIndex, renderMessages ] );
+  
+  const onChatBodyScroll = ( e : HTMLElement ) => {
+    const bottomHeight = e.scrollTop + e.clientHeight;
+    const edgeThreshold = e.clientHeight;
+    
+    const isTouchTopEdge = e.scrollTop <= edgeThreshold;
+    const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold;
+    const isHitBottom =
+        bottomHeight >= e.scrollHeight - ( isMobileScreen ? 4 : 10 );
+    
+    const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE;
+    const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE;
+    
+    if ( isTouchTopEdge && !isTouchBottomEdge ) {
+      setMsgRenderIndex( prevPageMsgIndex );
+    } else if ( isTouchBottomEdge ) {
+      setMsgRenderIndex( nextPageMsgIndex );
+    }
+    
+    setHitBottom( isHitBottom );
+    setAutoScroll( isHitBottom );
+  };
+  
+  function scrollToBottom() {
+    setMsgRenderIndex( renderMessages.length - CHAT_PAGE_SIZE );
+    scrollDomToBottom();
+  }
+  
+  // clear context index = context length + index in messages
+  const clearContextIndex =
+      ( session.clearContextIndex ?? - 1 ) >= 0
+          ? session.clearContextIndex! + context.length - msgRenderIndex
+          : - 1;
+  
+  const [ showPromptModal, setShowPromptModal ] = useState( false );
+  
+  const clientConfig = useMemo( () => getClientConfig(), [] );
+  
+  const autoFocus = !isMobileScreen; // wont auto focus on mobile screen
+  const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
+  
+  useCommand( {
+    fill: setUserInput,
+    submit: ( text ) => {
+      doSubmit( text );
+    },
+    code: ( text ) => {
+      if ( accessStore.disableFastLink ) return;
+      console.log( "[Command] got code from url: ", text );
+      showConfirm( Locale.URLCommand.Code + `code = ${ text }` ).then( ( res ) => {
+        if ( res ) {
+          accessStore.update( ( access ) => ( access.accessCode = text ) );
+        }
+      } );
+    },
+    settings: ( text ) => {
+      if ( accessStore.disableFastLink ) return;
+      
+      try {
+        const payload = JSON.parse( text ) as {
+          key? : string;
+          url? : string;
+        };
+        
+        console.log( "[Command] got settings from url: ", payload );
+        
+        if ( payload.key || payload.url ) {
+          showConfirm(
+              Locale.URLCommand.Settings +
+              `\n${ JSON.stringify( payload, null, 4 ) }`,
+          ).then( ( res ) => {
+            if ( !res ) return;
+            if ( payload.key ) {
+              accessStore.update(
+                  ( access ) => ( access.openaiApiKey = payload.key! ),
+              );
+            }
+            if ( payload.url ) {
+              accessStore.update( ( access ) => ( access.openaiUrl = payload.url! ) );
+            }
+            accessStore.update( ( access ) => ( access.useCustomConfig = true ) );
+          } );
+        }
+      } catch {
+        console.error( "[Command] failed to get settings from url: ", text );
+      }
+    },
+  } );
+  
+  // edit / insert message modal
+  const [ isEditingMessage, setIsEditingMessage ] = useState( false );
+  
+  // remember unfinished input
+  useEffect( () => {
+    // try to load from local storage
+    const key = UNFINISHED_INPUT( session.id );
+    const mayBeUnfinishedInput = localStorage.getItem( key );
+    if ( mayBeUnfinishedInput && userInput.length === 0 ) {
+      setUserInput( mayBeUnfinishedInput );
+      localStorage.removeItem( key );
+    }
+    
+    const dom = inputRef.current;
+    return () => {
+      localStorage.setItem( key, dom?.value ?? "" );
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [] );
+  
+  const handlePaste = useCallback(
+      async ( event : React.ClipboardEvent<HTMLTextAreaElement> ) => {
+        const currentModel = chatStore.currentSession().mask.modelConfig.model;
+        if ( !isVisionModel( currentModel ) ) {
+          return;
+        }
+        const items = ( event.clipboardData || window.clipboardData ).items;
+        for ( const item of items ) {
+          if ( item.kind === "file" && item.type.startsWith( "image/" ) ) {
+            event.preventDefault();
+            const file = item.getAsFile();
+            if ( file ) {
+              const images : string[] = [];
+              images.push( ...attachImages );
+              images.push(
+                  ...( await new Promise<string[]>( ( res, rej ) => {
+                    setUploading( true );
+                    const imagesData : string[] = [];
+                    uploadImageRemote( file ).then( ( dataUrl ) => {
+                      imagesData.push( dataUrl );
+                      setUploading( false );
+                      res( imagesData );
+                    } ).catch( ( e ) => {
+                      setUploading( false );
+                      rej( e );
+                    } );
+                  } ) ),
+              );
+              const imagesLength = images.length;
+              
+              if ( imagesLength > 3 ) {
+                images.splice( 3, imagesLength - 3 );
+              }
+              setAttachImages( images );
+            }
+          }
+        }
+      },
+      [ attachImages, chatStore ],
+  );
+  
+  async function uploadImage() {
+    const images : string[] = [];
+    images.push( ...attachImages );
+    
+    images.push(
+        ...( await new Promise<string[]>( ( res, rej ) => {
+          const fileInput = document.createElement( "input" );
+          fileInput.type = "file";
+          fileInput.accept =
+              "image/png, image/jpeg, image/webp, image/heic, image/heif";
+          fileInput.multiple = true;
+          fileInput.onchange = ( event : any ) => {
+            setUploading( true );
+            const files = event.target.files;
+            const imagesData : string[] = [];
+            for ( let i = 0; i < files.length; i ++ ) {
+              const file = event.target.files[ i ];
+              uploadImageRemote( file ).then( ( dataUrl ) => {
+                imagesData.push( dataUrl );
+                if (
+                    imagesData.length === 3 ||
+                    imagesData.length === files.length
+                ) {
+                  setUploading( false );
+                  res( imagesData );
+                }
+              } ).catch( ( e ) => {
+                setUploading( false );
+                rej( e );
+              } );
+            }
+          };
+          fileInput.click();
+        } ) ),
+    );
+    
+    const imagesLength = images.length;
+    if ( imagesLength > 3 ) {
+      images.splice( 3, imagesLength - 3 );
+    }
+    setAttachImages( images );
+  }
+  
+  const [ fileList, setFileList ] = useState<any[]>( [] );
+  
+  // 上传配置
+  const uploadConfig : UploadProps = {
+    action: '/deepseek-api' + '/upload/file',
+    method: 'POST',
+    accept: [ '.pdf', '.txt', '.doc', '.docx' ].join( ',' ),
+  };
+  
+  interface FileIconProps {
+    fileName : string;
+  }
+  
+  const FileIcon : React.FC<FileIconProps> = ( props : FileIconProps ) => {
+    const style = {
+      fontSize: '30px',
+      color: '#3875f6',
+    }
+    
+    let icon = <FileOutlined style={ style } />
+    if ( props.fileName ) {
+      const suffix = props.fileName.split( '.' ).pop() || '';
+      switch ( suffix ) {
+        case 'pdf':
+          icon = <FilePdfOutlined style={ style } />
+          break;
+        case 'txt':
+          icon = <FileTextOutlined style={ style } />
+          break;
+        case 'doc':
+        case 'docx':
+          icon = <FileWordOutlined style={ style } />
+          break;
+        default:
+          break;
+      }
+    }
+    return icon;
+  }
+  
+  const [ isDeepThink, setIsDeepThink ] = useState<boolean>( chatStore.isDeepThink );
+  
+  // 切换聊天窗口后清理上传文件信息
+  useEffect( () => {
+    setFileList( [] )
+  }, [ chatStore.currentSession() ] )
+  
+  const couldStop = ChatControllerPool.hasPending();
+  const stopAll = () => ChatControllerPool.stopAll();
+  
+  // 切换聊天窗口后清理上传文件信息
+  useEffect( () => {
+    setWebSearch( false );
+  }, [ chatStore.currentSession() ] )
+  
+  const [ webSearch, setWebSearch ] = useState<boolean>( chatStore.web_search );
+  
+  const [ drawerOpen, setDrawerOpen ] = useState( false );
+  type DrawerList = {
+    title : string,
+    content : string,
+    web_url : string,
+  }[]
+  const [ drawerList, setDrawerList ] = useState<DrawerList>( [] );
+  
+  interface NetworkDrawerProps {
+    list : DrawerList,
+  }
+  
+  const NetworkDrawer : React.FC<NetworkDrawerProps> = ( props ) => {
+    return (
+        <Drawer
+            title='网页搜索'
+            open={ drawerOpen }
+            onClose={ () => {
+              setDrawerOpen( false );
+            } }
+        >
+          { props.list.map( ( item, index ) => {
+            return <div
+                style={ {
+                  padding: 10,
+                  background: '#fafafa',
+                  borderRadius: 4,
+                  marginBottom: 10,
+                  cursor: 'pointer',
+                } }
+                key={ index }
+                onClick={ () => {
+                  window.open( item.web_url );
+                } }
+            >
+              <div style={ {
+                margin: '5px 0',
+                fontSize: 16,
+                display: '-webkit-box',
+                WebkitBoxOrient: 'vertical',
+                WebkitLineClamp: 2,// 限制显示两行
+                overflow: 'hidden',
+              } }>
+                { item.title }
+              </div>
+              <div style={ {
+                color: '#afafaf',
+                display: '-webkit-box',
+                WebkitBoxOrient: 'vertical',
+                WebkitLineClamp: 4,// 限制显示两行
+                overflow: 'hidden',
+                textOverflow: 'ellipsis',
+              } }>
+                { item.content }
+              </div>
+            </div>
+          } )
+          }
+        </Drawer>
+    )
+  }
+  
+  return (
+      <div className={ styles.chat } key={ session.id }>
+        {
+            isMobileScreen && location.pathname !== '/' &&
+            <div className="window-header" data-tauri-drag-region>
+              <div style={ { display: 'flex', alignItems: 'center' } }
+                   className={ `window-header-title ${ styles[ "chat-body-title" ] }` }>
+                <div>
+                  <IconButton
+                      style={ { padding: 0, marginRight: 20 } }
+                      icon={ <LeftIcon /> }
+                      text={ Locale.NewChat.Return }
+                      onClick={ () => navigate( '/deepseekChat' ) }
+                  />
+                </div>
+              </div>
+            </div>
+        }
+        <div
+            className={ styles[ "chat-body" ] }
+            ref={ scrollRef }
+            onScroll={ ( e ) => onChatBodyScroll( e.currentTarget ) }
+            onMouseDown={ () => inputRef.current?.blur() }
+            onTouchStart={ () => {
+              inputRef.current?.blur();
+              setAutoScroll( false );
+            } }
+        >
+          <>
+            { messages.map( ( message, i ) => {
+              const isUser = message.role === "user";
+              const isContext = i < context.length;
+              const showActions =
+                  i > 0 &&
+                  !( message.preview || message.content.length === 0 ) &&
+                  !isContext;
+              const showTyping = message.preview || message.streaming;
+              
+              const shouldShowClearContextDivider = i === clearContextIndex - 1;
+              
+              return (
+                  <Fragment key={ message.id }>
+                    <div
+                        className={
+                          isUser ? styles[ "chat-message-user" ] : styles[ "chat-message" ]
+                        }
+                    >
+                      <div className={ styles[ "chat-message-container" ] }
+                           style={ { display: 'flex', flexDirection: 'column' } }>
+                        <div className={ styles[ "chat-message-header" ] }>
+                          <div className={ styles[ "chat-message-avatar" ] }>
+                            { isUser ? (
+                                // 在这里换头像
+                                <div style={ { position: 'relative' } }>
+                                  <div
+                                      style={ {
+                                        position: 'absolute',
+                                        zIndex: 2,
+                                        top: '50%',
+                                        left: '50%',
+                                        transform: ' translate(-110%, -100%)',
+                                        fontSize: 14,
+                                      } }>
+                                    我
+                                  </div>
+                                </div>
+                            ) : (
+                                <>
+                                  { [ "system" ].includes( message.role ) ? (
+                                      <Avatar avatar="2699-fe0f" />
+                                  ) : (
+                                      <MaskAvatar
+                                          avatar={ session.mask.avatar }
+                                          model={
+                                              message.model || session.mask.modelConfig.model
+                                          }
+                                      />
+                                  ) }
+                                </>
+                            ) }
+                          </div>
+                        </div>
+                        {
+                            isUser && message.document && message.document.id &&
+                            <a style={ {
+                              padding: '10px',
+                              background: '#f7f7f7',
+                              borderRadius: '10px',
+                              textDecoration: 'none',
+                              color: '#24292f',
+                              display: 'flex',
+                              alignItems: 'center'
+                            } } href={ message.document.url } target="_blank">
+                              <FileIcon fileName={ message.document.name } />
+                              <div style={ { marginLeft: 8, fontSize: '14px' } }>
+                                { message.document.name }
+                              </div>
+                            </a>
+                        }
+                        {/* {showTyping && (
+                      <div className={styles["chat-message-status"]}>
+                        正在输入…
+                      </div>
+                    )} */ }
+                        {
+                            message.networkInfo && message.networkInfo.list.length > 0 &&
+                            <div style={ { marginTop: 10 } }>
+                              <Button
+                                  icon={ <RightOutlined /> }
+                                  iconPosition='end'
+                                  onClick={ () => {
+                                    setDrawerList( message.networkInfo!.list );
+                                    setDrawerOpen( true );
+                                  } }
+                              >
+                                搜索到{ message.networkInfo.list.length }篇相关资料
+                              </Button>
+                              {
+                                  drawerOpen &&
+                                  <NetworkDrawer
+                                      list={ message.networkInfo.list }
+                                  />
+                              }
+                            </div>
+                        }
+                        <div className={ styles[ "chat-message-item" ] }>
+                          <Markdown
+                              key={ message.streaming ? "loading" : "done" }
+                              content={ getMessageTextContent( message ) }
+                              loading={
+                                  ( message.preview || message.streaming ) &&
+                                  message.content.length === 0 &&
+                                  !isUser
+                              }
+                              onDoubleClickCapture={ () => {
+                                if ( !isMobileScreen ) return;
+                                setUserInput( getMessageTextContent( message ) );
+                              } }
+                              fontSize={ fontSize }
+                              fontFamily={ fontFamily }
+                              parentRef={ scrollRef }
+                              defaultShow={ i >= messages.length - 6 }
+                          />
+                          { getMessageImages( message ).length == 1 && (
+                              <img
+                                  className={ styles[ "chat-message-item-image" ] }
+                                  src={ getMessageImages( message )[ 0 ] }
+                                  alt=""
+                              />
+                          ) }
+                          { getMessageImages( message ).length > 1 && (
+                              <div
+                                  className={ styles[ "chat-message-item-images" ] }
+                                  style={
+                                    {
+                                      "--image-count": getMessageImages( message ).length,
+                                    } as React.CSSProperties
+                                  }
+                              >
+                                { getMessageImages( message ).map( ( image, index ) => {
+                                  return (
+                                      <img
+                                          className={
+                                            styles[ "chat-message-item-image-multi" ]
+                                          }
+                                          key={ index }
+                                          src={ image }
+                                          alt=""
+                                      />
+                                  );
+                                } ) }
+                              </div>
+                          ) }
+                        </div>
+                      </div>
+                    </div>
+                    { shouldShowClearContextDivider && <ClearContextDivider /> }
+                  </Fragment>
+              );
+            } ) }
+          </>
+        </div>
+        <div className={ styles[ "chat-input-panel" ] }>
+          <ChatActions
+              setUserInput={ setUserInput }
+              doSubmit={ doSubmit }
+              uploadImage={ uploadImage }
+              setAttachImages={ setAttachImages }
+              setUploading={ setUploading }
+              showPromptModal={ () => setShowPromptModal( true ) }
+              scrollToBottom={ scrollToBottom }
+              hitBottom={ hitBottom }
+              uploading={ uploading }
+              showPromptHints={ () => {
+                if ( promptHints.length > 0 ) {
+                  setPromptHints( [] );
+                  return;
+                }
+                inputRef.current?.focus();
+                setUserInput( "/" );
+                onSearch( "" );
+              } }
+          />
+          {
+              fileList.length > 0 &&
+              <div style={ { marginBottom: 20 } }>
+                <Upload
+                    fileList={ fileList }
+                    onRemove={ ( file ) => {
+                      setFileList( fileList.filter( item => item.uid !== file.uid ) );
+                    } }
+                />
+              </div>
+          }
+          <label
+              className={ `${ styles[ "chat-input-panel-inner" ] } ${ attachImages.length != 0
+                  ? styles[ "chat-input-panel-inner-attach" ]
+                  : ""
+              }` }
+              htmlFor="chat-input"
+          >
+          <textarea
+              id="chat-input"
+              ref={ inputRef }
+              className={ styles[ "chat-input2" ] }
+              placeholder={ Locale.Chat.Input( submitKey ) }
+              onInput={ ( e ) => onInput( e.currentTarget.value ) }
+              value={ userInput }
+              onKeyDown={ onInputKeyDown }
+              onFocus={ scrollToBottom }
+              onClick={ scrollToBottom }
+              onPaste={ handlePaste }
+              rows={ inputRows }
+              autoFocus={ autoFocus }
+              style={ {
+                fontSize: config.fontSize,
+                fontFamily: config.fontFamily,
+              } }
+          />
+            { attachImages.length != 0 && (
+                <div className={ styles[ "attach-images" ] }>
+                  { attachImages.map( ( image, index ) => {
+                    return (
+                        <div
+                            key={ index }
+                            className={ styles[ "attach-image" ] }
+                            style={ { backgroundImage: `url("${ image }")` } }
+                        >
+                          <div className={ styles[ "attach-image-mask" ] }>
+                            <DeleteImageButton
+                                deleteImage={ () => {
+                                  setAttachImages(
+                                      attachImages.filter( ( _, i ) => i !== index ),
+                                  );
+                                } }
+                            />
+                          </div>
+                        </div>
+                    );
+                  } ) }
+                </div>
+            ) }
+            {/* 修改样式:输入框内部按钮区域 */ }
+            {/* <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 10 }}> */ }
+            <div className={ styles[ "chat-input-bottom-bar" ] }>
+              {/* <div style={{ display: 'flex', alignItems: 'center' }}> */ }
+              <div className={ styles[ "left-options" ] }>
+                
+                {/*深度思考R1按钮*/ }
+                
+                <Tooltip
+                    title={
+                      <span style={ { fontSize: 12, lineHeight: 1.4, minHeight: 24, padding: '4px 8px' } }>
+                    { isDeepThink ? '关闭深度思考模式' : '启用深度思考模式' }
+                  </span>
+                    }
+                    placement="left"
+                >
+                  <div
+                      // className={styles["option-item"]}
+                      style={ {
+                        padding: '0 12px',
+                        height: 28,
+                        borderRadius: 18,
+                        fontSize: 12,
+                        display: 'flex',
+                        justifyContent: 'center',
+                        alignItems: 'center',
+                        // marginRight: 10,
+                        cursor: 'pointer',
+                        background: isDeepThink ? '#dee9fc' : '#f3f4f6',
+                        color: isDeepThink ? '#3875f6' : '#000000',
+                        // border: `1px solid ${isDeepThink ? '#3875f6' : 'transparent'}`,
+                        transition: 'all 0.2s ease',
+                        userSelect: 'none'
+                      } }
+                      onClick={ () => {
+                        setIsDeepThink( !isDeepThink );
+                        chatStore.setIsDeepThink( !isDeepThink );
+                      } }
+                  >
+                    <img src={ isDeepThink ? sdsk_selected.src : sdsk.src }
+                         style={ {
+                           width: 16,
+                           height: 16,
+                         } }
+                    />
+                    <span style={ { fontSize: 11, marginLeft: 5 } }>
+                      深度思考
+                    </span>
+                  </div>
+                </Tooltip>
+                {/*联网搜索按钮*/ }
+                <div style={ {
+                  padding: '0 12px',
+                  height: 28,
+                  borderRadius: 18,
+                  fontSize: 12,
+                  display: 'flex',
+                  justifyContent: 'center',
+                  alignItems: 'center',
+                  cursor: 'pointer',
+                  background: webSearch ? '#dee9fc' : '#f3f4f6',
+                  color: webSearch ? '#3875f6' : '#000000',
+                  transition: 'all 0.2s ease',
+                  userSelect: 'none'
+                } }
+                     onClick={ () => {
+                       setWebSearch( !webSearch );
+                       chatStore.setWebSearch( !webSearch );
+                     } }
+                >
+                  
+                  <img src={ webSearch ? hlw_selected.src : hlw.src }
+                       style={ {
+                         width: 16,
+                         height: 16,
+                       } }
+                  />
+                  <span style={ { fontSize: 11, marginLeft: 5, marginRight: 10 } }>
+                    联网搜索
+                  </span>
+                </div>
+              </div>
+              
+              <div style={ { display: 'flex', alignItems: 'center' } }>
+                {
+                    !webSearch &&
+                    <div style={ { marginRight: 10 } }>
+                      <Upload
+                          { ...uploadConfig }
+                          showUploadList={ false }
+                          maxCount={ 1 }
+                          onChange={ ( info ) => {
+                            const fileList = info.fileList.map( ( file ) => {
+                              const data = file.response;
+                              return {
+                                ...file,
+                                url: data?.document_url || file.url,
+                                documentId: data?.document_id || '',
+                              }
+                            } );
+                            setFileList( fileList );
+                            if ( info.file.status === 'done' ) {// 上传成功
+                              const { code, message: msg } = info.file.response;
+                              if ( code === 200 ) {
+                                message.success( '上传成功' );
+                              } else {
+                                message.error( msg );
+                              }
+                            } else if ( info.file.status === 'error' ) {// 上传失败
+                              message.error( '上传失败' );
+                            }
+                          } }
+                      >
+                        <Tooltip
+                            title={
+                              <div style={ { padding: '4px 8px' } }>
+                                <div style={ {
+                                  fontSize: 12,
+                                  lineHeight: 1.4,
+                                  marginBottom: 6,
+                                } }>
+                                  上传附件 (识别文本和图表中的内容)
+                                </div>
+                                <div style={ {
+                                  fontSize: 10,
+                                  color: '#8c8c8c',
+                                  lineHeight: 1.4,
+                            }}>
+                              <span>
+                              仅支持单个PDF/Word/TXT文件格式
+                              </span>
+                              <span>
+                              (单个文件≤50MB)
+                              </span>
+
+                              </div>
+                              </div>}
+                            placement="top"
+                        >
+                          <div
+                              style={ {
+                                width: 28,
+                                height: 28,
+                                borderRadius: '50%',
+                                background: '#4357d2',
+                                display: 'flex',
+                                justifyContent: 'center',
+                                alignItems: 'center',
+                                cursor: 'pointer',
+                                transition: 'all 0.2s ease',
+                                userSelect: 'none'
+                              } }
+                          >
+                            <PaperClipOutlined style={ { color: '#FFFFFF', fontSize: '18px' } } />
+                          </div>
+                        </Tooltip>
+                      </Upload>
+                    </div>
+                }
+                <div
+                    style={ {
+                      width: 28,
+                      height: 28,
+                      borderRadius: '50%',
+                      background: '#4357d2',
+                      display: 'flex',
+                      justifyContent: 'center',
+                      alignItems: 'center',
+                      cursor: 'pointer',
+                    } }
+                    onClick={ () => {
+                      if ( couldStop ) {
+                        stopAll();
+                      } else {
+                        doSubmit( userInput );
+                      }
+                    } }
+                >
+                  {
+                    couldStop ?
+                        <div style={ { width: 13, height: 13, background: '#FFFFFF', borderRadius: 2 } }></div>
+                        :
+                        <div style={ { transform: 'rotate(-45deg)', padding: '0px 0px 3px 5px' } }>
+                          <SendOutlined style={ { color: '#FFFFFF' } } />
+                        </div>
+                  }
+                
+                </div>
+              </div>
+            </div>
+          </label>
+          
+          <div style={ { marginTop: 8, textAlign: 'center', color: '#888888', fontSize: 12 } }>
+            内容由AI生成,仅供参考
+          </div>
+        </div>
+        {
+            showExport && (
+                <ExportMessageModal onClose={ () => setShowExport( false ) } />
+            )
+        }
+        {
+            isEditingMessage && (
+                <EditMessageModal
+                    onClose={ () => {
+                      setIsEditingMessage( false );
+                    } }
+                />
+            )
+        }
+      </div>
+  );
+}
+
+export function Chat() {
+  const globalStore = useGlobalStore();
+  const chatStore = useChatStore();
+  const sessionIndex = chatStore.currentSessionIndex;
+  
+  useEffect( () => {
+    globalStore.setShowMenu( true );
+    chatStore.setModel( 'DeepSeek' );
+    chatStore.setWebSearch( false );
+  }, [] );
+  
+  return <_Chat key={ sessionIndex }></_Chat>;
+}

+ 1533 - 0
app/components/DeepSeekHomeChat.tsx

@@ -0,0 +1,1533 @@
+import { useDebouncedCallback } from "use-debounce";
+import React, {
+  useState,
+  useRef,
+  useEffect,
+  useMemo,
+  useCallback,
+  Fragment,
+  RefObject,
+} from "react";
+
+import SendWhiteIcon from "../icons/send-white.svg";
+import BrainIcon from "../icons/brain.svg";
+import CopyIcon from "../icons/copy.svg";
+import LoadingIcon from "../icons/three-dots.svg";
+import ResetIcon from "../icons/reload.svg";
+import DeleteIcon from "../icons/clear.svg";
+import ConfirmIcon from "../icons/confirm.svg";
+import CancelIcon from "../icons/cancel.svg";
+import SizeIcon from "../icons/size.svg";
+import avatar from "../icons/aiIcon.png";
+import sdsk from "../icons/sdsk.png";
+import hlw from "../icons/hlw.png";
+
+import {
+  SubmitKey,
+  useChatStore,
+  useAccessStore,
+  Theme,
+  useAppConfig,
+  DEFAULT_TOPIC,
+  ModelType,
+} from "../store";
+
+import {
+  copyToClipboard,
+  selectOrCopy,
+  autoGrowTextArea,
+  useMobileScreen,
+  getMessageTextContent,
+  getMessageImages,
+  isVisionModel,
+  isDalle3,
+} from "../utils";
+
+import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
+
+import dynamic from "next/dynamic";
+
+import { ChatControllerPool } from "../client/controller";
+import { DalleSize } from "../typing";
+import type { RequestMessage } from "../client/api";
+import { Prompt, usePromptStore } from "../store/prompt";
+import { useGlobalStore } from "../store";
+import Locale from "../locales";
+
+import { IconButton } from "./button";
+import styles from "./chat.module.scss";
+
+import {
+  List,
+  ListItem,
+  Modal,
+  Selector,
+  showConfirm,
+  showToast,
+} from "./ui-lib";
+import { useNavigate, useLocation } from "react-router-dom";
+import {
+  CHAT_PAGE_SIZE,
+  LAST_INPUT_KEY,
+  Path,
+  REQUEST_TIMEOUT_MS,
+  UNFINISHED_INPUT,
+  ServiceProvider,
+  Plugin,
+} from "../constant";
+import { ContextPrompts, MaskConfig } from "./mask";
+import { useMaskStore } from "../store/mask";
+import { ChatCommandPrefix, useChatCommand, useCommand } from "../command";
+import { prettyObject } from "../utils/format";
+import { ExportMessageModal } from "./exporter";
+import { getClientConfig } from "../config/client";
+import { useAllModels } from "../utils/hooks";
+import { nanoid } from "nanoid";
+import { SendOutlined } from '@ant-design/icons';
+
+export function createMessage(override: Partial<ChatMessage>): ChatMessage {
+  return {
+    id: nanoid(),
+    date: new Date().toLocaleString(),
+    role: "user",
+    content: "",
+    ...override,
+  };
+}
+
+export type ChatMessage = RequestMessage & {
+  date: string;
+  streaming?: boolean;
+  isError?: boolean;
+  id: string;
+  model?: ModelType;
+};
+
+export const BOT_HELLO: ChatMessage = createMessage({
+  role: "assistant",
+  content: '你好,我是小智~\n' +
+      '我可以帮助你快速查询作业指导书、规范条文、公司信息等内容,如需获取上述内容,请点击上方导航栏中的「专业知识」或「职能管理」,选择相应的智能体进行提问。无论是现场技术,还是制度流程,我都会尽力为你解答!\n' +
+      '请注意:在这个对话框内,我只能请DeepSeek来帮忙回答常见通用问题哦!',
+});
+
+const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
+  loading: () => <LoadingIcon />,
+});
+
+export function SessionConfigModel(props: { onClose: () => void }) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const maskStore = useMaskStore();
+  const navigate = useNavigate();
+
+  return (
+    <div className="modal-mask">
+      <Modal
+        title={Locale.Context.Edit}
+        onClose={() => props.onClose()}
+        actions={[
+          <IconButton
+            key="reset"
+            icon={<ResetIcon />}
+            bordered
+            text={Locale.Chat.Config.Reset}
+            onClick={async () => {
+              if (await showConfirm(Locale.Memory.ResetConfirm)) {
+                chatStore.updateCurrentSession(
+                  (session) => (session.memoryPrompt = ""),
+                );
+              }
+            }}
+          />,
+          <IconButton
+            key="copy"
+            icon={<CopyIcon />}
+            bordered
+            text={Locale.Chat.Config.SaveAs}
+            onClick={() => {
+              navigate(Path.Masks);
+              setTimeout(() => {
+                maskStore.create(session.mask);
+              }, 500);
+            }}
+          />,
+        ]}
+      >
+        <MaskConfig
+          mask={session.mask}
+          updateMask={(updater) => {
+            const mask = { ...session.mask };
+            updater(mask);
+            chatStore.updateCurrentSession((session) => (session.mask = mask));
+          }}
+          shouldSyncFromGlobal
+          extraListItems={
+            session.mask.modelConfig.sendMemory ? (
+              <ListItem
+                className="copyable"
+                title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
+                subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
+              ></ListItem>
+            ) : (
+              <></>
+            )
+          }
+        ></MaskConfig>
+      </Modal>
+    </div>
+  );
+}
+
+// 提示词
+const CallWord = (props: {
+  setUserInput: (value: string) => void,
+  doSubmit: (userInput: string) => void,
+}) => {
+  const { setUserInput, doSubmit } = props
+  const list = [
+    {
+      title: '信息公布',
+      // text: '在哪里查看招聘信息?',
+      text: '今年上海建科工程咨询的校园招聘什么时候开始?如何查阅相关招聘信息?',
+    },
+    {
+      title: '招聘岗位',
+      // text: '今年招聘的岗位有哪些?',
+      text: '今年招聘的岗位有哪些?',
+    },
+    {
+      title: '专业要求',
+      // text: '招聘的岗位有什么专业要求?',
+      text: '招聘的岗位有什么专业要求?',
+    },
+    {
+      title: '工作地点',
+      // text: '全国都有工作地点吗?',
+      text: '工作地点是如何确定的?',
+    },
+    {
+      title: '薪资待遇',
+      // text: '企业可提供的薪资与福利待遇如何?',
+      text: '企业可提供的薪资与福利待遇如何?',
+    },
+    {
+      title: '职业发展',
+      // text: '我应聘贵单位,你们能提供怎样的职业发展规划?',
+      text: '公司有哪些职业发展通道?',
+    },
+    {
+      title: '落户政策',
+      // text: '公司是否能协助我落户?',
+      text: '关于落户支持?',
+    }
+  ]
+
+  return (
+    <>
+      {
+        list.map((item, index) => {
+          return <span
+            key={index}
+            style={{
+              padding: '5px 10px',
+              background: '#f6f7f8',
+              color: '#5e5e66',
+              borderRadius: 4,
+              margin: '0 5px 10px 0',
+              cursor: 'pointer',
+              fontSize: 12
+            }}
+            onClick={() => {
+              const plan: string = '2';
+              if (plan === '1') {
+                // 方案1.点击后出现在输入框内,用户自己点击发送
+                setUserInput(item.text);
+              } else {
+                // 方案2.点击后直接发送
+                doSubmit(item.text)
+              }
+            }}
+          >
+            {item.title}
+          </span>
+        })
+      }
+    </>
+  )
+}
+
+function PromptToast(props: {
+  showToast?: boolean;
+  showModal?: boolean;
+  setShowModal: (_: boolean) => void;
+}) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const context = session.mask.context;
+
+  return (
+    <div className={styles["prompt-toast"]} key="prompt-toast">
+      {props.showToast && (
+        <div
+          className={styles["prompt-toast-inner"] + " clickable"}
+          role="button"
+          onClick={() => props.setShowModal(true)}
+        >
+          <BrainIcon />
+          <span className={styles["prompt-toast-content"]}>
+            {Locale.Context.Toast(context.length)}
+          </span>
+        </div>
+      )}
+      {props.showModal && (
+        <SessionConfigModel onClose={() => props.setShowModal(false)} />
+      )}
+    </div>
+  );
+}
+
+function useSubmitHandler() {
+  const config = useAppConfig();
+  const submitKey = config.submitKey;
+  const isComposing = useRef(false);
+
+  useEffect(() => {
+    const onCompositionStart = () => {
+      isComposing.current = true;
+    };
+    const onCompositionEnd = () => {
+      isComposing.current = false;
+    };
+
+    window.addEventListener("compositionstart", onCompositionStart);
+    window.addEventListener("compositionend", onCompositionEnd);
+
+    return () => {
+      window.removeEventListener("compositionstart", onCompositionStart);
+      window.removeEventListener("compositionend", onCompositionEnd);
+    };
+  }, []);
+
+  const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    // Fix Chinese input method "Enter" on Safari
+    if (e.keyCode == 229) return false;
+    if (e.key !== "Enter") return false;
+    if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current))
+      return false;
+    return (
+      (config.submitKey === SubmitKey.AltEnter && e.altKey) ||
+      (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
+      (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
+      (config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
+      (config.submitKey === SubmitKey.Enter &&
+        !e.altKey &&
+        !e.ctrlKey &&
+        !e.shiftKey &&
+        !e.metaKey)
+    );
+  };
+
+  return {
+    submitKey,
+    shouldSubmit,
+  };
+}
+
+export type RenderPrompt = Pick<Prompt, "title" | "content">;
+
+export function PromptHints(props: {
+  prompts: RenderPrompt[];
+  onPromptSelect: (prompt: RenderPrompt) => void;
+}) {
+  const noPrompts = props.prompts.length === 0;
+  const [selectIndex, setSelectIndex] = useState(0);
+  const selectedRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    setSelectIndex(0);
+  }, [props.prompts.length]);
+
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (noPrompts || e.metaKey || e.altKey || e.ctrlKey) {
+        return;
+      }
+      // arrow up / down to select prompt
+      const changeIndex = (delta: number) => {
+        e.stopPropagation();
+        e.preventDefault();
+        const nextIndex = Math.max(
+          0,
+          Math.min(props.prompts.length - 1, selectIndex + delta),
+        );
+        setSelectIndex(nextIndex);
+        selectedRef.current?.scrollIntoView({
+          block: "center",
+        });
+      };
+
+      if (e.key === "ArrowUp") {
+        changeIndex(1);
+      } else if (e.key === "ArrowDown") {
+        changeIndex(-1);
+      } else if (e.key === "Enter") {
+        const selectedPrompt = props.prompts.at(selectIndex);
+        if (selectedPrompt) {
+          props.onPromptSelect(selectedPrompt);
+        }
+      }
+    };
+
+    window.addEventListener("keydown", onKeyDown);
+
+    return () => window.removeEventListener("keydown", onKeyDown);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [props.prompts.length, selectIndex]);
+
+  if (noPrompts) return null;
+  return (
+    <div className={styles["prompt-hints"]}>
+      {props.prompts.map((prompt, i) => (
+        <div
+          ref={i === selectIndex ? selectedRef : null}
+          className={
+            styles["prompt-hint"] +
+            ` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}`
+          }
+          key={prompt.title + i.toString()}
+          onClick={() => props.onPromptSelect(prompt)}
+          onMouseEnter={() => setSelectIndex(i)}
+        >
+          <div className={styles["hint-title"]}>{prompt.title}</div>
+          <div className={styles["hint-content"]}>{prompt.content}</div>
+        </div>
+      ))}
+    </div>
+  );
+}
+
+function ClearContextDivider() {
+  const chatStore = useChatStore();
+
+  return (
+    <div
+      className={styles["clear-context"]}
+      onClick={() =>
+        chatStore.updateCurrentSession(
+          (session) => (session.clearContextIndex = undefined),
+        )
+      }
+    >
+      <div className={styles["clear-context-tips"]}>{Locale.Context.Clear}</div>
+      <div className={styles["clear-context-revert-btn"]}>
+        {Locale.Context.Revert}
+      </div>
+    </div>
+  );
+}
+
+export function ChatAction(props: {
+  text: string;
+  icon: JSX.Element;
+  onClick: () => void;
+}) {
+  const iconRef = useRef<HTMLDivElement>(null);
+  const textRef = useRef<HTMLDivElement>(null);
+  const [width, setWidth] = useState({
+    full: 16,
+    icon: 16,
+  });
+
+  function updateWidth() {
+    if (!iconRef.current || !textRef.current) return;
+    const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width;
+    const textWidth = getWidth(textRef.current);
+    const iconWidth = getWidth(iconRef.current);
+    setWidth({
+      full: textWidth + iconWidth,
+      icon: iconWidth,
+    });
+  }
+
+  return (
+    <div
+      className={`${styles["chat-input-action"]} clickable`}
+      onClick={() => {
+        props.onClick();
+        setTimeout(updateWidth, 1);
+      }}
+      onMouseEnter={updateWidth}
+      onTouchStart={updateWidth}
+      style={
+        {
+          "--icon-width": `${width.icon}px`,
+          "--full-width": `${width.full}px`,
+        } as React.CSSProperties
+      }
+    >
+      <div ref={iconRef} className={styles["icon"]}>
+        {props.icon}
+      </div>
+      <div className={styles["text"]} ref={textRef}>
+        {props.text}
+      </div>
+    </div>
+  );
+}
+
+function useScrollToBottom(
+  scrollRef: RefObject<HTMLDivElement>,
+  detach: boolean = false,
+) {
+  // for auto-scroll
+
+  const [autoScroll, setAutoScroll] = useState(true);
+
+  function scrollDomToBottom() {
+    const dom = scrollRef.current;
+    if (dom) {
+      requestAnimationFrame(() => {
+        setAutoScroll(true);
+        dom.scrollTo(0, dom.scrollHeight);
+      });
+    }
+  }
+
+  // auto scroll
+  useEffect(() => {
+    if (autoScroll && !detach) {
+      scrollDomToBottom();
+    }
+  });
+
+  return {
+    scrollRef,
+    autoScroll,
+    setAutoScroll,
+    scrollDomToBottom,
+  };
+}
+
+export function ChatActions(props: {
+  setUserInput: (value: string) => void;
+  doSubmit: (userInput: string) => void;
+  uploadImage: () => void;
+  setAttachImages: (images: string[]) => void;
+  setUploading: (uploading: boolean) => void;
+  showPromptModal: () => void;
+  scrollToBottom: () => void;
+  showPromptHints: () => void;
+  hitBottom: boolean;
+  uploading: boolean;
+}) {
+  const config = useAppConfig();
+  const navigate = useNavigate();
+  const chatStore = useChatStore();
+
+  // switch themes
+  const theme = config.theme;
+
+  function nextTheme() {
+    const themes = [Theme.Auto, Theme.Light, Theme.Dark];
+    const themeIndex = themes.indexOf(theme);
+    const nextIndex = (themeIndex + 1) % themes.length;
+    const nextTheme = themes[nextIndex];
+    config.update((config) => (config.theme = nextTheme));
+  }
+
+  // stop all responses
+  const couldStop = ChatControllerPool.hasPending();
+  const stopAll = () => ChatControllerPool.stopAll();
+
+  // switch model
+  const currentModel = chatStore.currentSession().mask.modelConfig.model;
+  const currentProviderName =
+    chatStore.currentSession().mask.modelConfig?.providerName ||
+    ServiceProvider.OpenAI;
+  const allModels = useAllModels();
+  const models = useMemo(() => {
+    const filteredModels = allModels.filter((m) => m.available);
+    const defaultModel = filteredModels.find((m) => m.isDefault);
+
+    if (defaultModel) {
+      const arr = [
+        defaultModel,
+        ...filteredModels.filter((m) => m !== defaultModel),
+      ];
+      return arr;
+    } else {
+      return filteredModels;
+    }
+  }, [allModels]);
+  const currentModelName = useMemo(() => {
+    const model = models.find(
+      (m) =>
+        m.name == currentModel &&
+        m?.provider?.providerName == currentProviderName,
+    );
+    return model?.displayName ?? "";
+  }, [models, currentModel, currentProviderName]);
+  const [showModelSelector, setShowModelSelector] = useState(false);
+  const [showPluginSelector, setShowPluginSelector] = useState(false);
+  const [showUploadImage, setShowUploadImage] = useState(false);
+
+  type GuessList = string[]
+  const [guessList, setGuessList] = useState<GuessList>([]);
+
+  const [showSizeSelector, setShowSizeSelector] = useState(false);
+  const dalle3Sizes: DalleSize[] = ["1024x1024", "1792x1024", "1024x1792"];
+  const currentSize =
+    chatStore.currentSession().mask.modelConfig?.size ?? "1024x1024";
+  const session = chatStore.currentSession();
+
+  useEffect(() => {
+    const show = isVisionModel(currentModel);
+    setShowUploadImage(show);
+    if (!show) {
+      props.setAttachImages([]);
+      props.setUploading(false);
+    }
+
+    // if current model is not available
+    // switch to first available model
+    const isUnavaliableModel = !models.some((m) => m.name === currentModel);
+    if (isUnavaliableModel && models.length > 0) {
+      // show next model to default model if exist
+      let nextModel = models.find((model) => model.isDefault) || models[0];
+      chatStore.updateCurrentSession((session) => {
+        session.mask.modelConfig.model = nextModel.name;
+        session.mask.modelConfig.providerName = nextModel?.provider?.providerName as ServiceProvider;
+      });
+      showToast(
+        nextModel?.provider?.providerName == "ByteDance"
+          ? nextModel.displayName
+          : nextModel.name,
+      );
+    }
+  }, [chatStore, currentModel, models]);
+
+  return (
+    <div className={styles["chat-input-actions"]}>
+      {showModelSelector && (
+        <Selector
+          defaultSelectedValue={`${currentModel}@${currentProviderName}`}
+          items={models.map((m) => ({
+            title: `${m.displayName}${m?.provider?.providerName
+              ? "(" + m?.provider?.providerName + ")"
+              : ""
+              }`,
+            value: `${m.name}@${m?.provider?.providerName}`,
+          }))}
+          onClose={() => setShowModelSelector(false)}
+          onSelection={(s) => {
+            if (s.length === 0) return;
+            const [model, providerName] = s[0].split("@");
+            chatStore.updateCurrentSession((session) => {
+              session.mask.modelConfig.model = model as ModelType;
+              session.mask.modelConfig.providerName =
+                providerName as ServiceProvider;
+              session.mask.syncGlobalConfig = false;
+            });
+            if (providerName == "ByteDance") {
+              const selectedModel = models.find(
+                (m) =>
+                  m.name == model && m?.provider?.providerName == providerName,
+              );
+              showToast(selectedModel?.displayName ?? "");
+            } else {
+              showToast(model);
+            }
+          }}
+        />
+      )}
+
+      {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);
+          }}
+        />
+      )}
+
+      {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>
+  );
+}
+
+export function EditMessageModal(props: { onClose: () => void }) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const [messages, setMessages] = useState(session.messages.slice());
+
+  return (
+    <div className="modal-mask">
+      <Modal
+        title={Locale.Chat.EditMessage.Title}
+        onClose={props.onClose}
+        actions={[
+          <IconButton
+            text={Locale.UI.Cancel}
+            icon={<CancelIcon />}
+            key="cancel"
+            onClick={() => {
+              props.onClose();
+            }}
+          />,
+          <IconButton
+            type="primary"
+            text={Locale.UI.Confirm}
+            icon={<ConfirmIcon />}
+            key="ok"
+            onClick={() => {
+              chatStore.updateCurrentSession(
+                (session) => (session.messages = messages),
+              );
+              props.onClose();
+            }}
+          />,
+        ]}
+      >
+        <List>
+          <ListItem
+            title={Locale.Chat.EditMessage.Topic.Title}
+            subTitle={Locale.Chat.EditMessage.Topic.SubTitle}
+          >
+            <input
+              type="text"
+              value={session.topic}
+              onInput={(e) =>
+                chatStore.updateCurrentSession(
+                  (session) => (session.topic = e.currentTarget.value),
+                )
+              }
+            ></input>
+          </ListItem>
+        </List>
+        <ContextPrompts
+          context={messages}
+          updateContext={(updater) => {
+            const newMessages = messages.slice();
+            updater(newMessages);
+            setMessages(newMessages);
+          }}
+        />
+      </Modal>
+    </div>
+  );
+}
+
+export function DeleteImageButton(props: { deleteImage: () => void }) {
+  return (
+    <div className={styles["delete-image"]} onClick={props.deleteImage}>
+      <DeleteIcon />
+    </div>
+  );
+}
+
+function _Chat() {
+  type RenderMessage = ChatMessage & { preview?: boolean };
+
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const config = useAppConfig();
+  const fontSize = config.fontSize;
+  const fontFamily = config.fontFamily;
+
+  const [showExport, setShowExport] = useState(false);
+
+  const inputRef = useRef<HTMLTextAreaElement>(null);
+  const [userInput, setUserInput] = useState("");
+  const [isLoading, setIsLoading] = useState(false);
+  const { submitKey, shouldSubmit } = useSubmitHandler();
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const isScrolledToBottom = scrollRef?.current
+    ? Math.abs(
+      scrollRef.current.scrollHeight -
+      (scrollRef.current.scrollTop + scrollRef.current.clientHeight),
+    ) <= 1
+    : false;
+  const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(
+    scrollRef,
+    isScrolledToBottom,
+  );
+  const [hitBottom, setHitBottom] = useState(true);
+  const isMobileScreen = useMobileScreen();
+  const navigate = useNavigate();
+  const [attachImages, setAttachImages] = useState<string[]>([]);
+  const [uploading, setUploading] = useState(false);
+
+  // prompt hints
+  const promptStore = usePromptStore();
+  const [promptHints, setPromptHints] = useState<RenderPrompt[]>([]);
+  const onSearch = useDebouncedCallback(
+    (text: string) => {
+      const matchedPrompts = promptStore.search(text);
+      setPromptHints(matchedPrompts);
+    },
+    100,
+    { leading: true, trailing: true },
+  );
+
+  const [inputRows, setInputRows] = useState(2);
+  const measure = useDebouncedCallback(
+    () => {
+      const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
+      const inputRows = Math.min(
+        20,
+        Math.max(2 + Number(!isMobileScreen), rows),
+      );
+      setInputRows(inputRows);
+    },
+    100,
+    {
+      leading: true,
+      trailing: true,
+    },
+  );
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  useEffect(measure, [userInput]);
+
+  // chat commands shortcuts
+  const chatCommands = useChatCommand({
+    new: () => chatStore.newSession(),
+    // newm: () => navigate(Path.MaskChat),  // 关闭mask入口 ,后续有需求再二开
+    prev: () => chatStore.nextSession(-1),
+    next: () => chatStore.nextSession(1),
+    clear: () =>
+      chatStore.updateCurrentSession(
+        (session) => (session.clearContextIndex = session.messages.length),
+      ),
+    del: () => chatStore.deleteSession(chatStore.currentSessionIndex),
+  });
+
+  // only search prompts when user input is short
+  const SEARCH_TEXT_LIMIT = 30;
+  const onInput = (text: string) => {
+    setUserInput(text);
+    const n = text.trim().length;
+
+    // clear search results
+    if (n === 0) {
+      setPromptHints([]);
+    } else if (text.match(ChatCommandPrefix)) {
+      setPromptHints(chatCommands.search(text));
+    } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
+      // check if need to trigger auto completion
+      if (text.startsWith("/")) {
+        let searchText = text.slice(1);
+        onSearch(searchText);
+      }
+    }
+  };
+
+  const doSubmit = (userInput: string) => {
+    if (userInput.trim() === "") return;
+    const matchCommand = chatCommands.match(userInput);
+    if (matchCommand.matched) {
+      setUserInput("");
+      setPromptHints([]);
+      matchCommand.invoke();
+      return;
+    }
+    setIsLoading(true);
+    chatStore.onUserInput([], userInput, attachImages).then(() => setIsLoading(false));
+    setAttachImages([]);
+    localStorage.setItem(LAST_INPUT_KEY, userInput);
+    setUserInput("");
+    setPromptHints([]);
+    if (!isMobileScreen) inputRef.current?.focus();
+    setAutoScroll(true);
+  };
+
+  const onPromptSelect = (prompt: RenderPrompt) => {
+    setTimeout(() => {
+      setPromptHints([]);
+
+      const matchedChatCommand = chatCommands.match(prompt.content);
+      if (matchedChatCommand.matched) {
+        // if user is selecting a chat command, just trigger it
+        matchedChatCommand.invoke();
+        setUserInput("");
+      } else {
+        // or fill the prompt
+        setUserInput(prompt.content);
+      }
+      inputRef.current?.focus();
+    }, 30);
+  };
+
+  // stop response
+  const onUserStop = (messageId: string) => {
+    ChatControllerPool.stop(session.id, messageId);
+  };
+
+  useEffect(() => {
+    chatStore.updateCurrentSession((session) => {
+      const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
+      session.messages.forEach((m) => {
+        // check if should stop all stale messages
+        if (m.isError || new Date(m.date).getTime() < stopTiming) {
+          if (m.streaming) {
+            m.streaming = false;
+          }
+
+          if (m.content.length === 0) {
+            m.isError = true;
+            m.content = prettyObject({
+              error: true,
+              message: "empty response",
+            });
+          }
+        }
+      });
+
+      // auto sync mask config from global config
+      if (session.mask.syncGlobalConfig) {
+        console.log("[Mask] syncing from global, name = ", session.mask.name);
+        session.mask.modelConfig = { ...config.modelConfig };
+      }
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  // check if should send message
+  const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    if (
+      e.key === "ArrowUp" &&
+      userInput.length <= 0 &&
+      !(e.metaKey || e.altKey || e.ctrlKey)
+    ) {
+      setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
+      e.preventDefault();
+      return;
+    }
+    if (shouldSubmit(e) && promptHints.length === 0) {
+      doSubmit(userInput);
+      e.preventDefault();
+    }
+  };
+
+  const onRightClick = (e: any, message: ChatMessage) => {
+    // copy to clipboard
+    if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) {
+      if (userInput.length === 0) {
+        setUserInput(getMessageTextContent(message));
+      }
+
+      e.preventDefault();
+    }
+  };
+
+  const deleteMessage = (msgId?: string) => {
+    chatStore.updateCurrentSession(
+      (session) =>
+        (session.messages = session.messages.filter((m) => m.id !== msgId)),
+    );
+  };
+
+  const onDelete = (msgId: string) => {
+    deleteMessage(msgId);
+  };
+
+  const onResend = (message: ChatMessage) => {
+    // when it is resending a message
+    // 1. for a user's message, find the next bot response
+    // 2. for a bot's message, find the last user's input
+    // 3. delete original user input and bot's message
+    // 4. resend the user's input
+
+    const resendingIndex = session.messages.findIndex(
+      (m) => m.id === message.id,
+    );
+
+    if (resendingIndex < 0 || resendingIndex >= session.messages.length) {
+      console.error("[Chat] failed to find resending message", message);
+      return;
+    }
+
+    let userMessage: ChatMessage | undefined;
+    let botMessage: ChatMessage | undefined;
+
+    if (message.role === "assistant") {
+      // if it is resending a bot's message, find the user input for it
+      botMessage = message;
+      for (let i = resendingIndex; i >= 0; i -= 1) {
+        if (session.messages[i].role === "user") {
+          userMessage = session.messages[i];
+          break;
+        }
+      }
+    } else if (message.role === "user") {
+      // if it is resending a user's input, find the bot's response
+      userMessage = message;
+      for (let i = resendingIndex; i < session.messages.length; i += 1) {
+        if (session.messages[i].role === "assistant") {
+          botMessage = session.messages[i];
+          break;
+        }
+      }
+    }
+
+    if (userMessage === undefined) {
+      console.error("[Chat] failed to resend", message);
+      return;
+    }
+
+    // delete the original messages
+    deleteMessage(userMessage.id);
+    deleteMessage(botMessage?.id);
+
+    // resend the message
+    setIsLoading(true);
+    const textContent = getMessageTextContent(userMessage);
+    const images = getMessageImages(userMessage);
+    chatStore.onUserInput([], textContent, images).then(() => setIsLoading(false));
+    inputRef.current?.focus();
+  };
+
+  const onPinMessage = (message: ChatMessage) => {
+    chatStore.updateCurrentSession((session) =>
+      session.mask.context.push(message),
+    );
+
+    showToast(Locale.Chat.Actions.PinToastContent, {
+      text: Locale.Chat.Actions.PinToastAction,
+      onClick: () => {
+        setShowPromptModal(true);
+      },
+    });
+  };
+
+  const context: RenderMessage[] = useMemo(() => {
+    return session.mask.hideContext ? [] : session.mask.context.slice();
+  }, [session.mask.context, session.mask.hideContext]);
+  const accessStore = useAccessStore();
+
+  if (
+    context.length === 0 &&
+    session.messages.at(0)?.content !== BOT_HELLO.content
+  ) {
+    const copiedHello = Object.assign({}, BOT_HELLO);
+    if (!accessStore.isAuthorized()) {
+      copiedHello.content = Locale.Error.Unauthorized;
+    }
+    context.push(copiedHello);
+  }
+
+  // preview messages
+  const renderMessages = useMemo(() => {
+    return context.concat(session.messages as RenderMessage[]).concat(
+      isLoading
+        ? [
+          {
+            ...createMessage({
+              role: "assistant",
+              content: "……",
+            }),
+            preview: true,
+          },
+        ]
+        : [],
+    ).concat(
+      userInput.length > 0 && config.sendPreviewBubble
+        ? [
+          {
+            ...createMessage({
+              role: "user",
+              content: userInput,
+            }),
+            preview: true,
+          },
+        ]
+        : [],
+    );
+  }, [
+    config.sendPreviewBubble,
+    context,
+    isLoading,
+    session.messages,
+    userInput,
+  ]);
+
+  const [msgRenderIndex, _setMsgRenderIndex] = useState(
+    Math.max(0, renderMessages.length - CHAT_PAGE_SIZE),
+  );
+
+  function setMsgRenderIndex(newIndex: number) {
+    newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex);
+    newIndex = Math.max(0, newIndex);
+    _setMsgRenderIndex(newIndex);
+  }
+
+  const messages = useMemo(() => {
+    const endRenderIndex = Math.min(
+      msgRenderIndex + 3 * CHAT_PAGE_SIZE,
+      renderMessages.length,
+    );
+    return renderMessages.slice(msgRenderIndex, endRenderIndex);
+  }, [msgRenderIndex, renderMessages]);
+
+  const onChatBodyScroll = (e: HTMLElement) => {
+    const bottomHeight = e.scrollTop + e.clientHeight;
+    const edgeThreshold = e.clientHeight;
+
+    const isTouchTopEdge = e.scrollTop <= edgeThreshold;
+    const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold;
+    const isHitBottom =
+      bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10);
+
+    const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE;
+    const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE;
+
+    if (isTouchTopEdge && !isTouchBottomEdge) {
+      setMsgRenderIndex(prevPageMsgIndex);
+    } else if (isTouchBottomEdge) {
+      setMsgRenderIndex(nextPageMsgIndex);
+    }
+
+    setHitBottom(isHitBottom);
+    setAutoScroll(isHitBottom);
+  };
+
+  function scrollToBottom() {
+    setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
+    scrollDomToBottom();
+  }
+
+  // clear context index = context length + index in messages
+  const clearContextIndex =
+    (session.clearContextIndex ?? -1) >= 0
+      ? session.clearContextIndex! + context.length - msgRenderIndex
+      : -1;
+
+  const [showPromptModal, setShowPromptModal] = useState(false);
+
+  const clientConfig = useMemo(() => getClientConfig(), []);
+
+  const autoFocus = !isMobileScreen; // wont auto focus on mobile screen
+  const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
+
+  useCommand({
+    fill: setUserInput,
+    submit: (text) => {
+      doSubmit(text);
+    },
+    code: (text) => {
+      if (accessStore.disableFastLink) return;
+      console.log("[Command] got code from url: ", text);
+      showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => {
+        if (res) {
+          accessStore.update((access) => (access.accessCode = text));
+        }
+      });
+    },
+    settings: (text) => {
+      if (accessStore.disableFastLink) return;
+
+      try {
+        const payload = JSON.parse(text) as {
+          key?: string;
+          url?: string;
+        };
+
+        console.log("[Command] got settings from url: ", payload);
+
+        if (payload.key || payload.url) {
+          showConfirm(
+            Locale.URLCommand.Settings +
+            `\n${JSON.stringify(payload, null, 4)}`,
+          ).then((res) => {
+            if (!res) return;
+            if (payload.key) {
+              accessStore.update(
+                (access) => (access.openaiApiKey = payload.key!),
+              );
+            }
+            if (payload.url) {
+              accessStore.update((access) => (access.openaiUrl = payload.url!));
+            }
+            accessStore.update((access) => (access.useCustomConfig = true));
+          });
+        }
+      } catch {
+        console.error("[Command] failed to get settings from url: ", text);
+      }
+    },
+  });
+
+  // edit / insert message modal
+  const [isEditingMessage, setIsEditingMessage] = useState(false);
+
+  // remember unfinished input
+  useEffect(() => {
+    // try to load from local storage
+    const key = UNFINISHED_INPUT(session.id);
+    const mayBeUnfinishedInput = localStorage.getItem(key);
+    if (mayBeUnfinishedInput && userInput.length === 0) {
+      setUserInput(mayBeUnfinishedInput);
+      localStorage.removeItem(key);
+    }
+
+    const dom = inputRef.current;
+    return () => {
+      localStorage.setItem(key, dom?.value ?? "");
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  const handlePaste = useCallback(
+    async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
+      const currentModel = chatStore.currentSession().mask.modelConfig.model;
+      if (!isVisionModel(currentModel)) {
+        return;
+      }
+      const items = (event.clipboardData || window.clipboardData).items;
+      for (const item of items) {
+        if (item.kind === "file" && item.type.startsWith("image/")) {
+          event.preventDefault();
+          const file = item.getAsFile();
+          if (file) {
+            const images: string[] = [];
+            images.push(...attachImages);
+            images.push(
+              ...(await new Promise<string[]>((res, rej) => {
+                setUploading(true);
+                const imagesData: string[] = [];
+                uploadImageRemote(file).then((dataUrl) => {
+                  imagesData.push(dataUrl);
+                  setUploading(false);
+                  res(imagesData);
+                }).catch((e) => {
+                  setUploading(false);
+                  rej(e);
+                });
+              })),
+            );
+            const imagesLength = images.length;
+
+            if (imagesLength > 3) {
+              images.splice(3, imagesLength - 3);
+            }
+            setAttachImages(images);
+          }
+        }
+      }
+    },
+    [attachImages, chatStore],
+  );
+
+  async function uploadImage() {
+    const images: string[] = [];
+    images.push(...attachImages);
+
+    images.push(
+      ...(await new Promise<string[]>((res, rej) => {
+        const fileInput = document.createElement("input");
+        fileInput.type = "file";
+        fileInput.accept =
+          "image/png, image/jpeg, image/webp, image/heic, image/heif";
+        fileInput.multiple = true;
+        fileInput.onchange = (event: any) => {
+          setUploading(true);
+          const files = event.target.files;
+          const imagesData: string[] = [];
+          for (let i = 0; i < files.length; i++) {
+            const file = event.target.files[i];
+            uploadImageRemote(file).then((dataUrl) => {
+              imagesData.push(dataUrl);
+              if (
+                imagesData.length === 3 ||
+                imagesData.length === files.length
+              ) {
+                setUploading(false);
+                res(imagesData);
+              }
+            }).catch((e) => {
+              setUploading(false);
+              rej(e);
+            });
+          }
+        };
+        fileInput.click();
+      })),
+    );
+
+    const imagesLength = images.length;
+    if (imagesLength > 3) {
+      images.splice(3, imagesLength - 3);
+    }
+    setAttachImages(images);
+  }
+
+  return (
+    <div className={styles.chat} key={session.id}>
+      <div
+        className={styles["chat-body"]}
+        ref={scrollRef}
+        onScroll={(e) => onChatBodyScroll(e.currentTarget)}
+        onMouseDown={() => inputRef.current?.blur()}
+        onTouchStart={() => {
+          inputRef.current?.blur();
+          setAutoScroll(false);
+        }}
+      >
+        <>
+          {messages.map((message, i) => {
+            const isUser = message.role === "user";
+            const isContext = i < context.length;
+            const showActions =
+              i > 0 &&
+              !(message.preview || message.content.length === 0) &&
+              !isContext;
+            const showTyping = message.preview || message.streaming;
+
+            const shouldShowClearContextDivider = i === clearContextIndex - 1;
+
+            return (
+              <Fragment key={message.id}>
+                <div
+                  className={
+                    isUser ? styles["chat-message-user"] : styles["chat-message"]
+                  }
+                >
+                  <div className={styles["chat-message-container"]} style={{ display: 'flex', flexDirection: 'row' }}>
+                    <div className={styles["chat-message-header"]}>
+                      <div className={styles["chat-message-avatar"]}>
+                        {isUser ? null : (
+                          <img src={avatar.src} style={{ width: 40, marginRight: 10 }} />
+                        )}
+                      </div>
+                    </div>
+                    {/* {showTyping && (
+                      <div className={styles["chat-message-status"]}>
+                        正在输入…
+                      </div>
+                    )} */}
+                    <div className={styles["chat-message-item"]} style={{ marginTop: 20 }}>
+                      <Markdown
+                        key={message.streaming ? "loading" : "done"}
+                        content={getMessageTextContent(message)}
+                        loading={
+                          (message.preview || message.streaming) &&
+                          message.content.length === 0 &&
+                          !isUser
+                        }
+                        onDoubleClickCapture={() => {
+                          if (!isMobileScreen) return;
+                          setUserInput(getMessageTextContent(message));
+                        }}
+                        fontSize={fontSize}
+                        fontFamily={fontFamily}
+                        parentRef={scrollRef}
+                        defaultShow={i >= messages.length - 6}
+                      />
+                      {getMessageImages(message).length == 1 && (
+                        <img
+                          className={styles["chat-message-item-image"]}
+                          src={getMessageImages(message)[0]}
+                          alt=""
+                        />
+                      )}
+                      {getMessageImages(message).length > 1 && (
+                        <div
+                          className={styles["chat-message-item-images"]}
+                          style={
+                            {
+                              "--image-count": getMessageImages(message).length,
+                            } as React.CSSProperties
+                          }
+                        >
+                          {getMessageImages(message).map((image, index) => {
+                            return (
+                              <img
+                                className={
+                                  styles["chat-message-item-image-multi"]
+                                }
+                                key={index}
+                                src={image}
+                                alt=""
+                              />
+                            );
+                          })}
+                        </div>
+                      )}
+                    </div>
+                  </div>
+                </div>
+                {shouldShowClearContextDivider && <ClearContextDivider />}
+              </Fragment>
+            );
+          })}
+        </>
+      </div>
+
+      <div className={styles["chat-input-panel"]}>
+        <ChatActions
+          setUserInput={setUserInput}
+          doSubmit={doSubmit}
+          uploadImage={uploadImage}
+          setAttachImages={setAttachImages}
+          setUploading={setUploading}
+          showPromptModal={() => setShowPromptModal(true)}
+          scrollToBottom={scrollToBottom}
+          hitBottom={hitBottom}
+          uploading={uploading}
+          showPromptHints={() => {
+            if (promptHints.length > 0) {
+              setPromptHints([]);
+              return;
+            }
+
+            inputRef.current?.focus();
+            setUserInput("/");
+            onSearch("");
+          }}
+        />
+        <label
+          className={`${styles["chat-input-panel-inner"]} ${attachImages.length != 0
+            ? styles["chat-input-panel-inner-attach"]
+            : ""
+            }`}
+          htmlFor="chat-input"
+        >
+          <textarea
+            id="chat-input"
+            ref={inputRef}
+            className={styles["chat-input2"]}
+            placeholder={Locale.Chat.Input(submitKey)}
+            onInput={(e) => onInput(e.currentTarget.value)}
+            value={userInput}
+            onKeyDown={onInputKeyDown}
+            onFocus={scrollToBottom}
+            onClick={scrollToBottom}
+            onPaste={handlePaste}
+            rows={inputRows}
+            autoFocus={autoFocus}
+            style={{
+              fontSize: config.fontSize,
+              fontFamily: config.fontFamily,
+            }}
+          />
+          {attachImages.length != 0 && (
+            <div className={styles["attach-images"]}>
+              {attachImages.map((image, index) => {
+                return (
+                  <div
+                    key={index}
+                    className={styles["attach-image"]}
+                    style={{ backgroundImage: `url("${image}")` }}
+                  >
+                    <div className={styles["attach-image-mask"]}>
+                      <DeleteImageButton
+                        deleteImage={() => {
+                          setAttachImages(
+                            attachImages.filter((_, i) => i !== index),
+                          );
+                        }}
+                      />
+                    </div>
+                  </div>
+                );
+              })}
+            </div>
+          )}
+        </label>
+        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 10 }}>
+          <div style={{ display: 'flex', alignItems: 'center' }}>
+            <div
+              style={{ padding: '0 10px', height: 30, borderRadius: 30, fontSize: 12, background: '#f3f4f6', display: 'flex', justifyContent: 'center', alignItems: 'center', marginRight: 20 }}
+            >
+              <img src={sdsk.src} style={{ height: 23 }} />
+              <div style={{ marginLeft: 5 }}>
+                深度思考(R1)
+              </div>
+            </div>
+            <div
+              style={{ padding: '0 10px', height: 30, borderRadius: 30, fontSize: 12, background: '#f3f4f6', display: 'flex', justifyContent: 'center', alignItems: 'center' }}
+            >
+              <img src={hlw.src} style={{ height: 23 }} />
+              <div style={{ marginLeft: 5 }}>
+                联网搜索
+              </div>
+            </div>
+          </div>
+          <div
+            style={{
+              width: 35, height: 35, borderRadius: '50%', background: '#4357d2', display: 'flex', justifyContent: 'center', alignItems: 'center', cursor: 'pointer'
+            }}
+            onClick={() => doSubmit(userInput)}
+          >
+            <div style={{ transform: 'rotate(-45deg)', padding: '0px 0px 3px 5px' }}>
+              <SendOutlined style={{ color: '#FFFFFF' }} />
+            </div>
+          </div>
+        </div>
+        <div style={{ marginTop: 10, textAlign: 'center', color: '#888888', fontSize: 12 }}>
+          内容由AI生成,仅供参考
+        </div>
+      </div>
+      {showExport && (
+        <ExportMessageModal onClose={() => setShowExport(false)} />
+      )}
+      {isEditingMessage && (
+        <EditMessageModal
+          onClose={() => {
+            setIsEditingMessage(false);
+          }}
+        />
+      )}
+    </div>
+  );
+}
+
+export function Chat() {
+  const chatStore = useChatStore();
+  const sessionIndex = chatStore.currentSessionIndex;
+
+  useEffect(() => {
+    chatStore.setModel('DeepSeek');
+  }, []);
+
+  return <_Chat key={sessionIndex}></_Chat>;
+}

+ 120 - 0
app/components/Record.tsx

@@ -0,0 +1,120 @@
+import * as React from 'react';
+import { Link } from 'react-router-dom';
+import { IconButton } from './button';
+import ReturnIcon from "../icons/return.svg";
+import Excel from 'js-export-excel';
+import dayjs from 'dayjs';
+import api from '@/app/api/api';
+
+const RecordApp: React.FC = () => {
+    const [account, setAccount] = React.useState('');
+    const [password, setPassword] = React.useState('');
+
+    // 点击导出
+    const onClickExport = async (data: { account: string, password: string }) => {
+        if (data.account && data.password) {
+            if (data.account === 'root' && password === 'jkec@2024') {
+                const res = await api.get('/bigmodel/api/dialog/list');
+                const list: {
+                    id: string;
+                    type: "system" | "user" | "assistant";
+                    create_time: string;
+                    content: string;
+                }[] = res.data;
+                // 标题行
+                const headerRow = [
+                    { id: 'ID', role: '角色', createTime: '创建时间', content: '内容' },
+                ];
+                // 导出数据
+                const sheetData = [...headerRow, ...list.map(item => ({
+                    id: item.id,
+                    role: item.type,
+                    createTime: dayjs(item.create_time).format('YYYY-MM-DD HH:mm:ss'),
+                    content: item.content,
+                }))];
+                // 导出数据到Excel
+                const option = {
+                    fileName: '聊天记录',
+                    datas: [{
+                        sheetData: sheetData,
+                        sheetName: '聊天记录',
+                    }],
+                };
+                // 使用Excel构造函数导出数据
+                const excel = new Excel(option);
+                excel.saveExcel();
+            } else {
+                alert('账号密码不正确');
+            }
+        } else {
+            alert('请输入账号密码');
+        }
+    }
+
+    return (
+        <div>
+            <div style={{ padding: '14px 20px', borderBottom: '1px solid rgba(0, 0, 0, 0.1)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+                <div>
+                    <div style={{ fontSize: 20, fontWeight: 'bolder' }}>
+                        建科·小智聊天记录
+                    </div>
+                    <div style={{ fontSize: 14 }}>
+                        输入账号密码导出全部聊天记录
+                    </div>
+                </div>
+                <div>
+                    <Link to='/'>
+                        <IconButton
+                            icon={<ReturnIcon />}
+                            bordered
+                            title='返回'
+                            aria='返回'
+                        />
+                    </Link>
+                </div>
+            </div>
+            <div style={{ width: '100%', padding: '20px 0', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
+                <input
+                    style={{ width: '50%' }}
+                    type="text"
+                    placeholder="请输入账号"
+                    value={account}
+                    onChange={(e) => {
+                        const value = e.target.value;
+                        setAccount(value);
+                    }}
+                />
+                <input
+                    style={{ width: '50%', margin: '20px 0' }}
+                    type="password"
+                    placeholder="请输入密码"
+                    value={password}
+                    onChange={(e) => {
+                        const value = e.target.value;
+                        setPassword(value);
+                    }}
+                />
+                <button
+                    style={{
+                        width: '50%',
+                        border: '1px solid rgba(0, 0, 0, 0.1)',
+                        backgroundColor: '#FFFFFF',
+                        borderRadius: '10px',
+                        height: 36,
+                        cursor: 'pointer'
+                    }}
+                    onClick={async () => {
+                        await onClickExport({
+                            account: account,
+                            password: password,
+                        })
+                    }}
+                >
+                    导出
+                </button>
+            </div>
+        </div>
+    );
+};
+
+export default RecordApp;

+ 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>
+  );
+}

+ 36 - 0
app/components/auth.module.scss

@@ -0,0 +1,36 @@
+.auth-page {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 100%;
+  width: 100%;
+  flex-direction: column;
+
+  .auth-logo {
+    transform: scale(1.4);
+  }
+
+  .auth-title {
+    font-size: 24px;
+    font-weight: bold;
+    line-height: 2;
+  }
+
+  .auth-tips {
+    font-size: 14px;
+  }
+
+  .auth-input {
+    margin: 3vh 0;
+  }
+
+  .auth-actions {
+    display: flex;
+    justify-content: center;
+    flex-direction: column;
+
+    button:not(:last-child) {
+      margin-bottom: 10px;
+    }
+  }
+}

+ 87 - 0
app/components/auth.tsx

@@ -0,0 +1,87 @@
+import styles from "./auth.module.scss";
+import { IconButton } from "./button";
+
+import { useNavigate } from "react-router-dom";
+import { Path } from "../constant";
+import { useAccessStore } from "../store";
+import Locale from "../locales";
+
+import BotIcon from "../icons/bot.svg";
+import { useEffect } from "react";
+import { getClientConfig } from "../config/client";
+
+export function AuthPage() {
+  const navigate = useNavigate();
+  const accessStore = useAccessStore();
+
+  const goHome = () => navigate(Path.Home);
+  const goChat = () => navigate(Path.Chat);
+  const resetAccessCode = () => {
+    accessStore.update((access) => {
+      access.openaiApiKey = "";
+      access.accessCode = "";
+    });
+  }; // Reset access code to empty string
+
+  useEffect(() => {
+    if (getClientConfig()?.isApp) {
+      navigate(Path.Settings);
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  return (
+    <div className={styles["auth-page"]}>
+      <div className={`no-dark ${styles["auth-logo"]}`}>
+        <BotIcon />
+      </div>
+
+      <div className={styles["auth-title"]}>{Locale.Auth.Title}</div>
+      <div className={styles["auth-tips"]}>{Locale.Auth.Tips}</div>
+
+      <input
+        className={styles["auth-input"]}
+        type="password"
+        placeholder={Locale.Auth.Input}
+        value={accessStore.accessCode}
+        onChange={(e) => {
+          accessStore.update(
+            (access) => (access.accessCode = e.currentTarget.value),
+          );
+        }}
+      />
+      {!accessStore.hideUserApiKey ? (
+        <>
+          <div className={styles["auth-tips"]}>{Locale.Auth.SubTips}</div>
+          <input
+            className={styles["auth-input"]}
+            type="password"
+            placeholder={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
+            value={accessStore.openaiApiKey}
+            onChange={(e) => {
+              accessStore.update(
+                (access) => (access.openaiApiKey = e.currentTarget.value),
+              );
+            }}
+          />
+
+        </>
+      ) : null}
+
+      <div className={styles["auth-actions"]}>
+        <IconButton
+          text={Locale.Auth.Confirm}
+          type="primary"
+          onClick={goChat}
+        />
+        <IconButton
+          text={Locale.Auth.Later}
+          onClick={() => {
+            resetAccessCode();
+            goHome();
+          }}
+        />
+      </div>
+    </div>
+  );
+}

+ 83 - 0
app/components/button.module.scss

@@ -0,0 +1,83 @@
+.icon-button {
+  background-color: var(--white);
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 10px;
+
+  cursor: pointer;
+  transition: all 0.3s ease;
+  overflow: hidden;
+  user-select: none;
+  outline: none;
+  border: none;
+  color: var(--black);
+
+  &[disabled] {
+    cursor: not-allowed;
+    opacity: 0.5;
+  }
+
+  &.primary {
+    background-color: var(--primary);
+    color: white;
+
+    path {
+      fill: white !important;
+    }
+  }
+
+  &.danger {
+    color: rgba($color: red, $alpha: 0.8);
+    border-color: rgba($color: red, $alpha: 0.5);
+    background-color: rgba($color: red, $alpha: 0.05);
+
+    &:hover {
+      border-color: red;
+      background-color: rgba($color: red, $alpha: 0.1);
+    }
+
+    path {
+      fill: red !important;
+    }
+  }
+
+  &:hover,
+  &:focus {
+    border-color: var(--primary);
+  }
+}
+
+.shadow {
+  box-shadow: var(--card-shadow);
+}
+
+.border {
+  border: var(--border-in-light);
+}
+
+.icon-button-icon {
+  width: 16px;
+  height: 16px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+@media only screen and (max-width: 600px) {
+  .icon-button {
+    // padding: 16px;
+  }
+}
+
+.icon-button-text {
+  font-size: 12px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+
+  &:not(:first-child) {
+    margin-left: 5px;
+  }
+}

+ 62 - 0
app/components/button.tsx

@@ -0,0 +1,62 @@
+import * as React from "react";
+
+import styles from "./button.module.scss";
+import { CSSProperties } from "react";
+
+export type ButtonType = "primary" | "danger" | null;
+
+export function IconButton(props: {
+  onClick?: () => void;
+  icon?: JSX.Element;
+  type?: ButtonType;
+  text?: string;
+  bordered?: boolean;
+  shadow?: boolean;
+  className?: string;
+  title?: string;
+  disabled?: boolean;
+  tabIndex?: number;
+  autoFocus?: boolean;
+  style?: CSSProperties;
+  aria?: string;
+}) {
+  return (
+    <button
+      className={
+        styles["icon-button"] +
+        ` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
+          props.className ?? ""
+        } clickable ${styles[props.type ?? ""]}`
+      }
+      onClick={props.onClick}
+      title={props.title}
+      disabled={props.disabled}
+      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"}`
+          }
+        >
+          {props.icon}
+        </div>
+      )}
+
+      {props.text && (
+        <div
+          aria-label={props.text || props.title}
+          className={styles["icon-button-text"]}
+        >
+          {props.text}
+        </div>
+      )}
+    </button>
+  );
+}

+ 173 - 0
app/components/chat-list.tsx

@@ -0,0 +1,173 @@
+import DeleteIcon from "../icons/delete.svg";
+import BotIcon from "../icons/bot.svg";
+
+import styles from "./home.module.scss";
+import {
+  DragDropContext,
+  Droppable,
+  Draggable,
+  OnDragEndResponder,
+} from "@hello-pangea/dnd";
+
+import { useChatStore } from "../store";
+
+import Locale from "../locales";
+import { useLocation, useNavigate } from "react-router-dom";
+import { Path } from "../constant";
+import { MaskAvatar } from "./mask";
+import { Mask } from "../store/mask";
+import { useRef, useEffect } from "react";
+import { showConfirm } from "./ui-lib";
+import { useMobileScreen } from "../utils";
+
+export function ChatItem(props: {
+  onClick?: () => void;
+  onDelete?: () => void;
+  title: string;
+  count: number;
+  time: string;
+  selected: boolean;
+  id: string;
+  index: number;
+  narrow?: boolean;
+  mask: Mask;
+}) {
+  const draggableRef = useRef<HTMLDivElement | null>(null);
+  useEffect(() => {
+    if (props.selected && draggableRef.current) {
+      draggableRef.current?.scrollIntoView({
+        block: "center",
+      });
+    }
+  }, [props.selected]);
+
+  const { pathname: currentPath } = useLocation();
+  return (
+    <Draggable draggableId={`${props.id}`} index={props.index}>
+      {(provided) => (
+        <div
+          className={`${styles["chat-item"]} ${props.selected &&
+            (currentPath === Path.Chat || currentPath === Path.Home) &&
+            styles["chat-item-selected"]
+            }`}
+          onClick={props.onClick}
+          ref={(ele) => {
+            draggableRef.current = ele;
+            provided.innerRef(ele);
+          }}
+          {...provided.draggableProps}
+          {...provided.dragHandleProps}
+          title={`${props.title}\n${Locale.ChatItem.ChatItemCount(
+            props.count,
+          )}`}
+        >
+          {props.narrow ? (
+            <div className={styles["chat-item-narrow"]}>
+              <div className={styles["chat-item-avatar"] + " no-dark"}>
+                <MaskAvatar
+                  avatar={props.mask.avatar}
+                  model={props.mask.modelConfig.model}
+                />
+              </div>
+              <div className={styles["chat-item-narrow-count"]}>
+                {props.count}
+              </div>
+            </div>
+          ) : (
+            <>
+              <div className={styles["chat-item-title"]}>{props.title}</div>
+              <div className={styles["chat-item-info"]}>
+                <div className={styles["chat-item-count"]}>
+                  {Locale.ChatItem.ChatItemCount(props.count)}
+                </div>
+                <div className={styles["chat-item-date"]}>{props.time}</div>
+              </div>
+            </>
+          )}
+
+          <div
+            className={styles["chat-item-delete"]}
+            onClickCapture={(e) => {
+              props.onDelete?.();
+              e.preventDefault();
+              e.stopPropagation();
+            }}
+          >
+            <DeleteIcon />
+          </div>
+        </div>
+      )}
+    </Draggable>
+  );
+}
+
+export function ChatList(props: { narrow?: boolean }) {
+  const [sessions, selectedIndex, selectSession, moveSession] = useChatStore(
+    (state) => [
+      state.sessions,
+      state.currentSessionIndex,
+      state.selectSession,
+      state.moveSession,
+    ],
+  );
+  const chatStore = useChatStore();
+  const navigate = useNavigate();
+  const isMobileScreen = useMobileScreen();
+
+  const onDragEnd: OnDragEndResponder = (result) => {
+    const { destination, source } = result;
+    if (!destination) {
+      return;
+    }
+
+    if (
+      destination.droppableId === source.droppableId &&
+      destination.index === source.index
+    ) {
+      return;
+    }
+
+    moveSession(source.index, destination.index);
+  };
+
+  return (
+    <DragDropContext onDragEnd={onDragEnd}>
+      <Droppable droppableId="chat-list">
+        {(provided) => (
+          <div
+            className={styles["chat-list"]}
+            ref={provided.innerRef}
+            {...provided.droppableProps}
+          >
+            {sessions.map((item, i) => (
+              <ChatItem
+                title={item.topic}
+                time={new Date(item.lastUpdate).toLocaleString()}
+                count={item.messages.length}
+                key={item.id}
+                id={item.id}
+                index={i}
+                selected={i === selectedIndex}
+                onClick={() => {
+                  navigate(Path.Chat);
+                  selectSession(i);
+                }}
+                onDelete={async () => {
+                  if (
+                    (!props.narrow && !isMobileScreen) ||
+                    (await showConfirm(Locale.Home.DeleteChat))
+                  ) {
+                    chatStore.deleteSession(i);
+                  }
+                }}
+                narrow={props.narrow}
+                mask={item.mask}
+              />
+            ))}
+            {provided.placeholder}
+          </div>
+        )}
+      </Droppable>
+    </DragDropContext>
+  );
+}

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

@@ -0,0 +1,862 @@
+@import "../styles/animation.scss";
+
+.attach-images {
+  position: absolute;
+  left: 30px;
+  bottom: 32px;
+  display: flex;
+}
+
+.attach-image {
+  cursor: default;
+  width: 64px;
+  height: 64px;
+  border: rgba($color: #888, $alpha: 0.2) 1px solid;
+  border-radius: 5px;
+  margin-right: 10px;
+  background-size: cover;
+  background-position: center;
+  background-color: var(--white);
+
+  .attach-image-mask {
+    width: 100%;
+    height: 100%;
+    opacity: 0;
+    transition: all ease 0.2s;
+  }
+
+  .attach-image-mask:hover {
+    opacity: 1;
+  }
+
+  .delete-image {
+    width: 24px;
+    height: 24px;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 5px;
+    float: right;
+    background-color: var(--white);
+  }
+}
+
+.chat-input-actions {
+  display: flex;
+  flex-wrap: wrap;
+
+  .chat-input-action {
+    display: inline-flex;
+    border-radius: 20px;
+    font-size: 12px;
+    background-color: var(--white);
+    color: var(--black);
+    border: var(--border-in-light);
+    padding: 4px 10px;
+    animation: slide-in ease 0.3s;
+    box-shadow: var(--card-shadow);
+    transition: width ease 0.3s;
+    align-items: center;
+    height: 16px;
+    width: var(--icon-width);
+    overflow: hidden;
+
+    &:not(:last-child) {
+      margin-right: 5px;
+    }
+
+    .text {
+      white-space: nowrap;
+      padding-left: 5px;
+      opacity: 0;
+      transform: translateX(-5px);
+      transition: all ease 0.3s;
+      pointer-events: none;
+    }
+
+    &:hover {
+      --delay: 0.5s;
+      width: var(--full-width);
+      transition-delay: var(--delay);
+
+      .text {
+        transition-delay: var(--delay);
+        opacity: 1;
+        transform: translate(0);
+      }
+    }
+
+    .text,
+    .icon {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+  }
+}
+
+.prompt-toast {
+  position: absolute;
+  bottom: -50px;
+  z-index: 999;
+  display: flex;
+  justify-content: center;
+  width: calc(100% - 40px);
+
+  .prompt-toast-inner {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    font-size: 12px;
+    background-color: var(--white);
+    color: var(--black);
+
+    border: var(--border-in-light);
+    box-shadow: var(--card-shadow);
+    padding: 10px 20px;
+    border-radius: 100px;
+
+    animation: slide-in-from-top ease 0.3s;
+
+    .prompt-toast-content {
+      margin-left: 10px;
+    }
+  }
+}
+
+.section-title {
+  font-size: 12px;
+  font-weight: bold;
+  margin-bottom: 10px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  .section-title-action {
+    display: flex;
+    align-items: center;
+  }
+}
+
+.context-prompt {
+  .context-prompt-insert {
+    display: flex;
+    justify-content: center;
+    padding: 4px;
+    opacity: 0.2;
+    transition: all ease 0.3s;
+    background-color: rgba(0, 0, 0, 0);
+    cursor: pointer;
+    border-radius: 4px;
+    margin-top: 4px;
+    margin-bottom: 4px;
+
+    &:hover {
+      opacity: 1;
+      background-color: rgba(0, 0, 0, 0.05);
+    }
+  }
+
+  .context-prompt-row {
+    display: flex;
+    justify-content: center;
+    width: 100%;
+
+    &:hover {
+      .context-drag {
+        opacity: 1;
+      }
+    }
+
+    .context-drag {
+      display: flex;
+      align-items: center;
+      opacity: 0.5;
+      transition: all ease 0.3s;
+    }
+
+    .context-role {
+      margin-right: 10px;
+    }
+
+    .context-content {
+      flex: 1;
+      max-width: 100%;
+      text-align: left;
+    }
+
+    .context-delete-button {
+      margin-left: 10px;
+    }
+  }
+
+  .context-prompt-button {
+    flex: 1;
+  }
+}
+
+.memory-prompt {
+  margin: 20px 0;
+
+  .memory-prompt-content {
+    background-color: var(--white);
+    color: var(--black);
+    border: var(--border-in-light);
+    border-radius: 10px;
+    padding: 10px;
+    font-size: 12px;
+    user-select: text;
+  }
+}
+
+.clear-context {
+  margin: 20px 0 0 0;
+  padding: 4px 0;
+
+  border-top: var(--border-in-light);
+  border-bottom: var(--border-in-light);
+  box-shadow: var(--card-shadow) inset;
+
+  display: flex;
+  justify-content: center;
+  align-items: center;
+
+  color: var(--black);
+  transition: all ease 0.3s;
+  cursor: pointer;
+  overflow: hidden;
+  position: relative;
+  font-size: 12px;
+
+  animation: slide-in ease 0.3s;
+
+  $linear: linear-gradient(to right,
+      rgba(0, 0, 0, 0),
+      rgba(0, 0, 0, 1),
+      rgba(0, 0, 0, 0));
+  mask-image: $linear;
+
+  @mixin show {
+    transform: translateY(0);
+    position: relative;
+    transition: all ease 0.3s;
+    opacity: 1;
+  }
+
+  @mixin hide {
+    transform: translateY(-50%);
+    position: absolute;
+    transition: all ease 0.1s;
+    opacity: 0;
+  }
+
+  &-tips {
+    @include show;
+    opacity: 0.5;
+  }
+
+  &-revert-btn {
+    color: var(--primary);
+    @include hide;
+  }
+
+  &:hover {
+    opacity: 1;
+    border-color: var(--primary);
+
+    .clear-context-tips {
+      @include hide;
+    }
+
+    .clear-context-revert-btn {
+      @include show;
+    }
+  }
+}
+
+.chat {
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  height: 100%;
+  // background-image: url("/chat-bg.jpg");
+  /* 使背景图片按比例填充容器 */
+  background-size: cover;
+  /* 居中显示背景图片 */
+  background-position: center;
+  /* 避免重复 */
+  background-repeat: no-repeat;
+}
+
+.chat-body {
+  flex: 1;
+  overflow: auto;
+  overflow-x: hidden;
+  padding: 20px;
+  padding-bottom: 40px;
+}
+
+.chat-body-main-title {
+  cursor: pointer;
+
+  &:hover {
+    text-decoration: none;
+  }
+}
+
+@media only screen and (max-width: 600px) {
+  .chat-body-title {
+    text-align: center;
+  }
+}
+
+.chat-message {
+  display: flex;
+  flex-direction: row;
+
+  &:last-child {
+    animation: slide-in ease 0.3s;
+  }
+}
+
+.chat-message-user {
+  display: flex;
+  flex-direction: row-reverse;
+
+  .chat-message-header {
+    flex-direction: row-reverse;
+  }
+}
+
+.chat-message-header {
+  margin-top: 20px;
+  display: flex;
+  align-items: center;
+
+  .chat-message-actions {
+    display: flex;
+    box-sizing: border-box;
+    font-size: 12px;
+    align-items: flex-end;
+    justify-content: space-between;
+    transition: all ease 0.3s;
+    transform: scale(0.9) translateY(5px);
+    margin: 0 10px;
+    opacity: 0;
+    pointer-events: none;
+
+    .chat-input-actions {
+      display: flex;
+      flex-wrap: nowrap;
+    }
+  }
+}
+
+.chat-message-container {
+  max-width: var(--message-max-width);
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+
+  &:hover {
+    .chat-message-edit {
+      opacity: 0.9;
+    }
+
+    .chat-message-actions {
+      opacity: 1;
+      pointer-events: all;
+      transform: scale(1) translateY(0);
+    }
+  }
+}
+
+.chat-message-user>.chat-message-container {
+  align-items: flex-end;
+}
+
+.chat-message-avatar {
+  position: relative;
+
+  .chat-message-edit {
+    position: absolute;
+    height: 100%;
+    width: 100%;
+    overflow: hidden;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    opacity: 0;
+    transition: all ease 0.3s;
+
+    button {
+      padding: 7px;
+    }
+  }
+
+  /* Specific styles for iOS devices */
+  @media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) {
+    @supports (-webkit-touch-callout: none) {
+      .chat-message-edit {
+        top: -8%;
+      }
+    }
+  }
+}
+
+.chat-message-status {
+  font-size: 12px;
+  color: #aaa;
+  line-height: 1.5;
+  margin-top: 5px;
+}
+
+.chat-message-item {
+  box-sizing: border-box;
+  max-width: 100%;
+  margin-top: 10px;
+  border-radius: 10px;
+  background-color: #FFFFFF;
+  padding: 10px;
+  font-size: 14px;
+  user-select: text;
+  word-break: break-word;
+  border: var(--border-in-light);
+  position: relative;
+  transition: all ease 0.3s;
+}
+
+.chat-message-item-image {
+  width: 100%;
+  margin-top: 10px;
+}
+
+.chat-message-item-images {
+  width: 100%;
+  display: grid;
+  justify-content: left;
+  grid-gap: 10px;
+  grid-template-columns: repeat(var(--image-count), auto);
+  margin-top: 10px;
+}
+
+.chat-message-item-image-multi {
+  object-fit: cover;
+  background-size: cover;
+  background-position: center;
+  background-repeat: no-repeat;
+}
+
+.chat-message-item-image,
+.chat-message-item-image-multi {
+  box-sizing: border-box;
+  border-radius: 10px;
+  border: rgba($color: #888, $alpha: 0.2) 1px solid;
+}
+
+
+@media only screen and (max-width: 600px) {
+  $calc-image-width: calc(100vw / 3 * 2 / var(--image-count));
+
+  .chat-message-item-image-multi {
+    width: $calc-image-width;
+    height: $calc-image-width;
+  }
+
+  .chat-message-item-image {
+    max-width: calc(100vw / 3 * 2);
+  }
+}
+
+@media screen and (min-width: 600px) {
+  $max-image-width: calc(calc(1200px - var(--sidebar-width)) / 3 * 2 / var(--image-count));
+  $image-width: calc(calc(var(--window-width) - var(--sidebar-width)) / 3 * 2 / var(--image-count));
+
+  .chat-message-item-image-multi {
+    width: $image-width;
+    height: $image-width;
+    max-width: $max-image-width;
+    max-height: $max-image-width;
+  }
+
+  .chat-message-item-image {
+    max-width: calc(calc(1200px - var(--sidebar-width)) / 3 * 2);
+  }
+}
+
+.chat-message-action-date {
+  font-size: 12px;
+  opacity: 0.2;
+  white-space: nowrap;
+  transition: all ease 0.6s;
+  color: var(--black);
+  text-align: right;
+  width: 100%;
+  box-sizing: border-box;
+  padding-right: 10px;
+  pointer-events: none;
+  z-index: 1;
+}
+
+.chat-message-user>.chat-message-container>.chat-message-item {
+  background-color: var(--second);
+
+  &:hover {
+    min-width: 0;
+  }
+}
+
+.chat-input-panel {
+  position: relative;
+  width: 100%;
+  padding: 16px;
+  padding-top: 16px;
+  box-sizing: border-box;
+  flex-direction: column;
+  background: transparent;
+  // box-shadow: var(--card-shadow);
+  // 下方线条
+  border-top: 1px solid #dedede;
+  // border-top: none;
+
+  // 背景
+  // background: rgba(155, 155, 255, 0.2);
+  // 背景模糊程度
+  // backdrop-filter: blur(2px);
+
+  .chat-input-actions {
+    .chat-input-action {
+      margin-bottom: 10px;
+    }
+  }
+}
+
+@mixin single-line {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.prompt-hints {
+  min-height: 20px;
+  width: 100%;
+  max-height: 50vh;
+  overflow: auto;
+  display: flex;
+  flex-direction: column-reverse;
+
+  background-color: var(--white);
+  border: var(--border-in-light);
+  border-radius: 10px;
+  margin-bottom: 10px;
+  box-shadow: var(--shadow);
+
+  .prompt-hint {
+    color: var(--black);
+    padding: 6px 10px;
+    animation: slide-in ease 0.3s;
+    cursor: pointer;
+    transition: all ease 0.3s;
+    border: transparent 1px solid;
+    margin: 4px;
+    border-radius: 8px;
+
+    &:not(:last-child) {
+      margin-top: 0;
+    }
+
+    .hint-title {
+      font-size: 12px;
+      font-weight: bolder;
+
+      @include single-line();
+    }
+
+    .hint-content {
+      font-size: 12px;
+
+      @include single-line();
+    }
+
+    &-selected,
+    &:hover {
+      border-color: var(--primary);
+    }
+  }
+}
+
+.chat-input-panel-inner {
+  cursor: text;
+  display: flex;
+  flex-direction: column; // 垂直布局
+  flex: 1;
+  border-radius: 16px;
+  border: var(--border-in-light);
+  position: relative;
+  // min-height: 100px;
+  max-height: 120px;
+  overflow-y: auto;
+  overflow: hidden;
+}
+
+.chat-input-panel-inner-attach {
+  padding-bottom: 80px;
+}
+
+.chat-input-panel-inner:has(.chat-input:focus) {
+  border: 1px solid var(--primary);
+}
+
+.chat-input-panel-inner:has(.chat-input2:focus) {
+  border-color: #495fe6;
+}
+
+.chat-input {
+  flex: 1;
+  height: 100%;
+  width: 100%;
+  // border-radius: 16px;
+  border: none;
+  background: transparent;
+  box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
+  color: var(--black) !important;
+  font-family: inherit;
+  padding: 16px 16px 8px 16px;
+  resize: none;
+  outline: none;
+  box-sizing: border-box;
+  min-height: 60px;
+  font-size: 14px;
+  line-height: 1.5;
+  // 文字颜色
+  // color: #FFFFFF;
+}
+
+.chat-input2 {
+  @extend .chat-input;
+}
+
+// placeholder颜色
+.chat-input::placeholder {
+  color: #FFFFFF;
+}
+
+.chat-input-send {
+  background-color: var(--primary);
+  color: white;
+  position: absolute;
+  right: 30px;
+  bottom: 32px;
+}
+// 新增:输入框内部按钮区域样式
+.chat-input-bottom-bar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 8px 16px 16px 16px;
+  flex-shrink: 0;
+  // border-top: 1px solid #f3f4f6;
+  margin-top: auto;
+  // 确保按钮区域布局稳定
+  position: relative;
+  
+  // 移动端优化
+  @media only screen and (max-width: 600px) {
+    justify-content: flex-start;
+    gap: 8px;
+    flex-wrap: wrap;
+    
+    // 左侧选项区域
+    > div:first-child {
+      flex: 1;
+      min-width: 0;
+    }
+    
+    // 右侧按钮区域(包含上传和发送按钮)
+    > div:last-child {
+      flex-shrink: 0;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+  }
+  
+  .left-options {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    
+    .option-item { //  未使用
+      display: flex;
+      height: 28px;
+      border-radius: 16px;
+      font-size: 12px;
+      padding: 0px 12px;
+      justify-content: center;
+      align-items: center;
+      margin-right: 10px;
+      cursor: pointer;
+      background: #f3f4f6;  // 默认状态
+      color: #000000;        // 默认状态
+      // border: 1px solid transparent;  // 默认状态
+      transition: all 0.2s ease;
+      user-select: none;
+      
+      // 激活状态
+      &.active {
+        background: #dee9fc;
+        color: #3875f6;
+        // border: 1px solid #3875f6;
+      }
+      
+      &:hover {
+        background-color: #f9fafb;
+        color: #374151;
+      }
+      
+      // .chevron {
+      //   margin-left: 4px;
+      //   font-size: 10px;
+      //   opacity: 0.6;
+      // }
+    }
+  }
+  
+  .middle-action {
+    .primary-button {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      padding: 6px 12px;
+      background: #dbeafe;
+      color: #1e40af;
+      border: none;
+      border-radius: 8px;
+      font-size: 12px;
+      cursor: pointer;
+      transition: all 0.2s ease;
+      
+      &:hover {
+        background: #bfdbfe;
+      }
+      
+      .chevron {
+        font-size: 10px;
+        opacity: 0.7;
+      }
+    }
+  }
+  
+  .right-actions {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    
+    .action-icon {
+      width: 24px;
+      height: 24px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      border-radius: 50%;
+      cursor: pointer;
+      transition: all 0.2s ease;
+      
+      &:hover {
+        background-color: #f3f4f6;
+      }
+      
+      &.send-button {
+        background: #6b7280;
+        color: white;
+        
+        &:hover {
+          background: #4b5563;
+        }
+      }
+    }
+  }
+}
+
+@media only screen and (max-width: 600px) {
+  .chat-input {
+    font-size: 16px;
+  }
+
+  .chat-input-send {
+    bottom: 30px;
+  }
+  
+  // 移动端输入框内部优化
+  .chat-input-panel-inner {
+    min-height: 100px;
+    max-height: 120px;
+  }
+  
+
+  
+  // 针对特定手机屏幕的额外优化
+  @media only screen and (max-width: 480px) {
+    // 针对DeepSeekHomeChat组件的按钮优化
+    .chat-input-panel + div[style*="display: flex"] {
+      padding: 4px 8px !important;
+      gap: 4px !important;
+      
+      > div:first-child {
+        gap: 8px !important;
+        flex-wrap: wrap !important;
+        
+        > div {
+          padding: 0 8px !important;
+          height: 26px !important;
+          font-size: 11px !important;
+          margin-right: 8px !important;
+          
+          img {
+            height: 18px !important;
+          }
+        }
+      }
+      
+      > div:last-child {
+        width: 28px !important;
+        height: 28px !important;
+        flex-shrink: 0 !important;
+      }
+    }
+  }
+  
+  // 移动端输入框优化
+  .chat-input2 {
+    font-size: 16px; // 防止iOS缩放
+    line-height: 1.4;
+    padding: 12px 16px;
+  }
+  
+  // 通用移动端按钮容器优化
+  .chat-input-panel + div[style*="display: flex"] {
+    display: flex !important;
+    justify-content: space-between !important;
+    align-items: center !important;
+    flex-wrap: nowrap !important;
+    padding: 6px 12px !important;
+    gap: 8px !important;
+    
+    > div:first-child {
+      display: flex !important;
+      align-items: center !important;
+      gap: 8px !important;
+      flex-wrap: wrap !important;
+      flex: 1 !important;
+      min-width: 0 !important;
+    }
+    
+    > div:last-child {
+      flex-shrink: 0 !important;
+      display: flex !important;
+      align-items: center !important;
+      justify-content: center !important;
+    }
+  }
+}

+ 2344 - 0
app/components/chat.tsx

@@ -0,0 +1,2344 @@
+import { useDebouncedCallback } from "use-debounce";
+import React, {
+  useState,
+  useRef,
+  useEffect,
+  useMemo,
+  useCallback,
+  Fragment,
+  RefObject,
+} from "react";
+import { HomeOutlined, MenuOutlined } from '@ant-design/icons';
+import SendWhiteIcon from "../icons/send-white.svg";
+import BrainIcon from "../icons/brain.svg";
+import RenameIcon from "../icons/rename.svg";
+import ExportIcon from "../icons/share.svg";
+import ReturnIcon from "../icons/return.svg";
+import CopyIcon from "../icons/copy.svg";
+import faviconSrc from "../icons/favicon.png";
+import LeftIcon from "../icons/left.svg";
+import Favicon from "../icons/favicon.svg";
+import LoadingIcon from "../icons/three-dots.svg";
+import LoadingButtonIcon from "../icons/loading.svg";
+import PromptIcon from "../icons/prompt.svg";
+import MaskIcon from "../icons/mask.svg";
+import MaxIcon from "../icons/max.svg";
+import MinIcon from "../icons/min.svg";
+import ResetIcon from "../icons/reload.svg";
+import BreakIcon from "../icons/break.svg";
+import SettingsIcon from "../icons/chat-settings.svg";
+import DeleteIcon from "../icons/clear.svg";
+import PinIcon from "../icons/pin.svg";
+import EditIcon from "../icons/rename.svg";
+import ConfirmIcon from "../icons/confirm.svg";
+import CancelIcon from "../icons/cancel.svg";
+import ImageIcon from "../icons/image.svg";
+
+import LightIcon from "../icons/light.svg";
+import DarkIcon from "../icons/dark.svg";
+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 AddIcon from "../icons/add.svg";
+import SizeIcon from "../icons/size.svg";
+import PluginIcon from "../icons/plugin.svg";
+import avatarSrc from "../icons/avatar.png";
+
+import {
+  ChatMessage,
+  SubmitKey,
+  useChatStore,
+  BOT_HELLO,
+  createMessage,
+  useAccessStore,
+  Theme,
+  useAppConfig,
+  DEFAULT_TOPIC,
+  ModelType,
+} from "../store";
+
+import {
+  copyToClipboard,
+  selectOrCopy,
+  autoGrowTextArea,
+  useMobileScreen,
+  getMessageTextContent,
+  getMessageImages,
+  isVisionModel,
+  isDalle3,
+} from "../utils";
+
+import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
+
+import dynamic from "next/dynamic";
+
+import { ChatControllerPool } from "../client/controller";
+import { DalleSize } from "../typing";
+import { Prompt, usePromptStore } from "../store/prompt";
+import { useGlobalStore } from "../store";
+import Locale from "../locales";
+
+import { IconButton } from "./button";
+import styles from "./chat.module.scss";
+
+import {
+  List,
+  ListItem,
+  Modal,
+  Selector,
+  showConfirm,
+  showPrompt,
+  showToast,
+} from "./ui-lib";
+import { useNavigate, useLocation } from "react-router-dom";
+import {
+  CHAT_PAGE_SIZE,
+  LAST_INPUT_KEY,
+  Path,
+  REQUEST_TIMEOUT_MS,
+  UNFINISHED_INPUT,
+  ServiceProvider,
+  Plugin,
+} from "../constant";
+// Avatar组件替代实现
+import BotIcon from "../icons/bot.svg";
+import BlackBotIcon from "../icons/black-bot.svg";
+
+function Avatar(props: { model?: string; avatar?: string }) {
+  if (props.model) {
+    return (
+      <div className="no-dark">
+        {props.model?.startsWith("gpt-4") ? (
+          <BlackBotIcon className="user-avatar" />
+        ) : (
+          <BotIcon className="user-avatar" />
+        )}
+      </div>
+    );
+  }
+
+  return (
+    <div className="user-avatar">
+      {/* 移除emoji头像,使用默认bot图标 */}
+      <BotIcon className="user-avatar" />
+    </div>
+  );
+}
+import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
+import { useMaskStore } from "../store/mask";
+import { ChatCommandPrefix, useChatCommand, useCommand } from "../command";
+import { prettyObject } from "../utils/format";
+import { ExportMessageModal } from "./exporter";
+import { getClientConfig } from "../config/client";
+import { useAllModels } from "../utils/hooks";
+import { Button, Collapse, Drawer, message, Popover, Select, Skeleton, Space } from 'antd';
+import {
+  FileOutlined,
+  FilePdfOutlined,
+  FileTextOutlined,
+  FileWordOutlined
+} from '@ant-design/icons';
+import { RightOutlined, CheckCircleOutlined, CaretRightOutlined, StarTwoTone } from '@ant-design/icons';
+import api from "@/app/api/api";
+
+const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
+  loading: () => <LoadingIcon />,
+});
+
+export function SessionConfigModel(props: { onClose: () => void }) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const maskStore = useMaskStore();
+  const navigate = useNavigate();
+
+  return (
+    <div className="modal-mask">
+      <Modal
+        title={Locale.Context.Edit}
+        onClose={() => props.onClose()}
+        actions={[
+          <IconButton
+            key="reset"
+            icon={<ResetIcon />}
+            bordered
+            text={Locale.Chat.Config.Reset}
+            onClick={async () => {
+              if (await showConfirm(Locale.Memory.ResetConfirm)) {
+                chatStore.updateCurrentSession(
+                  (session) => (session.memoryPrompt = ""),
+                );
+              }
+            }}
+          />,
+          <IconButton
+            key="copy"
+            icon={<CopyIcon />}
+            bordered
+            text={Locale.Chat.Config.SaveAs}
+            onClick={() => {
+              navigate(Path.Masks);
+              setTimeout(() => {
+                maskStore.create(session.mask);
+              }, 500);
+            }}
+          />,
+        ]}
+      >
+        <MaskConfig
+          mask={session.mask}
+          updateMask={(updater) => {
+            const mask = { ...session.mask };
+            updater(mask);
+            chatStore.updateCurrentSession((session) => (session.mask = mask));
+          }}
+          shouldSyncFromGlobal
+          extraListItems={
+            session.mask.modelConfig.sendMemory ? (
+              <ListItem
+                className="copyable"
+                title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
+                subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
+              ></ListItem>
+            ) : (
+              <></>
+            )
+          }
+        ></MaskConfig>
+      </Modal>
+    </div>
+  );
+}
+
+// 提示词
+const CallWord = (props: {
+  setUserInput: (value: string) => void,
+  doSubmit: (userInput: string) => void,
+}) => {
+  const { setUserInput, doSubmit } = props
+  const list = [
+    {
+      title: '信息公布',
+      // text: '在哪里查看招聘信息?',
+      text: '今年上海建科工程咨询的校园招聘什么时候开始?如何查阅相关招聘信息?',
+    },
+    {
+      title: '招聘岗位',
+      // text: '今年招聘的岗位有哪些?',
+      text: '今年招聘的岗位有哪些?',
+    },
+    {
+      title: '专业要求',
+      // text: '招聘的岗位有什么专业要求?',
+      text: '招聘的岗位有什么专业要求?',
+    },
+    {
+      title: '工作地点',
+      // text: '全国都有工作地点吗?',
+      text: '工作地点是如何确定的?',
+    },
+    {
+      title: '薪资待遇',
+      // text: '企业可提供的薪资与福利待遇如何?',
+      text: '企业可提供的薪资与福利待遇如何?',
+    },
+    {
+      title: '职业发展',
+      // text: '我应聘贵单位,你们能提供怎样的职业发展规划?',
+      text: '公司有哪些职业发展通道?',
+    },
+    {
+      title: '落户政策',
+      // text: '公司是否能协助我落户?',
+      text: '关于落户支持?',
+    }
+  ]
+
+  return (
+    <>
+      {
+        list.map((item, index) => {
+          return <span
+            key={index}
+            style={{
+              padding: '5px 10px',
+              background: '#f6f7f8',
+              color: '#5e5e66',
+              borderRadius: 4,
+              margin: '0 5px 10px 0',
+              cursor: 'pointer',
+              fontSize: 12
+            }}
+            onClick={() => {
+              const plan: string = '2';
+              if (plan === '1') {
+                // 方案1.点击后出现在输入框内,用户自己点击发送
+                setUserInput(item.text);
+              } else {
+                // 方案2.点击后直接发送
+                doSubmit(item.text)
+              }
+            }}
+          >
+            {item.title}
+          </span>
+        })
+      }
+    </>
+  )
+}
+
+function PromptToast(props: {
+  showToast?: boolean;
+  showModal?: boolean;
+  setShowModal: (_: boolean) => void;
+}) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const context = session.mask.context;
+
+  return (
+    <div className={styles["prompt-toast"]} key="prompt-toast">
+      {props.showToast && (
+        <div
+          className={styles["prompt-toast-inner"] + " clickable"}
+          role="button"
+          onClick={() => props.setShowModal(true)}
+        >
+          <BrainIcon />
+          <span className={styles["prompt-toast-content"]}>
+            {Locale.Context.Toast(context.length)}
+          </span>
+        </div>
+      )}
+      {props.showModal && (
+        <SessionConfigModel onClose={() => props.setShowModal(false)} />
+      )}
+    </div>
+  );
+}
+
+function useSubmitHandler() {
+  const config = useAppConfig();
+  const submitKey = config.submitKey;
+  const isComposing = useRef(false);
+
+  useEffect(() => {
+    const onCompositionStart = () => {
+      isComposing.current = true;
+    };
+    const onCompositionEnd = () => {
+      isComposing.current = false;
+    };
+
+    window.addEventListener("compositionstart", onCompositionStart);
+    window.addEventListener("compositionend", onCompositionEnd);
+
+    return () => {
+      window.removeEventListener("compositionstart", onCompositionStart);
+      window.removeEventListener("compositionend", onCompositionEnd);
+    };
+  }, []);
+
+  const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    // Fix Chinese input method "Enter" on Safari
+    if (e.keyCode == 229) return false;
+    if (e.key !== "Enter") return false;
+    if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current))
+      return false;
+    return (
+      (config.submitKey === SubmitKey.AltEnter && e.altKey) ||
+      (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
+      (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
+      (config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
+      (config.submitKey === SubmitKey.Enter &&
+        !e.altKey &&
+        !e.ctrlKey &&
+        !e.shiftKey &&
+        !e.metaKey)
+    );
+  };
+
+  return {
+    submitKey,
+    shouldSubmit,
+  };
+}
+
+export type RenderPrompt = Pick<Prompt, "title" | "content">;
+
+export function PromptHints(props: {
+  prompts: RenderPrompt[];
+  onPromptSelect: (prompt: RenderPrompt) => void;
+}) {
+  const noPrompts = props.prompts.length === 0;
+  const [selectIndex, setSelectIndex] = useState(0);
+  const selectedRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    setSelectIndex(0);
+  }, [props.prompts.length]);
+
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (noPrompts || e.metaKey || e.altKey || e.ctrlKey) {
+        return;
+      }
+      // arrow up / down to select prompt
+      const changeIndex = (delta: number) => {
+        e.stopPropagation();
+        e.preventDefault();
+        const nextIndex = Math.max(
+          0,
+          Math.min(props.prompts.length - 1, selectIndex + delta),
+        );
+        setSelectIndex(nextIndex);
+        selectedRef.current?.scrollIntoView({
+          block: "center",
+        });
+      };
+
+      if (e.key === "ArrowUp") {
+        changeIndex(1);
+      } else if (e.key === "ArrowDown") {
+        changeIndex(-1);
+      } else if (e.key === "Enter") {
+        const selectedPrompt = props.prompts.at(selectIndex);
+        if (selectedPrompt) {
+          props.onPromptSelect(selectedPrompt);
+        }
+      }
+    };
+
+    window.addEventListener("keydown", onKeyDown);
+
+    return () => window.removeEventListener("keydown", onKeyDown);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [props.prompts.length, selectIndex]);
+
+  if (noPrompts) return null;
+  return (
+    <div className={styles["prompt-hints"]}>
+      {props.prompts.map((prompt, i) => (
+        <div
+          ref={i === selectIndex ? selectedRef : null}
+          className={
+            styles["prompt-hint"] +
+            ` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}`
+          }
+          key={prompt.title + i.toString()}
+          onClick={() => props.onPromptSelect(prompt)}
+          onMouseEnter={() => setSelectIndex(i)}
+        >
+          <div className={styles["hint-title"]}>{prompt.title}</div>
+          <div className={styles["hint-content"]}>{prompt.content}</div>
+        </div>
+      ))}
+    </div>
+  );
+}
+
+function ClearContextDivider() {
+  const chatStore = useChatStore();
+
+  return (
+    <div
+      className={styles["clear-context"]}
+      onClick={() =>
+        chatStore.updateCurrentSession(
+          (session) => (session.clearContextIndex = undefined),
+        )
+      }
+    >
+      <div className={styles["clear-context-tips"]}>{Locale.Context.Clear}</div>
+      <div className={styles["clear-context-revert-btn"]}>
+        {Locale.Context.Revert}
+      </div>
+    </div>
+  );
+}
+
+export function ChatAction(props: {
+  text: string;
+  icon: JSX.Element;
+  onClick: () => void;
+}) {
+  const iconRef = useRef<HTMLDivElement>(null);
+  const textRef = useRef<HTMLDivElement>(null);
+  const [width, setWidth] = useState({
+    full: 16,
+    icon: 16,
+  });
+
+  function updateWidth() {
+    if (!iconRef.current || !textRef.current) return;
+    const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width;
+    const textWidth = getWidth(textRef.current);
+    const iconWidth = getWidth(iconRef.current);
+    setWidth({
+      full: textWidth + iconWidth,
+      icon: iconWidth,
+    });
+  }
+
+  return (
+    <div
+      className={`${styles["chat-input-action"]} clickable`}
+      onClick={() => {
+        props.onClick();
+        setTimeout(updateWidth, 1);
+      }}
+      onMouseEnter={updateWidth}
+      onTouchStart={updateWidth}
+      style={
+        {
+          "--icon-width": `${width.icon}px`,
+          "--full-width": `${width.full}px`,
+        } as React.CSSProperties
+      }
+    >
+      <div ref={iconRef} className={styles["icon"]}>
+        {props.icon}
+      </div>
+      <div className={styles["text"]} ref={textRef}>
+        {props.text}
+      </div>
+    </div>
+  );
+}
+
+function useScrollToBottom(
+  scrollRef: RefObject<HTMLDivElement>,
+  detach: boolean = false,
+) {
+  // for auto-scroll
+
+  const [autoScroll, setAutoScroll] = useState(true);
+
+  function scrollDomToBottom() {
+    const dom = scrollRef.current;
+    if (dom) {
+      requestAnimationFrame(() => {
+        setAutoScroll(true);
+        dom.scrollTo(0, dom.scrollHeight);
+      });
+    }
+  }
+
+  // auto scroll
+  useEffect(() => {
+    if (autoScroll && !detach) {
+      scrollDomToBottom();
+    }
+  });
+
+  return {
+    scrollRef,
+    autoScroll,
+    setAutoScroll,
+    scrollDomToBottom,
+  };
+}
+
+export function ChatActions(props: {
+  isClickStop: boolean,
+  sendStatus: boolean,
+  setSendStatus: (status: boolean) => void;
+  setUserInput: (value: string) => void;
+  doSubmit: (userInput: string) => void;
+  uploadImage: () => void;
+  setAttachImages: (images: string[]) => void;
+  setUploading: (uploading: boolean) => void;
+  showPromptModal: () => void;
+  scrollToBottom: () => void;
+  showPromptHints: () => void;
+  hitBottom: boolean;
+  uploading: boolean;
+}) {
+  const config = useAppConfig();
+  const navigate = useNavigate();
+  const chatStore = useChatStore();
+
+  // switch themes
+  const theme = config.theme;
+
+  function nextTheme() {
+    const themes = [Theme.Auto, Theme.Light, Theme.Dark];
+    const themeIndex = themes.indexOf(theme);
+    const nextIndex = (themeIndex + 1) % themes.length;
+    const nextTheme = themes[nextIndex];
+    config.update((config) => (config.theme = nextTheme));
+  }
+
+  // stop all responses
+  const couldStop = ChatControllerPool.hasPending();
+  const stopAll = () => ChatControllerPool.stopAll();
+
+  // switch model
+  const currentModel = chatStore.currentSession().mask.modelConfig.model;
+  const currentProviderName =
+    chatStore.currentSession().mask.modelConfig?.providerName ||
+    ServiceProvider.OpenAI;
+  const allModels = useAllModels();
+  const models = useMemo(() => {
+    const filteredModels = allModels.filter((m) => m.available);
+    const defaultModel = filteredModels.find((m) => m.isDefault);
+
+    if (defaultModel) {
+      const arr = [
+        defaultModel,
+        ...filteredModels.filter((m) => m !== defaultModel),
+      ];
+      return arr;
+    } else {
+      return filteredModels;
+    }
+  }, [allModels]);
+  const currentModelName = useMemo(() => {
+    const model = models.find(
+      (m) =>
+        m.name == currentModel &&
+        m?.provider?.providerName == currentProviderName,
+    );
+    return model?.displayName ?? "";
+  }, [models, currentModel, currentProviderName]);
+  const [showModelSelector, setShowModelSelector] = useState(false);
+  const [showPluginSelector, setShowPluginSelector] = useState(false);
+  const [showUploadImage, setShowUploadImage] = useState(false);
+
+  type GuessList = string[]
+  const [guessList, setGuessList] = useState<GuessList>([]);
+  const [showSizeSelector, setShowSizeSelector] = useState(false);
+  const dalle3Sizes: DalleSize[] = ["1024x1024", "1792x1024", "1024x1792"];
+  const currentSize =
+    chatStore.currentSession().mask.modelConfig?.size ?? "1024x1024";
+  const session = chatStore.currentSession();
+
+  useEffect(() => {
+    const show = isVisionModel(currentModel);
+    setShowUploadImage(show);
+    if (!show) {
+      props.setAttachImages([]);
+      props.setUploading(false);
+    }
+
+    // if current model is not available
+    // switch to first available model
+    const isUnavaliableModel = !models.some((m) => m.name === currentModel);
+    if (isUnavaliableModel && models.length > 0) {
+      // show next model to default model if exist
+      let nextModel = models.find((model) => model.isDefault) || models[0];
+      chatStore.updateCurrentSession((session) => {
+        session.mask.modelConfig.model = nextModel.name;
+        session.mask.modelConfig.providerName = nextModel?.provider?.providerName as ServiceProvider;
+      });
+      showToast(
+        nextModel?.provider?.providerName == "ByteDance"
+          ? nextModel.displayName
+          : nextModel.name,
+      );
+    }
+  }, [chatStore, currentModel, models]);
+
+  const fetchGuessList = async (record: { content: string; role: string }) => {
+    try {
+      const data = {
+        appId: session.appId,
+        messages: [
+          {
+            content: record.content,
+            role: record.role,
+          }
+        ],
+        chat_id: session.chat_id,
+      }
+      let url = '';
+      if (chatStore.chatMode === 'LOCAL') {
+        url = '/deepseek/api/async/completions';
+      } else {
+        url = '/bigmodel/api/async/completions';
+      }
+      const res = await api.post(url, data);
+      setGuessList(res.data);
+    } catch (error) {
+      console.error(error);
+    }
+  }
+
+  useEffect(() => {
+    setGuessList([]);
+    if (chatStore.message.content) {
+      fetchGuessList(chatStore.message);
+    }
+  }, [chatStore.message]);
+
+  useEffect(() => {
+    if (props.isClickStop) {
+      props.setSendStatus(false);
+      setGuessList([]);
+    }
+  }, [props.isClickStop])
+
+  return (
+    <div className={styles["chat-input-actions"]}>
+      {
+        props.sendStatus &&
+        <div style={{ color: '#8096ca', fontSize: 13, overflowX: 'auto' }}>
+          <div>
+            你还可以尝试提问:
+          </div>
+          {
+            guessList.length === 0 ?
+              <Space style={{ margin: '10px 0' }}>
+                <Skeleton.Button size="small" active={true} />
+                <Skeleton.Button size="small" active={true} />
+                <Skeleton.Button size="small" active={true} />
+              </Space>
+              :
+              <div style={{ display: 'flex', margin: '10px 0', overflowX: 'auto' }}>
+                {
+                  guessList.map((item, index) => {
+                    return (
+                      <div
+                        style={{
+                          padding: '5px 10px',
+                          background: '#f2f4f8',
+                          borderRadius: 5,
+                          margin: '0 10px 10px 0',
+                          cursor: 'pointer',
+                        }}
+                        onClick={() => {
+                          props.setUserInput(item);
+                          props.doSubmit(item)
+                        }}
+                        key={index}
+                      >
+                        {item}
+                      </div>
+                    )
+                  })
+                }
+              </div>
+          }
+        </div>
+      }
+      {/* {couldStop && (
+        <ChatAction
+          onClick={stopAll}
+          text={Locale.Chat.InputActions.Stop}
+          icon={<StopIcon />}
+        />
+      )} */}
+
+      {/* {!props.hitBottom && (
+        <ChatAction
+          onClick={props.scrollToBottom}
+          text={Locale.Chat.InputActions.ToBottom}
+          icon={<BottomIcon />}
+        />
+      )} */}
+
+      {/* {props.hitBottom && (
+        <ChatAction
+          onClick={props.showPromptModal}
+          text={Locale.Chat.InputActions.Settings}
+          icon={<SettingsIcon />}
+        />
+      )} */}
+
+      {/* {showUploadImage && (
+        <ChatAction
+          onClick={props.uploadImage}
+          text={Locale.Chat.InputActions.UploadImage}
+          icon={props.uploading ? <LoadingButtonIcon /> : <ImageIcon />}
+        />
+      )} */}
+
+      {/* <ChatAction
+        onClick={nextTheme}
+        text={Locale.Chat.InputActions.Theme[theme]}
+        icon={
+          <>
+            {theme === Theme.Auto ? (
+              <AutoIcon />
+            ) : theme === Theme.Light ? (
+              <LightIcon />
+            ) : theme === Theme.Dark ? (
+              <DarkIcon />
+            ) : null}
+          </>
+        }
+      /> */}
+
+      {/* <CallWord */}
+      {/*   setUserInput={props.setUserInput} */}
+      {/*   doSubmit={props.doSubmit} */}
+      {/* /> */}
+
+      {/* <ChatAction
+        onClick={props.showPromptHints}
+        text={Locale.Chat.InputActions.Prompt}
+        icon={<PromptIcon />}
+      /> */}
+
+      {/* <ChatAction
+        onClick={() => {
+          navigate(Path.Masks);
+        }}
+        text={Locale.Chat.InputActions.Masks}
+        icon={<MaskIcon />}
+      /> */}
+
+      {/* <ChatAction
+        text={Locale.Chat.InputActions.Clear}
+        icon={<BreakIcon />}
+        onClick={() => {
+          chatStore.updateCurrentSession((session) => {
+            if (session.clearContextIndex === session.messages.length) {
+              session.clearContextIndex = undefined;
+            } else {
+              session.clearContextIndex = session.messages.length;
+              session.memoryPrompt = ""; // will clear memory
+            }
+          });
+        }}
+      /> */}
+
+      {/* <ChatAction
+        onClick={() => setShowModelSelector(true)}
+        text={currentModelName}
+        icon={<RobotIcon />}
+      /> */}
+
+      {showModelSelector && (
+        <Selector
+          defaultSelectedValue={`${currentModel}@${currentProviderName}`}
+          items={models.map((m) => ({
+            title: `${m.displayName}${m?.provider?.providerName
+              ? "(" + m?.provider?.providerName + ")"
+              : ""
+              }`,
+            value: `${m.name}@${m?.provider?.providerName}`,
+          }))}
+          onClose={() => setShowModelSelector(false)}
+          onSelection={(s) => {
+            if (s.length === 0) return;
+            const [model, providerName] = s[0].split("@");
+            chatStore.updateCurrentSession((session) => {
+              session.mask.modelConfig.model = model as ModelType;
+              session.mask.modelConfig.providerName =
+                providerName as ServiceProvider;
+              session.mask.syncGlobalConfig = false;
+            });
+            if (providerName == "ByteDance") {
+              const selectedModel = models.find(
+                (m) =>
+                  m.name == model && m?.provider?.providerName == providerName,
+              );
+              showToast(selectedModel?.displayName ?? "");
+            } else {
+              showToast(model);
+            }
+          }}
+        />
+      )}
+
+      {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);
+          }}
+        />
+      )}
+
+      {/* <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>
+  );
+}
+
+export function EditMessageModal(props: { onClose: () => void }) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const [messages, setMessages] = useState(session.messages.slice());
+
+  return (
+    <div className="modal-mask">
+      <Modal
+        title={Locale.Chat.EditMessage.Title}
+        onClose={props.onClose}
+        actions={[
+          <IconButton
+            text={Locale.UI.Cancel}
+            icon={<CancelIcon />}
+            key="cancel"
+            onClick={() => {
+              props.onClose();
+            }}
+          />,
+          <IconButton
+            type="primary"
+            text={Locale.UI.Confirm}
+            icon={<ConfirmIcon />}
+            key="ok"
+            onClick={() => {
+              chatStore.updateCurrentSession(
+                (session) => (session.messages = messages),
+              );
+              props.onClose();
+            }}
+          />,
+        ]}
+      >
+        <List>
+          <ListItem
+            title={Locale.Chat.EditMessage.Topic.Title}
+            subTitle={Locale.Chat.EditMessage.Topic.SubTitle}
+          >
+            <input
+              type="text"
+              value={session.topic}
+              onInput={(e) =>
+                chatStore.updateCurrentSession(
+                  (session) => (session.topic = e.currentTarget.value),
+                )
+              }
+            ></input>
+          </ListItem>
+        </List>
+        <ContextPrompts
+          context={messages}
+          updateContext={(updater) => {
+            const newMessages = messages.slice();
+            updater(newMessages);
+            setMessages(newMessages);
+          }}
+        />
+      </Modal>
+    </div>
+  );
+}
+
+export function DeleteImageButton(props: { deleteImage: () => void }) {
+  return (
+    <div className={styles["delete-image"]} onClick={props.deleteImage}>
+      <DeleteIcon />
+    </div>
+  );
+}
+
+function _Chat() {
+  type RenderMessage = ChatMessage & { preview?: boolean };
+
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const config = useAppConfig();
+  config.sendPreviewBubble = false;
+  const fontSize = config.fontSize;
+  const fontFamily = config.fontFamily;
+
+  const [showExport, setShowExport] = useState(false);
+
+  const inputRef = useRef<HTMLTextAreaElement>(null);
+  const [userInput, setUserInput] = useState("");
+  const [isLoading, setIsLoading] = useState(false);
+  const [sendStatus, setSendStatus] = useState(false);
+  const { submitKey, shouldSubmit } = useSubmitHandler();
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const isScrolledToBottom = scrollRef?.current
+    ? Math.abs(
+      scrollRef.current.scrollHeight -
+      (scrollRef.current.scrollTop + scrollRef.current.clientHeight),
+    ) <= 1
+    : false;
+  const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(
+    scrollRef,
+    isScrolledToBottom,
+  );
+  const [hitBottom, setHitBottom] = useState(true);
+  const isMobileScreen = useMobileScreen();
+  const navigate = useNavigate();
+  const [attachImages, setAttachImages] = useState<string[]>([]);
+  const [uploading, setUploading] = useState(false);
+
+  // prompt hints
+  const promptStore = usePromptStore();
+  const [promptHints, setPromptHints] = useState<RenderPrompt[]>([]);
+  const onSearch = useDebouncedCallback(
+    (text: string) => {
+      const matchedPrompts = promptStore.search(text);
+      setPromptHints(matchedPrompts);
+    },
+    100,
+    { leading: true, trailing: true },
+  );
+
+  type AppList = {
+    label: string,
+    value: string,
+    desc: string,
+  }[];
+
+  const [appList, setAppList] = useState<AppList>([]);
+  const [appValue, setAppValue] = useState<string>();
+  const globalStore = useGlobalStore();
+  type QuestionList = string[];
+  const [questionList, setQuestionList] = useState<QuestionList>([]);
+  const location = useLocation();
+  const [loading, setLoading] = useState<boolean>(false);
+
+  const fruits = [
+    { id: 'LOCAL', name: 'deepseek' },
+    { id: 'ONLINE', name: '智谱AI' },
+  ];
+
+  const [selectedFruit, setSelectedFruit] = React.useState(chatStore.chatMode);
+
+  // 获取应用列表
+  const fetchApplicationList = async (chatMode?: string) => {
+    let mode = chatMode || selectedFruit;
+    setLoading(true);
+    try {
+      let url = null;
+      if (mode === 'LOCAL') {
+        url = '/deepseek/api/application/list';
+      } else {
+        url = '/bigmodel/api/application/list';
+      }
+      const res = await api.get(url);
+      const list = res.data.filter((item: any) => item.appId !== '1234567890123456789').map((item: any) => {
+        return {
+          label: item.name,
+          value: item.appId,
+          desc: item.desc,
+        }
+      })
+      setAppList(list);
+      const search = location.search;
+      const params = new URLSearchParams(search);
+      const appId = params.get('appId') || '';
+      if (appId) {
+        setAppValue(appId);
+      } else {
+        setAppValue(list[0]?.value);
+      }
+    } catch (error) {
+      console.error(error);
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  // 获取预设问题列表
+  const fetchDefaultQuestion = async (appId: string) => {
+    try {
+      let url = null;
+      if (selectedFruit === 'LOCAL') {
+        url = '/deepseek/api/presets';
+      } else {
+        url = '/bigmodel/api/presets';
+      }
+      const res = await api.get(url + `/${appId}`);
+      setQuestionList(res.data);
+    } catch (error) {
+      console.error(error);
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  const init = async (chatMode?: string) => {
+    await fetchApplicationList(chatMode);
+  }
+
+  useEffect(() => {
+    const search = location.search;
+    const params = new URLSearchParams(search);
+    const chatMode = params.get('chatMode');
+    if (!chatMode) {
+      init();
+    }
+  }, [selectedFruit])
+
+  useEffect(() => {
+    const search = location.search;
+    const params = new URLSearchParams(search);
+    const chatMode = params.get('chatMode');
+
+    if (chatMode) {
+      setSelectedFruit(chatMode as "ONLINE" | "LOCAL");
+      const appId = params.get('appId');
+      if (appId) {
+        setAppValue(appId);
+        globalStore.setSelectedAppId(appId);
+        chatStore.updateCurrentSession((session) => {
+          session.appId = appId;
+        });
+      }
+      init(chatMode);
+    }
+  }, [])
+
+  useEffect(() => {
+    if (appValue) {
+      fetchDefaultQuestion(appValue);
+    }
+  }, [appValue, chatStore.chatMode])
+
+  const [inputRows, setInputRows] = useState(2);
+  const measure = useDebouncedCallback(
+    () => {
+      const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
+      const inputRows = Math.min(
+        20,
+        Math.max(2 + Number(!isMobileScreen), rows),
+      );
+      setInputRows(inputRows);
+    },
+    100,
+    {
+      leading: true,
+      trailing: true,
+    },
+  );
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  useEffect(measure, [userInput]);
+
+  // chat commands shortcuts
+  const chatCommands = useChatCommand({
+    new: () => chatStore.newSession(),
+    newm: () => navigate(Path.MaskChat),
+    prev: () => chatStore.nextSession(-1),
+    next: () => chatStore.nextSession(1),
+    clear: () =>
+      chatStore.updateCurrentSession(
+        (session) => (session.clearContextIndex = session.messages.length),
+      ),
+    del: () => chatStore.deleteSession(chatStore.currentSessionIndex),
+  });
+
+  // only search prompts when user input is short
+  const SEARCH_TEXT_LIMIT = 30;
+  const onInput = (text: string) => {
+    setUserInput(text);
+    const n = text.trim().length;
+
+    // clear search results
+    if (n === 0) {
+      setPromptHints([]);
+    } else if (text.match(ChatCommandPrefix)) {
+      setPromptHints(chatCommands.search(text));
+    } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
+      // check if need to trigger auto completion
+      if (text.startsWith("/")) {
+        let searchText = text.slice(1);
+        onSearch(searchText);
+      }
+    }
+  };
+
+  const doSubmit = (userInput: string) => {
+    if (userInput.trim() === "") return;
+    const matchCommand = chatCommands.match(userInput);
+    if (matchCommand.matched) {
+      setUserInput("");
+      setPromptHints([]);
+      matchCommand.invoke();
+      return;
+    }
+    setIsLoading(true);
+    chatStore.onUserInput([], userInput, attachImages).then(() => setIsLoading(false));
+    setAttachImages([]);
+    localStorage.setItem(LAST_INPUT_KEY, userInput);
+    setUserInput("");
+    setPromptHints([]);
+    if (!isMobileScreen) inputRef.current?.focus();
+    setAutoScroll(true);
+    setSendStatus(true);
+  };
+
+  const onPromptSelect = (prompt: RenderPrompt) => {
+    setTimeout(() => {
+      setPromptHints([]);
+
+      const matchedChatCommand = chatCommands.match(prompt.content);
+      if (matchedChatCommand.matched) {
+        // if user is selecting a chat command, just trigger it
+        matchedChatCommand.invoke();
+        setUserInput("");
+      } else {
+        // or fill the prompt
+        setUserInput(prompt.content);
+      }
+      inputRef.current?.focus();
+    }, 30);
+  };
+
+  // stop response
+  const onUserStop = (messageId: string) => {
+    ChatControllerPool.stop(session.id, messageId);
+  };
+
+  useEffect(() => {
+    chatStore.updateCurrentSession((session) => {
+      const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
+      session.messages.forEach((m) => {
+        // check if should stop all stale messages
+        if (m.isError || new Date(m.date).getTime() < stopTiming) {
+          if (m.streaming) {
+            m.streaming = false;
+          }
+
+          if (m.content.length === 0) {
+            m.isError = true;
+            m.content = prettyObject({
+              error: true,
+              message: "empty response",
+            });
+          }
+        }
+      });
+
+      // auto sync mask config from global config
+      if (session.mask.syncGlobalConfig) {
+        console.log("[Mask] syncing from global, name = ", session.mask.name);
+        session.mask.modelConfig = { ...config.modelConfig };
+      }
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  // check if should send message
+  const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    if (
+      e.key === "ArrowUp" &&
+      userInput.length <= 0 &&
+      !(e.metaKey || e.altKey || e.ctrlKey)
+    ) {
+      setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
+      e.preventDefault();
+      return;
+    }
+    if (shouldSubmit(e) && promptHints.length === 0) {
+      doSubmit(userInput);
+      e.preventDefault();
+    }
+  };
+
+  const onRightClick = (e: any, message: ChatMessage) => {
+    // copy to clipboard
+    if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) {
+      if (userInput.length === 0) {
+        setUserInput(getMessageTextContent(message));
+      }
+
+      e.preventDefault();
+    }
+  };
+
+  const deleteMessage = (msgId?: string) => {
+    chatStore.updateCurrentSession(
+      (session) =>
+        (session.messages = session.messages.filter((m) => m.id !== msgId)),
+    );
+  };
+
+  const onDelete = (msgId: string) => {
+    deleteMessage(msgId);
+  };
+
+  const onResend = (message: ChatMessage) => {
+    // when it is resending a message
+    // 1. for a user's message, find the next bot response
+    // 2. for a bot's message, find the last user's input
+    // 3. delete original user input and bot's message
+    // 4. resend the user's input
+
+    const resendingIndex = session.messages.findIndex(
+      (m) => m.id === message.id,
+    );
+
+    if (resendingIndex < 0 || resendingIndex >= session.messages.length) {
+      console.error("[Chat] failed to find resending message", message);
+      return;
+    }
+
+    let userMessage: ChatMessage | undefined;
+    let botMessage: ChatMessage | undefined;
+
+    if (message.role === "assistant") {
+      // if it is resending a bot's message, find the user input for it
+      botMessage = message;
+      for (let i = resendingIndex; i >= 0; i -= 1) {
+        if (session.messages[i].role === "user") {
+          userMessage = session.messages[i];
+          break;
+        }
+      }
+    } else if (message.role === "user") {
+      // if it is resending a user's input, find the bot's response
+      userMessage = message;
+      for (let i = resendingIndex; i < session.messages.length; i += 1) {
+        if (session.messages[i].role === "assistant") {
+          botMessage = session.messages[i];
+          break;
+        }
+      }
+    }
+
+    if (userMessage === undefined) {
+      console.error("[Chat] failed to resend", message);
+      return;
+    }
+
+    // delete the original messages
+    deleteMessage(userMessage.id);
+    deleteMessage(botMessage?.id);
+
+    // resend the message
+    setIsLoading(true);
+    const textContent = getMessageTextContent(userMessage);
+    const images = getMessageImages(userMessage);
+    chatStore.onUserInput([], textContent, images).then(() => setIsLoading(false));
+    inputRef.current?.focus();
+  };
+
+  const onPinMessage = (message: ChatMessage) => {
+    chatStore.updateCurrentSession((session) =>
+      session.mask.context.push(message),
+    );
+
+    showToast(Locale.Chat.Actions.PinToastContent, {
+      text: Locale.Chat.Actions.PinToastAction,
+      onClick: () => {
+        setShowPromptModal(true);
+      },
+    });
+  };
+
+  const context: RenderMessage[] = useMemo(() => {
+    return session.mask.hideContext ? [] : session.mask.context.slice();
+  }, [session.mask.context, session.mask.hideContext]);
+  const accessStore = useAccessStore();
+
+  if (
+    context.length === 0 &&
+    session.messages.at(0)?.content !== BOT_HELLO.content
+  ) {
+    const copiedHello = Object.assign({}, BOT_HELLO);
+    if (!accessStore.isAuthorized()) {
+      copiedHello.content = Locale.Error.Unauthorized;
+    }
+    context.push(copiedHello);
+  }
+
+  // preview messages
+  const renderMessages = useMemo(() => {
+    return context.concat(session.messages as RenderMessage[]).concat(
+      isLoading
+        ? [
+          {
+            ...createMessage({
+              role: "assistant",
+              content: "……",
+            }),
+            preview: true,
+          },
+        ]
+        : [],
+    ).concat(
+      userInput.length > 0 && config.sendPreviewBubble
+        ? [
+          {
+            ...createMessage({
+              role: "user",
+              content: userInput,
+            }),
+            preview: true,
+          },
+        ]
+        : [],
+    );
+  }, [
+    config.sendPreviewBubble,
+    context,
+    isLoading,
+    session.messages,
+    userInput,
+  ]);
+
+  const [msgRenderIndex, _setMsgRenderIndex] = useState(
+    Math.max(0, renderMessages.length - CHAT_PAGE_SIZE),
+  );
+
+  function setMsgRenderIndex(newIndex: number) {
+    newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex);
+    newIndex = Math.max(0, newIndex);
+    _setMsgRenderIndex(newIndex);
+  }
+
+  const messages = useMemo(() => {
+    const endRenderIndex = Math.min(
+      msgRenderIndex + 3 * CHAT_PAGE_SIZE,
+      renderMessages.length,
+    );
+    return renderMessages.slice(msgRenderIndex, endRenderIndex);
+  }, [msgRenderIndex, renderMessages]);
+
+  const onChatBodyScroll = (e: HTMLElement) => {
+    const bottomHeight = e.scrollTop + e.clientHeight;
+    const edgeThreshold = e.clientHeight;
+
+    const isTouchTopEdge = e.scrollTop <= edgeThreshold;
+    const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold;
+    const isHitBottom =
+      bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10);
+
+    const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE;
+    const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE;
+
+    if (isTouchTopEdge && !isTouchBottomEdge) {
+      setMsgRenderIndex(prevPageMsgIndex);
+    } else if (isTouchBottomEdge) {
+      setMsgRenderIndex(nextPageMsgIndex);
+    }
+
+    setHitBottom(isHitBottom);
+    setAutoScroll(isHitBottom);
+  };
+
+  function scrollToBottom() {
+    setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
+    scrollDomToBottom();
+  }
+
+  // clear context index = context length + index in messages
+  const clearContextIndex =
+    (session.clearContextIndex ?? -1) >= 0
+      ? session.clearContextIndex! + context.length - msgRenderIndex
+      : -1;
+
+  const [showPromptModal, setShowPromptModal] = useState(false);
+
+  const clientConfig = useMemo(() => getClientConfig(), []);
+
+  const autoFocus = !isMobileScreen; // wont auto focus on mobile screen
+  const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
+
+  useCommand({
+    fill: setUserInput,
+    submit: (text) => {
+      doSubmit(text);
+    },
+    code: (text) => {
+      if (accessStore.disableFastLink) return;
+      console.log("[Command] got code from url: ", text);
+      showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => {
+        if (res) {
+          accessStore.update((access) => (access.accessCode = text));
+        }
+      });
+    },
+    settings: (text) => {
+      if (accessStore.disableFastLink) return;
+
+      try {
+        const payload = JSON.parse(text) as {
+          key?: string;
+          url?: string;
+        };
+
+        console.log("[Command] got settings from url: ", payload);
+
+        if (payload.key || payload.url) {
+          showConfirm(
+            Locale.URLCommand.Settings +
+            `\n${JSON.stringify(payload, null, 4)}`,
+          ).then((res) => {
+            if (!res) return;
+            if (payload.key) {
+              accessStore.update(
+                (access) => (access.openaiApiKey = payload.key!),
+              );
+            }
+            if (payload.url) {
+              accessStore.update((access) => (access.openaiUrl = payload.url!));
+            }
+            accessStore.update((access) => (access.useCustomConfig = true));
+          });
+        }
+      } catch {
+        console.error("[Command] failed to get settings from url: ", text);
+      }
+    },
+  });
+
+  // edit / insert message modal
+  const [isEditingMessage, setIsEditingMessage] = useState(false);
+
+  // remember unfinished input
+  useEffect(() => {
+    // try to load from local storage
+    const key = UNFINISHED_INPUT(session.id);
+    const mayBeUnfinishedInput = localStorage.getItem(key);
+    if (mayBeUnfinishedInput && userInput.length === 0) {
+      setUserInput(mayBeUnfinishedInput);
+      localStorage.removeItem(key);
+    }
+
+    const dom = inputRef.current;
+    return () => {
+      localStorage.setItem(key, dom?.value ?? "");
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  const handlePaste = useCallback(
+    async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
+      const currentModel = chatStore.currentSession().mask.modelConfig.model;
+      if (!isVisionModel(currentModel)) {
+        return;
+      }
+      const items = (event.clipboardData || window.clipboardData).items;
+      for (const item of items) {
+        if (item.kind === "file" && item.type.startsWith("image/")) {
+          event.preventDefault();
+          const file = item.getAsFile();
+          if (file) {
+            const images: string[] = [];
+            images.push(...attachImages);
+            images.push(
+              ...(await new Promise<string[]>((res, rej) => {
+                setUploading(true);
+                const imagesData: string[] = [];
+                uploadImageRemote(file).then((dataUrl) => {
+                  imagesData.push(dataUrl);
+                  setUploading(false);
+                  res(imagesData);
+                }).catch((e) => {
+                  setUploading(false);
+                  rej(e);
+                });
+              })),
+            );
+            const imagesLength = images.length;
+
+            if (imagesLength > 3) {
+              images.splice(3, imagesLength - 3);
+            }
+            setAttachImages(images);
+          }
+        }
+      }
+    },
+    [attachImages, chatStore],
+  );
+
+  async function uploadImage() {
+    const images: string[] = [];
+    images.push(...attachImages);
+
+    images.push(
+      ...(await new Promise<string[]>((res, rej) => {
+        const fileInput = document.createElement("input");
+        fileInput.type = "file";
+        fileInput.accept =
+          "image/png, image/jpeg, image/webp, image/heic, image/heif";
+        fileInput.multiple = true;
+        fileInput.onchange = (event: any) => {
+          setUploading(true);
+          const files = event.target.files;
+          const imagesData: string[] = [];
+          for (let i = 0; i < files.length; i++) {
+            const file = event.target.files[i];
+            uploadImageRemote(file).then((dataUrl) => {
+              imagesData.push(dataUrl);
+              if (
+                imagesData.length === 3 ||
+                imagesData.length === files.length
+              ) {
+                setUploading(false);
+                res(imagesData);
+              }
+            }).catch((e) => {
+              setUploading(false);
+              rej(e);
+            });
+          }
+        };
+        fileInput.click();
+      })),
+    );
+
+    const imagesLength = images.length;
+    if (imagesLength > 3) {
+      images.splice(3, imagesLength - 3);
+    }
+    setAttachImages(images);
+  }
+
+  const getAppName = () => {
+    const search = location.search;
+    const params = new URLSearchParams(search);
+    const chatMode = params.get('chatMode');
+    let appId = '';
+    if (chatMode) {
+      appId = globalStore.selectedAppId;
+    } else {
+      appId = appValue as string;
+    }
+    const item = appList.find(item => item.value === appId);
+    if (!item) {
+      return '';
+    }
+    return item.label;
+  }
+
+  const getDesc = () => {
+    const search = location.search;
+    const params = new URLSearchParams(search);
+    const chatMode = params.get('chatMode');
+    let appId = '';
+    if (chatMode) {
+      appId = globalStore.selectedAppId;
+    } else {
+      appId = appValue as string;
+    }
+    const item = appList.find(item => item.value === appId);
+    if (!item) {
+      return '';
+    }
+    return item.desc;
+  }
+
+  const couldStop = ChatControllerPool.hasPending();
+
+  const stopAll = () => ChatControllerPool.stopAll();
+
+  const [isClickStop, setIsClickStop] = useState(false);
+
+  useEffect(() => {
+    if (!couldStop) {
+      setIsClickStop(false)
+    }
+  }, [couldStop])
+
+  interface FileIconProps {
+    fileName: string;
+  }
+
+  const FileIcon: React.FC<FileIconProps> = (props: FileIconProps) => {
+    const style = {
+      fontSize: '30px',
+      color: '#3875f6',
+    }
+
+    let icon = <FileOutlined style={style} />
+    if (props.fileName) {
+      const suffix = props.fileName.split('.').pop() || '';
+      switch (suffix) {
+        case 'pdf':
+          icon = <FilePdfOutlined style={style} />
+          break;
+        case 'txt':
+          icon = <FileTextOutlined style={style} />
+          break;
+        case 'doc':
+        case 'docx':
+          icon = <FileWordOutlined style={style} />
+          break;
+        default:
+          break;
+      }
+    }
+    return icon;
+  }
+
+  const [drawerOpen, setDrawerOpen] = useState(false);
+  const [drawerData, setDrawerData] = useState({
+    knowledge_id: '',
+    doc_name: '',
+    chunk_info: {
+      doc_id: '',
+      chunk_list: [],
+    }
+  });
+
+  const SliceDrawer: React.FC = () => {
+    const [pageLoading, setPageLoading] = useState(false);
+    const [list, setList] = useState([]);
+
+    const init = async () => {
+      setPageLoading(true);
+      try {
+        const res: any = await api.post('/deepseek/api/slicePage', {
+          knowledge_id: drawerData.knowledge_id,
+          chunk_info: drawerData.chunk_info,
+          pageNum: 1,
+          pageSize: 9999,
+        });
+        console.log(res.rows, 'res.rows');
+
+        setList(res.rows);
+      } catch (error) {
+        console.error(error);
+      } finally {
+        setPageLoading(false);
+      }
+    }
+
+    useEffect(() => {
+      init()
+    }, [])
+
+    return (
+      <Drawer
+        title={drawerData.doc_name}
+        loading={pageLoading}
+        open={drawerOpen}
+        onClose={() => {
+          setDrawerOpen(false);
+        }}
+      >
+        {list.map((item: any, index) => {
+          const score = parseFloat(item.rerankScore);
+          const formattedScore = isNaN(score) ? '0.00' : score.toFixed(2);
+          return <div
+            style={{
+              padding: 10,
+              background: '#fafafa',
+              borderRadius: 4,
+              marginBottom: 10
+            }}
+            key={item.sliceId}
+          >
+            <div
+              style={{
+                display: 'flex',
+                justifyContent: 'space-between',
+                alignItems: 'center',
+              }}
+            >
+              <div style={{ margin: '5px 0' }}>
+                片段{index + 1}
+              </div>
+              <div>
+                <StarTwoTone style={{ marginRight: 10 }} />
+                rerank得分{formattedScore}
+              </div>
+            </div>
+            <div>
+              {item.sliceText}
+            </div>
+          </div>
+        })}
+      </Drawer>
+    )
+  }
+
+  useEffect(() => {
+    // 当显示欢迎页面时,确保滚动到顶部
+    if (messages.length <= 1 && scrollRef.current) {
+      setTimeout(() => {
+        if (scrollRef.current) {
+          scrollRef.current.scrollTo(0, 0);
+        }
+      }, 0);
+    }
+  }, [messages.length]);
+
+  return (
+    <div className={styles.chat} key={session.id}>
+      {
+        <div className="window-header" data-tauri-drag-region>
+          <div style={{ display: 'flex', alignItems: 'center' }}
+            className={`window-header-title ${styles["chat-body-title"]}`}>
+            {
+              <div style={{ marginRight: 10 }}>
+                <Button
+                  type='text'
+                  icon={<MenuOutlined />}
+                  onClick={() => {
+                    globalStore.setShowMenu(!globalStore.showMenu);
+                  }}
+                />
+              </div>
+            }
+            {
+              false &&
+              <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
+                <Select
+                  style={{ width: '100%', height: 38, marginRight: 10 }}
+                  value={selectedFruit}
+                  onChange={(value: "ONLINE" | "LOCAL") => {
+                    chatStore.clearSessions();
+                    chatStore.updateCurrentSession((values) => {
+                      values.appId = globalStore.selectedAppId;
+                    });
+                    navigate({ pathname: '/newChat' });
+                    setSelectedFruit(value);
+                    chatStore.setChatMode(value);
+                  }}
+                >
+                  {fruits.map(fruit => (
+                    <Select.Option key={fruit.id} value={fruit.id}>
+                      {fruit.name}
+                    </Select.Option>
+                  ))}
+                </Select>
+                {
+                  appList.length > 0 ?
+                    <Select
+                      style={{ width: '100%', height: 38, marginRight: 5 }}
+                      placeholder='请选择'
+                      options={appList}
+                      value={appValue}
+                      onChange={(value) => {
+                        setAppValue(value);
+                        globalStore.setSelectedAppId(value);
+                        chatStore.clearSessions();
+                        chatStore.updateCurrentSession((values) => {
+                          values.appId = value;
+                        });
+                        useChatStore.setState({
+                          message: {
+                            content: '',
+                            role: 'assistant',
+                          }
+                        });
+                        setSendStatus(false);
+                      }}
+                    />
+                    :
+                    null
+                }
+              </div>
+            }
+          </div>
+          <div className="window-actions">
+            <div className="window-action-button">
+              <Popover
+                trigger="click"
+                title="分享该应用"
+                content={() => {
+                  const url = `${window.location.origin}/#/knowledgeChat?showMenu=false&chatMode=${selectedFruit}&appId=${appValue}`
+                  return <div>
+                    <div style={{ marginBottom: 10 }}>
+                      {url}
+                    </div>
+                    <Button onClick={() => {
+                      navigator.clipboard.writeText(url);
+                      message.success('分享链接已复制到剪贴板');
+                    }}>
+                      复制
+                    </Button>
+                  </div>
+                }
+                }>
+                <IconButton
+                  icon={<ExportIcon />}
+                  bordered
+                  title='分享该应用'
+                />
+              </Popover>
+            </div>
+            {/* {showMaxIcon && (
+            <div className="window-action-button">
+              <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),
+                  );
+                }}
+              />
+            </div>
+          )} */}
+          </div>
+          {/* <PromptToast
+          showToast={!hitBottom}
+          showModal={showPromptModal}
+          setShowModal={setShowPromptModal}
+        /> */}
+        </div>
+      }
+      <div
+        className={styles["chat-body"]}
+        ref={scrollRef}
+        onScroll={(e) => onChatBodyScroll(e.currentTarget)}
+        onMouseDown={() => inputRef.current?.blur()}
+        onTouchStart={() => {
+          inputRef.current?.blur();
+          setAutoScroll(false);
+        }}
+      >
+        {
+          messages.length > 1 ?
+            <>
+              {messages.map((message, i) => {
+                const isUser = message.role === "user";
+                const isContext = i < context.length;
+                const showActions =
+                  i > 0 &&
+                  !(message.preview || message.content.length === 0) &&
+                  !isContext;
+                const showTyping = message.preview || message.streaming;
+
+                const shouldShowClearContextDivider = i === clearContextIndex - 1;
+
+                return (
+                  <Fragment key={message.id}>
+                    <div
+                      className={
+                        isUser ? styles["chat-message-user"] : styles["chat-message"]
+                      }
+                    >
+                      <div className={styles["chat-message-container"]}>
+                        <div className={styles["chat-message-header"]}>
+                          <div className={styles["chat-message-avatar"]}>
+                            {isUser ? (
+                              // 在这里换头像
+                              <div style={{ position: 'relative' }}>
+                                <div
+                                  style={{
+                                    position: 'absolute',
+                                    zIndex: 2,
+                                    top: '50%',
+                                    left: '50%',
+                                    transform: ' translate(-110%, -100%)',
+                                    fontSize: 14,
+                                  }}>
+                                  我
+                                </div>
+                              </div>
+                            ) : (
+                              <>
+                                {["system"].includes(message.role) ? (
+                                  <Avatar avatar="2699-fe0f" />
+                                ) : (
+                                  <MaskAvatar
+                                    avatar={session.mask.avatar}
+                                    model={
+                                      message.model || session.mask.modelConfig.model
+                                    }
+                                  />
+                                )}
+                              </>
+                            )}
+                          </div>
+                          {/* {showActions && (
+                      <div className={styles["chat-message-actions"]}>
+                        <div className={styles["chat-input-actions"]}>
+                          {message.streaming ? (
+                            <ChatAction
+                              text={Locale.Chat.Actions.Stop}
+                              icon={<StopIcon />}
+                              onClick={() => onUserStop(message.id ?? i)}
+                            />
+                          ) : (
+                            <>
+                              <ChatAction
+                                text={Locale.Chat.Actions.Retry}
+                                icon={<ResetIcon />}
+                                onClick={() => onResend(message)}
+                              />
+
+                              <ChatAction
+                                text={Locale.Chat.Actions.Delete}
+                                icon={<DeleteIcon />}
+                                onClick={() => onDelete(message.id ?? i)}
+                              />
+
+                              <ChatAction
+                                text={Locale.Chat.Actions.Pin}
+                                icon={<PinIcon />}
+                                onClick={() => onPinMessage(message)}
+                              />
+                              <ChatAction
+                                text={Locale.Chat.Actions.Copy}
+                                icon={<CopyIcon />}
+                                onClick={() =>
+                                  copyToClipboard(
+                                    getMessageTextContent(message),
+                                  )
+                                }
+                              />
+                            </>
+                          )}
+                        </div>
+                      </div>
+                    )} */}
+                        </div>
+                        {
+                          showTyping ?
+                            <div className={styles["chat-message-status"]}>
+                              {isUser ? '' : '正在查询文档…'}
+                            </div>
+                            :
+                            <div className={styles["chat-message-status"]}>
+                              {
+                                message.role === 'assistant' && messages.length - 1 === i &&
+                                <div>
+                                  <CheckCircleOutlined /> 文档搜索成功
+                                </div>
+                              }
+                            </div>
+                        }
+                        {
+                          message.sliceInfo &&
+                          <div style={{ marginTop: 10 }}>
+                            <Collapse
+                              bordered={false}
+                              expandIcon={({ isActive }) => <CaretRightOutlined rotate={isActive ? 90 : 0} />}
+                              items={[
+                                {
+                                  key: '1',
+                                  label: `查询到“${message.sliceInfo.doc.length}条”相关切片`,
+                                  children: <div>
+                                    {message.sliceInfo.doc.map((item, index) => {
+                                      return <div
+                                        style={{
+                                          padding: 10,
+                                          background: '#FFFFFF',
+                                          borderRadius: 4,
+                                          display: 'flex',
+                                          justifyContent: 'space-between',
+                                          alignItems: 'center',
+                                          marginTop: index ? 10 : 0,
+                                          cursor: 'pointer',
+                                        }}
+                                        key={item.doc_id}
+                                        onClick={() => {
+                                          setDrawerData({
+                                            knowledge_id: message.sliceInfo!.knowledge_id,
+                                            doc_name: item.doc_name,
+                                            chunk_info: {
+                                              doc_id: item.doc_id,
+                                              chunk_list: item.chunk_info_list,
+                                            }
+                                          });
+                                          setDrawerOpen(true);
+                                        }}
+                                      >
+                                        <div style={{ display: 'flex', alignItems: 'center' }}>
+                                          <FileIcon fileName={item.doc_name} />
+                                          <div style={{ marginLeft: 10 }}>
+                                            {item.doc_name}
+                                          </div>
+                                        </div>
+                                        <div style={{
+                                          padding: 10,
+                                          background: '#FAFAFA',
+                                          borderRadius: 4,
+                                          marginLeft: 20,
+                                        }}>
+                                          {item.chunk_nums}
+                                        </div>
+                                      </div>
+                                    })}
+                                  </div>,
+                                }
+                              ]}
+                            />
+                            {
+                              drawerOpen &&
+                              <SliceDrawer />
+                            }
+                          </div>
+                        }
+                        <div className={styles["chat-message-item"]}>
+                          <Markdown
+                            key={message.streaming ? "loading" : "done"}
+                            content={getMessageTextContent(message)}
+                            loading={
+                              (message.preview || message.streaming) &&
+                              message.content.length === 0 &&
+                              !isUser
+                            }
+                            // onContextMenu={(e) => onRightClick(e, message)}
+                            onDoubleClickCapture={() => {
+                              if (!isMobileScreen) return;
+                              setUserInput(getMessageTextContent(message));
+                            }}
+                            fontSize={fontSize}
+                            fontFamily={fontFamily}
+                            parentRef={scrollRef}
+                            defaultShow={i >= messages.length - 6}
+                          />
+                          {getMessageImages(message).length == 1 && (
+                            <img
+                              className={styles["chat-message-item-image"]}
+                              src={getMessageImages(message)[0]}
+                              alt=""
+                            />
+                          )}
+                          {getMessageImages(message).length > 1 && (
+                            <div
+                              className={styles["chat-message-item-images"]}
+                              style={
+                                {
+                                  "--image-count": getMessageImages(message).length,
+                                } as React.CSSProperties
+                              }
+                            >
+                              {getMessageImages(message).map((image, index) => {
+                                return (
+                                  <img
+                                    className={
+                                      styles["chat-message-item-image-multi"]
+                                    }
+                                    key={index}
+                                    src={image}
+                                    alt=""
+                                  />
+                                );
+                              })}
+                            </div>
+                          )}
+                        </div>
+                        {/* <div className={styles["chat-message-action-date"]}>
+                    {isContext
+                      ? Locale.Chat.IsContext
+                      : message.date.toLocaleString()}
+                  </div> */}
+                      </div>
+                    </div>
+                    {shouldShowClearContextDivider && <ClearContextDivider />}
+                  </Fragment>
+                );
+              })}
+            </>
+            :
+            <>
+              <div style={{ padding: '0 20px' }}>
+                <div style={{ display: 'flex', justifyContent: 'center' }}>
+                  <img style={{ width: 80, height: 80 }} src={avatarSrc.src} />
+                </div>
+                <h1 style={{ textAlign: 'center' }}>
+                  {getAppName()}
+                </h1>
+                <p style={{ textAlign: 'center' }}>
+                  {getDesc()}
+                </p>
+                <p>我猜您可能想问:</p>
+                {
+                  questionList.map((item, index) => {
+                    return (
+                      <div
+                        style={{
+                          padding: '10px',
+                          marginBottom: '10px',
+                          border: '1px solid #e6e8f1',
+                          borderRadius: '10px',
+                          fontSize: '16px',
+                          display: 'flex',
+                          justifyContent: 'space-between',
+                          alignItems: 'center',
+                          cursor: 'pointer'
+                        }}
+                        onClick={() => {
+                          setUserInput(item)
+                          doSubmit(item)
+                        }}
+                        key={index}
+                      >
+                        <div>
+                          {item}
+                        </div>
+                        <RightOutlined />
+                      </div>
+                    )
+                  })
+                }
+              </div>
+            </>
+        }
+      </div>
+      <div className={styles["chat-input-panel"]}>
+        {/* <PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} /> */}
+        <ChatActions
+          isClickStop={isClickStop}
+          sendStatus={sendStatus}
+          setSendStatus={setSendStatus}
+          setUserInput={setUserInput}
+          doSubmit={doSubmit}
+          uploadImage={uploadImage}
+          setAttachImages={setAttachImages}
+          setUploading={setUploading}
+          showPromptModal={() => setShowPromptModal(true)}
+          scrollToBottom={scrollToBottom}
+          hitBottom={hitBottom}
+          uploading={uploading}
+          showPromptHints={() => {
+            // Click again to close
+            if (promptHints.length > 0) {
+              setPromptHints([]);
+              return;
+            }
+
+            inputRef.current?.focus();
+            setUserInput("/");
+            onSearch("");
+          }}
+        />
+        <label
+          className={`${styles["chat-input-panel-inner"]} ${attachImages.length != 0
+            ? styles["chat-input-panel-inner-attach"]
+            : ""
+            }`}
+          htmlFor="chat-input"
+        >
+          <textarea
+            id="chat-input"
+            ref={inputRef}
+            className={styles["chat-input"]}
+            placeholder={Locale.Chat.Input(submitKey)}
+            onInput={(e) => onInput(e.currentTarget.value)}
+            value={userInput}
+            onKeyDown={onInputKeyDown}
+            onFocus={scrollToBottom}
+            onClick={scrollToBottom}
+            onPaste={handlePaste}
+            rows={inputRows}
+            autoFocus={autoFocus}
+            style={{
+              fontSize: config.fontSize,
+              fontFamily: config.fontFamily,
+            }}
+          />
+          {attachImages.length != 0 && (
+            <div className={styles["attach-images"]}>
+              {attachImages.map((image, index) => {
+                return (
+                  <div
+                    key={index}
+                    className={styles["attach-image"]}
+                    style={{ backgroundImage: `url("${image}")` }}
+                  >
+                    <div className={styles["attach-image-mask"]}>
+                      <DeleteImageButton
+                        deleteImage={() => {
+                          setAttachImages(
+                            attachImages.filter((_, i) => i !== index),
+                          );
+                        }}
+                      />
+                    </div>
+                  </div>
+                );
+              })}
+            </div>
+          )}
+          <IconButton
+            icon={couldStop ? <div style={{ width: 13, height: 13, background: '#FFFFFF', borderRadius: 2 }}></div> : <SendWhiteIcon />}
+            text={couldStop ? '停止' : '发送'}
+            className={styles["chat-input-send"]}
+            type="primary"
+            onClick={() => {
+              if (couldStop) {
+                stopAll();
+                setIsClickStop(true);
+              } else {
+                doSubmit(userInput);
+              }
+            }}
+          />
+        </label>
+      </div>
+      <div style={{ paddingBottom: 12, textAlign: 'center', color: '#888888', fontSize: 12 }}>
+        内容由AI生成,仅供参考
+      </div>
+      {showExport && (
+        <ExportMessageModal onClose={() => setShowExport(false)} />
+      )}
+
+      {isEditingMessage && (
+        <EditMessageModal
+          onClose={() => {
+            setIsEditingMessage(false);
+          }}
+        />
+      )}
+    </div>
+  );
+}
+
+export function Chat() {
+  const globalStore = useGlobalStore();
+  const chatStore = useChatStore();
+  const location = useLocation();
+  const sessionIndex = chatStore.currentSessionIndex;
+
+  useEffect(() => {
+    chatStore.setModel('BigModel');
+    const search = location.search;
+    const params = new URLSearchParams(search);
+    const showMenu = params.get('showMenu');
+    const chatMode = params.get('chatMode');
+
+    if (showMenu) {
+      if (showMenu === 'true') {
+        globalStore.setShowMenu(true);
+      } else if (showMenu === 'false') {
+        globalStore.setShowMenu(false);
+      }
+    } else {
+      globalStore.setShowMenu(true);
+    }
+
+    if (chatMode) {
+      chatStore.setChatMode(chatMode as "ONLINE" | "LOCAL");
+    }
+  }, []);
+
+  return <_Chat key={sessionIndex}></_Chat>;
+}

+ 72 - 0
app/components/deepSeekHome.scss

@@ -0,0 +1,72 @@
+.deekSeek {
+  width: 100%;
+  /* 使用 fill-available 确保兼容性 */
+  height: -webkit-fill-available;
+  height: 100dvh;
+  /* 新的动态视口单位,推荐使用 */
+  background: linear-gradient(90.52deg, rgba(24, 126, 255, 1) 1.54%, rgba(23, 66, 255, 1) 99.26%);
+  padding-bottom: env(safe-area-inset-bottom);
+  /* 适配底部安全区域 */
+
+  &-header {
+    width: 100%;
+    height: 60px;
+    border: 1px solid rgba(24, 126, 255, 1);
+    display: flex;
+    color: #FFFFFF;
+    justify-content: center;
+    align-items: center;
+    overflow-x: auto;
+    overflow-y: hidden;
+    box-sizing: border-box;
+  }
+
+  &-content {
+    width: 100%;
+    height: calc(100% - 80px - env(safe-area-inset-bottom));
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    flex-direction: column;
+
+    &-title {
+      display: flex;
+      justify-content: center;
+      margin-bottom: 5px;
+
+      img {
+        width: 200px;
+      }
+    }
+
+    &-title-sm {
+      font-size: 20px;
+      color: #FFFFFF;
+
+      @media (max-width: 768px) {
+        font-size: 16px;
+      }
+
+      @media (max-width: 480px) {
+        font-size: 14px;
+      }
+    }
+
+    &-pc {
+      width: 36%;
+      min-width: 400px;
+      height: 78%;
+      background: #FFFFFF;
+      border-radius: 12px;
+      overflow: hidden;
+    }
+
+    &-mobile {
+      width: 90%;
+      height: 82%;
+      background: #FFFFFF;
+      border-radius: 12px;
+      overflow: hidden;
+    }
+  }
+}

+ 59 - 0
app/components/error.tsx

@@ -0,0 +1,59 @@
+"use client";
+
+import React from "react";
+import { IconButton } from "./button";
+import ResetIcon from "../icons/reload.svg";
+import Locale from "../locales";
+
+
+interface IErrorBoundaryState {
+  hasError: boolean;
+  error: Error | null;
+  info: React.ErrorInfo | null;
+}
+
+export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
+  constructor(props: any) {
+    super(props);
+    this.state = { hasError: false, error: null, info: null };
+  }
+
+  componentDidCatch(error: Error, info: React.ErrorInfo) {
+    // Update state with error details
+    this.setState({ hasError: true, error, info });
+  }
+
+  clearAndSaveData() {
+    // 直接清除数据,不下载备份
+    localStorage.clear();
+    location.reload();
+  }
+
+  render() {
+    if (this.state.hasError) {
+      // Render error message
+      return (
+        <div className="error">
+          <h2>Oops, something went wrong!</h2>
+          <pre>
+            <code>{this.state.error?.toString()}</code>
+            <code>{this.state.info?.componentStack}</code>
+          </pre>
+
+          <div style={{ display: "flex", justifyContent: "center" }}>
+            <IconButton
+              icon={<ResetIcon />}
+              text="Clear All Data"
+              onClick={() => {
+                this.clearAndSaveData();
+              }}
+              bordered
+            />
+          </div>
+        </div>
+      );
+    }
+    // if no error occurred, render children
+    return this.props.children;
+  }
+}

+ 271 - 0
app/components/exporter.module.scss

@@ -0,0 +1,271 @@
+.message-exporter {
+  &-body {
+    margin-top: 20px;
+  }
+}
+
+.export-content {
+  white-space: break-spaces;
+  padding: 10px !important;
+}
+
+.steps {
+  background-color: var(--gray);
+  border-radius: 10px;
+  overflow: hidden;
+  padding: 5px;
+  position: relative;
+  box-shadow: var(--card-shadow) inset;
+
+  .steps-progress {
+    $padding: 5px;
+    height: calc(100% - 2 * $padding);
+    width: calc(100% - 2 * $padding);
+    position: absolute;
+    top: $padding;
+    left: $padding;
+
+    &-inner {
+      box-sizing: border-box;
+      box-shadow: var(--card-shadow);
+      border: var(--border-in-light);
+      content: "";
+      display: inline-block;
+      width: 0%;
+      height: 100%;
+      background-color: var(--white);
+      transition: all ease 0.3s;
+      border-radius: 8px;
+    }
+  }
+
+  .steps-inner {
+    display: flex;
+    transform: scale(1);
+
+    .step {
+      flex-grow: 1;
+      padding: 5px 10px;
+      font-size: 14px;
+      color: var(--black);
+      opacity: 0.5;
+      transition: all ease 0.3s;
+
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      $radius: 8px;
+
+      &-finished {
+        opacity: 0.9;
+      }
+
+      &:hover {
+        opacity: 0.8;
+      }
+
+      &-current {
+        color: var(--primary);
+      }
+
+      .step-index {
+        background-color: var(--gray);
+        border: var(--border-in-light);
+        border-radius: 6px;
+        display: inline-block;
+        padding: 0px 5px;
+        font-size: 12px;
+        margin-right: 8px;
+        opacity: 0.8;
+      }
+
+      .step-name {
+        font-size: 12px;
+      }
+    }
+  }
+}
+
+.preview-actions {
+  margin-bottom: 20px;
+  display: flex;
+  justify-content: space-between;
+
+  button {
+    flex-grow: 1;
+
+    &:not(:last-child) {
+      margin-right: 10px;
+    }
+  }
+}
+
+.image-previewer {
+  .preview-body {
+    border-radius: 10px;
+    padding: 20px;
+    box-shadow: var(--card-shadow) inset;
+    background-color: var(--gray);
+
+    .chat-info {
+      background-color: var(--second);
+      padding: 20px;
+      border-radius: 10px;
+      margin-bottom: 20px;
+      display: flex;
+      justify-content: space-between;
+      align-items: flex-end;
+      position: relative;
+      overflow: hidden;
+
+      @media screen and (max-width: 600px) {
+        flex-direction: column;
+        align-items: flex-start;
+
+        .icons {
+          margin-bottom: 20px;
+        }
+      }
+
+      .logo {
+        position: absolute;
+        top: 0px;
+        left: 0px;
+        height: 50%;
+        transform: scale(1.5);
+      }
+
+      .main-title {
+        font-size: 20px;
+        font-weight: bolder;
+      }
+
+      .sub-title {
+        font-size: 12px;
+      }
+
+      .icons {
+        margin-top: 10px;
+        display: flex;
+        align-items: center;
+
+        .icon-space {
+          font-size: 12px;
+          margin: 0 10px;
+          font-weight: bolder;
+          color: var(--primary);
+        }
+      }
+
+      .chat-info-item {
+        font-size: 12px;
+        color: var(--primary);
+        padding: 2px 15px;
+        border-radius: 10px;
+        background-color: var(--white);
+        box-shadow: var(--card-shadow);
+
+        &:not(:last-child) {
+          margin-bottom: 5px;
+        }
+      }
+    }
+
+    .message {
+      margin-bottom: 20px;
+      display: flex;
+
+      .avatar {
+        margin-right: 10px;
+      }
+
+      .body {
+        border-radius: 10px;
+        padding: 8px 10px;
+        max-width: calc(100% - 104px);
+        box-shadow: var(--card-shadow);
+        border: var(--border-in-light);
+
+        code,
+        pre {
+          overflow: hidden;
+        }
+
+        .message-image {
+          width: 100%;
+          margin-top: 10px;
+        }
+
+        .message-images {
+          display: grid;
+          justify-content: left;
+          grid-gap: 10px;
+          grid-template-columns: repeat(var(--image-count), auto);
+          margin-top: 10px;
+        }
+
+        @media screen and (max-width: 600px) {
+          $image-width: calc(calc(100vw/2)/var(--image-count));
+
+          .message-image-multi {
+            width: $image-width;
+            height: $image-width;
+          }
+
+          .message-image {
+            max-width: calc(100vw/3*2);
+          }
+        }
+
+        @media screen and (min-width: 600px) {
+          $max-image-width: calc(900px/3*2/var(--image-count));
+          $image-width: calc(80vw/3*2/var(--image-count));
+
+          .message-image-multi {
+            width: $image-width;
+            height: $image-width;
+            max-width: $max-image-width;
+            max-height: $max-image-width;
+          }
+
+          .message-image {
+            max-width: calc(100vw/3*2);
+          }
+        }
+
+        .message-image-multi {
+          object-fit: cover;
+        }
+
+        .message-image,
+        .message-image-multi {
+          box-sizing: border-box;
+          border-radius: 10px;
+          border: rgba($color: #888, $alpha: 0.2) 1px solid;
+        }
+      }
+
+      &-assistant {
+        .body {
+          background-color: var(--white);
+        }
+      }
+
+      &-user {
+        flex-direction: row-reverse;
+
+        .avatar {
+          margin-right: 0;
+        }
+
+        .body {
+          background-color: var(--second);
+          margin-right: 10px;
+        }
+      }
+    }
+  }
+
+  .default-theme {}
+}

+ 721 - 0
app/components/exporter.tsx

@@ -0,0 +1,721 @@
+/* eslint-disable @next/next/no-img-element */
+import { ChatMessage, ModelType, useAppConfig, useChatStore } from "../store";
+import Locale from "../locales";
+import styles from "./exporter.module.scss";
+import {
+  List,
+  ListItem,
+  Modal,
+  Select,
+  showImageModal,
+  showModal,
+  showToast,
+} from "./ui-lib";
+import { IconButton } from "./button";
+import {
+  copyToClipboard,
+  downloadAs,
+  getMessageImages,
+  useMobileScreen,
+} from "../utils";
+
+import CopyIcon from "../icons/copy.svg";
+import LoadingIcon from "../icons/three-dots.svg";
+import ChatGptIcon from "../icons/chatgpt.png";
+import ShareIcon from "../icons/share.svg";
+import DownloadIcon from "../icons/download.svg";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { MessageSelector, useMessageSelector } from "./message-selector";
+// Avatar组件替代实现
+import BotIcon from "../icons/bot.svg";
+import BlackBotIcon from "../icons/black-bot.svg";
+
+function Avatar(props: { model?: string; avatar?: string }) {
+  if (props.model) {
+    return (
+      <div className="no-dark">
+        {props.model?.startsWith("gpt-4") ? (
+          <BlackBotIcon className="user-avatar" />
+        ) : (
+          <BotIcon className="user-avatar" />
+        )}
+      </div>
+    );
+  }
+
+  return (
+    <div className="user-avatar">
+      {/* 移除emoji头像,使用默认bot图标 */}
+      <BotIcon className="user-avatar" />
+    </div>
+  );
+}
+import dynamic from "next/dynamic";
+import NextImage from "next/image";
+
+import { toBlob, toPng } from "html-to-image";
+import { DEFAULT_MASK_AVATAR } from "../store/mask";
+
+import { prettyObject } from "../utils/format";
+import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
+import { getClientConfig } from "../config/client";
+import { type ClientApi, getClientApi } from "../client/api";
+import { getMessageTextContent } from "../utils";
+
+const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
+  loading: () => <LoadingIcon />,
+});
+
+export function ExportMessageModal(props: { onClose: () => void }) {
+  return (
+    <div className="modal-mask">
+      <Modal
+        title={Locale.Export.Title}
+        onClose={props.onClose}
+        footer={
+          <div
+            style={{
+              width: "100%",
+              textAlign: "center",
+              fontSize: 14,
+              opacity: 0.5,
+            }}
+          >
+            {Locale.Exporter.Description.Title}
+          </div>
+        }
+      >
+        <div style={{ minHeight: "40vh" }}>
+          <MessageExporter />
+        </div>
+      </Modal>
+    </div>
+  );
+}
+
+function useSteps(
+  steps: Array<{
+    name: string;
+    value: string;
+  }>,
+) {
+  const stepCount = steps.length;
+  const [currentStepIndex, setCurrentStepIndex] = useState(0);
+  const nextStep = () =>
+    setCurrentStepIndex((currentStepIndex + 1) % stepCount);
+  const prevStep = () =>
+    setCurrentStepIndex((currentStepIndex - 1 + stepCount) % stepCount);
+
+  return {
+    currentStepIndex,
+    setCurrentStepIndex,
+    nextStep,
+    prevStep,
+    currentStep: steps[currentStepIndex],
+  };
+}
+
+function Steps<
+  T extends {
+    name: string;
+    value: string;
+  }[],
+>(props: { steps: T; onStepChange?: (index: number) => void; index: number }) {
+  const steps = props.steps;
+  const stepCount = steps.length;
+
+  return (
+    <div className={styles["steps"]}>
+      <div className={styles["steps-progress"]}>
+        <div
+          className={styles["steps-progress-inner"]}
+          style={{
+            width: `${((props.index + 1) / stepCount) * 100}%`,
+          }}
+        ></div>
+      </div>
+      <div className={styles["steps-inner"]}>
+        {steps.map((step, i) => {
+          return (
+            <div
+              key={i}
+              className={`${styles["step"]} ${styles[i <= props.index ? "step-finished" : ""]
+                } ${i === props.index && styles["step-current"]} clickable`}
+              onClick={() => {
+                props.onStepChange?.(i);
+              }}
+              role="button"
+            >
+              <span className={styles["step-index"]}>{i + 1}</span>
+              <span className={styles["step-name"]}>{step.name}</span>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+export function MessageExporter() {
+  const steps = [
+    {
+      name: Locale.Export.Steps.Select,
+      value: "select",
+    },
+    {
+      name: Locale.Export.Steps.Preview,
+      value: "preview",
+    },
+  ];
+  const { currentStep, setCurrentStepIndex, currentStepIndex } =
+    useSteps(steps);
+  const formats = ["text", "image", "json"] as const;
+  type ExportFormat = (typeof formats)[number];
+
+  const [exportConfig, setExportConfig] = useState({
+    format: "image" as ExportFormat,
+    includeContext: true,
+  });
+
+  function updateExportConfig(updater: (config: typeof exportConfig) => void) {
+    const config = { ...exportConfig };
+    updater(config);
+    setExportConfig(config);
+  }
+
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const { selection, updateSelection } = useMessageSelector();
+  const selectedMessages = useMemo(() => {
+    const ret: ChatMessage[] = [];
+    if (exportConfig.includeContext) {
+      ret.push(...session.mask.context);
+    }
+    ret.push(...session.messages.filter((m) => selection.has(m.id)));
+    return ret;
+  }, [
+    exportConfig.includeContext,
+    session.messages,
+    session.mask.context,
+    selection,
+  ]);
+  function preview() {
+    if (exportConfig.format === "text") {
+      return (
+        <MarkdownPreviewer messages={selectedMessages} topic={session.topic} />
+      );
+    } else if (exportConfig.format === "json") {
+      return (
+        <JsonPreviewer messages={selectedMessages} topic={session.topic} />
+      );
+    } else {
+      return (
+        <ImagePreviewer messages={selectedMessages} topic={session.topic} />
+      );
+    }
+  }
+  return (
+    <>
+      <Steps
+        steps={steps}
+        index={currentStepIndex}
+        onStepChange={setCurrentStepIndex}
+      />
+      <div
+        className={styles["message-exporter-body"]}
+        style={currentStep.value !== "select" ? { display: "none" } : {}}
+      >
+        <List>
+          <ListItem
+            title={Locale.Export.Format.Title}
+            subTitle={Locale.Export.Format.SubTitle}
+          >
+            <Select
+              value={exportConfig.format}
+              onChange={(e) =>
+                updateExportConfig(
+                  (config) =>
+                    (config.format = e.currentTarget.value as ExportFormat),
+                )
+              }
+            >
+              {formats.map((f) => (
+                <option key={f} value={f}>
+                  {f}
+                </option>
+              ))}
+            </Select>
+          </ListItem>
+          <ListItem
+            title={Locale.Export.IncludeContext.Title}
+            subTitle={Locale.Export.IncludeContext.SubTitle}
+          >
+            <input
+              type="checkbox"
+              checked={exportConfig.includeContext}
+              onChange={(e) => {
+                updateExportConfig(
+                  (config) => (config.includeContext = e.currentTarget.checked),
+                );
+              }}
+            ></input>
+          </ListItem>
+        </List>
+        <MessageSelector
+          selection={selection}
+          updateSelection={updateSelection}
+          defaultSelectAll
+        />
+      </div>
+      {currentStep.value === "preview" && (
+        <div className={styles["message-exporter-body"]}>{preview()}</div>
+      )}
+    </>
+  );
+}
+
+export function RenderExport(props: {
+  messages: ChatMessage[];
+  onRender: (messages: ChatMessage[]) => void;
+}) {
+  const domRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!domRef.current) return;
+    const dom = domRef.current;
+    const messages = Array.from(
+      dom.getElementsByClassName(EXPORT_MESSAGE_CLASS_NAME),
+    );
+
+    if (messages.length !== props.messages.length) {
+      return;
+    }
+
+    const renderMsgs = messages.map((v, i) => {
+      const [role, _] = v.id.split(":");
+      return {
+        id: i.toString(),
+        role: role as any,
+        content: role === "user" ? v.textContent ?? "" : v.innerHTML,
+        date: "",
+      };
+    });
+
+    props.onRender(renderMsgs);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  return (
+    <div ref={domRef}>
+      {props.messages.map((m, i) => (
+        <div
+          key={i}
+          id={`${m.role}:${i}`}
+          className={EXPORT_MESSAGE_CLASS_NAME}
+        >
+          <Markdown content={getMessageTextContent(m)} defaultShow />
+        </div>
+      ))}
+    </div>
+  );
+}
+
+export function PreviewActions(props: {
+  download: () => void;
+  copy: () => void;
+  showCopy?: boolean;
+  messages?: ChatMessage[];
+}) {
+  const [loading, setLoading] = useState(false);
+  const [shouldExport, setShouldExport] = useState(false);
+  const config = useAppConfig();
+  const onRenderMsgs = (msgs: ChatMessage[]) => {
+    setShouldExport(false);
+
+    const api: ClientApi = getClientApi(config.modelConfig.providerName);
+
+    api
+      .share(msgs)
+      .then((res) => {
+        if (!res) return;
+        showModal({
+          title: Locale.Export.Share,
+          children: [
+            <input
+              type="text"
+              value={res}
+              key="input"
+              style={{
+                width: "100%",
+                maxWidth: "unset",
+              }}
+              readOnly
+              onClick={(e) => e.currentTarget.select()}
+            ></input>,
+          ],
+          actions: [
+            <IconButton
+              icon={<CopyIcon />}
+              text={Locale.Chat.Actions.Copy}
+              key="copy"
+              onClick={() => copyToClipboard(res)}
+            />,
+          ],
+        });
+        setTimeout(() => {
+          window.open(res, "_blank");
+        }, 800);
+      })
+      .catch((e) => {
+        console.error("[Share]", e);
+        showToast(prettyObject(e));
+      })
+      .finally(() => setLoading(false));
+  };
+
+  const share = async () => {
+    if (props.messages?.length) {
+      setLoading(true);
+      setShouldExport(true);
+    }
+  };
+
+  return (
+    <>
+      <div className={styles["preview-actions"]}>
+        {props.showCopy && (
+          <IconButton
+            text={Locale.Export.Copy}
+            bordered
+            shadow
+            icon={<CopyIcon />}
+            onClick={props.copy}
+          ></IconButton>
+        )}
+        <IconButton
+          text={Locale.Export.Download}
+          bordered
+          shadow
+          icon={<DownloadIcon />}
+          onClick={props.download}
+        ></IconButton>
+        <IconButton
+          text={Locale.Export.Share}
+          bordered
+          shadow
+          icon={loading ? <LoadingIcon /> : <ShareIcon />}
+          onClick={share}
+        ></IconButton>
+      </div>
+      <div
+        style={{
+          position: "fixed",
+          right: "200vw",
+          pointerEvents: "none",
+        }}
+      >
+        {shouldExport && (
+          <RenderExport
+            messages={props.messages ?? []}
+            onRender={onRenderMsgs}
+          />
+        )}
+      </div>
+    </>
+  );
+}
+
+function ExportAvatar(props: { avatar: string }) {
+  if (props.avatar === DEFAULT_MASK_AVATAR) {
+    return (
+      <img
+        src={BotIcon.src}
+        width={30}
+        height={30}
+        alt="bot"
+        className="user-avatar"
+      />
+    );
+  }
+
+  return <Avatar avatar={props.avatar} />;
+}
+
+export function ImagePreviewer(props: {
+  messages: ChatMessage[];
+  topic: string;
+}) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const mask = session.mask;
+  const config = useAppConfig();
+
+  const previewRef = useRef<HTMLDivElement>(null);
+
+  const copy = () => {
+    showToast(Locale.Export.Image.Toast);
+    const dom = previewRef.current;
+    if (!dom) return;
+    toBlob(dom).then((blob) => {
+      if (!blob) return;
+      try {
+        navigator.clipboard
+          .write([
+            new ClipboardItem({
+              "image/png": blob,
+            }),
+          ])
+          .then(() => {
+            showToast(Locale.Copy.Success);
+            refreshPreview();
+          });
+      } catch (e) {
+        console.error("[Copy Image] ", e);
+        showToast(Locale.Copy.Failed);
+      }
+    });
+  };
+
+  const isMobile = useMobileScreen();
+
+  const download = async () => {
+    showToast(Locale.Export.Image.Toast);
+    const dom = previewRef.current;
+    if (!dom) return;
+
+    const isApp = getClientConfig()?.isApp;
+
+    try {
+      const blob = await toPng(dom);
+      if (!blob) return;
+
+      if (isMobile || (isApp && window.__TAURI__)) {
+        if (isApp && window.__TAURI__) {
+          const result = await window.__TAURI__.dialog.save({
+            defaultPath: `${props.topic}.png`,
+            filters: [
+              {
+                name: "PNG Files",
+                extensions: ["png"],
+              },
+              {
+                name: "All Files",
+                extensions: ["*"],
+              },
+            ],
+          });
+
+          if (result !== null) {
+            const response = await fetch(blob);
+            const buffer = await response.arrayBuffer();
+            const uint8Array = new Uint8Array(buffer);
+            await window.__TAURI__.fs.writeBinaryFile(result, uint8Array);
+            showToast(Locale.Download.Success);
+          } else {
+            showToast(Locale.Download.Failed);
+          }
+        } else {
+          showImageModal(blob);
+        }
+      } else {
+        const link = document.createElement("a");
+        link.download = `${props.topic}.png`;
+        link.href = blob;
+        link.click();
+        refreshPreview();
+      }
+    } catch (error) {
+      showToast(Locale.Download.Failed);
+    }
+  };
+
+  const refreshPreview = () => {
+    const dom = previewRef.current;
+    if (dom) {
+      dom.innerHTML = dom.innerHTML; // Refresh the content of the preview by resetting its HTML for fix a bug glitching
+    }
+  };
+
+  return (
+    <div className={styles["image-previewer"]}>
+      <PreviewActions
+        copy={copy}
+        download={download}
+        showCopy={!isMobile}
+        messages={props.messages}
+      />
+      <div
+        className={`${styles["preview-body"]} ${styles["default-theme"]}`}
+        ref={previewRef}
+      >
+        <div className={styles["chat-info"]}>
+          <div className={styles["logo"] + " no-dark"}>
+            <NextImage
+              src={ChatGptIcon.src}
+              alt="logo"
+              width={50}
+              height={50}
+            />
+          </div>
+
+          <div>
+            <div className={styles["main-title"]}>NextChat</div>
+            <div className={styles["sub-title"]}>
+              github.com/ChatGPTNextWeb/ChatGPT-Next-Web
+            </div>
+            <div className={styles["icons"]}>
+              <ExportAvatar avatar={config.avatar} />
+              <span className={styles["icon-space"]}>&</span>
+              <ExportAvatar avatar={mask.avatar} />
+            </div>
+          </div>
+          <div>
+            <div className={styles["chat-info-item"]}>
+              {Locale.Exporter.Model}: {mask.modelConfig.model}
+            </div>
+            <div className={styles["chat-info-item"]}>
+              {Locale.Exporter.Messages}: {props.messages.length}
+            </div>
+            <div className={styles["chat-info-item"]}>
+              {Locale.Exporter.Topic}: {session.topic}
+            </div>
+            <div className={styles["chat-info-item"]}>
+              {Locale.Exporter.Time}:{" "}
+              {new Date(
+                props.messages.at(-1)?.date ?? Date.now(),
+              ).toLocaleString()}
+            </div>
+          </div>
+        </div>
+        {props.messages.map((m, i) => {
+          return (
+            <div
+              className={styles["message"] + " " + styles["message-" + m.role]}
+              key={i}
+            >
+              <div className={styles["avatar"]}>
+                <ExportAvatar
+                  avatar={m.role === "user" ? config.avatar : mask.avatar}
+                />
+              </div>
+
+              <div className={styles["body"]}>
+                <Markdown
+                  content={getMessageTextContent(m)}
+                  fontSize={config.fontSize}
+                  fontFamily={config.fontFamily}
+                  defaultShow
+                />
+                {getMessageImages(m).length == 1 && (
+                  <img
+                    key={i}
+                    src={getMessageImages(m)[0]}
+                    alt="message"
+                    className={styles["message-image"]}
+                  />
+                )}
+                {getMessageImages(m).length > 1 && (
+                  <div
+                    className={styles["message-images"]}
+                    style={
+                      {
+                        "--image-count": getMessageImages(m).length,
+                      } as React.CSSProperties
+                    }
+                  >
+                    {getMessageImages(m).map((src, i) => (
+                      <img
+                        key={i}
+                        src={src}
+                        alt="message"
+                        className={styles["message-image-multi"]}
+                      />
+                    ))}
+                  </div>
+                )}
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+export function MarkdownPreviewer(props: {
+  messages: ChatMessage[];
+  topic: string;
+}) {
+  const mdText =
+    `# ${props.topic}\n\n` +
+    props.messages
+      .map((m) => {
+        return m.role === "user"
+          ? `## ${Locale.Export.MessageFromYou}:\n${getMessageTextContent(m)}`
+          : `## ${Locale.Export.MessageFromChatGPT}:\n${getMessageTextContent(
+            m,
+          ).trim()}`;
+      })
+      .join("\n\n");
+
+  const copy = () => {
+    copyToClipboard(mdText);
+  };
+  const download = () => {
+    downloadAs(mdText, `${props.topic}.md`);
+  };
+  return (
+    <>
+      <PreviewActions
+        copy={copy}
+        download={download}
+        showCopy={true}
+        messages={props.messages}
+      />
+      <div className="markdown-body">
+        <pre className={styles["export-content"]}>{mdText}</pre>
+      </div>
+    </>
+  );
+}
+
+export function JsonPreviewer(props: {
+  messages: ChatMessage[];
+  topic: string;
+}) {
+  const msgs = {
+    messages: [
+      {
+        role: "system",
+        content: `${Locale.FineTuned.Sysmessage} ${props.topic}`,
+      },
+      ...props.messages.map((m) => ({
+        role: m.role,
+        content: m.content,
+      })),
+    ],
+  };
+  const mdText = "```json\n" + JSON.stringify(msgs, null, 2) + "\n```";
+  const minifiedJson = JSON.stringify(msgs);
+
+  const copy = () => {
+    copyToClipboard(minifiedJson);
+  };
+  const download = () => {
+    downloadAs(JSON.stringify(msgs), `${props.topic}.json`);
+  };
+
+  return (
+    <>
+      <PreviewActions
+        copy={copy}
+        download={download}
+        showCopy={false}
+        messages={props.messages}
+      />
+      <div className="markdown-body" onClick={copy}>
+        <Markdown content={mdText} />
+      </div>
+    </>
+  );
+}

+ 348 - 0
app/components/home.module.scss

@@ -0,0 +1,348 @@
+@mixin container {
+  background-color: var(--white);
+  border: var(--border-in-light);
+  border-radius: 20px;
+  box-shadow: var(--shadow);
+  color: var(--black);
+  background-color: var(--white);
+  min-width: 600px;
+  min-height: 370px;
+  max-width: 1200px;
+
+  display: flex;
+  overflow: hidden;
+  box-sizing: border-box;
+
+  width: var(--window-width);
+  height: var(--window-height);
+}
+
+.container {
+  @include container();
+}
+
+@media only screen and (min-width: 600px) {
+  .tight-container {
+    --window-width: 100vw;
+    --window-height: var(--full-height);
+    --window-content-width: calc(100% - var(--sidebar-width));
+
+    @include container();
+
+    max-width: 100vw;
+    max-height: var(--full-height);
+
+    border-radius: 0;
+    border: 0;
+  }
+}
+
+.sidebar {
+  top: 0;
+  width: var(--sidebar-width);
+  box-sizing: border-box;
+  padding: 20px;
+  background-color: var(--second);
+  display: flex;
+  flex-direction: column;
+  box-shadow: inset -2px 0px 2px 0px rgb(0, 0, 0, 0.05);
+  position: relative;
+  transition: width ease 0.05s;
+
+  .sidebar-header-bar {
+    display: flex;
+    margin-bottom: 20px;
+
+    .sidebar-bar-button {
+      flex-grow: 1;
+
+      &:not(:last-child) {
+        margin-right: 10px;
+      }
+    }
+  }
+
+  &:hover,
+  &:active {
+    .sidebar-drag {
+      background-color: rgba($color: #000000, $alpha: 0.01);
+
+      svg {
+        opacity: 0.2;
+      }
+    }
+  }
+}
+
+.sidebar-drag {
+  $width: 14px;
+
+  position: absolute;
+  top: 0;
+  right: 0;
+  height: 100%;
+  width: $width;
+  background-color: rgba($color: #000000, $alpha: 0);
+  cursor: ew-resize;
+  transition: all ease 0.3s;
+  display: flex;
+  align-items: center;
+
+  svg {
+    opacity: 0;
+    margin-left: -2px;
+  }
+}
+
+.window-content {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.mobile {
+  display: none;
+}
+
+@media only screen and (max-width: 600px) {
+  .container {
+    min-height: unset;
+    min-width: unset;
+    max-height: unset;
+    min-width: unset;
+    border: 0;
+    border-radius: 0;
+  }
+
+  .sidebar {
+    position: absolute;
+    left: -100%;
+    z-index: 1000;
+    height: var(--full-height);
+    transition: all ease 0.3s;
+    box-shadow: none;
+  }
+
+  .sidebar-show {
+    left: 0;
+  }
+
+  .mobile {
+    display: block;
+  }
+}
+
+.sidebar-header {
+  position: relative;
+  // padding-top: 20px;
+  padding-bottom: 20px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.sidebar-logo {
+  display: inline-flex;
+}
+
+.sidebar-title-container {
+  display: inline-flex;
+  flex-direction: column;
+}
+
+.sidebar-title {
+  font-size: 20px;
+  font-weight: bold;
+  animation: slide-in ease 0.3s;
+}
+
+.sidebar-sub-title {
+  font-size: 12px;
+  font-weight: 400;
+  animation: slide-in ease 0.3s;
+}
+
+.sidebar-body {
+  flex: 1;
+  overflow: auto;
+  overflow-x: hidden;
+}
+
+.chat-item {
+  padding: 10px 14px;
+  background-color: var(--white);
+  border-radius: 10px;
+  margin-bottom: 10px;
+  box-shadow: var(--card-shadow);
+  transition: background-color 0.3s ease;
+  cursor: pointer;
+  user-select: none;
+  border: 2px solid transparent;
+  position: relative;
+  content-visibility: auto;
+}
+
+.chat-item:hover {
+  background-color: var(--hover-color);
+}
+
+.chat-item-selected {
+  border-color: var(--primary);
+}
+
+.chat-item-title {
+  font-size: 14px;
+  font-weight: bolder;
+  display: block;
+  width: calc(100% - 15px);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  animation: slide-in ease 0.3s;
+}
+
+.chat-item-delete {
+  position: absolute;
+  top: 0;
+  right: 0;
+  transition: all ease 0.3s;
+  opacity: 0;
+  cursor: pointer;
+}
+
+.chat-item:hover>.chat-item-delete {
+  opacity: 0.5;
+  transform: translateX(-4px);
+}
+
+.chat-item:hover>.chat-item-delete:hover {
+  opacity: 1;
+}
+
+.chat-item-info {
+  display: flex;
+  justify-content: space-between;
+  color: rgb(166, 166, 166);
+  font-size: 12px;
+  margin-top: 8px;
+  animation: slide-in ease 0.3s;
+}
+
+.chat-item-count,
+.chat-item-date {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.narrow-sidebar {
+
+  .sidebar-title,
+  .sidebar-sub-title {
+    display: none;
+  }
+
+  .sidebar-logo {
+    position: relative;
+    display: flex;
+    justify-content: center;
+  }
+
+  .sidebar-header-bar {
+    flex-direction: column;
+
+    .sidebar-bar-button {
+      &:not(:last-child) {
+        margin-right: 0;
+        margin-bottom: 10px;
+      }
+    }
+  }
+
+  .chat-item {
+    padding: 0;
+    min-height: 50px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    transition: all ease 0.3s;
+    overflow: hidden;
+
+    &:hover {
+      .chat-item-narrow {
+        transform: scale(0.7) translateX(-50%);
+      }
+    }
+  }
+
+  .chat-item-narrow {
+    line-height: 0;
+    font-weight: lighter;
+    color: var(--black);
+    transform: translateX(0);
+    transition: all ease 0.3s;
+    padding: 4px;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+
+    .chat-item-avatar {
+      display: flex;
+      justify-content: center;
+      opacity: 0.2;
+      position: absolute;
+      transform: scale(4);
+    }
+
+    .chat-item-narrow-count {
+      font-size: 24px;
+      font-weight: bolder;
+      text-align: center;
+      color: var(--primary);
+      opacity: 0.6;
+    }
+  }
+
+  .sidebar-tail {
+    flex-direction: column-reverse;
+    align-items: center;
+
+    .sidebar-actions {
+      flex-direction: column-reverse;
+      align-items: center;
+
+      .sidebar-action {
+        margin-right: 0;
+        margin-top: 15px;
+      }
+    }
+  }
+}
+
+.sidebar-tail {
+  display: flex;
+  justify-content: space-between;
+  padding-top: 20px;
+}
+
+.sidebar-actions {
+  display: inline-flex;
+}
+
+.sidebar-action:not(:last-child) {
+  margin-right: 15px;
+}
+
+.loading-content {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  height: 100%;
+  width: 100%;
+}
+
+.rtl-screen {
+  direction: rtl;
+}

+ 456 - 0
app/components/home.tsx

@@ -0,0 +1,456 @@
+"use client";
+
+import { Modal } from "antd";
+
+require("../polyfill");
+import { useState, useEffect } from "react";
+import {
+  HashRouter as Router,
+  Routes,
+  Route,
+  useLocation,
+  useNavigate
+} from "react-router-dom";
+import styles from "./home.module.scss";
+import BotIcon from "../icons/bot.svg";
+import loadingIcon from "../icons/loading.gif";
+import { getCSSVar, useMobileScreen } from "../utils";
+import dynamic from "next/dynamic";
+import { Path, SlotID } from "../constant";
+import { ErrorBoundary } from "./error";
+import { getISOLang, getLang } from "../locales";
+import { SideBar } from "./sidebar";
+import { useAppConfig } from "../store/config";
+import { AuthPage } from "./auth";
+import { getClientConfig } from "../config/client";
+import { type ClientApi, getClientApi } from "../client/api";
+import { useAccessStore } from "../store";
+import api from "../api/api";
+
+export function Loading() {
+  /** second版本注释掉进度条 */
+  // const [progress, setProgress] = useState(1);
+  //
+  // useEffect(() => {
+  //   let isMounted = true;
+  //
+  //   const intervalId = setInterval(() => {
+  //     if (isMounted && progress < 100) {
+  //       // 每隔一段时间增加1%进度
+  //       setProgress(prevProgress => prevProgress + 1);
+  //     }
+  //   }, 30);// 每10毫秒更新1%进度
+  //
+  //   return () => {
+  //     isMounted = false;
+  //     clearInterval(intervalId);
+  //   };
+  // }, [progress]);
+
+  return (
+    <div className={styles["loading-content"] + " no-dark"}>
+      <img src={loadingIcon.src} />
+      {/* seceond版本注释掉进度条 */}
+      {/* <div style={{ width: '60%', height: 15, background: '#F5F6F9', borderRadius: 8, marginTop: '20%', position: 'relative' }}> */}
+      {/*   <div */}
+      {/*     style={{ */}
+      {/*       width: `${progress}%`, */}
+      {/*       height: 15, */}
+      {/*       background: '#265C7D', */}
+      {/*       borderRadius: 8, */}
+      {/*       display: 'flex', */}
+      {/*       justifyContent: 'flex-end', */}
+      {/*       alignItems: 'center', */}
+      {/*       position: 'absolute' */}
+      {/*     }} */}
+      {/*   > */}
+      {/*     <div style={{ color: '#FFFFFF', fontSize: 12, lineHeight: 12, marginRight: 4 }}> */}
+      {/*       {progress}% */}
+      {/*     </div> */}
+      {/*   </div> */}
+      {/* </div> */}
+    </div>
+  );
+}
+
+// 延时器
+export const delayer = (): Promise<any> => {
+  // 延时时间-秒
+  const time: number = 0.1;
+  return new Promise((resolve, reject) => {
+    setTimeout(() => {
+      resolve({});
+    }, time * 1000);
+  });
+}
+
+const Artifacts = dynamic(
+  async () => {
+    await delayer();
+    return (await import("./artifacts")).Artifacts
+  },
+  {
+    loading: () => <Loading />,
+  }
+);
+
+const Settings = dynamic(
+  async () => {
+    await delayer();
+    return (await import("./settings")).Settings
+  },
+  {
+    loading: () => <Loading />,
+  }
+);
+
+const Chat = dynamic(
+  async () => {
+    await delayer();
+    return (await import("./chat")).Chat
+  },
+  {
+    loading: () => <Loading />,
+  }
+);
+
+const DeepSeekChat = dynamic(
+  async () => {
+    await delayer();
+    return (await import("./DeepSeekChat")).Chat
+  },
+  {
+    loading: () => <Loading />,
+  }
+);
+
+const Record = dynamic(
+  async () => {
+    await delayer();
+    return (await import("./Record"))
+  },
+  {
+    loading: () => <Loading />,
+  }
+);
+
+const HomeApp = dynamic(
+  async () => {
+    return (await import("./DeekSeekHome"))
+  },
+  {
+    loading: () => <Loading />,
+  }
+);
+
+const MaskChat = dynamic(
+  async () => {
+    await delayer();
+    return (await import("./mask-chat")).MaskChat
+  },
+  {
+    loading: () => <Loading />,
+  }
+);
+
+const MaskPage = dynamic(
+  async () => {
+    await delayer();
+    return (await import("./mask")).MaskPage
+  },
+  {
+    loading: () => <Loading />,
+  }
+);
+
+export function useSwitchTheme() {
+  const config = useAppConfig();
+
+  useEffect(() => {
+    document.body.classList.remove("light");
+    document.body.classList.remove("dark");
+
+    if (config.theme === "dark") {
+      document.body.classList.add("dark");
+    } else if (config.theme === "light") {
+      document.body.classList.add("light");
+    }
+
+    const metaDescriptionDark = document.querySelector(
+      'meta[name="theme-color"][media*="dark"]',
+    );
+    const metaDescriptionLight = document.querySelector(
+      'meta[name="theme-color"][media*="light"]',
+    );
+
+    if (config.theme === "auto") {
+      metaDescriptionDark?.setAttribute("content", "#151515");
+      metaDescriptionLight?.setAttribute("content", "#fafafa");
+    } else {
+      const themeColor = getCSSVar("--theme-color");
+      metaDescriptionDark?.setAttribute("content", themeColor);
+      metaDescriptionLight?.setAttribute("content", themeColor);
+    }
+  }, [config.theme]);
+}
+
+function useHtmlLang() {
+  useEffect(() => {
+    const lang = getISOLang();
+    const htmlLang = document.documentElement.lang;
+
+    if (lang !== htmlLang) {
+      document.documentElement.lang = lang;
+    }
+  }, []);
+}
+
+const useHasHydrated = () => {
+  const [hasHydrated, setHasHydrated] = useState<boolean>(false);
+
+  useEffect(() => {
+    setHasHydrated(true);
+  }, []);
+
+  return hasHydrated;
+};
+
+const loadAsyncGoogleFont = () => {
+  const linkEl = document.createElement("link");
+  const proxyFontUrl = "/google-fonts";
+  const remoteFontUrl = "https://fonts.googleapis.com";
+  const googleFontUrl =
+    getClientConfig()?.buildMode === "export" ? remoteFontUrl : proxyFontUrl;
+  linkEl.rel = "stylesheet";
+  linkEl.href =
+    googleFontUrl +
+    "/css2?family=" +
+    encodeURIComponent("Noto Sans:wght@300;400;700;900") +
+    "&display=swap";
+  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 isAuth = location.pathname === Path.Auth;
+
+  const isMobileScreen = useMobileScreen();
+  const shouldTightBorder = getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
+
+  useEffect(() => {
+    loadAsyncGoogleFont();
+  }, []);
+
+  if (isArtifact) {
+    return (
+      <Routes>
+        <Route path="/artifacts/:id" element={<Artifacts />} />
+      </Routes>
+    );
+  }
+
+  const renderContent = () => {
+    if (isAuth) return <AuthPage />;
+
+    return (
+      <>
+        {
+          location.pathname !== '/' &&
+          <SideBar className={styles["sidebar-show"]} />
+        }
+        <WindowContent>
+          <Routes>
+            <Route path='/' element={<HomeApp />} />
+            <Route path='/knowledgeChat' element={<Chat />} />
+            <Route path='/newChat' element={<Chat />} />
+            <Route path='/deepseekChat' element={<DeepSeekChat />} />
+            <Route path='/newDeepseekChat' element={<DeepSeekChat />} />
+            {/* 关闭以下入口  后续有需求再开启*/}
+            {/* <Route path='/settings' element={<Settings />} /> */}
+            {/* <Route path='/mask-chat' element={<MaskChat />} /> */}
+            {/* <Route path='/masks' element={<MaskPage />} /> */}
+          </Routes>
+        </WindowContent>
+      </>
+    );
+  };
+
+  return (
+    <div
+      className={`${styles.container} ${shouldTightBorder ? styles["tight-container"] : styles.container
+        }`}
+    >
+      {renderContent()}
+    </div>
+  );
+}
+
+export function useLoadData() {
+  const config = useAppConfig();
+
+  const api: ClientApi = getClientApi(config.modelConfig.providerName);
+
+  useEffect(() => {
+    (async () => {
+      const models = await api.llm.models();
+      config.mergeModels(models);
+    })();
+  }, []);
+}
+
+export function Home() {
+  useSwitchTheme();
+  useLoadData();
+  useHtmlLang();
+
+  useEffect(() => {
+    useAccessStore.getState().fetch();
+  }, []);
+
+  if (typeof window !== "undefined") {
+    window.addEventListener("pageshow", (event) => {
+      const perfEntries = performance.getEntriesByType("navigation");
+      if (perfEntries.length > 0) {
+        const navEntry = perfEntries[0] as PerformanceNavigationTiming;
+        if (navEntry.type === "back_forward") {
+          window.location.reload();
+        }
+      }
+    });
+  }
+
+  const jkLogin = async (data: { code: string, redirectUrl: string }, url: string) => {
+    try {
+      const res = await api.post('jk_code_login', data);
+      localStorage.setItem('userInfo', JSON.stringify(res.data));
+      location.replace(url);
+    } catch (error: any) {
+      Modal.error({
+        title: '登录失败',
+        content: error.msg,
+      })
+    }
+  }
+
+  const frameLogin = async (data: { clientId: string,
+    workspaceId: string,
+    workspaceName: string,
+    userName: string,
+    timestamp: string,
+    signature: string }, url: string,fullUrl:string) => {
+    try {
+      const res = await api.post('frame_login', data);
+      localStorage.setItem('userInfo', JSON.stringify(res.data));
+      location.replace(url);
+    } catch (error: any) {
+      Modal.error({
+        title: '登录失败',
+        content: error.msg,
+      });
+      //停留5秒
+      setTimeout(() => {
+        toUninLogin(url,fullUrl);
+      }, 5000);
+
+    }
+  }
+
+  const toUninLogin = async (originUrl:string, fullUrl:string) => {
+    //测试环境
+    //const loginUrl = 'https://esctest.sribs.com.cn/esc-sso/oauth2.0/authorize?client_id=e97f94cf93761f4d69e8&response_type=code';
+    //生产环境
+    const loginUrl = 'http://esc.sribs.com.cn:8080/esc-sso/oauth2.0/authorize?client_id=e97f94cf93761f4d69e8&response_type=code';
+    const externalLoginUrl = loginUrl + `&redirect_uri=${encodeURIComponent(originUrl)}&state=${encodeURIComponent(fullUrl)}`;
+    location.replace(externalLoginUrl);
+  }
+
+  useEffect(() => {
+    // ==============
+    // 环境开关:暂时屏蔽 toUninLogin 跳转验证
+    // 说明:设置 NEXT_PUBLIC_DISABLE_UNIN_LOGIN=1 时,跳过所有统一登录跳转
+    // 用法:
+    //  1) 临时禁用:NEXT_PUBLIC_DISABLE_UNIN_LOGIN=1 yarn dev
+    //  2) 或在 .env.local 中加入 NEXT_PUBLIC_DISABLE_UNIN_LOGIN=1
+    // ==============
+    // const DISABLE_UNIN_LOGIN = typeof process !== 'undefined' && process.env.NEXT_PUBLIC_DISABLE_UNIN_LOGIN === '1';
+    // if (DISABLE_UNIN_LOGIN) {
+    //   console.log('[Home] 统一登录验证已禁用,跳过 toUninLogin 验证');
+    //   return;
+    // }
+
+    // const loginRes = { "nickName": "建科咨询虚拟账号", "userId": "2222331845498970571", "token": "eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjAyMDE4Mzg0LTFmNjctNDhkYi05NjNiLTJkOGNhMDMxNTkzMiJ9.zTkTv8gDgJN7tfyxJko_zG1VsESlZACeYkpdMbITqnIpIfvHkZo8l8_Kcv6zo77GnuDyzdpOEt-GzPufD2Ye8A" };
+    // if (loginRes) {
+    //   return localStorage.setItem('userInfo', JSON.stringify(loginRes));
+    // }
+    const originUrl = window.location.origin;
+    const fullUrl = window.location.href;
+    const urlParams = new URLSearchParams(new URL(fullUrl).search);
+    const code = urlParams.get('code');
+    const state = urlParams.get('state');
+    const userInfo = localStorage.getItem('userInfo');
+
+    if (fullUrl.includes(originUrl + '/?code') && code && state) {// 通过code登陆
+      if (!userInfo) {
+        jkLogin({ code: code, redirectUrl: encodeURIComponent(originUrl) }, state);
+      }
+    } else {
+      if (!userInfo) {
+        //判断是否是frame方式联登/frame
+        if (fullUrl.includes(originUrl + '/?frame=Y')) {
+
+          const workspaceId = urlParams.get('workspace_id');
+          const workspaceName = urlParams.get('workspace_name');
+          const username = urlParams.get('username');
+          const clientId = urlParams.get('client_id');
+          const timestamp = urlParams.get('timestamp');
+          const signature = urlParams.get('signature');
+          if (!clientId || !workspaceId || !workspaceName || !username || !timestamp || !signature) {
+            // 处理缺失参数的情况
+            Modal.error({
+              title: '参数错误',
+              content: '缺少必要的参数',
+            });
+            //停留5秒
+            setTimeout(() => {
+              toUninLogin(originUrl,fullUrl);
+            }, 5000);
+            return;
+          }
+          frameLogin({
+            clientId: clientId,
+            workspaceId: workspaceId,
+            workspaceName: workspaceName,
+            userName: username,
+            timestamp: timestamp,
+            signature: signature
+          },originUrl,fullUrl);
+
+        } else {
+          toUninLogin(originUrl,fullUrl);
+        }
+      }
+    }
+  }, []);
+
+  if (!useHasHydrated()) {
+    return <Loading />;
+  }
+
+  return (
+    <ErrorBoundary>
+      <Router>
+        <Screen />
+      </Router>
+    </ErrorBoundary>
+  );
+}

+ 13 - 0
app/components/input-range.module.scss

@@ -0,0 +1,13 @@
+.input-range {
+  border: var(--border-in-light);
+  border-radius: 10px;
+  padding: 5px 10px 5px 10px;
+  font-size: 12px;
+  display: flex;
+  justify-content: space-between;
+  max-width: 40%;
+
+  input[type="range"] {
+    max-width: calc(100% - 34px);
+  }
+}

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

@@ -0,0 +1,40 @@
+import * as React from "react";
+import styles from "./input-range.module.scss";
+
+interface InputRangeProps {
+  onChange: React.ChangeEventHandler<HTMLInputElement>;
+  title?: string;
+  value: number | string;
+  className?: string;
+  min: string;
+  max: string;
+  step: string;
+  aria: string;
+}
+
+export function InputRange({
+  onChange,
+  title,
+  value,
+  className,
+  min,
+  max,
+  step,
+  aria,
+}: InputRangeProps) {
+  return (
+    <div className={styles["input-range"] + ` ${className ?? ""}`}>
+      {title || value}
+      <input
+        aria-label={aria}
+        type="range"
+        title={title}
+        value={value}
+        min={min}
+        max={max}
+        step={step}
+        onChange={onChange}
+      ></input>
+    </div>
+  );
+}

+ 287 - 0
app/components/markdown.tsx

@@ -0,0 +1,287 @@
+import ReactMarkdown from "react-markdown";
+import { Image } from "antd";
+import RemarkMath from "remark-math";
+import RemarkBreaks from "remark-breaks";
+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, 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, FullScreen } from "./ui-lib";
+import { ArtifactsShareButton, HTMLPreview } from "./artifacts";
+import { Plugin } from "../constant";
+import { useChatStore } from "../store";
+import "katex/dist/katex.min.css";
+
+export function Mermaid(props: { code: string }) {
+  const ref = useRef<HTMLDivElement>(null);
+  const [hasError, setHasError] = useState(false);
+
+  useEffect(() => {
+    if (props.code && ref.current) {
+      mermaid
+        .run({
+          nodes: [ref.current],
+          suppressErrors: true,
+        })
+        .catch((e) => {
+          setHasError(true);
+          console.error("[Mermaid] ", e.message);
+        });
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [props.code]);
+
+  function viewSvgInNewWindow() {
+    const svg = ref.current?.querySelector("svg");
+    if (!svg) return;
+    const text = new XMLSerializer().serializeToString(svg);
+    const blob = new Blob([text], { type: "image/svg+xml" });
+    showImageModal(URL.createObjectURL(blob));
+  }
+
+  if (hasError) {
+    return null;
+  }
+
+  return (
+    <div
+      className="no-dark mermaid"
+      style={{
+        cursor: "pointer",
+        overflow: "auto",
+      }}
+      ref={ref}
+      onClick={() => viewSvgInNewWindow()}
+    >
+      {props.code}
+    </div>
+  );
+}
+
+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 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(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 = [
+        "",
+        "think",
+        "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 (
+    <>
+      <pre ref={ref}>
+        <span
+          className="copy-code-button"
+          onClick={() => {
+            if (ref.current) {
+              const code = ref.current.innerText;
+              copyToClipboard(code);
+            }
+          }}
+        ></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>
+      )}
+    </>
+  );
+}
+
+function escapeDollarNumber(text: string) {
+  let escapedText = "";
+
+  for (let i = 0; i < text.length; i += 1) {
+    let char = text[i];
+    const nextChar = text[i + 1] || " ";
+
+    if (char === "$" && nextChar >= "0" && nextChar <= "9") {
+      char = "\\$";
+    }
+
+    escapedText += char;
+  }
+
+  return escapedText;
+}
+
+function escapeBrackets(text: string) {
+  const pattern =
+    /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
+  return text.replace(
+    pattern,
+    (match, codeBlock, squareBracket, roundBracket) => {
+      if (codeBlock) {
+        return codeBlock;
+      } else if (squareBracket) {
+        return `$$${squareBracket}$$`;
+      } else if (roundBracket) {
+        return `$${roundBracket}$`;
+      }
+      return match;
+    },
+  );
+}
+
+function _MarkDownContent(props: { content: string }) {
+  const escapedContent = useMemo(() => {
+    return escapeBrackets(escapeDollarNumber(props.content));
+  }, [props.content]);
+
+  return (
+    <ReactMarkdown
+      remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
+      rehypePlugins={[
+        RehypeKatex,
+        [
+          RehypeHighlight,
+          {
+            detect: false,
+            ignoreMissing: true,
+          },
+        ],
+      ]}
+      // 控制不同标签的显示样式
+      components={{
+        pre: PreCode,
+        code: ({ className, children }) => {
+          if (className && className.includes('language-think')) {
+            return (
+              <code style={{ whiteSpace: 'pre-wrap', background: '#f3f4f6', color: '#525252' }}>
+                {children}
+              </code>
+            );
+          } else {
+            return children;
+          }
+        },
+        p: (pProps) => <p {...pProps} dir="auto" />,
+        a: (aProps) => {
+          const href = aProps.href || "";
+          const isInternal = /^\/#/i.test(href);
+          const target = isInternal ? "_self" : aProps.target ?? "_blank";
+          return <a {...aProps} target={target} />;
+        },
+        img: ({ src, alt }) => (
+          <div style={{ width: '100%', height: 'auto', cursor: 'pointer' }}>
+            <Image
+              width='80%'
+              src={src}
+              alt={alt}
+              preview={{
+                mask: null
+              }}
+            />
+          </div>
+        ),
+      }}
+    >
+      {escapedContent}
+    </ReactMarkdown >
+  );
+}
+
+export const MarkdownContent = React.memo(_MarkDownContent);
+
+export function Markdown(
+  props: {
+    content: string;
+    loading?: boolean;
+    fontSize?: number;
+    fontFamily?: string;
+    parentRef?: RefObject<HTMLDivElement>;
+    defaultShow?: boolean;
+  } & React.DOMAttributes<HTMLDivElement>,
+) {
+  const mdRef = useRef<HTMLDivElement>(null);
+
+  return (
+    <div
+      className="markdown-body"
+      style={{
+        fontSize: `${props.fontSize ?? 14}px`,
+        fontFamily: props.fontFamily || "inherit",
+      }}
+      ref={mdRef}
+      onContextMenu={props.onContextMenu}
+      onDoubleClickCapture={props.onDoubleClickCapture}
+      dir="auto"
+    >
+      {props.loading ? (
+        <LoadingIcon />
+      ) : (
+        <MarkdownContent content={props.content} />
+      )}
+    </div>
+  );
+}

+ 125 - 0
app/components/mask-chat.module.scss

@@ -0,0 +1,125 @@
+@import "../styles/animation.scss";
+
+.mask-chat {
+  height: 100%;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+
+  .mask-header {
+    display: flex;
+    justify-content: space-between;
+    width: 100%;
+    padding: 10px;
+    box-sizing: border-box;
+    animation: slide-in-from-top ease 0.3s;
+  }
+
+  .mask-cards {
+    display: flex;
+    margin-top: 5vh;
+    margin-bottom: 20px;
+    animation: slide-in ease 0.3s;
+
+    .mask-card {
+      padding: 20px 10px;
+      border: var(--border-in-light);
+      box-shadow: var(--card-shadow);
+      border-radius: 14px;
+      background-color: var(--white);
+      transform: scale(1);
+
+      &:first-child {
+        transform: rotate(-15deg) translateY(5px);
+      }
+
+      &:last-child {
+        transform: rotate(15deg) translateY(5px);
+      }
+    }
+  }
+
+  .title {
+    font-size: 32px;
+    font-weight: bolder;
+    margin-bottom: 1vh;
+    animation: slide-in ease 0.35s;
+  }
+
+  .sub-title {
+    animation: slide-in ease 0.4s;
+  }
+
+  .actions {
+    margin-top: 5vh;
+    margin-bottom: 2vh;
+    animation: slide-in ease 0.45s;
+    display: flex;
+    justify-content: center;
+    font-size: 12px;
+
+    .skip {
+      margin-left: 10px;
+    }
+  }
+
+  .masks {
+    flex-grow: 1;
+    width: 100%;
+    overflow: auto;
+    align-items: center;
+    padding-top: 20px;
+
+    $linear: linear-gradient(
+      to bottom,
+      rgba(0, 0, 0, 0),
+      rgba(0, 0, 0, 1),
+      rgba(0, 0, 0, 0)
+    );
+
+    -webkit-mask-image: $linear;
+    mask-image: $linear;
+
+    animation: slide-in ease 0.5s;
+
+    .mask-row {
+      display: flex;
+      // justify-content: center;
+      margin-bottom: 10px;
+
+      @for $i from 1 to 10 {
+        &:nth-child(#{$i * 2}) {
+          margin-left: 50px;
+        }
+      }
+
+      .mask {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        padding: 10px 14px;
+        border: var(--border-in-light);
+        box-shadow: var(--card-shadow);
+        background-color: var(--white);
+        border-radius: 10px;
+        margin-right: 10px;
+        max-width: 8em;
+        transform: scale(1);
+        cursor: pointer;
+        transition: all ease 0.3s;
+
+        &:hover {
+          transform: translateY(-5px) scale(1.1);
+          z-index: 999;
+          border-color: var(--primary);
+        }
+
+        .mask-name {
+          font-size: 14px;
+        }
+      }
+    }
+  }
+}

+ 195 - 0
app/components/mask-chat.tsx

@@ -0,0 +1,195 @@
+import { useEffect, useRef, useState } from "react";
+import { Path, SlotID } from "../constant";
+import { IconButton } from "./button";
+// EmojiAvatar组件替代实现
+import BotIcon from "../icons/bot.svg";
+
+function EmojiAvatar(props: { avatar: string; size?: number }) {
+  // 如果没有有效的emoji,显示默认图标
+  if (props.avatar && props.avatar.length > 0) {
+    return (
+      <span style={{ fontSize: props.size || 18 }}>
+        {props.avatar}
+      </span>
+    );
+  }
+  // 使用默认的BotIcon,并应用合适的样式
+  return <BotIcon className="user-avatar" style={{ width: props.size || 18, height: props.size || 18 }} />;
+}
+
+import styles from "./mask-chat.module.scss";
+
+import LeftIcon from "../icons/left.svg";
+import LightningIcon from "../icons/lightning.svg";
+import EyeIcon from "../icons/eye.svg";
+
+import { useLocation, useNavigate } from "react-router-dom";
+import { Mask, useMaskStore } from "../store/mask";
+import Locale from "../locales";
+import { useAppConfig, useChatStore } from "../store";
+
+import { useCommand } from "../command";
+import { showConfirm } from "./ui-lib";
+import { BUILTIN_MASK_STORE } from "../masks";
+
+function MaskItem(props: { mask: Mask; onClick?: () => void }) {
+  return (
+    <div className={styles["mask"]} onClick={props.onClick}>
+      <div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
+    </div>
+  );
+}
+
+function useMaskGroup(masks: Mask[]) {
+  const [groups, setGroups] = useState<Mask[][]>([]);
+
+  useEffect(() => {
+    const computeGroup = () => {
+      const appBody = document.getElementById(SlotID.AppBody);
+      if (!appBody || masks.length === 0) return;
+
+      const rect = appBody.getBoundingClientRect();
+      const maxWidth = rect.width;
+      const maxHeight = rect.height * 0.6;
+      const maskItemWidth = 120;
+      const maskItemHeight = 50;
+
+      const randomMask = () => masks[Math.floor(Math.random() * masks.length)];
+      let maskIndex = 0;
+      const nextMask = () => masks[maskIndex++ % masks.length];
+
+      const rows = Math.ceil(maxHeight / maskItemHeight);
+      const cols = Math.ceil(maxWidth / maskItemWidth);
+
+      const newGroups = new Array(rows)
+        .fill(0)
+        .map((_, _i) =>
+          new Array(cols)
+            .fill(0)
+            .map((_, j) => (j < 1 || j > cols - 2 ? randomMask() : nextMask())),
+        );
+
+      setGroups(newGroups);
+    };
+
+    computeGroup();
+
+    window.addEventListener("resize", computeGroup);
+    return () => window.removeEventListener("resize", computeGroup);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  return groups;
+}
+
+export function MaskChat() {
+  const chatStore = useChatStore();
+  const maskStore = useMaskStore();
+
+  const masks = maskStore.getAll();
+  const groups = useMaskGroup(masks);
+
+  const navigate = useNavigate();
+  const config = useAppConfig();
+
+  const maskRef = useRef<HTMLDivElement>(null);
+
+  const { state } = useLocation();
+
+  const startChat = (mask?: Mask) => {
+    setTimeout(() => {
+      chatStore.newSession(mask);
+      navigate(Path.Chat);
+    }, 10);
+  };
+
+  useCommand({
+    mask: (id) => {
+      try {
+        const mask = maskStore.get(id) ?? BUILTIN_MASK_STORE.get(id);
+        startChat(mask ?? undefined);
+      } catch {
+        console.error("[Mask Chat] failed to create chat from mask id=", id);
+      }
+    },
+  });
+
+  useEffect(() => {
+    if (maskRef.current) {
+      maskRef.current.scrollLeft =
+        (maskRef.current.scrollWidth - maskRef.current.clientWidth) / 2;
+    }
+  }, [groups]);
+
+  return (
+    <div className={styles["mask-chat"]}>
+      <div className={styles["mask-header"]}>
+        <IconButton
+          icon={<LeftIcon />}
+          text={Locale.NewChat.Return}
+          onClick={() => navigate(Path.Home)}
+        ></IconButton>
+        {!state?.fromHome && (
+          <IconButton
+            text={Locale.NewChat.NotShow}
+            onClick={async () => {
+              if (await showConfirm(Locale.NewChat.ConfirmNoShow)) {
+                startChat();
+                config.update(
+                  (config) => (config.dontShowMaskSplashScreen = true),
+                );
+              }
+            }}
+          ></IconButton>
+        )}
+      </div>
+      <div className={styles["mask-cards"]}>
+        <div className={styles["mask-card"]}>
+          <EmojiAvatar avatar="" size={24} />
+        </div>
+        <div className={styles["mask-card"]}>
+          <EmojiAvatar avatar="" size={24} />
+        </div>
+        <div className={styles["mask-card"]}>
+          <EmojiAvatar avatar="" size={24} />
+        </div>
+      </div>
+
+      <div className={styles["title"]}>{Locale.NewChat.Title}</div>
+      <div className={styles["sub-title"]}>{Locale.NewChat.SubTitle}</div>
+
+      <div className={styles["actions"]}>
+        <IconButton
+          text={Locale.NewChat.More}
+          onClick={() => navigate(Path.Masks)}
+          icon={<EyeIcon />}
+          bordered
+          shadow
+        />
+
+        <IconButton
+          text={Locale.NewChat.Skip}
+          onClick={() => startChat()}
+          icon={<LightningIcon />}
+          type="primary"
+          shadow
+          className={styles["skip"]}
+        />
+      </div>
+
+      <div className={styles["masks"]} ref={maskRef}>
+        {groups.map((masks, i) => (
+          <div key={i} className={styles["mask-row"]}>
+            {masks.map((mask, index) => (
+              <MaskItem
+                key={index}
+                mask={mask}
+                onClick={() => startChat(mask)}
+              />
+            ))}
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 108 - 0
app/components/mask.module.scss

@@ -0,0 +1,108 @@
+@import "../styles/animation.scss";
+.mask-page {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+
+  .mask-page-body {
+    padding: 20px;
+    overflow-y: auto;
+
+    .mask-filter {
+      width: 100%;
+      max-width: 100%;
+      margin-bottom: 20px;
+      animation: slide-in ease 0.3s;
+      height: 40px;
+
+      display: flex;
+
+      .search-bar {
+        flex-grow: 1;
+        max-width: 100%;
+        min-width: 0;
+      }
+
+      .mask-filter-lang {
+        height: 100%;
+        margin-left: 10px;
+      }
+
+      .mask-create {
+        height: 100%;
+        margin-left: 10px;
+        box-sizing: border-box;
+        min-width: 80px;
+      }
+    }
+
+    .mask-item {
+      display: flex;
+      justify-content: space-between;
+      padding: 20px;
+      border: var(--border-in-light);
+      animation: slide-in ease 0.3s;
+
+      &:not(:last-child) {
+        border-bottom: 0;
+      }
+
+      &:first-child {
+        border-top-left-radius: 10px;
+        border-top-right-radius: 10px;
+      }
+
+      &:last-child {
+        border-bottom-left-radius: 10px;
+        border-bottom-right-radius: 10px;
+      }
+
+      .mask-header {
+        display: flex;
+        align-items: center;
+
+        .mask-icon {
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          margin-right: 10px;
+        }
+
+        .mask-title {
+          .mask-name {
+            font-size: 14px;
+            font-weight: bold;
+          }
+          .mask-info {
+            font-size: 12px;
+          }
+        }
+      }
+
+      .mask-actions {
+        display: flex;
+        flex-wrap: nowrap;
+        transition: all ease 0.3s;
+      }
+
+      @media screen and (max-width: 600px) {
+        display: flex;
+        flex-direction: column;
+        padding-bottom: 10px;
+        border-radius: 10px;
+        margin-bottom: 20px;
+        box-shadow: var(--card-shadow);
+
+        &:not(:last-child) {
+          border-bottom: var(--border-in-light);
+        }
+
+        .mask-actions {
+          width: 100%;
+          justify-content: space-between;
+          padding-top: 10px;
+        }
+      }
+    }
+  }
+}

+ 705 - 0
app/components/mask.tsx

@@ -0,0 +1,705 @@
+import { IconButton } from "./button";
+import { ErrorBoundary } from "./error";
+
+import styles from "./mask.module.scss";
+
+import DownloadIcon from "../icons/download.svg";
+import UploadIcon from "../icons/upload.svg";
+import EditIcon from "../icons/edit.svg";
+import AddIcon from "../icons/add.svg";
+import CloseIcon from "../icons/close.svg";
+import DeleteIcon from "../icons/delete.svg";
+import EyeIcon from "../icons/eye.svg";
+import CopyIcon from "../icons/copy.svg";
+import DragIcon from "../icons/drag.svg";
+
+import { DEFAULT_MASK_AVATAR, Mask, useMaskStore } from "../store/mask";
+import {
+  ChatMessage,
+  createMessage,
+  ModelConfig,
+  ModelType,
+  useAppConfig,
+  useChatStore,
+} from "../store";
+import { MultimodalContent, ROLES } from "../client/api";
+import {
+  Input,
+  List,
+  ListItem,
+  Modal,
+  Popover,
+  Select,
+  showConfirm,
+} from "./ui-lib";
+// Avatar组件替代实现
+import BotIcon from "../icons/bot.svg";
+import BlackBotIcon from "../icons/black-bot.svg";
+
+function Avatar(props: { model?: string; avatar?: string }) {
+  if (props.model) {
+    return (
+      <div className="no-dark">
+        {props.model?.startsWith("gpt-4") ? (
+          <BlackBotIcon className="user-avatar" />
+        ) : (
+          <BotIcon className="user-avatar" />
+        )}
+      </div>
+    );
+  }
+
+  return (
+    <div className="user-avatar">
+      {/* 移除emoji头像,使用默认bot图标 */}
+      <BotIcon className="user-avatar" />
+    </div>
+  );
+}
+
+// 简化的AvatarPicker替代实现
+function AvatarPicker(props: { onEmojiClick: (emoji: string) => void }) {
+  const defaultAvatars = ["🤖", "👤", "💬", "🎯", "⭐", "🔥"];
+  
+  return (
+    <div style={{ padding: "10px", display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "10px" }}>
+      {defaultAvatars.map((emoji, index) => (
+        <div
+          key={index}
+          style={{
+            padding: "8px",
+            borderRadius: "4px",
+            border: "1px solid #ccc",
+            textAlign: "center",
+            cursor: "pointer",
+            fontSize: "20px"
+          }}
+          onClick={() => props.onEmojiClick(emoji)}
+        >
+          {emoji}
+        </div>
+      ))}
+    </div>
+  );
+}
+import Locale, { AllLangs, ALL_LANG_OPTIONS, Lang } from "../locales";
+import { useNavigate } from "react-router-dom";
+
+import chatStyle from "./chat.module.scss";
+import { useEffect, useState } from "react";
+import {
+  copyToClipboard,
+  downloadAs,
+  getMessageImages,
+  readFromFile,
+} from "../utils";
+import { Updater } from "../typing";
+import { ModelConfigList } from "./model-config";
+import { FileName, Path } from "../constant";
+import { BUILTIN_MASK_STORE } from "../masks";
+import { nanoid } from "nanoid";
+import {
+  DragDropContext,
+  Droppable,
+  Draggable,
+  OnDragEndResponder,
+} from "@hello-pangea/dnd";
+import { getMessageTextContent } from "../utils";
+
+// drag and drop helper function
+function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
+  const result = [...list];
+  const [removed] = result.splice(startIndex, 1);
+  result.splice(endIndex, 0, removed);
+  return result;
+}
+
+export function MaskAvatar(props: { avatar: string; model?: ModelType }) {
+  return props.avatar !== DEFAULT_MASK_AVATAR ? (
+    <Avatar avatar={props.avatar} />
+  ) : (
+    <Avatar model={props.model} />
+  );
+}
+
+export function MaskConfig(props: {
+  mask: Mask;
+  updateMask: Updater<Mask>;
+  extraListItems?: JSX.Element;
+  readonly?: boolean;
+  shouldSyncFromGlobal?: boolean;
+}) {
+  const [showPicker, setShowPicker] = useState(false);
+
+  const updateConfig = (updater: (config: ModelConfig) => void) => {
+    if (props.readonly) return;
+
+    const config = { ...props.mask.modelConfig };
+    updater(config);
+    props.updateMask((mask) => {
+      mask.modelConfig = config;
+      // if user changed current session mask, it will disable auto sync
+      mask.syncGlobalConfig = false;
+    });
+  };
+
+  const copyMaskLink = () => {
+    const maskLink = `${location.protocol}//${location.host}/#${Path.MaskChat}?mask=${props.mask.id}`;
+    copyToClipboard(maskLink);
+  };
+
+  const globalConfig = useAppConfig();
+
+  return (
+    <>
+      <ContextPrompts
+        context={props.mask.context}
+        updateContext={(updater) => {
+          const context = props.mask.context.slice();
+          updater(context);
+          props.updateMask((mask) => (mask.context = context));
+        }}
+      />
+
+      <List>
+        <ListItem title={Locale.Mask.Config.Avatar}>
+          <Popover
+            content={
+              <AvatarPicker
+                onEmojiClick={(emoji) => {
+                  props.updateMask((mask) => (mask.avatar = emoji));
+                  setShowPicker(false);
+                }}
+              ></AvatarPicker>
+            }
+            open={showPicker}
+            onClose={() => setShowPicker(false)}
+          >
+            <div
+              tabIndex={0}
+              aria-label={Locale.Mask.Config.Avatar}
+              onClick={() => setShowPicker(true)}
+              style={{ cursor: "pointer" }}
+            >
+              <MaskAvatar
+                avatar={props.mask.avatar}
+                model={props.mask.modelConfig.model}
+              />
+            </div>
+          </Popover>
+        </ListItem>
+        <ListItem title={Locale.Mask.Config.Name}>
+          <input
+            aria-label={Locale.Mask.Config.Name}
+            type="text"
+            value={props.mask.name}
+            onInput={(e) =>
+              props.updateMask((mask) => {
+                mask.name = e.currentTarget.value;
+              })
+            }
+          ></input>
+        </ListItem>
+        <ListItem
+          title={Locale.Mask.Config.HideContext.Title}
+          subTitle={Locale.Mask.Config.HideContext.SubTitle}
+        >
+          <input
+            aria-label={Locale.Mask.Config.HideContext.Title}
+            type="checkbox"
+            checked={props.mask.hideContext}
+            onChange={(e) => {
+              props.updateMask((mask) => {
+                mask.hideContext = e.currentTarget.checked;
+              });
+            }}
+          ></input>
+        </ListItem>
+
+        {!props.shouldSyncFromGlobal ? (
+          <ListItem
+            title={Locale.Mask.Config.Share.Title}
+            subTitle={Locale.Mask.Config.Share.SubTitle}
+          >
+            <IconButton
+              aria={Locale.Mask.Config.Share.Title}
+              icon={<CopyIcon />}
+              text={Locale.Mask.Config.Share.Action}
+              onClick={copyMaskLink}
+            />
+          </ListItem>
+        ) : null}
+
+        {props.shouldSyncFromGlobal ? (
+          <ListItem
+            title={Locale.Mask.Config.Sync.Title}
+            subTitle={Locale.Mask.Config.Sync.SubTitle}
+          >
+            <input
+              aria-label={Locale.Mask.Config.Sync.Title}
+              type="checkbox"
+              checked={props.mask.syncGlobalConfig}
+              onChange={async (e) => {
+                const checked = e.currentTarget.checked;
+                if (
+                  checked &&
+                  (await showConfirm(Locale.Mask.Config.Sync.Confirm))
+                ) {
+                  props.updateMask((mask) => {
+                    mask.syncGlobalConfig = checked;
+                    mask.modelConfig = { ...globalConfig.modelConfig };
+                  });
+                } else if (!checked) {
+                  props.updateMask((mask) => {
+                    mask.syncGlobalConfig = checked;
+                  });
+                }
+              }}
+            ></input>
+          </ListItem>
+        ) : null}
+      </List>
+
+      <List>
+        <ModelConfigList
+          modelConfig={{ ...props.mask.modelConfig }}
+          updateConfig={updateConfig}
+        />
+        {props.extraListItems}
+      </List>
+    </>
+  );
+}
+
+function ContextPromptItem(props: {
+  index: number;
+  prompt: ChatMessage;
+  update: (prompt: ChatMessage) => void;
+  remove: () => void;
+}) {
+  const [focusingInput, setFocusingInput] = useState(false);
+
+  return (
+    <div className={chatStyle["context-prompt-row"]}>
+      {!focusingInput && (
+        <>
+          <div className={chatStyle["context-drag"]}>
+            <DragIcon />
+          </div>
+          <Select
+            value={props.prompt.role}
+            className={chatStyle["context-role"]}
+            onChange={(e) =>
+              props.update({
+                ...props.prompt,
+                role: e.target.value as any,
+              })
+            }
+          >
+            {ROLES.map((r) => (
+              <option key={r} value={r}>
+                {r}
+              </option>
+            ))}
+          </Select>
+        </>
+      )}
+      <Input
+        value={getMessageTextContent(props.prompt)}
+        type="text"
+        className={chatStyle["context-content"]}
+        rows={focusingInput ? 5 : 1}
+        onFocus={() => setFocusingInput(true)}
+        onBlur={() => {
+          setFocusingInput(false);
+          // If the selection is not removed when the user loses focus, some
+          // extensions like "Translate" will always display a floating bar
+          window?.getSelection()?.removeAllRanges();
+        }}
+        onInput={(e) =>
+          props.update({
+            ...props.prompt,
+            content: e.currentTarget.value as any,
+          })
+        }
+      />
+      {!focusingInput && (
+        <IconButton
+          icon={<DeleteIcon />}
+          className={chatStyle["context-delete-button"]}
+          onClick={() => props.remove()}
+          bordered
+        />
+      )}
+    </div>
+  );
+}
+
+export function ContextPrompts(props: {
+  context: ChatMessage[];
+  updateContext: (updater: (context: ChatMessage[]) => void) => void;
+}) {
+  const context = props.context;
+
+  const addContextPrompt = (prompt: ChatMessage, i: number) => {
+    props.updateContext((context) => context.splice(i, 0, prompt));
+  };
+
+  const removeContextPrompt = (i: number) => {
+    props.updateContext((context) => context.splice(i, 1));
+  };
+
+  const updateContextPrompt = (i: number, prompt: ChatMessage) => {
+    props.updateContext((context) => {
+      const images = getMessageImages(context[i]);
+      context[i] = prompt;
+      if (images.length > 0) {
+        const text = getMessageTextContent(context[i]);
+        const newContext: MultimodalContent[] = [{ type: "text", text }];
+        for (const img of images) {
+          newContext.push({ type: "image_url", image_url: { url: img } });
+        }
+        context[i].content = newContext;
+      }
+    });
+  };
+
+  const onDragEnd: OnDragEndResponder = (result) => {
+    if (!result.destination) {
+      return;
+    }
+    const newContext = reorder(
+      context,
+      result.source.index,
+      result.destination.index,
+    );
+    props.updateContext((context) => {
+      context.splice(0, context.length, ...newContext);
+    });
+  };
+
+  return (
+    <>
+      <div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}>
+        <DragDropContext onDragEnd={onDragEnd}>
+          <Droppable droppableId="context-prompt-list">
+            {(provided) => (
+              <div ref={provided.innerRef} {...provided.droppableProps}>
+                {context.map((c, i) => (
+                  <Draggable
+                    draggableId={c.id || i.toString()}
+                    index={i}
+                    key={c.id}
+                  >
+                    {(provided) => (
+                      <div
+                        ref={provided.innerRef}
+                        {...provided.draggableProps}
+                        {...provided.dragHandleProps}
+                      >
+                        <ContextPromptItem
+                          index={i}
+                          prompt={c}
+                          update={(prompt) => updateContextPrompt(i, prompt)}
+                          remove={() => removeContextPrompt(i)}
+                        />
+                        <div
+                          className={chatStyle["context-prompt-insert"]}
+                          onClick={() => {
+                            addContextPrompt(
+                              createMessage({
+                                role: "user",
+                                content: "",
+                                date: new Date().toLocaleString(),
+                              }),
+                              i + 1,
+                            );
+                          }}
+                        >
+                          <AddIcon />
+                        </div>
+                      </div>
+                    )}
+                  </Draggable>
+                ))}
+                {provided.placeholder}
+              </div>
+            )}
+          </Droppable>
+        </DragDropContext>
+
+        {props.context.length === 0 && (
+          <div className={chatStyle["context-prompt-row"]}>
+            <IconButton
+              icon={<AddIcon />}
+              text={Locale.Context.Add}
+              bordered
+              className={chatStyle["context-prompt-button"]}
+              onClick={() =>
+                addContextPrompt(
+                  createMessage({
+                    role: "user",
+                    content: "",
+                    date: "",
+                  }),
+                  props.context.length,
+                )
+              }
+            />
+          </div>
+        )}
+      </div>
+    </>
+  );
+}
+
+export function MaskPage() {
+  const navigate = useNavigate();
+
+  const maskStore = useMaskStore();
+  const chatStore = useChatStore();
+
+  const [filterLang, setFilterLang] = useState<Lang | undefined>(
+    () => localStorage.getItem("Mask-language") as Lang | undefined,
+  );
+  useEffect(() => {
+    if (filterLang) {
+      localStorage.setItem("Mask-language", filterLang);
+    } else {
+      localStorage.removeItem("Mask-language");
+    }
+  }, [filterLang]);
+
+  const allMasks = maskStore
+    .getAll()
+    .filter((m) => !filterLang || m.lang === filterLang);
+
+  const [searchMasks, setSearchMasks] = useState<Mask[]>([]);
+  const [searchText, setSearchText] = useState("");
+  const masks = searchText.length > 0 ? searchMasks : allMasks;
+
+  // refactored already, now it accurate
+  const onSearch = (text: string) => {
+    setSearchText(text);
+    if (text.length > 0) {
+      const result = allMasks.filter((m) =>
+        m.name.toLowerCase().includes(text.toLowerCase()),
+      );
+      setSearchMasks(result);
+    } else {
+      setSearchMasks(allMasks);
+    }
+  };
+
+  const [editingMaskId, setEditingMaskId] = useState<string | undefined>();
+  const editingMask =
+    maskStore.get(editingMaskId) ?? BUILTIN_MASK_STORE.get(editingMaskId);
+  const closeMaskModal = () => setEditingMaskId(undefined);
+
+  const downloadAll = () => {
+    downloadAs(JSON.stringify(masks.filter((v) => !v.builtin)), FileName.Masks);
+  };
+
+  const importFromFile = () => {
+    readFromFile().then((content) => {
+      try {
+        const importMasks = JSON.parse(content);
+        if (Array.isArray(importMasks)) {
+          for (const mask of importMasks) {
+            if (mask.name) {
+              maskStore.create(mask);
+            }
+          }
+          return;
+        }
+        //if the content is a single mask.
+        if (importMasks.name) {
+          maskStore.create(importMasks);
+        }
+      } catch {}
+    });
+  };
+
+  return (
+    <ErrorBoundary>
+      <div className={styles["mask-page"]}>
+        <div className="window-header">
+          <div className="window-header-title">
+            <div className="window-header-main-title">
+              {Locale.Mask.Page.Title}
+            </div>
+            <div className="window-header-submai-title">
+              {Locale.Mask.Page.SubTitle(allMasks.length)}
+            </div>
+          </div>
+
+          <div className="window-actions">
+            <div className="window-action-button">
+              <IconButton
+                icon={<DownloadIcon />}
+                bordered
+                onClick={downloadAll}
+                text={Locale.UI.Export}
+              />
+            </div>
+            <div className="window-action-button">
+              <IconButton
+                icon={<UploadIcon />}
+                text={Locale.UI.Import}
+                bordered
+                onClick={() => importFromFile()}
+              />
+            </div>
+            <div className="window-action-button">
+              <IconButton
+                icon={<CloseIcon />}
+                bordered
+                onClick={() => navigate(-1)}
+              />
+            </div>
+          </div>
+        </div>
+
+        <div className={styles["mask-page-body"]}>
+          <div className={styles["mask-filter"]}>
+            <input
+              type="text"
+              className={styles["search-bar"]}
+              placeholder={Locale.Mask.Page.Search}
+              autoFocus
+              onInput={(e) => onSearch(e.currentTarget.value)}
+            />
+            <Select
+              className={styles["mask-filter-lang"]}
+              value={filterLang ?? Locale.Settings.Lang.All}
+              onChange={(e) => {
+                const value = e.currentTarget.value;
+                if (value === Locale.Settings.Lang.All) {
+                  setFilterLang(undefined);
+                } else {
+                  setFilterLang(value as Lang);
+                }
+              }}
+            >
+              <option key="all" value={Locale.Settings.Lang.All}>
+                {Locale.Settings.Lang.All}
+              </option>
+              {AllLangs.map((lang) => (
+                <option value={lang} key={lang}>
+                  {ALL_LANG_OPTIONS[lang]}
+                </option>
+              ))}
+            </Select>
+
+            <IconButton
+              className={styles["mask-create"]}
+              icon={<AddIcon />}
+              text={Locale.Mask.Page.Create}
+              bordered
+              onClick={() => {
+                const createdMask = maskStore.create();
+                setEditingMaskId(createdMask.id);
+              }}
+            />
+          </div>
+
+          <div>
+            {masks.map((m) => (
+              <div className={styles["mask-item"]} key={m.id}>
+                <div className={styles["mask-header"]}>
+                  <div className={styles["mask-icon"]}>
+                    <MaskAvatar avatar={m.avatar} model={m.modelConfig.model} />
+                  </div>
+                  <div className={styles["mask-title"]}>
+                    <div className={styles["mask-name"]}>{m.name}</div>
+                    <div className={styles["mask-info"] + " one-line"}>
+                      {`${Locale.Mask.Item.Info(m.context.length)} / ${
+                        ALL_LANG_OPTIONS[m.lang]
+                      } / ${m.modelConfig.model}`}
+                    </div>
+                  </div>
+                </div>
+                <div className={styles["mask-actions"]}>
+                  <IconButton
+                    icon={<AddIcon />}
+                    text={Locale.Mask.Item.Chat}
+                    onClick={() => {
+                      chatStore.newSession(m);
+                      navigate(Path.Chat);
+                    }}
+                  />
+                  {m.builtin ? (
+                    <IconButton
+                      icon={<EyeIcon />}
+                      text={Locale.Mask.Item.View}
+                      onClick={() => setEditingMaskId(m.id)}
+                    />
+                  ) : (
+                    <IconButton
+                      icon={<EditIcon />}
+                      text={Locale.Mask.Item.Edit}
+                      onClick={() => setEditingMaskId(m.id)}
+                    />
+                  )}
+                  {!m.builtin && (
+                    <IconButton
+                      icon={<DeleteIcon />}
+                      text={Locale.Mask.Item.Delete}
+                      onClick={async () => {
+                        if (await showConfirm(Locale.Mask.Item.DeleteConfirm)) {
+                          maskStore.delete(m.id);
+                        }
+                      }}
+                    />
+                  )}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      </div>
+
+      {editingMask && (
+        <div className="modal-mask">
+          <Modal
+            title={Locale.Mask.EditModal.Title(editingMask?.builtin)}
+            onClose={closeMaskModal}
+            actions={[
+              <IconButton
+                icon={<DownloadIcon />}
+                text={Locale.Mask.EditModal.Download}
+                key="export"
+                bordered
+                onClick={() =>
+                  downloadAs(
+                    JSON.stringify(editingMask),
+                    `${editingMask.name}.json`,
+                  )
+                }
+              />,
+              <IconButton
+                key="copy"
+                icon={<CopyIcon />}
+                bordered
+                text={Locale.Mask.EditModal.Clone}
+                onClick={() => {
+                  navigate(Path.Masks);
+                  maskStore.create(editingMask);
+                  setEditingMaskId(undefined);
+                }}
+              />,
+            ]}
+          >
+            <MaskConfig
+              mask={editingMask}
+              updateMask={(updater) =>
+                maskStore.updateMask(editingMaskId!, updater)
+              }
+              readonly={editingMask.builtin}
+            />
+          </Modal>
+        </div>
+      )}
+    </ErrorBoundary>
+  );
+}

+ 82 - 0
app/components/message-selector.module.scss

@@ -0,0 +1,82 @@
+.message-selector {
+  .message-filter {
+    display: flex;
+
+    .search-bar {
+      max-width: unset;
+      flex-grow: 1;
+      margin-right: 10px;
+    }
+
+    .actions {
+      display: flex;
+
+      button:not(:last-child) {
+        margin-right: 10px;
+      }
+    }
+
+    @media screen and (max-width: 600px) {
+      flex-direction: column;
+
+      .search-bar {
+        margin-right: 0;
+      }
+
+      .actions {
+        margin-top: 20px;
+
+        button {
+          flex-grow: 1;
+        }
+      }
+    }
+  }
+
+  .messages {
+    margin-top: 20px;
+    border-radius: 10px;
+    border: var(--border-in-light);
+    overflow: hidden;
+
+    .message {
+      display: flex;
+      align-items: center;
+      padding: 8px 10px;
+      cursor: pointer;
+
+      &-selected {
+        background-color: var(--second);
+      }
+
+      &:not(:last-child) {
+        border-bottom: var(--border-in-light);
+      }
+
+      .avatar {
+        margin-right: 10px;
+      }
+
+      .body {
+        flex: 1;
+        max-width: calc(100% - 80px);
+
+        .date {
+          font-size: 12px;
+          line-height: 1.2;
+          opacity: 0.5;
+        }
+
+        .content {
+          font-size: 12px;
+        }
+      }
+
+      .checkbox {
+        display: flex;
+        justify-content: flex-end;
+        flex: 1;
+      }
+    }
+  }
+}

+ 261 - 0
app/components/message-selector.tsx

@@ -0,0 +1,261 @@
+import { useEffect, useMemo, useState } from "react";
+import { ChatMessage, useAppConfig, useChatStore } from "../store";
+import { Updater } from "../typing";
+import { IconButton } from "./button";
+// Avatar组件替代实现
+import BotIcon from "../icons/bot.svg";
+import BlackBotIcon from "../icons/black-bot.svg";
+
+function Avatar(props: { model?: string; avatar?: string }) {
+  if (props.model) {
+    return (
+      <div className="no-dark">
+        {props.model?.startsWith("gpt-4") ? (
+          <BlackBotIcon className="user-avatar" />
+        ) : (
+          <BotIcon className="user-avatar" />
+        )}
+      </div>
+    );
+  }
+
+  return (
+    <div className="user-avatar">
+      {/* 移除emoji头像,使用默认bot图标 */}
+      <BotIcon className="user-avatar" />
+    </div>
+  );
+}
+import { MaskAvatar } from "./mask";
+import Locale from "../locales";
+
+import styles from "./message-selector.module.scss";
+import { getMessageTextContent } from "../utils";
+
+function useShiftRange() {
+  const [startIndex, setStartIndex] = useState<number>();
+  const [endIndex, setEndIndex] = useState<number>();
+  const [shiftDown, setShiftDown] = useState(false);
+
+  const onClickIndex = (index: number) => {
+    if (shiftDown && startIndex !== undefined) {
+      setEndIndex(index);
+    } else {
+      setStartIndex(index);
+      setEndIndex(undefined);
+    }
+  };
+
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key !== "Shift") return;
+      setShiftDown(true);
+    };
+    const onKeyUp = (e: KeyboardEvent) => {
+      if (e.key !== "Shift") return;
+      setShiftDown(false);
+      setStartIndex(undefined);
+      setEndIndex(undefined);
+    };
+
+    window.addEventListener("keyup", onKeyUp);
+    window.addEventListener("keydown", onKeyDown);
+
+    return () => {
+      window.removeEventListener("keyup", onKeyUp);
+      window.removeEventListener("keydown", onKeyDown);
+    };
+  }, []);
+
+  return {
+    onClickIndex,
+    startIndex,
+    endIndex,
+  };
+}
+
+export function useMessageSelector() {
+  const [selection, setSelection] = useState(new Set<string>());
+  const updateSelection: Updater<Set<string>> = (updater) => {
+    const newSelection = new Set<string>(selection);
+    updater(newSelection);
+    setSelection(newSelection);
+  };
+
+  return {
+    selection,
+    updateSelection,
+  };
+}
+
+export function MessageSelector(props: {
+  selection: Set<string>;
+  updateSelection: Updater<Set<string>>;
+  defaultSelectAll?: boolean;
+  onSelected?: (messages: ChatMessage[]) => void;
+}) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming;
+  const allMessages = useMemo(() => {
+    let startIndex = Math.max(0, session.clearContextIndex ?? 0);
+    if (startIndex === session.messages.length - 1) {
+      startIndex = 0;
+    }
+    return session.messages.slice(startIndex);
+  }, [session.messages, session.clearContextIndex]);
+
+  const messages = useMemo(
+    () =>
+      allMessages.filter(
+        (m, i) =>
+          m.id && // message must have id
+          isValid(m) &&
+          (i >= allMessages.length - 1 || isValid(allMessages[i + 1])),
+      ),
+    [allMessages],
+  );
+  const messageCount = messages.length;
+  const config = useAppConfig();
+
+  const [searchInput, setSearchInput] = useState("");
+  const [searchIds, setSearchIds] = useState(new Set<string>());
+  const isInSearchResult = (id: string) => {
+    return searchInput.length === 0 || searchIds.has(id);
+  };
+  const doSearch = (text: string) => {
+    const searchResults = new Set<string>();
+    if (text.length > 0) {
+      messages.forEach((m) =>
+        getMessageTextContent(m).includes(text)
+          ? searchResults.add(m.id!)
+          : null,
+      );
+    }
+    setSearchIds(searchResults);
+  };
+
+  // for range selection
+  const { startIndex, endIndex, onClickIndex } = useShiftRange();
+
+  const selectAll = () => {
+    props.updateSelection((selection) =>
+      messages.forEach((m) => selection.add(m.id!)),
+    );
+  };
+
+  useEffect(() => {
+    if (props.defaultSelectAll) {
+      selectAll();
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  useEffect(() => {
+    if (startIndex === undefined || endIndex === undefined) {
+      return;
+    }
+    const [start, end] = [startIndex, endIndex].sort((a, b) => a - b);
+    props.updateSelection((selection) => {
+      for (let i = start; i <= end; i += 1) {
+        selection.add(messages[i].id ?? i);
+      }
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [startIndex, endIndex]);
+
+  const LATEST_COUNT = 4;
+
+  return (
+    <div className={styles["message-selector"]}>
+      <div className={styles["message-filter"]}>
+        <input
+          type="text"
+          placeholder={Locale.Select.Search}
+          className={styles["filter-item"] + " " + styles["search-bar"]}
+          value={searchInput}
+          onInput={(e) => {
+            setSearchInput(e.currentTarget.value);
+            doSearch(e.currentTarget.value);
+          }}
+        ></input>
+
+        <div className={styles["actions"]}>
+          <IconButton
+            text={Locale.Select.All}
+            bordered
+            className={styles["filter-item"]}
+            onClick={selectAll}
+          />
+          <IconButton
+            text={Locale.Select.Latest}
+            bordered
+            className={styles["filter-item"]}
+            onClick={() =>
+              props.updateSelection((selection) => {
+                selection.clear();
+                messages
+                  .slice(messageCount - LATEST_COUNT)
+                  .forEach((m) => selection.add(m.id!));
+              })
+            }
+          />
+          <IconButton
+            text={Locale.Select.Clear}
+            bordered
+            className={styles["filter-item"]}
+            onClick={() =>
+              props.updateSelection((selection) => selection.clear())
+            }
+          />
+        </div>
+      </div>
+
+      <div className={styles["messages"]}>
+        {messages.map((m, i) => {
+          if (!isInSearchResult(m.id!)) return null;
+          const id = m.id ?? i;
+          const isSelected = props.selection.has(id);
+
+          return (
+            <div
+              className={`${styles["message"]} ${
+                props.selection.has(m.id!) && styles["message-selected"]
+              }`}
+              key={i}
+              onClick={() => {
+                props.updateSelection((selection) => {
+                  selection.has(id) ? selection.delete(id) : selection.add(id);
+                });
+                onClickIndex(i);
+              }}
+            >
+              <div className={styles["avatar"]}>
+                {m.role === "user" ? (
+                  <Avatar avatar={config.avatar}></Avatar>
+                ) : (
+                  <MaskAvatar
+                    avatar={session.mask.avatar}
+                    model={m.model || session.mask.modelConfig.model}
+                  />
+                )}
+              </div>
+              <div className={styles["body"]}>
+                <div className={styles["date"]}>
+                  {new Date(m.date).toLocaleString()}
+                </div>
+                <div className={`${styles["content"]} one-line`}>
+                  {getMessageTextContent(m)}
+                </div>
+              </div>
+
+              <div className={styles["checkbox"]}>
+                <input type="checkbox" checked={isSelected} readOnly></input>
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}

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

@@ -0,0 +1,231 @@
+import { ServiceProvider } from "@/app/constant";
+import { ModalConfigValidator, ModelConfig } from "../store";
+
+import Locale from "../locales";
+import { InputRange } from "./input-range";
+import { ListItem, Select } from "./ui-lib";
+import { useAllModels } from "../utils/hooks";
+
+export function ModelConfigList(props: {
+  modelConfig: ModelConfig;
+  updateConfig: (updater: (config: ModelConfig) => void) => void;
+}) {
+  const allModels = useAllModels();
+  const value = `${props.modelConfig.model}@${props.modelConfig?.providerName}`;
+
+  return (
+    <>
+      <ListItem title={Locale.Settings.Model}>
+        <Select
+          aria-label={Locale.Settings.Model}
+          value={value}
+          onChange={(e) => {
+            const [model, providerName] = e.currentTarget.value.split("@");
+            props.updateConfig((config) => {
+              config.model = ModalConfigValidator.model(model);
+              config.providerName = providerName as ServiceProvider;
+            });
+          }}
+        >
+          {allModels
+            .filter((v) => v.available)
+            .map((v, i) => (
+              <option value={`${v.name}@${v.provider?.providerName}`} key={i}>
+                {v.displayName}({v.provider?.providerName})
+              </option>
+            ))}
+        </Select>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Temperature.Title}
+        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
+          step="0.1"
+          onChange={(e) => {
+            props.updateConfig(
+              (config) =>
+                (config.temperature = ModalConfigValidator.temperature(
+                  e.currentTarget.valueAsNumber,
+                )),
+            );
+          }}
+        ></InputRange>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.TopP.Title}
+        subTitle={Locale.Settings.TopP.SubTitle}
+      >
+        <InputRange
+          aria={Locale.Settings.TopP.Title}
+          value={(props.modelConfig.top_p ?? 1).toFixed(1)}
+          min="0"
+          max="1"
+          step="0.1"
+          onChange={(e) => {
+            props.updateConfig(
+              (config) =>
+                (config.top_p = ModalConfigValidator.top_p(
+                  e.currentTarget.valueAsNumber,
+                )),
+            );
+          }}
+        ></InputRange>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.MaxTokens.Title}
+        subTitle={Locale.Settings.MaxTokens.SubTitle}
+      >
+        <input
+          aria-label={Locale.Settings.MaxTokens.Title}
+          type="number"
+          min={1024}
+          max={512000}
+          value={props.modelConfig.max_tokens}
+          onChange={(e) =>
+            props.updateConfig(
+              (config) =>
+                (config.max_tokens = ModalConfigValidator.max_tokens(
+                  e.currentTarget.valueAsNumber,
+                )),
+            )
+          }
+        ></input>
+      </ListItem>
+
+
+          <ListItem
+            title={Locale.Settings.PresencePenalty.Title}
+            subTitle={Locale.Settings.PresencePenalty.SubTitle}
+          >
+            <InputRange
+              aria={Locale.Settings.PresencePenalty.Title}
+              value={props.modelConfig.presence_penalty?.toFixed(1)}
+              min="-2"
+              max="2"
+              step="0.1"
+              onChange={(e) => {
+                props.updateConfig(
+                  (config) =>
+                    (config.presence_penalty =
+                      ModalConfigValidator.presence_penalty(
+                        e.currentTarget.valueAsNumber,
+                      )),
+                );
+              }}
+            ></InputRange>
+          </ListItem>
+
+          <ListItem
+            title={Locale.Settings.FrequencyPenalty.Title}
+            subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
+          >
+            <InputRange
+              aria={Locale.Settings.FrequencyPenalty.Title}
+              value={props.modelConfig.frequency_penalty?.toFixed(1)}
+              min="-2"
+              max="2"
+              step="0.1"
+              onChange={(e) => {
+                props.updateConfig(
+                  (config) =>
+                    (config.frequency_penalty =
+                      ModalConfigValidator.frequency_penalty(
+                        e.currentTarget.valueAsNumber,
+                      )),
+                );
+              }}
+            ></InputRange>
+          </ListItem>
+
+          <ListItem
+            title={Locale.Settings.InjectSystemPrompts.Title}
+            subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
+          >
+            <input
+              aria-label={Locale.Settings.InjectSystemPrompts.Title}
+              type="checkbox"
+              checked={props.modelConfig.enableInjectSystemPrompts}
+              onChange={(e) =>
+                props.updateConfig(
+                  (config) =>
+                    (config.enableInjectSystemPrompts =
+                      e.currentTarget.checked),
+                )
+              }
+            ></input>
+          </ListItem>
+
+          <ListItem
+            title={Locale.Settings.InputTemplate.Title}
+            subTitle={Locale.Settings.InputTemplate.SubTitle}
+          >
+            <input
+              aria-label={Locale.Settings.InputTemplate.Title}
+              type="text"
+              value={props.modelConfig.template}
+              onChange={(e) =>
+                props.updateConfig(
+                  (config) => (config.template = e.currentTarget.value),
+                )
+              }
+            ></input>
+          </ListItem>
+
+      <ListItem
+        title={Locale.Settings.HistoryCount.Title}
+        subTitle={Locale.Settings.HistoryCount.SubTitle}
+      >
+        <InputRange
+          aria={Locale.Settings.HistoryCount.Title}
+          title={props.modelConfig.historyMessageCount.toString()}
+          value={props.modelConfig.historyMessageCount}
+          min="0"
+          max="64"
+          step="1"
+          onChange={(e) =>
+            props.updateConfig(
+              (config) => (config.historyMessageCount = e.target.valueAsNumber),
+            )
+          }
+        ></InputRange>
+      </ListItem>
+
+      <ListItem
+        title={Locale.Settings.CompressThreshold.Title}
+        subTitle={Locale.Settings.CompressThreshold.SubTitle}
+      >
+        <input
+          aria-label={Locale.Settings.CompressThreshold.Title}
+          type="number"
+          min={500}
+          max={4000}
+          value={props.modelConfig.compressMessageLengthThreshold}
+          onChange={(e) =>
+            props.updateConfig(
+              (config) =>
+                (config.compressMessageLengthThreshold =
+                  e.currentTarget.valueAsNumber),
+            )
+          }
+        ></input>
+      </ListItem>
+      <ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
+        <input
+          aria-label={Locale.Memory.Title}
+          type="checkbox"
+          checked={props.modelConfig.sendMemory}
+          onChange={(e) =>
+            props.updateConfig(
+              (config) => (config.sendMemory = e.currentTarget.checked),
+            )
+          }
+        ></input>
+      </ListItem>
+    </>
+  );
+}

+ 74 - 0
app/components/settings.module.scss

@@ -0,0 +1,74 @@
+.settings {
+  padding: 20px;
+  overflow: auto;
+}
+
+.avatar {
+  cursor: pointer;
+  position: relative;
+  z-index: 1;
+}
+
+.edit-prompt-modal {
+  display: flex;
+  flex-direction: column;
+
+  .edit-prompt-title {
+    max-width: unset;
+    margin-bottom: 20px;
+    text-align: left;
+  }
+  .edit-prompt-content {
+    max-width: unset;
+  }
+}
+
+.user-prompt-modal {
+  min-height: 40vh;
+
+  .user-prompt-search {
+    width: 100%;
+    max-width: 100%;
+    margin-bottom: 10px;
+    background-color: var(--gray);
+  }
+
+  .user-prompt-list {
+    border: var(--border-in-light);
+    border-radius: 10px;
+
+    .user-prompt-item {
+      display: flex;
+      justify-content: space-between;
+      padding: 10px;
+
+      &:not(:last-child) {
+        border-bottom: var(--border-in-light);
+      }
+
+      .user-prompt-header {
+        max-width: calc(100% - 100px);
+
+        .user-prompt-title {
+          font-size: 14px;
+          line-height: 2;
+          font-weight: bold;
+        }
+        .user-prompt-content {
+          font-size: 12px;
+        }
+      }
+
+      .user-prompt-buttons {
+        display: flex;
+        align-items: center;
+        column-gap: 2px;
+
+        .user-prompt-button {
+          //height: 100%;
+          padding: 7px;
+        }
+      }
+    }
+  }
+}

+ 1399 - 0
app/components/settings.tsx

@@ -0,0 +1,1399 @@
+import { useState, useEffect, useMemo } from "react";
+
+import styles from "./settings.module.scss";
+
+import ResetIcon from "../icons/reload.svg";
+import AddIcon from "../icons/add.svg";
+import CloseIcon from "../icons/close.svg";
+import CopyIcon from "../icons/copy.svg";
+import ClearIcon from "../icons/clear.svg";
+import LoadingIcon from "../icons/three-dots.svg";
+import EditIcon from "../icons/edit.svg";
+import EyeIcon from "../icons/eye.svg";
+import DownloadIcon from "../icons/download.svg";
+import UploadIcon from "../icons/upload.svg";
+import ConfigIcon from "../icons/config.svg";
+import ConfirmIcon from "../icons/confirm.svg";
+
+import ConnectionIcon from "../icons/connection.svg";
+import CloudSuccessIcon from "../icons/cloud-success.svg";
+import CloudFailIcon from "../icons/cloud-fail.svg";
+
+import {
+  Input,
+  List,
+  ListItem,
+  Modal,
+  PasswordInput,
+  Popover,
+  Select,
+  showConfirm,
+  showToast,
+} from "./ui-lib";
+import { ModelConfigList } from "./model-config";
+
+import { IconButton } from "./button";
+import {
+  SubmitKey,
+  useChatStore,
+  Theme,
+  useAccessStore,
+  useAppConfig,
+} from "../store";
+
+import Locale, {
+  AllLangs,
+  ALL_LANG_OPTIONS,
+  changeLang,
+  getLang,
+} from "../locales";
+import { copyToClipboard } from "../utils";
+import Link from "next/link";
+import {
+  Azure,
+  Baidu,
+  Tencent,
+  ByteDance,
+  Alibaba,
+  OPENAI_BASE_URL,
+  Path,
+  STORAGE_KEY,
+  ServiceProvider,
+  SlotID,
+  Iflytek,
+} from "../constant";
+import { Prompt, SearchService, usePromptStore } from "../store/prompt";
+import { ErrorBoundary } from "./error";
+import { InputRange } from "./input-range";
+import { useNavigate } from "react-router-dom";
+// Avatar组件替代实现
+import BotIcon from "../icons/bot.svg";
+import BlackBotIcon from "../icons/black-bot.svg";
+
+function Avatar(props: { model?: string; avatar?: string }) {
+  if (props.model) {
+    return (
+      <div className="no-dark">
+        {props.model?.startsWith("gpt-4") ? (
+          <BlackBotIcon className="user-avatar" />
+        ) : (
+          <BotIcon className="user-avatar" />
+        )}
+      </div>
+    );
+  }
+
+  return (
+    <div className="user-avatar">
+      {/* 移除emoji头像,使用默认bot图标 */}
+      <BotIcon className="user-avatar" />
+    </div>
+  );
+}
+
+// 简化的AvatarPicker替代实现
+function AvatarPicker(props: { onEmojiClick: (emoji: string) => void }) {
+  const defaultAvatars = ["🤖", "👤", "💬", "🎯", "⭐", "🔥"];
+  
+  return (
+    <div style={{ padding: "10px", display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "10px" }}>
+      {defaultAvatars.map((emoji, index) => (
+        <div
+          key={index}
+          style={{
+            padding: "8px",
+            borderRadius: "4px",
+            border: "1px solid #ccc",
+            textAlign: "center",
+            cursor: "pointer",
+            fontSize: "20px"
+          }}
+          onClick={() => props.onEmojiClick(emoji)}
+        >
+          {emoji}
+        </div>
+      ))}
+    </div>
+  );
+}
+import { getClientConfig } from "../config/client";
+import { useSyncStore } from "../store/sync";
+import { nanoid } from "nanoid";
+import { useMaskStore } from "../store/mask";
+import { ProviderType } from "../utils/cloud";
+
+function EditPromptModal(props: { id: string; onClose: () => void }) {
+  const promptStore = usePromptStore();
+  const prompt = promptStore.get(props.id);
+
+  return prompt ? (
+    <div className="modal-mask">
+      <Modal
+        title={Locale.Settings.Prompt.EditModal.Title}
+        onClose={props.onClose}
+        actions={[
+          <IconButton
+            key=""
+            onClick={props.onClose}
+            text={Locale.UI.Confirm}
+            bordered
+          />,
+        ]}
+      >
+        <div className={styles["edit-prompt-modal"]}>
+          <input
+            type="text"
+            value={prompt.title}
+            readOnly={!prompt.isUser}
+            className={styles["edit-prompt-title"]}
+            onInput={(e) =>
+              promptStore.updatePrompt(
+                props.id,
+                (prompt) => (prompt.title = e.currentTarget.value),
+              )
+            }
+          ></input>
+          <Input
+            value={prompt.content}
+            readOnly={!prompt.isUser}
+            className={styles["edit-prompt-content"]}
+            rows={10}
+            onInput={(e) =>
+              promptStore.updatePrompt(
+                props.id,
+                (prompt) => (prompt.content = e.currentTarget.value),
+              )
+            }
+          ></Input>
+        </div>
+      </Modal>
+    </div>
+  ) : null;
+}
+
+function UserPromptModal(props: { onClose?: () => void }) {
+  const promptStore = usePromptStore();
+  const userPrompts = promptStore.getUserPrompts();
+  // 移除内置提示词,仅使用用户提示词
+  const allPrompts = userPrompts;
+  const [searchInput, setSearchInput] = useState("");
+  const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
+  const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
+
+  const [editingPromptId, setEditingPromptId] = useState<string>();
+
+  useEffect(() => {
+    if (searchInput.length > 0) {
+      const searchResult = SearchService.search(searchInput);
+      setSearchPrompts(searchResult);
+    } else {
+      setSearchPrompts([]);
+    }
+  }, [searchInput]);
+
+  return (
+    <div className="modal-mask">
+      <Modal
+        title={Locale.Settings.Prompt.Modal.Title}
+        onClose={() => props.onClose?.()}
+        actions={[
+          <IconButton
+            key="add"
+            onClick={() => {
+              const promptId = promptStore.add({
+                id: nanoid(),
+                createdAt: Date.now(),
+                title: "Empty Prompt",
+                content: "Empty Prompt Content",
+              });
+              setEditingPromptId(promptId);
+            }}
+            icon={<AddIcon />}
+            bordered
+            text={Locale.Settings.Prompt.Modal.Add}
+          />,
+        ]}
+      >
+        <div className={styles["user-prompt-modal"]}>
+          <input
+            type="text"
+            className={styles["user-prompt-search"]}
+            placeholder={Locale.Settings.Prompt.Modal.Search}
+            value={searchInput}
+            onInput={(e) => setSearchInput(e.currentTarget.value)}
+          ></input>
+
+          <div className={styles["user-prompt-list"]}>
+            {prompts.map((v, _) => (
+              <div className={styles["user-prompt-item"]} key={v.id ?? v.title}>
+                <div className={styles["user-prompt-header"]}>
+                  <div className={styles["user-prompt-title"]}>{v.title}</div>
+                  <div className={styles["user-prompt-content"] + " one-line"}>
+                    {v.content}
+                  </div>
+                </div>
+
+                <div className={styles["user-prompt-buttons"]}>
+                  {v.isUser && (
+                    <IconButton
+                      icon={<ClearIcon />}
+                      className={styles["user-prompt-button"]}
+                      onClick={() => promptStore.remove(v.id!)}
+                    />
+                  )}
+                  {v.isUser ? (
+                    <IconButton
+                      icon={<EditIcon />}
+                      className={styles["user-prompt-button"]}
+                      onClick={() => setEditingPromptId(v.id)}
+                    />
+                  ) : (
+                    <IconButton
+                      icon={<EyeIcon />}
+                      className={styles["user-prompt-button"]}
+                      onClick={() => setEditingPromptId(v.id)}
+                    />
+                  )}
+                  <IconButton
+                    icon={<CopyIcon />}
+                    className={styles["user-prompt-button"]}
+                    onClick={() => copyToClipboard(v.content)}
+                  />
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      </Modal>
+
+      {editingPromptId !== undefined && (
+        <EditPromptModal
+          id={editingPromptId!}
+          onClose={() => setEditingPromptId(undefined)}
+        />
+      )}
+    </div>
+  );
+}
+
+function DangerItems() {
+  const chatStore = useChatStore();
+  const appConfig = useAppConfig();
+
+  return (
+    <List>
+      <ListItem
+        title={Locale.Settings.Danger.Reset.Title}
+        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)) {
+              appConfig.reset();
+            }
+          }}
+          type="danger"
+        />
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Danger.Clear.Title}
+        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)) {
+              chatStore.clearAllData();
+            }
+          }}
+          type="danger"
+        />
+      </ListItem>
+    </List>
+  );
+}
+
+function CheckButton() {
+  const syncStore = useSyncStore();
+
+  const couldCheck = useMemo(() => {
+    return syncStore.cloudSync();
+  }, [syncStore]);
+
+  const [checkState, setCheckState] = useState<
+    "none" | "checking" | "success" | "failed"
+  >("none");
+
+  async function check() {
+    setCheckState("checking");
+    const valid = await syncStore.check();
+    setCheckState(valid ? "success" : "failed");
+  }
+
+  if (!couldCheck) return null;
+
+  return (
+    <IconButton
+      text={Locale.Settings.Sync.Config.Modal.Check}
+      bordered
+      onClick={check}
+      icon={
+        checkState === "none" ? (
+          <ConnectionIcon />
+        ) : checkState === "checking" ? (
+          <LoadingIcon />
+        ) : checkState === "success" ? (
+          <CloudSuccessIcon />
+        ) : checkState === "failed" ? (
+          <CloudFailIcon />
+        ) : (
+          <ConnectionIcon />
+        )
+      }
+    ></IconButton>
+  );
+}
+
+function SyncConfigModal(props: { onClose?: () => void }) {
+  const syncStore = useSyncStore();
+
+  return (
+    <div className="modal-mask">
+      <Modal
+        title={Locale.Settings.Sync.Config.Modal.Title}
+        onClose={() => props.onClose?.()}
+        actions={[
+          <CheckButton key="check" />,
+          <IconButton
+            key="confirm"
+            onClick={props.onClose}
+            icon={<ConfirmIcon />}
+            bordered
+            text={Locale.UI.Confirm}
+          />,
+        ]}
+      >
+        <List>
+          <ListItem
+            title={Locale.Settings.Sync.Config.SyncType.Title}
+            subTitle={Locale.Settings.Sync.Config.SyncType.SubTitle}
+          >
+            <select
+              value={syncStore.provider}
+              onChange={(e) => {
+                syncStore.update(
+                  (config) =>
+                    (config.provider = e.target.value as ProviderType),
+                );
+              }}
+            >
+              {Object.entries(ProviderType).map(([k, v]) => (
+                <option value={v} key={k}>
+                  {k}
+                </option>
+              ))}
+            </select>
+          </ListItem>
+
+          <ListItem
+            title={Locale.Settings.Sync.Config.Proxy.Title}
+            subTitle={Locale.Settings.Sync.Config.Proxy.SubTitle}
+          >
+            <input
+              type="checkbox"
+              checked={syncStore.useProxy}
+              onChange={(e) => {
+                syncStore.update(
+                  (config) => (config.useProxy = e.currentTarget.checked),
+                );
+              }}
+            ></input>
+          </ListItem>
+          {syncStore.useProxy ? (
+            <ListItem
+              title={Locale.Settings.Sync.Config.ProxyUrl.Title}
+              subTitle={Locale.Settings.Sync.Config.ProxyUrl.SubTitle}
+            >
+              <input
+                type="text"
+                value={syncStore.proxyUrl}
+                onChange={(e) => {
+                  syncStore.update(
+                    (config) => (config.proxyUrl = e.currentTarget.value),
+                  );
+                }}
+              ></input>
+            </ListItem>
+          ) : null}
+        </List>
+
+        {syncStore.provider === ProviderType.WebDAV && (
+          <>
+            <List>
+              <ListItem title={Locale.Settings.Sync.Config.WebDav.Endpoint}>
+                <input
+                  type="text"
+                  value={syncStore.webdav.endpoint}
+                  onChange={(e) => {
+                    syncStore.update(
+                      (config) =>
+                        (config.webdav.endpoint = e.currentTarget.value),
+                    );
+                  }}
+                ></input>
+              </ListItem>
+
+              <ListItem title={Locale.Settings.Sync.Config.WebDav.UserName}>
+                <input
+                  type="text"
+                  value={syncStore.webdav.username}
+                  onChange={(e) => {
+                    syncStore.update(
+                      (config) =>
+                        (config.webdav.username = e.currentTarget.value),
+                    );
+                  }}
+                ></input>
+              </ListItem>
+              <ListItem title={Locale.Settings.Sync.Config.WebDav.Password}>
+                <PasswordInput
+                  value={syncStore.webdav.password}
+                  onChange={(e) => {
+                    syncStore.update(
+                      (config) =>
+                        (config.webdav.password = e.currentTarget.value),
+                    );
+                  }}
+                ></PasswordInput>
+              </ListItem>
+            </List>
+          </>
+        )}
+
+        {syncStore.provider === ProviderType.UpStash && (
+          <List>
+            <ListItem title={Locale.Settings.Sync.Config.UpStash.Endpoint}>
+              <input
+                type="text"
+                value={syncStore.upstash.endpoint}
+                onChange={(e) => {
+                  syncStore.update(
+                    (config) =>
+                      (config.upstash.endpoint = e.currentTarget.value),
+                  );
+                }}
+              ></input>
+            </ListItem>
+
+            <ListItem title={Locale.Settings.Sync.Config.UpStash.UserName}>
+              <input
+                type="text"
+                value={syncStore.upstash.username}
+                placeholder={STORAGE_KEY}
+                onChange={(e) => {
+                  syncStore.update(
+                    (config) =>
+                      (config.upstash.username = e.currentTarget.value),
+                  );
+                }}
+              ></input>
+            </ListItem>
+            <ListItem title={Locale.Settings.Sync.Config.UpStash.Password}>
+              <PasswordInput
+                value={syncStore.upstash.apiKey}
+                onChange={(e) => {
+                  syncStore.update(
+                    (config) => (config.upstash.apiKey = e.currentTarget.value),
+                  );
+                }}
+              ></PasswordInput>
+            </ListItem>
+          </List>
+        )}
+      </Modal>
+    </div>
+  );
+}
+
+function SyncItems() {
+  const syncStore = useSyncStore();
+  const chatStore = useChatStore();
+  const promptStore = usePromptStore();
+  const maskStore = useMaskStore();
+  const couldSync = useMemo(() => {
+    return syncStore.cloudSync();
+  }, [syncStore]);
+
+  const [showSyncConfigModal, setShowSyncConfigModal] = useState(false);
+
+  const stateOverview = useMemo(() => {
+    const sessions = chatStore.sessions;
+    const messageCount = sessions.reduce((p, c) => p + c.messages.length, 0);
+
+    return {
+      chat: sessions.length,
+      message: messageCount,
+      prompt: Object.keys(promptStore.prompts).length,
+      mask: Object.keys(maskStore.masks).length,
+    };
+  }, [chatStore.sessions, maskStore.masks, promptStore.prompts]);
+
+  return (
+    <>
+      <List>
+        <ListItem
+          title={Locale.Settings.Sync.CloudState}
+          subTitle={
+            syncStore.lastProvider
+              ? `${new Date(syncStore.lastSyncTime).toLocaleString()} [${
+                  syncStore.lastProvider
+                }]`
+              : Locale.Settings.Sync.NotSyncYet
+          }
+        >
+          <div style={{ display: "flex" }}>
+            <IconButton
+              aria={Locale.Settings.Sync.CloudState + Locale.UI.Config}
+              icon={<ConfigIcon />}
+              text={Locale.UI.Config}
+              onClick={() => {
+                setShowSyncConfigModal(true);
+              }}
+            />
+            {couldSync && (
+              <IconButton
+                icon={<ResetIcon />}
+                text={Locale.UI.Sync}
+                onClick={async () => {
+                  try {
+                    await syncStore.sync();
+                    showToast(Locale.Settings.Sync.Success);
+                  } catch (e) {
+                    showToast(Locale.Settings.Sync.Fail);
+                    console.error("[Sync]", e);
+                  }
+                }}
+              />
+            )}
+          </div>
+        </ListItem>
+
+        <ListItem
+          title={Locale.Settings.Sync.LocalState}
+          subTitle={Locale.Settings.Sync.Overview(stateOverview)}
+        >
+          <div style={{ display: "flex" }}>
+            <IconButton
+              aria={Locale.Settings.Sync.LocalState + Locale.UI.Export}
+              icon={<UploadIcon />}
+              text={Locale.UI.Export}
+              onClick={() => {
+                syncStore.export();
+              }}
+            />
+            <IconButton
+              aria={Locale.Settings.Sync.LocalState + Locale.UI.Import}
+              icon={<DownloadIcon />}
+              text={Locale.UI.Import}
+              onClick={() => {
+                syncStore.import();
+              }}
+            />
+          </div>
+        </ListItem>
+      </List>
+
+      {showSyncConfigModal && (
+        <SyncConfigModal onClose={() => setShowSyncConfigModal(false)} />
+      )}
+    </>
+  );
+}
+
+export function Settings() {
+  const navigate = useNavigate();
+  const [showEmojiPicker, setShowEmojiPicker] = useState(false);
+  const config = useAppConfig();
+  const updateConfig = config.update;
+
+
+
+  const accessStore = useAccessStore();
+  const shouldHideBalanceQuery = useMemo(() => {
+    const isOpenAiUrl = accessStore.openaiUrl.includes(OPENAI_BASE_URL);
+
+    return (
+      accessStore.hideBalanceQuery ||
+      isOpenAiUrl ||
+      accessStore.provider === ServiceProvider.Azure
+    );
+  }, [
+    accessStore.hideBalanceQuery,
+    accessStore.openaiUrl,
+    accessStore.provider,
+  ]);
+
+
+
+  const enabledAccessControl = useMemo(
+    () => accessStore.enabledAccessControl(),
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [],
+  );
+
+  const promptStore = usePromptStore();
+  // 移除内置提示词,设置count为0
+  const builtinCount = 0;
+  const customCount = promptStore.getUserPrompts().length ?? 0;
+  const [shouldShowPromptModal, setShowPromptModal] = useState(false);
+
+
+
+  useEffect(() => {
+    const keydownEvent = (e: KeyboardEvent) => {
+      if (e.key === "Escape") {
+        navigate(Path.Home);
+      }
+    };
+    if (clientConfig?.isApp) {
+      // Force to set custom endpoint to true if it's app
+      accessStore.update((state) => {
+        state.useCustomConfig = true;
+      });
+    }
+    document.addEventListener("keydown", keydownEvent);
+    return () => {
+      document.removeEventListener("keydown", keydownEvent);
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  const clientConfig = useMemo(() => getClientConfig(), []);
+  const showAccessCode = enabledAccessControl && !clientConfig?.isApp;
+
+  const accessCodeComponent = showAccessCode && (
+    <ListItem
+      title={Locale.Settings.Access.AccessCode.Title}
+      subTitle={Locale.Settings.Access.AccessCode.SubTitle}
+    >
+      <PasswordInput
+        value={accessStore.accessCode}
+        type="text"
+        placeholder={Locale.Settings.Access.AccessCode.Placeholder}
+        onChange={(e) => {
+          accessStore.update(
+            (access) => (access.accessCode = e.currentTarget.value),
+          );
+        }}
+      />
+    </ListItem>
+  );
+
+  const useCustomConfigComponent = // Conditionally render the following ListItem based on clientConfig.isApp
+    !clientConfig?.isApp && ( // only show if isApp is false
+      <ListItem
+        title={Locale.Settings.Access.CustomEndpoint.Title}
+        subTitle={Locale.Settings.Access.CustomEndpoint.SubTitle}
+      >
+        <input
+          aria-label={Locale.Settings.Access.CustomEndpoint.Title}
+          type="checkbox"
+          checked={accessStore.useCustomConfig}
+          onChange={(e) =>
+            accessStore.update(
+              (access) => (access.useCustomConfig = e.currentTarget.checked),
+            )
+          }
+        ></input>
+      </ListItem>
+    );
+
+  const openAIConfigComponent = accessStore.provider ===
+    ServiceProvider.OpenAI && (
+    <>
+      <ListItem
+        title={Locale.Settings.Access.OpenAI.Endpoint.Title}
+        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}
+          onChange={(e) =>
+            accessStore.update(
+              (access) => (access.openaiUrl = e.currentTarget.value),
+            )
+          }
+        ></input>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Access.OpenAI.ApiKey.Title}
+        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}
+          onChange={(e) => {
+            accessStore.update(
+              (access) => (access.openaiApiKey = e.currentTarget.value),
+            );
+          }}
+        />
+      </ListItem>
+    </>
+  );
+
+  const azureConfigComponent = accessStore.provider ===
+    ServiceProvider.Azure && (
+    <>
+      <ListItem
+        title={Locale.Settings.Access.Azure.Endpoint.Title}
+        subTitle={
+          Locale.Settings.Access.Azure.Endpoint.SubTitle + Azure.ExampleEndpoint
+        }
+      >
+        <input
+          aria-label={Locale.Settings.Access.Azure.Endpoint.Title}
+          type="text"
+          value={accessStore.azureUrl}
+          placeholder={Azure.ExampleEndpoint}
+          onChange={(e) =>
+            accessStore.update(
+              (access) => (access.azureUrl = e.currentTarget.value),
+            )
+          }
+        ></input>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Access.Azure.ApiKey.Title}
+        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}
+          onChange={(e) => {
+            accessStore.update(
+              (access) => (access.azureApiKey = e.currentTarget.value),
+            );
+          }}
+        />
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Access.Azure.ApiVerion.Title}
+        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"
+          onChange={(e) =>
+            accessStore.update(
+              (access) => (access.azureApiVersion = e.currentTarget.value),
+            )
+          }
+        ></input>
+      </ListItem>
+    </>
+  );
+
+
+
+  const anthropicConfigComponent = null;
+
+  const baiduConfigComponent = accessStore.provider ===
+    ServiceProvider.Baidu && (
+    <>
+      <ListItem
+        title={Locale.Settings.Access.Baidu.Endpoint.Title}
+        subTitle={Locale.Settings.Access.Baidu.Endpoint.SubTitle}
+      >
+        <input
+          aria-label={Locale.Settings.Access.Baidu.Endpoint.Title}
+          type="text"
+          value={accessStore.baiduUrl}
+          placeholder={Baidu.ExampleEndpoint}
+          onChange={(e) =>
+            accessStore.update(
+              (access) => (access.baiduUrl = e.currentTarget.value),
+            )
+          }
+        ></input>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Access.Baidu.ApiKey.Title}
+        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}
+          onChange={(e) => {
+            accessStore.update(
+              (access) => (access.baiduApiKey = e.currentTarget.value),
+            );
+          }}
+        />
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Access.Baidu.SecretKey.Title}
+        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}
+          onChange={(e) => {
+            accessStore.update(
+              (access) => (access.baiduSecretKey = e.currentTarget.value),
+            );
+          }}
+        />
+      </ListItem>
+    </>
+  );
+
+  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 && (
+    <>
+      <ListItem
+        title={Locale.Settings.Access.ByteDance.Endpoint.Title}
+        subTitle={
+          Locale.Settings.Access.ByteDance.Endpoint.SubTitle +
+          ByteDance.ExampleEndpoint
+        }
+      >
+        <input
+          aria-label={Locale.Settings.Access.ByteDance.Endpoint.Title}
+          type="text"
+          value={accessStore.bytedanceUrl}
+          placeholder={ByteDance.ExampleEndpoint}
+          onChange={(e) =>
+            accessStore.update(
+              (access) => (access.bytedanceUrl = e.currentTarget.value),
+            )
+          }
+        ></input>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Access.ByteDance.ApiKey.Title}
+        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}
+          onChange={(e) => {
+            accessStore.update(
+              (access) => (access.bytedanceApiKey = e.currentTarget.value),
+            );
+          }}
+        />
+      </ListItem>
+    </>
+  );
+
+  const alibabaConfigComponent = accessStore.provider ===
+    ServiceProvider.Alibaba && (
+    <>
+      <ListItem
+        title={Locale.Settings.Access.Alibaba.Endpoint.Title}
+        subTitle={
+          Locale.Settings.Access.Alibaba.Endpoint.SubTitle +
+          Alibaba.ExampleEndpoint
+        }
+      >
+        <input
+          aria-label={Locale.Settings.Access.Alibaba.Endpoint.Title}
+          type="text"
+          value={accessStore.alibabaUrl}
+          placeholder={Alibaba.ExampleEndpoint}
+          onChange={(e) =>
+            accessStore.update(
+              (access) => (access.alibabaUrl = e.currentTarget.value),
+            )
+          }
+        ></input>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Access.Alibaba.ApiKey.Title}
+        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}
+          onChange={(e) => {
+            accessStore.update(
+              (access) => (access.alibabaApiKey = e.currentTarget.value),
+            );
+          }}
+        />
+      </ListItem>
+    </>
+  );
+
+  const moonshotConfigComponent = null;
+
+  const stabilityConfigComponent = null;
+  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>
+        <div className="window-header-title">
+          <div className="window-header-main-title">
+            {Locale.Settings.Title}
+          </div>
+          <div className="window-header-sub-title">
+            {Locale.Settings.SubTitle}
+          </div>
+        </div>
+        <div className="window-actions">
+          <div className="window-action-button"></div>
+          <div className="window-action-button"></div>
+          <div className="window-action-button">
+            <IconButton
+              aria={Locale.UI.Close}
+              icon={<CloseIcon />}
+              onClick={() => navigate(Path.Home)}
+              bordered
+            />
+          </div>
+        </div>
+      </div>
+      <div className={styles["settings"]}>
+        <List>
+          <ListItem title={Locale.Settings.Avatar}>
+            <Popover
+              onClose={() => setShowEmojiPicker(false)}
+              content={
+                <AvatarPicker
+                  onEmojiClick={(avatar: string) => {
+                    updateConfig((config) => (config.avatar = avatar));
+                    setShowEmojiPicker(false);
+                  }}
+                />
+              }
+              open={showEmojiPicker}
+            >
+              <div
+                aria-label={Locale.Settings.Avatar}
+                tabIndex={0}
+                className={styles.avatar}
+                onClick={() => {
+                  setShowEmojiPicker(!showEmojiPicker);
+                }}
+              >
+                <Avatar avatar={config.avatar} />
+              </div>
+            </Popover>
+          </ListItem>
+
+
+
+          <ListItem title={Locale.Settings.SendKey}>
+            <Select
+              aria-label={Locale.Settings.SendKey}
+              value={config.submitKey}
+              onChange={(e) => {
+                updateConfig(
+                  (config) =>
+                    (config.submitKey = e.target.value as any as SubmitKey),
+                );
+              }}
+            >
+              {Object.values(SubmitKey).map((v) => (
+                <option value={v} key={v}>
+                  {v}
+                </option>
+              ))}
+            </Select>
+          </ListItem>
+
+          <ListItem title={Locale.Settings.Theme}>
+            <Select
+              aria-label={Locale.Settings.Theme}
+              value={config.theme}
+              onChange={(e) => {
+                updateConfig(
+                  (config) => (config.theme = e.target.value as any as Theme),
+                );
+              }}
+            >
+              {Object.values(Theme).map((v) => (
+                <option value={v} key={v}>
+                  {v}
+                </option>
+              ))}
+            </Select>
+          </ListItem>
+
+          <ListItem title={Locale.Settings.Lang.Name}>
+            <Select
+              aria-label={Locale.Settings.Lang.Name}
+              value={getLang()}
+              onChange={(e) => {
+                changeLang(e.target.value as any);
+              }}
+            >
+              {AllLangs.map((lang) => (
+                <option value={lang} key={lang}>
+                  {ALL_LANG_OPTIONS[lang]}
+                </option>
+              ))}
+            </Select>
+          </ListItem>
+
+          <ListItem
+            title={Locale.Settings.FontSize.Title}
+            subTitle={Locale.Settings.FontSize.SubTitle}
+          >
+            <InputRange
+              aria={Locale.Settings.FontSize.Title}
+              title={`${config.fontSize ?? 14}px`}
+              value={config.fontSize}
+              min="12"
+              max="40"
+              step="1"
+              onChange={(e) =>
+                updateConfig(
+                  (config) =>
+                    (config.fontSize = Number.parseInt(e.currentTarget.value)),
+                )
+              }
+            ></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) =>
+                updateConfig(
+                  (config) =>
+                    (config.enableAutoGenerateTitle = e.currentTarget.checked),
+                )
+              }
+            ></input>
+          </ListItem>
+
+          <ListItem
+            title={Locale.Settings.SendPreviewBubble.Title}
+            subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
+          >
+            <input
+              aria-label={Locale.Settings.SendPreviewBubble.Title}
+              type="checkbox"
+              checked={config.sendPreviewBubble}
+              onChange={(e) =>
+                updateConfig(
+                  (config) =>
+                    (config.sendPreviewBubble = e.currentTarget.checked),
+                )
+              }
+            ></input>
+          </ListItem>
+        </List>
+
+        <SyncItems />
+
+        <List>
+          <ListItem
+            title={Locale.Settings.Mask.Splash.Title}
+            subTitle={Locale.Settings.Mask.Splash.SubTitle}
+          >
+            <input
+              aria-label={Locale.Settings.Mask.Splash.Title}
+              type="checkbox"
+              checked={!config.dontShowMaskSplashScreen}
+              onChange={(e) =>
+                updateConfig(
+                  (config) =>
+                    (config.dontShowMaskSplashScreen =
+                      !e.currentTarget.checked),
+                )
+              }
+            ></input>
+          </ListItem>
+
+          <ListItem
+            title={Locale.Settings.Mask.Builtin.Title}
+            subTitle={Locale.Settings.Mask.Builtin.SubTitle}
+          >
+            <input
+              aria-label={Locale.Settings.Mask.Builtin.Title}
+              type="checkbox"
+              checked={config.hideBuiltinMasks}
+              onChange={(e) =>
+                updateConfig(
+                  (config) =>
+                    (config.hideBuiltinMasks = e.currentTarget.checked),
+                )
+              }
+            ></input>
+          </ListItem>
+        </List>
+
+        <List>
+          <ListItem
+            title={Locale.Settings.Prompt.Disable.Title}
+            subTitle={Locale.Settings.Prompt.Disable.SubTitle}
+          >
+            <input
+              aria-label={Locale.Settings.Prompt.Disable.Title}
+              type="checkbox"
+              checked={config.disablePromptHint}
+              onChange={(e) =>
+                updateConfig(
+                  (config) =>
+                    (config.disablePromptHint = e.currentTarget.checked),
+                )
+              }
+            ></input>
+          </ListItem>
+
+          <ListItem
+            title={Locale.Settings.Prompt.List}
+            subTitle={Locale.Settings.Prompt.ListCount(
+              builtinCount,
+              customCount,
+            )}
+          >
+            <IconButton
+              aria={Locale.Settings.Prompt.List + Locale.Settings.Prompt.Edit}
+              icon={<EditIcon />}
+              text={Locale.Settings.Prompt.Edit}
+              onClick={() => setShowPromptModal(true)}
+            />
+          </ListItem>
+        </List>
+
+        <List id={SlotID.CustomModel}>
+          {accessCodeComponent}
+
+          {!accessStore.hideUserApiKey && (
+            <>
+              {useCustomConfigComponent}
+
+              {accessStore.useCustomConfig && (
+                <>
+                  <ListItem
+                    title={Locale.Settings.Access.Provider.Title}
+                    subTitle={Locale.Settings.Access.Provider.SubTitle}
+                  >
+                    <Select
+                      aria-label={Locale.Settings.Access.Provider.Title}
+                      value={accessStore.provider}
+                      onChange={(e) => {
+                        accessStore.update(
+                          (access) =>
+                            (access.provider = e.target
+                              .value as ServiceProvider),
+                        );
+                      }}
+                    >
+                      {Object.entries(ServiceProvider).map(([k, v]) => (
+                        <option value={v} key={k}>
+                          {k}
+                        </option>
+                      ))}
+                    </Select>
+                  </ListItem>
+
+                  {openAIConfigComponent}
+                  {azureConfigComponent}
+                  {baiduConfigComponent}
+                  {byteDanceConfigComponent}
+                  {alibabaConfigComponent}
+                  {tencentConfigComponent}
+                  {lflytekConfigComponent}
+                </>
+              )}
+            </>
+          )}
+
+
+
+          <ListItem
+            title={Locale.Settings.Access.CustomModel.Title}
+            subTitle={Locale.Settings.Access.CustomModel.SubTitle}
+          >
+            <input
+              aria-label={Locale.Settings.Access.CustomModel.Title}
+              type="text"
+              value={config.customModels}
+              placeholder="model1,model2,model3"
+              onChange={(e) =>
+                config.update(
+                  (config) => (config.customModels = e.currentTarget.value),
+                )
+              }
+            ></input>
+          </ListItem>
+        </List>
+
+        <List>
+          <ModelConfigList
+            modelConfig={config.modelConfig}
+            updateConfig={(updater) => {
+              const modelConfig = { ...config.modelConfig };
+              updater(modelConfig);
+              config.update((config) => (config.modelConfig = modelConfig));
+            }}
+          />
+        </List>
+
+        {shouldShowPromptModal && (
+          <UserPromptModal onClose={() => setShowPromptModal(false)} />
+        )}
+
+        <DangerItems />
+      </div>
+    </ErrorBoundary>
+  );
+}

+ 764 - 0
app/components/sidebar.tsx

@@ -0,0 +1,764 @@
+import React, { useEffect, useRef, useMemo, useState, Fragment } from "react";
+import styles from "./home.module.scss";
+import DragIcon from "../icons/drag.svg";
+import faviconSrc from "../icons/favicon.png";
+import deepSeekSrc from "../icons/deepSeek.png";
+import { AppstoreOutlined, EditOutlined, MenuOutlined } from '@ant-design/icons';
+import { useAppConfig, useChatStore, useGlobalStore } from "../store";
+import {
+  DEFAULT_SIDEBAR_WIDTH,
+  MAX_SIDEBAR_WIDTH,
+  MIN_SIDEBAR_WIDTH,
+  NARROW_SIDEBAR_WIDTH,
+} from "../constant";
+import { useLocation, useNavigate } from "react-router-dom";
+import { isIOS, useMobileScreen } from "../utils";
+import api from "@/app/api/api";
+import { Button, Drawer, Dropdown, Empty, Form, Input, Menu, message, Modal, Rate, Tag } from "antd";
+import { downloadFile } from "../utils/index";
+import dayjs from "dayjs";
+
+const FormItem = Form.Item;
+
+export function useHotKey() {
+  const chatStore = useChatStore();
+
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.altKey || e.ctrlKey) {
+        if (e.key === "ArrowUp") {
+          chatStore.nextSession(-1);
+        } else if (e.key === "ArrowDown") {
+          chatStore.nextSession(1);
+        }
+      }
+    };
+
+    window.addEventListener("keydown", onKeyDown);
+    return () => window.removeEventListener("keydown", onKeyDown);
+  });
+}
+
+export function useDragSideBar() {
+  const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
+
+  const config = useAppConfig();
+  const startX = useRef(0);
+  const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
+  const lastUpdateTime = useRef(Date.now());
+
+  const toggleSideBar = () => {
+    config.update((config) => {
+      if (config.sidebarWidth < MIN_SIDEBAR_WIDTH) {
+        config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
+      } else {
+        config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
+      }
+    });
+  };
+
+  const onDragStart = (e: MouseEvent) => {
+    // Remembers the initial width each time the mouse is pressed
+    startX.current = e.clientX;
+    startDragWidth.current = config.sidebarWidth;
+    const dragStartTime = Date.now();
+
+    const handleDragMove = (e: MouseEvent) => {
+      if (Date.now() < lastUpdateTime.current + 20) {
+        return;
+      }
+      lastUpdateTime.current = Date.now();
+      const d = e.clientX - startX.current;
+      const nextWidth = limit(startDragWidth.current + d);
+      config.update((config) => {
+        if (nextWidth < MIN_SIDEBAR_WIDTH) {
+          config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
+        } else {
+          config.sidebarWidth = nextWidth;
+        }
+      });
+    };
+
+    const handleDragEnd = () => {
+      // In useRef the data is non-responsive, so `config.sidebarWidth` can't get the dynamic sidebarWidth
+      window.removeEventListener("pointermove", handleDragMove);
+      window.removeEventListener("pointerup", handleDragEnd);
+
+      // if user click the drag icon, should toggle the sidebar
+      const shouldFireClick = Date.now() - dragStartTime < 300;
+      if (shouldFireClick) {
+        toggleSideBar();
+      }
+    };
+
+    window.addEventListener("pointermove", handleDragMove);
+    window.addEventListener("pointerup", handleDragEnd);
+  };
+
+  const isMobileScreen = useMobileScreen();
+  const shouldNarrow =
+    !isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
+
+  useEffect(() => {
+    const barWidth = shouldNarrow
+      ? NARROW_SIDEBAR_WIDTH
+      : limit(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
+    const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
+    document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
+  }, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
+
+  return {
+    onDragStart,
+    shouldNarrow,
+  };
+}
+export function SideBarContainer(props: {
+  children: React.ReactNode;
+  onDragStart: (e: MouseEvent) => void;
+  shouldNarrow: boolean;
+  className?: string;
+}) {
+  const isMobileScreen = useMobileScreen();
+  const isIOSMobile = useMemo(
+    () => isIOS() && isMobileScreen,
+    [isMobileScreen],
+  );
+  const { children, className, onDragStart, shouldNarrow } = props;
+  return (
+    <div
+      className={`${styles.sidebar} ${className} ${shouldNarrow && styles["narrow-sidebar"]
+        }`}
+      style={{
+        transition: isMobileScreen && isIOSMobile ? "none" : undefined,
+        background: '#FFFFFF',
+        overflowY: "auto",
+      }}
+    >
+      {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-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>
+  );
+}
+
+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>
+  );
+}
+
+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>
+  );
+}
+
+interface AppDrawerProps {
+  isMobileScreen: boolean,
+  selectedAppId: string,
+  type: 'all' | 'collect',
+  open: boolean,
+  onClose: () => void,
+}
+
+const AppDrawer: React.FC<AppDrawerProps> = (props) => {
+  const {
+    isMobileScreen,
+    selectedAppId,
+    type,
+    open,
+    onClose,
+  } = props;
+
+  const navigate = useNavigate();
+
+  const [listLoading, setListLoading] = useState(false);
+
+  type List = {
+    name: string,
+    chatMode: string,
+    appId: string,
+    desc: string,
+    createTime: string,
+    typeName: string,
+    isCollect: boolean,
+  }[];
+
+  const [list, setList] = useState<List>([]);
+
+  const fetchAppList = async () => {
+    setListLoading(true);
+    try {
+      const res = await api.get(`/deepseek/api/project/app`);
+      // 确保 res.data 是数组,如果不是则设为空数组
+      const data = Array.isArray(res.data) ? res.data : [];
+      
+      if (type === 'all') {
+        setList(data);
+      } else {
+        setList(data.filter((item: any) => item.isCollect));
+      }
+    } catch (error) {
+      console.error(error);
+      // 出错时设置为空数组,避免渲染错误
+      setList([]);
+    } finally {
+      setListLoading(false);
+    }
+  };
+
+  // 收藏应用
+  const collectApp = async (appId: string) => {
+    try {
+      await api.post('/deepseek/api/app/collect', {
+        appId: appId
+      });
+      message.success('收藏成功');
+      await fetchAppList();
+    } catch (error: any) {
+      message.error(error.msg);
+    }
+  };
+
+  // 取消收藏应用
+  const cancelCollectApp = async (appId: string) => {
+    try {
+      await api.delete(`/deepseek/api/app/collect/${appId}`);
+      message.success('操作成功');
+      await fetchAppList();
+    } catch (error: any) {
+      message.error(error.msg);
+    }
+  };
+
+  const init = async () => {
+    await fetchAppList();
+  }
+
+  useEffect(() => {
+    init();
+  }, [])
+
+  return (
+    <Drawer
+      width={isMobileScreen ? '100%' : 400}
+      title={type === 'all' ? '我的应用' : '我的收藏'}
+      open={open}
+      loading={listLoading}
+      onClose={onClose}
+    >
+      {
+        Array.isArray(list) && list.length > 0 ?
+          list.map((item, index) => {
+            return <div
+              style={{
+                padding: 20,
+                border: '1px solid #f0f0f0',
+                borderRadius: 4,
+                marginBottom: 20,
+                cursor: 'pointer',
+              }}
+              key={index}
+            >
+              <div style={{ display: 'flex', marginBottom: 10 }}>
+                <AppstoreOutlined style={{ fontSize: 40, color: '#3875f6', marginRight: 20 }} />
+                <div>
+                  <div style={{ fontSize: 16, fontWeight: 'bold', marginBottom: 5 }}>
+                    {item.name}
+                  </div>
+                  <div style={{ color: '#d4d7de' }}>
+                    ID:{item.appId}
+                  </div>
+                </div>
+              </div>
+              <div style={{ color: '#8f949e', marginBottom: 10 }}>
+                {item.desc}
+              </div>
+              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
+                <div style={{ color: '#747a86' }}>
+                  {dayjs(item.createTime).format('YYYY-MM-DD')} 发布
+                </div>
+                <div>
+                  <Tag style={{ margin: 0 }} color="blue">
+                    {item.typeName}
+                  </Tag>
+                </div>
+              </div>
+              <div
+                style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+                <div onClick={async () => {
+                  if (item.isCollect) {
+                    await cancelCollectApp(item.appId);
+                  } else {
+                    await collectApp(item.appId);
+                  }
+                }}>
+                  {
+                    item.isCollect ?
+                      <Rate count={1} value={1} />
+                      :
+                      <Rate count={1} />
+                  }
+                </div>
+                <Button type='primary' size="small" onClick={() => {
+                  const search = `?showMenu=false&chatMode=${item.chatMode}&appId=${item.appId}`;
+                  navigate({
+                    pathname: '/knowledgeChat',
+                    search: search,
+                  })
+                  location.reload();
+                }}>
+                  使用
+                </Button>
+              </div>
+            </div>
+          })
+          :
+          <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
+      }
+    </Drawer>
+  )
+}
+
+export const SideBar = (props: { className?: string }) => {
+  // useHotKey();
+  const { onDragStart, shouldNarrow } = useDragSideBar();
+  const [showPluginSelector, setShowPluginSelector] = useState(false);
+  const navigate = useNavigate();
+  const location = useLocation();
+  const chatStore = useChatStore();
+  const globalStore = useGlobalStore();
+
+  const [menuList, setMenuList] = useState([])
+  const [modalOpen, setModalOpen] = useState(false)
+  const [form] = Form.useForm();
+  const getType = (): 'bigModel' | 'deepSeek' => {
+    if (['/knowledgeChat', '/newChat'].includes(location.pathname)) {
+      return 'bigModel';
+    } else if (['/deepseekChat', '/newDeepseekChat'].includes(location.pathname)) {
+      return 'deepSeek';
+    } else {
+      return 'bigModel';
+    }
+  }
+
+  // 获取聊天列表
+  const fetchChatList = async (chatMode?: 'ONLINE' | 'LOCAL') => {
+    try {
+      let url = '';
+      if (getType() === 'bigModel') {
+        const appId = globalStore.selectedAppId;
+        if (appId) {
+          if (chatMode === 'LOCAL') {
+            url = `/deepseek/api/dialog/list/${appId}`;
+          } else {
+            url = `/bigmodel/api/dialog/list/${appId}`;
+          }
+        }
+      } else {
+        const appId = '1881269958412521255';
+        url = `/bigmodel/api/dialog/list/${appId}`;
+      }
+      const res = await api.get(url);
+      const list = res.data.map((item: any) => {
+        return {
+          ...item,
+          children: item.children.map((child: any) => {
+            const items = [
+              {
+                key: '1',
+                label: (
+                  <a onClick={() => {
+                    setModalOpen(true);
+                    form.setFieldsValue({
+                      dialogId: child.key,
+                      dialogName: child.label
+                    });
+                  }}>
+                    重命名
+                  </a>
+                ),
+              },
+              {
+                key: '2',
+                label: (
+                  <a onClick={async () => {
+                    try {
+                      let blob = null;
+                      if (getType() === 'bigModel') {
+                        if (chatMode === 'LOCAL') {
+                          blob = await api.post(`/deepseek/api/dialog/export/${child.key}`, {}, { responseType: 'blob' });
+                        } else {
+                          blob = await api.post(`/bigmodel/api/dialog/export/${child.key}`, {}, { responseType: 'blob' });
+                        }
+                      } else {
+                        blob = await api.post(`/bigmodel/api/dialog/export/${child.key}`, {}, { responseType: 'blob' });
+                      }
+                      const fileName = `${child.label}.xlsx`;
+                      downloadFile(blob, fileName);
+                    } catch (error) {
+                      console.error(error);
+                    }
+                  }}>
+                    导出
+                  </a>
+                ),
+              },
+              {
+                key: '3',
+                label: (
+                  <a onClick={async () => {
+                    try {
+                      if (getType() === 'bigModel') {
+                        if (chatMode === 'LOCAL') {
+                          await api.delete(`/deepseek/api/dialog/del/${child.key}`);
+                          await fetchChatList(chatMode);
+                        } else {
+                          await api.delete(`/bigmodel/api/dialog/del/${child.key}`);
+                          await fetchChatList();
+                        }
+                      } else {
+                        await api.delete(`/bigmodel/api/dialog/del/${child.key}`);
+                        await fetchChatList();
+                      }
+                      chatStore.clearSessions();
+                      useChatStore.setState({
+                        message: {
+                          content: '',
+                          role: 'assistant',
+                        }
+                      });
+                    } catch (error) {
+                      console.error(error);
+                    }
+                  }}>
+                    删除
+                  </a>
+                ),
+              },
+            ];
+            return {
+              ...child,
+              label: <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+                <div style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginRight: 10 }}>
+                  {child.label}
+                </div>
+                <div style={{ width: 20 }}>
+                  <Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
+                    <EditOutlined onClick={(e) => e.stopPropagation()} />
+                  </Dropdown>
+                </div>
+              </div>
+            }
+          })
+        }
+      })
+      setMenuList(list);
+    } catch (error) {
+      console.error(error)
+    }
+  }
+
+  useEffect(() => {
+    if (getType() === 'bigModel') {
+      if (globalStore.selectedAppId) {
+        fetchChatList(chatStore.chatMode);
+      }
+    }
+  }, [globalStore.selectedAppId]);
+
+  useEffect(() => {
+    chatStore.clearSessions();
+    useChatStore.setState({
+      message: {
+        content: '',
+        role: 'assistant',
+      }
+    });
+  }, []);
+
+  useEffect(() => {
+    fetchChatList(chatStore.chatMode);
+  }, [chatStore.chatMode]);
+
+  const isMobileScreen = useMobileScreen();
+
+  const [drawerOpen, setDrawerOpen] = useState(false);
+
+  const [drawerType, setDrawerType] = useState<'all' | 'collect'>('all');
+
+  return (
+    <>
+      {
+        globalStore.showMenu &&
+        <SideBarContainer
+          onDragStart={onDragStart}
+          shouldNarrow={shouldNarrow}
+          {...props}
+        >
+          {
+            getType() === 'deepSeek' &&
+            <div>
+              <img style={{ width: '100%' }} src={deepSeekSrc.src} />
+            </div>
+          }
+          <SideBarHeader
+            title={getType() === 'bigModel' ?
+              <div style={{ display: 'flex', alignItems: 'center' }}>
+                {
+                  isMobileScreen && <div>
+                    <Button
+                      type='text'
+                      icon={<MenuOutlined />}
+                      onClick={() => {
+                        globalStore.setShowMenu(!globalStore.showMenu);
+                      }}
+                    />
+                  </div>
+                }
+                问答历史
+              </div>
+              :
+              ''
+            }
+            logo={getType() === 'bigModel' ? <img style={{ height: 40 }} src={faviconSrc.src} /> : ''}
+          >
+            <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
+              <Button
+                style={{ width: '48%' }}
+                onClick={() => {
+                  navigate({ pathname: '/' });
+                }}
+              >
+                回到首页
+              </Button>
+              <Button
+                style={{ width: '48%' }}
+                onClick={async () => {
+                  chatStore.clearSessions();
+                  chatStore.updateCurrentSession((value) => {
+                    value.appId = globalStore.selectedAppId;
+                  });
+                  if (isMobileScreen) {
+                    globalStore.setShowMenu(false);
+                  }
+                  if (getType() === 'bigModel') {
+                    navigate({ pathname: '/newChat' });
+                  } else {
+                    navigate({ pathname: '/newDeepseekChat' });
+                  }
+                  if (getType() === 'bigModel') {
+                    if (chatStore.chatMode === 'LOCAL') {
+                      await fetchChatList(chatStore.chatMode);
+                    } else {
+                      await fetchChatList();
+                    }
+                  } else {
+                    await fetchChatList();
+                  }
+                }}
+              >
+                新建对话
+              </Button>
+            </div>
+            <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
+              <Button
+                style={{ width: '48%' }}
+                type="primary"
+                onClick={() => {
+                  setDrawerType('all');
+                  setDrawerOpen(true);
+                }}
+              >
+                我的应用
+              </Button>
+              <Button
+                style={{ width: '48%' }}
+                type="primary"
+                onClick={() => {
+                  setDrawerType('collect');
+                  setDrawerOpen(true);
+                }}
+              >
+                我的收藏
+              </Button>
+            </div>
+          </SideBarHeader>
+          <Menu
+            style={{ border: 'none' }}
+            onClick={async (info) => {
+              const key = info.key;
+              // @ts-ignore
+              const props = info.item.props;
+              const { showMenu, chatMode, appId } = props;
+              if (isMobileScreen) {
+                globalStore.setShowMenu(false);
+              }
+              let url = ``;
+              if (getType() === 'bigModel') {
+                if (chatStore.chatMode === 'LOCAL') {
+                  url = `/deepseek/api/dialog/detail/${key}`;
+                } else {
+                  url = `/bigmodel/api/dialog/detail/${key}`;
+                }
+              } else {
+                url = `/bigmodel/api/dialog/detail/${key}`;
+              }
+              const res = await api.get(url);
+              const list = res.data.map(((item: any) => {
+                return {
+                  id: item.did,
+                  role: item.type,
+                  date: item.create_time,
+                  content: item.content,
+                  document: item.document ? item.document : undefined,
+                  sliceInfo: item.sliceInfo ? item.sliceInfo : undefined,
+                  networkInfo: item.networkInfo ? item.networkInfo : undefined,
+                }
+              }))
+              const session = {
+                appId: res.data.length ? res.data[0].appId : '',
+                dialogName: res.data.length ? res.data[0].dialog_name : '',
+                id: res.data.length ? res.data[0].id : '',
+                messages: list,
+              }
+              globalStore.setCurrentSession(session);
+              chatStore.clearSessions();
+              chatStore.updateCurrentSession((value) => {
+                value.appId = session.appId;
+                value.topic = session.dialogName;
+                value.id = session.id;
+                value.messages = list;
+              });
+              if (getType() === 'bigModel') {
+                const search = `?showMenu=${showMenu}&chatMode=${chatMode}&appId=${appId}`;
+                if (appId) {
+                  navigate({
+                    pathname: '/knowledgeChat',
+                    search: search,
+                  })
+                }
+              } else {
+                navigate({ pathname: '/newDeepseekChat' });
+              }
+            }}
+            mode="inline"
+            items={menuList}
+          />
+          <Modal
+            title="重命名"
+            open={modalOpen}
+            width={300}
+            maskClosable={false}
+            onOk={() => {
+              form.validateFields().then(async (values) => {
+                setModalOpen(false);
+                try {
+                  if (getType() === 'bigModel') {
+                    if (chatStore.chatMode === 'LOCAL') {
+                      await api.put(`/deepseek/api/dialog/update`, {
+                        id: values.dialogId,
+                        dialogName: values.dialogName
+                      });
+                      await fetchChatList(chatStore.chatMode);
+                    } else {
+                      await api.put(`/bigmodel/api/dialog/update`, {
+                        id: values.dialogId,
+                        dialogName: values.dialogName
+                      });
+                      await fetchChatList();
+                    }
+                  } else {
+                    await api.put(`/bigmodel/api/dialog/update`, {
+                      id: values.dialogId,
+                      dialogName: values.dialogName
+                    });
+                    await fetchChatList();
+                  }
+                  chatStore.updateCurrentSession((value) => {
+                    value.topic = values.dialogName;
+                  });
+                } catch (error) {
+                  console.error(error);
+                }
+              }).catch((error) => {
+                console.error(error);
+              });
+            }}
+            onCancel={() => {
+              setModalOpen(false);
+            }}
+          >
+            <Form form={form} layout='inline'>
+              <FormItem name='dialogId' noStyle />
+              <FormItem
+                label='名称'
+                name='dialogName'
+                rules={[{ required: true, message: '名称不能为空', whitespace: true }]}
+              >
+                <Input
+                  style={{ width: 300 }}
+                  placeholder='请输入'
+                  maxLength={20}
+                />
+              </FormItem>
+            </Form>
+          </Modal>
+        </SideBarContainer>
+      }
+      {
+        drawerOpen &&
+        <AppDrawer
+          isMobileScreen={isMobileScreen}
+          selectedAppId={globalStore.selectedAppId}
+          type={drawerType}
+          open={drawerOpen}
+          onClose={() => {
+            setDrawerOpen(false);
+          }}
+        />
+      }
+    </>
+  );
+}

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

@@ -0,0 +1,332 @@
+@import "../styles/animation.scss";
+
+.card {
+  background-color: var(--white);
+  border-radius: 10px;
+  box-shadow: var(--card-shadow);
+  padding: 10px;
+}
+
+.popover {
+  position: relative;
+  z-index: 2;
+}
+
+.popover-content {
+  position: absolute;
+  width: 350px;
+  animation: slide-in 0.3s ease;
+  right: 0;
+  top: calc(100% + 10px);
+}
+@media screen and (max-width: 600px) {
+  .popover-content {
+    width: auto;
+  }
+}
+.popover-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100vw;
+  height: 100vh;
+  background-color: rgba(0, 0, 0, 0.3);
+  backdrop-filter: blur(5px);
+}
+
+.list-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  min-height: 40px;
+  border-bottom: var(--border-in-light);
+  padding: 10px 20px;
+  animation: slide-in ease 0.6s;
+
+  .list-header {
+    display: flex;
+    align-items: center;
+
+    .list-icon {
+      margin-right: 10px;
+    }
+
+    .list-item-title {
+      font-size: 14px;
+      font-weight: bolder;
+    }
+
+    .list-item-sub-title {
+      font-size: 12px;
+      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 {
+  border: var(--border-in-light);
+  border-radius: 10px;
+  box-shadow: var(--card-shadow);
+  margin-bottom: 20px;
+  animation: slide-in ease 0.3s;
+  background: var(--white);
+}
+
+.list .list-item:last-child {
+  border: 0;
+}
+
+.modal-container {
+  box-shadow: var(--card-shadow);
+  background-color: var(--white);
+  border-radius: 12px;
+  width: 80vw;
+  max-width: 900px;
+  min-width: 300px;
+  animation: slide-in ease 0.3s;
+
+  --modal-padding: 20px;
+
+  &-max {
+    width: 95vw;
+    max-width: unset;
+    height: 95vh;
+    display: flex;
+    flex-direction: column;
+
+    .modal-content {
+      max-height: unset !important;
+      flex-grow: 1;
+    }
+  }
+
+  .modal-header {
+    padding: var(--modal-padding);
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    border-bottom: var(--border-in-light);
+
+    .modal-title {
+      font-weight: bolder;
+      font-size: 16px;
+    }
+
+    .modal-header-actions {
+      display: flex;
+
+      .modal-header-action {
+        cursor: pointer;
+
+        &:not(:last-child) {
+          margin-right: 20px;
+        }
+
+        &:hover {
+          filter: brightness(1.2);
+        }
+      }
+    }
+  }
+
+  .modal-content {
+    max-height: 40vh;
+    padding: var(--modal-padding);
+    overflow: auto;
+  }
+
+  .modal-footer {
+    padding: var(--modal-padding);
+    display: flex;
+    justify-content: flex-end;
+    border-top: var(--border-in-light);
+    box-shadow: var(--shadow);
+
+    .modal-actions {
+      display: flex;
+      align-items: center;
+
+      .modal-action {
+        &:not(:last-child) {
+          margin-right: 20px;
+        }
+      }
+    }
+  }
+}
+
+@media screen and (max-width: 600px) {
+  .modal-container {
+    width: 100vw;
+    border-bottom-left-radius: 0;
+    border-bottom-right-radius: 0;
+
+    .modal-content {
+      max-height: 50vh;
+    }
+  }
+}
+
+.show {
+  opacity: 1;
+  transition: all ease 0.3s;
+  transform: translateY(0);
+  position: fixed;
+  left: 0;
+  bottom: 0;
+  animation: slide-in ease 0.6s;
+  z-index: 99999;
+}
+
+.hide {
+  opacity: 0;
+  transition: all ease 0.3s;
+  transform: translateY(20px);
+}
+
+.toast-container {
+  position: fixed;
+  bottom: 5vh;
+  left: 0;
+  width: 100vw;
+  display: flex;
+  justify-content: center;
+  pointer-events: none;
+
+  .toast-content {
+    max-width: 80vw;
+    word-break: break-all;
+    font-size: 14px;
+    background-color: var(--white);
+    box-shadow: var(--card-shadow);
+    border: var(--border-in-light);
+    color: var(--black);
+    padding: 10px 20px;
+    border-radius: 50px;
+    margin-bottom: 20px;
+    display: flex;
+    align-items: center;
+    pointer-events: all;
+
+    .toast-action {
+      padding-left: 20px;
+      color: var(--primary);
+      opacity: 0.8;
+      border: 0;
+      background: none;
+      cursor: pointer;
+      font-family: inherit;
+
+      &:hover {
+        opacity: 1;
+      }
+    }
+  }
+}
+
+.input {
+  border: var(--border-in-light);
+  border-radius: 10px;
+  padding: 10px;
+  font-family: inherit;
+  background-color: var(--white);
+  color: var(--black);
+  resize: none;
+  min-width: 50px;
+}
+
+.select-with-icon {
+  position: relative;
+  max-width: fit-content;
+
+  .select-with-icon-select {
+    height: 100%;
+    border: var(--border-in-light);
+    padding: 10px 35px 10px 10px;
+    border-radius: 10px;
+    appearance: none;
+    cursor: pointer;
+    background-color: var(--white);
+    color: var(--black);
+    text-align: center;
+  }
+
+  .select-with-icon-icon {
+    position: absolute;
+    top: 50%;
+    right: 10px;
+    transform: translateY(-50%);
+    pointer-events: none;
+  }
+}
+
+.modal-input {
+  height: 100%;
+  width: 100%;
+  border-radius: 10px;
+  border: var(--border-in-light);
+  box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
+  background-color: var(--white);
+  color: var(--black);
+  font-family: inherit;
+  padding: 10px;
+  resize: none;
+  outline: none;
+  box-sizing: border-box;
+
+  &:focus {
+    border: 1px solid var(--primary);
+  }
+}
+
+.selector {
+  position: fixed;
+  top: 0;
+  left: 0;
+  height: 100vh;
+  width: 100vw;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 999;
+
+  .selector-item-disabled{
+    opacity: 0.6;
+  }
+
+  &-content {
+    min-width: 300px;
+    .list {
+      max-height: 90vh;
+      overflow-x: hidden;
+      overflow-y: auto;
+
+      .list-item {
+        cursor: pointer;
+        background-color: var(--white);
+
+        &:hover {
+          filter: brightness(0.95);
+        }
+
+        &:active {
+          filter: brightness(0.9);
+        }
+      }
+    }
+  }
+}

+ 574 - 0
app/components/ui-lib.tsx

@@ -0,0 +1,574 @@
+/* eslint-disable @next/next/no-img-element */
+import styles from "./ui-lib.module.scss";
+import LoadingIcon from "../icons/three-dots.svg";
+import CloseIcon from "../icons/close.svg";
+import EyeIcon from "../icons/eye.svg";
+import EyeOffIcon from "../icons/eye-off.svg";
+import DownIcon from "../icons/down.svg";
+import ConfirmIcon from "../icons/confirm.svg";
+import CancelIcon from "../icons/cancel.svg";
+import MaxIcon from "../icons/max.svg";
+import MinIcon from "../icons/min.svg";
+
+import Locale from "../locales";
+
+import { createRoot } from "react-dom/client";
+import React, {
+  CSSProperties,
+  HTMLProps,
+  MouseEvent,
+  useEffect,
+  useState,
+  useCallback,
+  useRef,
+} from "react";
+import { IconButton } from "./button";
+
+export function Popover(props: {
+  children: JSX.Element;
+  content: JSX.Element;
+  open?: boolean;
+  onClose?: () => void;
+}) {
+  return (
+    <div className={styles.popover}>
+      {props.children}
+      {props.open && (
+        <div className={styles["popover-mask"]} onClick={props.onClose}></div>
+      )}
+      {props.open && (
+        <div className={styles["popover-content"]}>{props.content}</div>
+      )}
+    </div>
+  );
+}
+
+export function Card(props: { children: JSX.Element[]; className?: string }) {
+  return (
+    <div className={styles.card + " " + props.className}>{props.children}</div>
+  );
+}
+
+export function ListItem(props: {
+  title: string;
+  subTitle?: string;
+  children?: JSX.Element | JSX.Element[];
+  icon?: JSX.Element;
+  className?: string;
+  onClick?: (e: MouseEvent) => void;
+  vertical?: boolean;
+}) {
+  return (
+    <div
+      className={
+        styles["list-item"] +
+        ` ${props.vertical ? styles["vertical"] : ""} ` +
+        ` ${props.className || ""}`
+      }
+      onClick={props.onClick}
+    >
+      <div className={styles["list-header"]}>
+        {props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
+        <div className={styles["list-item-title"]}>
+          <div>{props.title}</div>
+          {props.subTitle && (
+            <div className={styles["list-item-sub-title"]}>
+              {props.subTitle}
+            </div>
+          )}
+        </div>
+      </div>
+      {props.children}
+    </div>
+  );
+}
+
+export function List(props: { children: React.ReactNode; id?: string }) {
+  return (
+    <div className={styles.list} id={props.id}>
+      {props.children}
+    </div>
+  );
+}
+
+export function Loading() {
+  return (
+    <div
+      style={{
+        height: "100vh",
+        width: "100vw",
+        display: "flex",
+        alignItems: "center",
+        justifyContent: "center",
+      }}
+    >
+      <LoadingIcon />
+    </div>
+  );
+}
+
+interface ModalProps {
+  title: string;
+  children?: any;
+  actions?: React.ReactNode[];
+  defaultMax?: boolean;
+  footer?: React.ReactNode;
+  onClose?: () => void;
+}
+export function Modal(props: ModalProps) {
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === "Escape") {
+        props.onClose?.();
+      }
+    };
+
+    window.addEventListener("keydown", onKeyDown);
+
+    return () => {
+      window.removeEventListener("keydown", onKeyDown);
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  const [isMax, setMax] = useState(!!props.defaultMax);
+
+  return (
+    <div
+      className={
+        styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
+      }
+    >
+      <div className={styles["modal-header"]}>
+        <div className={styles["modal-title"]}>{props.title}</div>
+
+        <div className={styles["modal-header-actions"]}>
+          <div
+            className={styles["modal-header-action"]}
+            onClick={() => setMax(!isMax)}
+          >
+            {isMax ? <MinIcon /> : <MaxIcon />}
+          </div>
+          <div
+            className={styles["modal-header-action"]}
+            onClick={props.onClose}
+          >
+            <CloseIcon />
+          </div>
+        </div>
+      </div>
+
+      <div className={styles["modal-content"]}>{props.children}</div>
+
+      <div className={styles["modal-footer"]}>
+        {props.footer}
+        <div className={styles["modal-actions"]}>
+          {props.actions?.map((action, i) => (
+            <div key={i} className={styles["modal-action"]}>
+              {action}
+            </div>
+          ))}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function showModal(props: ModalProps) {
+  const div = document.createElement("div");
+  div.className = "modal-mask";
+  document.body.appendChild(div);
+
+  const root = createRoot(div);
+  const closeModal = () => {
+    props.onClose?.();
+    root.unmount();
+    div.remove();
+  };
+
+  div.onclick = (e) => {
+    if (e.target === div) {
+      closeModal();
+    }
+  };
+
+  root.render(<Modal {...props} onClose={closeModal}></Modal>);
+}
+
+export type ToastProps = {
+  content: string;
+  action?: {
+    text: string;
+    onClick: () => void;
+  };
+  onClose?: () => void;
+};
+
+export function Toast(props: ToastProps) {
+  return (
+    <div className={styles["toast-container"]}>
+      <div className={styles["toast-content"]}>
+        <span>{props.content}</span>
+        {props.action && (
+          <button
+            onClick={() => {
+              props.action?.onClick?.();
+              props.onClose?.();
+            }}
+            className={styles["toast-action"]}
+          >
+            {props.action.text}
+          </button>
+        )}
+      </div>
+    </div>
+  );
+}
+
+export function showToast(
+  content: string,
+  action?: ToastProps["action"],
+  delay = 3000,
+) {
+  const div = document.createElement("div");
+  div.className = styles.show;
+  document.body.appendChild(div);
+
+  const root = createRoot(div);
+  const close = () => {
+    div.classList.add(styles.hide);
+
+    setTimeout(() => {
+      root.unmount();
+      div.remove();
+    }, 300);
+  };
+
+  setTimeout(() => {
+    close();
+  }, delay);
+
+  root.render(<Toast content={content} action={action} onClose={close} />);
+}
+
+export type InputProps = React.HTMLProps<HTMLTextAreaElement> & {
+  autoHeight?: boolean;
+  rows?: number;
+};
+
+export function Input(props: InputProps) {
+  return (
+    <textarea
+      {...props}
+      className={`${styles["input"]} ${props.className}`}
+    ></textarea>
+  );
+}
+
+export function PasswordInput(
+  props: HTMLProps<HTMLInputElement> & { aria?: string },
+) {
+  const [visible, setVisible] = useState(false);
+  function changeVisibility() {
+    setVisible(!visible);
+  }
+
+  return (
+    <div className={"password-input-container"}>
+      <IconButton
+        aria={props.aria}
+        icon={visible ? <EyeIcon /> : <EyeOffIcon />}
+        onClick={changeVisibility}
+        className={"password-eye"}
+      />
+      <input
+        {...props}
+        type={visible ? "text" : "password"}
+        className={"password-input"}
+      />
+    </div>
+  );
+}
+
+export function Select(
+  props: React.DetailedHTMLProps<
+    React.SelectHTMLAttributes<HTMLSelectElement>,
+    HTMLSelectElement
+  >,
+) {
+  const { className, children, ...otherProps } = props;
+  return (
+    <div className={`${styles["select-with-icon"]} ${className}`}>
+      <select className={styles["select-with-icon-select"]} {...otherProps}>
+        {children}
+      </select>
+      <DownIcon className={styles["select-with-icon-icon"]} />
+    </div>
+  );
+}
+
+export function showConfirm(content: any) {
+  const div = document.createElement("div");
+  div.className = "modal-mask";
+  document.body.appendChild(div);
+
+  const root = createRoot(div);
+  const closeModal = () => {
+    root.unmount();
+    div.remove();
+  };
+
+  return new Promise<boolean>((resolve) => {
+    root.render(
+      <Modal
+        title={Locale.UI.Confirm}
+        actions={[
+          <IconButton
+            key="cancel"
+            text={Locale.UI.Cancel}
+            onClick={() => {
+              resolve(false);
+              closeModal();
+            }}
+            icon={<CancelIcon />}
+            tabIndex={0}
+            bordered
+            shadow
+          ></IconButton>,
+          <IconButton
+            key="confirm"
+            text={Locale.UI.Confirm}
+            type="primary"
+            onClick={() => {
+              resolve(true);
+              closeModal();
+            }}
+            icon={<ConfirmIcon />}
+            tabIndex={0}
+            autoFocus
+            bordered
+            shadow
+          ></IconButton>,
+        ]}
+        onClose={closeModal}
+      >
+        {content}
+      </Modal>,
+    );
+  });
+}
+
+function PromptInput(props: {
+  value: string;
+  onChange: (value: string) => void;
+  rows?: number;
+}) {
+  const [input, setInput] = useState(props.value);
+  const onInput = (value: string) => {
+    props.onChange(value);
+    setInput(value);
+  };
+
+  return (
+    <textarea
+      className={styles["modal-input"]}
+      autoFocus
+      value={input}
+      onInput={(e) => onInput(e.currentTarget.value)}
+      rows={props.rows ?? 3}
+    ></textarea>
+  );
+}
+
+export function showPrompt(content: any, value = "", rows = 3) {
+  const div = document.createElement("div");
+  div.className = "modal-mask";
+  document.body.appendChild(div);
+
+  const root = createRoot(div);
+  const closeModal = () => {
+    root.unmount();
+    div.remove();
+  };
+
+  return new Promise<string>((resolve) => {
+    let userInput = value;
+
+    root.render(
+      <Modal
+        title={content}
+        actions={[
+          <IconButton
+            key="cancel"
+            text={Locale.UI.Cancel}
+            onClick={() => {
+              closeModal();
+            }}
+            icon={<CancelIcon />}
+            bordered
+            shadow
+            tabIndex={0}
+          ></IconButton>,
+          <IconButton
+            key="confirm"
+            text={Locale.UI.Confirm}
+            type="primary"
+            onClick={() => {
+              resolve(userInput);
+              closeModal();
+            }}
+            icon={<ConfirmIcon />}
+            bordered
+            shadow
+            tabIndex={0}
+          ></IconButton>,
+        ]}
+        onClose={closeModal}
+      >
+        <PromptInput
+          onChange={(val) => (userInput = val)}
+          value={value}
+          rows={rows}
+        ></PromptInput>
+      </Modal>,
+    );
+  });
+}
+
+export function showImageModal(
+  img: string,
+  defaultMax?: boolean,
+  style?: CSSProperties,
+  boxStyle?: CSSProperties,
+) {
+  showModal({
+    title: Locale.Export.Image.Modal,
+    defaultMax: defaultMax,
+    children: (
+      <div style={{ display: "flex", justifyContent: "center", ...boxStyle }}>
+        <img
+          src={img}
+          alt="preview"
+          style={
+            style ?? {
+              maxWidth: "100%",
+            }
+          }
+        ></img>
+      </div>
+    ),
+  });
+}
+
+export function Selector<T>(props: {
+  items: Array<{
+    title: string;
+    subTitle?: string;
+    value: T;
+    disable?: boolean;
+  }>;
+  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 = selectedValues.includes(item.value);
+            return (
+              <ListItem
+                className={`${styles["selector-item"]} ${
+                  item.disable && styles["selector-item-disabled"]
+                }`}
+                key={i}
+                title={item.title}
+                subTitle={item.subTitle}
+                onClick={(e) => {
+                  if (item.disable) {
+                    e.stopPropagation();
+                  } else {
+                    handleSelection(e, item.value);
+                  }
+                }}
+              >
+                {selected ? (
+                  <div
+                    style={{
+                      height: 10,
+                      width: 10,
+                      backgroundColor: "var(--primary)",
+                      borderRadius: 10,
+                    }}
+                  ></div>
+                ) : (
+                  <></>
+                )}
+              </ListItem>
+            );
+          })}
+        </List>
+      </div>
+    </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>
+  );
+}

+ 46 - 0
app/config/build.ts

@@ -0,0 +1,46 @@
+import tauriConfig from "../../src-tauri/tauri.conf.json";
+import { DEFAULT_INPUT_TEMPLATE } from "../constant";
+
+export const getBuildConfig = () => {
+  if (typeof process === "undefined") {
+    throw Error(
+      "[Server Config] you are importing a nodejs-only module outside of nodejs",
+    );
+  }
+
+  const buildMode = process.env.BUILD_MODE ?? "standalone";
+  const isApp = !!process.env.BUILD_APP;
+  const version = "v" + tauriConfig.package.version;
+
+  const commitInfo = (() => {
+    try {
+      const childProcess = require("child_process");
+      const commitDate: string = childProcess
+        .execSync('git log -1 --format="%at000" --date=unix')
+        .toString()
+        .trim();
+      const commitHash: string = childProcess
+        .execSync('git log --pretty=format:"%H" -n 1')
+        .toString()
+        .trim();
+
+      return { commitDate, commitHash };
+    } catch (e) {
+      console.error("[Build Config] No git or not from git repo.");
+      return {
+        commitDate: "unknown",
+        commitHash: "unknown",
+      };
+    }
+  })();
+
+  return {
+    version,
+    ...commitInfo,
+    buildMode,
+    isApp,
+    template: process.env.DEFAULT_INPUT_TEMPLATE ?? DEFAULT_INPUT_TEMPLATE,
+  };
+};
+
+export type BuildConfig = ReturnType<typeof getBuildConfig>;

+ 27 - 0
app/config/client.ts

@@ -0,0 +1,27 @@
+import { BuildConfig, getBuildConfig } from "./build";
+
+export function getClientConfig() {
+  if (typeof document !== "undefined") {
+    // client side
+    return JSON.parse(queryMeta("config") || "{}") as BuildConfig;
+  }
+
+  if (typeof process !== "undefined") {
+    // server side
+    return getBuildConfig();
+  }
+}
+
+function queryMeta(key: string, defaultValue?: string): string {
+  let ret: string;
+  if (document) {
+    const meta = document.head.querySelector(
+      `meta[name='${key}']`,
+    ) as HTMLMetaElement;
+    ret = meta?.content ?? "";
+  } else {
+    ret = defaultValue ?? "";
+  }
+
+  return ret;
+}

+ 230 - 0
app/config/server.ts

@@ -0,0 +1,230 @@
+import md5 from "spark-md5";
+import { DEFAULT_MODELS } from "../constant";
+
+declare global {
+  namespace NodeJS {
+    interface ProcessEnv {
+      PROXY_URL?: string; // docker only
+
+      OPENAI_API_KEY?: string;
+      CODE?: string;
+
+      BASE_URL?: string;
+      OPENAI_ORG_ID?: string; // openai only
+
+      VERCEL?: string;
+      BUILD_MODE?: "standalone" | "export";
+      BUILD_APP?: string; // is building desktop app
+
+      HIDE_USER_API_KEY?: string; // disable user's api key input
+      DISABLE_GPT4?: string; // allow user to use gpt-4 or not
+      ENABLE_BALANCE_QUERY?: string; // allow user to query balance or not
+      DISABLE_FAST_LINK?: string; // disallow parse settings from url or not
+      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;
+      AZURE_API_VERSION?: string;
+
+      // google only
+      GOOGLE_API_KEY?: string;
+      GOOGLE_URL?: string;
+
+      // google tag manager
+      GTM_ID?: string;
+
+      // anthropic only
+      ANTHROPIC_URL?: string;
+      ANTHROPIC_API_KEY?: string;
+      ANTHROPIC_API_VERSION?: string;
+
+      // baidu only
+      BAIDU_URL?: string;
+      BAIDU_API_KEY?: string;
+      BAIDU_SECRET_KEY?: string;
+
+      // bytedance only
+      BYTEDANCE_URL?: string;
+      BYTEDANCE_API_KEY?: string;
+
+      // alibaba only
+      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;
+    }
+  }
+}
+
+const ACCESS_CODES = (function getAccessCodes(): Set<string> {
+  const code = process.env.CODE;
+
+  try {
+    const codes = (code?.split(",") ?? [])
+      .filter((v) => !!v)
+      .map((v) => md5.hash(v.trim()));
+    return new Set(codes);
+  } catch (e) {
+    return new Set();
+  }
+})();
+
+function getApiKey(keys?: string) {
+  const apiKeyEnvVar = keys ?? "";
+  const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
+  const randomIndex = Math.floor(Math.random() * apiKeys.length);
+  const apiKey = apiKeys[randomIndex];
+  if (apiKey) {
+    console.log(
+      `[Server Config] using ${randomIndex + 1} of ${
+        apiKeys.length
+      } api key - ${apiKey}`,
+    );
+  }
+
+  return apiKey;
+}
+
+export const getServerSideConfig = () => {
+  if (typeof process === "undefined") {
+    throw Error(
+      "[Server Config] you are importing a nodejs-only module outside of nodejs",
+    );
+  }
+
+  const disableGPT4 = !!process.env.DISABLE_GPT4;
+  let customModels = process.env.CUSTOM_MODELS ?? "";
+  let defaultModel = process.env.DEFAULT_MODEL ?? "";
+
+  if (disableGPT4) {
+    if (customModels) customModels += ",";
+    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.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);
+  // const apiKey = apiKeys[randomIndex];
+  // console.log(
+  //   `[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`,
+  // );
+
+  const allowedWebDevEndpoints = (
+    process.env.WHITE_WEBDEV_ENDPOINTS ?? ""
+  ).split(",");
+
+  return {
+    baseUrl: process.env.BASE_URL,
+    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),
+    azureApiVersion: process.env.AZURE_API_VERSION,
+
+    isGoogle,
+    googleApiKey: getApiKey(process.env.GOOGLE_API_KEY),
+    googleUrl: process.env.GOOGLE_URL,
+
+    isAnthropic,
+    anthropicApiKey: getApiKey(process.env.ANTHROPIC_API_KEY),
+    anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
+    anthropicUrl: process.env.ANTHROPIC_URL,
+
+    isBaidu,
+    baiduUrl: process.env.BAIDU_URL,
+    baiduApiKey: getApiKey(process.env.BAIDU_API_KEY),
+    baiduSecretKey: process.env.BAIDU_SECRET_KEY,
+
+    isBytedance,
+    bytedanceApiKey: getApiKey(process.env.BYTEDANCE_API_KEY),
+    bytedanceUrl: process.env.BYTEDANCE_URL,
+
+    isAlibaba,
+    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,
+
+    needCode: ACCESS_CODES.size > 0,
+    code: process.env.CODE,
+    codes: ACCESS_CODES,
+
+    proxyUrl: process.env.PROXY_URL,
+    isVercel: !!process.env.VERCEL,
+
+    hideUserApiKey: !!process.env.HIDE_USER_API_KEY,
+    disableGPT4,
+    hideBalanceQuery: !process.env.ENABLE_BALANCE_QUERY,
+    disableFastLink: !!process.env.DISABLE_FAST_LINK,
+    customModels,
+    defaultModel,
+    allowedWebDevEndpoints,
+  };
+};

+ 399 - 0
app/constant.ts

@@ -0,0 +1,399 @@
+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`;
+export const UPDATE_URL = `${REPO_URL}#keep-updated`;
+export const RELEASE_URL = `${REPO_URL}/releases`;
+export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`;
+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 BAIDU_BASE_URL = "https://aip.baidubce.com";
+export const BAIDU_OATUH_URL = `${BAIDU_BASE_URL}/oauth/2.0/token`;
+
+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 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`;
+
+export enum Path {
+  Home = "/",
+  Chat = "/chat",
+  Settings = "/settings",
+  MaskChat = "/mask-chat",
+  Masks = "/masks",
+  Auth = "/auth",
+  Artifacts = "/artifacts",
+}
+
+export enum ApiPath {
+  Cors = "",
+  Azure = "/api/azure",
+  OpenAI = "/api/openai",
+  Baidu = "/api/baidu",
+  ByteDance = "/api/bytedance",
+  Alibaba = "/api/alibaba",
+  Tencent = "/api/tencent",
+  Iflytek = "/api/iflytek",
+  Artifacts = "/api/artifacts",
+  BigModel = "/api/bigModel",
+}
+
+export enum SlotID {
+  AppBody = "app-body",
+  CustomModel = "custom-model",
+}
+
+export enum FileName {
+  Masks = "masks.json",
+  Prompts = "prompts.json",
+}
+
+export enum Plugin {
+  Artifacts = "artifacts",
+}
+
+export enum Plugin {
+  BigModel = "bigModel",
+}
+
+export enum StoreKey {
+  Chat = "chat-next-web-store",
+  Access = "access-control",
+  Config = "app-config",
+  Mask = "mask-store",
+  Prompt = "prompt-store",
+  Update = "chat-update",
+  Sync = "sync",
+}
+
+export const DEFAULT_SIDEBAR_WIDTH = 300;
+export const MAX_SIDEBAR_WIDTH = 500;
+export const MIN_SIDEBAR_WIDTH = 200;
+export const NARROW_SIDEBAR_WIDTH = 200;
+
+export const ACCESS_CODE_PREFIX = "nk-";
+
+export const LAST_INPUT_KEY = "last-input";
+export const UNFINISHED_INPUT = (id: string) => "unfinished-input-" + id;
+
+export const STORAGE_KEY = "chatgpt-next-web";
+
+export const REQUEST_TIMEOUT_MS = 60000;
+
+export const EXPORT_MESSAGE_CLASS_NAME = "export-markdown";
+
+export enum ServiceProvider {
+  BigModel = "BigModel",
+  DeepSeek = "DeepSeek",
+  OpenAI = "OpenAI",
+  Azure = "Azure",
+  Baidu = "Baidu",
+  ByteDance = "ByteDance",
+  Alibaba = "Alibaba",
+  Tencent = "Tencent",
+  Iflytek = "Iflytek",
+}
+
+
+
+export enum ModelProvider {
+  BigModel = "bigModel",
+  DeepSeek = "deepSeek",
+  GPT = "GPT",
+  Ernie = "Ernie",
+  Doubao = "Doubao",
+  Qwen = "Qwen",
+  Hunyuan = "Hunyuan",
+  Iflytek = "Iflytek",
+}
+
+
+
+
+
+export const OpenaiPath = {
+  ChatPath: "v1/chat/completions",
+  ImagePath: "v1/images/generations",
+  UsagePath: "dashboard/billing/usage",
+  SubsPath: "dashboard/billing/subscription",
+  ListModelPath: "v1/models",
+};
+
+export const Azure = {
+  ChatPath: (deployName: string, apiVersion: string) =>
+    `deployments/${deployName}/chat/completions?api-version=${apiVersion}`,
+  // 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 Baidu = {
+  ExampleEndpoint: BAIDU_BASE_URL,
+  ChatPath: (modelName: string) => {
+    let endpoint = modelName;
+    if (modelName === "ernie-4.0-8k") {
+      endpoint = "completions_pro";
+    }
+    if (modelName === "ernie-4.0-8k-preview-0518") {
+      endpoint = "completions_adv_pro";
+    }
+    if (modelName === "ernie-3.5-8k") {
+      endpoint = "completions";
+    }
+    if (modelName === "ernie-speed-8k") {
+      endpoint = "ernie_speed";
+    }
+    return `rpc/2.0/ai_custom/v1/wenxinworkshop/chat/${endpoint}`;
+  },
+};
+
+export const ByteDance = {
+  ExampleEndpoint: "https://ark.cn-beijing.volces.com/api/",
+  ChatPath: "api/v3/chat/completions",
+};
+
+export const Alibaba = {
+  ExampleEndpoint: ALIBABA_BASE_URL,
+  ChatPath: "v1/services/aigc/text-generation/generation",
+};
+
+export const Tencent = {
+  ExampleEndpoint: TENCENT_BASE_URL,
+};
+
+
+
+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}}.
+// Knowledge cutoff: {{cutoff}}
+// Current model: {{model}}
+// Current time: {{time}}
+// Latex inline: $x^2$
+// Latex block: $$e=mc^2$$
+// `;
+export const DEFAULT_SYSTEM_TEMPLATE: any = null;
+// `
+// You are ChatGPT, a large language model trained by {{ServiceProvider}}.
+// Knowledge cutoff: {{cutoff}}
+// Current model: {{model}}
+// Current time: {{time}}
+// Latex inline: \\(x^2\\)
+// Latex block: $$e=mc^2$$
+// `;
+
+export const SUMMARIZE_MODEL = "gpt-4o-mini";
+
+
+export const KnowledgeCutOffDate: Record<string, string> = {
+  default: "2021-09",
+  "gpt-4-turbo": "2023-12",
+  "gpt-4-turbo-2024-04-09": "2023-12",
+  "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",
+  // After improvements,
+  // it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
+};
+
+const openaiModels = [
+  "gpt-3.5-turbo",
+  "gpt-3.5-turbo-1106",
+  "gpt-3.5-turbo-0125",
+  "gpt-4",
+  "gpt-4-0613",
+  "gpt-4-32k",
+  "gpt-4-32k-0613",
+  "gpt-4-turbo",
+  "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 baiduModels = [
+  "ernie-4.0-turbo-8k",
+  "ernie-4.0-8k",
+  "ernie-4.0-8k-preview",
+  "ernie-4.0-8k-preview-0518",
+  "ernie-4.0-8k-latest",
+  "ernie-3.5-8k",
+  "ernie-3.5-8k-0205",
+  "ernie-speed-128k",
+  "ernie-speed-8k",
+  "ernie-lite-8k",
+  "ernie-tiny-8k",
+];
+
+const bytedanceModels = [
+  "Doubao-lite-4k",
+  "Doubao-lite-32k",
+  "Doubao-lite-128k",
+  "Doubao-pro-4k",
+  "Doubao-pro-32k",
+  "Doubao-pro-128k",
+];
+
+const alibabaModes = [
+  "qwen-turbo",
+  "qwen-plus",
+  "qwen-max",
+  "qwen-max-0428",
+  "qwen-max-0403",
+  "qwen-max-0107",
+  "qwen-max-longcontext",
+];
+
+const tencentModels = [
+  "hunyuan-pro",
+  "hunyuan-standard",
+  "hunyuan-lite",
+  "hunyuan-role",
+  "hunyuan-functioncall",
+  "hunyuan-code",
+  "hunyuan-vision",
+];
+
+
+
+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,
+    },
+  })),
+
+  ...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,
+    },
+  })),
+
+  ...iflytekModels.map((name) => ({
+    name,
+    available: true,
+    sorted: seq++,
+    provider: {
+      id: "iflytek",
+      providerName: "Iflytek",
+      providerType: "iflytek",
+      sorted: 10,
+    },
+  })),
+] as const;
+
+export const CHAT_PAGE_SIZE = 15;
+export const MAX_RENDER_MSG_COUNT = 45;
+
+// some famous webdav endpoints
+export const internalAllowedWebDavEndpoints = [
+  "https://dav.jianguoyun.com/dav/",
+  "https://dav.dropdav.com/",
+  "https://dav.box.com/dav",
+  "https://nanao.teracloud.jp/dav/",
+  "https://bora.teracloud.jp/dav/",
+  "https://webdav.4shared.com/",
+  "https://dav.idrivesync.com",
+  "https://webdav.yandex.com",
+  "https://app.koofr.net/dav/Koofr",
+];
+

+ 17 - 0
app/global.d.ts

@@ -0,0 +1,17 @@
+declare module "*.jpg";
+declare module "*.png";
+declare module '*.gif';
+declare module "*.woff2";
+declare module "*.woff";
+declare module "*.ttf";
+declare module "*.scss" {
+  const content: Record<string, string>;
+  export default content;
+}
+
+declare module "*.svg";
+
+declare module 'js-export-excel' {
+  const Excel: any;
+  export default Excel;
+}

+ 11 - 0
app/icons/add.svg

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="17px" viewBox="0 0 16 17" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>add</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="add" transform="translate(0.000000, 0.689655)" fill="#333333" fill-rule="nonzero">
+            <path d="M13.3181818,16 L2.68181818,16 C1.22727273,16 0,14.7586207 0,13.3333333 L0,2.66666666 C0,1.1954023 1.18181818,0 2.68181818,0 L9.27272727,0 C9.63636363,0 9.95454545,0.321839079 9.95454545,0.689655174 C9.95454545,1.05747127 9.63636363,1.37931035 9.27272727,1.37931033 L2.68181818,1.37931033 C1.95454545,1.37931033 1.31818182,1.97701147 1.31818182,2.71264368 L1.31818182,13.3793103 C1.31818182,14.0689655 1.95454545,14.7126437 2.68181818,14.7126437 L13.2727273,14.7126437 C14,14.7126437 14.6363636,14.1149425 14.6363636,13.3793103 L14.6363636,5.28735631 C14.6363636,4.91954021 14.9545454,4.59770114 15.3181818,4.59770114 C15.6818182,4.59770114 16,4.91954021 16,5.28735631 L16,13.3333333 C15.9545454,14.8045977 14.7727273,16 13.3181818,16 Z" id="路径"></path>
+            <path d="M12.7115207,5.97142857 L9.48571429,2.74618757 L11.2829493,0.923225242 C12.1585254,0.0351153954 13.6331797,0.0351153954 14.5087558,0.923225242 C14.9695852,1.34390886 15.2,1.90482033 15.2,2.51247444 C15.2,3.12012854 14.9695852,3.68104002 14.5087558,4.14846625 L12.7115207,5.97142857 Z M11.4211982,2.69944494 L12.7115207,4.00823838 L13.5870968,3.16687116 C13.7253456,2.97990067 13.8175115,2.79293017 13.8175115,2.51247444 C13.8175115,2.23201871 13.7253456,2.04504821 13.5410138,1.85807772 C13.1723502,1.48413673 12.5732719,1.48413673 12.2046083,1.85807772 L11.4211982,2.69944494 Z" id="形状"></path>
+            <path d="M3.91225328,12.8285714 C3.54755,12.8285714 3.22843463,12.6905569 3.00049508,12.4145278 C2.68137972,12.0924939 2.54461599,11.5864407 2.68137972,11.1723971 L3.13725882,9.37820823 C3.18284674,9.2401937 3.27402255,9.01016948 3.45637419,8.82615013 L10.2945606,1.97142857 L13.4857143,5.14576273 L6.60193994,12.0464891 C6.4195883,12.2305085 6.23723666,12.3225182 6.05488502,12.368523 L6.00929711,12.368523 L4.23136864,12.7825666 C4.09460492,12.8285714 4.00342909,12.8285714 3.91225328,12.8285714 L3.91225328,12.8285714 Z M4.36813237,9.79225182 L3.95784119,11.494431 L5.69018174,11.0803874 L11.61661,5.19176755 L10.3401485,3.90363197 L4.36813237,9.79225182 Z" id="形状"></path>
+        </g>
+    </g>
+</svg>

+ 1 - 0
app/icons/add_bk.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M13.33,6.67C13.33,2.98 10.35,0 6.67,0C2.98,0 0,2.98 0,6.67C0,10.35 2.98,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67Z" transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.666666666666666 6.666666666666666)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L0,5.33" transform="translate(8 5.333333333333333) rotate(0 0 2.6666666666666665)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L5.33,0" transform="translate(5.333333333333333 8) rotate(0 2.6666666666666665 0)"/></g></g></svg>

BIN
app/icons/aiIcon.png


+ 1 - 0
app/icons/auto.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="分组 1" style="stroke:#333;stroke-width:1;stroke-opacity:1;stroke-dasharray:0 0" d="M0 5.33667L0.73 3.66667 M4.6675 5.33667L3.9375 3.66667 M0.729167 3.67L2.32917 0L3.93917 3.67 M0.729167 3.66667L3.93917 3.66667" transform="translate(5.666666666666666 5.333333333333333) rotate(0 2.333750009536743 2.6666666666666665)"/><path id="路径 5" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M13.07,5.33C12.45,2.29 9.76,0 6.53,0C3.31,0 0.62,2.29 0,5.33L2,4.67" transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.533316666666666 2.6666666666666665)"/><path id="路径 6" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0C0.62,3.04 3.31,5.33 6.53,5.33C9.76,5.33 12.45,3.04 13.07,0L11.33,0.67" transform="translate(1.3333333333333333 9.333333333333332) rotate(0 6.533316666666666 2.6666666666666665)"/></g></g></svg>

BIN
app/icons/avatar.png


+ 19 - 0
app/icons/black-bot.svg

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="30px" height="30px" viewBox="0 0 30 30" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>black-bot</title>
+    <defs>
+        <radialGradient cx="13.6659223%" cy="62.3104639%" fx="13.6659223%" fy="62.3104639%" r="131.641227%" gradientTransform="translate(0.136659,0.623105),scale(1.000000,0.380421),rotate(-50.804522),translate(-0.136659,-0.623105)" id="radialGradient-1">
+            <stop stop-color="#00A0C7" offset="0%"></stop>
+            <stop stop-color="#08A84C" offset="100%"></stop>
+        </radialGradient>
+    </defs>
+    <g id="black-bot" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="shjk_logo" transform="translate(6.583207, 3.000000)" fill-rule="nonzero">
+            <g id="编组">
+                <path d="M8.47349755,11.999709 C8.15613735,14.5495611 9.3249157,14.9280434 10.8729151,12.6633333 L16.1860178,5.23921878 L9.98561007,1.61757709 L8.47349755,11.9996787 L8.47349755,11.999709 Z M11.1946574,14.9291415 C10.4603828,15.6608658 9.90985391,16.7619438 11.6679576,17.7945371 L16.935721,20.6376072 L16.935721,8.73967388 L11.1946777,14.929101 L11.1946574,14.9291415 Z M8.82050036,20.3041303 L10.0010032,23.9993371 L16.9356957,23.9993371 L11.706744,19.6038855 C10.7667522,18.7722426 10.0233237,18.431818 9.50499283,18.431818 C8.70702045,18.431818 8.44382963,19.2404168 8.8204953,20.3041303 L8.82050036,20.3041303 Z" id="形状" fill="url(#radialGradient-1)"></path>
+                <polygon id="路径" fill="#005D80" points="2.12487674 5.10074151 2.12196713 14.3825545 0 16.6088274 0 24 5.09789262 24 5.09789262 23.9996407 7.22350814 23.9996407 7.22350814 0 2.1248565 5.10082753 2.1248565 5.10078705"></polygon>
+            </g>
+        </g>
+        <rect id="矩形" stroke="#4F4F4F" fill-rule="nonzero" x="0.5" y="0.5" width="29" height="29" rx="10"></rect>
+    </g>
+</svg>

+ 19 - 0
app/icons/black-bot_bk.svg

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="30px" height="30px" viewBox="0 0 30 30" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>black-bot</title>
+    <defs>
+        <radialGradient cx="13.6659223%" cy="62.3104639%" fx="13.6659223%" fy="62.3104639%" r="131.641227%" gradientTransform="translate(0.136659,0.623105),scale(1.000000,0.380421),rotate(-50.804522),translate(-0.136659,-0.623105)" id="radialGradient-1">
+            <stop stop-color="#00A0C7" offset="0%"></stop>
+            <stop stop-color="#08A84C" offset="100%"></stop>
+        </radialGradient>
+    </defs>
+    <g id="black-bot" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="shjk_logo" fill-rule="nonzero">
+            <rect id="矩形" fill="#E7F8FF" x="0" y="0" width="30" height="30" rx="10"></rect>
+            <g id="编组" transform="translate(6.583207, 3.000000)">
+                <path d="M8.47349755,11.999709 C8.15613735,14.5495611 9.3249157,14.9280434 10.8729151,12.6633333 L16.1860178,5.23921878 L9.98561007,1.61757709 L8.47349755,11.9996787 L8.47349755,11.999709 Z M11.1946574,14.9291415 C10.4603828,15.6608658 9.90985391,16.7619438 11.6679576,17.7945371 L16.935721,20.6376072 L16.935721,8.73967388 L11.1946777,14.929101 L11.1946574,14.9291415 Z M8.82050036,20.3041303 L10.0010032,23.9993371 L16.9356957,23.9993371 L11.706744,19.6038855 C10.7667522,18.7722426 10.0233237,18.431818 9.50499283,18.431818 C8.70702045,18.431818 8.44382963,19.2404168 8.8204953,20.3041303 L8.82050036,20.3041303 Z" id="形状" fill="url(#radialGradient-1)"></path>
+                <polygon id="路径" fill="#005D80" points="2.12487674 5.10074151 2.12196713 14.3825545 0 16.6088274 0 24 5.09789262 24 5.09789262 23.9996407 7.22350814 23.9996407 7.22350814 0 2.1248565 5.10082753 2.1248565 5.10078705"></polygon>
+            </g>
+        </g>
+    </g>
+</svg>

BIN
app/icons/bot.png


ファイルの差分が大きいため隠しています
+ 11 - 0
app/icons/bot.svg


+ 19 - 0
app/icons/bot_bk.svg

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="30px" height="30px" viewBox="0 0 30 30" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>bot</title>
+    <defs>
+        <radialGradient cx="13.6659223%" cy="62.3104639%" fx="13.6659223%" fy="62.3104639%" r="131.641227%" gradientTransform="translate(0.136659,0.623105),scale(1.000000,0.380421),rotate(-50.804522),translate(-0.136659,-0.623105)" id="radialGradient-1">
+            <stop stop-color="#00A0C7" offset="0%"></stop>
+            <stop stop-color="#08A84C" offset="100%"></stop>
+        </radialGradient>
+    </defs>
+    <g id="bot" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="shjk_logo" fill-rule="nonzero">
+            <rect id="矩形" fill="#E7F8FF" x="0" y="0" width="30" height="30" rx="10"></rect>
+            <g id="编组" transform="translate(6.583207, 3.000000)">
+                <path d="M8.47349755,11.999709 C8.15613735,14.5495611 9.3249157,14.9280434 10.8729151,12.6633333 L16.1860178,5.23921878 L9.98561007,1.61757709 L8.47349755,11.9996787 L8.47349755,11.999709 Z M11.1946574,14.9291415 C10.4603828,15.6608658 9.90985391,16.7619438 11.6679576,17.7945371 L16.935721,20.6376072 L16.935721,8.73967388 L11.1946777,14.929101 L11.1946574,14.9291415 Z M8.82050036,20.3041303 L10.0010032,23.9993371 L16.9356957,23.9993371 L11.706744,19.6038855 C10.7667522,18.7722426 10.0233237,18.431818 9.50499283,18.431818 C8.70702045,18.431818 8.44382963,19.2404168 8.8204953,20.3041303 L8.82050036,20.3041303 Z" id="形状" fill="url(#radialGradient-1)"></path>
+                <polygon id="路径" fill="#005D80" points="2.12487674 5.10074151 2.12196713 14.3825545 0 16.6088274 0 24 5.09789262 24 5.09789262 23.9996407 7.22350814 23.9996407 7.22350814 0 2.1248565 5.10082753 2.1248565 5.10078705"></polygon>
+            </g>
+        </g>
+    </g>
+</svg>

+ 1 - 0
app/icons/bottom.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M8,0L4,4L0,0" transform="translate(4 4) rotate(0 4 2)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M8,0L4,4L0,0" transform="translate(4 8) rotate(0 4 2)"/></g></g></svg>

+ 1 - 0
app/icons/brain.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M5.01,13.33C4.69,12.27 4.19,11.47 3.53,10.95C2.55,10.17 0.97,10.65 0.39,9.84C-0.19,9.04 0.8,7.55 1.15,6.67C1.49,5.79 -0.18,5.48 0.02,5.23C0.15,5.07 0.99,4.59 2.55,3.79C3,1.26 4.63,0 7.47,0C11.71,0 13.33,3.6 13.33,5.89C13.33,8.18 11.37,10.65 8.58,11.18C8.33,11.55 8.69,12.26 9.66,13.33" transform="translate(1.3333323286384866 1.3334133333333331) rotate(0 6.66666716901409 6.66666)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M2.1,3.33C1.91,4.42 2.14,4.93 2.79,4.86C3.44,4.79 3.84,4.52 3.97,4.05C4.99,4.33 5.54,4.09 5.63,3.33C5.75,2.18 5.13,1.26 4.88,1.26C4.63,1.26 3.97,1.23 3.97,0.88C3.97,0.52 3.2,0.33 2.5,0.33C1.81,0.33 2.23,-0.14 1.27,0.04C0.64,0.17 0.26,0.44 0.13,0.88C-0.09,1.72 -0.03,2.31 0.32,2.66C0.67,3 1.26,3.22 2.1,3.33Z" transform="translate(6.374029736345404 3.9567867125879106) rotate(0 2.8215982497276006 2.4327734241007346)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M1.97,0C1.63,0.21 1.17,0.56 0.97,0.83C0.48,1.52 0.09,1.93 0,2.37" transform="translate(8.193033333333332 8.500066666666665) rotate(0 0.9868499999999998 1.1846833333333333)"/></g></g></svg>

ファイルの差分が大きいため隠しています
+ 0 - 0
app/icons/break.svg


ファイルの差分が大きいため隠しています
+ 0 - 0
app/icons/cancel.svg


ファイルの差分が大きいため隠しています
+ 0 - 0
app/icons/chat-settings.svg


+ 1 - 0
app/icons/chat.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity=".8" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#a6a6a6;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M6.67,0C2.98,0 0,2.98 0,6.67C0,8.36 0,13.33 0,13.33C0,13.33 4.68,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67C13.33,2.98 10.35,0 6.67,0Z" transform="translate(1.3333533333333334 1.3333333333333333) rotate(0 6.666673333333334 6.666666666666666)"/><path id="路径 2" style="stroke:#a6a6a6;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L6,0" transform="translate(4.666666666666666 6) rotate(0 3 0)"/><path id="路径 3" style="stroke:#a6a6a6;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L6,0" transform="translate(4.666666666666666 8.666666666666666) rotate(0 3 0)"/><path id="路径 4" style="stroke:#a6a6a6;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L3.33,0" transform="translate(4.666666666666666 11.333333333333332) rotate(0 1.6666666666666665 0)"/></g></g></svg>

BIN
app/icons/chatgpt.png


ファイルの差分が大きいため隠しています
+ 0 - 0
app/icons/chatgpt.svg


+ 1 - 0
app/icons/clear.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M1,9.67L9.67,9.67L10.67,0L0,0L1,9.67Z" transform="translate(2.6666666666666665 5) rotate(0 5.333333333333333 4.833333333333333)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L0,3.33" transform="translate(6.667333333333333 8.334133333333334) rotate(0 0 1.6666999999999998)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L0,3.33" transform="translate(9.334133333333334 8.333166666666667) rotate(0 0 1.666283333333333)"/><path id="路径 4" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,4L5.44,0L8,4" transform="translate(4 1) rotate(0 4 2)"/></g></g></svg>

+ 1 - 0
app/icons/close.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L10.67,10.67" transform="translate(2.6666666666666665 2.6666666666666665) rotate(0 5.333333333333333 5.333333333333333)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,10.67L10.67,0" transform="translate(2.6666666666666665 2.6666666666666665) rotate(0 5.333333333333333 5.333333333333333)"/></g></g></svg>

ファイルの差分が大きいため隠しています
+ 0 - 0
app/icons/cloud-fail.svg


ファイルの差分が大きいため隠しています
+ 0 - 0
app/icons/cloud-success.svg


ファイルの差分が大きいため隠しています
+ 0 - 0
app/icons/config.svg


ファイルの差分が大きいため隠しています
+ 0 - 0
app/icons/confirm.svg


ファイルの差分が大きいため隠しています
+ 0 - 0
app/icons/connection.svg


+ 1 - 0
app/icons/copy.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,2.48L0,0.94C0,0.42 0.42,0 0.94,0L9.06,0C9.58,0 10,0.42 10,0.94L10,9.06C10,9.58 9.58,10 9.06,10L7.51,10" transform="translate(4.333333333333333 1.6666666666666665) rotate(0 5 5)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0.94,0C0.42,0 0,0.42 0,0.94L0,9.06C0,9.58 0.42,10 0.94,10L9.06,10C9.58,10 10,9.58 10,9.06L10,0.94C10,0.42 9.58,0 9.06,0L0.94,0Z" transform="translate(1.6666666666666665 4.333333333333333) rotate(0 5 5)"/></g></g></svg>

+ 1 - 0
app/icons/dark.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M6.67,0C2.98,0 0,2.98 0,6.67C0,10.35 2.98,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67C13.33,6.2 13.29,5.75 13.2,5.32C12.72,7.14 11.06,8.48 9.09,8.48C6.75,8.48 4.85,6.59 4.85,4.24C4.85,2.27 6.19,0.61 8.02,0.14C7.58,0.05 7.13,0 6.67,0Z" transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.666666666666666 6.666666666666666)"/></g></g></svg>

BIN
app/icons/deepSeek.png


ファイルの差分が大きいため隠しています
+ 0 - 0
app/icons/delete.svg


+ 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>

+ 1 - 0
app/icons/down.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(-90 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M4,8L0,4L4,0" transform="translate(6.333333333333333 4) rotate(0 2 4)"/></g></g></svg>

+ 1 - 0
app/icons/download.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M1,12L11,12C11.55,12 12,11.55 12,11L12,1C12,0.45 11.55,0 11,0L1,0C0.45,0 0,0.45 0,1L0,11C0,11.55 0.45,12 1,12Z" transform="translate(2 2) rotate(0 6 6)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L3.67,0L4.33,1.33L9,1.33L9.67,0L13.33,0" transform="translate(1.3333333333333333 10.333333333333332) rotate(0 6.666666666666666 0.6666666666666666)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,3.33L0,0" transform="translate(14 8.666666666666666) rotate(0 0 1.6666666666666665)"/><path id="路径 4" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L2,2L4,0" transform="translate(6 7.333333333333333) rotate(0 2 1)"/><path id="路径 5" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,5.33L0,0" transform="translate(8 4) rotate(0 0 2.6666666666666665)"/><path id="路径 6" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,3.33L0,0" transform="translate(2 8.666666666666666) rotate(0 0 1.6666666666666665)"/></g></g></svg>

+ 1 - 0
app/icons/drag.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><g opacity="1" transform="translate(0 0) rotate(0)"><mask id="bg-mask-0" fill="#fff"><use transform="translate(0 0) rotate(0)" xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" fill-rule="evenodd" style="fill:#333" d="M6.33663,3.33c0,0.74 -0.6,1.34 -1.34,1.34c-0.73,0 -1.33,-0.6 -1.33,-1.34c0,-0.73 0.6,-1.33 1.33,-1.33c0.74,0 1.34,0.6 1.34,1.33zM4.99663,9.33c-0.73,0 -1.33,-0.59 -1.33,-1.33c0,-0.74 0.6,-1.33 1.33,-1.33c0.74,0 1.34,0.59 1.34,1.33c0,0.74 -0.6,1.33 -1.34,1.33zM4.99663,14c-0.73,0 -1.33,-0.6 -1.33,-1.33c0,-0.74 0.6,-1.34 1.33,-1.34c0.74,0 1.34,0.6 1.34,1.34c0,0.73 -0.6,1.33 -1.34,1.33z" opacity="1"/><path id="路径 2" fill-rule="evenodd" style="fill:#333" d="M12.3366,3.33c0,0.74 -0.6,1.34 -1.34,1.34c-0.73,0 -1.32997,-0.6 -1.32997,-1.34c0,-0.73 0.59997,-1.33 1.32997,-1.33c0.74,0 1.34,0.6 1.34,1.33zM10.9966,9.33c-0.73,0 -1.32997,-0.59 -1.32997,-1.33c0,-0.74 0.59997,-1.33 1.32997,-1.33c0.74,0 1.34,0.59 1.34,1.33c0,0.74 -0.6,1.33 -1.34,1.33zM10.9966,14c-0.73,0 -1.32997,-0.6 -1.32997,-1.33c0,-0.74 0.59997,-1.34 1.32997,-1.34c0.74,0 1.34,0.6 1.34,1.34c0,0.73 -0.6,1.33 -1.34,1.33z" opacity="1"/></g></g><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs></svg>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません