Compare commits

...

453 Commits
v2.9.10 ... v3

Author SHA1 Message Date
Fred
00b1a9781d Merge branch 'feat-redesign-ui' into v3 2024-05-08 14:24:43 +08:00
Dean-YZG
240d330001 feat: 1)add font source 2)add validator in ListItem 3)settings page ui optiminize 2024-05-07 15:05:29 +08:00
Dean-YZG
4e4431339f feat: old page ui style optiminize 2024-05-07 11:22:17 +08:00
Dean-YZG
fa2f8c66d1 feat: old page dark mode compatible 2024-05-06 22:23:21 +08:00
Fred
32f62d70af feat: bump version 2024-05-06 14:15:27 +08:00
butterfly
68f0fa917f complete colors in dark mode 2024-05-01 22:35:23 +08:00
butterfly
8a14cb19a9 feat: merge remote 2024-04-30 19:19:27 +08:00
butterfly
3d99965a8f feat: bugfix 2024-04-30 19:11:59 +08:00
Fred
4d5a9476b6 style: add transition 2024-04-30 19:04:17 +08:00
Fred
15d6ed252f style: add transition 2024-04-30 19:02:18 +08:00
Fred
ecf6cc27d6 style: add transition 2024-04-30 18:58:19 +08:00
Fred
cadd2558fd chore: update settings width 2024-04-30 17:42:59 +08:00
butterfly
c3d91bf0cd feat: complete the missing UI 2024-04-30 12:37:28 +08:00
butterfly
996537d262 feat: optiminize modal UE on mobile dev 2024-04-30 11:03:37 +08:00
butterfly
5ea6206319 feat: select model done 2024-04-29 20:37:27 +08:00
butterfly
8c28c408d8 feat: refactor select model 2024-04-29 16:29:47 +08:00
butterfly
c34b8ab919 feat: ui optiminize 2024-04-28 19:58:59 +08:00
butterfly
9f4813326c feat: HoverPopover done 2024-04-28 14:10:58 +08:00
butterfly
9569888b0e feat: ui fixed 2024-04-28 12:49:06 +08:00
butterfly
1a636b0f50 feat: function delete chat dev done 2024-04-26 19:33:22 +08:00
butterfly
48e8c0a194 feat: optiminize 2024-04-26 01:31:03 +08:00
butterfly
59583e53bd feat: light theme mode 2024-04-25 21:57:50 +08:00
butterfly
bb7422c526 Merge remote-tracking branch 'origin/main' into feat-redesign-ui 2024-04-25 11:02:12 +08:00
DeanYao
9aec3b714e Merge pull request #4545 from jalr4ever/main-default-model-env
Support a way to define default model by adding DEFAULT_MODEL env.
2024-04-25 10:58:14 +08:00
butterfly
c99086447e feat: redesign settings page 2024-04-24 15:44:24 +08:00
butterfly
f7074bba8c feat: chat panel header add zindex config 2024-04-22 11:31:53 +08:00
butterfly
4400392c0c feat: chat panel header background blur&transparent 2024-04-22 11:29:20 +08:00
butterfly
4a5465f884 feat: chat panel header absolute 2024-04-22 11:02:25 +08:00
butterfly
37cc87531c feat: optiminize message&img display 2024-04-19 19:28:48 +08:00
Wayland Zhan
c96e4b7966 feat: Support a way to define default model by adding DEFAULT_MODEL env. 2024-04-19 06:57:15 +00:00
butterfly
1074fffe79 feat: clear trash 2024-04-19 14:50:11 +08:00
butterfly
3d0a98d5d2 feat: maskpage&newchatpage adapt new ui framework done 2024-04-19 11:55:51 +08:00
butterfly
b3559f99a2 feat: chat panel UE done 2024-04-18 12:27:44 +08:00
DeanYao
9b2cb1e1c3 Merge pull request #4525 from ChatGPTNextWeb/chore-fix
Chore fix
2024-04-16 14:59:22 +08:00
butterfly
fb8b8d28da feat: (1) fix issues/4335 and issues/4518 2024-04-16 14:50:48 +08:00
butterfly
51a1d9f92a feat: chat panel redesigned ui 2024-04-16 14:07:51 +08:00
DeanYao
ad80153bbb Merge pull request #4520 from Algorithm5838/refactor-models
Refactor DEFAULT_MODELS for better maintainability
2024-04-16 09:33:00 +08:00
Algorithm5838
9564b261d5 Update constant.ts 2024-04-15 13:14:14 +03:00
DeanYao
1e2a662fa6 Merge pull request #4412 from RubuJam/main
Gemini will generate the request address based on the selected model name and supports Gemini 1.5 Pro (gemini-1.5-pro-latest).
2024-04-15 11:44:53 +08:00
DeanYao
51f7daaeaf Merge pull request #4514 from SukkaW/fix-ls-performance
perf: avoid read localStorage on every render
2024-04-15 10:11:03 +08:00
DeanYao
f742a7ec4e Merge pull request #4510 from MrrDrr/add_timezone_in_system_prompts
add timezone in system prompts
2024-04-15 10:09:53 +08:00
DeanYao
e2c0d2a07b Merge pull request #4509 from MrrDrr/add_knowledge_cutoff
add knowledge cutoff date for gpt-4-turbo-2024-04-09
2024-04-15 10:02:41 +08:00
DeanYao
d112dc41b2 Merge pull request #4500 from PeterDaveHello/locale-tw-cht
Improve tw Traditional Chinese locale
2024-04-15 09:47:36 +08:00
SukkaW
2322851ac4 perf: avoid read localStorage on every render 2024-04-14 17:38:54 +08:00
l.tingting
aa084ea09a add timezone in system prompts 2024-04-12 23:07:29 +08:00
l.tingting
6520f9b7eb add knowledge cutoff date for gpt-4-turbo-2024-04-09 2024-04-12 22:44:26 +08:00
butterfly
fd8d0a1746 feat: fix the logtics of client joining webdav url 2024-04-12 14:20:15 +08:00
DeanYao
af3ebacee6 Merge pull request #4507 from ChatGPTNextWeb/chore-fix
feat: fix codes of joining webdav url in client & webdav proxy
2024-04-12 14:07:24 +08:00
butterfly
55d7014301 feat: fix the logtics of client joining webdav url 2024-04-12 14:02:05 +08:00
butterfly
b72d7fbeda feat: fix webdav 逻辑2 2024-04-12 13:46:37 +08:00
butterfly
ee15c14049 feat: fix webdav 逻辑 2024-04-12 13:40:37 +08:00
butterfly
3fc9b91bf1 feat: choe 2024-04-12 10:59:28 +08:00
butterfly
0a8e5d6734 feat: seperate chat page 2024-04-12 10:57:57 +08:00
Peter Dave Hello
1756bdd033 Improve tw Traditional Chinese locale 2024-04-12 00:18:15 +08:00
黑云白土
0cffaf8dc5 Merge branch 'main' into main 2024-04-11 10:30:05 +08:00
DeanYao
55a93e7b47 Merge pull request #4487 from leo4life2/main
Support `gpt-4-turbo` and `gpt-4-turbo-2024-04-09`
2024-04-11 09:26:08 +08:00
黑云白土
5dc5bfb797 Merge branch 'main' into main 2024-04-11 01:24:34 +08:00
Leo Li
f101ee3c4f support new vision models 2024-04-10 05:33:54 -04:00
Leo Li
6319f41b2c add new turbo 2024-04-10 05:18:39 -04:00
Leo Li
6c718ada1b Merge branch 'main' of github.com:ChatGPTNextWeb/ChatGPT-Next-Web 2024-04-10 05:14:44 -04:00
DeanYao
67acc38a1f Merge pull request #4480 from ChatGPTNextWeb/chore-fix
feat: Solve the problem of using openai interface protocol for user-d…
2024-04-10 09:26:21 +08:00
DeanYao
dd1d8509f0 Merge pull request #4476 from dlb-data/dlb-data-patch-1
Update layout.tsx
2024-04-10 09:13:22 +08:00
butterfly
79f342439a feat: Solve the problem of using openai interface protocol for user-defined claude model & add some famous webdav endpoints 2024-04-09 20:49:51 +08:00
DeanYao
13db64f0ec Merge pull request #4479 from ChatGPTNextWeb/chore-fix
feat: white webdav server domain
2024-04-09 18:34:28 +08:00
butterfly
908ce3bbd9 feat: Optimize document 2024-04-09 18:25:51 +08:00
butterfly
df3313971d feat: Optimize code 2024-04-09 18:24:22 +08:00
butterfly
b175132854 feat: Optimize var names 2024-04-09 18:23:52 +08:00
butterfly
4cb0655192 feat: Optimize document 2024-04-09 18:17:00 +08:00
butterfly
8b191bd2f7 feat: white webdav server domain 2024-04-09 18:05:56 +08:00
DeanYao
f3106e3bbb Merge pull request #4477 from ChatGPTNextWeb/chore-fix
feat: 补充文档
2024-04-09 16:50:47 +08:00
butterfly
7fcfbc3729 feat: 补充文档 2024-04-09 16:49:51 +08:00
dlb-data
598468c2b7 Update layout.tsx 2024-04-09 16:34:21 +08:00
dlb-data
84681d3878 Update layout.tsx 2024-04-09 16:24:03 +08:00
DeanYao
c7b14cba4d Merge pull request #4470 from ChatGPTNextWeb/chore-fix
feat: fix system prompt
2024-04-09 10:45:55 +08:00
butterfly
d508127452 feat: fix system prompt 2024-04-09 10:45:09 +08:00
DeanYao
984c79e2d2 Merge pull request #4469 from ChatGPTNextWeb/chore-fix
feat: remove debug code
2024-04-09 09:13:07 +08:00
butterfly
6cb296f952 feat: remove debug code 2024-04-09 09:12:18 +08:00
DeanYao
db533fc166 Merge pull request #4466 from ChatGPTNextWeb/chore-fix
feat: modify some propmt in DEFAULT_INPUT_TEMPLATE about expressing l…
2024-04-08 19:33:27 +08:00
butterfly
02b0e79ba3 feat: modify some propmt in DEFAULT_INPUT_TEMPLATE about expressing latex 2024-04-08 19:27:22 +08:00
DeanYao
1b83dd0a8a Merge pull request #4462 from ChatGPTNextWeb/chore-fix
feat: fix no max_tokens in payload when calling openai vision model
2024-04-08 18:31:52 +08:00
butterfly
9b982b408d feat: fix no max_tokens in payload when calling openai vision model 2024-04-08 18:29:08 +08:00
DeanYao
9b03ab830d Merge pull request #4461 from ChatGPTNextWeb/chore-fix
feat: remove duplicate Input Template
2024-04-08 18:08:48 +08:00
butterfly
264da6798c feat: remove duplicate Input Template 2024-04-08 18:06:17 +08:00
DeanYao
f68b8afa8d Merge pull request #4457 from ChatGPTNextWeb/feat-multi-models
Feat multi models
2024-04-08 17:10:29 +08:00
butterfly
63f9063255 feat: call claude api not in credential 'include' mode 2024-04-08 15:33:27 +08:00
butterfly
6dad353e1c feat: call claude api not in credential 'include' mode 2024-04-08 15:33:02 +08:00
butterfly
5446d8d4a2 feat: fix illegal exports in app/api/anthropic/[...path]/route.ts 2024-04-08 13:59:55 +08:00
butterfly
ef7617d545 feat: configs about app client 2024-04-08 13:41:02 +08:00
butterfly
0fbb560e90 feat: delete returned models in modals function of ClaudeApi instance 2024-04-07 20:05:19 +08:00
butterfly
86b5c55855 feat: roles must alternate between user and assistant in claude, so add a fake assistant message between two user messages 2024-04-07 18:02:31 +08:00
butterfly
768decde93 feat: parse response message 2024-04-07 15:20:27 +08:00
butterfly
3cb4315193 feat: clean codes 2024-04-07 11:50:25 +08:00
butterfly
69b079c86e feat: dev done 2024-04-07 11:32:57 +08:00
DeanYao
9f3fc5eb9f Merge pull request #4417 from xiaotianxt/main
Update apple-touch-icon.png
2024-04-04 08:32:39 +08:00
butterfly
15e595837b feat: settings command dev done 2024-04-02 14:21:49 +08:00
xiaotianxt
17e57bb28e feat: update apple-touch-icon.png 2024-03-30 11:38:20 +08:00
黑云白土
4d0c77b973 更新 constant.ts 2024-03-28 21:42:45 +08:00
黑云白土
f8b180ac44 Update google.ts 2024-03-28 15:52:38 +08:00
黑云白土
cd30368da9 Update constant.ts 2024-03-28 15:51:06 +08:00
黑云白土
27ed57a648 Update utils.ts 2024-03-28 15:49:49 +08:00
DeanYao
e38b527ac2 Merge pull request #3205 from H0llyW00dzZ/summarizelogic
Refactor Summarize Logic
2024-03-28 15:19:32 +08:00
DeanYao
113d9612db Merge pull request #3280 from surkaa/patch-1
Update .env.template 更正单词
2024-03-28 13:40:17 +08:00
DeanYao
6b3daec23f Merge pull request #3314 from H0llyW00dzZ/text-moderation-azure
Feat ChatGPT LLM Api [Console Log] [Text Moderation] [Azure]
2024-03-28 13:38:56 +08:00
DeanYao
e056a1d46d Merge pull request #3405 from Yuliang-Lee/fix/MessageSelectorWarning
fix: MessageSelectorWarning
2024-03-28 11:38:45 +08:00
DeanYao
57026f6262 Merge pull request #3424 from H0llyW00dzZ/serverrside
Refactor Api Common [Server Side] [Console Log]
2024-03-28 11:29:48 +08:00
DeanYao
8ef77f50c3 Merge branch 'main' into serverrside 2024-03-28 11:20:52 +08:00
DeanYao
93e21515e5 Merge pull request #4408 from hmhuming/main
fix docker
2024-03-28 10:28:11 +08:00
hmhuming
24caa3b97b Merge branch 'main' of https://github.com/hmhuming/ChatGPT-Next-Web 2024-03-28 09:18:51 +08:00
DeanYao
c93b36fe79 Merge pull request #3508 from reece00/Mask-language
The language filtering option of the mask is stored
2024-03-27 19:58:30 +08:00
DeanYao
0de9242a26 Merge pull request #3529 from erich2s/chat-item-selected-border
fix(chat-item): selected ChatItem showing border in other pages
2024-03-27 19:00:16 +08:00
hmhuming
53fb52c6c0 fix docker 2024-03-27 17:58:55 +08:00
DeanYao
afaa529ba6 Merge pull request #3870 from Dup4/fix-webdav-check
fix: webdav check httpcode list
2024-03-27 14:04:15 +08:00
DeanYao
43824bd621 Merge pull request #4193 from MrrDrr/env_bug_fix
Update README.md
2024-03-26 17:53:57 +08:00
DeanYao
3c97a4f5a1 Merge pull request #4091 from H0llyW00dzZ/docker-ignore
Update Docker Ignore
2024-03-26 17:44:52 +08:00
DeanYao
711bf190d4 Merge pull request #4264 from ChatGPTNextWeb/dependabot/npm_and_yarn/tauri-apps/cli-1.5.11
chore(deps-dev): bump @tauri-apps/cli from 1.5.7 to 1.5.11
2024-03-26 17:32:01 +08:00
DeanYao
1049006cf9 Merge pull request #4366 from Essmatiko123/dependabot/npm_and_yarn/eslint-plugin-prettier-5.1.3
chore(deps-dev): bump eslint-plugin-prettier from 4.2.1 to 5.1.3
2024-03-26 13:57:50 +08:00
dependabot[bot]
76603d108d chore(deps-dev): bump @tauri-apps/cli from 1.5.7 to 1.5.11
Bumps [@tauri-apps/cli](https://github.com/tauri-apps/tauri) from 1.5.7 to 1.5.11.
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/@tauri-apps/cli-v1.5.7...@tauri-apps/cli-v1.5.11)

---
updated-dependencies:
- dependency-name: "@tauri-apps/cli"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-26 05:42:55 +00:00
DeanYao
5bc3930230 Merge pull request #4389 from ChatGPTNextWeb/dependabot/npm_and_yarn/types/react-18.2.70
chore(deps-dev): bump @types/react from 18.2.14 to 18.2.70
2024-03-26 13:41:13 +08:00
DeanYao
e5edd851b3 Merge pull request #4390 from ChatGPTNextWeb/dependabot/npm_and_yarn/emoji-picker-react-4.9.2
chore(deps): bump emoji-picker-react from 4.5.15 to 4.9.2
2024-03-26 13:35:03 +08:00
dependabot[bot]
dcad400758 chore(deps-dev): bump @types/react from 18.2.14 to 18.2.70
Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 18.2.14 to 18.2.70.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-26 05:26:51 +00:00
DeanYao
a1aaea9c55 Merge pull request #4391 from ChatGPTNextWeb/dependabot/npm_and_yarn/types/node-20.11.30
chore(deps-dev): bump @types/node from 20.9.0 to 20.11.30
2024-03-26 13:24:24 +08:00
DeanYao
a4e4286e04 Merge pull request #4186 from MrrDrr/formula_rendering
support \(...\) and \[...\] style math formula
2024-03-25 19:55:57 +08:00
dependabot[bot]
6dd7a6a171 chore(deps-dev): bump @types/node from 20.9.0 to 20.11.30
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.9.0 to 20.11.30.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-25 10:47:55 +00:00
dependabot[bot]
8e554a87b0 chore(deps): bump emoji-picker-react from 4.5.15 to 4.9.2
Bumps [emoji-picker-react](https://github.com/ealush/emoji-picker-react) from 4.5.15 to 4.9.2.
- [Release notes](https://github.com/ealush/emoji-picker-react/releases)
- [Commits](https://github.com/ealush/emoji-picker-react/commits)

---
updated-dependencies:
- dependency-name: emoji-picker-react
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-25 10:46:24 +00:00
fred-bf
f1b4c083a4 Merge pull request #4379 from EasonQwQ/main
Fix: Handle empty server response in API call
2024-03-24 14:18:14 +08:00
fred-bf
90af4e3b77 Merge pull request #4381 from ChatGPTNextWeb/fred-bf-patch-3
patch: disable webdav redirect
2024-03-24 14:17:11 +08:00
fred-bf
e8d76a513d patch: disable webdav redirect 2024-03-24 14:15:04 +08:00
kidv
29e03b88c7 Fix: Handle empty server response in API call 2024-03-24 04:07:25 +08:00
fred-bf
ebbd870150 Merge pull request #4353 from H0llyW00dzZ/cherry-pick-webdav
[Cherry Pick] Fix Webdav Syncing Issues
2024-03-21 18:56:26 +08:00
H0llyW00dzZ
c0c54e5709 Fix Webdav Syncing Issues
- [+] feat(route.ts): add endpoint validation and improve error handling
- [+] refactor(route.ts): use targetPath for request validation and error messages
- [+] fix(route.ts): correct targetUrl formation
2024-03-20 01:40:41 +07:00
fred-bf
3ba984d09e Merge pull request #4306 from H0llyW00dzZ/simplify-cherry-pick
[Cherry Pick] Improve [Utils] Check Vision Model
2024-03-19 17:45:57 +08:00
fred-bf
f274683d46 Merge pull request #4322 from imraax/dev
Fix "Enter" bug
2024-03-19 17:44:41 +08:00
fred-bf
e20ce8e335 Merge pull request #4339 from ChatGPTNextWeb/fred-bf-patch-2
feat: update vercel deploy env
2024-03-18 18:25:24 +08:00
fred-bf
9fd750511c feat: update vercel deploy env 2024-03-18 18:24:48 +08:00
Raax
028957fcdc Fix "Enter" bug
Fix Chinese input method "Enter" on Safari
2024-03-16 21:55:16 +08:00
H0llyW00dzZ
a4c54cae60 Improve [Utils] Check Vision Model
- [+] refactor(utils.ts): improve isVisionModel function to use array.some instead of model.includes
2024-03-15 09:38:42 +07:00
fred-bf
cc0eae7153 Merge pull request #4288 from fred-bf/fix/migrate-proxy-url
fix: auto migrate proxy config
2024-03-14 03:05:08 +08:00
Fred
066ca9e552 fix: auto migrate proxy config 2024-03-14 03:03:46 +08:00
fred-bf
7c04a90d77 Merge pull request #4287 from fred-bf/main
feat: bump version
2024-03-14 02:30:58 +08:00
fred-bf
a8a65ac769 Merge branch 'ChatGPTNextWeb:main' into main 2024-03-14 02:30:22 +08:00
Fred
aec3c5d6cc feat: bump version 2024-03-14 02:29:31 +08:00
fred-bf
a22141c2eb Merge pull request #4285 from fred-bf/fix/cors-ssrf
[Bugfix] Fix CORS SSRF security issue
2024-03-14 02:27:55 +08:00
Fred
99aa064319 fix: fix webdav sync issue 2024-03-14 01:58:25 +08:00
Fred
6aaf83f3c2 fix: fix upstash sync issue 2024-03-14 01:56:36 +08:00
Fred
133ce39a13 chore: update cors default path 2024-03-14 01:33:41 +08:00
Fred
8645214654 fix: change matching pattern 2024-03-14 01:26:13 +08:00
Fred
eebc334e02 fix: remove corsFetch 2024-03-14 00:57:54 +08:00
Fred
038fa3b301 fix: add webdav request filter 2024-03-14 00:33:26 +08:00
Fred
9a8497299d fix: adjust upstash api 2024-03-13 23:58:28 +08:00
fred-bf
61ce3868b5 Merge pull request #4279 from SukkaW/package-json-corepack
chore: specify yarn 1 in package.json
2024-03-13 20:09:57 +08:00
SukkaW
844c2a26bc chore: specify yarn 1 in package.json 2024-03-13 13:30:16 +08:00
fred-bf
a15c4d9c20 Merge pull request #4234 from fengzai6/main
Fix EmojiPicker mobile width adaptation and update avatar clicking behavior
2024-03-11 13:59:09 +08:00
fred-bf
ff9f0e60ac Merge pull request #3972 from greenjerry/fix-export-garbled
fix: 修复导出时字符乱码问题
2024-03-07 17:07:16 +08:00
fred-bf
2bf6111bf5 Merge branch 'main' into fix-export-garbled 2024-03-07 17:07:08 +08:00
fengzai6
ad10a11903 Add z-index to avatar 2024-03-07 15:51:58 +08:00
fengzai6
c22153a4eb Revert "fix: No history message attached when for gemini-pro-vision"
This reverts commit c197962851.
2024-03-07 15:46:13 +08:00
fengzai6
5348d57057 Fix EmojiPicker mobile width adaptation and update avatar clicking behavior 2024-03-07 15:36:19 +08:00
fengzai6
052524dabd Merge remote-tracking branch 'upstream/main' 2024-03-07 15:32:09 +08:00
Leo Li
e33d05cfe5 merge 2024-03-05 16:48:10 -05:00
fred-bf
5529ece220 Merge pull request #4218 from ChatGPTNextWeb/fred-bf-patch-1
chore: update GTM_ID definition
2024-03-05 17:37:22 +08:00
fred-bf
e71094d4a8 chore: update GTM_ID definition, close #4217 2024-03-05 17:36:52 +08:00
fred-bf
98aa023d70 Merge pull request #4195 from aliceric27/main
slightly polishes the tw text.
2024-03-04 19:03:23 +08:00
aliceric27
e1066434d0 fix some text 2024-03-03 00:23:00 +08:00
aliceric27
86ae4b2a75 slightly polishes the tw text. 2024-03-02 23:58:23 +08:00
l.tingting
ed8099bf1e Update README.md 2024-03-02 15:26:19 +08:00
l.tingting
524c9beee4 support \(...\) and \[...\] style math formula 2024-03-02 11:08:34 +08:00
fred-bf
99fb9dcf11 Merge pull request #4164 from KSnow616/main
feat: Pasting images into the textbox
2024-02-29 22:14:02 +08:00
fred-bf
1294817103 Merge pull request #4089 from H0llyW00dzZ/cherry-pick
[Cherry Pick] Fix [Utils] Regex trimTopic
2024-02-29 16:31:30 +08:00
Snow Kawashiro
9775660da7 Update chat.tsx 2024-02-28 20:45:42 +08:00
Snow Kawashiro
e7051353eb vision_model_only 2024-02-28 20:38:00 +08:00
Snow Kawashiro
bd19e97cf8 add_image_pasting 2024-02-28 20:05:13 +08:00
fred-bf
8b821ac0c9 Merge pull request #4162 from fred-bf/fix/identify-vision-model
fix: fix the method to detect vision model
2024-02-28 11:35:22 +08:00
Fred
43e5dc2292 fix: fix the method to detect vision model 2024-02-28 11:33:43 +08:00
fred-bf
08fa22749a fix: add max_tokens when using vision model (#4157) 2024-02-27 17:28:01 +08:00
fengzai6
c197962851 fix: No history message attached when for gemini-pro-vision 2024-02-27 15:02:58 +08:00
fred-bf
44a51273be Merge pull request #4149 from fred-bf/feat/auto-detach-scrolling
feat: auto detach scrolling
2024-02-27 11:56:37 +08:00
Fred
e3b3ae97bc chore: clear scroll info 2024-02-27 11:49:44 +08:00
Fred
410a22dc63 feat: auto detach scrolling 2024-02-27 11:43:40 +08:00
Algorithm5838
069766d581 Correct cutoff dates (#4118) 2024-02-27 10:28:54 +08:00
DonaldBear
f22e36e52f feat(tw.ts): added new translations (#4142)
* feat(tw.ts): added new translations

I have translated previously untranslated text in response to the latest update.

* feat(tw.ts): added new translations

I have translated previously untranslated text in response to the latest update.
2024-02-27 00:16:56 +08:00
fred-bf
bc1794fb4a feat: bump version (#4133) 2024-02-26 18:15:00 +08:00
Fred
aacd26c7db feat: bump version 2024-02-26 18:14:10 +08:00
fred-bf
ff166f7b4c chore: adjust for ollama support (#4129) 2024-02-26 17:18:46 +08:00
H0llyW00dzZ
bf1b5c3951 Update Docker Ignore
- [+] chore(dockerignore): update .dockerignore file with more comprehensive ignore rules
2024-02-21 08:46:21 +07:00
H0llyW00dzZ
22baebaf8c [Cherry Pick] Fix [Utils] Regex trimTopic
- [+] fix(utils.ts): update regular expressions in trimTopic function to handle asterisks
2024-02-21 04:19:12 +07:00
H0llyW00dzZ
e756506c18 [Cherry Pick] Improve Github Issue Template (#4041)
* Improve Github Issue Template

- [+] feat(issue templates): update issue templates from markdown to form schema
- [+] feat(issue templates): translate issue templates to Chinese
- [+] remove(issue templates): delete old markdown issue templates

* chore: remove Chinese template issue temporarily

---------

Co-authored-by: Fred <fred@nextchat.dev>
2024-02-20 18:11:02 +08:00
Qiying Wang
fd67f980a5 Fix temperature range (#4083) 2024-02-20 18:05:17 +08:00
TheRam_
e2da3406d2 Add vision support (#4076) 2024-02-20 18:04:32 +08:00
Ikko Eltociear Ashimine
05b6d989b6 chore: fix typo in next.config.mjs (#4072)
verison -> version
2024-02-20 17:59:59 +08:00
H0llyW00dzZ
1d6ee64e1d [Cherry Pick] Fix [UI/UX] [Front End] Settings Page (#4032)
* Fix [UI/UX] [Locales] Correct Spelling

- [+] fix(locales): correct spelling and improve wording in cn.ts and en.ts locale files

* Fix [UI/UX] [Front End] Settings Page

- [+] fix(settings.tsx): correct typo in ApiVerion to ApiVersion
- [+] refactor(settings.tsx): switch Azure.ApiKey to Google.ApiKey in ListItem title and subTitle

* Fix [UI/UX] [Locales] [SK] Correct Typo

- [+] fix(sk.ts): correct typo in ApiVersion key in Slovak locale file
2024-02-12 20:36:52 +08:00
fred-bf
bfefb99192 chore: update tauri dependencies (#4018)
* feat: bump version

* feat: bump version

* chore: update tauri dependencies
2024-02-07 14:12:04 +08:00
Anivie Michaelis
47ae874e4d fix: add support to http scheme. (#3985)
Co-authored-by: fred-bf <157469842+fred-bf@users.noreply.github.com>
2024-02-07 13:48:28 +08:00
fred-bf
d74f636558 Fix/gemini app endpoint (#4017)
* fix: support custom api endpoint

* fix: attach api key to google gemini
2024-02-07 13:46:52 +08:00
fred-bf
b8f0822214 fix: support custom api endpoint (#4016) 2024-02-07 13:40:30 +08:00
fred-bf
0869455612 feat: bump version (#4015)
* feat: bump version

* feat: bump version
2024-02-07 13:38:02 +08:00
fred-bf
bca74241e6 fix: fix gemini issue when using app (#4013)
* chore: update path

* fix: fix google auth logic

* fix: not using header authorization for google api

* chore: revert to allow stream
2024-02-07 13:17:11 +08:00
fred-bf
9d5801fb5f fix: avoiding not operation for custom models (#4010) 2024-02-07 10:31:49 +08:00
H0llyW00dzZ
462a88ae82 Fix [CI/CD] [Vercel] Deploy Preview (#4005)
- [+] feat(.github/workflows/deploy_preview.yml): add 'reopened' event trigger
2024-02-06 17:20:12 +08:00
fred-bf
887bec019a feat: bump version (#4009) 2024-02-06 15:34:43 +08:00
吴迪
d0463b2089 feat(mac): add sign config, fix arm64 build (#4008)
Co-authored-by: vir <wudi@hudiexiaoying.cn>
2024-02-06 15:11:20 +08:00
Shiroki Satsuki
deea4320ad ci: build universal binary for macos (#3711)
* ci: build universal binary for macos

* ci: add tauri args

* ci: restore rust_target

* ci: fallback value for tauri build args

* ci: cache yarn
2024-02-06 15:07:01 +08:00
H0llyW00dzZ
bb26c03141 Feat [UI/UX] [Constants] [Models] gemini-pro KnowledgeCutOffDate (#3997)
- [+] feat(constant.ts): add 'gemini-pro' to KnowledgeCutOffDate constant
2024-02-05 14:44:46 +08:00
H0llyW00dzZ
a5517a1a51 Improve Default System Template (#3996)
* Feat [UI/UX] [Constant] [DEFAULT System Template] replace hardcoded

- [+] feat(constant.ts): replace hardcoded OpenAI with dynamic ServiceProvider variable in DEFAULT_SYSTEM_TEMPLATE

* Improve [UI/UX] [Chat] "fillTemplateWith"

- [+] feat(chat.ts): add DEFAULT_MODELS to modelConfig
- [+] fix(chat.ts): replace replaceAll with regex in output string replacement
- [+] refactor(chat.ts): use const instead of let for cutoff variable
2024-02-05 13:35:51 +08:00
H0llyW00dzZ
4511aa4d21 Fix [UI/UX] [Emoji] Update CDN (#3966)
- [+] fix(emoji.tsx): update CDN url from 'cdn.jsdelivr.net' to 'fastly.jsdelivr.net'
2024-02-03 01:24:37 +08:00
Duplicate4
b25a0545f5 fix: typo (#3871)
Signed-off-by: Dup4 <lyuzhi.pan@gmail.com>
2024-02-03 01:20:10 +08:00
H0llyW00dzZ
1a97bd55c7 Feat [OpenAI] [Models] gpt-3.5-turbo-0125 (#3971)
- [+] feat(constant.ts): add new model 'gpt-3.5-turbo-0125' to DEFAULT_MODELS array
2024-02-03 01:19:25 +08:00
greenjerry
bf711f2ad7 修复导出json和markdown时中文及其他utf8字符乱码问题 2024-02-02 13:58:06 +08:00
Jerry Zhou
e1b065c74a [ImgBot] Optimize images (#3941)
*Total -- 2,603.15kb -> 2,299.83kb (11.65%)

/docs/images/upstash-7.png -- 83.76kb -> 56.04kb (33.1%)
/public/android-chrome-512x512.png -- 95.26kb -> 65.26kb (31.49%)
/docs/images/upstash-1.png -- 23.29kb -> 15.98kb (31.4%)
/docs/images/upstash-6.png -- 66.20kb -> 46.73kb (29.42%)
/docs/images/upstash-5.png -- 66.88kb -> 47.31kb (29.26%)
/docs/images/upstash-4.png -- 37.04kb -> 26.39kb (28.75%)
/docs/images/upstash-3.png -- 49.25kb -> 35.67kb (27.57%)
/public/apple-touch-icon.png -- 18.71kb -> 14.60kb (21.97%)
/public/android-chrome-192x192.png -- 20.52kb -> 16.06kb (21.74%)
/public/macos.png -- 73.77kb -> 57.84kb (21.59%)
/src-tauri/icons/icon.png -- 82.18kb -> 65.26kb (20.58%)
/docs/images/upstash-2.png -- 64.79kb -> 51.85kb (19.97%)
/app/icons/send-white.svg -- 0.95kb -> 0.82kb (13.83%)
/app/icons/close.svg -- 0.96kb -> 0.83kb (13.76%)
/app/icons/reload.svg -- 1.22kb -> 1.06kb (13.36%)
/app/icons/return.svg -- 0.99kb -> 0.86kb (13.33%)
/app/icons/chat.svg -- 1.50kb -> 1.31kb (12.28%)
/app/icons/settings.svg -- 1.10kb -> 0.96kb (12.02%)
/app/icons/eye.svg -- 1.30kb -> 1.17kb (9.92%)
/public/favicon-32x32.png -- 1.70kb -> 1.56kb (8.12%)
/app/icons/eye-off.svg -- 1.84kb -> 1.71kb (6.99%)
/docs/images/head-cover.png -- 1,724.78kb -> 1,608.59kb (6.74%)
/app/icons/bot.svg -- 4.17kb -> 3.89kb (6.57%)
/src-tauri/icons/Square71x71Logo.png -- 6.38kb -> 6.03kb (5.47%)
/src-tauri/icons/Square89x89Logo.png -- 8.61kb -> 8.16kb (5.25%)
/src-tauri/icons/Square44x44Logo.png -- 3.44kb -> 3.30kb (4.09%)
/app/icons/clear.svg -- 1.24kb -> 1.19kb (3.87%)
/app/icons/edit.svg -- 1.61kb -> 1.55kb (3.58%)
/app/icons/pause.svg -- 1.09kb -> 1.05kb (3.58%)
/src-tauri/icons/Square107x107Logo.png -- 11.03kb -> 10.64kb (3.57%)
/app/icons/rename.svg -- 0.89kb -> 0.86kb (3.29%)
/src-tauri/icons/StoreLogo.png -- 4.03kb -> 3.90kb (3.2%)
/app/icons/auto.svg -- 1.30kb -> 1.26kb (3.01%)
/app/icons/chatgpt.svg -- 3.51kb -> 3.42kb (2.64%)
/app/icons/dark.svg -- 0.83kb -> 0.81kb (2.35%)
/app/icons/light.svg -- 2.98kb -> 2.91kb (2.23%)
/app/icons/drag.svg -- 1.36kb -> 1.33kb (2.16%)
/src-tauri/icons/128x128.png -- 13.99kb -> 13.73kb (1.82%)
/app/icons/pin.svg -- 5.83kb -> 5.75kb (1.39%)
/app/icons/break.svg -- 4.53kb -> 4.47kb (1.21%)
/app/icons/connection.svg -- 8.14kb -> 8.05kb (1.01%)
/app/icons/confirm.svg -- 2.24kb -> 2.22kb (1%)
/app/icons/cancel.svg -- 3.54kb -> 3.50kb (0.97%)
/app/icons/robot.svg -- 11.72kb -> 11.62kb (0.89%)
/app/icons/config.svg -- 14.93kb -> 14.81kb (0.78%)
/app/icons/cloud-fail.svg -- 6.25kb -> 6.20kb (0.75%)
/app/icons/cloud-success.svg -- 4.69kb -> 4.66kb (0.75%)
/app/icons/upload.svg -- 10.70kb -> 10.62kb (0.75%)
/app/icons/lightning.svg -- 3.49kb -> 3.47kb (0.62%)
/app/icons/chat-settings.svg -- 8.99kb -> 8.96kb (0.38%)
/src-tauri/icons/Square142x142Logo.png -- 16.15kb -> 16.09kb (0.34%)
/src-tauri/icons/Square150x150Logo.png -- 17.53kb -> 17.51kb (0.07%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
Co-authored-by: ImgBotApp <ImgBotHelp@gmail.com>
2024-01-29 14:10:07 +08:00
Takanezawa, Kosuke
eacc911cdc Add Japanese translations for ChatGPT messages (#3936) 2024-01-29 10:38:25 +08:00
H0llyW00dzZ
91a5f4af9a Feat Models [GPT-4-turbo] (#3932)
[+] feat(constant.ts): add new models to DEFAULT_MODELS array
2024-01-27 15:58:35 +08:00
NieXi
f6cdda9029 update google api region (#3934)
Co-authored-by: GH Action - Upstream Sync <action@github.com>
2024-01-27 15:55:44 +08:00
Leo Li
981a3629b6 Add gpt-4-0125-preview (#3924) 2024-01-26 15:00:02 +08:00
Leo Li
3554872d9a Add gpt-4-0125-preview 2024-01-25 15:09:48 -05:00
sungaomeng
118e8f541c fix: cicd, checkout sha Merge pull request #3920 from sungaomeng/main
fix: cicd, checkout sha
2024-01-25 21:25:49 +08:00
孙高猛
3fa55f9022 fix: cicd, checkout sha 2024-01-25 21:17:10 +08:00
fred-bf
1c73c6cc70 Merge pull request #3917 from sungaomeng/main
fix: cicd, remove workflow_dispatch
2024-01-25 20:50:21 +08:00
孙高猛
148c32a383 fix: cicd, remove workflow_dispatch 2024-01-25 20:46:42 +08:00
sungaomeng
ca08062bf3 Merge pull request #3913 from sungaomeng/main
fix: cicd, delete velcel pre
2024-01-25 20:00:11 +08:00
孙高猛
ecbab75a25 fix: cicd, alias domain env name 2024-01-25 19:54:58 +08:00
孙高猛
15d25df245 fix: cicd, delete velcel pre 2024-01-25 19:43:26 +08:00
sungaomeng
b2f7bdca34 Merge branch 'ChatGPTNextWeb:main' into main 2024-01-25 19:38:35 +08:00
sungaomeng
32bcdb8982 fix: cicd, delete vercel pre (#3910) 2024-01-25 19:37:29 +08:00
孙高猛
ba3e7e7974 fix: cicd, vercel alias domain 2024-01-25 19:31:17 +08:00
孙高猛
fae82a30e7 fix: cicd, delete vercel pre 2024-01-25 16:53:03 +08:00
fred-bf
5ce1a55a4f Merge pull request #3909 from sungaomeng/main
fix: ci for vercel deployment
2024-01-25 15:40:11 +08:00
sungaomeng
1132816902 Merge pull request #2 from sungaomeng/sgm/cicd
fix: ci for vercel deployment
2024-01-25 15:28:23 +08:00
sungaomeng
a4c1f1ac47 Merge branch 'main' into sgm/cicd 2024-01-25 15:27:41 +08:00
fred-bf
f619e9df24 chore: change default submit key 2024-01-25 15:22:45 +08:00
孙高猛
43631a3271 fix: cicd, vercel domain suffix secret 2024-01-25 15:17:03 +08:00
fred-bf
04855de495 Merge pull request #3789 from wyxogo/upstream
Fix: Routes `/api/cors/[...path]` was not configured to run with the …
2024-01-25 15:07:40 +08:00
孙高猛
6206ceb49b fix: cicd , add pull_request_target 2024-01-25 15:00:44 +08:00
sungaomeng
628f3ba05f Merge branch 'ChatGPTNextWeb:sgm/cicd' into sgm/cicd 2024-01-25 14:35:32 +08:00
孙高猛
94f534f36f fix vercel alias domains 2024-01-25 14:33:38 +08:00
孙高猛
2f11e7c087 test pr deploy 2024-01-25 11:04:32 +08:00
孙高猛
0739ced407 add workflow_dispatch 2024-01-25 10:57:47 +08:00
孙高猛
8fb34465cd add delete preview scripts 2024-01-25 10:57:47 +08:00
孙高猛
04fe9fadb1 add workflows/deploy preview 2024-01-25 10:57:47 +08:00
孙高猛
998c95da51 test pr deploy 2024-01-24 22:10:02 +08:00
孙高猛
3bc771a0d9 add workflow_dispatch 2024-01-24 22:03:47 +08:00
孙高猛
bdc25adb41 add delete preview scripts 2024-01-24 20:49:30 +08:00
孙高猛
7ec29c2eea add workflows/deploy preview 2024-01-24 20:19:39 +08:00
Fred Liang
b9995e7f70 feat: update dependencies 2024-01-22 15:12:21 +08:00
Fred Liang
31d9d2efcd feat: support setting up GTM 2024-01-22 15:12:21 +08:00
Dup4
86f42d56f2 fix: webdav check httpcode list
Signed-off-by: Dup4 <lyuzhi.pan@gmail.com>
2024-01-18 09:11:13 +08:00
dependabot[bot]
f05bf0a6f6 chore(deps-dev): bump eslint-plugin-prettier from 4.2.1 to 5.1.3
Bumps [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) from 4.2.1 to 5.1.3.
- [Release notes](https://github.com/prettier/eslint-plugin-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-plugin-prettier/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-plugin-prettier/compare/v4.2.1...v5.1.3)

---
updated-dependencies:
- dependency-name: eslint-plugin-prettier
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-15 10:20:50 +00:00
Yasin
a06a2989d2 Merge branch 'ChatGPTNextWeb:main' into upstream 2024-01-11 13:17:56 +08:00
Yifei Zhang
0cf6614186 Merge pull request #3813 from ChatGPTNextWeb/update-cn-qq-info
Update README_CN.md
2024-01-11 01:27:43 +08:00
Yifei Zhang
ddc16cc2be Update README_CN.md 2024-01-11 01:27:31 +08:00
Yifei Zhang
616f0b3e73 Merge pull request #3812 from ChatGPTNextWeb/update-twitter-info
doc: update twitter & QQ group info
2024-01-11 01:26:30 +08:00
Yifei Zhang
bd1056b13f Update README.md 2024-01-11 01:25:20 +08:00
Yasin
5a7b9abe33 Fix: Routes /api/cors/[...path] was not configured to run with the Edge Runtime 2024-01-08 18:43:31 +08:00
Fred Liang
cc86923fd5 fix: import typing error 2024-01-05 21:19:51 +08:00
Fred Liang
916d764477 fix: import language issue 2024-01-05 21:17:26 +08:00
levidius
4a21a8fdae Added Slovak language support
Slovak language was added app/locales/sk.ts and also the language was added to app/locales/index.ts
2024-01-05 21:05:17 +08:00
H0llyW00dzZ
49df5bd590 Chore [Tauri Desktop] Update icons
- [+] chore(icons): update icon files in src-tauri/icons directory
2024-01-05 21:03:44 +08:00
Fred Liang
9eaf492d5b chore: low the google safety setting to avoid unexpected blocking 2023-12-31 19:49:22 +08:00
Fred Liang
a80502f7db fix: fix gemini pro streaming api duplicated issue (#3721)
* fix: streaming duplicated issue

* chore: remove debug logs

* chore: add types defination
2023-12-31 19:08:16 +08:00
Fred Liang
eade013138 fix: update google url description (#3719)
* feat: update new logo & cover image

* fix: update google url description
2023-12-31 18:02:04 +08:00
Fred Liang
912254751a feat: update new logo & cover image (#3716) 2023-12-31 13:24:36 +08:00
Tashvik Srivastava
c3c6f7f7ae update readme (#3715) 2023-12-31 01:43:29 +08:00
Fred Liang
5cf58d9446 feat: support streaming for Gemini Pro (#3688)
* feat: support streaming for Gemini Pro

* feat: display texts smoothly

* chore: remove comments
2023-12-29 03:42:45 +08:00
reece00
3ba598633c Non -GPT model disable system prompt (#3684) 2023-12-28 23:52:45 +08:00
Fred Liang
406530ca69 feat: support vercel speed insight (#3686) 2023-12-28 23:10:19 +08:00
Fred Liang
f8b963df6d chore(docs): update readme for Gemini Pro (#3685)
* chore: update auth value logic

* chore: bump version 2.10.1

* Chore [Package] Downgrade Tauri Builder Version (#3656)

- [+] chore(package.json): update @tauri-apps/cli devDependency to version 1.5.7
- [+] chore(yarn.lock): update @tauri-apps/cli versions to 1.5.7

* chore: update README.md

---------

Co-authored-by: H0llyW00dzZ <priv8@btz.pm>
2023-12-28 22:56:01 +08:00
Fred Liang
d17000975f Release 2.10.1: Support Google Gemini Pro model (#3668)
* chore: update auth value logic

* chore: bump version 2.10.1
2023-12-27 23:45:40 +08:00
H0llyW00dzZ
64a8ba6212 Chore [Package] Downgrade Tauri Builder Version (#3656)
- [+] chore(package.json): update @tauri-apps/cli devDependency to version 1.5.7
- [+] chore(yarn.lock): update @tauri-apps/cli versions to 1.5.7
2023-12-27 23:43:48 +08:00
Yifei Zhang
350ddd2af1 Merge pull request #3215 from akira-cn/main
fix: remove the content-encoding header
2023-12-27 01:26:48 +08:00
Yifei Zhang
c03abdac42 Merge pull request #3570 from Rocky0102/main 2023-12-27 01:24:44 +08:00
ChatGPTNextWeb
bb3cc2c821 Update CN web app link in README.md 2023-12-26 12:49:11 +08:00
ChatGPTNextWeb
0b814eff83 Update Web App link in README.md 2023-12-26 12:47:25 +08:00
H0llyW00dzZ
a91ac91977 Fix [UI/UX] [Emoji] emoji URL (#3647) 2023-12-26 03:44:40 +08:00
Fred Liang
422d70d928 chore: update auth value logic (#3630) 2023-12-25 10:25:43 +08:00
Fred Liang
a4cb859472 Merge pull request #3629 from fredliang44/main
fix: return bearer header when using openai
2023-12-25 10:02:17 +08:00
Fred Liang
19137b79bc fix: return bearer header when using openai 2023-12-25 09:57:40 +08:00
Fred Liang
bd1e311674 Merge pull request #3626 from fredliang44/main
fix: removing `bearer` from auth header when using azure
2023-12-25 05:26:55 +08:00
Fred Liang
c127db335a Merge branch 'ChatGPTNextWeb:main' into main 2023-12-25 05:24:58 +08:00
Fred Liang
f5ed1604aa fix: fix removing bearer header 2023-12-25 05:24:20 +08:00
Fred Liang
771cc9e583 Merge pull request #3625 from fredliang44/main
fix: fix auth key selection when using different model
2023-12-25 05:13:38 +08:00
Fred Liang
7ad1e246a0 Merge branch 'ChatGPTNextWeb:main' into main 2023-12-25 05:12:57 +08:00
Fred Liang
5c638251f8 fix: fix using different model 2023-12-25 05:12:21 +08:00
Fred Liang
0d189dc78e Merge pull request #3622 from fredliang44/main
chore: auto concat messages for gemini-pro
2023-12-25 04:39:03 +08:00
Fred Liang
a9d7253143 Merge branch 'ChatGPTNextWeb:main' into main 2023-12-25 04:38:11 +08:00
Fred Liang
199f29e63c chore: auto concat messages 2023-12-25 04:33:47 +08:00
Fred Liang
c82efceae0 Merge pull request #3620 from fredliang44/main
fix: fix issue cannot using gemini-pro with code auth
2023-12-25 04:09:53 +08:00
Fred Liang
cad461b121 chore: remove console log 2023-12-25 04:09:38 +08:00
Fred Liang
5af68ac545 fix: fix issue https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/3616 2023-12-25 04:07:35 +08:00
Fred Liang
c0ce70ccc7 Merge pull request #3617 from H0llyW00dzZ/fixfixbug
Fix [Bug] Issue ChatGPTNextWeb#3615
2023-12-25 03:47:16 +08:00
Fred Liang
753c518d33 chore: update how to identify google model 2023-12-25 03:46:35 +08:00
Fred Liang
b9ca6694f7 Merge pull request #3619 from H0llyW00dzZ/fix_llm_issue 2023-12-25 03:32:02 +08:00
H0llyW00dzZ
0c116251b1 Revert "Fix [TypesScript] [LLM Api] Chaining Model"
This reverts commit 8ca525dc7a.

Reason: It's suddenly stopped lmao
2023-12-25 01:45:24 +07:00
H0llyW00dzZ
e9def2cdc5 Refactor [Model] [LLM Api] Chaining Model
- [+] refactor(model.ts): change forEach loop to arrow function for readability and consistency
- [+] fix(model.ts): mark 'provider' property as optional in modelTable type
- [+] fix(model.ts): use optional chaining when assigning provider property in modelTable
2023-12-25 01:12:44 +07:00
H0llyW00dzZ
8ca525dc7a Fix [TypesScript] [LLM Api] Chaining Model
- [+] fix(api.ts): make provider property optional in LLMModel interface
2023-12-25 01:12:44 +07:00
H0llyW00dzZ
281fe6927a Feat [Server Side] Google Api Configuration
- [+] feat(server.ts): add support for Google API configuration variables
2023-12-25 00:37:49 +07:00
H0llyW00dzZ
35471a41c8 Fix & Feat [Auth] Api Key Variable
- [+] fix(auth.ts): fix variable name from serverApiKey to systemApiKey
- [+] feat(auth.ts): add support for Google API key in addition to Azure API key
2023-12-25 00:37:48 +07:00
ChatGPTNextWeb
bda3098a31 Merge pull request #3609 from fredliang44/main
[Feature] support Gemini Pro API
2023-12-24 18:42:41 +08:00
Fred Liang
1e05eb1d60 chore: update docs for gemini pro 2023-12-24 17:54:33 +08:00
Fred Liang
6369b902bf chore: update README for google gemini pro model 2023-12-24 17:43:41 +08:00
Fred Liang
aea794e522 chore: remove openai log from google api handler 2023-12-24 17:37:07 +08:00
Fred Liang
7c3dfb7bae feat: support custom gemini pro params 2023-12-24 17:24:04 +08:00
Fred Liang
75057f9a91 chroe: support error reason for gemini pro 2023-12-24 04:32:25 +08:00
Fred Liang
7026bd926a chroe: update prompts 2023-12-24 04:24:38 +08:00
Fred Liang
ae19a0dc5f chroe: update model name 2023-12-24 04:22:12 +08:00
Fred Liang
7d9a2132cb fix: fix server token fetch policy 2023-12-24 03:57:30 +08:00
Fred Liang
20f2f61349 feat: support set api key from client side 2023-12-24 03:44:13 +08:00
Fred Liang
4169431f2c fix: fix add api auth 2023-12-24 03:05:23 +08:00
Fred Liang
45798f993d fix: fix type errors 2023-12-24 02:39:13 +08:00
Fred Liang
ae0d68c27e fix: fix llm models field 2023-12-24 02:39:13 +08:00
Fred Liang
070b191ffe Merge branch 'ChatGPTNextWeb:main' into main 2023-12-24 02:16:51 +08:00
Fred Liang
778e88cb56 chore: resolve conflict 2023-12-24 02:15:30 +08:00
Yifei Zhang
d1fcfa05db Merge pull request #3574 from RoboticsAndCloud/main
Update README.md
2023-12-23 23:56:35 +08:00
ChatGPTNextWeb
f4754dc5fc Merge pull request #3588 from qiuqfang/main
chore: update readme
2023-12-23 23:31:23 +08:00
ChatGPTNextWeb
0f885e73c3 Merge pull request #3607 from fredliang44/main
chore: update product name from`ChatGPT-Next-Web` to `NextChat` for legal concerns of the ChatGPT trademark. The repo name will not be changed for now.
2023-12-23 23:29:18 +08:00
Fred Liang
75acd4c1aa chore: append previous name 2023-12-23 22:21:27 +08:00
Fred Liang
3ef0621eb0 refactor: update product name 2023-12-23 16:48:16 +08:00
GH Action - Upstream Sync
b1db9ead0d Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2023-12-23 00:41:51 +00:00
Yifei Zhang
9cc6000d89 Merge pull request #3601 from zeabur/main
docs: add deploy to Zeabur button in README
2023-12-22 14:59:36 +08:00
Yuhang
42515899d9 docs: Add deploy to Zeabur button in REAME for Chinese 2023-12-22 14:58:30 +08:00
Yuhang
1d096eeb17 docs: Add deploy to Zeabur button in README 2023-12-22 14:57:54 +08:00
pdx
bc5b8f0ff9 Update README.md 2023-12-20 13:55:53 +08:00
pdx
bf412fd309 Update README.md 2023-12-20 13:55:22 +08:00
Fei
6e1f4246b8 Merge branch 'ChatGPTNextWeb:main' into main 2023-12-19 11:27:38 -08:00
Yifei Zhang
ed88619623 Merge pull request #3565 from ChatGPTNextWeb/dependabot/npm_and_yarn/hello-pangea/dnd-16.5.0
chore(deps): bump @hello-pangea/dnd from 16.3.0 to 16.5.0
2023-12-19 15:33:28 +08:00
Yifei Zhang
37b8922348 Merge pull request #3566 from ChatGPTNextWeb/dependabot/npm_and_yarn/tauri-apps/cli-1.5.8
chore(deps-dev): bump @tauri-apps/cli from 1.4.0 to 1.5.8
2023-12-19 15:33:19 +08:00
Yifei Zhang
4a23a0064b Merge pull request #3567 from ChatGPTNextWeb/dependabot/npm_and_yarn/fuse.js-7.0.0
chore(deps): bump fuse.js from 6.6.2 to 7.0.0
2023-12-19 15:33:10 +08:00
Yifei Zhang
e012b1e5d1 Merge pull request #3061 from ChatGPTNextWeb/dependabot/npm_and_yarn/babel/traverse-7.23.2
chore(deps): bump @babel/traverse from 7.21.3 to 7.23.2
2023-12-19 15:32:55 +08:00
Fei
2bc84ec908 Update README.md 2023-12-18 22:43:59 -06:00
Rocky
8efe26ed57 Merge pull request #1 from Rocky0102/Rocky0102-patch-proper-accept-header
Update api.ts to set proper header "Accept"
2023-12-18 20:36:59 +08:00
Rocky
24d7dc9bf9 Update api.ts to set proper header "Accept"
So it can work fine for some GPT forward service.
2023-12-18 20:34:37 +08:00
dependabot[bot]
4b7139d9ae chore(deps): bump fuse.js from 6.6.2 to 7.0.0
Bumps [fuse.js](https://github.com/krisk/Fuse) from 6.6.2 to 7.0.0.
- [Release notes](https://github.com/krisk/Fuse/releases)
- [Changelog](https://github.com/krisk/Fuse/blob/main/CHANGELOG.md)
- [Commits](https://github.com/krisk/Fuse/compare/v6.6.2...v7.0.0)

---
updated-dependencies:
- dependency-name: fuse.js
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 10:30:29 +00:00
dependabot[bot]
1f35624121 chore(deps-dev): bump @tauri-apps/cli from 1.4.0 to 1.5.8
Bumps [@tauri-apps/cli](https://github.com/tauri-apps/tauri) from 1.4.0 to 1.5.8.
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/@tauri-apps/cli-v1.4.0...@tauri-apps/cli-v1.5.8)

---
updated-dependencies:
- dependency-name: "@tauri-apps/cli"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 10:30:04 +00:00
dependabot[bot]
860c5b8f20 chore(deps): bump @hello-pangea/dnd from 16.3.0 to 16.5.0
Bumps [@hello-pangea/dnd](https://github.com/hello-pangea/dnd) from 16.3.0 to 16.5.0.
- [Release notes](https://github.com/hello-pangea/dnd/releases)
- [Changelog](https://github.com/hello-pangea/dnd/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hello-pangea/dnd/compare/v16.3.0...v16.5.0)

---
updated-dependencies:
- dependency-name: "@hello-pangea/dnd"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 10:29:13 +00:00
Yifei Zhang
a454c39ca0 Merge pull request #3511 from KevenLi8888/add-dockerignore
ci: add .dockerignore to exclude env files to prevent potential key leakages 避免潜在的密钥泄漏
2023-12-18 15:26:32 +08:00
Yifei Zhang
6938910f08 Merge pull request #3559 from RoboticsAndCloud/patch-1
Update README.md
2023-12-18 15:25:49 +08:00
Fei
2c63dde6c5 Update README.md 2023-12-17 21:24:36 -06:00
Yifei Zhang
e69d20a209 Merge pull request #3538 from MCheping8108/main
fix sync.yml
2023-12-18 01:16:54 +08:00
和平peaceful
0b731edd21 fix sync.yml 2023-12-15 21:24:37 +08:00
Yifei Zhang
efdd61595e Merge pull request #3535 from ChatGPTNextWeb/Yidadaa-patch-1
Update README_CN.md
2023-12-15 17:47:47 +08:00
Yifei Zhang
d676f88fed Update README_CN.md 2023-12-15 17:47:36 +08:00
Yifei Zhang
07f2cf7ad0 Merge pull request #3532 from ChatGPTNextWebTeam/update-emoji-url
fix: update emoji url
2023-12-15 15:20:15 +08:00
Yifei Zhang
bcb520ed3b Update emoji.tsx 2023-12-15 15:18:02 +08:00
Eric Huang
943a2707d2 fix(chat-item): selected chat-item showing border in other pages 2023-12-15 09:37:37 +08:00
Qian (Keven) Li
93cee18300 ci: add .dockerignore to exclude env files 2023-12-12 02:26:49 -05:00
reece00
1442337e3c The language filtering option of the mask is stored 2023-12-12 02:22:22 +08:00
Yifei Zhang
cae4655785 Update tauri.conf.json 2023-12-11 15:59:37 +08:00
Yifei Zhang
28c12606a4 Merge pull request #3455 from H0llyW00dzZ/trimtopic 2023-12-06 18:58:38 +08:00
H0llyW00dzZ
54df355014 Fix Trim Topic in Indonesia Language
- [+] fix(utils.ts): fix trimTopic function to remove double quotes from the start and end of the string, and remove specified punctuation from the end of the string
2023-12-05 07:26:11 +07:00
H0llyW00dzZ
8dc8682078 Fix Api Common [Server Side]
- [+] fix(common.ts): improve handling of OpenAI-Organization header
 - Check if serverConfig.openaiOrgId is defined and not an empty string
 - Log the value of openaiOrganizationHeader if present, otherwise log that the header is not present
 - Conditionally delete the OpenAI-Organization header from the response if [Org ID] is undefined or empty (not setup in ENV)
2023-12-04 13:33:23 +07:00
H0llyW00dzZ
36e9c6ac4d Refactor Api Common [Server Side] [Console Log]
- [+] refactor(common.ts): remove unnecessary console.log for [Org ID] in requestOpenai function
- [+] refactor(common.ts): conditionally delete OpenAI-Organization header from response if [Org ID] is not set up in ENV
2023-12-01 19:49:12 +07:00
frankylli
10ea9bf1e3 fix: MessageSelectorWarning 2023-11-29 16:25:15 +08:00
Yifei Zhang
cf50299b14 Update README_CN.md 2023-11-29 11:39:21 +08:00
Yifei Zhang
2c12be62c4 Merge pull request #3383 from H0llyW00dzZ/auth 2023-11-27 15:51:27 +08:00
Yifei Zhang
4636a75b5e Merge pull request #3382 from H0llyW00dzZ/docs 2023-11-27 15:47:39 +08:00
Yifei Zhang
03756e364a Merge pull request #3372 from H0llyW00dzZ/fixserverside 2023-11-27 15:47:24 +08:00
Yifei Zhang
ce1715c79e Merge pull request #3369 from H0llyW00dzZ/chatz 2023-11-27 15:47:01 +08:00
Yifei Zhang
a62ab3c649 Merge pull request #3354 from AnYiEE/fix-type 2023-11-27 15:45:42 +08:00
Yifei Zhang
bfb7b988f4 Merge pull request #3356 from durianice/pr_merge 2023-11-27 15:44:03 +08:00
Yifei Zhang
84f41262f5 Update README.md 2023-11-27 15:28:34 +08:00
H0llyW00dzZ
dda40e29f4 Fix & Refactor UI/UX Page [Settings]
- [+] fix(settings.tsx): fix conditional rendering of ListItem based on clientConfig.isApp
- [+] refactor(settings.tsx): improve readability of conditional rendering code
2023-11-27 08:07:53 +07:00
H0llyW00dzZ
7df868e22a Revert "Fix & Refactor UI/UX Page [Auth]"
This reverts commit d76e744eab.

Reason: Move to commits bf5e7aaa48
2023-11-27 07:12:57 +07:00
H0llyW00dzZ
bf5e7aaa48 Feat UI/UX Page [Settings]
- [+] feat(settings.tsx): set useCustomConfig to true if clientConfig.isApp is truthy
2023-11-27 07:03:41 +07:00
H0llyW00dzZ
d76e744eab Fix & Refactor UI/UX Page [Auth]
- [+] fix(auth.tsx): fix condition to set custom endpoint to true if it's app
- [+] refactor(auth.tsx): update accessStore to set useCustomConfig to true
2023-11-27 05:44:26 +07:00
H0llyW00dzZ
6f5699fe09 Chore Docs (README.md)
- [+] chore(README.md): add support for Korean and Indonesian languages
2023-11-27 04:49:15 +07:00
H0llyW00dzZ
f9d916925e Fixup Api Common [Server Side] [Console Log]
- [+] fix(common.ts): fix condition to check if serverConfig.openaiOrgId is not undefined
2023-11-25 10:23:16 +07:00
H0llyW00dzZ
f9258878db Fix Api Common [Server Side] [Console Log]
- [+] fix(common.ts): fix console.log statement for [Org ID] to handle undefined value
2023-11-25 10:10:06 +07:00
H0llyW00dzZ
ef9e86b50d Fix UI/UX Page Chats [Memory Prompt] [Stored Local Storage]
- [+] fix(chat.ts): update the memory prompt in onFinish callback
- [+] feat(chat.ts): update the current session with lastSummarizeIndex and memoryPrompt
2023-11-25 05:03:34 +07:00
Yifei Zhang
b21931c667 Update README.md 2023-11-24 11:36:14 +08:00
Yifei Zhang
06de3f5e69 Update README.md 2023-11-24 10:33:33 +08:00
durian
261a8fd83b fix(CUSTOM_MODELS):#3349 Dependency chatStore otherwise the session and view will not updated 2023-11-23 15:37:57 +08:00
安忆
6527074cde fix(locales): type error in pt.ts 2023-11-23 11:02:20 +08:00
H0llyW00dzZ
fe0f078353 Feat ChatGPT LLM Api [Console Log] [Text Moderation] [Azure]
[+] fix(openai.ts): fix parsing error in ChatGPTApi's message handler
[+] feat(openai.ts): add logging for flagged categories in text moderation
2023-11-19 19:49:52 +07:00
Yidadaa
f2485931d9 feat: better animation speed 2023-11-19 19:15:11 +08:00
Yifei Zhang
4f8a0b7711 Merge pull request #3308 from yuri-filipe/main 2023-11-19 18:45:31 +08:00
Yifei Zhang
2dde55050e Merge pull request #3311 from Yidadaa/bugfix-1119 2023-11-19 18:44:14 +08:00
Yidadaa
6aade62ce2 feat: close #3300 support multiple api keys 2023-11-19 18:42:30 +08:00
Yidadaa
45b88ebb2a feat: close #3304 use = instead of : to map model name in CUSTOM_MODELS 2023-11-19 18:34:39 +08:00
Yidadaa
dc7159a450 feat: close #3301 enable or diable default models with -all / +all 2023-11-19 18:32:54 +08:00
Yidadaa
536ace8e10 feat: animate streaming response to make more smooth 2023-11-19 18:24:51 +08:00
Yifei Zhang
16b2a3e66e Update vercel.json 2023-11-19 04:23:25 +08:00
Yuri Filipe
6f135a0cce Translation of the application into Brazilian Portuguese 2023-11-18 15:12:15 -03:00
Yifei Zhang
cf220dd2eb Update vercel.json 2023-11-17 15:22:11 +08:00
Yifei Zhang
914f4fb862 Update vercel.json 2023-11-17 15:11:03 +08:00
Yifei Zhang
7bdb68eecf config: disable ip forward in vercel json config 2023-11-17 15:10:51 +08:00
Yifei Zhang
3c510cfaf0 chore: update gh actions nodejs version to 18 2023-11-17 11:56:42 +08:00
Yifei Zhang
401fa198c9 Update tauri.conf.json 2023-11-17 11:29:52 +08:00
Yifei Zhang
600df4f63a Merge pull request #3290 from Eric-2369/main 2023-11-17 11:26:21 +08:00
Eric-2369
74eb42c111 config: exclude hkg1 from the deployment regions of the edge function 2023-11-16 11:33:10 +08:00
SurKaa
39f3afd52c Update .env.template 更正单词 2023-11-16 09:22:56 +08:00
Yidadaa
9876a1aeca fix: #3275 refuse on server side if hide user api key 2023-11-16 00:53:11 +08:00
Yifei Zhang
d898ffce23 Merge pull request #3261 from H0llyW00dzZ/clients 2023-11-14 16:07:16 +08:00
H0llyW00dzZ
f1772f4625 Fix UI/UX Page [Settings]
[+] fix(settings.tsx): fix condition to show balance query in settings component
[+] fix(settings.tsx): add condition to hide balance query for app clients
2023-11-14 15:04:57 +07:00
Yifei Zhang
9da455a7ea Merge pull request #3248 from Yidadaa/dependabot/npm_and_yarn/nanoid-5.0.3 2023-11-14 01:24:10 +08:00
Yifei Zhang
2bd6342309 Merge pull request #3249 from Yidadaa/dependabot/npm_and_yarn/emoji-picker-react-4.5.15 2023-11-14 01:23:50 +08:00
Yifei Zhang
5e73577088 Merge pull request #3250 from Yidadaa/dependabot/npm_and_yarn/types/node-20.9.0 2023-11-14 01:23:37 +08:00
Yifei Zhang
5156a80738 Merge pull request #3251 from Yidadaa/dependabot/npm_and_yarn/types/spark-md5-3.0.4 2023-11-14 01:23:17 +08:00
dependabot[bot]
a9d605ed30 chore(deps): bump nanoid from 4.0.2 to 5.0.3
Bumps [nanoid](https://github.com/ai/nanoid) from 4.0.2 to 5.0.3.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/4.0.2...5.0.3)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-13 17:22:51 +00:00
dependabot[bot]
7d1fae32cd chore(deps): bump emoji-picker-react from 4.5.1 to 4.5.15
Bumps [emoji-picker-react](https://github.com/ealush/emoji-picker-react) from 4.5.1 to 4.5.15.
- [Release notes](https://github.com/ealush/emoji-picker-react/releases)
- [Commits](https://github.com/ealush/emoji-picker-react/commits)

---
updated-dependencies:
- dependency-name: emoji-picker-react
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-13 17:22:50 +00:00
Yifei Zhang
9f17b45bc4 Merge pull request #3252 from Yidadaa/dependabot/npm_and_yarn/mermaid-10.6.1 2023-11-14 01:21:11 +08:00
dependabot[bot]
a64c9dae42 chore(deps): bump mermaid from 10.3.1 to 10.6.1
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 10.3.1 to 10.6.1.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Changelog](https://github.com/mermaid-js/mermaid/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/mermaid-js/mermaid/compare/v10.3.1...v10.6.1)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-13 10:45:59 +00:00
dependabot[bot]
5fbf4c394c chore(deps-dev): bump @types/spark-md5 from 3.0.2 to 3.0.4
Bumps [@types/spark-md5](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/spark-md5) from 3.0.2 to 3.0.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/spark-md5)

---
updated-dependencies:
- dependency-name: "@types/spark-md5"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-13 10:45:47 +00:00
dependabot[bot]
1e5153173c chore(deps-dev): bump @types/node from 20.3.3 to 20.9.0
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.3.3 to 20.9.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-13 10:45:37 +00:00
Yifei Zhang
011b52d07d Update README.md 2023-11-13 16:53:36 +08:00
Yifei Zhang
d033168d80 fix: #3241 should not ensure openai url non-empty 2023-11-13 10:53:30 +08:00
Yifei Zhang
fdca9e59de Merge pull request #3233 from nanaya-tachibana/nanaya/model-selection 2023-11-13 10:44:03 +08:00
Yifei Zhang
549a2fd206 Merge pull request #3237 from Yidadaa/latex 2023-11-12 19:47:26 +08:00
Yidadaa
a0cd939bfd fix: #2841 dollar sign conflict with latex math 2023-11-12 19:45:58 +08:00
Yifei Zhang
4f52679ec6 Merge pull request #3236 from Yidadaa/latex 2023-11-12 19:35:22 +08:00
Yidadaa
b52e237044 fix: #3189 should correct math eq in exporter 2023-11-12 19:33:19 +08:00
Yifei Zhang
17b329bb63 Merge pull request #3232 from H0llyW00dzZ/language 2023-11-12 18:00:02 +08:00
nanaya
3a654ba199 UI (model selection): hide unavailable model options 2023-11-12 11:18:14 +08:00
H0llyW00dzZ
5ba3fc9321 Revert "Fix UI/UX Page Local Language [Exporter Message]"
This reverts commit a46f08154e.
Reason : better after instead of before
2023-11-12 01:56:31 +07:00
H0llyW00dzZ
a46f08154e Fix UI/UX Page Local Language [Exporter Message]
fix(locales): fix incorrect description title in en.ts and id.ts
 - Change "Only messages after clearing the context will be displayed" to "Only messages before clearing the context will be displayed" in en.ts
 - Change "Hanya pesan setelah menghapus konteks yang akan ditampilkan" to "Hanya pesan sebelum menghapus konteks yang akan ditampilkan" in id.ts
2023-11-12 01:50:35 +07:00
H0llyW00dzZ
0f6ed9c293 Feat UI/UX Page Local Language [Exporter Message]
[+] fix(exporter.tsx): update the text in the ExportMessageModal component to use the localized title from the locale file
[+] feat(cn.ts, en.ts, id.ts): add localized title for the Exporter Description in the respective locale files
2023-11-12 00:53:15 +07:00
Yifei Zhang
295864d36b Merge pull request #3231 from Yidadaa/bugfix-1112 2023-11-12 01:23:56 +08:00
Yidadaa
be6d45e49f feat: close #3222 share message list should start from clear context index 2023-11-12 01:21:39 +08:00
Yifei Zhang
22b6987249 Merge pull request #3230 from Yidadaa/bugfix-1112 2023-11-12 00:52:21 +08:00
Yidadaa
64647b0bb3 chore: update doc for mapped CUSTOM_MODELS 2023-11-12 00:49:58 +08:00
Yidadaa
a5a1f2e8ad feat: CUSTOM_MODELS support mapper 2023-11-12 00:46:21 +08:00
Yifei Zhang
8bd39f33e0 Merge pull request #3229 from Yidadaa/bugfix-1112 2023-11-12 00:31:15 +08:00
Yidadaa
be9774943b feat: #3224 auto switch to first avaliable model 2023-11-12 00:29:36 +08:00
akira-cn
dd6e79922a fix: remove the content-encoding header 2023-11-10 20:42:12 +08:00
akira-cn
bf84269520 fix: remove the content-encoding header 2023-11-10 20:38:53 +08:00
Yifei Zhang
943214c6a7 Update README_CN.md 2023-11-10 18:21:22 +08:00
Yifei Zhang
ca792669fc Update constant.ts 2023-11-10 18:20:51 +08:00
Yifei Zhang
78d68a9949 Merge pull request #3214 from Ensteinjun/main 2023-11-10 15:58:28 +08:00
Ensteinjun
6b2db97347 fixbug 2023-11-10 15:44:07 +08:00
Yifei Zhang
2bfb362832 Update tauri.conf.json 2023-11-10 15:19:31 +08:00
Yidadaa
cb140e482f fix: #3207 ensure corner case 2023-11-10 15:15:43 +08:00
Yifei Zhang
e6b72ac1ff Update README.md 2023-11-10 02:59:47 +08:00
Yifei Zhang
8032e6d68d Update README.md 2023-11-10 02:59:30 +08:00
Yidadaa
c7e0a6f37f doc: update azure env vars 2023-11-10 02:57:46 +08:00
Yifei Zhang
1141cd2e6e Merge pull request #3206 from Yidadaa/azure 2023-11-10 02:55:12 +08:00
Yidadaa
c9dd953817 fixup 2023-11-10 02:50:50 +08:00
Yidadaa
b7ffca031e feat: close #935 add azure support 2023-11-10 02:43:37 +08:00
H0llyW00dzZ
544bab0fe2 Refactor Summarize Logic
[+] chore(chat.ts): remove unnecessary comment and refactor variable name
[+] feat(chat.ts): add stream: false to config object
2023-11-09 20:56:45 +07:00
Yifei Zhang
fd2f441e02 feat: wont send max_tokens 2023-11-09 20:45:25 +08:00
Yifei Zhang
3b3ebda34b fix: #3196 3.5-turbo-1106 should use old cutoff date 2023-11-09 11:01:41 +08:00
Yifei Zhang
87e3d663a2 fix: #3192 use smaller max_tokens as default 2023-11-09 11:00:09 +08:00
Yidadaa
d0a1d910d4 fix: #3186 enable max_tokens in chat payload 2023-11-09 03:19:13 +08:00
Yifei Zhang
33b97082fa Merge pull request #3192 from Yidadaa/bugfix-1109 2023-11-09 03:05:41 +08:00
Yidadaa
d93f05f511 feat: close #3187 use CUSTOM_MODELS to control model list 2023-11-09 03:01:29 +08:00
Yifei Zhang
3dc29197e1 Merge pull request #3191 from Yidadaa/bugfix-1109 2023-11-09 02:23:20 +08:00
Yidadaa
fbc0236748 fix: #3174 should prompt to confirm to delete chat 2023-11-09 02:03:05 +08:00
Yifei Zhang
1304a3943a Merge pull request #3190 from Yidadaa/bugfix-1109 2023-11-09 01:54:09 +08:00
Yidadaa
8c0ba1aee2 feat: close #2954 chat summary should be copyable 2023-11-09 01:51:33 +08:00
Yifei Zhang
47154773f2 Merge pull request #3181 from AnYiEE/docs-typo 2023-11-08 18:37:59 +08:00
Yifei Zhang
deca9bf06d Merge pull request #3183 from hekbobo/patch-1 2023-11-08 18:37:35 +08:00
bob
6ab4c9be2e Update cloudflare-pages-cn.md
cloudflare 构建命令
2023-11-08 17:40:28 +08:00
安忆
3a519612a0 docs: fix typo in #3171 2023-11-08 15:53:45 +08:00
Yifei Zhang
d1ec26ae83 Merge pull request #3171 from AnYiEE/docs-update 2023-11-08 15:31:17 +08:00
安忆
9cb889c34f docs: up to date 2023-11-08 07:09:52 +08:00
Yidadaa
cdf0311d27 feat: add claude and bard 2023-11-07 23:22:11 +08:00
Yidadaa
5610f423d0 feat: add multi-model support 2023-10-30 02:07:11 +08:00
dependabot[bot]
b799213a14 chore(deps): bump @babel/traverse from 7.21.3 to 7.23.2
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.21.3 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-18 23:52:00 +00:00
304 changed files with 17371 additions and 1688 deletions

97
.dockerignore Normal file
View File

@@ -0,0 +1,97 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Node.js dependencies
/node_modules
/jspm_packages
# TypeScript v1 declaration files
typings
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.test
# local env files
.env*.local
# Next.js build output
.next
out
# Nuxt.js build output
.nuxt
dist
# Gatsby files
.cache/
# Vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Temporary folders
tmp
temp
# IDE and editor directories
.idea
.vscode
*.swp
*.swo
*~
# OS generated files
.DS_Store
Thumbs.db
# secret key
*.key
*.key.pub

View File

@@ -2,12 +2,22 @@
# Your openai api key. (required)
OPENAI_API_KEY=sk-xxxx
# Access passsword, separated by comma. (optional)
# Access password, separated by comma. (optional)
CODE=your-password
# You can start service behind a proxy
PROXY_URL=http://localhost:7890
# (optional)
# Default: Empty
# Googel 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_URL=
# Override openai api request base url. (optional)
# Default: https://api.openai.com
# Examples: http://your-openai-proxy.com
@@ -17,11 +27,6 @@ BASE_URL=
# Default: Empty
OPENAI_ORG_ID=
# (optional)
# Default: Empty
# If you do not want users to input their own API key, set this value to 1.
HIDE_USER_API_KEY=
# (optional)
# Default: Empty
# If you do not want users to use GPT-4, set this value to 1.
@@ -29,5 +34,30 @@ DISABLE_GPT4=
# (optional)
# Default: Empty
# If you do not want users to query balance, set this value to 1.
HIDE_BALANCE_QUERY=
# If you do not want users to input their own API key, set this value to 1.
HIDE_USER_API_KEY=
# (optional)
# Default: Empty
# If you do want users to query balance, set this value to 1.
ENABLE_BALANCE_QUERY=
# (optional)
# Default: Empty
# If you want to disable parse settings from url, set this value to 1.
DISABLE_FAST_LINK=
# anthropic claude Api Key.(optional)
ANTHROPIC_API_KEY=
### anthropic claude Api version. (optional)
ANTHROPIC_API_VERSION=
### anthropic claude Api url (optional)
ANTHROPIC_URL=
### (optional)
WHITE_WEBDEV_ENDPOINTS=

View File

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

View File

@@ -1,43 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: "[Bug] "
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Deployment**
- [ ] Docker
- [ ] Vercel
- [ ] Server
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional Logs**
Add any logs about the problem here.

146
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,146 @@
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,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature] "
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,53 @@
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

View File

@@ -1,24 +0,0 @@
---
name: 功能建议
about: 请告诉我们你的灵光一闪
title: "[Feature] "
labels: ''
assignees: ''
---
> 为了提高交流效率,我们设立了官方 QQ 群和 QQ 频道,如果你在使用或者搭建过程中遇到了任何问题,请先第一时间加群或者频道咨询解决,除非是可以稳定复现的 Bug 或者较为有创意的功能建议,否则请不要随意往 Issue 区发送低质无意义帖子。
> [点击加入官方群聊](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724)
**这个功能与现有的问题有关吗?**
如果有关,请在此列出链接或者描述问题。
**你想要什么功能或者有什么建议?**
尽管告诉我们。
**有没有可以参考的同类竞品?**
可以给出参考产品的链接或者截图。
**其他信息**
可以说说你的其他考虑。

View File

@@ -1,36 +0,0 @@
---
name: 反馈问题
about: 请告诉我们你遇到的问题
title: "[Bug] "
labels: ''
assignees: ''
---
> 为了提高交流效率,我们设立了官方 QQ 群和 QQ 频道,如果你在使用或者搭建过程中遇到了任何问题,请先第一时间加群或者频道咨询解决,除非是可以稳定复现的 Bug 或者较为有创意的功能建议,否则请不要随意往 Issue 区发送低质无意义帖子。
> [点击加入官方群聊](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724)
**反馈须知**
⚠️ 注意:不遵循此模板的任何帖子都会被立即关闭,如果没有提供下方的信息,我们无法定位你的问题。
请在下方中括号内输入 x 来表示你已经知晓相关内容。
- [ ] 我确认已经在 [常见问题](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/faq-cn.md) 中搜索了此次反馈的问题,没有找到解答;
- [ ] 我确认已经在 [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) 列表(包括已经 Close 的)中搜索了此次反馈的问题,没有找到解答。
- [ ] 我确认已经在 [Vercel 使用教程](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/vercel-cn.md) 中搜索了此次反馈的问题,没有找到解答。
**描述问题**
请在此描述你遇到了什么问题。
**如何复现**
请告诉我们你是通过什么操作触发的该问题。
**截图**
请在此提供控制台截图、屏幕截图或者服务端的 log 截图。
**一些必要的信息**
- 系统:[比如 windows 10/ macos 12/ linux / android 11 / ios 16]
- 浏览器: [比如 chrome, safari]
- 版本: [填写设置页面的版本号]
- 部署方式:[比如 vercel、docker 或者服务器部署]

View File

@@ -18,7 +18,7 @@ jobs:
- name: setup node
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
- name: get version
run: echo "PACKAGE_VERSION=$(node -p "require('./src-tauri/tauri.conf.json').package.version")" >> $GITHUB_ENV
- name: create release
@@ -43,12 +43,9 @@ jobs:
- os: ubuntu-latest
arch: x86_64
rust_target: x86_64-unknown-linux-gnu
- os: macos-latest
arch: x86_64
rust_target: x86_64-apple-darwin
- os: macos-latest
arch: aarch64
rust_target: aarch64-apple-darwin
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
- os: windows-latest
arch: x86_64
rust_target: x86_64-pc-windows-msvc
@@ -59,14 +56,15 @@ jobs:
- name: setup node
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
cache: 'yarn'
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.config.rust_target }}
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.config.rust_target }}
key: ${{ matrix.config.os }}
- name: install dependencies (ubuntu only)
if: matrix.config.os == 'ubuntu-latest'
run: |
@@ -79,8 +77,15 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.config.os == 'macos-latest' && '--target universal-apple-darwin' || '' }}
publish-release:
permissions:

84
.github/workflows/deploy_preview.yml vendored Normal file
View File

@@ -0,0 +1,84 @@
name: VercelPreviewDeployment
on:
pull_request_target:
types:
- opened
- synchronize
- reopened
env:
VERCEL_TEAM: ${{ secrets.VERCEL_TEAM }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
VERCEL_PR_DOMAIN_SUFFIX: ${{ secrets.VERCEL_PR_DOMAIN_SUFFIX }}
permissions:
contents: read
statuses: write
pull-requests: write
jobs:
deploy-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Extract branch name
shell: bash
run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> "$GITHUB_OUTPUT"
id: extract_branch
- name: Hash branch name
uses: pplanel/hash-calculator-action@v1.3.1
id: hash_branch
with:
input: ${{ steps.extract_branch.outputs.branch }}
method: MD5
- name: Set Environment Variables
id: set_env
if: github.event_name == 'pull_request_target'
run: |
echo "VERCEL_ALIAS_DOMAIN=${{ github.event.pull_request.number }}-${{ github.workflow }}.${VERCEL_PR_DOMAIN_SUFFIX}" >> $GITHUB_OUTPUT
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Cache dependencies
uses: actions/cache@v2
id: cache-npm
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
restore-keys: npm-
- name: Pull Vercel Environment Information
run: vercel pull --yes --environment=preview --token=${VERCEL_TOKEN}
- name: Deploy Project Artifacts to Vercel
id: vercel
env:
META_TAG: ${{ steps.hash_branch.outputs.digest }}-${{ github.run_number }}-${{ github.run_attempt}}
run: |
set -e
vercel pull --yes --environment=preview --token=${VERCEL_TOKEN}
vercel build --token=${VERCEL_TOKEN}
vercel deploy --prebuilt --archive=tgz --token=${VERCEL_TOKEN} --meta base_hash=${{ env.META_TAG }}
DEFAULT_URL=$(vercel ls --token=${VERCEL_TOKEN} --meta base_hash=${{ env.META_TAG }})
ALIAS_URL=$(vercel alias set ${DEFAULT_URL} ${{ steps.set_env.outputs.VERCEL_ALIAS_DOMAIN }} --token=${VERCEL_TOKEN} --scope ${VERCEL_TEAM}| awk '{print $3}')
echo "New preview URL: ${DEFAULT_URL}"
echo "New alias URL: ${ALIAS_URL}"
echo "VERCEL_URL=${ALIAS_URL}" >> "$GITHUB_OUTPUT"
- uses: mshick/add-pr-comment@v2
with:
message: |
Your build has completed!
[Preview deployment](${{ steps.vercel.outputs.VERCEL_URL }})

View File

@@ -0,0 +1,40 @@
name: Removedeploypreview
permissions:
contents: read
statuses: write
pull-requests: write
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
on:
pull_request_target:
types:
- closed
jobs:
delete-deployments:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Extract branch name
shell: bash
run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT
id: extract_branch
- name: Hash branch name
uses: pplanel/hash-calculator-action@v1.3.1
id: hash_branch
with:
input: ${{ steps.extract_branch.outputs.branch }}
method: MD5
- name: Call the delete-deployment-preview.sh script
env:
META_TAG: ${{ steps.hash_branch.outputs.digest }}
run: |
bash ./scripts/delete-deployment-preview.sh

View File

@@ -24,7 +24,7 @@ jobs:
id: sync
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
with:
upstream_sync_repo: Yidadaa/ChatGPT-Next-Web
upstream_sync_repo: ChatGPTNextWeb/ChatGPT-Next-Web
upstream_sync_branch: main
target_sync_branch: main
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set

View File

@@ -16,6 +16,7 @@ FROM base AS builder
RUN apk update && apk add --no-cache git
ENV OPENAI_API_KEY=""
ENV GOOGLE_API_KEY=""
ENV CODE=""
WORKDIR /app
@@ -31,6 +32,7 @@ RUN apk add proxychains-ng
ENV PROXY_URL=""
ENV OPENAI_API_KEY=""
ENV GOOGLE_API_KEY=""
ENV CODE=""
COPY --from=builder /app/public ./public
@@ -41,22 +43,22 @@ COPY --from=builder /app/.next/server ./.next/server
EXPOSE 3000
CMD if [ -n "$PROXY_URL" ]; then \
export HOSTNAME="127.0.0.1"; \
protocol=$(echo $PROXY_URL | cut -d: -f1); \
host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \
port=$(echo $PROXY_URL | cut -d: -f3); \
conf=/etc/proxychains.conf; \
echo "strict_chain" > $conf; \
echo "proxy_dns" >> $conf; \
echo "remote_dns_subnet 224" >> $conf; \
echo "tcp_read_time_out 15000" >> $conf; \
echo "tcp_connect_time_out 8000" >> $conf; \
echo "localnet 127.0.0.0/255.0.0.0" >> $conf; \
echo "localnet ::1/128" >> $conf; \
echo "[ProxyList]" >> $conf; \
echo "$protocol $host $port" >> $conf; \
cat /etc/proxychains.conf; \
proxychains -f $conf node server.js; \
export HOSTNAME="127.0.0.1"; \
protocol=$(echo $PROXY_URL | cut -d: -f1); \
host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \
port=$(echo $PROXY_URL | cut -d: -f3); \
conf=/etc/proxychains.conf; \
echo "strict_chain" > $conf; \
echo "proxy_dns" >> $conf; \
echo "remote_dns_subnet 224" >> $conf; \
echo "tcp_read_time_out 15000" >> $conf; \
echo "tcp_connect_time_out 8000" >> $conf; \
echo "localnet 127.0.0.0/255.0.0.0" >> $conf; \
echo "localnet ::1/128" >> $conf; \
echo "[ProxyList]" >> $conf; \
echo "$protocol $host $port" >> $conf; \
cat /etc/proxychains.conf; \
proxychains -f $conf node server.js; \
else \
node server.js; \
node server.js; \
fi

101
README.md
View File

@@ -1,22 +1,22 @@
<div align="center">
<img src="./docs/images/icon.svg" alt="icon"/>
<img src="./docs/images/head-cover.png" alt="icon"/>
<h1 align="center">ChatGPT Next Web</h1>
<h1 align="center">NextChat (ChatGPT Next Web)</h1>
English / [简体中文](./README_CN.md) / [日本語](./README_JA.md)
English / [简体中文](./README_CN.md)
One-Click to get well-designed cross-platform ChatGPT web UI.
One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 & Gemini Pro support.
一键免费部署你的跨平台私人 ChatGPT 应用。
一键免费部署你的跨平台私人 ChatGPT 应用, 支持 GPT3, GPT4 & Gemini Pro 模型
[![Web][Web-image]][web-url]
[![Windows][Windows-image]][download-url]
[![MacOS][MacOS-image]][download-url]
[![Linux][Linux-image]][download-url]
[Web App](https://chatgpt.nextweb.fun/) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Discord](https://discord.gg/YCkeafCafC) / [Twitter](https://twitter.com/mortiest_ricky) / [Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa)
[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)
[网页版](https://chatgpt.nextweb.fun/) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [QQ 群](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg)
[网页版](https://app.nextchat.dev/) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues)
[web-url]: https://chatgpt.nextweb.fun
[download-url]: https://github.com/Yidadaa/ChatGPT-Next-Web/releases
@@ -25,7 +25,9 @@ One-Click to get well-designed cross-platform ChatGPT web UI.
[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%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web)
[![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)
@@ -37,8 +39,8 @@ One-Click to get well-designed cross-platform ChatGPT web UI.
- **Deploy for free with one-click** on Vercel in under 1 minute
- Compact client (~5MB) on Linux/Windows/MacOS, [download it now](https://github.com/Yidadaa/ChatGPT-Next-Web/releases)
- Fully compatible with self-deployed llms, recommended for use with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) or [LocalAI](https://github.com/go-skynet/LocalAI)
- Privacy first, all data stored locally in the browser
- Fully compatible with self-deployed LLMs, recommended for use with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) or [LocalAI](https://github.com/go-skynet/LocalAI)
- Privacy first, all data is stored locally in the browser
- Markdown support: LaTex, mermaid, code highlight, etc.
- Responsive design, dark mode and PWA
- Fast first screen loading speed (~100kb), support streaming response
@@ -59,9 +61,11 @@ One-Click to get well-designed cross-platform ChatGPT web UI.
## What's New
- 🚀 v2.0 is released, now you can create prompt templates, turn your ideas into reality! Read this: [ChatGPT Prompt Engineering Tips: Zero, One and Few Shot Prompting](https://www.allabtai.com/prompt-engineering-tips-zero-one-and-few-shot-prompting/).
- 🚀 v2.7 let's share conversations as image, or share to ShareGPT!
- 🚀 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!
- 🚀 v2.7 let's share conversations as image, or share to ShareGPT!
- 🚀 v2.0 is released, now you can create prompt templates, turn your ideas into reality! Read this: [ChatGPT Prompt Engineering Tips: Zero, One and Few Shot Prompting](https://www.allabtai.com/prompt-engineering-tips-zero-one-and-few-shot-prompting/).
## 主要功能
@@ -74,7 +78,7 @@ One-Click to get well-designed cross-platform ChatGPT web UI.
- 预制角色功能(面具),方便地创建、分享和调试你的个性化对话
- 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts)
- 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话
- 多国语言支持English, 简体中文, 繁体中文, 日本語, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština
- 多国语言支持English, 简体中文, 繁体中文, 日本語, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어, Indonesia
- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问
## 开发计划
@@ -93,6 +97,7 @@ One-Click to get well-designed cross-platform ChatGPT web UI.
- 💡 想要更方便地随时随地使用本项目可以试下这款桌面插件https://github.com/mushan0x0/AI0x0.com
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
## Get Started
@@ -153,14 +158,14 @@ After adding or modifying this environment variable, please redeploy the project
> [简体中文 > 如何配置 api key、访问密码、接口代理](./README_CN.md#环境变量)
### `OPENAI_API_KEY` (required)
Your openai api key.
### `CODE` (optional)
Access password, separated by comma.
### `OPENAI_API_KEY` (required)
Your openai api key, join multiple api keys with comma.
### `BASE_URL` (optional)
> Default: `https://api.openai.com`
@@ -173,6 +178,40 @@ Override openai api request base url.
Specify OpenAI organization ID.
### `AZURE_URL` (optional)
> Example: https://{azure-resource-url}/openai/deployments/{deploy-name}
Azure deploy url.
### `AZURE_API_KEY` (optional)
Azure Api Key.
### `AZURE_API_VERSION` (optional)
Azure Api Version, find it at [Azure Documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions).
### `GOOGLE_API_KEY` (optional)
Google Gemini Pro Api Key.
### `GOOGLE_URL` (optional)
Google Gemini Pro Api Url.
### `ANTHROPIC_API_KEY` (optional)
anthropic claude Api Key.
### `ANTHROPIC_API_VERSION` (optional)
anthropic claude Api version.
### `ANTHROPIC_URL` (optional)
anthropic claude Api Url.
### `HIDE_USER_API_KEY` (optional)
> Default: Empty
@@ -189,7 +228,7 @@ If you do not want users to use GPT-4, set this value to 1.
> Default: Empty
If you do want users to query balance, set this value to 1, or you should set it to 0.
If you do want users to query balance, set this value to 1.
### `DISABLE_FAST_LINK` (optional)
@@ -197,6 +236,22 @@ If you do want users to query balance, set this value to 1, or you should set it
If you want to disable parse settings from url, set this to 1.
### `CUSTOM_MODELS` (optional)
> Default: Empty
> Example: `+llama,+claude-2,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` means add `llama, claude-2` to model list, and remove `gpt-3.5-turbo` from list, and display `gpt-4-1106-preview` as `gpt-4-turbo`.
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.
User `-all` to disable all default models, `+all` to enable all default models.
### `WHITE_WEBDEV_ENDPOINTS` (可选)
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 ', '
## Requirements
NodeJS >= 18, Docker >= 20
@@ -320,10 +375,16 @@ If you want to add a new translation, read this [document](./docs/translation.md
[@synwith](https://github.com/synwith)
[@piksonGit](https://github.com/piksonGit)
[@ouyangzhiping](https://github.com/ouyangzhiping)
[@wenjiavv](https://github.com/wenjiavv)
[@LeXwDeX](https://github.com/LeXwDeX)
[@Licoy](https://github.com/Licoy)
[@shangmin2009](https://github.com/shangmin2009)
### Contributor
### Contributors
[Contributors](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
<a href="https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/graphs/contributors">
<img src="https://contrib.rocks/image?repo=ChatGPTNextWeb/ChatGPT-Next-Web" />
</a>
## LICENSE

View File

@@ -1,14 +1,16 @@
<div align="center">
<img src="./docs/images/icon.svg" alt="预览"/>
<h1 align="center">ChatGPT Next Web</h1>
<h1 align="center">NextChat</h1>
一键免费部署你的私人 ChatGPT 网页应用。
一键免费部署你的私人 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) / [QQ 群](https://user-images.githubusercontent.com/16968934/228190818-7dd00845-e9b9-4363-97e5-44c507ac76da.jpeg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) / [Donate](#捐赠-donate-usdt)
[演示 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)
@@ -19,7 +21,7 @@
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&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web),直接使用 Github 账号登录即可,记得在环境变量页填入 API Key 和[页面访问密码](#配置页面访问密码) CODE
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%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 在某些区域被污染了,绑定自定义域名即可直连。
@@ -68,7 +70,7 @@ code1,code2,code3
### `OPENAI_API_KEY` (必填项)
OpanAI 密钥,你在 openai 账户页面申请的 api key。
OpanAI 密钥,你在 openai 账户页面申请的 api key使用英文逗号隔开多个 key这样可以随机轮询这些 key。
### `CODE` (可选)
@@ -90,6 +92,40 @@ OpenAI 接口代理 URL如果你手动配置了 openai 接口代理,请填
指定 OpenAI 中的组织 ID。
### `AZURE_URL` (可选)
> 形如https://{azure-resource-url}/openai/deployments/{deploy-name}
Azure 部署地址。
### `AZURE_API_KEY` (可选)
Azure 密钥。
### `AZURE_API_VERSION` (可选)
Azure Api 版本,你可以在这里找到:[Azure 文档](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions)。
### `GOOGLE_API_KEY` (optional)
Google Gemini Pro 密钥.
### `GOOGLE_URL` (optional)
Google Gemini Pro Api Url.
### `ANTHROPIC_API_KEY` (optional)
anthropic claude Api Key.
### `ANTHROPIC_API_VERSION` (optional)
anthropic claude Api version.
### `ANTHROPIC_URL` (optional)
anthropic claude Api Url.
### `HIDE_USER_API_KEY` (可选)
如果你不想让用户自行填入 API Key将此环境变量设置为 1 即可。
@@ -106,6 +142,20 @@ OpenAI 接口代理 URL如果你手动配置了 openai 接口代理,请填
如果你想禁用从链接解析预制设置,将此环境变量设置为 1 即可。
### `WHITE_WEBDEV_ENDPOINTS` (可选)
如果你想增加允许访问的webdav服务地址可以使用该选项格式要求
- 每一个地址必须是一个完整的 endpoint
> `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`
用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。
## 开发
点击下方按钮,开始二次开发:
@@ -118,7 +168,7 @@ OpenAI 接口代理 URL如果你手动配置了 openai 接口代理,请填
OPENAI_API_KEY=<your api key here>
# 中国大陆用户,可以使用本项目自带的代理进行开发,你也可以自由选择其他代理地址
BASE_URL=https://nb.nextweb.fun/api/proxy
BASE_URL=https://b.nextweb.fun/api/proxy
```
### 本地开发

View File

@@ -0,0 +1,189 @@
import { getServerSideConfig } from "@/app/config/server";
import {
ANTHROPIC_BASE_URL,
Anthropic,
ApiPath,
DEFAULT_MODELS,
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";
const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
console.log("[Anthropic Route] params ", params);
if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const subpath = params.path.join("/");
if (!ALLOWD_PATH.has(subpath)) {
console.log("[Anthropic Route] forbidden path ", subpath);
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + subpath,
},
{
status: 403,
},
);
}
const authResult = auth(req, ModelProvider.Claude);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
try {
const response = await request(req);
return response;
} catch (e) {
console.error("[Anthropic] ", 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",
];
const serverConfig = getServerSideConfig();
async function request(req: NextRequest) {
const controller = new AbortController();
let authHeaderName = "x-api-key";
let authValue =
req.headers.get(authHeaderName) ||
req.headers.get("Authorization")?.replaceAll("Bearer ", "").trim() ||
serverConfig.anthropicApiKey ||
"";
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Anthropic, "");
let baseUrl =
serverConfig.anthropicUrl || serverConfig.baseUrl || ANTHROPIC_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",
"Cache-Control": "no-store",
[authHeaderName]: authValue,
"anthropic-version":
req.headers.get("anthropic-version") ||
serverConfig.anthropicApiVersion ||
Anthropic.Vision,
},
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 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) {
return NextResponse.json(
{
error: true,
message: `you are not allowed to use ${jsonBody?.model} model`,
},
{
status: 403,
},
);
}
} catch (e) {
console.error(`[Anthropic] filter`, e);
}
}
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,
);
// 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,7 +1,7 @@
import { NextRequest } from "next/server";
import { getServerSideConfig } from "../config/server";
import md5 from "spark-md5";
import { ACCESS_CODE_PREFIX } from "../constant";
import { ACCESS_CODE_PREFIX, ModelProvider } from "../constant";
function getIP(req: NextRequest) {
let ip = req.ip ?? req.headers.get("x-real-ip");
@@ -16,19 +16,19 @@ function getIP(req: NextRequest) {
function parseApiKey(bearToken: string) {
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
const isOpenAiKey = !token.startsWith(ACCESS_CODE_PREFIX);
const isApiKey = !token.startsWith(ACCESS_CODE_PREFIX);
return {
accessCode: isOpenAiKey ? "" : token.slice(ACCESS_CODE_PREFIX.length),
apiKey: isOpenAiKey ? token : "",
accessCode: isApiKey ? "" : token.slice(ACCESS_CODE_PREFIX.length),
apiKey: isApiKey ? token : "",
};
}
export function auth(req: NextRequest) {
export function auth(req: NextRequest, modelProvider: ModelProvider) {
const authToken = req.headers.get("Authorization") ?? "";
// check if it is openai api key or user token
const { accessCode, apiKey: token } = parseApiKey(authToken);
const { accessCode, apiKey } = parseApiKey(authToken);
const hashedCode = md5.hash(accessCode ?? "").trim();
@@ -39,19 +39,52 @@ export function auth(req: NextRequest) {
console.log("[User IP] ", getIP(req));
console.log("[Time] ", new Date().toLocaleString());
if (serverConfig.needCode && !serverConfig.codes.has(hashedCode) && !token) {
if (serverConfig.needCode && !serverConfig.codes.has(hashedCode) && !apiKey) {
return {
error: true,
msg: !accessCode ? "empty access code" : "wrong access code",
};
}
if (serverConfig.hideUserApiKey && !!apiKey) {
return {
error: true,
msg: "you are not allowed to access with your own api key",
};
}
// if user does not provide an api key, inject system api key
if (!token) {
const apiKey = serverConfig.apiKey;
if (apiKey) {
if (!apiKey) {
const serverConfig = getServerSideConfig();
// const systemApiKey =
// modelProvider === ModelProvider.GeminiPro
// ? serverConfig.googleApiKey
// : serverConfig.isAzure
// ? serverConfig.azureApiKey
// : serverConfig.apiKey;
let systemApiKey: string | undefined;
switch (modelProvider) {
case ModelProvider.GeminiPro:
systemApiKey = serverConfig.googleApiKey;
break;
case ModelProvider.Claude:
systemApiKey = serverConfig.anthropicApiKey;
break;
case ModelProvider.GPT:
default:
if (serverConfig.isAzure) {
systemApiKey = serverConfig.azureApiKey;
} else {
systemApiKey = serverConfig.apiKey;
}
}
if (systemApiKey) {
console.log("[Auth] use system api key");
req.headers.set("Authorization", `Bearer ${apiKey}`);
req.headers.set("Authorization", `Bearer ${systemApiKey}`);
} else {
console.log("[Auth] admin did not provide an api key");
}

View File

@@ -1,48 +1,74 @@
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";
export const OPENAI_URL = "api.openai.com";
const DEFAULT_PROTOCOL = "https";
const PROTOCOL = process.env.PROTOCOL || DEFAULT_PROTOCOL;
const BASE_URL = process.env.BASE_URL || OPENAI_URL;
const DISABLE_GPT4 = !!process.env.DISABLE_GPT4;
const serverConfig = getServerSideConfig();
export async function requestOpenai(req: NextRequest) {
const controller = new AbortController();
const authValue = req.headers.get("Authorization") ?? "";
const openaiPath = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
var authValue,
authHeaderName = "";
if (serverConfig.isAzure) {
authValue =
req.headers
.get("Authorization")
?.trim()
.replaceAll("Bearer ", "")
.trim() ?? "";
authHeaderName = "api-key";
} else {
authValue = req.headers.get("Authorization") ?? "";
authHeaderName = "Authorization";
}
let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
"/api/openai/",
"",
);
let baseUrl = BASE_URL;
let baseUrl =
serverConfig.azureUrl || serverConfig.baseUrl || OPENAI_BASE_URL;
if (!baseUrl.startsWith("http")) {
baseUrl = `${PROTOCOL}://${baseUrl}`;
baseUrl = `https://${baseUrl}`;
}
if (baseUrl.endsWith('/')) {
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, -1);
}
console.log("[Proxy] ", openaiPath);
console.log("[Proxy] ", path);
console.log("[Base Url]", baseUrl);
if (process.env.OPENAI_ORG_ID) {
console.log("[Org ID]", process.env.OPENAI_ORG_ID);
const timeoutId = setTimeout(
() => {
controller.abort();
},
10 * 60 * 1000,
);
if (serverConfig.isAzure) {
if (!serverConfig.azureApiVersion) {
return NextResponse.json({
error: true,
message: `missing AZURE_API_VERSION in server env vars`,
});
}
path = makeAzurePath(path, serverConfig.azureApiVersion);
}
const timeoutId = setTimeout(() => {
controller.abort();
}, 10 * 60 * 1000);
const fetchUrl = `${baseUrl}/${openaiPath}`;
const fetchUrl = `${baseUrl}/${path}`;
const fetchOptions: RequestInit = {
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
Authorization: authValue,
...(process.env.OPENAI_ORG_ID && {
"OpenAI-Organization": process.env.OPENAI_ORG_ID,
[authHeaderName]: authValue,
...(serverConfig.openaiOrgId && {
"OpenAI-Organization": serverConfig.openaiOrgId,
}),
},
method: req.method,
@@ -55,18 +81,23 @@ export async function requestOpenai(req: NextRequest) {
};
// #1815 try to refuse gpt4 request
if (DISABLE_GPT4 && req.body) {
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);
const jsonBody = JSON.parse(clonedBody) as { model?: string };
if ((jsonBody?.model ?? "").includes("gpt-4")) {
// not undefined and is false
if (modelTable[jsonBody?.model ?? ""].available === false) {
return NextResponse.json(
{
error: true,
message: "you are not allowed to use gpt-4 model",
message: `you are not allowed to use ${jsonBody?.model} model`,
},
{
status: 403,
@@ -81,12 +112,37 @@ 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");
// Check if serverConfig.openaiOrgId is defined and not an empty string
if (serverConfig.openaiOrgId && serverConfig.openaiOrgId.trim() !== "") {
// If openaiOrganizationHeader is present, log it; otherwise, log that the header is not present
console.log("[Org ID]", openaiOrganizationHeader);
} else {
console.log("[Org ID] is not set up.");
}
// to prevent browser prompt for credentials
const newHeaders = new Headers(res.headers);
newHeaders.delete("www-authenticate");
// to disable nginx buffering
newHeaders.set("X-Accel-Buffering", "no");
// Conditionally delete the OpenAI-Organization header from the response if [Org ID] is undefined or empty (not setup in ENV)
// Also, this is to prevent the header from being sent to the client
if (!serverConfig.openaiOrgId || serverConfig.openaiOrgId.trim() === "") {
newHeaders.delete("OpenAI-Organization");
}
// The latest version of the OpenAI API forced the content-encoding to be "br" in json response
// So if the streaming is disabled, we need to remove the content-encoding header
// Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header
// The browser will try to decode the response with brotli and fail
newHeaders.delete("content-encoding");
return new Response(res.body, {
status: res.status,
statusText: res.statusText,

View File

@@ -12,6 +12,8 @@ const DANGER_CONFIG = {
disableGPT4: serverConfig.disableGPT4,
hideBalanceQuery: serverConfig.hideBalanceQuery,
disableFastLink: serverConfig.disableFastLink,
customModels: serverConfig.customModels,
defaultModel: serverConfig.defaultModel,
};
declare global {

View File

@@ -1,43 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const [protocol, ...subpath] = params.path;
const targetUrl = `${protocol}://${subpath.join("/")}`;
const method = req.headers.get("method") ?? undefined;
const shouldNotHaveBody = ["get", "head"].includes(
method?.toLowerCase() ?? "",
);
const fetchOptions: RequestInit = {
headers: {
authorization: req.headers.get("authorization") ?? "",
},
body: shouldNotHaveBody ? null : req.body,
method,
// @ts-ignore
duplex: "half",
};
const fetchResult = await fetch(targetUrl, fetchOptions);
console.log("[Any Proxy]", targetUrl, {
status: fetchResult.status,
statusText: fetchResult.statusText,
});
return fetchResult;
}
export const POST = handle;
export const GET = handle;
export const OPTIONS = handle;
export const runtime = "nodejs";

View File

@@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "../../auth";
import { getServerSideConfig } from "@/app/config/server";
import { GEMINI_BASE_URL, Google, ModelProvider } from "@/app/constant";
async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
console.log("[Google Route] params ", params);
if (req.method === "OPTIONS") {
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, {
status: 401,
});
}
const bearToken = req.headers.get("Authorization") ?? "";
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
const key = token ? token : serverConfig.googleApiKey;
if (!key) {
return NextResponse.json(
{
error: true,
message: `missing GOOGLE_API_KEY in server env vars`,
},
{
status: 401,
},
);
}
const fetchUrl = `${baseUrl}/${path}?key=${key}`;
const fetchOptions: RequestInit = {
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
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);
}
}
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",
];

View File

@@ -1,6 +1,6 @@
import { type OpenAIListModelResponse } from "@/app/client/platforms/openai";
import { getServerSideConfig } from "@/app/config/server";
import { OpenaiPath } from "@/app/constant";
import { ModelProvider, OpenaiPath } from "@/app/constant";
import { prettyObject } from "@/app/utils/format";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "../../auth";
@@ -45,7 +45,7 @@ async function handle(
);
}
const authResult = auth(req);
const authResult = auth(req, ModelProvider.GPT);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
@@ -75,3 +75,22 @@ 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",
];

View File

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

View File

@@ -0,0 +1,142 @@
import { NextRequest, NextResponse } from "next/server";
import { STORAGE_KEY, internalWhiteWebDavEndpoints } from "../../../constant";
import { getServerSideConfig } from "@/app/config/server";
const config = getServerSideConfig();
const mergedWhiteWebDavEndpoints = [
...internalWhiteWebDavEndpoints,
...config.whiteWebDevEndpoints,
].filter((domain) => Boolean(domain.trim()));
async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const folder = STORAGE_KEY;
const fileName = `${folder}/backup.json`;
const requestUrl = new URL(req.url);
let endpoint = requestUrl.searchParams.get("endpoint");
// Validate the endpoint to prevent potential SSRF attacks
if (
!mergedWhiteWebDavEndpoints.some((white) => endpoint?.startsWith(white))
) {
return NextResponse.json(
{
error: true,
msg: "Invalid endpoint",
},
{
status: 400,
},
);
}
if (!endpoint?.endsWith("/")) {
endpoint += "/";
}
const endpointPath = params.path.join("/");
const targetPath = `${endpoint}${endpointPath}`;
// only allow MKCOL, GET, PUT
if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") {
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + targetPath,
},
{
status: 403,
},
);
}
// for MKCOL request, only allow request ${folder}
if (req.method === "MKCOL" && !targetPath.endsWith(folder)) {
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + targetPath,
},
{
status: 403,
},
);
}
// for GET request, only allow request ending with fileName
if (req.method === "GET" && !targetPath.endsWith(fileName)) {
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + targetPath,
},
{
status: 403,
},
);
}
// for PUT request, only allow request ending with fileName
if (req.method === "PUT" && !targetPath.endsWith(fileName)) {
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + targetPath,
},
{
status: 403,
},
);
}
const targetUrl = targetPath;
const method = req.method;
const shouldNotHaveBody = ["get", "head"].includes(
method?.toLowerCase() ?? "",
);
const fetchOptions: RequestInit = {
headers: {
authorization: req.headers.get("authorization") ?? "",
},
body: shouldNotHaveBody ? null : req.body,
redirect: "manual",
method,
// @ts-ignore
duplex: "half",
};
let fetchResult;
try {
fetchResult = await fetch(targetUrl, fetchOptions);
} finally {
console.log(
"[Any Proxy]",
targetUrl,
{
method: req.method,
},
{
status: fetchResult?.status,
statusText: fetchResult?.statusText,
},
);
}
return fetchResult;
}
export const PUT = handle;
export const GET = handle;
export const OPTIONS = handle;
export const runtime = "edge";

9
app/azure.ts Normal file
View File

@@ -0,0 +1,9 @@
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

@@ -1,17 +1,31 @@
import { getClientConfig } from "../config/client";
import { ACCESS_CODE_PREFIX } from "../constant";
import { ChatMessage, ModelType, useAccessStore } from "../store";
import {
ACCESS_CODE_PREFIX,
Azure,
ModelProvider,
ServiceProvider,
} from "../constant";
import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store";
import { ChatGPTApi } from "./platforms/openai";
import { GeminiProApi } from "./platforms/google";
import { ClaudeApi } from "./platforms/anthropic";
export const ROLES = ["system", "user", "assistant"] as const;
export type MessageRole = (typeof ROLES)[number];
export const Models = ["gpt-3.5-turbo", "gpt-4"] as const;
export type ChatModel = ModelType;
export interface MultimodalContent {
type: "text" | "image_url";
text?: string;
image_url?: {
url: string;
};
}
export interface RequestMessage {
role: MessageRole;
content: string;
content: string | MultimodalContent[];
}
export interface LLMConfig {
@@ -41,6 +55,13 @@ export interface LLMUsage {
export interface LLMModel {
name: string;
available: boolean;
provider: LLMModelProvider;
}
export interface LLMModelProvider {
id: string;
providerName: string;
providerType: string;
}
export abstract class LLMApi {
@@ -73,8 +94,17 @@ interface ChatProvider {
export class ClientApi {
public llm: LLMApi;
constructor() {
this.llm = new ChatGPTApi();
constructor(provider: ModelProvider = ModelProvider.GPT) {
switch (provider) {
case ModelProvider.GeminiPro:
this.llm = new GeminiProApi();
break;
case ModelProvider.Claude:
this.llm = new ClaudeApi();
break;
default:
this.llm = new ChatGPTApi();
}
}
config() {}
@@ -93,7 +123,7 @@ export class ClientApi {
{
from: "human",
value:
"Share from [ChatGPT Next Web]: https://github.com/Yidadaa/ChatGPT-Next-Web",
"Share from [NextChat]: https://github.com/Yidadaa/ChatGPT-Next-Web",
},
]);
// 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用
@@ -123,28 +153,38 @@ export class ClientApi {
}
}
export const api = new ClientApi();
export function getHeaders() {
const accessStore = useAccessStore.getState();
let headers: Record<string, string> = {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-requested-with": "XMLHttpRequest",
Accept: "application/json",
};
const makeBearer = (token: string) => `Bearer ${token.trim()}`;
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;
// use user's api key first
if (validString(accessStore.token)) {
headers.Authorization = makeBearer(accessStore.token);
} else if (
accessStore.enabledAccessControl() &&
validString(accessStore.accessCode)
) {
headers.Authorization = makeBearer(
ACCESS_CODE_PREFIX + accessStore.accessCode,
);
// 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,
);
}
}
return headers;

View File

@@ -0,0 +1,408 @@
import { ACCESS_CODE_PREFIX, Anthropic, ApiPath } from "@/app/constant";
import { ChatOptions, 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,
} from "@fortaine/fetch-event-source";
import Locale from "../../locales";
import { prettyObject } from "@/app/utils/format";
import { getMessageTextContent, isVisionModel } from "@/app/utils";
export type MultiBlockContent = {
type: "image" | "text";
source?: {
type: string;
media_type: string;
data: string;
};
text?: string;
};
export type AnthropicMessage = {
role: (typeof ClaudeMapper)[keyof typeof ClaudeMapper];
content: string | MultiBlockContent[];
};
export interface AnthropicChatRequest {
model: string; // The model that will complete your prompt.
messages: AnthropicMessage[]; // The prompt that you want Claude to complete.
max_tokens: number; // The maximum number of tokens to generate before stopping.
stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text.
temperature?: number; // Amount of randomness injected into the response.
top_p?: number; // Use nucleus sampling.
top_k?: number; // Only sample from the top K options for each subsequent token.
metadata?: object; // An object describing metadata about the request.
stream?: boolean; // Whether to incrementally stream the response using server-sent events.
}
export interface ChatRequest {
model: string; // The model that will complete your prompt.
prompt: string; // The prompt that you want Claude to complete.
max_tokens_to_sample: number; // The maximum number of tokens to generate before stopping.
stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text.
temperature?: number; // Amount of randomness injected into the response.
top_p?: number; // Use nucleus sampling.
top_k?: number; // Only sample from the top K options for each subsequent token.
metadata?: object; // An object describing metadata about the request.
stream?: boolean; // Whether to incrementally stream the response using server-sent events.
}
export interface ChatResponse {
completion: string;
stop_reason: "stop_sequence" | "max_tokens";
model: string;
}
export type ChatStreamResponse = ChatResponse & {
stop?: string;
log_id: string;
};
const ClaudeMapper = {
assistant: "assistant",
user: "user",
system: "user",
} as const;
const keys = ["claude-2, claude-instant-1"];
export class ClaudeApi implements LLMApi {
extractMessage(res: any) {
console.log("[Response] claude response: ", res);
return res?.content?.[0]?.text;
}
async chat(options: ChatOptions): Promise<void> {
const visionModel = isVisionModel(options.config.model);
const accessStore = useAccessStore.getState();
const shouldStream = !!options.config.stream;
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
...{
model: options.config.model,
},
};
const messages = [...options.messages];
const keys = ["system", "user"];
// roles must alternate between "user" and "assistant" in claude, so add a fake assistant message between two user messages
for (let i = 0; i < messages.length - 1; i++) {
const message = messages[i];
const nextMessage = messages[i + 1];
if (keys.includes(message.role) && keys.includes(nextMessage.role)) {
messages[i] = [
message,
{
role: "assistant",
content: ";",
},
] as any;
}
}
const prompt = messages
.flat()
.filter((v) => {
if (!v.content) return false;
if (typeof v.content === "string" && !v.content.trim()) return false;
return true;
})
.map((v) => {
const { role, content } = v;
const insideRole = ClaudeMapper[role] ?? "user";
if (!visionModel || typeof content === "string") {
return {
role: insideRole,
content: getMessageTextContent(v),
};
}
return {
role: insideRole,
content: content
.filter((v) => v.image_url || v.text)
.map(({ type, text, image_url }) => {
if (type === "text") {
return {
type,
text: text!,
};
}
const { url = "" } = image_url || {};
const colonIndex = url.indexOf(":");
const semicolonIndex = url.indexOf(";");
const comma = url.indexOf(",");
const mimeType = url.slice(colonIndex + 1, semicolonIndex);
const encodeType = url.slice(semicolonIndex + 1, comma);
const data = url.slice(comma + 1);
return {
type: "image" as const,
source: {
type: encodeType,
media_type: mimeType,
data,
},
};
}),
};
});
const requestBody: AnthropicChatRequest = {
messages: prompt,
stream: shouldStream,
model: modelConfig.model,
max_tokens: modelConfig.max_tokens,
temperature: modelConfig.temperature,
top_p: modelConfig.top_p,
// top_k: modelConfig.top_k,
top_k: 5,
};
const path = this.path(Anthropic.ChatPath);
const controller = new AbortController();
options.onController?.(controller);
const payload = {
method: "POST",
body: JSON.stringify(requestBody),
signal: controller.signal,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"x-api-key": accessStore.anthropicApiKey,
"anthropic-version": accessStore.anthropicApiVersion,
Authorization: getAuthKey(accessStore.anthropicApiKey),
},
};
if (shouldStream) {
try {
const context = {
text: "",
finished: false,
};
const finish = () => {
if (!context.finished) {
options.onFinish(context.text);
context.finished = true;
}
};
controller.signal.onabort = finish;
fetchEventSource(path, {
...payload,
async onopen(res) {
const contentType = res.headers.get("content-type");
console.log("response content type: ", contentType);
if (contentType?.startsWith("text/plain")) {
context.text = await res.clone().text();
return finish();
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [context.text];
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);
}
context.text = responseTexts.join("\n\n");
return finish();
}
},
onmessage(msg) {
let chunkJson:
| undefined
| {
type: "content_block_delta" | "content_block_stop";
delta?: {
type: "text_delta";
text: string;
};
index: number;
};
try {
chunkJson = JSON.parse(msg.data);
} catch (e) {
console.error("[Response] parse error", msg.data);
}
if (!chunkJson || chunkJson.type === "content_block_stop") {
return finish();
}
const { delta } = chunkJson;
if (delta?.text) {
context.text += delta.text;
options.onUpdate?.(context.text, delta.text);
}
},
onclose() {
finish();
},
onerror(e) {
options.onError?.(e);
throw e;
},
openWhenHidden: true,
});
} catch (e) {
console.error("failed to chat", e);
options.onError?.(e as Error);
}
} else {
try {
controller.signal.onabort = () => options.onFinish("");
const res = await fetch(path, payload);
const resJson = await res.json();
const message = this.extractMessage(resJson);
options.onFinish(message);
} catch (e) {
console.error("failed to chat", e);
options.onError?.(e as Error);
}
}
}
async usage() {
return {
used: 0,
total: 0,
};
}
async models() {
// const provider = {
// id: "anthropic",
// providerName: "Anthropic",
// providerType: "anthropic",
// };
return [
// {
// name: "claude-instant-1.2",
// available: true,
// provider,
// },
// {
// name: "claude-2.0",
// available: true,
// provider,
// },
// {
// name: "claude-2.1",
// available: true,
// provider,
// },
// {
// name: "claude-3-opus-20240229",
// available: true,
// provider,
// },
// {
// name: "claude-3-sonnet-20240229",
// available: true,
// provider,
// },
// {
// name: "claude-3-haiku-20240307",
// available: true,
// provider,
// },
];
}
path(path: string): string {
const accessStore = useAccessStore.getState();
let baseUrl: string = "";
if (accessStore.useCustomConfig) {
baseUrl = accessStore.anthropicUrl;
}
// if endpoint is empty, use default endpoint
if (baseUrl.trim().length === 0) {
const isApp = !!getClientConfig()?.isApp;
baseUrl = isApp
? DEFAULT_API_HOST + "/api/proxy/anthropic"
: ApiPath.Anthropic;
}
if (!baseUrl.startsWith("http") && !baseUrl.startsWith("/api")) {
baseUrl = "https://" + baseUrl;
}
baseUrl = trimEnd(baseUrl, "/");
return `${baseUrl}/${path}`;
}
}
function trimEnd(s: string, end = " ") {
if (end.length === 0) return s;
while (s.endsWith(end)) {
s = s.slice(0, -end.length);
}
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,284 @@
import { 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 {
getMessageTextContent,
getMessageImages,
isVisionModel,
} from "@/app/utils";
export class GeminiProApi implements LLMApi {
extractMessage(res: any) {
console.log("[Response] gemini-pro response: ", res);
return (
res?.candidates?.at(0)?.content?.parts.at(0)?.text ||
res?.error?.message ||
""
);
}
async chat(options: ChatOptions): Promise<void> {
// const apiClient = this;
const visionModel = isVisionModel(options.config.model);
let multimodal = false;
const messages = options.messages.map((v) => {
let parts: any[] = [{ text: getMessageTextContent(v) }];
if (visionModel) {
const images = getMessageImages(v);
if (images.length > 0) {
multimodal = true;
parts = parts.concat(
images.map((image) => {
const imageType = image.split(";")[0].split(":")[1];
const imageData = image.split(",")[1];
return {
inline_data: {
mime_type: imageType,
data: imageData,
},
};
}),
);
}
}
return {
role: v.role.replace("assistant", "model").replace("system", "user"),
parts: parts,
};
});
// google requires that role in neighboring messages must not be the same
for (let i = 0; i < messages.length - 1; ) {
// Check if current and next item both have the role "model"
if (messages[i].role === messages[i + 1].role) {
// Concatenate the 'parts' of the current and next item
messages[i].parts = messages[i].parts.concat(messages[i + 1].parts);
// Remove the next item
messages.splice(i + 1, 1);
} else {
// Move to the next item
i++;
}
}
// if (visionModel && messages.length > 1) {
// options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision"));
// }
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
...{
model: options.config.model,
},
};
const requestPayload = {
contents: messages,
generationConfig: {
// stopSequences: [
// "Title"
// ],
temperature: modelConfig.temperature,
maxOutputTokens: modelConfig.max_tokens,
topP: modelConfig.top_p,
// "topK": modelConfig.top_k,
},
safetySettings: [
{
category: "HARM_CATEGORY_HARASSMENT",
threshold: "BLOCK_ONLY_HIGH",
},
{
category: "HARM_CATEGORY_HATE_SPEECH",
threshold: "BLOCK_ONLY_HIGH",
},
{
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
threshold: "BLOCK_ONLY_HIGH",
},
{
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
threshold: "BLOCK_ONLY_HIGH",
},
],
};
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);
// 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),
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;
let existingTexts: string[] = [];
const finish = () => {
finished = true;
options.onFinish(existingTexts.join(""));
};
// animate response to make it looks smooth
function animateResponseText() {
if (finished || controller.signal.aborted) {
responseText += remainText;
finish();
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();
fetch(
baseUrl.replace("generateContent", "streamGenerateContent"),
chatPayload,
)
.then((response) => {
const reader = response?.body?.getReader();
const decoder = new TextDecoder();
let partialData = "";
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"));
}
}
console.log("Stream complete");
// options.onFinish(responseText + remainText);
finished = true;
return Promise.resolve();
}
partialData += decoder.decode(value, { stream: true });
try {
let data = JSON.parse(ensureProperEnding(partialData));
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
}
return reader.read().then(processText);
});
})
.catch((error) => {
console.error("Error:", error);
});
} else {
const res = await fetch(baseUrl, chatPayload);
clearTimeout(requestTimeoutId);
const resJson = await res.json();
if (resJson?.promptFeedback?.blockReason) {
// being blocked
options.onError?.(
new Error(
"Message is being blocked for reason: " +
resJson.promptFeedback.blockReason,
),
);
}
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);
}
}
usage(): Promise<LLMUsage> {
throw new Error("Method not implemented.");
}
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

@@ -1,12 +1,22 @@
"use client";
import {
ApiPath,
DEFAULT_API_HOST,
DEFAULT_MODELS,
OpenaiPath,
REQUEST_TIMEOUT_MS,
ServiceProvider,
} from "@/app/constant";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api";
import {
ChatOptions,
getHeaders,
LLMApi,
LLMModel,
LLMUsage,
MultimodalContent,
} from "../api";
import Locale from "../../locales";
import {
EventStreamContentType,
@@ -14,6 +24,12 @@ 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,
} from "@/app/utils";
export interface OpenAIListModelResponse {
object: string;
@@ -24,24 +40,61 @@ export interface OpenAIListModelResponse {
}>;
}
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 ChatGPTApi implements LLMApi {
private disableListModels = true;
path(path: string): string {
let openaiUrl = useAccessStore.getState().openaiUrl;
const apiPath = "/api/openai";
const accessStore = useAccessStore.getState();
if (openaiUrl.length === 0) {
let baseUrl = "";
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;
openaiUrl = isApp ? DEFAULT_API_HOST : apiPath;
baseUrl = isApp
? DEFAULT_API_HOST + "/proxy" + ApiPath.OpenAI
: ApiPath.OpenAI;
}
if (openaiUrl.endsWith("/")) {
openaiUrl = openaiUrl.slice(0, openaiUrl.length - 1);
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
}
if (!openaiUrl.startsWith("http") && !openaiUrl.startsWith(apiPath)) {
openaiUrl = "https://" + openaiUrl;
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.OpenAI)) {
baseUrl = "https://" + baseUrl;
}
return [openaiUrl, path].join("/");
console.log("[Proxy Endpoint] ", baseUrl, path);
return [baseUrl, path].join("/");
}
extractMessage(res: any) {
@@ -49,9 +102,10 @@ export class ChatGPTApi implements LLMApi {
}
async chat(options: ChatOptions) {
const visionModel = isVisionModel(options.config.model);
const messages = options.messages.map((v) => ({
role: v.role,
content: v.content,
content: visionModel ? v.content : getMessageTextContent(v),
}));
const modelConfig = {
@@ -62,7 +116,7 @@ export class ChatGPTApi implements LLMApi {
},
};
const requestPayload = {
const requestPayload: RequestPayload = {
messages,
stream: options.config.stream,
model: modelConfig.model,
@@ -70,8 +124,15 @@ export class ChatGPTApi implements LLMApi {
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) {
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
}
console.log("[Request] openai payload: ", requestPayload);
const shouldStream = !!options.config.stream;
@@ -95,12 +156,38 @@ export class ChatGPTApi implements LLMApi {
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) {
options.onFinish(responseText);
finished = true;
options.onFinish(responseText + remainText);
}
};
@@ -155,10 +242,27 @@ export class ChatGPTApi implements LLMApi {
const text = msg.data;
try {
const json = JSON.parse(text);
const delta = json.choices[0].delta.content;
const choices = json.choices as Array<{
delta: { content: string };
}>;
const delta = choices[0]?.delta?.content;
const textmoderation = json?.prompt_filter_results;
if (delta) {
responseText += delta;
options.onUpdate?.(responseText, delta);
remainText += delta;
}
if (
textmoderation &&
textmoderation.length > 0 &&
ServiceProvider.Azure
) {
const contentFilterResults =
textmoderation[0]?.content_filter_results;
console.log(
`[${ServiceProvider.Azure}] [Text Moderation] flagged categories result:`,
contentFilterResults,
);
}
} catch (e) {
console.error("[Request] parse error", text, msg);
@@ -275,6 +379,11 @@ export class ChatGPTApi implements LLMApi {
return chatModels.map((m) => ({
name: m.id,
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
}));
}
}

View File

@@ -0,0 +1,123 @@
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

@@ -0,0 +1,78 @@
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

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,42 @@
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

@@ -0,0 +1,88 @@
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

@@ -0,0 +1,157 @@
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

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,115 @@
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

@@ -0,0 +1,352 @@
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

@@ -0,0 +1,352 @@
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

@@ -0,0 +1,71 @@
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

@@ -0,0 +1,24 @@
.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

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,118 @@
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

@@ -0,0 +1,99 @@
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

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,27 @@
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

@@ -6,6 +6,8 @@
width: 100%;
flex-direction: column;
background-color: var(--white);
.auth-logo {
transform: scale(1.4);
}
@@ -33,4 +35,18 @@
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

@@ -18,7 +18,7 @@ export function AuthPage() {
const goChat = () => navigate(Path.Chat);
const resetAccessCode = () => {
accessStore.update((access) => {
access.token = "";
access.openaiApiKey = "";
access.accessCode = "";
});
}; // Reset access code to empty string
@@ -56,11 +56,22 @@ export function AuthPage() {
<input
className={styles["auth-input"]}
type="password"
placeholder={Locale.Settings.Token.Placeholder}
value={accessStore.token}
placeholder={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
value={accessStore.openaiApiKey}
onChange={(e) => {
accessStore.update(
(access) => (access.token = e.currentTarget.value),
(access) => (access.openaiApiKey = e.currentTarget.value),
);
}}
/>
<input
className={styles["auth-input"]}
type="password"
placeholder={Locale.Settings.Access.Google.ApiKey.Placeholder}
value={accessStore.googleApiKey}
onChange={(e) => {
accessStore.update(
(access) => (access.googleApiKey = e.currentTarget.value),
);
}}
/>

View File

@@ -12,12 +12,13 @@ import {
import { useChatStore } from "../store";
import Locale from "../locales";
import { Link, useNavigate } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Path } from "../constant";
import { MaskAvatar } from "./mask";
import { Mask } from "../store/mask";
import { useRef, useEffect } from "react";
import { showConfirm } from "./ui-lib";
import { useMobileScreen } from "../utils";
export function ChatItem(props: {
onClick?: () => void;
@@ -39,12 +40,16 @@ export function ChatItem(props: {
});
}
}, [props.selected]);
const { pathname: currentPath } = useLocation();
return (
<Draggable draggableId={`${props.id}`} index={props.index}>
{(provided) => (
<div
className={`${styles["chat-item"]} ${
props.selected && styles["chat-item-selected"]
props.selected &&
(currentPath === Path.Chat || currentPath === Path.Home) &&
styles["chat-item-selected"]
}`}
onClick={props.onClick}
ref={(ele) => {
@@ -60,7 +65,10 @@ export function ChatItem(props: {
{props.narrow ? (
<div className={styles["chat-item-narrow"]}>
<div className={styles["chat-item-avatar"] + " no-dark"}>
<MaskAvatar mask={props.mask} />
<MaskAvatar
avatar={props.mask.avatar}
model={props.mask.modelConfig.model}
/>
</div>
<div className={styles["chat-item-narrow-count"]}>
{props.count}
@@ -80,7 +88,11 @@ export function ChatItem(props: {
<div
className={styles["chat-item-delete"]}
onClickCapture={props.onDelete}
onClickCapture={(e) => {
props.onDelete?.();
e.preventDefault();
e.stopPropagation();
}}
>
<DeleteIcon />
</div>
@@ -101,6 +113,7 @@ export function ChatList(props: { narrow?: boolean }) {
);
const chatStore = useChatStore();
const navigate = useNavigate();
const isMobileScreen = useMobileScreen();
const onDragEnd: OnDragEndResponder = (result) => {
const { destination, source } = result;
@@ -142,7 +155,7 @@ export function ChatList(props: { narrow?: boolean }) {
}}
onDelete={async () => {
if (
!props.narrow ||
(!props.narrow && !isMobileScreen) ||
(await showConfirm(Locale.Home.DeleteChat))
) {
chatStore.deleteSession(i);

View File

@@ -1,5 +1,47 @@
@import "../styles/animation.scss";
.attach-images {
position: absolute;
left: 30px;
bottom: 32px;
display: flex;
}
.attach-image {
cursor: default;
width: 64px;
height: 64px;
border: rgba($color: #888, $alpha: 0.2) 1px solid;
border-radius: 5px;
margin-right: 10px;
background-size: cover;
background-position: center;
background-color: var(--white);
.attach-image-mask {
width: 100%;
height: 100%;
opacity: 0;
transition: all ease 0.2s;
}
.attach-image-mask:hover {
opacity: 1;
}
.delete-image {
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
float: right;
background-color: var(--white);
}
}
.chat-input-actions {
display: flex;
flex-wrap: wrap;
@@ -189,12 +231,10 @@
animation: slide-in ease 0.3s;
$linear: linear-gradient(
to right,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 1),
rgba(0, 0, 0, 0)
);
$linear: linear-gradient(to right,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 1),
rgba(0, 0, 0, 0));
mask-image: $linear;
@mixin show {
@@ -327,7 +367,7 @@
}
}
.chat-message-user > .chat-message-container {
.chat-message-user>.chat-message-container {
align-items: flex-end;
}
@@ -349,6 +389,7 @@
padding: 7px;
}
}
/* Specific styles for iOS devices */
@media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) {
@supports (-webkit-touch-callout: none) {
@@ -381,6 +422,64 @@
transition: all ease 0.3s;
}
.chat-message-item-image {
width: 100%;
margin-top: 10px;
}
.chat-message-item-images {
width: 100%;
display: grid;
justify-content: left;
grid-gap: 10px;
grid-template-columns: repeat(var(--image-count), auto);
margin-top: 10px;
}
.chat-message-item-image-multi {
object-fit: cover;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.chat-message-item-image,
.chat-message-item-image-multi {
box-sizing: border-box;
border-radius: 10px;
border: rgba($color: #888, $alpha: 0.2) 1px solid;
}
@media only screen and (max-width: 600px) {
$calc-image-width: calc(100vw/3*2/var(--image-count));
.chat-message-item-image-multi {
width: $calc-image-width;
height: $calc-image-width;
}
.chat-message-item-image {
max-width: calc(100vw/3*2);
}
}
@media screen and (min-width: 600px) {
$max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count));
$image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count));
.chat-message-item-image-multi {
width: $image-width;
height: $image-width;
max-width: $max-image-width;
max-height: $max-image-width;
}
.chat-message-item-image {
max-width: calc(calc(1200px - var(--sidebar-width))/3*2);
}
}
.chat-message-action-date {
font-size: 12px;
opacity: 0.2;
@@ -395,7 +494,7 @@
z-index: 1;
}
.chat-message-user > .chat-message-container > .chat-message-item {
.chat-message-user>.chat-message-container>.chat-message-item {
background-color: var(--second);
&:hover {
@@ -460,6 +559,7 @@
@include single-line();
}
.hint-content {
font-size: 12px;
@@ -474,15 +574,26 @@
}
.chat-input-panel-inner {
cursor: text;
display: flex;
flex: 1;
border-radius: 10px;
border: var(--border-in-light);
}
.chat-input-panel-inner-attach {
padding-bottom: 80px;
}
.chat-input-panel-inner:has(.chat-input:focus) {
border: 1px solid var(--primary);
}
.chat-input {
height: 100%;
width: 100%;
border-radius: 10px;
border: var(--border-in-light);
border: none;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
background-color: var(--white);
color: var(--black);
@@ -494,9 +605,7 @@
min-height: 68px;
}
.chat-input:focus {
border: 1px solid var(--primary);
}
.chat-input:focus {}
.chat-input-send {
background-color: var(--primary);
@@ -515,4 +624,4 @@
.chat-input-send {
bottom: 30px;
}
}
}

View File

@@ -6,6 +6,7 @@ import React, {
useMemo,
useCallback,
Fragment,
RefObject,
} from "react";
import SendWhiteIcon from "../icons/send-white.svg";
@@ -15,6 +16,7 @@ import ExportIcon from "../icons/share.svg";
import ReturnIcon from "../icons/return.svg";
import CopyIcon from "../icons/copy.svg";
import LoadingIcon from "../icons/three-dots.svg";
import LoadingButtonIcon from "../icons/loading.svg";
import PromptIcon from "../icons/prompt.svg";
import MaskIcon from "../icons/mask.svg";
import MaxIcon from "../icons/max.svg";
@@ -27,6 +29,7 @@ import PinIcon from "../icons/pin.svg";
import EditIcon from "../icons/rename.svg";
import ConfirmIcon from "../icons/confirm.svg";
import CancelIcon from "../icons/cancel.svg";
import ImageIcon from "../icons/image.svg";
import LightIcon from "../icons/light.svg";
import DarkIcon from "../icons/dark.svg";
@@ -53,6 +56,10 @@ import {
selectOrCopy,
autoGrowTextArea,
useMobileScreen,
getMessageTextContent,
getMessageImages,
isVisionModel,
compressImage,
} from "../utils";
import dynamic from "next/dynamic";
@@ -88,6 +95,8 @@ import { ChatCommandPrefix, useChatCommand, useCommand } from "../command";
import { prettyObject } from "../utils/format";
import { ExportMessageModal } from "./exporter";
import { getClientConfig } from "../config/client";
import { useAllModels } from "../utils/hooks";
import { MultimodalContent } from "../client/api";
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
@@ -143,6 +152,7 @@ export function SessionConfigModel(props: { onClose: () => void }) {
extraListItems={
session.mask.modelConfig.sendMemory ? (
<ListItem
className="copyable"
title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
></ListItem>
@@ -209,6 +219,8 @@ function useSubmitHandler() {
}, []);
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Fix Chinese input method "Enter" on Safari
if (e.keyCode == 229) return false;
if (e.key !== "Enter") return false;
if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current))
return false;
@@ -373,11 +385,13 @@ function ChatAction(props: {
);
}
function useScrollToBottom() {
function useScrollToBottom(
scrollRef: RefObject<HTMLDivElement>,
detach: boolean = false,
) {
// for auto-scroll
const scrollRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const [autoScroll, setAutoScroll] = useState(true);
function scrollDomToBottom() {
const dom = scrollRef.current;
if (dom) {
@@ -390,7 +404,7 @@ function useScrollToBottom() {
// auto scroll
useEffect(() => {
if (autoScroll) {
if (autoScroll && !detach) {
scrollDomToBottom();
}
});
@@ -404,10 +418,14 @@ function useScrollToBottom() {
}
export function ChatActions(props: {
uploadImage: () => void;
setAttachImages: (images: string[]) => void;
setUploading: (uploading: boolean) => void;
showPromptModal: () => void;
scrollToBottom: () => void;
showPromptHints: () => void;
hitBottom: boolean;
uploading: boolean;
}) {
const config = useAppConfig();
const navigate = useNavigate();
@@ -429,15 +447,46 @@ export function ChatActions(props: {
// switch model
const currentModel = chatStore.currentSession().mask.modelConfig.model;
const models = useMemo(
() =>
config
.allModels()
.filter((m) => m.available)
.map((m) => m.name),
[config],
);
const allModels = useAllModels();
const models = useMemo(() => {
const filteredModels = allModels.filter((m) => m.available);
const defaultModel = filteredModels.find((m) => m.isDefault);
if (defaultModel) {
const arr = [
defaultModel,
...filteredModels.filter((m) => m !== defaultModel),
];
return arr;
} else {
return filteredModels;
}
}, [allModels]);
const [showModelSelector, setShowModelSelector] = useState(false);
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) {
// 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),
);
showToast(nextModel);
}
}, [chatStore, currentModel, models]);
return (
<div className={styles["chat-input-actions"]}>
@@ -463,6 +512,13 @@ export function ChatActions(props: {
/>
)}
{showUploadImage && (
<ChatAction
onClick={props.uploadImage}
text={Locale.Chat.InputActions.UploadImage}
icon={props.uploading ? <LoadingButtonIcon /> : <ImageIcon />}
/>
)}
<ChatAction
onClick={nextTheme}
text={Locale.Chat.InputActions.Theme[theme]}
@@ -518,8 +574,8 @@ export function ChatActions(props: {
<Selector
defaultSelectedValue={currentModel}
items={models.map((m) => ({
title: m,
value: m,
title: m.displayName,
value: m.name,
}))}
onClose={() => setShowModelSelector(false)}
onSelection={(s) => {
@@ -598,6 +654,14 @@ export function EditMessageModal(props: { onClose: () => void }) {
);
}
export function DeleteImageButton(props: { deleteImage: () => void }) {
return (
<div className={styles["delete-image"]} onClick={props.deleteImage}>
<DeleteIcon />
</div>
);
}
function _Chat() {
type RenderMessage = ChatMessage & { preview?: boolean };
@@ -612,10 +676,22 @@ function _Chat() {
const [userInput, setUserInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { submitKey, shouldSubmit } = useSubmitHandler();
const { scrollRef, setAutoScroll, scrollDomToBottom } = useScrollToBottom();
const scrollRef = useRef<HTMLDivElement>(null);
const isScrolledToBottom = scrollRef?.current
? Math.abs(
scrollRef.current.scrollHeight -
(scrollRef.current.scrollTop + scrollRef.current.clientHeight),
) <= 1
: false;
const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(
scrollRef,
isScrolledToBottom,
);
const [hitBottom, setHitBottom] = useState(true);
const isMobileScreen = useMobileScreen();
const navigate = useNavigate();
const [attachImages, setAttachImages] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
// prompt hints
const promptStore = usePromptStore();
@@ -693,7 +769,10 @@ function _Chat() {
return;
}
setIsLoading(true);
chatStore.onUserInput(userInput).then(() => setIsLoading(false));
chatStore
.onUserInput(userInput, attachImages)
.then(() => setIsLoading(false));
setAttachImages([]);
localStorage.setItem(LAST_INPUT_KEY, userInput);
setUserInput("");
setPromptHints([]);
@@ -771,9 +850,9 @@ function _Chat() {
};
const onRightClick = (e: any, message: ChatMessage) => {
// copy to clipboard
if (selectOrCopy(e.currentTarget, message.content)) {
if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) {
if (userInput.length === 0) {
setUserInput(message.content);
setUserInput(getMessageTextContent(message));
}
e.preventDefault();
@@ -841,7 +920,9 @@ function _Chat() {
// resend the message
setIsLoading(true);
chatStore.onUserInput(userMessage.content).then(() => setIsLoading(false));
const textContent = getMessageTextContent(userMessage);
const images = getMessageImages(userMessage);
chatStore.onUserInput(textContent, images).then(() => setIsLoading(false));
inputRef.current?.focus();
};
@@ -950,7 +1031,6 @@ function _Chat() {
setHitBottom(isHitBottom);
setAutoScroll(isHitBottom);
};
function scrollToBottom() {
setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
scrollDomToBottom();
@@ -1001,7 +1081,9 @@ function _Chat() {
).then((res) => {
if (!res) return;
if (payload.key) {
accessStore.update((access) => (access.token = payload.key!));
accessStore.update(
(access) => (access.openaiApiKey = payload.key!),
);
}
if (payload.url) {
accessStore.update((access) => (access.openaiUrl = payload.url!));
@@ -1034,6 +1116,94 @@ function _Chat() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handlePaste = useCallback(
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const currentModel = chatStore.currentSession().mask.modelConfig.model;
if (!isVisionModel(currentModel)) {
return;
}
const items = (event.clipboardData || window.clipboardData).items;
for (const item of items) {
if (item.kind === "file" && item.type.startsWith("image/")) {
event.preventDefault();
const file = item.getAsFile();
if (file) {
const images: string[] = [];
images.push(...attachImages);
images.push(
...(await new Promise<string[]>((res, rej) => {
setUploading(true);
const imagesData: string[] = [];
compressImage(file, 256 * 1024)
.then((dataUrl) => {
imagesData.push(dataUrl);
setUploading(false);
res(imagesData);
})
.catch((e) => {
setUploading(false);
rej(e);
});
})),
);
const imagesLength = images.length;
if (imagesLength > 3) {
images.splice(3, imagesLength - 3);
}
setAttachImages(images);
}
}
}
},
[attachImages, chatStore],
);
async function uploadImage() {
const images: string[] = [];
images.push(...attachImages);
images.push(
...(await new Promise<string[]>((res, rej) => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept =
"image/png, image/jpeg, image/webp, image/heic, image/heif";
fileInput.multiple = true;
fileInput.onchange = (event: any) => {
setUploading(true);
const files = event.target.files;
const imagesData: string[] = [];
for (let i = 0; i < files.length; i++) {
const file = event.target.files[i];
compressImage(file, 256 * 1024)
.then((dataUrl) => {
imagesData.push(dataUrl);
if (
imagesData.length === 3 ||
imagesData.length === files.length
) {
setUploading(false);
res(imagesData);
}
})
.catch((e) => {
setUploading(false);
rej(e);
});
}
};
fileInput.click();
})),
);
const imagesLength = images.length;
if (imagesLength > 3) {
images.splice(3, imagesLength - 3);
}
setAttachImages(images);
}
return (
<div className={styles.chat} key={session.id}>
<div className="window-header" data-tauri-drag-region>
@@ -1140,15 +1310,29 @@ function _Chat() {
onClick={async () => {
const newMessage = await showPrompt(
Locale.Chat.Actions.Edit,
message.content,
getMessageTextContent(message),
10,
);
let newContent: string | MultimodalContent[] =
newMessage;
const images = getMessageImages(message);
if (images.length > 0) {
newContent = [{ type: "text", text: newMessage }];
for (let i = 0; i < images.length; i++) {
newContent.push({
type: "image_url",
image_url: {
url: images[i],
},
});
}
}
chatStore.updateCurrentSession((session) => {
const m = session.mask.context
.concat(session.messages)
.find((m) => m.id === message.id);
if (m) {
m.content = newMessage;
m.content = newContent;
}
});
}}
@@ -1161,7 +1345,12 @@ function _Chat() {
{["system"].includes(message.role) ? (
<Avatar avatar="2699-fe0f" />
) : (
<MaskAvatar mask={session.mask} />
<MaskAvatar
avatar={session.mask.avatar}
model={
message.model || session.mask.modelConfig.model
}
/>
)}
</>
)}
@@ -1198,7 +1387,11 @@ function _Chat() {
<ChatAction
text={Locale.Chat.Actions.Copy}
icon={<CopyIcon />}
onClick={() => copyToClipboard(message.content)}
onClick={() =>
copyToClipboard(
getMessageTextContent(message),
)
}
/>
</>
)}
@@ -1213,7 +1406,7 @@ function _Chat() {
)}
<div className={styles["chat-message-item"]}>
<Markdown
content={message.content}
content={getMessageTextContent(message)}
loading={
(message.preview || message.streaming) &&
message.content.length === 0 &&
@@ -1222,12 +1415,42 @@ function _Chat() {
onContextMenu={(e) => onRightClick(e, message)}
onDoubleClickCapture={() => {
if (!isMobileScreen) return;
setUserInput(message.content);
setUserInput(getMessageTextContent(message));
}}
fontSize={fontSize}
parentRef={scrollRef}
defaultShow={i >= messages.length - 6}
/>
{getMessageImages(message).length == 1 && (
<img
className={styles["chat-message-item-image"]}
src={getMessageImages(message)[0]}
alt=""
/>
)}
{getMessageImages(message).length > 1 && (
<div
className={styles["chat-message-item-images"]}
style={
{
"--image-count": getMessageImages(message).length,
} as React.CSSProperties
}
>
{getMessageImages(message).map((image, index) => {
return (
<img
className={
styles["chat-message-item-image-multi"]
}
key={index}
src={image}
alt=""
/>
);
})}
</div>
)}
</div>
<div className={styles["chat-message-action-date"]}>
@@ -1247,9 +1470,13 @@ function _Chat() {
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
<ChatActions
uploadImage={uploadImage}
setAttachImages={setAttachImages}
setUploading={setUploading}
showPromptModal={() => setShowPromptModal(true)}
scrollToBottom={scrollToBottom}
hitBottom={hitBottom}
uploading={uploading}
showPromptHints={() => {
// Click again to close
if (promptHints.length > 0) {
@@ -1262,8 +1489,16 @@ function _Chat() {
onSearch("");
}}
/>
<div className={styles["chat-input-panel-inner"]}>
<label
className={`${styles["chat-input-panel-inner"]} ${
attachImages.length != 0
? styles["chat-input-panel-inner-attach"]
: ""
}`}
htmlFor="chat-input"
>
<textarea
id="chat-input"
ref={inputRef}
className={styles["chat-input"]}
placeholder={Locale.Chat.Input(submitKey)}
@@ -1272,12 +1507,36 @@ function _Chat() {
onKeyDown={onInputKeyDown}
onFocus={scrollToBottom}
onClick={scrollToBottom}
onPaste={handlePaste}
rows={inputRows}
autoFocus={autoFocus}
style={{
fontSize: config.fontSize,
}}
/>
{attachImages.length != 0 && (
<div className={styles["attach-images"]}>
{attachImages.map((image, index) => {
return (
<div
key={index}
className={styles["attach-image"]}
style={{ backgroundImage: `url("${image}")` }}
>
<div className={styles["attach-image-mask"]}>
<DeleteImageButton
deleteImage={() => {
setAttachImages(
attachImages.filter((_, i) => i !== index),
);
}}
/>
</div>
</div>
);
})}
</div>
)}
<IconButton
icon={<SendWhiteIcon />}
text={Locale.Chat.Send}
@@ -1285,7 +1544,7 @@ function _Chat() {
type="primary"
onClick={() => doSubmit(userInput)}
/>
</div>
</label>
</div>
{showExport && (

View File

@@ -10,7 +10,10 @@ import BotIcon from "../icons/bot.svg";
import BlackBotIcon from "../icons/black-bot.svg";
export function getEmojiUrl(unified: string, style: EmojiStyle) {
return `https://cdn.staticfile.org/emoji-datasource-apple/14.0.0/img/${style}/64/${unified}.png`;
// Whoever owns this Content Delivery Network (CDN), I am using your CDN to serve emojis
// Old CDN broken, so I had to switch to this one
// Author: https://github.com/H0llyW00dzZ
return `https://fastly.jsdelivr.net/npm/emoji-datasource-apple/img/${style}/64/${unified}.png`;
}
export function AvatarPicker(props: {
@@ -18,6 +21,7 @@ export function AvatarPicker(props: {
}) {
return (
<EmojiPicker
width={"100%"}
lazyLoadEmojis
theme={EmojiTheme.AUTO}
getEmojiUrl={getEmojiUrl}

View File

@@ -2,6 +2,9 @@
&-body {
margin-top: 20px;
}
div:not(.no-dark) > svg {
filter: invert(0.5);
}
}
.export-content {
@@ -94,6 +97,7 @@
button {
flex-grow: 1;
&:not(:last-child) {
margin-right: 10px;
}
@@ -186,9 +190,63 @@
box-shadow: var(--card-shadow);
border: var(--border-in-light);
*:not(li) {
code,
pre {
overflow: hidden;
}
.message-image {
width: 100%;
margin-top: 10px;
}
.message-images {
display: grid;
justify-content: left;
grid-gap: 10px;
grid-template-columns: repeat(var(--image-count), auto);
margin-top: 10px;
}
@media screen and (max-width: 600px) {
$image-width: calc(calc(100vw/2)/var(--image-count));
.message-image-multi {
width: $image-width;
height: $image-width;
}
.message-image {
max-width: calc(100vw/3*2);
}
}
@media screen and (min-width: 600px) {
$max-image-width: calc(900px/3*2/var(--image-count));
$image-width: calc(80vw/3*2/var(--image-count));
.message-image-multi {
width: $image-width;
height: $image-width;
max-width: $max-image-width;
max-height: $max-image-width;
}
.message-image {
max-width: calc(100vw/3*2);
}
}
.message-image-multi {
object-fit: cover;
}
.message-image,
.message-image-multi {
box-sizing: border-box;
border-radius: 10px;
border: rgba($color: #888, $alpha: 0.2) 1px solid;
}
}
&-assistant {
@@ -212,6 +270,5 @@
}
}
.default-theme {
}
}
.default-theme {}
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable @next/next/no-img-element */
import { ChatMessage, useAppConfig, useChatStore } from "../store";
import { ChatMessage, ModelType, useAppConfig, useChatStore } from "../store";
import Locale from "../locales";
import styles from "./exporter.module.scss";
import {
@@ -12,7 +12,12 @@ import {
showToast,
} from "./ui-lib";
import { IconButton } from "./button";
import { copyToClipboard, downloadAs, useMobileScreen } from "../utils";
import {
copyToClipboard,
downloadAs,
getMessageImages,
useMobileScreen,
} from "../utils";
import CopyIcon from "../icons/copy.svg";
import LoadingIcon from "../icons/three-dots.svg";
@@ -27,12 +32,15 @@ import { Avatar } from "./emoji";
import dynamic from "next/dynamic";
import NextImage from "next/image";
import { toBlob, toJpeg, toPng } from "html-to-image";
import { toBlob, toPng } from "html-to-image";
import { DEFAULT_MASK_AVATAR } from "../store/mask";
import { api } from "../client/api";
import { prettyObject } from "../utils/format";
import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
import { EXPORT_MESSAGE_CLASS_NAME, ModelProvider } from "../constant";
import { getClientConfig } from "../config/client";
import { ClientApi } from "../client/api";
import { getMessageTextContent } from "../utils";
import { identifyDefaultClaudeModel } from "../utils/checkers";
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
@@ -41,7 +49,22 @@ const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
export function ExportMessageModal(props: { onClose: () => void }) {
return (
<div className="modal-mask">
<Modal title={Locale.Export.Title} onClose={props.onClose}>
<Modal
title={Locale.Export.Title}
onClose={props.onClose}
footer={
<div
style={{
width: "100%",
textAlign: "center",
fontSize: 14,
opacity: 0.5,
}}
>
{Locale.Exporter.Description.Title}
</div>
}
>
<div style={{ minHeight: "40vh" }}>
<MessageExporter />
</div>
@@ -149,7 +172,7 @@ export function MessageExporter() {
if (exportConfig.includeContext) {
ret.push(...session.mask.context);
}
ret.push(...session.messages.filter((m, i) => selection.has(m.id)));
ret.push(...session.messages.filter((m) => selection.has(m.id)));
return ret;
}, [
exportConfig.includeContext,
@@ -260,7 +283,8 @@ export function RenderExport(props: {
});
props.onRender(renderMsgs);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div ref={domRef}>
@@ -270,7 +294,7 @@ export function RenderExport(props: {
id={`${m.role}:${i}`}
className={EXPORT_MESSAGE_CLASS_NAME}
>
<Markdown content={m.content} defaultShow />
<Markdown content={getMessageTextContent(m)} defaultShow />
</div>
))}
</div>
@@ -285,10 +309,19 @@ export function PreviewActions(props: {
}) {
const [loading, setLoading] = useState(false);
const [shouldExport, setShouldExport] = useState(false);
const config = useAppConfig();
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);
}
api
.share(msgs)
.then((res) => {
@@ -437,13 +470,13 @@ export function ImagePreviewer(props: {
showToast(Locale.Export.Image.Toast);
const dom = previewRef.current;
if (!dom) return;
const isApp = getClientConfig()?.isApp;
try {
const blob = await toPng(dom);
if (!blob) return;
if (isMobile || (isApp && window.__TAURI__)) {
if (isApp && window.__TAURI__) {
const result = await window.__TAURI__.dialog.save({
@@ -459,7 +492,7 @@ export function ImagePreviewer(props: {
},
],
});
if (result !== null) {
const response = await fetch(blob);
const buffer = await response.arrayBuffer();
@@ -514,7 +547,7 @@ export function ImagePreviewer(props: {
</div>
<div>
<div className={styles["main-title"]}>ChatGPT Next Web</div>
<div className={styles["main-title"]}>NextChat</div>
<div className={styles["sub-title"]}>
github.com/Yidadaa/ChatGPT-Next-Web
</div>
@@ -556,10 +589,37 @@ export function ImagePreviewer(props: {
<div className={styles["body"]}>
<Markdown
content={m.content}
content={getMessageTextContent(m)}
fontSize={config.fontSize}
defaultShow
/>
{getMessageImages(m).length == 1 && (
<img
key={i}
src={getMessageImages(m)[0]}
alt="message"
className={styles["message-image"]}
/>
)}
{getMessageImages(m).length > 1 && (
<div
className={styles["message-images"]}
style={
{
"--image-count": getMessageImages(m).length,
} as React.CSSProperties
}
>
{getMessageImages(m).map((src, i) => (
<img
key={i}
src={src}
alt="message"
className={styles["message-image-multi"]}
/>
))}
</div>
)}
</div>
</div>
);
@@ -578,8 +638,10 @@ export function MarkdownPreviewer(props: {
props.messages
.map((m) => {
return m.role === "user"
? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
: `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
? `## ${Locale.Export.MessageFromYou}:\n${getMessageTextContent(m)}`
: `## ${Locale.Export.MessageFromChatGPT}:\n${getMessageTextContent(
m,
).trim()}`;
})
.join("\n\n");
@@ -604,8 +666,6 @@ export function MarkdownPreviewer(props: {
);
}
// modified by BackTrackZ now it's looks better
export function JsonPreviewer(props: {
messages: ChatMessage[];
topic: string;

View File

@@ -12,7 +12,7 @@ import LoadingIcon from "../icons/three-dots.svg";
import { getCSSVar, useMobileScreen } from "../utils";
import dynamic from "next/dynamic";
import { Path, SlotID } from "../constant";
import { ModelProvider, Path, SlotID } from "../constant";
import { ErrorBoundary } from "./error";
import { getISOLang, getLang } from "../locales";
@@ -27,8 +27,9 @@ import { SideBar } from "./sidebar";
import { useAppConfig } from "../store/config";
import { AuthPage } from "./auth";
import { getClientConfig } from "../config/client";
import { api } from "../client/api";
import { ClientApi } from "../client/api";
import { useAccessStore } from "../store";
import { identifyDefaultClaudeModel } from "../utils/checkers";
export function Loading(props: { noLogo?: boolean }) {
return (
@@ -128,7 +129,8 @@ function Screen() {
const isHome = location.pathname === Path.Home;
const isAuth = location.pathname === Path.Auth;
const isMobileScreen = useMobileScreen();
const shouldTightBorder = getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
const shouldTightBorder =
getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
useEffect(() => {
loadAsyncGoogleFont();
@@ -169,6 +171,14 @@ 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);
}
useEffect(() => {
(async () => {
const models = await api.llm.models();

View File

@@ -5,13 +5,13 @@ import RemarkBreaks from "remark-breaks";
import RehypeKatex from "rehype-katex";
import RemarkGfm from "remark-gfm";
import RehypeHighlight from "rehype-highlight";
import { useRef, useState, RefObject, useEffect } from "react";
import { useRef, useState, RefObject, useEffect, useMemo } from "react";
import { copyToClipboard } from "../utils";
import mermaid from "mermaid";
import LoadingIcon from "../icons/three-dots.svg";
import React from "react";
import { useDebouncedCallback, useThrottledCallback } from "use-debounce";
import { useDebouncedCallback } from "use-debounce";
import { showImageModal } from "./ui-lib";
export function Mermaid(props: { code: string }) {
@@ -99,7 +99,46 @@ export function PreCode(props: { children: any }) {
);
}
function escapeDollarNumber(text: string) {
let escapedText = "";
for (let i = 0; i < text.length; i += 1) {
let char = text[i];
const nextChar = text[i + 1] || " ";
if (char === "$" && nextChar >= "0" && nextChar <= "9") {
char = "\\$";
}
escapedText += char;
}
return escapedText;
}
function escapeBrackets(text: string) {
const pattern =
/(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
return text.replace(
pattern,
(match, codeBlock, squareBracket, roundBracket) => {
if (codeBlock) {
return codeBlock;
} else if (squareBracket) {
return `$$${squareBracket}$$`;
} else if (roundBracket) {
return `$${roundBracket}$`;
}
return match;
},
);
}
function _MarkDownContent(props: { content: string }) {
const escapedContent = useMemo(() => {
return escapeBrackets(escapeDollarNumber(props.content));
}, [props.content]);
return (
<ReactMarkdown
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
@@ -124,7 +163,7 @@ function _MarkDownContent(props: { content: string }) {
},
}}
>
{props.content}
{escapedContent}
</ReactMarkdown>
);
}
@@ -138,13 +177,14 @@ export function Markdown(
fontSize?: number;
parentRef?: RefObject<HTMLDivElement>;
defaultShow?: boolean;
className?: string;
} & React.DOMAttributes<HTMLDivElement>,
) {
const mdRef = useRef<HTMLDivElement>(null);
return (
<div
className="markdown-body"
className={`markdown-body ${props.className}`}
style={{
fontSize: `${props.fontSize ?? 14}px`,
}}

View File

@@ -4,6 +4,10 @@
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,5 +1,4 @@
import { IconButton } from "./button";
import { ErrorBoundary } from "./error";
import styles from "./mask.module.scss";
@@ -18,10 +17,11 @@ import {
ChatMessage,
createMessage,
ModelConfig,
ModelType,
useAppConfig,
useChatStore,
} from "../store";
import { ROLES } from "../client/api";
import { MultimodalContent, ROLES } from "../client/api";
import {
Input,
List,
@@ -37,7 +37,12 @@ import { useNavigate } from "react-router-dom";
import chatStyle from "./chat.module.scss";
import { useEffect, useState } from "react";
import { copyToClipboard, downloadAs, readFromFile } from "../utils";
import {
copyToClipboard,
downloadAs,
getMessageImages,
readFromFile,
} from "../utils";
import { Updater } from "../typing";
import { ModelConfigList } from "./model-config";
import { FileName, Path } from "../constant";
@@ -49,6 +54,8 @@ import {
Draggable,
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[] {
@@ -58,11 +65,11 @@ function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
return result;
}
export function MaskAvatar(props: { mask: Mask }) {
return props.mask.avatar !== DEFAULT_MASK_AVATAR ? (
<Avatar avatar={props.mask.avatar} />
export function MaskAvatar(props: { avatar: string; model?: ModelType }) {
return props.avatar !== DEFAULT_MASK_AVATAR ? (
<Avatar avatar={props.avatar} />
) : (
<Avatar model={props.mask.modelConfig.model} />
<Avatar model={props.model} />
);
}
@@ -123,7 +130,10 @@ export function MaskConfig(props: {
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
>
<MaskAvatar mask={props.mask} />
<MaskAvatar
avatar={props.mask.avatar}
model={props.mask.modelConfig.model}
/>
</div>
</Popover>
</ListItem>
@@ -240,7 +250,7 @@ function ContextPromptItem(props: {
</>
)}
<Input
value={props.prompt.content}
value={getMessageTextContent(props.prompt)}
type="text"
className={chatStyle["context-content"]}
rows={focusingInput ? 5 : 1}
@@ -285,7 +295,18 @@ export function ContextPrompts(props: {
};
const updateContextPrompt = (i: number, prompt: ChatMessage) => {
props.updateContext((context) => (context[i] = prompt));
props.updateContext((context) => {
const images = getMessageImages(context[i]);
context[i] = prompt;
if (images.length > 0) {
const text = getMessageTextContent(context[i]);
const newContext: MultimodalContent[] = [{ type: "text", text }];
for (const img of images) {
newContext.push({ type: "image_url", image_url: { url: img } });
}
context[i].content = newContext;
}
});
};
const onDragEnd: OnDragEndResponder = (result) => {
@@ -377,13 +398,22 @@ export function ContextPrompts(props: {
);
}
export function MaskPage() {
export function MaskPage(props: { className?: string }) {
const navigate = useNavigate();
const maskStore = useMaskStore();
const chatStore = useChatStore();
const [filterLang, setFilterLang] = useState<Lang>();
const [filterLang, setFilterLang] = useState<Lang | undefined>(
() => localStorage.getItem("Mask-language") as Lang | undefined,
);
useEffect(() => {
if (filterLang) {
localStorage.setItem("Mask-language", filterLang);
} else {
localStorage.removeItem("Mask-language");
}
}, [filterLang]);
const allMasks = maskStore
.getAll()
@@ -398,7 +428,7 @@ export function MaskPage() {
setSearchText(text);
if (text.length > 0) {
const result = allMasks.filter((m) =>
m.name.toLowerCase().includes(text.toLowerCase())
m.name.toLowerCase().includes(text.toLowerCase()),
);
setSearchMasks(result);
} else {
@@ -436,8 +466,13 @@ export function MaskPage() {
};
return (
<ErrorBoundary>
<div className={styles["mask-page"]}>
<>
<div
className={`
${styles["mask-page"]}
${props.className}
`}
>
<div className="window-header">
<div className="window-header-title">
<div className="window-header-main-title">
@@ -523,7 +558,7 @@ export function MaskPage() {
<div className={styles["mask-item"]} key={m.id}>
<div className={styles["mask-header"]}>
<div className={styles["mask-icon"]}>
<MaskAvatar mask={m} />
<MaskAvatar avatar={m.avatar} model={m.modelConfig.model} />
</div>
<div className={styles["mask-title"]}>
<div className={styles["mask-name"]}>{m.name}</div>
@@ -615,6 +650,6 @@ export function MaskPage() {
</Modal>
</div>
)}
</ErrorBoundary>
</>
);
}

View File

@@ -58,8 +58,8 @@
}
.body {
flex-grow: 1;
max-width: calc(100% - 40px);
flex: 1;
max-width: calc(100% - 80px);
.date {
font-size: 12px;
@@ -71,6 +71,12 @@
font-size: 12px;
}
}
.checkbox {
display: flex;
justify-content: flex-end;
flex: 1;
}
}
}
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { ChatMessage, useAppConfig, useChatStore } from "../store";
import { Updater } from "../typing";
import { IconButton } from "./button";
@@ -7,6 +7,7 @@ import { MaskAvatar } from "./mask";
import Locale from "../locales";
import styles from "./message-selector.module.scss";
import { getMessageTextContent } from "../utils";
function useShiftRange() {
const [startIndex, setStartIndex] = useState<number>();
@@ -73,11 +74,23 @@ export function MessageSelector(props: {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming;
const messages = session.messages.filter(
(m, i) =>
m.id && // message must have id
isValid(m) &&
(i >= session.messages.length - 1 || isValid(session.messages[i + 1])),
const allMessages = useMemo(() => {
let startIndex = Math.max(0, session.clearContextIndex ?? 0);
if (startIndex === session.messages.length - 1) {
startIndex = 0;
}
return session.messages.slice(startIndex);
}, [session.messages, session.clearContextIndex]);
const messages = useMemo(
() =>
allMessages.filter(
(m, i) =>
m.id && // message must have id
isValid(m) &&
(i >= allMessages.length - 1 || isValid(allMessages[i + 1])),
),
[allMessages],
);
const messageCount = messages.length;
const config = useAppConfig();
@@ -91,7 +104,9 @@ export function MessageSelector(props: {
const searchResults = new Set<string>();
if (text.length > 0) {
messages.forEach((m) =>
m.content.includes(text) ? searchResults.add(m.id!) : null,
getMessageTextContent(m).includes(text)
? searchResults.add(m.id!)
: null,
);
}
setSearchIds(searchResults);
@@ -176,6 +191,8 @@ export function MessageSelector(props: {
<div className={styles["messages"]}>
{messages.map((m, i) => {
if (!isInSearchResult(m.id!)) return null;
const id = m.id ?? i;
const isSelected = props.selection.has(id);
return (
<div
@@ -185,7 +202,6 @@ export function MessageSelector(props: {
key={i}
onClick={() => {
props.updateSelection((selection) => {
const id = m.id ?? i;
selection.has(id) ? selection.delete(id) : selection.add(id);
});
onClickIndex(i);
@@ -195,7 +211,10 @@ export function MessageSelector(props: {
{m.role === "user" ? (
<Avatar avatar={config.avatar}></Avatar>
) : (
<MaskAvatar mask={session.mask} />
<MaskAvatar
avatar={session.mask.avatar}
model={m.model || session.mask.modelConfig.model}
/>
)}
</div>
<div className={styles["body"]}>
@@ -203,9 +222,13 @@ export function MessageSelector(props: {
{new Date(m.date).toLocaleString()}
</div>
<div className={`${styles["content"]} one-line`}>
{m.content}
{getMessageTextContent(m)}
</div>
</div>
<div className={styles["checkbox"]}>
<input type="checkbox" checked={isSelected} readOnly></input>
</div>
</div>
);
})}

View File

@@ -1,14 +1,15 @@
import { ModalConfigValidator, ModelConfig, useAppConfig } from "../store";
import { ModalConfigValidator, ModelConfig } from "../store";
import Locale from "../locales";
import { InputRange } from "./input-range";
import { ListItem, Select } from "./ui-lib";
import { useAllModels } from "../utils/hooks";
export function ModelConfigList(props: {
modelConfig: ModelConfig;
updateConfig: (updater: (config: ModelConfig) => void) => void;
}) {
const config = useAppConfig();
const allModels = useAllModels();
return (
<>
@@ -24,11 +25,13 @@ export function ModelConfigList(props: {
);
}}
>
{config.allModels().map((v, i) => (
<option value={v.name} key={i} disabled={!v.available}>
{v.name}
</option>
))}
{allModels
.filter((v) => v.available)
.map((v, i) => (
<option value={v.name} key={i}>
{v.displayName}({v.provider?.providerName})
</option>
))}
</Select>
</ListItem>
<ListItem
@@ -75,8 +78,8 @@ export function ModelConfigList(props: {
>
<input
type="number"
min={100}
max={100000}
min={1024}
max={512000}
value={props.modelConfig.max_tokens}
onChange={(e) =>
props.updateConfig(
@@ -88,79 +91,84 @@ export function ModelConfigList(props: {
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.PresencePenalty.Title}
subTitle={Locale.Settings.PresencePenalty.SubTitle}
>
<InputRange
value={props.modelConfig.presence_penalty?.toFixed(1)}
min="-2"
max="2"
step="0.1"
onChange={(e) => {
props.updateConfig(
(config) =>
(config.presence_penalty =
ModalConfigValidator.presence_penalty(
e.currentTarget.valueAsNumber,
)),
);
}}
></InputRange>
</ListItem>
<ListItem
title={Locale.Settings.FrequencyPenalty.Title}
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
>
<InputRange
value={props.modelConfig.frequency_penalty?.toFixed(1)}
min="-2"
max="2"
step="0.1"
onChange={(e) => {
props.updateConfig(
(config) =>
(config.frequency_penalty =
ModalConfigValidator.frequency_penalty(
e.currentTarget.valueAsNumber,
)),
);
}}
></InputRange>
</ListItem>
{props.modelConfig.model.startsWith("gemini") ? null : (
<>
<ListItem
title={Locale.Settings.PresencePenalty.Title}
subTitle={Locale.Settings.PresencePenalty.SubTitle}
>
<InputRange
value={props.modelConfig.presence_penalty?.toFixed(1)}
min="-2"
max="2"
step="0.1"
onChange={(e) => {
props.updateConfig(
(config) =>
(config.presence_penalty =
ModalConfigValidator.presence_penalty(
e.currentTarget.valueAsNumber,
)),
);
}}
></InputRange>
</ListItem>
<ListItem
title={Locale.Settings.InjectSystemPrompts.Title}
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
>
<input
type="checkbox"
checked={props.modelConfig.enableInjectSystemPrompts}
onChange={(e) =>
props.updateConfig(
(config) =>
(config.enableInjectSystemPrompts = e.currentTarget.checked),
)
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.FrequencyPenalty.Title}
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
>
<InputRange
value={props.modelConfig.frequency_penalty?.toFixed(1)}
min="-2"
max="2"
step="0.1"
onChange={(e) => {
props.updateConfig(
(config) =>
(config.frequency_penalty =
ModalConfigValidator.frequency_penalty(
e.currentTarget.valueAsNumber,
)),
);
}}
></InputRange>
</ListItem>
<ListItem
title={Locale.Settings.InputTemplate.Title}
subTitle={Locale.Settings.InputTemplate.SubTitle}
>
<input
type="text"
value={props.modelConfig.template}
onChange={(e) =>
props.updateConfig(
(config) => (config.template = e.currentTarget.value),
)
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.InjectSystemPrompts.Title}
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
>
<input
type="checkbox"
checked={props.modelConfig.enableInjectSystemPrompts}
onChange={(e) =>
props.updateConfig(
(config) =>
(config.enableInjectSystemPrompts =
e.currentTarget.checked),
)
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.InputTemplate.Title}
subTitle={Locale.Settings.InputTemplate.SubTitle}
>
<input
type="text"
value={props.modelConfig.template}
onChange={(e) =>
props.updateConfig(
(config) => (config.template = e.currentTarget.value),
)
}
></input>
</ListItem>
</>
)}
<ListItem
title={Locale.Settings.HistoryCount.Title}
subTitle={Locale.Settings.HistoryCount.SubTitle}

View File

@@ -8,6 +8,10 @@
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,22 +16,15 @@ import { MaskAvatar } from "./mask";
import { useCommand } from "../command";
import { showConfirm } from "./ui-lib";
import { BUILTIN_MASK_STORE } from "../masks";
function getIntersectionArea(aRect: DOMRect, bRect: DOMRect) {
const xmin = Math.max(aRect.x, bRect.x);
const xmax = Math.min(aRect.x + aRect.width, bRect.x + bRect.width);
const ymin = Math.max(aRect.y, bRect.y);
const ymax = Math.min(aRect.y + aRect.height, bRect.y + bRect.height);
const width = xmax - xmin;
const height = ymax - ymin;
const intersectionArea = width < 0 || height < 0 ? 0 : width * height;
return intersectionArea;
}
import useMobileScreen from "@/app/hooks/useMobileScreen";
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
return (
<div className={styles["mask"]} onClick={props.onClick}>
<MaskAvatar mask={props.mask} />
<MaskAvatar
avatar={props.mask.avatar}
model={props.mask.modelConfig.model}
/>
<div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
</div>
);
@@ -79,7 +72,7 @@ function useMaskGroup(masks: Mask[]) {
return groups;
}
export function NewChat() {
export function NewChat(props: { className?: string }) {
const chatStore = useChatStore();
const maskStore = useMaskStore();
@@ -118,8 +111,15 @@ export function NewChat() {
}
}, [groups]);
const isMobileScreen = useMobileScreen();
return (
<div className={styles["new-chat"]}>
<div
className={`
${styles["new-chat"]}
${props.className}
`}
>
<div className={styles["mask-header"]}>
<IconButton
icon={<LeftIcon />}

View File

@@ -5,6 +5,8 @@
.avatar {
cursor: pointer;
position: relative;
z-index: 1;
}
.edit-prompt-modal {

View File

@@ -51,10 +51,15 @@ import Locale, {
import { copyToClipboard } from "../utils";
import Link from "next/link";
import {
Anthropic,
Azure,
Google,
OPENAI_BASE_URL,
Path,
RELEASE_URL,
STORAGE_KEY,
ServiceProvider,
SlotID,
UPDATE_URL,
} from "../constant";
import { Prompt, SearchService, usePromptStore } from "../store/prompt";
@@ -264,7 +269,7 @@ function CheckButton() {
const syncStore = useSyncStore();
const couldCheck = useMemo(() => {
return syncStore.coundSync();
return syncStore.cloudSync();
}, [syncStore]);
const [checkState, setCheckState] = useState<
@@ -468,7 +473,7 @@ function SyncItems() {
const promptStore = usePromptStore();
const maskStore = useMaskStore();
const couldSync = useMemo(() => {
return syncStore.coundSync();
return syncStore.cloudSync();
}, [syncStore]);
const [showSyncConfigModal, setShowSyncConfigModal] = useState(false);
@@ -580,8 +585,17 @@ export function Settings() {
const accessStore = useAccessStore();
const shouldHideBalanceQuery = useMemo(() => {
const isOpenAiUrl = accessStore.openaiUrl.includes(OPENAI_BASE_URL);
return accessStore.hideBalanceQuery || isOpenAiUrl;
}, [accessStore.hideBalanceQuery, accessStore.openaiUrl]);
return (
accessStore.hideBalanceQuery ||
isOpenAiUrl ||
accessStore.provider === ServiceProvider.Azure
);
}, [
accessStore.hideBalanceQuery,
accessStore.openaiUrl,
accessStore.provider,
]);
const usage = {
used: updateStore.used,
@@ -624,6 +638,12 @@ export function Settings() {
navigate(Path.Home);
}
};
if (clientConfig?.isApp) {
// Force to set custom endpoint to true if it's app
accessStore.update((state) => {
state.useCustomConfig = true;
});
}
document.addEventListener("keydown", keydownEvent);
return () => {
document.removeEventListener("keydown", keydownEvent);
@@ -674,7 +694,9 @@ export function Settings() {
>
<div
className={styles.avatar}
onClick={() => setShowEmojiPicker(true)}
onClick={() => {
setShowEmojiPicker(!showEmojiPicker);
}}
>
<Avatar avatar={config.avatar} />
</div>
@@ -877,16 +899,16 @@ export function Settings() {
</ListItem>
</List>
<List>
{showAccessCode ? (
<List id={SlotID.CustomModel}>
{showAccessCode && (
<ListItem
title={Locale.Settings.AccessCode.Title}
subTitle={Locale.Settings.AccessCode.SubTitle}
title={Locale.Settings.Access.AccessCode.Title}
subTitle={Locale.Settings.Access.AccessCode.SubTitle}
>
<PasswordInput
value={accessStore.accessCode}
type="text"
placeholder={Locale.Settings.AccessCode.Placeholder}
placeholder={Locale.Settings.Access.AccessCode.Placeholder}
onChange={(e) => {
accessStore.update(
(access) => (access.accessCode = e.currentTarget.value),
@@ -894,46 +916,283 @@ export function Settings() {
}}
/>
</ListItem>
) : (
<></>
)}
{!accessStore.hideUserApiKey ? (
{!accessStore.hideUserApiKey && (
<>
<ListItem
title={Locale.Settings.Endpoint.Title}
subTitle={Locale.Settings.Endpoint.SubTitle}
>
<input
type="text"
value={accessStore.openaiUrl}
placeholder="https://api.openai.com/"
onChange={(e) =>
accessStore.update(
(access) => (access.openaiUrl = e.currentTarget.value),
)
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.Token.Title}
subTitle={Locale.Settings.Token.SubTitle}
>
<PasswordInput
value={accessStore.token}
type="text"
placeholder={Locale.Settings.Token.Placeholder}
onChange={(e) => {
accessStore.update(
(access) => (access.token = e.currentTarget.value),
);
}}
/>
</ListItem>
</>
) : null}
{
// Conditionally render the following ListItem based on clientConfig.isApp
!clientConfig?.isApp && ( // only show if isApp is false
<ListItem
title={Locale.Settings.Access.CustomEndpoint.Title}
subTitle={Locale.Settings.Access.CustomEndpoint.SubTitle}
>
<input
type="checkbox"
checked={accessStore.useCustomConfig}
onChange={(e) =>
accessStore.update(
(access) =>
(access.useCustomConfig = e.currentTarget.checked),
)
}
></input>
</ListItem>
)
}
{accessStore.useCustomConfig && (
<>
<ListItem
title={Locale.Settings.Access.Provider.Title}
subTitle={Locale.Settings.Access.Provider.SubTitle}
>
<Select
value={accessStore.provider}
onChange={(e) => {
accessStore.update(
(access) =>
(access.provider = e.target
.value as ServiceProvider),
);
}}
>
{Object.entries(ServiceProvider).map(([k, v]) => (
<option value={v} key={k}>
{k}
</option>
))}
</Select>
</ListItem>
{!shouldHideBalanceQuery ? (
{accessStore.provider === ServiceProvider.OpenAI && (
<>
<ListItem
title={Locale.Settings.Access.OpenAI.Endpoint.Title}
subTitle={
Locale.Settings.Access.OpenAI.Endpoint.SubTitle
}
>
<input
type="text"
value={accessStore.openaiUrl}
placeholder={OPENAI_BASE_URL}
onChange={(e) =>
accessStore.update(
(access) =>
(access.openaiUrl = e.currentTarget.value),
)
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.Access.OpenAI.ApiKey.Title}
subTitle={Locale.Settings.Access.OpenAI.ApiKey.SubTitle}
>
<PasswordInput
value={accessStore.openaiApiKey}
type="text"
placeholder={
Locale.Settings.Access.OpenAI.ApiKey.Placeholder
}
onChange={(e) => {
accessStore.update(
(access) =>
(access.openaiApiKey = e.currentTarget.value),
);
}}
/>
</ListItem>
</>
)}
{accessStore.provider === ServiceProvider.Azure && (
<>
<ListItem
title={Locale.Settings.Access.Azure.Endpoint.Title}
subTitle={
Locale.Settings.Access.Azure.Endpoint.SubTitle +
Azure.ExampleEndpoint
}
>
<input
type="text"
value={accessStore.azureUrl}
placeholder={Azure.ExampleEndpoint}
onChange={(e) =>
accessStore.update(
(access) =>
(access.azureUrl = e.currentTarget.value),
)
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.Access.Azure.ApiKey.Title}
subTitle={Locale.Settings.Access.Azure.ApiKey.SubTitle}
>
<PasswordInput
value={accessStore.azureApiKey}
type="text"
placeholder={
Locale.Settings.Access.Azure.ApiKey.Placeholder
}
onChange={(e) => {
accessStore.update(
(access) =>
(access.azureApiKey = e.currentTarget.value),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Azure.ApiVerion.Title}
subTitle={
Locale.Settings.Access.Azure.ApiVerion.SubTitle
}
>
<input
type="text"
value={accessStore.azureApiVersion}
placeholder="2023-08-01-preview"
onChange={(e) =>
accessStore.update(
(access) =>
(access.azureApiVersion =
e.currentTarget.value),
)
}
></input>
</ListItem>
</>
)}
{accessStore.provider === ServiceProvider.Google && (
<>
<ListItem
title={Locale.Settings.Access.Google.Endpoint.Title}
subTitle={
Locale.Settings.Access.Google.Endpoint.SubTitle +
Google.ExampleEndpoint
}
>
<input
type="text"
value={accessStore.googleUrl}
placeholder={Google.ExampleEndpoint}
onChange={(e) =>
accessStore.update(
(access) =>
(access.googleUrl = e.currentTarget.value),
)
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.Access.Google.ApiKey.Title}
subTitle={Locale.Settings.Access.Google.ApiKey.SubTitle}
>
<PasswordInput
value={accessStore.googleApiKey}
type="text"
placeholder={
Locale.Settings.Access.Google.ApiKey.Placeholder
}
onChange={(e) => {
accessStore.update(
(access) =>
(access.googleApiKey = e.currentTarget.value),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Google.ApiVersion.Title}
subTitle={
Locale.Settings.Access.Google.ApiVersion.SubTitle
}
>
<input
type="text"
value={accessStore.googleApiVersion}
placeholder="2023-08-01-preview"
onChange={(e) =>
accessStore.update(
(access) =>
(access.googleApiVersion =
e.currentTarget.value),
)
}
></input>
</ListItem>
</>
)}
{accessStore.provider === ServiceProvider.Anthropic && (
<>
<ListItem
title={Locale.Settings.Access.Anthropic.Endpoint.Title}
subTitle={
Locale.Settings.Access.Anthropic.Endpoint.SubTitle +
Anthropic.ExampleEndpoint
}
>
<input
type="text"
value={accessStore.anthropicUrl}
placeholder={Anthropic.ExampleEndpoint}
onChange={(e) =>
accessStore.update(
(access) =>
(access.anthropicUrl = e.currentTarget.value),
)
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.Access.Anthropic.ApiKey.Title}
subTitle={
Locale.Settings.Access.Anthropic.ApiKey.SubTitle
}
>
<PasswordInput
value={accessStore.anthropicApiKey}
type="text"
placeholder={
Locale.Settings.Access.Anthropic.ApiKey.Placeholder
}
onChange={(e) => {
accessStore.update(
(access) =>
(access.anthropicApiKey =
e.currentTarget.value),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Anthropic.ApiVerion.Title}
subTitle={
Locale.Settings.Access.Anthropic.ApiVerion.SubTitle
}
>
<input
type="text"
value={accessStore.anthropicApiVersion}
placeholder={Anthropic.Vision}
onChange={(e) =>
accessStore.update(
(access) =>
(access.anthropicApiVersion =
e.currentTarget.value),
)
}
></input>
</ListItem>
</>
)}
</>
)}
</>
)}
{!shouldHideBalanceQuery && !clientConfig?.isApp ? (
<ListItem
title={Locale.Settings.Usage.Title}
subTitle={
@@ -960,8 +1219,8 @@ export function Settings() {
) : null}
<ListItem
title={Locale.Settings.CustomModel.Title}
subTitle={Locale.Settings.CustomModel.SubTitle}
title={Locale.Settings.Access.CustomModel.Title}
subTitle={Locale.Settings.Access.CustomModel.SubTitle}
>
<input
type="text"

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback, useMemo } from "react";
import { useEffect, useRef, useMemo } from "react";
import styles from "./home.module.scss";
@@ -8,6 +8,7 @@ import GithubIcon from "../icons/github.svg";
import ChatGptIcon from "../icons/chatgpt.svg";
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";
@@ -154,7 +155,7 @@ export function SideBar(props: { className?: string }) {
>
<div className={styles["sidebar-header"]} data-tauri-drag-region>
<div className={styles["sidebar-title"]} data-tauri-drag-region>
ChatGPT Next
NextChat
</div>
<div className={styles["sidebar-sub-title"]}>
Build your own AI assistant.
@@ -202,7 +203,7 @@ export function SideBar(props: { className?: string }) {
<div className={styles["sidebar-actions"]}>
<div className={styles["sidebar-action"] + " " + styles.mobile}>
<IconButton
icon={<CloseIcon />}
icon={<DeleteIcon />}
onClick={async () => {
if (await showConfirm(Locale.Home.DeleteChat)) {
chatStore.deleteSession(chatStore.currentSessionIndex);

View File

@@ -14,17 +14,24 @@
.popover-content {
position: absolute;
width: 350px;
animation: slide-in 0.3s ease;
right: 0;
top: calc(100% + 10px);
}
@media screen and (max-width: 600px) {
.popover-content {
width: auto;
}
}
.popover-mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(5px);
}
.list-item {
@@ -235,7 +242,7 @@
.select-with-icon-select {
height: 100%;
border: var(--border-in-light);
padding: 10px 25px 10px 10px;
padding: 10px 35px 10px 10px;
border-radius: 10px;
appearance: none;
cursor: pointer;

View File

@@ -26,10 +26,10 @@ export function Popover(props: {
<div className={styles.popover}>
{props.children}
{props.open && (
<div className={styles["popover-content"]}>
<div className={styles["popover-mask"]} onClick={props.onClose}></div>
{props.content}
</div>
<div className={styles["popover-mask"]} onClick={props.onClose}></div>
)}
{props.open && (
<div className={styles["popover-content"]}>{props.content}</div>
)}
</div>
);
@@ -70,14 +70,12 @@ export function ListItem(props: {
);
}
export function List(props: {
children:
| Array<JSX.Element | null | undefined>
| JSX.Element
| null
| undefined;
}) {
return <div className={styles.list}>{props.children}</div>;
export function List(props: { children: React.ReactNode; id?: string }) {
return (
<div className={styles.list} id={props.id}>
{props.children}
</div>
);
}
export function Loading() {
@@ -99,9 +97,11 @@ export function Loading() {
interface ModalProps {
title: string;
children?: any;
actions?: JSX.Element[];
actions?: React.ReactNode[];
defaultMax?: boolean;
footer?: React.ReactNode;
onClose?: () => void;
className?: string;
}
export function Modal(props: ModalProps) {
useEffect(() => {
@@ -123,14 +123,14 @@ export function Modal(props: ModalProps) {
return (
<div
className={
styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
}
className={`${styles["modal-container"]} ${
isMax && styles["modal-container-max"]
} ${props.className ?? ""}`}
>
<div className={styles["modal-header"]}>
<div className={styles["modal-title"]}>{props.title}</div>
<div className={`${styles["modal-header"]} new-header follow-parent-svg`}>
<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,10 +148,11 @@ export function Modal(props: ModalProps) {
<div className={styles["modal-content"]}>{props.children}</div>
<div className={styles["modal-footer"]}>
<div className={`${styles["modal-footer"]} new-footer`}>
{props.footer}
<div className={styles["modal-actions"]}>
{props.actions?.map((action, i) => (
<div key={i} className={styles["modal-action"]}>
<div key={i} className={`${styles["modal-action"]} new-btn`}>
{action}
</div>
))}

View File

@@ -1,19 +1,39 @@
import md5 from "spark-md5";
import { DEFAULT_MODELS } from "../constant";
declare global {
namespace NodeJS {
interface ProcessEnv {
PROXY_URL?: string; // docker only
OPENAI_API_KEY?: string;
CODE?: string;
BASE_URL?: string;
PROXY_URL?: string;
OPENAI_ORG_ID?: string; // openai only
VERCEL?: string;
HIDE_USER_API_KEY?: string; // disable user's api key input
DISABLE_GPT4?: string; // allow user to use gpt-4 or not
BUILD_MODE?: "standalone" | "export";
BUILD_APP?: string; // is building desktop app
HIDE_USER_API_KEY?: string; // disable user's api key input
DISABLE_GPT4?: string; // allow user to use gpt-4 or not
ENABLE_BALANCE_QUERY?: string; // allow user to query balance or not
DISABLE_FAST_LINK?: string; // disallow parse settings from url or not
CUSTOM_MODELS?: string; // to control custom models
DEFAULT_MODEL?: string; // to cnntrol default model in every new chat window
// azure only
AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
AZURE_API_KEY?: string;
AZURE_API_VERSION?: string;
// google only
GOOGLE_API_KEY?: string;
GOOGLE_URL?: string;
// google tag manager
GTM_ID?: string;
}
}
}
@@ -38,17 +58,68 @@ export const getServerSideConfig = () => {
);
}
const disableGPT4 = !!process.env.DISABLE_GPT4;
let customModels = process.env.CUSTOM_MODELS ?? "";
let defaultModel = process.env.DEFAULT_MODEL ?? "";
if (disableGPT4) {
if (customModels) customModels += ",";
customModels += DEFAULT_MODELS.filter((m) => m.name.startsWith("gpt-4"))
.map((m) => "-" + m.name)
.join(",");
if (defaultModel.startsWith("gpt-4")) defaultModel = "";
}
const isAzure = !!process.env.AZURE_URL;
const isGoogle = !!process.env.GOOGLE_API_KEY;
const isAnthropic = !!process.env.ANTHROPIC_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(
",",
);
return {
apiKey: process.env.OPENAI_API_KEY,
baseUrl: process.env.BASE_URL,
apiKey,
openaiOrgId: process.env.OPENAI_ORG_ID,
isAzure,
azureUrl: process.env.AZURE_URL,
azureApiKey: process.env.AZURE_API_KEY,
azureApiVersion: process.env.AZURE_API_VERSION,
isGoogle,
googleApiKey: process.env.GOOGLE_API_KEY,
googleUrl: process.env.GOOGLE_URL,
isAnthropic,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
anthropicUrl: process.env.ANTHROPIC_URL,
gtmId: process.env.GTM_ID,
needCode: ACCESS_CODES.size > 0,
code: process.env.CODE,
codes: ACCESS_CODES,
needCode: ACCESS_CODES.size > 0,
baseUrl: process.env.BASE_URL,
proxyUrl: process.env.PROXY_URL,
isVercel: !!process.env.VERCEL,
hideUserApiKey: !!process.env.HIDE_USER_API_KEY,
disableGPT4: !!process.env.DISABLE_GPT4,
disableGPT4,
hideBalanceQuery: !process.env.ENABLE_BALANCE_QUERY,
disableFastLink: !!process.env.DISABLE_FAST_LINK,
customModels,
defaultModel,
whiteWebDevEndpoints,
};
};

View File

@@ -8,9 +8,11 @@ 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 DEFAULT_CORS_HOST = "https://ab.nextweb.fun";
export const DEFAULT_API_HOST = `${DEFAULT_CORS_HOST}/api/proxy`;
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 enum Path {
Home = "/",
@@ -22,11 +24,14 @@ export enum Path {
}
export enum ApiPath {
Cors = "/api/cors",
Cors = "",
OpenAI = "/api/openai",
Anthropic = "/api/anthropic",
}
export enum SlotID {
AppBody = "app-body",
CustomModel = "custom-model",
}
export enum FileName {
@@ -44,11 +49,18 @@ export enum StoreKey {
Sync = "sync",
}
export const DEFAULT_SIDEBAR_WIDTH = 300;
export const MAX_SIDEBAR_WIDTH = 500;
export const MIN_SIDEBAR_WIDTH = 230;
export const NARROW_SIDEBAR_WIDTH = 100;
export const DEFAULT_SIDEBAR_WIDTH = 340;
export const MAX_SIDEBAR_WIDTH = 440;
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 ACCESS_CODE_PREFIX = "nk-";
export const LAST_INPUT_KEY = "last-input";
@@ -60,6 +72,26 @@ export const REQUEST_TIMEOUT_MS = 60000;
export const EXPORT_MESSAGE_CLASS_NAME = "export-markdown";
export enum ServiceProvider {
OpenAI = "OpenAI",
Azure = "Azure",
Google = "Google",
Anthropic = "Anthropic",
}
export enum ModelProvider {
GPT = "GPT",
GeminiPro = "GeminiPro",
Claude = "Claude",
}
export const Anthropic = {
ChatPath: "v1/messages",
ChatPath1: "v1/complete",
ExampleEndpoint: "https://api.anthropic.com",
Vision: "2023-06-01",
};
export const OpenaiPath = {
ChatPath: "v1/chat/completions",
UsagePath: "dashboard/billing/usage",
@@ -67,81 +99,131 @@ export const OpenaiPath = {
ListModelPath: "v1/models",
};
export const Azure = {
ExampleEndpoint: "https://{resource-url}/openai/deployments/{deploy-id}",
};
export const Google = {
ExampleEndpoint: "https://generativelanguage.googleapis.com/",
ChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
VisionChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
};
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
// export const DEFAULT_SYSTEM_TEMPLATE = `
// You are ChatGPT, a large language model trained by {{ServiceProvider}}.
// Knowledge cutoff: {{cutoff}}
// Current model: {{model}}
// Current time: {{time}}
// Latex inline: $x^2$
// Latex block: $$e=mc^2$$
// `;
export const DEFAULT_SYSTEM_TEMPLATE = `
You are ChatGPT, a large language model trained by OpenAI.
You are ChatGPT, a large language model trained by {{ServiceProvider}}.
Knowledge cutoff: {{cutoff}}
Current model: {{model}}
Current time: {{time}}
Latex inline: \\(x^2\\)
Latex block: $$e=mc^2$$
`;
export const SUMMARIZE_MODEL = "gpt-3.5-turbo";
export const GEMINI_SUMMARIZE_MODEL = "gemini-pro";
export const KnowledgeCutOffDate: Record<string, string> = {
default: "2021-09",
"gpt-3.5-turbo-1106": "2023-04",
"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-4-vision-preview": "2023-04",
// After improvements,
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
"gemini-pro": "2023-12",
"gemini-pro-vision": "2023-12",
};
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-4-vision-preview",
"gpt-4-turbo-2024-04-09",
];
const googleModels = [
"gemini-1.0-pro",
"gemini-1.5-pro-latest",
"gemini-pro-vision",
];
const anthropicModels = [
"claude-instant-1.2",
"claude-2.0",
"claude-2.1",
"claude-3-sonnet-20240229",
"claude-3-opus-20240229",
"claude-3-haiku-20240307",
];
export const DEFAULT_MODELS = [
{
name: "gpt-4",
...openaiModels.map((name) => ({
name,
available: true,
},
{
name: "gpt-4-0314",
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
})),
...googleModels.map((name) => ({
name,
available: true,
},
{
name: "gpt-4-0613",
provider: {
id: "google",
providerName: "Google",
providerType: "google",
},
})),
...anthropicModels.map((name) => ({
name,
available: true,
},
{
name: "gpt-4-32k",
available: true,
},
{
name: "gpt-4-32k-0314",
available: true,
},
{
name: "gpt-4-32k-0613",
available: true,
},
{
name: "gpt-4-1106-preview",
available: true,
},
{
name: "gpt-4-vision-preview",
available: true,
},
{
name: "gpt-3.5-turbo",
available: true,
},
{
name: "gpt-3.5-turbo-0301",
available: true,
},
{
name: "gpt-3.5-turbo-0613",
available: true,
},
{
name: "gpt-3.5-turbo-1106",
available: true,
},
{
name: "gpt-3.5-turbo-16k",
available: true,
},
{
name: "gpt-3.5-turbo-16k-0613",
available: true,
},
provider: {
id: "anthropic",
providerName: "Anthropic",
providerType: "anthropic",
},
})),
] as const;
export const CHAT_PAGE_SIZE = 15;
export const MAX_RENDER_MSG_COUNT = 45;
// some famous webdav endpoints
export const internalWhiteWebDavEndpoints = [
"https://dav.jianguoyun.com/dav/",
"https://dav.dropdav.com/",
"https://dav.box.com/dav",
"https://nanao.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";

View File

@@ -0,0 +1,301 @@
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

@@ -0,0 +1,277 @@
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

@@ -0,0 +1,91 @@
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

@@ -0,0 +1,322 @@
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

@@ -0,0 +1,246 @@
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

@@ -0,0 +1,46 @@
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>
);
}

View File

@@ -0,0 +1,75 @@
import { useState } from "react";
import { useChatStore } from "@/app/store/chat";
import { List, ListItem, Modal } from "@/app/components/ui-lib";
import Locale from "@/app/locales";
import { IconButton } from "@/app/components/button";
import { ContextPrompts } from "@/app/components/mask";
import CancelIcon from "@/app/icons/cancel.svg";
import ConfirmIcon from "@/app/icons/confirm.svg";
import Input from "@/app/components/Input";
export function EditMessageModal(props: { onClose: () => void }) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const [messages, setMessages] = useState(session.messages.slice());
return (
<div className="modal-mask">
<Modal
title={Locale.Chat.EditMessage.Title}
onClose={props.onClose}
actions={[
<IconButton
text={Locale.UI.Cancel}
icon={<CancelIcon />}
key="cancel"
onClick={() => {
props.onClose();
}}
/>,
<IconButton
type="primary"
text={Locale.UI.Confirm}
icon={<ConfirmIcon />}
key="ok"
onClick={() => {
chatStore.updateCurrentSession(
(session) => (session.messages = messages),
);
props.onClose();
}}
/>,
]}
// className="!bg-modal-mask"
>
<List>
<ListItem
title={Locale.Chat.EditMessage.Topic.Title}
subTitle={Locale.Chat.EditMessage.Topic.SubTitle}
>
<Input
type="text"
value={session.topic}
onChange={(e) =>
chatStore.updateCurrentSession(
(session) => (session.topic = e || ""),
)
}
className=" text-center"
></Input>
</ListItem>
</List>
<ContextPrompts
context={messages}
updateContext={(updater) => {
const newMessages = messages.slice();
updater(newMessages);
setMessages(newMessages);
}}
/>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,295 @@
import Locale from "@/app/locales";
import StopIcon from "@/app/icons/pause.svg";
import DeleteRequestIcon from "@/app/icons/deleteRequestIcon.svg";
import RetryRequestIcon from "@/app/icons/retryRequestIcon.svg";
import CopyRequestIcon from "@/app/icons/copyRequestIcon.svg";
import EditRequestIcon from "@/app/icons/editRequestIcon.svg";
import PinRequestIcon from "@/app/icons/pinRequestIcon.svg";
import { showPrompt, showToast } from "@/app/components/ui-lib";
import {
copyToClipboard,
getMessageImages,
getMessageTextContent,
} from "@/app/utils";
import { MultimodalContent } from "@/app/client/api";
import { ChatMessage, useChatStore } from "@/app/store/chat";
import ActionsBar from "@/app/components/ActionsBar";
import { ChatControllerPool } from "@/app/client/controller";
import { RefObject } from "react";
export type RenderMessage = ChatMessage & { preview?: boolean };
export interface MessageActionsProps {
message: RenderMessage;
isUser: boolean;
isContext: boolean;
showActions?: boolean;
inputRef: RefObject<HTMLTextAreaElement>;
className?: string;
setIsLoading?: (value: boolean) => void;
setShowPromptModal?: (value: boolean) => void;
}
const genActionsShema = (
message: RenderMessage,
{
onEdit,
onCopy,
onPinMessage,
onDelete,
onResend,
onUserStop,
}: Record<
| "onEdit"
| "onCopy"
| "onPinMessage"
| "onDelete"
| "onResend"
| "onUserStop",
(message: RenderMessage) => void
>,
) => {
const className =
" !p-1 hover:bg-chat-message-actions-btn-hovered !rounded-actions-bar-btn ";
return [
{
id: "Edit",
icons: <EditRequestIcon />,
title: "Edit",
className,
onClick: () => onEdit(message),
},
{
id: Locale.Chat.Actions.Copy,
icons: <CopyRequestIcon />,
title: Locale.Chat.Actions.Copy,
className,
onClick: () => onCopy(message),
},
{
id: Locale.Chat.Actions.Pin,
icons: <PinRequestIcon />,
title: Locale.Chat.Actions.Pin,
className,
onClick: () => onPinMessage(message),
},
{
id: Locale.Chat.Actions.Delete,
icons: <DeleteRequestIcon />,
title: Locale.Chat.Actions.Delete,
className,
onClick: () => onDelete(message),
},
{
id: Locale.Chat.Actions.Retry,
icons: <RetryRequestIcon />,
title: Locale.Chat.Actions.Retry,
className,
onClick: () => onResend(message),
},
{
id: Locale.Chat.Actions.Stop,
icons: <StopIcon />,
title: Locale.Chat.Actions.Stop,
className,
onClick: () => onUserStop(message),
},
];
};
enum GroupType {
"streaming" = "streaming",
"isContext" = "isContext",
"normal" = "normal",
}
const groupsTypes = {
[GroupType.streaming]: [[Locale.Chat.Actions.Stop]],
[GroupType.isContext]: [["Edit"]],
[GroupType.normal]: [
[
Locale.Chat.Actions.Retry,
"Edit",
Locale.Chat.Actions.Copy,
Locale.Chat.Actions.Pin,
Locale.Chat.Actions.Delete,
],
],
};
export default function MessageActions(props: MessageActionsProps) {
const {
className,
message,
isUser,
isContext,
showActions = true,
setIsLoading,
inputRef,
setShowPromptModal,
} = props;
const chatStore = useChatStore();
const session = chatStore.currentSession();
const deleteMessage = (msgId?: string) => {
chatStore.updateCurrentSession(
(session) =>
(session.messages = session.messages.filter((m) => m.id !== msgId)),
);
};
const onDelete = (message: ChatMessage) => {
deleteMessage(message.id);
};
const onResend = (message: ChatMessage) => {
// when it is resending a message
// 1. for a user's message, find the next bot response
// 2. for a bot's message, find the last user's input
// 3. delete original user input and bot's message
// 4. resend the user's input
const resendingIndex = session.messages.findIndex(
(m) => m.id === message.id,
);
if (resendingIndex < 0 || resendingIndex >= session.messages.length) {
console.error("[Chat] failed to find resending message", message);
return;
}
let userMessage: ChatMessage | undefined;
let botMessage: ChatMessage | undefined;
if (message.role === "assistant") {
// if it is resending a bot's message, find the user input for it
botMessage = message;
for (let i = resendingIndex; i >= 0; i -= 1) {
if (session.messages[i].role === "user") {
userMessage = session.messages[i];
break;
}
}
} else if (message.role === "user") {
// if it is resending a user's input, find the bot's response
userMessage = message;
for (let i = resendingIndex; i < session.messages.length; i += 1) {
if (session.messages[i].role === "assistant") {
botMessage = session.messages[i];
break;
}
}
}
if (userMessage === undefined) {
console.error("[Chat] failed to resend", message);
return;
}
// delete the original messages
deleteMessage(userMessage.id);
deleteMessage(botMessage?.id);
// resend the message
setIsLoading?.(true);
const textContent = getMessageTextContent(userMessage);
const images = getMessageImages(userMessage);
chatStore
.onUserInput(textContent, images)
.then(() => setIsLoading?.(false));
inputRef.current?.focus();
};
const onPinMessage = (message: ChatMessage) => {
chatStore.updateCurrentSession((session) =>
session.mask.context.push(message),
);
showToast(Locale.Chat.Actions.PinToastContent, {
text: Locale.Chat.Actions.PinToastAction,
onClick: () => {
setShowPromptModal?.(true);
},
});
};
// stop response
const onUserStop = (message: ChatMessage) => {
ChatControllerPool.stop(session.id, message.id);
};
const onEdit = async () => {
const newMessage = await showPrompt(
Locale.Chat.Actions.Edit,
getMessageTextContent(message),
10,
);
let newContent: string | MultimodalContent[] = newMessage;
const images = getMessageImages(message);
if (images.length > 0) {
newContent = [{ type: "text", text: newMessage }];
for (let i = 0; i < images.length; i++) {
newContent.push({
type: "image_url",
image_url: {
url: images[i],
},
});
}
}
chatStore.updateCurrentSession((session) => {
const m = session.mask.context
.concat(session.messages)
.find((m) => m.id === message.id);
if (m) {
m.content = newContent;
}
});
};
const onCopy = () => copyToClipboard(getMessageTextContent(message));
const groupsType = [
message.streaming && GroupType.streaming,
isContext && GroupType.isContext,
GroupType.normal,
].find((i) => i) as GroupType;
return (
showActions && (
<div
className={`
absolute z-10 w-[100%]
${isUser ? "right-0" : "left-0"}
transition-all duration-300
opacity-0
pointer-events-none
group-hover:opacity-100
group-hover:pointer-events-auto
${className}
`}
>
<ActionsBar
actionsShema={genActionsShema(message, {
onCopy,
onDelete,
onPinMessage,
onEdit,
onResend,
onUserStop,
})}
groups={groupsTypes[groupsType]}
className={`
float-right flex flex-row gap-1 p-1
bg-chat-message-actions
rounded-md
shadow-message-actions-bar
dark:bg-none
`}
/>
</div>
)
);
}

View File

@@ -0,0 +1,159 @@
import Popover from "@/app/components/Popover";
import React, { useMemo, useRef } from "react";
import useRelativePosition, {
Orientation,
} from "@/app/hooks/useRelativePosition";
import Locale from "@/app/locales";
import { useChatStore } from "@/app/store/chat";
import { useAllModels } from "@/app/utils/hooks";
import { ModelType, useAppConfig } from "@/app/store/config";
import { showToast } from "@/app/components/ui-lib";
import BottomArrow from "@/app/icons/downArrowLgIcon.svg";
import BottomArrowMobile from "@/app/icons/bottomArrow.svg";
import Modal, { TriggerProps } from "@/app/components/Modal";
import Selected from "@/app/icons/selectedIcon.svg";
const ModelSelect = () => {
const config = useAppConfig();
const { isMobileScreen } = config;
const chatStore = useChatStore();
const currentModel = chatStore.currentSession().mask.modelConfig.model;
const allModels = useAllModels();
const models = useMemo(() => {
const filteredModels = allModels.filter((m) => m.available);
const defaultModel = filteredModels.find((m) => m.isDefault);
if (defaultModel) {
const arr = [
defaultModel,
...filteredModels.filter((m) => m !== defaultModel),
];
return arr;
} else {
return filteredModels;
}
}, [allModels]);
const rootRef = useRef<HTMLDivElement>(null);
const { position, getRelativePosition } = useRelativePosition({
delay: 0,
});
const contentRef = useMemo<{ current: HTMLDivElement | null }>(() => {
return {
current: null,
};
}, []);
const selectedItemRef = useRef<HTMLDivElement>(null);
const autoScrollToSelectedModal = () => {
window.setTimeout(() => {
const distanceToParent = selectedItemRef.current?.offsetTop || 0;
const childHeight = selectedItemRef.current?.offsetHeight || 0;
const parentHeight = contentRef.current?.offsetHeight || 0;
const distanceToParentCenter =
distanceToParent + childHeight / 2 - parentHeight / 2;
if (distanceToParentCenter > 0 && contentRef.current) {
contentRef.current.scrollTop = distanceToParentCenter;
}
});
};
const content: TriggerProps["content"] = ({ close }) => (
<div
className={`flex flex-col gap-1 overflow-x-hidden relative text-sm-title`}
>
{models?.map((o) => (
<div
key={o.displayName}
className={`flex items-center px-3 py-2 gap-3 rounded-action-btn hover:bg-select-option-hovered cursor-pointer`}
onClick={() => {
close();
chatStore.updateCurrentSession((session) => {
session.mask.modelConfig.model = o.name as ModelType;
session.mask.syncGlobalConfig = false;
});
showToast(o.name);
}}
ref={currentModel === o.name ? selectedItemRef : undefined}
>
<div className={`flex-1 text-text-select`}>{o.name}</div>
<div
className={currentModel === o.name ? "opacity-100" : "opacity-0"}
>
<Selected />
</div>
</div>
))}
</div>
);
if (isMobileScreen) {
return (
<Modal.Trigger
content={(e) => (
<div className="h-[100%] overflow-y-auto" ref={contentRef}>
{content(e)}
</div>
)}
type="bottom-drawer"
onOpen={(e) => {
if (e) {
autoScrollToSelectedModal();
getRelativePosition(rootRef.current!, "");
}
}}
title={Locale.Chat.SelectModel}
headerBordered
noFooter
modelClassName="h-model-bottom-drawer"
>
<div
className="flex items-center gap-1 cursor-pointer text-text-modal-select"
ref={rootRef}
>
{currentModel}
<BottomArrowMobile />
</div>
</Modal.Trigger>
);
}
return (
<Popover
content={
<div className="max-h-chat-actions-select-model-popover overflow-y-auto">
{content({ close: () => {} })}
</div>
}
trigger="click"
noArrow
placement={
position?.poi.relativePosition[1] !== Orientation.bottom ? "lb" : "lt"
}
popoverClassName="border border-select-popover rounded-lg shadow-select-popover-shadow w-actions-popover bg-model-select-popover-panel w-[280px]"
onShow={(e) => {
if (e) {
autoScrollToSelectedModal();
getRelativePosition(rootRef.current!, "");
}
}}
getPopoverPanelRef={(ref) => (contentRef.current = ref.current)}
>
<div
className="flex items-center justify-center gap-1 cursor-pointer rounded-chat-model-select pl-3 pr-2.5 py-2 font-common leading-4 bg-chat-actions-select-model hover:bg-chat-actions-select-model-hover"
ref={rootRef}
>
<div className="line-clamp-1 max-w-chat-actions-select-model text-sm-title text-text-modal-select">
{currentModel}
</div>
<BottomArrow />
</div>
</Popover>
);
};
export default ModelSelect;

View File

@@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from "react";
import { Prompt } from "@/app/store/prompt";
import styles from "../index.module.scss";
import useShowPromptHint from "@/app/hooks/useShowPromptHint";
export type RenderPompt = Pick<Prompt, "title" | "content">;
export default function PromptHints(props: {
prompts: RenderPompt[];
onPromptSelect: (prompt: RenderPompt) => void;
className?: string;
}) {
const noPrompts = props.prompts.length === 0;
const [selectIndex, setSelectIndex] = useState(0);
const selectedRef = useRef<HTMLDivElement>(null);
const { internalPrompts, notShowPrompt } = useShowPromptHint({ ...props });
useEffect(() => {
setSelectIndex(0);
}, [props.prompts.length]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (noPrompts || e.metaKey || e.altKey || e.ctrlKey) {
return;
}
// arrow up / down to select prompt
const changeIndex = (delta: number) => {
e.stopPropagation();
e.preventDefault();
const nextIndex = Math.max(
0,
Math.min(props.prompts.length - 1, selectIndex + delta),
);
setSelectIndex(nextIndex);
selectedRef.current?.scrollIntoView({
block: "center",
});
};
if (e.key === "ArrowUp") {
changeIndex(1);
} else if (e.key === "ArrowDown") {
changeIndex(-1);
} else if (e.key === "Enter") {
const selectedPrompt = props.prompts.at(selectIndex);
if (selectedPrompt) {
props.onPromptSelect(selectedPrompt);
}
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.prompts.length, selectIndex]);
if (!internalPrompts.length) {
return null;
}
return (
<div
className={`
transition-all duration-300 shadow-prompt-hint-container rounded-none flex flex-col-reverse overflow-x-hidden
${
notShowPrompt
? "max-h-[0vh] border-none"
: "border-b pt-2.5 max-h-[50vh]"
}
${props.className}
`}
>
{internalPrompts.map((prompt, i) => (
<div
ref={i === selectIndex ? selectedRef : null}
className={
styles["prompt-hint"] +
` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}`
}
key={prompt.title + i.toString()}
onClick={() => props.onPromptSelect(prompt)}
onMouseEnter={() => setSelectIndex(i)}
>
<div className={styles["hint-title"]}>{prompt.title}</div>
<div className={styles["hint-content"]}>{prompt.content}</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { useChatStore } from "@/app/store/chat";
import Locale from "@/app/locales";
import BrainIcon from "@/app/icons/brain.svg";
import styles from "../index.module.scss";
export default function PromptToast(props: {
showToast?: boolean;
setShowModal: (_: boolean) => void;
}) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const context = session.mask.context;
return (
<div className={styles["prompt-toast"]} key="prompt-toast">
{props.showToast && (
<div
className={styles["prompt-toast-inner"] + " clickable"}
role="button"
onClick={() => props.setShowModal(true)}
>
<BrainIcon />
<span className={styles["prompt-toast-content"]}>
{Locale.Context.Toast(context.length)}
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { Modal, showConfirm } from "@/app/components/ui-lib";
import { useChatStore } from "@/app/store/chat";
import { useMaskStore } from "@/app/store/mask";
import { useNavigate } from "react-router-dom";
import Locale from "@/app/locales";
import { IconButton } from "@/app/components/button";
import { Path } from "@/app/constant";
import ResetIcon from "@/app/icons/reload.svg";
import CopyIcon from "@/app/icons/copy.svg";
import MaskConfig from "@/app/containers/Settings/components/MaskConfig";
import { ListItem } from "@/app/components/List";
export default function SessionConfigModel(props: { onClose: () => void }) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const maskStore = useMaskStore();
const navigate = useNavigate();
return (
<div className="modal-mask">
<Modal
title={Locale.Context.Edit}
onClose={() => props.onClose()}
actions={[
<IconButton
key="reset"
icon={<ResetIcon />}
bordered
text={Locale.Chat.Config.Reset}
onClick={async () => {
if (await showConfirm(Locale.Memory.ResetConfirm)) {
chatStore.updateCurrentSession(
(session) => (session.memoryPrompt = ""),
);
}
}}
/>,
<IconButton
key="copy"
icon={<CopyIcon />}
bordered
text={Locale.Chat.Config.SaveAs}
onClick={() => {
navigate(Path.Masks);
setTimeout(() => {
maskStore.create(session.mask);
}, 500);
}}
/>,
]}
// className="!bg-modal-mask"
>
<MaskConfig
mask={session.mask}
updateMask={(updater) => {
const mask = { ...session.mask };
updater(mask);
chatStore.updateCurrentSession((session) => (session.mask = mask));
}}
shouldSyncFromGlobal
extraListItems={
session.mask.modelConfig.sendMemory ? (
<ListItem
className="copyable"
title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
></ListItem>
) : (
<></>
)
}
></MaskConfig>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,184 @@
import { Draggable } from "@hello-pangea/dnd";
import Locale from "@/app/locales";
import { useLocation } from "react-router-dom";
import { Path } from "@/app/constant";
import { Mask } from "@/app/store/mask";
import { useRef, useEffect } from "react";
import DeleteChatIcon from "@/app/icons/deleteChatIcon.svg";
import { getTime } from "@/app/utils";
import DeleteIcon from "@/app/icons/deleteIcon.svg";
import LogIcon from "@/app/icons/logIcon.svg";
import HoverPopover from "@/app/components/HoverPopover";
import Popover from "@/app/components/Popover";
export default function SessionItem(props: {
onClick?: () => void;
onDelete?: () => void;
title: string;
count: number;
time: string;
selected: boolean;
id: string;
index: number;
narrow?: boolean;
mask: Mask;
isMobileScreen: boolean;
}) {
const draggableRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (props.selected && draggableRef.current) {
draggableRef.current?.scrollIntoView({
block: "center",
});
}
}, [props.selected]);
const { pathname: currentPath } = useLocation();
return (
<Draggable draggableId={`${props.id}`} index={props.index}>
{(provided) => (
<div
className={`
group/chat-menu-list relative flex p-3 items-center gap-2 self-stretch rounded-md mb-2
border
transition-colors duration-300 ease-in-out
bg-chat-menu-session-unselected-mobile border-chat-menu-session-unselected-mobile
md:bg-chat-menu-session-unselected md:border-chat-menu-session-unselected
${
props.selected &&
(currentPath === Path.Chat || currentPath === Path.Home)
? `
md:!bg-chat-menu-session-selected md:!border-chat-menu-session-selected
!bg-chat-menu-session-selected-mobile !border-chat-menu-session-selected-mobile
`
: `md:hover:bg-chat-menu-session-hovered md:hover:chat-menu-session-hovered`
}
`}
onClick={props.onClick}
ref={(ele) => {
draggableRef.current = ele;
provided.innerRef(ele);
}}
{...provided.draggableProps}
{...provided.dragHandleProps}
title={`${props.title}\n${Locale.ChatItem.ChatItemCount(
props.count,
)}`}
>
<div className=" flex-shrink-0">
<LogIcon />
</div>
<div className="flex flex-col flex-1">
<div className={`flex justify-between items-center`}>
<div
className={` text-text-chat-menu-item-title text-sm-title line-clamp-1 flex-1`}
>
{props.title}
</div>
<div
className={`text-text-chat-menu-item-time text-sm group-hover/chat-menu-list:opacity-0 pl-3 hidden md:block`}
>
{getTime(props.time)}
</div>
</div>
<div className={`text-text-chat-menu-item-description text-sm`}>
{Locale.ChatItem.ChatItemCount(props.count)}
</div>
</div>
<div
className={`text-text-chat-menu-item-time text-sm pl-3 block md:hidden`}
>
{getTime(props.time)}
</div>
{props.isMobileScreen ? (
<Popover
content={
<div
className={`
flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer
follow-parent-svg
fill-none
text-text-chat-menu-item-delete
`}
onClickCapture={(e) => {
props.onDelete?.();
}}
>
<DeleteChatIcon />
<div className="flex-1 font-common text-actions-popover-menu-item ">
{Locale.Chat.Actions.Delete}
</div>
</div>
}
popoverClassName={`
px-2 py-1 border-delete-chat-popover bg-delete-chat-popover-panel rounded-md shadow-delete-chat-popover-shadow
`}
noArrow
placement="r"
>
<div
className={`
cursor-pointer rounded-chat-img
md:!absolute md:top-[50%] md:translate-y-[-50%] md:right-3 md:pointer-events-none md:opacity-0
md:group-hover/chat-menu-list:pointer-events-auto
md:group-hover/chat-menu-list:opacity-100
md:hover:bg-select-hover
follow-parent-svg
fill-none
text-text-chat-menu-item-time
`}
>
<DeleteIcon />
</div>
</Popover>
) : (
<HoverPopover
content={
<div
className={`
flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer
follow-parent-svg
fill-none
text-text-chat-menu-item-delete
`}
onClickCapture={(e) => {
props.onDelete?.();
e.preventDefault();
e.stopPropagation();
}}
>
<DeleteChatIcon />
<div className="flex-1 font-common text-actions-popover-menu-item text-text-chat-menu-item-delete">
{Locale.Chat.Actions.Delete}
</div>
</div>
}
popoverClassName={`
px-2 py-1 border-delete-chat-popover bg-delete-chat-popover-panel rounded-md shadow-delete-chat-popover-shadow
`}
noArrow
align="start"
>
<div
className={`
cursor-pointer rounded-chat-img
md:!absolute md:top-[50%] md:translate-y-[-50%] md:right-3 md:pointer-events-none md:opacity-0
md:group-hover/chat-menu-list:pointer-events-auto
md:group-hover/chat-menu-list:opacity-100
md:hover:bg-select-hover
`}
>
<DeleteIcon />
</div>
</HoverPopover>
)}
</div>
)}
</Draggable>
);
}

View File

@@ -0,0 +1,609 @@
@import "~@/app/styles/animation.scss";
.attach-images {
position: absolute;
left: 30px;
bottom: 32px;
display: flex;
}
.attach-image {
cursor: default;
width: 64px;
height: 64px;
border: rgba($color: #888, $alpha: 0.2) 1px solid;
border-radius: 5px;
margin-right: 10px;
background-size: cover;
background-position: center;
background-color: var(--white);
.attach-image-mask {
width: 100%;
height: 100%;
opacity: 0;
transition: all ease 0.2s;
}
.attach-image-mask:hover {
opacity: 1;
}
.delete-image {
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
float: right;
background-color: var(--white);
}
}
.chat-input-actions {
display: flex;
flex-wrap: wrap;
.chat-input-action {
display: inline-flex;
border-radius: 20px;
font-size: 12px;
background-color: var(--white);
color: var(--black);
border: var(--border-in-light);
padding: 4px 10px;
animation: slide-in ease 0.3s;
box-shadow: var(--card-shadow);
transition: width ease 0.3s;
align-items: center;
height: 16px;
width: var(--icon-width);
overflow: hidden;
&:not(:last-child) {
margin-right: 5px;
}
.text {
white-space: nowrap;
padding-left: 5px;
opacity: 0;
transform: translateX(-5px);
transition: all ease 0.3s;
pointer-events: none;
}
&:hover {
--delay: 0.5s;
width: var(--full-width);
transition-delay: var(--delay);
.text {
transition-delay: var(--delay);
opacity: 1;
transform: translate(0);
}
}
.text,
.icon {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.prompt-toast {
position: absolute;
bottom: -50px;
z-index: 999;
display: flex;
justify-content: center;
width: calc(100% - 40px);
.prompt-toast-inner {
display: flex;
justify-content: center;
align-items: center;
font-size: 12px;
background-color: var(--white);
color: var(--black);
border: var(--border-in-light);
box-shadow: var(--card-shadow);
padding: 10px 20px;
border-radius: 100px;
animation: slide-in-from-top ease 0.3s;
.prompt-toast-content {
margin-left: 10px;
}
}
}
.section-title {
font-size: 12px;
font-weight: bold;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
.section-title-action {
display: flex;
align-items: center;
}
}
.context-prompt {
.context-prompt-insert {
display: flex;
justify-content: center;
padding: 4px;
opacity: 0.2;
transition: all ease 0.3s;
background-color: rgba(0, 0, 0, 0);
cursor: pointer;
border-radius: 4px;
margin-top: 4px;
margin-bottom: 4px;
&:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.05);
}
}
.context-prompt-row {
display: flex;
justify-content: center;
width: 100%;
&:hover {
.context-drag {
opacity: 1;
}
}
.context-drag {
display: flex;
align-items: center;
opacity: 0.5;
transition: all ease 0.3s;
}
.context-role {
margin-right: 10px;
}
.context-content {
flex: 1;
max-width: 100%;
text-align: left;
}
.context-delete-button {
margin-left: 10px;
}
}
.context-prompt-button {
flex: 1;
}
}
.memory-prompt {
margin: 20px 0;
.memory-prompt-content {
background-color: var(--white);
color: var(--black);
border: var(--border-in-light);
border-radius: 10px;
padding: 10px;
font-size: 12px;
user-select: text;
}
}
.clear-context {
margin: 20px 0 0 0;
padding: 4px 0;
border-top: var(--border-in-light);
border-bottom: var(--border-in-light);
box-shadow: var(--card-shadow) inset;
display: flex;
justify-content: center;
align-items: center;
color: var(--black);
transition: all ease 0.3s;
cursor: pointer;
overflow: hidden;
position: relative;
font-size: 12px;
animation: slide-in ease 0.3s;
$linear: linear-gradient(to right,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 1),
rgba(0, 0, 0, 0));
mask-image: $linear;
@mixin show {
transform: translateY(0);
position: relative;
transition: all ease 0.3s;
opacity: 1;
}
@mixin hide {
transform: translateY(-50%);
position: absolute;
transition: all ease 0.1s;
opacity: 0;
}
&-tips {
@include show;
opacity: 0.5;
}
&-revert-btn {
color: var(--primary);
@include hide;
}
&:hover {
opacity: 1;
border-color: var(--primary);
.clear-context-tips {
@include hide;
}
.clear-context-revert-btn {
@include show;
}
}
}
.chat {
display: flex;
flex-direction: column;
position: relative;
// height: 100%;
}
.chat-body {
flex: 1;
overflow: auto;
overflow-x: hidden;
padding: 20px;
padding-bottom: 40px;
position: relative;
overscroll-behavior: none;
}
.chat-body-main-title {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
@media only screen and (max-width: 600px) {
.chat-body-title {
text-align: center;
}
}
.chat-message {
display: flex;
flex-direction: row;
&:last-child {
animation: slide-in ease 0.3s;
}
}
.chat-message-user {
display: flex;
flex-direction: row-reverse;
.chat-message-header {
flex-direction: row-reverse;
}
}
.chat-message-header {
margin-top: 20px;
display: flex;
align-items: center;
.chat-message-actions {
display: flex;
box-sizing: border-box;
font-size: 12px;
align-items: flex-end;
justify-content: space-between;
transition: all ease 0.3s;
transform: scale(0.9) translateY(5px);
margin: 0 10px;
opacity: 0;
pointer-events: none;
.chat-input-actions {
display: flex;
flex-wrap: nowrap;
}
}
}
.chat-message-container {
max-width: var(--message-max-width);
display: flex;
flex-direction: column;
align-items: flex-start;
&:hover {
.chat-message-edit {
opacity: 0.9;
}
.chat-message-actions {
opacity: 1;
pointer-events: all;
transform: scale(1) translateY(0);
}
}
}
.chat-message-user>.chat-message-container {
align-items: flex-end;
}
.chat-message-avatar {
position: relative;
.chat-message-edit {
position: absolute;
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all ease 0.3s;
button {
padding: 7px;
}
}
/* Specific styles for iOS devices */
@media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) {
@supports (-webkit-touch-callout: none) {
.chat-message-edit {
top: -8%;
}
}
}
}
.chat-message-status {
font-size: 12px;
color: #aaa;
line-height: 1.5;
margin-top: 5px;
}
.chat-message-item {
// box-sizing: border-box;
// max-width: 100%;
// margin-top: 10px;
// border-radius: 10px;
// background-color: rgba(0, 0, 0, 0.05);
// padding: 10px;
// font-size: 14px;
// user-select: text;
// word-break: break-word;
// border: var(--border-in-light);
// position: relative;
transition: all ease 0.3s;
}
.chat-message-item-image {
width: 100%;
margin-top: 10px;
}
.chat-message-item-images {
width: 100%;
display: grid;
justify-content: left;
grid-gap: 10px;
grid-template-columns: repeat(var(--image-count), auto);
margin-top: 10px;
}
.chat-message-item-image-multi {
object-fit: cover;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.chat-message-item-image,
.chat-message-item-image-multi {
box-sizing: border-box;
border-radius: 10px;
border: rgba($color: #888, $alpha: 0.2) 1px solid;
}
@media only screen and (max-width: 600px) {
$calc-image-width: calc(100vw/3*2/var(--image-count));
.chat-message-item-image-multi {
width: $calc-image-width;
height: $calc-image-width;
}
.chat-message-item-image {
max-width: calc(100vw/3*2);
}
}
@media screen and (min-width: 600px) {
$max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count));
$image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count));
.chat-message-item-image-multi {
width: $image-width;
height: $image-width;
max-width: $max-image-width;
max-height: $max-image-width;
}
.chat-message-item-image {
max-width: calc(calc(1200px - var(--sidebar-width))/3*2);
}
}
// .chat-message-action-date {
// // font-size: 12px;
// // opacity: 0.2;
// // white-space: nowrap;
// // transition: all ease 0.6s;
// // color: var(--black);
// // text-align: right;
// // width: 100%;
// // box-sizing: border-box;
// // padding-right: 10px;
// // pointer-events: none;
// // z-index: 1;
// }
.chat-message-user>.chat-message-container>.chat-message-item {
background-color: var(--second);
&:hover {
min-width: 0;
}
}
.chat-input-panel {
// position: relative;
// width: 100%;
// padding: 20px;
// padding-top: 10px;
// box-sizing: border-box;
// flex-direction: column;
// border-top: var(--border-in-light);
// box-shadow: var(--card-shadow);
.chat-input-actions {
.chat-input-action {
margin-bottom: 10px;
}
}
}
@mixin single-line {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.prompt-hint {
color:var(--btn-default-text);
padding: 6px 10px;
border: transparent 1px solid;
margin: 4px;
border-radius: 8px;
&:not(:last-child) {
margin-top: 0;
}
.hint-title {
font-size: 12px;
font-weight: bolder;
@include single-line();
}
.hint-content {
font-size: 12px;
@include single-line();
}
&-selected,
&:hover {
border-color: var(--primary);
}
}
// .chat-input-panel-inner {
// cursor: text;
// display: flex;
// flex: 1;
// border-radius: 10px;
// border: var(--border-in-light);
// }
.chat-input-panel-inner-attach {
padding-bottom: 80px;
}
.chat-input-panel-inner:has(.chat-input:focus) {
border: 1px solid var(--primary);
}
.chat-input {
height: 100%;
width: 100%;
border-radius: 10px;
border: none;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
background-color: var(--white);
color: var(--black);
font-family: inherit;
padding: 10px 90px 10px 14px;
resize: none;
outline: none;
box-sizing: border-box;
min-height: 68px;
}
.chat-input:focus {}
.chat-input-send {
background-color: var(--primary);
color: white;
position: absolute;
right: 30px;
bottom: 32px;
}
@media only screen and (max-width: 600px) {
.chat-input {
font-size: 16px;
}
.chat-input-send {
bottom: 30px;
}
}

View File

@@ -0,0 +1,146 @@
import {
DragDropContext,
Droppable,
OnDragEndResponder,
} from "@hello-pangea/dnd";
import { useAppConfig, useChatStore } from "@/app/store";
import Locale from "@/app/locales";
import { useLocation, useNavigate } from "react-router-dom";
import { Path } from "@/app/constant";
import { useEffect } from "react";
import AddIcon from "@/app/icons/addIcon.svg";
import NextChatTitle from "@/app/icons/nextchatTitle.svg";
import MenuLayout from "@/app/components/MenuLayout";
import Panel from "./ChatPanel";
import Modal from "@/app/components/Modal";
import SessionItem from "./components/SessionItem";
export default MenuLayout(function SessionList(props) {
const { setShowPanel } = props;
const [sessions, selectedIndex, selectSession, moveSession] = useChatStore(
(state) => [
state.sessions,
state.currentSessionIndex,
state.selectSession,
state.moveSession,
],
);
const navigate = useNavigate();
const config = useAppConfig();
const { isMobileScreen } = config;
const chatStore = useChatStore();
const { pathname: currentPath } = useLocation();
useEffect(() => {
setShowPanel?.(currentPath === Path.Chat);
}, [currentPath]);
const onDragEnd: OnDragEndResponder = (result) => {
const { destination, source } = result;
if (!destination) {
return;
}
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return;
}
moveSession(source.index, destination.index);
};
return (
<div
className={`
h-[100%] flex flex-col
md:px-0
`}
>
<div data-tauri-drag-region>
<div
className={`
flex items-center justify-between
py-6 max-md:box-content max-md:h-0
md:py-7
`}
data-tauri-drag-region
>
<div className="">
<NextChatTitle />
</div>
<div
className=" cursor-pointer"
onClick={() => {
if (config.dontShowMaskSplashScreen) {
chatStore.newSession();
navigate(Path.Chat);
} else {
navigate(Path.NewChat);
}
}}
>
<AddIcon />
</div>
</div>
<div
className={`pb-3 text-sm sm:text-sm-mobile text-text-chat-header-subtitle`}
>
Build your own AI assistant.
</div>
</div>
<div className={`flex-1 overflow-y-auto max-md:pb-chat-panel-mobile `}>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="chat-list">
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={`w-[100%]`}
>
{sessions.map((item, i) => (
<SessionItem
title={item.topic}
time={new Date(item.lastUpdate).toLocaleString()}
count={item.messages.length}
key={item.id}
id={item.id}
index={i}
selected={i === selectedIndex}
onClick={() => {
navigate(Path.Chat);
selectSession(i);
}}
onDelete={async () => {
if (
await Modal.warn({
okText: Locale.ChatItem.DeleteOkBtn,
cancelText: Locale.ChatItem.DeleteCancelBtn,
title: Locale.ChatItem.DeleteTitle,
content: Locale.ChatItem.DeleteContent,
})
) {
chatStore.deleteSession(i);
}
}}
mask={item.mask}
isMobileScreen={isMobileScreen}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
</div>
);
}, Panel);

View File

@@ -0,0 +1,137 @@
import { useEffect, useMemo } from "react";
import { useAccessStore, useAppConfig } from "@/app/store";
import Locale from "@/app/locales";
import { Path } from "@/app/constant";
import List from "@/app/components/List";
import { useNavigate } from "react-router-dom";
import { getClientConfig } from "@/app/config/client";
import Card from "@/app/components/Card";
import SettingHeader from "./components/SettingHeader";
import { MenuWrapperInspectProps } from "@/app/components/MenuLayout";
import SyncItems from "./components/SyncItems";
import DangerItems from "./components/DangerItems";
import AppSetting from "./components/AppSetting";
import MaskSetting from "./components/MaskSetting";
import PromptSetting from "./components/PromptSetting";
import ProviderSetting from "./components/ProviderSetting";
import ModelConfigList from "./components/ModelSetting";
export default function Settings(props: MenuWrapperInspectProps) {
const { setShowPanel, id } = props;
const navigate = useNavigate();
const accessStore = useAccessStore();
const config = useAppConfig();
const { isMobileScreen } = config;
useEffect(() => {
const keydownEvent = (e: KeyboardEvent) => {
if (e.key === "Escape") {
navigate(Path.Home);
}
};
if (clientConfig?.isApp) {
// Force to set custom endpoint to true if it's app
accessStore.update((state) => {
state.useCustomConfig = true;
});
}
document.addEventListener("keydown", keydownEvent);
return () => {
document.removeEventListener("keydown", keydownEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const clientConfig = useMemo(() => getClientConfig(), []);
const cardClassName = "mb-6 md:mb-8 last:mb-0";
const itemMap = {
[Locale.Settings.GeneralSettings]: (
<>
<Card className={cardClassName} title={Locale.Settings.Basic.Title}>
<AppSetting />
</Card>
<Card className={cardClassName} title={Locale.Settings.Mask.Title}>
<MaskSetting />
</Card>
<Card className={cardClassName} title={Locale.Settings.Prompt.Title}>
<PromptSetting />
</Card>
<Card className={cardClassName} title={Locale.Settings.Provider.Title}>
<ProviderSetting />
</Card>
<Card className={cardClassName} title={Locale.Settings.Danger.Title}>
<DangerItems />
</Card>
</>
),
[Locale.Settings.ModelSettings]: (
<Card className={cardClassName} title={Locale.Settings.Models.Title}>
<List
widgetStyle={{
// selectClassName: "min-w-select-mobile-lg",
selectClassName: "min-w-select-mobile md:min-w-select",
inputClassName: "md:min-w-select",
rangeClassName: "md:min-w-select",
rangeNextLine: isMobileScreen,
}}
>
<ModelConfigList
modelConfig={config.modelConfig}
updateConfig={(updater) => {
const modelConfig = { ...config.modelConfig };
updater(modelConfig);
config.update((config) => (config.modelConfig = modelConfig));
}}
/>
</List>
</Card>
),
[Locale.Settings.DataSettings]: (
<Card className={cardClassName} title={Locale.Settings.Sync.Title}>
<SyncItems />
</Card>
),
};
return (
<div
className={`
flex flex-col overflow-hidden bg-settings-panel
h-setting-panel-mobile
md:h-[100%] md:mr-2.5 md:rounded-md
`}
>
<SettingHeader
isMobileScreen={isMobileScreen}
goback={() => setShowPanel?.(false)}
/>
<div
className={`
max-md:w-[100%]
px-4 py-5
md:px-6 md:py-8
flex items-start justify-center
overflow-y-auto
`}
>
<div
className={`
w-full
max-w-screen-md
!overflow-x-hidden
overflow-y-auto
`}
>
{itemMap[id] || null}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,200 @@
import LoadingIcon from "@/app/icons/three-dots.svg";
import ResetIcon from "@/app/icons/reload.svg";
import styles from "../index.module.scss";
import { useEffect, useState } from "react";
import { Avatar, AvatarPicker } from "@/app/components/emoji";
import { Popover } from "@/app/components/ui-lib";
import Locale, {
ALL_LANG_OPTIONS,
AllLangs,
changeLang,
getLang,
} from "@/app/locales";
import Link from "next/link";
import { IconButton } from "@/app/components/button";
import { useUpdateStore } from "@/app/store/update";
import {
SubmitKey,
Theme,
ThemeConfig,
useAppConfig,
} from "@/app/store/config";
import { getClientConfig } from "@/app/config/client";
import { RELEASE_URL, UPDATE_URL } from "@/app/constant";
import List, { ListItem } from "@/app/components/List";
import Select from "@/app/components/Select";
import SlideRange from "@/app/components/SlideRange";
import Switch from "@/app/components/Switch";
export interface AppSettingProps {}
export default function AppSetting(props: AppSettingProps) {
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const updateStore = useUpdateStore();
const config = useAppConfig();
const { update: updateConfig, isMobileScreen } = config;
const currentVersion = updateStore.formatVersion(updateStore.version);
const remoteId = updateStore.formatVersion(updateStore.remoteVersion);
const hasNewVersion = currentVersion !== remoteId;
const updateUrl = getClientConfig()?.isApp ? RELEASE_URL : UPDATE_URL;
function checkUpdate(force = false) {
setCheckingUpdate(true);
updateStore.getLatestVersion(force).then(() => {
setCheckingUpdate(false);
});
console.log("[Update] local version ", updateStore.version);
console.log("[Update] remote version ", updateStore.remoteVersion);
}
useEffect(() => {
// checks per minutes
checkUpdate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<List
widgetStyle={{
selectClassName: "min-w-select-mobile md:min-w-select",
inputClassName: "md:min-w-select",
rangeClassName: "md:min-w-select",
rangeNextLine: isMobileScreen,
}}
>
<ListItem title={Locale.Settings.Avatar}>
<Popover
onClose={() => setShowEmojiPicker(false)}
content={
<AvatarPicker
onEmojiClick={(avatar: string) => {
updateConfig((config) => (config.avatar = avatar));
setShowEmojiPicker(false);
}}
/>
}
open={showEmojiPicker}
>
<div
className={styles.avatar}
onClick={() => {
setShowEmojiPicker(!showEmojiPicker);
}}
>
<Avatar avatar={config.avatar} />
</div>
</Popover>
</ListItem>
<ListItem
title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
subTitle={
checkingUpdate
? Locale.Settings.Update.IsChecking
: hasNewVersion
? Locale.Settings.Update.FoundUpdate(remoteId ?? "ERROR")
: Locale.Settings.Update.IsLatest
}
>
{checkingUpdate ? (
<LoadingIcon />
) : hasNewVersion ? (
<Link href={updateUrl} target="_blank" className="link">
{Locale.Settings.Update.GoToUpdate}
</Link>
) : (
<IconButton
icon={<ResetIcon />}
text={Locale.Settings.Update.CheckUpdate}
onClick={() => checkUpdate(true)}
/>
)}
</ListItem>
<ListItem title={Locale.Settings.SendKey}>
<Select
value={config.submitKey}
options={Object.values(SubmitKey).map((v) => ({
value: v,
label: v,
}))}
onSelect={(v) => {
updateConfig((config) => (config.submitKey = v));
}}
/>
</ListItem>
<ListItem title={Locale.Settings.Theme}>
<Select
value={config.theme}
options={Object.entries(ThemeConfig).map(([k, t]) => ({
value: k as Theme,
label: t.title,
icon: <t.icon />,
}))}
onSelect={(e) => {
updateConfig((config) => (config.theme = e));
}}
/>
</ListItem>
<ListItem title={Locale.Settings.Lang.Name}>
<Select
value={getLang()}
options={AllLangs.map((lang) => ({
value: lang,
label: ALL_LANG_OPTIONS[lang],
}))}
onSelect={(e) => {
changeLang(e);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.FontSize.Title}
subTitle={Locale.Settings.FontSize.SubTitle}
>
<SlideRange
value={config.fontSize}
range={{
start: 12,
stroke: 28,
}}
step={1}
onSlide={(e) => updateConfig((config) => (config.fontSize = e))}
></SlideRange>
</ListItem>
<ListItem
title={Locale.Settings.AutoGenerateTitle.Title}
subTitle={Locale.Settings.AutoGenerateTitle.SubTitle}
>
<Switch
value={config.enableAutoGenerateTitle}
onChange={(e) =>
updateConfig((config) => (config.enableAutoGenerateTitle = e))
}
/>
</ListItem>
<ListItem
title={Locale.Settings.SendPreviewBubble.Title}
subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
>
<Switch
value={config.sendPreviewBubble}
onChange={(e) =>
updateConfig((config) => (config.sendPreviewBubble = e))
}
/>
</ListItem>
</List>
);
}

View File

@@ -0,0 +1,153 @@
import { IconButton } from "@/app/components/button";
import { showConfirm } from "@/app/components/ui-lib";
import { useChatStore } from "@/app/store/chat";
import { useAppConfig } from "@/app/store/config";
import Locale from "@/app/locales";
import { useAccessStore } from "@/app/store/access";
import { useEffect, useMemo, useState } from "react";
import { getClientConfig } from "@/app/config/client";
import { OPENAI_BASE_URL, ServiceProvider } from "@/app/constant";
import { useUpdateStore } from "@/app/store/update";
import ResetIcon from "@/app/icons/reload.svg";
import List, { ListItem } from "@/app/components/List";
import Input from "@/app/components/Input";
import Btn from "@/app/components/Btn";
export default function DangerItems() {
const chatStore = useChatStore();
const appConfig = useAppConfig();
const accessStore = useAccessStore();
const updateStore = useUpdateStore();
const { isMobileScreen } = appConfig;
const enabledAccessControl = useMemo(
() => accessStore.enabledAccessControl(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const clientConfig = useMemo(() => getClientConfig(), []);
const showAccessCode = enabledAccessControl && !clientConfig?.isApp;
const shouldHideBalanceQuery = useMemo(() => {
const isOpenAiUrl = accessStore.openaiUrl.includes(OPENAI_BASE_URL);
return (
accessStore.hideBalanceQuery ||
isOpenAiUrl ||
accessStore.provider === ServiceProvider.Azure
);
}, [
accessStore.hideBalanceQuery,
accessStore.openaiUrl,
accessStore.provider,
]);
const [loadingUsage, setLoadingUsage] = useState(false);
const usage = {
used: updateStore.used,
subscription: updateStore.subscription,
};
function checkUsage(force = false) {
if (shouldHideBalanceQuery) {
return;
}
setLoadingUsage(true);
updateStore.updateUsage(force).finally(() => {
setLoadingUsage(false);
});
}
const showUsage = accessStore.isAuthorized();
useEffect(() => {
showUsage && checkUsage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<List
widgetStyle={{
selectClassName: "min-w-select-mobile md:min-w-select",
inputClassName: "md:min-w-select",
rangeClassName: "md:min-w-select",
rangeNextLine: isMobileScreen,
inputNextLine: isMobileScreen,
}}
>
{showAccessCode && (
<ListItem
title={Locale.Settings.Access.AccessCode.Title}
subTitle={Locale.Settings.Access.AccessCode.SubTitle}
>
<Input
value={accessStore.accessCode}
type="password"
placeholder={Locale.Settings.Access.AccessCode.Placeholder}
onChange={(e) => {
accessStore.update((access) => (access.accessCode = e));
}}
/>
</ListItem>
)}
{!shouldHideBalanceQuery && !clientConfig?.isApp ? (
<ListItem
title={Locale.Settings.Usage.Title}
subTitle={
showUsage
? loadingUsage
? Locale.Settings.Usage.IsChecking
: Locale.Settings.Usage.SubTitle(
usage?.used ?? "[?]",
usage?.subscription ?? "[?]",
)
: Locale.Settings.Usage.NoAccess
}
>
{!showUsage || loadingUsage ? (
<div />
) : (
<IconButton
icon={<ResetIcon />}
text={Locale.Settings.Usage.Check}
onClick={() => checkUsage(true)}
/>
)}
</ListItem>
) : null}
<ListItem
title={Locale.Settings.Danger.Reset.Title}
subTitle={Locale.Settings.Danger.Reset.SubTitle}
>
<Btn
text={Locale.Settings.Danger.Reset.Action}
onClick={async () => {
if (await showConfirm(Locale.Settings.Danger.Reset.Confirm)) {
appConfig.reset();
}
}}
type="danger"
/>
</ListItem>
<ListItem
title={Locale.Settings.Danger.Clear.Title}
subTitle={Locale.Settings.Danger.Clear.SubTitle}
>
<Btn
text={Locale.Settings.Danger.Clear.Action}
onClick={async () => {
if (await showConfirm(Locale.Settings.Danger.Clear.Confirm)) {
chatStore.clearAllData();
}
}}
type="danger"
/>
</ListItem>
</List>
);
}

View File

@@ -0,0 +1,162 @@
import { useState } from "react";
import List, { ListItem } from "@/app/components/List";
import { ContextPrompts, MaskAvatar } from "@/app/components/mask";
import { Path } from "@/app/constant";
import { ModelConfig, useAppConfig } from "@/app/store/config";
import { Mask } from "@/app/store/mask";
import { Updater } from "@/app/typing";
import { copyToClipboard } from "@/app/utils";
import Locale from "@/app/locales";
import { Popover, showConfirm } from "@/app/components/ui-lib";
import { AvatarPicker } from "@/app/components/emoji";
import ModelSetting from "@/app/containers/Settings/components/ModelSetting";
import { IconButton } from "@/app/components/button";
import CopyIcon from "@/app/icons/copy.svg";
import Switch from "@/app/components/Switch";
import Input from "@/app/components/Input";
export default function MaskConfig(props: {
mask: Mask;
updateMask: Updater<Mask>;
extraListItems?: JSX.Element;
readonly?: boolean;
shouldSyncFromGlobal?: boolean;
}) {
const [showPicker, setShowPicker] = useState(false);
const updateConfig = (updater: (config: ModelConfig) => void) => {
if (props.readonly) return;
const config = { ...props.mask.modelConfig };
updater(config);
props.updateMask((mask) => {
mask.modelConfig = config;
// if user changed current session mask, it will disable auto sync
mask.syncGlobalConfig = false;
});
};
const copyMaskLink = () => {
const maskLink = `${location.protocol}//${location.host}/#${Path.NewChat}?mask=${props.mask.id}`;
copyToClipboard(maskLink);
};
const globalConfig = useAppConfig();
const { isMobileScreen } = globalConfig;
return (
<>
<ContextPrompts
context={props.mask.context}
updateContext={(updater) => {
const context = props.mask.context.slice();
updater(context);
props.updateMask((mask) => (mask.context = context));
}}
/>
<List
widgetStyle={{
rangeNextLine: isMobileScreen,
}}
>
<ListItem title={Locale.Mask.Config.Avatar}>
<Popover
content={
<AvatarPicker
onEmojiClick={(emoji) => {
props.updateMask((mask) => (mask.avatar = emoji));
setShowPicker(false);
}}
></AvatarPicker>
}
open={showPicker}
onClose={() => setShowPicker(false)}
>
<div
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
>
<MaskAvatar
avatar={props.mask.avatar}
model={props.mask.modelConfig.model}
/>
</div>
</Popover>
</ListItem>
<ListItem title={Locale.Mask.Config.Name}>
<Input
type="text"
value={props.mask.name}
onChange={(e) =>
props.updateMask((mask) => {
mask.name = e;
})
}
></Input>
</ListItem>
<ListItem
title={Locale.Mask.Config.HideContext.Title}
subTitle={Locale.Mask.Config.HideContext.SubTitle}
>
<Switch
value={!!props.mask.hideContext}
onChange={(e) => {
props.updateMask((mask) => {
mask.hideContext = e;
});
}}
></Switch>
</ListItem>
{!props.shouldSyncFromGlobal ? (
<ListItem
title={Locale.Mask.Config.Share.Title}
subTitle={Locale.Mask.Config.Share.SubTitle}
>
<IconButton
icon={<CopyIcon />}
text={Locale.Mask.Config.Share.Action}
onClick={copyMaskLink}
/>
</ListItem>
) : null}
{props.shouldSyncFromGlobal ? (
<ListItem
title={Locale.Mask.Config.Sync.Title}
subTitle={Locale.Mask.Config.Sync.SubTitle}
>
<Switch
value={!!props.mask.syncGlobalConfig}
onChange={async (e) => {
const checked = e;
if (
checked &&
(await showConfirm(Locale.Mask.Config.Sync.Confirm))
) {
props.updateMask((mask) => {
mask.syncGlobalConfig = checked;
mask.modelConfig = { ...globalConfig.modelConfig };
});
} else if (!checked) {
props.updateMask((mask) => {
mask.syncGlobalConfig = checked;
});
}
}}
/>
</ListItem>
) : null}
<ModelSetting
modelConfig={{ ...props.mask.modelConfig }}
updateConfig={updateConfig}
/>
{props.extraListItems}
</List>
</>
);
}

View File

@@ -0,0 +1,39 @@
import List, { ListItem } from "@/app/components/List";
import Switch from "@/app/components/Switch";
import Locale from "@/app/locales";
import { useAppConfig } from "@/app/store/config";
export interface MaskSettingProps {}
export default function MaskSetting(props: MaskSettingProps) {
const config = useAppConfig();
const updateConfig = config.update;
return (
<List>
<ListItem
title={Locale.Settings.Mask.Splash.Title}
subTitle={Locale.Settings.Mask.Splash.SubTitle}
>
<Switch
value={!config.dontShowMaskSplashScreen}
onChange={(e) =>
updateConfig((config) => (config.dontShowMaskSplashScreen = !e))
}
/>
</ListItem>
<ListItem
title={Locale.Settings.Mask.Builtin.Title}
subTitle={Locale.Settings.Mask.Builtin.SubTitle}
>
<Switch
value={config.hideBuiltinMasks}
onChange={(e) =>
updateConfig((config) => (config.hideBuiltinMasks = e))
}
/>
</ListItem>
</List>
);
}

View File

@@ -0,0 +1,220 @@
import { ListItem } from "@/app/components/List";
import {
ModalConfigValidator,
ModelConfig,
useAppConfig,
} from "@/app/store/config";
import { useAllModels } from "@/app/utils/hooks";
import Locale from "@/app/locales";
import Select from "@/app/components/Select";
import SlideRange from "@/app/components/SlideRange";
import Switch from "@/app/components/Switch";
import Input from "@/app/components/Input";
export default function ModelSetting(props: {
modelConfig: ModelConfig;
updateConfig: (updater: (config: ModelConfig) => void) => void;
}) {
const allModels = useAllModels();
const { isMobileScreen } = useAppConfig();
return (
<>
<ListItem title={Locale.Settings.Model}>
<Select
value={props.modelConfig.model}
options={allModels
.filter((v) => v.available)
.map((v) => ({
value: v.name,
label: `${v.displayName}(${v.provider?.providerName})`,
}))}
onSelect={(e) => {
props.updateConfig(
(config) => (config.model = ModalConfigValidator.model(e)),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Temperature.Title}
subTitle={Locale.Settings.Temperature.SubTitle}
>
<SlideRange
value={props.modelConfig.temperature}
range={{
start: 0,
stroke: 1,
}}
step={0.1}
onSlide={(e) => {
props.updateConfig(
(config) =>
(config.temperature = ModalConfigValidator.temperature(e)),
);
}}
></SlideRange>
</ListItem>
<ListItem
title={Locale.Settings.TopP.Title}
subTitle={Locale.Settings.TopP.SubTitle}
>
<SlideRange
value={props.modelConfig.top_p ?? 1}
range={{
start: 0,
stroke: 1,
}}
step={0.1}
onSlide={(e) => {
props.updateConfig(
(config) => (config.top_p = ModalConfigValidator.top_p(e)),
);
}}
></SlideRange>
</ListItem>
<ListItem
title={Locale.Settings.MaxTokens.Title}
subTitle={Locale.Settings.MaxTokens.SubTitle}
>
<Input
type="number"
min={1024}
max={512000}
value={props.modelConfig.max_tokens}
onChange={(e) =>
props.updateConfig(
(config) =>
(config.max_tokens = ModalConfigValidator.max_tokens(e)),
)
}
></Input>
</ListItem>
{props.modelConfig.model.startsWith("gemini") ? null : (
<>
<ListItem
title={Locale.Settings.PresencePenalty.Title}
subTitle={Locale.Settings.PresencePenalty.SubTitle}
>
<SlideRange
value={props.modelConfig.presence_penalty}
range={{
start: -2,
stroke: 4,
}}
step={0.1}
onSlide={(e) => {
props.updateConfig(
(config) =>
(config.presence_penalty =
ModalConfigValidator.presence_penalty(e)),
);
}}
></SlideRange>
</ListItem>
<ListItem
title={Locale.Settings.FrequencyPenalty.Title}
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
>
<SlideRange
value={props.modelConfig.frequency_penalty}
range={{
start: -2,
stroke: 4,
}}
step={0.1}
onSlide={(e) => {
props.updateConfig(
(config) =>
(config.frequency_penalty =
ModalConfigValidator.frequency_penalty(e)),
);
}}
></SlideRange>
</ListItem>
<ListItem
title={Locale.Settings.InjectSystemPrompts.Title}
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
>
<Switch
value={props.modelConfig.enableInjectSystemPrompts}
onChange={(e) =>
props.updateConfig(
(config) => (config.enableInjectSystemPrompts = e),
)
}
/>
</ListItem>
<ListItem
title={Locale.Settings.InputTemplate.Title}
subTitle={Locale.Settings.InputTemplate.SubTitle}
nextline={isMobileScreen}
validator={(v: string) => {
if (!v.includes("{{input}}")) {
return {
error: true,
message: Locale.Settings.InputTemplate.Error,
};
}
return { error: false };
}}
>
<Input
type="text"
value={props.modelConfig.template}
onChange={(e = "") =>
props.updateConfig((config) => (config.template = e))
}
></Input>
</ListItem>
</>
)}
<ListItem
title={Locale.Settings.HistoryCount.Title}
subTitle={Locale.Settings.HistoryCount.SubTitle}
>
<SlideRange
value={props.modelConfig.historyMessageCount}
range={{
start: 0,
stroke: 64,
}}
step={1}
onSlide={(e) => {
props.updateConfig((config) => (config.historyMessageCount = e));
}}
></SlideRange>
</ListItem>
<ListItem
title={Locale.Settings.CompressThreshold.Title}
subTitle={Locale.Settings.CompressThreshold.SubTitle}
>
<Input
type="number"
min={500}
max={4000}
value={props.modelConfig.compressMessageLengthThreshold}
onChange={(e) =>
props.updateConfig(
(config) => (config.compressMessageLengthThreshold = e),
)
}
></Input>
</ListItem>
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
<Switch
value={props.modelConfig.sendMemory}
onChange={(e) =>
props.updateConfig((config) => (config.sendMemory = e))
}
/>
</ListItem>
</>
);
}

View File

@@ -0,0 +1,63 @@
import { useState } from "react";
import UserPromptModal from "./UserPromptModal";
import List, { ListItem } from "@/app/components/List";
import Locale from "@/app/locales";
import { useAppConfig } from "@/app/store/config";
import { SearchService, usePromptStore } from "@/app/store/prompt";
import Switch from "@/app/components/Switch";
import Btn from "@/app/components/Btn";
import EditIcon from "@/app/icons/editIcon.svg";
export interface PromptSettingProps {}
export default function PromptSetting(props: PromptSettingProps) {
const [shouldShowPromptModal, setShowPromptModal] = useState(false);
const config = useAppConfig();
const updateConfig = config.update;
const builtinCount = SearchService.count.builtin;
const promptStore = usePromptStore();
const customCount = promptStore.getUserPrompts().length ?? 0;
const textStyle = " !text-sm";
return (
<>
<List>
<ListItem
title={Locale.Settings.Prompt.Disable.Title}
subTitle={Locale.Settings.Prompt.Disable.SubTitle}
>
<Switch
value={config.disablePromptHint}
onChange={(e) =>
updateConfig((config) => (config.disablePromptHint = e))
}
/>
</ListItem>
<ListItem
title={Locale.Settings.Prompt.List}
subTitle={Locale.Settings.Prompt.ListCount(builtinCount, customCount)}
>
<div className="flex gap-3">
<Btn
onClick={() => setShowPromptModal(true)}
text={
<span className={textStyle}>{Locale.Settings.Prompt.Edit}</span>
}
prefixIcon={config.isMobileScreen ? undefined : <EditIcon />}
></Btn>
</div>
</ListItem>
</List>
{shouldShowPromptModal && (
<UserPromptModal onClose={() => setShowPromptModal(false)} />
)}
</>
);
}

View File

@@ -0,0 +1,283 @@
import { useMemo } from "react";
import {
Anthropic,
Azure,
Google,
OPENAI_BASE_URL,
ServiceProvider,
SlotID,
} from "@/app/constant";
import Locale from "@/app/locales";
import { useAccessStore } from "@/app/store/access";
import { getClientConfig } from "@/app/config/client";
import { useAppConfig } from "@/app/store/config";
import List, { ListItem } from "@/app/components/List";
import Select from "@/app/components/Select";
import Switch from "@/app/components/Switch";
import Input from "@/app/components/Input";
export default function ProviderSetting() {
const accessStore = useAccessStore();
const config = useAppConfig();
const { isMobileScreen } = config;
const clientConfig = useMemo(() => getClientConfig(), []);
return (
<List
id={SlotID.CustomModel}
widgetStyle={{
selectClassName: "min-w-select-mobile md:min-w-select",
inputClassName: "md:min-w-select",
rangeClassName: "md:min-w-select",
inputNextLine: isMobileScreen,
}}
>
{!accessStore.hideUserApiKey && (
<>
{
// Conditionally render the following ListItem based on clientConfig.isApp
!clientConfig?.isApp && ( // only show if isApp is false
<ListItem
title={Locale.Settings.Access.CustomEndpoint.Title}
subTitle={Locale.Settings.Access.CustomEndpoint.SubTitle}
>
<Switch
value={accessStore.useCustomConfig}
onChange={(e) =>
accessStore.update((access) => (access.useCustomConfig = e))
}
/>
</ListItem>
)
}
{accessStore.useCustomConfig && (
<>
<ListItem
title={Locale.Settings.Access.Provider.Title}
subTitle={Locale.Settings.Access.Provider.SubTitle}
>
<Select
value={accessStore.provider}
onSelect={(e) => {
accessStore.update((access) => (access.provider = e));
}}
options={Object.entries(ServiceProvider).map(([k, v]) => ({
value: v,
label: k,
}))}
/>
</ListItem>
{accessStore.provider === ServiceProvider.OpenAI && (
<>
<ListItem
title={Locale.Settings.Access.OpenAI.Endpoint.Title}
subTitle={Locale.Settings.Access.OpenAI.Endpoint.SubTitle}
>
<Input
type="text"
value={accessStore.openaiUrl}
placeholder={OPENAI_BASE_URL}
onChange={(e = "") =>
accessStore.update((access) => (access.openaiUrl = e))
}
></Input>
</ListItem>
<ListItem
title={Locale.Settings.Access.OpenAI.ApiKey.Title}
subTitle={Locale.Settings.Access.OpenAI.ApiKey.SubTitle}
>
<Input
value={accessStore.openaiApiKey}
type="password"
placeholder={
Locale.Settings.Access.OpenAI.ApiKey.Placeholder
}
onChange={(e) => {
accessStore.update(
(access) => (access.openaiApiKey = e),
);
}}
/>
</ListItem>
</>
)}
{accessStore.provider === ServiceProvider.Azure && (
<>
<ListItem
title={Locale.Settings.Access.Azure.Endpoint.Title}
subTitle={
Locale.Settings.Access.Azure.Endpoint.SubTitle +
Azure.ExampleEndpoint
}
>
<Input
type="text"
value={accessStore.azureUrl}
placeholder={Azure.ExampleEndpoint}
onChange={(e) =>
accessStore.update((access) => (access.azureUrl = e))
}
></Input>
</ListItem>
<ListItem
title={Locale.Settings.Access.Azure.ApiKey.Title}
subTitle={Locale.Settings.Access.Azure.ApiKey.SubTitle}
>
<Input
value={accessStore.azureApiKey}
type="password"
placeholder={
Locale.Settings.Access.Azure.ApiKey.Placeholder
}
onChange={(e) => {
accessStore.update(
(access) => (access.azureApiKey = e),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Azure.ApiVerion.Title}
subTitle={Locale.Settings.Access.Azure.ApiVerion.SubTitle}
>
<Input
type="text"
value={accessStore.azureApiVersion}
placeholder="2023-08-01-preview"
onChange={(e) =>
accessStore.update(
(access) => (access.azureApiVersion = e),
)
}
></Input>
</ListItem>
</>
)}
{accessStore.provider === ServiceProvider.Google && (
<>
<ListItem
title={Locale.Settings.Access.Google.Endpoint.Title}
subTitle={
Locale.Settings.Access.Google.Endpoint.SubTitle +
Google.ExampleEndpoint
}
>
<Input
type="text"
value={accessStore.googleUrl}
placeholder={Google.ExampleEndpoint}
onChange={(e) =>
accessStore.update((access) => (access.googleUrl = e))
}
></Input>
</ListItem>
<ListItem
title={Locale.Settings.Access.Google.ApiKey.Title}
subTitle={Locale.Settings.Access.Google.ApiKey.SubTitle}
>
<Input
value={accessStore.googleApiKey}
type="password"
placeholder={
Locale.Settings.Access.Google.ApiKey.Placeholder
}
onChange={(e) => {
accessStore.update(
(access) => (access.googleApiKey = e),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Google.ApiVersion.Title}
subTitle={Locale.Settings.Access.Google.ApiVersion.SubTitle}
>
<Input
type="text"
value={accessStore.googleApiVersion}
placeholder="2023-08-01-preview"
onChange={(e) =>
accessStore.update(
(access) => (access.googleApiVersion = e),
)
}
></Input>
</ListItem>
</>
)}
{accessStore.provider === ServiceProvider.Anthropic && (
<>
<ListItem
title={Locale.Settings.Access.Anthropic.Endpoint.Title}
subTitle={
Locale.Settings.Access.Anthropic.Endpoint.SubTitle +
Anthropic.ExampleEndpoint
}
>
<Input
type="text"
value={accessStore.anthropicUrl}
placeholder={Anthropic.ExampleEndpoint}
onChange={(e) =>
accessStore.update(
(access) => (access.anthropicUrl = e),
)
}
></Input>
</ListItem>
<ListItem
title={Locale.Settings.Access.Anthropic.ApiKey.Title}
subTitle={Locale.Settings.Access.Anthropic.ApiKey.SubTitle}
>
<Input
value={accessStore.anthropicApiKey}
type="password"
placeholder={
Locale.Settings.Access.Anthropic.ApiKey.Placeholder
}
onChange={(e) => {
accessStore.update(
(access) => (access.anthropicApiKey = e),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Anthropic.ApiVerion.Title}
subTitle={
Locale.Settings.Access.Anthropic.ApiVerion.SubTitle
}
>
<Input
type="text"
value={accessStore.anthropicApiVersion}
placeholder={Anthropic.Vision}
onChange={(e) =>
accessStore.update(
(access) => (access.anthropicApiVersion = e),
)
}
></Input>
</ListItem>
</>
)}
</>
)}
</>
)}
<ListItem
title={Locale.Settings.Access.CustomModel.Title}
subTitle={Locale.Settings.Access.CustomModel.SubTitle}
>
<Input
type="text"
value={config.customModels}
placeholder="model1,model2,model3"
onChange={(e) => config.update((config) => (config.customModels = e))}
></Input>
</ListItem>
</List>
);
}

View File

@@ -0,0 +1,47 @@
import Locale from "@/app/locales";
import GobackIcon from "@/app/icons/goback.svg";
export interface ChatHeaderProps {
isMobileScreen: boolean;
goback: () => void;
}
export default function SettingHeader(props: ChatHeaderProps) {
const { isMobileScreen, goback } = props;
return (
<div
className={`
relative flex flex-0 justify-between items-center px-6 py-4 gap-chat-header-gap border-b border-settings-header
max-md:h-menu-title-mobile max-md:bg-settings-header-mobile
`}
data-tauri-drag-region
>
{isMobileScreen ? (
<div
className="absolute left-4 top-[50%] translate-y-[-50%] cursor-pointer"
onClick={() => goback()}
>
<GobackIcon />
</div>
) : null}
<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-settings-panel-header-title text-chat-header-title font-common
max-md:text-sm-title max-md:h-chat-header-title-mobile max-md:leading-5 !font-medium
`}
>
{Locale.Settings.Title}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
import { Modal } from "@/app/components/ui-lib";
import { useSyncStore } from "@/app/store/sync";
import Locale from "@/app/locales";
import { IconButton } from "@/app/components/button";
import { ProviderType } from "@/app/utils/cloud";
import { STORAGE_KEY } from "@/app/constant";
import { useMemo, useState } from "react";
import ConnectionIcon from "@/app/icons/connection.svg";
import CloudSuccessIcon from "@/app/icons/cloud-success.svg";
import CloudFailIcon from "@/app/icons/cloud-fail.svg";
import ConfirmIcon from "@/app/icons/confirm.svg";
import LoadingIcon from "@/app/icons/three-dots.svg";
import List, { ListItem } from "@/app/components/List";
import Switch from "@/app/components/Switch";
import Select from "@/app/components/Select";
import Input from "@/app/components/Input";
import { useAppConfig } from "@/app/store";
function CheckButton() {
const syncStore = useSyncStore();
const couldCheck = useMemo(() => {
return syncStore.cloudSync();
}, [syncStore]);
const [checkState, setCheckState] = useState<
"none" | "checking" | "success" | "failed"
>("none");
async function check() {
setCheckState("checking");
const valid = await syncStore.check();
setCheckState(valid ? "success" : "failed");
}
if (!couldCheck) return null;
return (
<IconButton
text={Locale.Settings.Sync.Config.Modal.Check}
bordered
onClick={check}
icon={
checkState === "none" ? (
<ConnectionIcon />
) : checkState === "checking" ? (
<LoadingIcon />
) : checkState === "success" ? (
<CloudSuccessIcon />
) : checkState === "failed" ? (
<CloudFailIcon />
) : (
<ConnectionIcon />
)
}
></IconButton>
);
}
export default function SyncConfigModal(props: { onClose?: () => void }) {
const syncStore = useSyncStore();
const config = useAppConfig();
const { isMobileScreen } = config;
return (
<div className="modal-mask">
<Modal
title={Locale.Settings.Sync.Config.Modal.Title}
onClose={() => props.onClose?.()}
actions={[
<CheckButton key="check" />,
<IconButton
key="confirm"
onClick={props.onClose}
icon={<ConfirmIcon />}
bordered
text={Locale.UI.Confirm}
/>,
]}
className="!bg-modal-mask active-new"
>
<List
widgetStyle={{
rangeNextLine: isMobileScreen,
}}
>
<ListItem
title={Locale.Settings.Sync.Config.SyncType.Title}
subTitle={Locale.Settings.Sync.Config.SyncType.SubTitle}
>
<Select
value={syncStore.provider}
options={Object.entries(ProviderType).map(([k, v]) => ({
value: v,
label: k,
}))}
onSelect={(v) => {
syncStore.update((config) => (config.provider = v));
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Sync.Config.Proxy.Title}
subTitle={Locale.Settings.Sync.Config.Proxy.SubTitle}
>
<Switch
value={syncStore.useProxy}
onChange={(e) => {
syncStore.update((config) => (config.useProxy = e));
}}
/>
</ListItem>
{syncStore.useProxy ? (
<ListItem
title={Locale.Settings.Sync.Config.ProxyUrl.Title}
subTitle={Locale.Settings.Sync.Config.ProxyUrl.SubTitle}
>
<Input
type="text"
value={syncStore.proxyUrl}
onChange={(e) => {
syncStore.update((config) => (config.proxyUrl = e));
}}
></Input>
</ListItem>
) : null}
{syncStore.provider === ProviderType.WebDAV && (
<>
<ListItem title={Locale.Settings.Sync.Config.WebDav.Endpoint}>
<Input
type="text"
value={syncStore.webdav.endpoint}
onChange={(e) => {
syncStore.update((config) => (config.webdav.endpoint = e));
}}
></Input>
</ListItem>
<ListItem title={Locale.Settings.Sync.Config.WebDav.UserName}>
<Input
type="text"
value={syncStore.webdav.username}
onChange={(e) => {
syncStore.update((config) => (config.webdav.username = e));
}}
></Input>
</ListItem>
<ListItem title={Locale.Settings.Sync.Config.WebDav.Password}>
<Input
value={syncStore.webdav.password}
type="password"
onChange={(e) => {
syncStore.update((config) => (config.webdav.password = e));
}}
></Input>
</ListItem>
</>
)}
{syncStore.provider === ProviderType.UpStash && (
<>
<ListItem title={Locale.Settings.Sync.Config.UpStash.Endpoint}>
<Input
type="text"
value={syncStore.upstash.endpoint}
onChange={(e) => {
syncStore.update((config) => (config.upstash.endpoint = e));
}}
></Input>
</ListItem>
<ListItem title={Locale.Settings.Sync.Config.UpStash.UserName}>
<Input
type="text"
value={syncStore.upstash.username}
placeholder={STORAGE_KEY}
onChange={(e) => {
syncStore.update((config) => (config.upstash.username = e));
}}
></Input>
</ListItem>
<ListItem title={Locale.Settings.Sync.Config.UpStash.Password}>
<Input
value={syncStore.upstash.apiKey}
type="password"
onChange={(e) => {
syncStore.update((config) => (config.upstash.apiKey = e));
}}
></Input>
</ListItem>
</>
)}
</List>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import ConfigIcon from "@/app/icons/configIcon2.svg";
import ExportIcon from "@/app/icons/exportIcon.svg";
import ImportIcon from "@/app/icons/importIcon.svg";
import SyncIcon from "@/app/icons/syncIcon.svg";
import { showToast } from "@/app/components/ui-lib";
import { useChatStore } from "@/app/store/chat";
import { useMaskStore } from "@/app/store/mask";
import { usePromptStore } from "@/app/store/prompt";
import { useSyncStore } from "@/app/store/sync";
import { useMemo, useState } from "react";
import Locale from "@/app/locales";
import SyncConfigModal from "./SyncConfigModal";
import List, { ListItem } from "@/app/components/List";
import Btn from "@/app/components/Btn";
import { useAppConfig } from "@/app/store";
export default function SyncItems() {
const syncStore = useSyncStore();
const chatStore = useChatStore();
const promptStore = usePromptStore();
const maskStore = useMaskStore();
const couldSync = useMemo(() => {
return syncStore.cloudSync();
}, [syncStore]);
const { isMobileScreen } = useAppConfig();
const [showSyncConfigModal, setShowSyncConfigModal] = useState(false);
const stateOverview = useMemo(() => {
const sessions = chatStore.sessions;
const messageCount = sessions.reduce((p, c) => p + c.messages.length, 0);
return {
chat: sessions.length,
message: messageCount,
prompt: Object.keys(promptStore.prompts).length,
mask: Object.keys(maskStore.masks).length,
};
}, [chatStore.sessions, maskStore.masks, promptStore.prompts]);
const textStyle = "!text-sm";
return (
<>
<List>
<ListItem
title={Locale.Settings.Sync.CloudState}
subTitle={
syncStore.lastProvider
? `${new Date(syncStore.lastSyncTime).toLocaleString()} [${
syncStore.lastProvider
}]`
: Locale.Settings.Sync.NotSyncYet
}
>
<div className="flex gap-3">
<Btn
onClick={() => {
setShowSyncConfigModal(true);
}}
text={<span className={textStyle}>{Locale.UI.Config}</span>}
prefixIcon={isMobileScreen ? undefined : <ConfigIcon />}
></Btn>
{couldSync && (
<Btn
onClick={async () => {
try {
await syncStore.sync();
showToast(Locale.Settings.Sync.Success);
} catch (e) {
showToast(Locale.Settings.Sync.Fail);
console.error("[Sync]", e);
}
}}
text={<span className={textStyle}>{Locale.UI.Sync}</span>}
prefixIcon={<SyncIcon />}
></Btn>
)}
</div>
</ListItem>
<ListItem
title={Locale.Settings.Sync.LocalState}
subTitle={Locale.Settings.Sync.Overview(stateOverview)}
>
<div className="flex gap-3">
<Btn
onClick={() => {
syncStore.export();
}}
text={<span className={textStyle}>{Locale.UI.Export}</span>}
prefixIcon={<ExportIcon />}
></Btn>
<Btn
onClick={async () => {
syncStore.import();
}}
text={<span className={textStyle}>{Locale.UI.Import}</span>}
prefixIcon={<ImportIcon />}
></Btn>
</div>
</ListItem>
</List>
{showSyncConfigModal && (
<SyncConfigModal onClose={() => setShowSyncConfigModal(false)} />
)}
</>
);
}

View File

@@ -0,0 +1,169 @@
import { useEffect, useState } from "react";
import { nanoid } from "nanoid";
import { Prompt, SearchService, usePromptStore } from "@/app/store/prompt";
import { Input as Textarea, Modal } from "@/app/components/ui-lib";
import Locale from "@/app/locales";
import { IconButton } from "@/app/components/button";
import AddIcon from "@/app/icons/add.svg";
import CopyIcon from "@/app/icons/copy.svg";
import ClearIcon from "@/app/icons/clear.svg";
import EditIcon from "@/app/icons/edit.svg";
import EyeIcon from "@/app/icons/eye.svg";
import styles from "../index.module.scss";
import { copyToClipboard } from "@/app/utils";
import Input from "@/app/components/Input";
function EditPromptModal(props: { id: string; onClose: () => void }) {
const promptStore = usePromptStore();
const prompt = promptStore.get(props.id);
return prompt ? (
<div className="modal-mask">
<Modal
title={Locale.Settings.Prompt.EditModal.Title}
onClose={props.onClose}
actions={[
<IconButton
key=""
onClick={props.onClose}
text={Locale.UI.Confirm}
bordered
/>,
]}
// className="!bg-modal-mask"
>
<div className={styles["edit-prompt-modal"]}>
<Input
type="text"
value={prompt.title}
readOnly={!prompt.isUser}
className={styles["edit-prompt-title"]}
onChange={(e) =>
promptStore.updatePrompt(props.id, (prompt) => (prompt.title = e))
}
></Input>
<Textarea
value={prompt.content}
readOnly={!prompt.isUser}
className={styles["edit-prompt-content"]}
rows={10}
onInput={(e) =>
promptStore.updatePrompt(
props.id,
(prompt) => (prompt.content = e.currentTarget.value),
)
}
></Textarea>
</div>
</Modal>
</div>
) : null;
}
export default function UserPromptModal(props: { onClose?: () => void }) {
const promptStore = usePromptStore();
const userPrompts = promptStore.getUserPrompts();
const builtinPrompts = SearchService.builtinPrompts;
const allPrompts = userPrompts.concat(builtinPrompts);
const [searchInput, setSearchInput] = useState("");
const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
const [editingPromptId, setEditingPromptId] = useState<string>();
useEffect(() => {
if (searchInput.length > 0) {
const searchResult = SearchService.search(searchInput);
setSearchPrompts(searchResult);
} else {
setSearchPrompts([]);
}
}, [searchInput]);
return (
<div className="modal-mask">
<Modal
title={Locale.Settings.Prompt.Modal.Title}
onClose={() => props.onClose?.()}
actions={[
<IconButton
key="add"
onClick={() => {
const promptId = promptStore.add({
id: nanoid(),
createdAt: Date.now(),
title: "Empty Prompt",
content: "Empty Prompt Content",
});
setEditingPromptId(promptId);
}}
icon={<AddIcon />}
bordered
text={Locale.Settings.Prompt.Modal.Add}
/>,
]}
// className="!bg-modal-mask"
>
<div className={styles["user-prompt-modal"]}>
<Input
type="text"
className={styles["user-prompt-search"]}
placeholder={Locale.Settings.Prompt.Modal.Search}
value={searchInput}
onChange={(e) => setSearchInput(e)}
></Input>
<div className={styles["user-prompt-list"]}>
{prompts.map((v, _) => (
<div className={styles["user-prompt-item"]} key={v.id ?? v.title}>
<div className={styles["user-prompt-header"]}>
<div className={styles["user-prompt-title"]}>{v.title}</div>
<div className={styles["user-prompt-content"] + " one-line"}>
{v.content}
</div>
</div>
<div className={styles["user-prompt-buttons"]}>
{v.isUser && (
<IconButton
icon={<ClearIcon />}
className={styles["user-prompt-button"]}
onClick={() => promptStore.remove(v.id!)}
/>
)}
{v.isUser ? (
<IconButton
icon={<EditIcon />}
className={styles["user-prompt-button"]}
onClick={() => setEditingPromptId(v.id)}
/>
) : (
<IconButton
icon={<EyeIcon />}
className={styles["user-prompt-button"]}
onClick={() => setEditingPromptId(v.id)}
/>
)}
<IconButton
icon={<CopyIcon />}
className={styles["user-prompt-button"]}
onClick={() => copyToClipboard(v.content)}
/>
</div>
</div>
))}
</div>
</div>
</Modal>
{editingPromptId !== undefined && (
<EditPromptModal
id={editingPromptId!}
onClose={() => setEditingPromptId(undefined)}
/>
)}
</div>
);
}

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