Compare commits
243 Commits
feature/aa
...
Leizhenpen
Author | SHA1 | Date | |
---|---|---|---|
|
81bd83eb44 | ||
|
b2b6fd81be | ||
|
f22cfd7b33 | ||
|
8111acff34 | ||
|
4cad55379d | ||
|
a3d3ce3f4c | ||
|
611e97e641 | ||
|
bfeea4ed49 | ||
|
bc71ae247b | ||
|
0112b54bc7 | ||
|
65810d918b | ||
|
4d535b1cd0 | ||
|
588d81e8f1 | ||
|
d4f499ee41 | ||
|
4d63d73b2e | ||
|
07c63497dc | ||
|
e440ff56c8 | ||
|
c89e4883b2 | ||
|
ac3d940de8 | ||
|
be59de56f0 | ||
|
a70e9a3c01 | ||
|
8aa9a500fd | ||
|
93652db688 | ||
|
8421c483e8 | ||
|
4ac27fdd4d | ||
|
b6b2c501fd | ||
|
ce13cf61a7 | ||
|
a3af563e89 | ||
|
e95c94d7be | ||
|
125a71fead | ||
|
b410ec399c | ||
|
7d51bfd42e | ||
|
0c14ce6417 | ||
|
f2a2b40d2c | ||
|
77be190d76 | ||
|
c56587c438 | ||
|
840c151ab9 | ||
|
0af04e0f2f | ||
|
d184eb6458 | ||
|
c5d9b1131e | ||
|
e13408dd24 | ||
|
aba4baf384 | ||
|
6d84f9d3ae | ||
|
63c5baaa80 | ||
|
defefba925 | ||
|
90c531c224 | ||
|
266e9efd2e | ||
|
57c88c0717 | ||
|
5b5dea1c59 | ||
|
d56566cd73 | ||
|
b5d104c908 | ||
|
f9e9129d52 | ||
|
2a8a18391e | ||
|
e1cb8e36fa | ||
|
b948d6bf86 | ||
|
fe67f79050 | ||
|
67338ff9b7 | ||
|
7380c8a2c1 | ||
|
e1ba8f1b0f | ||
|
c0062ff280 | ||
|
39e593da48 | ||
|
f8b10ad8b1 | ||
|
8a22c9d6db | ||
|
5f96804f3b | ||
|
13430ea3e2 | ||
|
664879b9df | ||
|
9df24e568b | ||
|
bc322be448 | ||
|
a867adaf04 | ||
|
0cb186846a | ||
|
e467ce028d | ||
|
cdfe907fb5 | ||
|
d91af7f983 | ||
|
c3108ad333 | ||
|
081daf937e | ||
|
0c3d4462ca | ||
|
3c859fc29f | ||
|
e1c7c54dfa | ||
|
87b5e3bf62 | ||
|
1d15666713 | ||
|
a127ae1fb4 | ||
|
ea1329f73e | ||
|
149d732cb7 | ||
|
210b29bfbe | ||
|
acc2e97aab | ||
|
93ac0e5017 | ||
|
ed8c3580c8 | ||
|
0a056a7c5c | ||
|
74c4711cdd | ||
|
eceec092cf | ||
|
42743410a8 | ||
|
0f04756d4c | ||
|
acdded8161 | ||
|
e939ce5a02 | ||
|
46a0b100f7 | ||
|
e27e8fb0e1 | ||
|
93c5320bf2 | ||
|
a433d1606c | ||
|
cc5e16b045 | ||
|
54f6feb2d7 | ||
|
e1ac0538b8 | ||
|
1a678cb4d8 | ||
|
83cea3a90d | ||
|
759a09a76c | ||
|
2623a92763 | ||
|
3932c594c7 | ||
|
b7acb89096 | ||
|
ef24d3e633 | ||
|
23350c842b | ||
|
a2adfbbd32 | ||
|
f22cec1eb4 | ||
|
e56216549e | ||
|
19facc7c85 | ||
|
b08ce5630c | ||
|
b41c012d27 | ||
|
a392daab71 | ||
|
0628ddfc6f | ||
|
7eda14f138 | ||
|
9a86c42c95 | ||
|
819d249a09 | ||
|
8d66fedb1f | ||
|
7cf89b53ce | ||
|
459c373f13 | ||
|
1d14a991ee | ||
|
05ef5adfa7 | ||
|
38fa3056df | ||
|
289aeec8af | ||
|
7d71da938f | ||
|
f8f6954115 | ||
|
6e03f32871 | ||
|
18a6571883 | ||
|
14f444e1f0 | ||
|
2b0f2e5f9d | ||
|
4629b39c29 | ||
|
d33e772fa5 | ||
|
89136fba32 | ||
|
8b4ca133fd | ||
|
a4c9eaf6cd | ||
|
50e63109a3 | ||
|
48a1e8a584 | ||
|
e44ebe3f0e | ||
|
108069a0c6 | ||
|
d5bda2904d | ||
|
283caba8ce | ||
|
b78e5db817 | ||
|
46c469b2d7 | ||
|
c00ebbea4f | ||
|
c526ff80b5 | ||
|
0037b0c944 | ||
|
6f81bb3b8a | ||
|
7bdc45ed3e | ||
|
88cd3ac122 | ||
|
4988d2ee26 | ||
|
8deb7a92ee | ||
|
db060d732a | ||
|
522627820a | ||
|
cf46d5ad63 | ||
|
a4941521d0 | ||
|
f6e1f8398b | ||
|
d544eead38 | ||
|
fbb9385f23 | ||
|
18144c3d9c | ||
|
64aa760e58 | ||
|
e0bbb8bb68 | ||
|
6667ee1c7f | ||
|
6ded4e96e7 | ||
|
85cdcab850 | ||
|
f4c9410c29 | ||
|
adf7d8200b | ||
|
3086a2fa77 | ||
|
f526d6f560 | ||
|
106461a1e7 | ||
|
c4e19dbc59 | ||
|
f3603e59fa | ||
|
8e2484fcdf | ||
|
00d6cb27f7 | ||
|
b844045d23 | ||
|
e49fe976d9 | ||
|
14f751965f | ||
|
0ec423389f | ||
|
820ab54e2d | ||
|
a6c1eb27a8 | ||
|
0dc4071ccc | ||
|
4d3949718a | ||
|
aef535f1a7 | ||
|
686a80e727 | ||
|
e49466fa05 | ||
|
4b93370814 | ||
|
5733e3c588 | ||
|
44fc5b5cbf | ||
|
2d3f7c922f | ||
|
fe8cca3730 | ||
|
fbb7a1e853 | ||
|
fb2c15567d | ||
|
c2c52a1f60 | ||
|
106ddc17cd | ||
|
17d5209738 | ||
|
d66bfc6352 | ||
|
4d75b23ed1 | ||
|
36bfa2ef7c | ||
|
afe12c212e | ||
|
adf97c6d8b | ||
|
7a8d557ea3 | ||
|
d3f0a77830 | ||
|
0581e37236 | ||
|
44383a8b33 | ||
|
7c466c9b9c | ||
|
a0fa4d7e72 | ||
|
d357b45e84 | ||
|
d0bd1bf8fd | ||
|
86ffa1e643 | ||
|
b0d28eb77e | ||
|
736cbdbdd1 | ||
|
613d67eada | ||
|
89cea18955 | ||
|
56bc77d20b | ||
|
6d93d37963 | ||
|
24df85cf9d | ||
|
a4d7a2c6e3 | ||
|
49d42bb45d | ||
|
4f49626303 | ||
|
45db20c1c3 | ||
|
82994843f5 | ||
|
1110a087a0 | ||
|
f0b3e10a6c | ||
|
f89872b833 | ||
|
90ced92876 | ||
|
2c74559010 | ||
|
e3ca7e8b44 | ||
|
4745706c42 | ||
|
801dc412f9 | ||
|
c7c2c0211a | ||
|
65bb962fc0 | ||
|
e791cd441d | ||
|
8455fefc8a | ||
|
06f897f32f | ||
|
deb1e76c41 | ||
|
463fa743e9 | ||
|
064e964d75 | ||
|
47fb40d572 | ||
|
9e18cc260b | ||
|
4c63ee23cd | ||
|
03268ce4d8 |
@@ -7,6 +7,11 @@ CODE=your-password
|
||||
# You can start service behind a proxy. (optional)
|
||||
PROXY_URL=http://localhost:7890
|
||||
|
||||
# Enable MCP functionality (optional)
|
||||
# Default: Empty (disabled)
|
||||
# Set to "true" to enable MCP functionality
|
||||
ENABLE_MCP=
|
||||
|
||||
# (optional)
|
||||
# Default: Empty
|
||||
# Google Gemini Pro API key, set if you want to use Google Gemini Pro API.
|
||||
|
@@ -1 +1,2 @@
|
||||
public/serviceWorker.js
|
||||
public/serviceWorker.js
|
||||
app/mcp/mcp_config.json
|
3
.gitignore
vendored
@@ -46,3 +46,6 @@ dev
|
||||
*.key.pub
|
||||
|
||||
masks.json
|
||||
|
||||
# mcp config
|
||||
app/mcp/mcp_config.json
|
||||
|
@@ -34,12 +34,16 @@ ENV PROXY_URL=""
|
||||
ENV OPENAI_API_KEY=""
|
||||
ENV GOOGLE_API_KEY=""
|
||||
ENV CODE=""
|
||||
ENV ENABLE_MCP=""
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/.next/server ./.next/server
|
||||
|
||||
RUN mkdir -p /app/app/mcp && chmod 777 /app/app/mcp
|
||||
COPY --from=builder /app/app/mcp/mcp_config.json /app/app/mcp/
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD if [ -n "$PROXY_URL" ]; then \
|
||||
|
164
README.md
@@ -1,16 +1,20 @@
|
||||
<div align="center">
|
||||
|
||||
<a href='#企业版'>
|
||||
<img src="./docs/images/ent.svg" alt="icon"/>
|
||||
<a href='https://nextchat.dev/chat'>
|
||||
<img src="https://github.com/user-attachments/assets/287c510f-f508-478e-ade3-54d30453dc18" width="1000" alt="icon"/>
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
<h1 align="center">NextChat (ChatGPT Next Web)</h1>
|
||||
|
||||
English / [简体中文](./README_CN.md)
|
||||
|
||||
One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 & Gemini Pro support.
|
||||
<a href="https://trendshift.io/repositories/5973" target="_blank"><img src="https://trendshift.io/api/badge/repositories/5973" alt="ChatGPTNextWeb%2FChatGPT-Next-Web | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
一键免费部署你的跨平台私人 ChatGPT 应用, 支持 GPT3, GPT4 & Gemini Pro 模型。
|
||||
|
||||
|
||||
One-Click to get a well-designed cross-platform ChatGPT web UI, with Claude, GPT4 & Gemini Pro support.
|
||||
|
||||
[![Saas][Saas-image]][saas-url]
|
||||
[![Web][Web-image]][web-url]
|
||||
@@ -18,9 +22,8 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4
|
||||
[![MacOS][MacOS-image]][download-url]
|
||||
[![Linux][Linux-image]][download-url]
|
||||
|
||||
[NextChatAI](https://nextchat.dev/chat?utm_source=readme) / [Web App](https://app.nextchat.dev) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Discord](https://discord.gg/YCkeafCafC) / [Enterprise Edition](#enterprise-edition) / [Twitter](https://twitter.com/NextChatDev)
|
||||
[NextChatAI](https://nextchat.dev/chat?utm_source=readme) / [Web App Demo](https://app.nextchat.dev) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Discord](https://discord.gg/YCkeafCafC) / [Enterprise Edition](#enterprise-edition) / [Twitter](https://twitter.com/NextChatDev)
|
||||
|
||||
[NextChatAI](https://nextchat.dev/chat) / [网页版](https://app.nextchat.dev) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [企业版](#%E4%BC%81%E4%B8%9A%E7%89%88) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues)
|
||||
|
||||
[saas-url]: https://nextchat.dev/chat?utm_source=readme
|
||||
[saas-image]: https://img.shields.io/badge/NextChat-Saas-green?logo=microsoftedge
|
||||
@@ -31,12 +34,18 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4
|
||||
[MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple
|
||||
[Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu
|
||||
|
||||
[<img src="https://vercel.com/button" alt="Deploy on Zeabur" height="30">](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30">](https://zeabur.com/templates/ZBUEFA) [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Open in Gitpod" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
[<img src="https://vercel.com/button" alt="Deploy on Vercel" height="30">](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30">](https://zeabur.com/templates/ZBUEFA) [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Open in Gitpod" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) [<img src="https://img.shields.io/badge/BT_Deploy-Install-20a53a" alt="BT Deply Install" height="30">](https://www.bt.cn/new/download.html)
|
||||
|
||||
[<img src="https://github.com/user-attachments/assets/903482d4-3e87-4134-9af1-f2588fa90659" height="60" width="288" >](https://monica.im/?utm=nxcrp)
|
||||
|
||||
</div>
|
||||
|
||||
## 🫣 NextChat Support MCP !
|
||||
> Before build, please set env ENABLE_MCP=true
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/d8851f40-4e36-4335-b1a4-ec1e11488c7e"/>
|
||||
|
||||
|
||||
## Enterprise Edition
|
||||
|
||||
Meeting Your Company's Privatization and Customization Deployment Requirements:
|
||||
@@ -50,20 +59,12 @@ Meeting Your Company's Privatization and Customization Deployment Requirements:
|
||||
|
||||
For enterprise inquiries, please contact: **business@nextchat.dev**
|
||||
|
||||
## 企业版
|
||||
## Screenshots
|
||||
|
||||
满足企业用户私有化部署和个性化定制需求:
|
||||
- **品牌定制**:企业量身定制 VI/UI,与企业品牌形象无缝契合
|
||||
- **资源集成**:由企业管理人员统一配置和管理数十种 AI 资源,团队成员开箱即用
|
||||
- **权限管理**:成员权限、资源权限、知识库权限层级分明,企业级 Admin Panel 统一控制
|
||||
- **知识接入**:企业内部知识库与 AI 能力相结合,比通用 AI 更贴近企业自身业务需求
|
||||
- **安全审计**:自动拦截敏感提问,支持追溯全部历史对话记录,让 AI 也能遵循企业信息安全规范
|
||||
- **私有部署**:企业级私有部署,支持各类主流私有云部署,确保数据安全和隐私保护
|
||||
- **持续更新**:提供多模态、智能体等前沿能力持续更新升级服务,常用常新、持续先进
|
||||

|
||||
|
||||
企业版咨询: **business@nextchat.dev**
|
||||

|
||||
|
||||
<img width="300" src="https://github.com/user-attachments/assets/3d4305ac-6e95-489e-884b-51d51db5f692">
|
||||
|
||||
## Features
|
||||
|
||||
@@ -96,10 +97,11 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
|
||||
- [x] Artifacts: Easily preview, copy and share generated content/webpages through a separate window [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
|
||||
- [x] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
|
||||
- [x] network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
|
||||
- [x] Supports Realtime Chat [#5672](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5672)
|
||||
- [ ] local knowledge base
|
||||
|
||||
## What's New
|
||||
|
||||
- 🚀 v2.15.8 Now supports Realtime Chat [#5672](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5672)
|
||||
- 🚀 v2.15.4 The Application supports using Tauri fetch LLM API, MORE SECURITY! [#5379](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5379)
|
||||
- 🚀 v2.15.0 Now supports Plugins! Read this: [NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins)
|
||||
- 🚀 v2.14.0 Now supports Artifacts & SD
|
||||
@@ -109,49 +111,8 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
|
||||
- 🚀 v2.7 let's share conversations as image, or share to ShareGPT!
|
||||
- 🚀 v2.0 is released, now you can create prompt templates, turn your ideas into reality! Read this: [ChatGPT Prompt Engineering Tips: Zero, One and Few Shot Prompting](https://www.allabtai.com/prompt-engineering-tips-zero-one-and-few-shot-prompting/).
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 在 1 分钟内使用 Vercel **免费一键部署**
|
||||
- 提供体积极小(~5MB)的跨平台客户端(Linux/Windows/MacOS), [下载地址](https://github.com/Yidadaa/ChatGPT-Next-Web/releases)
|
||||
- 完整的 Markdown 支持:LaTex 公式、Mermaid 流程图、代码高亮等等
|
||||
- 精心设计的 UI,响应式设计,支持深色模式,支持 PWA
|
||||
- 极快的首屏加载速度(~100kb),支持流式响应
|
||||
- 隐私安全,所有数据保存在用户浏览器本地
|
||||
- 预制角色功能(面具),方便地创建、分享和调试你的个性化对话
|
||||
- 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts)
|
||||
- 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话
|
||||
- 多国语言支持:English, 简体中文, 繁体中文, 日本語, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어, Indonesia
|
||||
- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问
|
||||
|
||||
## 开发计划
|
||||
|
||||
- [x] 为每个对话设置系统 Prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)
|
||||
- [x] 允许用户自行编辑内置 Prompt 列表
|
||||
- [x] 预制角色:使用预制角色快速定制新对话 [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993)
|
||||
- [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
||||
- [x] 使用 tauri 打包桌面应用
|
||||
- [x] 支持自部署的大语言模型:开箱即用 [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) ,服务端部署 [LocalAI 项目](https://github.com/go-skynet/LocalAI) llama / gpt4all / rwkv / vicuna / koala / gpt4all-j / cerebras / falcon / dolly 等等,或者使用 [api-for-open-llm](https://github.com/xusenlinzy/api-for-open-llm)
|
||||
- [x] Artifacts: 通过独立窗口,轻松预览、复制和分享生成的内容/可交互网页 [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
|
||||
- [x] 插件机制,支持`联网搜索`、`计算器`、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
|
||||
- [x] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
|
||||
- [ ] 本地知识库
|
||||
|
||||
## 最新动态
|
||||
|
||||
- 🚀 v2.15.4 客户端支持Tauri本地直接调用大模型API,更安全
|
||||
- 🚀 v2.15.0 现在支持插件功能了!了解更多:[NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins)
|
||||
- 🚀 v2.14.0 现在支持 Artifacts & SD 了。
|
||||
- 🚀 v2.10.1 现在支持 Gemini Pro 模型。
|
||||
- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
|
||||
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
|
||||
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
|
||||
- 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
|
||||
- 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
|
||||
|
||||
## Get Started
|
||||
|
||||
> [简体中文 > 如何开始使用](./README_CN.md#开始使用)
|
||||
|
||||
1. Get [OpenAI API Key](https://platform.openai.com/account/api-keys);
|
||||
2. Click
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web), remember that `CODE` is your page password;
|
||||
@@ -159,14 +120,10 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
|
||||
|
||||
## FAQ
|
||||
|
||||
[简体中文 > 常见问题](./docs/faq-cn.md)
|
||||
|
||||
[English > FAQ](./docs/faq-en.md)
|
||||
|
||||
## Keep Updated
|
||||
|
||||
> [简体中文 > 如何保持代码更新](./README_CN.md#保持更新)
|
||||
|
||||
If you have deployed your own project with just one click following the steps above, you may encounter the issue of "Updates Available" constantly showing up. This is because Vercel will create a new project for you by default instead of forking this project, resulting in the inability to detect updates correctly.
|
||||
|
||||
We recommend that you follow the steps below to re-deploy:
|
||||
@@ -193,8 +150,6 @@ You can star or watch this project or follow author to get release notifications
|
||||
|
||||
## Access Password
|
||||
|
||||
> [简体中文 > 如何增加访问密码](./README_CN.md#配置页面访问密码)
|
||||
|
||||
This project provides limited access control. Please add an environment variable named `CODE` on the vercel environment variables page. The value should be passwords separated by comma like this:
|
||||
|
||||
```
|
||||
@@ -205,8 +160,6 @@ After adding or modifying this environment variable, please redeploy the project
|
||||
|
||||
## Environment Variables
|
||||
|
||||
> [简体中文 > 如何配置 api key、访问密码、接口代理](./README_CN.md#环境变量)
|
||||
|
||||
### `CODE` (optional)
|
||||
|
||||
Access password, separated by comma.
|
||||
@@ -301,6 +254,22 @@ iflytek Api Key.
|
||||
|
||||
iflytek Api Secret.
|
||||
|
||||
### `CHATGLM_API_KEY` (optional)
|
||||
|
||||
ChatGLM Api Key.
|
||||
|
||||
### `CHATGLM_URL` (optional)
|
||||
|
||||
ChatGLM Api Url.
|
||||
|
||||
### `DEEPSEEK_API_KEY` (optional)
|
||||
|
||||
DeepSeek Api Key.
|
||||
|
||||
### `DEEPSEEK_URL` (optional)
|
||||
|
||||
DeepSeek Api Url.
|
||||
|
||||
### `HIDE_USER_API_KEY` (optional)
|
||||
|
||||
> Default: Empty
|
||||
@@ -345,6 +314,13 @@ For ByteDance: use `modelName@bytedance=deploymentName` to customize model name
|
||||
|
||||
Change default model
|
||||
|
||||
### `VISION_MODELS` (optional)
|
||||
|
||||
> Default: Empty
|
||||
> Example: `gpt-4-vision,claude-3-opus,my-custom-model` means add vision capabilities to these models in addition to the default pattern matches (which detect models containing keywords like "vision", "claude-3", "gemini-1.5", etc).
|
||||
|
||||
Add additional models to have vision capabilities, beyond the default pattern matching. Multiple models should be separated by commas.
|
||||
|
||||
### `WHITE_WEBDAV_ENDPOINTS` (optional)
|
||||
|
||||
You can use this option if you want to increase the number of webdav service addresses you are allowed to access, as required by the format:
|
||||
@@ -364,13 +340,18 @@ Stability API key.
|
||||
|
||||
Customize Stability API url.
|
||||
|
||||
|
||||
### `ENABLE_MCP` (optional)
|
||||
|
||||
Enable MCP(Model Context Protocol)Feature
|
||||
|
||||
|
||||
## Requirements
|
||||
|
||||
NodeJS >= 18, Docker >= 20
|
||||
|
||||
## Development
|
||||
|
||||
> [简体中文 > 如何进行二次开发](./README_CN.md#开发)
|
||||
|
||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
|
||||
@@ -395,7 +376,6 @@ yarn dev
|
||||
|
||||
## Deployment
|
||||
|
||||
> [简体中文 > 如何部署到私人服务器](./README_CN.md#部署)
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
@@ -424,6 +404,16 @@ If your proxy needs password, use:
|
||||
-e PROXY_URL="http://127.0.0.1:7890 user pass"
|
||||
```
|
||||
|
||||
If enable MCP, use:
|
||||
|
||||
```
|
||||
docker run -d -p 3000:3000 \
|
||||
-e OPENAI_API_KEY=sk-xxxx \
|
||||
-e CODE=your-password \
|
||||
-e ENABLE_MCP=true \
|
||||
yidadaa/chatgpt-next-web
|
||||
```
|
||||
|
||||
### Shell
|
||||
|
||||
```shell
|
||||
@@ -444,11 +434,7 @@ bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/s
|
||||
- [How to use Vercel (No English)](./docs/vercel-cn.md)
|
||||
- [User Manual (Only Chinese, WIP)](./docs/user-manual-cn.md)
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Translation
|
||||
|
||||
@@ -460,37 +446,7 @@ If you want to add a new translation, read this [document](./docs/translation.md
|
||||
|
||||
## Special Thanks
|
||||
|
||||
### Sponsor
|
||||
|
||||
> 仅列出捐赠金额 >= 100RMB 的用户。
|
||||
|
||||
[@mushan0x0](https://github.com/mushan0x0)
|
||||
[@ClarenceDan](https://github.com/ClarenceDan)
|
||||
[@zhangjia](https://github.com/zhangjia)
|
||||
[@hoochanlon](https://github.com/hoochanlon)
|
||||
[@relativequantum](https://github.com/relativequantum)
|
||||
[@desenmeng](https://github.com/desenmeng)
|
||||
[@webees](https://github.com/webees)
|
||||
[@chazzhou](https://github.com/chazzhou)
|
||||
[@hauy](https://github.com/hauy)
|
||||
[@Corwin006](https://github.com/Corwin006)
|
||||
[@yankunsong](https://github.com/yankunsong)
|
||||
[@ypwhs](https://github.com/ypwhs)
|
||||
[@fxxxchao](https://github.com/fxxxchao)
|
||||
[@hotic](https://github.com/hotic)
|
||||
[@WingCH](https://github.com/WingCH)
|
||||
[@jtung4](https://github.com/jtung4)
|
||||
[@micozhu](https://github.com/micozhu)
|
||||
[@jhansion](https://github.com/jhansion)
|
||||
[@Sha1rholder](https://github.com/Sha1rholder)
|
||||
[@AnsonHyq](https://github.com/AnsonHyq)
|
||||
[@synwith](https://github.com/synwith)
|
||||
[@piksonGit](https://github.com/piksonGit)
|
||||
[@ouyangzhiping](https://github.com/ouyangzhiping)
|
||||
[@wenjiavv](https://github.com/wenjiavv)
|
||||
[@LeXwDeX](https://github.com/LeXwDeX)
|
||||
[@Licoy](https://github.com/Licoy)
|
||||
[@shangmin2009](https://github.com/shangmin2009)
|
||||
|
||||
### Contributors
|
||||
|
||||
|
44
README_CN.md
@@ -6,7 +6,7 @@
|
||||
|
||||
<h1 align="center">NextChat</h1>
|
||||
|
||||
一键免费部署你的私人 ChatGPT 网页应用,支持 GPT3, GPT4 & Gemini Pro 模型。
|
||||
一键免费部署你的私人 ChatGPT 网页应用,支持 Claude, GPT4 & Gemini Pro 模型。
|
||||
|
||||
[NextChatAI](https://nextchat.dev/chat?utm_source=readme) / [企业版](#%E4%BC%81%E4%B8%9A%E7%89%88) / [演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N)
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
|
||||
企业版咨询: **business@nextchat.dev**
|
||||
|
||||
<img width="300" src="https://github.com/user-attachments/assets/3daeb7b6-ab63-4542-9141-2e4a12c80601">
|
||||
<img width="300" src="https://github.com/user-attachments/assets/bb29a11d-ff75-48a8-b1f8-d2d7238cf987">
|
||||
|
||||
|
||||
## 开始使用
|
||||
|
||||
@@ -184,6 +185,21 @@ ByteDance Api Url.
|
||||
|
||||
讯飞星火Api Secret.
|
||||
|
||||
### `CHATGLM_API_KEY` (可选)
|
||||
|
||||
ChatGLM Api Key.
|
||||
|
||||
### `CHATGLM_URL` (可选)
|
||||
|
||||
ChatGLM Api Url.
|
||||
|
||||
### `DEEPSEEK_API_KEY` (可选)
|
||||
|
||||
DeepSeek Api Key.
|
||||
|
||||
### `DEEPSEEK_URL` (可选)
|
||||
|
||||
DeepSeek Api Url.
|
||||
|
||||
|
||||
### `HIDE_USER_API_KEY` (可选)
|
||||
@@ -228,6 +244,13 @@ ByteDance Api Url.
|
||||
|
||||
更改默认模型
|
||||
|
||||
### `VISION_MODELS` (可选)
|
||||
|
||||
> 默认值:空
|
||||
> 示例:`gpt-4-vision,claude-3-opus,my-custom-model` 表示为这些模型添加视觉能力,作为对默认模式匹配的补充(默认会检测包含"vision"、"claude-3"、"gemini-1.5"等关键词的模型)。
|
||||
|
||||
在默认模式匹配之外,添加更多具有视觉能力的模型。多个模型用逗号分隔。
|
||||
|
||||
### `DEFAULT_INPUT_TEMPLATE` (可选)
|
||||
|
||||
自定义默认的 template,用于初始化『设置』中的『用户输入预处理』配置项
|
||||
@@ -240,6 +263,10 @@ Stability API密钥
|
||||
|
||||
自定义的Stability API请求地址
|
||||
|
||||
### `ENABLE_MCP` (optional)
|
||||
|
||||
启用MCP(Model Context Protocol)功能
|
||||
|
||||
|
||||
## 开发
|
||||
|
||||
@@ -264,6 +291,9 @@ BASE_URL=https://b.nextweb.fun/api/proxy
|
||||
|
||||
## 部署
|
||||
|
||||
### 宝塔面板部署
|
||||
> [简体中文 > 如何通过宝塔一键部署](./docs/bt-cn.md)
|
||||
|
||||
### 容器部署 (推荐)
|
||||
|
||||
> Docker 版本需要在 20 及其以上,否则会提示找不到镜像。
|
||||
@@ -290,6 +320,16 @@ docker run -d -p 3000:3000 \
|
||||
yidadaa/chatgpt-next-web
|
||||
```
|
||||
|
||||
如需启用 MCP 功能,可以使用:
|
||||
|
||||
```shell
|
||||
docker run -d -p 3000:3000 \
|
||||
-e OPENAI_API_KEY=sk-xxxx \
|
||||
-e CODE=页面访问密码 \
|
||||
-e ENABLE_MCP=true \
|
||||
yidadaa/chatgpt-next-web
|
||||
```
|
||||
|
||||
如果你的本地代理需要账号密码,可以使用:
|
||||
|
||||
```shell
|
||||
|
@@ -217,6 +217,13 @@ ByteDance モードでは、`modelName@bytedance=deploymentName` 形式でモデ
|
||||
|
||||
デフォルトのモデルを変更します。
|
||||
|
||||
### `VISION_MODELS` (オプション)
|
||||
|
||||
> デフォルト:空
|
||||
> 例:`gpt-4-vision,claude-3-opus,my-custom-model` は、これらのモデルにビジョン機能を追加します。これはデフォルトのパターンマッチング("vision"、"claude-3"、"gemini-1.5"などのキーワードを含むモデルを検出)に加えて適用されます。
|
||||
|
||||
デフォルトのパターンマッチングに加えて、追加のモデルにビジョン機能を付与します。複数のモデルはカンマで区切ります。
|
||||
|
||||
### `DEFAULT_INPUT_TEMPLATE` (オプション)
|
||||
|
||||
『設定』の『ユーザー入力前処理』の初期設定に使用するテンプレートをカスタマイズします。
|
||||
|
@@ -10,6 +10,9 @@ import { handle as alibabaHandler } from "../../alibaba";
|
||||
import { handle as moonshotHandler } from "../../moonshot";
|
||||
import { handle as stabilityHandler } from "../../stability";
|
||||
import { handle as iflytekHandler } from "../../iflytek";
|
||||
import { handle as deepseekHandler } from "../../deepseek";
|
||||
import { handle as xaiHandler } from "../../xai";
|
||||
import { handle as chatglmHandler } from "../../glm";
|
||||
import { handle as proxyHandler } from "../../proxy";
|
||||
|
||||
async function handle(
|
||||
@@ -38,6 +41,12 @@ async function handle(
|
||||
return stabilityHandler(req, { params });
|
||||
case ApiPath.Iflytek:
|
||||
return iflytekHandler(req, { params });
|
||||
case ApiPath.DeepSeek:
|
||||
return deepseekHandler(req, { params });
|
||||
case ApiPath.XAI:
|
||||
return xaiHandler(req, { params });
|
||||
case ApiPath.ChatGLM:
|
||||
return chatglmHandler(req, { params });
|
||||
case ApiPath.OpenAI:
|
||||
return openaiHandler(req, { params });
|
||||
default:
|
||||
|
@@ -8,7 +8,7 @@ import {
|
||||
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 { isModelNotavailableInServer } from "@/app/utils/model";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
@@ -89,7 +89,7 @@ async function request(req: NextRequest) {
|
||||
|
||||
// not undefined and is false
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
isModelNotavailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.Alibaba as string,
|
||||
|
@@ -9,7 +9,7 @@ import {
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "./auth";
|
||||
import { isModelAvailableInServer } from "@/app/utils/model";
|
||||
import { isModelNotavailableInServer } from "@/app/utils/model";
|
||||
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||
|
||||
const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
|
||||
@@ -122,7 +122,7 @@ async function request(req: NextRequest) {
|
||||
|
||||
// not undefined and is false
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
isModelNotavailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.Anthropic as string,
|
||||
|
@@ -92,6 +92,15 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
|
||||
systemApiKey =
|
||||
serverConfig.iflytekApiKey + ":" + serverConfig.iflytekApiSecret;
|
||||
break;
|
||||
case ModelProvider.DeepSeek:
|
||||
systemApiKey = serverConfig.deepseekApiKey;
|
||||
break;
|
||||
case ModelProvider.XAI:
|
||||
systemApiKey = serverConfig.xaiApiKey;
|
||||
break;
|
||||
case ModelProvider.ChatGLM:
|
||||
systemApiKey = serverConfig.chatglmApiKey;
|
||||
break;
|
||||
case ModelProvider.GPT:
|
||||
default:
|
||||
if (req.nextUrl.pathname.includes("azure/deployments")) {
|
||||
|
@@ -8,7 +8,7 @@ import {
|
||||
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 { isModelNotavailableInServer } from "@/app/utils/model";
|
||||
import { getAccessToken } from "@/app/utils/baidu";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
@@ -104,7 +104,7 @@ async function request(req: NextRequest) {
|
||||
|
||||
// not undefined and is false
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
isModelNotavailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.Baidu as string,
|
||||
|
@@ -8,7 +8,7 @@ import {
|
||||
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 { isModelNotavailableInServer } from "@/app/utils/model";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
@@ -88,7 +88,7 @@ async function request(req: NextRequest) {
|
||||
|
||||
// not undefined and is false
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
isModelNotavailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.ByteDance as string,
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSideConfig } from "../config/server";
|
||||
import { OPENAI_BASE_URL, ServiceProvider } from "../constant";
|
||||
import { isModelAvailableInServer } from "../utils/model";
|
||||
import { cloudflareAIGatewayUrl } from "../utils/cloudflare";
|
||||
import { getModelProvider, isModelNotavailableInServer } from "../utils/model";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function requestOpenai(req: NextRequest) {
|
||||
.filter((v) => !!v && !v.startsWith("-") && v.includes(modelName))
|
||||
.forEach((m) => {
|
||||
const [fullName, displayName] = m.split("=");
|
||||
const [_, providerName] = fullName.split("@");
|
||||
const [_, providerName] = getModelProvider(fullName);
|
||||
if (providerName === "azure" && !displayName) {
|
||||
const [_, deployId] = (serverConfig?.azureUrl ?? "").split(
|
||||
"deployments/",
|
||||
@@ -118,15 +118,14 @@ export async function requestOpenai(req: NextRequest) {
|
||||
|
||||
// not undefined and is false
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
isModelNotavailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.OpenAI as string,
|
||||
) ||
|
||||
isModelAvailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.Azure as string,
|
||||
[
|
||||
ServiceProvider.OpenAI,
|
||||
ServiceProvider.Azure,
|
||||
jsonBody?.model as string, // support provider-unspecified model
|
||||
],
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
|
@@ -14,6 +14,7 @@ const DANGER_CONFIG = {
|
||||
disableFastLink: serverConfig.disableFastLink,
|
||||
customModels: serverConfig.customModels,
|
||||
defaultModel: serverConfig.defaultModel,
|
||||
visionModels: serverConfig.visionModels,
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
128
app/api/deepseek.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
DEEPSEEK_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 { isModelNotavailableInServer } from "@/app/utils/model";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[DeepSeek Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.DeepSeek);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(req);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[DeepSeek] ", 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.DeepSeek, "");
|
||||
|
||||
let baseUrl = serverConfig.deepseekUrl || DEEPSEEK_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 (
|
||||
isModelNotavailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.DeepSeek as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[DeepSeek] 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
app/api/glm.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
CHATGLM_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 { isModelNotavailableInServer } from "@/app/utils/model";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[GLM Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.ChatGLM);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(req);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[GLM] ", 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.ChatGLM, "");
|
||||
|
||||
let baseUrl = serverConfig.chatglmUrl || CHATGLM_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}`;
|
||||
console.log("[Fetch Url] ", fetchUrl);
|
||||
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 (
|
||||
isModelNotavailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.ChatGLM as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[GLM] 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);
|
||||
}
|
||||
}
|
@@ -8,7 +8,7 @@ import {
|
||||
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 { isModelNotavailableInServer } from "@/app/utils/model";
|
||||
// iflytek
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
@@ -89,7 +89,7 @@ async function request(req: NextRequest) {
|
||||
|
||||
// not undefined and is false
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
isModelNotavailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.Iflytek as string,
|
||||
|
@@ -8,7 +8,7 @@ import {
|
||||
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 { isModelNotavailableInServer } from "@/app/utils/model";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
@@ -88,7 +88,7 @@ async function request(req: NextRequest) {
|
||||
|
||||
// not undefined and is false
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
isModelNotavailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.Moonshot as string,
|
||||
|
@@ -14,7 +14,7 @@ function getModels(remoteModelRes: OpenAIListModelResponse) {
|
||||
if (config.disableGPT4) {
|
||||
remoteModelRes.data = remoteModelRes.data.filter(
|
||||
(m) =>
|
||||
!(m.id.startsWith("gpt-4") || m.id.startsWith("chatgpt-4o")) ||
|
||||
!(m.id.startsWith("gpt-4") || m.id.startsWith("chatgpt-4o") || m.id.startsWith("o1")) ||
|
||||
m.id.startsWith("gpt-4o-mini"),
|
||||
);
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
@@ -9,6 +10,7 @@ export async function handle(
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
// remove path params from searchParams
|
||||
req.nextUrl.searchParams.delete("path");
|
||||
@@ -31,6 +33,18 @@ export async function handle(
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
// if dalle3 use openai api key
|
||||
const baseUrl = req.headers.get("x-base-url");
|
||||
if (baseUrl?.includes("api.openai.com")) {
|
||||
if (!serverConfig.apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "OpenAI API key not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
headers.set("Authorization", `Bearer ${serverConfig.apiKey}`);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const fetchOptions: RequestInit = {
|
||||
headers,
|
||||
|
128
app/api/xai.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
XAI_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 { isModelNotavailableInServer } from "@/app/utils/model";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[XAI Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.XAI);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(req);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[XAI] ", 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.XAI, "");
|
||||
|
||||
let baseUrl = serverConfig.xaiUrl || XAI_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 (
|
||||
isModelNotavailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.XAI as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[XAI] 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);
|
||||
}
|
||||
}
|
@@ -20,6 +20,9 @@ import { QwenApi } from "./platforms/alibaba";
|
||||
import { HunyuanApi } from "./platforms/tencent";
|
||||
import { MoonshotApi } from "./platforms/moonshot";
|
||||
import { SparkApi } from "./platforms/iflytek";
|
||||
import { DeepSeekApi } from "./platforms/deepseek";
|
||||
import { XAIApi } from "./platforms/xai";
|
||||
import { ChatGLMApi } from "./platforms/glm";
|
||||
|
||||
export const ROLES = ["system", "user", "assistant"] as const;
|
||||
export type MessageRole = (typeof ROLES)[number];
|
||||
@@ -68,7 +71,7 @@ export interface ChatOptions {
|
||||
config: LLMConfig;
|
||||
|
||||
onUpdate?: (message: string, chunk: string) => void;
|
||||
onFinish: (message: string) => void;
|
||||
onFinish: (message: string, responseRes: Response) => void;
|
||||
onError?: (err: Error) => void;
|
||||
onController?: (controller: AbortController) => void;
|
||||
onBeforeTool?: (tool: ChatMessageTool) => void;
|
||||
@@ -152,6 +155,15 @@ export class ClientApi {
|
||||
case ModelProvider.Iflytek:
|
||||
this.llm = new SparkApi();
|
||||
break;
|
||||
case ModelProvider.DeepSeek:
|
||||
this.llm = new DeepSeekApi();
|
||||
break;
|
||||
case ModelProvider.XAI:
|
||||
this.llm = new XAIApi();
|
||||
break;
|
||||
case ModelProvider.ChatGLM:
|
||||
this.llm = new ChatGLMApi();
|
||||
break;
|
||||
default:
|
||||
this.llm = new ChatGPTApi();
|
||||
}
|
||||
@@ -239,6 +251,9 @@ export function getHeaders(ignoreHeaders: boolean = false) {
|
||||
const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba;
|
||||
const isMoonshot = modelConfig.providerName === ServiceProvider.Moonshot;
|
||||
const isIflytek = modelConfig.providerName === ServiceProvider.Iflytek;
|
||||
const isDeepSeek = modelConfig.providerName === ServiceProvider.DeepSeek;
|
||||
const isXAI = modelConfig.providerName === ServiceProvider.XAI;
|
||||
const isChatGLM = modelConfig.providerName === ServiceProvider.ChatGLM;
|
||||
const isEnabledAccessControl = accessStore.enabledAccessControl();
|
||||
const apiKey = isGoogle
|
||||
? accessStore.googleApiKey
|
||||
@@ -252,6 +267,12 @@ export function getHeaders(ignoreHeaders: boolean = false) {
|
||||
? accessStore.alibabaApiKey
|
||||
: isMoonshot
|
||||
? accessStore.moonshotApiKey
|
||||
: isXAI
|
||||
? accessStore.xaiApiKey
|
||||
: isDeepSeek
|
||||
? accessStore.deepseekApiKey
|
||||
: isChatGLM
|
||||
? accessStore.chatglmApiKey
|
||||
: isIflytek
|
||||
? accessStore.iflytekApiKey && accessStore.iflytekApiSecret
|
||||
? accessStore.iflytekApiKey + ":" + accessStore.iflytekApiSecret
|
||||
@@ -266,6 +287,9 @@ export function getHeaders(ignoreHeaders: boolean = false) {
|
||||
isAlibaba,
|
||||
isMoonshot,
|
||||
isIflytek,
|
||||
isDeepSeek,
|
||||
isXAI,
|
||||
isChatGLM,
|
||||
apiKey,
|
||||
isEnabledAccessControl,
|
||||
};
|
||||
@@ -286,6 +310,13 @@ export function getHeaders(ignoreHeaders: boolean = false) {
|
||||
isAzure,
|
||||
isAnthropic,
|
||||
isBaidu,
|
||||
isByteDance,
|
||||
isAlibaba,
|
||||
isMoonshot,
|
||||
isIflytek,
|
||||
isDeepSeek,
|
||||
isXAI,
|
||||
isChatGLM,
|
||||
apiKey,
|
||||
isEnabledAccessControl,
|
||||
} = getConfig();
|
||||
@@ -328,6 +359,12 @@ export function getClientApi(provider: ServiceProvider): ClientApi {
|
||||
return new ClientApi(ModelProvider.Moonshot);
|
||||
case ServiceProvider.Iflytek:
|
||||
return new ClientApi(ModelProvider.Iflytek);
|
||||
case ServiceProvider.DeepSeek:
|
||||
return new ClientApi(ModelProvider.DeepSeek);
|
||||
case ServiceProvider.XAI:
|
||||
return new ClientApi(ModelProvider.XAI);
|
||||
case ServiceProvider.ChatGLM:
|
||||
return new ClientApi(ModelProvider.ChatGLM);
|
||||
default:
|
||||
return new ClientApi(ModelProvider.GPT);
|
||||
}
|
||||
|
@@ -143,6 +143,7 @@ export class QwenApi implements LLMApi {
|
||||
let responseText = "";
|
||||
let remainText = "";
|
||||
let finished = false;
|
||||
let responseRes: Response;
|
||||
|
||||
// animate response to make it looks smooth
|
||||
function animateResponseText() {
|
||||
@@ -172,7 +173,7 @@ export class QwenApi implements LLMApi {
|
||||
const finish = () => {
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
options.onFinish(responseText + remainText);
|
||||
options.onFinish(responseText + remainText, responseRes);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -188,6 +189,7 @@ export class QwenApi implements LLMApi {
|
||||
"[Alibaba] request response content type: ",
|
||||
contentType,
|
||||
);
|
||||
responseRes = res;
|
||||
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
@@ -254,7 +256,7 @@ export class QwenApi implements LLMApi {
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
|
@@ -13,6 +13,7 @@ import { getMessageTextContent, isVisionModel } from "@/app/utils";
|
||||
import { preProcessImageContent, stream } from "@/app/utils/chat";
|
||||
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||
import { RequestPayload } from "./openai";
|
||||
import { fetch } from "@/app/utils/stream";
|
||||
|
||||
export type MultiBlockContent = {
|
||||
type: "image" | "text";
|
||||
@@ -316,13 +317,14 @@ export class ClaudeApi implements LLMApi {
|
||||
};
|
||||
|
||||
try {
|
||||
controller.signal.onabort = () => options.onFinish("");
|
||||
controller.signal.onabort = () =>
|
||||
options.onFinish("", new Response(null, { status: 400 }));
|
||||
|
||||
const res = await fetch(path, payload);
|
||||
const resJson = await res.json();
|
||||
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
options.onFinish(message, res);
|
||||
} catch (e) {
|
||||
console.error("failed to chat", e);
|
||||
options.onError?.(e as Error);
|
||||
|
@@ -162,6 +162,7 @@ export class ErnieApi implements LLMApi {
|
||||
let responseText = "";
|
||||
let remainText = "";
|
||||
let finished = false;
|
||||
let responseRes: Response;
|
||||
|
||||
// animate response to make it looks smooth
|
||||
function animateResponseText() {
|
||||
@@ -191,7 +192,7 @@ export class ErnieApi implements LLMApi {
|
||||
const finish = () => {
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
options.onFinish(responseText + remainText);
|
||||
options.onFinish(responseText + remainText, responseRes);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -204,7 +205,7 @@ export class ErnieApi implements LLMApi {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log("[Baidu] request response content type: ", contentType);
|
||||
|
||||
responseRes = res;
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
return finish();
|
||||
@@ -267,7 +268,7 @@ export class ErnieApi implements LLMApi {
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = resJson?.result;
|
||||
options.onFinish(message);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
|
@@ -130,6 +130,7 @@ export class DoubaoApi implements LLMApi {
|
||||
let responseText = "";
|
||||
let remainText = "";
|
||||
let finished = false;
|
||||
let responseRes: Response;
|
||||
|
||||
// animate response to make it looks smooth
|
||||
function animateResponseText() {
|
||||
@@ -159,7 +160,7 @@ export class DoubaoApi implements LLMApi {
|
||||
const finish = () => {
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
options.onFinish(responseText + remainText);
|
||||
options.onFinish(responseText + remainText, responseRes);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -175,7 +176,7 @@ export class DoubaoApi implements LLMApi {
|
||||
"[ByteDance] request response content type: ",
|
||||
contentType,
|
||||
);
|
||||
|
||||
responseRes = res;
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
return finish();
|
||||
@@ -241,7 +242,7 @@ export class DoubaoApi implements LLMApi {
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
|
200
app/client/platforms/deepseek.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
// azure and openai, using same models. so using same LLMApi.
|
||||
import {
|
||||
ApiPath,
|
||||
DEEPSEEK_BASE_URL,
|
||||
DeepSeek,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
} from "@/app/constant";
|
||||
import {
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
ChatMessageTool,
|
||||
usePluginStore,
|
||||
} from "@/app/store";
|
||||
import { stream } from "@/app/utils/chat";
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { getMessageTextContent } from "@/app/utils";
|
||||
import { RequestPayload } from "./openai";
|
||||
import { fetch } from "@/app/utils/stream";
|
||||
|
||||
export class DeepSeekApi implements LLMApi {
|
||||
private disableListModels = true;
|
||||
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.deepseekUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
const apiPath = ApiPath.DeepSeek;
|
||||
baseUrl = isApp ? DEEPSEEK_BASE_URL : apiPath;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.DeepSeek)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
return res.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const messages: ChatOptions["messages"] = [];
|
||||
for (const v of options.messages) {
|
||||
const content = getMessageTextContent(v);
|
||||
messages.push({ role: v.role, content });
|
||||
}
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
providerName: options.config.providerName,
|
||||
},
|
||||
};
|
||||
|
||||
const requestPayload: RequestPayload = {
|
||||
messages,
|
||||
stream: options.config.stream,
|
||||
model: modelConfig.model,
|
||||
temperature: modelConfig.temperature,
|
||||
presence_penalty: modelConfig.presence_penalty,
|
||||
frequency_penalty: modelConfig.frequency_penalty,
|
||||
top_p: modelConfig.top_p,
|
||||
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
|
||||
// Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
|
||||
};
|
||||
|
||||
console.log("[Request] openai payload: ", requestPayload);
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPath = this.path(DeepSeek.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) {
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
return stream(
|
||||
chatPath,
|
||||
requestPayload,
|
||||
getHeaders(),
|
||||
tools as any,
|
||||
funcs,
|
||||
controller,
|
||||
// parseSSE
|
||||
(text: string, runTools: ChatMessageTool[]) => {
|
||||
// console.log("parseSSE", text, runTools);
|
||||
const json = JSON.parse(text);
|
||||
const choices = json.choices as Array<{
|
||||
delta: {
|
||||
content: string;
|
||||
tool_calls: ChatMessageTool[];
|
||||
};
|
||||
}>;
|
||||
const tool_calls = choices[0]?.delta?.tool_calls;
|
||||
if (tool_calls?.length > 0) {
|
||||
const index = tool_calls[0]?.index;
|
||||
const id = tool_calls[0]?.id;
|
||||
const args = tool_calls[0]?.function?.arguments;
|
||||
if (id) {
|
||||
runTools.push({
|
||||
id,
|
||||
type: tool_calls[0]?.type,
|
||||
function: {
|
||||
name: tool_calls[0]?.function?.name as string,
|
||||
arguments: args,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// @ts-ignore
|
||||
runTools[index]["function"]["arguments"] += args;
|
||||
}
|
||||
}
|
||||
return choices[0]?.delta?.content;
|
||||
},
|
||||
// processToolMessage, include tool_calls message and tool call results
|
||||
(
|
||||
requestPayload: RequestPayload,
|
||||
toolCallMessage: any,
|
||||
toolCallResult: any[],
|
||||
) => {
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.splice(
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.length,
|
||||
0,
|
||||
toolCallMessage,
|
||||
...toolCallResult,
|
||||
);
|
||||
},
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} 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 [];
|
||||
}
|
||||
}
|
293
app/client/platforms/glm.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
import {
|
||||
ApiPath,
|
||||
CHATGLM_BASE_URL,
|
||||
ChatGLM,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
} from "@/app/constant";
|
||||
import {
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
ChatMessageTool,
|
||||
usePluginStore,
|
||||
} from "@/app/store";
|
||||
import { stream } from "@/app/utils/chat";
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { getMessageTextContent, isVisionModel } from "@/app/utils";
|
||||
import { RequestPayload } from "./openai";
|
||||
import { fetch } from "@/app/utils/stream";
|
||||
import { preProcessImageContent } from "@/app/utils/chat";
|
||||
|
||||
interface BasePayload {
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface ChatPayload extends BasePayload {
|
||||
messages: ChatOptions["messages"];
|
||||
stream?: boolean;
|
||||
temperature?: number;
|
||||
presence_penalty?: number;
|
||||
frequency_penalty?: number;
|
||||
top_p?: number;
|
||||
}
|
||||
|
||||
interface ImageGenerationPayload extends BasePayload {
|
||||
prompt: string;
|
||||
size?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
interface VideoGenerationPayload extends BasePayload {
|
||||
prompt: string;
|
||||
duration?: number;
|
||||
resolution?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
type ModelType = "chat" | "image" | "video";
|
||||
|
||||
export class ChatGLMApi implements LLMApi {
|
||||
private disableListModels = true;
|
||||
|
||||
private getModelType(model: string): ModelType {
|
||||
if (model.startsWith("cogview-")) return "image";
|
||||
if (model.startsWith("cogvideo-")) return "video";
|
||||
return "chat";
|
||||
}
|
||||
|
||||
private getModelPath(type: ModelType): string {
|
||||
switch (type) {
|
||||
case "image":
|
||||
return ChatGLM.ImagePath;
|
||||
case "video":
|
||||
return ChatGLM.VideoPath;
|
||||
default:
|
||||
return ChatGLM.ChatPath;
|
||||
}
|
||||
}
|
||||
|
||||
private createPayload(
|
||||
messages: ChatOptions["messages"],
|
||||
modelConfig: any,
|
||||
options: ChatOptions,
|
||||
): BasePayload {
|
||||
const modelType = this.getModelType(modelConfig.model);
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const prompt =
|
||||
typeof lastMessage.content === "string"
|
||||
? lastMessage.content
|
||||
: lastMessage.content.map((c) => c.text).join("\n");
|
||||
|
||||
switch (modelType) {
|
||||
case "image":
|
||||
return {
|
||||
model: modelConfig.model,
|
||||
prompt,
|
||||
size: options.config.size,
|
||||
} as ImageGenerationPayload;
|
||||
default:
|
||||
return {
|
||||
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,
|
||||
} as ChatPayload;
|
||||
}
|
||||
}
|
||||
|
||||
private parseResponse(modelType: ModelType, json: any): string {
|
||||
switch (modelType) {
|
||||
case "image": {
|
||||
const imageUrl = json.data?.[0]?.url;
|
||||
return imageUrl ? `` : "";
|
||||
}
|
||||
case "video": {
|
||||
const videoUrl = json.data?.[0]?.url;
|
||||
return videoUrl ? `<video controls src="${videoUrl}"></video>` : "";
|
||||
}
|
||||
default:
|
||||
return this.extractMessage(json);
|
||||
}
|
||||
}
|
||||
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.chatglmUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
const apiPath = ApiPath.ChatGLM;
|
||||
baseUrl = isApp ? CHATGLM_BASE_URL : apiPath;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.ChatGLM)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
return [baseUrl, path].join("/");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
return res.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const visionModel = isVisionModel(options.config.model);
|
||||
const messages: ChatOptions["messages"] = [];
|
||||
for (const v of options.messages) {
|
||||
const content = visionModel
|
||||
? await preProcessImageContent(v.content)
|
||||
: getMessageTextContent(v);
|
||||
messages.push({ role: v.role, content });
|
||||
}
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
providerName: options.config.providerName,
|
||||
},
|
||||
};
|
||||
const modelType = this.getModelType(modelConfig.model);
|
||||
const requestPayload = this.createPayload(messages, modelConfig, options);
|
||||
const path = this.path(this.getModelPath(modelType));
|
||||
|
||||
console.log(`[Request] glm ${modelType} payload: `, requestPayload);
|
||||
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
headers: getHeaders(),
|
||||
};
|
||||
|
||||
const requestTimeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (modelType === "image" || modelType === "video") {
|
||||
const res = await fetch(path, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
console.log(`[Response] glm ${modelType}:`, resJson);
|
||||
const message = this.parseResponse(modelType, resJson);
|
||||
options.onFinish(message, res);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
if (shouldStream) {
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
return stream(
|
||||
path,
|
||||
requestPayload,
|
||||
getHeaders(),
|
||||
tools as any,
|
||||
funcs,
|
||||
controller,
|
||||
// parseSSE
|
||||
(text: string, runTools: ChatMessageTool[]) => {
|
||||
const json = JSON.parse(text);
|
||||
const choices = json.choices as Array<{
|
||||
delta: {
|
||||
content: string;
|
||||
tool_calls: ChatMessageTool[];
|
||||
};
|
||||
}>;
|
||||
const tool_calls = choices[0]?.delta?.tool_calls;
|
||||
if (tool_calls?.length > 0) {
|
||||
const index = tool_calls[0]?.index;
|
||||
const id = tool_calls[0]?.id;
|
||||
const args = tool_calls[0]?.function?.arguments;
|
||||
if (id) {
|
||||
runTools.push({
|
||||
id,
|
||||
type: tool_calls[0]?.type,
|
||||
function: {
|
||||
name: tool_calls[0]?.function?.name as string,
|
||||
arguments: args,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// @ts-ignore
|
||||
runTools[index]["function"]["arguments"] += args;
|
||||
}
|
||||
}
|
||||
return choices[0]?.delta?.content;
|
||||
},
|
||||
// processToolMessage
|
||||
(
|
||||
requestPayload: RequestPayload,
|
||||
toolCallMessage: any,
|
||||
toolCallResult: any[],
|
||||
) => {
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.splice(
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.length,
|
||||
0,
|
||||
toolCallMessage,
|
||||
...toolCallResult,
|
||||
);
|
||||
},
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
const res = await fetch(path, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} 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 [];
|
||||
}
|
||||
}
|
@@ -29,7 +29,7 @@ import { RequestPayload } from "./openai";
|
||||
import { fetch } from "@/app/utils/stream";
|
||||
|
||||
export class GeminiProApi implements LLMApi {
|
||||
path(path: string): string {
|
||||
path(path: string, shouldStream = false): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
@@ -51,15 +51,27 @@ export class GeminiProApi implements LLMApi {
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
let chatPath = [baseUrl, path].join("/");
|
||||
if (shouldStream) {
|
||||
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
|
||||
}
|
||||
|
||||
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
|
||||
return chatPath;
|
||||
}
|
||||
extractMessage(res: any) {
|
||||
console.log("[Response] gemini-pro response: ", res);
|
||||
|
||||
const getTextFromParts = (parts: any[]) => {
|
||||
if (!Array.isArray(parts)) return "";
|
||||
|
||||
return parts
|
||||
.map((part) => part?.text || "")
|
||||
.filter((text) => text.trim() !== "")
|
||||
.join("\n\n");
|
||||
};
|
||||
|
||||
return (
|
||||
res?.candidates?.at(0)?.content?.parts.at(0)?.text ||
|
||||
getTextFromParts(res?.candidates?.at(0)?.content?.parts) ||
|
||||
getTextFromParts(res?.at(0)?.candidates?.at(0)?.content?.parts) ||
|
||||
res?.error?.message ||
|
||||
""
|
||||
);
|
||||
@@ -166,7 +178,10 @@ export class GeminiProApi implements LLMApi {
|
||||
options.onController?.(controller);
|
||||
try {
|
||||
// https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb
|
||||
const chatPath = this.path(Google.ChatPath(modelConfig.model));
|
||||
const chatPath = this.path(
|
||||
Google.ChatPath(modelConfig.model),
|
||||
shouldStream,
|
||||
);
|
||||
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
@@ -192,7 +207,10 @@ export class GeminiProApi implements LLMApi {
|
||||
requestPayload,
|
||||
getHeaders(),
|
||||
// @ts-ignore
|
||||
[{ functionDeclarations: tools.map((tool) => tool.function) }],
|
||||
tools.length > 0
|
||||
? // @ts-ignore
|
||||
[{ functionDeclarations: tools.map((tool) => tool.function) }]
|
||||
: [],
|
||||
funcs,
|
||||
controller,
|
||||
// parseSSE
|
||||
@@ -214,7 +232,10 @@ export class GeminiProApi implements LLMApi {
|
||||
},
|
||||
});
|
||||
}
|
||||
return chunkJson?.candidates?.at(0)?.content.parts.at(0)?.text;
|
||||
return chunkJson?.candidates
|
||||
?.at(0)
|
||||
?.content.parts?.map((part: { text: string }) => part.text)
|
||||
.join("\n\n");
|
||||
},
|
||||
// processToolMessage, include tool_calls message and tool call results
|
||||
(
|
||||
@@ -271,7 +292,7 @@ export class GeminiProApi implements LLMApi {
|
||||
);
|
||||
}
|
||||
const message = apiClient.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
|
@@ -117,6 +117,7 @@ export class SparkApi implements LLMApi {
|
||||
let responseText = "";
|
||||
let remainText = "";
|
||||
let finished = false;
|
||||
let responseRes: Response;
|
||||
|
||||
// Animate response text to make it look smooth
|
||||
function animateResponseText() {
|
||||
@@ -143,7 +144,7 @@ export class SparkApi implements LLMApi {
|
||||
const finish = () => {
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
options.onFinish(responseText + remainText);
|
||||
options.onFinish(responseText + remainText, responseRes);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,7 +157,7 @@ export class SparkApi implements LLMApi {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log("[Spark] request response content type: ", contentType);
|
||||
|
||||
responseRes = res;
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
return finish();
|
||||
@@ -231,7 +232,7 @@ export class SparkApi implements LLMApi {
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
|
@@ -24,6 +24,7 @@ import {
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { getMessageTextContent } from "@/app/utils";
|
||||
import { RequestPayload } from "./openai";
|
||||
import { fetch } from "@/app/utils/stream";
|
||||
|
||||
export class MoonshotApi implements LLMApi {
|
||||
private disableListModels = true;
|
||||
@@ -179,7 +180,7 @@ export class MoonshotApi implements LLMApi {
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
|
@@ -24,7 +24,7 @@ import {
|
||||
stream,
|
||||
} from "@/app/utils/chat";
|
||||
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||
import { DalleSize, DalleQuality, DalleStyle } from "@/app/typing";
|
||||
import { ModelSize, DalleQuality, DalleStyle } from "@/app/typing";
|
||||
|
||||
import {
|
||||
ChatOptions,
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
isVisionModel,
|
||||
isDalle3 as _isDalle3,
|
||||
} from "@/app/utils";
|
||||
import { fetch } from "@/app/utils/stream";
|
||||
|
||||
export interface OpenAIListModelResponse {
|
||||
object: string;
|
||||
@@ -64,6 +65,7 @@ export interface RequestPayload {
|
||||
frequency_penalty: number;
|
||||
top_p: number;
|
||||
max_tokens?: number;
|
||||
max_completion_tokens?: number;
|
||||
}
|
||||
|
||||
export interface DalleRequestPayload {
|
||||
@@ -71,7 +73,7 @@ export interface DalleRequestPayload {
|
||||
prompt: string;
|
||||
response_format: "url" | "b64_json";
|
||||
n: number;
|
||||
size: DalleSize;
|
||||
size: ModelSize;
|
||||
quality: DalleQuality;
|
||||
style: DalleStyle;
|
||||
}
|
||||
@@ -222,7 +224,7 @@ export class ChatGPTApi implements LLMApi {
|
||||
// O1 not support image, tools (plugin in ChatGPTNextWeb) and system, stream, logprobs, temperature, top_p, n, presence_penalty, frequency_penalty yet.
|
||||
requestPayload = {
|
||||
messages,
|
||||
stream: !isO1 ? options.config.stream : false,
|
||||
stream: options.config.stream,
|
||||
model: modelConfig.model,
|
||||
temperature: !isO1 ? modelConfig.temperature : 1,
|
||||
presence_penalty: !isO1 ? modelConfig.presence_penalty : 0,
|
||||
@@ -232,6 +234,11 @@ export class ChatGPTApi implements LLMApi {
|
||||
// Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
|
||||
};
|
||||
|
||||
// O1 使用 max_completion_tokens 控制token数 (https://platform.openai.com/docs/guides/reasoning#controlling-costs)
|
||||
if (isO1) {
|
||||
requestPayload["max_completion_tokens"] = modelConfig.max_tokens;
|
||||
}
|
||||
|
||||
// add max_tokens to vision model
|
||||
if (visionModel) {
|
||||
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
|
||||
@@ -240,7 +247,7 @@ export class ChatGPTApi implements LLMApi {
|
||||
|
||||
console.log("[Request] openai payload: ", requestPayload);
|
||||
|
||||
const shouldStream = !isDalle3 && !!options.config.stream && !isO1;
|
||||
const shouldStream = !isDalle3 && !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
@@ -360,7 +367,7 @@ export class ChatGPTApi implements LLMApi {
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = await this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
|
@@ -142,6 +142,7 @@ export class HunyuanApi implements LLMApi {
|
||||
let responseText = "";
|
||||
let remainText = "";
|
||||
let finished = false;
|
||||
let responseRes: Response;
|
||||
|
||||
// animate response to make it looks smooth
|
||||
function animateResponseText() {
|
||||
@@ -171,7 +172,7 @@ export class HunyuanApi implements LLMApi {
|
||||
const finish = () => {
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
options.onFinish(responseText + remainText);
|
||||
options.onFinish(responseText + remainText, responseRes);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -187,7 +188,7 @@ export class HunyuanApi implements LLMApi {
|
||||
"[Tencent] request response content type: ",
|
||||
contentType,
|
||||
);
|
||||
|
||||
responseRes = res;
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
return finish();
|
||||
@@ -253,7 +254,7 @@ export class HunyuanApi implements LLMApi {
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
|
193
app/client/platforms/xai.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
// azure and openai, using same models. so using same LLMApi.
|
||||
import { ApiPath, XAI_BASE_URL, XAI, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||
import {
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
ChatMessageTool,
|
||||
usePluginStore,
|
||||
} from "@/app/store";
|
||||
import { stream } from "@/app/utils/chat";
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { getMessageTextContent } from "@/app/utils";
|
||||
import { RequestPayload } from "./openai";
|
||||
import { fetch } from "@/app/utils/stream";
|
||||
|
||||
export class XAIApi implements LLMApi {
|
||||
private disableListModels = true;
|
||||
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.xaiUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
const apiPath = ApiPath.XAI;
|
||||
baseUrl = isApp ? XAI_BASE_URL : apiPath;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.XAI)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
return res.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
console.log("[Request] xai payload: ", requestPayload);
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPath = this.path(XAI.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) {
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
return stream(
|
||||
chatPath,
|
||||
requestPayload,
|
||||
getHeaders(),
|
||||
tools as any,
|
||||
funcs,
|
||||
controller,
|
||||
// parseSSE
|
||||
(text: string, runTools: ChatMessageTool[]) => {
|
||||
// console.log("parseSSE", text, runTools);
|
||||
const json = JSON.parse(text);
|
||||
const choices = json.choices as Array<{
|
||||
delta: {
|
||||
content: string;
|
||||
tool_calls: ChatMessageTool[];
|
||||
};
|
||||
}>;
|
||||
const tool_calls = choices[0]?.delta?.tool_calls;
|
||||
if (tool_calls?.length > 0) {
|
||||
const index = tool_calls[0]?.index;
|
||||
const id = tool_calls[0]?.id;
|
||||
const args = tool_calls[0]?.function?.arguments;
|
||||
if (id) {
|
||||
runTools.push({
|
||||
id,
|
||||
type: tool_calls[0]?.type,
|
||||
function: {
|
||||
name: tool_calls[0]?.function?.name as string,
|
||||
arguments: args,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// @ts-ignore
|
||||
runTools[index]["function"]["arguments"] += args;
|
||||
}
|
||||
}
|
||||
return choices[0]?.delta?.content;
|
||||
},
|
||||
// processToolMessage, include tool_calls message and tool call results
|
||||
(
|
||||
requestPayload: RequestPayload,
|
||||
toolCallMessage: any,
|
||||
toolCallResult: any[],
|
||||
) => {
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.splice(
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.length,
|
||||
0,
|
||||
toolCallMessage,
|
||||
...toolCallResult,
|
||||
);
|
||||
},
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} 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 [];
|
||||
}
|
||||
}
|
@@ -18,6 +18,8 @@ import {
|
||||
trackSettingsPageGuideToCPaymentClick,
|
||||
trackAuthorizationPageButtonToCPaymentClick,
|
||||
} from "../utils/auth-settings-events";
|
||||
import clsx from "clsx";
|
||||
|
||||
const storage = safeLocalStorage();
|
||||
|
||||
export function AuthPage() {
|
||||
@@ -54,7 +56,7 @@ export function AuthPage() {
|
||||
onClick={() => navigate(Path.Home)}
|
||||
></IconButton>
|
||||
</div>
|
||||
<div className={`no-dark ${styles["auth-logo"]}`}>
|
||||
<div className={clsx("no-dark", styles["auth-logo"])}>
|
||||
<BotIcon />
|
||||
</div>
|
||||
|
||||
@@ -163,7 +165,7 @@ function TopBanner() {
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className={`${styles["top-banner-inner"]} no-dark`}>
|
||||
<div className={clsx(styles["top-banner-inner"], "no-dark")}>
|
||||
<Logo className={styles["top-banner-logo"]}></Logo>
|
||||
<span>
|
||||
{Locale.Auth.TopTips}
|
||||
|
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
|
||||
import styles from "./button.module.scss";
|
||||
import { CSSProperties } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export type ButtonType = "primary" | "danger" | null;
|
||||
|
||||
@@ -22,12 +23,16 @@ export function IconButton(props: {
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={
|
||||
styles["icon-button"] +
|
||||
` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
|
||||
props.className ?? ""
|
||||
} clickable ${styles[props.type ?? ""]}`
|
||||
}
|
||||
className={clsx(
|
||||
"clickable",
|
||||
styles["icon-button"],
|
||||
{
|
||||
[styles.border]: props.bordered,
|
||||
[styles.shadow]: props.shadow,
|
||||
},
|
||||
styles[props.type ?? ""],
|
||||
props.className,
|
||||
)}
|
||||
onClick={props.onClick}
|
||||
title={props.title}
|
||||
disabled={props.disabled}
|
||||
@@ -40,10 +45,9 @@ export function IconButton(props: {
|
||||
{props.icon && (
|
||||
<div
|
||||
aria-label={props.text || props.title}
|
||||
className={
|
||||
styles["icon-button-icon"] +
|
||||
` ${props.type === "primary" && "no-dark"}`
|
||||
}
|
||||
className={clsx(styles["icon-button-icon"], {
|
||||
"no-dark": props.type === "primary",
|
||||
})}
|
||||
>
|
||||
{props.icon}
|
||||
</div>
|
||||
|
@@ -18,6 +18,7 @@ import { Mask } from "../store/mask";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { showConfirm } from "./ui-lib";
|
||||
import { useMobileScreen } from "../utils";
|
||||
import clsx from "clsx";
|
||||
|
||||
export function ChatItem(props: {
|
||||
onClick?: () => void;
|
||||
@@ -45,11 +46,11 @@ export function ChatItem(props: {
|
||||
<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"]
|
||||
}`}
|
||||
className={clsx(styles["chat-item"], {
|
||||
[styles["chat-item-selected"]]:
|
||||
props.selected &&
|
||||
(currentPath === Path.Chat || currentPath === Path.Home),
|
||||
})}
|
||||
onClick={props.onClick}
|
||||
ref={(ele) => {
|
||||
draggableRef.current = ele;
|
||||
@@ -63,7 +64,7 @@ export function ChatItem(props: {
|
||||
>
|
||||
{props.narrow ? (
|
||||
<div className={styles["chat-item-narrow"]}>
|
||||
<div className={styles["chat-item-avatar"] + " no-dark"}>
|
||||
<div className={clsx(styles["chat-item-avatar"], "no-dark")}>
|
||||
<MaskAvatar
|
||||
avatar={props.mask.avatar}
|
||||
model={props.mask.modelConfig.model}
|
||||
|
@@ -45,6 +45,14 @@
|
||||
.chat-input-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
|
||||
&-end {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chat-input-action {
|
||||
display: inline-flex;
|
||||
@@ -62,10 +70,6 @@
|
||||
width: var(--icon-width);
|
||||
overflow: hidden;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: nowrap;
|
||||
padding-left: 5px;
|
||||
@@ -231,10 +235,12 @@
|
||||
|
||||
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));
|
||||
$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 {
|
||||
@@ -373,7 +379,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-user>.chat-message-container {
|
||||
.chat-message-user > .chat-message-container {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
@@ -443,6 +449,25 @@
|
||||
transition: all ease 0.3s;
|
||||
}
|
||||
|
||||
.chat-message-audio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border: var(--border-in-light);
|
||||
position: relative;
|
||||
transition: all ease 0.3s;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
user-select: text;
|
||||
word-break: break-word;
|
||||
box-sizing: border-box;
|
||||
audio {
|
||||
height: 30px; /* 调整高度 */
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-item-image {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
@@ -471,23 +496,27 @@
|
||||
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));
|
||||
$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);
|
||||
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));
|
||||
$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;
|
||||
@@ -497,7 +526,7 @@
|
||||
}
|
||||
|
||||
.chat-message-item-image {
|
||||
max-width: calc(calc(1200px - var(--sidebar-width))/3*2);
|
||||
max-width: calc(calc(1200px - var(--sidebar-width)) / 3 * 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,7 +544,7 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chat-message-user>.chat-message-container>.chat-message-item {
|
||||
.chat-message-user > .chat-message-container > .chat-message-item {
|
||||
background-color: var(--second);
|
||||
|
||||
&:hover {
|
||||
@@ -626,7 +655,8 @@
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.chat-input:focus {}
|
||||
.chat-input:focus {
|
||||
}
|
||||
|
||||
.chat-input-send {
|
||||
background-color: var(--primary);
|
||||
@@ -693,4 +723,31 @@
|
||||
.shortcut-key span {
|
||||
font-size: 12px;
|
||||
color: var(--black);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
.chat-body-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
.chat-side-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--white);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
transform: translateX(100%);
|
||||
transition: all ease 0.3s;
|
||||
&-show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -37,7 +37,8 @@ export function Avatar(props: { model?: ModelType; avatar?: string }) {
|
||||
return (
|
||||
<div className="no-dark">
|
||||
{props.model?.startsWith("gpt-4") ||
|
||||
props.model?.startsWith("chatgpt-4o") ? (
|
||||
props.model?.startsWith("chatgpt-4o") ||
|
||||
props.model?.startsWith("o1") ? (
|
||||
<BlackBotIcon className="user-avatar" />
|
||||
) : (
|
||||
<BotIcon className="user-avatar" />
|
||||
|
@@ -40,6 +40,7 @@ import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
|
||||
import { getClientConfig } from "../config/client";
|
||||
import { type ClientApi, getClientApi } from "../client/api";
|
||||
import { getMessageTextContent } from "../utils";
|
||||
import clsx from "clsx";
|
||||
|
||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||
loading: () => <LoadingIcon />,
|
||||
@@ -118,9 +119,10 @@ function Steps<
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles["step"]} ${
|
||||
styles[i <= props.index ? "step-finished" : ""]
|
||||
} ${i === props.index && styles["step-current"]} clickable`}
|
||||
className={clsx("clickable", styles["step"], {
|
||||
[styles["step-finished"]]: i <= props.index,
|
||||
[styles["step-current"]]: i === props.index,
|
||||
})}
|
||||
onClick={() => {
|
||||
props.onStepChange?.(i);
|
||||
}}
|
||||
@@ -525,11 +527,11 @@ export function ImagePreviewer(props: {
|
||||
messages={props.messages}
|
||||
/>
|
||||
<div
|
||||
className={`${styles["preview-body"]} ${styles["default-theme"]}`}
|
||||
className={clsx(styles["preview-body"], styles["default-theme"])}
|
||||
ref={previewRef}
|
||||
>
|
||||
<div className={styles["chat-info"]}>
|
||||
<div className={styles["logo"] + " no-dark"}>
|
||||
<div className={clsx(styles["logo"], "no-dark")}>
|
||||
<NextImage
|
||||
src={ChatGptIcon.src}
|
||||
alt="logo"
|
||||
@@ -570,7 +572,7 @@ export function ImagePreviewer(props: {
|
||||
{props.messages.map((m, i) => {
|
||||
return (
|
||||
<div
|
||||
className={styles["message"] + " " + styles["message-" + m.role]}
|
||||
className={clsx(styles["message"], styles["message-" + m.role])}
|
||||
key={i}
|
||||
>
|
||||
<div className={styles["avatar"]}>
|
||||
|
@@ -2,8 +2,7 @@
|
||||
|
||||
require("../polyfill");
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import styles from "./home.module.scss";
|
||||
|
||||
import BotIcon from "../icons/bot.svg";
|
||||
@@ -19,8 +18,8 @@ import { getISOLang, getLang } from "../locales";
|
||||
|
||||
import {
|
||||
HashRouter as Router,
|
||||
Routes,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import { SideBar } from "./sidebar";
|
||||
@@ -29,10 +28,12 @@ import { AuthPage } from "./auth";
|
||||
import { getClientConfig } from "../config/client";
|
||||
import { type ClientApi, getClientApi } from "../client/api";
|
||||
import { useAccessStore } from "../store";
|
||||
import clsx from "clsx";
|
||||
import { initializeMcpSystem, isMcpEnabled } from "../mcp/actions";
|
||||
|
||||
export function Loading(props: { noLogo?: boolean }) {
|
||||
return (
|
||||
<div className={styles["loading-content"] + " no-dark"}>
|
||||
<div className={clsx("no-dark", styles["loading-content"])}>
|
||||
{!props.noLogo && <BotIcon />}
|
||||
<LoadingIcon />
|
||||
</div>
|
||||
@@ -74,6 +75,13 @@ const Sd = dynamic(async () => (await import("./sd")).Sd, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
const McpMarketPage = dynamic(
|
||||
async () => (await import("./mcp-market")).McpMarketPage,
|
||||
{
|
||||
loading: () => <Loading noLogo />,
|
||||
},
|
||||
);
|
||||
|
||||
export function useSwitchTheme() {
|
||||
const config = useAppConfig();
|
||||
|
||||
@@ -179,7 +187,11 @@ function Screen() {
|
||||
if (isSdNew) return <Sd />;
|
||||
return (
|
||||
<>
|
||||
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
|
||||
<SideBar
|
||||
className={clsx({
|
||||
[styles["sidebar-show"]]: isHome,
|
||||
})}
|
||||
/>
|
||||
<WindowContent>
|
||||
<Routes>
|
||||
<Route path={Path.Home} element={<Chat />} />
|
||||
@@ -189,6 +201,7 @@ function Screen() {
|
||||
<Route path={Path.SearchChat} element={<SearchChat />} />
|
||||
<Route path={Path.Chat} element={<Chat />} />
|
||||
<Route path={Path.Settings} element={<Settings />} />
|
||||
<Route path={Path.McpMarket} element={<McpMarketPage />} />
|
||||
</Routes>
|
||||
</WindowContent>
|
||||
</>
|
||||
@@ -197,9 +210,10 @@ function Screen() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.container} ${
|
||||
shouldTightBorder ? styles["tight-container"] : styles.container
|
||||
} ${getLang() === "ar" ? styles["rtl-screen"] : ""}`}
|
||||
className={clsx(styles.container, {
|
||||
[styles["tight-container"]]: shouldTightBorder,
|
||||
[styles["rtl-screen"]]: getLang() === "ar",
|
||||
})}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
@@ -228,6 +242,20 @@ export function Home() {
|
||||
useEffect(() => {
|
||||
console.log("[Config] got config from build time", getClientConfig());
|
||||
useAccessStore.getState().fetch();
|
||||
|
||||
const initMcp = async () => {
|
||||
try {
|
||||
const enabled = await isMcpEnabled();
|
||||
if (enabled) {
|
||||
console.log("[MCP] initializing...");
|
||||
await initializeMcpSystem();
|
||||
console.log("[MCP] initialized");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[MCP] failed to initialize:", err);
|
||||
}
|
||||
};
|
||||
initMcp();
|
||||
}, []);
|
||||
|
||||
if (!useHasHydrated()) {
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styles from "./input-range.module.scss";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface InputRangeProps {
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>;
|
||||
@@ -23,7 +24,7 @@ export function InputRange({
|
||||
aria,
|
||||
}: InputRangeProps) {
|
||||
return (
|
||||
<div className={styles["input-range"] + ` ${className ?? ""}`}>
|
||||
<div className={clsx(styles["input-range"], className)}>
|
||||
{title || value}
|
||||
<input
|
||||
aria-label={aria}
|
||||
|
@@ -23,6 +23,7 @@ import { useChatStore } from "../store";
|
||||
import { IconButton } from "./button";
|
||||
|
||||
import { useAppConfig } from "../store/config";
|
||||
import clsx from "clsx";
|
||||
|
||||
export function Mermaid(props: { code: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -57,7 +58,7 @@ export function Mermaid(props: { code: string }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="no-dark mermaid"
|
||||
className={clsx("no-dark", "mermaid")}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
overflow: "auto",
|
||||
@@ -89,7 +90,11 @@ export function PreCode(props: { children: any }) {
|
||||
const refText = ref.current.querySelector("code")?.innerText;
|
||||
if (htmlDom) {
|
||||
setHtmlCode((htmlDom as HTMLElement).innerText);
|
||||
} else if (refText?.startsWith("<!DOCTYPE")) {
|
||||
} else if (
|
||||
refText?.startsWith("<!DOCTYPE") ||
|
||||
refText?.startsWith("<svg") ||
|
||||
refText?.startsWith("<?xml")
|
||||
) {
|
||||
setHtmlCode(refText);
|
||||
}
|
||||
}, 600);
|
||||
@@ -193,7 +198,12 @@ function CustomCode(props: { children: any; className?: string }) {
|
||||
const renderShowMoreButton = () => {
|
||||
if (showToggle && enableCodeFold && collapsed) {
|
||||
return (
|
||||
<div className={`show-hide-button ${collapsed ? "collapsed" : "expanded"}`}>
|
||||
<div
|
||||
className={clsx("show-hide-button", {
|
||||
collapsed,
|
||||
expanded: !collapsed,
|
||||
})}
|
||||
>
|
||||
<button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
|
||||
</div>
|
||||
);
|
||||
@@ -203,7 +213,7 @@ function CustomCode(props: { children: any; className?: string }) {
|
||||
return (
|
||||
<>
|
||||
<code
|
||||
className={props?.className}
|
||||
className={clsx(props?.className)}
|
||||
ref={ref}
|
||||
style={{
|
||||
maxHeight: enableCodeFold && collapsed ? "400px" : "none",
|
||||
@@ -238,6 +248,10 @@ function escapeBrackets(text: string) {
|
||||
|
||||
function tryWrapHtmlCode(text: string) {
|
||||
// try add wrap html code (fixed: html codeblock include 2 newline)
|
||||
// ignore embed codeblock
|
||||
if (text.includes("```")) {
|
||||
return text;
|
||||
}
|
||||
return text
|
||||
.replace(
|
||||
/([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,
|
||||
|
@@ -55,6 +55,7 @@ import {
|
||||
OnDragEndResponder,
|
||||
} from "@hello-pangea/dnd";
|
||||
import { getMessageTextContent } from "../utils";
|
||||
import clsx from "clsx";
|
||||
|
||||
// drag and drop helper function
|
||||
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
||||
@@ -588,7 +589,7 @@ export function MaskPage() {
|
||||
</div>
|
||||
<div className={styles["mask-title"]}>
|
||||
<div className={styles["mask-name"]}>{m.name}</div>
|
||||
<div className={styles["mask-info"] + " one-line"}>
|
||||
<div className={clsx(styles["mask-info"], "one-line")}>
|
||||
{`${Locale.Mask.Item.Info(m.context.length)} / ${
|
||||
ALL_LANG_OPTIONS[m.lang]
|
||||
} / ${m.modelConfig.model}`}
|
||||
|
657
app/components/mcp-market.module.scss
Normal file
@@ -0,0 +1,657 @@
|
||||
@import "../styles/animation.scss";
|
||||
|
||||
.mcp-market-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.loading-indicator {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
margin-left: 8px;
|
||||
font-weight: normal;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.mcp-market-page-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
.loading-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
width: 100%;
|
||||
background-color: var(--white);
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
animation: slide-in ease 0.3s;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: var(--black);
|
||||
opacity: 0.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mcp-market-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;
|
||||
}
|
||||
}
|
||||
|
||||
.server-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.mcp-market-item {
|
||||
padding: 20px;
|
||||
border: var(--border-in-light);
|
||||
animation: slide-in ease 0.3s;
|
||||
background-color: var(--white);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&: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;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
position: relative;
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
transparent
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: loading-pulse 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.operation-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: #16a34a;
|
||||
color: #fff;
|
||||
animation: pulse 1.5s infinite;
|
||||
|
||||
&[data-status="stopping"] {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
|
||||
&[data-status="starting"] {
|
||||
background-color: #4ade80;
|
||||
}
|
||||
|
||||
&[data-status="error"] {
|
||||
background-color: #f87171;
|
||||
}
|
||||
}
|
||||
|
||||
.mcp-market-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
|
||||
.mcp-market-title {
|
||||
flex-grow: 1;
|
||||
margin-right: 20px;
|
||||
max-width: calc(100% - 300px);
|
||||
}
|
||||
|
||||
.mcp-market-name {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.server-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: #22c55e;
|
||||
color: #fff;
|
||||
|
||||
&.error {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
&.stopped {
|
||||
background-color: #6b7280;
|
||||
}
|
||||
|
||||
&.initializing {
|
||||
background-color: #f59e0b;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.repo-link {
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-decoration: none;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--gray);
|
||||
color: var(--black);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.mcp-market-info {
|
||||
color: var(--black);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mcp-market-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
min-width: 180px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.array-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 10px;
|
||||
background-color: var(--white);
|
||||
|
||||
.array-input-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--gray-50);
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--gray-200);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray-100);
|
||||
border-color: var(--gray-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: var(--white);
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--primary-10);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--gray-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.icon-button.add-path-button) {
|
||||
width: 100%;
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
height: 36px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
filter: brightness(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.path-list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.path-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 10px;
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--primary-10);
|
||||
}
|
||||
}
|
||||
|
||||
.browse-button {
|
||||
padding: 8px;
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
background-color: transparent;
|
||||
color: var(--black-50);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
padding: 8px;
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
background-color: transparent;
|
||||
color: var(--black-50);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.add-button {
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 8px 12px;
|
||||
background-color: transparent;
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
color: var(--black);
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.config-section {
|
||||
width: 100%;
|
||||
|
||||
.config-header {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.config-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--black);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.config-description {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.array-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 10px;
|
||||
background-color: var(--white);
|
||||
|
||||
.array-input-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--gray-50);
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--gray-200);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray-100);
|
||||
border-color: var(--gray-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: var(--white);
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--primary-10);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--gray-300);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.icon-button) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--gray-200);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray-100);
|
||||
border-color: var(--gray-300);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.icon-button.add-path-button) {
|
||||
width: 100%;
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
height: 36px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
filter: brightness(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-item {
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--primary-10);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--gray-300) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tools-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
word-break: break-word;
|
||||
box-sizing: border-box;
|
||||
|
||||
.tool-item {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.tool-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--black);
|
||||
margin-bottom: 8px;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid var(--primary);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-description {
|
||||
font-size: 13px;
|
||||
color: var(--gray-500);
|
||||
line-height: 1.6;
|
||||
padding-left: 15px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global {
|
||||
.modal-content {
|
||||
margin-top: 20px;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.list-header {
|
||||
margin-bottom: 0;
|
||||
|
||||
.list-title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-transform: capitalize;
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.list-sub-title {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-pulse {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
755
app/components/mcp-market.tsx
Normal file
@@ -0,0 +1,755 @@
|
||||
import { IconButton } from "./button";
|
||||
import { ErrorBoundary } from "./error";
|
||||
import styles from "./mcp-market.module.scss";
|
||||
import EditIcon from "../icons/edit.svg";
|
||||
import AddIcon from "../icons/add.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import RestartIcon from "../icons/reload.svg";
|
||||
import EyeIcon from "../icons/eye.svg";
|
||||
import GithubIcon from "../icons/github.svg";
|
||||
import { List, ListItem, Modal, showToast } from "./ui-lib";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
addMcpServer,
|
||||
getClientsStatus,
|
||||
getClientTools,
|
||||
getMcpConfigFromFile,
|
||||
isMcpEnabled,
|
||||
pauseMcpServer,
|
||||
restartAllClients,
|
||||
resumeMcpServer,
|
||||
} from "../mcp/actions";
|
||||
import {
|
||||
ListToolsResponse,
|
||||
McpConfigData,
|
||||
PresetServer,
|
||||
ServerConfig,
|
||||
ServerStatusResponse,
|
||||
} from "../mcp/types";
|
||||
import clsx from "clsx";
|
||||
import PlayIcon from "../icons/play.svg";
|
||||
import StopIcon from "../icons/pause.svg";
|
||||
import { Path } from "../constant";
|
||||
|
||||
interface ConfigProperty {
|
||||
type: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
minItems?: number;
|
||||
}
|
||||
|
||||
export function McpMarketPage() {
|
||||
const navigate = useNavigate();
|
||||
const [mcpEnabled, setMcpEnabled] = useState(false);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [userConfig, setUserConfig] = useState<Record<string, any>>({});
|
||||
const [editingServerId, setEditingServerId] = useState<string | undefined>();
|
||||
const [tools, setTools] = useState<ListToolsResponse["tools"] | null>(null);
|
||||
const [viewingServerId, setViewingServerId] = useState<string | undefined>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [config, setConfig] = useState<McpConfigData>();
|
||||
const [clientStatuses, setClientStatuses] = useState<
|
||||
Record<string, ServerStatusResponse>
|
||||
>({});
|
||||
const [loadingPresets, setLoadingPresets] = useState(true);
|
||||
const [presetServers, setPresetServers] = useState<PresetServer[]>([]);
|
||||
const [loadingStates, setLoadingStates] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// 检查 MCP 是否启用
|
||||
useEffect(() => {
|
||||
const checkMcpStatus = async () => {
|
||||
const enabled = await isMcpEnabled();
|
||||
setMcpEnabled(enabled);
|
||||
if (!enabled) {
|
||||
navigate(Path.Home);
|
||||
}
|
||||
};
|
||||
checkMcpStatus();
|
||||
}, [navigate]);
|
||||
|
||||
// 添加状态轮询
|
||||
useEffect(() => {
|
||||
if (!mcpEnabled || !config) return;
|
||||
|
||||
const updateStatuses = async () => {
|
||||
const statuses = await getClientsStatus();
|
||||
setClientStatuses(statuses);
|
||||
};
|
||||
|
||||
// 立即执行一次
|
||||
updateStatuses();
|
||||
// 每 1000ms 轮询一次
|
||||
const timer = setInterval(updateStatuses, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [mcpEnabled, config]);
|
||||
|
||||
// 加载预设服务器
|
||||
useEffect(() => {
|
||||
const loadPresetServers = async () => {
|
||||
if (!mcpEnabled) return;
|
||||
try {
|
||||
setLoadingPresets(true);
|
||||
const response = await fetch("https://nextchat.club/mcp/list");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load preset servers");
|
||||
}
|
||||
const data = await response.json();
|
||||
setPresetServers(data?.data ?? []);
|
||||
} catch (error) {
|
||||
console.error("Failed to load preset servers:", error);
|
||||
showToast("Failed to load preset servers");
|
||||
} finally {
|
||||
setLoadingPresets(false);
|
||||
}
|
||||
};
|
||||
loadPresetServers();
|
||||
}, [mcpEnabled]);
|
||||
|
||||
// 加载初始状态
|
||||
useEffect(() => {
|
||||
const loadInitialState = async () => {
|
||||
if (!mcpEnabled) return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const config = await getMcpConfigFromFile();
|
||||
setConfig(config);
|
||||
|
||||
// 获取所有客户端的状态
|
||||
const statuses = await getClientsStatus();
|
||||
setClientStatuses(statuses);
|
||||
} catch (error) {
|
||||
console.error("Failed to load initial state:", error);
|
||||
showToast("Failed to load initial state");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadInitialState();
|
||||
}, [mcpEnabled]);
|
||||
|
||||
// 加载当前编辑服务器的配置
|
||||
useEffect(() => {
|
||||
if (!editingServerId || !config) return;
|
||||
const currentConfig = config.mcpServers[editingServerId];
|
||||
if (currentConfig) {
|
||||
// 从当前配置中提取用户配置
|
||||
const preset = presetServers.find((s) => s.id === editingServerId);
|
||||
if (preset?.configSchema) {
|
||||
const userConfig: Record<string, any> = {};
|
||||
Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => {
|
||||
if (mapping.type === "spread") {
|
||||
// For spread types, extract the array from args.
|
||||
const startPos = mapping.position ?? 0;
|
||||
userConfig[key] = currentConfig.args.slice(startPos);
|
||||
} else if (mapping.type === "single") {
|
||||
// For single types, get a single value
|
||||
userConfig[key] = currentConfig.args[mapping.position ?? 0];
|
||||
} else if (
|
||||
mapping.type === "env" &&
|
||||
mapping.key &&
|
||||
currentConfig.env
|
||||
) {
|
||||
// For env types, get values from environment variables
|
||||
userConfig[key] = currentConfig.env[mapping.key];
|
||||
}
|
||||
});
|
||||
setUserConfig(userConfig);
|
||||
}
|
||||
} else {
|
||||
setUserConfig({});
|
||||
}
|
||||
}, [editingServerId, config, presetServers]);
|
||||
|
||||
if (!mcpEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查服务器是否已添加
|
||||
const isServerAdded = (id: string) => {
|
||||
return id in (config?.mcpServers ?? {});
|
||||
};
|
||||
|
||||
// 保存服务器配置
|
||||
const saveServerConfig = async () => {
|
||||
const preset = presetServers.find((s) => s.id === editingServerId);
|
||||
if (!preset || !preset.configSchema || !editingServerId) return;
|
||||
|
||||
const savingServerId = editingServerId;
|
||||
setEditingServerId(undefined);
|
||||
|
||||
try {
|
||||
updateLoadingState(savingServerId, "Updating configuration...");
|
||||
// 构建服务器配置
|
||||
const args = [...preset.baseArgs];
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => {
|
||||
const value = userConfig[key];
|
||||
if (mapping.type === "spread" && Array.isArray(value)) {
|
||||
const pos = mapping.position ?? 0;
|
||||
args.splice(pos, 0, ...value);
|
||||
} else if (
|
||||
mapping.type === "single" &&
|
||||
mapping.position !== undefined
|
||||
) {
|
||||
args[mapping.position] = value;
|
||||
} else if (
|
||||
mapping.type === "env" &&
|
||||
mapping.key &&
|
||||
typeof value === "string"
|
||||
) {
|
||||
env[mapping.key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const serverConfig: ServerConfig = {
|
||||
command: preset.command,
|
||||
args,
|
||||
...(Object.keys(env).length > 0 ? { env } : {}),
|
||||
};
|
||||
|
||||
const newConfig = await addMcpServer(savingServerId, serverConfig);
|
||||
setConfig(newConfig);
|
||||
showToast("Server configuration updated successfully");
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "Failed to save configuration",
|
||||
);
|
||||
} finally {
|
||||
updateLoadingState(savingServerId, null);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取服务器支持的 Tools
|
||||
const loadTools = async (id: string) => {
|
||||
try {
|
||||
const result = await getClientTools(id);
|
||||
if (result) {
|
||||
setTools(result);
|
||||
} else {
|
||||
throw new Error("Failed to load tools");
|
||||
}
|
||||
} catch (error) {
|
||||
showToast("Failed to load tools");
|
||||
console.error(error);
|
||||
setTools(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 更新加载状态的辅助函数
|
||||
const updateLoadingState = (id: string, message: string | null) => {
|
||||
setLoadingStates((prev) => {
|
||||
if (message === null) {
|
||||
const { [id]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return { ...prev, [id]: message };
|
||||
});
|
||||
};
|
||||
|
||||
// 修改添加服务器函数
|
||||
const addServer = async (preset: PresetServer) => {
|
||||
if (!preset.configurable) {
|
||||
try {
|
||||
const serverId = preset.id;
|
||||
updateLoadingState(serverId, "Creating MCP client...");
|
||||
|
||||
const serverConfig: ServerConfig = {
|
||||
command: preset.command,
|
||||
args: [...preset.baseArgs],
|
||||
};
|
||||
const newConfig = await addMcpServer(preset.id, serverConfig);
|
||||
setConfig(newConfig);
|
||||
|
||||
// 更新状态
|
||||
const statuses = await getClientsStatus();
|
||||
setClientStatuses(statuses);
|
||||
} finally {
|
||||
updateLoadingState(preset.id, null);
|
||||
}
|
||||
} else {
|
||||
// 如果需要配置,打开配置对话框
|
||||
setEditingServerId(preset.id);
|
||||
setUserConfig({});
|
||||
}
|
||||
};
|
||||
|
||||
// 修改暂停服务器函数
|
||||
const pauseServer = async (id: string) => {
|
||||
try {
|
||||
updateLoadingState(id, "Stopping server...");
|
||||
const newConfig = await pauseMcpServer(id);
|
||||
setConfig(newConfig);
|
||||
showToast("Server stopped successfully");
|
||||
} catch (error) {
|
||||
showToast("Failed to stop server");
|
||||
console.error(error);
|
||||
} finally {
|
||||
updateLoadingState(id, null);
|
||||
}
|
||||
};
|
||||
|
||||
// Restart server
|
||||
const restartServer = async (id: string) => {
|
||||
try {
|
||||
updateLoadingState(id, "Starting server...");
|
||||
await resumeMcpServer(id);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to start server, please check logs",
|
||||
);
|
||||
console.error(error);
|
||||
} finally {
|
||||
updateLoadingState(id, null);
|
||||
}
|
||||
};
|
||||
|
||||
// Restart all clients
|
||||
const handleRestartAll = async () => {
|
||||
try {
|
||||
updateLoadingState("all", "Restarting all servers...");
|
||||
const newConfig = await restartAllClients();
|
||||
setConfig(newConfig);
|
||||
showToast("Restarting all clients");
|
||||
} catch (error) {
|
||||
showToast("Failed to restart clients");
|
||||
console.error(error);
|
||||
} finally {
|
||||
updateLoadingState("all", null);
|
||||
}
|
||||
};
|
||||
|
||||
// Render configuration form
|
||||
const renderConfigForm = () => {
|
||||
const preset = presetServers.find((s) => s.id === editingServerId);
|
||||
if (!preset?.configSchema) return null;
|
||||
|
||||
return Object.entries(preset.configSchema.properties).map(
|
||||
([key, prop]: [string, ConfigProperty]) => {
|
||||
if (prop.type === "array") {
|
||||
const currentValue = userConfig[key as keyof typeof userConfig] || [];
|
||||
const itemLabel = (prop as any).itemLabel || key;
|
||||
const addButtonText =
|
||||
(prop as any).addButtonText || `Add ${itemLabel}`;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={key}
|
||||
title={key}
|
||||
subTitle={prop.description}
|
||||
vertical
|
||||
>
|
||||
<div className={styles["path-list"]}>
|
||||
{(currentValue as string[]).map(
|
||||
(value: string, index: number) => (
|
||||
<div key={index} className={styles["path-item"]}>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={`${itemLabel} ${index + 1}`}
|
||||
onChange={(e) => {
|
||||
const newValue = [...currentValue] as string[];
|
||||
newValue[index] = e.target.value;
|
||||
setUserConfig({ ...userConfig, [key]: newValue });
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
className={styles["delete-button"]}
|
||||
onClick={() => {
|
||||
const newValue = [...currentValue] as string[];
|
||||
newValue.splice(index, 1);
|
||||
setUserConfig({ ...userConfig, [key]: newValue });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<IconButton
|
||||
icon={<AddIcon />}
|
||||
text={addButtonText}
|
||||
className={styles["add-button"]}
|
||||
bordered
|
||||
onClick={() => {
|
||||
const newValue = [...currentValue, ""] as string[];
|
||||
setUserConfig({ ...userConfig, [key]: newValue });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ListItem>
|
||||
);
|
||||
} else if (prop.type === "string") {
|
||||
const currentValue = userConfig[key as keyof typeof userConfig] || "";
|
||||
return (
|
||||
<ListItem key={key} title={key} subTitle={prop.description}>
|
||||
<input
|
||||
aria-label={key}
|
||||
type="text"
|
||||
value={currentValue}
|
||||
placeholder={`Enter ${key}`}
|
||||
onChange={(e) => {
|
||||
setUserConfig({ ...userConfig, [key]: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const checkServerStatus = (clientId: string) => {
|
||||
return clientStatuses[clientId] || { status: "undefined", errorMsg: null };
|
||||
};
|
||||
|
||||
const getServerStatusDisplay = (clientId: string) => {
|
||||
const status = checkServerStatus(clientId);
|
||||
|
||||
const statusMap = {
|
||||
undefined: null, // 未配置/未找到不显示
|
||||
// 添加初始化状态
|
||||
initializing: (
|
||||
<span className={clsx(styles["server-status"], styles["initializing"])}>
|
||||
Initializing
|
||||
</span>
|
||||
),
|
||||
paused: (
|
||||
<span className={clsx(styles["server-status"], styles["stopped"])}>
|
||||
Stopped
|
||||
</span>
|
||||
),
|
||||
active: <span className={styles["server-status"]}>Running</span>,
|
||||
error: (
|
||||
<span className={clsx(styles["server-status"], styles["error"])}>
|
||||
Error
|
||||
<span className={styles["error-message"]}>: {status.errorMsg}</span>
|
||||
</span>
|
||||
),
|
||||
};
|
||||
|
||||
return statusMap[status.status];
|
||||
};
|
||||
|
||||
// Get the type of operation status
|
||||
const getOperationStatusType = (message: string) => {
|
||||
if (message.toLowerCase().includes("stopping")) return "stopping";
|
||||
if (message.toLowerCase().includes("starting")) return "starting";
|
||||
if (message.toLowerCase().includes("error")) return "error";
|
||||
return "default";
|
||||
};
|
||||
|
||||
// 渲染服务器列表
|
||||
const renderServerList = () => {
|
||||
if (loadingPresets) {
|
||||
return (
|
||||
<div className={styles["loading-container"]}>
|
||||
<div className={styles["loading-text"]}>
|
||||
Loading preset server list...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(presetServers) || presetServers.length === 0) {
|
||||
return (
|
||||
<div className={styles["empty-container"]}>
|
||||
<div className={styles["empty-text"]}>No servers available</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return presetServers
|
||||
.filter((server) => {
|
||||
if (searchText.length === 0) return true;
|
||||
const searchLower = searchText.toLowerCase();
|
||||
return (
|
||||
server.name.toLowerCase().includes(searchLower) ||
|
||||
server.description.toLowerCase().includes(searchLower) ||
|
||||
server.tags.some((tag) => tag.toLowerCase().includes(searchLower))
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aStatus = checkServerStatus(a.id).status;
|
||||
const bStatus = checkServerStatus(b.id).status;
|
||||
const aLoading = loadingStates[a.id];
|
||||
const bLoading = loadingStates[b.id];
|
||||
|
||||
// 定义状态优先级
|
||||
const statusPriority: Record<string, number> = {
|
||||
error: 0, // Highest priority for error status
|
||||
active: 1, // Second for active
|
||||
initializing: 2, // Initializing
|
||||
starting: 3, // Starting
|
||||
stopping: 4, // Stopping
|
||||
paused: 5, // Paused
|
||||
undefined: 6, // Lowest priority for undefined
|
||||
};
|
||||
|
||||
// Get actual status (including loading status)
|
||||
const getEffectiveStatus = (status: string, loading?: string) => {
|
||||
if (loading) {
|
||||
const operationType = getOperationStatusType(loading);
|
||||
return operationType === "default" ? status : operationType;
|
||||
}
|
||||
|
||||
if (status === "initializing" && !loading) {
|
||||
return "active";
|
||||
}
|
||||
|
||||
return status;
|
||||
};
|
||||
|
||||
const aEffectiveStatus = getEffectiveStatus(aStatus, aLoading);
|
||||
const bEffectiveStatus = getEffectiveStatus(bStatus, bLoading);
|
||||
|
||||
// 首先按状态排序
|
||||
if (aEffectiveStatus !== bEffectiveStatus) {
|
||||
return (
|
||||
(statusPriority[aEffectiveStatus] ?? 6) -
|
||||
(statusPriority[bEffectiveStatus] ?? 6)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by name when statuses are the same
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((server) => (
|
||||
<div
|
||||
className={clsx(styles["mcp-market-item"], {
|
||||
[styles["loading"]]: loadingStates[server.id],
|
||||
})}
|
||||
key={server.id}
|
||||
>
|
||||
<div className={styles["mcp-market-header"]}>
|
||||
<div className={styles["mcp-market-title"]}>
|
||||
<div className={styles["mcp-market-name"]}>
|
||||
{server.name}
|
||||
{loadingStates[server.id] && (
|
||||
<span
|
||||
className={styles["operation-status"]}
|
||||
data-status={getOperationStatusType(
|
||||
loadingStates[server.id],
|
||||
)}
|
||||
>
|
||||
{loadingStates[server.id]}
|
||||
</span>
|
||||
)}
|
||||
{!loadingStates[server.id] && getServerStatusDisplay(server.id)}
|
||||
{server.repo && (
|
||||
<a
|
||||
href={server.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles["repo-link"]}
|
||||
title="Open repository"
|
||||
>
|
||||
<GithubIcon />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles["tags-container"]}>
|
||||
{server.tags.map((tag, index) => (
|
||||
<span key={index} className={styles["tag"]}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles["mcp-market-info"], "one-line")}
|
||||
title={server.description}
|
||||
>
|
||||
{server.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles["mcp-market-actions"]}>
|
||||
{isServerAdded(server.id) ? (
|
||||
<>
|
||||
{server.configurable && (
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
text="Configure"
|
||||
onClick={() => setEditingServerId(server.id)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
{checkServerStatus(server.id).status === "paused" ? (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<PlayIcon />}
|
||||
text="Start"
|
||||
onClick={() => restartServer(server.id)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{/* <IconButton
|
||||
icon={<DeleteIcon />}
|
||||
text="Remove"
|
||||
onClick={() => removeServer(server.id)}
|
||||
disabled={isLoading}
|
||||
/> */}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<EyeIcon />}
|
||||
text="Tools"
|
||||
onClick={async () => {
|
||||
setViewingServerId(server.id);
|
||||
await loadTools(server.id);
|
||||
}}
|
||||
disabled={
|
||||
isLoading ||
|
||||
checkServerStatus(server.id).status === "error"
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<StopIcon />}
|
||||
text="Stop"
|
||||
onClick={() => pauseServer(server.id)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<AddIcon />}
|
||||
text="Add"
|
||||
onClick={() => addServer(server)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className={styles["mcp-market-page"]}>
|
||||
<div className="window-header">
|
||||
<div className="window-header-title">
|
||||
<div className="window-header-main-title">
|
||||
MCP Market
|
||||
{loadingStates["all"] && (
|
||||
<span className={styles["loading-indicator"]}>
|
||||
{loadingStates["all"]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="window-header-sub-title">
|
||||
{Object.keys(config?.mcpServers ?? {}).length} servers configured
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="window-actions">
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<RestartIcon />}
|
||||
bordered
|
||||
onClick={handleRestartAll}
|
||||
text="Restart All"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
bordered
|
||||
onClick={() => navigate(-1)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["mcp-market-page-body"]}>
|
||||
<div className={styles["mcp-market-filter"]}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles["search-bar"]}
|
||||
placeholder={"Search MCP Server"}
|
||||
autoFocus
|
||||
onInput={(e) => setSearchText(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles["server-list"]}>{renderServerList()}</div>
|
||||
</div>
|
||||
|
||||
{/*编辑服务器配置*/}
|
||||
{editingServerId && (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={`Configure Server - ${editingServerId}`}
|
||||
onClose={() => !isLoading && setEditingServerId(undefined)}
|
||||
actions={[
|
||||
<IconButton
|
||||
key="cancel"
|
||||
text="Cancel"
|
||||
onClick={() => setEditingServerId(undefined)}
|
||||
bordered
|
||||
disabled={isLoading}
|
||||
/>,
|
||||
<IconButton
|
||||
key="confirm"
|
||||
text="Save"
|
||||
type="primary"
|
||||
onClick={saveServerConfig}
|
||||
bordered
|
||||
disabled={isLoading}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<List>{renderConfigForm()}</List>
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewingServerId && (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={`Server Details - ${viewingServerId}`}
|
||||
onClose={() => setViewingServerId(undefined)}
|
||||
actions={[
|
||||
<IconButton
|
||||
key="close"
|
||||
text="Close"
|
||||
onClick={() => setViewingServerId(undefined)}
|
||||
bordered
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div className={styles["tools-list"]}>
|
||||
{isLoading ? (
|
||||
<div>Loading...</div>
|
||||
) : tools?.tools ? (
|
||||
tools.tools.map(
|
||||
(tool: ListToolsResponse["tools"], index: number) => (
|
||||
<div key={index} className={styles["tool-item"]}>
|
||||
<div className={styles["tool-name"]}>{tool.name}</div>
|
||||
<div className={styles["tool-description"]}>
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<div>No tools available</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
@@ -8,6 +8,7 @@ import Locale from "../locales";
|
||||
|
||||
import styles from "./message-selector.module.scss";
|
||||
import { getMessageTextContent } from "../utils";
|
||||
import clsx from "clsx";
|
||||
|
||||
function useShiftRange() {
|
||||
const [startIndex, setStartIndex] = useState<number>();
|
||||
@@ -71,6 +72,7 @@ export function MessageSelector(props: {
|
||||
defaultSelectAll?: boolean;
|
||||
onSelected?: (messages: ChatMessage[]) => void;
|
||||
}) {
|
||||
const LATEST_COUNT = 4;
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming;
|
||||
@@ -141,15 +143,13 @@ export function MessageSelector(props: {
|
||||
// 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"]}
|
||||
className={clsx(styles["filter-item"], styles["search-bar"])}
|
||||
value={searchInput}
|
||||
onInput={(e) => {
|
||||
setSearchInput(e.currentTarget.value);
|
||||
@@ -196,9 +196,9 @@ export function MessageSelector(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles["message"]} ${
|
||||
props.selection.has(m.id!) && styles["message-selected"]
|
||||
}`}
|
||||
className={clsx(styles["message"], {
|
||||
[styles["message-selected"]]: props.selection.has(m.id!),
|
||||
})}
|
||||
key={i}
|
||||
onClick={() => {
|
||||
props.updateSelection((selection) => {
|
||||
@@ -221,7 +221,7 @@ export function MessageSelector(props: {
|
||||
<div className={styles["date"]}>
|
||||
{new Date(m.date).toLocaleString()}
|
||||
</div>
|
||||
<div className={`${styles["content"]} one-line`}>
|
||||
<div className={clsx(styles["content"], "one-line")}>
|
||||
{getMessageTextContent(m)}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -7,6 +7,7 @@ import { ListItem, Select } from "./ui-lib";
|
||||
import { useAllModels } from "../utils/hooks";
|
||||
import { groupBy } from "lodash-es";
|
||||
import styles from "./model-config.module.scss";
|
||||
import { getModelProvider } from "../utils/model";
|
||||
|
||||
export function ModelConfigList(props: {
|
||||
modelConfig: ModelConfig;
|
||||
@@ -28,7 +29,9 @@ export function ModelConfigList(props: {
|
||||
value={value}
|
||||
align="left"
|
||||
onChange={(e) => {
|
||||
const [model, providerName] = e.currentTarget.value.split("@");
|
||||
const [model, providerName] = getModelProvider(
|
||||
e.currentTarget.value,
|
||||
);
|
||||
props.updateConfig((config) => {
|
||||
config.model = ModalConfigValidator.model(model);
|
||||
config.providerName = providerName as ServiceProvider;
|
||||
@@ -247,7 +250,9 @@ export function ModelConfigList(props: {
|
||||
aria-label={Locale.Settings.CompressModel.Title}
|
||||
value={compressModelValue}
|
||||
onChange={(e) => {
|
||||
const [model, providerName] = e.currentTarget.value.split("@");
|
||||
const [model, providerName] = getModelProvider(
|
||||
e.currentTarget.value,
|
||||
);
|
||||
props.updateConfig((config) => {
|
||||
config.compressModel = ModalConfigValidator.model(model);
|
||||
config.compressProviderName = providerName as ServiceProvider;
|
||||
|
@@ -16,6 +16,7 @@ import { MaskAvatar } from "./mask";
|
||||
import { useCommand } from "../command";
|
||||
import { showConfirm } from "./ui-lib";
|
||||
import { BUILTIN_MASK_STORE } from "../masks";
|
||||
import clsx from "clsx";
|
||||
|
||||
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
||||
return (
|
||||
@@ -24,7 +25,9 @@ function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
||||
avatar={props.mask.avatar}
|
||||
model={props.mask.modelConfig.model}
|
||||
/>
|
||||
<div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
|
||||
<div className={clsx(styles["mask-name"], "one-line")}>
|
||||
{props.mask.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -28,6 +28,7 @@ import {
|
||||
import Locale from "../locales";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export function PluginPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -199,7 +200,7 @@ export function PluginPage() {
|
||||
<div className={styles["mask-name"]}>
|
||||
{m.title}@<small>{m.version}</small>
|
||||
</div>
|
||||
<div className={styles["mask-info"] + " one-line"}>
|
||||
<div className={clsx(styles["mask-info"], "one-line")}>
|
||||
{Locale.Plugin.Item.Info(
|
||||
FunctionToolService.add(m).length,
|
||||
)}
|
||||
@@ -335,7 +336,10 @@ export function PluginPage() {
|
||||
<ListItem
|
||||
subTitle={
|
||||
<div
|
||||
className={`markdown-body ${pluginStyles["plugin-content"]}`}
|
||||
className={clsx(
|
||||
"markdown-body",
|
||||
pluginStyles["plugin-content"],
|
||||
)}
|
||||
dir="auto"
|
||||
>
|
||||
<pre>
|
||||
|
1
app/components/realtime-chat/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./realtime-chat";
|
74
app/components/realtime-chat/realtime-chat.module.scss
Normal file
@@ -0,0 +1,74 @@
|
||||
.realtime-chat {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
.circle-mic {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(to bottom right, #a0d8ef, #f0f8ff);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.icon-center {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.bottom-icons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.icon-left,
|
||||
.icon-right {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
font-size: 36px;
|
||||
background: var(--second);
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&.mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
359
app/components/realtime-chat/realtime-chat.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import VoiceIcon from "@/app/icons/voice.svg";
|
||||
import VoiceOffIcon from "@/app/icons/voice-off.svg";
|
||||
import PowerIcon from "@/app/icons/power.svg";
|
||||
|
||||
import styles from "./realtime-chat.module.scss";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
|
||||
import { useChatStore, createMessage, useAppConfig } from "@/app/store";
|
||||
|
||||
import { IconButton } from "@/app/components/button";
|
||||
|
||||
import {
|
||||
Modality,
|
||||
RTClient,
|
||||
RTInputAudioItem,
|
||||
RTResponse,
|
||||
TurnDetection,
|
||||
} from "rt-client";
|
||||
import { AudioHandler } from "@/app/lib/audio";
|
||||
import { uploadImage } from "@/app/utils/chat";
|
||||
import { VoicePrint } from "@/app/components/voice-print";
|
||||
|
||||
interface RealtimeChatProps {
|
||||
onClose?: () => void;
|
||||
onStartVoice?: () => void;
|
||||
onPausedVoice?: () => void;
|
||||
}
|
||||
|
||||
export function RealtimeChat({
|
||||
onClose,
|
||||
onStartVoice,
|
||||
onPausedVoice,
|
||||
}: RealtimeChatProps) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const config = useAppConfig();
|
||||
const [status, setStatus] = useState("");
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [modality, setModality] = useState("audio");
|
||||
const [useVAD, setUseVAD] = useState(true);
|
||||
const [frequencies, setFrequencies] = useState<Uint8Array | undefined>();
|
||||
|
||||
const clientRef = useRef<RTClient | null>(null);
|
||||
const audioHandlerRef = useRef<AudioHandler | null>(null);
|
||||
const initRef = useRef(false);
|
||||
|
||||
const temperature = config.realtimeConfig.temperature;
|
||||
const apiKey = config.realtimeConfig.apiKey;
|
||||
const model = config.realtimeConfig.model;
|
||||
const azure = config.realtimeConfig.provider === "Azure";
|
||||
const azureEndpoint = config.realtimeConfig.azure.endpoint;
|
||||
const azureDeployment = config.realtimeConfig.azure.deployment;
|
||||
const voice = config.realtimeConfig.voice;
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (isConnecting) return;
|
||||
if (!isConnected) {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
clientRef.current = azure
|
||||
? new RTClient(
|
||||
new URL(azureEndpoint),
|
||||
{ key: apiKey },
|
||||
{ deployment: azureDeployment },
|
||||
)
|
||||
: new RTClient({ key: apiKey }, { model });
|
||||
const modalities: Modality[] =
|
||||
modality === "audio" ? ["text", "audio"] : ["text"];
|
||||
const turnDetection: TurnDetection = useVAD
|
||||
? { type: "server_vad" }
|
||||
: null;
|
||||
await clientRef.current.configure({
|
||||
instructions: "",
|
||||
voice,
|
||||
input_audio_transcription: { model: "whisper-1" },
|
||||
turn_detection: turnDetection,
|
||||
tools: [],
|
||||
temperature,
|
||||
modalities,
|
||||
});
|
||||
startResponseListener();
|
||||
|
||||
setIsConnected(true);
|
||||
// TODO
|
||||
// try {
|
||||
// const recentMessages = chatStore.getMessagesWithMemory();
|
||||
// for (const message of recentMessages) {
|
||||
// const { role, content } = message;
|
||||
// if (typeof content === "string") {
|
||||
// await clientRef.current.sendItem({
|
||||
// type: "message",
|
||||
// role: role as any,
|
||||
// content: [
|
||||
// {
|
||||
// type: (role === "assistant" ? "text" : "input_text") as any,
|
||||
// text: content as string,
|
||||
// },
|
||||
// ],
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// // await clientRef.current.generateResponse();
|
||||
// } catch (error) {
|
||||
// console.error("Set message failed:", error);
|
||||
// }
|
||||
} catch (error) {
|
||||
console.error("Connection failed:", error);
|
||||
setStatus("Connection failed");
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
} else {
|
||||
await disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = async () => {
|
||||
if (clientRef.current) {
|
||||
try {
|
||||
await clientRef.current.close();
|
||||
clientRef.current = null;
|
||||
setIsConnected(false);
|
||||
} catch (error) {
|
||||
console.error("Disconnect failed:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startResponseListener = async () => {
|
||||
if (!clientRef.current) return;
|
||||
|
||||
try {
|
||||
for await (const serverEvent of clientRef.current.events()) {
|
||||
if (serverEvent.type === "response") {
|
||||
await handleResponse(serverEvent);
|
||||
} else if (serverEvent.type === "input_audio") {
|
||||
await handleInputAudio(serverEvent);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (clientRef.current) {
|
||||
console.error("Response iteration error:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResponse = async (response: RTResponse) => {
|
||||
for await (const item of response) {
|
||||
if (item.type === "message" && item.role === "assistant") {
|
||||
const botMessage = createMessage({
|
||||
role: item.role,
|
||||
content: "",
|
||||
});
|
||||
// add bot message first
|
||||
chatStore.updateTargetSession(session, (session) => {
|
||||
session.messages = session.messages.concat([botMessage]);
|
||||
});
|
||||
let hasAudio = false;
|
||||
for await (const content of item) {
|
||||
if (content.type === "text") {
|
||||
for await (const text of content.textChunks()) {
|
||||
botMessage.content += text;
|
||||
}
|
||||
} else if (content.type === "audio") {
|
||||
const textTask = async () => {
|
||||
for await (const text of content.transcriptChunks()) {
|
||||
botMessage.content += text;
|
||||
}
|
||||
};
|
||||
const audioTask = async () => {
|
||||
audioHandlerRef.current?.startStreamingPlayback();
|
||||
for await (const audio of content.audioChunks()) {
|
||||
hasAudio = true;
|
||||
audioHandlerRef.current?.playChunk(audio);
|
||||
}
|
||||
};
|
||||
await Promise.all([textTask(), audioTask()]);
|
||||
}
|
||||
// update message.content
|
||||
chatStore.updateTargetSession(session, (session) => {
|
||||
session.messages = session.messages.concat();
|
||||
});
|
||||
}
|
||||
if (hasAudio) {
|
||||
// upload audio get audio_url
|
||||
const blob = audioHandlerRef.current?.savePlayFile();
|
||||
uploadImage(blob!).then((audio_url) => {
|
||||
botMessage.audio_url = audio_url;
|
||||
// update text and audio_url
|
||||
chatStore.updateTargetSession(session, (session) => {
|
||||
session.messages = session.messages.concat();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputAudio = async (item: RTInputAudioItem) => {
|
||||
await item.waitForCompletion();
|
||||
if (item.transcription) {
|
||||
const userMessage = createMessage({
|
||||
role: "user",
|
||||
content: item.transcription,
|
||||
});
|
||||
chatStore.updateTargetSession(session, (session) => {
|
||||
session.messages = session.messages.concat([userMessage]);
|
||||
});
|
||||
// save input audio_url, and update session
|
||||
const { audioStartMillis, audioEndMillis } = item;
|
||||
// upload audio get audio_url
|
||||
const blob = audioHandlerRef.current?.saveRecordFile(
|
||||
audioStartMillis,
|
||||
audioEndMillis,
|
||||
);
|
||||
uploadImage(blob!).then((audio_url) => {
|
||||
userMessage.audio_url = audio_url;
|
||||
chatStore.updateTargetSession(session, (session) => {
|
||||
session.messages = session.messages.concat();
|
||||
});
|
||||
});
|
||||
}
|
||||
// stop streaming play after get input audio.
|
||||
audioHandlerRef.current?.stopStreamingPlayback();
|
||||
};
|
||||
|
||||
const toggleRecording = async () => {
|
||||
if (!isRecording && clientRef.current) {
|
||||
try {
|
||||
if (!audioHandlerRef.current) {
|
||||
audioHandlerRef.current = new AudioHandler();
|
||||
await audioHandlerRef.current.initialize();
|
||||
}
|
||||
await audioHandlerRef.current.startRecording(async (chunk) => {
|
||||
await clientRef.current?.sendAudio(chunk);
|
||||
});
|
||||
setIsRecording(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to start recording:", error);
|
||||
}
|
||||
} else if (audioHandlerRef.current) {
|
||||
try {
|
||||
audioHandlerRef.current.stopRecording();
|
||||
if (!useVAD) {
|
||||
const inputAudio = await clientRef.current?.commitAudio();
|
||||
await handleInputAudio(inputAudio!);
|
||||
await clientRef.current?.generateResponse();
|
||||
}
|
||||
setIsRecording(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to stop recording:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 防止重复初始化
|
||||
if (initRef.current) return;
|
||||
initRef.current = true;
|
||||
|
||||
const initAudioHandler = async () => {
|
||||
const handler = new AudioHandler();
|
||||
await handler.initialize();
|
||||
audioHandlerRef.current = handler;
|
||||
await handleConnect();
|
||||
await toggleRecording();
|
||||
};
|
||||
|
||||
initAudioHandler().catch((error) => {
|
||||
setStatus(error);
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (isRecording) {
|
||||
toggleRecording();
|
||||
}
|
||||
audioHandlerRef.current?.close().catch(console.error);
|
||||
disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let animationFrameId: number;
|
||||
|
||||
if (isConnected && isRecording) {
|
||||
const animationFrame = () => {
|
||||
if (audioHandlerRef.current) {
|
||||
const freqData = audioHandlerRef.current.getByteFrequencyData();
|
||||
setFrequencies(freqData);
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(animationFrame);
|
||||
};
|
||||
|
||||
animationFrameId = requestAnimationFrame(animationFrame);
|
||||
} else {
|
||||
setFrequencies(undefined);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
};
|
||||
}, [isConnected, isRecording]);
|
||||
|
||||
// update session params
|
||||
useEffect(() => {
|
||||
clientRef.current?.configure({ voice });
|
||||
}, [voice]);
|
||||
useEffect(() => {
|
||||
clientRef.current?.configure({ temperature });
|
||||
}, [temperature]);
|
||||
|
||||
const handleClose = async () => {
|
||||
onClose?.();
|
||||
if (isRecording) {
|
||||
await toggleRecording();
|
||||
}
|
||||
disconnect().catch(console.error);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles["realtime-chat"]}>
|
||||
<div
|
||||
className={clsx(styles["circle-mic"], {
|
||||
[styles["pulse"]]: isRecording,
|
||||
})}
|
||||
>
|
||||
<VoicePrint frequencies={frequencies} isActive={isRecording} />
|
||||
</div>
|
||||
|
||||
<div className={styles["bottom-icons"]}>
|
||||
<div>
|
||||
<IconButton
|
||||
icon={isRecording ? <VoiceIcon /> : <VoiceOffIcon />}
|
||||
onClick={toggleRecording}
|
||||
disabled={!isConnected}
|
||||
shadow
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["icon-center"]}>{status}</div>
|
||||
<div>
|
||||
<IconButton
|
||||
icon={<PowerIcon />}
|
||||
onClick={handleClose}
|
||||
shadow
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
173
app/components/realtime-chat/realtime-config.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { RealtimeConfig } from "@/app/store";
|
||||
|
||||
import Locale from "@/app/locales";
|
||||
import { ListItem, Select, PasswordInput } from "@/app/components/ui-lib";
|
||||
|
||||
import { InputRange } from "@/app/components/input-range";
|
||||
import { Voice } from "rt-client";
|
||||
import { ServiceProvider } from "@/app/constant";
|
||||
|
||||
const providers = [ServiceProvider.OpenAI, ServiceProvider.Azure];
|
||||
|
||||
const models = ["gpt-4o-realtime-preview-2024-10-01"];
|
||||
|
||||
const voice = ["alloy", "shimmer", "echo"];
|
||||
|
||||
export function RealtimeConfigList(props: {
|
||||
realtimeConfig: RealtimeConfig;
|
||||
updateConfig: (updater: (config: RealtimeConfig) => void) => void;
|
||||
}) {
|
||||
const azureConfigComponent = props.realtimeConfig.provider ===
|
||||
ServiceProvider.Azure && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Realtime.Azure.Endpoint.Title}
|
||||
subTitle={Locale.Settings.Realtime.Azure.Endpoint.SubTitle}
|
||||
>
|
||||
<input
|
||||
value={props.realtimeConfig?.azure?.endpoint}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.Realtime.Azure.Endpoint.Title}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) => (config.azure.endpoint = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Realtime.Azure.Deployment.Title}
|
||||
subTitle={Locale.Settings.Realtime.Azure.Deployment.SubTitle}
|
||||
>
|
||||
<input
|
||||
value={props.realtimeConfig?.azure?.deployment}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.Realtime.Azure.Deployment.Title}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) => (config.azure.deployment = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Realtime.Enable.Title}
|
||||
subTitle={Locale.Settings.Realtime.Enable.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.realtimeConfig.enable}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) => (config.enable = e.currentTarget.checked),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
|
||||
{props.realtimeConfig.enable && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Realtime.Provider.Title}
|
||||
subTitle={Locale.Settings.Realtime.Provider.SubTitle}
|
||||
>
|
||||
<Select
|
||||
aria-label={Locale.Settings.Realtime.Provider.Title}
|
||||
value={props.realtimeConfig.provider}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.provider = e.target.value as ServiceProvider),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{providers.map((v, i) => (
|
||||
<option value={v} key={i}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Realtime.Model.Title}
|
||||
subTitle={Locale.Settings.Realtime.Model.SubTitle}
|
||||
>
|
||||
<Select
|
||||
aria-label={Locale.Settings.Realtime.Model.Title}
|
||||
value={props.realtimeConfig.model}
|
||||
onChange={(e) => {
|
||||
props.updateConfig((config) => (config.model = e.target.value));
|
||||
}}
|
||||
>
|
||||
{models.map((v, i) => (
|
||||
<option value={v} key={i}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Realtime.ApiKey.Title}
|
||||
subTitle={Locale.Settings.Realtime.ApiKey.SubTitle}
|
||||
>
|
||||
<PasswordInput
|
||||
aria={Locale.Settings.ShowPassword}
|
||||
aria-label={Locale.Settings.Realtime.ApiKey.Title}
|
||||
value={props.realtimeConfig.apiKey}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.Realtime.ApiKey.Placeholder}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) => (config.apiKey = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
{azureConfigComponent}
|
||||
<ListItem
|
||||
title={Locale.Settings.TTS.Voice.Title}
|
||||
subTitle={Locale.Settings.TTS.Voice.SubTitle}
|
||||
>
|
||||
<Select
|
||||
value={props.realtimeConfig.voice}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) => (config.voice = e.currentTarget.value as Voice),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{voice.map((v, i) => (
|
||||
<option value={v} key={i}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Realtime.Temperature.Title}
|
||||
subTitle={Locale.Settings.Realtime.Temperature.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
aria={Locale.Settings.Temperature.Title}
|
||||
value={props.realtimeConfig?.temperature?.toFixed(1)}
|
||||
min="0.6"
|
||||
max="1"
|
||||
step="0.1"
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.temperature = e.currentTarget.valueAsNumber),
|
||||
);
|
||||
}}
|
||||
></InputRange>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -4,6 +4,7 @@ import { Select } from "@/app/components/ui-lib";
|
||||
import { IconButton } from "@/app/components/button";
|
||||
import Locale from "@/app/locales";
|
||||
import { useSdStore } from "@/app/store/sd";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const params = [
|
||||
{
|
||||
@@ -136,7 +137,7 @@ export function ControlParamItem(props: {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles["ctrl-param-item"] + ` ${props.className || ""}`}>
|
||||
<div className={clsx(styles["ctrl-param-item"], props.className)}>
|
||||
<div className={styles["ctrl-param-item-header"]}>
|
||||
<div className={styles["ctrl-param-item-title"]}>
|
||||
<div>
|
||||
|
@@ -36,6 +36,7 @@ import { removeImage } from "@/app/utils/chat";
|
||||
import { SideBar } from "./sd-sidebar";
|
||||
import { WindowContent } from "@/app/components/home";
|
||||
import { params } from "./sd-panel";
|
||||
import clsx from "clsx";
|
||||
|
||||
function getSdTaskStatus(item: any) {
|
||||
let s: string;
|
||||
@@ -104,7 +105,7 @@ export function Sd() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideBar className={isSd ? homeStyles["sidebar-show"] : ""} />
|
||||
<SideBar className={clsx({ [homeStyles["sidebar-show"]]: isSd })} />
|
||||
<WindowContent>
|
||||
<div className={chatStyles.chat} key={"1"}>
|
||||
<div className="window-header" data-tauri-drag-region>
|
||||
@@ -121,7 +122,10 @@ export function Sd() {
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`window-header-title ${chatStyles["chat-body-title"]}`}
|
||||
className={clsx(
|
||||
"window-header-title",
|
||||
chatStyles["chat-body-title"],
|
||||
)}
|
||||
>
|
||||
<div className={`window-header-main-title`}>Stability AI</div>
|
||||
<div className="window-header-sub-title">
|
||||
|
@@ -59,6 +59,7 @@ import {
|
||||
ByteDance,
|
||||
Alibaba,
|
||||
Moonshot,
|
||||
XAI,
|
||||
Google,
|
||||
GoogleSafetySettingsThreshold,
|
||||
OPENAI_BASE_URL,
|
||||
@@ -71,6 +72,8 @@ import {
|
||||
Stability,
|
||||
Iflytek,
|
||||
SAAS_CHAT_URL,
|
||||
ChatGLM,
|
||||
DeepSeek,
|
||||
} from "../constant";
|
||||
import { Prompt, SearchService, usePromptStore } from "../store/prompt";
|
||||
import { ErrorBoundary } from "./error";
|
||||
@@ -83,6 +86,7 @@ import { nanoid } from "nanoid";
|
||||
import { useMaskStore } from "../store/mask";
|
||||
import { ProviderType } from "../utils/cloud";
|
||||
import { TTSConfigList } from "./tts-config";
|
||||
import { RealtimeConfigList } from "./realtime-chat/realtime-config";
|
||||
|
||||
function EditPromptModal(props: { id: string; onClose: () => void }) {
|
||||
const promptStore = usePromptStore();
|
||||
@@ -1194,6 +1198,127 @@ export function Settings() {
|
||||
</>
|
||||
);
|
||||
|
||||
const deepseekConfigComponent = accessStore.provider ===
|
||||
ServiceProvider.DeepSeek && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.DeepSeek.Endpoint.Title}
|
||||
subTitle={
|
||||
Locale.Settings.Access.DeepSeek.Endpoint.SubTitle +
|
||||
DeepSeek.ExampleEndpoint
|
||||
}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Settings.Access.DeepSeek.Endpoint.Title}
|
||||
type="text"
|
||||
value={accessStore.deepseekUrl}
|
||||
placeholder={DeepSeek.ExampleEndpoint}
|
||||
onChange={(e) =>
|
||||
accessStore.update(
|
||||
(access) => (access.deepseekUrl = e.currentTarget.value),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.DeepSeek.ApiKey.Title}
|
||||
subTitle={Locale.Settings.Access.DeepSeek.ApiKey.SubTitle}
|
||||
>
|
||||
<PasswordInput
|
||||
aria-label={Locale.Settings.Access.DeepSeek.ApiKey.Title}
|
||||
value={accessStore.deepseekApiKey}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.Access.DeepSeek.ApiKey.Placeholder}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.deepseekApiKey = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
|
||||
const XAIConfigComponent = accessStore.provider === ServiceProvider.XAI && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.XAI.Endpoint.Title}
|
||||
subTitle={
|
||||
Locale.Settings.Access.XAI.Endpoint.SubTitle + XAI.ExampleEndpoint
|
||||
}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Settings.Access.XAI.Endpoint.Title}
|
||||
type="text"
|
||||
value={accessStore.xaiUrl}
|
||||
placeholder={XAI.ExampleEndpoint}
|
||||
onChange={(e) =>
|
||||
accessStore.update(
|
||||
(access) => (access.xaiUrl = e.currentTarget.value),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.XAI.ApiKey.Title}
|
||||
subTitle={Locale.Settings.Access.XAI.ApiKey.SubTitle}
|
||||
>
|
||||
<PasswordInput
|
||||
aria-label={Locale.Settings.Access.XAI.ApiKey.Title}
|
||||
value={accessStore.xaiApiKey}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.Access.XAI.ApiKey.Placeholder}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.xaiApiKey = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
|
||||
const chatglmConfigComponent = accessStore.provider ===
|
||||
ServiceProvider.ChatGLM && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.ChatGLM.Endpoint.Title}
|
||||
subTitle={
|
||||
Locale.Settings.Access.ChatGLM.Endpoint.SubTitle +
|
||||
ChatGLM.ExampleEndpoint
|
||||
}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Settings.Access.ChatGLM.Endpoint.Title}
|
||||
type="text"
|
||||
value={accessStore.chatglmUrl}
|
||||
placeholder={ChatGLM.ExampleEndpoint}
|
||||
onChange={(e) =>
|
||||
accessStore.update(
|
||||
(access) => (access.chatglmUrl = e.currentTarget.value),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.ChatGLM.ApiKey.Title}
|
||||
subTitle={Locale.Settings.Access.ChatGLM.ApiKey.SubTitle}
|
||||
>
|
||||
<PasswordInput
|
||||
aria-label={Locale.Settings.Access.ChatGLM.ApiKey.Title}
|
||||
value={accessStore.chatglmApiKey}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.Access.ChatGLM.ApiKey.Placeholder}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.chatglmApiKey = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
|
||||
const stabilityConfigComponent = accessStore.provider ===
|
||||
ServiceProvider.Stability && (
|
||||
<>
|
||||
@@ -1650,8 +1775,11 @@ export function Settings() {
|
||||
{alibabaConfigComponent}
|
||||
{tencentConfigComponent}
|
||||
{moonshotConfigComponent}
|
||||
{deepseekConfigComponent}
|
||||
{stabilityConfigComponent}
|
||||
{lflytekConfigComponent}
|
||||
{XAIConfigComponent}
|
||||
{chatglmConfigComponent}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -1686,9 +1814,11 @@ export function Settings() {
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.CustomModel.Title}
|
||||
subTitle={Locale.Settings.Access.CustomModel.SubTitle}
|
||||
vertical={true}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Settings.Access.CustomModel.Title}
|
||||
style={{ width: "100%", maxWidth: "unset", textAlign: "left" }}
|
||||
type="text"
|
||||
value={config.customModels}
|
||||
placeholder="model1,model2,model3"
|
||||
@@ -1715,7 +1845,18 @@ export function Settings() {
|
||||
{shouldShowPromptModal && (
|
||||
<UserPromptModal onClose={() => setShowPromptModal(false)} />
|
||||
)}
|
||||
|
||||
<List>
|
||||
<RealtimeConfigList
|
||||
realtimeConfig={config.realtimeConfig}
|
||||
updateConfig={(updater) => {
|
||||
const realtimeConfig = { ...config.realtimeConfig };
|
||||
updater(realtimeConfig);
|
||||
config.update(
|
||||
(config) => (config.realtimeConfig = realtimeConfig),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</List>
|
||||
<List>
|
||||
<TTSConfigList
|
||||
ttsConfig={config.ttsConfig}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useMemo, useState, Fragment } from "react";
|
||||
import React, { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import styles from "./home.module.scss";
|
||||
|
||||
@@ -9,6 +9,7 @@ import ChatGptIcon from "../icons/chatgpt.svg";
|
||||
import AddIcon from "../icons/add.svg";
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import MaskIcon from "../icons/mask.svg";
|
||||
import McpIcon from "../icons/mcp.svg";
|
||||
import DragIcon from "../icons/drag.svg";
|
||||
import DiscoveryIcon from "../icons/discovery.svg";
|
||||
|
||||
@@ -22,14 +23,21 @@ import {
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
NARROW_SIDEBAR_WIDTH,
|
||||
Path,
|
||||
PLUGINS,
|
||||
REPO_URL,
|
||||
} from "../constant";
|
||||
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { isIOS, useMobileScreen } from "../utils";
|
||||
import dynamic from "next/dynamic";
|
||||
import { showConfirm, Selector } from "./ui-lib";
|
||||
import { Selector, showConfirm } from "./ui-lib";
|
||||
import clsx from "clsx";
|
||||
import { isMcpEnabled } from "../mcp/actions";
|
||||
|
||||
const DISCOVERY = [
|
||||
{ name: Locale.Plugin.Name, path: Path.Plugins },
|
||||
{ name: "Stable Diffusion", path: Path.Sd },
|
||||
{ name: Locale.SearchChat.Page.Title, path: Path.SearchChat },
|
||||
];
|
||||
|
||||
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
|
||||
loading: () => null,
|
||||
@@ -127,6 +135,7 @@ export function useDragSideBar() {
|
||||
shouldNarrow,
|
||||
};
|
||||
}
|
||||
|
||||
export function SideBarContainer(props: {
|
||||
children: React.ReactNode;
|
||||
onDragStart: (e: MouseEvent) => void;
|
||||
@@ -141,9 +150,9 @@ export function SideBarContainer(props: {
|
||||
const { children, className, onDragStart, shouldNarrow } = props;
|
||||
return (
|
||||
<div
|
||||
className={`${styles.sidebar} ${className} ${
|
||||
shouldNarrow && styles["narrow-sidebar"]
|
||||
}`}
|
||||
className={clsx(styles.sidebar, className, {
|
||||
[styles["narrow-sidebar"]]: shouldNarrow,
|
||||
})}
|
||||
style={{
|
||||
// #3016 disable transition on ios mobile screen
|
||||
transition: isMobileScreen && isIOSMobile ? "none" : undefined,
|
||||
@@ -171,9 +180,9 @@ export function SideBarHeader(props: {
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
className={`${styles["sidebar-header"]} ${
|
||||
shouldNarrow ? styles["sidebar-header-narrow"] : ""
|
||||
}`}
|
||||
className={clsx(styles["sidebar-header"], {
|
||||
[styles["sidebar-header-narrow"]]: shouldNarrow,
|
||||
})}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className={styles["sidebar-title-container"]}>
|
||||
@@ -182,7 +191,7 @@ export function SideBarHeader(props: {
|
||||
</div>
|
||||
<div className={styles["sidebar-sub-title"]}>{subTitle}</div>
|
||||
</div>
|
||||
<div className={styles["sidebar-logo"] + " no-dark"}>{logo}</div>
|
||||
<div className={clsx(styles["sidebar-logo"], "no-dark")}>{logo}</div>
|
||||
</div>
|
||||
{children}
|
||||
</Fragment>
|
||||
@@ -218,10 +227,21 @@ export function SideBarTail(props: {
|
||||
export function SideBar(props: { className?: string }) {
|
||||
useHotKey();
|
||||
const { onDragStart, shouldNarrow } = useDragSideBar();
|
||||
const [showPluginSelector, setShowPluginSelector] = useState(false);
|
||||
const [showDiscoverySelector, setshowDiscoverySelector] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const config = useAppConfig();
|
||||
const chatStore = useChatStore();
|
||||
const [mcpEnabled, setMcpEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 检查 MCP 是否启用
|
||||
const checkMcpStatus = async () => {
|
||||
const enabled = await isMcpEnabled();
|
||||
setMcpEnabled(enabled);
|
||||
console.log("[SideBar] MCP enabled:", enabled);
|
||||
};
|
||||
checkMcpStatus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SideBarContainer
|
||||
@@ -249,25 +269,36 @@ export function SideBar(props: { className?: string }) {
|
||||
}}
|
||||
shadow
|
||||
/>
|
||||
{mcpEnabled && (
|
||||
<IconButton
|
||||
icon={<McpIcon />}
|
||||
text={shouldNarrow ? undefined : Locale.Mcp.Name}
|
||||
className={styles["sidebar-bar-button"]}
|
||||
onClick={() => {
|
||||
navigate(Path.McpMarket, { state: { fromHome: true } });
|
||||
}}
|
||||
shadow
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={<DiscoveryIcon />}
|
||||
text={shouldNarrow ? undefined : Locale.Discovery.Name}
|
||||
className={styles["sidebar-bar-button"]}
|
||||
onClick={() => setShowPluginSelector(true)}
|
||||
onClick={() => setshowDiscoverySelector(true)}
|
||||
shadow
|
||||
/>
|
||||
</div>
|
||||
{showPluginSelector && (
|
||||
{showDiscoverySelector && (
|
||||
<Selector
|
||||
items={[
|
||||
...PLUGINS.map((item) => {
|
||||
...DISCOVERY.map((item) => {
|
||||
return {
|
||||
title: item.name,
|
||||
value: item.path,
|
||||
};
|
||||
}),
|
||||
]}
|
||||
onClose={() => setShowPluginSelector(false)}
|
||||
onClose={() => setshowDiscoverySelector(false)}
|
||||
onSelection={(s) => {
|
||||
navigate(s[0], { state: { fromHome: true } });
|
||||
}}
|
||||
@@ -286,7 +317,7 @@ export function SideBar(props: { className?: string }) {
|
||||
<SideBarTail
|
||||
primaryAction={
|
||||
<>
|
||||
<div className={styles["sidebar-action"] + " " + styles.mobile}>
|
||||
<div className={clsx(styles["sidebar-action"], styles.mobile)}>
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
onClick={async () => {
|
||||
|
@@ -23,6 +23,7 @@ import React, {
|
||||
useRef,
|
||||
} from "react";
|
||||
import { IconButton } from "./button";
|
||||
import clsx from "clsx";
|
||||
|
||||
export function Popover(props: {
|
||||
children: JSX.Element;
|
||||
@@ -45,7 +46,7 @@ export function Popover(props: {
|
||||
|
||||
export function Card(props: { children: JSX.Element[]; className?: string }) {
|
||||
return (
|
||||
<div className={styles.card + " " + props.className}>{props.children}</div>
|
||||
<div className={clsx(styles.card, props.className)}>{props.children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,11 +61,13 @@ export function ListItem(props: {
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
styles["list-item"] +
|
||||
` ${props.vertical ? styles["vertical"] : ""} ` +
|
||||
` ${props.className || ""}`
|
||||
}
|
||||
className={clsx(
|
||||
styles["list-item"],
|
||||
{
|
||||
[styles["vertical"]]: props.vertical,
|
||||
},
|
||||
props.className,
|
||||
)}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<div className={styles["list-header"]}>
|
||||
@@ -135,9 +138,9 @@ export function Modal(props: ModalProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
|
||||
}
|
||||
className={clsx(styles["modal-container"], {
|
||||
[styles["modal-container-max"]]: isMax,
|
||||
})}
|
||||
>
|
||||
<div className={styles["modal-header"]}>
|
||||
<div className={styles["modal-title"]}>{props.title}</div>
|
||||
@@ -260,7 +263,7 @@ export function Input(props: InputProps) {
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
className={`${styles["input"]} ${props.className}`}
|
||||
className={clsx(styles["input"], props.className)}
|
||||
></textarea>
|
||||
);
|
||||
}
|
||||
@@ -301,9 +304,13 @@ export function Select(
|
||||
const { className, children, align, ...otherProps } = props;
|
||||
return (
|
||||
<div
|
||||
className={`${styles["select-with-icon"]} ${
|
||||
align === "left" ? styles["left-align-option"] : ""
|
||||
} ${className}`}
|
||||
className={clsx(
|
||||
styles["select-with-icon"],
|
||||
{
|
||||
[styles["left-align-option"]]: align === "left",
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<select className={styles["select-with-icon-select"]} {...otherProps}>
|
||||
{children}
|
||||
@@ -509,9 +516,9 @@ export function Selector<T>(props: {
|
||||
const selected = selectedValues.includes(item.value);
|
||||
return (
|
||||
<ListItem
|
||||
className={`${styles["selector-item"]} ${
|
||||
item.disable && styles["selector-item-disabled"]
|
||||
}`}
|
||||
className={clsx(styles["selector-item"], {
|
||||
[styles["selector-item-disabled"]]: item.disable,
|
||||
})}
|
||||
key={i}
|
||||
title={item.title}
|
||||
subTitle={item.subTitle}
|
||||
|
1
app/components/voice-print/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./voice-print";
|
11
app/components/voice-print/voice-print.module.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.voice-print {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
margin: 20px 0;
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: brightness(1.2); // 增加整体亮度
|
||||
}
|
||||
}
|
180
app/components/voice-print/voice-print.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import styles from "./voice-print.module.scss";
|
||||
|
||||
interface VoicePrintProps {
|
||||
frequencies?: Uint8Array;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function VoicePrint({ frequencies, isActive }: VoicePrintProps) {
|
||||
// Canvas引用,用于获取绘图上下文
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
// 存储历史频率数据,用于平滑处理
|
||||
const historyRef = useRef<number[][]>([]);
|
||||
// 控制保留的历史数据帧数,影响平滑度
|
||||
const historyLengthRef = useRef(10);
|
||||
// 存储动画帧ID,用于清理
|
||||
const animationFrameRef = useRef<number>();
|
||||
|
||||
/**
|
||||
* 更新频率历史数据
|
||||
* 使用FIFO队列维护固定长度的历史记录
|
||||
*/
|
||||
const updateHistory = useCallback((freqArray: number[]) => {
|
||||
historyRef.current.push(freqArray);
|
||||
if (historyRef.current.length > historyLengthRef.current) {
|
||||
historyRef.current.shift();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
/**
|
||||
* 处理高DPI屏幕显示
|
||||
* 根据设备像素比例调整canvas实际渲染分辨率
|
||||
*/
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = canvas.offsetWidth * dpr;
|
||||
canvas.height = canvas.offsetHeight * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
/**
|
||||
* 主要绘制函数
|
||||
* 使用requestAnimationFrame实现平滑动画
|
||||
* 包含以下步骤:
|
||||
* 1. 清空画布
|
||||
* 2. 更新历史数据
|
||||
* 3. 计算波形点
|
||||
* 4. 绘制上下对称的声纹
|
||||
*/
|
||||
const draw = () => {
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (!frequencies || !isActive) {
|
||||
historyRef.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const freqArray = Array.from(frequencies);
|
||||
updateHistory(freqArray);
|
||||
|
||||
// 绘制声纹
|
||||
const points: [number, number][] = [];
|
||||
const centerY = canvas.height / 2;
|
||||
const width = canvas.width;
|
||||
const sliceWidth = width / (frequencies.length - 1);
|
||||
|
||||
// 绘制主波形
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, centerY);
|
||||
|
||||
/**
|
||||
* 声纹绘制算法:
|
||||
* 1. 使用历史数据平均值实现平滑过渡
|
||||
* 2. 通过正弦函数添加自然波动
|
||||
* 3. 使用贝塞尔曲线连接点,使曲线更平滑
|
||||
* 4. 绘制对称部分形成完整声纹
|
||||
*/
|
||||
for (let i = 0; i < frequencies.length; i++) {
|
||||
const x = i * sliceWidth;
|
||||
let avgFrequency = frequencies[i];
|
||||
|
||||
/**
|
||||
* 波形平滑处理:
|
||||
* 1. 收集历史数据中对应位置的频率值
|
||||
* 2. 计算当前值与历史值的加权平均
|
||||
* 3. 根据平均值计算实际显示高度
|
||||
*/
|
||||
if (historyRef.current.length > 0) {
|
||||
const historicalValues = historyRef.current.map((h) => h[i] || 0);
|
||||
avgFrequency =
|
||||
(avgFrequency + historicalValues.reduce((a, b) => a + b, 0)) /
|
||||
(historyRef.current.length + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 波形变换:
|
||||
* 1. 归一化频率值到0-1范围
|
||||
* 2. 添加时间相关的正弦变换
|
||||
* 3. 使用贝塞尔曲线平滑连接点
|
||||
*/
|
||||
const normalized = avgFrequency / 255.0;
|
||||
const height = normalized * (canvas.height / 2);
|
||||
const y = centerY + height * Math.sin(i * 0.2 + Date.now() * 0.002);
|
||||
|
||||
points.push([x, y]);
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
// 使用贝塞尔曲线使波形更平滑
|
||||
const prevPoint = points[i - 1];
|
||||
const midX = (prevPoint[0] + x) / 2;
|
||||
ctx.quadraticCurveTo(
|
||||
prevPoint[0],
|
||||
prevPoint[1],
|
||||
midX,
|
||||
(prevPoint[1] + y) / 2,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制对称的下半部分
|
||||
for (let i = points.length - 1; i >= 0; i--) {
|
||||
const [x, y] = points[i];
|
||||
const symmetricY = centerY - (y - centerY);
|
||||
if (i === points.length - 1) {
|
||||
ctx.lineTo(x, symmetricY);
|
||||
} else {
|
||||
const nextPoint = points[i + 1];
|
||||
const midX = (nextPoint[0] + x) / 2;
|
||||
ctx.quadraticCurveTo(
|
||||
nextPoint[0],
|
||||
centerY - (nextPoint[1] - centerY),
|
||||
midX,
|
||||
centerY - ((nextPoint[1] + y) / 2 - centerY),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.closePath();
|
||||
|
||||
/**
|
||||
* 渐变效果:
|
||||
* 从左到右应用三色渐变,带透明度
|
||||
* 使用蓝色系配色提升视觉效果
|
||||
*/
|
||||
const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
||||
gradient.addColorStop(0, "rgba(100, 180, 255, 0.95)");
|
||||
gradient.addColorStop(0.5, "rgba(140, 200, 255, 0.9)");
|
||||
gradient.addColorStop(1, "rgba(180, 220, 255, 0.95)");
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
// 启动动画循环
|
||||
draw();
|
||||
|
||||
// 清理函数:在组件卸载时取消动画
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [frequencies, isActive, updateHistory]);
|
||||
|
||||
return (
|
||||
<div className={styles["voice-print"]}>
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
import md5 from "spark-md5";
|
||||
import { DEFAULT_MODELS, DEFAULT_GA_ID } from "../constant";
|
||||
import { isGPT4Model } from "../utils/model";
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
@@ -22,6 +23,7 @@ declare global {
|
||||
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
|
||||
VISION_MODELS?: string; // to control vision models
|
||||
|
||||
// stability only
|
||||
STABILITY_URL?: string;
|
||||
@@ -71,8 +73,21 @@ declare global {
|
||||
IFLYTEK_API_KEY?: string;
|
||||
IFLYTEK_API_SECRET?: string;
|
||||
|
||||
DEEPSEEK_URL?: string;
|
||||
DEEPSEEK_API_KEY?: string;
|
||||
|
||||
// xai only
|
||||
XAI_URL?: string;
|
||||
XAI_API_KEY?: string;
|
||||
|
||||
// chatglm only
|
||||
CHATGLM_URL?: string;
|
||||
CHATGLM_API_KEY?: string;
|
||||
|
||||
// custom template for preprocessing user input
|
||||
DEFAULT_INPUT_TEMPLATE?: string;
|
||||
|
||||
ENABLE_MCP?: string; // enable mcp functionality
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,22 +131,16 @@ export const getServerSideConfig = () => {
|
||||
const disableGPT4 = !!process.env.DISABLE_GPT4;
|
||||
let customModels = process.env.CUSTOM_MODELS ?? "";
|
||||
let defaultModel = process.env.DEFAULT_MODEL ?? "";
|
||||
let visionModels = process.env.VISION_MODELS ?? "";
|
||||
|
||||
if (disableGPT4) {
|
||||
if (customModels) customModels += ",";
|
||||
customModels += DEFAULT_MODELS.filter(
|
||||
(m) =>
|
||||
(m.name.startsWith("gpt-4") || m.name.startsWith("chatgpt-4o")) &&
|
||||
!m.name.startsWith("gpt-4o-mini"),
|
||||
)
|
||||
customModels += DEFAULT_MODELS.filter((m) => isGPT4Model(m.name))
|
||||
.map((m) => "-" + m.name)
|
||||
.join(",");
|
||||
if (
|
||||
(defaultModel.startsWith("gpt-4") ||
|
||||
defaultModel.startsWith("chatgpt-4o")) &&
|
||||
!defaultModel.startsWith("gpt-4o-mini")
|
||||
)
|
||||
if (defaultModel && isGPT4Model(defaultModel)) {
|
||||
defaultModel = "";
|
||||
}
|
||||
}
|
||||
|
||||
const isStability = !!process.env.STABILITY_API_KEY;
|
||||
@@ -146,6 +155,9 @@ export const getServerSideConfig = () => {
|
||||
const isAlibaba = !!process.env.ALIBABA_API_KEY;
|
||||
const isMoonshot = !!process.env.MOONSHOT_API_KEY;
|
||||
const isIflytek = !!process.env.IFLYTEK_API_KEY;
|
||||
const isDeepSeek = !!process.env.DEEPSEEK_API_KEY;
|
||||
const isXAI = !!process.env.XAI_API_KEY;
|
||||
const isChatGLM = !!process.env.CHATGLM_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);
|
||||
@@ -208,6 +220,18 @@ export const getServerSideConfig = () => {
|
||||
iflytekApiKey: process.env.IFLYTEK_API_KEY,
|
||||
iflytekApiSecret: process.env.IFLYTEK_API_SECRET,
|
||||
|
||||
isDeepSeek,
|
||||
deepseekUrl: process.env.DEEPSEEK_URL,
|
||||
deepseekApiKey: getApiKey(process.env.DEEPSEEK_API_KEY),
|
||||
|
||||
isXAI,
|
||||
xaiUrl: process.env.XAI_URL,
|
||||
xaiApiKey: getApiKey(process.env.XAI_API_KEY),
|
||||
|
||||
isChatGLM,
|
||||
chatglmUrl: process.env.CHATGLM_URL,
|
||||
chatglmApiKey: getApiKey(process.env.CHATGLM_API_KEY),
|
||||
|
||||
cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID,
|
||||
cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID,
|
||||
cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY),
|
||||
@@ -229,6 +253,8 @@ export const getServerSideConfig = () => {
|
||||
disableFastLink: !!process.env.DISABLE_FAST_LINK,
|
||||
customModels,
|
||||
defaultModel,
|
||||
visionModels,
|
||||
allowedWebDavEndpoints,
|
||||
enableMcp: !!process.env.ENABLE_MCP,
|
||||
};
|
||||
};
|
||||
|
263
app/constant.ts
@@ -28,6 +28,12 @@ export const TENCENT_BASE_URL = "https://hunyuan.tencentcloudapi.com";
|
||||
export const MOONSHOT_BASE_URL = "https://api.moonshot.cn";
|
||||
export const IFLYTEK_BASE_URL = "https://spark-api-open.xf-yun.com";
|
||||
|
||||
export const DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
||||
|
||||
export const XAI_BASE_URL = "https://api.x.ai";
|
||||
|
||||
export const CHATGLM_BASE_URL = "https://open.bigmodel.cn";
|
||||
|
||||
export const CACHE_URL_PREFIX = "/api/cache";
|
||||
export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`;
|
||||
|
||||
@@ -43,6 +49,7 @@ export enum Path {
|
||||
SdNew = "/sd-new",
|
||||
Artifacts = "/artifacts",
|
||||
SearchChat = "/search-chat",
|
||||
McpMarket = "/mcp-market",
|
||||
}
|
||||
|
||||
export enum ApiPath {
|
||||
@@ -59,6 +66,9 @@ export enum ApiPath {
|
||||
Iflytek = "/api/iflytek",
|
||||
Stability = "/api/stability",
|
||||
Artifacts = "/api/artifacts",
|
||||
XAI = "/api/xai",
|
||||
ChatGLM = "/api/chatglm",
|
||||
DeepSeek = "/api/deepseek",
|
||||
}
|
||||
|
||||
export enum SlotID {
|
||||
@@ -81,6 +91,7 @@ export enum StoreKey {
|
||||
Update = "chat-update",
|
||||
Sync = "sync",
|
||||
SdList = "sd-list",
|
||||
Mcp = "mcp-store",
|
||||
}
|
||||
|
||||
export const DEFAULT_SIDEBAR_WIDTH = 300;
|
||||
@@ -111,6 +122,9 @@ export enum ServiceProvider {
|
||||
Moonshot = "Moonshot",
|
||||
Stability = "Stability",
|
||||
Iflytek = "Iflytek",
|
||||
XAI = "XAI",
|
||||
ChatGLM = "ChatGLM",
|
||||
DeepSeek = "DeepSeek",
|
||||
}
|
||||
|
||||
// Google API safety settings, see https://ai.google.dev/gemini-api/docs/safety-settings
|
||||
@@ -133,6 +147,9 @@ export enum ModelProvider {
|
||||
Hunyuan = "Hunyuan",
|
||||
Moonshot = "Moonshot",
|
||||
Iflytek = "Iflytek",
|
||||
XAI = "XAI",
|
||||
ChatGLM = "ChatGLM",
|
||||
DeepSeek = "DeepSeek",
|
||||
}
|
||||
|
||||
export const Stability = {
|
||||
@@ -215,6 +232,23 @@ export const Iflytek = {
|
||||
ChatPath: "v1/chat/completions",
|
||||
};
|
||||
|
||||
export const DeepSeek = {
|
||||
ExampleEndpoint: DEEPSEEK_BASE_URL,
|
||||
ChatPath: "chat/completions",
|
||||
};
|
||||
|
||||
export const XAI = {
|
||||
ExampleEndpoint: XAI_BASE_URL,
|
||||
ChatPath: "v1/chat/completions",
|
||||
};
|
||||
|
||||
export const ChatGLM = {
|
||||
ExampleEndpoint: CHATGLM_BASE_URL,
|
||||
ChatPath: "api/paas/v4/chat/completions",
|
||||
ImagePath: "api/paas/v4/images/generations",
|
||||
VideoPath: "api/paas/v4/videos/generations",
|
||||
};
|
||||
|
||||
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}}.
|
||||
@@ -233,6 +267,130 @@ Latex inline: \\(x^2\\)
|
||||
Latex block: $$e=mc^2$$
|
||||
`;
|
||||
|
||||
export const MCP_TOOLS_TEMPLATE = `
|
||||
[clientId]
|
||||
{{ clientId }}
|
||||
[tools]
|
||||
{{ tools }}
|
||||
`;
|
||||
|
||||
export const MCP_SYSTEM_TEMPLATE = `
|
||||
You are an AI assistant with access to system tools. Your role is to help users by combining natural language understanding with tool operations when needed.
|
||||
|
||||
1. AVAILABLE TOOLS:
|
||||
{{ MCP_TOOLS }}
|
||||
|
||||
2. WHEN TO USE TOOLS:
|
||||
- ALWAYS USE TOOLS when they can help answer user questions
|
||||
- DO NOT just describe what you could do - TAKE ACTION immediately
|
||||
- If you're not sure whether to use a tool, USE IT
|
||||
- Common triggers for tool use:
|
||||
* Questions about files or directories
|
||||
* Requests to check, list, or manipulate system resources
|
||||
* Any query that can be answered with available tools
|
||||
|
||||
3. HOW TO USE TOOLS:
|
||||
A. Tool Call Format:
|
||||
- Use markdown code blocks with format: \`\`\`json:mcp:{clientId}\`\`\`
|
||||
- Always include:
|
||||
* method: "tools/call"(Only this method is supported)
|
||||
* params:
|
||||
- name: must match an available primitive name
|
||||
- arguments: required parameters for the primitive
|
||||
|
||||
B. Response Format:
|
||||
- Tool responses will come as user messages
|
||||
- Format: \`\`\`json:mcp-response:{clientId}\`\`\`
|
||||
- Wait for response before making another tool call
|
||||
|
||||
C. Important Rules:
|
||||
- Only use tools/call method
|
||||
- Only ONE tool call per message
|
||||
- ALWAYS TAKE ACTION instead of just describing what you could do
|
||||
- Include the correct clientId in code block language tag
|
||||
- Verify arguments match the primitive's requirements
|
||||
|
||||
4. INTERACTION FLOW:
|
||||
A. When user makes a request:
|
||||
- IMMEDIATELY use appropriate tool if available
|
||||
- DO NOT ask if user wants you to use the tool
|
||||
- DO NOT just describe what you could do
|
||||
B. After receiving tool response:
|
||||
- Explain results clearly
|
||||
- Take next appropriate action if needed
|
||||
C. If tools fail:
|
||||
- Explain the error
|
||||
- Try alternative approach immediately
|
||||
|
||||
5. EXAMPLE INTERACTION:
|
||||
|
||||
good example:
|
||||
|
||||
\`\`\`json:mcp:filesystem
|
||||
{
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "list_allowed_directories",
|
||||
"arguments": {}
|
||||
}
|
||||
}
|
||||
\`\`\`"
|
||||
|
||||
|
||||
\`\`\`json:mcp-response:filesystem
|
||||
{
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "write_file",
|
||||
"arguments": {
|
||||
"path": "/Users/river/dev/nextchat/test/joke.txt",
|
||||
"content": "为什么数学书总是感到忧伤?因为它有太多的问题。"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
follwing is the wrong! mcp json example:
|
||||
|
||||
\`\`\`json:mcp:filesystem
|
||||
{
|
||||
"method": "write_file",
|
||||
"params": {
|
||||
"path": "NextChat_Information.txt",
|
||||
"content": "1"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
This is wrong because the method is not tools/call.
|
||||
|
||||
\`\`\`{
|
||||
"method": "search_repositories",
|
||||
"params": {
|
||||
"query": "2oeee"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
This is wrong because the method is not tools/call.!!!!!!!!!!!
|
||||
|
||||
the right format is:
|
||||
\`\`\`json:mcp:filesystem
|
||||
{
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "search_repositories",
|
||||
"arguments": {
|
||||
"query": "2oeee"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
please follow the format strictly ONLY use tools/call method!!!!!!!!!!!
|
||||
|
||||
`;
|
||||
|
||||
export const SUMMARIZE_MODEL = "gpt-4o-mini";
|
||||
export const GEMINI_SUMMARIZE_MODEL = "gemini-pro";
|
||||
|
||||
@@ -244,6 +402,7 @@ export const KnowledgeCutOffDate: Record<string, string> = {
|
||||
"gpt-4o": "2023-10",
|
||||
"gpt-4o-2024-05-13": "2023-10",
|
||||
"gpt-4o-2024-08-06": "2023-10",
|
||||
"gpt-4o-2024-11-20": "2023-10",
|
||||
"chatgpt-4o-latest": "2023-10",
|
||||
"gpt-4o-mini": "2023-10",
|
||||
"gpt-4o-mini-2024-07-18": "2023-10",
|
||||
@@ -254,6 +413,8 @@ export const KnowledgeCutOffDate: Record<string, string> = {
|
||||
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
|
||||
"gemini-pro": "2023-12",
|
||||
"gemini-pro-vision": "2023-12",
|
||||
"deepseek-chat": "2024-07",
|
||||
"deepseek-coder": "2024-07",
|
||||
};
|
||||
|
||||
export const DEFAULT_TTS_ENGINE = "OpenAI-TTS";
|
||||
@@ -270,6 +431,23 @@ export const DEFAULT_TTS_VOICES = [
|
||||
"shimmer",
|
||||
];
|
||||
|
||||
export const VISION_MODEL_REGEXES = [
|
||||
/vision/,
|
||||
/gpt-4o/,
|
||||
/claude-3/,
|
||||
/gemini-1\.5/,
|
||||
/gemini-exp/,
|
||||
/gemini-2\.0/,
|
||||
/learnlm/,
|
||||
/qwen-vl/,
|
||||
/qwen2-vl/,
|
||||
/gpt-4-turbo(?!.*preview)/, // Matches "gpt-4-turbo" but not "gpt-4-turbo-preview"
|
||||
/^dall-e-3$/, // Matches exactly "dall-e-3"
|
||||
/glm-4v/,
|
||||
];
|
||||
|
||||
export const EXCLUDE_VISION_MODEL_REGEXES = [/claude-3-5-haiku-20241022/];
|
||||
|
||||
const openaiModels = [
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-3.5-turbo-1106",
|
||||
@@ -283,6 +461,7 @@ const openaiModels = [
|
||||
"gpt-4o",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-2024-08-06",
|
||||
"gpt-4o-2024-11-20",
|
||||
"chatgpt-4o-latest",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
@@ -295,10 +474,23 @@ const openaiModels = [
|
||||
];
|
||||
|
||||
const googleModels = [
|
||||
"gemini-1.0-pro",
|
||||
"gemini-1.0-pro", // Deprecated on 2/15/2025
|
||||
"gemini-1.5-pro-latest",
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-pro-002",
|
||||
"gemini-1.5-pro-exp-0827",
|
||||
"gemini-1.5-flash-latest",
|
||||
"gemini-pro-vision",
|
||||
"gemini-1.5-flash-8b-latest",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-flash-8b",
|
||||
"gemini-1.5-flash-002",
|
||||
"gemini-1.5-flash-exp-0827",
|
||||
"learnlm-1.5-pro-experimental",
|
||||
"gemini-exp-1114",
|
||||
"gemini-exp-1121",
|
||||
"gemini-exp-1206",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-2.0-flash-thinking-exp-1219",
|
||||
];
|
||||
|
||||
const anthropicModels = [
|
||||
@@ -307,8 +499,13 @@ const anthropicModels = [
|
||||
"claude-2.1",
|
||||
"claude-3-sonnet-20240229",
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-opus-latest",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"claude-3-5-haiku-latest",
|
||||
"claude-3-5-sonnet-20240620",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-sonnet-latest",
|
||||
];
|
||||
|
||||
const baiduModels = [
|
||||
@@ -364,6 +561,30 @@ const iflytekModels = [
|
||||
"4.0Ultra",
|
||||
];
|
||||
|
||||
const deepseekModels = ["deepseek-chat", "deepseek-coder"];
|
||||
|
||||
const xAIModes = ["grok-beta"];
|
||||
|
||||
const chatglmModels = [
|
||||
"glm-4-plus",
|
||||
"glm-4-0520",
|
||||
"glm-4",
|
||||
"glm-4-air",
|
||||
"glm-4-airx",
|
||||
"glm-4-long",
|
||||
"glm-4-flashx",
|
||||
"glm-4-flash",
|
||||
"glm-4v-plus",
|
||||
"glm-4v",
|
||||
"glm-4v-flash", // free
|
||||
"cogview-3-plus",
|
||||
"cogview-3",
|
||||
"cogview-3-flash", // free
|
||||
// 目前无法适配轮询任务
|
||||
// "cogvideox",
|
||||
// "cogvideox-flash", // free
|
||||
];
|
||||
|
||||
let seq = 1000; // 内置的模型序号生成器从1000开始
|
||||
export const DEFAULT_MODELS = [
|
||||
...openaiModels.map((name) => ({
|
||||
@@ -476,6 +697,39 @@ export const DEFAULT_MODELS = [
|
||||
sorted: 10,
|
||||
},
|
||||
})),
|
||||
...xAIModes.map((name) => ({
|
||||
name,
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "xai",
|
||||
providerName: "XAI",
|
||||
providerType: "xai",
|
||||
sorted: 11,
|
||||
},
|
||||
})),
|
||||
...chatglmModels.map((name) => ({
|
||||
name,
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "chatglm",
|
||||
providerName: "ChatGLM",
|
||||
providerType: "chatglm",
|
||||
sorted: 12,
|
||||
},
|
||||
})),
|
||||
...deepseekModels.map((name) => ({
|
||||
name,
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "deepseek",
|
||||
providerName: "DeepSeek",
|
||||
providerType: "deepseek",
|
||||
sorted: 13,
|
||||
},
|
||||
})),
|
||||
] as const;
|
||||
|
||||
export const CHAT_PAGE_SIZE = 15;
|
||||
@@ -495,11 +749,6 @@ export const internalAllowedWebDavEndpoints = [
|
||||
];
|
||||
|
||||
export const DEFAULT_GA_ID = "G-89WN60ZK2E";
|
||||
export const PLUGINS = [
|
||||
{ name: "Plugins", path: Path.Plugins },
|
||||
{ name: "Stable Diffusion", path: Path.Sd },
|
||||
{ name: "Search Chat", path: Path.SearchChat },
|
||||
];
|
||||
|
||||
export const SAAS_CHAT_URL = "https://nextchat.dev/chat";
|
||||
export const SAAS_CHAT_UTM_URL = "https://nextchat.dev/chat?utm=github";
|
||||
|
11
app/icons/headphone.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="16" height="16" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 28C4 26.8954 4.89543 26 6 26H10V38H6C4.89543 38 4 37.1046 4 36V28Z" fill="none" />
|
||||
<path d="M38 26H42C43.1046 26 44 26.8954 44 28V36C44 37.1046 43.1046 38 42 38H38V26Z"
|
||||
fill="none" />
|
||||
<path
|
||||
d="M10 36V24C10 16.268 16.268 10 24 10C31.732 10 38 16.268 38 24V36M10 26H6C4.89543 26 4 26.8954 4 28V36C4 37.1046 4.89543 38 6 38H10V26ZM38 26H42C43.1046 26 44 26.8954 44 28V36C44 37.1046 43.1046 38 42 38H38V26Z"
|
||||
stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 32H20L22 26L26 38L28 32H32" stroke="#333" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
After Width: | Height: | Size: 808 B |
15
app/icons/mcp.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 180 180" fill="none">
|
||||
<g clip-path="url(#clip0_19_13)">
|
||||
<path d="M18 84.8528L85.8822 16.9706C95.2548 7.59798 110.451 7.59798 119.823 16.9706V16.9706C129.196 26.3431 129.196 41.5391 119.823 50.9117L68.5581 102.177"
|
||||
stroke="black" stroke-width="12" stroke-linecap="round"/>
|
||||
<path d="M69.2652 101.47L119.823 50.9117C129.196 41.5391 144.392 41.5391 153.765 50.9117L154.118 51.2652C163.491 60.6378 163.491 75.8338 154.118 85.2063L92.7248 146.6C89.6006 149.724 89.6006 154.789 92.7248 157.913L105.331 170.52"
|
||||
stroke="black" stroke-width="12" stroke-linecap="round"/>
|
||||
<path d="M102.853 33.9411L52.6482 84.1457C43.2756 93.5183 43.2756 108.714 52.6482 118.087V118.087C62.0208 127.459 77.2167 127.459 86.5893 118.087L136.794 67.8822"
|
||||
stroke="black" stroke-width="12" stroke-linecap="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_19_13">
|
||||
<rect width="180" height="180" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1 +1,3 @@
|
||||
<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,4" transform="translate(6.333333333333333 6) rotate(0 0 2)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L0,4" transform="translate(9.666666666666666 6) rotate(0 0 2)"/></g></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 253 B |
3
app/icons/play.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||
</svg>
|
After Width: | Height: | Size: 239 B |
7
app/icons/power.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M14.5 8C13.8406 8.37652 13.2062 8.79103 12.6 9.24051C11.5625 10.0097 10.6074 10.8814 9.75 11.8402C6.79377 15.1463 5 19.4891 5 24.2455C5 34.6033 13.5066 43 24 43C34.4934 43 43 34.6033 43 24.2455C43 19.4891 41.2062 15.1463 38.25 11.8402C37.3926 10.8814 36.4375 10.0097 35.4 9.24051C34.7938 8.79103 34.1594 8.37652 33.5 8"
|
||||
stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M24 4V24" stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
After Width: | Height: | Size: 675 B |
1
app/icons/tool.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M10.155 3.247c-.519.396-1.129 1.004-2.012 1.887s-1.49 1.493-1.887 2.012c-.383.502-.497.83-.497 1.14s.114.638.497 1.14c.397.52 1.004 1.13 1.887 2.012l4.419 4.419c.883.883 1.493 1.49 2.012 1.887c.502.383.83.497 1.14.497s.638-.114 1.14-.497c.519-.396 1.129-1.004 2.012-1.887s1.49-1.493 1.887-2.012c.383-.503.497-.83.497-1.14s-.114-.638-.497-1.14c-.396-.52-1.004-1.13-1.887-2.012l-4.419-4.419c-.883-.883-1.493-1.49-2.012-1.887c-.502-.383-.83-.497-1.14-.497s-.637.114-1.14.497m-.91-1.192c.636-.485 1.28-.805 2.05-.805s1.414.32 2.05.805c.609.464 1.29 1.145 2.125 1.98l.244.245c.239-.238.451-.44.685-.574a2.31 2.31 0 0 1 2.312 0c.267.154.505.393.787.675l.06.06l.061.061c.282.282.521.52.675.787a2.31 2.31 0 0 1 0 2.312c-.135.234-.336.446-.574.685l.245.244c.835.836 1.516 1.516 1.98 2.125c.485.636.805 1.28.805 2.05s-.32 1.414-.805 2.05c-.464.608-1.145 1.289-1.98 2.124l-.077.077c-.835.835-1.516 1.516-2.125 1.98c-.635.485-1.28.805-2.05.805c-.768 0-1.413-.32-2.049-.805c-.609-.464-1.29-1.145-2.125-1.98l-.244-.245l-4.993 4.994l-.06.06c-.282.282-.52.521-.787.675a2.31 2.31 0 0 1-2.312 0c-.267-.154-.505-.393-.787-.675l-.06-.06l-.061-.061c-.282-.282-.521-.52-.675-.787a2.31 2.31 0 0 1 0-2.312c.154-.266.393-.505.675-.786l.06-.061l4.994-4.993l-.245-.244c-.835-.836-1.516-1.516-1.98-2.125c-.485-.636-.805-1.28-.805-2.05s.32-1.414.805-2.05c.464-.608 1.145-1.289 1.98-2.124l.077-.077c.835-.835 1.516-1.516 2.125-1.98m-.896 11.71L3.356 18.76c-.376.376-.456.465-.497.536a.81.81 0 0 0 0 .812c.04.072.12.16.497.537c.377.376.466.456.537.497a.81.81 0 0 0 .812 0c.07-.04.16-.12.536-.497l4.994-4.993zm10.31-6.54c.24-.243.302-.314.336-.374a.81.81 0 0 0 0-.812c-.041-.071-.12-.16-.497-.537c-.377-.376-.466-.456-.537-.497a.81.81 0 0 0-.812 0c-.06.034-.131.096-.374.336z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 1.9 KiB |
13
app/icons/voice-off.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M31 24V11C31 7.13401 27.866 4 24 4C20.134 4 17 7.13401 17 11V24C17 27.866 20.134 31 24 31C27.866 31 31 27.866 31 24Z"
|
||||
stroke="#d0021b" stroke-width="4" stroke-linejoin="round" />
|
||||
<path
|
||||
d="M9 23C9 31.2843 15.7157 38 24 38C25.7532 38 27.4361 37.6992 29 37.1465M39 23C39 25.1333 38.5547 27.1626 37.7519 29"
|
||||
stroke="#d0021b" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M24 38V44" stroke="#d0021b" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M42 42L6 6" stroke="#d0021b" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
After Width: | Height: | Size: 811 B |
9
app/icons/voice.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="17" y="4" width="14" height="27" rx="7" fill="none" stroke="#333" stroke-width="4"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M9 23C9 31.2843 15.7157 38 24 38C32.2843 38 39 31.2843 39 23" stroke="#333"
|
||||
stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M24 38V44" stroke="#333" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
After Width: | Height: | Size: 549 B |
@@ -5,9 +5,8 @@ import "./styles/highlight.scss";
|
||||
import { getClientConfig } from "./config/client";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { getServerSideConfig } from "./config/server";
|
||||
import { GoogleTagManager, GoogleAnalytics } from "@next/third-parties/google";
|
||||
const serverConfig = getServerSideConfig();
|
||||
import { getServerSideConfig } from "./config/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "NextChat",
|
||||
@@ -33,6 +32,8 @@ export default function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
200
app/lib/audio.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
export class AudioHandler {
|
||||
private context: AudioContext;
|
||||
private mergeNode: ChannelMergerNode;
|
||||
private analyserData: Uint8Array;
|
||||
public analyser: AnalyserNode;
|
||||
private workletNode: AudioWorkletNode | null = null;
|
||||
private stream: MediaStream | null = null;
|
||||
private source: MediaStreamAudioSourceNode | null = null;
|
||||
private recordBuffer: Int16Array[] = [];
|
||||
private readonly sampleRate = 24000;
|
||||
|
||||
private nextPlayTime: number = 0;
|
||||
private isPlaying: boolean = false;
|
||||
private playbackQueue: AudioBufferSourceNode[] = [];
|
||||
private playBuffer: Int16Array[] = [];
|
||||
|
||||
constructor() {
|
||||
this.context = new AudioContext({ sampleRate: this.sampleRate });
|
||||
// using ChannelMergerNode to get merged audio data, and then get analyser data.
|
||||
this.mergeNode = new ChannelMergerNode(this.context, { numberOfInputs: 2 });
|
||||
this.analyser = new AnalyserNode(this.context, { fftSize: 256 });
|
||||
this.analyserData = new Uint8Array(this.analyser.frequencyBinCount);
|
||||
this.mergeNode.connect(this.analyser);
|
||||
}
|
||||
|
||||
getByteFrequencyData() {
|
||||
this.analyser.getByteFrequencyData(this.analyserData);
|
||||
return this.analyserData;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await this.context.audioWorklet.addModule("/audio-processor.js");
|
||||
}
|
||||
|
||||
async startRecording(onChunk: (chunk: Uint8Array) => void) {
|
||||
try {
|
||||
if (!this.workletNode) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
sampleRate: this.sampleRate,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.context.resume();
|
||||
this.source = this.context.createMediaStreamSource(this.stream);
|
||||
this.workletNode = new AudioWorkletNode(
|
||||
this.context,
|
||||
"audio-recorder-processor",
|
||||
);
|
||||
|
||||
this.workletNode.port.onmessage = (event) => {
|
||||
if (event.data.eventType === "audio") {
|
||||
const float32Data = event.data.audioData;
|
||||
const int16Data = new Int16Array(float32Data.length);
|
||||
|
||||
for (let i = 0; i < float32Data.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, float32Data[i]));
|
||||
int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
||||
}
|
||||
|
||||
const uint8Data = new Uint8Array(int16Data.buffer);
|
||||
onChunk(uint8Data);
|
||||
// save recordBuffer
|
||||
// @ts-ignore
|
||||
this.recordBuffer.push.apply(this.recordBuffer, int16Data);
|
||||
}
|
||||
};
|
||||
|
||||
this.source.connect(this.workletNode);
|
||||
this.source.connect(this.mergeNode, 0, 0);
|
||||
this.workletNode.connect(this.context.destination);
|
||||
|
||||
this.workletNode.port.postMessage({ command: "START_RECORDING" });
|
||||
} catch (error) {
|
||||
console.error("Error starting recording:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
stopRecording() {
|
||||
if (!this.workletNode || !this.source || !this.stream) {
|
||||
throw new Error("Recording not started");
|
||||
}
|
||||
|
||||
this.workletNode.port.postMessage({ command: "STOP_RECORDING" });
|
||||
|
||||
this.workletNode.disconnect();
|
||||
this.source.disconnect();
|
||||
this.stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
startStreamingPlayback() {
|
||||
this.isPlaying = true;
|
||||
this.nextPlayTime = this.context.currentTime;
|
||||
}
|
||||
|
||||
stopStreamingPlayback() {
|
||||
this.isPlaying = false;
|
||||
this.playbackQueue.forEach((source) => source.stop());
|
||||
this.playbackQueue = [];
|
||||
this.playBuffer = [];
|
||||
}
|
||||
|
||||
playChunk(chunk: Uint8Array) {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
const int16Data = new Int16Array(chunk.buffer);
|
||||
// @ts-ignore
|
||||
this.playBuffer.push.apply(this.playBuffer, int16Data); // save playBuffer
|
||||
|
||||
const float32Data = new Float32Array(int16Data.length);
|
||||
for (let i = 0; i < int16Data.length; i++) {
|
||||
float32Data[i] = int16Data[i] / (int16Data[i] < 0 ? 0x8000 : 0x7fff);
|
||||
}
|
||||
|
||||
const audioBuffer = this.context.createBuffer(
|
||||
1,
|
||||
float32Data.length,
|
||||
this.sampleRate,
|
||||
);
|
||||
audioBuffer.getChannelData(0).set(float32Data);
|
||||
|
||||
const source = this.context.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(this.context.destination);
|
||||
source.connect(this.mergeNode, 0, 1);
|
||||
|
||||
const chunkDuration = audioBuffer.length / this.sampleRate;
|
||||
|
||||
source.start(this.nextPlayTime);
|
||||
|
||||
this.playbackQueue.push(source);
|
||||
source.onended = () => {
|
||||
const index = this.playbackQueue.indexOf(source);
|
||||
if (index > -1) {
|
||||
this.playbackQueue.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
this.nextPlayTime += chunkDuration;
|
||||
|
||||
if (this.nextPlayTime < this.context.currentTime) {
|
||||
this.nextPlayTime = this.context.currentTime;
|
||||
}
|
||||
}
|
||||
_saveData(data: Int16Array, bytesPerSample = 16): Blob {
|
||||
const headerLength = 44;
|
||||
const numberOfChannels = 1;
|
||||
const byteLength = data.buffer.byteLength;
|
||||
const header = new Uint8Array(headerLength);
|
||||
const view = new DataView(header.buffer);
|
||||
view.setUint32(0, 1380533830, false); // RIFF identifier 'RIFF'
|
||||
view.setUint32(4, 36 + byteLength, true); // file length minus RIFF identifier length and file description length
|
||||
view.setUint32(8, 1463899717, false); // RIFF type 'WAVE'
|
||||
view.setUint32(12, 1718449184, false); // format chunk identifier 'fmt '
|
||||
view.setUint32(16, 16, true); // format chunk length
|
||||
view.setUint16(20, 1, true); // sample format (raw)
|
||||
view.setUint16(22, numberOfChannels, true); // channel count
|
||||
view.setUint32(24, this.sampleRate, true); // sample rate
|
||||
view.setUint32(28, this.sampleRate * 4, true); // byte rate (sample rate * block align)
|
||||
view.setUint16(32, numberOfChannels * 2, true); // block align (channel count * bytes per sample)
|
||||
view.setUint16(34, bytesPerSample, true); // bits per sample
|
||||
view.setUint32(36, 1684108385, false); // data chunk identifier 'data'
|
||||
view.setUint32(40, byteLength, true); // data chunk length
|
||||
|
||||
// using data.buffer, so no need to setUint16 to view.
|
||||
return new Blob([view, data.buffer], { type: "audio/mpeg" });
|
||||
}
|
||||
savePlayFile() {
|
||||
// @ts-ignore
|
||||
return this._saveData(new Int16Array(this.playBuffer));
|
||||
}
|
||||
saveRecordFile(
|
||||
audioStartMillis: number | undefined,
|
||||
audioEndMillis: number | undefined,
|
||||
) {
|
||||
const startIndex = audioStartMillis
|
||||
? Math.floor((audioStartMillis * this.sampleRate) / 1000)
|
||||
: 0;
|
||||
const endIndex = audioEndMillis
|
||||
? Math.floor((audioEndMillis * this.sampleRate) / 1000)
|
||||
: this.recordBuffer.length;
|
||||
return this._saveData(
|
||||
// @ts-ignore
|
||||
new Int16Array(this.recordBuffer.slice(startIndex, endIndex)),
|
||||
);
|
||||
}
|
||||
async close() {
|
||||
this.recordBuffer = [];
|
||||
this.workletNode?.disconnect();
|
||||
this.source?.disconnect();
|
||||
this.stream?.getTracks().forEach((track) => track.stop());
|
||||
await this.context.close();
|
||||
}
|
||||
}
|
@@ -106,6 +106,7 @@ const cn = {
|
||||
copyLastMessage: "复制最后一个回复",
|
||||
copyLastCode: "复制最后一个代码块",
|
||||
showShortcutKey: "显示快捷方式",
|
||||
clearContext: "清除上下文",
|
||||
},
|
||||
},
|
||||
Export: {
|
||||
@@ -176,7 +177,7 @@ const cn = {
|
||||
},
|
||||
},
|
||||
Lang: {
|
||||
Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
|
||||
Name: "Language", // 注意:如果要添加新的翻译,请不要翻译此值,将它保留为 `Language`
|
||||
All: "所有语言",
|
||||
},
|
||||
Avatar: "头像",
|
||||
@@ -462,6 +463,39 @@ const cn = {
|
||||
SubTitle: "样例:",
|
||||
},
|
||||
},
|
||||
DeepSeek: {
|
||||
ApiKey: {
|
||||
Title: "接口密钥",
|
||||
SubTitle: "使用自定义DeepSeek API Key",
|
||||
Placeholder: "DeepSeek API Key",
|
||||
},
|
||||
Endpoint: {
|
||||
Title: "接口地址",
|
||||
SubTitle: "样例:",
|
||||
},
|
||||
},
|
||||
XAI: {
|
||||
ApiKey: {
|
||||
Title: "接口密钥",
|
||||
SubTitle: "使用自定义XAI API Key",
|
||||
Placeholder: "XAI API Key",
|
||||
},
|
||||
Endpoint: {
|
||||
Title: "接口地址",
|
||||
SubTitle: "样例:",
|
||||
},
|
||||
},
|
||||
ChatGLM: {
|
||||
ApiKey: {
|
||||
Title: "接口密钥",
|
||||
SubTitle: "使用自定义 ChatGLM API Key",
|
||||
Placeholder: "ChatGLM API Key",
|
||||
},
|
||||
Endpoint: {
|
||||
Title: "接口地址",
|
||||
SubTitle: "样例:",
|
||||
},
|
||||
},
|
||||
Stability: {
|
||||
ApiKey: {
|
||||
Title: "接口密钥",
|
||||
@@ -540,6 +574,39 @@ const cn = {
|
||||
SubTitle: "生成语音的速度",
|
||||
},
|
||||
},
|
||||
Realtime: {
|
||||
Enable: {
|
||||
Title: "实时聊天",
|
||||
SubTitle: "开启实时聊天功能",
|
||||
},
|
||||
Provider: {
|
||||
Title: "模型服务商",
|
||||
SubTitle: "切换不同的服务商",
|
||||
},
|
||||
Model: {
|
||||
Title: "模型",
|
||||
SubTitle: "选择一个模型",
|
||||
},
|
||||
ApiKey: {
|
||||
Title: "API Key",
|
||||
SubTitle: "API Key",
|
||||
Placeholder: "API Key",
|
||||
},
|
||||
Azure: {
|
||||
Endpoint: {
|
||||
Title: "接口地址",
|
||||
SubTitle: "接口地址",
|
||||
},
|
||||
Deployment: {
|
||||
Title: "部署名称",
|
||||
SubTitle: "部署名称",
|
||||
},
|
||||
},
|
||||
Temperature: {
|
||||
Title: "随机性 (temperature)",
|
||||
SubTitle: "值越大,回复越随机",
|
||||
},
|
||||
},
|
||||
},
|
||||
Store: {
|
||||
DefaultTopic: "新的聊天",
|
||||
@@ -571,11 +638,14 @@ const cn = {
|
||||
Discovery: {
|
||||
Name: "发现",
|
||||
},
|
||||
Mcp: {
|
||||
Name: "MCP",
|
||||
},
|
||||
FineTuned: {
|
||||
Sysmessage: "你是一个助手",
|
||||
},
|
||||
SearchChat: {
|
||||
Name: "搜索",
|
||||
Name: "搜索聊天记录",
|
||||
Page: {
|
||||
Title: "搜索聊天记录",
|
||||
Search: "输入搜索关键词",
|
||||
|
@@ -107,6 +107,7 @@ const en: LocaleType = {
|
||||
copyLastMessage: "Copy Last Reply",
|
||||
copyLastCode: "Copy Last Code Block",
|
||||
showShortcutKey: "Show Shortcuts",
|
||||
clearContext: "Clear Context",
|
||||
},
|
||||
},
|
||||
Export: {
|
||||
@@ -446,6 +447,39 @@ const en: LocaleType = {
|
||||
SubTitle: "Example: ",
|
||||
},
|
||||
},
|
||||
DeepSeek: {
|
||||
ApiKey: {
|
||||
Title: "DeepSeek API Key",
|
||||
SubTitle: "Use a custom DeepSeek API Key",
|
||||
Placeholder: "DeepSeek API Key",
|
||||
},
|
||||
Endpoint: {
|
||||
Title: "Endpoint Address",
|
||||
SubTitle: "Example: ",
|
||||
},
|
||||
},
|
||||
XAI: {
|
||||
ApiKey: {
|
||||
Title: "XAI API Key",
|
||||
SubTitle: "Use a custom XAI API Key",
|
||||
Placeholder: "XAI API Key",
|
||||
},
|
||||
Endpoint: {
|
||||
Title: "Endpoint Address",
|
||||
SubTitle: "Example: ",
|
||||
},
|
||||
},
|
||||
ChatGLM: {
|
||||
ApiKey: {
|
||||
Title: "ChatGLM API Key",
|
||||
SubTitle: "Use a custom ChatGLM API Key",
|
||||
Placeholder: "ChatGLM API Key",
|
||||
},
|
||||
Endpoint: {
|
||||
Title: "Endpoint Address",
|
||||
SubTitle: "Example: ",
|
||||
},
|
||||
},
|
||||
Stability: {
|
||||
ApiKey: {
|
||||
Title: "Stability API Key",
|
||||
@@ -548,6 +582,39 @@ const en: LocaleType = {
|
||||
},
|
||||
Engine: "TTS Engine",
|
||||
},
|
||||
Realtime: {
|
||||
Enable: {
|
||||
Title: "Realtime Chat",
|
||||
SubTitle: "Enable realtime chat feature",
|
||||
},
|
||||
Provider: {
|
||||
Title: "Model Provider",
|
||||
SubTitle: "Switch between different providers",
|
||||
},
|
||||
Model: {
|
||||
Title: "Model",
|
||||
SubTitle: "Select a model",
|
||||
},
|
||||
ApiKey: {
|
||||
Title: "API Key",
|
||||
SubTitle: "API Key",
|
||||
Placeholder: "API Key",
|
||||
},
|
||||
Azure: {
|
||||
Endpoint: {
|
||||
Title: "Endpoint",
|
||||
SubTitle: "Endpoint",
|
||||
},
|
||||
Deployment: {
|
||||
Title: "Deployment Name",
|
||||
SubTitle: "Deployment Name",
|
||||
},
|
||||
},
|
||||
Temperature: {
|
||||
Title: "Randomness (temperature)",
|
||||
SubTitle: "Higher values result in more random responses",
|
||||
},
|
||||
},
|
||||
},
|
||||
Store: {
|
||||
DefaultTopic: "New Conversation",
|
||||
@@ -580,6 +647,9 @@ const en: LocaleType = {
|
||||
Discovery: {
|
||||
Name: "Discovery",
|
||||
},
|
||||
Mcp: {
|
||||
Name: "MCP",
|
||||
},
|
||||
FineTuned: {
|
||||
Sysmessage: "You are an assistant that",
|
||||
},
|
||||
|
@@ -100,6 +100,7 @@ const tw = {
|
||||
copyLastMessage: "複製最後一個回覆",
|
||||
copyLastCode: "複製最後一個程式碼區塊",
|
||||
showShortcutKey: "顯示快捷方式",
|
||||
clearContext: "清除上下文",
|
||||
},
|
||||
},
|
||||
Export: {
|
||||
@@ -485,7 +486,7 @@ const tw = {
|
||||
},
|
||||
},
|
||||
SearchChat: {
|
||||
Name: "搜尋",
|
||||
Name: "搜尋聊天記錄",
|
||||
Page: {
|
||||
Title: "搜尋聊天記錄",
|
||||
Search: "輸入搜尋關鍵詞",
|
||||
|
@@ -3,7 +3,7 @@ import { BuiltinMask } from "./typing";
|
||||
export const CN_MASKS: BuiltinMask[] = [
|
||||
{
|
||||
avatar: "1f5bc-fe0f",
|
||||
name: "以文搜图",
|
||||
name: "AI文生图",
|
||||
context: [
|
||||
{
|
||||
id: "text-to-pic-0",
|
||||
@@ -28,7 +28,7 @@ export const CN_MASKS: BuiltinMask[] = [
|
||||
id: "text-to-pic-3",
|
||||
role: "system",
|
||||
content:
|
||||
"助手善于判断用户意图,当确定需要提供图片时,助手会变得沉默寡言,只使用以下格式输出markdown图片:,因为这个语法可以自动按照提示生成并渲染图片。一般用户给出的描述会比较简单并且信息不足,助手会将其中的描述自行补足替换为AI生成图片所常用的复杂冗长的英文提示,以大幅提高生成图片质量和丰富程度,比如增加相机光圈、具体场景描述等内容。助手会避免用代码块或原始块包围markdown标记,因为那样只会渲染出代码块或原始块而不是图片。",
|
||||
"助手善于判断用户意图,当确定需要提供图片时,助手会变得沉默寡言,只使用以下格式输出markdown图片:,因为这个语法可以自动按照提示生成并渲染图片。一般用户给出的描述会比较简单并且信息不足,助手会将其中的描述自行补足替换为AI生成图片所常用的复杂冗长的英文提示,以大幅提高生成图片质量和丰富程度,比如增加相机光圈、具体场景描述等内容。助手会避免用代码块或原始块包围markdown标记,因为那样只会渲染出代码块或原始块而不是图片。url中的空格等符号需要转义。",
|
||||
date: "",
|
||||
},
|
||||
],
|
||||
|
383
app/mcp/actions.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
"use server";
|
||||
import {
|
||||
createClient,
|
||||
executeRequest,
|
||||
listTools,
|
||||
removeClient,
|
||||
} from "./client";
|
||||
import { MCPClientLogger } from "./logger";
|
||||
import {
|
||||
DEFAULT_MCP_CONFIG,
|
||||
McpClientData,
|
||||
McpConfigData,
|
||||
McpRequestMessage,
|
||||
ServerConfig,
|
||||
ServerStatusResponse,
|
||||
} from "./types";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { getServerSideConfig } from "../config/server";
|
||||
|
||||
const logger = new MCPClientLogger("MCP Actions");
|
||||
const CONFIG_PATH = path.join(process.cwd(), "app/mcp/mcp_config.json");
|
||||
|
||||
const clientsMap = new Map<string, McpClientData>();
|
||||
|
||||
// 获取客户端状态
|
||||
export async function getClientsStatus(): Promise<
|
||||
Record<string, ServerStatusResponse>
|
||||
> {
|
||||
const config = await getMcpConfigFromFile();
|
||||
const result: Record<string, ServerStatusResponse> = {};
|
||||
|
||||
for (const clientId of Object.keys(config.mcpServers)) {
|
||||
const status = clientsMap.get(clientId);
|
||||
const serverConfig = config.mcpServers[clientId];
|
||||
|
||||
if (!serverConfig) {
|
||||
result[clientId] = { status: "undefined", errorMsg: null };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (serverConfig.status === "paused") {
|
||||
result[clientId] = { status: "paused", errorMsg: null };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
result[clientId] = { status: "undefined", errorMsg: null };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
status.client === null &&
|
||||
status.tools === null &&
|
||||
status.errorMsg === null
|
||||
) {
|
||||
result[clientId] = { status: "initializing", errorMsg: null };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status.errorMsg) {
|
||||
result[clientId] = { status: "error", errorMsg: status.errorMsg };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status.client) {
|
||||
result[clientId] = { status: "active", errorMsg: null };
|
||||
continue;
|
||||
}
|
||||
|
||||
result[clientId] = { status: "error", errorMsg: "Client not found" };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 获取客户端工具
|
||||
export async function getClientTools(clientId: string) {
|
||||
return clientsMap.get(clientId)?.tools ?? null;
|
||||
}
|
||||
|
||||
// 获取可用客户端数量
|
||||
export async function getAvailableClientsCount() {
|
||||
let count = 0;
|
||||
clientsMap.forEach((map) => !map.errorMsg && count++);
|
||||
return count;
|
||||
}
|
||||
|
||||
// 获取所有客户端工具
|
||||
export async function getAllTools() {
|
||||
const result = [];
|
||||
for (const [clientId, status] of clientsMap.entries()) {
|
||||
result.push({
|
||||
clientId,
|
||||
tools: status.tools,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 初始化单个客户端
|
||||
async function initializeSingleClient(
|
||||
clientId: string,
|
||||
serverConfig: ServerConfig,
|
||||
) {
|
||||
// 如果服务器状态是暂停,则不初始化
|
||||
if (serverConfig.status === "paused") {
|
||||
logger.info(`Skipping initialization for paused client [${clientId}]`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Initializing client [${clientId}]...`);
|
||||
|
||||
// 先设置初始化状态
|
||||
clientsMap.set(clientId, {
|
||||
client: null,
|
||||
tools: null,
|
||||
errorMsg: null, // null 表示正在初始化
|
||||
});
|
||||
|
||||
// 异步初始化
|
||||
createClient(clientId, serverConfig)
|
||||
.then(async (client) => {
|
||||
const tools = await listTools(client);
|
||||
logger.info(
|
||||
`Supported tools for [${clientId}]: ${JSON.stringify(tools, null, 2)}`,
|
||||
);
|
||||
clientsMap.set(clientId, { client, tools, errorMsg: null });
|
||||
logger.success(`Client [${clientId}] initialized successfully`);
|
||||
})
|
||||
.catch((error) => {
|
||||
clientsMap.set(clientId, {
|
||||
client: null,
|
||||
tools: null,
|
||||
errorMsg: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
logger.error(`Failed to initialize client [${clientId}]: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化系统
|
||||
export async function initializeMcpSystem() {
|
||||
logger.info("MCP Actions starting...");
|
||||
try {
|
||||
// 检查是否已有活跃的客户端
|
||||
if (clientsMap.size > 0) {
|
||||
logger.info("MCP system already initialized, skipping...");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await getMcpConfigFromFile();
|
||||
// 初始化所有客户端
|
||||
for (const [clientId, serverConfig] of Object.entries(config.mcpServers)) {
|
||||
await initializeSingleClient(clientId, serverConfig);
|
||||
}
|
||||
return config;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to initialize MCP system: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加服务器
|
||||
export async function addMcpServer(clientId: string, config: ServerConfig) {
|
||||
try {
|
||||
const currentConfig = await getMcpConfigFromFile();
|
||||
const isNewServer = !(clientId in currentConfig.mcpServers);
|
||||
|
||||
// 如果是新服务器,设置默认状态为 active
|
||||
if (isNewServer && !config.status) {
|
||||
config.status = "active";
|
||||
}
|
||||
|
||||
const newConfig = {
|
||||
...currentConfig,
|
||||
mcpServers: {
|
||||
...currentConfig.mcpServers,
|
||||
[clientId]: config,
|
||||
},
|
||||
};
|
||||
await updateMcpConfig(newConfig);
|
||||
|
||||
// 只有新服务器或状态为 active 的服务器才初始化
|
||||
if (isNewServer || config.status === "active") {
|
||||
await initializeSingleClient(clientId, config);
|
||||
}
|
||||
|
||||
return newConfig;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to add server [${clientId}]: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 暂停服务器
|
||||
export async function pauseMcpServer(clientId: string) {
|
||||
try {
|
||||
const currentConfig = await getMcpConfigFromFile();
|
||||
const serverConfig = currentConfig.mcpServers[clientId];
|
||||
if (!serverConfig) {
|
||||
throw new Error(`Server ${clientId} not found`);
|
||||
}
|
||||
|
||||
// 先更新配置
|
||||
const newConfig: McpConfigData = {
|
||||
...currentConfig,
|
||||
mcpServers: {
|
||||
...currentConfig.mcpServers,
|
||||
[clientId]: {
|
||||
...serverConfig,
|
||||
status: "paused",
|
||||
},
|
||||
},
|
||||
};
|
||||
await updateMcpConfig(newConfig);
|
||||
|
||||
// 然后关闭客户端
|
||||
const client = clientsMap.get(clientId);
|
||||
if (client?.client) {
|
||||
await removeClient(client.client);
|
||||
}
|
||||
clientsMap.delete(clientId);
|
||||
|
||||
return newConfig;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to pause server [${clientId}]: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复服务器
|
||||
export async function resumeMcpServer(clientId: string): Promise<void> {
|
||||
try {
|
||||
const currentConfig = await getMcpConfigFromFile();
|
||||
const serverConfig = currentConfig.mcpServers[clientId];
|
||||
if (!serverConfig) {
|
||||
throw new Error(`Server ${clientId} not found`);
|
||||
}
|
||||
|
||||
// 先尝试初始化客户端
|
||||
logger.info(`Trying to initialize client [${clientId}]...`);
|
||||
try {
|
||||
const client = await createClient(clientId, serverConfig);
|
||||
const tools = await listTools(client);
|
||||
clientsMap.set(clientId, { client, tools, errorMsg: null });
|
||||
logger.success(`Client [${clientId}] initialized successfully`);
|
||||
|
||||
// 初始化成功后更新配置
|
||||
const newConfig: McpConfigData = {
|
||||
...currentConfig,
|
||||
mcpServers: {
|
||||
...currentConfig.mcpServers,
|
||||
[clientId]: {
|
||||
...serverConfig,
|
||||
status: "active" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
await updateMcpConfig(newConfig);
|
||||
} catch (error) {
|
||||
const currentConfig = await getMcpConfigFromFile();
|
||||
const serverConfig = currentConfig.mcpServers[clientId];
|
||||
|
||||
// 如果配置中存在该服务器,则更新其状态为 error
|
||||
if (serverConfig) {
|
||||
serverConfig.status = "error";
|
||||
await updateMcpConfig(currentConfig);
|
||||
}
|
||||
|
||||
// 初始化失败
|
||||
clientsMap.set(clientId, {
|
||||
client: null,
|
||||
tools: null,
|
||||
errorMsg: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
logger.error(`Failed to initialize client [${clientId}]: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to resume server [${clientId}]: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 移除服务器
|
||||
export async function removeMcpServer(clientId: string) {
|
||||
try {
|
||||
const currentConfig = await getMcpConfigFromFile();
|
||||
const { [clientId]: _, ...rest } = currentConfig.mcpServers;
|
||||
const newConfig = {
|
||||
...currentConfig,
|
||||
mcpServers: rest,
|
||||
};
|
||||
await updateMcpConfig(newConfig);
|
||||
|
||||
// 关闭并移除客户端
|
||||
const client = clientsMap.get(clientId);
|
||||
if (client?.client) {
|
||||
await removeClient(client.client);
|
||||
}
|
||||
clientsMap.delete(clientId);
|
||||
|
||||
return newConfig;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove server [${clientId}]: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 重启所有客户端
|
||||
export async function restartAllClients() {
|
||||
logger.info("Restarting all clients...");
|
||||
try {
|
||||
// 关闭所有客户端
|
||||
for (const client of clientsMap.values()) {
|
||||
if (client.client) {
|
||||
await removeClient(client.client);
|
||||
}
|
||||
}
|
||||
|
||||
// 清空状态
|
||||
clientsMap.clear();
|
||||
|
||||
// 重新初始化
|
||||
const config = await getMcpConfigFromFile();
|
||||
for (const [clientId, serverConfig] of Object.entries(config.mcpServers)) {
|
||||
await initializeSingleClient(clientId, serverConfig);
|
||||
}
|
||||
return config;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restart clients: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 执行 MCP 请求
|
||||
export async function executeMcpAction(
|
||||
clientId: string,
|
||||
request: McpRequestMessage,
|
||||
) {
|
||||
try {
|
||||
const client = clientsMap.get(clientId);
|
||||
if (!client?.client) {
|
||||
throw new Error(`Client ${clientId} not found`);
|
||||
}
|
||||
logger.info(`Executing request for [${clientId}]`);
|
||||
return await executeRequest(client.client, request);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to execute request for [${clientId}]: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 MCP 配置文件
|
||||
export async function getMcpConfigFromFile(): Promise<McpConfigData> {
|
||||
try {
|
||||
const configStr = await fs.readFile(CONFIG_PATH, "utf-8");
|
||||
return JSON.parse(configStr);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load MCP config, using default config: ${error}`);
|
||||
return DEFAULT_MCP_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 MCP 配置文件
|
||||
async function updateMcpConfig(config: McpConfigData): Promise<void> {
|
||||
try {
|
||||
await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 MCP 是否启用
|
||||
export async function isMcpEnabled() {
|
||||
try {
|
||||
const serverConfig = getServerSideConfig();
|
||||
return serverConfig.enableMcp;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to check MCP status: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
55
app/mcp/client.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { MCPClientLogger } from "./logger";
|
||||
import { ListToolsResponse, McpRequestMessage, ServerConfig } from "./types";
|
||||
import { z } from "zod";
|
||||
|
||||
const logger = new MCPClientLogger();
|
||||
|
||||
export async function createClient(
|
||||
id: string,
|
||||
config: ServerConfig,
|
||||
): Promise<Client> {
|
||||
logger.info(`Creating client for ${id}...`);
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env)
|
||||
.filter(([_, v]) => v !== undefined)
|
||||
.map(([k, v]) => [k, v as string]),
|
||||
),
|
||||
...(config.env || {}),
|
||||
},
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: `nextchat-mcp-client-${id}`,
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
capabilities: {},
|
||||
},
|
||||
);
|
||||
await client.connect(transport);
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function removeClient(client: Client) {
|
||||
logger.info(`Removing client...`);
|
||||
await client.close();
|
||||
}
|
||||
|
||||
export async function listTools(client: Client): Promise<ListToolsResponse> {
|
||||
return client.listTools();
|
||||
}
|
||||
|
||||
export async function executeRequest(
|
||||
client: Client,
|
||||
request: McpRequestMessage,
|
||||
) {
|
||||
return client.request(request, z.any());
|
||||
}
|
65
app/mcp/logger.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// ANSI color codes for terminal output
|
||||
const colors = {
|
||||
reset: "\x1b[0m",
|
||||
bright: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
red: "\x1b[31m",
|
||||
blue: "\x1b[34m",
|
||||
};
|
||||
|
||||
export class MCPClientLogger {
|
||||
private readonly prefix: string;
|
||||
private readonly debugMode: boolean;
|
||||
|
||||
constructor(
|
||||
prefix: string = "NextChat MCP Client",
|
||||
debugMode: boolean = false,
|
||||
) {
|
||||
this.prefix = prefix;
|
||||
this.debugMode = debugMode;
|
||||
}
|
||||
|
||||
info(message: any) {
|
||||
this.print(colors.blue, message);
|
||||
}
|
||||
|
||||
success(message: any) {
|
||||
this.print(colors.green, message);
|
||||
}
|
||||
|
||||
error(message: any) {
|
||||
this.print(colors.red, message);
|
||||
}
|
||||
|
||||
warn(message: any) {
|
||||
this.print(colors.yellow, message);
|
||||
}
|
||||
|
||||
debug(message: any) {
|
||||
if (this.debugMode) {
|
||||
this.print(colors.dim, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format message to string, if message is object, convert to JSON string
|
||||
*/
|
||||
private formatMessage(message: any): string {
|
||||
return typeof message === "object"
|
||||
? JSON.stringify(message, null, 2)
|
||||
: message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print formatted message to console
|
||||
*/
|
||||
private print(color: string, message: any) {
|
||||
const formattedMessage = this.formatMessage(message);
|
||||
const logMessage = `${color}${colors.bright}[${this.prefix}]${colors.reset} ${formattedMessage}`;
|
||||
|
||||
// 只使用 console.log,这样日志会显示在 Tauri 的终端中
|
||||
console.log(logMessage);
|
||||
}
|
||||
}
|
180
app/mcp/types.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// ref: https://spec.modelcontextprotocol.io/specification/basic/messages/
|
||||
|
||||
import { z } from "zod";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
|
||||
export interface McpRequestMessage {
|
||||
jsonrpc?: "2.0";
|
||||
id?: string | number;
|
||||
method: "tools/call" | string;
|
||||
params?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export const McpRequestMessageSchema: z.ZodType<McpRequestMessage> = z.object({
|
||||
jsonrpc: z.literal("2.0").optional(),
|
||||
id: z.union([z.string(), z.number()]).optional(),
|
||||
method: z.string(),
|
||||
params: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export interface McpResponseMessage {
|
||||
jsonrpc?: "2.0";
|
||||
id?: string | number;
|
||||
result?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export const McpResponseMessageSchema: z.ZodType<McpResponseMessage> = z.object(
|
||||
{
|
||||
jsonrpc: z.literal("2.0").optional(),
|
||||
id: z.union([z.string(), z.number()]).optional(),
|
||||
result: z.record(z.unknown()).optional(),
|
||||
error: z
|
||||
.object({
|
||||
code: z.number(),
|
||||
message: z.string(),
|
||||
data: z.unknown().optional(),
|
||||
})
|
||||
.optional(),
|
||||
},
|
||||
);
|
||||
|
||||
export interface McpNotifications {
|
||||
jsonrpc?: "2.0";
|
||||
method: string;
|
||||
params?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export const McpNotificationsSchema: z.ZodType<McpNotifications> = z.object({
|
||||
jsonrpc: z.literal("2.0").optional(),
|
||||
method: z.string(),
|
||||
params: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
////////////
|
||||
// Next Chat
|
||||
////////////
|
||||
export interface ListToolsResponse {
|
||||
tools: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
inputSchema?: object;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export type McpClientData =
|
||||
| McpActiveClient
|
||||
| McpErrorClient
|
||||
| McpInitializingClient;
|
||||
|
||||
interface McpInitializingClient {
|
||||
client: null;
|
||||
tools: null;
|
||||
errorMsg: null;
|
||||
}
|
||||
|
||||
interface McpActiveClient {
|
||||
client: Client;
|
||||
tools: ListToolsResponse;
|
||||
errorMsg: null;
|
||||
}
|
||||
|
||||
interface McpErrorClient {
|
||||
client: null;
|
||||
tools: null;
|
||||
errorMsg: string;
|
||||
}
|
||||
|
||||
// 服务器状态类型
|
||||
export type ServerStatus =
|
||||
| "undefined"
|
||||
| "active"
|
||||
| "paused"
|
||||
| "error"
|
||||
| "initializing";
|
||||
|
||||
export interface ServerStatusResponse {
|
||||
status: ServerStatus;
|
||||
errorMsg: string | null;
|
||||
}
|
||||
|
||||
// MCP 服务器配置相关类型
|
||||
export interface ServerConfig {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
status?: "active" | "paused" | "error";
|
||||
}
|
||||
|
||||
export interface McpConfigData {
|
||||
// MCP Server 的配置
|
||||
mcpServers: Record<string, ServerConfig>;
|
||||
}
|
||||
|
||||
export const DEFAULT_MCP_CONFIG: McpConfigData = {
|
||||
mcpServers: {},
|
||||
};
|
||||
|
||||
export interface ArgsMapping {
|
||||
// 参数映射的类型
|
||||
type: "spread" | "single" | "env";
|
||||
|
||||
// 参数映射的位置
|
||||
position?: number;
|
||||
|
||||
// 参数映射的 key
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export interface PresetServer {
|
||||
// MCP Server 的唯一标识,作为最终配置文件 Json 的 key
|
||||
id: string;
|
||||
|
||||
// MCP Server 的显示名称
|
||||
name: string;
|
||||
|
||||
// MCP Server 的描述
|
||||
description: string;
|
||||
|
||||
// MCP Server 的仓库地址
|
||||
repo: string;
|
||||
|
||||
// MCP Server 的标签
|
||||
tags: string[];
|
||||
|
||||
// MCP Server 的命令
|
||||
command: string;
|
||||
|
||||
// MCP Server 的参数
|
||||
baseArgs: string[];
|
||||
|
||||
// MCP Server 是否需要配置
|
||||
configurable: boolean;
|
||||
|
||||
// MCP Server 的配置 schema
|
||||
configSchema?: {
|
||||
properties: Record<
|
||||
string,
|
||||
{
|
||||
type: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
minItems?: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
// MCP Server 的参数映射
|
||||
argsMapping?: Record<string, ArgsMapping>;
|
||||
}
|
11
app/mcp/utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function isMcpJson(content: string) {
|
||||
return content.match(/```json:mcp:([^{\s]+)([\s\S]*?)```/);
|
||||
}
|
||||
|
||||
export function extractMcpJson(content: string) {
|
||||
const match = content.match(/```json:mcp:([^{\s]+)([\s\S]*?)```/);
|
||||
if (match && match.length === 3) {
|
||||
return { clientId: match[1], mcp: JSON.parse(match[2]) };
|
||||
}
|
||||
return null;
|
||||
}
|
@@ -1,7 +1,5 @@
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
|
||||
import { Home } from "./components/home";
|
||||
|
||||
import { getServerSideConfig } from "./config/server";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
@@ -13,12 +13,16 @@ import {
|
||||
MOONSHOT_BASE_URL,
|
||||
STABILITY_BASE_URL,
|
||||
IFLYTEK_BASE_URL,
|
||||
DEEPSEEK_BASE_URL,
|
||||
XAI_BASE_URL,
|
||||
CHATGLM_BASE_URL,
|
||||
} from "../constant";
|
||||
import { getHeaders } from "../client/api";
|
||||
import { getClientConfig } from "../config/client";
|
||||
import { createPersistStore } from "../utils/store";
|
||||
import { ensure } from "../utils/clone";
|
||||
import { DEFAULT_CONFIG } from "./config";
|
||||
import { getModelProvider } from "../utils/model";
|
||||
|
||||
let fetchState = 0; // 0 not fetch, 1 fetching, 2 done
|
||||
|
||||
@@ -44,6 +48,12 @@ const DEFAULT_STABILITY_URL = isApp ? STABILITY_BASE_URL : ApiPath.Stability;
|
||||
|
||||
const DEFAULT_IFLYTEK_URL = isApp ? IFLYTEK_BASE_URL : ApiPath.Iflytek;
|
||||
|
||||
const DEFAULT_DEEPSEEK_URL = isApp ? DEEPSEEK_BASE_URL : ApiPath.DeepSeek;
|
||||
|
||||
const DEFAULT_XAI_URL = isApp ? XAI_BASE_URL : ApiPath.XAI;
|
||||
|
||||
const DEFAULT_CHATGLM_URL = isApp ? CHATGLM_BASE_URL : ApiPath.ChatGLM;
|
||||
|
||||
const DEFAULT_ACCESS_STATE = {
|
||||
accessCode: "",
|
||||
useCustomConfig: false,
|
||||
@@ -101,6 +111,18 @@ const DEFAULT_ACCESS_STATE = {
|
||||
iflytekApiKey: "",
|
||||
iflytekApiSecret: "",
|
||||
|
||||
// deepseek
|
||||
deepseekUrl: DEFAULT_DEEPSEEK_URL,
|
||||
deepseekApiKey: "",
|
||||
|
||||
// xai
|
||||
xaiUrl: DEFAULT_XAI_URL,
|
||||
xaiApiKey: "",
|
||||
|
||||
// chatglm
|
||||
chatglmUrl: DEFAULT_CHATGLM_URL,
|
||||
chatglmApiKey: "",
|
||||
|
||||
// server config
|
||||
needCode: true,
|
||||
hideUserApiKey: false,
|
||||
@@ -109,6 +131,7 @@ const DEFAULT_ACCESS_STATE = {
|
||||
disableFastLink: false,
|
||||
customModels: "",
|
||||
defaultModel: "",
|
||||
visionModels: "",
|
||||
|
||||
// tts config
|
||||
edgeTTSVoiceName: "zh-CN-YunxiNeural",
|
||||
@@ -123,7 +146,10 @@ export const useAccessStore = createPersistStore(
|
||||
|
||||
return get().needCode;
|
||||
},
|
||||
|
||||
getVisionModels() {
|
||||
this.fetch();
|
||||
return get().visionModels;
|
||||
},
|
||||
edgeVoiceName() {
|
||||
this.fetch();
|
||||
|
||||
@@ -168,6 +194,17 @@ export const useAccessStore = createPersistStore(
|
||||
isValidIflytek() {
|
||||
return ensure(get(), ["iflytekApiKey"]);
|
||||
},
|
||||
isValidDeepSeek() {
|
||||
return ensure(get(), ["deepseekApiKey"]);
|
||||
},
|
||||
|
||||
isValidXAI() {
|
||||
return ensure(get(), ["xaiApiKey"]);
|
||||
},
|
||||
|
||||
isValidChatGLM() {
|
||||
return ensure(get(), ["chatglmApiKey"]);
|
||||
},
|
||||
|
||||
isAuthorized() {
|
||||
this.fetch();
|
||||
@@ -184,6 +221,9 @@ export const useAccessStore = createPersistStore(
|
||||
this.isValidTencent() ||
|
||||
this.isValidMoonshot() ||
|
||||
this.isValidIflytek() ||
|
||||
this.isValidDeepSeek() ||
|
||||
this.isValidXAI() ||
|
||||
this.isValidChatGLM() ||
|
||||
!this.enabledAccessControl() ||
|
||||
(this.enabledAccessControl() && ensure(get(), ["accessCode"]))
|
||||
);
|
||||
@@ -202,9 +242,9 @@ export const useAccessStore = createPersistStore(
|
||||
.then((res) => {
|
||||
const defaultModel = res.defaultModel ?? "";
|
||||
if (defaultModel !== "") {
|
||||
const [model, providerName] = defaultModel.split("@");
|
||||
const [model, providerName] = getModelProvider(defaultModel);
|
||||
DEFAULT_CONFIG.modelConfig.model = model;
|
||||
DEFAULT_CONFIG.modelConfig.providerName = providerName;
|
||||
DEFAULT_CONFIG.modelConfig.providerName = providerName as any;
|
||||
}
|
||||
|
||||
return res;
|
||||
|
@@ -1,4 +1,9 @@
|
||||
import { getMessageTextContent, trimTopic } from "../utils";
|
||||
import {
|
||||
getMessageTextContent,
|
||||
isDalle3,
|
||||
safeLocalStorage,
|
||||
trimTopic,
|
||||
} from "../utils";
|
||||
|
||||
import { indexedDBStorage } from "@/app/utils/indexedDB-storage";
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -14,14 +19,15 @@ import {
|
||||
DEFAULT_INPUT_TEMPLATE,
|
||||
DEFAULT_MODELS,
|
||||
DEFAULT_SYSTEM_TEMPLATE,
|
||||
GEMINI_SUMMARIZE_MODEL,
|
||||
KnowledgeCutOffDate,
|
||||
MCP_SYSTEM_TEMPLATE,
|
||||
MCP_TOOLS_TEMPLATE,
|
||||
ServiceProvider,
|
||||
StoreKey,
|
||||
SUMMARIZE_MODEL,
|
||||
GEMINI_SUMMARIZE_MODEL,
|
||||
ServiceProvider,
|
||||
} from "../constant";
|
||||
import Locale, { getLang } from "../locales";
|
||||
import { isDalle3, safeLocalStorage } from "../utils";
|
||||
import { prettyObject } from "../utils/format";
|
||||
import { createPersistStore } from "../utils/store";
|
||||
import { estimateTokenLength } from "../utils/token";
|
||||
@@ -29,6 +35,8 @@ import { ModelConfig, ModelType, useAppConfig } from "./config";
|
||||
import { useAccessStore } from "./access";
|
||||
import { collectModelsWithDefaultModel } from "../utils/model";
|
||||
import { createEmptyMask, Mask } from "./mask";
|
||||
import { executeMcpAction, getAllTools } from "../mcp/actions";
|
||||
import { extractMcpJson, isMcpJson } from "../mcp/utils";
|
||||
|
||||
const localStorage = safeLocalStorage();
|
||||
|
||||
@@ -52,6 +60,8 @@ export type ChatMessage = RequestMessage & {
|
||||
id: string;
|
||||
model?: ModelType;
|
||||
tools?: ChatMessageTool[];
|
||||
audio_url?: string;
|
||||
isMcpResponse?: boolean;
|
||||
};
|
||||
|
||||
export function createMessage(override: Partial<ChatMessage>): ChatMessage {
|
||||
@@ -188,6 +198,27 @@ function fillTemplateWith(input: string, modelConfig: ModelConfig) {
|
||||
return output;
|
||||
}
|
||||
|
||||
async function getMcpSystemPrompt(): Promise<string> {
|
||||
const tools = await getAllTools();
|
||||
|
||||
let toolsStr = "";
|
||||
|
||||
tools.forEach((i) => {
|
||||
// error client has no tools
|
||||
if (!i.tools) return;
|
||||
|
||||
toolsStr += MCP_TOOLS_TEMPLATE.replace(
|
||||
"{{ clientId }}",
|
||||
i.clientId,
|
||||
).replace(
|
||||
"{{ tools }}",
|
||||
i.tools.tools.map((p: object) => JSON.stringify(p, null, 2)).join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
return MCP_SYSTEM_TEMPLATE.replace("{{ MCP_TOOLS }}", toolsStr);
|
||||
}
|
||||
|
||||
const DEFAULT_CHAT_STATE = {
|
||||
sessions: [createEmptySession()],
|
||||
currentSessionIndex: 0,
|
||||
@@ -213,7 +244,11 @@ export const useChatStore = createPersistStore(
|
||||
const newSession = createEmptySession();
|
||||
|
||||
newSession.topic = currentSession.topic;
|
||||
newSession.messages = [...currentSession.messages];
|
||||
// 深拷贝消息
|
||||
newSession.messages = currentSession.messages.map(msg => ({
|
||||
...msg,
|
||||
id: nanoid(), // 生成新的消息 ID
|
||||
}));
|
||||
newSession.mask = {
|
||||
...currentSession.mask,
|
||||
modelConfig: {
|
||||
@@ -352,29 +387,35 @@ export const useChatStore = createPersistStore(
|
||||
return session;
|
||||
},
|
||||
|
||||
onNewMessage(message: ChatMessage) {
|
||||
get().updateCurrentSession((session) => {
|
||||
onNewMessage(message: ChatMessage, targetSession: ChatSession) {
|
||||
get().updateTargetSession(targetSession, (session) => {
|
||||
session.messages = session.messages.concat();
|
||||
session.lastUpdate = Date.now();
|
||||
});
|
||||
get().updateStat(message);
|
||||
get().summarizeSession();
|
||||
|
||||
get().updateStat(message, targetSession);
|
||||
|
||||
get().checkMcpJson(message);
|
||||
|
||||
get().summarizeSession(false, targetSession);
|
||||
},
|
||||
|
||||
async onUserInput(content: string, attachImages?: string[]) {
|
||||
async onUserInput(
|
||||
content: string,
|
||||
attachImages?: string[],
|
||||
isMcpResponse?: boolean,
|
||||
) {
|
||||
const session = get().currentSession();
|
||||
const modelConfig = session.mask.modelConfig;
|
||||
|
||||
const userContent = fillTemplateWith(content, modelConfig);
|
||||
console.log("[User Input] after template: ", userContent);
|
||||
// MCP Response no need to fill template
|
||||
let mContent: string | MultimodalContent[] = isMcpResponse
|
||||
? content
|
||||
: fillTemplateWith(content, modelConfig);
|
||||
|
||||
let mContent: string | MultimodalContent[] = userContent;
|
||||
|
||||
if (attachImages && attachImages.length > 0) {
|
||||
if (!isMcpResponse && attachImages && attachImages.length > 0) {
|
||||
mContent = [
|
||||
...(userContent
|
||||
? [{ type: "text" as const, text: userContent }]
|
||||
: []),
|
||||
...(content ? [{ type: "text" as const, text: content }] : []),
|
||||
...attachImages.map((url) => ({
|
||||
type: "image_url" as const,
|
||||
image_url: { url },
|
||||
@@ -385,6 +426,7 @@ export const useChatStore = createPersistStore(
|
||||
let userMessage: ChatMessage = createMessage({
|
||||
role: "user",
|
||||
content: mContent,
|
||||
isMcpResponse,
|
||||
});
|
||||
|
||||
const botMessage: ChatMessage = createMessage({
|
||||
@@ -394,12 +436,12 @@ export const useChatStore = createPersistStore(
|
||||
});
|
||||
|
||||
// get recent messages
|
||||
const recentMessages = get().getMessagesWithMemory();
|
||||
const recentMessages = await get().getMessagesWithMemory();
|
||||
const sendMessages = recentMessages.concat(userMessage);
|
||||
const messageIndex = get().currentSession().messages.length + 1;
|
||||
const messageIndex = session.messages.length + 1;
|
||||
|
||||
// save user's and bot's message
|
||||
get().updateCurrentSession((session) => {
|
||||
get().updateTargetSession(session, (session) => {
|
||||
const savedUserMessage = {
|
||||
...userMessage,
|
||||
content: mContent,
|
||||
@@ -420,21 +462,22 @@ export const useChatStore = createPersistStore(
|
||||
if (message) {
|
||||
botMessage.content = message;
|
||||
}
|
||||
get().updateCurrentSession((session) => {
|
||||
get().updateTargetSession(session, (session) => {
|
||||
session.messages = session.messages.concat();
|
||||
});
|
||||
},
|
||||
onFinish(message) {
|
||||
async onFinish(message) {
|
||||
botMessage.streaming = false;
|
||||
if (message) {
|
||||
botMessage.content = message;
|
||||
get().onNewMessage(botMessage);
|
||||
botMessage.date = new Date().toLocaleString();
|
||||
get().onNewMessage(botMessage, session);
|
||||
}
|
||||
ChatControllerPool.remove(session.id, botMessage.id);
|
||||
},
|
||||
onBeforeTool(tool: ChatMessageTool) {
|
||||
(botMessage.tools = botMessage?.tools || []).push(tool);
|
||||
get().updateCurrentSession((session) => {
|
||||
get().updateTargetSession(session, (session) => {
|
||||
session.messages = session.messages.concat();
|
||||
});
|
||||
},
|
||||
@@ -444,7 +487,7 @@ export const useChatStore = createPersistStore(
|
||||
tools[i] = { ...tool };
|
||||
}
|
||||
});
|
||||
get().updateCurrentSession((session) => {
|
||||
get().updateTargetSession(session, (session) => {
|
||||
session.messages = session.messages.concat();
|
||||
});
|
||||
},
|
||||
@@ -459,7 +502,7 @@ export const useChatStore = createPersistStore(
|
||||
botMessage.streaming = false;
|
||||
userMessage.isError = !isAborted;
|
||||
botMessage.isError = !isAborted;
|
||||
get().updateCurrentSession((session) => {
|
||||
get().updateTargetSession(session, (session) => {
|
||||
session.messages = session.messages.concat();
|
||||
});
|
||||
ChatControllerPool.remove(
|
||||
@@ -492,7 +535,7 @@ export const useChatStore = createPersistStore(
|
||||
}
|
||||
},
|
||||
|
||||
getMessagesWithMemory() {
|
||||
async getMessagesWithMemory() {
|
||||
const session = get().currentSession();
|
||||
const modelConfig = session.mask.modelConfig;
|
||||
const clearContextIndex = session.clearContextIndex ?? 0;
|
||||
@@ -508,18 +551,26 @@ export const useChatStore = createPersistStore(
|
||||
(session.mask.modelConfig.model.startsWith("gpt-") ||
|
||||
session.mask.modelConfig.model.startsWith("chatgpt-"));
|
||||
|
||||
const mcpSystemPrompt = await getMcpSystemPrompt();
|
||||
|
||||
var systemPrompts: ChatMessage[] = [];
|
||||
systemPrompts = shouldInjectSystemPrompts
|
||||
? [
|
||||
createMessage({
|
||||
role: "system",
|
||||
content: fillTemplateWith("", {
|
||||
...modelConfig,
|
||||
template: DEFAULT_SYSTEM_TEMPLATE,
|
||||
}),
|
||||
content:
|
||||
fillTemplateWith("", {
|
||||
...modelConfig,
|
||||
template: DEFAULT_SYSTEM_TEMPLATE,
|
||||
}) + mcpSystemPrompt,
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
: [
|
||||
createMessage({
|
||||
role: "system",
|
||||
content: mcpSystemPrompt,
|
||||
}),
|
||||
];
|
||||
if (shouldInjectSystemPrompts) {
|
||||
console.log(
|
||||
"[Global System Prompt] ",
|
||||
@@ -591,16 +642,19 @@ export const useChatStore = createPersistStore(
|
||||
set(() => ({ sessions }));
|
||||
},
|
||||
|
||||
resetSession() {
|
||||
get().updateCurrentSession((session) => {
|
||||
resetSession(session: ChatSession) {
|
||||
get().updateTargetSession(session, (session) => {
|
||||
session.messages = [];
|
||||
session.memoryPrompt = "";
|
||||
});
|
||||
},
|
||||
|
||||
summarizeSession(refreshTitle: boolean = false) {
|
||||
summarizeSession(
|
||||
refreshTitle: boolean = false,
|
||||
targetSession: ChatSession,
|
||||
) {
|
||||
const config = useAppConfig.getState();
|
||||
const session = get().currentSession();
|
||||
const session = targetSession;
|
||||
const modelConfig = session.mask.modelConfig;
|
||||
// skip summarize when using dalle3?
|
||||
if (isDalle3(modelConfig.model)) {
|
||||
@@ -649,13 +703,15 @@ export const useChatStore = createPersistStore(
|
||||
stream: false,
|
||||
providerName,
|
||||
},
|
||||
onFinish(message) {
|
||||
if (!isValidMessage(message)) return;
|
||||
get().updateCurrentSession(
|
||||
(session) =>
|
||||
(session.topic =
|
||||
message.length > 0 ? trimTopic(message) : DEFAULT_TOPIC),
|
||||
);
|
||||
onFinish(message, responseRes) {
|
||||
if (responseRes?.status === 200) {
|
||||
get().updateTargetSession(
|
||||
session,
|
||||
(session) =>
|
||||
(session.topic =
|
||||
message.length > 0 ? trimTopic(message) : DEFAULT_TOPIC),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -669,7 +725,7 @@ export const useChatStore = createPersistStore(
|
||||
|
||||
const historyMsgLength = countMessages(toBeSummarizedMsgs);
|
||||
|
||||
if (historyMsgLength > modelConfig?.max_tokens ?? 4000) {
|
||||
if (historyMsgLength > (modelConfig?.max_tokens || 4000)) {
|
||||
const n = toBeSummarizedMsgs.length;
|
||||
toBeSummarizedMsgs = toBeSummarizedMsgs.slice(
|
||||
Math.max(0, n - modelConfig.historyMessageCount),
|
||||
@@ -715,38 +771,38 @@ export const useChatStore = createPersistStore(
|
||||
onUpdate(message) {
|
||||
session.memoryPrompt = message;
|
||||
},
|
||||
onFinish(message) {
|
||||
console.log("[Memory] ", message);
|
||||
get().updateCurrentSession((session) => {
|
||||
session.lastSummarizeIndex = lastSummarizeIndex;
|
||||
session.memoryPrompt = message; // Update the memory prompt for stored it in local storage
|
||||
});
|
||||
onFinish(message, responseRes) {
|
||||
if (responseRes?.status === 200) {
|
||||
console.log("[Memory] ", message);
|
||||
get().updateTargetSession(session, (session) => {
|
||||
session.lastSummarizeIndex = lastSummarizeIndex;
|
||||
session.memoryPrompt = message; // Update the memory prompt for stored it in local storage
|
||||
});
|
||||
}
|
||||
},
|
||||
onError(err) {
|
||||
console.error("[Summarize] ", err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isValidMessage(message: any): boolean {
|
||||
return typeof message === "string" && !message.startsWith("```json");
|
||||
}
|
||||
},
|
||||
|
||||
updateStat(message: ChatMessage) {
|
||||
get().updateCurrentSession((session) => {
|
||||
updateStat(message: ChatMessage, session: ChatSession) {
|
||||
get().updateTargetSession(session, (session) => {
|
||||
session.stat.charCount += message.content.length;
|
||||
// TODO: should update chat count and word count
|
||||
});
|
||||
},
|
||||
|
||||
updateCurrentSession(updater: (session: ChatSession) => void) {
|
||||
updateTargetSession(
|
||||
targetSession: ChatSession,
|
||||
updater: (session: ChatSession) => void,
|
||||
) {
|
||||
const sessions = get().sessions;
|
||||
const index = get().currentSessionIndex;
|
||||
const index = sessions.findIndex((s) => s.id === targetSession.id);
|
||||
if (index < 0) return;
|
||||
updater(sessions[index]);
|
||||
set(() => ({ sessions }));
|
||||
},
|
||||
|
||||
async clearAllData() {
|
||||
await indexedDBStorage.clear();
|
||||
localStorage.clear();
|
||||
@@ -757,6 +813,36 @@ export const useChatStore = createPersistStore(
|
||||
lastInput,
|
||||
});
|
||||
},
|
||||
|
||||
/** check if the message contains MCP JSON and execute the MCP action */
|
||||
checkMcpJson(message: ChatMessage) {
|
||||
const content = getMessageTextContent(message);
|
||||
if (isMcpJson(content)) {
|
||||
try {
|
||||
const mcpRequest = extractMcpJson(content);
|
||||
if (mcpRequest) {
|
||||
console.debug("[MCP Request]", mcpRequest);
|
||||
|
||||
executeMcpAction(mcpRequest.clientId, mcpRequest.mcp)
|
||||
.then((result) => {
|
||||
console.log("[MCP Response]", result);
|
||||
const mcpResponse =
|
||||
typeof result === "object"
|
||||
? JSON.stringify(result)
|
||||
: String(result);
|
||||
get().onUserInput(
|
||||
`\`\`\`json:mcp-response:${mcpRequest.clientId}\n${mcpResponse}\n\`\`\``,
|
||||
[],
|
||||
true,
|
||||
);
|
||||
})
|
||||
.catch((error) => showToast("MCP execution failed", error));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Check MCP JSON]", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return methods;
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { LLMModel } from "../client/api";
|
||||
import { DalleSize, DalleQuality, DalleStyle } from "../typing";
|
||||
import { DalleQuality, DalleStyle, ModelSize } from "../typing";
|
||||
import { getClientConfig } from "../config/client";
|
||||
import {
|
||||
DEFAULT_INPUT_TEMPLATE,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ServiceProvider,
|
||||
} from "../constant";
|
||||
import { createPersistStore } from "../utils/store";
|
||||
import type { Voice } from "rt-client";
|
||||
|
||||
export type ModelType = (typeof DEFAULT_MODELS)[number]["name"];
|
||||
export type TTSModelType = (typeof DEFAULT_TTS_MODELS)[number];
|
||||
@@ -77,7 +78,7 @@ export const DEFAULT_CONFIG = {
|
||||
compressProviderName: "",
|
||||
enableInjectSystemPrompts: true,
|
||||
template: config?.template ?? DEFAULT_INPUT_TEMPLATE,
|
||||
size: "1024x1024" as DalleSize,
|
||||
size: "1024x1024" as ModelSize,
|
||||
quality: "standard" as DalleQuality,
|
||||
style: "vivid" as DalleStyle,
|
||||
},
|
||||
@@ -90,12 +91,26 @@ export const DEFAULT_CONFIG = {
|
||||
voice: DEFAULT_TTS_VOICE,
|
||||
speed: 1.0,
|
||||
},
|
||||
|
||||
realtimeConfig: {
|
||||
enable: false,
|
||||
provider: "OpenAI" as ServiceProvider,
|
||||
model: "gpt-4o-realtime-preview-2024-10-01",
|
||||
apiKey: "",
|
||||
azure: {
|
||||
endpoint: "",
|
||||
deployment: "",
|
||||
},
|
||||
temperature: 0.9,
|
||||
voice: "alloy" as Voice,
|
||||
},
|
||||
};
|
||||
|
||||
export type ChatConfig = typeof DEFAULT_CONFIG;
|
||||
|
||||
export type ModelConfig = ChatConfig["modelConfig"];
|
||||
export type TTSConfig = ChatConfig["ttsConfig"];
|
||||
export type RealtimeConfig = ChatConfig["realtimeConfig"];
|
||||
|
||||
export function limitNumber(
|
||||
x: number,
|
||||
|
@@ -11,3 +11,14 @@ export interface RequestMessage {
|
||||
export type DalleSize = "1024x1024" | "1792x1024" | "1024x1792";
|
||||
export type DalleQuality = "standard" | "hd";
|
||||
export type DalleStyle = "vivid" | "natural";
|
||||
|
||||
export type ModelSize =
|
||||
| "1024x1024"
|
||||
| "1792x1024"
|
||||
| "1024x1792"
|
||||
| "768x1344"
|
||||
| "864x1152"
|
||||
| "1344x768"
|
||||
| "1152x864"
|
||||
| "1440x720"
|
||||
| "720x1440";
|
||||
|
51
app/utils.ts
@@ -5,6 +5,9 @@ import { RequestMessage } from "./client/api";
|
||||
import { ServiceProvider } from "./constant";
|
||||
// import { fetch as tauriFetch, ResponseType } from "@tauri-apps/api/http";
|
||||
import { fetch as tauriStreamFetch } from "./utils/stream";
|
||||
import { VISION_MODEL_REGEXES, EXCLUDE_VISION_MODEL_REGEXES } from "./constant";
|
||||
import { useAccessStore } from "./store";
|
||||
import { ModelSize } from "./typing";
|
||||
|
||||
export function trimTopic(topic: string) {
|
||||
// Fix an issue where double quotes still show in the Indonesian language
|
||||
@@ -252,21 +255,16 @@ export function getMessageImages(message: RequestMessage): string[] {
|
||||
}
|
||||
|
||||
export function isVisionModel(model: string) {
|
||||
// Note: This is a better way using the TypeScript feature instead of `&&` or `||` (ts v5.5.0-dev.20240314 I've been using)
|
||||
|
||||
const visionKeywords = [
|
||||
"vision",
|
||||
"claude-3",
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
];
|
||||
const isGpt4Turbo =
|
||||
model.includes("gpt-4-turbo") && !model.includes("preview");
|
||||
|
||||
const visionModels = useAccessStore.getState().visionModels;
|
||||
const envVisionModels = visionModels
|
||||
?.split(",")
|
||||
.map((m) => m.trim());
|
||||
if (envVisionModels?.includes(model)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
visionKeywords.some((keyword) => model.includes(keyword)) || isGpt4Turbo
|
||||
!EXCLUDE_VISION_MODEL_REGEXES.some((regex) => regex.test(model)) &&
|
||||
VISION_MODEL_REGEXES.some((regex) => regex.test(model))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -274,11 +272,34 @@ export function isDalle3(model: string) {
|
||||
return "dall-e-3" === model;
|
||||
}
|
||||
|
||||
export function getModelSizes(model: string): ModelSize[] {
|
||||
if (isDalle3(model)) {
|
||||
return ["1024x1024", "1792x1024", "1024x1792"];
|
||||
}
|
||||
if (model.toLowerCase().includes("cogview")) {
|
||||
return [
|
||||
"1024x1024",
|
||||
"768x1344",
|
||||
"864x1152",
|
||||
"1344x768",
|
||||
"1152x864",
|
||||
"1440x720",
|
||||
"720x1440",
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function supportsCustomSize(model: string): boolean {
|
||||
return getModelSizes(model).length > 0;
|
||||
}
|
||||
|
||||
export function showPlugins(provider: ServiceProvider, model: string) {
|
||||
if (
|
||||
provider == ServiceProvider.OpenAI ||
|
||||
provider == ServiceProvider.Azure ||
|
||||
provider == ServiceProvider.Moonshot
|
||||
provider == ServiceProvider.Moonshot ||
|
||||
provider == ServiceProvider.ChatGLM
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
@@ -138,7 +138,7 @@ export function uploadImage(file: Blob): Promise<string> {
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
console.log("res", res);
|
||||
// console.log("res", res);
|
||||
if (res?.code == 0 && res?.data) {
|
||||
return res?.data;
|
||||
}
|
||||
@@ -174,6 +174,7 @@ export function stream(
|
||||
let finished = false;
|
||||
let running = false;
|
||||
let runTools: any[] = [];
|
||||
let responseRes: Response;
|
||||
|
||||
// animate response to make it looks smooth
|
||||
function animateResponseText() {
|
||||
@@ -272,7 +273,7 @@ export function stream(
|
||||
}
|
||||
console.debug("[ChatAPI] end");
|
||||
finished = true;
|
||||
options.onFinish(responseText + remainText);
|
||||
options.onFinish(responseText + remainText, responseRes); // 将res传递给onFinish
|
||||
}
|
||||
};
|
||||
|
||||
@@ -304,6 +305,7 @@ export function stream(
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log("[Request] response content type: ", contentType);
|
||||
responseRes = res;
|
||||
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
|
@@ -37,6 +37,17 @@ const sortModelTable = (models: ReturnType<typeof collectModels>) =>
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* get model name and provider from a formatted string,
|
||||
* e.g. `gpt-4@OpenAi` or `claude-3-5-sonnet@20240620@Google`
|
||||
* @param modelWithProvider model name with provider separated by last `@` char,
|
||||
* @returns [model, provider] tuple, if no `@` char found, provider is undefined
|
||||
*/
|
||||
export function getModelProvider(modelWithProvider: string): [string, string?] {
|
||||
const [model, provider] = modelWithProvider.split(/@(?!.*@)/);
|
||||
return [model, provider];
|
||||
}
|
||||
|
||||
export function collectModelTable(
|
||||
models: readonly LLMModel[],
|
||||
customModels: string,
|
||||
@@ -79,10 +90,10 @@ export function collectModelTable(
|
||||
);
|
||||
} else {
|
||||
// 1. find model by name, and set available value
|
||||
const [customModelName, customProviderName] = name.split("@");
|
||||
const [customModelName, customProviderName] = getModelProvider(name);
|
||||
let count = 0;
|
||||
for (const fullName in modelTable) {
|
||||
const [modelName, providerName] = fullName.split("@");
|
||||
const [modelName, providerName] = getModelProvider(fullName);
|
||||
if (
|
||||
customModelName == modelName &&
|
||||
(customProviderName === undefined ||
|
||||
@@ -102,7 +113,7 @@ export function collectModelTable(
|
||||
}
|
||||
// 2. if model not exists, create new model with available value
|
||||
if (count === 0) {
|
||||
let [customModelName, customProviderName] = name.split("@");
|
||||
let [customModelName, customProviderName] = getModelProvider(name);
|
||||
const provider = customProvider(
|
||||
customProviderName || customModelName,
|
||||
);
|
||||
@@ -139,7 +150,7 @@ export function collectModelTableWithDefaultModel(
|
||||
for (const key of Object.keys(modelTable)) {
|
||||
if (
|
||||
modelTable[key].available &&
|
||||
key.split("@").shift() == defaultModel
|
||||
getModelProvider(key)[0] == defaultModel
|
||||
) {
|
||||
modelTable[key].isDefault = true;
|
||||
break;
|
||||
@@ -191,3 +202,52 @@ export function isModelAvailableInServer(
|
||||
const modelTable = collectModelTable(DEFAULT_MODELS, customModels);
|
||||
return modelTable[fullName]?.available === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the model name is a GPT-4 related model
|
||||
*
|
||||
* @param modelName The name of the model to check
|
||||
* @returns True if the model is a GPT-4 related model (excluding gpt-4o-mini)
|
||||
*/
|
||||
export function isGPT4Model(modelName: string): boolean {
|
||||
return (
|
||||
(modelName.startsWith("gpt-4") ||
|
||||
modelName.startsWith("chatgpt-4o") ||
|
||||
modelName.startsWith("o1")) &&
|
||||
!modelName.startsWith("gpt-4o-mini")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a model is not available on any of the specified providers in the server.
|
||||
*
|
||||
* @param {string} customModels - A string of custom models, comma-separated.
|
||||
* @param {string} modelName - The name of the model to check.
|
||||
* @param {string|string[]} providerNames - A string or array of provider names to check against.
|
||||
*
|
||||
* @returns {boolean} True if the model is not available on any of the specified providers, false otherwise.
|
||||
*/
|
||||
export function isModelNotavailableInServer(
|
||||
customModels: string,
|
||||
modelName: string,
|
||||
providerNames: string | string[],
|
||||
): boolean {
|
||||
// Check DISABLE_GPT4 environment variable
|
||||
if (
|
||||
process.env.DISABLE_GPT4 === "1" &&
|
||||
isGPT4Model(modelName.toLowerCase())
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const modelTable = collectModelTable(DEFAULT_MODELS, customModels);
|
||||
|
||||
const providerNamesArray = Array.isArray(providerNames)
|
||||
? providerNames
|
||||
: [providerNames];
|
||||
for (const providerName of providerNamesArray) {
|
||||
const fullName = `${modelName}@${providerName.toLowerCase()}`;
|
||||
if (modelTable?.[fullName]?.available === true) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
@@ -19,7 +19,7 @@ type StreamResponse = {
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
export function fetch(url: string, options?: RequestInit): Promise<any> {
|
||||
export function fetch(url: string, options?: RequestInit): Promise<Response> {
|
||||
if (window.__TAURI__) {
|
||||
const {
|
||||
signal,
|
||||
@@ -100,7 +100,8 @@ export function fetch(url: string, options?: RequestInit): Promise<any> {
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("stream error", e);
|
||||
throw e;
|
||||
// throw e;
|
||||
return new Response("", { status: 599 });
|
||||
});
|
||||
}
|
||||
return window.fetch(url, options);
|
||||
|
29
docs/bt-cn.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 宝塔面板 的部署说明
|
||||
|
||||
## 拥有自己的宝塔
|
||||
当你需要通过 宝塔面板 部署本项目之前,需要在服务器上先安装好 宝塔面板工具。 接下来的 部署流程 都建立在已有宝塔面板的前提下。宝塔安装请参考 ([宝塔官网](https://www.bt.cn/new/download.html))
|
||||
|
||||
> 注意:本项目需要宝塔面板版本 9.2.0 及以上
|
||||
|
||||
## 一键安装
|
||||

|
||||
1. 在 宝塔面板 -> Docker -> 应用商店 页面,搜索 ChatGPT-Next-Web 找到本项目的docker应用;
|
||||
2. 点击 安装 开始部署本项目
|
||||
|
||||

|
||||
1. 在项目配置页,根据要求开始配置环境变量;
|
||||
2. 如勾选 允许外部访问 配置,请注意为配置的 web端口 开放安全组端口访问权限;
|
||||
3. 请确保你添加了正确的 Open Api Key,否则无法使用;当配置 OpenAI官方 提供的key(国内无法访问),请配置代理地址;
|
||||
4. 建议配置 访问权限密码,否则部署后所有人均可使用已配置的 Open Api Key(当允许外部访问时);
|
||||
5. 点击 确认 开始自动部署。
|
||||
|
||||
## 如何访问
|
||||

|
||||
通过根据 服务器IP地址 和配置的 web端口 http://$(host):$(port),在浏览器中打开 ChatGPT-Next-Web。
|
||||
|
||||

|
||||
若配置了 访问权限密码,访问大模型前需要登录,请点击 登录,获取访问权限。
|
||||
|
||||

|
||||
|
||||

|
BIN
docs/images/bt/bt-install-1.jpeg
Normal file
After Width: | Height: | Size: 161 KiB |
BIN
docs/images/bt/bt-install-2.jpeg
Normal file
After Width: | Height: | Size: 196 KiB |
BIN
docs/images/bt/bt-install-3.jpeg
Normal file
After Width: | Height: | Size: 117 KiB |
BIN
docs/images/bt/bt-install-4.jpeg
Normal file
After Width: | Height: | Size: 159 KiB |
BIN
docs/images/bt/bt-install-5.jpeg
Normal file
After Width: | Height: | Size: 74 KiB |