Compare commits

...

102 Commits

Author SHA1 Message Date
Dogtiti
ba8e2414c6 fix: type error 2024-12-27 22:35:40 +08:00
Dogtiti
fe7f726c4b fix: size 2024-12-27 21:57:23 +08:00
Dogtiti
6b914b7ced feature: support glm Cogview 2024-12-27 21:52:22 +08:00
Dogtiti
175c52b13b chore: update vercel url 2024-12-27 20:39:08 +08:00
Dogtiti
0db21cf836 Merge pull request #18 from Dogtiti/merge
Merge
2024-12-27 20:30:44 +08:00
Dogtiti
5b1a759f86 Update 2024-12-27 20:29:44 +08:00
RiverRay
0c3d4462ca Merge pull request #5976 from ChatGPTNextWeb/Leizhenpeng-patch-1
Update README.md
2024-12-23 22:47:59 +08:00
RiverRay
3c859fc29f Update README.md 2024-12-23 22:47:16 +08:00
Dogtiti
1d15666713 Merge pull request #5919 from Yiming3/feature/flexible-visual-model
feat: runtime configuration of vision-capable models
2024-12-22 10:37:57 +08:00
Yiming Zhang
a127ae1fb4 docs: add VISION_MODELS section to README files 2024-12-21 13:12:41 -05:00
Yiming Zhang
ea1329f73e fix: add optional chaining to prevent errors when accessing visionModels 2024-12-21 04:07:58 -05:00
Yiming Zhang
149d732cb7 Merge remote-tracking branch 'upstream/main' into feature/flexible-visual-model 2024-12-21 03:53:05 -05:00
Yiming Zhang
210b29bfbe refactor: remove NEXT_PUBLIC_ prefix from VISION_MODELS env var 2024-12-21 03:51:54 -05:00
Dogtiti
acc2e97aab Merge pull request #5959 from dupl/gemini
add gemini-exp-1206, gemini-2.0-flash-thinking-exp-1219
2024-12-21 16:30:09 +08:00
dupl
93ac0e5017 Reorganized the Gemini model 2024-12-21 15:26:33 +08:00
Yiming Zhang
ed8c3580c8 test: add unit tests for isVisionModel utility function 2024-12-20 19:07:00 -05:00
dupl
0a056a7c5c add gemini-exp-1206, gemini-2.0-flash-thinking-exp-1219 2024-12-21 08:00:37 +08:00
Yiming Zhang
74c4711cdd Merge remote-tracking branch 'upstream/main' into feature/flexible-visual-model 2024-12-20 18:34:07 -05:00
Dogtiti
eceec092cf Merge pull request #5932 from fengzai6/update-google-models
Update google models to add gemini-2.0
2024-12-21 00:43:02 +08:00
Dogtiti
42743410a8 Merge pull request #5940 from ChatGPTNextWeb/dependabot/npm_and_yarn/testing-library/react-16.1.0
chore(deps-dev): bump @testing-library/react from 16.0.1 to 16.1.0
2024-12-21 00:41:45 +08:00
Dogtiti
0f04756d4c Merge pull request #5936 from InitialXKO/main
面具“以文搜图”改成“AI文生图”,微调提示让图片生成更稳定无水印
2024-12-21 00:40:45 +08:00
Dogtiti
ab158bf042 Merge pull request #9 from Dogtiti/Dogtiti-patch-1
Update
2024-12-20 22:18:49 +08:00
Dogtiti
14e3b409cc Update 2024-12-20 22:17:12 +08:00
dependabot[bot]
acdded8161 chore(deps-dev): bump @testing-library/react from 16.0.1 to 16.1.0
Bumps [@testing-library/react](https://github.com/testing-library/react-testing-library) from 16.0.1 to 16.1.0.
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.0.1...v16.1.0)

---
updated-dependencies:
- dependency-name: "@testing-library/react"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-16 10:57:34 +00:00
InitialXKO
e939ce5a02 面具“以文搜图”改成“AI文生图”,微调提示让图片生成更稳定无水印 2024-12-13 22:29:14 +08:00
Nacho.L
46a0b100f7 Update versionKeywords 2024-12-13 08:29:43 +08:00
Nacho.L
e27e8fb0e1 Update google models 2024-12-13 07:22:16 +08:00
Yiming Zhang
a433d1606c feat: use regex patterns for vision models and allow adding capabilities to models through env var NEXT_PUBLIC_VISION_MODELS. 2024-12-10 00:22:45 -05:00
Dogtiti
83cea3a90d Merge pull request #5879 from frostime/textline-custom-model
🎨 style(setting): Place custom-model's input a separated row.
2024-11-28 12:02:42 +08:00
frostime
759a09a76c 🎨 style(setting): Place custom-model's input a seperated row. 2024-11-27 13:11:18 +08:00
Dogtiti
2623a92763 Merge pull request #5850 from code-october/fix-o1
Fix o1
2024-11-25 12:31:36 +08:00
Dogtiti
3932c594c7 Merge pull request #5861 from code-october/update-model
update new model for gpt-4o and gemini-exp
2024-11-22 20:59:30 +08:00
code-october
b7acb89096 update new model for gpt-4o and gemini-exp 2024-11-22 09:48:50 +00:00
code-october
ef24d3e633 use stream when request o1 2024-11-21 03:46:10 +00:00
code-october
23350c842b fix o1 in disableGPT4 2024-11-21 03:45:07 +00:00
Dogtiti
a2adfbbd32 Merge pull request #5821 from Sherlocksuper/scroll
feat: support more user-friendly scrolling
2024-11-16 15:24:46 +08:00
Lloyd Zhou
f22cec1eb4 Merge pull request #5827 from ConnectAI-E/fix/markdown-embed-codeblock
fix: 代码块嵌入小代码块时渲染错误
2024-11-15 16:03:27 +08:00
opchips
e56216549e fix: 代码块嵌入小代码块时渲染错误 2024-11-15 11:56:26 +08:00
Sherlock
19facc7c85 feat: support mort user-friendly scrolling 2024-11-14 21:31:45 +08:00
Lloyd Zhou
b08ce5630c Merge pull request #5819 from ConnectAI-E/fix-gemini-summary
Fix gemini summary
2024-11-13 15:17:44 +08:00
DDMeaqua
b41c012d27 chore: shouldStream 2024-11-13 15:12:46 +08:00
Lloyd Zhou
a392daab71 Merge pull request #5816 from ConnectAI-E/feature/artifacts-svg
artifacts support svg
2024-11-13 14:58:33 +08:00
DDMeaqua
0628ddfc6f chore: update 2024-11-13 14:27:41 +08:00
DDMeaqua
7eda14f138 fix: [#5308] gemini对话总结 2024-11-13 14:24:44 +08:00
opchips
9a86c42c95 update 2024-11-12 16:33:55 +08:00
Lloyd Zhou
819d249a09 Merge pull request #5815 from LovelyGuYiMeng/main
更新视觉模型匹配关键词
2024-11-12 15:04:11 +08:00
LovelyGuYiMeng
8d66fedb1f Update visionKeywords 2024-11-12 14:28:11 +08:00
Lloyd Zhou
7cf89b53ce Merge pull request #5812 from ConnectAI-E/fix/rerender-chat
fix: use current session id to trigger rerender
2024-11-12 13:49:51 +08:00
Dogtiti
459c373f13 Merge pull request #5807 from ChatGPTNextWeb/dependabot/npm_and_yarn/testing-library/jest-dom-6.6.3
chore(deps-dev): bump @testing-library/jest-dom from 6.6.2 to 6.6.3
2024-11-11 20:59:56 +08:00
Dogtiti
1d14a991ee fix: use current session id to trigger rerender 2024-11-11 20:30:59 +08:00
dependabot[bot]
05ef5adfa7 chore(deps-dev): bump @testing-library/jest-dom from 6.6.2 to 6.6.3
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.6.2 to 6.6.3.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.6.2...v6.6.3)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-11 10:53:00 +00:00
lloydzhou
38fa3056df update version 2024-11-11 13:26:08 +08:00
Lloyd Zhou
289aeec8af Merge pull request #5786 from ConnectAI-E/feature/realtime-chat
Feature/realtime chat
2024-11-11 13:19:26 +08:00
lloydzhou
7d71da938f remove close-24 svg 2024-11-11 13:15:09 +08:00
Lloyd Zhou
f8f6954115 Merge pull request #5779 from ConnectAI-E/feature/model/claude35haiku
add claude35haiku & not support vision
2024-11-11 13:13:09 +08:00
Lloyd Zhou
6e03f32871 Merge pull request #5795 from JingSyue/main
fix: built-in plugin dalle3 error #5787
2024-11-11 13:10:00 +08:00
JingSyue
18a6571883 Update proxy.ts
Update proxy.ts
2024-11-11 12:59:29 +08:00
Dogtiti
14f444e1f0 doc: realtime chat 2024-11-11 11:47:41 +08:00
JingSyue
2b0f2e5f9d fix: built-in plugin dalle3 error #5787 2024-11-10 10:28:25 +08:00
Dogtiti
4629b39c29 chore: comment context history 2024-11-09 16:22:01 +08:00
Dogtiti
d33e772fa5 feat: voice print 2024-11-08 22:39:17 +08:00
Dogtiti
89136fba32 feat: voice print 2024-11-08 22:18:39 +08:00
Dogtiti
8b4ca133fd feat: voice print 2024-11-08 22:02:31 +08:00
lloydzhou
a4c9eaf6cd do not save empty audio file 2024-11-08 13:43:13 +08:00
lloydzhou
50e63109a3 merge code and get analyser data 2024-11-08 13:21:40 +08:00
Dogtiti
48a1e8a584 chore: i18n 2024-11-07 21:32:47 +08:00
Dogtiti
e44ebe3f0e feat: realtime config 2024-11-07 21:28:23 +08:00
Lloyd Zhou
108069a0c6 Merge pull request #5788 from ConnectAI-E/fix-o1-maxtokens
chore: o1模型使用max_completion_tokens
2024-11-07 20:06:30 +08:00
DDMeaqua
d5bda2904d chore: o1模型使用max_completion_tokens 2024-11-07 19:45:27 +08:00
lloydzhou
283caba8ce stop streaming play after get input audio. 2024-11-07 18:57:57 +08:00
lloydzhou
b78e5db817 add temperature config 2024-11-07 17:55:51 +08:00
lloydzhou
46c469b2d7 add voice config 2024-11-07 17:47:55 +08:00
lloydzhou
c00ebbea4f update 2024-11-07 17:40:03 +08:00
lloydzhou
c526ff80b5 update 2024-11-07 17:23:20 +08:00
lloydzhou
0037b0c944 ts error 2024-11-07 17:03:04 +08:00
lloydzhou
6f81bb3b8a add context after connected 2024-11-07 16:56:15 +08:00
lloydzhou
7bdc45ed3e connect realtime model when open panel 2024-11-07 16:41:24 +08:00
Dogtiti
88cd3ac122 fix: ts error 2024-11-07 12:16:11 +08:00
Dogtiti
4988d2ee26 fix: ts error 2024-11-07 11:56:58 +08:00
lloydzhou
8deb7a92ee hotfix for update target session 2024-11-07 11:53:01 +08:00
lloydzhou
db060d732a upload save record wav file 2024-11-07 11:45:38 +08:00
lloydzhou
522627820a upload save wav file logic 2024-11-07 09:36:22 +08:00
lloydzhou
cf46d5ad63 upload response audio, and update audio_url to session message 2024-11-07 01:12:08 +08:00
Dogtiti
a4941521d0 feat: audio to message 2024-11-06 22:30:02 +08:00
Dogtiti
f6e1f8398b wip 2024-11-06 22:07:33 +08:00
Dogtiti
d544eead38 feat: realtime chat ui 2024-11-06 21:14:45 +08:00
Lloyd Zhou
fbb9385f23 Merge pull request #5782 from ConnectAI-E/style/classname
style: improve classname by clsx
2024-11-06 20:33:51 +08:00
Dogtiti
18144c3d9c chore: clsx 2024-11-06 20:16:38 +08:00
opchips
64aa760e58 update claude rank 2024-11-06 19:18:05 +08:00
Dogtiti
e0bbb8bb68 style: improve classname by clsx 2024-11-06 16:58:26 +08:00
opchips
6667ee1c7f merge main 2024-11-06 15:08:18 +08:00
Lloyd Zhou
6ded4e96e7 Merge pull request #5778 from ConnectAI-E/fix/5436
fix: botMessage reply date
2024-11-06 15:04:46 +08:00
Dogtiti
85cdcab850 fix: botMessage reply date 2024-11-06 14:53:08 +08:00
Lloyd Zhou
f4c9410c29 Merge pull request #5776 from ConnectAI-E/feat-glm
fix: glm chatpath
2024-11-06 14:02:20 +08:00
DDMeaqua
adf7d8200b fix: glm chatpath 2024-11-06 13:55:57 +08:00
opchips
3086a2fa77 add claude35haiku not vision 2024-11-06 12:56:24 +08:00
Lloyd Zhou
f526d6f560 Merge pull request #5774 from ConnectAI-E/feature/update-target-session
fix: updateCurrentSession => updateTargetSession
2024-11-06 11:16:33 +08:00
Dogtiti
106461a1e7 Merge branch 'main' of https://github.com/ConnectAI-E/ChatGPT-Next-Web into feature/update-target-session 2024-11-06 11:08:41 +08:00
Dogtiti
c4e19dbc59 fix: updateCurrentSession => updateTargetSession 2024-11-06 11:06:18 +08:00
Dogtiti
f3603e59fa Merge pull request #5769 from ryanhex53/fix-model-multi@
Custom model names can include the `@` symbol by itself.
2024-11-06 10:49:28 +08:00
ryanhex53
8e2484fcdf Refactor: Replace all provider split occurrences with getModelProvider utility method 2024-11-05 13:52:54 +00:00
ryanhex53
b844045d23 Custom model names can include the @ symbol by itself.
To specify the model's provider, append it after the model name using `@` as before.

This format supports cases like `google vertex ai` with a model name like `claude-3-5-sonnet@20240620`.

For instance, `claude-3-5-sonnet@20240620@vertex-ai` will be split by `split(/@(?!.*@)/)` into:

`[ 'claude-3-5-sonnet@20240620', 'vertex-ai' ]`, where the former is the model name and the latter is the custom provider.
2024-11-05 07:44:12 +00:00
60 changed files with 2552 additions and 835 deletions

View File

@@ -1,16 +1,17 @@
<div align="center"> <div align="center">
<a href='#企业版'> <a href='https://nextchat.dev/chat'>
<img src="./docs/images/ent.svg" alt="icon"/> <img src="https://github.com/user-attachments/assets/287c510f-f508-478e-ade3-54d30453dc18" width="1000" alt="icon"/>
</a> </a>
<h1 align="center">NextChat (ChatGPT Next Web)</h1> <h1 align="center">NextChat (ChatGPT Next Web)</h1>
English / [简体中文](./README_CN.md) English / [简体中文](./README_CN.md)
One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 & Gemini Pro support. One-Click to get a well-designed cross-platform ChatGPT web UI, with Claude, GPT4 & Gemini Pro support.
一键免费部署你的跨平台私人 ChatGPT 应用, 支持 GPT3, GPT4 & Gemini Pro 模型。 一键免费部署你的跨平台私人 ChatGPT 应用, 支持 Claude, GPT4 & Gemini Pro 模型。
[![Saas][Saas-image]][saas-url] [![Saas][Saas-image]][saas-url]
[![Web][Web-image]][web-url] [![Web][Web-image]][web-url]
@@ -31,7 +32,7 @@ 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 [MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple
[Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu [Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu
[<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://svgshare.com/i/1AVg.svg" alt="Deploy to Alibaba Cloud" height="30">](https://computenest.aliyun.com/market/service-f1c9b75e59814dc49d52) [<img src="https://vercel.com/button" alt="Deploy on Vercel" height="30">](https://vercel.com/new/clone?repository-url=https://github.com/Dogtiti/ChatGPT-Next-Web-EarlyBird&env=OPENAI_API_KEY&env=CODE&project-name=nextchat-earlyBird&repository-name=NextChat-EarlyBird) [<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) [<img src="https://github.com/user-attachments/assets/903482d4-3e87-4134-9af1-f2588fa90659" height="60" width="288" >](https://monica.im/?utm=nxcrp)
@@ -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] 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] 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] 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 - [ ] local knowledge base
## What's New ## 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.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.15.0 Now supports Plugins! Read this: [NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins)
- 🚀 v2.14.0 Now supports Artifacts & SD - 🚀 v2.14.0 Now supports Artifacts & SD
@@ -134,10 +136,11 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
- [x] Artifacts: 通过独立窗口,轻松预览、复制和分享生成的内容/可交互网页 [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092) - [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)
- [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)
- [x] 支持 Realtime Chat [#5672](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5672)
- [ ] 本地知识库 - [ ] 本地知识库
## 最新动态 ## 最新动态
- 🚀 v2.15.8 现在支持Realtime Chat [#5672](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5672)
- 🚀 v2.15.4 客户端支持Tauri本地直接调用大模型API更安全[#5379](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5379) - 🚀 v2.15.4 客户端支持Tauri本地直接调用大模型API更安全[#5379](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5379)
- 🚀 v2.15.0 现在支持插件功能了!了解更多:[NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins) - 🚀 v2.15.0 现在支持插件功能了!了解更多:[NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins)
- 🚀 v2.14.0 现在支持 Artifacts & SD 了。 - 🚀 v2.14.0 现在支持 Artifacts & SD 了。
@@ -353,6 +356,13 @@ For ByteDance: use `modelName@bytedance=deploymentName` to customize model name
Change default model 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) ### `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 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
@@ -467,7 +477,7 @@ If you want to add a new translation, read this [document](./docs/translation.md
## Donation ## Donation
[Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa) [Buy Me a Coffee](https://1kafei.com/dogtiti)
## Special Thanks ## Special Thanks

View File

@@ -33,7 +33,7 @@
1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys); 1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys);
2. 点击右侧按钮开始部署: 2. 点击右侧按钮开始部署:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&env=GOOGLE_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web),直接使用 Github 账号登录即可,记得在环境变量页填入 API Key 和[页面访问密码](#配置页面访问密码) CODE [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/Dogtiti/ChatGPT-Next-Web-EarlyBird&env=OPENAI_API_KEY&env=CODE&project-name=nextchat-earlyBird&repository-name=NextChat-EarlyBird),直接使用 Github 账号登录即可,记得在环境变量页填入 API Key 和[页面访问密码](#配置页面访问密码) CODE
3. 部署完毕后,即可开始使用; 3. 部署完毕后,即可开始使用;
4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain)Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。 4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain)Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。
@@ -235,6 +235,13 @@ ChatGLM Api Url.
更改默认模型 更改默认模型
### `VISION_MODELS` (可选)
> 默认值:空
> 示例:`gpt-4-vision,claude-3-opus,my-custom-model` 表示为这些模型添加视觉能力,作为对默认模式匹配的补充(默认会检测包含"vision"、"claude-3"、"gemini-1.5"等关键词的模型)。
在默认模式匹配之外,添加更多具有视觉能力的模型。多个模型用逗号分隔。
### `DEFAULT_INPUT_TEMPLATE` (可选) ### `DEFAULT_INPUT_TEMPLATE` (可选)
自定义默认的 template用于初始化『设置』中的『用户输入预处理』配置项 自定义默认的 template用于初始化『设置』中的『用户输入预处理』配置项

View File

@@ -30,7 +30,7 @@
1. [OpenAI API Key](https://platform.openai.com/account/api-keys)を準備する; 1. [OpenAI API Key](https://platform.openai.com/account/api-keys)を準備する;
2. 右側のボタンをクリックしてデプロイを開始: 2. 右側のボタンをクリックしてデプロイを開始:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&env=GOOGLE_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) 、GitHubアカウントで直接ログインし、環境変数ページにAPI Keyと[ページアクセスパスワード](#設定ページアクセスパスワード) CODEを入力してください; [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/Dogtiti/ChatGPT-Next-Web-EarlyBird&env=OPENAI_API_KEY&env=CODE&project-name=nextchat-earlyBird&repository-name=NextChat-EarlyBird) 、GitHubアカウントで直接ログインし、環境変数ページにAPI Keyと[ページアクセスパスワード](#設定ページアクセスパスワード) CODEを入力してください;
3. デプロイが完了したら、すぐに使用を開始できます; 3. デプロイが完了したら、すぐに使用を開始できます;
4. (オプション)[カスタムドメインをバインド](https://vercel.com/docs/concepts/projects/domains/add-a-domain)Vercelが割り当てたドメインDNSは一部の地域で汚染されているため、カスタムドメインをバインドすると直接接続できます。 4. (オプション)[カスタムドメインをバインド](https://vercel.com/docs/concepts/projects/domains/add-a-domain)Vercelが割り当てたドメインDNSは一部の地域で汚染されているため、カスタムドメインをバインドすると直接接続できます。
@@ -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` (オプション) ### `DEFAULT_INPUT_TEMPLATE` (オプション)
『設定』の『ユーザー入力前処理』の初期設定に使用するテンプレートをカスタマイズします。 『設定』の『ユーザー入力前処理』の初期設定に使用するテンプレートをカスタマイズします。

View File

@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getServerSideConfig } from "../config/server"; import { getServerSideConfig } from "../config/server";
import { OPENAI_BASE_URL, ServiceProvider } from "../constant"; import { OPENAI_BASE_URL, ServiceProvider } from "../constant";
import { isModelAvailableInServer } from "../utils/model";
import { cloudflareAIGatewayUrl } from "../utils/cloudflare"; import { cloudflareAIGatewayUrl } from "../utils/cloudflare";
import { getModelProvider, isModelAvailableInServer } from "../utils/model";
const serverConfig = getServerSideConfig(); const serverConfig = getServerSideConfig();
@@ -71,7 +71,7 @@ export async function requestOpenai(req: NextRequest) {
.filter((v) => !!v && !v.startsWith("-") && v.includes(modelName)) .filter((v) => !!v && !v.startsWith("-") && v.includes(modelName))
.forEach((m) => { .forEach((m) => {
const [fullName, displayName] = m.split("="); const [fullName, displayName] = m.split("=");
const [_, providerName] = fullName.split("@"); const [_, providerName] = getModelProvider(fullName);
if (providerName === "azure" && !displayName) { if (providerName === "azure" && !displayName) {
const [_, deployId] = (serverConfig?.azureUrl ?? "").split( const [_, deployId] = (serverConfig?.azureUrl ?? "").split(
"deployments/", "deployments/",

View File

@@ -14,7 +14,7 @@ function getModels(remoteModelRes: OpenAIListModelResponse) {
if (config.disableGPT4) { if (config.disableGPT4) {
remoteModelRes.data = remoteModelRes.data.filter( remoteModelRes.data = remoteModelRes.data.filter(
(m) => (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"), m.id.startsWith("gpt-4o-mini"),
); );
} }

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getServerSideConfig } from "@/app/config/server";
export async function handle( export async function handle(
req: NextRequest, req: NextRequest,
@@ -9,6 +10,7 @@ export async function handle(
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 }); return NextResponse.json({ body: "OK" }, { status: 200 });
} }
const serverConfig = getServerSideConfig();
// remove path params from searchParams // remove path params from searchParams
req.nextUrl.searchParams.delete("path"); req.nextUrl.searchParams.delete("path");
@@ -31,6 +33,18 @@ export async function handle(
return true; 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 controller = new AbortController();
const fetchOptions: RequestInit = { const fetchOptions: RequestInit = {
headers, headers,

View File

@@ -25,12 +25,103 @@ import { getMessageTextContent } from "@/app/utils";
import { RequestPayload } from "./openai"; import { RequestPayload } from "./openai";
import { fetch } from "@/app/utils/stream"; import { fetch } from "@/app/utils/stream";
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 { export class ChatGLMApi implements LLMApi {
private disableListModels = true; 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 ? `![Generated Image](${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 { path(path: string): string {
const accessStore = useAccessStore.getState(); const accessStore = useAccessStore.getState();
let baseUrl = ""; let baseUrl = "";
if (accessStore.useCustomConfig) { if (accessStore.useCustomConfig) {
@@ -51,7 +142,6 @@ export class ChatGLMApi implements LLMApi {
} }
console.log("[Proxy Endpoint] ", baseUrl, path); console.log("[Proxy Endpoint] ", baseUrl, path);
return [baseUrl, path].join("/"); return [baseUrl, path].join("/");
} }
@@ -79,24 +169,16 @@ export class ChatGLMApi implements LLMApi {
}, },
}; };
const requestPayload: RequestPayload = { const modelType = this.getModelType(modelConfig.model);
messages, const requestPayload = this.createPayload(messages, modelConfig, options);
stream: options.config.stream, const path = this.path(this.getModelPath(modelType));
model: modelConfig.model,
temperature: modelConfig.temperature,
presence_penalty: modelConfig.presence_penalty,
frequency_penalty: modelConfig.frequency_penalty,
top_p: modelConfig.top_p,
};
console.log("[Request] glm payload: ", requestPayload); console.log(`[Request] glm ${modelType} payload: `, requestPayload);
const shouldStream = !!options.config.stream;
const controller = new AbortController(); const controller = new AbortController();
options.onController?.(controller); options.onController?.(controller);
try { try {
const chatPath = this.path(ChatGLM.ChatPath);
const chatPayload = { const chatPayload = {
method: "POST", method: "POST",
body: JSON.stringify(requestPayload), body: JSON.stringify(requestPayload),
@@ -104,12 +186,23 @@ export class ChatGLMApi implements LLMApi {
headers: getHeaders(), headers: getHeaders(),
}; };
// make a fetch request
const requestTimeoutId = setTimeout( const requestTimeoutId = setTimeout(
() => controller.abort(), () => controller.abort(),
REQUEST_TIMEOUT_MS, 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) { if (shouldStream) {
const [tools, funcs] = usePluginStore const [tools, funcs] = usePluginStore
.getState() .getState()
@@ -117,7 +210,7 @@ export class ChatGLMApi implements LLMApi {
useChatStore.getState().currentSession().mask?.plugin || [], useChatStore.getState().currentSession().mask?.plugin || [],
); );
return stream( return stream(
chatPath, path,
requestPayload, requestPayload,
getHeaders(), getHeaders(),
tools as any, tools as any,
@@ -125,7 +218,6 @@ export class ChatGLMApi implements LLMApi {
controller, controller,
// parseSSE // parseSSE
(text: string, runTools: ChatMessageTool[]) => { (text: string, runTools: ChatMessageTool[]) => {
// console.log("parseSSE", text, runTools);
const json = JSON.parse(text); const json = JSON.parse(text);
const choices = json.choices as Array<{ const choices = json.choices as Array<{
delta: { delta: {
@@ -154,7 +246,7 @@ export class ChatGLMApi implements LLMApi {
} }
return choices[0]?.delta?.content; return choices[0]?.delta?.content;
}, },
// processToolMessage, include tool_calls message and tool call results // processToolMessage
( (
requestPayload: RequestPayload, requestPayload: RequestPayload,
toolCallMessage: any, toolCallMessage: any,
@@ -172,7 +264,7 @@ export class ChatGLMApi implements LLMApi {
options, options,
); );
} else { } else {
const res = await fetch(chatPath, chatPayload); const res = await fetch(path, chatPayload);
clearTimeout(requestTimeoutId); clearTimeout(requestTimeoutId);
const resJson = await res.json(); const resJson = await res.json();
@@ -184,6 +276,7 @@ export class ChatGLMApi implements LLMApi {
options.onError?.(e as Error); options.onError?.(e as Error);
} }
} }
async usage() { async usage() {
return { return {
used: 0, used: 0,

View File

@@ -29,7 +29,7 @@ import { RequestPayload } from "./openai";
import { fetch } from "@/app/utils/stream"; import { fetch } from "@/app/utils/stream";
export class GeminiProApi implements LLMApi { export class GeminiProApi implements LLMApi {
path(path: string): string { path(path: string, shouldStream = false): string {
const accessStore = useAccessStore.getState(); const accessStore = useAccessStore.getState();
let baseUrl = ""; let baseUrl = "";
@@ -51,8 +51,10 @@ export class GeminiProApi implements LLMApi {
console.log("[Proxy Endpoint] ", baseUrl, path); console.log("[Proxy Endpoint] ", baseUrl, path);
let chatPath = [baseUrl, path].join("/"); let chatPath = [baseUrl, path].join("/");
if (shouldStream) {
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
}
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
return chatPath; return chatPath;
} }
extractMessage(res: any) { extractMessage(res: any) {
@@ -60,6 +62,7 @@ export class GeminiProApi implements LLMApi {
return ( return (
res?.candidates?.at(0)?.content?.parts.at(0)?.text || res?.candidates?.at(0)?.content?.parts.at(0)?.text ||
res?.at(0)?.candidates?.at(0)?.content?.parts.at(0)?.text ||
res?.error?.message || res?.error?.message ||
"" ""
); );
@@ -166,7 +169,10 @@ export class GeminiProApi implements LLMApi {
options.onController?.(controller); options.onController?.(controller);
try { try {
// https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb // 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 = { const chatPayload = {
method: "POST", method: "POST",

View File

@@ -24,7 +24,7 @@ import {
stream, stream,
} from "@/app/utils/chat"; } from "@/app/utils/chat";
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
import { DalleSize, DalleQuality, DalleStyle } from "@/app/typing"; import { ModelSize, DalleQuality, DalleStyle } from "@/app/typing";
import { import {
ChatOptions, ChatOptions,
@@ -65,6 +65,7 @@ export interface RequestPayload {
frequency_penalty: number; frequency_penalty: number;
top_p: number; top_p: number;
max_tokens?: number; max_tokens?: number;
max_completion_tokens?: number;
} }
export interface DalleRequestPayload { export interface DalleRequestPayload {
@@ -72,7 +73,7 @@ export interface DalleRequestPayload {
prompt: string; prompt: string;
response_format: "url" | "b64_json"; response_format: "url" | "b64_json";
n: number; n: number;
size: DalleSize; size: ModelSize;
quality: DalleQuality; quality: DalleQuality;
style: DalleStyle; style: DalleStyle;
} }
@@ -223,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. // O1 not support image, tools (plugin in ChatGPTNextWeb) and system, stream, logprobs, temperature, top_p, n, presence_penalty, frequency_penalty yet.
requestPayload = { requestPayload = {
messages, messages,
stream: !isO1 ? options.config.stream : false, stream: options.config.stream,
model: modelConfig.model, model: modelConfig.model,
temperature: !isO1 ? modelConfig.temperature : 1, temperature: !isO1 ? modelConfig.temperature : 1,
presence_penalty: !isO1 ? modelConfig.presence_penalty : 0, presence_penalty: !isO1 ? modelConfig.presence_penalty : 0,
@@ -233,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. // 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 // add max_tokens to vision model
if (visionModel) { if (visionModel) {
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000); requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
@@ -241,7 +247,7 @@ export class ChatGPTApi implements LLMApi {
console.log("[Request] openai payload: ", requestPayload); console.log("[Request] openai payload: ", requestPayload);
const shouldStream = !isDalle3 && !!options.config.stream && !isO1; const shouldStream = !isDalle3 && !!options.config.stream;
const controller = new AbortController(); const controller = new AbortController();
options.onController?.(controller); options.onController?.(controller);

View File

@@ -18,6 +18,8 @@ import {
trackSettingsPageGuideToCPaymentClick, trackSettingsPageGuideToCPaymentClick,
trackAuthorizationPageButtonToCPaymentClick, trackAuthorizationPageButtonToCPaymentClick,
} from "../utils/auth-settings-events"; } from "../utils/auth-settings-events";
import clsx from "clsx";
const storage = safeLocalStorage(); const storage = safeLocalStorage();
export function AuthPage() { export function AuthPage() {
@@ -54,7 +56,7 @@ export function AuthPage() {
onClick={() => navigate(Path.Home)} onClick={() => navigate(Path.Home)}
></IconButton> ></IconButton>
</div> </div>
<div className={`no-dark ${styles["auth-logo"]}`}> <div className={clsx("no-dark", styles["auth-logo"])}>
<BotIcon /> <BotIcon />
</div> </div>
@@ -163,7 +165,7 @@ function TopBanner() {
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} 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> <Logo className={styles["top-banner-logo"]}></Logo>
<span> <span>
{Locale.Auth.TopTips} {Locale.Auth.TopTips}

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import styles from "./button.module.scss"; import styles from "./button.module.scss";
import { CSSProperties } from "react"; import { CSSProperties } from "react";
import clsx from "clsx";
export type ButtonType = "primary" | "danger" | null; export type ButtonType = "primary" | "danger" | null;
@@ -22,12 +23,16 @@ export function IconButton(props: {
}) { }) {
return ( return (
<button <button
className={ className={clsx(
styles["icon-button"] + "clickable",
` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${ styles["icon-button"],
props.className ?? "" {
} clickable ${styles[props.type ?? ""]}` [styles.border]: props.bordered,
} [styles.shadow]: props.shadow,
},
styles[props.type ?? ""],
props.className,
)}
onClick={props.onClick} onClick={props.onClick}
title={props.title} title={props.title}
disabled={props.disabled} disabled={props.disabled}
@@ -40,10 +45,9 @@ export function IconButton(props: {
{props.icon && ( {props.icon && (
<div <div
aria-label={props.text || props.title} aria-label={props.text || props.title}
className={ className={clsx(styles["icon-button-icon"], {
styles["icon-button-icon"] + "no-dark": props.type === "primary",
` ${props.type === "primary" && "no-dark"}` })}
}
> >
{props.icon} {props.icon}
</div> </div>

View File

@@ -18,6 +18,7 @@ import { Mask } from "../store/mask";
import { useRef, useEffect } from "react"; import { useRef, useEffect } from "react";
import { showConfirm } from "./ui-lib"; import { showConfirm } from "./ui-lib";
import { useMobileScreen } from "../utils"; import { useMobileScreen } from "../utils";
import clsx from "clsx";
export function ChatItem(props: { export function ChatItem(props: {
onClick?: () => void; onClick?: () => void;
@@ -45,11 +46,11 @@ export function ChatItem(props: {
<Draggable draggableId={`${props.id}`} index={props.index}> <Draggable draggableId={`${props.id}`} index={props.index}>
{(provided) => ( {(provided) => (
<div <div
className={`${styles["chat-item"]} ${ className={clsx(styles["chat-item"], {
props.selected && [styles["chat-item-selected"]]:
(currentPath === Path.Chat || currentPath === Path.Home) && props.selected &&
styles["chat-item-selected"] (currentPath === Path.Chat || currentPath === Path.Home),
}`} })}
onClick={props.onClick} onClick={props.onClick}
ref={(ele) => { ref={(ele) => {
draggableRef.current = ele; draggableRef.current = ele;
@@ -63,7 +64,7 @@ export function ChatItem(props: {
> >
{props.narrow ? ( {props.narrow ? (
<div className={styles["chat-item-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 <MaskAvatar
avatar={props.mask.avatar} avatar={props.mask.avatar}
model={props.mask.modelConfig.model} model={props.mask.modelConfig.model}

View File

@@ -45,6 +45,14 @@
.chat-input-actions { .chat-input-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between;
gap: 5px;
&-end {
display: flex;
margin-left: auto;
gap: 5px;
}
.chat-input-action { .chat-input-action {
display: inline-flex; display: inline-flex;
@@ -62,10 +70,6 @@
width: var(--icon-width); width: var(--icon-width);
overflow: hidden; overflow: hidden;
&:not(:last-child) {
margin-right: 5px;
}
.text { .text {
white-space: nowrap; white-space: nowrap;
padding-left: 5px; padding-left: 5px;
@@ -231,10 +235,12 @@
animation: slide-in ease 0.3s; animation: slide-in ease 0.3s;
$linear: linear-gradient(to right, $linear: linear-gradient(
rgba(0, 0, 0, 0), to right,
rgba(0, 0, 0, 1), rgba(0, 0, 0, 0),
rgba(0, 0, 0, 0)); rgba(0, 0, 0, 1),
rgba(0, 0, 0, 0)
);
mask-image: $linear; mask-image: $linear;
@mixin show { @mixin show {
@@ -373,7 +379,7 @@
} }
} }
.chat-message-user>.chat-message-container { .chat-message-user > .chat-message-container {
align-items: flex-end; align-items: flex-end;
} }
@@ -443,6 +449,25 @@
transition: all ease 0.3s; 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 { .chat-message-item-image {
width: 100%; width: 100%;
margin-top: 10px; margin-top: 10px;
@@ -471,23 +496,27 @@
border: rgba($color: #888, $alpha: 0.2) 1px solid; border: rgba($color: #888, $alpha: 0.2) 1px solid;
} }
@media only screen and (max-width: 600px) { @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 { .chat-message-item-image-multi {
width: $calc-image-width; width: $calc-image-width;
height: $calc-image-width; height: $calc-image-width;
} }
.chat-message-item-image { .chat-message-item-image {
max-width: calc(100vw/3*2); max-width: calc(100vw / 3 * 2);
} }
} }
@media screen and (min-width: 600px) { @media screen and (min-width: 600px) {
$max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count)); $max-image-width: calc(
$image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count)); 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 { .chat-message-item-image-multi {
width: $image-width; width: $image-width;
@@ -497,7 +526,7 @@
} }
.chat-message-item-image { .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; 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); background-color: var(--second);
&:hover { &:hover {
@@ -626,7 +655,8 @@
min-height: 68px; min-height: 68px;
} }
.chat-input:focus {} .chat-input:focus {
}
.chat-input-send { .chat-input-send {
background-color: var(--primary); background-color: var(--primary);
@@ -693,4 +723,31 @@
.shortcut-key span { .shortcut-key span {
font-size: 12px; font-size: 12px;
color: var(--black); 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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -40,6 +40,7 @@ import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
import { getClientConfig } from "../config/client"; import { getClientConfig } from "../config/client";
import { type ClientApi, getClientApi } from "../client/api"; import { type ClientApi, getClientApi } from "../client/api";
import { getMessageTextContent } from "../utils"; import { getMessageTextContent } from "../utils";
import clsx from "clsx";
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />, loading: () => <LoadingIcon />,
@@ -118,9 +119,10 @@ function Steps<
return ( return (
<div <div
key={i} key={i}
className={`${styles["step"]} ${ className={clsx("clickable", styles["step"], {
styles[i <= props.index ? "step-finished" : ""] [styles["step-finished"]]: i <= props.index,
} ${i === props.index && styles["step-current"]} clickable`} [styles["step-current"]]: i === props.index,
})}
onClick={() => { onClick={() => {
props.onStepChange?.(i); props.onStepChange?.(i);
}} }}
@@ -525,11 +527,11 @@ export function ImagePreviewer(props: {
messages={props.messages} messages={props.messages}
/> />
<div <div
className={`${styles["preview-body"]} ${styles["default-theme"]}`} className={clsx(styles["preview-body"], styles["default-theme"])}
ref={previewRef} ref={previewRef}
> >
<div className={styles["chat-info"]}> <div className={styles["chat-info"]}>
<div className={styles["logo"] + " no-dark"}> <div className={clsx(styles["logo"], "no-dark")}>
<NextImage <NextImage
src={ChatGptIcon.src} src={ChatGptIcon.src}
alt="logo" alt="logo"
@@ -570,7 +572,7 @@ export function ImagePreviewer(props: {
{props.messages.map((m, i) => { {props.messages.map((m, i) => {
return ( return (
<div <div
className={styles["message"] + " " + styles["message-" + m.role]} className={clsx(styles["message"], styles["message-" + m.role])}
key={i} key={i}
> >
<div className={styles["avatar"]}> <div className={styles["avatar"]}>

View File

@@ -3,7 +3,6 @@
require("../polyfill"); require("../polyfill");
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import styles from "./home.module.scss"; import styles from "./home.module.scss";
import BotIcon from "../icons/bot.svg"; import BotIcon from "../icons/bot.svg";
@@ -29,10 +28,11 @@ import { AuthPage } from "./auth";
import { getClientConfig } from "../config/client"; import { getClientConfig } from "../config/client";
import { type ClientApi, getClientApi } from "../client/api"; import { type ClientApi, getClientApi } from "../client/api";
import { useAccessStore } from "../store"; import { useAccessStore } from "../store";
import clsx from "clsx";
export function Loading(props: { noLogo?: boolean }) { export function Loading(props: { noLogo?: boolean }) {
return ( return (
<div className={styles["loading-content"] + " no-dark"}> <div className={clsx("no-dark", styles["loading-content"])}>
{!props.noLogo && <BotIcon />} {!props.noLogo && <BotIcon />}
<LoadingIcon /> <LoadingIcon />
</div> </div>
@@ -179,7 +179,11 @@ function Screen() {
if (isSdNew) return <Sd />; if (isSdNew) return <Sd />;
return ( return (
<> <>
<SideBar className={isHome ? styles["sidebar-show"] : ""} /> <SideBar
className={clsx({
[styles["sidebar-show"]]: isHome,
})}
/>
<WindowContent> <WindowContent>
<Routes> <Routes>
<Route path={Path.Home} element={<Chat />} /> <Route path={Path.Home} element={<Chat />} />
@@ -197,9 +201,10 @@ function Screen() {
return ( return (
<div <div
className={`${styles.container} ${ className={clsx(styles.container, {
shouldTightBorder ? styles["tight-container"] : styles.container [styles["tight-container"]]: shouldTightBorder,
} ${getLang() === "ar" ? styles["rtl-screen"] : ""}`} [styles["rtl-screen"]]: getLang() === "ar",
})}
> >
{renderContent()} {renderContent()}
</div> </div>

View File

@@ -1,5 +1,6 @@
import * as React from "react"; import * as React from "react";
import styles from "./input-range.module.scss"; import styles from "./input-range.module.scss";
import clsx from "clsx";
interface InputRangeProps { interface InputRangeProps {
onChange: React.ChangeEventHandler<HTMLInputElement>; onChange: React.ChangeEventHandler<HTMLInputElement>;
@@ -23,7 +24,7 @@ export function InputRange({
aria, aria,
}: InputRangeProps) { }: InputRangeProps) {
return ( return (
<div className={styles["input-range"] + ` ${className ?? ""}`}> <div className={clsx(styles["input-range"], className)}>
{title || value} {title || value}
<input <input
aria-label={aria} aria-label={aria}

View File

@@ -23,6 +23,7 @@ import { useChatStore } from "../store";
import { IconButton } from "./button"; import { IconButton } from "./button";
import { useAppConfig } from "../store/config"; import { useAppConfig } from "../store/config";
import clsx from "clsx";
export function Mermaid(props: { code: string }) { export function Mermaid(props: { code: string }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -57,7 +58,7 @@ export function Mermaid(props: { code: string }) {
return ( return (
<div <div
className="no-dark mermaid" className={clsx("no-dark", "mermaid")}
style={{ style={{
cursor: "pointer", cursor: "pointer",
overflow: "auto", overflow: "auto",
@@ -89,7 +90,11 @@ export function PreCode(props: { children: any }) {
const refText = ref.current.querySelector("code")?.innerText; const refText = ref.current.querySelector("code")?.innerText;
if (htmlDom) { if (htmlDom) {
setHtmlCode((htmlDom as HTMLElement).innerText); setHtmlCode((htmlDom as HTMLElement).innerText);
} else if (refText?.startsWith("<!DOCTYPE")) { } else if (
refText?.startsWith("<!DOCTYPE") ||
refText?.startsWith("<svg") ||
refText?.startsWith("<?xml")
) {
setHtmlCode(refText); setHtmlCode(refText);
} }
}, 600); }, 600);
@@ -193,7 +198,12 @@ function CustomCode(props: { children: any; className?: string }) {
const renderShowMoreButton = () => { const renderShowMoreButton = () => {
if (showToggle && enableCodeFold && collapsed) { if (showToggle && enableCodeFold && collapsed) {
return ( 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> <button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
</div> </div>
); );
@@ -203,7 +213,7 @@ function CustomCode(props: { children: any; className?: string }) {
return ( return (
<> <>
<code <code
className={props?.className} className={clsx(props?.className)}
ref={ref} ref={ref}
style={{ style={{
maxHeight: enableCodeFold && collapsed ? "400px" : "none", maxHeight: enableCodeFold && collapsed ? "400px" : "none",
@@ -238,6 +248,10 @@ function escapeBrackets(text: string) {
function tryWrapHtmlCode(text: string) { function tryWrapHtmlCode(text: string) {
// try add wrap html code (fixed: html codeblock include 2 newline) // try add wrap html code (fixed: html codeblock include 2 newline)
// ignore embed codeblock
if (text.includes("```")) {
return text;
}
return text return text
.replace( .replace(
/([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g, /([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,

View File

@@ -55,6 +55,7 @@ import {
OnDragEndResponder, OnDragEndResponder,
} from "@hello-pangea/dnd"; } from "@hello-pangea/dnd";
import { getMessageTextContent } from "../utils"; import { getMessageTextContent } from "../utils";
import clsx from "clsx";
// drag and drop helper function // drag and drop helper function
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] { function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
@@ -588,7 +589,7 @@ export function MaskPage() {
</div> </div>
<div className={styles["mask-title"]}> <div className={styles["mask-title"]}>
<div className={styles["mask-name"]}>{m.name}</div> <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)} / ${ {`${Locale.Mask.Item.Info(m.context.length)} / ${
ALL_LANG_OPTIONS[m.lang] ALL_LANG_OPTIONS[m.lang]
} / ${m.modelConfig.model}`} } / ${m.modelConfig.model}`}

View File

@@ -8,6 +8,7 @@ import Locale from "../locales";
import styles from "./message-selector.module.scss"; import styles from "./message-selector.module.scss";
import { getMessageTextContent } from "../utils"; import { getMessageTextContent } from "../utils";
import clsx from "clsx";
function useShiftRange() { function useShiftRange() {
const [startIndex, setStartIndex] = useState<number>(); const [startIndex, setStartIndex] = useState<number>();
@@ -71,6 +72,7 @@ export function MessageSelector(props: {
defaultSelectAll?: boolean; defaultSelectAll?: boolean;
onSelected?: (messages: ChatMessage[]) => void; onSelected?: (messages: ChatMessage[]) => void;
}) { }) {
const LATEST_COUNT = 4;
const chatStore = useChatStore(); const chatStore = useChatStore();
const session = chatStore.currentSession(); const session = chatStore.currentSession();
const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming; 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [startIndex, endIndex]); }, [startIndex, endIndex]);
const LATEST_COUNT = 4;
return ( return (
<div className={styles["message-selector"]}> <div className={styles["message-selector"]}>
<div className={styles["message-filter"]}> <div className={styles["message-filter"]}>
<input <input
type="text" type="text"
placeholder={Locale.Select.Search} placeholder={Locale.Select.Search}
className={styles["filter-item"] + " " + styles["search-bar"]} className={clsx(styles["filter-item"], styles["search-bar"])}
value={searchInput} value={searchInput}
onInput={(e) => { onInput={(e) => {
setSearchInput(e.currentTarget.value); setSearchInput(e.currentTarget.value);
@@ -196,9 +196,9 @@ export function MessageSelector(props: {
return ( return (
<div <div
className={`${styles["message"]} ${ className={clsx(styles["message"], {
props.selection.has(m.id!) && styles["message-selected"] [styles["message-selected"]]: props.selection.has(m.id!),
}`} })}
key={i} key={i}
onClick={() => { onClick={() => {
props.updateSelection((selection) => { props.updateSelection((selection) => {
@@ -221,7 +221,7 @@ export function MessageSelector(props: {
<div className={styles["date"]}> <div className={styles["date"]}>
{new Date(m.date).toLocaleString()} {new Date(m.date).toLocaleString()}
</div> </div>
<div className={`${styles["content"]} one-line`}> <div className={clsx(styles["content"], "one-line")}>
{getMessageTextContent(m)} {getMessageTextContent(m)}
</div> </div>
</div> </div>

View File

@@ -7,6 +7,7 @@ import { ListItem, Select } from "./ui-lib";
import { useAllModels } from "../utils/hooks"; import { useAllModels } from "../utils/hooks";
import { groupBy } from "lodash-es"; import { groupBy } from "lodash-es";
import styles from "./model-config.module.scss"; import styles from "./model-config.module.scss";
import { getModelProvider } from "../utils/model";
export function ModelConfigList(props: { export function ModelConfigList(props: {
modelConfig: ModelConfig; modelConfig: ModelConfig;
@@ -28,7 +29,9 @@ export function ModelConfigList(props: {
value={value} value={value}
align="left" align="left"
onChange={(e) => { onChange={(e) => {
const [model, providerName] = e.currentTarget.value.split("@"); const [model, providerName] = getModelProvider(
e.currentTarget.value,
);
props.updateConfig((config) => { props.updateConfig((config) => {
config.model = ModalConfigValidator.model(model); config.model = ModalConfigValidator.model(model);
config.providerName = providerName as ServiceProvider; config.providerName = providerName as ServiceProvider;
@@ -247,7 +250,9 @@ export function ModelConfigList(props: {
aria-label={Locale.Settings.CompressModel.Title} aria-label={Locale.Settings.CompressModel.Title}
value={compressModelValue} value={compressModelValue}
onChange={(e) => { onChange={(e) => {
const [model, providerName] = e.currentTarget.value.split("@"); const [model, providerName] = getModelProvider(
e.currentTarget.value,
);
props.updateConfig((config) => { props.updateConfig((config) => {
config.compressModel = ModalConfigValidator.model(model); config.compressModel = ModalConfigValidator.model(model);
config.compressProviderName = providerName as ServiceProvider; config.compressProviderName = providerName as ServiceProvider;

View File

@@ -16,6 +16,7 @@ import { MaskAvatar } from "./mask";
import { useCommand } from "../command"; import { useCommand } from "../command";
import { showConfirm } from "./ui-lib"; import { showConfirm } from "./ui-lib";
import { BUILTIN_MASK_STORE } from "../masks"; import { BUILTIN_MASK_STORE } from "../masks";
import clsx from "clsx";
function MaskItem(props: { mask: Mask; onClick?: () => void }) { function MaskItem(props: { mask: Mask; onClick?: () => void }) {
return ( return (
@@ -24,7 +25,9 @@ function MaskItem(props: { mask: Mask; onClick?: () => void }) {
avatar={props.mask.avatar} avatar={props.mask.avatar}
model={props.mask.modelConfig.model} 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> </div>
); );
} }

View File

@@ -28,6 +28,7 @@ import {
import Locale from "../locales"; import Locale from "../locales";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useState } from "react"; import { useState } from "react";
import clsx from "clsx";
export function PluginPage() { export function PluginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -199,7 +200,7 @@ export function PluginPage() {
<div className={styles["mask-name"]}> <div className={styles["mask-name"]}>
{m.title}@<small>{m.version}</small> {m.title}@<small>{m.version}</small>
</div> </div>
<div className={styles["mask-info"] + " one-line"}> <div className={clsx(styles["mask-info"], "one-line")}>
{Locale.Plugin.Item.Info( {Locale.Plugin.Item.Info(
FunctionToolService.add(m).length, FunctionToolService.add(m).length,
)} )}
@@ -335,7 +336,10 @@ export function PluginPage() {
<ListItem <ListItem
subTitle={ subTitle={
<div <div
className={`markdown-body ${pluginStyles["plugin-content"]}`} className={clsx(
"markdown-body",
pluginStyles["plugin-content"],
)}
dir="auto" dir="auto"
> >
<pre> <pre>

View File

@@ -0,0 +1 @@
export * from "./realtime-chat";

View 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;
}
}

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

View 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>
</>
)}
</>
);
}

View File

@@ -4,6 +4,7 @@ import { Select } from "@/app/components/ui-lib";
import { IconButton } from "@/app/components/button"; import { IconButton } from "@/app/components/button";
import Locale from "@/app/locales"; import Locale from "@/app/locales";
import { useSdStore } from "@/app/store/sd"; import { useSdStore } from "@/app/store/sd";
import clsx from "clsx";
export const params = [ export const params = [
{ {
@@ -136,7 +137,7 @@ export function ControlParamItem(props: {
className?: string; className?: string;
}) { }) {
return ( 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-header"]}>
<div className={styles["ctrl-param-item-title"]}> <div className={styles["ctrl-param-item-title"]}>
<div> <div>

View File

@@ -36,6 +36,7 @@ import { removeImage } from "@/app/utils/chat";
import { SideBar } from "./sd-sidebar"; import { SideBar } from "./sd-sidebar";
import { WindowContent } from "@/app/components/home"; import { WindowContent } from "@/app/components/home";
import { params } from "./sd-panel"; import { params } from "./sd-panel";
import clsx from "clsx";
function getSdTaskStatus(item: any) { function getSdTaskStatus(item: any) {
let s: string; let s: string;
@@ -104,7 +105,7 @@ export function Sd() {
return ( return (
<> <>
<SideBar className={isSd ? homeStyles["sidebar-show"] : ""} /> <SideBar className={clsx({ [homeStyles["sidebar-show"]]: isSd })} />
<WindowContent> <WindowContent>
<div className={chatStyles.chat} key={"1"}> <div className={chatStyles.chat} key={"1"}>
<div className="window-header" data-tauri-drag-region> <div className="window-header" data-tauri-drag-region>
@@ -121,7 +122,10 @@ export function Sd() {
</div> </div>
)} )}
<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-main-title`}>Stability AI</div>
<div className="window-header-sub-title"> <div className="window-header-sub-title">

View File

@@ -85,6 +85,7 @@ import { nanoid } from "nanoid";
import { useMaskStore } from "../store/mask"; import { useMaskStore } from "../store/mask";
import { ProviderType } from "../utils/cloud"; import { ProviderType } from "../utils/cloud";
import { TTSConfigList } from "./tts-config"; import { TTSConfigList } from "./tts-config";
import { RealtimeConfigList } from "./realtime-chat/realtime-config";
function EditPromptModal(props: { id: string; onClose: () => void }) { function EditPromptModal(props: { id: string; onClose: () => void }) {
const promptStore = usePromptStore(); const promptStore = usePromptStore();
@@ -1770,9 +1771,11 @@ export function Settings() {
<ListItem <ListItem
title={Locale.Settings.Access.CustomModel.Title} title={Locale.Settings.Access.CustomModel.Title}
subTitle={Locale.Settings.Access.CustomModel.SubTitle} subTitle={Locale.Settings.Access.CustomModel.SubTitle}
vertical={true}
> >
<input <input
aria-label={Locale.Settings.Access.CustomModel.Title} aria-label={Locale.Settings.Access.CustomModel.Title}
style={{ width: "100%", maxWidth: "unset", textAlign: "left" }}
type="text" type="text"
value={config.customModels} value={config.customModels}
placeholder="model1,model2,model3" placeholder="model1,model2,model3"
@@ -1799,7 +1802,18 @@ export function Settings() {
{shouldShowPromptModal && ( {shouldShowPromptModal && (
<UserPromptModal onClose={() => setShowPromptModal(false)} /> <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> <List>
<TTSConfigList <TTSConfigList
ttsConfig={config.ttsConfig} ttsConfig={config.ttsConfig}

View File

@@ -30,6 +30,7 @@ import { Link, useNavigate } from "react-router-dom";
import { isIOS, useMobileScreen } from "../utils"; import { isIOS, useMobileScreen } from "../utils";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { showConfirm, Selector } from "./ui-lib"; import { showConfirm, Selector } from "./ui-lib";
import clsx from "clsx";
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, { const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
loading: () => null, loading: () => null,
@@ -141,9 +142,9 @@ export function SideBarContainer(props: {
const { children, className, onDragStart, shouldNarrow } = props; const { children, className, onDragStart, shouldNarrow } = props;
return ( return (
<div <div
className={`${styles.sidebar} ${className} ${ className={clsx(styles.sidebar, className, {
shouldNarrow && styles["narrow-sidebar"] [styles["narrow-sidebar"]]: shouldNarrow,
}`} })}
style={{ style={{
// #3016 disable transition on ios mobile screen // #3016 disable transition on ios mobile screen
transition: isMobileScreen && isIOSMobile ? "none" : undefined, transition: isMobileScreen && isIOSMobile ? "none" : undefined,
@@ -171,9 +172,9 @@ export function SideBarHeader(props: {
return ( return (
<Fragment> <Fragment>
<div <div
className={`${styles["sidebar-header"]} ${ className={clsx(styles["sidebar-header"], {
shouldNarrow ? styles["sidebar-header-narrow"] : "" [styles["sidebar-header-narrow"]]: shouldNarrow,
}`} })}
data-tauri-drag-region data-tauri-drag-region
> >
<div className={styles["sidebar-title-container"]}> <div className={styles["sidebar-title-container"]}>
@@ -182,7 +183,7 @@ export function SideBarHeader(props: {
</div> </div>
<div className={styles["sidebar-sub-title"]}>{subTitle}</div> <div className={styles["sidebar-sub-title"]}>{subTitle}</div>
</div> </div>
<div className={styles["sidebar-logo"] + " no-dark"}>{logo}</div> <div className={clsx(styles["sidebar-logo"], "no-dark")}>{logo}</div>
</div> </div>
{children} {children}
</Fragment> </Fragment>
@@ -286,7 +287,7 @@ export function SideBar(props: { className?: string }) {
<SideBarTail <SideBarTail
primaryAction={ primaryAction={
<> <>
<div className={styles["sidebar-action"] + " " + styles.mobile}> <div className={clsx(styles["sidebar-action"], styles.mobile)}>
<IconButton <IconButton
icon={<DeleteIcon />} icon={<DeleteIcon />}
onClick={async () => { onClick={async () => {

View File

@@ -23,6 +23,7 @@ import React, {
useRef, useRef,
} from "react"; } from "react";
import { IconButton } from "./button"; import { IconButton } from "./button";
import clsx from "clsx";
export function Popover(props: { export function Popover(props: {
children: JSX.Element; children: JSX.Element;
@@ -45,7 +46,7 @@ export function Popover(props: {
export function Card(props: { children: JSX.Element[]; className?: string }) { export function Card(props: { children: JSX.Element[]; className?: string }) {
return ( 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 ( return (
<div <div
className={ className={clsx(
styles["list-item"] + styles["list-item"],
` ${props.vertical ? styles["vertical"] : ""} ` + {
` ${props.className || ""}` [styles["vertical"]]: props.vertical,
} },
props.className,
)}
onClick={props.onClick} onClick={props.onClick}
> >
<div className={styles["list-header"]}> <div className={styles["list-header"]}>
@@ -135,9 +138,9 @@ export function Modal(props: ModalProps) {
return ( return (
<div <div
className={ className={clsx(styles["modal-container"], {
styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}` [styles["modal-container-max"]]: isMax,
} })}
> >
<div className={styles["modal-header"]}> <div className={styles["modal-header"]}>
<div className={styles["modal-title"]}>{props.title}</div> <div className={styles["modal-title"]}>{props.title}</div>
@@ -260,7 +263,7 @@ export function Input(props: InputProps) {
return ( return (
<textarea <textarea
{...props} {...props}
className={`${styles["input"]} ${props.className}`} className={clsx(styles["input"], props.className)}
></textarea> ></textarea>
); );
} }
@@ -301,9 +304,13 @@ export function Select(
const { className, children, align, ...otherProps } = props; const { className, children, align, ...otherProps } = props;
return ( return (
<div <div
className={`${styles["select-with-icon"]} ${ className={clsx(
align === "left" ? styles["left-align-option"] : "" styles["select-with-icon"],
} ${className}`} {
[styles["left-align-option"]]: align === "left",
},
className,
)}
> >
<select className={styles["select-with-icon-select"]} {...otherProps}> <select className={styles["select-with-icon-select"]} {...otherProps}>
{children} {children}
@@ -509,9 +516,9 @@ export function Selector<T>(props: {
const selected = selectedValues.includes(item.value); const selected = selectedValues.includes(item.value);
return ( return (
<ListItem <ListItem
className={`${styles["selector-item"]} ${ className={clsx(styles["selector-item"], {
item.disable && styles["selector-item-disabled"] [styles["selector-item-disabled"]]: item.disable,
}`} })}
key={i} key={i}
title={item.title} title={item.title}
subTitle={item.subTitle} subTitle={item.subTitle}

View File

@@ -0,0 +1 @@
export * from "./voice-print";

View File

@@ -0,0 +1,11 @@
.voice-print {
width: 100%;
height: 60px;
margin: 20px 0;
canvas {
width: 100%;
height: 100%;
filter: brightness(1.2); // 增加整体亮度
}
}

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

View File

@@ -40,6 +40,7 @@ export const getBuildConfig = () => {
buildMode, buildMode,
isApp, isApp,
template: process.env.DEFAULT_INPUT_TEMPLATE ?? DEFAULT_INPUT_TEMPLATE, template: process.env.DEFAULT_INPUT_TEMPLATE ?? DEFAULT_INPUT_TEMPLATE,
visionModels: process.env.VISION_MODELS || "",
}; };
}; };

View File

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

View File

@@ -232,7 +232,9 @@ export const XAI = {
export const ChatGLM = { export const ChatGLM = {
ExampleEndpoint: CHATGLM_BASE_URL, ExampleEndpoint: CHATGLM_BASE_URL,
ChatPath: "/api/paas/v4/chat/completions", 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_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
@@ -264,6 +266,7 @@ export const KnowledgeCutOffDate: Record<string, string> = {
"gpt-4o": "2023-10", "gpt-4o": "2023-10",
"gpt-4o-2024-05-13": "2023-10", "gpt-4o-2024-05-13": "2023-10",
"gpt-4o-2024-08-06": "2023-10", "gpt-4o-2024-08-06": "2023-10",
"gpt-4o-2024-11-20": "2023-10",
"chatgpt-4o-latest": "2023-10", "chatgpt-4o-latest": "2023-10",
"gpt-4o-mini": "2023-10", "gpt-4o-mini": "2023-10",
"gpt-4o-mini-2024-07-18": "2023-10", "gpt-4o-mini-2024-07-18": "2023-10",
@@ -290,6 +293,22 @@ export const DEFAULT_TTS_VOICES = [
"shimmer", "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"
];
export const EXCLUDE_VISION_MODEL_REGEXES = [/claude-3-5-haiku-20241022/];
const openaiModels = [ const openaiModels = [
"gpt-3.5-turbo", "gpt-3.5-turbo",
"gpt-3.5-turbo-1106", "gpt-3.5-turbo-1106",
@@ -303,6 +322,7 @@ const openaiModels = [
"gpt-4o", "gpt-4o",
"gpt-4o-2024-05-13", "gpt-4o-2024-05-13",
"gpt-4o-2024-08-06", "gpt-4o-2024-08-06",
"gpt-4o-2024-11-20",
"chatgpt-4o-latest", "chatgpt-4o-latest",
"gpt-4o-mini", "gpt-4o-mini",
"gpt-4o-mini-2024-07-18", "gpt-4o-mini-2024-07-18",
@@ -315,10 +335,23 @@ const openaiModels = [
]; ];
const googleModels = [ const googleModels = [
"gemini-1.0-pro", "gemini-1.0-pro", // Deprecated on 2/15/2025
"gemini-1.5-pro-latest", "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-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 = [ const anthropicModels = [
@@ -329,10 +362,11 @@ const anthropicModels = [
"claude-3-opus-20240229", "claude-3-opus-20240229",
"claude-3-opus-latest", "claude-3-opus-latest",
"claude-3-haiku-20240307", "claude-3-haiku-20240307",
"claude-3-5-haiku-20241022",
"claude-3-5-haiku-latest",
"claude-3-5-sonnet-20240620", "claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022", "claude-3-5-sonnet-20241022",
"claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest",
"claude-3-5-haiku-latest",
]; ];
const baiduModels = [ const baiduModels = [
@@ -399,6 +433,15 @@ const chatglmModels = [
"glm-4-long", "glm-4-long",
"glm-4-flashx", "glm-4-flashx",
"glm-4-flash", "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开始 let seq = 1000; // 内置的模型序号生成器从1000开始

11
app/icons/headphone.svg Normal file
View 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

7
app/icons/power.svg Normal file
View 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

13
app/icons/voice-off.svg Normal file
View 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
View 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

200
app/lib/audio.ts Normal file
View 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();
}
}

View File

@@ -562,6 +562,39 @@ const cn = {
SubTitle: "生成语音的速度", 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: { Store: {
DefaultTopic: "新的聊天", DefaultTopic: "新的聊天",

View File

@@ -570,6 +570,39 @@ const en: LocaleType = {
}, },
Engine: "TTS Engine", 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: { Store: {
DefaultTopic: "New Conversation", DefaultTopic: "New Conversation",

View File

@@ -3,7 +3,7 @@ import { BuiltinMask } from "./typing";
export const CN_MASKS: BuiltinMask[] = [ export const CN_MASKS: BuiltinMask[] = [
{ {
avatar: "1f5bc-fe0f", avatar: "1f5bc-fe0f",
name: "以文搜图", name: "AI文生图",
context: [ context: [
{ {
id: "text-to-pic-0", id: "text-to-pic-0",
@@ -28,7 +28,7 @@ export const CN_MASKS: BuiltinMask[] = [
id: "text-to-pic-3", id: "text-to-pic-3",
role: "system", role: "system",
content: content:
"助手善于判断用户意图当确定需要提供图片时助手会变得沉默寡言只使用以下格式输出markdown图片![描述](https://image.pollinations.ai/prompt/描述)因为这个语法可以自动按照提示生成并渲染图片。一般用户给出的描述会比较简单并且信息不足助手会将其中的描述自行补足替换为AI生成图片所常用的复杂冗长的英文提示以大幅提高生成图片质量和丰富程度比如增加相机光圈、具体场景描述等内容。助手会避免用代码块或原始块包围markdown标记因为那样只会渲染出代码块或原始块而不是图片。", "助手善于判断用户意图当确定需要提供图片时助手会变得沉默寡言只使用以下格式输出markdown图片![description](https://image.pollinations.ai/prompt/description?nologo=true)因为这个语法可以自动按照提示生成并渲染图片。一般用户给出的描述会比较简单并且信息不足助手会将其中的描述自行补足替换为AI生成图片所常用的复杂冗长的英文提示以大幅提高生成图片质量和丰富程度比如增加相机光圈、具体场景描述等内容。助手会避免用代码块或原始块包围markdown标记因为那样只会渲染出代码块或原始块而不是图片。url中的空格等符号需要转义。",
date: "", date: "",
}, },
], ],

View File

@@ -21,6 +21,7 @@ import { getClientConfig } from "../config/client";
import { createPersistStore } from "../utils/store"; import { createPersistStore } from "../utils/store";
import { ensure } from "../utils/clone"; import { ensure } from "../utils/clone";
import { DEFAULT_CONFIG } from "./config"; import { DEFAULT_CONFIG } from "./config";
import { getModelProvider } from "../utils/model";
let fetchState = 0; // 0 not fetch, 1 fetching, 2 done let fetchState = 0; // 0 not fetch, 1 fetching, 2 done
@@ -226,9 +227,9 @@ export const useAccessStore = createPersistStore(
.then((res) => { .then((res) => {
const defaultModel = res.defaultModel ?? ""; const defaultModel = res.defaultModel ?? "";
if (defaultModel !== "") { if (defaultModel !== "") {
const [model, providerName] = defaultModel.split("@"); const [model, providerName] = getModelProvider(defaultModel);
DEFAULT_CONFIG.modelConfig.model = model; DEFAULT_CONFIG.modelConfig.model = model;
DEFAULT_CONFIG.modelConfig.providerName = providerName; DEFAULT_CONFIG.modelConfig.providerName = providerName as any;
} }
return res; return res;

View File

@@ -52,6 +52,7 @@ export type ChatMessage = RequestMessage & {
id: string; id: string;
model?: ModelType; model?: ModelType;
tools?: ChatMessageTool[]; tools?: ChatMessageTool[];
audio_url?: string;
}; };
export function createMessage(override: Partial<ChatMessage>): ChatMessage { export function createMessage(override: Partial<ChatMessage>): ChatMessage {
@@ -357,7 +358,7 @@ export const useChatStore = createPersistStore(
session.messages = session.messages.concat(); session.messages = session.messages.concat();
session.lastUpdate = Date.now(); session.lastUpdate = Date.now();
}); });
get().updateStat(message); get().updateStat(message, targetSession);
get().summarizeSession(false, targetSession); get().summarizeSession(false, targetSession);
}, },
@@ -396,10 +397,10 @@ export const useChatStore = createPersistStore(
// get recent messages // get recent messages
const recentMessages = get().getMessagesWithMemory(); const recentMessages = get().getMessagesWithMemory();
const sendMessages = recentMessages.concat(userMessage); 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 // save user's and bot's message
get().updateCurrentSession((session) => { get().updateTargetSession(session, (session) => {
const savedUserMessage = { const savedUserMessage = {
...userMessage, ...userMessage,
content: mContent, content: mContent,
@@ -420,7 +421,7 @@ export const useChatStore = createPersistStore(
if (message) { if (message) {
botMessage.content = message; botMessage.content = message;
} }
get().updateCurrentSession((session) => { get().updateTargetSession(session, (session) => {
session.messages = session.messages.concat(); session.messages = session.messages.concat();
}); });
}, },
@@ -428,13 +429,14 @@ export const useChatStore = createPersistStore(
botMessage.streaming = false; botMessage.streaming = false;
if (message) { if (message) {
botMessage.content = message; botMessage.content = message;
botMessage.date = new Date().toLocaleString();
get().onNewMessage(botMessage, session); get().onNewMessage(botMessage, session);
} }
ChatControllerPool.remove(session.id, botMessage.id); ChatControllerPool.remove(session.id, botMessage.id);
}, },
onBeforeTool(tool: ChatMessageTool) { onBeforeTool(tool: ChatMessageTool) {
(botMessage.tools = botMessage?.tools || []).push(tool); (botMessage.tools = botMessage?.tools || []).push(tool);
get().updateCurrentSession((session) => { get().updateTargetSession(session, (session) => {
session.messages = session.messages.concat(); session.messages = session.messages.concat();
}); });
}, },
@@ -444,7 +446,7 @@ export const useChatStore = createPersistStore(
tools[i] = { ...tool }; tools[i] = { ...tool };
} }
}); });
get().updateCurrentSession((session) => { get().updateTargetSession(session, (session) => {
session.messages = session.messages.concat(); session.messages = session.messages.concat();
}); });
}, },
@@ -459,7 +461,7 @@ export const useChatStore = createPersistStore(
botMessage.streaming = false; botMessage.streaming = false;
userMessage.isError = !isAborted; userMessage.isError = !isAborted;
botMessage.isError = !isAborted; botMessage.isError = !isAborted;
get().updateCurrentSession((session) => { get().updateTargetSession(session, (session) => {
session.messages = session.messages.concat(); session.messages = session.messages.concat();
}); });
ChatControllerPool.remove( ChatControllerPool.remove(
@@ -591,8 +593,8 @@ export const useChatStore = createPersistStore(
set(() => ({ sessions })); set(() => ({ sessions }));
}, },
resetSession() { resetSession(session: ChatSession) {
get().updateCurrentSession((session) => { get().updateTargetSession(session, (session) => {
session.messages = []; session.messages = [];
session.memoryPrompt = ""; session.memoryPrompt = "";
}); });
@@ -736,19 +738,12 @@ export const useChatStore = createPersistStore(
} }
}, },
updateStat(message: ChatMessage) { updateStat(message: ChatMessage, session: ChatSession) {
get().updateCurrentSession((session) => { get().updateTargetSession(session, (session) => {
session.stat.charCount += message.content.length; session.stat.charCount += message.content.length;
// TODO: should update chat count and word count // TODO: should update chat count and word count
}); });
}, },
updateCurrentSession(updater: (session: ChatSession) => void) {
const sessions = get().sessions;
const index = get().currentSessionIndex;
updater(sessions[index]);
set(() => ({ sessions }));
},
updateTargetSession( updateTargetSession(
targetSession: ChatSession, targetSession: ChatSession,
updater: (session: ChatSession) => void, updater: (session: ChatSession) => void,

View File

@@ -1,5 +1,5 @@
import { LLMModel } from "../client/api"; import { LLMModel } from "../client/api";
import { DalleSize, DalleQuality, DalleStyle } from "../typing"; import { DalleQuality, DalleStyle, ModelSize } from "../typing";
import { getClientConfig } from "../config/client"; import { getClientConfig } from "../config/client";
import { import {
DEFAULT_INPUT_TEMPLATE, DEFAULT_INPUT_TEMPLATE,
@@ -15,6 +15,7 @@ import {
ServiceProvider, ServiceProvider,
} from "../constant"; } from "../constant";
import { createPersistStore } from "../utils/store"; import { createPersistStore } from "../utils/store";
import type { Voice } from "rt-client";
export type ModelType = (typeof DEFAULT_MODELS)[number]["name"]; export type ModelType = (typeof DEFAULT_MODELS)[number]["name"];
export type TTSModelType = (typeof DEFAULT_TTS_MODELS)[number]; export type TTSModelType = (typeof DEFAULT_TTS_MODELS)[number];
@@ -77,7 +78,7 @@ export const DEFAULT_CONFIG = {
compressProviderName: "", compressProviderName: "",
enableInjectSystemPrompts: true, enableInjectSystemPrompts: true,
template: config?.template ?? DEFAULT_INPUT_TEMPLATE, template: config?.template ?? DEFAULT_INPUT_TEMPLATE,
size: "1024x1024" as DalleSize, size: "1024x1024" as ModelSize,
quality: "standard" as DalleQuality, quality: "standard" as DalleQuality,
style: "vivid" as DalleStyle, style: "vivid" as DalleStyle,
}, },
@@ -90,12 +91,26 @@ export const DEFAULT_CONFIG = {
voice: DEFAULT_TTS_VOICE, voice: DEFAULT_TTS_VOICE,
speed: 1.0, 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 ChatConfig = typeof DEFAULT_CONFIG;
export type ModelConfig = ChatConfig["modelConfig"]; export type ModelConfig = ChatConfig["modelConfig"];
export type TTSConfig = ChatConfig["ttsConfig"]; export type TTSConfig = ChatConfig["ttsConfig"];
export type RealtimeConfig = ChatConfig["realtimeConfig"];
export function limitNumber( export function limitNumber(
x: number, x: number,

View File

@@ -11,3 +11,14 @@ export interface RequestMessage {
export type DalleSize = "1024x1024" | "1792x1024" | "1024x1792"; export type DalleSize = "1024x1024" | "1792x1024" | "1024x1792";
export type DalleQuality = "standard" | "hd"; export type DalleQuality = "standard" | "hd";
export type DalleStyle = "vivid" | "natural"; export type DalleStyle = "vivid" | "natural";
export type ModelSize =
| "1024x1024"
| "1792x1024"
| "1024x1792"
| "768x1344"
| "864x1152"
| "1344x768"
| "1152x864"
| "1440x720"
| "720x1440";

View File

@@ -5,6 +5,9 @@ import { RequestMessage } from "./client/api";
import { ServiceProvider } from "./constant"; import { ServiceProvider } from "./constant";
// import { fetch as tauriFetch, ResponseType } from "@tauri-apps/api/http"; // import { fetch as tauriFetch, ResponseType } from "@tauri-apps/api/http";
import { fetch as tauriStreamFetch } from "./utils/stream"; import { fetch as tauriStreamFetch } from "./utils/stream";
import { VISION_MODEL_REGEXES, EXCLUDE_VISION_MODEL_REGEXES } from "./constant";
import { getClientConfig } from "./config/client";
import { ModelSize } from "./typing";
export function trimTopic(topic: string) { export function trimTopic(topic: string) {
// Fix an issue where double quotes still show in the Indonesian language // Fix an issue where double quotes still show in the Indonesian language
@@ -252,23 +255,16 @@ export function getMessageImages(message: RequestMessage): string[] {
} }
export function isVisionModel(model: 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 clientConfig = getClientConfig();
const envVisionModels = clientConfig?.visionModels
const visionKeywords = [ ?.split(",")
"vision", .map((m) => m.trim());
"claude-3", if (envVisionModels?.includes(model)) {
"gemini-1.5-pro", return true;
"gemini-1.5-flash", }
"gpt-4o",
"gpt-4o-mini",
];
const isGpt4Turbo =
model.includes("gpt-4-turbo") && !model.includes("preview");
return ( return (
visionKeywords.some((keyword) => model.includes(keyword)) || !EXCLUDE_VISION_MODEL_REGEXES.some((regex) => regex.test(model)) &&
isGpt4Turbo || VISION_MODEL_REGEXES.some((regex) => regex.test(model))
isDalle3(model)
); );
} }
@@ -276,6 +272,28 @@ export function isDalle3(model: string) {
return "dall-e-3" === model; 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) { export function showPlugins(provider: ServiceProvider, model: string) {
if ( if (
provider == ServiceProvider.OpenAI || provider == ServiceProvider.OpenAI ||

View File

@@ -138,7 +138,7 @@ export function uploadImage(file: Blob): Promise<string> {
}) })
.then((res) => res.json()) .then((res) => res.json())
.then((res) => { .then((res) => {
console.log("res", res); // console.log("res", res);
if (res?.code == 0 && res?.data) { if (res?.code == 0 && res?.data) {
return res?.data; return res?.data;
} }

View File

@@ -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( export function collectModelTable(
models: readonly LLMModel[], models: readonly LLMModel[],
customModels: string, customModels: string,
@@ -79,10 +90,10 @@ export function collectModelTable(
); );
} else { } else {
// 1. find model by name, and set available value // 1. find model by name, and set available value
const [customModelName, customProviderName] = name.split("@"); const [customModelName, customProviderName] = getModelProvider(name);
let count = 0; let count = 0;
for (const fullName in modelTable) { for (const fullName in modelTable) {
const [modelName, providerName] = fullName.split("@"); const [modelName, providerName] = getModelProvider(fullName);
if ( if (
customModelName == modelName && customModelName == modelName &&
(customProviderName === undefined || (customProviderName === undefined ||
@@ -102,7 +113,7 @@ export function collectModelTable(
} }
// 2. if model not exists, create new model with available value // 2. if model not exists, create new model with available value
if (count === 0) { if (count === 0) {
let [customModelName, customProviderName] = name.split("@"); let [customModelName, customProviderName] = getModelProvider(name);
const provider = customProvider( const provider = customProvider(
customProviderName || customModelName, customProviderName || customModelName,
); );
@@ -139,7 +150,7 @@ export function collectModelTableWithDefaultModel(
for (const key of Object.keys(modelTable)) { for (const key of Object.keys(modelTable)) {
if ( if (
modelTable[key].available && modelTable[key].available &&
key.split("@").shift() == defaultModel getModelProvider(key)[0] == defaultModel
) { ) {
modelTable[key].isDefault = true; modelTable[key].isDefault = true;
break; break;

View File

@@ -27,6 +27,7 @@
"@vercel/analytics": "^0.1.11", "@vercel/analytics": "^0.1.11",
"@vercel/speed-insights": "^1.0.2", "@vercel/speed-insights": "^1.0.2",
"axios": "^1.7.5", "axios": "^1.7.5",
"clsx": "^2.1.1",
"emoji-picker-react": "^4.9.2", "emoji-picker-react": "^4.9.2",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
@@ -51,14 +52,15 @@
"sass": "^1.59.2", "sass": "^1.59.2",
"spark-md5": "^3.0.2", "spark-md5": "^3.0.2",
"use-debounce": "^9.0.4", "use-debounce": "^9.0.4",
"zustand": "^4.3.8" "zustand": "^4.3.8",
"rt-client": "https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/api": "^1.6.0", "@tauri-apps/api": "^1.6.0",
"@tauri-apps/cli": "1.5.11", "@tauri-apps/cli": "1.5.11",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.2", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1", "@testing-library/react": "^16.1.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",

48
public/audio-processor.js Normal file
View File

@@ -0,0 +1,48 @@
// @ts-nocheck
class AudioRecorderProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.isRecording = false;
this.bufferSize = 2400; // 100ms at 24kHz
this.currentBuffer = [];
this.port.onmessage = (event) => {
if (event.data.command === "START_RECORDING") {
this.isRecording = true;
} else if (event.data.command === "STOP_RECORDING") {
this.isRecording = false;
if (this.currentBuffer.length > 0) {
this.sendBuffer();
}
}
};
}
sendBuffer() {
if (this.currentBuffer.length > 0) {
const audioData = new Float32Array(this.currentBuffer);
this.port.postMessage({
eventType: "audio",
audioData: audioData,
});
this.currentBuffer = [];
}
}
process(inputs) {
const input = inputs[0];
if (input.length > 0 && this.isRecording) {
const audioData = input[0];
this.currentBuffer.push(...audioData);
if (this.currentBuffer.length >= this.bufferSize) {
this.sendBuffer();
}
}
return true;
}
}
registerProcessor("audio-recorder-processor", AudioRecorderProcessor);

View File

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

View File

@@ -0,0 +1,31 @@
import { getModelProvider } from "../app/utils/model";
describe("getModelProvider", () => {
test("should return model and provider when input contains '@'", () => {
const input = "model@provider";
const [model, provider] = getModelProvider(input);
expect(model).toBe("model");
expect(provider).toBe("provider");
});
test("should return model and undefined provider when input does not contain '@'", () => {
const input = "model";
const [model, provider] = getModelProvider(input);
expect(model).toBe("model");
expect(provider).toBeUndefined();
});
test("should handle multiple '@' characters correctly", () => {
const input = "model@provider@extra";
const [model, provider] = getModelProvider(input);
expect(model).toBe("model@provider");
expect(provider).toBe("extra");
});
test("should return empty strings when input is empty", () => {
const input = "";
const [model, provider] = getModelProvider(input);
expect(model).toBe("");
expect(provider).toBeUndefined();
});
});

View File

@@ -0,0 +1,67 @@
import { isVisionModel } from "../app/utils";
describe("isVisionModel", () => {
const originalEnv = process.env;
beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
test("should identify vision models using regex patterns", () => {
const visionModels = [
"gpt-4-vision",
"claude-3-opus",
"gemini-1.5-pro",
"gemini-2.0",
"gemini-exp-vision",
"learnlm-vision",
"qwen-vl-max",
"qwen2-vl-max",
"gpt-4-turbo",
"dall-e-3",
];
visionModels.forEach((model) => {
expect(isVisionModel(model)).toBe(true);
});
});
test("should exclude specific models", () => {
expect(isVisionModel("claude-3-5-haiku-20241022")).toBe(false);
});
test("should not identify non-vision models", () => {
const nonVisionModels = [
"gpt-3.5-turbo",
"gpt-4-turbo-preview",
"claude-2",
"regular-model",
];
nonVisionModels.forEach((model) => {
expect(isVisionModel(model)).toBe(false);
});
});
test("should identify models from VISION_MODELS env var", () => {
process.env.VISION_MODELS = "custom-vision-model,another-vision-model";
expect(isVisionModel("custom-vision-model")).toBe(true);
expect(isVisionModel("another-vision-model")).toBe(true);
expect(isVisionModel("unrelated-model")).toBe(false);
});
test("should handle empty or missing VISION_MODELS", () => {
process.env.VISION_MODELS = "";
expect(isVisionModel("unrelated-model")).toBe(false);
delete process.env.VISION_MODELS;
expect(isVisionModel("unrelated-model")).toBe(false);
expect(isVisionModel("gpt-4-vision")).toBe(true);
});
});

View File

@@ -2114,10 +2114,10 @@
lz-string "^1.5.0" lz-string "^1.5.0"
pretty-format "^27.0.2" pretty-format "^27.0.2"
"@testing-library/jest-dom@^6.6.2": "@testing-library/jest-dom@^6.6.3":
version "6.6.2" version "6.6.3"
resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.6.2.tgz#8186aa9a07263adef9cc5a59a4772db8c31f4a5b" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz#26ba906cf928c0f8172e182c6fe214eb4f9f2bd2"
integrity sha512-P6GJD4yqc9jZLbe98j/EkyQDTPgqftohZF5FBkHY5BUERZmcf4HeO2k0XaefEg329ux2p21i1A1DmyQ1kKw2Jw== integrity sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==
dependencies: dependencies:
"@adobe/css-tools" "^4.4.0" "@adobe/css-tools" "^4.4.0"
aria-query "^5.0.0" aria-query "^5.0.0"
@@ -2127,10 +2127,10 @@
lodash "^4.17.21" lodash "^4.17.21"
redent "^3.0.0" redent "^3.0.0"
"@testing-library/react@^16.0.1": "@testing-library/react@^16.1.0":
version "16.0.1" version "16.1.0"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.0.1.tgz#29c0ee878d672703f5e7579f239005e4e0faa875" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.1.0.tgz#aa0c61398bac82eaf89776967e97de41ac742d71"
integrity sha512-dSmwJVtJXmku+iocRhWOUFbrERC76TX2Mnf0ATODz8brzAZrMBbzLwQixlBSanZxR6LddK3eiwpSFZgDET1URg== integrity sha512-Q2ToPvg0KsVL0ohND9A3zLJWcOXXcO8IDu3fj11KhNt0UlCWyFyvnCIBkd12tidB2lkiVRG8VFqdhcqhqnAQtg==
dependencies: dependencies:
"@babel/runtime" "^7.12.5" "@babel/runtime" "^7.12.5"
@@ -3189,6 +3189,11 @@ cliui@^8.0.1:
strip-ansi "^6.0.1" strip-ansi "^6.0.1"
wrap-ansi "^7.0.0" wrap-ansi "^7.0.0"
clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
co@^4.6.0: co@^4.6.0:
version "4.6.0" version "4.6.0"
resolved "https://registry.npmmirror.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" resolved "https://registry.npmmirror.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -7450,6 +7455,12 @@ robust-predicates@^3.0.0:
resolved "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.1.tgz#ecde075044f7f30118682bd9fb3f123109577f9a" resolved "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.1.tgz#ecde075044f7f30118682bd9fb3f123109577f9a"
integrity sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g== integrity sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g==
"rt-client@https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz":
version "0.5.0"
resolved "https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz#abf2e9a850201e3571b8d36830f77bc52af3de9b"
dependencies:
ws "^8.18.0"
run-parallel@^1.1.9: run-parallel@^1.1.9:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
@@ -8493,9 +8504,9 @@ write-file-atomic@^4.0.2:
imurmurhash "^0.1.4" imurmurhash "^0.1.4"
signal-exit "^3.0.7" signal-exit "^3.0.7"
ws@^8.11.0: ws@^8.11.0, ws@^8.18.0:
version "8.18.0" version "8.18.0"
resolved "https://registry.npmmirror.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc"
integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==
xml-name-validator@^4.0.0: xml-name-validator@^4.0.0: