Ryuiso 3 долоо хоног өмнө
commit
c98421f4d9
100 өөрчлөгдсөн 21183 нэмэгдсэн , 0 устгасан
  1. 14 0
      .babelrc
  2. 54 0
      .gitignore
  3. 164 0
      ALIBABA_VL_INTEGRATION.md
  4. 158 0
      README.md
  5. 54 0
      app/api/[provider]/[...path]/route.ts
  6. 131 0
      app/api/alibaba.ts
  7. 73 0
      app/api/api.ts
  8. 73 0
      app/api/artifacts/route.ts
  9. 105 0
      app/api/auth.ts
  10. 33 0
      app/api/azure.ts
  11. 145 0
      app/api/baidu.ts
  12. 129 0
      app/api/bytedance.ts
  13. 194 0
      app/api/common.ts
  14. 30 0
      app/api/config/route.ts
  15. 131 0
      app/api/iflytek.ts
  16. 72 0
      app/api/openai.ts
  17. 124 0
      app/api/tencent/route.ts
  18. 73 0
      app/api/upstash/[action]/[...key]/route.ts
  19. 167 0
      app/api/webdav/[...path]/route.ts
  20. 289 0
      app/client/api.ts
  21. 37 0
      app/client/controller.ts
  22. 321 0
      app/client/platforms/alibaba.ts
  23. 281 0
      app/client/platforms/baidu.ts
  24. 298 0
      app/client/platforms/bigModel.ts
  25. 255 0
      app/client/platforms/bytedance.ts
  26. 261 0
      app/client/platforms/deepSeek.ts
  27. 240 0
      app/client/platforms/iflytek.ts
  28. 482 0
      app/client/platforms/openai.ts
  29. 268 0
      app/client/platforms/tencent.ts
  30. 78 0
      app/command.ts
  31. 34 0
      app/common/user.tsx
  32. 206 0
      app/components/DeekSeekHome.tsx
  33. 1968 0
      app/components/DeepSeekChat.tsx
  34. 1533 0
      app/components/DeepSeekHomeChat.tsx
  35. 120 0
      app/components/Record.tsx
  36. 31 0
      app/components/artifacts.module.scss
  37. 234 0
      app/components/artifacts.tsx
  38. 36 0
      app/components/auth.module.scss
  39. 87 0
      app/components/auth.tsx
  40. 83 0
      app/components/button.module.scss
  41. 62 0
      app/components/button.tsx
  42. 173 0
      app/components/chat-list.tsx
  43. 952 0
      app/components/chat.module.scss
  44. 2470 0
      app/components/chat.tsx
  45. 120 0
      app/components/deepSeekHome.scss
  46. 56 0
      app/components/error.scss
  47. 81 0
      app/components/error.tsx
  48. 271 0
      app/components/exporter.module.scss
  49. 719 0
      app/components/exporter.tsx
  50. 391 0
      app/components/home.module.scss
  51. 510 0
      app/components/home.tsx
  52. 13 0
      app/components/input-range.module.scss
  53. 40 0
      app/components/input-range.tsx
  54. 321 0
      app/components/markdown.tsx
  55. 125 0
      app/components/mask-chat.module.scss
  56. 195 0
      app/components/mask-chat.tsx
  57. 108 0
      app/components/mask.module.scss
  58. 707 0
      app/components/mask.tsx
  59. 82 0
      app/components/message-selector.module.scss
  60. 261 0
      app/components/message-selector.tsx
  61. 231 0
      app/components/model-config.tsx
  62. 74 0
      app/components/settings.module.scss
  63. 1399 0
      app/components/settings.tsx
  64. 23 0
      app/components/sidebar.module.scss
  65. 1052 0
      app/components/sidebar.tsx
  66. 332 0
      app/components/ui-lib.module.scss
  67. 574 0
      app/components/ui-lib.tsx
  68. 46 0
      app/config/build.ts
  69. 27 0
      app/config/client.ts
  70. 230 0
      app/config/server.ts
  71. 368 0
      app/constant.ts
  72. 17 0
      app/global.d.ts
  73. 11 0
      app/icons/add.svg
  74. 1 0
      app/icons/add_bk.svg
  75. BIN
      app/icons/aiIcon-1.png
  76. BIN
      app/icons/aiIcon.png
  77. 1 0
      app/icons/auto.svg
  78. BIN
      app/icons/avatar-1.png
  79. BIN
      app/icons/avatar.png
  80. 19 0
      app/icons/black-bot.svg
  81. 19 0
      app/icons/black-bot_bk.svg
  82. BIN
      app/icons/bot.png
  83. 11 0
      app/icons/bot.svg
  84. 19 0
      app/icons/bot_bk.svg
  85. 1 0
      app/icons/bottom.svg
  86. 1 0
      app/icons/brain.svg
  87. 0 0
      app/icons/break.svg
  88. 0 0
      app/icons/cancel.svg
  89. 0 0
      app/icons/chat-settings.svg
  90. 1 0
      app/icons/chat.svg
  91. BIN
      app/icons/chatgpt.png
  92. 0 0
      app/icons/chatgpt.svg
  93. 1 0
      app/icons/clear.svg
  94. 1 0
      app/icons/close.svg
  95. 0 0
      app/icons/cloud-fail.svg
  96. 0 0
      app/icons/cloud-success.svg
  97. 0 0
      app/icons/config.svg
  98. 0 0
      app/icons/confirm.svg
  99. 0 0
      app/icons/connection.svg
  100. 1 0
      app/icons/copy.svg

+ 14 - 0
.babelrc

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

+ 54 - 0
.gitignore

@@ -0,0 +1,54 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+local.md
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+out.zip
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# local env files
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+dev
+
+.vscode
+.idea
+
+# docker-compose env files
+.env
+.env.local
+
+*.key
+*.key.pub
+
+masks.json
+
+# .cursor
+.cursor
+.history

+ 164 - 0
ALIBABA_VL_INTEGRATION.md

@@ -0,0 +1,164 @@
+# 阿里云 qwen-vl 模型集成总结
+
+## 测试结果
+
+### ✅ 成功的功能
+
+1. **模型识别**: 
+   - `qwen-vl-plus` 和 `qwen-vl-max` 模型已成功添加到常量文件中
+   - `isVisionModel` 函数已更新,能够正确识别 `qwen-vl` 系列模型
+
+2. **文本对话**: 
+   - qwen-vl 模型支持纯文本对话
+   - 使用 `/services/aigc/multimodal-generation/generation` 端点
+   - 响应格式与标准 qwen 模型一致
+
+3. **API 路由**: 
+   - 阿里云 API 路由已更新,支持根据模型类型选择不同端点
+   - 视觉模型自动使用多模态端点
+
+### ✅ 已解决的问题
+
+1. **多模态图片格式**: 
+   - ✅ 确认阿里云 API 支持 URL 格式的图片
+   - ✅ qwen-vl-max 模型成功处理了任天堂官方图片
+   - ✅ 图片分析功能完全正常,能够识别游戏、角色、风格等
+
+### ⚠️ 部分问题
+
+1. **qwen-vl-plus 超时**: 
+   - 在处理大尺寸图片时可能出现超时
+   - qwen-vl-max 模型表现更稳定
+
+## 已完成的代码修改
+
+### 1. 常量文件 (`app/constant.ts`)
+```typescript
+const alibabaModes = [
+  "qwen-turbo",
+  "qwen-plus", 
+  "qwen-max",
+  "qwen-max-longcontext",
+  "qwen-vl-plus",    // 新增
+  "qwen-vl-max",     // 新增
+];
+```
+
+### 2. 工具函数 (`app/utils.ts`)
+```typescript
+export function isVisionModel(model: string) {
+  const visionKeywords = [
+    "vision",
+    "claude-3",
+    "gemini-1.5-pro",
+    "gemini-1.5-flash",
+    "gpt-4o",
+    "gpt-4o-mini",
+    "qwen-vl",        // 新增
+  ];
+  // ...
+}
+```
+
+### 3. 阿里云客户端 (`app/client/platforms/alibaba.ts`)
+
+#### 新增图片预处理函数
+```typescript
+async function preProcessImageContent(content: string | MultimodalContent[]) {
+  // 处理文本和图片内容,转换为阿里云API格式
+}
+```
+
+#### 更新 chat 方法
+```typescript
+async chat(options: ChatOptions) {
+  const visionModel = isVisionModel(options.config.model);
+  const messages: any[] = [];
+  
+  for (const v of options.messages) {
+    const content = visionModel
+      ? await preProcessImageContent(v.content)
+      : getMessageTextContent(v);
+    messages.push({ role: v.role, content });
+  }
+  
+  // 根据模型类型选择端点
+  let chatPath = this.path(Alibaba.ChatPath);
+  if (visionModel) {
+    chatPath = this.path('/services/aigc/multimodal-generation/generation');
+  }
+}
+```
+
+## 兼容性分析
+
+### 与百炼大模型接口的兼容性
+
+1. **消息格式**: 
+   - 阿里云 API 使用标准的 messages 数组格式
+   - 与百炼大模型接口基本兼容
+
+2. **多模态支持**: 
+   - 阿里云支持文本和图片混合内容
+   - 格式略有不同,但概念相似
+
+3. **流式响应**: 
+   - 阿里云支持 SSE 流式响应
+   - 与百炼大模型接口兼容
+
+## 下一步工作
+
+1. **图片格式研究**: 
+   - 查阅阿里云官方文档
+   - 测试不同的图片格式(base64、URL、文件上传等)
+   - 确定正确的图片输入格式
+
+2. **错误处理优化**: 
+   - 添加更详细的错误信息
+   - 提供用户友好的错误提示
+
+3. **测试完善**: 
+   - 创建完整的集成测试
+   - 测试各种边界情况
+
+## 测试验证
+
+### 成功案例:任天堂游戏图片分析
+
+#### 案例1:Bayonetta游戏图片
+使用任天堂官方图片进行测试:
+
+**qwen-vl-max 模型成功识别并分析:**
+- ✅ 正确识别游戏:《Bayonetta》(猎天使魔女)
+- ✅ 详细分析游戏类型、视觉风格、目标受众
+- ✅ 准确描述角色设计和艺术风格
+- ✅ 提供完整的游戏背景信息
+
+**技术指标:**
+- 输入token:1278(图片1252 + 文本26)
+- 输出token:452
+- 处理时间:正常
+
+#### 案例2:Super Mario Party Jamboree游戏图片
+使用任天堂官方图片进行测试:
+
+**测试单元结果(100%成功率):**
+- ✅ qwen-vl-plus 基础测试:准确识别游戏名称和内容
+- ✅ qwen-vl-max 详细分析:完整分析游戏类型、角色、目标受众
+- ✅ qwen-vl-plus 对话测试:简洁回答游戏相关问题
+
+**技术指标:**
+- qwen-vl-plus:输入1238 tokens,输出444 tokens
+- qwen-vl-max:输入1251 tokens,输出682 tokens
+- 图片处理:约1224 tokens(高效)
+- 响应时间:所有请求正常完成,无超时
+
+## 结论
+
+阿里云 qwen-vl 模型的集成已经**完全完成**,包括:
+- ✅ 文本对话功能正常工作
+- ✅ 多模态功能完全可用,支持URL图片
+- ✅ qwen-vl-max 模型表现优秀,能够准确分析复杂图片
+- ✅ 整体架构与百炼大模型接口兼容,可以无缝集成到现有系统中
+
+**推荐使用 qwen-vl-max 模型**进行多模态任务,其稳定性和准确性都优于 qwen-vl-plus。

+ 158 - 0
README.md

@@ -0,0 +1,158 @@
+
+# 盈科·小智客户端项目概述
+
+## 业务目标
+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服务
+
+# 智普问答 可以访问的地址
+http://localhost:4000/#/knowledgeChat?showMenu=true&chatMode=LOCAL&appId=2924812721300312064
+http://localhost:4000/#/knowledgeChat?showMenu=false&chatMode=LOCAL&appId=2924812721300312064
+
+常改文件---components/chat.tsx--->对应的是智普问答
+常改文件---components/DeepSeekChat.tsx--->对应的是deepSeek问答
+
+本地调试需要改变文件
+1、app/client/platforms/deepSeek.ts
+    this.apiPath = this.baseURL + '/vllm/ai/chat';//线上地址
+    this.apiPath = this.baseURL + '/vllm/chat'; // 测试地址
+2、 destination: "http://xia0miduo.gicp.net:8401/:path*",----这一步可以改也可以不改
+        // destination: "http://xia0miduo.gicp.net:8401/:path*",
+        destination: "http://192.168.3.123:8091/:path*",
+3、app/components/home.tsx 
+    toUninLogin方法下增加 return 禁止跳转到首页
+    如下:
+    const toUninLogin = async (originUrl:string, fullUrl:string) => {
+    // return ----- 这里
+    //测试环境
+    //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);
+  }
+ # 分支对应项目
+  app-permission --- 目前线上
+  dev/app
+  dev/second
+  master
+  plusSingle --- 招聘
+  single

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

+ 73 - 0
app/api/api.ts

@@ -0,0 +1,73 @@
+import axios, { AxiosResponse } from 'axios';
+import { message } from 'antd';
+
+import { encryptBase64, encryptWithAes, generateAesKey, decryptWithAes, decryptBase64 } from '@/app/utils/crypto';
+import { encrypt, decrypt } from '@/app/utils/jsencrypt';
+const encryptHeader = 'encrypt-key';
+import {replaceUrl} from '@/app/utils/index'
+
+// 创建axios实例
+const axiosInstance = axios.create({
+    baseURL: '/bigmodel-api',
+    timeout: 300000,// 请求超时5分钟
+});
+
+// 请求拦截器
+axiosInstance.interceptors.request.use(
+    (config) => {
+        const isEncrypt = config.headers?.isEncrypt === 'true';
+        
+        config.headers['Content-Language'] = 'zh_CN';
+        
+        const userInfoStr = localStorage.getItem('userInfo');
+        const token = localStorage.getItem('token');
+        config.headers['clientid'] = 'e5cd7e4891bf95d1d19206ce24a7b32e';
+        if (userInfoStr) {
+            const userInfo = JSON.parse(userInfoStr);
+            if (userInfo.token) {
+                config.headers['Authorization'] = 'Bearer '+userInfo.token;
+            }
+        }else if (token) {
+            config.headers['Authorization'] = 'Bearer '+token;
+        }
+         if (isEncrypt && (config.method === 'post' || config.method === 'put')) {
+            // 生成一个 AES 密钥
+            const aesKey = generateAesKey();
+            config.headers[encryptHeader] = encrypt(encryptBase64(aesKey));
+            config.data = typeof config.data === 'object' ? encryptWithAes(JSON.stringify(config.data), aesKey) : encryptWithAes(config.data, aesKey);
+        }
+        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 {// 失败
+                // console.log('请求失败',config,'--data',data)
+                if(data.msg||data.code){
+                    message.error(data.msg || `请求失败`);
+                }
+                if (data.code === 401) {
+                    localStorage.clear();
+                    window.sessionStorage.clear();
+                    window.location.replace(`${replaceUrl}?redirectUrl=${encodeURIComponent(window.location.href)}`);
+                    // const originUrl = window.location.origin;
+                    // window.open(originUrl + '/#/login', '_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}`;
+  },
+};

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

@@ -0,0 +1,321 @@
+"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, isVisionModel } from "@/app/utils";
+
+// 预处理图片内容,将base64转换为阿里云API格式
+async function preProcessImageContent(content: string | MultimodalContent[]) {
+  if (typeof content === "string") {
+    return content;
+  }
+
+  const processedContent: any[] = [];
+  
+  for (const item of content) {
+    if (item.type === "text") {
+      processedContent.push({
+        text: item.text
+      });
+    } else if (item.type === "image_url") {
+      // 阿里云API支持URL和base64格式的图片
+      let imageData = item.image_url?.url || "";
+      
+      if (imageData.startsWith("data:image/")) {
+        // 提取base64部分
+        const base64Match = imageData.match(/data:image\/[^;]+;base64,(.+)/);
+        if (base64Match) {
+          imageData = base64Match[1];
+        }
+        processedContent.push({
+          image: imageData
+        });
+      } else if (imageData.startsWith("http")) {
+        // 直接使用URL
+        processedContent.push({
+          image: imageData
+        });
+      } else {
+        // 假设是纯base64
+        processedContent.push({
+          image: imageData
+        });
+      }
+    }
+  }
+  
+  return processedContent;
+}
+
+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 modelConfig = {
+      ...useAppConfig.getState().modelConfig,
+      ...useChatStore.getState().currentSession().mask.modelConfig,
+      ...{
+        model: options.config.model,
+      },
+    };
+
+    const visionModel = isVisionModel(options.config.model);
+    const messages: any[] = [];
+    
+    for (const v of options.messages) {
+      const content = visionModel
+        ? await preProcessImageContent(v.content)
+        : getMessageTextContent(v);
+      messages.push({ role: v.role, content });
+    }
+
+    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 {
+      // 根据模型类型选择不同的端点
+      let chatPath = this.path(Alibaba.ChatPath);
+      if (visionModel) {
+        chatPath = this.path('/services/aigc/multimodal-generation/generation');
+      }
+      
+      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 };

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

@@ -0,0 +1,298 @@
+"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";
+import {processSliceData} from "@/app/utils/index"; 
+
+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';
+    this.apiPath = this.baseURL + '/deepseek/api/chat';
+  }
+
+  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 userInfo = localStorage.getItem('userInfo');
+      const token = userInfo ? JSON.parse(userInfo).token : "";
+      const chatPath = this.apiPath;
+      const paramsflag = { ...params }
+      paramsflag.prompt.forEach(val => {
+        val.content = btoa(unescape(encodeURIComponent(val.content)))
+      });
+      const chatPayload = {
+        method: "POST",
+        body: JSON.stringify(paramsflag),
+        // body: btoa(unescape(encodeURIComponent(JSON.stringify(params)))),
+        signal: controller.signal,
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer ' + token,
+          'clientid': 'e5cd7e4891bf95d1d19206ce24a7b32e'
+        },
+      };
+
+      const requestTimeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
+
+      let responseText = "";
+      let remainText = "";
+      let finished = false;
+      let finishData: any = {}; //保存后数据
+
+      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 (info.data) {
+              // console.log('info.data---', info.data);
+              finishData = info && info?.data ? JSON.parse(info.data) : {};
+            }
+            // console.log('finishData', finishData);
+            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}`);
+                  let allChunkNum = 0;
+                  delete res.data.code;
+                  const values1 = Object.keys(res.data).reduce((acc, knowledge_id) => {
+                    const docs = res.data[knowledge_id];
+                    const transformedDocs = docs.map((doc: any) => {
+                      allChunkNum += doc.chunk_nums;
+                      return {
+                        knowledge_id,
+                        ...doc
+                      }
+                    });
+                    return acc.concat(transformedDocs);
+                  }, []);
+                  const result = processSliceData(values1);
+                  // 使用解构赋值,让结果更清晰
+                  const { withDeprecated, withoutDeprecated } = result;
+                  const sliceInfo = {
+                    allChunkNum: allChunkNum,
+                    ...res.data,
+                    doc: values1,
+                    docDeprecated:withDeprecated,
+                    docActive:withoutDeprecated,
+                  };
+                  // console.log('values1---', values1,withDeprecated,withoutDeprecated);
+                  // console.log('sliceInfo---', sliceInfo);
+                  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,
+                          delSliceInfo: withDeprecated ? 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: any) => ({
+              id: item.id,
+              date: item.date,
+              role: item.role,
+              content: btoa(unescape(encodeURIComponent(item.content))),
+              sliceInfo: item.sliceInfo,
+              ...finishData
+            })),
+          };
+          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;
+          // console.log('保存对话数据----', data);
+          // 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 };

+ 261 - 0
app/client/platforms/deepSeek.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 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';//线上地址
+    // this.apiPath = this.baseURL + '/vllm/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 userInfo = localStorage.getItem('userInfo');
+      const token = userInfo ? JSON.parse(userInfo).token : "";
+      const chatPath = this.apiPath;
+      const chatPayload = {
+        method: "POST",
+        body: JSON.stringify(params),
+        signal: controller.signal,
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': token
+        },
+      };
+
+      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('deepseek/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 };
+}

+ 34 - 0
app/common/user.tsx

@@ -0,0 +1,34 @@
+"use client";
+import React, {
+  useState,
+  useRef,
+  useEffect,
+  useMemo,
+  useCallback,
+  Fragment,
+  RefObject,
+} from "react";
+
+import { UserSwitchOutlined } from '@ant-design/icons';
+import {  Modal } from "antd";
+import {replaceUrl} from '@/app/utils/index'
+
+export function UserOut(props: { globalStore: any; chatStore: any }) {
+  const {globalStore,chatStore} = props;
+  return <UserSwitchOutlined className="cursor-pointer" onClick={() => {
+    Modal.confirm({
+      title: '确认退出当前用户吗?',
+      onOk() {
+        localStorage.clear();
+        window.sessionStorage.clear();
+        // globalStore.setSelectedAppId('1881234567890');
+        globalStore.setSelectedAppId('');
+        chatStore.clearSessions();
+        
+        window.location.replace(`${replaceUrl}?redirectUrl=${encodeURIComponent(window.location.href)}`);
+        // const originUrl = window.location.origin;
+        // window.open(originUrl + '/#/login', '_self');
+      },
+    });
+    }} />
+}

+ 206 - 0
app/components/DeekSeekHome.tsx

@@ -0,0 +1,206 @@
+import * as React from 'react';
+import { useNavigate } from "react-router-dom";
+import { Dropdown, Spin,Tooltip } 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>([
+        {
+            "children": [
+                {
+                    "chatMode": "LOCAL",
+                    "showMenu": "false",
+                    "appId": "2935625422066814976",
+                    "title": "公司作业指导书(2023版)"
+                },
+                {
+                    "chatMode": "LOCAL",
+                    "showMenu": "false",
+                    "appId": "2935200536655695872",
+                    "title": "常用验收规范问答"
+                },
+                {
+                    "chatMode": "LOCAL",
+                    "showMenu": "false",
+                    "appId": "2942568790025965568",
+                    "title": "钢结构AI监理师"
+                }
+            ],
+            "title": "专业知识"
+        },
+        {
+            "children": [
+                {
+                    "chatMode": "LOCAL",
+                    "showMenu": "false",
+                    "appId": "2919677614293913600",
+                    "title": "员工入职小百科"
+                },
+                {
+                    "chatMode": "LOCAL",
+                    "showMenu": "false",
+                    "appId": "2919668410128666624",
+                    "title": "数字系统答疑"
+                },
+                {
+                    "chatMode": "LOCAL",
+                    "showMenu": "false",
+                    "appId": "2945774476037853184",
+                    "title": "企业介绍"
+                }
+            ],
+            "title": "职能管理"
+        },
+        {
+            "children": [],
+            "title": "项目级应用"
+        }
+    ]);
+
+    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
+                                            className='deekSeek-menuds'
+                                                onClick={() => {
+                                                    const search = `?showMenu=${child.showMenu}&chatMode=${child.chatMode}&appId=${child.appId}`;
+                                                    if (child.appId) {
+                                                        navigate({
+                                                            pathname: '/knowledgeChat',
+                                                            search: search,
+                                                        })
+                                                    }
+                                                }}
+                                            >
+                                                <Tooltip placement="left" title={child.title}>
+                                                    {child.title} 
+                                                </Tooltip>
+                                            </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: '#fff', cursor: 'pointer' }}>
+                                    {item.title}
+                                </div>
+                            </Dropdown>
+                        })
+                    }
+                    {/*<div style={{ whiteSpace: 'nowrap', marginRight: 20, color: '#98b4fa', cursor: 'pointer' }} onClick={() => {*/}
+                    {/*    navigate({*/}
+                    {/*        pathname: '/deepseekChat',*/}
+                    {/*    })*/}
+                    {/*}}>*/}
+                    {/*    DeepSeek问答*/}
+                    {/*</div>*/}
+
+                    {/* 右侧区域 - 开放平台按钮 */}
+                    <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
+                        <button
+                            className='open-platform-btn'
+                            onClick={() => {
+                                // 跳转到其他平台的逻辑,这里使用window.open举例
+                                const userInfo = localStorage.getItem('userInfo');
+                                const tokenParam = userInfo ? "?token=" + JSON.parse(userInfo).token : "";
+                                window.open('https://llm.jkec.info:11431/deepseek/questionAnswer' + tokenParam, '_blank');
+                            }}
+                        >
+                            更多
+                        </button>
+                    </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;

+ 1968 - 0
app/components/DeepSeekChat.tsx

@@ -0,0 +1,1968 @@
+// 第三方库导入
+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 avatarSrc from "../icons/avatar.png";
+
+
+// 状态管理和类型导入
+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 = '1881234567890';
+    } );
+  }, [] )
+  
+  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 } style={messages.length === 1 ? { justifyContent:'center',alignItems:'center' } : {}} 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>
+        }
+        {messages.length > 1?<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 style={{ padding: '0 20px' }}>
+            <div style={{ display: 'flex', justifyContent: 'center' }}>
+              <img style={{ width: 80, height: 80 }} src={avatarSrc.src} />
+            </div>
+            <p style={{ textAlign: 'center' }}>
+              你好 我是小智
+            </p>
+          </div>
+        </>}
+        {/* 底部按钮 */}
+        <div style={messages.length === 1 ? { border:'none', width:'60%' } : {} } 
+            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 }}> */ }
+            {true&&<div className={ styles[ "chat-input-bottom-bar" ] }>
+              {/* <div style={{ display: 'flex', alignItems: 'center' }}> */ }
+              <div className={ styles[ "left-options" ] }>
+                
+                {/*深度思考R1按钮*/ }
+                
+               {false&&<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>
+  );
+}

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

@@ -0,0 +1,952 @@
+@import "../styles/animation.scss";
+
+
+.markdown-preview {
+  :global {
+    img {
+      max-width: 100%;
+      width: 100%;
+      height: auto;
+      display: block;
+    }
+
+    // /* 覆盖 markdown 渲染出来的样式 */
+    .ant-typography h1 {
+      font-size: 18px !important;
+      /* 调整大小 */
+      color: #333;
+      /* 字体颜色 */
+      margin: 2px 0;
+    }
+
+    .ant-typography h2 {
+      font-size: 16px !important;
+      color: #333;
+      margin: 2px 0;
+    }
+
+    .ant-typography h3 {
+      font-size: 14px !important;
+      color: #333;
+      margin: 2px 0;
+      //   border-left: 4px solid #4caf50; /* 左边加一条绿色竖线 */
+    }
+    .ant-typography h4 {
+      font-size: 14px !important;
+      color: #333;
+      margin: 2px 0;
+      //   border-left: 4px solid #4caf50; /* 左边加一条绿色竖线 */
+    }
+  }
+}
+
+.ant-spin-container {
+  width: 100%;
+}
+
+.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);
+  color: #4096ff !important;
+  border: 1px solid #4096ff;
+
+  .markdown-body {
+    color: #4096ff !important;
+  }
+
+  &: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: 10px;
+  bottom: 10px;
+  border-radius: 50%;
+}
+.speech-button {
+  background: transparent;
+  background: transparent;
+  color: #4096ff;
+  box-shadow:none;
+  padding: 0 10px;
+  margin-right: 10px;
+}
+.speech-button:hover {
+  background: rgba(64, 150, 255, 0.1) !important;
+  color: #4096ff !important;
+  box-shadow:none;
+  padding: 0 10px;
+  margin-right: 10px;
+}
+.send_input:disabled {
+  background:  #4096ff !important;
+  color: white !important;
+  opacity: 0.45 !important;
+}
+.chat-input-lianwang {
+  width: 50px;
+  min-width: 83px;
+  padding: 0 12px;
+  height: 28px;
+  border-radius: 18px;
+  font-size: 12px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  user-select: none;
+  margin-left: 10px;
+  margin-bottom: 10px;
+}
+
+// 新增:输入框内部按钮区域样式
+.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;
+    }
+  }
+}

+ 2470 - 0
app/components/chat.tsx

@@ -0,0 +1,2470 @@
+"use client";
+import { useDebouncedCallback } from "use-debounce";
+import React, {
+  useState,
+  useRef,
+  useEffect,
+  useMemo,
+  useCallback,
+  Fragment,
+  RefObject,
+} from "react";
+import { HomeOutlined, MenuOutlined, MessageOutlined, CopyOutlined, ArrowUpOutlined, AudioOutlined, PauseCircleOutlined, DeleteOutlined } 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 hlw_selected from "../icons/hlw_selected.png";
+import hlw from "../icons/hlw.png";
+import tubing from "../icons/tubing.png";
+import ffq from "../icons/ffq.png";
+import rfq from "../icons/rfq.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 MarkdownIt from 'markdown-it';
+
+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";
+// import avatar from "../icons/avatar.png";
+import { Typography, Image, Tooltip, Tag } from "antd";
+
+const marked = new MarkdownIt({ html: true, typographer: true });
+
+
+
+function Avatar(props: { model?: string; avatar?: string }) {
+  if (props.model) {
+    return (
+      <div className="no-dark">
+        {props.model?.startsWith("gpt-4") ? (
+          <BlackBotIcon className="user-avatar" />
+        ) : (
+          <img src={avatarSrc.src} className="user-avatar" />
+          // <BotIcon className="user-avatar" />
+        )}
+      </div>
+    );
+  }
+
+  return (
+    <div className="user-avatar">
+      {/* 移除emoji头像,使用默认bot图标 */}
+      <img src={avatarSrc.src} 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";
+
+import { UserOut } from "../common/user";
+
+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 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>
+  );
+}
+
+// SpeechButton: uses Web Speech API to capture speech and return transcript via onResult
+function SpeechButton(props: { onResult: (text: string) => void }) {
+  const [listening, setListening] = useState(false);
+  const recognitionRef = useRef<any>(null);
+
+  useEffect(() => {
+    const AnySpeech: any = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition;
+    if (!AnySpeech) return;
+    const recog = new AnySpeech();
+    recog.continuous = false;
+    recog.interimResults = false;
+    recog.lang = 'zh-CN';
+    recognitionRef.current = recog;
+    // do not call stop() here — only start/stop on user action
+    const isRunningRef = { current: false } as { current: boolean };
+
+    recog.onstart = () => {
+      isRunningRef.current = true;
+      setListening(true);
+    };
+    const onresult = (e: any) => {
+      try {
+        const transcript = Array.from(e.results)
+          .map((r: any) => r[0].transcript)
+          .join('');
+        if (transcript && props.onResult) props.onResult(transcript.trim());
+      } catch (err) {
+        console.error('speech onresult', err);
+      }
+    };
+
+    const onend = () => {
+      setListening(false);
+    };
+
+    recog.onresult = onresult;
+    recog.onend = onend;
+
+    return () => {
+      try {
+        recog.onresult = null;
+        recog.onend = null;
+        recog.onstart = null;
+        recog.onresult = null;
+        recog.onend = null;
+        try {
+          recog.stop && recog.stop();
+        } catch (e) {
+          // ignore stop errors during cleanup
+        }
+      } catch (e) {
+        // ignore
+      }
+    };
+  }, []);
+
+  const start = () => {
+    const recog = recognitionRef.current;
+    if (!recog) {
+      showToast('当前浏览器不支持语音识别');
+      return;
+    }
+    try {
+      // prevent double start which causes InvalidStateError
+      // some browsers throw if start is called while recognition already running
+      if ((recog as any).running) {
+        return;
+      }
+      recog.start();
+    } catch (e) {
+      console.error('start recog', e);
+      // provide friendly message for common invalid state
+      if ((e as any).name === 'InvalidStateError') {
+        showToast('语音识别已在运行中');
+      }
+    }
+  };
+
+  const stop = () => {
+    const recog = recognitionRef.current;
+    try {
+      // stop only if available and running
+      try {
+        recog.stop && recog.stop();
+      } catch (e) {
+        console.warn('stop recog error', e);
+      }
+    } catch (e) {
+      // ignore
+    }
+    setListening(false);
+  };
+
+  return (
+    <Button
+      className={styles['speech-button']}
+      onClick={() => (listening ? stop() : start())}
+      title={listening ? '点击停止语音输入' : '点击开始语音输入'}
+      type="primary"
+    >
+      {listening ? <PauseCircleOutlined style={{ fontSize: 18 }} /> : <AudioOutlined style={{ fontSize: 18 }} />}
+    </Button>
+  );
+}
+
+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: btoa(unescape(encodeURIComponent(record.content))),
+            role: record.role,
+          }
+        ],
+        chat_id: session.chat_id,
+      }
+      let url = '/deepseek/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])
+
+  const [activeKey, setActiveKey] = useState('0'); // 控制手风琴是否展示
+
+  return (
+    <div className={styles["chat-input-actions"]}>
+      {
+        props.sendStatus &&
+        <Collapse
+          accordion={true}
+          activeKey={activeKey}
+          onChange={(key) => { setActiveKey(key[0]) }}
+          bordered={false}
+          style={{ width: '100%', backgroundColor: '#fff' }}
+          expandIconPosition="end"
+          items={[
+            {
+              key: '1',
+              label: <span style={{ color: '#8096ca' }}>你还可以尝试提问:</span>,
+              children: <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)
+                                setActiveKey('')
+                              }}
+                              key={index}
+                            >
+                              {item}
+                            </div>
+                          )
+                        })
+                      }
+                    </div>
+                }
+              </div>
+            }
+          ]}
+        />
+      }
+
+      {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 = { question: string, questionIndex: number, id: string, appId: string, createTime: string }[];
+  const [questionList, setQuestionList] = useState<QuestionList>([]);// 预设问题列表
+  const [detail, setDetail] = useState<any>(null);// 应用详情
+  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 fetchApplicationDetail = async (appId: any) => {
+    if (!appId) return
+    setLoading(true);
+    try {
+      let url = `/deepseek/api/selectApplication/${appId}`;
+      const res: any = await api.get(url);
+      if (res.code === 200) {
+        setDetail(res.data?.detail);
+        setQuestionList(res.data?.questionlist || []);
+      } else {
+        message.error(res.message || '应用详情获取失败');
+      }
+      return
+    } catch (error) {
+      console.error(error);
+    } finally {
+      setLoading(false);
+    }
+  }
+
+
+  const InitChat = async (appId: any) => {
+    await fetchApplicationDetail(appId);
+  }
+  useEffect(() => {
+    const search = location.search;
+    const params = new URLSearchParams(search);
+    const chatMode = params.get('chatMode') || 'LOCAL';
+    if (chatMode) {
+      setSelectedFruit(chatMode as "ONLINE" | "LOCAL");
+      let appId = params.get('appId');
+      console.log('appId', appId)
+      if (appId) {
+        setAppValue(appId);
+        globalStore.setSelectedAppId(appId);
+        chatStore.updateCurrentSession((session: any) => {
+          session.appId = appId;
+        });
+        InitChat(appId);
+      }
+    }
+  }, [])
+
+  useEffect(() => {
+    if (appValue !== globalStore.selectedAppId) {
+      setAppValue(globalStore.selectedAppId);
+      chatStore.updateCurrentSession((session: any) => {
+        session.appId = globalStore.selectedAppId;
+      });
+      InitChat(globalStore.selectedAppId);
+    }
+  }, [globalStore.selectedAppId])
+
+  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 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 InitSlice = 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(() => {
+      InitSlice();
+    }, [])
+
+    return (
+      <Drawer
+        title={drawerData.doc_name}
+        loading={pageLoading}
+        open={drawerOpen}
+        onClose={() => {
+          setDrawerOpen(false);
+        }}
+      >
+        {list.map((item: any, index) => {
+          const score = Number(item.rerankScore);
+          const formattedScore = isNaN(score) ? '0.00' : (Math.trunc(score * 100) / 100);
+          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' }} className="flex items-center justify-center">
+                <span className="mr-2">片段{index + 1}</span>  {item.revisionStatus === '0' && <Tooltip title="已废弃">
+                  <div className="flex items-center justify-center">
+                    <Image width={14} height={14} src={rfq.src}
+                      preview={false}
+                      className="mr-[10px]"
+                      onClick={() => {
+                        // setDeleSlice((prev)=>!prev)
+                        // setShowSlice(false);
+                      }}  ></Image>
+                    <Tag color="red" style={{ padding: '0 2px', fontSize: '12px',marginLeft:'5px' }}> 已废弃 </Tag>
+                  </div>
+                </Tooltip>}
+              </div>
+              <div>
+                <StarTwoTone style={{ marginRight: 10 }} />
+                rerank得分{formattedScore}
+              </div>
+            </div>
+            <div className={styles['markdown-preview']}>
+              {/* {item.sliceText} */}
+              <Typography
+                className={`text-sm leading-7 text-gray-800 ${styles["markdown-preview"]}`}
+                dangerouslySetInnerHTML={{
+                  __html: marked.render(item.sliceText),
+                }}
+              />
+              {item.parentSection && <p className="text-right mt-[2px]">{item.parentSection}</p>}
+            </div>
+            {item.revisionStatus === '1' && <div className={styles['markdown-preview']}>
+              <div><Image width={18} height={18} src={tubing.src} preview={false} className='cursor-pointer' /></div>
+              <h3 className="text-[20px]">{item.refDocumentName}</h3>
+              <Typography
+                className={`text-sm leading-7 text-gray-800 ${styles["markdown-preview"]}`}
+                dangerouslySetInnerHTML={{
+                  __html: marked.render(item.revisionSliceText),
+                }}
+              />
+            </div>}
+          </div>
+        })}
+      </Drawer>
+    )
+  }
+
+  useEffect(() => {
+    // 当显示欢迎页面时,确保滚动到顶部
+    if (messages.length <= 1 && scrollRef.current) {
+      setTimeout(() => {
+        if (scrollRef.current) {
+          scrollRef.current.scrollTo(0, 0);
+        }
+      }, 0);
+    }
+  }, [messages.length]);
+  const [visibleOut, setVisibleOut] = useState(false);
+  const [webSearch, setWebSearch] = useState(false);
+  const [deleSliceMap, setDeleSliceMap] = useState<Record<string, boolean>>({});
+  const [showSliceMap, setShowSliceMap] = useState<Record<string, boolean>>({});
+  return (
+    <div className={styles.chat} key={session.id}>
+      {
+        <div className="window-header" style={{}} data-tauri-drag-region>
+          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: 'auto' }}
+            className={`window-header-title ${styles["chat-body-title"]}`}>
+            {
+              <div style={{ marginRight: 10 }} className="flex items-center ">
+                <Button
+                  type='text'
+                  icon={<MenuOutlined />}
+                  onClick={() => {
+                    globalStore.setShowMenu(!globalStore.showMenu);
+                  }}
+                />
+                {/* <p className="cursor-pointer text-[#000000] ml-[6px]"
+                  onClick={() => {
+                    globalStore.setSelectedAppId('1881234567890');
+                    chatStore.clearSessions();
+                    navigate({ pathname: '/deepseekChat' });
+
+                  }}
+                > 通用问答 <MessageOutlined /></p> */}
+              </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>
+          <h3 style={{ color: '#111111', margin: 0, padding: 0 }}>
+            {messages.length > 1 ? detail?.name : ''}
+          </h3>
+          <div className="window-actions">
+            <div className="window-action-button">
+              <UserOut globalStore={globalStore} chatStore={chatStore}></UserOut>
+              {/* <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" />
+                                ) : (
+                                  // 1111
+                                  <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>
+                        }
+                        <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>
+                        {/* 是否显示按钮 */}
+                        {!isUser && <div className="ml-2 flex items-center justify-center cursor-pointer mt-2">
+                          {message?.delSliceInfo?.docDeprecated && message?.delSliceInfo?.docDeprecated.length > 0 && <div className="mr-2 flex items-center justify-center">
+                            <Tooltip title='废弃内容'>
+                              <Image width={16} height={16} src={ffq.src}
+                                preview={false}
+                                onClick={() => {
+                                  setDeleSliceMap((prev) => ({ ...prev, [message.id]: !prev[message.id] }));
+                                  setShowSliceMap((prev) => ({ ...prev, [message.id]: false }));
+                                }}  ></Image>
+                            </Tooltip>
+                          </div>}
+                          {message.sliceInfo?.docActive && message.sliceInfo?.docActive.length > 0 &&
+                            <Tooltip title='参考内容'>
+                              <CopyOutlined onClick={() => {
+                                setShowSliceMap((prev) => ({ ...prev, [message.id]: !prev[message.id] }));
+                                setDeleSliceMap((prev) => ({ ...prev, [message.id]: false }));
+                              }} />
+                            </Tooltip>
+                          }
+                        </div>}
+                        {
+                          deleSliceMap[message.id] && message.delSliceInfo?.docDeprecated &&
+                          <div style={{ marginTop: 10 }}>
+                            <Collapse
+                              bordered={false}
+                              defaultActiveKey={['1']}
+                              expandIcon={({ isActive }) => <CaretRightOutlined rotate={isActive ? 90 : 0} />}
+                              items={[
+                                {
+                                  key: '1',
+                                  // label: `查询到“${message.sliceInfo?.doc?.length}条”相关切片`,
+                                  // label: `查询到${message.sliceInfo?.doc?.length}个文档共${message.sliceInfo?.allChunkNum}条切片`,
+                                  label: `以下内容由于已废弃,未参与最终结果输出`,
+                                  children: <div>
+                                    {message.delSliceInfo?.docDeprecated?.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: item!.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_info_list.length}
+                                        </div>
+                                      </div>
+                                    })}
+                                  </div>,
+                                }
+                              ]}
+                            />
+                            {
+                              drawerOpen &&
+                              <SliceDrawer />
+                            }
+                          </div>
+                        }
+                        {
+                          showSliceMap[message.id] && message.sliceInfo?.docActive && message.sliceInfo?.docActive.length > 0 &&
+                          <div style={{ marginTop: 10 }}>
+                            <Collapse
+                              bordered={false}
+                              defaultActiveKey={['1']}
+                              expandIcon={({ isActive }) => <CaretRightOutlined rotate={isActive ? 90 : 0} />}
+                              items={[
+                                {
+                                  key: '1',
+                                  label: `参考文档信息`,
+                                  // label: `查询到${message.sliceInfo?.doc?.length}个文共${message.sliceInfo?.allChunkNum}条切片`,
+                                  // label: `一`,
+                                  children: <div>
+                                    {message.sliceInfo?.docActive?.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: item!.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_info_list.length}
+                                        </div>
+                                      </div>
+                                    })}
+                                  </div>,
+                                }
+                              ]}
+                            />
+                            {
+                              drawerOpen &&
+                              <SliceDrawer />
+                            }
+                          </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' }}>
+                  {detail?.name}
+                </h1>
+                <p style={{ textAlign: 'center' }}>
+                  {detail?.desc}
+                </p>
+                <div className="flex flex-col items-center justify-center">
+                  <p className="text-left w-[320px]">我猜您可能想问:</p>
+                  <>
+                    {
+                      questionList.map((item, index) => {
+                        return (
+                          <div
+                            style={{
+                              width: '300px',
+                              padding: '10px',
+                              marginBottom: '10px',
+                              border: '1px solid #e6e8f1',
+                              borderRadius: '10px',
+                              fontSize: '14px',
+                              display: 'flex',
+                              justifyContent: 'space-between',
+                              alignItems: 'center',
+                              cursor: 'pointer'
+                            }}
+                            onClick={() => {
+                              setUserInput(item.question)
+                              doSubmit(item.question)
+                            }}
+                            key={index}
+                          >
+                            <div>
+                              {item.question}
+                            </div>
+                            <RightOutlined />
+                          </div>
+                        )
+                      })
+                    }
+                  </>
+                </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>
+          )}
+          {/*联网搜索按钮*/}
+          {/* <div className={styles["chat-input-lianwang"]} style={{
+              background: webSearch ? '#dee9fc': '#f3f4f6',
+              color: webSearch ? '#3875f6': '#000000',
+          }}
+            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 className={styles["chat-input-send"] + ' flex items-center justify-center'}>
+            {/* SpeechButton */}
+            <SpeechButton
+              onResult={(text: string) => {
+                // append or set the recognized text into input
+                setUserInput((prev) => (prev ? prev + (prev.endsWith(' ') ? '' : ' ') + text : text));
+              }}
+            />
+            <Button disabled={couldStop} className={styles['send_input'] + ' rounded-full'} type="primary" onClick={() => {
+              if (couldStop) {
+                stopAll();
+                setIsClickStop(true);
+              } else {
+                doSubmit(userInput);
+              }
+            }} icon={<ArrowUpOutlined />}></Button>
+          </div>
+
+          {/* <IconButton
+            icon={couldStop ? <div style={{ width: 13, height: 13, background: '#FFFFFF', borderRadius: 2 }}></div> : <SendWhiteIcon />}
+            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>;
+}

+ 120 - 0
app/components/deepSeekHome.scss

@@ -0,0 +1,120 @@
+.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, 0.5);
+    display: flex;
+    color: #FFFFFF;
+    // justify-content: center;
+    justify-content: space-between;
+    align-items: center;
+    overflow-x: auto;
+    overflow-y: hidden;
+    box-sizing: border-box;
+    // position: relative;
+    background-color: blur(10px);
+    background: rgba(24, 126, 255, 0.3);
+
+    &::before {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+
+    }
+  }
+
+  &-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;
+    }
+  }
+
+  &-menuds {
+    max-width: 130px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    cursor:pointer;
+  }
+}
+
+// 开放平台按钮样式
+.open-platform-btn {
+  @media (max-width: 768px) {
+    display: none; // 移动端隐藏
+  }
+
+  background: #FFFFFF;
+  color: #1890FF;
+  border: 1px solid #1890FF;
+  border-radius: 6px;
+  padding: 8px 16px;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  white-space: nowrap;
+
+  &:hover {
+    background: #F0F8FF;
+    border-color: #40A9FF;
+  }
+
+  &:active {
+    background: #E6F7FF;
+    border-color: #1890FF;
+  }
+}

+ 56 - 0
app/components/error.scss

@@ -0,0 +1,56 @@
+.error{
+    text-align: center;
+    padding: 50px;
+    font-family: Arial, sans-serif;
+    color: #333;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    width: 50% !important;
+      .error-icon {
+      font-size: 3rem;
+      color: #dc3545;
+      margin-bottom: 0.3rem;
+    }
+    .error-title {
+      font-size: 1.8rem;
+      font-weight: 600;
+      margin-bottom: 1rem;
+      color: #212529;
+    }
+    .error-desc {
+      font-size: 1rem;
+      line-height: 1.6;
+      margin-bottom: 2rem;
+      opacity: 0.8;
+    }
+    .error-actions {
+      display: flex;
+      gap: 1rem;
+      justify-content: center;
+    }
+     .btn {
+      padding: 0.75rem 1.5rem;
+      border-radius: 8px;
+      border: none;
+      font-weight: 500;
+      cursor: pointer;
+      transition: all 0.2s ease;
+    }
+    .btn-primary {
+      background-color: #007bff;
+      color: white;
+    }
+    .btn-primary:hover {
+      background-color: #0056b3;
+    }
+    .btn-outline {
+      background-color: transparent;
+      border: 1px solid #ced4da;
+      color: #495057;
+    }
+    .btn-outline:hover {
+      background-color: #f1f3f5;
+    }
+}

+ 81 - 0
app/components/error.tsx

@@ -0,0 +1,81 @@
+"use client";
+
+import React from "react";
+import { IconButton } from "./button";
+import ResetIcon from "../icons/reload.svg";
+import Locale from "../locales";
+import './error.scss';
+import { Button} from 'antd';
+import { Color } from "antd/es/color-picker";
+
+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">
+          <div className="error-icon">❌</div>
+          <h2 className="error-title">哎呀,出了点小问题!</h2>
+          <p className="error-desc">系统遇到了意外状况,别担心,我们的技术团队已经收到通知。您可以尝试刷新页面或清除数据来解决问题。</p>
+          <div className="error-actions">
+            {/* <IconButton
+              icon={<ResetIcon />}
+              text="Clear All Data"
+              onClick={() => {
+                this.clearAndSaveData();
+              }}
+              bordered
+            /> */}
+            <Button className="btn" type="primary" onClick={()=>{
+              location.reload();
+            }}> 刷新页面</Button>
+            <Button className="btn btn-outline" onClick={() => {
+                this.clearAndSaveData();
+              }}>清除所有数据</Button>
+          </div>
+
+          {/* 注释掉错误信息 */}
+          {/* <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 {}
+}

+ 719 - 0
app/components/exporter.tsx

@@ -0,0 +1,719 @@
+/* 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 className={styles["main-title"]}/>
+            <div className={styles["sub-title"]}/>
+            <div className={styles["icons"]}>
+              <ExportAvatar avatar={config.avatar} />
+              <span className={styles["icon-space"]}>&</span>
+              <ExportAvatar avatar={mask.avatar} />
+            </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>
+    </>
+  );
+}

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

@@ -0,0 +1,391 @@
+@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;
+  display: flex;
+  flex-direction: column;
+  box-shadow: none;
+  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);
+      background-color: transparent;
+      
+      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: flex-start;
+  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;
+}
+
+/* Narrow scrollbar for sidebar (max 2px) - WebKit and Firefox */
+.sidebar,
+.sidebar-body {
+  scrollbar-width: thin;
+  scrollbar-color: rgba(0,0,0,0.12) transparent;
+
+  &::-webkit-scrollbar {
+    width: 1px;
+    height: 1px;
+  }
+
+  &::-webkit-scrollbar-track {
+    background: transparent;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: rgba(0,0,0,0.12);
+    border-radius: 2px;
+  }
+
+  &::-webkit-scrollbar-thumb:hover {
+    background: rgba(0,0,0,0.18);
+  }
+}
+
+.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%;
+
+  .loading-dots {
+    font-size: 40px;
+    span {
+      animation: dot-blink 1.4s infinite both;
+    }
+    span:nth-child(2) {
+      animation-delay: 0.2s;
+    }
+    span:nth-child(3) {
+      animation-delay: 0.4s;
+    }
+  }
+}
+
+@keyframes dot-blink {
+  0% { opacity: 0.2; }
+  20% { opacity: 1; }
+  100% { opacity: 0.2; }
+}
+
+.rtl-screen {
+  direction: rtl;
+}

+ 510 - 0
app/components/home.tsx

@@ -0,0 +1,510 @@
+"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 avatarSrc from "../icons/avatar.png";
+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={avatarSrc.src} style={{width:'150px',height:'150px'}} />
+      <p className="mt-[-1px]">数据加载中<span className={styles["loading-dots"]}><span>.</span><span>.</span><span>.</span></span></p>
+
+      {/* 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 Welcome = dynamic(
+  async () => {
+    await delayer();
+    return (await import("../pages/index/welcome")).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 />,
+  }
+);
+
+const Login = dynamic(
+  async () => {
+    await delayer();
+    return (await import("../pages/login"))
+  },
+  {
+    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 !== '/' &&location.pathname !== '/login'&&
+          <SideBar className={styles["sidebar-show"]} />
+        }
+        <WindowContent>
+          <Routes>
+            {/* <Route path='/' element={<Login />} /> */}
+            <Route path='/login' element={<Login />} />
+            <Route path='/knowledgeChat' element={<Chat />} />
+            <Route path='/newChat' element={<Chat />} />
+            <Route path='/deepseekChat' element={<DeepSeekChat />} />
+            <Route path='/newDeepseekChat' element={<DeepSeekChat />} />
+            <Route path='/welcome' element={<Welcome />} />
+            <Route path='*' element={<Welcome />} />
+
+            {/* 关闭以下入口  后续有需求再开启*/}
+            {/* <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) => {
+    return
+    //测试环境
+    //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));
+    // }
+    // 如果有token就先去存数据
+    const hash = window.location.hash;
+    const queryString = hash.includes("?") ? hash.split("?")[1] : "";
+    const urlHachParams = new URLSearchParams(queryString);
+    const token = urlHachParams.get('token');
+    const nickName = urlHachParams.get('nickName');
+    const userId = urlHachParams.get('userId');
+    if(token) {
+      localStorage.setItem('userInfo', JSON.stringify({ token, nickName, userId }));
+      // 清除url中的token参数
+      if (window.history.replaceState) {
+        const cleanUrl = window.location.href.split("#")[0];
+        // http://localhost:4000/#/knowledgeChat?showMenu=true&chatMode=LOCAL&appId=2965620717148049408&userId=8&nickName=test&token=eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjQ3M2QzOWI0LTVhOGYtNGYxZS1iOGY
+        window.history.replaceState({}, document.title, `${cleanUrl}#/knowledgeChat?showMenu=true&chatMode=LOCAL&&appId=${urlHachParams.get('appId')}`);
+          // debugger
+      }
+    }
+
+
+
+    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 {
+
+      //判断是否是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 {
+        if (!userInfo) {
+          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>
+  );
+}

+ 321 - 0
app/components/markdown.tsx

@@ -0,0 +1,321 @@
+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 rehypeMathjaxChtml from 'rehype-mathjax/chtml';
+
+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) {
+  // 原有的 escapeDollarNumber 逻辑被移除,因为它会破坏以数字开头的公式
+  // 如果需要防止 $100 被识别为公式,应建议用户正确转义 \$100,或优化 preprocessLaTeX 的识别逻辑
+  return text;
+}
+
+function preprocessLaTeX(content: string) {
+  const pattern =
+    /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)|(\$\$[\s\S]*?\$\$)|(\$(?!\s)[\s\S]*?\S\$)/g;
+  
+  return content.replace(
+    pattern,
+    (match, codeBlock, squareBracket, roundBracket, doubleDollar, singleDollar) => {
+      if (codeBlock) {
+        return codeBlock;
+      }
+
+      let innerContent = "";
+      let isBlock = false;
+
+      if (squareBracket) {
+        innerContent = squareBracket;
+        isBlock = true;
+      } else if (roundBracket) {
+        innerContent = roundBracket;
+        isBlock = false;
+      } else if (doubleDollar) {
+        innerContent = doubleDollar.slice(2, -2);
+        isBlock = true;
+      } else if (singleDollar) {
+        innerContent = singleDollar.slice(1, -1);
+        isBlock = false;
+      } else {
+        return match;
+      }
+
+      // 修复1: \200,000 -> 200,000 (移除数字前的反斜杠)
+      innerContent = innerContent.replace(/\\(\d)/g, "$1");
+      // 修复2: 400;kg -> 400\;kg (分号间隔)
+      innerContent = innerContent.replace(/(\d+)\s*;\s*/g, "$1\\;");
+
+      return isBlock ? `$$${innerContent}$$` : `$${innerContent}$`;
+    },
+  );
+}
+
+function _MarkDownContent(props: { content: string }) {
+  const escapedContent = useMemo(() => {
+    return preprocessLaTeX(props.content);
+  }, [props.content]);
+
+  return (
+    <ReactMarkdown
+      remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
+      rehypePlugins={[
+        [RehypeKatex,
+          {
+            strict: 'ignore',
+            throwOnError: false, // 关键:即使有错误也不抛出异常,仅渲染错误内容
+            errorColor: '#ff0000', // 错误内容标红(StackEdit同款)
+            trust: true, // 允许更多语法,提升兼容性
+          }],
+        [
+          RehypeHighlight,
+          {
+            detect: false,
+            ignoreMissing: true,
+          },
+        ],
+      ]}
+      // 控制不同标签的显示样式
+      components={{
+        pre: PreCode,
+        head: () => (
+          <link
+            rel="stylesheet"
+            href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"
+            integrity="sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV"
+            crossOrigin="anonymous"
+          />
+        ),
+        code: ({ className, children }) => {
+          // console.log('className, children-----3',className, children)
+          if (className && className.includes('language-think')) {
+            return (
+              <code style={{ whiteSpace: 'pre-wrap', background: '#f3f4f6', color: '#525252' }}>
+                {children}
+              </code>
+            );
+          } else {
+            return children;
+          }
+        },
+        div: (pProps) => {
+          // console.log('pProps-----2',pProps)
+          return <div {...pProps} dir="auto" />
+        },
+        a: (aProps) => {
+          // console.log('aProps-----4',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 }) => (
+          // console.log('src, alt-----5',src, alt),
+          <span style={{ width: '100%', height: 'auto', cursor: 'pointer', display: 'inline-block' }}>
+            <Image
+              width='80%'
+              src={src}
+              alt={alt}
+              preview={{
+                mask: null
+              }}
+            />
+          </span>
+        ),
+      }}
+    >
+      {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;
+        }
+      }
+    }
+  }
+}

+ 707 - 0
app/components/mask.tsx

@@ -0,0 +1,707 @@
+import { IconButton } from "./button";
+import { ErrorBoundary } from "./error";
+
+import styles from "./mask.module.scss";
+
+import avatarSrc from "../icons/avatar.png";
+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" />
+          <img src={avatarSrc.src} 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>
+  );
+}

+ 23 - 0
app/components/sidebar.module.scss

@@ -0,0 +1,23 @@
+.sidebarContainer {
+    :global {
+
+        .ant-menu-item,
+        .ant-menu-submenu-title {
+            padding-left: 6px !important;
+        }
+        .ant-menu-sub{
+            .ant-menu-title-content{
+                padding-left: 10px !important;
+            }
+        }
+    }
+}
+.isMobildwid{
+    width: 100% !important;
+}
+.antSelectCustom {
+  :global(.ant-select-selection-placeholder) {
+    padding-left: 8px !important;
+    text-align: left !important;
+  }
+}

+ 1052 - 0
app/components/sidebar.tsx

@@ -0,0 +1,1052 @@
+import React, { useEffect, useRef, useMemo, useState, Fragment } from "react";
+import Image from 'next/image';
+import styles from "./home.module.scss";
+import newStyles from './sidebar.module.scss';
+import DragIcon from "../icons/drag.svg";
+import logoSrc from "../icons/logo.png";
+import deepSeekSrc from "../icons/deepSeek.png";
+import { AppstoreOutlined, EditOutlined, MenuOutlined, HomeOutlined, PlusOutlined, StarOutlined, CommentOutlined } from '@ant-design/icons';
+import * as AllIcons 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, getContrastColor } from "../utils";
+import api from "@/app/api/api";
+import { Button, Drawer, Dropdown, Empty, Form, Input, Menu, message, Modal, Rate, Tag, Select } from "antd";
+import { downloadFile } from "../utils/index";
+import dayjs from "dayjs";
+import type { DrawerProps, RadioChangeEvent } from 'antd';
+import '@/app/styles/common.scss'
+const FormItem = Form.Item;
+import { processSliceData } from "@/app/utils/index";
+
+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;
+
+  // shadow-sidebar
+  return (
+    <div
+      className={`${styles.sidebar} ${className} ${shouldNarrow && styles["narrow-sidebar"]} 
+        bg-light-sidebar  ${isMobileScreen && newStyles.isMobildwid}`}
+      style={{
+        transition: isMobileScreen && isIOSMobile ? "none" : undefined,
+        overflowY: "auto",
+      }}
+    >
+      {children}
+      <div
+        className={styles["sidebar-drag"]}
+        onPointerDown={(e) => onDragStart(e as any)}
+      >
+        <DragIcon />
+      </div>
+    </div>
+  );
+}
+// Sidebar 头部
+export function SideBarHeader(props: {
+  title?: string | React.ReactNode;
+  subTitle?: string | React.ReactNode;
+  logo?: React.ReactNode;
+  children?: React.ReactNode;
+}) {
+  const { title, subTitle, logo, children } = props;
+  const navigate = useNavigate();
+
+  return (
+    <Fragment>
+      <div className={`${styles["sidebar-header"]} cursor-pointer`} data-tauri-drag-region onClick={() => {
+        window.open('http://10.1.14.17:3200/appCenter')
+      }} >
+        <div className={styles["sidebar-logo"] + " no-dark"}>{logo}</div>
+        <div className={styles["sidebar-title-container"] + ' ml-[10px]'}>
+          <div className={`${styles["sidebar-title"]} text-gray-800`} data-tauri-drag-region>
+            {title}
+          </div>
+          <div className={`${styles["sidebar-sub-title"]} text-gray-500 text-sm`}>{subTitle}</div>
+        </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"]} scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent`} onClick={onClick}>
+      {children}
+    </div>
+  );
+}
+
+export function SideBarTail(props: {
+  primaryAction?: React.ReactNode;
+  secondaryAction?: React.ReactNode;
+}) {
+  const { primaryAction, secondaryAction } = props;
+
+  return (
+    <div className={`${styles["sidebar-tail"]} border-t border-gray-200 pt-4`}>
+      <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,
+}
+
+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','/welcome'].includes(location.pathname)) {
+      return 'deepSeek';
+    } else {
+      return 'bigModel';
+    }
+  }
+  // 获取应用类型 app_type
+  const fetchAppType = async () => {
+    try {
+      const res = await api.get(`/deepseek/api/app_type`);
+      // 解析返回并设置状态
+      if (res && res.data) {
+        setAppTypes([{ dictLabel: '收藏', dictValue: '收藏' }, ...res.data]);
+      }
+    } catch (error) {
+      console.error('Failed to fetch app types:', error);
+    }
+  }
+  // 获取应用列表
+  const fetchGetApplicationList = async (typeId?: string | null, name?: string) => {
+    setAppListLoading(true);
+    try {
+      const data = {
+        pageSize: 1000,
+        pageNum: 1,
+        userId: 1,
+        isCollect: typeId === '收藏' ? '1' : null,
+        typeId: typeId === '收藏' ? null : typeId,
+        name: name,
+      }
+      const res: any = await api.post('/deepseek/api/getApplicationList', data);
+      // 解析返回并设置状态
+      if (res && res.rows) {
+        setAppListState(res.rows);
+        if (name) {
+          setSearchOptions(res.rows.map((item: any) => ({ label: item.name, value: item.appId })));
+        }
+      }
+
+    } catch (error) {
+      console.error('Failed to fetch app list:', error);
+    } finally {
+      setAppListLoading(false);
+      setSearchFetching(false);
+    }
+  }
+  // 应用类型与列表状态
+  const [appTypes, setAppTypes] = useState<any[]>([]);
+  const [appListState, setAppListState] = useState<any[]>([]);
+  const [appListLoading, setAppListLoading] = useState<boolean>(false);
+  const [openKeys, setOpenKeys] = useState<string[]>([]); // 当前打开的菜单项
+  // 渲染应用列表为 Antd Menu(一级:类型,带图标;二级:该类型下的应用)
+  const previewAppList = () => {
+    const iconFor = (index: number) => {
+      const icons = [<AppstoreOutlined />, <StarOutlined />, <HomeOutlined />];
+      return icons[index % icons.length];
+    };
+
+    const items = appTypes && appTypes.length
+      ? appTypes.map((t: any, idx: number) => {
+        // 当该一级是当前打开项时,使用 appListState 作为 children(fetchGetApplicationList 填充)
+        const typeKey = `${t.dictValue}`;
+        let children = [] as any[];
+        // if (openKeys.includes(typeKey)) {
+        if (appListLoading) {
+          children = [{ key: `${typeKey}-loading`, label: <span>加载中...</span> }];
+        } else {
+          children = (appListState || []).map((a: any, i: number) => ({
+            key: a.appId || `app-${idx}-${i}`,
+            // label: a.dictLabel || a.name || a.appName || `应用 ${i}`,
+            label: a.iconColor ? (() => {
+              const C = (AllIcons as any)[a.iconType];
+              const iconColor = getContrastColor(a.iconColor);
+              return C ? <div className="flex items-center justify-start">
+                <p className="flex items-center justify-center" style={{ overflow: 'auto', background: a.iconColor, minWidth: '28px', width: 28, height: 28, borderRadius: 8, padding: 0, margin: 0, marginRight: 4 }}>
+                  <C style={{ fontSize: 28, color: iconColor }} />
+                </p>
+
+                <span className="truncate ml-2">
+                  {a.name || a.appName || `应用 ${i}`}
+                </span>
+              </div> : <span style={{ fontSize: 12 }}>{a.iconType}</span>
+            })() : <span className="truncate">
+              {a.name || a.appName || `应用 ${i}`}
+            </span>,
+            onClick: () => {
+              chatStore.updateCurrentSession((value) => {
+                value.appId = a.appId;
+              });
+              // 点击二级应用的处理:打印或导航(保留为 UI 先)
+              chatStore.clearSessions();
+              if (getType() === 'bigModel') {
+                globalStore.setSelectedAppId(a.appId);
+              } else {
+                const search = `?showMenu=false&chatMode=LOCAL&appId=${a.appId}`;
+                navigate({
+                  pathname: '/knowledgeChat',
+                  search: search,
+                })
+                globalStore.setSelectedAppId(a.appId);
+                // location.reload();
+              }
+            }
+          }));
+        }
+        // }
+        if (openKeys.includes(typeKey)) {
+          return {
+            key: t.dictValue,
+            icon: iconFor(idx),
+            label: t.dictLabel || `类型 ${idx}`,
+            children: children.length ? children : [{ key: `empty-${idx}`, label: <span className="text-xs text-gray-400">暂无应用</span>, }],
+          };
+        } else {
+          return {
+            key: t.dictValue,
+            icon: iconFor(idx),
+            label: t.dictLabel || `类型 ${idx}`,
+            children: [],
+          };
+        }
+      })
+      : [];
+    return (
+      <Menu
+        items={items}
+        mode="inline"
+        openKeys={openKeys}
+        selectable={false}
+        className={`bg-transparent p-[0] ${newStyles.sidebarContainer}`}
+        style={{ border: 'none', background: 'transparent' }}
+        onOpenChange={(e) => {
+          console.log('e', e);
+          if (e.length > 0) {
+            setOpenKeys(e.slice(-1));
+            fetchGetApplicationList(e.slice(-1)[0]);
+          } else {
+            setOpenKeys([]);
+          }
+        }}
+      />
+    );
+  }
+
+
+  // 获取聊天列表
+  const fetchChatList = async (chatMode?: 'ONLINE' | 'LOCAL') => {
+    try {
+      let url = '';
+      const appId = globalStore.selectedAppId;
+      if (appId) {
+        url = `/deepseek/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={(e: React.MouseEvent) => {
+                      e.preventDefault();
+                      e.stopPropagation();
+                      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('LOCAL');
+    }
+    // }
+  }, [globalStore.selectedAppId]);
+
+  useEffect(() => {
+    fetchAppType();
+    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');
+
+  // Select 远程搜索 UI 状态(UI-only 模拟)
+  const [searchOptions, setSearchOptions] = useState<any[]>([]);
+  const [searchFetching, setSearchFetching] = useState(false);
+
+  const handleSearch = (value: string) => {
+    console.log('search value', value);
+    if (!value) {
+      setSearchOptions([]);
+      return;
+    }
+    setSearchFetching(true);
+    fetchGetApplicationList(null, value);
+    // 模拟异步请求
+  };
+  const [placement, setPlacement] = useState<DrawerProps['placement']>('left');
+
+  return (
+    <>
+      {
+        isMobileScreen ? globalStore.showMenu &&
+          <Drawer
+            title="Basic Drawer"
+            placement={placement}
+            closable={true}
+            maskClosable={true}
+            onClose={(e) => {
+              console.log('close drawer');
+              e.preventDefault();
+              e.stopPropagation();
+              globalStore.setShowMenu(false);
+            }}
+            open={globalStore.showMenu}
+            key={placement}
+            style={{
+              // 1. 自定义 Drawer 整体背景色(包括头部、内容区)
+              background: 'none', // 浅灰背景,可替换为 #fff、rgb(255,255,255) 等
+              width: '100%',
+            }}
+            styles={{
+              mask: {
+                zIndex: 1000, // 遮罩层层级(原 maskStyle 中的配置)
+                background: 'rgba(0, 0, 0, 0.3)', // 遮罩层背景色/透明度
+                // 其他遮罩层样式均可在此配置,与原 maskStyle 用法一致
+              },
+            }}
+          >
+            <SideBarContainer
+              onDragStart={onDragStart}
+              shouldNarrow={shouldNarrow}
+              {...props}
+            >
+              {/* {
+            getType() === 'deepSeek' &&
+            <div>
+              <img style={{ width: '100%' }} src={deepSeekSrc.src} />
+            </div>
+          } */}
+              <SideBarHeader
+                title={getType() === 'bigModel' || true ?
+                  <div className="flex items-center">
+                    {/* {
+                      isMobileScreen && <div>
+                        <Button
+                          type='text'
+                          icon={<MenuOutlined />}
+                          onClick={() => {
+                            globalStore.setShowMenu(!globalStore.showMenu);
+                          }}
+                        />
+                      </div>
+                    } */}
+                    <img style={{ height: 40 }} src={logoSrc.src} />
+                    {/* 盈科 */}
+                  </div>
+                  :
+                  ''
+                }
+                // logo={getType() === 'bigModel' || true ? <img style={{ height: 40 }} src={logoSrc.src} /> : ''}
+              >
+                <div className="w-full">
+                  <Button type="primary"
+                    icon={<PlusOutlined />}
+                    className="border border-[#4096ff] text-[#4096ff] bg-transparent w-full mb-[10px]"
+                    onClick={async () => {
+                      chatStore.clearSessions();
+                      chatStore.updateCurrentSession((value) => {
+                        value.appId = globalStore.selectedAppId;
+                      });
+                      if (isMobileScreen) {
+                        globalStore.setShowMenu(false);
+                      }
+                      if (getType() === 'bigModel') {
+                        navigate({ pathname: '/newChat' });
+                      } else {
+                        message.info('请选择应用')
+                        // navigate({ pathname: '/newDeepseekChat' });
+                      }
+                      if (getType() === 'bigModel') {
+                        // if (chatStore.chatMode === 'LOCAL') {
+                          await fetchChatList(chatStore.chatMode);
+                        // } else {
+                          // await fetchChatList();
+                        // }
+                      } else {
+                        // await fetchChatList();
+                      }
+                    }}
+                  >
+                    新建对话
+                  </Button>
+                </div>
+                {/* 搜索框 - antd Select 远程搜索(UI only) */}
+                <div className="mb-[5px] text-left">
+                  <Select
+                    className="text-left bg-transparent w-full"
+                    showSearch
+                    allowClear
+                    placeholder="搜索应用名称"
+                    filterOption={false}
+                    onSearch={handleSearch}
+                    options={searchOptions}
+                    notFoundContent={searchFetching ? '搜索中...' : '无匹配'}
+                    onChange={(appId) => {
+                      // 选择后可触发打开抽屉或填写表单等动作(目前只做UI)
+                      console.log('select val', appId);
+                      chatStore.updateCurrentSession((value) => {
+                        value.appId = appId;
+                      });
+                      // 点击二级应用的处理:打印或导航(保留为 UI 先)
+                      chatStore.clearSessions();
+                      if (getType() === 'bigModel') {
+                        globalStore.setSelectedAppId(appId);
+                      } else {
+                        const search = `?showMenu=false&chatMode=LOCAL&appId=${appId}`;
+                        navigate({
+                          pathname: '/knowledgeChat',
+                          search: search,
+                        })
+                        globalStore.setSelectedAppId(appId);
+                        // location.reload();
+                      }
+                    }}
+                    style={{ width: '100%', textAlign: 'left', background: 'transparent' }}
+                  />
+                </div>
+                {/* 应用列表 */}
+                {previewAppList()}
+              </SideBarHeader>
+              {/* 最近对话 */}
+              {menuList.length > 0 && <p className="text-[14px] ml-[6px]"> <CommentOutlined /> 最近对话</p>}
+              <Menu
+                className="bg-transparent"
+                style={{ border: 'none', background: 'transparent' }}
+                selectable={false}
+                onClick={async (info: any) => {
+                  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}`;
+                  }
+                  const res = await api.get(url);
+                  const list = res.data.map(((item: any) => {
+                    if (item.sliceInfo) {
+                      let allChunkNum = 0;
+                      item.sliceInfo.doc.forEach((doc: any) => {
+                        allChunkNum += doc.chunk_nums;
+                      });
+                      item.sliceInfo.allChunkNum = allChunkNum;
+                      const values1 = item.sliceInfo?.doc;
+                      const result = processSliceData(values1);
+                      // 使用解构赋值,让结果更清晰
+                      const { withDeprecated, withoutDeprecated } = result;
+                      item.sliceInfo.docDeprecated = withDeprecated;
+                      item.sliceInfo.docActive = withoutDeprecated;
+                      console.log('item.sliceInfo', item.sliceInfo)
+                    }
+                    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,
+                      delSliceInfo: 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>
+          </Drawer> :
+          globalStore.showMenu && <SideBarContainer
+            onDragStart={onDragStart}
+            shouldNarrow={shouldNarrow}
+            {...props}
+          >
+            {/* {
+            getType() === 'deepSeek' &&
+            <div>
+              <img style={{ width: '100%' }} src={deepSeekSrc.src} />
+            </div>
+          } */}
+            <SideBarHeader
+              title={getType() === 'bigModel' || true ?
+                <div className="flex items-center">
+                  {
+                    isMobileScreen && <div>
+                      <Button
+                        type='text'
+                        icon={<MenuOutlined />}
+                        onClick={() => {
+                          globalStore.setShowMenu(!globalStore.showMenu);
+                        }}
+                      />
+                    </div>
+                  }
+                  <img style={{ height: 40 }} src={logoSrc.src} />
+                  {/* 盈科2 */}
+                </div>
+                :
+                ''
+              }
+              // logo={getType() === 'bigModel' || true ? <Logosvg></Logosvg> : ''}
+            >
+              <div className="w-full">
+                <Button type="primary"
+                  icon={<PlusOutlined />}
+                  className="border border-[#4096ff] text-[#4096ff] bg-transparent w-full mb-[10px]"
+                  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' });
+                      message.info('请选择应用')
+                    }
+                    if (getType() === 'bigModel') {
+                      if (chatStore.chatMode === 'LOCAL') {
+                        await fetchChatList(chatStore.chatMode);
+                      // } else {
+                        // await fetchChatList();
+                      }
+                    }
+                    //  else {
+                    //   await fetchChatList();
+                    // }
+                  }}
+                >
+                  新建对话
+                </Button>
+              </div>
+              {/* 搜索框 - antd Select 远程搜索(UI only) */}
+              <div className="mb-[5px] text-left">
+                <Select
+                  className="text-left bg-transparent w-full"
+                  showSearch
+                  allowClear
+                  placeholder="搜索应用名称"
+                  filterOption={false}
+                  onSearch={handleSearch}
+                  options={searchOptions}
+                  notFoundContent={searchFetching ? '搜索中...' : '无匹配'}
+                  onChange={(appId) => {
+                    // 选择后可触发打开抽屉或填写表单等动作(目前只做UI)
+                    console.log('select val', appId);
+                    // 选择后可触发打开抽屉或填写表单等动作(目前只做UI)
+                    console.log('select val', appId);
+                    chatStore.updateCurrentSession((value) => {
+                      value.appId = appId;
+                    });
+                    // 点击二级应用的处理:打印或导航(保留为 UI 先)
+                    chatStore.clearSessions();
+                    if (getType() === 'bigModel') {
+                      globalStore.setSelectedAppId(appId);
+                    } else {
+                      const search = `?showMenu=false&chatMode=LOCAL&appId=${appId}`;
+                      navigate({
+                        pathname: '/knowledgeChat',
+                        search: search,
+                      })
+                      globalStore.setSelectedAppId(appId);
+                      // location.reload();
+                    }
+                  }}
+                  style={{ width: '100%', textAlign: 'left', background: 'transparent' }}
+                />
+              </div>
+              {/* 应用列表 */}
+              {previewAppList()}
+            </SideBarHeader>
+            {/* 最近对话 */}
+            {menuList.length > 0 && <p className="text-[14px] ml-[6px]"> <CommentOutlined /> 最近对话</p>}
+            <Menu
+              className="bg-transparent"
+              style={{ border: 'none', background: 'transparent' }}
+              selectable={false}
+              onClick={async (info: any) => {
+                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}`;
+                }
+                const res = await api.get(url);
+                const list = res.data.map(((item: any) => {
+                  if (item.sliceInfo) {
+                    let allChunkNum = 0;
+                    item.sliceInfo.doc.forEach((doc: any) => {
+                      allChunkNum += doc.chunk_nums;
+                    });
+                    item.sliceInfo.allChunkNum = allChunkNum;
+                    const values1 = item.sliceInfo?.doc;
+                    // console.log('values1', values1);
+                    const result = processSliceData(values1);
+                    // console.log('result---',result)
+                    // 使用解构赋值,让结果更清晰
+                    const { withDeprecated, withoutDeprecated } = result;
+                    item.sliceInfo.docDeprecated = withDeprecated;
+                    item.sliceInfo.docActive = withoutDeprecated;
+                    // console.log('item.sliceInfo',item.sliceInfo)
+                  }
+                  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,
+                    delSliceInfo: 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>
+      }
+    </>
+  );
+}

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

+ 368 - 0
app/constant.ts

@@ -0,0 +1,368 @@
+export const OWNER = "ChatClientWeb";
+export const REPO = "chat-client-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-client-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 = "chat-client-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-longcontext",
+  "qwen-vl-plus",
+  "qwen-vl-max",
+];
+
+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-1.png


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-1.png


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>

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно