Compare commits

..

427 Commits
v3 ... v2.14.2

Author SHA1 Message Date
lloydzhou
495b321d0a Merge remote-tracking branch 'origin/main' 2024-08-16 16:35:31 +08:00
lloydzhou
8a38cdc1d7 update version 2024-08-16 16:35:13 +08:00
Lloyd Zhou
e210db7bd9 Merge pull request #5235 from ConnectAI-E/feature/access
代码折叠
2024-08-16 16:34:13 +08:00
Lloyd Zhou
f64763b74b Merge pull request #5093 from DDMeaqua/feat-i18n
feat: update i18n
2024-08-16 16:20:31 +08:00
Lloyd Zhou
e033bef544 Merge branch 'main' into feat-i18n 2024-08-16 16:13:13 +08:00
DDMeaqua
d88cc575e5 chore: update 2024-08-16 16:10:31 +08:00
Dogtiti
87325fad74 Merge pull request #5270 from DDMeaqua/main
feat: add model name to conversation page
2024-08-15 20:12:33 +08:00
Lloyd Zhou
122aa94c9f Merge pull request #5273 from ElricLiu/main
Update README.md
2024-08-15 14:35:48 +08:00
ElricLiu
e2e8a45104 Update README.md 2024-08-14 20:38:52 +08:00
DDMeaqua
fb5fc13f72 feat: add model name 2024-08-13 19:58:44 +08:00
Dogtiti
edb92f7bfb Merge pull request #5245 from MrrDrr/dalle3_add_options
dall-e-3 adds 'quality' and 'style' options
2024-08-13 15:31:18 +08:00
lyf
1980f43b9f feat(auth): xg feature 2024-08-12 16:31:12 +08:00
lyf
c3c3dd5154 feature add zhedie 2024-08-12 14:06:21 +08:00
lyf
356155fd30 feature add zhedie 2024-08-12 13:58:53 +08:00
lyf
6f75ef8f0a addcd 2024-08-10 12:45:50 +08:00
l.tingting
ca865a80dc dall-e-3 adds 'quality' and 'style' options 2024-08-10 11:09:07 +08:00
lyf
8f759d1c3e 代码折叠 2024-08-09 14:55:25 +08:00
lyf
44787637f2 tj 2024-08-08 21:07:03 +08:00
Dogtiti
cf1c8e8f2a Merge pull request #5233 from ConnectAI-E/feature/bangpt
Feature/bangpt
2024-08-08 17:27:50 +08:00
lyf
d948be2372 Merge branch 'feature/bangpt' of ssh://github.com/ConnectAI-E/ChatGPT-Next-Web into feature/bangpt 2024-08-08 16:25:49 +08:00
lyf
cc28aef625 add gpt-4o-2024-08-06 2024-08-08 16:23:40 +08:00
Dogtiti
036358de7c Merge pull request #5219 from ConnectAI-E/feature/access
修复ChatGPTNextWeb页面的无障碍问题
2024-08-08 13:59:16 +08:00
lyf
0958b9ee12 tsxiugai 2024-08-08 13:08:56 +08:00
Dogtiti
aff1d7ecd6 Merge pull request #5223 from 122cygf/main
修复拼写错误
2024-08-07 21:29:43 +08:00
lzz
42fdbd9bb8 Fix spelling errors 2024-08-07 17:22:32 +08:00
lyf
034c82e514 修改设置的无障碍 2024-08-07 13:39:23 +08:00
lyf
14ff46b5cd 解决按钮无障碍 2024-08-07 13:01:08 +08:00
lyf
c9099ca0a5 无障碍按钮和链接 2024-08-07 10:55:02 +08:00
Dogtiti
58b144b345 Merge pull request #5218 from ConnectAI-E/feature/bangpt
解决禁止gpt4时禁止gtp4o-mini问题
2024-08-07 10:45:38 +08:00
lyf
af21c57e77 ban gpt4 2024-08-07 10:15:39 +08:00
lloydzhou
624e4dbaaf update version 2024-08-06 22:41:35 +08:00
Dogtiti
9bbb4f396b Merge pull request #5181 from webws/feature/support-iflytek-spark
Feature/support iflytek spark
2024-08-06 19:32:43 +08:00
webws
b2c1644d69 feat: add support for iFLYTEK Spark API (接入讯飞星火模型) 2024-08-06 17:54:30 +08:00
Lloyd Zhou
5629f842da Merge pull request #5174 from ahzmr/main
style: Fixed an issue where the sample of the reply content was displayed out of order
2024-08-06 16:52:43 +08:00
HyiKi
f900283b09 fix: tencent InvalidParameter error
fix "Messages 中 system 角色必须位于列表的最开始"
2024-08-06 15:50:12 +08:00
HyiKi
b667eff6bd fix: baidu error_code 336006
change the summary role from system to user
2024-08-06 15:50:12 +08:00
HyiKi
54fdf40f5a fix: baidu error_code 336006 2024-08-06 15:50:12 +08:00
Lloyd Zhou
690542145d Merge pull request #5194 from HyiKi/main
fix: baidu error_code 336006
2024-08-06 13:56:44 +08:00
Lloyd Zhou
94c4cf0624 Merge pull request #5205 from ConnectAI-E/fix/azure-summary
fix: azure summary
2024-08-06 11:23:00 +08:00
Dogtiti
3da717d9fc fix: azure summary 2024-08-06 11:20:03 +08:00
HyiKi
0902efc719 Merge branch 'fix-role-error' 2024-08-06 10:53:32 +08:00
HyiKi
d7e2ee63d8 fix: tencent InvalidParameter error
fix "Messages 中 system 角色必须位于列表的最开始"
2024-08-06 10:45:25 +08:00
GH Action - Upstream Sync
7deb36ee1f Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-08-06 01:33:38 +00:00
GH Action - Upstream Sync
bfe4e88246 Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-08-06 01:00:29 +00:00
HyiKi
9ab45c3969 fix: baidu error_code 336006
change the summary role from system to user
2024-08-05 20:50:36 +08:00
Dogtiti
fec80c6c51 Merge pull request #5173 from ConnectAI-E/feature/dalle
add dalle3 model
2024-08-05 20:49:37 +08:00
Dogtiti
a6b7432358 Merge pull request #5180 from frostime/contrib-modellist
 feat: 调整模型列表,将自定义模型放在前面显示
2024-08-05 20:49:07 +08:00
frostime
3486954e07 🐛 fix(openai): 上次 commit 后 openai.ts 文件中出现类型不匹配的 bug 2024-08-05 20:26:48 +08:00
frostime
150fc84b9b feat(model): 增加 sorted 字段,并使用该字段对模型列表进行排序
1. 在 Model 和 Provider 类型中增加 sorted 字段(api.ts)
2. 默认模型在初始化的时候,自动设置默认 sorted 字段,从 1000 开始自增长(constant.ts)
3. 自定义模型更新的时候,自动分配 sorted 字段(model.ts)
2024-08-05 19:43:32 +08:00
frostime
b023a00445 🔨 refactor(model): 更改原先的实现方法,在 collect table 函数后面增加额外的 sort 处理 2024-08-05 16:37:22 +08:00
HyiKi
d0e296adf8 fix: baidu error_code 336006 2024-08-05 15:41:13 +08:00
Dogtiti
aa40015e9b Merge pull request #5190 from ConnectAI-E/feaure/hmac
reduce cloudflare functions build size
2024-08-05 14:07:55 +08:00
lloydzhou
141ce2c99a reduce cloudflare functions build size 2024-08-05 12:59:27 +08:00
lloydzhou
4a95dcb6e9 hotfix get wrong llm 2024-08-05 12:45:25 +08:00
lloydzhou
1610675c8f remove hash.js 2024-08-05 11:36:35 +08:00
GH Action - Upstream Sync
724c814bfe Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-08-04 01:04:56 +00:00
Dogtiti
764c0cb865 Merge pull request #5179 from frostime/contrib-font
 feat(config): Config the font-family of chat content
2024-08-03 21:04:43 +08:00
frostime
8a4b8a84d6 feat: 调整模型列表,将自定义模型放在前面显示 2024-08-03 17:16:05 +08:00
frostime
8ec6acc55a 🎨 i18n: add locale about "font family config"
Note: The language is translated by GPT
2024-08-03 16:24:51 +08:00
frostime
b6a022b0ef feat(config): Set markdown's font-family 2024-08-03 16:04:20 +08:00
李超
716899c030 fix: Fixed the issue that WebDAV synchronization could not check the status and failed during the first backup 2024-08-03 12:40:48 +08:00
Dogtiti
d9e407fd2b Merge pull request #5175 from frostime/upstream-main
 feat: 为命令前缀( `:` )增加对中文符号 `:`的支持
2024-08-03 10:37:51 +08:00
frostime
deb140de73 feat: 为命令前缀( : )支持中文符号 2024-08-02 22:38:31 +08:00
织梦人
3c1e5e7978 Merge branch 'ChatGPTNextWeb:main' into main 2024-08-02 22:26:28 +08:00
Dogtiti
4a8e85c28a fix: empty response 2024-08-02 22:16:08 +08:00
李超
8498cadae8 fix: Fixed an issue where the sample of the reply content was displayed out of order 2024-08-02 21:06:03 +08:00
lloydzhou
8c83fe23a1 using b64_json for dall-e-3 2024-08-02 20:58:21 +08:00
Lloyd Zhou
a8c65e3d27 Merge pull request #5126 from DDDDD12138/fix-watch-mask
fix: replace '&' with concurrently for cross-platform compatibility
2024-08-02 19:22:57 +08:00
Lloyd Zhou
324d30bef9 Merge pull request #5136 from frostime/contrib-txtcode
 feat(markdown): 对纯文本的代码块内容进行折行处理
2024-08-02 19:22:13 +08:00
lloydzhou
46cb48023e fix typescript error 2024-08-02 18:50:48 +08:00
lloydzhou
1c24ca58c7 add dalle3 model 2024-08-02 18:03:19 +08:00
Lloyd Zhou
9193a9a0e0 Merge pull request #5147 from ConnectAI-E/fix-locales
fix: locales => Locale
2024-08-02 18:02:13 +08:00
Lloyd Zhou
957244ba2e Merge pull request #5154 from ConnectAI-E/fix-sidebar-style
fix: sidebar title style
2024-08-02 18:01:51 +08:00
lloydzhou
ac599aa47c add dalle3 model 2024-08-02 18:00:42 +08:00
Dogtiti
67a90ffb76 Merge pull request #5171 from ConnectAI-E/feature/tencent
Feature/tencent
2024-08-02 16:43:00 +08:00
lloydzhou
feaa6f9bf0 Merge remote-tracking branch 'connectai/feature/tencent' into feature/tencent 2024-08-02 15:56:25 +08:00
lloydzhou
753bf3b924 hotfix for cf deploy error #5168 2024-08-02 15:55:26 +08:00
Dogtiti
b3219f57c8 Merge pull request #5157 from ConnectAI-E/feature/tencent
Feature/tencent
2024-08-01 23:56:27 +08:00
Dogtiti
a17df037af feat: add lodash-es 2024-08-01 23:54:14 +08:00
lloydzhou
dfc36e5210 update 2024-08-01 19:02:40 +08:00
lloydzhou
c359b92ddc update 2024-08-01 18:58:07 +08:00
lloydzhou
e1d6131f13 update 2024-08-01 16:49:55 +08:00
lloydzhou
6a0bda00f5 merge main 2024-08-01 15:39:54 +08:00
lloydzhou
f85ec95877 hotfix for tencent sign 2024-08-01 15:33:48 +08:00
lloydzhou
a024980c03 revert package.json 2024-08-01 13:21:36 +08:00
lloydzhou
fd9e94e078 merge main 2024-08-01 13:18:33 +08:00
Dogtiti
f6a6c51d15 Merge pull request #5156 from ConnectAI-E/feature/moonshot
add moonshot api
2024-08-01 12:28:10 +08:00
lloydzhou
966db1e4be add moonshot settings 2024-08-01 12:03:22 +08:00
lloydzhou
b8bbc37b8e merge main 2024-08-01 11:57:23 +08:00
lloydzhou
40cbabc330 add moonshot api 2024-08-01 11:55:22 +08:00
wuzhiqing
04a4e1b39a fix: replace '&' with concurrently for cross-platform compatibility
- Used `concurrently` to run parallel commands in package.json scripts
- Added `concurrently` as a devDependency in package.json
2024-08-01 10:05:21 +08:00
Dogtiti
99f3160aa2 fix: sidebar title style 2024-07-31 22:10:24 +08:00
Dogtiti
8cb72d8452 fix: locales => Locale 2024-07-30 21:25:33 +08:00
Dogtiti
c9eb9f3eda Merge pull request #5141 from ConnectAI-E/hotfix-google-api-in-app
hotfix for using google api in app
2024-07-30 15:42:30 +08:00
lloydzhou
64c3dcd732 hotfix for using google api in app 2024-07-30 12:03:06 +08:00
frostime
d49ececcc5 feat(markdown): wrap the plain text code block 2024-07-29 19:10:00 +08:00
Lloyd Zhou
90e1fadb1e Merge pull request #5124 from ElricLiu/main
Update README.md
2024-07-29 14:58:07 +08:00
ElricLiu
071391ddff Update README.md 2024-07-29 14:57:46 +08:00
ElricLiu
d70d46b4d5 Update README.md 2024-07-29 14:56:39 +08:00
ElricLiu
3ef596b215 Update README.md
Update redame,add Artifacts & SD
2024-07-29 14:42:30 +08:00
Lloyd Zhou
35c5518668 Merge pull request #5110 from ConnectAI-E/fix/owner
chore: update owner
2024-07-26 19:28:45 +08:00
lloydzhou
8b513537b7 release v2.14.0 2024-07-26 19:24:16 +08:00
ElricLiu
b27f394995 Merge pull request #5092 from ConnectAI-E/feature-artifacts
[Artifacts] add preview html code
2024-07-26 17:28:49 +08:00
Dogtiti
3f9f556e1c fix: iframe bg 2024-07-26 17:06:10 +08:00
Dogtiti
1772d5c4b6 chore: update owner 2024-07-26 16:00:25 +08:00
Dogtiti
715d1dc02f fix: default enable artifacts 2024-07-26 15:55:01 +08:00
Dogtiti
6737f016f5 chore: artifact => artifacts 2024-07-26 15:50:26 +08:00
Dogtiti
f2d2622172 fix: uploading loading 2024-07-26 13:49:15 +08:00
Dogtiti
72d6f97024 fix: ts error 2024-07-26 11:21:51 +08:00
Lloyd Zhou
a0f0b4ff9e Merge pull request #5 from ConnectAI-E/feature/artifacts-style
Feature/artifacts style
2024-07-25 23:36:34 +08:00
Dogtiti
c27ef6ffbf feat: artifacts style 2024-07-25 23:29:29 +08:00
Lloyd Zhou
f5499ff699 Merge pull request #5071 from ZTH7/main
Fix defaultModel undefined error
2024-07-25 21:02:39 +08:00
Mr. Z
c4334d4e5f Update model.ts 2024-07-25 20:03:54 +08:00
Dogtiti
51e8f0440d Merge branch 'feature-artifacts' of https://github.com/ConnectAI-E/ChatGPT-Next-Web into feature/artifacts-style 2024-07-25 19:43:20 +08:00
lloydzhou
5ec0311f84 fix typescript error 2024-07-25 19:38:18 +08:00
lloydzhou
556d563ba0 update 2024-07-25 19:31:16 +08:00
lloydzhou
6a083b24c4 fix typescript error 2024-07-25 19:22:18 +08:00
lloydzhou
825929fdc8 merge main 2024-07-25 19:18:45 +08:00
Lloyd Zhou
941a03ed6c Merge pull request #4983 from OpenAI-Next/dev-sd
[Feature] Stable Diffusion
2024-07-25 19:13:37 +08:00
Lloyd Zhou
cf63619182 Merge pull request #5103 from ConnectAI-E/hotfix-cache-upload-image
hotfix cache upload image
2024-07-25 19:01:10 +08:00
Mr. Z
5c04d3c5ea Change method 2024-07-25 17:59:15 +08:00
Mr. Z
46a47db2d8 Merge branch 'ChatGPTNextWeb:main' into main 2024-07-25 17:55:53 +08:00
Dogtiti
21ef9a4567 feat: artifacts style 2024-07-25 17:37:21 +08:00
lloydzhou
6f0846b2af hotfix cache upload image 2024-07-25 17:26:16 +08:00
lloydzhou
ecd78b3bdd hotfix cache upload image 2024-07-25 17:23:21 +08:00
lloydzhou
d8afd1af88 add expiration_ttl for kv storage 2024-07-25 16:56:08 +08:00
lloydzhou
7c1bc1f1a1 hotfix: auto set height 2024-07-25 15:27:44 +08:00
lloydzhou
763fc89b29 add fullscreen button on artifact component 2024-07-25 15:10:17 +08:00
lloydzhou
47b33f2b17 hotfix: auto set height 2024-07-25 14:15:16 +08:00
lloydzhou
9f0e16b045 hotfix: ts check 2024-07-25 13:48:21 +08:00
lloydzhou
2efedb1736 update 2024-07-25 13:34:59 +08:00
lloydzhou
044116c14c add plugin selector on chat 2024-07-25 13:29:39 +08:00
lloydzhou
b4bf11d648 add loading icon when upload artifact content 2024-07-25 12:49:19 +08:00
lloydzhou
6cc0a5a1a4 remove code 2024-07-25 12:36:48 +08:00
lloydzhou
8f14de5108 hotfix: ts check 2024-07-25 12:34:35 +08:00
lloydzhou
8f6e5d73a2 hotfix: can send sd task in client 2024-07-25 12:31:30 +08:00
lloydzhou
ab9f5382b2 fix typescript 2024-07-24 20:51:33 +08:00
Dogtiti
fd441d9303 feat: discovery icon 2024-07-24 20:41:41 +08:00
lloydzhou
e31bec3aff save artifact content to cloudflare workers kv 2024-07-24 20:36:11 +08:00
Dogtiti
2a1c05a028 fix: bugs 2024-07-24 20:04:22 +08:00
lloydzhou
421bf33c0e save artifact content to cloudflare workers kv 2024-07-24 20:02:37 +08:00
DDMeaqua
9c7bacc65e chore: update 2024-07-24 10:45:17 +08:00
Dogtiti
3935c725c9 feat: sd setting 2024-07-23 22:44:09 +08:00
Dogtiti
908ee0060f chore: remove sd new 2024-07-23 22:23:34 +08:00
Dogtiti
82e6fd7bb5 feat: move sd config to store 2024-07-23 21:43:55 +08:00
Dogtiti
6b98b14179 fix: sd mobile 2024-07-23 20:24:56 +08:00
lloydzhou
1ecefd88f7 hotfix 2024-07-23 17:57:41 +08:00
lloydzhou
2e9e20ce7c auto height for html preview 2024-07-23 17:54:07 +08:00
lloydzhou
fb60fbb217 auto height for html preview 2024-07-23 17:51:44 +08:00
lloydzhou
4199e17da0 auto height for html preview 2024-07-23 17:44:15 +08:00
DDMeaqua
86b8bfcb1f feat: update i18n 2024-07-23 15:21:38 +08:00
lloydzhou
dfd089132d add preview html code 2024-07-23 14:39:31 +08:00
lloydzhou
3a10f58b28 add preview html code 2024-07-23 14:33:41 +08:00
Dogtiti
9d55adbaf2 refator: sd 2024-07-23 00:51:58 +08:00
Lloyd Zhou
00be2be24f Merge pull request #5088 from ChatGPTNextWeb/revert-5080-refactor-components
Revert "feat: improve components structure"
2024-07-22 22:19:05 +08:00
Lloyd Zhou
5b126c7e52 Revert "feat: improve components structure" 2024-07-22 22:18:51 +08:00
Lloyd Zhou
1943f3b53f Merge pull request #5080 from ConnectAI-E/refactor-components
feat: improve components structure
2024-07-22 19:08:02 +08:00
Dogtiti
4a0bef9afb Merge pull request #5081 from consistent-k/main
chore: Remove useless judgment conditions
2024-07-22 17:31:00 +08:00
consistent-k
dfd2a53129 chore: Remove useless judgment conditions 2024-07-22 08:50:15 +00:00
Mr. Z
aa4e855012 Compatibility changes 2024-07-22 16:41:11 +08:00
Dogtiti
d6089e6309 fix: mask json 2024-07-22 16:33:03 +08:00
Dogtiti
038e6df8f0 feat: improve components structure 2024-07-22 16:02:45 +08:00
Mr. Z
2fd68bcac3 Fix defaultModel undefined error 2024-07-21 16:42:34 +08:00
lloydzhou
e468fecf12 update 2024-07-20 20:44:19 +08:00
lloydzhou
fc31d8e5d1 merge origin/main 2024-07-20 15:15:46 +08:00
Dogtiti
115f357a07 Merge pull request #5067 from ConnectAI-E/feature-setting-page
refactor setting page
2024-07-20 14:53:20 +08:00
lloydzhou
ac04a1cac8 resolve conflicts 2024-07-20 14:41:42 +08:00
lloydzhou
87a286ef07 refactor setting page 2024-07-20 14:36:47 +08:00
Dogtiti
622d8a4edb Merge pull request #5063 from ChenglongWang/summary_model
Change gpt summary model to gpt-4o-mini
2024-07-19 23:53:00 +08:00
Dogtiti
b44086f0dc Merge pull request #4847 from yeung66/main
add google api safety settings by Settings page
2024-07-19 23:41:04 +08:00
Chenglong Wang
0236e13187 Change gpt summary model to gpt-4o-mini. 2024-07-19 23:36:57 +08:00
YeungYeah
a3d4a7253f Merge remote-tracking branch 'source/main' 2024-07-19 21:38:25 +08:00
lloydzhou
e079f1b31a release v2.13.1 2024-07-19 18:16:05 +08:00
Dogtiti
9a78a72eb3 Merge pull request #5061 from ConnectAI-E/feature-cache-storage
using cache storage store image data #5013
2024-07-19 17:37:14 +08:00
lloydzhou
862c2e8810 hotfix for code review 2024-07-19 17:22:54 +08:00
Lloyd Zhou
12cad4c418 Merge pull request #5062 from yaway/main
Add README_JA.md
2024-07-19 17:14:32 +08:00
yaway
89b9d3a7f7 Update README_JA.md 2024-07-19 16:41:46 +08:00
yaway
57831d4880 Update and rename README_JP.md to README_JA.md 2024-07-19 16:31:12 +08:00
lloydzhou
052004d70e using compressImage when serviceWorker register error 2024-07-19 16:03:22 +08:00
lloydzhou
a765237441 reload page when sw installed. 2024-07-19 15:40:14 +08:00
lloydzhou
ac470a6d07 Merge remote-tracking branch 'connectai/feature-cache-storage' into feature-cache-storage 2024-07-19 15:39:51 +08:00
Dogtiti
7237d33be3 fix: ts type 2024-07-19 14:55:47 +08:00
lloydzhou
1610b480af remove console.log 2024-07-19 13:54:33 +08:00
lloydzhou
287fa0a39c feat: 1. using cache storage store image data; 2. get base64image before chat to api #5013 2024-07-19 13:50:10 +08:00
Dogtiti
afa1a4303b Merge pull request #5060 from DDMeaqua/main
readme_cn 补充锚点
2024-07-19 11:28:04 +08:00
DDMeaqua
28cedb1493 feat: 中文版锚点 2024-07-19 11:24:13 +08:00
Dogtiti
a280e25ee7 Merge pull request #5046 from PeterDaveHello/Add-zh-tw-prompts
Add Traditional Chinese prompts converted from Simplified Chinese version
2024-07-19 10:54:54 +08:00
Dogtiti
8464ca8931 Merge pull request #5045 from ConnectAI-E/fix/google
refactor: google
2024-07-19 10:19:04 +08:00
Dogtiti
44340f277d Merge pull request #5054 from Leizhenpeng/main
chore: update nav link to #企业版
2024-07-19 09:51:32 +08:00
Dogtiti
74bf99f9ea Merge pull request #5057 from PeterDaveHello/support-gpt-4o-mini
Add GPT-4o mini support
2024-07-19 09:48:48 +08:00
Peter Dave Hello
9caf820758 Add GPT-4o mini support
Reference:
- https://platform.openai.com/docs/models/gpt-4o-mini
- https://openai.com/index/gpt-4o-mini-advancing-cost-efficient-intelligence/
2024-07-19 03:21:40 +08:00
YeungYeah
26c2598f56 fix: fix bug in generating wrong gemini request url 2024-07-18 23:41:20 +08:00
RiverRay
6d9abf261c chore: update md 2024-07-18 23:24:29 +08:00
Lloyd Zhou
b16d0185dd Merge pull request #5033 from arloor/main
doc: fix Azure docs
2024-07-18 21:06:59 +08:00
Lloyd Zhou
ca51c2e93d Merge pull request #5035 from DDDDD12138/code-cleanup
chore: remove unused imports and correct typos
2024-07-18 21:05:52 +08:00
Lloyd Zhou
da254975cd Merge pull request #5052 from LiuElric/main
Add enterprise edition introduction
2024-07-18 21:05:03 +08:00
LiuElric
86bae6be3a Merge pull request #1 from yaway/patch-2
Update README.md
2024-07-18 17:59:45 +08:00
yaway
fc8c7ef18d Update README.md 2024-07-18 17:56:37 +08:00
LiuElric
f654629c6a Update README.md 2024-07-18 17:37:47 +08:00
LiuElric
d4a87c561a Update README.md 2024-07-18 17:34:27 +08:00
LiuElric
d8872d48b3 Update README_CN.md
Added Chinese introduction to enterprise edition
2024-07-18 17:30:56 +08:00
LiuElric
ed16c2c18d Update README.md
Add enterprise version description
2024-07-18 17:20:47 +08:00
yaway
0478a6ce3b Create README_JP.md 2024-07-18 17:20:21 +08:00
LiuElric
68b60e82ba Add ent cover 2024-07-18 17:09:32 +08:00
DDDDD12138
8edc0989e2 chore: remove unused imports and correct typos 2024-07-18 01:51:56 +08:00
Dogtiti
fbf3551bbe Merge pull request #5036 from DDDDD12138/update-env-template
chore: update .env.template to align with README.md
2024-07-17 22:55:49 +08:00
Peter Dave Hello
238d3122c4 Add Traditional Chinese prompts converted from Simplified Chinese version 2024-07-17 22:38:43 +08:00
YeungYeah
ee22fba448 Merge branch 'main' into main 2024-07-17 22:16:30 +08:00
Dogtiti
f8a2a28bff Merge pull request #5041 from DDMeaqua/feat-scrollbar-css
scrollbar width change
2024-07-17 20:09:43 +08:00
Dogtiti
b3cfaf1420 refator: google 2024-07-17 20:05:34 +08:00
Dogtiti
2e9f701bb7 Merge pull request #5040 from consistent-k/main
update constant for baidu  add ENIRE Speed  ENIRE Lite model
2024-07-17 19:05:31 +08:00
consistent-k
9aabc4ad6a update constant for baidu add ernie-tiny model 2024-07-17 10:41:31 +00:00
consistent-k
5dc731bc77 update constant for baidu add ernie-speed model 2024-07-17 09:29:43 +00:00
DDMeaqua
32d05c9855 feat: scrollbar width change 2024-07-17 17:01:50 +08:00
consistent-k
5a0d0c0b75 update constant for baidu add ernie-speed model 2024-07-17 08:54:53 +00:00
wuzhiqing
17d4a8fb26 chore: update .env.template to align with README.md 2024-07-17 11:12:26 +08:00
arloor
348c1a7d5f doc: fix Azure docs 2024-07-17 09:15:20 +08:00
lloydzhou
49151dabf5 hotfix 2024-07-16 23:18:34 +08:00
lloydzhou
eb7c7cdcb6 更新serviceworker逻辑 2024-07-16 16:37:55 +08:00
Dogtiti
ec95292209 Merge pull request #5025 from jiangying000/main
fix typo in app/components/chat.tsx
2024-07-16 16:13:07 +08:00
Lloyd Zhou
4b84fb328c Merge pull request #2 from OpenAI-Next/dev-sd-test
using indexdb store image data
2024-07-16 15:12:10 +08:00
jiangying
47d27c1f41 fix typo 2024-07-16 14:35:06 +08:00
lloydzhou
5267ad46da add header for service worker upload api 2024-07-16 11:51:43 +08:00
lloydzhou
94bc880b7f fixed typescript error 2024-07-16 01:45:15 +08:00
lloydzhou
bab3e0bc9b using CacheStorage to store image #5013 2024-07-16 01:19:40 +08:00
lloydzhou
b3a324b6f5 fixed typescript error 2024-07-15 21:21:50 +08:00
lloydzhou
a1117cd4ee save blob to indexeddb instead of base64 image string 2024-07-15 20:47:49 +08:00
lloydzhou
6ece818d69 remove no need code 2024-07-15 20:28:55 +08:00
lloydzhou
5df09d5e2a move code to utils/file 2024-07-15 20:26:03 +08:00
lloydzhou
33450ce429 move code to utils/file 2024-07-15 20:09:21 +08:00
lloydzhou
e2f0206d88 update using indexdb read sd image data 2024-07-15 19:29:25 +08:00
licoy
3767b2c7f9 test 2024-07-15 15:29:56 +08:00
Dogtiti
1779f1f3da Merge pull request #4984 from RubuJam/chore/pr
chore: add chinese template
2024-07-15 11:51:23 +08:00
lloydzhou
b9d1dca65d update version v2.13.0 2024-07-13 21:30:31 +08:00
LiuElric
8e4d26163a Merge pull request #4989 from ConnectAI-E/hotfix/cf-ai-gateway
update custom bytedance models, and update labels in setting page
2024-07-12 23:10:25 +08:00
lloydzhou
53c1176cbf update labels in setting page 2024-07-12 23:06:37 +08:00
lloydzhou
46d3e7884b hotfix: bytedance custom models 2024-07-12 22:53:39 +08:00
Rubu Jam
a0290b0c1b chore: Modify title and labels 2024-07-12 14:52:07 +00:00
Lloyd Zhou
b4ae706914 Merge pull request #4988 from ConnectAI-E/hotfix/cf-ai-gateway
Hotfix: default config
2024-07-12 22:06:02 +08:00
lloydzhou
476bdac717 update 2024-07-12 21:52:38 +08:00
lloydzhou
831627268d update 2024-07-12 21:41:12 +08:00
lloydzhou
9b97dca601 hotfix: custom comfig for Gemini api. #4944 2024-07-12 21:27:30 +08:00
LiuElric
4ea8c0802a Merge pull request #4986 from ConnectAI-E/hotfix/cf-ai-gateway
hotfix: using custom models, create custom provider
2024-07-12 20:31:22 +08:00
lloydzhou
9203870df5 hotfix: using custom models, create custom provider 2024-07-12 20:19:36 +08:00
Dogtiti
e8088d6e38 Merge pull request #4979 from ConnectAI-E/hotfix/cf-ai-gateway
support cloudflare ai gateway
2024-07-12 19:12:45 +08:00
LiuElric
59d9bcdd27 Merge pull request #4946 from billxc/fix_mac_large_icon
feat: update macOS icon to be consistent with design in public/macos.png
2024-07-12 19:01:43 +08:00
LiuElric
9d1b13ba73 Merge pull request #4933 from PeterDaveHello/locale-tw
Improve tw Traditional Chinese locale
2024-07-12 19:01:05 +08:00
licoy
dd1030139b fix: sd image preview modal size 2024-07-12 15:20:09 +08:00
Rubu Jam
30ca2117bb chore: add chinese template 2024-07-12 07:00:53 +00:00
Rubu Jam
89024a8dc8 chore: add PR template 2024-07-12 06:28:21 +00:00
lloydzhou
728c38396a support cloudflare ai gateway 2024-07-12 12:00:25 +08:00
licoy
d61cb98ac7 Merge remote-tracking branch 'origin/dev-sd' into dev-sd 2024-07-12 10:33:58 +08:00
licoy
a7ceb61e27 pref: remove console 2024-07-12 10:33:43 +08:00
licoy
74b915a790 fix: sd3 model default select 2024-07-12 10:32:06 +08:00
lloydzhou
01ea690421 remove code 2024-07-11 18:35:44 +08:00
lloydzhou
17cc9284a0 add config in readme 2024-07-11 15:35:36 +08:00
lloydzhou
498d0f0b8b merge main 2024-07-11 15:29:47 +08:00
Dogtiti
89049e1a22 Merge pull request #4974 from ConnectAI-E/hotfix/bytedance
hotfix: doubao display name
2024-07-11 14:57:21 +08:00
lloydzhou
5e7254e8dc hotfix: doubao display name 2024-07-11 14:46:12 +08:00
Dogtiti
f8c2732fdc Merge pull request #4971 from ConnectAI-E/hotfix/alibaba
change build messages for qwen in client
2024-07-11 10:25:39 +08:00
lloydzhou
fec36eb298 hotfix 2024-07-11 10:22:30 +08:00
lloydzhou
2299a4156d change build messages for qwen in client 2024-07-11 00:50:58 +08:00
lloydzhou
32b82b9cb3 change build messages for qwen in client 2024-07-11 00:48:58 +08:00
Lloyd Zhou
ba6039fc8b Merge pull request #4968 from ConnectAI-E/hotfix/google
hotfix Gemini finish twice. #4955 #4966
2024-07-10 20:19:29 +08:00
lloydzhou
6885812d21 hotfix Gemini finish twice. #4955 #4966 2024-07-10 18:59:44 +08:00
Lloyd Zhou
844025ec14 Merge pull request #4942 from ConnectAI-E/feature/alibaba
feat: qwen
2024-07-09 21:51:16 +08:00
Lloyd Zhou
94bc91c554 Merge pull request #4939 from ConnectAI-E/feature/ByteDance
Feature/byte dance
2024-07-09 21:48:22 +08:00
lloydzhou
044c16da4c update 2024-07-09 21:17:32 +08:00
lloydzhou
cd4784c54a update 2024-07-09 21:14:38 +08:00
lloydzhou
814aaa4a69 update config for alibaba(qwen) 2024-07-09 20:15:20 +08:00
lloydzhou
e3b3a4fefa add custom settings 2024-07-09 20:09:03 +08:00
lloydzhou
3fcbb3010d Merge branch 'feature/ByteDance' into feature/alibaba 2024-07-09 20:04:53 +08:00
lloydzhou
7573a19dc9 add custom settings 2024-07-09 20:01:58 +08:00
lloydzhou
3628d68d9a update 2024-07-09 19:56:52 +08:00
lloydzhou
23872086fa merge code 2024-07-09 19:37:47 +08:00
lloydzhou
bb349a03da fix get headers for bytedance 2024-07-09 19:21:27 +08:00
lloydzhou
82be426f78 fix eslint error 2024-07-09 18:19:34 +08:00
lloydzhou
9d2a633f5e 更新文档支持配置豆包的模型 2024-07-09 18:15:43 +08:00
lloydzhou
1149d45589 remove check vision model 2024-07-09 18:06:59 +08:00
lloydzhou
9d7e19cebf display doubao model name when select model 2024-07-09 18:05:23 +08:00
lloydzhou
b3023543d6 update 2024-07-09 16:55:33 +08:00
lloydzhou
c229d2c3ce merge main 2024-07-09 16:53:15 +08:00
Lloyd Zhou
47ea383ddd Merge pull request #4936 from ConnectAI-E/feat-baidu
feat: support baidu model
2024-07-09 16:45:46 +08:00
lloydzhou
f2a35f1114 add missing file 2024-07-09 16:38:22 +08:00
lloydzhou
147fc9a35a fix ts type error 2024-07-09 15:10:23 +08:00
lloydzhou
93a03f8fe4 Merge remote-tracking branch 'origin/main' into feat-baidu 2024-07-09 15:06:10 +08:00
lloydzhou
230e3823a9 update readme 2024-07-09 15:02:44 +08:00
lloydzhou
b14a0f24ae update locales 2024-07-09 14:57:19 +08:00
Lloyd Zhou
5295802720 Merge pull request #4953 from ConnectAI-E/hotfix/azure-deployment-notfound
hotfix: old AZURE_URL config error: "DeploymentNotFound". #4945 #4930
2024-07-09 14:52:26 +08:00
lloydzhou
fadd7f6eb4 try getAccessToken in app, fixbug to fetch in none stream mode 2024-07-09 14:50:40 +08:00
lloydzhou
011b76e4e7 review code 2024-07-09 13:39:39 +08:00
lloydzhou
f68cd2c5c0 review code 2024-07-09 12:27:44 +08:00
lloydzhou
6ac9789a1c hotfix 2024-07-09 12:16:37 +08:00
licoy
2b0153807c feat: Add Stability API server relay sending 2024-07-09 09:50:04 +08:00
lloydzhou
34ab37f31e update CUSTOM_MODELS config for Azure mode. 2024-07-09 00:47:35 +08:00
lloydzhou
71af2628eb hotfix: old AZURE_URL config error: "DeploymentNotFound". #4945 #4930 2024-07-09 00:32:18 +08:00
Xiaochen
15f028abfb update macOS icon to be consistent with design in public/macos.png 2024-07-08 14:15:51 +08:00
Dogtiti
9bdd37bb63 feat: qwen 2024-07-07 21:59:56 +08:00
Dogtiti
d726c71141 wip: tencent 2024-07-07 14:43:28 +08:00
Dogtiti
1caa61f4c0 feat: swap name and displayName for bytedance in custom models 2024-07-06 22:59:20 +08:00
Dogtiti
f3e3f08377 fix: apiClient 2024-07-06 21:25:00 +08:00
Dogtiti
2ec8b7a804 Merge branch 'main' of https://github.com/ConnectAI-E/ChatGPT-Next-Web into feature/ByteDance 2024-07-06 21:14:07 +08:00
Dogtiti
9f7d137b05 Merge branch 'main' of https://github.com/ConnectAI-E/ChatGPT-Next-Web into feat-baidu 2024-07-06 21:11:50 +08:00
Lloyd Zhou
7218f13783 Merge pull request #4934 from ConnectAI-E/feature/client-headers
feat: optimize getHeaders
2024-07-06 20:08:16 +08:00
Lloyd Zhou
fa31e7802c Merge pull request #4935 from ConnectAI-E/feature/get-client-api
feat: add getClientApi method
2024-07-06 20:06:59 +08:00
Dogtiti
9b3b4494ba wip: doubao 2024-07-06 14:59:37 +08:00
Dogtiti
785d3748e1 feat: support baidu model 2024-07-06 13:05:09 +08:00
Dogtiti
5e0657ce55 feat: add getClientApi method 2024-07-06 11:27:53 +08:00
Dogtiti
700b06f9c5 feat: optimize getHeaders 2024-07-06 11:16:00 +08:00
Dogtiti
b58bbf8eb4 feat: optimize getHeaders 2024-07-06 11:15:06 +08:00
Dogtiti
2d1f522aaf Merge pull request #4930 from ConnectAI-E/feature-azure
support azure deployment name
2024-07-06 10:50:33 +08:00
Peter Dave Hello
0b2863dfab Improve tw Traditional Chinese locale 2024-07-06 02:36:29 +08:00
Dogtiti
70907ead8a Merge pull request #4850 from ImBIOS/patch-2
chore(app/layout.tsx): fix deprecated viewport nextjs 14
2024-07-06 00:06:44 +08:00
lloydzhou
6dc4844c12 using default azure api-version value 2024-07-06 00:05:59 +08:00
Dogtiti
14bc1b6aac chore: optimize the code 2024-07-05 23:56:10 +08:00
lloydzhou
183ad2a34b add missing file 2024-07-05 20:57:55 +08:00
lloydzhou
d9758be3ae fix ts 2024-07-05 20:20:21 +08:00
lloydzhou
6b1b530443 remove makeAzurePath 2024-07-05 20:15:56 +08:00
lloydzhou
1c20137b0e support azure deployment name 2024-07-05 19:59:45 +08:00
Dogtiti
c4a6c933f8 Merge pull request #4923 from ConnectAI-E/refactor-model-table
Refactor model table
2024-07-04 21:04:43 +08:00
lloydzhou
31d9444264 hotfix 2024-07-04 19:38:26 +08:00
Lloyd Zhou
8cb204e22e refactor: get language (#4922)
* refactor: get language
2024-07-04 17:18:42 +08:00
lloydzhou
97aa72ec5b hotfix ts 2024-07-04 08:36:25 +00:00
lloydzhou
a68341eae6 include providerId in fullName 2024-07-04 16:11:37 +08:00
lloydzhou
aa08183439 hotfix 2024-07-04 16:03:35 +08:00
lloydzhou
7a5596b909 hotfix 2024-07-04 15:48:48 +08:00
lloydzhou
b9ffd50992 using <modelName>@<providerName> as fullName in modelTable 2024-07-04 15:44:36 +08:00
lloydzhou
14f2a8f370 merge model with modelName and providerName 2024-07-04 15:32:08 +08:00
lloydzhou
e7b16bfbc0 add function to check model is available 2024-07-04 15:30:24 +08:00
licoy
a16725ac17 feat: Improve SD list data and API integration 2024-07-03 15:37:34 +08:00
ji-jinlong
2803a91673 readme 添加 DEFAULT_MODEL 参数 (#4915)
* README_CN.md 添加 DEFAULT_MODEL 的说明

更改默认模型, 很久之前就有大佬支持了, 但更多人只会看readme, readme没有的就以为不支持(包括我).

https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/4545

* Update README.md

Change default model, it has been supported by experts long ago, but more people only read the readme. If it's not in the readme, they assume it's not supported (including me).

https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/4545

* Update README.md

ch to en

* en2cn

* 保持位置和readme.md一致
2024-07-03 15:18:24 +08:00
Lloyd Zhou
cf2fce7666 Merge pull request #4917 from ConnectAI-E/feature-make-mask-outsite
feat: using fetch to get buildin masks
2024-07-03 14:50:35 +08:00
lloydzhou
1609abd166 fix ts 2024-07-03 14:18:22 +08:00
lloydzhou
88c74ae18d feat: using fetch to get buildin masks 2024-07-03 14:09:55 +08:00
Lloyd Zhou
78e2b41e0c Merge pull request #4906 from ConnectAI-E/feature-gemini-streaming
gemini using real sse format response #3677 #3688
2024-07-03 10:58:27 +08:00
Lloyd Zhou
501f8b028b Merge pull request #4903 from ConnectAI-E/fix-claude-get-headers
Fix claude get headers
2024-07-03 10:57:49 +08:00
licoy
54401162bd fix: model version field name 2024-07-02 15:31:30 +08:00
licoy
7fde9327a2 feat: Improve the data input and submission acquisition of SD parameter panel 2024-07-02 15:19:44 +08:00
licoy
bbbf59c74a Improve the Stability parameter control panel 2024-07-02 10:24:19 +08:00
lloydzhou
c4ad66f745 remove console.log 2024-07-01 13:27:06 +00:00
lloydzhou
69974d5651 gemini using real sse format response #3677 #3688 2024-07-01 13:24:01 +00:00
Lloyd Zhou
ce3b6a04c2 Merge pull request #4782 from josephrocca/patch-1
Fix web url
2024-07-01 11:18:22 +00:00
lloydzhou
37e2517dac fix: 1. anthropic client using common getHeaders; 2. always using Authorization header send access code 2024-07-01 10:24:33 +00:00
lloydzhou
d65ddead11 fix: anthropic client using common getHeaders 2024-07-01 09:41:01 +00:00
licoy
34034be0e3 hide new chat button on sd page 2024-06-27 16:13:51 +08:00
licoy
d21481173e feat: add SD page switching 2024-06-27 16:06:15 +08:00
licoy
fa6ebadc7b feat: add plugin entry selection 2024-06-27 15:35:16 +08:00
licoy
a51fb24f36 fix ts error 2024-06-27 15:13:45 +08:00
fred-bf
c359b30763 Merge pull request #4891 from ChatGPTNextWeb/fred-bf-patch-4
fix: revert proxy url changes
2024-06-26 17:42:29 +08:00
fred-bf
95e3b156c0 Update Dockerfile 2024-06-26 17:36:14 +08:00
Fred
b972a0d081 feat: bump version 2024-06-24 14:45:45 +08:00
fred-bf
20749355da Merge pull request #4841 from ImBIOS/patch-1
fix someone forgot to update license year to 2024
2024-06-24 14:35:11 +08:00
fred-bf
dad122199a Merge pull request from GHSA-gph5-rx77-3pjg
fix: validate the url to avoid SSRF
2024-06-24 14:33:31 +08:00
Fred
9fb8fbcc65 fix: validate the url to avoid SSRF 2024-06-24 14:31:50 +08:00
fred-bf
78e7ea72dc Merge pull request #4869 from hengstchon/feat/models
feat: support model claude-3-5-sonnet-20240620
2024-06-24 14:20:35 +08:00
hengstchon
4640060891 feat: support model: claude-3-5-sonnet-20240620 2024-06-21 12:28:17 +02:00
Imamuzzaki Abu Salam
6efe4fb734 chore(app/layout.tsx): fix deprecated viewport nextjs 14 2024-06-16 10:17:58 +07:00
YeungYeah
74986803db feat: add google api safety setting 2024-06-15 12:09:58 +08:00
Imamuzzaki Abu Salam
9b0a705055 Update LICENSE 2024-06-14 09:19:38 +07:00
Imamuzzaki Abu Salam
163fc9e3a3 fix someone forgot to update license year to 2024 2024-06-14 08:45:06 +07:00
YeungYeah
24bf7950d8 chore: set the google safety setting to lowest 2024-06-12 21:59:28 +08:00
fred-bf
b6735bffe4 Merge pull request #4826 from junxian-li-hpc/fix-webdav
Add new Teracloud domain
2024-06-07 17:03:36 +08:00
junxian li-ssslab win10
1d8fd480ca Add new Teracloud domain
- Added 'bora.teracloud.jp' to the list of supported domains.
2024-06-07 03:28:00 +08:00
DeanYao
da2e2372aa Merge pull request #4771 from yangxiang92/main
fix: make env PROXY_URL avaliable in Docker container.
2024-05-27 16:16:18 +08:00
josephrocca
f3b972e573 Fix web url 2024-05-27 10:31:29 +08:00
xiang.yang
bf3bc3c7e9 fix: make env PROXY_URL avaliable in Docker container. 2024-05-24 17:49:25 +08:00
fred-bf
38664487a0 Merge pull request #4689 from ReeseWang/main
Dockerfile: Listen to any addresses instead of localhost, fixes #4682
2024-05-22 14:19:42 +08:00
DeanYao
de1111286c Merge pull request #4743 from ChatGPTNextWeb/revert-4710-chatGPT
Revert "Chat gpt"
2024-05-20 19:03:11 +08:00
DeanYao
d89a12aa05 Revert "Chat gpt" 2024-05-20 19:02:46 +08:00
DeanYao
754acd7c26 Merge pull request #4710 from Kivi1998/chatGPT
Chat gpt
2024-05-20 19:02:39 +08:00
DeanYao
c3e2f3b714 Merge pull request #4732 from zhz8951/main
update translation
2024-05-20 17:48:45 +08:00
zhz8951
22ef3d3a46 update translation 2024-05-19 09:57:37 +08:00
Kivi1998
7f3516f44f Merge branch 'main' into chatGPT 2024-05-16 15:14:43 +08:00
Hao Jia
bfdb47a7ed ChatGPT Logo 2024-05-16 15:03:14 +08:00
DeanYao
f55f04ab4f Merge pull request #4671 from ChatGPTNextWeb/chore-fix
Chore fix
2024-05-16 14:51:06 +08:00
Hao Jia
01c9dbc1fd Merge remote-tracking branch 'origin/main' into gpt-4o
# Conflicts:
#	public/apple-touch-icon.png
2024-05-16 14:43:10 +08:00
Dean-YZG
0aa807df19 feat: remove empty memoryPrompt in ChatMessages 2024-05-16 14:41:18 +08:00
fred-bf
48d44ece58 Merge branch 'main' into chore-fix 2024-05-16 14:13:28 +08:00
fred-bf
e58cb2b0db chore: wrap the node command flag 2024-05-16 14:11:27 +08:00
fred-bf
bffd9d9173 Merge pull request #4706 from leo4life2/patch-1
gpt-4o as vision model
2024-05-16 14:09:58 +08:00
Leo Li
8688842984 gpt-4o as vision model
https://platform.openai.com/docs/guides/vision
2024-05-15 17:53:27 -04:00
fred-bf
cf29a8f2c8 chore: custom node listen address by --host flag 2024-05-15 18:13:40 +08:00
fred-bf
1e00c89988 Merge pull request #4703 from ChatGPTNextWeb/feat/gemini-flash
feat: add gemini flash into vision model list
2024-05-15 15:44:45 +08:00
fred-bf
0eccb547b5 Merge branch 'main' into feat/gemini-flash 2024-05-15 15:44:35 +08:00
Fred
4789a7f6a9 feat: add gemini flash into vision model list 2024-05-15 15:42:06 +08:00
fred-bf
0bf758afd4 Merge pull request #4702 from ChatGPTNextWeb/feat/gemini-flash
feat: support gemini flash
2024-05-15 15:30:23 +08:00
Fred
6612550c06 feat: support gemini flash 2024-05-15 15:29:38 +08:00
Ruoxi Wang
d411159124 Dockerfile: Listen to any addresses instead of localhost, fixes #4682 2024-05-14 19:31:53 +08:00
fred-bf
cf635a5e6f Merge pull request #4684 from ChatGPTNextWeb/fred-bf-patch-4
feat: bump version
2024-05-14 17:36:06 +08:00
fred-bf
3a007e4f3d feat: bump version 2024-05-14 17:35:58 +08:00
fred-bf
9faab960f6 Merge pull request #4674 from leo4life2/main
support gpt-4o
2024-05-14 14:36:23 +08:00
fred-bf
5df8b1d183 fix: revert gpt-4-turbo-preview detection 2024-05-14 14:32:34 +08:00
Leo Li
ef5f910f19 support gpt-4o 2024-05-13 17:28:13 -04:00
Dean-YZG
fffbee80e8 Merge remote-tracking branch 'origin/main' into chore-fix 2024-05-13 17:58:28 +08:00
DeanYao
6b30e167e1 Merge pull request #4647 from ChatGPTNextWeb/dependabot/npm_and_yarn/next-14.1.1
chore(deps): bump next from 13.4.9 to 14.1.1
2024-05-13 17:15:08 +08:00
DeanYao
8ec721259a Merge pull request #4670 from DmitrySandalov/patch-1
Fix typo for "OpenAI Endpoint" in the en locale
2024-05-13 17:13:44 +08:00
Dean-YZG
9d7ce207b6 feat: support env var DEFAULT_INPUT_TEMPLATE to custom default template for preprocessing user inputs 2024-05-13 17:11:35 +08:00
Dean-YZG
2d1f0c9f57 feat: support env var DEFAULT_INPUT_TEMPLATE to custom default template for preprocessing user inputs 2024-05-13 17:11:11 +08:00
Dmitry Sandalov
d3131d2f55 Fix typo for "OpenAI Endpoint" in the en locale 2024-05-13 10:39:49 +02:00
Dean-YZG
c10447df79 feat: 1)upload image with type 'heic' 2)change the empty message to ';' for models 3) 2024-05-13 16:24:15 +08:00
DeanYao
212ae76d76 Merge pull request #4610 from rooben-me/fix-sync
Fix Sync Issue with Upstash
2024-05-13 11:28:29 +08:00
dependabot[bot]
cd48f7eff4 chore(deps): bump next from 13.4.9 to 14.1.1
Bumps [next](https://github.com/vercel/next.js) from 13.4.9 to 14.1.1.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v13.4.9...v14.1.1)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-10 00:27:02 +00:00
DeanYao
3513c6801e Merge pull request #4626 from ChatGPTNextWeb/chore-fix
feat: googleApiKey & anthropicApiKey support setting multi-key
2024-05-07 15:06:02 +08:00
Dean-YZG
864529cbf6 feat: googleApiKey & anthropicApiKey support setting multi-key 2024-05-06 21:14:53 +08:00
DeanYao
58c0d3e12d Merge pull request #4625 from ChatGPTNextWeb/chore-fix
feat: fix 1)the property named 'role' of the first message must be 'u…
2024-05-06 20:48:29 +08:00
Dean-YZG
a1493bfb4e feat: bugfix 2024-05-06 20:46:53 +08:00
butterfly
b3e856df1d feat: fix 1)the property named 'role' of the first message must be 'user' 2)if default summarize model 'gpt-3.5-turbo' is blocked, use currentModel instead 3)if apiurl&apikey set by location, useCustomConfig would be opened 2024-05-06 19:26:39 +08:00
ruban
8ef2617eec Removed spaces 2024-05-02 23:24:41 -07:00
ruban
1da7d81122 Fix cloud data sync issue with Upstash (#4563) 2024-05-02 23:22:32 -07:00
ruban
a103582346 fix 2024-05-02 23:10:10 -07:00
ruban
7b61d05e88 new fix 2024-05-02 23:08:17 -07:00
ruban
6fc7c50f19 this 2024-05-02 22:55:41 -07:00
ruban
9d728ec3c5 this is ti 2024-05-02 22:50:35 -07:00
ruban
9cd3358e4e this is the fix 2024-05-02 22:40:52 -07:00
ruban
4cd94370e8 fix i think 2024-05-03 05:25:11 +00:00
fred-bf
52312dbd23 Merge pull request #4595 from ChatGPTNextWeb/feat/bump-version
feat: bump version code
2024-04-30 13:28:30 +08:00
Fred
b2e8a1eaa2 feat: bump version code 2024-04-30 13:27:07 +08:00
DeanYao
506c17a093 Merge pull request #4564 from MrrDrr/gpt4v_remove_max_tokens
remove max_tokens from the official version of gpt4-turbo
2024-04-25 13:01:21 +08:00
DeanYao
69642fba52 Merge pull request #4557 from RoyRao2333/dev/no-fucos-outline
chore: No outline when element is in `:focus-visible` state
2024-04-25 12:58:19 +08:00
DeanYao
7d647c981f Merge pull request #4535 from RubuJam/main
Refer to OpenAI documentation to delete some models.
2024-04-25 11:44:01 +08:00
l.tingting
dd4648ed9a remove max_tokens from the official version of gpt4-turbo 2024-04-24 22:59:14 +08:00
Roy
1cd0beb231 chore: No outline when element is in :focus-visible state 2024-04-23 11:48:54 +08:00
黑云白土
b7aab3c102 Update google.ts 2024-04-17 17:16:31 +08:00
黑云白土
fcb1a657e3 Update constant.ts 2024-04-17 16:24:11 +08:00
Kivi1998
5bf402710f Merge branch 'main' into main 2024-03-21 11:56:09 +08:00
Hao Jia
2053db4cfc ChatGPT Logo 2024-03-19 15:34:44 +08:00
Hao Jia
754303e7c7 v0.0.0 2024-03-14 16:36:32 +08:00
268 changed files with 15473 additions and 12851 deletions

View File

@@ -1,21 +1,20 @@
# Your openai api key. (required)
OPENAI_API_KEY=sk-xxxx
# Access password, separated by comma. (optional)
CODE=your-password
# You can start service behind a proxy
# You can start service behind a proxy. (optional)
PROXY_URL=http://localhost:7890
# (optional)
# Default: Empty
# Googel Gemini Pro API key, set if you want to use Google Gemini Pro API.
# Google Gemini Pro API key, set if you want to use Google Gemini Pro API.
GOOGLE_API_KEY=
# (optional)
# Default: https://generativelanguage.googleapis.com/
# Googel Gemini Pro API url without pathname, set if you want to customize Google Gemini Pro API url.
# Google Gemini Pro API url without pathname, set if you want to customize Google Gemini Pro API url.
GOOGLE_URL=
# Override openai api request base url. (optional)
@@ -47,6 +46,15 @@ ENABLE_BALANCE_QUERY=
# If you want to disable parse settings from url, set this value to 1.
DISABLE_FAST_LINK=
# (optional)
# Default: Empty
# To control custom models, use + to add a custom model, use - to hide a model, use name=displayName to customize model name, separated by comma.
CUSTOM_MODELS=
# (optional)
# Default: Empty
# Change default model
DEFAULT_MODEL=
# anthropic claude Api Key.(optional)
ANTHROPIC_API_KEY=
@@ -54,8 +62,6 @@ ANTHROPIC_API_KEY=
### anthropic claude Api version. (optional)
ANTHROPIC_API_VERSION=
### anthropic claude Api url (optional)
ANTHROPIC_URL=

View File

@@ -1,12 +1,4 @@
{
"extends": "next/core-web-vitals",
"plugins": [
"prettier"
],
"parserOptions": {
"ecmaFeatures": {
"legacyDecorators": true
}
},
"ignorePatterns": ["globals.css"]
"plugins": ["prettier"]
}

80
.github/ISSUE_TEMPLATE/1_bug_report.yml vendored Normal file
View File

@@ -0,0 +1,80 @@
name: '🐛 Bug Report'
description: 'Report an bug'
title: '[Bug] '
labels: ['bug']
body:
- type: dropdown
attributes:
label: '📦 Deployment Method'
multiple: true
options:
- 'Official installation package'
- 'Vercel'
- 'Zeabur'
- 'Sealos'
- 'Netlify'
- 'Docker'
- 'Other'
validations:
required: true
- type: input
attributes:
label: '📌 Version'
validations:
required: true
- type: dropdown
attributes:
label: '💻 Operating System'
multiple: true
options:
- 'Windows'
- 'macOS'
- 'Ubuntu'
- 'Other Linux'
- 'iOS'
- 'iPad OS'
- 'Android'
- 'Other'
validations:
required: true
- type: input
attributes:
label: '📌 System Version'
validations:
required: true
- type: dropdown
attributes:
label: '🌐 Browser'
multiple: true
options:
- 'Chrome'
- 'Edge'
- 'Safari'
- 'Firefox'
- 'Other'
validations:
required: true
- type: input
attributes:
label: '📌 Browser Version'
validations:
required: true
- type: textarea
attributes:
label: '🐛 Bug Description'
description: A clear and concise description of the bug, if the above option is `Other`, please also explain in detail.
validations:
required: true
- type: textarea
attributes:
label: '📷 Recurrence Steps'
description: A clear and concise description of how to recurrence.
- type: textarea
attributes:
label: '🚦 Expected Behavior'
description: A clear and concise description of what you expected to happen.
- type: textarea
attributes:
label: '📝 Additional Information'
description: If your problem needs further explanation, or if the issue you're seeing cannot be reproduced in a gist, please add more information here.

View File

@@ -0,0 +1,80 @@
name: '🐛 反馈缺陷'
description: '反馈一个问题/缺陷'
title: '[Bug] '
labels: ['bug']
body:
- type: dropdown
attributes:
label: '📦 部署方式'
multiple: true
options:
- '官方安装包'
- 'Vercel'
- 'Zeabur'
- 'Sealos'
- 'Netlify'
- 'Docker'
- 'Other'
validations:
required: true
- type: input
attributes:
label: '📌 软件版本'
validations:
required: true
- type: dropdown
attributes:
label: '💻 系统环境'
multiple: true
options:
- 'Windows'
- 'macOS'
- 'Ubuntu'
- 'Other Linux'
- 'iOS'
- 'iPad OS'
- 'Android'
- 'Other'
validations:
required: true
- type: input
attributes:
label: '📌 系统版本'
validations:
required: true
- type: dropdown
attributes:
label: '🌐 浏览器'
multiple: true
options:
- 'Chrome'
- 'Edge'
- 'Safari'
- 'Firefox'
- 'Other'
validations:
required: true
- type: input
attributes:
label: '📌 浏览器版本'
validations:
required: true
- type: textarea
attributes:
label: '🐛 问题描述'
description: 请提供一个清晰且简洁的问题描述,若上述选项为`Other`,也请详细说明。
validations:
required: true
- type: textarea
attributes:
label: '📷 复现步骤'
description: 请提供一个清晰且简洁的描述,说明如何复现问题。
- type: textarea
attributes:
label: '🚦 期望结果'
description: 请提供一个清晰且简洁的描述,说明您期望发生什么。
- type: textarea
attributes:
label: '📝 补充信息'
description: 如果您的问题需要进一步说明,或者您遇到的问题无法在一个简单的示例中复现,请在这里添加更多信息。

View File

@@ -0,0 +1,21 @@
name: '🌠 Feature Request'
description: 'Suggest an idea'
title: '[Feature Request] '
labels: ['enhancement']
body:
- type: textarea
attributes:
label: '🥰 Feature Description'
description: Please add a clear and concise description of the problem you are seeking to solve with this feature request.
validations:
required: true
- type: textarea
attributes:
label: '🧐 Proposed Solution'
description: Describe the solution you'd like in a clear and concise manner.
validations:
required: true
- type: textarea
attributes:
label: '📝 Additional Information'
description: Add any other context about the problem here.

View File

@@ -0,0 +1,21 @@
name: '🌠 功能需求'
description: '提出需求或建议'
title: '[Feature Request] '
labels: ['enhancement']
body:
- type: textarea
attributes:
label: '🥰 需求描述'
description: 请添加一个清晰且简洁的问题描述,阐述您希望通过这个功能需求解决的问题。
validations:
required: true
- type: textarea
attributes:
label: '🧐 解决方案'
description: 请清晰且简洁地描述您想要的解决方案。
validations:
required: true
- type: textarea
attributes:
label: '📝 补充信息'
description: 在这里添加关于问题的任何其他背景信息。

View File

@@ -1,146 +0,0 @@
name: Bug report
description: Create a report to help us improve
title: "[Bug] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: "## Describe the bug"
- type: textarea
id: bug-description
attributes:
label: "Bug Description"
description: "A clear and concise description of what the bug is."
placeholder: "Explain the bug..."
validations:
required: true
- type: markdown
attributes:
value: "## To Reproduce"
- type: textarea
id: steps-to-reproduce
attributes:
label: "Steps to Reproduce"
description: "Steps to reproduce the behavior:"
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: markdown
attributes:
value: "## Expected behavior"
- type: textarea
id: expected-behavior
attributes:
label: "Expected Behavior"
description: "A clear and concise description of what you expected to happen."
placeholder: "Describe what you expected to happen..."
validations:
required: true
- type: markdown
attributes:
value: "## Screenshots"
- type: textarea
id: screenshots
attributes:
label: "Screenshots"
description: "If applicable, add screenshots to help explain your problem."
placeholder: "Paste your screenshots here or write 'N/A' if not applicable..."
validations:
required: false
- type: markdown
attributes:
value: "## Deployment"
- type: checkboxes
id: deployment
attributes:
label: "Deployment Method"
description: "Please select the deployment method you are using."
options:
- label: "Docker"
- label: "Vercel"
- label: "Server"
- type: markdown
attributes:
value: "## Desktop (please complete the following information):"
- type: input
id: desktop-os
attributes:
label: "Desktop OS"
description: "Your desktop operating system."
placeholder: "e.g., Windows 10"
validations:
required: false
- type: input
id: desktop-browser
attributes:
label: "Desktop Browser"
description: "Your desktop browser."
placeholder: "e.g., Chrome, Safari"
validations:
required: false
- type: input
id: desktop-version
attributes:
label: "Desktop Browser Version"
description: "Version of your desktop browser."
placeholder: "e.g., 89.0"
validations:
required: false
- type: markdown
attributes:
value: "## Smartphone (please complete the following information):"
- type: input
id: smartphone-device
attributes:
label: "Smartphone Device"
description: "Your smartphone device."
placeholder: "e.g., iPhone X"
validations:
required: false
- type: input
id: smartphone-os
attributes:
label: "Smartphone OS"
description: "Your smartphone operating system."
placeholder: "e.g., iOS 14.4"
validations:
required: false
- type: input
id: smartphone-browser
attributes:
label: "Smartphone Browser"
description: "Your smartphone browser."
placeholder: "e.g., Safari"
validations:
required: false
- type: input
id: smartphone-version
attributes:
label: "Smartphone Browser Version"
description: "Version of your smartphone browser."
placeholder: "e.g., 14"
validations:
required: false
- type: markdown
attributes:
value: "## Additional Logs"
- type: textarea
id: additional-logs
attributes:
label: "Additional Logs"
description: "Add any logs about the problem here."
placeholder: "Paste any relevant logs here..."
validations:
required: false

View File

@@ -1,53 +0,0 @@
name: Feature request
description: Suggest an idea for this project
title: "[Feature Request]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: "## Is your feature request related to a problem? Please describe."
- type: textarea
id: problem-description
attributes:
label: Problem Description
description: "A clear and concise description of what the problem is. Example: I'm always frustrated when [...]"
placeholder: "Explain the problem you are facing..."
validations:
required: true
- type: markdown
attributes:
value: "## Describe the solution you'd like"
- type: textarea
id: desired-solution
attributes:
label: Solution Description
description: A clear and concise description of what you want to happen.
placeholder: "Describe the solution you'd like..."
validations:
required: true
- type: markdown
attributes:
value: "## Describe alternatives you've considered"
- type: textarea
id: alternatives-considered
attributes:
label: Alternatives Considered
description: A clear and concise description of any alternative solutions or features you've considered.
placeholder: "Describe any alternative solutions or features you've considered..."
validations:
required: false
- type: markdown
attributes:
value: "## Additional context"
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context or screenshots about the feature request here.
placeholder: "Add any other context or screenshots about the feature request here..."
validations:
required: false

28
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,28 @@
#### 💻 变更类型 | Change Type
<!-- For change type, change [ ] to [x]. -->
- [ ] feat <!-- 引入新功能 | Introduce new features -->
- [ ] fix <!-- 修复 Bug | Fix a bug -->
- [ ] refactor <!-- 重构代码(既不修复 Bug 也不添加新功能) | Refactor code that neither fixes a bug nor adds a feature -->
- [ ] perf <!-- 提升性能的代码变更 | A code change that improves performance -->
- [ ] style <!-- 添加或更新不影响代码含义的样式文件 | Add or update style files that do not affect the meaning of the code -->
- [ ] test <!-- 添加缺失的测试或纠正现有的测试 | Adding missing tests or correcting existing tests -->
- [ ] docs <!-- 仅文档更新 | Documentation only changes -->
- [ ] ci <!-- 修改持续集成配置文件和脚本 | Changes to our CI configuration files and scripts -->
- [ ] chore <!-- 其他不修改 src 或 test 文件的变更 | Other changes that dont modify src or test files -->
- [ ] build <!-- 进行架构变更 | Make architectural changes -->
#### 🔀 变更说明 | Description of Change
<!--
感谢您的 Pull Request ,请提供此 Pull Request 的变更说明
Thank you for your Pull Request. Please provide a description above.
-->
#### 📝 补充信息 | Additional Information
<!--
请添加与此 Pull Request 相关的补充信息
Add any other context about the Pull Request here.
-->

4
.gitignore vendored
View File

@@ -43,4 +43,6 @@ dev
.env
*.key
*.key.pub
*.key.pub
masks.json

View File

@@ -43,7 +43,7 @@ COPY --from=builder /app/.next/server ./.next/server
EXPOSE 3000
CMD if [ -n "$PROXY_URL" ]; then \
export HOSTNAME="127.0.0.1"; \
export HOSTNAME="0.0.0.0"; \
protocol=$(echo $PROXY_URL | cut -d: -f1); \
host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \
port=$(echo $PROXY_URL | cut -d: -f3); \

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 Zhang Yifei
Copyright (c) 2023-2024 Zhang Yifei
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

141
README.md
View File

@@ -1,5 +1,8 @@
<div align="center">
<img src="./docs/images/head-cover.png" alt="icon"/>
<a href='#企业版'>
<img src="./docs/images/ent.svg" alt="icon"/>
</a>
<h1 align="center">NextChat (ChatGPT Next Web)</h1>
@@ -14,27 +17,49 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4
[![MacOS][MacOS-image]][download-url]
[![Linux][Linux-image]][download-url]
[Web App](https://app.nextchat.dev/) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Discord](https://discord.gg/YCkeafCafC) / [Twitter](https://twitter.com/NextChatDev)
[Web App](https://app.nextchat.dev/) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Discord](https://discord.gg/YCkeafCafC) / [Enterprise Edition](#enterprise-edition) / [Twitter](https://twitter.com/NextChatDev)
[网页版](https://app.nextchat.dev/) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues)
[网页版](https://app.nextchat.dev/) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [企业版](#%E4%BC%81%E4%B8%9A%E7%89%88) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues)
[web-url]: https://chatgpt.nextweb.fun
[web-url]: https://app.nextchat.dev/
[download-url]: https://github.com/Yidadaa/ChatGPT-Next-Web/releases
[Web-image]: https://img.shields.io/badge/Web-PWA-orange?logo=microsoftedge
[Windows-image]: https://img.shields.io/badge/-Windows-blue?logo=windows
[MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple
[Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu
[![Deploy with Vercel](https://vercel.com/button)](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)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/ZBUEFA)
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
![cover](./docs/images/cover.png)
[<img src="https://vercel.com/button" alt="Deploy on Zeabur" height="30">](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30">](https://zeabur.com/templates/ZBUEFA) [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Open in Gitpod" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
</div>
## Enterprise Edition
Meeting Your Company's Privatization and Customization Deployment Requirements:
- **Brand Customization**: Tailored VI/UI to seamlessly align with your corporate brand image.
- **Resource Integration**: Unified configuration and management of dozens of AI resources by company administrators, ready for use by team members.
- **Permission Control**: Clearly defined member permissions, resource permissions, and knowledge base permissions, all controlled via a corporate-grade Admin Panel.
- **Knowledge Integration**: Combining your internal knowledge base with AI capabilities, making it more relevant to your company's specific business needs compared to general AI.
- **Security Auditing**: Automatically intercept sensitive inquiries and trace all historical conversation records, ensuring AI adherence to corporate information security standards.
- **Private Deployment**: Enterprise-level private deployment supporting various mainstream private cloud solutions, ensuring data security and privacy protection.
- **Continuous Updates**: Ongoing updates and upgrades in cutting-edge capabilities like multimodal AI, ensuring consistent innovation and advancement.
For enterprise inquiries, please contact: **business@nextchat.dev**
## 企业版
满足企业用户私有化部署和个性化定制需求:
- **品牌定制**:企业量身定制 VI/UI与企业品牌形象无缝契合
- **资源集成**:由企业管理人员统一配置和管理数十种 AI 资源,团队成员开箱即用
- **权限管理**:成员权限、资源权限、知识库权限层级分明,企业级 Admin Panel 统一控制
- **知识接入**:企业内部知识库与 AI 能力相结合,比通用 AI 更贴近企业自身业务需求
- **安全审计**:自动拦截敏感提问,支持追溯全部历史对话记录,让 AI 也能遵循企业信息安全规范
- **私有部署**:企业级私有部署,支持各类主流私有云部署,确保数据安全和隐私保护
- **持续更新**:提供多模态、智能体等前沿能力持续更新升级服务,常用常新、持续先进
企业版咨询: **business@nextchat.dev**
<img width="300" src="https://github.com/user-attachments/assets/3daeb7b6-ab63-4542-9141-2e4a12c80601">
## Features
- **Deploy for free with one-click** on Vercel in under 1 minute
@@ -49,6 +74,12 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4
- Automatically compresses chat history to support long conversations while also saving your tokens
- I18n: English, 简体中文, 繁体中文, 日本語, Français, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어, Indonesia
<div align="center">
![主界面](./docs/images/cover.png)
</div>
## Roadmap
- [x] System Prompt: pin a user defined prompt as system prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)
@@ -57,10 +88,15 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4
- [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
- [x] Desktop App with tauri
- [x] Self-host Model: Fully compatible with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), as well as server deployment of [LocalAI](https://github.com/go-skynet/LocalAI): llama/gpt4all/rwkv/vicuna/koala/gpt4all-j/cerebras/falcon/dolly etc.
- [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
- [x] Artifacts: Easily preview, copy and share generated content/webpages through a separate window [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
- [x] Plugins: support artifacts, network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
- [x] artifacts
- [ ] network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
- [ ] local knowledge base
## What's New
- 🚀 v2.14.0 Now supports Artifacts & SD
- 🚀 v2.10.1 support Google Gemini Pro model.
- 🚀 v2.9.11 you can use azure endpoint now.
- 🚀 v2.8 now we have a client that runs across all platforms!
@@ -89,15 +125,21 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4
- [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
- [x] 使用 tauri 打包桌面应用
- [x] 支持自部署的大语言模型:开箱即用 [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) ,服务端部署 [LocalAI 项目](https://github.com/go-skynet/LocalAI) llama / gpt4all / rwkv / vicuna / koala / gpt4all-j / cerebras / falcon / dolly 等等,或者使用 [api-for-open-llm](https://github.com/xusenlinzy/api-for-open-llm)
- [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
- [x] Artifacts: 通过独立窗口,轻松预览、复制和分享生成的内容/可交互网页 [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
- [x] 插件机制,支持 artifacts联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
- [x] artifacts
- [ ] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
- [ ] 本地知识库
## 最新动态
- 🚀 v2.14.0 现在支持 Artifacts & SD 了。
- 🚀 v2.10.1 现在支持 Gemini Pro 模型。
- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
- 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
- 💡 想要更方便地随时随地使用本项目可以试下这款桌面插件https://github.com/mushan0x0/AI0x0.com
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
## Get Started
@@ -180,7 +222,7 @@ Specify OpenAI organization ID.
### `AZURE_URL` (optional)
> Example: https://{azure-resource-url}/openai/deployments/{deploy-name}
> Example: https://{azure-resource-url}/openai
Azure deploy url.
@@ -212,6 +254,46 @@ anthropic claude Api version.
anthropic claude Api Url.
### `BAIDU_API_KEY` (optional)
Baidu Api Key.
### `BAIDU_SECRET_KEY` (optional)
Baidu Secret Key.
### `BAIDU_URL` (optional)
Baidu Api Url.
### `BYTEDANCE_API_KEY` (optional)
ByteDance Api Key.
### `BYTEDANCE_URL` (optional)
ByteDance Api Url.
### `ALIBABA_API_KEY` (optional)
Alibaba Cloud Api Key.
### `ALIBABA_URL` (optional)
Alibaba Cloud Api Url.
### `IFLYTEK_URL` (Optional)
iflytek Api Url.
### `IFLYTEK_API_KEY` (Optional)
iflytek Api Key.
### `IFLYTEK_API_SECRET` (Optional)
iflytek Api Secret.
### `HIDE_USER_API_KEY` (optional)
> Default: Empty
@@ -245,13 +327,36 @@ To control custom models, use `+` to add a custom model, use `-` to hide a model
User `-all` to disable all default models, `+all` to enable all default models.
### `WHITE_WEBDEV_ENDPOINTS` (可选)
For Azure: use `modelName@azure=deploymentName` to customize model name and deployment name.
> Example: `+gpt-3.5-turbo@azure=gpt35` will show option `gpt35(Azure)` in model list.
> If you only can use Azure model, `-all,+gpt-3.5-turbo@azure=gpt35` will `gpt35(Azure)` the only option in model list.
For ByteDance: use `modelName@bytedance=deploymentName` to customize model name and deployment name.
> Example: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` will show option `Doubao-lite-4k(ByteDance)` in model list.
### `DEFAULT_MODEL` optional
Change default model
### `WHITE_WEBDEV_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
- Each address must be a complete endpoint
> `https://xxxx/yyy`
- Multiple addresses are connected by ', '
### `DEFAULT_INPUT_TEMPLATE` (optional)
Customize the default template used to initialize the User Input Preprocessing configuration item in Settings.
### `STABILITY_API_KEY` (optional)
Stability API key.
### `STABILITY_URL` (optional)
Customize Stability API url.
## Requirements
NodeJS >= 18, Docker >= 20

View File

@@ -1,22 +1,34 @@
<div align="center">
<img src="./docs/images/icon.svg" alt="预览"/>
<a href='#企业版'>
<img src="./docs/images/ent.svg" alt="icon"/>
</a>
<h1 align="center">NextChat</h1>
一键免费部署你的私人 ChatGPT 网页应用,支持 GPT3, GPT4 & Gemini Pro 模型。
[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N)
[企业版](#%E4%BC%81%E4%B8%9A%E7%89%88) /[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N)
[![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&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/ZBUEFA)
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
![主界面](./docs/images/cover.png)
[<img src="https://vercel.com/button" alt="Deploy on Zeabur" height="30">](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30">](https://zeabur.com/templates/ZBUEFA) [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Open in Gitpod" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
</div>
## 企业版
满足您公司私有化部署和定制需求
- **品牌定制**:企业量身定制 VI/UI与企业品牌形象无缝契合
- **资源集成**:由企业管理人员统一配置和管理数十种 AI 资源,团队成员开箱即用
- **权限管理**:成员权限、资源权限、知识库权限层级分明,企业级 Admin Panel 统一控制
- **知识接入**:企业内部知识库与 AI 能力相结合,比通用 AI 更贴近企业自身业务需求
- **安全审计**:自动拦截敏感提问,支持追溯全部历史对话记录,让 AI 也能遵循企业信息安全规范
- **私有部署**:企业级私有部署,支持各类主流私有云部署,确保数据安全和隐私保护
- **持续更新**:提供多模态、智能体等前沿能力持续更新升级服务,常用常新、持续先进
企业版咨询: **business@nextchat.dev**
<img width="300" src="https://github.com/user-attachments/assets/3daeb7b6-ab63-4542-9141-2e4a12c80601">
## 开始使用
1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys);
@@ -25,6 +37,12 @@
3. 部署完毕后,即可开始使用;
4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain)Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。
<div align="center">
![主界面](./docs/images/cover.png)
</div>
## 保持更新
如果你按照上述步骤一键部署了自己的项目,可能会发现总是提示“存在更新”的问题,这是由于 Vercel 会默认为你创建一个新项目而不是 fork 本项目,这会导致无法正确地检测更新。
@@ -94,7 +112,7 @@ OpenAI 接口代理 URL如果你手动配置了 openai 接口代理,请填
### `AZURE_URL` (可选)
> 形如https://{azure-resource-url}/openai/deployments/{deploy-name}
> 形如https://{azure-resource-url}/openai
Azure 部署地址。
@@ -106,26 +124,68 @@ Azure 密钥。
Azure Api 版本,你可以在这里找到:[Azure 文档](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions)。
### `GOOGLE_API_KEY` (optional)
### `GOOGLE_API_KEY` (可选)
Google Gemini Pro 密钥.
### `GOOGLE_URL` (optional)
### `GOOGLE_URL` (可选)
Google Gemini Pro Api Url.
### `ANTHROPIC_API_KEY` (optional)
### `ANTHROPIC_API_KEY` (可选)
anthropic claude Api Key.
### `ANTHROPIC_API_VERSION` (optional)
### `ANTHROPIC_API_VERSION` (可选)
anthropic claude Api version.
### `ANTHROPIC_URL` (optional)
### `ANTHROPIC_URL` (可选)
anthropic claude Api Url.
### `BAIDU_API_KEY` (可选)
Baidu Api Key.
### `BAIDU_SECRET_KEY` (可选)
Baidu Secret Key.
### `BAIDU_URL` (可选)
Baidu Api Url.
### `BYTEDANCE_API_KEY` (可选)
ByteDance Api Key.
### `BYTEDANCE_URL` (可选)
ByteDance Api Url.
### `ALIBABA_API_KEY` (可选)
阿里云千问Api Key.
### `ALIBABA_URL` (可选)
阿里云千问Api Url.
### `IFLYTEK_URL` (可选)
讯飞星火Api Url.
### `IFLYTEK_API_KEY` (可选)
讯飞星火Api Key.
### `IFLYTEK_API_SECRET` (可选)
讯飞星火Api Secret.
### `HIDE_USER_API_KEY` (可选)
如果你不想让用户自行填入 API Key将此环境变量设置为 1 即可。
@@ -156,6 +216,31 @@ anthropic claude Api Url.
用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。
在Azure的模式下支持使用`modelName@azure=deploymentName`的方式配置模型名称和部署名称(deploy-name)
> 示例:`+gpt-3.5-turbo@azure=gpt35`这个配置会在模型列表显示一个`gpt35(Azure)`的选项。
> 如果你只能使用Azure模式那么设置 `-all,+gpt-3.5-turbo@azure=gpt35` 则可以让对话的默认使用 `gpt35(Azure)`
在ByteDance的模式下支持使用`modelName@bytedance=deploymentName`的方式配置模型名称和部署名称(deploy-name)
> 示例: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx`这个配置会在模型列表显示一个`Doubao-lite-4k(ByteDance)`的选项
### `DEFAULT_MODEL` (可选)
更改默认模型
### `DEFAULT_INPUT_TEMPLATE` (可选)
自定义默认的 template用于初始化『设置』中的『用户输入预处理』配置项
### `STABILITY_API_KEY` (optional)
Stability API密钥
### `STABILITY_URL` (optional)
自定义的Stability API请求地址
## 开发
点击下方按钮,开始二次开发:

310
README_JA.md Normal file
View File

@@ -0,0 +1,310 @@
<div align="center">
<img src="./docs/images/ent.svg" alt="プレビュー"/>
<h1 align="center">NextChat</h1>
ワンクリックで無料であなた専用の ChatGPT ウェブアプリをデプロイ。GPT3、GPT4 & Gemini Pro モデルをサポート。
[企業版](#企業版) / [デモ](https://chat-gpt-next-web.vercel.app/) / [フィードバック](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Discordに参加](https://discord.gg/zrhvHCr79N)
[<img src="https://vercel.com/button" alt="Zeaburでデプロイ" height="30">](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [<img src="https://zeabur.com/button.svg" alt="Zeaburでデプロイ" height="30">](https://zeabur.com/templates/ZBUEFA) [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Gitpodで開く" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
</div>
## 企業版
あなたの会社のプライベートデプロイとカスタマイズのニーズに応える
- **ブランドカスタマイズ**:企業向けに特別に設計された VI/UI、企業ブランドイメージとシームレスにマッチ
- **リソース統合**企業管理者が数十種類のAIリソースを統一管理、チームメンバーはすぐに使用可能
- **権限管理**メンバーの権限、リソースの権限、ナレッジベースの権限を明確にし、企業レベルのAdmin Panelで統一管理
- **知識の統合**企業内部のナレッジベースとAI機能を結びつけ、汎用AIよりも企業自身の業務ニーズに近づける
- **セキュリティ監査**機密質問を自動的にブロックし、すべての履歴対話を追跡可能にし、AIも企業の情報セキュリティ基準に従わせる
- **プライベートデプロイ**:企業レベルのプライベートデプロイ、主要なプライベートクラウドデプロイをサポートし、データのセキュリティとプライバシーを保護
- **継続的な更新**:マルチモーダル、エージェントなどの最先端機能を継続的に更新し、常に最新であり続ける
企業版のお問い合わせ: **business@nextchat.dev**
## 始めに
1. [OpenAI API Key](https://platform.openai.com/account/api-keys)を準備する;
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を入力してください;
3. デプロイが完了したら、すぐに使用を開始できます;
4. (オプション)[カスタムドメインをバインド](https://vercel.com/docs/concepts/projects/domains/add-a-domain)Vercelが割り当てたドメインDNSは一部の地域で汚染されているため、カスタムドメインをバインドすると直接接続できます。
<div align="center">
![メインインターフェース](./docs/images/cover.png)
</div>
## 更新を維持する
もし上記の手順に従ってワンクリックでプロジェクトをデプロイした場合、「更新があります」というメッセージが常に表示されることがあります。これは、Vercel がデフォルトで新しいプロジェクトを作成するためで、本プロジェクトを fork していないことが原因です。そのため、正しく更新を検出できません。
以下の手順に従って再デプロイすることをお勧めします:
- 元のリポジトリを削除する
- ページ右上の fork ボタンを使って、本プロジェクトを fork する
- Vercel で再度選択してデプロイする、[詳細な手順はこちらを参照してください](./docs/vercel-ja.md)。
### 自動更新を開く
> Upstream Sync の実行エラーが発生した場合は、手動で Sync Fork してください!
プロジェクトを fork した後、GitHub の制限により、fork 後のプロジェクトの Actions ページで Workflows を手動で有効にし、Upstream Sync Action を有効にする必要があります。有効化後、毎時の定期自動更新が可能になります:
![自動更新](./docs/images/enable-actions.jpg)
![自動更新を有効にする](./docs/images/enable-actions-sync.jpg)
### 手動でコードを更新する
手動で即座に更新したい場合は、[GitHub のドキュメント](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork)を参照して、fork したプロジェクトを上流のコードと同期する方法を確認してください。
このプロジェクトをスターまたはウォッチしたり、作者をフォローすることで、新機能の更新通知をすぐに受け取ることができます。
## ページアクセスパスワードを設定する
> パスワードを設定すると、ユーザーは設定ページでアクセスコードを手動で入力しない限り、通常のチャットができず、未承認の状態であることを示すメッセージが表示されます。
> **警告**パスワードの桁数は十分に長く設定してください。7桁以上が望ましいです。さもないと、[ブルートフォース攻撃を受ける可能性があります](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)。
このプロジェクトは限られた権限管理機能を提供しています。Vercel プロジェクトのコントロールパネルで、環境変数ページに `CODE` という名前の環境変数を追加し、値をカンマで区切ったカスタムパスワードに設定してください:
```
code1,code2,code3
```
この環境変数を追加または変更した後、**プロジェクトを再デプロイ**して変更を有効にしてください。
## 環境変数
> 本プロジェクトのほとんどの設定は環境変数で行います。チュートリアル:[Vercel の環境変数を変更する方法](./docs/vercel-ja.md)。
### `OPENAI_API_KEY` (必須)
OpenAI の API キー。OpenAI アカウントページで申請したキーをカンマで区切って複数設定できます。これにより、ランダムにキーが選択されます。
### `CODE` (オプション)
アクセスパスワード。カンマで区切って複数設定可能。
**警告**:この項目を設定しないと、誰でもデプロイしたウェブサイトを利用でき、トークンが急速に消耗する可能性があるため、設定をお勧めします。
### `BASE_URL` (オプション)
> デフォルト: `https://api.openai.com`
> 例: `http://your-openai-proxy.com`
OpenAI API のプロキシ URL。手動で OpenAI API のプロキシを設定している場合はこのオプションを設定してください。
> SSL 証明書の問題がある場合は、`BASE_URL` のプロトコルを http に設定してください。
### `OPENAI_ORG_ID` (オプション)
OpenAI の組織 ID を指定します。
### `AZURE_URL` (オプション)
> 形式: https://{azure-resource-url}/openai/deployments/{deploy-name}
> `CUSTOM_MODELS` で `displayName` 形式で {deploy-name} を設定した場合、`AZURE_URL` から {deploy-name} を省略できます。
Azure のデプロイ URL。
### `AZURE_API_KEY` (オプション)
Azure の API キー。
### `AZURE_API_VERSION` (オプション)
Azure API バージョン。[Azure ドキュメント](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions)で確認できます。
### `GOOGLE_API_KEY` (オプション)
Google Gemini Pro API キー。
### `GOOGLE_URL` (オプション)
Google Gemini Pro API の URL。
### `ANTHROPIC_API_KEY` (オプション)
Anthropic Claude API キー。
### `ANTHROPIC_API_VERSION` (オプション)
Anthropic Claude API バージョン。
### `ANTHROPIC_URL` (オプション)
Anthropic Claude API の URL。
### `BAIDU_API_KEY` (オプション)
Baidu API キー。
### `BAIDU_SECRET_KEY` (オプション)
Baidu シークレットキー。
### `BAIDU_URL` (オプション)
Baidu API の URL。
### `BYTEDANCE_API_KEY` (オプション)
ByteDance API キー。
### `BYTEDANCE_URL` (オプション)
ByteDance API の URL。
### `ALIBABA_API_KEY` (オプション)
アリババ千问API キー。
### `ALIBABA_URL` (オプション)
アリババ千问API の URL。
### `HIDE_USER_API_KEY` (オプション)
ユーザーが API キーを入力できないようにしたい場合は、この環境変数を 1 に設定します。
### `DISABLE_GPT4` (オプション)
ユーザーが GPT-4 を使用できないようにしたい場合は、この環境変数を 1 に設定します。
### `ENABLE_BALANCE_QUERY` (オプション)
バランスクエリ機能を有効にしたい場合は、この環境変数を 1 に設定します。
### `DISABLE_FAST_LINK` (オプション)
リンクからのプリセット設定解析を無効にしたい場合は、この環境変数を 1 に設定します。
### `WHITE_WEBDEV_ENDPOINTS` (オプション)
アクセス許可を与える WebDAV サービスのアドレスを追加したい場合、このオプションを使用します。フォーマット要件:
- 各アドレスは完全なエンドポイントでなければなりません。
> `https://xxxx/xxx`
- 複数のアドレスは `,` で接続します。
### `CUSTOM_MODELS` (オプション)
> 例:`+qwen-7b-chat,+glm-6b,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` は `qwen-7b-chat` と `glm-6b` をモデルリストに追加し、`gpt-3.5-turbo` を削除し、`gpt-4-1106-preview` のモデル名を `gpt-4-turbo` として表示します。
> すべてのモデルを無効にし、特定のモデルを有効にしたい場合は、`-all,+gpt-3.5-turbo` を使用します。これは `gpt-3.5-turbo` のみを有効にすることを意味します。
モデルリストを管理します。`+` でモデルを追加し、`-` でモデルを非表示にし、`モデル名=表示名` でモデルの表示名をカスタマイズし、カンマで区切ります。
Azure モードでは、`modelName@azure=deploymentName` 形式でモデル名とデプロイ名deploy-nameを設定できます。
> 例:`+gpt-3.5-turbo@azure=gpt35` この設定でモデルリストに `gpt35(Azure)` のオプションが表示されます。
ByteDance モードでは、`modelName@bytedance=deploymentName` 形式でモデル名とデプロイ名deploy-nameを設定できます。
> 例: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` この設定でモデルリストに `Doubao-lite-4k(ByteDance)` のオプションが表示されます。
### `DEFAULT_MODEL` (オプション)
デフォルトのモデルを変更します。
### `DEFAULT_INPUT_TEMPLATE` (オプション)
『設定』の『ユーザー入力前処理』の初期設定に使用するテンプレートをカスタマイズします。
## 開発
下のボタンをクリックして二次開発を開始してください:
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
コードを書く前に、プロジェクトのルートディレクトリに `.env.local` ファイルを新規作成し、環境変数を記入します:
```
OPENAI_API_KEY=<your api key here>
```
### ローカル開発
1. Node.js 18 と Yarn をインストールします。具体的な方法は ChatGPT にお尋ねください。
2. `yarn install && yarn dev` を実行します。⚠️ 注意:このコマンドはローカル開発用であり、デプロイには使用しないでください。
3. ローカルでデプロイしたい場合は、`yarn install && yarn build && yarn start` コマンドを使用してください。プロセスを守るために pm2 を使用することもできます。詳細は ChatGPT にお尋ねください。
## デプロイ
### コンテナデプロイ(推奨)
> Docker バージョンは 20 以上が必要です。それ以下だとイメージが見つからないというエラーが出ます。
> ⚠️ 注意Docker バージョンは最新バージョンより 12 日遅れることが多いため、デプロイ後に「更新があります」の通知が出続けることがありますが、正常です。
```shell
docker pull yidadaa/chatgpt-next-web
docker run -d -p 3000:3000 \
-e OPENAI_API_KEY=sk-xxxx \
-e CODE=ページアクセスパスワード \
yidadaa/chatgpt-next-web
```
プロキシを指定することもできます:
```shell
docker run -d -p 3000:3000 \
-e OPENAI_API_KEY=sk-xxxx \
-e CODE=ページアクセスパスワード \
--net=host \
-e PROXY_URL=http://127.0.0.1:7890 \
yidadaa/chatgpt-next-web
```
ローカルプロキシがアカウントとパスワードを必要とする場合は、以下を使用できます:
```shell
-e PROXY_URL="http://127.0.0.1:7890 user password"
```
他の環境変数を指定する必要がある場合は、上記のコマンドに `-e 環境変数=環境変数値` を追加して指定してください。
### ローカルデプロイ
コンソールで以下のコマンドを実行します:
```shell
bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh)
```
⚠️ 注意インストール中に問題が発生した場合は、Docker を使用してデプロイしてください。
## 謝辞
### 寄付者
> 英語版をご覧ください。
### 貢献者
[プロジェクトの貢献者リストはこちら](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
### 関連プロジェクト
- [one-api](https://github.com/songquanpeng/one-api): 一つのプラットフォームで大規模モデルのクォータ管理を提供し、市場に出回っているすべての主要な大規模言語モデルをサポートします。
## オープンソースライセンス
[MIT](https://opensource.org/license/mit/)

View File

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

131
app/api/alibaba.ts Normal file
View File

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

View File

@@ -4,16 +4,18 @@ import {
Anthropic,
ApiPath,
DEFAULT_MODELS,
ServiceProvider,
ModelProvider,
} from "@/app/constant";
import { prettyObject } from "@/app/utils/format";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "../../auth";
import { collectModelTable } from "@/app/utils/model";
import { auth } from "./auth";
import { isModelAvailableInServer } from "@/app/utils/model";
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
async function handle(
export async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
@@ -54,30 +56,6 @@ async function handle(
}
}
export const GET = handle;
export const POST = handle;
export const runtime = "edge";
export const preferredRegion = [
"arn1",
"bom1",
"cdg1",
"cle1",
"cpt1",
"dub1",
"fra1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"lhr1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];
const serverConfig = getServerSideConfig();
async function request(req: NextRequest) {
@@ -113,7 +91,8 @@ async function request(req: NextRequest) {
10 * 60 * 1000,
);
const fetchUrl = `${baseUrl}${path}`;
// try rebuild url, when using cloudflare ai gateway in server
const fetchUrl = cloudflareAIGatewayUrl(`${baseUrl}${path}`);
const fetchOptions: RequestInit = {
headers: {
@@ -136,17 +115,19 @@ async function request(req: NextRequest) {
// #1815 try to refuse some request to some models
if (serverConfig.customModels && req.body) {
try {
const modelTable = collectModelTable(
DEFAULT_MODELS,
serverConfig.customModels,
);
const clonedBody = await req.text();
fetchOptions.body = clonedBody;
const jsonBody = JSON.parse(clonedBody) as { model?: string };
// not undefined and is false
if (modelTable[jsonBody?.model ?? ""].available === false) {
if (
isModelAvailableInServer(
serverConfig.customModels,
jsonBody?.model as string,
ServiceProvider.Anthropic as string,
)
) {
return NextResponse.json(
{
error: true,
@@ -161,17 +142,17 @@ async function request(req: NextRequest) {
console.error(`[Anthropic] filter`, e);
}
}
console.log("[Anthropic request]", fetchOptions.headers, req.method);
// console.log("[Anthropic request]", fetchOptions.headers, req.method);
try {
const res = await fetch(fetchUrl, fetchOptions);
console.log(
"[Anthropic response]",
res.status,
" ",
res.headers,
res.url,
);
// console.log(
// "[Anthropic response]",
// res.status,
// " ",
// res.headers,
// res.url,
// );
// to prevent browser prompt for credentials
const newHeaders = new Headers(res.headers);
newHeaders.delete("www-authenticate");

View File

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

View File

@@ -67,15 +67,34 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
let systemApiKey: string | undefined;
switch (modelProvider) {
case ModelProvider.Stability:
systemApiKey = serverConfig.stabilityApiKey;
break;
case ModelProvider.GeminiPro:
systemApiKey = serverConfig.googleApiKey;
break;
case ModelProvider.Claude:
systemApiKey = serverConfig.anthropicApiKey;
break;
case ModelProvider.Doubao:
systemApiKey = serverConfig.bytedanceApiKey;
break;
case ModelProvider.Ernie:
systemApiKey = serverConfig.baiduApiKey;
break;
case ModelProvider.Qwen:
systemApiKey = serverConfig.alibabaApiKey;
break;
case ModelProvider.Moonshot:
systemApiKey = serverConfig.moonshotApiKey;
break;
case ModelProvider.Iflytek:
systemApiKey =
serverConfig.iflytekApiKey + ":" + serverConfig.iflytekApiSecret;
break;
case ModelProvider.GPT:
default:
if (serverConfig.isAzure) {
if (req.nextUrl.pathname.includes("azure/deployments")) {
systemApiKey = serverConfig.azureApiKey;
} else {
systemApiKey = serverConfig.apiKey;

33
app/api/azure.ts Normal file
View File

@@ -0,0 +1,33 @@
import { getServerSideConfig } from "@/app/config/server";
import { ModelProvider } from "@/app/constant";
import { prettyObject } from "@/app/utils/format";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "./auth";
import { requestOpenai } from "./common";
export async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
console.log("[Azure Route] params ", params);
if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const subpath = params.path.join("/");
const authResult = auth(req, ModelProvider.GPT);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
try {
return await requestOpenai(req);
} catch (e) {
console.error("[Azure] ", e);
return NextResponse.json(prettyObject(e));
}
}

145
app/api/baidu.ts Normal file
View File

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

129
app/api/bytedance.ts Normal file
View File

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

View File

@@ -1,17 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSideConfig } from "../config/server";
import { DEFAULT_MODELS, OPENAI_BASE_URL, GEMINI_BASE_URL } from "../constant";
import { collectModelTable } from "../utils/model";
import { makeAzurePath } from "../azure";
import {
DEFAULT_MODELS,
OPENAI_BASE_URL,
GEMINI_BASE_URL,
ServiceProvider,
} from "../constant";
import { isModelAvailableInServer } from "../utils/model";
import { cloudflareAIGatewayUrl } from "../utils/cloudflare";
const serverConfig = getServerSideConfig();
export async function requestOpenai(req: NextRequest) {
const controller = new AbortController();
const isAzure = req.nextUrl.pathname.includes("azure/deployments");
var authValue,
authHeaderName = "";
if (serverConfig.isAzure) {
if (isAzure) {
authValue =
req.headers
.get("Authorization")
@@ -31,7 +38,7 @@ export async function requestOpenai(req: NextRequest) {
);
let baseUrl =
serverConfig.azureUrl || serverConfig.baseUrl || OPENAI_BASE_URL;
(isAzure ? serverConfig.azureUrl : serverConfig.baseUrl) || OPENAI_BASE_URL;
if (!baseUrl.startsWith("http")) {
baseUrl = `https://${baseUrl}`;
@@ -51,17 +58,46 @@ export async function requestOpenai(req: NextRequest) {
10 * 60 * 1000,
);
if (serverConfig.isAzure) {
if (!serverConfig.azureApiVersion) {
return NextResponse.json({
error: true,
message: `missing AZURE_API_VERSION in server env vars`,
});
if (isAzure) {
const azureApiVersion =
req?.nextUrl?.searchParams?.get("api-version") ||
serverConfig.azureApiVersion;
baseUrl = baseUrl.split("/deployments").shift() as string;
path = `${req.nextUrl.pathname.replaceAll(
"/api/azure/",
"",
)}?api-version=${azureApiVersion}`;
// Forward compatibility:
// if display_name(deployment_name) not set, and '{deploy-id}' in AZURE_URL
// then using default '{deploy-id}'
if (serverConfig.customModels && serverConfig.azureUrl) {
const modelName = path.split("/")[1];
let realDeployName = "";
serverConfig.customModels
.split(",")
.filter((v) => !!v && !v.startsWith("-") && v.includes(modelName))
.forEach((m) => {
const [fullName, displayName] = m.split("=");
const [_, providerName] = fullName.split("@");
if (providerName === "azure" && !displayName) {
const [_, deployId] = (serverConfig?.azureUrl ?? "").split(
"deployments/",
);
if (deployId) {
realDeployName = deployId;
}
}
});
if (realDeployName) {
console.log("[Replace with DeployId", realDeployName);
path = path.replaceAll(modelName, realDeployName);
}
}
path = makeAzurePath(path, serverConfig.azureApiVersion);
}
const fetchUrl = `${baseUrl}/${path}`;
const fetchUrl = cloudflareAIGatewayUrl(`${baseUrl}/${path}`);
console.log("fetchUrl", fetchUrl);
const fetchOptions: RequestInit = {
headers: {
"Content-Type": "application/json",
@@ -83,17 +119,24 @@ export async function requestOpenai(req: NextRequest) {
// #1815 try to refuse gpt4 request
if (serverConfig.customModels && req.body) {
try {
const modelTable = collectModelTable(
DEFAULT_MODELS,
serverConfig.customModels,
);
const clonedBody = await req.text();
fetchOptions.body = clonedBody;
const jsonBody = JSON.parse(clonedBody) as { model?: string };
// not undefined and is false
if (modelTable[jsonBody?.model ?? ""].available === false) {
if (
isModelAvailableInServer(
serverConfig.customModels,
jsonBody?.model as string,
ServiceProvider.OpenAI as string,
) ||
isModelAvailableInServer(
serverConfig.customModels,
jsonBody?.model as string,
ServiceProvider.Azure as string,
)
) {
return NextResponse.json(
{
error: true,
@@ -112,16 +155,16 @@ export async function requestOpenai(req: NextRequest) {
try {
const res = await fetch(fetchUrl, fetchOptions);
// Extract the OpenAI-Organization header from the response
const openaiOrganizationHeader = res.headers.get("OpenAI-Organization");
// Extract the OpenAI-Organization header from the response
const openaiOrganizationHeader = res.headers.get("OpenAI-Organization");
// Check if serverConfig.openaiOrgId is defined and not an empty string
if (serverConfig.openaiOrgId && serverConfig.openaiOrgId.trim() !== "") {
// If openaiOrganizationHeader is present, log it; otherwise, log that the header is not present
console.log("[Org ID]", openaiOrganizationHeader);
} else {
console.log("[Org ID] is not set up.");
}
// Check if serverConfig.openaiOrgId is defined and not an empty string
if (serverConfig.openaiOrgId && serverConfig.openaiOrgId.trim() !== "") {
// If openaiOrganizationHeader is present, log it; otherwise, log that the header is not present
console.log("[Org ID]", openaiOrganizationHeader);
} else {
console.log("[Org ID] is not set up.");
}
// to prevent browser prompt for credentials
const newHeaders = new Headers(res.headers);
@@ -129,7 +172,6 @@ export async function requestOpenai(req: NextRequest) {
// to disable nginx buffering
newHeaders.set("X-Accel-Buffering", "no");
// Conditionally delete the OpenAI-Organization header from the response if [Org ID] is undefined or empty (not setup in ENV)
// Also, this is to prevent the header from being sent to the client
if (!serverConfig.openaiOrgId || serverConfig.openaiOrgId.trim() === "") {
@@ -142,7 +184,6 @@ export async function requestOpenai(req: NextRequest) {
// The browser will try to decode the response with brotli and fail
newHeaders.delete("content-encoding");
return new Response(res.body, {
status: res.status,
statusText: res.statusText,

View File

@@ -1,11 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "../../auth";
import { auth } from "./auth";
import { getServerSideConfig } from "@/app/config/server";
import { GEMINI_BASE_URL, Google, ModelProvider } from "@/app/constant";
import {
ApiPath,
GEMINI_BASE_URL,
Google,
ModelProvider,
} from "@/app/constant";
import { prettyObject } from "@/app/utils/format";
async function handle(
const serverConfig = getServerSideConfig();
export async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
{ params }: { params: { provider: string; path: string[] } },
) {
console.log("[Google Route] params ", params);
@@ -13,32 +21,6 @@ async function handle(
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const controller = new AbortController();
const serverConfig = getServerSideConfig();
let baseUrl = serverConfig.googleUrl || GEMINI_BASE_URL;
if (!baseUrl.startsWith("http")) {
baseUrl = `https://${baseUrl}`;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, -1);
}
let path = `${req.nextUrl.pathname}`.replaceAll("/api/google/", "");
console.log("[Proxy] ", path);
console.log("[Base Url]", baseUrl);
const timeoutId = setTimeout(
() => {
controller.abort();
},
10 * 60 * 1000,
);
const authResult = auth(req, ModelProvider.GeminiPro);
if (authResult.error) {
return NextResponse.json(authResult, {
@@ -49,9 +31,9 @@ async function handle(
const bearToken = req.headers.get("Authorization") ?? "";
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
const key = token ? token : serverConfig.googleApiKey;
const apiKey = token ? token : serverConfig.googleApiKey;
if (!key) {
if (!apiKey) {
return NextResponse.json(
{
error: true,
@@ -62,8 +44,63 @@ async function handle(
},
);
}
try {
const response = await request(req, apiKey);
return response;
} catch (e) {
console.error("[Google] ", e);
return NextResponse.json(prettyObject(e));
}
}
const fetchUrl = `${baseUrl}/${path}?key=${key}`;
export const GET = handle;
export const POST = handle;
export const runtime = "edge";
export const preferredRegion = [
"bom1",
"cle1",
"cpt1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];
async function request(req: NextRequest, apiKey: string) {
const controller = new AbortController();
let baseUrl = serverConfig.googleUrl || GEMINI_BASE_URL;
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Google, "");
if (!baseUrl.startsWith("http")) {
baseUrl = `https://${baseUrl}`;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, -1);
}
console.log("[Proxy] ", path);
console.log("[Base Url]", baseUrl);
const timeoutId = setTimeout(
() => {
controller.abort();
},
10 * 60 * 1000,
);
const fetchUrl = `${baseUrl}${path}?key=${apiKey}${
req?.nextUrl?.searchParams?.get("alt") === "sse" ? "&alt=sse" : ""
}`;
console.log("[Fetch Url] ", fetchUrl);
const fetchOptions: RequestInit = {
headers: {
"Content-Type": "application/json",
@@ -95,22 +132,3 @@ async function handle(
clearTimeout(timeoutId);
}
}
export const GET = handle;
export const POST = handle;
export const runtime = "edge";
export const preferredRegion = [
"bom1",
"cle1",
"cpt1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];

131
app/api/iflytek.ts Normal file
View File

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

130
app/api/moonshot.ts Normal file
View File

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

View File

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

99
app/api/stability.ts Normal file
View File

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

124
app/api/tencent/route.ts Normal file
View File

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

View File

@@ -1,14 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { STORAGE_KEY, internalWhiteWebDavEndpoints } from "../../../constant";
import { STORAGE_KEY, internalAllowedWebDavEndpoints } from "../../../constant";
import { getServerSideConfig } from "@/app/config/server";
const config = getServerSideConfig();
const mergedWhiteWebDavEndpoints = [
...internalWhiteWebDavEndpoints,
...config.whiteWebDevEndpoints,
const mergedAllowedWebDavEndpoints = [
...internalAllowedWebDavEndpoints,
...config.allowedWebDevEndpoints,
].filter((domain) => Boolean(domain.trim()));
const normalizeUrl = (url: string) => {
try {
return new URL(url);
} catch (err) {
return null;
}
};
async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
@@ -21,10 +29,23 @@ async function handle(
const requestUrl = new URL(req.url);
let endpoint = requestUrl.searchParams.get("endpoint");
let proxy_method = requestUrl.searchParams.get("proxy_method") || req.method;
// Validate the endpoint to prevent potential SSRF attacks
if (
!mergedWhiteWebDavEndpoints.some((white) => endpoint?.startsWith(white))
!endpoint ||
!mergedAllowedWebDavEndpoints.some((allowedEndpoint) => {
const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint);
const normalizedEndpoint = normalizeUrl(endpoint as string);
return (
normalizedEndpoint &&
normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname &&
normalizedEndpoint.pathname.startsWith(
normalizedAllowedEndpoint.pathname,
)
);
})
) {
return NextResponse.json(
{
@@ -45,7 +66,11 @@ async function handle(
const targetPath = `${endpoint}${endpointPath}`;
// only allow MKCOL, GET, PUT
if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") {
if (
proxy_method !== "MKCOL" &&
proxy_method !== "GET" &&
proxy_method !== "PUT"
) {
return NextResponse.json(
{
error: true,
@@ -58,7 +83,7 @@ async function handle(
}
// for MKCOL request, only allow request ${folder}
if (req.method === "MKCOL" && !targetPath.endsWith(folder)) {
if (proxy_method === "MKCOL" && !targetPath.endsWith(folder)) {
return NextResponse.json(
{
error: true,
@@ -71,7 +96,7 @@ async function handle(
}
// for GET request, only allow request ending with fileName
if (req.method === "GET" && !targetPath.endsWith(fileName)) {
if (proxy_method === "GET" && !targetPath.endsWith(fileName)) {
return NextResponse.json(
{
error: true,
@@ -84,7 +109,7 @@ async function handle(
}
// for PUT request, only allow request ending with fileName
if (req.method === "PUT" && !targetPath.endsWith(fileName)) {
if (proxy_method === "PUT" && !targetPath.endsWith(fileName)) {
return NextResponse.json(
{
error: true,
@@ -98,7 +123,7 @@ async function handle(
const targetUrl = targetPath;
const method = req.method;
const method = proxy_method || req.method;
const shouldNotHaveBody = ["get", "head"].includes(
method?.toLowerCase() ?? "",
);
@@ -123,7 +148,7 @@ async function handle(
"[Any Proxy]",
targetUrl,
{
method: req.method,
method: method,
},
{
status: fetchResult?.status,

View File

@@ -1,9 +0,0 @@
export function makeAzurePath(path: string, apiVersion: string) {
// should omit /v1 prefix
path = path.replaceAll("v1/", "");
// should add api-key to query string
path += `${path.includes("?") ? "&" : "?"}api-version=${apiVersion}`;
return path;
}

View File

@@ -6,9 +6,16 @@ import {
ServiceProvider,
} from "../constant";
import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store";
import { ChatGPTApi } from "./platforms/openai";
import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai";
import { GeminiProApi } from "./platforms/google";
import { ClaudeApi } from "./platforms/anthropic";
import { ErnieApi } from "./platforms/baidu";
import { DoubaoApi } from "./platforms/bytedance";
import { QwenApi } from "./platforms/alibaba";
import { HunyuanApi } from "./platforms/tencent";
import { MoonshotApi } from "./platforms/moonshot";
import { SparkApi } from "./platforms/iflytek";
export const ROLES = ["system", "user", "assistant"] as const;
export type MessageRole = (typeof ROLES)[number];
@@ -30,11 +37,15 @@ export interface RequestMessage {
export interface LLMConfig {
model: string;
providerName?: string;
temperature?: number;
top_p?: number;
stream?: boolean;
presence_penalty?: number;
frequency_penalty?: number;
size?: DalleRequestPayload["size"];
quality?: DalleRequestPayload["quality"];
style?: DalleRequestPayload["style"];
}
export interface ChatOptions {
@@ -54,14 +65,17 @@ export interface LLMUsage {
export interface LLMModel {
name: string;
displayName?: string;
available: boolean;
provider: LLMModelProvider;
sorted: number;
}
export interface LLMModelProvider {
id: string;
providerName: string;
providerType: string;
sorted: number;
}
export abstract class LLMApi {
@@ -102,6 +116,24 @@ export class ClientApi {
case ModelProvider.Claude:
this.llm = new ClaudeApi();
break;
case ModelProvider.Ernie:
this.llm = new ErnieApi();
break;
case ModelProvider.Doubao:
this.llm = new DoubaoApi();
break;
case ModelProvider.Qwen:
this.llm = new QwenApi();
break;
case ModelProvider.Hunyuan:
this.llm = new HunyuanApi();
break;
case ModelProvider.Moonshot:
this.llm = new MoonshotApi();
break;
case ModelProvider.Iflytek:
this.llm = new SparkApi();
break;
default:
this.llm = new ChatGPTApi();
}
@@ -153,39 +185,122 @@ export class ClientApi {
}
}
export function getBearerToken(
apiKey: string,
noBearer: boolean = false,
): string {
return validString(apiKey)
? `${noBearer ? "" : "Bearer "}${apiKey.trim()}`
: "";
}
export function validString(x: string): boolean {
return x?.length > 0;
}
export function getHeaders() {
const accessStore = useAccessStore.getState();
const chatStore = useChatStore.getState();
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};
const modelConfig = useChatStore.getState().currentSession().mask.modelConfig;
const isGoogle = modelConfig.model.startsWith("gemini");
const isAzure = accessStore.provider === ServiceProvider.Azure;
const authHeader = isAzure ? "api-key" : "Authorization";
const apiKey = isGoogle
? accessStore.googleApiKey
: isAzure
? accessStore.azureApiKey
: accessStore.openaiApiKey;
const clientConfig = getClientConfig();
const makeBearer = (s: string) => `${isAzure ? "" : "Bearer "}${s.trim()}`;
const validString = (x: string) => x && x.length > 0;
const clientConfig = getClientConfig();
function getConfig() {
const modelConfig = chatStore.currentSession().mask.modelConfig;
const isGoogle = modelConfig.providerName == ServiceProvider.Google;
const isAzure = modelConfig.providerName === ServiceProvider.Azure;
const isAnthropic = modelConfig.providerName === ServiceProvider.Anthropic;
const isBaidu = modelConfig.providerName == ServiceProvider.Baidu;
const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance;
const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba;
const isMoonshot = modelConfig.providerName === ServiceProvider.Moonshot;
const isIflytek = modelConfig.providerName === ServiceProvider.Iflytek;
const isEnabledAccessControl = accessStore.enabledAccessControl();
const apiKey = isGoogle
? accessStore.googleApiKey
: isAzure
? accessStore.azureApiKey
: isAnthropic
? accessStore.anthropicApiKey
: isByteDance
? accessStore.bytedanceApiKey
: isAlibaba
? accessStore.alibabaApiKey
: isMoonshot
? accessStore.moonshotApiKey
: isIflytek
? accessStore.iflytekApiKey && accessStore.iflytekApiSecret
? accessStore.iflytekApiKey + ":" + accessStore.iflytekApiSecret
: ""
: accessStore.openaiApiKey;
return {
isGoogle,
isAzure,
isAnthropic,
isBaidu,
isByteDance,
isAlibaba,
isMoonshot,
isIflytek,
apiKey,
isEnabledAccessControl,
};
}
function getAuthHeader(): string {
return isAzure ? "api-key" : isAnthropic ? "x-api-key" : "Authorization";
}
const {
isGoogle,
isAzure,
isAnthropic,
isBaidu,
apiKey,
isEnabledAccessControl,
} = getConfig();
// when using google api in app, not set auth header
if (!(isGoogle && clientConfig?.isApp)) {
// use user's api key first
if (validString(apiKey)) {
headers[authHeader] = makeBearer(apiKey);
} else if (
accessStore.enabledAccessControl() &&
validString(accessStore.accessCode)
) {
headers[authHeader] = makeBearer(
ACCESS_CODE_PREFIX + accessStore.accessCode,
);
}
if (isGoogle && clientConfig?.isApp) return headers;
// when using baidu api in app, not set auth header
if (isBaidu && clientConfig?.isApp) return headers;
const authHeader = getAuthHeader();
const bearerToken = getBearerToken(apiKey, isAzure || isAnthropic);
if (bearerToken) {
headers[authHeader] = bearerToken;
} else if (isEnabledAccessControl && validString(accessStore.accessCode)) {
headers["Authorization"] = getBearerToken(
ACCESS_CODE_PREFIX + accessStore.accessCode,
);
}
return headers;
}
export function getClientApi(provider: ServiceProvider): ClientApi {
switch (provider) {
case ServiceProvider.Google:
return new ClientApi(ModelProvider.GeminiPro);
case ServiceProvider.Anthropic:
return new ClientApi(ModelProvider.Claude);
case ServiceProvider.Baidu:
return new ClientApi(ModelProvider.Ernie);
case ServiceProvider.ByteDance:
return new ClientApi(ModelProvider.Doubao);
case ServiceProvider.Alibaba:
return new ClientApi(ModelProvider.Qwen);
case ServiceProvider.Tencent:
return new ClientApi(ModelProvider.Hunyuan);
case ServiceProvider.Moonshot:
return new ClientApi(ModelProvider.Moonshot);
case ServiceProvider.Iflytek:
return new ClientApi(ModelProvider.Iflytek);
default:
return new ClientApi(ModelProvider.GPT);
}
}

View File

@@ -0,0 +1,268 @@
"use client";
import {
ApiPath,
Alibaba,
ALIBABA_BASE_URL,
REQUEST_TIMEOUT_MS,
} from "@/app/constant";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
import {
ChatOptions,
getHeaders,
LLMApi,
LLMModel,
MultimodalContent,
} from "../api";
import Locale from "../../locales";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import { prettyObject } from "@/app/utils/format";
import { getClientConfig } from "@/app/config/client";
import { getMessageTextContent } from "@/app/utils";
export interface OpenAIListModelResponse {
object: string;
data: Array<{
id: string;
object: string;
root: string;
}>;
}
interface RequestInput {
messages: {
role: "system" | "user" | "assistant";
content: string | MultimodalContent[];
}[];
}
interface RequestParam {
result_format: string;
incremental_output?: boolean;
temperature: number;
repetition_penalty?: number;
top_p: number;
max_tokens?: number;
}
interface RequestPayload {
model: string;
input: RequestInput;
parameters: RequestParam;
}
export class QwenApi implements LLMApi {
path(path: string): string {
const accessStore = useAccessStore.getState();
let baseUrl = "";
if (accessStore.useCustomConfig) {
baseUrl = accessStore.alibabaUrl;
}
if (baseUrl.length === 0) {
const isApp = !!getClientConfig()?.isApp;
baseUrl = isApp ? ALIBABA_BASE_URL : ApiPath.Alibaba;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
}
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Alibaba)) {
baseUrl = "https://" + baseUrl;
}
console.log("[Proxy Endpoint] ", baseUrl, path);
return [baseUrl, path].join("/");
}
extractMessage(res: any) {
return res?.output?.choices?.at(0)?.message?.content ?? "";
}
async chat(options: ChatOptions) {
const messages = options.messages.map((v) => ({
role: v.role,
content: getMessageTextContent(v),
}));
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
...{
model: options.config.model,
},
};
const shouldStream = !!options.config.stream;
const requestPayload: RequestPayload = {
model: modelConfig.model,
input: {
messages,
},
parameters: {
result_format: "message",
incremental_output: shouldStream,
temperature: modelConfig.temperature,
// max_tokens: modelConfig.max_tokens,
top_p: modelConfig.top_p === 1 ? 0.99 : modelConfig.top_p, // qwen top_p is should be < 1
},
};
const controller = new AbortController();
options.onController?.(controller);
try {
const chatPath = this.path(Alibaba.ChatPath);
const chatPayload = {
method: "POST",
body: JSON.stringify(requestPayload),
signal: controller.signal,
headers: {
...getHeaders(),
"X-DashScope-SSE": shouldStream ? "enable" : "disable",
},
};
// make a fetch request
const requestTimeoutId = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
if (shouldStream) {
let responseText = "";
let remainText = "";
let finished = false;
// animate response to make it looks smooth
function animateResponseText() {
if (finished || controller.signal.aborted) {
responseText += remainText;
console.log("[Response Animation] finished");
if (responseText?.length === 0) {
options.onError?.(new Error("empty response from server"));
}
return;
}
if (remainText.length > 0) {
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
const fetchText = remainText.slice(0, fetchCount);
responseText += fetchText;
remainText = remainText.slice(fetchCount);
options.onUpdate?.(responseText, fetchText);
}
requestAnimationFrame(animateResponseText);
}
// start animaion
animateResponseText();
const finish = () => {
if (!finished) {
finished = true;
options.onFinish(responseText + remainText);
}
};
controller.signal.onabort = finish;
fetchEventSource(chatPath, {
...chatPayload,
async onopen(res) {
clearTimeout(requestTimeoutId);
const contentType = res.headers.get("content-type");
console.log(
"[Alibaba] request response content type: ",
contentType,
);
if (contentType?.startsWith("text/plain")) {
responseText = await res.clone().text();
return finish();
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [responseText];
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
if (extraInfo) {
responseTexts.push(extraInfo);
}
responseText = responseTexts.join("\n\n");
return finish();
}
},
onmessage(msg) {
if (msg.data === "[DONE]" || finished) {
return finish();
}
const text = msg.data;
try {
const json = JSON.parse(text);
const choices = json.output.choices as Array<{
message: { content: string };
}>;
const delta = choices[0]?.message?.content;
if (delta) {
remainText += delta;
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
finish();
},
onerror(e) {
options.onError?.(e);
throw e;
},
openWhenHidden: true,
});
} else {
const res = await fetch(chatPath, chatPayload);
clearTimeout(requestTimeoutId);
const resJson = await res.json();
const message = this.extractMessage(resJson);
options.onFinish(message);
}
} catch (e) {
console.log("[Request] failed to make a chat request", e);
options.onError?.(e as Error);
}
}
async usage() {
return {
used: 0,
total: 0,
};
}
async models(): Promise<LLMModel[]> {
return [];
}
}
export { Alibaba };

View File

@@ -1,9 +1,8 @@
import { ACCESS_CODE_PREFIX, Anthropic, ApiPath } from "@/app/constant";
import { ChatOptions, LLMApi, MultimodalContent } from "../api";
import { ChatOptions, getHeaders, LLMApi, MultimodalContent } from "../api";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
import { getClientConfig } from "@/app/config/client";
import { DEFAULT_API_HOST } from "@/app/constant";
import { RequestMessage } from "@/app/typing";
import {
EventStreamContentType,
fetchEventSource,
@@ -12,6 +11,8 @@ import {
import Locale from "../../locales";
import { prettyObject } from "@/app/utils/format";
import { getMessageTextContent, isVisionModel } from "@/app/utils";
import { preProcessImageContent } from "@/app/utils/chat";
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
export type MultiBlockContent = {
type: "image" | "text";
@@ -92,7 +93,12 @@ export class ClaudeApi implements LLMApi {
},
};
const messages = [...options.messages];
// try get base64image from local cache image_url
const messages: ChatOptions["messages"] = [];
for (const v of options.messages) {
const content = await preProcessImageContent(v.content);
messages.push({ role: v.role, content });
}
const keys = ["system", "user"];
@@ -161,6 +167,13 @@ export class ClaudeApi implements LLMApi {
};
});
if (prompt[0]?.role === "assistant") {
prompt.unshift({
role: "user",
content: ";",
});
}
const requestBody: AnthropicChatRequest = {
messages: prompt,
stream: shouldStream,
@@ -183,11 +196,10 @@ export class ClaudeApi implements LLMApi {
body: JSON.stringify(requestBody),
signal: controller.signal,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"x-api-key": accessStore.anthropicApiKey,
...getHeaders(), // get common headers
"anthropic-version": accessStore.anthropicApiVersion,
Authorization: getAuthKey(accessStore.anthropicApiKey),
// do not send `anthropicApiKey` in browser!!!
// Authorization: getAuthKey(accessStore.anthropicApiKey),
},
};
@@ -369,7 +381,8 @@ export class ClaudeApi implements LLMApi {
baseUrl = trimEnd(baseUrl, "/");
return `${baseUrl}/${path}`;
// try rebuild url, when using cloudflare ai gateway in client
return cloudflareAIGatewayUrl(`${baseUrl}/${path}`);
}
}
@@ -382,27 +395,3 @@ function trimEnd(s: string, end = " ") {
return s;
}
function bearer(value: string) {
return `Bearer ${value.trim()}`;
}
function getAuthKey(apiKey = "") {
const accessStore = useAccessStore.getState();
const isApp = !!getClientConfig()?.isApp;
let authKey = "";
if (apiKey) {
// use user's api key first
authKey = bearer(apiKey);
} else if (
accessStore.enabledAccessControl() &&
!isApp &&
!!accessStore.accessCode
) {
// or use access code
authKey = bearer(ACCESS_CODE_PREFIX + accessStore.accessCode);
}
return authKey;
}

View File

@@ -0,0 +1,281 @@
"use client";
import {
ApiPath,
Baidu,
BAIDU_BASE_URL,
REQUEST_TIMEOUT_MS,
} from "@/app/constant";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
import { getAccessToken } from "@/app/utils/baidu";
import {
ChatOptions,
getHeaders,
LLMApi,
LLMModel,
MultimodalContent,
} from "../api";
import Locale from "../../locales";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import { prettyObject } from "@/app/utils/format";
import { getClientConfig } from "@/app/config/client";
import { getMessageTextContent } from "@/app/utils";
export interface OpenAIListModelResponse {
object: string;
data: Array<{
id: string;
object: string;
root: string;
}>;
}
interface RequestPayload {
messages: {
role: "system" | "user" | "assistant";
content: string | MultimodalContent[];
}[];
stream?: boolean;
model: string;
temperature: number;
presence_penalty: number;
frequency_penalty: number;
top_p: number;
max_tokens?: number;
}
export class ErnieApi implements LLMApi {
path(path: string): string {
const accessStore = useAccessStore.getState();
let baseUrl = "";
if (accessStore.useCustomConfig) {
baseUrl = accessStore.baiduUrl;
}
if (baseUrl.length === 0) {
const isApp = !!getClientConfig()?.isApp;
// do not use proxy for baidubce api
baseUrl = isApp ? BAIDU_BASE_URL : ApiPath.Baidu;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
}
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Baidu)) {
baseUrl = "https://" + baseUrl;
}
console.log("[Proxy Endpoint] ", baseUrl, path);
return [baseUrl, path].join("/");
}
async chat(options: ChatOptions) {
const messages = options.messages.map((v) => ({
// "error_code": 336006, "error_msg": "the role of message with even index in the messages must be user or function",
role: v.role === "system" ? "user" : v.role,
content: getMessageTextContent(v),
}));
// "error_code": 336006, "error_msg": "the length of messages must be an odd number",
if (messages.length % 2 === 0) {
if (messages.at(0)?.role === "user") {
messages.splice(1, 0, {
role: "assistant",
content: " ",
});
} else {
messages.unshift({
role: "user",
content: " ",
});
}
}
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
...{
model: options.config.model,
},
};
const shouldStream = !!options.config.stream;
const requestPayload: RequestPayload = {
messages,
stream: shouldStream,
model: modelConfig.model,
temperature: modelConfig.temperature,
presence_penalty: modelConfig.presence_penalty,
frequency_penalty: modelConfig.frequency_penalty,
top_p: modelConfig.top_p,
};
console.log("[Request] Baidu payload: ", requestPayload);
const controller = new AbortController();
options.onController?.(controller);
try {
let chatPath = this.path(Baidu.ChatPath(modelConfig.model));
// getAccessToken can not run in browser, because cors error
if (!!getClientConfig()?.isApp) {
const accessStore = useAccessStore.getState();
if (accessStore.useCustomConfig) {
if (accessStore.isValidBaidu()) {
const { access_token } = await getAccessToken(
accessStore.baiduApiKey,
accessStore.baiduSecretKey,
);
chatPath = `${chatPath}${
chatPath.includes("?") ? "&" : "?"
}access_token=${access_token}`;
}
}
}
const chatPayload = {
method: "POST",
body: JSON.stringify(requestPayload),
signal: controller.signal,
headers: getHeaders(),
};
// make a fetch request
const requestTimeoutId = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
if (shouldStream) {
let responseText = "";
let remainText = "";
let finished = false;
// animate response to make it looks smooth
function animateResponseText() {
if (finished || controller.signal.aborted) {
responseText += remainText;
console.log("[Response Animation] finished");
if (responseText?.length === 0) {
options.onError?.(new Error("empty response from server"));
}
return;
}
if (remainText.length > 0) {
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
const fetchText = remainText.slice(0, fetchCount);
responseText += fetchText;
remainText = remainText.slice(fetchCount);
options.onUpdate?.(responseText, fetchText);
}
requestAnimationFrame(animateResponseText);
}
// start animaion
animateResponseText();
const finish = () => {
if (!finished) {
finished = true;
options.onFinish(responseText + remainText);
}
};
controller.signal.onabort = finish;
fetchEventSource(chatPath, {
...chatPayload,
async onopen(res) {
clearTimeout(requestTimeoutId);
const contentType = res.headers.get("content-type");
console.log("[Baidu] request response content type: ", contentType);
if (contentType?.startsWith("text/plain")) {
responseText = await res.clone().text();
return finish();
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [responseText];
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
if (extraInfo) {
responseTexts.push(extraInfo);
}
responseText = responseTexts.join("\n\n");
return finish();
}
},
onmessage(msg) {
if (msg.data === "[DONE]" || finished) {
return finish();
}
const text = msg.data;
try {
const json = JSON.parse(text);
const delta = json?.result;
if (delta) {
remainText += delta;
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
finish();
},
onerror(e) {
options.onError?.(e);
throw e;
},
openWhenHidden: true,
});
} else {
const res = await fetch(chatPath, chatPayload);
clearTimeout(requestTimeoutId);
const resJson = await res.json();
const message = resJson?.result;
options.onFinish(message);
}
} catch (e) {
console.log("[Request] failed to make a chat request", e);
options.onError?.(e as Error);
}
}
async usage() {
return {
used: 0,
total: 0,
};
}
async models(): Promise<LLMModel[]> {
return [];
}
}
export { Baidu };

View File

@@ -0,0 +1,255 @@
"use client";
import {
ApiPath,
ByteDance,
BYTEDANCE_BASE_URL,
REQUEST_TIMEOUT_MS,
} from "@/app/constant";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
import {
ChatOptions,
getHeaders,
LLMApi,
LLMModel,
MultimodalContent,
} from "../api";
import Locale from "../../locales";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import { prettyObject } from "@/app/utils/format";
import { getClientConfig } from "@/app/config/client";
import { getMessageTextContent } from "@/app/utils";
export interface OpenAIListModelResponse {
object: string;
data: Array<{
id: string;
object: string;
root: string;
}>;
}
interface RequestPayload {
messages: {
role: "system" | "user" | "assistant";
content: string | MultimodalContent[];
}[];
stream?: boolean;
model: string;
temperature: number;
presence_penalty: number;
frequency_penalty: number;
top_p: number;
max_tokens?: number;
}
export class DoubaoApi implements LLMApi {
path(path: string): string {
const accessStore = useAccessStore.getState();
let baseUrl = "";
if (accessStore.useCustomConfig) {
baseUrl = accessStore.bytedanceUrl;
}
if (baseUrl.length === 0) {
const isApp = !!getClientConfig()?.isApp;
baseUrl = isApp ? BYTEDANCE_BASE_URL : ApiPath.ByteDance;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
}
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.ByteDance)) {
baseUrl = "https://" + baseUrl;
}
console.log("[Proxy Endpoint] ", baseUrl, path);
return [baseUrl, path].join("/");
}
extractMessage(res: any) {
return res.choices?.at(0)?.message?.content ?? "";
}
async chat(options: ChatOptions) {
const messages = options.messages.map((v) => ({
role: v.role,
content: getMessageTextContent(v),
}));
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
...{
model: options.config.model,
},
};
const shouldStream = !!options.config.stream;
const requestPayload: RequestPayload = {
messages,
stream: shouldStream,
model: modelConfig.model,
temperature: modelConfig.temperature,
presence_penalty: modelConfig.presence_penalty,
frequency_penalty: modelConfig.frequency_penalty,
top_p: modelConfig.top_p,
};
const controller = new AbortController();
options.onController?.(controller);
try {
const chatPath = this.path(ByteDance.ChatPath);
const chatPayload = {
method: "POST",
body: JSON.stringify(requestPayload),
signal: controller.signal,
headers: getHeaders(),
};
// make a fetch request
const requestTimeoutId = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
if (shouldStream) {
let responseText = "";
let remainText = "";
let finished = false;
// animate response to make it looks smooth
function animateResponseText() {
if (finished || controller.signal.aborted) {
responseText += remainText;
console.log("[Response Animation] finished");
if (responseText?.length === 0) {
options.onError?.(new Error("empty response from server"));
}
return;
}
if (remainText.length > 0) {
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
const fetchText = remainText.slice(0, fetchCount);
responseText += fetchText;
remainText = remainText.slice(fetchCount);
options.onUpdate?.(responseText, fetchText);
}
requestAnimationFrame(animateResponseText);
}
// start animaion
animateResponseText();
const finish = () => {
if (!finished) {
finished = true;
options.onFinish(responseText + remainText);
}
};
controller.signal.onabort = finish;
fetchEventSource(chatPath, {
...chatPayload,
async onopen(res) {
clearTimeout(requestTimeoutId);
const contentType = res.headers.get("content-type");
console.log(
"[ByteDance] request response content type: ",
contentType,
);
if (contentType?.startsWith("text/plain")) {
responseText = await res.clone().text();
return finish();
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [responseText];
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
if (extraInfo) {
responseTexts.push(extraInfo);
}
responseText = responseTexts.join("\n\n");
return finish();
}
},
onmessage(msg) {
if (msg.data === "[DONE]" || finished) {
return finish();
}
const text = msg.data;
try {
const json = JSON.parse(text);
const choices = json.choices as Array<{
delta: { content: string };
}>;
const delta = choices[0]?.delta?.content;
if (delta) {
remainText += delta;
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
finish();
},
onerror(e) {
options.onError?.(e);
throw e;
},
openWhenHidden: true,
});
} else {
const res = await fetch(chatPath, chatPayload);
clearTimeout(requestTimeoutId);
const resJson = await res.json();
const message = this.extractMessage(resJson);
options.onFinish(message);
}
} catch (e) {
console.log("[Request] failed to make a chat request", e);
options.onError?.(e as Error);
}
}
async usage() {
return {
used: 0,
total: 0,
};
}
async models(): Promise<LLMModel[]> {
return [];
}
}
export { ByteDance };

View File

@@ -1,15 +1,52 @@
import { Google, REQUEST_TIMEOUT_MS } from "@/app/constant";
import { ApiPath, Google, REQUEST_TIMEOUT_MS } from "@/app/constant";
import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
import { getClientConfig } from "@/app/config/client";
import { DEFAULT_API_HOST } from "@/app/constant";
import Locale from "../../locales";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import { prettyObject } from "@/app/utils/format";
import {
getMessageTextContent,
getMessageImages,
isVisionModel,
} from "@/app/utils";
import { preProcessImageContent } from "@/app/utils/chat";
export class GeminiProApi implements LLMApi {
path(path: string): string {
const accessStore = useAccessStore.getState();
let baseUrl = "";
if (accessStore.useCustomConfig) {
baseUrl = accessStore.googleUrl;
}
const isApp = !!getClientConfig()?.isApp;
if (baseUrl.length === 0) {
baseUrl = isApp ? DEFAULT_API_HOST + `/api/proxy/google` : ApiPath.Google;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
}
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Google)) {
baseUrl = "https://" + baseUrl;
}
console.log("[Proxy Endpoint] ", baseUrl, path);
let chatPath = [baseUrl, path].join("/");
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
// if chatPath.startsWith('http') then add key in query string
if (chatPath.startsWith("http") && accessStore.googleApiKey) {
chatPath += `&key=${accessStore.googleApiKey}`;
}
return chatPath;
}
extractMessage(res: any) {
console.log("[Response] gemini-pro response: ", res);
@@ -20,12 +57,18 @@ export class GeminiProApi implements LLMApi {
);
}
async chat(options: ChatOptions): Promise<void> {
// const apiClient = this;
const visionModel = isVisionModel(options.config.model);
const apiClient = this;
let multimodal = false;
const messages = options.messages.map((v) => {
// try get base64image from local cache image_url
const _messages: ChatOptions["messages"] = [];
for (const v of options.messages) {
const content = await preProcessImageContent(v.content);
_messages.push({ role: v.role, content });
}
const messages = _messages.map((v) => {
let parts: any[] = [{ text: getMessageTextContent(v) }];
if (visionModel) {
if (isVisionModel(options.config.model)) {
const images = getMessageImages(v);
if (images.length > 0) {
multimodal = true;
@@ -65,6 +108,9 @@ export class GeminiProApi implements LLMApi {
// if (visionModel && messages.length > 1) {
// options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision"));
// }
const accessStore = useAccessStore.getState();
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
@@ -86,53 +132,30 @@ export class GeminiProApi implements LLMApi {
safetySettings: [
{
category: "HARM_CATEGORY_HARASSMENT",
threshold: "BLOCK_ONLY_HIGH",
threshold: accessStore.googleSafetySettings,
},
{
category: "HARM_CATEGORY_HATE_SPEECH",
threshold: "BLOCK_ONLY_HIGH",
threshold: accessStore.googleSafetySettings,
},
{
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
threshold: "BLOCK_ONLY_HIGH",
threshold: accessStore.googleSafetySettings,
},
{
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
threshold: "BLOCK_ONLY_HIGH",
threshold: accessStore.googleSafetySettings,
},
],
};
const accessStore = useAccessStore.getState();
let baseUrl = "";
if (accessStore.useCustomConfig) {
baseUrl = accessStore.googleUrl;
}
const isApp = !!getClientConfig()?.isApp;
let shouldStream = !!options.config.stream;
const controller = new AbortController();
options.onController?.(controller);
try {
let googleChatPath = visionModel
? Google.VisionChatPath(modelConfig.model)
: Google.ChatPath(modelConfig.model);
let chatPath = this.path(googleChatPath);
// https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb
const chatPath = this.path(Google.ChatPath(modelConfig.model));
// let baseUrl = accessStore.googleUrl;
if (!baseUrl) {
baseUrl = isApp
? DEFAULT_API_HOST + "/api/proxy/google/" + googleChatPath
: chatPath;
}
if (isApp) {
baseUrl += `?key=${accessStore.googleApiKey}`;
}
const chatPayload = {
method: "POST",
body: JSON.stringify(requestPayload),
@@ -145,15 +168,17 @@ export class GeminiProApi implements LLMApi {
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
if (shouldStream) {
let responseText = "";
let remainText = "";
let finished = false;
let existingTexts: string[] = [];
const finish = () => {
finished = true;
options.onFinish(existingTexts.join(""));
if (!finished) {
finished = true;
options.onFinish(responseText + remainText);
}
};
// animate response to make it looks smooth
@@ -178,74 +203,83 @@ export class GeminiProApi implements LLMApi {
// start animaion
animateResponseText();
fetch(
baseUrl.replace("generateContent", "streamGenerateContent"),
chatPayload,
)
.then((response) => {
const reader = response?.body?.getReader();
const decoder = new TextDecoder();
let partialData = "";
controller.signal.onabort = finish;
return reader?.read().then(function processText({
done,
value,
}): Promise<any> {
if (done) {
if (response.status !== 200) {
try {
let data = JSON.parse(ensureProperEnding(partialData));
if (data && data[0].error) {
options.onError?.(new Error(data[0].error.message));
} else {
options.onError?.(new Error("Request failed"));
}
} catch (_) {
options.onError?.(new Error("Request failed"));
}
}
fetchEventSource(chatPath, {
...chatPayload,
async onopen(res) {
clearTimeout(requestTimeoutId);
const contentType = res.headers.get("content-type");
console.log(
"[Gemini] request response content type: ",
contentType,
);
console.log("Stream complete");
// options.onFinish(responseText + remainText);
finished = true;
return Promise.resolve();
}
partialData += decoder.decode(value, { stream: true });
if (contentType?.startsWith("text/plain")) {
responseText = await res.clone().text();
return finish();
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [responseText];
let extraInfo = await res.clone().text();
try {
let data = JSON.parse(ensureProperEnding(partialData));
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
const textArray = data.reduce(
(acc: string[], item: { candidates: any[] }) => {
const texts = item.candidates.map((candidate) =>
candidate.content.parts
.map((part: { text: any }) => part.text)
.join(""),
);
return acc.concat(texts);
},
[],
);
if (textArray.length > existingTexts.length) {
const deltaArray = textArray.slice(existingTexts.length);
existingTexts = textArray;
remainText += deltaArray.join("");
}
} catch (error) {
// console.log("[Response Animation] error: ", error,partialData);
// skip error message when parsing json
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
return reader.read().then(processText);
});
})
.catch((error) => {
console.error("Error:", error);
});
if (extraInfo) {
responseTexts.push(extraInfo);
}
responseText = responseTexts.join("\n\n");
return finish();
}
},
onmessage(msg) {
if (msg.data === "[DONE]" || finished) {
return finish();
}
const text = msg.data;
try {
const json = JSON.parse(text);
const delta = apiClient.extractMessage(json);
if (delta) {
remainText += delta;
}
const blockReason = json?.promptFeedback?.blockReason;
if (blockReason) {
// being blocked
console.log(`[Google] [Safety Ratings] result:`, blockReason);
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
finish();
},
onerror(e) {
options.onError?.(e);
throw e;
},
openWhenHidden: true,
});
} else {
const res = await fetch(baseUrl, chatPayload);
const res = await fetch(chatPath, chatPayload);
clearTimeout(requestTimeoutId);
const resJson = await res.json();
if (resJson?.promptFeedback?.blockReason) {
@@ -257,7 +291,7 @@ export class GeminiProApi implements LLMApi {
),
);
}
const message = this.extractMessage(resJson);
const message = apiClient.extractMessage(resJson);
options.onFinish(message);
}
} catch (e) {
@@ -271,14 +305,4 @@ export class GeminiProApi implements LLMApi {
async models(): Promise<LLMModel[]> {
return [];
}
path(path: string): string {
return "/api/google/" + path;
}
}
function ensureProperEnding(str: string) {
if (str.startsWith("[") && !str.endsWith("]")) {
return str + "]";
}
return str;
}

View File

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

View File

@@ -0,0 +1,251 @@
"use client";
// azure and openai, using same models. so using same LLMApi.
import {
ApiPath,
DEFAULT_API_HOST,
DEFAULT_MODELS,
Moonshot,
REQUEST_TIMEOUT_MS,
ServiceProvider,
} from "@/app/constant";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
import { collectModelsWithDefaultModel } from "@/app/utils/model";
import { preProcessImageContent } from "@/app/utils/chat";
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
import {
ChatOptions,
getHeaders,
LLMApi,
LLMModel,
LLMUsage,
MultimodalContent,
} from "../api";
import Locale from "../../locales";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import { prettyObject } from "@/app/utils/format";
import { getClientConfig } from "@/app/config/client";
import { getMessageTextContent } from "@/app/utils";
import { OpenAIListModelResponse, RequestPayload } from "./openai";
export class MoonshotApi implements LLMApi {
private disableListModels = true;
path(path: string): string {
const accessStore = useAccessStore.getState();
let baseUrl = "";
if (accessStore.useCustomConfig) {
baseUrl = accessStore.moonshotUrl;
}
if (baseUrl.length === 0) {
const isApp = !!getClientConfig()?.isApp;
const apiPath = ApiPath.Moonshot;
baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
}
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Moonshot)) {
baseUrl = "https://" + baseUrl;
}
console.log("[Proxy Endpoint] ", baseUrl, path);
return [baseUrl, path].join("/");
}
extractMessage(res: any) {
return res.choices?.at(0)?.message?.content ?? "";
}
async chat(options: ChatOptions) {
const messages: ChatOptions["messages"] = [];
for (const v of options.messages) {
const content = getMessageTextContent(v);
messages.push({ role: v.role, content });
}
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
...{
model: options.config.model,
providerName: options.config.providerName,
},
};
const requestPayload: RequestPayload = {
messages,
stream: options.config.stream,
model: modelConfig.model,
temperature: modelConfig.temperature,
presence_penalty: modelConfig.presence_penalty,
frequency_penalty: modelConfig.frequency_penalty,
top_p: modelConfig.top_p,
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
// Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
};
console.log("[Request] openai payload: ", requestPayload);
const shouldStream = !!options.config.stream;
const controller = new AbortController();
options.onController?.(controller);
try {
const chatPath = this.path(Moonshot.ChatPath);
const chatPayload = {
method: "POST",
body: JSON.stringify(requestPayload),
signal: controller.signal,
headers: getHeaders(),
};
// make a fetch request
const requestTimeoutId = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
if (shouldStream) {
let responseText = "";
let remainText = "";
let finished = false;
// animate response to make it looks smooth
function animateResponseText() {
if (finished || controller.signal.aborted) {
responseText += remainText;
console.log("[Response Animation] finished");
if (responseText?.length === 0) {
options.onError?.(new Error("empty response from server"));
}
return;
}
if (remainText.length > 0) {
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
const fetchText = remainText.slice(0, fetchCount);
responseText += fetchText;
remainText = remainText.slice(fetchCount);
options.onUpdate?.(responseText, fetchText);
}
requestAnimationFrame(animateResponseText);
}
// start animaion
animateResponseText();
const finish = () => {
if (!finished) {
finished = true;
options.onFinish(responseText + remainText);
}
};
controller.signal.onabort = finish;
fetchEventSource(chatPath, {
...chatPayload,
async onopen(res) {
clearTimeout(requestTimeoutId);
const contentType = res.headers.get("content-type");
console.log(
"[OpenAI] request response content type: ",
contentType,
);
if (contentType?.startsWith("text/plain")) {
responseText = await res.clone().text();
return finish();
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [responseText];
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
if (extraInfo) {
responseTexts.push(extraInfo);
}
responseText = responseTexts.join("\n\n");
return finish();
}
},
onmessage(msg) {
if (msg.data === "[DONE]" || finished) {
return finish();
}
const text = msg.data;
try {
const json = JSON.parse(text);
const choices = json.choices as Array<{
delta: { content: string };
}>;
const delta = choices[0]?.delta?.content;
const textmoderation = json?.prompt_filter_results;
if (delta) {
remainText += delta;
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
finish();
},
onerror(e) {
options.onError?.(e);
throw e;
},
openWhenHidden: true,
});
} else {
const res = await fetch(chatPath, chatPayload);
clearTimeout(requestTimeoutId);
const resJson = await res.json();
const message = this.extractMessage(resJson);
options.onFinish(message);
}
} catch (e) {
console.log("[Request] failed to make a chat request", e);
options.onError?.(e as Error);
}
}
async usage() {
return {
used: 0,
total: 0,
};
}
async models(): Promise<LLMModel[]> {
return [];
}
}

View File

@@ -1,13 +1,23 @@
"use client";
// azure and openai, using same models. so using same LLMApi.
import {
ApiPath,
DEFAULT_API_HOST,
DEFAULT_MODELS,
OpenaiPath,
Azure,
REQUEST_TIMEOUT_MS,
ServiceProvider,
} from "@/app/constant";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
import { collectModelsWithDefaultModel } from "@/app/utils/model";
import {
preProcessImageContent,
uploadImage,
base64Image2Blob,
} from "@/app/utils/chat";
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
import { DalleSize, DalleQuality, DalleStyle } from "@/app/typing";
import {
ChatOptions,
@@ -24,11 +34,11 @@ import {
} from "@fortaine/fetch-event-source";
import { prettyObject } from "@/app/utils/format";
import { getClientConfig } from "@/app/config/client";
import { makeAzurePath } from "@/app/azure";
import {
getMessageTextContent,
getMessageImages,
isVisionModel,
isDalle3 as _isDalle3,
} from "@/app/utils";
export interface OpenAIListModelResponse {
@@ -40,7 +50,7 @@ export interface OpenAIListModelResponse {
}>;
}
interface RequestPayload {
export interface RequestPayload {
messages: {
role: "system" | "user" | "assistant";
content: string | MultimodalContent[];
@@ -54,6 +64,16 @@ interface RequestPayload {
max_tokens?: number;
}
export interface DalleRequestPayload {
model: string;
prompt: string;
response_format: "url" | "b64_json";
n: number;
size: DalleSize;
quality: DalleQuality;
style: DalleStyle;
}
export class ChatGPTApi implements LLMApi {
private disableListModels = true;
@@ -62,85 +82,157 @@ export class ChatGPTApi implements LLMApi {
let baseUrl = "";
const isAzure = path.includes("deployments");
if (accessStore.useCustomConfig) {
const isAzure = accessStore.provider === ServiceProvider.Azure;
if (isAzure && !accessStore.isValidAzure()) {
throw Error(
"incomplete azure config, please check it in your settings page",
);
}
if (isAzure) {
path = makeAzurePath(path, accessStore.azureApiVersion);
}
baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl;
}
if (baseUrl.length === 0) {
const isApp = !!getClientConfig()?.isApp;
baseUrl = isApp
? DEFAULT_API_HOST + "/proxy" + ApiPath.OpenAI
: ApiPath.OpenAI;
const apiPath = isAzure ? ApiPath.Azure : ApiPath.OpenAI;
baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
}
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.OpenAI)) {
if (
!baseUrl.startsWith("http") &&
!isAzure &&
!baseUrl.startsWith(ApiPath.OpenAI)
) {
baseUrl = "https://" + baseUrl;
}
console.log("[Proxy Endpoint] ", baseUrl, path);
return [baseUrl, path].join("/");
// try rebuild url, when using cloudflare ai gateway in client
return cloudflareAIGatewayUrl([baseUrl, path].join("/"));
}
extractMessage(res: any) {
return res.choices?.at(0)?.message?.content ?? "";
async extractMessage(res: any) {
if (res.error) {
return "```\n" + JSON.stringify(res, null, 4) + "\n```";
}
// dalle3 model return url, using url create image message
if (res.data) {
let url = res.data?.at(0)?.url ?? "";
const b64_json = res.data?.at(0)?.b64_json ?? "";
if (!url && b64_json) {
// uploadImage
url = await uploadImage(base64Image2Blob(b64_json, "image/png"));
}
return [
{
type: "image_url",
image_url: {
url,
},
},
];
}
return res.choices?.at(0)?.message?.content ?? res;
}
async chat(options: ChatOptions) {
const visionModel = isVisionModel(options.config.model);
const messages = options.messages.map((v) => ({
role: v.role,
content: visionModel ? v.content : getMessageTextContent(v),
}));
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
...{
model: options.config.model,
providerName: options.config.providerName,
},
};
const requestPayload: RequestPayload = {
messages,
stream: options.config.stream,
model: modelConfig.model,
temperature: modelConfig.temperature,
presence_penalty: modelConfig.presence_penalty,
frequency_penalty: modelConfig.frequency_penalty,
top_p: modelConfig.top_p,
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
// Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
};
let requestPayload: RequestPayload | DalleRequestPayload;
// add max_tokens to vision model
if (visionModel) {
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
const isDalle3 = _isDalle3(options.config.model);
if (isDalle3) {
const prompt = getMessageTextContent(
options.messages.slice(-1)?.pop() as any,
);
requestPayload = {
model: options.config.model,
prompt,
// URLs are only valid for 60 minutes after the image has been generated.
response_format: "b64_json", // using b64_json, and save image in CacheStorage
n: 1,
size: options.config?.size ?? "1024x1024",
quality: options.config?.quality ?? "standard",
style: options.config?.style ?? "vivid",
};
} else {
const visionModel = isVisionModel(options.config.model);
const messages: ChatOptions["messages"] = [];
for (const v of options.messages) {
const content = visionModel
? await preProcessImageContent(v.content)
: getMessageTextContent(v);
messages.push({ role: v.role, content });
}
requestPayload = {
messages,
stream: options.config.stream,
model: modelConfig.model,
temperature: modelConfig.temperature,
presence_penalty: modelConfig.presence_penalty,
frequency_penalty: modelConfig.frequency_penalty,
top_p: modelConfig.top_p,
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
// Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
};
// add max_tokens to vision model
if (visionModel && modelConfig.model.includes("preview")) {
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
}
}
console.log("[Request] openai payload: ", requestPayload);
const shouldStream = !!options.config.stream;
const shouldStream = !isDalle3 && !!options.config.stream;
const controller = new AbortController();
options.onController?.(controller);
try {
const chatPath = this.path(OpenaiPath.ChatPath);
let chatPath = "";
if (modelConfig.providerName === ServiceProvider.Azure) {
// find model, and get displayName as deployName
const { models: configModels, customModels: configCustomModels } =
useAppConfig.getState();
const {
defaultModel,
customModels: accessCustomModels,
useCustomConfig,
} = useAccessStore.getState();
const models = collectModelsWithDefaultModel(
configModels,
[configCustomModels, accessCustomModels].join(","),
defaultModel,
);
const model = models.find(
(model) =>
model.name === modelConfig.model &&
model?.provider?.providerName === ServiceProvider.Azure,
);
chatPath = this.path(
(isDalle3 ? Azure.ImagePath : Azure.ChatPath)(
(model?.displayName ?? model?.name) as string,
useCustomConfig ? useAccessStore.getState().azureApiVersion : "",
),
);
} else {
chatPath = this.path(
isDalle3 ? OpenaiPath.ImagePath : OpenaiPath.ChatPath,
);
}
const chatPayload = {
method: "POST",
body: JSON.stringify(requestPayload),
@@ -151,7 +243,7 @@ export class ChatGPTApi implements LLMApi {
// make a fetch request
const requestTimeoutId = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS,
isDalle3 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow.
);
if (shouldStream) {
@@ -282,7 +374,7 @@ export class ChatGPTApi implements LLMApi {
clearTimeout(requestTimeoutId);
const resJson = await res.json();
const message = this.extractMessage(resJson);
const message = await this.extractMessage(resJson);
options.onFinish(message);
}
} catch (e) {
@@ -376,13 +468,17 @@ export class ChatGPTApi implements LLMApi {
return [];
}
//由于目前 OpenAI 的 disableListModels 默认为 true所以当前实际不会运行到这场
let seq = 1000; //同 Constant.ts 中的排序保持一致
return chatModels.map((m) => ({
name: m.id,
available: true,
sorted: seq++,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
sorted: 1,
},
}));
}

View File

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

View File

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

View File

@@ -1,123 +0,0 @@
import { isValidElement } from "react";
type IconMap = {
active?: JSX.Element;
inactive?: JSX.Element;
mobileActive?: JSX.Element;
mobileInactive?: JSX.Element;
};
interface Action {
id: string;
title?: string;
icons: JSX.Element | IconMap;
className?: string;
onClick?: () => void;
activeClassName?: string;
}
type Groups = {
normal: string[][];
mobile: string[][];
};
export interface ActionsBarProps {
actionsShema: Action[];
onSelect?: (id: string) => void;
selected?: string;
groups: string[][] | Groups;
className?: string;
inMobile?: boolean;
}
export default function ActionsBar(props: ActionsBarProps) {
const { actionsShema, onSelect, selected, groups, className, inMobile } =
props;
const handlerClick =
(action: Action) => (e: { preventDefault: () => void }) => {
e.preventDefault();
if (action.onClick) {
action.onClick();
}
if (selected !== action.id) {
onSelect?.(action.id);
}
};
const internalGroup = Array.isArray(groups)
? groups
: inMobile
? groups.mobile
: groups.normal;
const content = internalGroup.reduce((res, group, ind, arr) => {
res.push(
...group.map((i) => {
const action = actionsShema.find((a) => a.id === i);
if (!action) {
return <></>;
}
const { icons } = action;
let activeIcon, inactiveIcon, mobileActiveIcon, mobileInactiveIcon;
if (isValidElement(icons)) {
activeIcon = icons;
inactiveIcon = icons;
mobileActiveIcon = icons;
mobileInactiveIcon = icons;
} else {
activeIcon = (icons as IconMap).active;
inactiveIcon = (icons as IconMap).inactive;
mobileActiveIcon = (icons as IconMap).mobileActive;
mobileInactiveIcon = (icons as IconMap).mobileInactive;
}
if (inMobile) {
return (
<div
key={action.id}
className={` cursor-pointer shrink-1 grow-0 basis-[${
(100 - 1) / arr.length
}%] flex flex-col items-center justify-around gap-0.5 py-1.5
${
selected === action.id
? "text-text-sidebar-tab-mobile-active"
: "text-text-sidebar-tab-mobile-inactive"
}
`}
onClick={handlerClick(action)}
>
{selected === action.id ? mobileActiveIcon : mobileInactiveIcon}
<div className=" leading-3 text-sm-mobile-tab h-3 font-common w-[100%]">
{action.title || " "}
</div>
</div>
);
}
return (
<div
key={action.id}
className={`cursor-pointer p-3 ${
selected === action.id
? `!bg-actions-bar-btn-default ${action.activeClassName}`
: "bg-transparent"
} rounded-md items-center ${
action.className
} transition duration-300 ease-in-out`}
onClick={handlerClick(action)}
>
{selected === action.id ? activeIcon : inactiveIcon}
</div>
);
}),
);
if (ind < arr.length - 1) {
res.push(<div key={String(ind)} className=" flex-1"></div>);
}
return res;
}, [] as JSX.Element[]);
return <div className={`flex items-center ${className} `}>{content}</div>;
}

View File

@@ -1,78 +0,0 @@
import * as React from "react";
export type ButtonType = "primary" | "danger" | null;
export interface BtnProps {
onClick?: () => void;
icon?: JSX.Element;
prefixIcon?: JSX.Element;
type?: ButtonType;
text?: React.ReactNode;
bordered?: boolean;
shadow?: boolean;
className?: string;
title?: string;
disabled?: boolean;
tabIndex?: number;
autoFocus?: boolean;
}
export default function Btn(props: BtnProps) {
const {
onClick,
icon,
type,
text,
className,
title,
disabled,
tabIndex,
autoFocus,
prefixIcon,
} = props;
let btnClassName;
switch (type) {
case "primary":
btnClassName = `${
disabled
? "bg-primary-btn-disabled dark:opacity-30 dark:text-primary-btn-disabled-dark"
: "bg-primary-btn shadow-btn"
} text-text-btn-primary `;
break;
case "danger":
btnClassName = `bg-danger-btn text-text-btn-danger hover:bg-hovered-danger-btn`;
break;
default:
btnClassName = `bg-default-btn text-text-btn-default hover:bg-hovered-btn`;
}
return (
<button
className={`
${className ?? ""}
py-2 px-3 flex items-center justify-center gap-1 rounded-action-btn transition-all duration-300 select-none
${disabled ? "cursor-not-allowed" : "cursor-pointer"}
${btnClassName}
follow-parent-svg
`}
onClick={onClick}
title={title}
disabled={disabled}
role="button"
tabIndex={tabIndex}
autoFocus={autoFocus}
>
{prefixIcon && (
<div className={`flex items-center justify-center`}>{prefixIcon}</div>
)}
{text && (
<div className={`font-common text-sm-title leading-4 line-clamp-1`}>
{text}
</div>
)}
{icon && <div className={`flex items-center justify-center`}>{icon}</div>}
</button>
);
}

View File

@@ -1,32 +0,0 @@
import { ReactNode } from "react";
export interface CardProps {
className?: string;
children?: ReactNode;
title?: ReactNode;
}
export default function Card(props: CardProps) {
const { className, children, title } = props;
return (
<>
{title && (
<div
className={`
capitalize !font-semibold text-sm-mobile font-weight-setting-card-title text-text-card-title
mb-3
ml-3
md:ml-4
`}
>
{title}
</div>
)}
<div className={`px-4 py-1 rounded-lg bg-card ${className}`}>
{children}
</div>
</>
);
}

View File

@@ -1,18 +0,0 @@
import BotIcon from "@/app/icons/bot.svg";
import LoadingIcon from "@/app/icons/three-dots.svg";
export default function GloablLoading({
noLogo,
}: {
noLogo?: boolean;
useSkeleton?: boolean;
}) {
return (
<div
className={`flex flex-col justify-center items-center w-[100%] h-[100%]`}
>
{!noLogo && <BotIcon />}
<LoadingIcon />
</div>
);
}

View File

@@ -1,39 +0,0 @@
import * as HoverCard from "@radix-ui/react-hover-card";
import { ComponentProps } from "react";
export interface PopoverProps {
content?: JSX.Element | string;
children?: JSX.Element;
arrowClassName?: string;
popoverClassName?: string;
noArrow?: boolean;
align?: ComponentProps<typeof HoverCard.Content>["align"];
openDelay?: number;
}
export default function HoverPopover(props: PopoverProps) {
const {
content,
children,
arrowClassName,
popoverClassName,
noArrow = false,
align,
openDelay = 300,
} = props;
return (
<HoverCard.Root openDelay={openDelay}>
<HoverCard.Trigger asChild>{children}</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content
className={`${popoverClassName}`}
sideOffset={5}
align={align}
>
{content}
{!noArrow && <HoverCard.Arrow className={`${arrowClassName}`} />}
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
);
}

View File

@@ -1,42 +0,0 @@
import { CSSProperties } from "react";
import { getMessageImages } from "@/app/utils";
import { RequestMessage } from "@/app/client/api";
interface ImgsProps {
message: RequestMessage;
}
export default function Imgs(props: ImgsProps) {
const { message } = props;
const imgSrcs = getMessageImages(message);
if (imgSrcs.length < 1) {
return <></>;
}
const imgVars = {
"--imgs-width": `calc(var(--max-message-width) - ${
imgSrcs.length - 1
}*0.25rem)`,
"--img-width": `calc(var(--imgs-width)/ ${imgSrcs.length})`,
};
return (
<div
className={`w-[100%] mt-[0.625rem] flex gap-1`}
style={imgVars as CSSProperties}
>
{imgSrcs.map((image, index) => {
return (
<div
key={index}
className="flex-1 min-w-[var(--img-width)] pb-[var(--img-width)] object-cover bg-cover bg-no-repeat bg-center box-border rounded-chat-img"
style={{
backgroundImage: `url(${image})`,
}}
/>
);
})}
</div>
);
}

View File

@@ -1,88 +0,0 @@
import PasswordVisible from "@/app/icons/passwordVisible.svg";
import PasswordInvisible from "@/app/icons/passwordInvisible.svg";
import {
DetailedHTMLProps,
InputHTMLAttributes,
useContext,
useLayoutEffect,
useState,
} from "react";
import List, { ListContext } from "@/app/components/List";
export interface CommonInputProps
extends Omit<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
"onChange" | "type" | "value"
> {
className?: string;
}
export interface NumberInputProps {
onChange?: (v: number) => void;
type?: "number";
value?: number;
}
export interface TextInputProps {
onChange?: (v: string) => void;
type?: "text" | "password";
value?: string;
}
export interface InputProps {
onChange?: ((v: string) => void) | ((v: number) => void);
type?: "text" | "password" | "number";
value?: string | number;
}
export default function Input(
props: CommonInputProps & NumberInputProps,
): JSX.Element;
export default function Input(
props: CommonInputProps & TextInputProps,
): JSX.Element;
export default function Input(props: CommonInputProps & InputProps) {
const { value, type = "text", onChange, className, ...rest } = props;
const [show, setShow] = useState(false);
const { inputClassName } = useContext(ListContext);
const internalType = (show && "text") || type;
const { update, handleValidate } = useContext(List.ListContext);
useLayoutEffect(() => {
update?.({ type: "input" });
}, []);
useLayoutEffect(() => {
handleValidate?.(value);
}, [value]);
return (
<div
className={` group/input w-[100%] rounded-chat-input bg-input transition-colors duration-300 ease-in-out flex gap-3 items-center px-3 py-2 ${className} hover:bg-select-hover ${inputClassName}`}
>
<input
{...rest}
className=" overflow-hidden text-text-input text-sm-title leading-input outline-none flex-1 group-hover/input:bg-input-input-ele-hover"
type={internalType}
value={value}
onChange={(e) => {
if (type === "number") {
const v = e.currentTarget.valueAsNumber;
(onChange as NumberInputProps["onChange"])?.(v);
} else {
const v = e.currentTarget.value;
(onChange as TextInputProps["onChange"])?.(v);
}
}}
/>
{type == "password" && (
<div className=" cursor-pointer" onClick={() => setShow((pre) => !pre)}>
{show ? <PasswordVisible /> : <PasswordInvisible />}
</div>
)}
</div>
);
}

View File

@@ -1,157 +0,0 @@
import {
ReactNode,
createContext,
useCallback,
useContext,
useState,
} from "react";
interface WidgetStyle {
selectClassName?: string;
inputClassName?: string;
rangeClassName?: string;
switchClassName?: string;
inputNextLine?: boolean;
rangeNextLine?: boolean;
}
interface ChildrenMeta {
type?: "unknown" | "input" | "range";
error?: string;
}
export interface ListProps {
className?: string;
children?: ReactNode;
id?: string;
isMobileScreen?: boolean;
widgetStyle?: WidgetStyle;
}
type Error =
| {
error: true;
message: string;
}
| {
error: false;
};
export interface ListItemProps {
title: string;
subTitle?: string;
children?: JSX.Element | JSX.Element[];
className?: string;
onClick?: () => void;
nextline?: boolean;
validator?: (v: any) => Error | Promise<Error>;
}
export const ListContext = createContext<
{
isMobileScreen?: boolean;
update?: (m: ChildrenMeta) => void;
handleValidate?: (v: any) => void;
} & WidgetStyle
>({ isMobileScreen: false });
export function ListItem(props: ListItemProps) {
const {
className = "",
onClick,
title,
subTitle,
children,
nextline,
validator,
} = props;
const context = useContext(ListContext);
const [childrenMeta, setMeta] = useState<ChildrenMeta>({});
const { inputNextLine, rangeNextLine } = context;
const { type, error } = childrenMeta;
let internalNextLine;
switch (type) {
case "input":
internalNextLine = !!(nextline || inputNextLine);
break;
case "range":
internalNextLine = !!(nextline || rangeNextLine);
break;
default:
internalNextLine = false;
}
const update = useCallback((m: ChildrenMeta) => {
setMeta((pre) => ({ ...pre, ...m }));
}, []);
const handleValidate = useCallback((v: any) => {
const insideValidator = validator || (() => {});
Promise.resolve(insideValidator(v)).then((result) => {
if (result && result.error) {
return update({
error: result.message,
});
}
update({
error: undefined,
});
});
}, []);
return (
<div
className={`relative after:h-[0.5px] after:bottom-0 after:w-[100%] after:left-0 after:absolute last:after:hidden after:bg-list-item-divider ${
internalNextLine ? "" : "flex gap-3"
} justify-between items-center px-0 py-2 md:py-3 ${className}`}
onClick={onClick}
>
<div className={`flex-1 flex flex-col justify-start gap-1`}>
<div className=" font-common text-sm-mobile font-weight-[500] line-clamp-1 text-text-list-title">
{title}
</div>
{subTitle && (
<div className={` text-sm text-text-list-subtitle`}>{subTitle}</div>
)}
</div>
<ListContext.Provider value={{ ...context, update, handleValidate }}>
<div
className={`${
internalNextLine ? "mt-[0.625rem]" : "max-w-[70%]"
} flex flex-col items-center justify-center`}
>
<div>{children}</div>
{!!error && (
<div className="text-text-btn-danger text-sm-mobile-tab mt-[0.3125rem] flex items-start w-[100%]">
<div className="">{error}</div>
</div>
)}
</div>
</ListContext.Provider>
</div>
);
}
function List(props: ListProps) {
const { className, children, id, widgetStyle } = props;
const { isMobileScreen } = useContext(ListContext);
return (
<ListContext.Provider value={{ isMobileScreen, ...widgetStyle }}>
<div className={`flex flex-col w-[100%] ${className}`} id={id}>
{children}
</div>
</ListContext.Provider>
);
}
List.ListItem = ListItem;
List.ListContext = ListContext;
export default List;

View File

@@ -1,35 +0,0 @@
import BotIcon from "@/app/icons/bot.svg";
import LoadingIcon from "@/app/icons/three-dots.svg";
import { getCSSVar } from "@/app/utils";
export default function Loading({
noLogo,
useSkeleton = true,
}: {
noLogo?: boolean;
useSkeleton?: boolean;
}) {
let theme;
if (typeof window !== "undefined") {
theme = getCSSVar("--default-container-bg");
}
return (
<div
className={`
flex flex-col justify-center items-center w-[100%]
h-[100%]
md:my-2.5
md:ml-1
md:mr-2.5
md:rounded-md
md:h-[calc(100%-1.25rem)]
`}
style={{ background: useSkeleton ? theme : "" }}
>
{!noLogo && <BotIcon />}
<LoadingIcon />
</div>
);
}

View File

@@ -1,115 +0,0 @@
import {
DEFAULT_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
Path,
} from "@/app/constant";
import useDrag from "@/app/hooks/useDrag";
import useMobileScreen from "@/app/hooks/useMobileScreen";
import { updateGlobalCSSVars } from "@/app/utils/client";
import { ComponentType, useRef, useState } from "react";
import { useAppConfig } from "@/app/store/config";
export interface MenuWrapperInspectProps {
setExternalProps?: (v: Record<string, any>) => void;
setShowPanel?: (v: boolean) => void;
showPanel?: boolean;
[k: string]: any;
}
export default function MenuLayout<
ListComponentProps extends MenuWrapperInspectProps,
PanelComponentProps extends MenuWrapperInspectProps,
>(
ListComponent: ComponentType<ListComponentProps>,
PanelComponent: ComponentType<PanelComponentProps>,
) {
return function MenuHood(props: ListComponentProps & PanelComponentProps) {
const [showPanel, setShowPanel] = useState(false);
const [externalProps, setExternalProps] = useState({});
const config = useAppConfig();
const isMobileScreen = useMobileScreen();
const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
// drag side bar
const { onDragStart } = useDrag({
customToggle: () => {
config.update((config) => {
config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
});
},
customDragMove: (nextWidth: number) => {
const { menuWidth } = updateGlobalCSSVars(nextWidth);
document.documentElement.style.setProperty(
"--menu-width",
`${menuWidth}px`,
);
config.update((config) => {
config.sidebarWidth = nextWidth;
});
},
customLimit: (x: number) =>
Math.max(
MIN_SIDEBAR_WIDTH,
Math.min(MAX_SIDEBAR_WIDTH, startDragWidth.current + x),
),
});
return (
<div
className={`
w-[100%] relative bg-center
max-md:h-[100%]
md:flex md:my-2.5
`}
>
<div
className={`
flex flex-col px-6
h-[100%]
max-md:w-[100%] max-md:px-4 max-md:pb-4 max-md:flex-1
md:relative md:basis-sidebar md:pb-6 md:rounded-md md:bg-menu
`}
>
<ListComponent
{...props}
setShowPanel={setShowPanel}
setExternalProps={setExternalProps}
showPanel={showPanel}
/>
</div>
{!isMobileScreen && (
<div
className={`group/menu-dragger cursor-col-resize w-[0.25rem] flex items-center justify-center`}
onPointerDown={(e) => {
startDragWidth.current = config.sidebarWidth;
onDragStart(e as any);
}}
>
<div className="w-[2px] opacity-0 group-hover/menu-dragger:opacity-100 bg-menu-dragger h-[100%] rounded-[2px]">
&nbsp;
</div>
</div>
)}
<div
className={`
md:flex-1 md:h-[100%] md:w-page
max-md:transition-all max-md:duration-300 max-md:absolute max-md:top-0 max-md:max-h-[100vh] max-md:w-[100%] ${
showPanel ? "max-md:left-0" : "max-md:left-[101%]"
} max-md:z-10
`}
>
<PanelComponent
{...props}
{...externalProps}
setShowPanel={setShowPanel}
setExternalProps={setExternalProps}
showPanel={showPanel}
/>
</div>
</div>
);
};
}

View File

@@ -1,352 +0,0 @@
import React, { useLayoutEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import * as AlertDialog from "@radix-ui/react-alert-dialog";
import Btn, { BtnProps } from "@/app/components/Btn";
import Warning from "@/app/icons/warning.svg";
import Close from "@/app/icons/closeIcon.svg";
export interface ModalProps {
onOk?: () => void;
onCancel?: () => void;
okText?: string;
cancelText?: string;
okBtnProps?: BtnProps;
cancelBtnProps?: BtnProps;
content?:
| React.ReactNode
| ((handlers: { close: () => void }) => JSX.Element);
title?: React.ReactNode;
visible?: boolean;
noFooter?: boolean;
noHeader?: boolean;
isMobile?: boolean;
closeble?: boolean;
type?: "modal" | "bottom-drawer";
headerBordered?: boolean;
modelClassName?: string;
onOpen?: (v: boolean) => void;
maskCloseble?: boolean;
}
export interface WarnProps
extends Omit<
ModalProps,
| "closeble"
| "isMobile"
| "noHeader"
| "noFooter"
| "onOk"
| "okBtnProps"
| "cancelBtnProps"
| "content"
> {
onOk?: () => Promise<void> | void;
content?: React.ReactNode;
}
export interface TriggerProps
extends Omit<ModalProps, "visible" | "onOk" | "onCancel"> {
children: JSX.Element;
className?: string;
}
const baseZIndex = 150;
const Modal = (props: ModalProps) => {
const {
onOk,
onCancel,
okText,
cancelText,
content,
title,
visible,
noFooter,
noHeader,
closeble = true,
okBtnProps,
cancelBtnProps,
type = "modal",
headerBordered,
modelClassName,
onOpen,
maskCloseble = true,
} = props;
const [open, setOpen] = useState(!!visible);
const mergeOpen = visible ?? open;
const handleClose = () => {
setOpen(false);
onCancel?.();
};
const handleOk = () => {
setOpen(false);
onOk?.();
};
useLayoutEffect(() => {
onOpen?.(mergeOpen);
}, [mergeOpen]);
let layoutClassName = "";
let panelClassName = "";
let titleClassName = "";
let footerClassName = "";
switch (type) {
case "bottom-drawer":
layoutClassName = "fixed inset-0 flex flex-col w-[100%] bottom-0";
panelClassName =
"rounded-t-chat-model-select overflow-y-auto overflow-x-hidden";
titleClassName = "px-4 py-3";
footerClassName = "absolute w-[100%]";
break;
case "modal":
default:
layoutClassName =
"fixed inset-0 flex flex-col item-start top-0 left-[50vw] translate-x-[-50%] max-sm:w-modal-modal-type-mobile";
panelClassName = "rounded-lg px-6 sm:w-modal-modal-type";
titleClassName = "py-6 max-sm:pb-3";
footerClassName = "py-6";
}
const btnCommonClass = "px-4 py-2.5 rounded-md max-sm:flex-1";
const { className: okBtnClass } = okBtnProps || {};
const { className: cancelBtnClass } = cancelBtnProps || {};
return (
<AlertDialog.Root open={mergeOpen} onOpenChange={setOpen}>
<AlertDialog.Portal>
<AlertDialog.Overlay
className="bg-modal-mask fixed inset-0 animate-mask "
style={{ zIndex: baseZIndex - 1 }}
onClick={() => {
if (maskCloseble) {
handleClose();
}
}}
/>
<AlertDialog.Content
className={`
${layoutClassName}
`}
style={{ zIndex: baseZIndex - 1 }}
>
<div
className="flex-1"
onClick={() => {
if (maskCloseble) {
handleClose();
}
}}
>
&nbsp;
</div>
<div
className={`flex flex-col flex-0
bg-moda-panel text-modal-panel
${modelClassName}
${panelClassName}
`}
>
{!noHeader && (
<AlertDialog.Title
className={`
flex items-center justify-between gap-3 font-common
md:text-chat-header-title md:font-bold md:leading-5
${
headerBordered
? " border-b border-modal-header-bottom"
: ""
}
${titleClassName}
`}
>
<div className="flex gap-3 justify-start flex-1 items-center text-text-modal-title text-chat-header-title">
{title}
</div>
{closeble && (
<div
className="items-center"
onClick={() => {
handleClose();
}}
>
<Close />
</div>
)}
</AlertDialog.Title>
)}
<div className="flex-1 overflow-hidden text-text-modal-content text-sm-title">
{typeof content === "function"
? content({
close: () => {
handleClose();
},
})
: content}
</div>
{!noFooter && (
<div
className={`
flex gap-3 sm:justify-end max-sm:justify-between
${footerClassName}
`}
>
<AlertDialog.Cancel asChild>
<Btn
{...cancelBtnProps}
onClick={() => handleClose()}
text={cancelText}
className={`${btnCommonClass} ${cancelBtnClass}`}
/>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<Btn
{...okBtnProps}
onClick={handleOk}
text={okText}
className={`${btnCommonClass} ${okBtnClass}`}
/>
</AlertDialog.Action>
</div>
)}
</div>
{type === "modal" && (
<div
className="flex-1"
onClick={() => {
if (maskCloseble) {
handleClose();
}
}}
>
&nbsp;
</div>
)}
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
);
};
export const Warn = ({
title,
onOk,
visible,
content,
...props
}: WarnProps) => {
const [internalVisible, setVisible] = useState(visible);
return (
<Modal
{...props}
title={
<>
<Warning />
{title}
</>
}
content={
<AlertDialog.Description
className={`
font-common font-normal
md:text-sm-title md:leading-[158%]
`}
>
{content}
</AlertDialog.Description>
}
closeble={false}
onOk={() => {
const toDo = onOk?.();
if (toDo instanceof Promise) {
toDo.then(() => {
setVisible(false);
});
} else {
setVisible(false);
}
}}
visible={internalVisible}
okBtnProps={{
className: `bg-delete-chat-ok-btn text-text-delete-chat-ok-btn `,
}}
cancelBtnProps={{
className: `bg-delete-chat-cancel-btn border border-delete-chat-cancel-btn text-text-delete-chat-cancel-btn`,
}}
/>
);
};
const div = document.createElement("div");
div.id = "confirm-root";
div.style.height = "0px";
document.body.appendChild(div);
Modal.warn = (props: Omit<WarnProps, "visible" | "onCancel" | "onOk">) => {
const root = createRoot(div);
const closeModal = () => {
root.unmount();
};
return new Promise<boolean>((resolve) => {
root.render(
<Warn
{...props}
visible={true}
onCancel={() => {
closeModal();
resolve(false);
}}
onOk={() => {
closeModal();
resolve(true);
}}
/>,
);
});
};
export const Trigger = (props: TriggerProps) => {
const { children, className, content, ...rest } = props;
const [internalVisible, setVisible] = useState(false);
return (
<>
<div
className={className}
onClick={() => {
setVisible(true);
}}
>
{children}
</div>
<Modal
{...rest}
visible={internalVisible}
onCancel={() => {
setVisible(false);
}}
content={
typeof content === "function"
? content({
close: () => {
setVisible(false);
},
})
: content
}
/>
</>
);
};
Modal.Trigger = Trigger;
export default Modal;

View File

@@ -1,352 +0,0 @@
import useRelativePosition from "@/app/hooks/useRelativePosition";
import {
RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
const ArrowIcon = ({ sibling }: { sibling: RefObject<HTMLDivElement> }) => {
const [color, setColor] = useState<string>("");
useEffect(() => {
if (sibling.current) {
const { backgroundColor } = window.getComputedStyle(sibling.current);
setColor(backgroundColor);
}
}, []);
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="6"
viewBox="0 0 16 6"
fill="none"
>
<path
d="M16 0H0C1.28058 0 2.50871 0.508709 3.41421 1.41421L6.91 4.91C7.51199 5.51199 8.48801 5.51199 9.09 4.91L12.5858 1.41421C13.4913 0.508708 14.7194 0 16 0Z"
fill={color}
/>
</svg>
);
};
const baseZIndex = 100;
const popoverRootName = "popoverRoot";
let popoverRoot = document.querySelector(
`#${popoverRootName}`,
) as HTMLDivElement;
if (!popoverRoot) {
popoverRoot = document.createElement("div");
document.body.appendChild(popoverRoot);
popoverRoot.style.height = "0px";
popoverRoot.style.width = "100%";
popoverRoot.style.position = "fixed";
popoverRoot.style.bottom = "0";
popoverRoot.style.zIndex = "10000";
popoverRoot.id = "popover-root";
}
export interface PopoverProps {
content?: JSX.Element | string;
children?: JSX.Element;
show?: boolean;
onShow?: (v: boolean) => void;
className?: string;
popoverClassName?: string;
trigger?: "hover" | "click";
placement?: "t" | "lt" | "rt" | "lb" | "rb" | "b" | "l" | "r";
noArrow?: boolean;
delayClose?: number;
useGlobalRoot?: boolean;
getPopoverPanelRef?: (ref: RefObject<HTMLDivElement>) => void;
}
export default function Popover(props: PopoverProps) {
const {
content,
children,
show,
onShow,
className,
popoverClassName,
trigger = "hover",
placement = "t",
noArrow = false,
delayClose = 0,
useGlobalRoot,
getPopoverPanelRef,
} = props;
const [internalShow, setShow] = useState(false);
const { position, getRelativePosition } = useRelativePosition({
delay: 0,
});
const popoverCommonClass = `absolute p-2 box-border`;
const mergedShow = show ?? internalShow;
const { arrowClassName, placementStyle, placementClassName } = useMemo(() => {
const arrowCommonClassName = `${
noArrow ? "hidden" : ""
} absolute z-10 left-[50%] translate-x-[calc(-50%)]`;
let defaultTopPlacement = true; // when users dont config 't' or 'b'
const {
distanceToBottomBoundary = 0,
distanceToLeftBoundary = 0,
distanceToRightBoundary = -10000,
distanceToTopBoundary = 0,
targetH = 0,
targetW = 0,
} = position?.poi || {};
if (distanceToBottomBoundary > distanceToTopBoundary) {
defaultTopPlacement = false;
}
const placements = {
lt: {
placementStyle: {
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
left: `calc(${distanceToLeftBoundary}px - ${targetW * 0.02}px)`,
},
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
placementClassName: "bottom-[calc(100%+0.5rem)] left-[calc(-2%)]",
},
lb: {
placementStyle: {
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
left: `calc(${distanceToLeftBoundary}px - ${targetW * 0.02}px)`,
},
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
placementClassName: "top-[calc(100%+0.5rem)] left-[calc(-2%)]",
},
rt: {
placementStyle: {
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
right: `calc(${distanceToRightBoundary}px - ${targetW * 0.02}px)`,
},
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
placementClassName: "bottom-[calc(100%+0.5rem)] right-[calc(-2%)]",
},
rb: {
placementStyle: {
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
right: `calc(${distanceToRightBoundary}px - ${targetW * 0.02}px)`,
},
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
placementClassName: "top-[calc(100%+0.5rem)] right-[calc(-2%)]",
},
t: {
placementStyle: {
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
left: `calc(${distanceToLeftBoundary + targetW / 2}px`,
transform: "translateX(-50%)",
},
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
placementClassName:
"bottom-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]",
},
b: {
placementStyle: {
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
left: `calc(${distanceToLeftBoundary + targetW / 2}px`,
transform: "translateX(-50%)",
},
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
placementClassName:
"top-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]",
},
};
const getStyle = () => {
if (["l", "r"].includes(placement)) {
return placements[
`${placement}${defaultTopPlacement ? "t" : "b"}` as
| "lt"
| "lb"
| "rb"
| "rt"
];
}
return placements[placement as Exclude<typeof placement, "l" | "r">];
};
return getStyle();
}, [Object.values(position?.poi || {})]);
const popoverRef = useRef<HTMLDivElement>(null);
const closeTimer = useRef<number>(0);
useLayoutEffect(() => {
getPopoverPanelRef?.(popoverRef);
onShow?.(internalShow);
}, [internalShow]);
if (trigger === "click") {
const handleOpen = (e: { currentTarget: any }) => {
clearTimeout(closeTimer.current);
setShow(true);
getRelativePosition(e.currentTarget, "");
window.document.documentElement.style.overflow = "hidden";
};
const handleClose = () => {
if (delayClose) {
closeTimer.current = window.setTimeout(() => {
setShow(false);
}, delayClose);
} else {
setShow(false);
}
window.document.documentElement.style.overflow = "auto";
};
return (
<div
className={`relative ${className}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!mergedShow) {
handleOpen(e);
} else {
handleClose();
}
}}
>
{children}
{mergedShow && (
<>
{!noArrow && (
<div className={`${arrowClassName}`}>
<ArrowIcon sibling={popoverRef} />
</div>
)}
{createPortal(
<div
className={`${popoverCommonClass} ${popoverClassName} cursor-pointer overflow-auto`}
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
ref={popoverRef}
>
{content}
</div>,
popoverRoot,
)}
{createPortal(
<div
className=" fixed w-[100vw] h-[100vh] right-0 bottom-0"
style={{ zIndex: baseZIndex }}
onClick={(e) => {
e.preventDefault();
handleClose();
}}
>
&nbsp;
</div>,
popoverRoot,
)}
</>
)}
</div>
);
}
if (useGlobalRoot) {
return (
<div
className={`relative ${className}`}
onPointerEnter={(e) => {
e.preventDefault();
clearTimeout(closeTimer.current);
onShow?.(true);
setShow(true);
getRelativePosition(e.currentTarget, "");
window.document.documentElement.style.overflow = "hidden";
}}
onPointerLeave={(e) => {
e.preventDefault();
if (delayClose) {
closeTimer.current = window.setTimeout(() => {
onShow?.(false);
setShow(false);
}, delayClose);
} else {
onShow?.(false);
setShow(false);
}
window.document.documentElement.style.overflow = "auto";
}}
>
{children}
{mergedShow && (
<>
<div
className={`${
noArrow ? "opacity-0" : ""
} bg-inherit ${arrowClassName}`}
style={{ zIndex: baseZIndex + 1 }}
>
<ArrowIcon sibling={popoverRef} />
</div>
{createPortal(
<div
className={` whitespace-nowrap ${popoverCommonClass} ${popoverClassName} cursor-pointer`}
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
ref={popoverRef}
>
{content}
</div>,
popoverRoot,
)}
</>
)}
</div>
);
}
return (
<div
className={`group/popover relative ${className}`}
onPointerEnter={(e) => {
getRelativePosition(e.currentTarget, "");
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{children}
<div
className={`
hidden group-hover/popover:block
${noArrow ? "opacity-0" : ""}
bg-inherit
${arrowClassName}
`}
style={{ zIndex: baseZIndex + 1 }}
>
<ArrowIcon sibling={popoverRef} />
</div>
<div
className={`
hidden group-hover/popover:block whitespace-nowrap
${popoverCommonClass}
${placementClassName}
${popoverClassName}
`}
ref={popoverRef}
style={{ zIndex: baseZIndex + 1 }}
>
{content}
</div>
</div>
);
}

View File

@@ -1,71 +0,0 @@
import { useLocation } from "react-router-dom";
import { useMemo, ReactNode } from "react";
import { Path, SIDEBAR_ID, SlotID } from "@/app/constant";
import { getLang } from "@/app/locales";
import useMobileScreen from "@/app/hooks/useMobileScreen";
import { isIOS } from "@/app/utils";
import useListenWinResize from "@/app/hooks/useListenWinResize";
interface ScreenProps {
children: ReactNode;
noAuth: ReactNode;
sidebar: ReactNode;
}
export default function Screen(props: ScreenProps) {
const location = useLocation();
const isAuth = location.pathname === Path.Auth;
const isMobileScreen = useMobileScreen();
const isIOSMobile = useMemo(
() => isIOS() && isMobileScreen,
[isMobileScreen],
);
useListenWinResize();
return (
<div
className={`
flex h-[100%] w-[100%] bg-center
max-md:relative max-md:flex-col-reverse max-md:bg-global-mobile
md:overflow-hidden md:bg-global
`}
style={{
direction: getLang() === "ar" ? "rtl" : "ltr",
}}
>
{isAuth ? (
props.noAuth
) : (
<>
<div
className={`
max-md:absolute max-md:w-[100%] max-md:bottom-0 max-md:z-10
md:flex-0 md:overflow-hidden
`}
id={SIDEBAR_ID}
>
{props.sidebar}
</div>
<div
className={`
h-[100%]
max-md:w-[100%]
md:flex-1 md:min-w-0 md:overflow-hidden md:flex
`}
id={SlotID.AppBody}
style={{
// #3016 disable transition on ios mobile screen
transition: isIOSMobile ? "none" : undefined,
}}
>
{props.children}
</div>
</>
)}
</div>
);
}

View File

@@ -1,24 +0,0 @@
.search {
display: flex;
max-width: 460px;
height: 50px;
padding: 16px;
align-items: center;
gap: 8px;
flex-shrink: 0;
border-radius: 16px;
border: 1px solid var(--Light-Text-Black, #18182A);
background: var(--light-opacity-white-70, rgba(255, 255, 255, 0.70));
box-shadow: 0px 8px 40px 0px rgba(60, 68, 255, 0.12);
.icon {
height: 20px;
width: 20px;
flex: 0 0;
}
.input {
height: 18px;
flex: 1 1;
}
}

View File

@@ -1,30 +0,0 @@
import styles from "./index.module.scss";
import SearchIcon from "@/app/icons/search.svg";
export interface SearchProps {
value?: string;
onSearch?: (v: string) => void;
placeholder?: string;
}
const Search = (props: SearchProps) => {
const { placeholder = "", value, onSearch } = props;
return (
<div className={styles["search"]}>
<div className={styles["icon"]}>
<SearchIcon />
</div>
<input
className={styles["input"]}
placeholder={placeholder}
value={value}
onChange={(e) => {
e.preventDefault();
onSearch?.(e.target.value);
}}
/>
</div>
);
};
export default Search;

View File

@@ -1,118 +0,0 @@
import SelectIcon from "@/app/icons/downArrowIcon.svg";
import Popover from "@/app/components/Popover";
import React, { useContext, useMemo, useRef } from "react";
import useRelativePosition, {
Orientation,
} from "@/app/hooks/useRelativePosition";
import List from "@/app/components/List";
import Selected from "@/app/icons/selectedIcon.svg";
export type Option<Value> = {
value: Value;
label: string;
icon?: React.ReactNode;
};
export interface SearchProps<Value> {
value?: string;
onSelect?: (v: Value) => void;
options?: Option<Value>[];
inMobile?: boolean;
}
const Select = <Value extends number | string>(props: SearchProps<Value>) => {
const { value, onSelect, options = [], inMobile } = props;
const { isMobileScreen, selectClassName } = useContext(List.ListContext);
const optionsRef = useRef<Option<Value>[]>([]);
optionsRef.current = options;
const selectedOption = useMemo(
() => optionsRef.current.find((o) => o.value === value),
[value],
);
const contentRef = useRef<HTMLDivElement>(null);
const { position, getRelativePosition } = useRelativePosition({
delay: 0,
});
let headerH = 100;
let baseH = position?.poi.distanceToBottomBoundary || 0;
if (isMobileScreen) {
headerH = 60;
}
if (position?.poi.relativePosition[1] === Orientation.bottom) {
baseH = position?.poi.distanceToTopBoundary;
}
const maxHeight = `${baseH - headerH}px`;
const content = (
<div
className={` flex flex-col gap-1 overflow-y-auto overflow-x-hidden`}
style={{ maxHeight }}
>
{options?.map((o) => (
<div
key={o.value}
className={`
flex items-center px-3 py-2 gap-3 rounded-action-btn hover:bg-select-option-hovered cursor-pointer
`}
onClick={() => {
onSelect?.(o.value);
}}
>
<div className="flex gap-2 flex-1 follow-parent-svg text-text-select-option">
{!!o.icon && <div className="flex items-center">{o.icon}</div>}
<div className={`flex-1 text-text-select-option`}>{o.label}</div>
</div>
<div
className={
selectedOption?.value === o.value ? "opacity-100" : "opacity-0"
}
>
<Selected />
</div>
</div>
))}
</div>
);
return (
<Popover
content={content}
trigger="click"
noArrow
placement={
position?.poi.relativePosition[1] !== Orientation.bottom ? "rb" : "rt"
}
popoverClassName="border border-select-popover rounded-lg shadow-select-popover-shadow w-actions-popover bg-select-popover-panel"
onShow={(e) => {
getRelativePosition(contentRef.current!, "");
}}
className={selectClassName}
>
<div
className={`flex items-center gap-3 py-2 px-3 bg-select rounded-action-btn font-common text-sm-title cursor-pointer hover:bg-select-hover transition duration-300 ease-in-out`}
ref={contentRef}
>
<div
className={`flex items-center gap-2 flex-1 follow-parent-svg text-text-select`}
>
{!!selectedOption?.icon && (
<div className={``}>{selectedOption?.icon}</div>
)}
<div className={`flex-1`}>{selectedOption?.label}</div>
</div>
<div className={``}>
<SelectIcon />
</div>
</div>
</Popover>
);
};
export default Select;

View File

@@ -1,99 +0,0 @@
import { useContext, useEffect, useRef } from "react";
import { ListContext } from "@/app/components/List";
import { useResizeObserver } from "usehooks-ts";
interface SlideRangeProps {
className?: string;
description?: string;
range?: {
start?: number;
stroke?: number;
};
onSlide?: (v: number) => void;
value?: number;
step?: number;
}
const margin = 15;
export default function SlideRange(props: SlideRangeProps) {
const {
className = "",
description = "",
range = {},
value,
onSlide,
step,
} = props;
const { start = 0, stroke = 1 } = range;
const { rangeClassName, update } = useContext(ListContext);
const slideRef = useRef<HTMLDivElement>(null);
useResizeObserver({
ref: slideRef,
onResize: () => {
setProperty(value);
},
});
const transformToWidth = (x: number = start) => {
const abs = x - start;
const maxWidth = (slideRef.current?.clientWidth || 1) - margin * 2;
const result = (abs / stroke) * maxWidth;
return result;
};
const setProperty = (value?: number) => {
const initWidth = transformToWidth(value);
slideRef.current?.style.setProperty(
"--slide-value-size",
`${initWidth + margin}px`,
);
};
useEffect(() => {
update?.({ type: "range" });
}, []);
return (
<div
className={`flex flex-col justify-center items-end gap-1 w-[100%] ${className} ${rangeClassName}`}
>
{!!description && (
<div className=" text-common text-sm ">{description}</div>
)}
<div
className="flex my-1.5 relative w-[100%] h-1.5 bg-slider rounded-slide cursor-pointer"
ref={slideRef}
>
<div className="cursor-pointer absolute marker:top-0 h-[100%] w-[var(--slide-value-size)] bg-slider-slided-travel rounded-slide">
&nbsp;
</div>
<div
className="cursor-pointer absolute z-1 w-[30px] top-[50%] translate-y-[-50%] left-[var(--slide-value-size)] translate-x-[-50%] h-slide-btn leading-slide-btn text-sm-mobile text-center rounded-slide border border-slider-block bg-slider-block hover:bg-slider-block-hover text-text-slider-block"
// onPointerDown={onPointerDown}
>
{value}
</div>
<input
type="range"
className="w-[100%] h-[100%] opacity-0 cursor-pointer"
value={value}
min={start}
max={start + stroke}
step={step}
onChange={(e) => {
setProperty(e.target.valueAsNumber);
onSlide?.(e.target.valueAsNumber);
}}
style={{
marginLeft: margin,
marginRight: margin,
}}
/>
</div>
</div>
);
}

View File

@@ -1,33 +0,0 @@
import * as RadixSwitch from "@radix-ui/react-switch";
import { useContext } from "react";
import List from "../List";
interface SwitchProps {
value: boolean;
onChange: (v: boolean) => void;
}
export default function Switch(props: SwitchProps) {
const { value, onChange } = props;
const { switchClassName = "" } = useContext(List.ListContext);
return (
<RadixSwitch.Root
checked={value}
onCheckedChange={onChange}
className={`
cursor-pointer flex w-switch h-switch p-0.5 box-content rounded-md transition-colors duration-300 ease-in-out
${switchClassName}
${
value
? "bg-switch-checked justify-end"
: "bg-switch-unchecked justify-start"
}
`}
>
<RadixSwitch.Thumb
className={` bg-switch-btn block w-4 h-4 drop-shadow-sm rounded-md`}
/>
</RadixSwitch.Root>
);
}

View File

@@ -1,27 +0,0 @@
import ImgDeleteIcon from "@/app/icons/imgDeleteIcon.svg";
export interface ThumbnailProps {
image: string;
deleteImage: () => void;
}
export default function Thumbnail(props: ThumbnailProps) {
const { image, deleteImage } = props;
return (
<div
className={` h-thumbnail w-thumbnail cursor-default border border-thumbnail rounded-action-btn flex-0 bg-cover bg-center`}
style={{ backgroundImage: `url("${image}")` }}
>
<div
className={` w-[100%] h-[100%] opacity-0 transition-all duration-200 rounded-action-btn hover:opacity-100 hover:bg-thumbnail-mask`}
>
<div
className={`cursor-pointer flex items-center justify-center float-right`}
onClick={deleteImage}
>
<ImgDeleteIcon />
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -6,8 +6,6 @@
width: 100%;
flex-direction: column;
background-color: var(--white);
.auth-logo {
transform: scale(1.4);
}
@@ -35,18 +33,4 @@
margin-bottom: 10px;
}
}
input[type="number"],
input[type="text"],
input[type="password"] {
appearance: none;
border-radius: 10px;
border: var(--border-in-light);
min-height: 36px;
box-sizing: border-box;
background: var(--white);
color: var(--black);
padding: 0 10px;
max-width: 50%;
font-family: inherit;
}
}

View File

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

View File

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

View File

@@ -37,6 +37,10 @@ import AutoIcon from "../icons/auto.svg";
import BottomIcon from "../icons/bottom.svg";
import StopIcon from "../icons/pause.svg";
import RobotIcon from "../icons/robot.svg";
import SizeIcon from "../icons/size.svg";
import QualityIcon from "../icons/hd.svg";
import StyleIcon from "../icons/palette.svg";
import PluginIcon from "../icons/plugin.svg";
import {
ChatMessage,
@@ -59,12 +63,15 @@ import {
getMessageTextContent,
getMessageImages,
isVisionModel,
compressImage,
isDalle3,
} from "../utils";
import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
import dynamic from "next/dynamic";
import { ChatControllerPool } from "../client/controller";
import { DalleSize, DalleQuality, DalleStyle } from "../typing";
import { Prompt, usePromptStore } from "../store/prompt";
import Locale from "../locales";
@@ -87,6 +94,8 @@ import {
Path,
REQUEST_TIMEOUT_MS,
UNFINISHED_INPUT,
ServiceProvider,
Plugin,
} from "../constant";
import { Avatar } from "./emoji";
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
@@ -243,11 +252,11 @@ function useSubmitHandler() {
};
}
export type RenderPompt = Pick<Prompt, "title" | "content">;
export type RenderPrompt = Pick<Prompt, "title" | "content">;
export function PromptHints(props: {
prompts: RenderPompt[];
onPromptSelect: (prompt: RenderPompt) => void;
prompts: RenderPrompt[];
onPromptSelect: (prompt: RenderPrompt) => void;
}) {
const noPrompts = props.prompts.length === 0;
const [selectIndex, setSelectIndex] = useState(0);
@@ -336,7 +345,7 @@ function ClearContextDivider() {
);
}
function ChatAction(props: {
export function ChatAction(props: {
text: string;
icon: JSX.Element;
onClick: () => void;
@@ -447,6 +456,9 @@ export function ChatActions(props: {
// switch model
const currentModel = chatStore.currentSession().mask.modelConfig.model;
const currentProviderName =
chatStore.currentSession().mask.modelConfig?.providerName ||
ServiceProvider.OpenAI;
const allModels = useAllModels();
const models = useMemo(() => {
const filteredModels = allModels.filter((m) => m.available);
@@ -462,9 +474,31 @@ export function ChatActions(props: {
return filteredModels;
}
}, [allModels]);
const currentModelName = useMemo(() => {
const model = models.find(
(m) =>
m.name == currentModel &&
m?.provider?.providerName == currentProviderName,
);
return model?.displayName ?? "";
}, [models, currentModel, currentProviderName]);
const [showModelSelector, setShowModelSelector] = useState(false);
const [showPluginSelector, setShowPluginSelector] = useState(false);
const [showUploadImage, setShowUploadImage] = useState(false);
const [showSizeSelector, setShowSizeSelector] = useState(false);
const [showQualitySelector, setShowQualitySelector] = useState(false);
const [showStyleSelector, setShowStyleSelector] = useState(false);
const dalle3Sizes: DalleSize[] = ["1024x1024", "1792x1024", "1024x1792"];
const dalle3Qualitys: DalleQuality[] = ["standard", "hd"];
const dalle3Styles: DalleStyle[] = ["vivid", "natural"];
const currentSize =
chatStore.currentSession().mask.modelConfig?.size ?? "1024x1024";
const currentQuality =
chatStore.currentSession().mask.modelConfig?.quality ?? "standard";
const currentStyle =
chatStore.currentSession().mask.modelConfig?.style ?? "vivid";
useEffect(() => {
const show = isVisionModel(currentModel);
setShowUploadImage(show);
@@ -478,13 +512,17 @@ export function ChatActions(props: {
const isUnavaliableModel = !models.some((m) => m.name === currentModel);
if (isUnavaliableModel && models.length > 0) {
// show next model to default model if exist
let nextModel: ModelType = (
models.find((model) => model.isDefault) || models[0]
).name;
chatStore.updateCurrentSession(
(session) => (session.mask.modelConfig.model = nextModel),
let nextModel = models.find((model) => model.isDefault) || models[0];
chatStore.updateCurrentSession((session) => {
session.mask.modelConfig.model = nextModel.name;
session.mask.modelConfig.providerName = nextModel?.provider
?.providerName as ServiceProvider;
});
showToast(
nextModel?.provider?.providerName == "ByteDance"
? nextModel.displayName
: nextModel.name,
);
showToast(nextModel);
}
}, [chatStore, currentModel, models]);
@@ -566,25 +604,149 @@ export function ChatActions(props: {
<ChatAction
onClick={() => setShowModelSelector(true)}
text={currentModel}
text={currentModelName}
icon={<RobotIcon />}
/>
{showModelSelector && (
<Selector
defaultSelectedValue={currentModel}
defaultSelectedValue={`${currentModel}@${currentProviderName}`}
items={models.map((m) => ({
title: m.displayName,
value: m.name,
title: `${m.displayName}${
m?.provider?.providerName
? "(" + m?.provider?.providerName + ")"
: ""
}`,
value: `${m.name}@${m?.provider?.providerName}`,
}))}
onClose={() => setShowModelSelector(false)}
onSelection={(s) => {
if (s.length === 0) return;
const [model, providerName] = s[0].split("@");
chatStore.updateCurrentSession((session) => {
session.mask.modelConfig.model = s[0] as ModelType;
session.mask.modelConfig.model = model as ModelType;
session.mask.modelConfig.providerName =
providerName as ServiceProvider;
session.mask.syncGlobalConfig = false;
});
showToast(s[0]);
if (providerName == "ByteDance") {
const selectedModel = models.find(
(m) =>
m.name == model && m?.provider?.providerName == providerName,
);
showToast(selectedModel?.displayName ?? "");
} else {
showToast(model);
}
}}
/>
)}
{isDalle3(currentModel) && (
<ChatAction
onClick={() => setShowSizeSelector(true)}
text={currentSize}
icon={<SizeIcon />}
/>
)}
{showSizeSelector && (
<Selector
defaultSelectedValue={currentSize}
items={dalle3Sizes.map((m) => ({
title: m,
value: m,
}))}
onClose={() => setShowSizeSelector(false)}
onSelection={(s) => {
if (s.length === 0) return;
const size = s[0];
chatStore.updateCurrentSession((session) => {
session.mask.modelConfig.size = size;
});
showToast(size);
}}
/>
)}
{isDalle3(currentModel) && (
<ChatAction
onClick={() => setShowQualitySelector(true)}
text={currentQuality}
icon={<QualityIcon />}
/>
)}
{showQualitySelector && (
<Selector
defaultSelectedValue={currentQuality}
items={dalle3Qualitys.map((m) => ({
title: m,
value: m,
}))}
onClose={() => setShowQualitySelector(false)}
onSelection={(q) => {
if (q.length === 0) return;
const quality = q[0];
chatStore.updateCurrentSession((session) => {
session.mask.modelConfig.quality = quality;
});
showToast(quality);
}}
/>
)}
{isDalle3(currentModel) && (
<ChatAction
onClick={() => setShowStyleSelector(true)}
text={currentStyle}
icon={<StyleIcon />}
/>
)}
{showStyleSelector && (
<Selector
defaultSelectedValue={currentStyle}
items={dalle3Styles.map((m) => ({
title: m,
value: m,
}))}
onClose={() => setShowStyleSelector(false)}
onSelection={(s) => {
if (s.length === 0) return;
const style = s[0];
chatStore.updateCurrentSession((session) => {
session.mask.modelConfig.style = style;
});
showToast(style);
}}
/>
)}
<ChatAction
onClick={() => setShowPluginSelector(true)}
text={Locale.Plugin.Name}
icon={<PluginIcon />}
/>
{showPluginSelector && (
<Selector
multiple
defaultSelectedValue={chatStore.currentSession().mask?.plugin}
items={[
{
title: Locale.Plugin.Artifacts,
value: Plugin.Artifacts,
},
]}
onClose={() => setShowPluginSelector(false)}
onSelection={(s) => {
const plugin = s[0];
chatStore.updateCurrentSession((session) => {
session.mask.plugin = s;
});
if (plugin) {
showToast(plugin);
}
}}
/>
)}
@@ -669,6 +831,7 @@ function _Chat() {
const session = chatStore.currentSession();
const config = useAppConfig();
const fontSize = config.fontSize;
const fontFamily = config.fontFamily;
const [showExport, setShowExport] = useState(false);
@@ -695,7 +858,7 @@ function _Chat() {
// prompt hints
const promptStore = usePromptStore();
const [promptHints, setPromptHints] = useState<RenderPompt[]>([]);
const [promptHints, setPromptHints] = useState<RenderPrompt[]>([]);
const onSearch = useDebouncedCallback(
(text: string) => {
const matchedPrompts = promptStore.search(text);
@@ -748,7 +911,7 @@ function _Chat() {
// clear search results
if (n === 0) {
setPromptHints([]);
} else if (text.startsWith(ChatCommandPrefix)) {
} else if (text.match(ChatCommandPrefix)) {
setPromptHints(chatCommands.search(text));
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
// check if need to trigger auto completion
@@ -780,7 +943,7 @@ function _Chat() {
setAutoScroll(true);
};
const onPromptSelect = (prompt: RenderPompt) => {
const onPromptSelect = (prompt: RenderPrompt) => {
setTimeout(() => {
setPromptHints([]);
@@ -1088,6 +1251,7 @@ function _Chat() {
if (payload.url) {
accessStore.update((access) => (access.openaiUrl = payload.url!));
}
accessStore.update((access) => (access.useCustomConfig = true));
});
}
} catch {
@@ -1134,7 +1298,7 @@ function _Chat() {
...(await new Promise<string[]>((res, rej) => {
setUploading(true);
const imagesData: string[] = [];
compressImage(file, 256 * 1024)
uploadImageRemote(file)
.then((dataUrl) => {
imagesData.push(dataUrl);
setUploading(false);
@@ -1176,7 +1340,7 @@ function _Chat() {
const imagesData: string[] = [];
for (let i = 0; i < files.length; i++) {
const file = event.target.files[i];
compressImage(file, 256 * 1024)
uploadImageRemote(file)
.then((dataUrl) => {
imagesData.push(dataUrl);
if (
@@ -1237,6 +1401,8 @@ function _Chat() {
<IconButton
icon={<RenameIcon />}
bordered
title={Locale.Chat.EditMessage.Title}
aria={Locale.Chat.EditMessage.Title}
onClick={() => setIsEditingMessage(true)}
/>
</div>
@@ -1256,6 +1422,8 @@ function _Chat() {
<IconButton
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
bordered
title={Locale.Chat.Actions.FullScreen}
aria={Locale.Chat.Actions.FullScreen}
onClick={() => {
config.update(
(config) => (config.tightBorder = !config.tightBorder),
@@ -1307,6 +1475,7 @@ function _Chat() {
<div className={styles["chat-message-edit"]}>
<IconButton
icon={<EditIcon />}
aria={Locale.Chat.Actions.Edit}
onClick={async () => {
const newMessage = await showPrompt(
Locale.Chat.Actions.Edit,
@@ -1355,6 +1524,11 @@ function _Chat() {
</>
)}
</div>
{!isUser && (
<div className={styles["chat-model-name"]}>
{message.model}
</div>
)}
{showActions && (
<div className={styles["chat-message-actions"]}>
@@ -1406,6 +1580,7 @@ function _Chat() {
)}
<div className={styles["chat-message-item"]}>
<Markdown
key={message.streaming ? "loading" : "done"}
content={getMessageTextContent(message)}
loading={
(message.preview || message.streaming) &&
@@ -1418,6 +1593,7 @@ function _Chat() {
setUserInput(getMessageTextContent(message));
}}
fontSize={fontSize}
fontFamily={fontFamily}
parentRef={scrollRef}
defaultShow={i >= messages.length - 6}
/>
@@ -1512,6 +1688,7 @@ function _Chat() {
autoFocus={autoFocus}
style={{
fontSize: config.fontSize,
fontFamily: config.fontFamily,
}}
/>
{attachImages.length != 0 && (

View File

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

View File

@@ -2,9 +2,6 @@
&-body {
margin-top: 20px;
}
div:not(.no-dark) > svg {
filter: invert(0.5);
}
}
.export-content {

View File

@@ -36,11 +36,10 @@ import { toBlob, toPng } from "html-to-image";
import { DEFAULT_MASK_AVATAR } from "../store/mask";
import { prettyObject } from "../utils/format";
import { EXPORT_MESSAGE_CLASS_NAME, ModelProvider } from "../constant";
import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
import { getClientConfig } from "../config/client";
import { ClientApi } from "../client/api";
import { type ClientApi, getClientApi } from "../client/api";
import { getMessageTextContent } from "../utils";
import { identifyDefaultClaudeModel } from "../utils/checkers";
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
@@ -313,14 +312,7 @@ export function PreviewActions(props: {
const onRenderMsgs = (msgs: ChatMessage[]) => {
setShouldExport(false);
var api: ClientApi;
if (config.modelConfig.model.startsWith("gemini")) {
api = new ClientApi(ModelProvider.GeminiPro);
} else if (identifyDefaultClaudeModel(config.modelConfig.model)) {
api = new ClientApi(ModelProvider.Claude);
} else {
api = new ClientApi(ModelProvider.GPT);
}
const api: ClientApi = getClientApi(config.modelConfig.providerName);
api
.share(msgs)
@@ -549,7 +541,7 @@ export function ImagePreviewer(props: {
<div>
<div className={styles["main-title"]}>NextChat</div>
<div className={styles["sub-title"]}>
github.com/Yidadaa/ChatGPT-Next-Web
github.com/ChatGPTNextWeb/ChatGPT-Next-Web
</div>
<div className={styles["icons"]}>
<ExportAvatar avatar={config.avatar} />
@@ -591,6 +583,7 @@ export function ImagePreviewer(props: {
<Markdown
content={getMessageTextContent(m)}
fontSize={config.fontSize}
fontFamily={config.fontFamily}
defaultShow
/>
{getMessageImages(m).length == 1 && (

View File

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

View File

@@ -12,7 +12,7 @@ import LoadingIcon from "../icons/three-dots.svg";
import { getCSSVar, useMobileScreen } from "../utils";
import dynamic from "next/dynamic";
import { ModelProvider, Path, SlotID } from "../constant";
import { Path, SlotID } from "../constant";
import { ErrorBoundary } from "./error";
import { getISOLang, getLang } from "../locales";
@@ -27,9 +27,8 @@ import { SideBar } from "./sidebar";
import { useAppConfig } from "../store/config";
import { AuthPage } from "./auth";
import { getClientConfig } from "../config/client";
import { ClientApi } from "../client/api";
import { type ClientApi, getClientApi } from "../client/api";
import { useAccessStore } from "../store";
import { identifyDefaultClaudeModel } from "../utils/checkers";
export function Loading(props: { noLogo?: boolean }) {
return (
@@ -40,6 +39,10 @@ export function Loading(props: { noLogo?: boolean }) {
);
}
const Artifacts = dynamic(async () => (await import("./artifacts")).Artifacts, {
loading: () => <Loading noLogo />,
});
const Settings = dynamic(async () => (await import("./settings")).Settings, {
loading: () => <Loading noLogo />,
});
@@ -56,6 +59,10 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
loading: () => <Loading noLogo />,
});
const Sd = dynamic(async () => (await import("./sd")).Sd, {
loading: () => <Loading noLogo />,
});
export function useSwitchTheme() {
const config = useAppConfig();
@@ -123,11 +130,23 @@ const loadAsyncGoogleFont = () => {
document.head.appendChild(linkEl);
};
export function WindowContent(props: { children: React.ReactNode }) {
return (
<div className={styles["window-content"]} id={SlotID.AppBody}>
{props?.children}
</div>
);
}
function Screen() {
const config = useAppConfig();
const location = useLocation();
const isArtifact = location.pathname.includes(Path.Artifacts);
const isHome = location.pathname === Path.Home;
const isAuth = location.pathname === Path.Auth;
const isSd = location.pathname === Path.Sd;
const isSdNew = location.pathname === Path.SdNew;
const isMobileScreen = useMobileScreen();
const shouldTightBorder =
getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
@@ -136,34 +155,40 @@ function Screen() {
loadAsyncGoogleFont();
}, []);
if (isArtifact) {
return (
<Routes>
<Route path="/artifacts/:id" element={<Artifacts />} />
</Routes>
);
}
const renderContent = () => {
if (isAuth) return <AuthPage />;
if (isSd) return <Sd />;
if (isSdNew) return <Sd />;
return (
<>
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
<WindowContent>
<Routes>
<Route path={Path.Home} element={<Chat />} />
<Route path={Path.NewChat} element={<NewChat />} />
<Route path={Path.Masks} element={<MaskPage />} />
<Route path={Path.Chat} element={<Chat />} />
<Route path={Path.Settings} element={<Settings />} />
</Routes>
</WindowContent>
</>
);
};
return (
<div
className={
styles.container +
` ${shouldTightBorder ? styles["tight-container"] : styles.container} ${
getLang() === "ar" ? styles["rtl-screen"] : ""
}`
}
className={`${styles.container} ${
shouldTightBorder ? styles["tight-container"] : styles.container
} ${getLang() === "ar" ? styles["rtl-screen"] : ""}`}
>
{isAuth ? (
<>
<AuthPage />
</>
) : (
<>
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
<div className={styles["window-content"]} id={SlotID.AppBody}>
<Routes>
<Route path={Path.Home} element={<Chat />} />
<Route path={Path.NewChat} element={<NewChat />} />
<Route path={Path.Masks} element={<MaskPage />} />
<Route path={Path.Chat} element={<Chat />} />
<Route path={Path.Settings} element={<Settings />} />
</Routes>
</div>
</>
)}
{renderContent()}
</div>
);
}
@@ -171,14 +196,8 @@ function Screen() {
export function useLoadData() {
const config = useAppConfig();
var api: ClientApi;
if (config.modelConfig.model.startsWith("gemini")) {
api = new ClientApi(ModelProvider.GeminiPro);
} else if (identifyDefaultClaudeModel(config.modelConfig.model)) {
api = new ClientApi(ModelProvider.Claude);
} else {
api = new ClientApi(ModelProvider.GPT);
}
const api: ClientApi = getClientApi(config.modelConfig.providerName);
useEffect(() => {
(async () => {
const models = await api.llm.models();

View File

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

View File

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

View File

@@ -4,10 +4,6 @@
display: flex;
flex-direction: column;
div:not(.no-dark) > svg {
filter: invert(0.5);
}
.mask-page-body {
padding: 20px;
overflow-y: auto;

View File

@@ -1,4 +1,5 @@
import { IconButton } from "./button";
import { ErrorBoundary } from "./error";
import styles from "./mask.module.scss";
@@ -55,7 +56,6 @@ import {
OnDragEndResponder,
} from "@hello-pangea/dnd";
import { getMessageTextContent } from "../utils";
import useMobileScreen from "@/app/hooks/useMobileScreen";
// drag and drop helper function
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
@@ -127,6 +127,8 @@ export function MaskConfig(props: {
onClose={() => setShowPicker(false)}
>
<div
tabIndex={0}
aria-label={Locale.Mask.Config.Avatar}
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
>
@@ -139,6 +141,7 @@ export function MaskConfig(props: {
</ListItem>
<ListItem title={Locale.Mask.Config.Name}>
<input
aria-label={Locale.Mask.Config.Name}
type="text"
value={props.mask.name}
onInput={(e) =>
@@ -153,6 +156,7 @@ export function MaskConfig(props: {
subTitle={Locale.Mask.Config.HideContext.SubTitle}
>
<input
aria-label={Locale.Mask.Config.HideContext.Title}
type="checkbox"
checked={props.mask.hideContext}
onChange={(e) => {
@@ -169,6 +173,7 @@ export function MaskConfig(props: {
subTitle={Locale.Mask.Config.Share.SubTitle}
>
<IconButton
aria={Locale.Mask.Config.Share.Title}
icon={<CopyIcon />}
text={Locale.Mask.Config.Share.Action}
onClick={copyMaskLink}
@@ -182,6 +187,7 @@ export function MaskConfig(props: {
subTitle={Locale.Mask.Config.Sync.SubTitle}
>
<input
aria-label={Locale.Mask.Config.Sync.Title}
type="checkbox"
checked={props.mask.syncGlobalConfig}
onChange={async (e) => {
@@ -398,7 +404,7 @@ export function ContextPrompts(props: {
);
}
export function MaskPage(props: { className?: string }) {
export function MaskPage() {
const navigate = useNavigate();
const maskStore = useMaskStore();
@@ -466,13 +472,8 @@ export function MaskPage(props: { className?: string }) {
};
return (
<>
<div
className={`
${styles["mask-page"]}
${props.className}
`}
>
<ErrorBoundary>
<div className={styles["mask-page"]}>
<div className="window-header">
<div className="window-header-title">
<div className="window-header-main-title">
@@ -650,6 +651,6 @@ export function MaskPage(props: { className?: string }) {
</Modal>
</div>
)}
</>
</ErrorBoundary>
);
}

View File

@@ -1,3 +1,4 @@
import { ServiceProvider } from "@/app/constant";
import { ModalConfigValidator, ModelConfig } from "../store";
import Locale from "../locales";
@@ -10,25 +11,26 @@ export function ModelConfigList(props: {
updateConfig: (updater: (config: ModelConfig) => void) => void;
}) {
const allModels = useAllModels();
const value = `${props.modelConfig.model}@${props.modelConfig?.providerName}`;
return (
<>
<ListItem title={Locale.Settings.Model}>
<Select
value={props.modelConfig.model}
aria-label={Locale.Settings.Model}
value={value}
onChange={(e) => {
props.updateConfig(
(config) =>
(config.model = ModalConfigValidator.model(
e.currentTarget.value,
)),
);
const [model, providerName] = e.currentTarget.value.split("@");
props.updateConfig((config) => {
config.model = ModalConfigValidator.model(model);
config.providerName = providerName as ServiceProvider;
});
}}
>
{allModels
.filter((v) => v.available)
.map((v, i) => (
<option value={v.name} key={i}>
<option value={`${v.name}@${v.provider?.providerName}`} key={i}>
{v.displayName}({v.provider?.providerName})
</option>
))}
@@ -39,6 +41,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.Temperature.SubTitle}
>
<InputRange
aria={Locale.Settings.Temperature.Title}
value={props.modelConfig.temperature?.toFixed(1)}
min="0"
max="1" // lets limit it to 0-1
@@ -58,6 +61,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.TopP.SubTitle}
>
<InputRange
aria={Locale.Settings.TopP.Title}
value={(props.modelConfig.top_p ?? 1).toFixed(1)}
min="0"
max="1"
@@ -77,6 +81,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.MaxTokens.SubTitle}
>
<input
aria-label={Locale.Settings.MaxTokens.Title}
type="number"
min={1024}
max={512000}
@@ -92,13 +97,14 @@ export function ModelConfigList(props: {
></input>
</ListItem>
{props.modelConfig.model.startsWith("gemini") ? null : (
{props.modelConfig?.providerName == ServiceProvider.Google ? null : (
<>
<ListItem
title={Locale.Settings.PresencePenalty.Title}
subTitle={Locale.Settings.PresencePenalty.SubTitle}
>
<InputRange
aria={Locale.Settings.PresencePenalty.Title}
value={props.modelConfig.presence_penalty?.toFixed(1)}
min="-2"
max="2"
@@ -120,6 +126,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
>
<InputRange
aria={Locale.Settings.FrequencyPenalty.Title}
value={props.modelConfig.frequency_penalty?.toFixed(1)}
min="-2"
max="2"
@@ -141,6 +148,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
>
<input
aria-label={Locale.Settings.InjectSystemPrompts.Title}
type="checkbox"
checked={props.modelConfig.enableInjectSystemPrompts}
onChange={(e) =>
@@ -158,6 +166,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.InputTemplate.SubTitle}
>
<input
aria-label={Locale.Settings.InputTemplate.Title}
type="text"
value={props.modelConfig.template}
onChange={(e) =>
@@ -174,6 +183,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.HistoryCount.SubTitle}
>
<InputRange
aria={Locale.Settings.HistoryCount.Title}
title={props.modelConfig.historyMessageCount.toString()}
value={props.modelConfig.historyMessageCount}
min="0"
@@ -192,6 +202,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.CompressThreshold.SubTitle}
>
<input
aria-label={Locale.Settings.CompressThreshold.Title}
type="number"
min={500}
max={4000}
@@ -207,6 +218,7 @@ export function ModelConfigList(props: {
</ListItem>
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
<input
aria-label={Locale.Memory.Title}
type="checkbox"
checked={props.modelConfig.sendMemory}
onChange={(e) =>

View File

@@ -8,10 +8,6 @@
justify-content: center;
flex-direction: column;
div:not(.no-dark) > svg {
filter: invert(0.5);
}
.mask-header {
display: flex;
justify-content: space-between;

View File

@@ -16,7 +16,6 @@ import { MaskAvatar } from "./mask";
import { useCommand } from "../command";
import { showConfirm } from "./ui-lib";
import { BUILTIN_MASK_STORE } from "../masks";
import useMobileScreen from "@/app/hooks/useMobileScreen";
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
return (
@@ -72,7 +71,7 @@ function useMaskGroup(masks: Mask[]) {
return groups;
}
export function NewChat(props: { className?: string }) {
export function NewChat() {
const chatStore = useChatStore();
const maskStore = useMaskStore();
@@ -111,15 +110,8 @@ export function NewChat(props: { className?: string }) {
}
}, [groups]);
const isMobileScreen = useMobileScreen();
return (
<div
className={`
${styles["new-chat"]}
${props.className}
`}
>
<div className={styles["new-chat"]}>
<div className={styles["mask-header"]}>
<IconButton
icon={<LeftIcon />}

View File

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

View File

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

View File

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

View File

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

View File

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

336
app/components/sd/sd.tsx Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import tauriConfig from "../../src-tauri/tauri.conf.json";
import { DEFAULT_INPUT_TEMPLATE } from "../constant";
export const getBuildConfig = () => {
if (typeof process === "undefined") {
@@ -38,6 +39,7 @@ export const getBuildConfig = () => {
...commitInfo,
buildMode,
isApp,
template: process.env.DEFAULT_INPUT_TEMPLATE ?? DEFAULT_INPUT_TEMPLATE,
};
};

View File

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

View File

@@ -1,5 +1,5 @@
import md5 from "spark-md5";
import { DEFAULT_MODELS } from "../constant";
import { DEFAULT_MODELS, DEFAULT_GA_ID } from "../constant";
declare global {
namespace NodeJS {
@@ -21,7 +21,11 @@ declare global {
ENABLE_BALANCE_QUERY?: string; // allow user to query balance or not
DISABLE_FAST_LINK?: string; // disallow parse settings from url or not
CUSTOM_MODELS?: string; // to control custom models
DEFAULT_MODEL?: string; // to cnntrol default model in every new chat window
DEFAULT_MODEL?: string; // to control default model in every new chat window
// stability only
STABILITY_URL?: string;
STABILITY_API_KEY?: string;
// azure only
AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
@@ -34,6 +38,41 @@ declare global {
// google tag manager
GTM_ID?: string;
// anthropic only
ANTHROPIC_URL?: string;
ANTHROPIC_API_KEY?: string;
ANTHROPIC_API_VERSION?: string;
// baidu only
BAIDU_URL?: string;
BAIDU_API_KEY?: string;
BAIDU_SECRET_KEY?: string;
// bytedance only
BYTEDANCE_URL?: string;
BYTEDANCE_API_KEY?: string;
// alibaba only
ALIBABA_URL?: string;
ALIBABA_API_KEY?: string;
// tencent only
TENCENT_URL?: string;
TENCENT_SECRET_KEY?: string;
TENCENT_SECRET_ID?: string;
// moonshot only
MOONSHOT_URL?: string;
MOONSHOT_API_KEY?: string;
// iflytek only
IFLYTEK_URL?: string;
IFLYTEK_API_KEY?: string;
IFLYTEK_API_SECRET?: string;
// custom template for preprocessing user input
DEFAULT_INPUT_TEMPLATE?: string;
}
}
}
@@ -51,6 +90,22 @@ const ACCESS_CODES = (function getAccessCodes(): Set<string> {
}
})();
function getApiKey(keys?: string) {
const apiKeyEnvVar = keys ?? "";
const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
const randomIndex = Math.floor(Math.random() * apiKeys.length);
const apiKey = apiKeys[randomIndex];
if (apiKey) {
console.log(
`[Server Config] using ${randomIndex + 1} of ${
apiKeys.length
} api key - ${apiKey}`,
);
}
return apiKey;
}
export const getServerSideConfig = () => {
if (typeof process === "undefined") {
throw Error(
@@ -64,48 +119,99 @@ export const getServerSideConfig = () => {
if (disableGPT4) {
if (customModels) customModels += ",";
customModels += DEFAULT_MODELS.filter((m) => m.name.startsWith("gpt-4"))
customModels += DEFAULT_MODELS.filter(
(m) => m.name.startsWith("gpt-4") && !m.name.startsWith("gpt-4o-mini"),
)
.map((m) => "-" + m.name)
.join(",");
if (defaultModel.startsWith("gpt-4")) defaultModel = "";
if (
defaultModel.startsWith("gpt-4") &&
!defaultModel.startsWith("gpt-4o-mini")
)
defaultModel = "";
}
const isStability = !!process.env.STABILITY_API_KEY;
const isAzure = !!process.env.AZURE_URL;
const isGoogle = !!process.env.GOOGLE_API_KEY;
const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
const isTencent = !!process.env.TENCENT_API_KEY;
const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
const randomIndex = Math.floor(Math.random() * apiKeys.length);
const apiKey = apiKeys[randomIndex];
console.log(
`[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`,
);
const isBaidu = !!process.env.BAIDU_API_KEY;
const isBytedance = !!process.env.BYTEDANCE_API_KEY;
const isAlibaba = !!process.env.ALIBABA_API_KEY;
const isMoonshot = !!process.env.MOONSHOT_API_KEY;
const isIflytek = !!process.env.IFLYTEK_API_KEY;
// const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
// const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
// const randomIndex = Math.floor(Math.random() * apiKeys.length);
// const apiKey = apiKeys[randomIndex];
// console.log(
// `[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`,
// );
const whiteWebDevEndpoints = (process.env.WHITE_WEBDEV_ENDPOINTS ?? "").split(
",",
);
const allowedWebDevEndpoints = (
process.env.WHITE_WEBDEV_ENDPOINTS ?? ""
).split(",");
return {
baseUrl: process.env.BASE_URL,
apiKey,
apiKey: getApiKey(process.env.OPENAI_API_KEY),
openaiOrgId: process.env.OPENAI_ORG_ID,
isStability,
stabilityUrl: process.env.STABILITY_URL,
stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY),
isAzure,
azureUrl: process.env.AZURE_URL,
azureApiKey: process.env.AZURE_API_KEY,
azureApiKey: getApiKey(process.env.AZURE_API_KEY),
azureApiVersion: process.env.AZURE_API_VERSION,
isGoogle,
googleApiKey: process.env.GOOGLE_API_KEY,
googleApiKey: getApiKey(process.env.GOOGLE_API_KEY),
googleUrl: process.env.GOOGLE_URL,
isAnthropic,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
anthropicApiKey: getApiKey(process.env.ANTHROPIC_API_KEY),
anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
anthropicUrl: process.env.ANTHROPIC_URL,
isBaidu,
baiduUrl: process.env.BAIDU_URL,
baiduApiKey: getApiKey(process.env.BAIDU_API_KEY),
baiduSecretKey: process.env.BAIDU_SECRET_KEY,
isBytedance,
bytedanceApiKey: getApiKey(process.env.BYTEDANCE_API_KEY),
bytedanceUrl: process.env.BYTEDANCE_URL,
isAlibaba,
alibabaUrl: process.env.ALIBABA_URL,
alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY),
isTencent,
tencentUrl: process.env.TENCENT_URL,
tencentSecretKey: getApiKey(process.env.TENCENT_SECRET_KEY),
tencentSecretId: process.env.TENCENT_SECRET_ID,
isMoonshot,
moonshotUrl: process.env.MOONSHOT_URL,
moonshotApiKey: getApiKey(process.env.MOONSHOT_API_KEY),
isIflytek,
iflytekUrl: process.env.IFLYTEK_URL,
iflytekApiKey: process.env.IFLYTEK_API_KEY,
iflytekApiSecret: process.env.IFLYTEK_API_SECRET,
cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID,
cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID,
cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY),
cloudflareKVTTL: process.env.CLOUDFLARE_KV_TTL,
gtmId: process.env.GTM_ID,
gaId: process.env.GA_ID || DEFAULT_GA_ID,
needCode: ACCESS_CODES.size > 0,
code: process.env.CODE,
@@ -120,6 +226,6 @@ export const getServerSideConfig = () => {
disableFastLink: !!process.env.DISABLE_FAST_LINK,
customModels,
defaultModel,
whiteWebDevEndpoints,
allowedWebDevEndpoints,
};
};

View File

@@ -1,4 +1,4 @@
export const OWNER = "Yidadaa";
export const OWNER = "ChatGPTNextWeb";
export const REPO = "ChatGPT-Next-Web";
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
@@ -8,12 +8,29 @@ export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/c
export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
export const STABILITY_BASE_URL = "https://api.stability.ai";
export const DEFAULT_API_HOST = "https://api.nextchat.dev";
export const OPENAI_BASE_URL = "https://api.openai.com";
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/";
export const BAIDU_BASE_URL = "https://aip.baidubce.com";
export const BAIDU_OATUH_URL = `${BAIDU_BASE_URL}/oauth/2.0/token`;
export const BYTEDANCE_BASE_URL = "https://ark.cn-beijing.volces.com";
export const ALIBABA_BASE_URL = "https://dashscope.aliyuncs.com/api/";
export const TENCENT_BASE_URL = "https://hunyuan.tencentcloudapi.com";
export const MOONSHOT_BASE_URL = "https://api.moonshot.cn";
export const IFLYTEK_BASE_URL = "https://spark-api-open.xf-yun.com";
export const CACHE_URL_PREFIX = "/api/cache";
export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`;
export enum Path {
Home = "/",
Chat = "/chat",
@@ -21,12 +38,25 @@ export enum Path {
NewChat = "/new-chat",
Masks = "/masks",
Auth = "/auth",
Sd = "/sd",
SdNew = "/sd-new",
Artifacts = "/artifacts",
}
export enum ApiPath {
Cors = "",
Azure = "/api/azure",
OpenAI = "/api/openai",
Anthropic = "/api/anthropic",
Google = "/api/google",
Baidu = "/api/baidu",
ByteDance = "/api/bytedance",
Alibaba = "/api/alibaba",
Tencent = "/api/tencent",
Moonshot = "/api/moonshot",
Iflytek = "/api/iflytek",
Stability = "/api/stability",
Artifacts = "/api/artifacts",
}
export enum SlotID {
@@ -39,6 +69,10 @@ export enum FileName {
Prompts = "prompts.json",
}
export enum Plugin {
Artifacts = "artifacts",
}
export enum StoreKey {
Chat = "chat-next-web-store",
Access = "access-control",
@@ -47,19 +81,13 @@ export enum StoreKey {
Prompt = "prompt-store",
Update = "chat-update",
Sync = "sync",
SdList = "sd-list",
}
export const NARROW_SIDEBAR_WIDTH = 100;
export const DEFAULT_SIDEBAR_WIDTH = 340;
export const MAX_SIDEBAR_WIDTH = 440;
export const DEFAULT_SIDEBAR_WIDTH = 300;
export const MAX_SIDEBAR_WIDTH = 500;
export const MIN_SIDEBAR_WIDTH = 230;
export const WINDOW_WIDTH_SM = 480;
export const WINDOW_WIDTH_MD = 768;
export const WINDOW_WIDTH_LG = 1120;
export const WINDOW_WIDTH_XL = 1440;
export const WINDOW_WIDTH_2XL = 1980;
export const NARROW_SIDEBAR_WIDTH = 100;
export const ACCESS_CODE_PREFIX = "nk-";
@@ -77,14 +105,42 @@ export enum ServiceProvider {
Azure = "Azure",
Google = "Google",
Anthropic = "Anthropic",
Baidu = "Baidu",
ByteDance = "ByteDance",
Alibaba = "Alibaba",
Tencent = "Tencent",
Moonshot = "Moonshot",
Stability = "Stability",
Iflytek = "Iflytek",
}
// Google API safety settings, see https://ai.google.dev/gemini-api/docs/safety-settings
// BLOCK_NONE will not block any content, and BLOCK_ONLY_HIGH will block only high-risk content.
export enum GoogleSafetySettingsThreshold {
BLOCK_NONE = "BLOCK_NONE",
BLOCK_ONLY_HIGH = "BLOCK_ONLY_HIGH",
BLOCK_MEDIUM_AND_ABOVE = "BLOCK_MEDIUM_AND_ABOVE",
BLOCK_LOW_AND_ABOVE = "BLOCK_LOW_AND_ABOVE",
}
export enum ModelProvider {
Stability = "Stability",
GPT = "GPT",
GeminiPro = "GeminiPro",
Claude = "Claude",
Ernie = "Ernie",
Doubao = "Doubao",
Qwen = "Qwen",
Hunyuan = "Hunyuan",
Moonshot = "Moonshot",
Iflytek = "Iflytek",
}
export const Stability = {
GeneratePath: "v2beta/stable-image/generate",
ExampleEndpoint: "https://api.stability.ai",
};
export const Anthropic = {
ChatPath: "v1/messages",
ChatPath1: "v1/complete",
@@ -94,19 +150,69 @@ export const Anthropic = {
export const OpenaiPath = {
ChatPath: "v1/chat/completions",
ImagePath: "v1/images/generations",
UsagePath: "dashboard/billing/usage",
SubsPath: "dashboard/billing/subscription",
ListModelPath: "v1/models",
};
export const Azure = {
ExampleEndpoint: "https://{resource-url}/openai/deployments/{deploy-id}",
ChatPath: (deployName: string, apiVersion: string) =>
`deployments/${deployName}/chat/completions?api-version=${apiVersion}`,
// https://<your_resource_name>.openai.azure.com/openai/deployments/<your_deployment_name>/images/generations?api-version=<api_version>
ImagePath: (deployName: string, apiVersion: string) =>
`deployments/${deployName}/images/generations?api-version=${apiVersion}`,
ExampleEndpoint: "https://{resource-url}/openai",
};
export const Google = {
ExampleEndpoint: "https://generativelanguage.googleapis.com/",
ChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
VisionChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
ChatPath: (modelName: string) =>
`v1beta/models/${modelName}:streamGenerateContent`,
};
export const Baidu = {
ExampleEndpoint: BAIDU_BASE_URL,
ChatPath: (modelName: string) => {
let endpoint = modelName;
if (modelName === "ernie-4.0-8k") {
endpoint = "completions_pro";
}
if (modelName === "ernie-4.0-8k-preview-0518") {
endpoint = "completions_adv_pro";
}
if (modelName === "ernie-3.5-8k") {
endpoint = "completions";
}
if (modelName === "ernie-speed-8k") {
endpoint = "ernie_speed";
}
return `rpc/2.0/ai_custom/v1/wenxinworkshop/chat/${endpoint}`;
},
};
export const ByteDance = {
ExampleEndpoint: "https://ark.cn-beijing.volces.com/api/",
ChatPath: "api/v3/chat/completions",
};
export const Alibaba = {
ExampleEndpoint: ALIBABA_BASE_URL,
ChatPath: "v1/services/aigc/text-generation/generation",
};
export const Tencent = {
ExampleEndpoint: TENCENT_BASE_URL,
};
export const Moonshot = {
ExampleEndpoint: MOONSHOT_BASE_URL,
ChatPath: "v1/chat/completions",
};
export const Iflytek = {
ExampleEndpoint: IFLYTEK_BASE_URL,
ChatPath: "v1/chat/completions",
};
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
@@ -127,7 +233,7 @@ Latex inline: \\(x^2\\)
Latex block: $$e=mc^2$$
`;
export const SUMMARIZE_MODEL = "gpt-3.5-turbo";
export const SUMMARIZE_MODEL = "gpt-4o-mini";
export const GEMINI_SUMMARIZE_MODEL = "gemini-pro";
export const KnowledgeCutOffDate: Record<string, string> = {
@@ -135,8 +241,11 @@ export const KnowledgeCutOffDate: Record<string, string> = {
"gpt-4-turbo": "2023-12",
"gpt-4-turbo-2024-04-09": "2023-12",
"gpt-4-turbo-preview": "2023-12",
"gpt-4-1106-preview": "2023-04",
"gpt-4-0125-preview": "2023-12",
"gpt-4o": "2023-10",
"gpt-4o-2024-05-13": "2023-10",
"gpt-4o-2024-08-06": "2023-10",
"gpt-4o-mini": "2023-10",
"gpt-4o-mini-2024-07-18": "2023-10",
"gpt-4-vision-preview": "2023-04",
// After improvements,
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
@@ -146,29 +255,29 @@ export const KnowledgeCutOffDate: Record<string, string> = {
const openaiModels = [
"gpt-3.5-turbo",
"gpt-3.5-turbo-0301",
"gpt-3.5-turbo-0613",
"gpt-3.5-turbo-1106",
"gpt-3.5-turbo-0125",
"gpt-3.5-turbo-16k",
"gpt-3.5-turbo-16k-0613",
"gpt-4",
"gpt-4-0314",
"gpt-4-0613",
"gpt-4-1106-preview",
"gpt-4-0125-preview",
"gpt-4-32k",
"gpt-4-32k-0314",
"gpt-4-32k-0613",
"gpt-4-turbo",
"gpt-4-turbo-preview",
"gpt-4o",
"gpt-4o-2024-05-13",
"gpt-4o-2024-08-06",
"gpt-4o-mini",
"gpt-4o-mini-2024-07-18",
"gpt-4-vision-preview",
"gpt-4-turbo-2024-04-09",
"gpt-4-1106-preview",
"dall-e-3",
];
const googleModels = [
"gemini-1.0-pro",
"gemini-1.5-pro-latest",
"gemini-1.5-flash-latest",
"gemini-pro-vision",
];
@@ -179,34 +288,172 @@ const anthropicModels = [
"claude-3-sonnet-20240229",
"claude-3-opus-20240229",
"claude-3-haiku-20240307",
"claude-3-5-sonnet-20240620",
];
const baiduModels = [
"ernie-4.0-turbo-8k",
"ernie-4.0-8k",
"ernie-4.0-8k-preview",
"ernie-4.0-8k-preview-0518",
"ernie-4.0-8k-latest",
"ernie-3.5-8k",
"ernie-3.5-8k-0205",
"ernie-speed-128k",
"ernie-speed-8k",
"ernie-lite-8k",
"ernie-tiny-8k",
];
const bytedanceModels = [
"Doubao-lite-4k",
"Doubao-lite-32k",
"Doubao-lite-128k",
"Doubao-pro-4k",
"Doubao-pro-32k",
"Doubao-pro-128k",
];
const alibabaModes = [
"qwen-turbo",
"qwen-plus",
"qwen-max",
"qwen-max-0428",
"qwen-max-0403",
"qwen-max-0107",
"qwen-max-longcontext",
];
const tencentModels = [
"hunyuan-pro",
"hunyuan-standard",
"hunyuan-lite",
"hunyuan-role",
"hunyuan-functioncall",
"hunyuan-code",
"hunyuan-vision",
];
const moonshotModes = ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"];
const iflytekModels = [
"general",
"generalv3",
"pro-128k",
"generalv3.5",
"4.0Ultra",
];
let seq = 1000; // 内置的模型序号生成器从1000开始
export const DEFAULT_MODELS = [
...openaiModels.map((name) => ({
name,
available: true,
sorted: seq++, // Global sequence sort(index)
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
sorted: 1, // 这里是固定的,确保顺序与之前内置的版本一致
},
})),
...openaiModels.map((name) => ({
name,
available: true,
sorted: seq++,
provider: {
id: "azure",
providerName: "Azure",
providerType: "azure",
sorted: 2,
},
})),
...googleModels.map((name) => ({
name,
available: true,
sorted: seq++,
provider: {
id: "google",
providerName: "Google",
providerType: "google",
sorted: 3,
},
})),
...anthropicModels.map((name) => ({
name,
available: true,
sorted: seq++,
provider: {
id: "anthropic",
providerName: "Anthropic",
providerType: "anthropic",
sorted: 4,
},
})),
...baiduModels.map((name) => ({
name,
available: true,
sorted: seq++,
provider: {
id: "baidu",
providerName: "Baidu",
providerType: "baidu",
sorted: 5,
},
})),
...bytedanceModels.map((name) => ({
name,
available: true,
sorted: seq++,
provider: {
id: "bytedance",
providerName: "ByteDance",
providerType: "bytedance",
sorted: 6,
},
})),
...alibabaModes.map((name) => ({
name,
available: true,
sorted: seq++,
provider: {
id: "alibaba",
providerName: "Alibaba",
providerType: "alibaba",
sorted: 7,
},
})),
...tencentModels.map((name) => ({
name,
available: true,
sorted: seq++,
provider: {
id: "tencent",
providerName: "Tencent",
providerType: "tencent",
sorted: 8,
},
})),
...moonshotModes.map((name) => ({
name,
available: true,
sorted: seq++,
provider: {
id: "moonshot",
providerName: "Moonshot",
providerType: "moonshot",
sorted: 9,
},
})),
...iflytekModels.map((name) => ({
name,
available: true,
sorted: seq++,
provider: {
id: "iflytek",
providerName: "Iflytek",
providerType: "iflytek",
sorted: 10,
},
})),
] as const;
@@ -215,15 +462,17 @@ export const CHAT_PAGE_SIZE = 15;
export const MAX_RENDER_MSG_COUNT = 45;
// some famous webdav endpoints
export const internalWhiteWebDavEndpoints = [
export const internalAllowedWebDavEndpoints = [
"https://dav.jianguoyun.com/dav/",
"https://dav.dropdav.com/",
"https://dav.box.com/dav",
"https://nanao.teracloud.jp/dav/",
"https://bora.teracloud.jp/dav/",
"https://webdav.4shared.com/",
"https://dav.idrivesync.com",
"https://webdav.yandex.com",
"https://app.koofr.net/dav/Koofr",
];
export const SIDEBAR_ID = "sidebar";
export const DEFAULT_GA_ID = "G-89WN60ZK2E";
export const PLUGINS = [{ name: "Stable Diffusion", path: Path.Sd }];

View File

@@ -1,301 +0,0 @@
import React, { useState, useRef, useEffect, useMemo } from "react";
import {
useChatStore,
BOT_HELLO,
createMessage,
useAccessStore,
useAppConfig,
ModelType,
} from "@/app/store";
import Locale from "@/app/locales";
import { Selector, showConfirm, showToast } from "@/app/components/ui-lib";
import {
CHAT_PAGE_SIZE,
REQUEST_TIMEOUT_MS,
UNFINISHED_INPUT,
} from "@/app/constant";
import { useCommand } from "@/app/command";
import { prettyObject } from "@/app/utils/format";
import { ExportMessageModal } from "@/app/components/exporter";
import PromptToast from "./components/PromptToast";
import { EditMessageModal } from "./components/EditMessageModal";
import ChatHeader from "./components/ChatHeader";
import ChatInputPanel, {
ChatInputPanelInstance,
} from "./components/ChatInputPanel";
import ChatMessagePanel, { RenderMessage } from "./components/ChatMessagePanel";
import { useAllModels } from "@/app/utils/hooks";
import useRows from "@/app/hooks/useRows";
import SessionConfigModel from "./components/SessionConfigModal";
import useScrollToBottom from "@/app/hooks/useScrollToBottom";
function _Chat() {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const config = useAppConfig();
const { isMobileScreen } = config;
const [showExport, setShowExport] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const chatInputPanelRef = useRef<ChatInputPanelInstance | null>(null);
const [hitBottom, setHitBottom] = useState(true);
const [attachImages, setAttachImages] = useState<string[]>([]);
// auto grow input
const { measure, inputRows } = useRows({
inputRef,
});
const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(scrollRef);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(measure, [userInput]);
useEffect(() => {
chatStore.updateCurrentSession((session) => {
const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
session.messages.forEach((m) => {
// check if should stop all stale messages
if (m.isError || new Date(m.date).getTime() < stopTiming) {
if (m.streaming) {
m.streaming = false;
}
if (m.content.length === 0) {
m.isError = true;
m.content = prettyObject({
error: true,
message: "empty response",
});
}
}
});
// auto sync mask config from global config
if (session.mask.syncGlobalConfig) {
console.log("[Mask] syncing from global, name = ", session.mask.name);
session.mask.modelConfig = { ...config.modelConfig };
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const context: RenderMessage[] = useMemo(() => {
return session.mask.hideContext ? [] : session.mask.context.slice();
}, [session.mask.context, session.mask.hideContext]);
const accessStore = useAccessStore();
if (
context.length === 0 &&
session.messages.at(0)?.content !== BOT_HELLO.content
) {
const copiedHello = Object.assign({}, BOT_HELLO);
if (!accessStore.isAuthorized()) {
copiedHello.content = Locale.Error.Unauthorized;
}
context.push(copiedHello);
}
// preview messages
const renderMessages = useMemo(() => {
return context
.concat(session.messages as RenderMessage[])
.concat(
isLoading
? [
{
...createMessage({
role: "assistant",
content: "……",
}),
preview: true,
},
]
: [],
)
.concat(
userInput.length > 0 && config.sendPreviewBubble
? [
{
...createMessage(
{
role: "user",
content: userInput,
},
{
customId: "typing",
},
),
preview: true,
},
]
: [],
);
}, [
config.sendPreviewBubble,
context,
isLoading,
session.messages,
userInput,
]);
const [msgRenderIndex, _setMsgRenderIndex] = useState(
Math.max(0, renderMessages.length - CHAT_PAGE_SIZE),
);
const [showPromptModal, setShowPromptModal] = useState(false);
useCommand({
fill: setUserInput,
submit: (text) => {
chatInputPanelRef.current?.doSubmit(text);
},
code: (text) => {
if (accessStore.disableFastLink) return;
console.log("[Command] got code from url: ", text);
showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => {
if (res) {
accessStore.update((access) => (access.accessCode = text));
}
});
},
settings: (text) => {
if (accessStore.disableFastLink) return;
try {
const payload = JSON.parse(text) as {
key?: string;
url?: string;
};
console.log("[Command] got settings from url: ", payload);
if (payload.key || payload.url) {
showConfirm(
Locale.URLCommand.Settings +
`\n${JSON.stringify(payload, null, 4)}`,
).then((res) => {
if (!res) return;
if (payload.key) {
accessStore.update(
(access) => (access.openaiApiKey = payload.key!),
);
}
if (payload.url) {
accessStore.update((access) => (access.openaiUrl = payload.url!));
}
});
}
} catch {
console.error("[Command] failed to get settings from url: ", text);
}
},
});
// edit / insert message modal
const [isEditingMessage, setIsEditingMessage] = useState(false);
// remember unfinished input
useEffect(() => {
// try to load from local storage
const key = UNFINISHED_INPUT(session.id);
const mayBeUnfinishedInput = localStorage.getItem(key);
if (mayBeUnfinishedInput && userInput.length === 0) {
setUserInput(mayBeUnfinishedInput);
localStorage.removeItem(key);
}
const dom = inputRef.current;
return () => {
localStorage.setItem(key, dom?.value ?? "");
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const chatinputPanelProps = {
inputRef,
isMobileScreen,
renderMessages,
attachImages,
userInput,
hitBottom,
inputRows,
setAttachImages,
setUserInput,
setIsLoading,
showChatSetting: setShowPromptModal,
_setMsgRenderIndex,
scrollDomToBottom,
setAutoScroll,
};
const chatMessagePanelProps = {
scrollRef,
inputRef,
isMobileScreen,
msgRenderIndex,
userInput,
context,
renderMessages,
setAutoScroll,
setMsgRenderIndex: chatInputPanelRef.current?.setMsgRenderIndex,
setHitBottom,
setUserInput,
setIsLoading,
setShowPromptModal,
scrollDomToBottom,
};
return (
<div
className={`
relative flex flex-col overflow-hidden bg-chat-panel
max-md:absolute max-md:h-[100vh] max-md:w-[100%]
md:h-[100%] md:mr-2.5 md:rounded-md
`}
key={session.id}
>
<ChatHeader
setIsEditingMessage={setIsEditingMessage}
setShowExport={setShowExport}
isMobileScreen={isMobileScreen}
/>
<ChatMessagePanel {...chatMessagePanelProps} />
<ChatInputPanel ref={chatInputPanelRef} {...chatinputPanelProps} />
{showExport && (
<ExportMessageModal onClose={() => setShowExport(false)} />
)}
{isEditingMessage && (
<EditMessageModal
onClose={() => {
setIsEditingMessage(false);
}}
/>
)}
<PromptToast showToast={!hitBottom} setShowModal={setShowPromptModal} />
{showPromptModal && (
<SessionConfigModel onClose={() => setShowPromptModal(false)} />
)}
</div>
);
}
export default function Chat() {
const chatStore = useChatStore();
const sessionIndex = chatStore.currentSessionIndex;
return <_Chat key={sessionIndex}></_Chat>;
}

View File

@@ -1,277 +0,0 @@
import { useNavigate } from "react-router-dom";
import { ModelType, Theme, useAppConfig } from "@/app/store/config";
import { useChatStore } from "@/app/store/chat";
import { ChatControllerPool } from "@/app/client/controller";
import { useAllModels } from "@/app/utils/hooks";
import { useEffect, useMemo, useState } from "react";
import { isVisionModel } from "@/app/utils";
import { showToast } from "@/app/components/ui-lib";
import Locale from "@/app/locales";
import { Path } from "@/app/constant";
import BottomIcon from "@/app/icons/bottom.svg";
import StopIcon from "@/app/icons/pause.svg";
import LoadingButtonIcon from "@/app/icons/loading.svg";
import PromptIcon from "@/app/icons/comandIcon.svg";
import MaskIcon from "@/app/icons/maskIcon.svg";
import BreakIcon from "@/app/icons/eraserIcon.svg";
import SettingsIcon from "@/app/icons/configIcon.svg";
import ImageIcon from "@/app/icons/uploadImgIcon.svg";
import AddCircleIcon from "@/app/icons/addCircle.svg";
import Popover from "@/app/components/Popover";
import ModelSelect from "./ModelSelect";
export interface Action {
onClick?: () => void;
text: string;
isShow: boolean;
render?: (key: string) => JSX.Element;
icon?: JSX.Element;
placement: "left" | "right";
className?: string;
}
export function ChatActions(props: {
uploadImage: () => void;
setAttachImages: (images: string[]) => void;
setUploading: (uploading: boolean) => void;
showChatSetting: () => void;
scrollToBottom: () => void;
showPromptHints: () => void;
hitBottom: boolean;
uploading: boolean;
isMobileScreen: boolean;
className?: string;
}) {
const config = useAppConfig();
const navigate = useNavigate();
const chatStore = useChatStore();
// switch themes
const theme = config.theme;
function nextTheme() {
const themes = [Theme.Auto, Theme.Light, Theme.Dark];
const themeIndex = themes.indexOf(theme);
const nextIndex = (themeIndex + 1) % themes.length;
const nextTheme = themes[nextIndex];
config.update((config) => (config.theme = nextTheme));
}
// stop all responses
const couldStop = ChatControllerPool.hasPending();
const stopAll = () => ChatControllerPool.stopAll();
// switch model
const currentModel = chatStore.currentSession().mask.modelConfig.model;
const allModels = useAllModels();
const models = useMemo(
() => allModels.filter((m) => m.available),
[allModels],
);
const [showUploadImage, setShowUploadImage] = useState(false);
useEffect(() => {
const show = isVisionModel(currentModel);
setShowUploadImage(show);
if (!show) {
props.setAttachImages([]);
props.setUploading(false);
}
// if current model is not available
// switch to first available model
const isUnavaliableModel = !models.some((m) => m.name === currentModel);
if (isUnavaliableModel && models.length > 0) {
const nextModel = models[0].name as ModelType;
chatStore.updateCurrentSession(
(session) => (session.mask.modelConfig.model = nextModel),
);
showToast(nextModel);
}
}, [chatStore, currentModel, models]);
const actions: Action[] = [
{
onClick: stopAll,
text: Locale.Chat.InputActions.Stop,
isShow: couldStop,
icon: <StopIcon />,
placement: "left",
},
{
text: currentModel,
isShow: !props.isMobileScreen,
render: (key: string) => <ModelSelect key={key} />,
placement: "left",
},
{
onClick: props.scrollToBottom,
text: Locale.Chat.InputActions.ToBottom,
isShow: !props.hitBottom,
icon: <BottomIcon />,
placement: "left",
},
{
onClick: props.uploadImage,
text: Locale.Chat.InputActions.UploadImage,
isShow: showUploadImage,
icon: props.uploading ? <LoadingButtonIcon /> : <ImageIcon />,
placement: "left",
},
// {
// onClick: nextTheme,
// text: Locale.Chat.InputActions.Theme[theme],
// isShow: true,
// icon: (
// <>
// {theme === Theme.Auto ? (
// <AutoIcon />
// ) : theme === Theme.Light ? (
// <LightIcon />
// ) : theme === Theme.Dark ? (
// <DarkIcon />
// ) : null}
// </>
// ),
// placement: "left",
// },
{
onClick: props.showPromptHints,
text: Locale.Chat.InputActions.Prompt,
isShow: true,
icon: <PromptIcon />,
placement: "left",
},
{
onClick: () => {
navigate(Path.Masks);
},
text: Locale.Chat.InputActions.Masks,
isShow: true,
icon: <MaskIcon />,
placement: "left",
},
{
onClick: () => {
chatStore.updateCurrentSession((session) => {
if (session.clearContextIndex === session.messages.length) {
session.clearContextIndex = undefined;
} else {
session.clearContextIndex = session.messages.length;
session.memoryPrompt = ""; // will clear memory
}
});
},
text: Locale.Chat.InputActions.Clear,
isShow: true,
icon: <BreakIcon />,
placement: "right",
},
{
onClick: props.showChatSetting,
text: Locale.Chat.InputActions.Settings,
isShow: true,
icon: <SettingsIcon />,
placement: "right",
},
];
if (props.isMobileScreen) {
const content = (
<div className="w-[100%]">
{actions
.filter((v) => v.isShow && v.icon)
.map((act) => {
return (
<div
key={act.text}
className={`flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer follow-parent-svg default-icon-color`}
onClick={act.onClick}
>
{act.icon}
<div className="flex-1 font-common text-actions-popover-menu-item">
{act.text}
</div>
</div>
);
})}
</div>
);
return (
<Popover
content={content}
trigger="click"
placement="rt"
noArrow
popoverClassName="border border-chat-actions-popover-mobile rounded-md shadow-chat-actions-popover-mobile w-actions-popover bg-chat-actions-popover-panel-mobile "
className=" cursor-pointer follow-parent-svg default-icon-color"
>
<AddCircleIcon />
</Popover>
);
}
const popoverClassName = `bg-chat-actions-btn-popover px-3 py-2.5 text-text-chat-actions-btn-popover text-sm-title rounded-md`;
return (
<div className={`flex gap-2 item-center ${props.className}`}>
{actions
.filter((v) => v.placement === "left" && v.isShow)
.map((act, ind) => {
if (act.render) {
return (
<div className={`${act.className ?? ""}`} key={act.text}>
{act.render(act.text)}
</div>
);
}
return (
<Popover
key={act.text}
content={act.text}
popoverClassName={`${popoverClassName}`}
placement={ind ? "t" : "lt"}
className={`${act.className ?? ""}`}
>
<div
className={`
cursor-pointer h-[32px] w-[32px] flex items-center justify-center transition duration-300 ease-in-out
hover:bg-chat-actions-btn-hovered hover:rounded-action-btn
follow-parent-svg default-icon-color
`}
onClick={act.onClick}
>
{act.icon}
</div>
</Popover>
);
})}
<div className="flex-1"></div>
{actions
.filter((v) => v.placement === "right" && v.isShow)
.map((act, ind, arr) => {
return (
<Popover
key={act.text}
content={act.text}
popoverClassName={`${popoverClassName}`}
placement={ind === arr.length - 1 ? "rt" : "t"}
>
<div
className={`
cursor-pointer h-[32px] w-[32px] flex items-center transition duration-300 ease-in-out justify-center
hover:bg-chat-actions-btn-hovered hover:rounded-action-btn
follow-parent-svg default-icon-color
`}
onClick={act.onClick}
>
{act.icon}
</div>
</Popover>
);
})}
</div>
);
}

View File

@@ -1,91 +0,0 @@
import { useNavigate } from "react-router-dom";
import Locale from "@/app/locales";
import { Path } from "@/app/constant";
import { DEFAULT_TOPIC, useChatStore } from "@/app/store/chat";
import LogIcon from "@/app/icons/logIcon.svg";
import GobackIcon from "@/app/icons/goback.svg";
import ShareIcon from "@/app/icons/shareIcon.svg";
import ModelSelect from "./ModelSelect";
export interface ChatHeaderProps {
isMobileScreen: boolean;
setIsEditingMessage: (v: boolean) => void;
setShowExport: (v: boolean) => void;
}
export default function ChatHeader(props: ChatHeaderProps) {
const { isMobileScreen, setIsEditingMessage, setShowExport } = props;
const navigate = useNavigate();
const chatStore = useChatStore();
const session = chatStore.currentSession();
return (
<div
className={`
absolute w-[100%] backdrop-blur-[30px] z-20 flex flex-0 justify-between items-center px-6 py-4 gap-chat-header-gap
sm:border-b sm:border-chat-header-bottom
max-md:h-menu-title-mobile
`}
data-tauri-drag-region
>
<div
className={`absolute z-[-1] top-0 left-0 w-[100%] h-[100%] opacity-85 backdrop-blur-[20px] sm:bg-chat-panel-header-mask bg-chat-panel-header-mobile flex flex-0 justify-between items-center gap-chat-header-gap`}
>
{" "}
</div>
{isMobileScreen ? (
<div
className=" cursor-pointer follow-parent-svg default-icon-color"
onClick={() => navigate(Path.Home)}
>
<GobackIcon />
</div>
) : (
<LogIcon />
)}
<div
className={`
flex-1
max-md:flex max-md:flex-col max-md:items-center max-md:justify-center max-md:gap-0.5 max-md:text
md:mr-4
`}
>
<div
className={`
line-clamp-1 cursor-pointer text-text-chat-header-title text-chat-header-title font-common
max-md:text-sm-title max-md:h-chat-header-title-mobile max-md:leading-5
`}
onClickCapture={() => setIsEditingMessage(true)}
>
{!session.topic ? DEFAULT_TOPIC : session.topic}
</div>
<div
className={`
text-text-chat-header-subtitle text-sm
max-md:text-sm-mobile-tab max-md:leading-4
`}
>
{isMobileScreen ? (
<ModelSelect />
) : (
Locale.Chat.SubTitle(session.messages.length)
)}
</div>
</div>
<div
className=" cursor-pointer hover:bg-hovered-btn p-1.5 rounded-action-btn follow-parent-svg default-icon-color"
onClick={() => {
setShowExport(true);
}}
>
<ShareIcon />
</div>
</div>
);
}

View File

@@ -1,322 +0,0 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useDebouncedCallback } from "use-debounce";
import useUploadImage from "@/app/hooks/useUploadImage";
import Locale from "@/app/locales";
import useSubmitHandler from "@/app/hooks/useSubmitHandler";
import { CHAT_PAGE_SIZE, LAST_INPUT_KEY, Path } from "@/app/constant";
import { ChatCommandPrefix, useChatCommand } from "@/app/command";
import { useChatStore } from "@/app/store/chat";
import { usePromptStore } from "@/app/store/prompt";
import { useAppConfig } from "@/app/store/config";
import usePaste from "@/app/hooks/usePaste";
import { ChatActions } from "./ChatActions";
import PromptHints, { RenderPompt } from "./PromptHint";
// import CEIcon from "@/app/icons/command&enterIcon.svg";
// import EnterIcon from "@/app/icons/enterIcon.svg";
import SendIcon from "@/app/icons/sendIcon.svg";
import Btn from "@/app/components/Btn";
import Thumbnail from "@/app/components/ThumbnailImg";
export interface ChatInputPanelProps {
inputRef: React.RefObject<HTMLTextAreaElement>;
isMobileScreen: boolean;
renderMessages: any[];
attachImages: string[];
userInput: string;
hitBottom: boolean;
inputRows: number;
setAttachImages: (imgs: string[]) => void;
setUserInput: (v: string) => void;
setIsLoading: (value: boolean) => void;
showChatSetting: (value: boolean) => void;
_setMsgRenderIndex: (value: number) => void;
setAutoScroll: (value: boolean) => void;
scrollDomToBottom: () => void;
}
export interface ChatInputPanelInstance {
setUploading: (v: boolean) => void;
doSubmit: (userInput: string) => void;
setMsgRenderIndex: (v: number) => void;
}
// only search prompts when user input is short
const SEARCH_TEXT_LIMIT = 30;
export default forwardRef<ChatInputPanelInstance, ChatInputPanelProps>(
function ChatInputPanel(props, ref) {
const {
attachImages,
inputRef,
setAttachImages,
userInput,
isMobileScreen,
setUserInput,
setIsLoading,
showChatSetting,
renderMessages,
_setMsgRenderIndex,
hitBottom,
inputRows,
setAutoScroll,
scrollDomToBottom,
} = props;
const [uploading, setUploading] = useState(false);
const [promptHints, setPromptHints] = useState<RenderPompt[]>([]);
const chatStore = useChatStore();
const navigate = useNavigate();
const config = useAppConfig();
const { uploadImage } = useUploadImage(attachImages, {
emitImages: setAttachImages,
setUploading,
});
const { submitKey, shouldSubmit } = useSubmitHandler();
const autoFocus = !isMobileScreen; // wont auto focus on mobile screen
// chat commands shortcuts
const chatCommands = useChatCommand({
new: () => chatStore.newSession(),
newm: () => navigate(Path.NewChat),
prev: () => chatStore.nextSession(-1),
next: () => chatStore.nextSession(1),
clear: () =>
chatStore.updateCurrentSession(
(session) => (session.clearContextIndex = session.messages.length),
),
del: () => chatStore.deleteSession(chatStore.currentSessionIndex),
});
// prompt hints
const promptStore = usePromptStore();
const onSearch = useDebouncedCallback(
(text: string) => {
const matchedPrompts = promptStore.search(text);
setPromptHints(matchedPrompts);
},
100,
{ leading: true, trailing: true },
);
// check if should send message
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// if ArrowUp and no userInput, fill with last input
if (
e.key === "ArrowUp" &&
userInput.length <= 0 &&
!(e.metaKey || e.altKey || e.ctrlKey)
) {
setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
e.preventDefault();
return;
}
if (shouldSubmit(e) && promptHints.length === 0) {
doSubmit(userInput);
e.preventDefault();
}
};
const onPromptSelect = (prompt: RenderPompt) => {
setTimeout(() => {
setPromptHints([]);
const matchedChatCommand = chatCommands.match(prompt.content);
if (matchedChatCommand.matched) {
// if user is selecting a chat command, just trigger it
matchedChatCommand.invoke();
setUserInput("");
} else {
// or fill the prompt
setUserInput(prompt.content);
}
inputRef.current?.focus();
}, 30);
};
const doSubmit = (userInput: string) => {
if (userInput.trim() === "") return;
const matchCommand = chatCommands.match(userInput);
if (matchCommand.matched) {
setUserInput("");
setPromptHints([]);
matchCommand.invoke();
return;
}
setIsLoading(true);
chatStore
.onUserInput(userInput, attachImages)
.then(() => setIsLoading(false));
setAttachImages([]);
localStorage.setItem(LAST_INPUT_KEY, userInput);
setUserInput("");
setPromptHints([]);
if (!isMobileScreen) inputRef.current?.focus();
setAutoScroll(true);
};
useImperativeHandle(ref, () => ({
setUploading,
doSubmit,
setMsgRenderIndex,
}));
function scrollToBottom() {
setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
scrollDomToBottom();
}
const onInput = (text: string) => {
setUserInput(text);
const n = text.trim().length;
// clear search results
if (n === 0) {
setPromptHints([]);
} else if (text.startsWith(ChatCommandPrefix)) {
setPromptHints(chatCommands.search(text));
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
// check if need to trigger auto completion
if (text.startsWith("/")) {
let searchText = text.slice(1);
onSearch(searchText);
}
}
};
function setMsgRenderIndex(newIndex: number) {
newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex);
newIndex = Math.max(0, newIndex);
_setMsgRenderIndex(newIndex);
}
const { handlePaste } = usePaste(attachImages, {
emitImages: setAttachImages,
setUploading,
});
return (
<div
className={`
relative w-[100%] box-border
max-md:rounded-tl-md max-md:rounded-tr-md
md:border-t md:border-chat-input-top
`}
>
<PromptHints
prompts={promptHints}
onPromptSelect={onPromptSelect}
className=" border-chat-input-top"
/>
<div
className={`
flex
max-md:flex-row-reverse max-md:items-center max-md:gap-2 max-md:p-3
md:flex-col md:px-5 md:pb-5
`}
>
<ChatActions
uploadImage={uploadImage}
setAttachImages={setAttachImages}
setUploading={setUploading}
showChatSetting={() => showChatSetting(true)}
scrollToBottom={scrollToBottom}
hitBottom={hitBottom}
uploading={uploading}
showPromptHints={() => {
// Click again to close
if (promptHints.length > 0) {
setPromptHints([]);
return;
}
inputRef.current?.focus();
setUserInput("/");
onSearch("");
}}
className={`
md:py-2.5
`}
isMobileScreen={isMobileScreen}
/>
<label
className={`
cursor-text flex flex-col bg-chat-panel-input-hood border border-chat-input-hood
focus-within:border-chat-input-hood-focus sm:focus-within:shadow-chat-input-hood-focus-shadow
rounded-chat-input p-3 gap-3 max-md:flex-1
md:rounded-md md:p-4 md:gap-4
`}
htmlFor="chat-input"
>
{attachImages.length != 0 && (
<div className={`flex gap-2`}>
{attachImages.map((image, index) => {
return (
<Thumbnail
key={index}
deleteImage={() => {
setAttachImages(
attachImages.filter((_, i) => i !== index),
);
}}
image={image}
/>
);
})}
</div>
)}
<textarea
id="chat-input"
ref={inputRef}
className={`
leading-[19px] flex-1 focus:outline-none focus:shadow-none focus:border-none resize-none bg-inherit text-text-input
max-md:h-chat-input-mobile
md:min-h-chat-input
`}
placeholder={
isMobileScreen
? Locale.Chat.Input(submitKey, isMobileScreen)
: undefined
}
onInput={(e) => onInput(e.currentTarget.value)}
value={userInput}
onKeyDown={onInputKeyDown}
onFocus={scrollToBottom}
onClick={scrollToBottom}
onPaste={handlePaste}
rows={inputRows}
autoFocus={autoFocus}
style={{
fontSize: config.fontSize,
}}
/>
{!isMobileScreen && (
<div className="flex items-center justify-center text-sm gap-3">
<div className="flex-1">&nbsp;</div>
<div className="text-text-chat-input-placeholder font-common line-clamp-1">
{Locale.Chat.Input(submitKey)}
</div>
<Btn
className="min-w-[77px]"
icon={<SendIcon />}
text={Locale.Chat.Send}
disabled={!userInput.length}
type="primary"
onClick={() => doSubmit(userInput)}
/>
</div>
)}
</label>
</div>
</div>
);
},
);

View File

@@ -1,246 +0,0 @@
import { Fragment, useMemo } from "react";
import { ChatMessage, useChatStore } from "@/app/store/chat";
import { CHAT_PAGE_SIZE } from "@/app/constant";
import Locale from "@/app/locales";
import { getMessageTextContent, selectOrCopy } from "@/app/utils";
import LoadingIcon from "@/app/icons/three-dots.svg";
import { Avatar } from "@/app/components/emoji";
import { MaskAvatar } from "@/app/components/mask";
import { useAppConfig } from "@/app/store/config";
import ClearContextDivider from "./ClearContextDivider";
import dynamic from "next/dynamic";
import useRelativePosition, {
Orientation,
} from "@/app/hooks/useRelativePosition";
import MessageActions, { RenderMessage } from "./MessageActions";
import Imgs from "@/app/components/Imgs";
export type { RenderMessage };
export interface ChatMessagePanelProps {
scrollRef: React.RefObject<HTMLDivElement>;
inputRef: React.RefObject<HTMLTextAreaElement>;
isMobileScreen: boolean;
msgRenderIndex: number;
userInput: string;
context: any[];
renderMessages: RenderMessage[];
scrollDomToBottom: () => void;
setAutoScroll?: (value: boolean) => void;
setMsgRenderIndex?: (newIndex: number) => void;
setHitBottom?: (value: boolean) => void;
setUserInput?: (v: string) => void;
setIsLoading?: (value: boolean) => void;
setShowPromptModal?: (value: boolean) => void;
}
let MarkdownLoadedCallback: () => void;
const Markdown = dynamic(
async () => {
const bundle = await import("@/app/components/markdown");
if (MarkdownLoadedCallback) {
MarkdownLoadedCallback();
}
return bundle.Markdown;
},
{
loading: () => <LoadingIcon />,
},
);
export default function ChatMessagePanel(props: ChatMessagePanelProps) {
const {
scrollRef,
inputRef,
setAutoScroll,
setMsgRenderIndex,
isMobileScreen,
msgRenderIndex,
setHitBottom,
setUserInput,
userInput,
context,
renderMessages,
setIsLoading,
setShowPromptModal,
scrollDomToBottom,
} = props;
const chatStore = useChatStore();
const session = chatStore.currentSession();
const config = useAppConfig();
const fontSize = config.fontSize;
const { position, getRelativePosition } = useRelativePosition({
containerRef: scrollRef,
delay: 0,
offsetDistance: 20,
});
// clear context index = context length + index in messages
const clearContextIndex =
(session.clearContextIndex ?? -1) >= 0
? session.clearContextIndex! + context.length - msgRenderIndex
: -1;
if (!MarkdownLoadedCallback) {
MarkdownLoadedCallback = () => {
window.setTimeout(scrollDomToBottom, 100);
};
}
const messages = useMemo(() => {
const endRenderIndex = Math.min(
msgRenderIndex + 3 * CHAT_PAGE_SIZE,
renderMessages.length,
);
return renderMessages.slice(msgRenderIndex, endRenderIndex);
}, [msgRenderIndex, renderMessages]);
const onChatBodyScroll = (e: HTMLElement) => {
const bottomHeight = e.scrollTop + e.clientHeight;
const edgeThreshold = e.clientHeight;
const isTouchTopEdge = e.scrollTop <= edgeThreshold;
const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold;
const isHitBottom =
bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10);
const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE;
const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE;
if (isTouchTopEdge && !isTouchBottomEdge) {
setMsgRenderIndex?.(prevPageMsgIndex);
} else if (isTouchBottomEdge) {
setMsgRenderIndex?.(nextPageMsgIndex);
}
setHitBottom?.(isHitBottom);
setAutoScroll?.(isHitBottom);
};
const onRightClick = (e: any, message: ChatMessage) => {
// copy to clipboard
if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) {
if (userInput.length === 0) {
setUserInput?.(getMessageTextContent(message));
}
e.preventDefault();
}
};
return (
<div
className={`pt-[80px] relative flex-1 overscroll-y-none overflow-y-auto overflow-x-hidden px-3 pb-6 md:bg-chat-panel-message bg-chat-panel-message-mobile`}
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
onMouseDown={() => inputRef.current?.blur()}
onTouchStart={() => {
inputRef.current?.blur();
setAutoScroll?.(false);
}}
>
{messages.map((message, i) => {
const isUser = message.role === "user";
const isContext = i < context.length;
const shouldShowClearContextDivider = i === clearContextIndex - 1;
const actionsBarPosition =
position?.id === message.id &&
position?.poi.overlapPositions[Orientation.bottom]
? "bottom-[calc(100%-0.25rem)]"
: "top-[calc(100%-0.25rem)]";
return (
<Fragment key={message.id}>
<div
className={`flex mt-6 gap-2 ${isUser ? "flex-row-reverse" : ""}`}
>
<div className={`relative flex-0`}>
{isUser ? (
<Avatar avatar={config.avatar} />
) : (
<>
{["system"].includes(message.role) ? (
<Avatar avatar="2699-fe0f" />
) : (
<MaskAvatar
avatar={session.mask.avatar}
model={message.model || session.mask.modelConfig.model}
/>
)}
</>
)}
</div>
<div
className={`group relative flex ${
isUser ? "flex-row-reverse" : ""
}`}
>
<div
className={` pointer-events-none text-text-chat-message-date text-right font-common whitespace-nowrap transition-all duration-500 text-sm absolute z-1 ${
isUser ? "right-0" : "left-0"
} bottom-[100%] hidden group-hover:block`}
>
{isContext
? Locale.Chat.IsContext
: message.date.toLocaleString()}
</div>
<div
className={`transition-all duration-300 select-text break-words font-common text-sm-title ${
isUser
? "rounded-user-message bg-chat-panel-message-user"
: "rounded-bot-message bg-chat-panel-message-bot"
} box-border peer py-2 px-3`}
onPointerMoveCapture={(e) =>
getRelativePosition(e.currentTarget, message.id)
}
>
<Markdown
content={getMessageTextContent(message)}
loading={
(message.preview || message.streaming) &&
message.content.length === 0 &&
!isUser
}
onContextMenu={(e) => onRightClick(e, message)}
onDoubleClickCapture={() => {
if (!isMobileScreen) return;
setUserInput?.(getMessageTextContent(message));
}}
fontSize={fontSize}
parentRef={scrollRef}
defaultShow={i >= messages.length - 6}
className={`leading-6 max-w-message-width ${
isUser
? " text-text-chat-message-markdown-user"
: "text-text-chat-message-markdown-bot"
}`}
/>
<Imgs message={message} />
</div>
<MessageActions
className={actionsBarPosition}
message={message}
inputRef={inputRef}
isUser={isUser}
isContext={isContext}
setIsLoading={setIsLoading}
setShowPromptModal={setShowPromptModal}
/>
</div>
</div>
{shouldShowClearContextDivider && <ClearContextDivider />}
</Fragment>
);
})}
</div>
);
}

View File

@@ -1,46 +0,0 @@
import { useChatStore } from "@/app/store/chat";
import Locale from "@/app/locales";
import { useAppConfig } from "@/app/store";
export default function ClearContextDivider() {
const chatStore = useChatStore();
const { isMobileScreen } = useAppConfig();
return (
<div
className={`mt-6 mb-8 flex items-center justify-center gap-2.5 max-md:cursor-pointer`}
onClick={() => {
if (!isMobileScreen) {
return;
}
chatStore.updateCurrentSession(
(session) => (session.clearContextIndex = undefined),
);
}}
>
<div className="bg-chat-panel-message-clear-divider h-[1px] w-10"> </div>
<div className="flex items-center justify-between gap-1 text-sm">
<div className={`text-text-chat-panel-message-clear`}>
{Locale.Context.Clear}
</div>
<div
className={`
text-text-chat-panel-message-clear-revert underline font-common
md:cursor-pointer
`}
onClick={() => {
if (isMobileScreen) {
return;
}
chatStore.updateCurrentSession(
(session) => (session.clearContextIndex = undefined),
);
}}
>
{Locale.Context.Revert}
</div>
</div>
<div className="bg-chat-panel-message-clear-divider h-[1px] w-10"> </div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More