Compare commits

...

212 Commits

Author SHA1 Message Date
Danny Avila
90b74aff2e Merge pull request #104 from HyunggyuJang/refactor/docker
Refactor: merge docker setup file into one dockerfile & one docker-compose.yml
2023-03-22 20:13:10 -04:00
HyunggyuJang
f5d102b7bd Update docker-compose.yml 2023-03-22 23:20:03 +09:00
Hyunggyu Jang
40ed6fa9ec Provide nginx docker build recipe 2023-03-22 22:59:29 +09:00
Hyunggyu Jang
36f3d37ecc Remove nginx setting 2023-03-22 10:50:59 +09:00
Hyunggyu Jang
c233cc0d5c Move Dockerfiles into one toplevel Dockerfile 2023-03-22 10:50:44 +09:00
Danny Avila
1041146fcb Merge pull request #108 from danny-avila/upgrade-fix
fix: chatgpt now using latest
2023-03-21 17:41:05 -04:00
Danny Avila
e531a17e0f fix: chatgpt now using latest 2023-03-21 16:32:32 -04:00
Danny Avila
30a7a80bfc Merge pull request #107 from danny-avila/chores
Chores
2023-03-21 14:27:00 -04:00
Danny Avila
67f8374c9e chore: eslint rules and blinker update in text handling 2023-03-21 13:41:31 -04:00
Danny Avila
0cc4aea204 chore: reorg. content files, add blinking cursor 2023-03-21 09:46:08 -04:00
Danny Avila
04796824d5 chore: init eslint 2023-03-21 08:48:35 -04:00
Danny Avila
9020239e1f Merge pull request #103 from HyunggyuJang/use-lock-file
Use npm ci rather than install to be consistent with lock file
2023-03-21 08:31:09 -04:00
Hyunggyu Jang
0a12b47760 Use npm ci rather than install to be consistent with lock file 2023-03-21 12:23:25 +09:00
Danny Avila
9358a4fdb5 Merge pull request #102 from danny-avila/bing-hotfix
bing hotfix, latest api, uses sydney
2023-03-20 17:16:45 -04:00
Daniel Avila
7d796f2c3e bing hotfix, latest api, uses sydney 2023-03-20 17:02:37 -04:00
Danny Avila
0a1651f6a1 Update README.md 2023-03-20 04:44:42 -04:00
Daniel Avila
d13315c45b chore: comment out auth route 2023-03-20 02:58:41 -04:00
Daniel Avila
0b75d5d6fe chore: another hotfix patch on getConvoTitle 2023-03-20 02:30:08 -04:00
Wentao Lyu
39819b744c doc: add magic of generate auth code 2023-03-20 02:22:04 -04:00
Daniel Avila
28c8f066d9 chore: add latest api as dep with alias 2023-03-20 02:18:03 -04:00
Daniel Avila
c85602b93b chore: Replace hard coded message ID with unique one 2023-03-20 01:51:07 -04:00
Daniel Avila
08c91871c7 chore: hotfix: browser will not leave empty convo 2023-03-20 01:42:06 -04:00
Daniel Avila
80ca3bc375 chore: hotfix for browser client 2023-03-20 01:35:02 -04:00
HyunggyuJang
0405206438 Remove crypto from client side
Unless, if we tries to access the client side page from http protocol, crypto.randomUUID() isn't available.
2023-03-20 00:58:17 -04:00
Daniel Avila
0af8f6a699 chore: fix broken browser client 2023-03-20 00:51:56 -04:00
Daniel Avila
b0936fa322 chore: unplug meilisearch, add leading option to throttle 2023-03-20 00:48:16 -04:00
Daniel Avila
4cd0ff2682 fix: throttle scroll to bottom 2023-03-19 11:45:03 -04:00
Daniel Avila
4ce60537ca search result styling changes 2023-03-19 11:25:12 -04:00
Daniel Avila
0b47218cd5 markdown library change 2023-03-19 01:14:19 -04:00
Daniel Avila
d56aa2edef loads up to 20 messages, debugging markdown issue 2023-03-18 23:18:36 -04:00
Daniel Avila
4e6168d8fa setup message population on search 2023-03-18 18:40:53 -04:00
Daniel Avila
4197a92609 feat: search working as expected 2023-03-18 17:49:24 -04:00
Daniel Avila
da42d6272a add loading state for fetching 2023-03-18 15:59:59 -04:00
Daniel Avila
b97594c000 fix: conflicting fetch with /api/convos 2023-03-18 14:28:10 -04:00
Daniel Avila
0f54ffd8b4 feat: search bar working, still in progress 2023-03-18 01:40:49 -04:00
Daniel Avila
610cba4a60 backend logic drafted, moving to frontend 2023-03-17 22:20:36 -04:00
Daniel Avila
4f5ee8b198 move db functions 2023-03-17 19:58:13 -04:00
Daniel Avila
586c162404 merge commit 2023-03-17 19:40:44 -04:00
Danny Avila
1308ef1394 Merge pull request #88 from wtlyu/feat-clean-after-regenerate-pr
Feat clean after regenerate pr
2023-03-17 15:31:32 -04:00
Danny Avila
1513c27f7d adjust: wording and placeholder text size 2023-03-17 14:48:21 -04:00
Wentao Lyu
0ff3bbb28f clean up 2023-03-18 02:15:24 +08:00
Wentao Lyu
7987c0100c fix: add crypto as deps. [need confirm] 2023-03-18 02:04:51 +08:00
Wentao Lyu
e8611a1d07 fix: don't reset isSubmitting in useEffect of submission 2023-03-18 01:54:06 +08:00
Wentao Lyu
ce78123369 fix: allow delete of last char while submitting 2023-03-18 01:24:35 +08:00
Wentao Lyu
a90db1f1a4 fix: add model to customGpts
fix: filter models by model only
2023-03-18 01:12:45 +08:00
Wentao Lyu
a213868b17 fix: set isSubmitting with messages together
style: some notification
2023-03-18 01:12:45 +08:00
Wentao Lyu
6b2a2bb858 feat: save cancelled flag in message 2023-03-18 01:11:44 +08:00
Wentao Lyu
fea3afa740 fix: missing import 2023-03-18 01:10:20 +08:00
Wentao Lyu
7372b37fe6 style: hide footer in mobile mode
style: avoid ui overflow in mobile mode
2023-03-18 01:09:59 +08:00
Danny Avila
e11ce141d7 Merge pull request #86 from danny-avila/fix-scroll
fix: fix weird scrolling behavior on last message
2023-03-17 12:38:15 -04:00
Danny Avila
46fbd3b66a fix: fix weird scrolling behavior on last message 2023-03-17 12:34:54 -04:00
Danny Avila
ce3f03267a Merge pull request #80 from wtlyu/feat-regenerate-and-cancel
Feat regenerate and cancel
2023-03-17 10:40:45 -04:00
Danny Avila
9a2392e4d5 fix: mobile styling after regen buttons 2023-03-17 22:20:23 +08:00
Danny Avila
1eab4d240d Merge pull request #85 from danny-avila/bing-refusal
Bing refusal
2023-03-17 09:14:40 -04:00
Danny Avila
5568a60174 handle bing message refusal 2023-03-17 09:11:45 -04:00
Danny Avila
ea4180f22a remove detect code (not always accurate) 2023-03-17 09:11:31 -04:00
Daniel Avila
6d2f3361d0 feat: search in progress 2023-03-16 21:20:40 -04:00
Daniel Avila
9995a159aa feat: reorganize api files, add mongoMeili 2023-03-16 19:38:16 -04:00
Daniel Avila
854f1c3572 feat: search, refactoring messages model 2023-03-16 17:20:26 -04:00
Danny Avila
dcc13daf67 testing: mongo meilisearch 2023-03-16 16:22:08 -04:00
Wentao Lyu
2310bab348 revert: dont overwrite the mobile input panel color 2023-03-17 03:54:15 +08:00
Wentao Lyu
d64edfdc7d feat: add chrome title color 2023-03-17 03:50:34 +08:00
Wentao Lyu
a8c53f1f0d mobile style, for input panel and regenerate buttons 2023-03-17 03:50:34 +08:00
Wentao Lyu
ef9f1ee1cf feat: cancellable api request 2023-03-17 03:50:34 +08:00
Wentao Lyu
66ad54168a feat: regenerate for bingai 2023-03-17 03:50:34 +08:00
Wentao Lyu
0891566d1e feat: add regenerate to all response message as official 2023-03-17 03:49:59 +08:00
Wentao Lyu
e3b0cb7db7 feat: show model at the top of conversation messages. 2023-03-17 03:48:45 +08:00
Danny Avila
c27554ed2e fix: allow customs in model filter 2023-03-16 15:05:23 -04:00
Danny Avila
87f793f1c4 Merge pull request #79 from danny-avila/improve-titles
fix: add deterministic titling
2023-03-16 15:03:04 -04:00
Danny Avila
4078c5283b fix: add deterministic titling 2023-03-16 15:02:40 -04:00
Danny Avila
5cac7e48f0 Merge pull request #74 from wtlyu/feat-model-based-on-key
feat: show model based on configured Keys
2023-03-16 15:00:23 -04:00
Danny Avila
7a08c77850 Update README.md 2023-03-16 14:11:24 -04:00
Danny Avila
1fe9e29187 Merge branch 'master' into feat-model-based-on-key 2023-03-16 13:39:41 -04:00
Danny Avila
ba8692dbe4 Merge pull request #59 from wtlyu/feat-usersys
Feat usersys
2023-03-16 13:35:03 -04:00
Danny Avila
d30b406c4c Merge pull request #4 from wtlyu/revisions
Revisions
2023-03-16 13:34:36 -04:00
Danny Avila
915cda70ef fix: user:user redundancies 2023-03-16 13:32:04 -04:00
Danny Avila
df19595c5b fix: icon and mobile textarea styling 2023-03-16 13:30:10 -04:00
Danny Avila
867b3073d4 fix: icon and mobile textarea styling 2023-03-16 13:29:13 -04:00
Danny Avila
e4e28dbbe2 Update README.md 2023-03-16 10:57:44 -04:00
Danny Avila
cdbc0e21e7 Update README.md 2023-03-16 10:57:18 -04:00
Wentao Lyu
b6f7f95709 feat: set default model once model list read. 2023-03-16 21:12:33 +08:00
Wentao Lyu
131af50034 feat: show model based on configured Keys 2023-03-16 14:44:14 +08:00
Wentao Lyu
23c050b54e style: mobile style of landing 2023-03-16 14:07:48 +08:00
Wentao Lyu
7442294c41 fix: mobile sizing style of icon 2023-03-16 14:05:12 +08:00
Wentao Lyu
a47dbe6262 feat: use same icon style in TextChat 2023-03-16 13:45:46 +08:00
Wentao Lyu
aabb19656e feat: combine customgpt to user 2023-03-16 13:30:20 +08:00
Wentao Lyu
b0284b6974 sync updates and merge with feat-resubmit 2023-03-16 13:20:54 +08:00
Wentao Lyu
62d88380e0 feat: add sample multi-user support
feat: update README
2023-03-16 13:20:34 +08:00
Daniel Avila
41f351786f fix: oversight, artifact in bing route 2023-03-15 22:36:16 -04:00
Danny Avila
ffcfb69dee Merge pull request #72 from danny-avila/fix-mobile-switch
fix: mobile view for sibling switch
2023-03-15 19:06:41 -04:00
Daniel Avila
c91ce36227 fix: mobile view for sibling switch 2023-03-15 19:05:17 -04:00
Danny Avila
d06e58f043 Merge pull request #55 from wtlyu/feat-resubmit
Feature: multipath message & resubmit, conversation placeholder, separate title generation, and some bug fix.
2023-03-15 18:06:35 -04:00
Daniel Avila
d052d221dc chore: switch focus to textarea when custom model change (still need to figure out reg model change) 2023-03-15 18:05:34 -04:00
Danny Avila
ff45511011 Merge pull request #3 from wtlyu/final-adj
Final adjustments
2023-03-15 17:08:18 -04:00
Danny Avila
8c6340aed0 chore: refactor cursor blink, debugging 2023-03-15 16:38:01 -04:00
Danny Avila
84b104e65f chore: delegate response text parsing to one location 2023-03-15 15:44:48 -04:00
Danny Avila
a0c94715ce chore: refactor titleConvo 2023-03-15 15:21:04 -04:00
Danny Avila
a8aad30fc8 chore: memoized Messages component, will require custom equality check 2023-03-15 14:36:17 -04:00
Danny Avila
2fd50c99b8 fix: debounce title request and handle error with default title 2023-03-15 12:47:30 -04:00
Danny Avila
96ca783517 chore: re-organize message modules, fix icon size, convo reset properly rebuilds Tree 2023-03-15 10:42:45 -04:00
Wentao Lyu
45ca0a8713 fix: gptCustom icon should show as same in model and message 2023-03-15 14:42:39 +08:00
Wentao Lyu
5d0b849930 feat: show icon within model select menu
fix: use icon for gptCustom
2023-03-15 14:21:08 +08:00
Wentao Lyu
54aa9debb4 fix: don't reset new convo if model not change
fix: change model will clear all messages.
2023-03-15 13:38:01 +08:00
Daniel Avila
4e91437049 fix: convo resets, sets new Convo 2023-03-14 21:32:25 -04:00
Daniel Avila
918f2fecb6 fix: convo resets on model change 2023-03-14 21:25:02 -04:00
Daniel Avila
796d8031e8 fix: ensure custom params are not passed to non custom models 2023-03-14 20:21:41 -04:00
Daniel Avila
6e32f71565 fix: adjust custom client for new progress CB 2023-03-14 20:15:06 -04:00
Daniel Avila
6192c2964e fix: validation to avoid saving customGpt params to non-custom models 2023-03-14 20:14:38 -04:00
Daniel Avila
626a8fbd8e fix: if db is empty, will never show new convos until refresh 2023-03-14 18:53:46 -04:00
Danny Avila
a8344ec5bf Merge pull request #2 from wtlyu/refactors
Refactors
2023-03-14 18:16:44 -04:00
Danny Avila
c230fe41f4 Merge branch 'feat-resubmit' into refactors 2023-03-14 16:09:25 -04:00
Danny Avila
d0ef0f84c8 chore: delegate text handling to one place, html sanitization in progress 2023-03-14 16:05:46 -04:00
Wentao Lyu
8289558d94 feat: pagination in nav 2023-03-15 04:05:14 +08:00
Danny Avila
2e20b28c4d chore: refactor progressCB to one place, fix sydney, and sanitize html 2023-03-14 15:42:59 -04:00
Danny Avila
9a17e94f8f fix: refactor migration and sort old convos correctly 2023-03-14 14:51:26 -04:00
Wentao Lyu
71fc86b9a6 fix: buildTree should store parent-not-exist message as root. rather than dropping them. 2023-03-15 02:43:21 +08:00
Wentao Lyu
8882432210 fix: hide the edit button when bingai 2023-03-15 02:33:08 +08:00
Wentao Lyu
644f3f716f feat: re-orginazed three ask api. To provide ability to reproduce message.
feat: bing and sydney come to work again, [need more test]
2023-03-15 02:15:46 +08:00
Wentao Lyu
8b00805d24 fix: dont send gen_title twice 2023-03-15 02:15:46 +08:00
Wentao Lyu
d73375958b feat: return error as a error message, not only text 2023-03-15 02:15:46 +08:00
Wentao Lyu
7168498543 fix: jailbreakConversationId=false response will be saved jailbreakConversationId='false' in database. 2023-03-15 02:15:46 +08:00
Wentao Lyu
27515cb00a fix: generate title by backend 2023-03-15 02:15:46 +08:00
Daniel Avila
3e7ce67609 fix: migrate old schema to new 2023-03-15 02:15:46 +08:00
Daniel Avila
4fd05e15b4 fix: migrate old schema to new 2023-03-15 02:15:46 +08:00
Wentao Lyu
0fa19bb6ad feat: save error message into database. 2023-03-15 02:15:46 +08:00
Wentao Lyu
d9e5464b3b fix: cleanup debug msg 2023-03-15 02:15:46 +08:00
Wentao Lyu
953c5fc970 fix: w<1024px, will overflow. 2023-03-15 02:15:46 +08:00
Wentao Lyu
2afbc5883f fix: use onCompositionStart and onCompositionEnd to aviod enter submit when using input method. 2023-03-15 02:15:46 +08:00
Wentao Lyu
953f846958 fix: loading and send button, mobile style
feat: sibling switch, mobile style
fix: only the real submitting message will blink
feat: drop the text version username, use a similar square. (or it will mass up the sibling switch)
2023-03-15 02:15:46 +08:00
Wentao Lyu
a4d5f6a3f2 feat: fully multipath and resubmit 2023-03-15 02:15:46 +08:00
Wentao Lyu
4a39965b22 fix: add proxy to titleConvo 2023-03-15 02:15:46 +08:00
Wentao Lyu
90dc171b34 test: generate of title shouldn't be mislead by answer 2023-03-15 02:15:46 +08:00
Wentao Lyu
0e98cb4206 fix: in mobile view, resubmit edit button should always visible 2023-03-15 02:15:46 +08:00
Wentao Lyu
9f8e9cb091 feat: gen title by sperate api call
feat:

fix: rename of convo should based on real request.
2023-03-15 02:15:46 +08:00
Wentao Lyu
8773878be2 feat: create conversation at the beginning then return the userMessage 2023-03-15 02:15:46 +08:00
Wentao Lyu
5a409ccfa6 fix: don't resubmit html label
fix: hide the resubmit editor border
2023-03-15 02:15:46 +08:00
Wentao Lyu
0ed8a40a41 feat: merge all message.id into message.messageId
feat: the first message will have a parentMessageId as 00000000-0000-0000-0000-000000000000 (in order not to create new convo when resubmit)
feat: ask will return the userMessage as well, to send back the messageId

TODO: comment out the title generation.
TODO: bing version need to be test

fix: never use the same messageId
fix: never delete exist messages
fix: connect response.parentMessageId to the userMessage.messageId
fix: set default convo title as new Chat
2023-03-15 02:15:46 +08:00
Wentao Lyu
be71140dd4 fix: new message should append to the exist one 2023-03-15 02:15:46 +08:00
Wentao Lyu
6d51ec3e37 feat: auto hide edit button when edit is enabled. 2023-03-15 02:15:46 +08:00
Wentao Lyu
bdfc895800 feat: support resubmit.
TODO: basic implementation. should add multi-path record in future.

feat: add deleteMessahesSince
feat: saveMessage will do createOrSave
feat: reorginazed submission
2023-03-15 02:15:46 +08:00
Wentao Lyu
b9975ac283 fix: missing setSubmission 2023-03-15 02:15:46 +08:00
Wentao Lyu
dd1f74da72 replace all created to timestamps: true in db 2023-03-15 02:15:46 +08:00
Danny Avila
5d65427a6e Merge pull request #56 from HyunggyuJang/build/docker-from-rootdir
Build docker files from root dir
2023-03-13 13:17:23 -04:00
Danny Avila
6a16d31f26 Merge pull request #60 from wtlyu/feat-autofocus
feat: auto focus
2023-03-13 13:15:43 -04:00
Wentao Lyu
8affbfdb4a feat: auto focus 2023-03-14 01:12:11 +08:00
Hyunggyu Jang
70d59c5b3c Build docker files from root dir 2023-03-13 22:49:40 +09:00
Danny Avila
73979ee67f Merge pull request #54 from danny-avila/minor-fixes
fix: model menu key issue
2023-03-12 16:46:25 -04:00
Daniel Avila
8e513d83a5 fix: model menu key issue 2023-03-12 16:45:44 -04:00
Danny Avila
9bc85ea83d Merge pull request #47 from wtlyu/feat-title-with-language
fix: give a stronger prompt to generate tile using the input language
2023-03-12 15:10:45 -04:00
Wentao Lyu
943eb5c74d fix: give a stronger prompt to generate tile using the input language 2023-03-13 01:51:32 +08:00
Danny Avila
bac37cfe36 Update README.md 2023-03-11 23:29:27 -05:00
Danny Avila
b2334054da Update README.md 2023-03-11 23:28:56 -05:00
Danny Avila
a705e907ff Merge pull request #45 from danny-avila/submit-state
fixes: allow simultaneous convos & minor improvements
2023-03-11 23:28:33 -05:00
Daniel Avila
c8bb6b13bf update: readme.md 2 2023-03-11 23:24:53 -05:00
Daniel Avila
50a17bc1b8 update: readme.md 2023-03-11 23:22:51 -05:00
Daniel Avila
3038015a5b fix: removes newline from start of stream response if present 2023-03-11 23:11:55 -05:00
Daniel Avila
20da895ee7 update bypass proxy url for browser client 2023-03-11 23:08:04 -05:00
Daniel Avila
8762765dd0 fix: retain scroll view on delete convo 2023-03-11 22:59:32 -05:00
Daniel Avila
f1aabfa543 fix: reset submission when convo clears 2023-03-11 22:35:39 -05:00
Daniel Avila
23de688bf3 fix: stops stream upon conversation 2023-03-11 21:42:08 -05:00
Daniel Avila
79f050bac7 feat: loading state for messages 2023-03-11 18:39:46 -05:00
Danny Avila
afae13eec6 Merge pull request #43 from danny-avila/fix-custom-name
fix: format sender text change and mobile styling
2023-03-11 17:38:17 -05:00
Daniel Avila
4fa599e97a fix: format sender text change and mobile styling 2023-03-11 17:36:10 -05:00
Danny Avila
3b1d6aaa79 Merge pull request #42 from danny-avila/title-lang
feat: title the chat in user's language
2023-03-11 16:28:36 -05:00
Daniel Avila
339230cd74 feat: title the chat in user's language 2023-03-11 16:26:41 -05:00
Daniel Avila
95bad60f7d fix: detectCode bug 2023-03-11 14:46:21 -05:00
Danny Avila
950b8f3f1e Merge pull request #34 from wtlyu/feat-mobile
feat: basic support mobile mode.
2023-03-11 12:59:09 -05:00
Danny Avila
0e2e5b8393 Update README.md 2023-03-11 12:32:59 -05:00
Danny Avila
d2cb9957fe Merge pull request #35 from danny-avila/rm-short-filter
fix: rm short filter
2023-03-11 12:10:56 -05:00
Daniel Avila
78b4004ead fix: rm short filter 2023-03-11 12:10:00 -05:00
Danny Avila
8624062488 Merge pull request #26 from wtlyu/master
support config host name and proxy address. and fix a docker bug
2023-03-11 12:05:16 -05:00
Wentao Lyu
070fee2ece feat: basic support in mobile mode. including:
navbar show and hide,
similar fade animation,
auto close when select new convo,
mobile title will change with convo,
new chat button.
2023-03-12 00:32:03 +08:00
Danny Avila
b1569450fd Merge pull request #32 from danny-avila/fix-hjs
Fix hjs
2023-03-11 10:32:38 -05:00
Daniel Avila
09659be002 fix: unsupported lang handler in client 2023-03-11 10:28:46 -05:00
Wentao Lyu
5e4fa09dcb update readme, add proxy and host information 2023-03-11 16:14:24 +08:00
Wentao Lyu
16c9589058 fix: use quotes outside of key=value in docker-compose.yml
fix: set HOST=0.0.0.0 by default in api's dockerfile
2023-03-11 16:14:11 +08:00
Wentao Lyu
8604030404 Add more information in env.example.
Give current host address when HOST=0.0.0.0
2023-03-11 15:03:18 +08:00
Wentao Lyu
97668217b9 fix: add proxy to bingai too 2023-03-11 15:02:26 +08:00
Wentao Lyu
c5c865a25f fix: frontend api request shouldn't hard code the domain and port. 2023-03-11 14:13:39 +08:00
Wentao Lyu
06c99154ac feat: support config host name and proxy address 2023-03-11 14:12:51 +08:00
Daniel Avila
9d71b58345 fix: handles unsupported hjs languages in client response 2023-03-10 22:21:15 -05:00
Danny Avila
fb9f77ae5e Merge pull request #23 from danny-avila/tab-links
Add back Tab links
2023-03-10 13:18:54 -05:00
Danny Avila
fc2b9bf7f2 feat: tab link component, edit wrapper 2023-03-10 13:12:24 -05:00
Danny Avila
cdbe00ec6f feat: tab link component 2023-03-10 13:11:17 -05:00
Danny Avila
72ff47e204 Rolled back to v0.0.2 2023-03-10 12:55:45 -05:00
Danny Avila
57d3025717 Merge pull request #20 from danny-avila/override-links
feat: links open in new tab
2023-03-10 09:45:52 -05:00
Danny Avila
6c02558f1b feat: links open in new tab 2023-03-10 09:44:40 -05:00
Danny Avila
0c62863e52 Update README.md 2023-03-10 08:38:45 -05:00
Danny Avila
d825721dad fix: update readme and compose file 2023-03-10 08:37:15 -05:00
Danny Avila
0b3b6f91fc Merge pull request #19 from wtlyu/master
support config host name and proxy address.
2023-03-10 08:21:22 -05:00
Wentao Lyu
3a9b532248 fix: frontend api request shouldn't hard code the domain and port. 2023-03-10 21:12:16 +08:00
Wentao Lyu
67156f4a7a feat: support config host name and proxy address 2023-03-10 21:12:16 +08:00
Daniel Avila
4e616cd2ed fix: error handling titles, remove openAI req 2023-03-10 07:43:43 -05:00
Danny Avila
12cf3405e4 Merge pull request #16 from danny-avila/updateAI
feat: add sydney (jailbroken bing) and more bing styling with citations
2023-03-09 20:34:28 -05:00
Daniel Avila
4383894ad3 finish update 2023-03-09 20:31:56 -05:00
Daniel Avila
d0ecaabfd8 feat: cites text with links 2023-03-09 20:29:44 -05:00
Daniel Avila
a286f027c8 model menu won't close on model deletion 2023-03-09 19:02:59 -05:00
Daniel Avila
0ba92c4c03 add new reducer to ensure conversations are renewed on model change 2023-03-09 18:57:37 -05:00
Daniel Avila
30936573ac feat: includes sources 2023-03-09 18:42:36 -05:00
Daniel Avila
a451574760 feat: cites text with links 2023-03-09 18:07:36 -05:00
Danny Avila
5e57deab5f bing styling in progress 2023-03-09 16:09:53 -05:00
Daniel Avila
eae82edb83 merge readme update 2023-03-08 22:33:25 -05:00
Daniel Avila
1e0cbbf1cc merge readme update 2023-03-08 22:32:51 -05:00
Daniel Avila
3986b4ec9e Merge branch 'master' of https://github.com/danny-avila/chatgpt-clone into updateAI 2023-03-08 22:31:40 -05:00
Daniel Avila
67054e7504 working sydney, need to test the other models 2023-03-08 22:30:29 -05:00
Daniel Avila
2c1ae68dc4 adding sydney in progress. only uses jailbreakConvoId and parentMsgId 2023-03-08 21:06:58 -05:00
Daniel Avila
69b3edc52c feat: sydney is functional 2023-03-08 19:47:23 -05:00
Danny Avila
732c75d145 Update README.md 2023-03-08 10:26:12 -05:00
Danny Avila
0f54251459 Merge pull request #12 from HyunggyuJang/fix/webpack-artifacts
webpack artifacts should exist for nginx image
2023-03-07 21:07:13 -05:00
Hyunggyu Jang
72ef76b8a0 webpack artifacts should exist for nginx image 2023-03-08 10:38:22 +09:00
Danny Avila
81abbdb335 Merge pull request #11 from HyunggyuJang/fix/docker-name
DockerFile is invalid name
2023-03-07 20:11:44 -05:00
Hyunggyu Jang
67ca79dd3f DockerFile is invalid name 2023-03-08 09:51:04 +09:00
109 changed files with 12163 additions and 1193 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
**/node_modules
**/.env

3
.gitignore vendored
View File

@@ -2,6 +2,7 @@
# Logs
data-node
meili_data
logs
*.log
@@ -33,6 +34,7 @@ client/public/main.js.LICENSE.txt
# Deployed apps should consider commenting these lines out:
# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git
node_modules/
meili_data/
api/node_modules/
client/node_modules/
bower_components/
@@ -47,7 +49,6 @@ bower_components/
.env
cache.json
api/data/
.eslintrc.js
owner.yml
archive
.vscode/settings.json

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
FROM node:19-alpine AS react-client
WORKDIR /client
# copy package.json into the container at /client
COPY /client/package*.json /client/
# install dependencies
RUN npm ci
# Copy the current directory contents into the container at /client
COPY /client/ /client/
# Build webpack artifacts
RUN npm run build
FROM node:19-alpine AS node-api
WORKDIR /api
# copy package.json into the container at /api
COPY /api/package*.json /api/
# install dependencies
RUN npm ci
# Copy the current directory contents into the container at /api
COPY /api/ /api/
# Copy the client side code
COPY --from=react-client /client/public /client/public
# Make port 3080 available to the world outside this container
EXPOSE 3080
# Expose the server to 0.0.0.0
ENV HOST=0.0.0.0
# Run the app when the container launches
CMD ["npm", "start"]
# Optional: for client with nginx routing
FROM nginx:stable-alpine AS nginx-client
WORKDIR /usr/share/nginx/html
COPY --from=react-client /client/public /usr/share/nginx/html
# Add your nginx.conf
COPY /client/nginx.conf /etc/nginx/conf.d/default.conf
ENTRYPOINT ["nginx", "-g", "daemon off;"]

197
README.md
View File

@@ -1,20 +1,66 @@
# ChatGPT Clone #
![chatgpt-clone demo](./images/demo.gif)
https://user-images.githubusercontent.com/110412045/223754183-8b7f45ce-6517-4bd5-9b39-c624745bf399.mp4
## All AI Conversations under One Roof. ##
Assistant AIs are the future and OpenAI revolutionized this movement with ChatGPT. While numerous methods exist to integrate them, this app commemorates the original styling of ChatGPT, with the ability to integrate any current/future AI models, while improving upon original client features, such as conversation search and prompt templates (currently WIP).
This project was started early in Feb '23, anticipating the release of the official ChatGPT API from OpenAI, and now uses it. Through this clone, you can avoid ChatGPT Plus in favor of free or pay-per-call APIs. I will soon deploy a demo of this app. Feel free to contribute, clone, or fork. Currently dockerized.
## Updates
<details open>
<summary><strong>2023-03-20</strong></summary>
**Searching messages** is almost here as I test more of its functionality. There've been a lot of great features requested and great contributions and I will work on some soon, namely, further customizing the custom gpt params with sliders similar to the OpenAI playground, and including the custom params and system messages available to Bing.
The above features are next and then I will have to focus on building the **test environment.** I would **greatly appreciate** help in this area with any test environment you're familiar with (mocha, chai, jest, playwright, puppeteer). This is to aid in the velocity of contributing and to save time I spend debugging.
On that note, I had to switch the default branch due to some breaking changes that haven't been straight forward to debug, mainly related to node-chat-gpt the main dependency of the project. Thankfully, my working branch, now switched to default as main, is working as expected.
</details>
<details>
<details>
<details>
<summary><strong>2023-03-16</strong></summary>
[Latest release (v0.0.4)](https://github.com/danny-avila/chatgpt-clone/releases/tag/v0.0.4) includes Resubmitting messages & Branching messages, which mirrors official ChatGPT feature of editing a sent message, that then branches the conversation into separate message paths (works only with ChatGPT)
Full details and [example here](https://github.com/danny-avila/chatgpt-clone/releases/tag/v0.0.4). Message search is on the docket
</details>
<summary><strong>2023-03-12</strong></summary>
Really thankful for all the issues reported and contributions made, the project's features and improvements have accelerated as result. Honorable mention is [wtlyu](https://github.com/wtlyu) for contributing a lot of mindful code, namely hostname configuration and mobile styling. I will upload images on next release for faster docker setup, and starting updating them simultaneously with this repo.
Many improvements across the board, the biggest is being able to start conversations simultaneously (again thanks to [wtlyu](https://github.com/wtlyu) for bringing it to my attention), as you can switch conversations or start a new chat without any response streaming from a prior one, as the backend will still process/save client responses. Just watch out for any rate limiting from OpenAI/Microsoft if this is done excessively.
Adding support for conversation search is next! Thank you [mysticaltech](https://github.com/mysticaltech) for bringing up a method I can use for this.
</details>
<details>
<summary><strong>2023-03-09</strong></summary>
Released v.0.0.2
Adds Sydney (jailbroken Bing AI) to the model menu. Thank you [DavesDevFails](https://github.com/DavesDevFails) for bringing it to my attention in this [issue](https://github.com/danny-avila/chatgpt-clone/issues/13). Bing/Sydney now correctly cite links, more styling to come. Fix some overlooked bugs, and model menu doesn't close upon deleting a customGpt.
I've re-enabled the ChatGPT browser client (free version) since it might be working for most people, it no longer works for me. Sydney is the best free route anyway.
</details>
<details>
<summary><strong>2023-03-07</strong></summary>
Due to increased interest in the repo, I've dockerized the app as of this update for quick setup! See setup instructions below. I realize this still takes some time with installing docker dependencies, so it's on the roadmap to have a deployed demo. Besides this, I've made major improvements for a lot of the existing features across the board, mainly UI/UX.
Also worth noting, the method to access the Free Version is no longer working, so I've removed it from model selection until further notice.
</details>
<details>
<summary><strong>Previous Updates</strong></summary>
<details>
@@ -45,21 +91,28 @@ Currently, this project is only functional with the `text-davinci-003` model.
</details>
# Table of Contents
* [Roadmap](#roadmap)
* [Features](#features)
* [Tech Stack](#tech-stack)
* [Getting Started](#getting-started)
* [Prerequisites](#prerequisites)
* [Usage](#usage)
* [Local (npm)](#npm)
* [Docker](#docker)
* [Access Tokens](#access-tokens)
* [Updating](#updating)
* [Use Cases](#use-cases)
* [Origin](#origin)
* [Caveats](#caveats)
* [Contributing](#contributing)
* [License](#license)
- [ChatGPT Clone](#chatgpt-clone)
- [All AI Conversations under One Roof.](#all-ai-conversations-under-one-roof)
- [Updates](#updates)
- [Table of Contents](#table-of-contents)
- [Roadmap](#roadmap)
- [Features](#features)
- [Tech Stack](#tech-stack)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Usage](#usage)
- [Local](#local)
- [Docker](#docker)
- [Access Tokens](#access-tokens)
- [Proxy](#proxy)
- [User System](#user-system)
- [Updating](#updating)
- [Use Cases](#use-cases)
- [Origin](#origin)
- [Caveats](#caveats)
- [Regarding use of Official ChatGPT API](#regarding-use-of-official-chatgpt-api)
- [Contributing](#contributing)
- [License](#license)
## Roadmap
@@ -79,15 +132,16 @@ Here are my recently completed and planned features:
- [x] Customize prompt prefix/label (custom ChatGPT using official API)
- [x] Server convo pagination (limit fetch and load more with 'show more' button)
- [x] Config file for easy startup (docker compose)
- [x] Mobile styling (thanks to [wtlyu](https://github.com/wtlyu))
- [x] Resubmit/edit sent messages (thanks to [wtlyu](https://github.com/wtlyu))
- [ ] Message Search
- [ ] Custom params for ChatGPT API (temp, top_p, presence_penalty)
- [ ] Bing AI Styling (params, suggested responses, convo end, etc.) - **In progress**
- [ ] Add warning before clearing convos
- [ ] Build test suite for CI/CD
- [ ] Conversation Search (by title)
- [ ] Resubmit/edit sent messages
- [ ] Semantic Search Option (requires more tokens)
- [ ] Bing AI Styling (for suggested responses, convo end, etc.)
- [ ] Prompt Templates/Search
- [ ] Refactor/clean up code (tech debt)
- [ ] Optional use of local storage for credentials
- [ ] Mobile styling (half-finished)
- [ ] Deploy demo
### Features
@@ -114,6 +168,7 @@ Here are my recently completed and planned features:
- npm
- Node.js >= 19.0.0
- MongoDB installed or [MongoDB Atlas](https://account.mongodb.com/account/login) (required if not using Docker)
- MongoDB does not support older ARM CPUs like those found in Raspberry Pis. However, you can make it work by setting MongoDB's version to mongo:4.4.18 in docker-compose.yml, the most recent version compatible with
- [Docker (optional)](https://www.docker.com/get-started/)
- [OpenAI API key](https://platform.openai.com/account/api-keys)
- BingAI, ChatGPT access tokens (optional, free AIs)
@@ -127,34 +182,24 @@ Here are my recently completed and planned features:
- If using MongoDB Atlas, remove `&w=majority` from default connection string.
### Local
- **Run npm** install in both the api and client directories
- **Run npm** ci in both the api and client directories
- **Provide** all credentials, (API keys, access tokens, and Mongo Connection String) in api/.env [(see .env example)](api/.env.example)
- **Run** `npm run build` in /client/ dir, `npm start` in /api/ dir
- **Visit** http://localhost:3080 (default port) & enjoy
By default, only local machine can access this server. To share within network or serve as a public server, set `HOST` to `0.0.0.0` in `.env` file
### Docker
- **Provide** all credentials, (API keys, access tokens, and Mongo Connection String) in [docker-compose.yml](docker-compose.yml) under api service
- **Build images** in both /api/ and /client/ directories (will eventually share through docker hub)
- `api/`
```bash
docker build -t node-api .
```
- `client/`
```bash
docker build -t react-client .
```
- **Run** `docker-compose build` in project root dir and then `docker-compose up` to start the app
- **Run** `docker-compose up` to start the app
- Note: MongoDB does not support older ARM CPUs like those found in Raspberry Pis. However, you can make it work by setting MongoDB's version to mongo:4.4.18 in docker-compose.yml, the most recent version compatible with
### Access Tokens
<details>
<summary><strong>ChatGPT Free Instructions</strong></summary>
**This has been disabled as is no longer working as of 3-07-23**
To get your Access token For ChatGPT 'Free Version', login to chat.openai.com, then visit https://chat.openai.com/api/auth/session.
@@ -169,8 +214,80 @@ The Bing Access Token is the "_U" cookie from bing.com. Use dev tools or an exte
**Note:** Specific error handling and styling for this model is still in progress.
</details>
### Proxy
If your server cannot connect to the chatGPT API server by some reason, (eg in China). You can set a environment variable `PROXY`. This will be transmitted to `node-chatgpt-api` interface.
**Warning:** `PROXY` is not `reverseProxyUrl` in `node-chatgpt-api`
<details>
<summary><strong>Set up proxy in local environment </strong></summary>
Here is two ways to set proxy.
- Option 1: system level environment
`export PROXY="http://127.0.0.1:7890"`
- Option 2: set in .env file
`PROXY="http://127.0.0.1:7890"`
**Change `http://127.0.0.1:7890` to your proxy server**
</details>
<details>
<summary><strong>Set up proxy in docker environment </strong></summary>
set in docker-compose.yml file, under services - api - environment
```
api:
...
environment:
...
- "PROXY=http://127.0.0.1:7890"
# add this line ↑
```
**Change `http://127.0.0.1:7890` to your proxy server**
</details>
### User System
By default, there is no user system enabled, so anyone can access your server.
**This project is not designed to provide a complete and full-featured user system.** It's not high priority task and might never be provided.
[wtlyu](https://github.com/wtlyu) provide a sample user system structure, that you can implement your own user system. It's simple and not a ready-for-use edition.
(If you want to implement your user system, open this ↓)
<details>
<summary><strong>Implement your own user system </strong></summary>
To enable the user system, set `ENABLE_USER_SYSTEM=1` in your `.env` file.
The sample structure is simple. It provide three basic endpoint:
1. `/auth/login` will redirect to your own login url. In the sample code, it's `/auth/your_login_page`.
2. `/auth/logout` will redirect to your own logout url. In the sample code, it's `/auth/your_login_page/logout`.
3. `/api/me` will return the userinfo: `{ username, display }`.
1. `username` will be used in db, used to distinguish between users.
2. `display` will be displayed in UI.
The only one thing that drive user system work is `req.session.user`. Once it's set, the client will be trusted. Set to `null` if logout.
Please refer to `/api/server/routes/authYourLogin.js` file. It's very clear and simple to tell you how to implement your user system.
Or you can ask chatGPT to write the code for you, here is one example to connect LDAP:
```
Please write me an express module, that serve the login and logout endpoint as a router. The login and logout uri is '/' and '/logout'. Once loginned, save display name and username in session.user, as {display, username}. Then redirect to '/'. Please write the code using express and other lib, and storage any server configuration in a config variable. I want the user to be connected to my LDAP server.
```
</details>
### Updating
- As the project is still a work-in-progress, you should pull the latest and run some of the steps above again
- As the project is still a work-in-progress, you should pull the latest and run the steps over. Reset your browser cache/clear site data.
## Use Cases ##

View File

@@ -1,2 +0,0 @@
/node_modules
.env

View File

@@ -1,7 +1,29 @@
OPENAI_KEY=
# Server configuration.
# The server will listen to localhost:3080 request by default. You can set the target ip as you want.
# If you want this server can be used outside your local machine, for example to share with other
# machine or expose this from a docker container, set HOST=0.0.0.0 or your external ip interface.
#
# Tips: HOST=0.0.0.0 means listening on all interface. It's not a real ip. Use localhost:port rather
# than 0.0.0.0:port to open it.
HOST=localhost
PORT=3080
NODE_ENV=development
# Change this to proxy any API request. It's useful if your machine have difficulty calling the original API server.
# PROXY="http://YOUR_PROXY_SERVER"
# Change this to your MongoDB URI if different and I recommend appending chatgpt-clone
MONGO_URI="mongodb://127.0.0.1:27017/chatgpt-clone"
CHATGPT_TOKEN=""
BING_TOKEN=""
# API key configuration.
# Leave blank if you don't want them.
OPENAI_KEY=
CHATGPT_TOKEN=
BING_TOKEN=
# User System
# global enable/disable the sample user system.
# this is not a ready to use user system.
# dont't use it, unless you can write your own code.
ENABLE_USER_SYSTEM=

39
api/.eslintrc.js Normal file
View File

@@ -0,0 +1,39 @@
module.exports = {
env: {
es2021: true,
node: true
},
extends: ['eslint:recommended'],
overrides: [],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
rules: {
indent: ['error', 2, { SwitchCase: 1 }],
'max-len': [
'error',
{
code: 150,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreComments: true
}
],
'linebreak-style': 0,
'arrow-parens': [2, 'as-needed', { requireForBlockBody: true }],
// 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }],
'no-console': 'off',
'import/extensions': 'off',
'no-use-before-define': [
'error',
{
functions: false
}
],
'no-promise-executor-return': 'off',
'no-param-reassign': 'off',
'no-continue': 'off',
'no-restricted-syntax': 'off'
}
};

View File

@@ -1,14 +0,0 @@
FROM node:19-alpine
WORKDIR /api
# copy package.json into the container at /api
COPY package*.json /api/
# install dependencies
RUN npm install
# Copy the current directory contents into the container at /api
COPY . /api/
# Make port 3080 available to the world outside this container
EXPOSE 3080
# Run the app when the container launches
CMD ["npm", "start"]
# docker build -t node-api .

View File

@@ -1,38 +0,0 @@
require('dotenv').config();
const Keyv = require('keyv');
const { Configuration, OpenAIApi } = require('openai');
const messageStore = new Keyv(process.env.MONGODB_URI, { namespace: 'chatgpt' });
const ask = async (question, progressCallback, convo) => {
const { ChatGPTAPI } = await import('chatgpt');
const api = new ChatGPTAPI({ apiKey: process.env.OPENAI_KEY, messageStore });
let options = {
onProgress: async (partialRes) => {
if (partialRes.text.length > 0) {
await progressCallback(partialRes);
}
}
};
if (!!convo.parentMessageId && !!convo.conversationId) {
options = { ...options, ...convo };
}
const res = await api.sendMessage(question, options);
return res;
};
const titleConvo = async (message, response, model) => {
const configuration = new Configuration({
apiKey: process.env.OPENAI_KEY
});
const openai = new OpenAIApi(configuration);
const completion = await openai.createCompletion({
model: 'text-davinci-002',
prompt: `Write a short title in title case, ideally in 5 words or less, and do not refer to the user or ${model}, that summarizes this conversation:\nUser:"${message}"\n${model}:"${response}"\nTitle: `
});
return completion.data.choices[0].text.replace(/\n/g, '');
};
module.exports = { ask, titleConvo };

View File

@@ -1,7 +1,7 @@
require('dotenv').config();
const { KeyvFile } = require('keyv-file');
const askBing = async ({ text, progressCallback, convo }) => {
const askBing = async ({ text, onProgress, convo }) => {
const { BingAIClient } = (await import('@waylaidwanderer/chatgpt-api'));
const bingAIClient = new BingAIClient({
@@ -10,19 +10,19 @@ const askBing = async ({ text, progressCallback, convo }) => {
// If the above doesn't work, provide all your cookies as a string instead
// cookies: '',
debug: false,
store: new KeyvFile({ filename: './data/cache.json' })
cache: { store: new KeyvFile({ filename: './data/cache.json' }) },
proxy: process.env.PROXY || null,
});
let options = {
onProgress: async (partialRes) => await progressCallback(partialRes),
};
let options = { onProgress };
if (convo) {
options = { ...options, ...convo };
}
const res = await bingAIClient.sendMessage(text, options
);
if (options?.jailbreakConversationId == 'false')
options.jailbreakConversationId = false
const res = await bingAIClient.sendMessage(text, options);
return res;

View File

@@ -3,12 +3,14 @@ const { KeyvFile } = require('keyv-file');
const clientOptions = {
// Warning: This will expose your access token to a third party. Consider the risks before using this.
reverseProxyUrl: 'https://chatgpt.duti.tech/api/conversation',
reverseProxyUrl: 'https://bypass.duti.tech/api/conversation',
// Access token from https://chat.openai.com/api/auth/session
accessToken: process.env.CHATGPT_TOKEN
accessToken: process.env.CHATGPT_TOKEN,
// debug: true
proxy: process.env.PROXY || null,
};
const browserClient = async ({ text, progressCallback, convo }) => {
const browserClient = async ({ text, onProgress, convo, abortController }) => {
const { ChatGPTBrowserClient } = await import('@waylaidwanderer/chatgpt-api');
const store = {
@@ -16,15 +18,17 @@ const browserClient = async ({ text, progressCallback, convo }) => {
};
const client = new ChatGPTBrowserClient(clientOptions, store);
let options = {
onProgress: async (partialRes) => await progressCallback(partialRes)
};
let options = { onProgress, abortController };
if (!!convo.parentMessageId && !!convo.conversationId) {
options = { ...options, ...convo };
}
/* will error if given a convoId at the start */
if (convo.parentMessageId.startsWith('0000')) {
delete options.conversationId;
}
const res = await client.sendMessage(text, options);
return res;
};

View File

@@ -5,20 +5,18 @@ const clientOptions = {
modelOptions: {
model: 'gpt-3.5-turbo'
},
proxy: process.env.PROXY || null,
debug: false
};
const askClient = async ({ text, progressCallback, convo }) => {
const askClient = async ({ text, onProgress, convo, abortController }) => {
const ChatGPTClient = (await import('@waylaidwanderer/chatgpt-api')).default;
const store = {
store: new KeyvFile({ filename: './data/cache.json' })
};
const client = new ChatGPTClient(process.env.OPENAI_KEY, clientOptions, store);
let options = {
onProgress: async (partialRes) => await progressCallback(partialRes)
};
let options = { onProgress, abortController };
if (!!convo.parentMessageId && !!convo.conversationId) {
options = { ...options, ...convo };

View File

@@ -5,10 +5,11 @@ const clientOptions = {
modelOptions: {
model: 'gpt-3.5-turbo'
},
proxy: process.env.PROXY || null,
debug: false
};
const customClient = async ({ text, progressCallback, convo, promptPrefix, chatGptLabel }) => {
const customClient = async ({ text, onProgress, convo, promptPrefix, chatGptLabel, abortController }) => {
const ChatGPTClient = (await import('@waylaidwanderer/chatgpt-api')).default;
const store = {
store: new KeyvFile({ filename: './data/cache.json' })
@@ -16,16 +17,13 @@ const customClient = async ({ text, progressCallback, convo, promptPrefix, chatG
clientOptions.chatGptLabel = chatGptLabel;
if (promptPrefix.length > 0) {
if (promptPrefix?.length > 0) {
clientOptions.promptPrefix = promptPrefix;
}
const client = new ChatGPTClient(process.env.OPENAI_KEY, clientOptions, store);
let options = {
onProgress: async (partialRes) => await progressCallback(partialRes)
};
let options = { onProgress, abortController };
if (!!convo.parentMessageId && !!convo.conversationId) {
options = { ...options, ...convo };
}

36
api/app/clients/sydney.js Normal file
View File

@@ -0,0 +1,36 @@
require('dotenv').config();
const { KeyvFile } = require('keyv-file');
const askSydney = async ({ text, onProgress, convo }) => {
const { BingAIClient } = (await import('@waylaidwanderer/chatgpt-api'));
const sydneyClient = new BingAIClient({
// "_U" cookie from bing.com
userToken: process.env.BING_TOKEN,
// If the above doesn't work, provide all your cookies as a string instead
// cookies: '',
debug: false,
cache: { store: new KeyvFile({ filename: './data/cache.json' }) }
});
let options = {
jailbreakConversationId: true,
onProgress,
};
if (convo.jailbreakConversationId) {
options = { ...options, jailbreakConversationId: convo.jailbreakConversationId, parentMessageId: convo.parentMessageId };
}
console.log('sydney options', options);
const res = await sydneyClient.sendMessage(text, options
);
return res;
// for reference:
// https://github.com/waylaidwanderer/node-chatgpt-api/blob/main/demos/use-bing-client.js
};
module.exports = { askSydney };

View File

@@ -1,54 +0,0 @@
const { ModelOperations } = require('@vscode/vscode-languagedetection');
const codeRegex = /(```[\s\S]*?```)/g;
const languageMatch = /```(\w+)/;
const detectCode = async (text) => {
try {
if (!text.match(codeRegex)) {
// console.log('disqualified for non-code match')
return text;
}
if (text.match(languageMatch)) {
// console.log('disqualified for language match')
return text;
}
// console.log('qualified for code match');
const modelOperations = new ModelOperations();
const regexSplit = (await import('./regexSplit.mjs')).default;
const parts = regexSplit(text, codeRegex);
const output = parts.map(async (part) => {
if (part.match(codeRegex)) {
const code = part.slice(3, -3);
const language = await modelOperations.runModel(code);
return part.replace(/^```/, `\`\`\`${language[0].languageId}`);
} else {
// return i > 0 ? '\n' + part : part;
return part;
}
});
return (await Promise.all(output)).join('');
} catch (e) {
console.log('Error in detectCode function\n', e);
return text;
}
};
// const example3 = {
// text: "By default, the function generates an 8-character password with uppercase and lowercase letters and digits, but no special characters.\n\nTo use this function, simply call it with the desired arguments. For example:\n\n```\n>>> generate_password()\n'wE5pUxV7'\n>>> generate_password(length=12, special_chars=True)\n'M4v&^gJ*8#qH'\n>>> generate_password(uppercase=False, digits=False)\n'zajyprxr'\n``` \n\nNote that the randomness is used to select characters from the available character sets, but the resulting password is always deterministic given the same inputs. This makes the function useful for generating secure passwords that meet specific requirements."
// };
// const example4 = {
// text: 'here\'s a cool function:\n```\nimport random\nimport string\n\ndef generate_password(length=8, uppercase=True, lowercase=True, digits=True, special_chars=False):\n """Generate a random password with specified requirements.\n\n Args:\n length (int): The length of the password. Default is 8.\n uppercase (bool): Whether to include uppercase letters. Default is True.\n lowercase (bool): Whether to include lowercase letters. Default is True.\n digits (bool): Whether to include digits. Default is True.\n special_chars (bool): Whether to include special characters. Default is False.\n\n Returns:\n str: A random password with the specified requirements.\n """\n # Define character sets to use in password generation\n chars = ""\n if uppercase:\n chars += string.ascii_uppercase\n if lowercase:\n chars += string.ascii_lowercase\n if digits:\n chars += string.digits\n if special_chars:\n chars += string.punctuation\n\n # Generate the password\n password = "".join(random.choice(chars) for _ in range(length))\n return password\n```\n\nThis function takes several arguments'
// };
// write an immediately invoked function to test this
// (async () => {
// const result = await detectCode(example3.text);
// console.log(result);
// })();
module.exports = detectCode;

View File

@@ -1,15 +1,21 @@
const { askClient } = require('./chatgpt-client');
const { browserClient } = require('./chatgpt-browser');
const customClient = require('./chatgpt-custom');
const { askBing } = require('./bingai');
const { askClient } = require('./clients/chatgpt-client');
const { browserClient } = require('./clients/chatgpt-browser');
const { askBing } = require('./clients/bingai');
const { askSydney } = require('./clients/sydney');
const customClient = require('./clients/chatgpt-custom');
const titleConvo = require('./titleConvo');
const detectCode = require('./detectCode');
const getCitations = require('../lib/parse/getCitations');
const citeText = require('../lib/parse/citeText');
const detectCode = require('../lib/parse/detectCode');
module.exports = {
askClient,
browserClient,
customClient,
askBing,
askSydney,
titleConvo,
getCitations,
citeText,
detectCode
};

View File

@@ -1,24 +1,62 @@
const { Configuration, OpenAIApi } = require('openai');
const _ = require('lodash');
const titleConvo = async ({ message, response, model }) => {
const configuration = new Configuration({
apiKey: process.env.OPENAI_KEY
});
const openai = new OpenAIApi(configuration);
const completion = await openai.createChatCompletion({
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content:
'You are a title-generator with one job: titling the conversation provided by a user in title case.'
},
{ role: 'user', content: `In 5 words or less, summarize the conversation below with a title in title case. Don't refer to the participants of the conversation by name. Do not include punctuation or quotation marks. Your response should be in title case, exclusively containing the title. Conversation:\n\nUser: "${message}"\n\n${model}: "${response}"\n\nTitle: ` },
]
});
const proxyEnvToAxiosProxy = (proxyString) => {
if (!proxyString) return null;
//eslint-disable-next-line
return completion.data.choices[0].message.content.replace(/["\.]/g, '');
const regex = /^([^:]+):\/\/(?:([^:@]*):?([^:@]*)@)?([^:]+)(?::(\d+))?/;
const [, protocol, username, password, host, port] = proxyString.match(regex);
const proxyConfig = {
protocol,
host,
port: port ? parseInt(port) : undefined,
auth: username && password ? { username, password } : undefined
};
return proxyConfig;
};
module.exports = titleConvo;
const titleConvo = async ({ model, text, response }) => {
let title = 'New Chat';
try {
const configuration = new Configuration({
apiKey: process.env.OPENAI_KEY
});
const openai = new OpenAIApi(configuration);
const completion = await openai.createChatCompletion(
{
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content:
'You are a title-generator with one job: giving a conversation, detect the language and titling the conversation provided by a user in title case, using the same language.'
},
{
role: 'user',
content: `In 5 words or less, summarize the conversation below with a title in title case using the language the user writes in. Don't refer to the participants of the conversation nor the language. Do not include punctuation or quotation marks. Your response should be in title case, exclusively containing the title. Conversation:\n\nUser: "${text}"\n\n${model}: "${JSON.stringify(
response?.text
)}"\n\nTitle: `
}
],
temperature: 0,
presence_penalty: 0,
frequency_penalty: 0,
},
{ proxy: proxyEnvToAxiosProxy(process.env.PROXY || null) }
);
//eslint-disable-next-line
title = completion.data.choices[0].message.content.replace(/["\.]/g, '');
} catch (e) {
console.error(e);
console.log('There was an issue generating title, see error above');
}
console.log('CONVERSATION TITLE', title);
return title;
};
const throttledTitleConvo = _.throttle(titleConvo, 1000);
module.exports = throttledTitleConvo;

View File

@@ -17,7 +17,7 @@ if (!cached) {
cached = global.mongoose = { conn: null, promise: null };
}
async function dbConnect() {
async function connectDb() {
if (cached.conn) {
return cached.conn;
}
@@ -41,4 +41,4 @@ async function dbConnect() {
return cached.conn;
}
module.exports = dbConnect;
module.exports = connectDb;

63
api/lib/db/migrateDb.js Normal file
View File

@@ -0,0 +1,63 @@
const mongoose = require('mongoose');
const { Conversation, } = require('../../models/Conversation');
const { getMessages, } = require('../../models/');
async function migrateDb() {
try {
const conversations = await Conversation.find({ model: null }).exec();
if (!conversations || conversations.length === 0)
return { message: '[Migrate] No conversations to migrate' };
for (let convo of conversations) {
const messages = await getMessages({
conversationId: convo.conversationId,
messageId: { $exists: false }
});
let model;
let oldId;
const promises = [];
messages.forEach((message, i) => {
const msgObj = message.toObject();
const newId = msgObj.id;
if (i === 0) {
message.parentMessageId = '00000000-0000-0000-0000-000000000000';
} else {
message.parentMessageId = oldId;
}
oldId = newId;
message.messageId = newId;
if (message.sender.toLowerCase() !== 'user' && !model) {
model = message.sender.toLowerCase();
}
if (message.sender.toLowerCase() === 'user') {
message.isCreatedByUser = true;
}
promises.push(message.save());
});
await Promise.all(promises);
await Conversation.findOneAndUpdate(
{ conversationId: convo.conversationId },
{ model },
{ new: true }
).exec();
}
try {
await mongoose.connection.db.collection('messages').dropIndex('id_1');
} catch (error) {
console.log("[Migrate] Index doesn't exist or already dropped");
}
} catch (error) {
console.log(error);
return { message: '[Migrate] Error migrating conversations' };
}
}
module.exports = migrateDb;

190
api/lib/db/mongoMeili.js Normal file
View File

@@ -0,0 +1,190 @@
const { MeiliSearch } = require('meilisearch');
const _ = require('lodash');
const validateOptions = function (options) {
const requiredKeys = ['host', 'apiKey', 'indexName'];
requiredKeys.forEach((key) => {
if (!options[key]) throw new Error(`Missing mongoMeili Option: ${key}`);
});
};
const createMeiliMongooseModel = function ({ index, indexName, client, attributesToIndex }) {
// console.log('attributesToIndex', attributesToIndex);
const primaryKey = attributesToIndex[0];
// MeiliMongooseModel is of type Mongoose.Model
class MeiliMongooseModel {
// Clear Meili index
static async clearMeiliIndex() {
await index.delete();
// await index.deleteAllDocuments();
await this.collection.updateMany(
{ _meiliIndex: true },
{ $set: { _meiliIndex: false } }
);
}
static async resetIndex() {
await this.clearMeiliIndex();
await client.createIndex(indexName, { primaryKey });
}
// Clear Meili index
// Push a mongoDB collection to Meili index
static async syncWithMeili() {
await this.resetIndex();
// const docs = await this.find();
const docs = await this.find({ _meiliIndex: { $in: [null, false] } });
console.log('docs', docs.length);
await Promise.all(
docs.map(function (doc) {
return doc.addObjectToMeili();
})
);
}
// Set one or more settings of the meili index
static async setMeiliIndexSettings(settings) {
return await index.updateSettings(settings);
}
// Search the index
static async meiliSearch(q, params, populate) {
const data = await index.search(q, params);
// Populate hits with content from mongodb
if (populate) {
// Find objects into mongodb matching `objectID` from Meili search
const query = {};
query[primaryKey] = { $in: _.map(data.hits, primaryKey) };
const hitsFromMongoose = await this.find(
query,
_.reduce(
this.schema.obj,
function (results, value, key) {
return { ...results, [key]: 1 };
},
{ _id: 1 }
),
);
// Add additional data from mongodb into Meili search hits
const populatedHits = data.hits.map(function (hit) {
const query = {};
query[primaryKey] = hit[primaryKey];
const originalHit = _.find(hitsFromMongoose, query);
return {
...(originalHit ? originalHit.toJSON() : {}),
...hit
};
});
data.hits = populatedHits;
}
return data;
}
// Push new document to Meili
async addObjectToMeili() {
const object = _.pick(this.toJSON(), attributesToIndex);
try {
// console.log('Adding document to Meili', object);
await index.addDocuments([object]);
} catch (error) {
console.log('Error adding document to Meili');
console.error(error);
}
await this.collection.updateMany({ _id: this._id }, { $set: { _meiliIndex: true } });
}
// Update an existing document in Meili
async updateObjectToMeili() {
const object = pick(this.toJSON(), attributesToIndex);
await index.updateDocuments([object]);
}
// Delete a document from Meili
async deleteObjectFromMeili() {
await index.deleteDocument(this._id);
}
// * schema.post('save')
postSaveHook() {
if (this._meiliIndex) {
this.updateObjectToMeili();
} else {
this.addObjectToMeili();
}
}
// * schema.post('update')
postUpdateHook() {
if (this._meiliIndex) {
this.updateObjectToMeili();
}
}
// * schema.post('remove')
postRemoveHook() {
if (this._meiliIndex) {
this.deleteObjectFromMeili();
}
}
}
return MeiliMongooseModel;
};
module.exports = function mongoMeili(schema, options) {
// Vaidate Options for mongoMeili
validateOptions(options);
// Add meiliIndex to schema
schema.add({
_meiliIndex: {
type: Boolean,
required: false,
select: false,
default: false
}
});
const { host, apiKey, indexName, primaryKey } = options;
// Setup MeiliSearch Client
const client = new MeiliSearch({ host, apiKey });
// Asynchronously create the index
client.createIndex(indexName, { primaryKey });
// Setup the index to search for this schema
const index = client.index(indexName);
const attributesToIndex = [
..._.reduce(
schema.obj,
function (results, value, key) {
return value.meiliIndex ? [...results, key] : results;
// }, []), '_id'];
},
[]
)
];
schema.loadClass(createMeiliMongooseModel({ index, indexName, client, attributesToIndex }));
// Register hooks
schema.post('save', function (doc) {
doc.postSaveHook();
});
schema.post('update', function (doc) {
doc.postUpdateHook();
});
schema.post('remove', function (doc) {
doc.postRemoveHook();
});
schema.post('findOneAndUpdate', function (doc) {
doc.postSaveHook();
});
};

31
api/lib/parse/citeText.js Normal file
View File

@@ -0,0 +1,31 @@
const citationRegex = /\[\^\d+?\^]/g;
const citeText = (res, noLinks = false) => {
let result = res.text || res;
const citations = Array.from(new Set(result.match(citationRegex)));
if (citations?.length === 0) return result;
if (noLinks) {
citations.forEach((citation) => {
const digit = citation.match(/\d+?/g)[0];
// result = result.replaceAll(citation, `<sup>[${digit}](#) </sup>`);
result = result.replaceAll(citation, `<sup>[${digit}](#) </sup>`);
});
return result;
}
let sources = res.details.sourceAttributions;
if (sources?.length === 0) return result;
sources = sources.map((source) => source.seeMoreUrl);
citations.forEach((citation) => {
const digit = citation.match(/\d+?/g)[0];
result = result.replaceAll(citation, `<sup>[${digit}](${sources[digit - 1]}) </sup>`);
// result = result.replaceAll(citation, `<sup>[${digit}](${sources[digit - 1]}) </sup>`);
});
return result;
};
module.exports = citeText;

View File

@@ -0,0 +1,52 @@
const { ModelOperations } = require('@vscode/vscode-languagedetection');
const languages = require('./languages.js');
const codeRegex = /(```[\s\S]*?```)/g;
// const languageMatch = /```(\w+)/;
const replaceRegex = /```\w+\n/g;
const detectCode = async (input) => {
try {
let text = input;
if (!text.match(codeRegex)) {
return text;
}
const langMatches = text.match(replaceRegex);
if (langMatches?.length > 0) {
langMatches.forEach(match => {
let lang = match.split('```')[1].trim();
if (languages.has(lang)) {
return;
}
console.log('[detectCode.js] replacing', match, 'with', '```shell');
text = text.replace(match, '```shell\n');
});
return text;
}
const modelOperations = new ModelOperations();
const regexSplit = (await import('./regexSplit.mjs')).default;
const parts = regexSplit(text, codeRegex);
const output = parts.map(async (part) => {
if (part.match(codeRegex)) {
const code = part.slice(3, -3);
let lang = (await modelOperations.runModel(code))[0].languageId;
return part.replace(/^```/, `\`\`\`${languages.has(lang) ? lang : 'shell'}`);
} else {
return part;
}
});
return (await Promise.all(output)).join('');
} catch (e) {
console.log('Error in detectCode function\n', e);
return input;
}
};
module.exports = detectCode;

View File

@@ -0,0 +1,13 @@
// const regex = / \[\d+\..*?\]\(.*?\)/g;
const regex = / \[.*?]\(.*?\)/g;
const getCitations = (res) => {
const textBlocks = res.details.adaptiveCards[0].body;
if (!textBlocks) return '';
let links = textBlocks[textBlocks.length - 1]?.text.match(regex);
if (links?.length === 0 || !links) return '';
links = links.map((link) => link.trim());
return links.join('\n');
};
module.exports = getCitations;

318
api/lib/parse/languages.js Normal file
View File

@@ -0,0 +1,318 @@
const languages = new Set([
'adoc',
'apacheconf',
'arm',
'as',
'asc',
'atom',
'bat',
'bf',
'bind',
'c++',
'capnp',
'cc',
'clj',
'cls',
'cmake.in',
'cmd',
'coffee',
'console',
'cr',
'craftcms',
'crm',
'cs',
'cson',
'cts',
'cxx',
'dfm',
'docker',
'dst',
'erl',
'f90',
'f95',
'fs',
'gawk',
'gemspec',
'gms',
'golang',
'gololang',
'gss',
'gyp',
'h',
'h++',
'hbs',
'hh',
'hpp',
'hs',
'html',
'html.handlebars',
'html.hbs',
'https',
'hx',
'hxx',
'hylang',
'i7',
'iced',
'ino',
'instances',
'irb',
'jinja',
'js',
'jsp',
'jsx',
'julia-repl',
'kdb',
'kt',
'lassoscript',
'ls',
'ls',
'mak',
'make',
'mawk',
'md',
'mipsasm',
'mk',
'mkd',
'mkdown',
'ml',
'ml',
'mm',
'mma',
'moon',
'mts',
'nawk',
'nc',
'nginxconf',
'nimrod',
'objc',
'obj-c',
'obj-c++',
'objective-c++',
'osascript',
'pas',
'pascal',
'patch',
'pcmk',
'pf.conf',
'pl',
'plist',
'pm',
'podspec',
'postgres',
'postgresql',
'pp',
'ps',
'ps1',
'py',
'pycon',
'rb',
're',
'rs',
'rss',
'sas',
'scad',
'sci',
'sh',
'st',
'stanfuncs',
'step',
'stp',
'styl',
'svg',
'tao',
'text',
'thor',
'tk',
'toml',
'ts',
'tsx',
'txt',
'v',
'vb',
'vbs',
'wl',
'x++',
'xhtml',
'xjb',
'xls',
'xlsx',
'xpath',
'xq',
'xsd',
'xsl',
'yaml',
'zep',
'zone',
'zsh',
'1c',
'abnf',
'accesslog',
'actionscript',
'ada',
'angelscript',
'apache',
'applescript',
'arcade',
'arduino',
'armasm',
'asciidoc',
'aspectj',
'autohotkey',
'autoit',
'avrasm',
'awk',
'axapta',
'bash',
'basic',
'bnf',
'brainfuck',
'c',
'cal',
'capnproto',
'clojure',
'cmake',
'coffeescript',
'coq',
'cos',
'cpp',
'crmsh',
'crystal',
'csharp',
'csp',
'css',
'd',
'dart',
'diff',
'django',
'dns',
'dockerfile',
'dos',
'dpr',
'dsconfig',
'dts',
'dust',
'ebnf',
'elixir',
'elm',
'erlang',
'excel',
'fix',
'fortran',
'fsharp',
'gams',
'gauss',
'gcode',
'gherkin',
'glsl',
'go',
'golo',
'gradle',
'graph',
'graphql',
'groovy',
'haml',
'handlebars',
'haskell',
'haxe',
'http',
'hy',
'inform7',
'ini',
'irpf90',
'java',
'javascript',
'json',
'julia',
'k',
'kotlin',
'lasso',
'ldif',
'leaf',
'less',
'lisp',
'livecodeserver',
'livescript',
'lua',
'makefile',
'markdown',
'mathematica',
'matlab',
'maxima',
'mel',
'mercury',
'mips',
'mizar',
'mojolicious',
'monkey',
'moonscript',
'n1ql',
'nginx',
'nim',
'nix',
'nsis',
'objectivec',
'ocaml',
'openscad',
'oxygene',
'p21',
'parser3',
'perl',
'pf',
'pgsql',
'php',
'plaintext',
'pony',
'powershell',
'processing',
'profile',
'prolog',
'properties',
'protobuf',
'puppet',
'python',
'python-repl',
'qml',
'r',
'reasonml',
'rib',
'rsl',
'ruby',
'ruleslanguage',
'rust',
'SAS',
'scala' ,
'scheme',
'scilab',
'scss',
'shell',
'smali',
'smalltalk',
'sml',
'sql',
'stan',
'stata',
'stylus',
'subunit',
'swift',
'tap',
'tcl',
'tex',
'thrift',
'tp',
'twig',
'typescript',
'vala',
'vbnet',
'vbscript',
'verilog',
'vhdl',
'vim',
'x86asm',
'xl',
'xml',
'xquery',
'yml',
'zephir',
]);
module.exports = languages;

View File

@@ -0,0 +1,29 @@
function mergeSort(arr, compareFn) {
if (arr.length <= 1) {
return arr;
}
const mid = Math.floor(arr.length / 2);
const leftArr = arr.slice(0, mid);
const rightArr = arr.slice(mid);
return merge(mergeSort(leftArr, compareFn), mergeSort(rightArr, compareFn), compareFn);
}
function merge(leftArr, rightArr, compareFn) {
const result = [];
let leftIndex = 0;
let rightIndex = 0;
while (leftIndex < leftArr.length && rightIndex < rightArr.length) {
if (compareFn(leftArr[leftIndex], rightArr[rightIndex]) < 0) {
result.push(leftArr[leftIndex++]);
} else {
result.push(rightArr[rightIndex++]);
}
}
return result.concat(leftArr.slice(leftIndex)).concat(rightArr.slice(rightIndex));
}
module.exports = mergeSort;

View File

@@ -0,0 +1,57 @@
const mergeSort = require('./mergeSort');
function reduceMessages(hits) {
const counts = {};
for (const hit of hits) {
if (!counts[hit.conversationId]) {
counts[hit.conversationId] = 1;
} else {
counts[hit.conversationId]++;
}
}
const result = [];
for (const [conversationId, count] of Object.entries(counts)) {
result.push({
conversationId,
count
});
}
return mergeSort(result, (a, b) => b.count - a.count);
}
function reduceHits(hits, titles = []) {
const counts = {};
const titleMap = {};
const convos = [...hits, ...titles];
for (const convo of convos) {
if (!counts[convo.conversationId]) {
counts[convo.conversationId] = 1;
} else {
counts[convo.conversationId]++;
}
if (convo.title) {
// titleMap[convo.conversationId] = convo._formatted.title;
titleMap[convo.conversationId] = convo.title;
}
}
const result = [];
for (const [conversationId, count] of Object.entries(counts)) {
result.push({
conversationId,
count,
title: titleMap[conversationId] ? titleMap[conversationId] : null
});
}
return mergeSort(result, (a, b) => b.count - a.count);
}
module.exports = { reduceMessages, reduceHits };

84
api/models/Config.js Normal file
View File

@@ -0,0 +1,84 @@
const mongoose = require('mongoose');
const major = [0, 0];
const minor = [0, 0];
const patch = [0, 5];
const configSchema = mongoose.Schema(
{
tag: {
type: String,
required: true,
validate: {
validator: function (tag) {
const [part1, part2, part3] = tag.replace('v', '').split('.').map(Number);
// Check if all parts are numbers
if (isNaN(part1) || isNaN(part2) || isNaN(part3)) {
return false;
}
// Check if all parts are within their respective ranges
if (part1 < major[0] || part1 > major[1]) {
return false;
}
if (part2 < minor[0] || part2 > minor[1]) {
return false;
}
if (part3 < patch[0] || part3 > patch[1]) {
return false;
}
return true;
},
message: 'Invalid tag value'
}
},
searchEnabled: {
type: Boolean,
default: false
},
usersEnabled: {
type: Boolean,
default: false
},
startupCounts: {
type: Number,
default: 0
}
},
{ timestamps: true }
);
// Instance method
ConfigSchema.methods.incrementCount = function () {
this.startupCounts += 1;
};
// Static methods
ConfigSchema.statics.findByTag = async function (tag) {
return await this.findOne({ tag });
};
ConfigSchema.statics.updateByTag = async function (tag, update) {
return await this.findOneAndUpdate({ tag }, update, { new: true });
};
const Config = mongoose.models.Config || mongoose.model('Config', configSchema);
module.exports = {
getConfigs: async (filter) => {
try {
return await Config.find(filter).exec();
} catch (error) {
console.error(error);
return { config: 'Error getting configs' };
}
},
deleteConfigs: async (filter) => {
try {
return await Config.deleteMany(filter).exec();
} catch (error) {
console.error(error);
return { config: 'Error deleting configs' };
}
}
};

View File

@@ -1,60 +1,103 @@
const mongoose = require('mongoose');
const mongoMeili = require('../lib/db/mongoMeili');
const { getMessages, deleteMessages } = require('./Message');
const convoSchema = mongoose.Schema({
conversationId: {
type: String,
unique: true,
required: true
const convoSchema = mongoose.Schema(
{
conversationId: {
type: String,
unique: true,
required: true,
index: true,
meiliIndex: true
},
parentMessageId: {
type: String,
required: true
},
title: {
type: String,
default: 'New Chat',
meiliIndex: true
},
jailbreakConversationId: {
type: String,
default: null
},
conversationSignature: {
type: String,
default: null
},
clientId: {
type: String
},
invocationId: {
type: String
},
chatGptLabel: {
type: String,
default: null
},
promptPrefix: {
type: String,
default: null
},
model: {
type: String,
required: true
},
user: {
type: String
},
suggestions: [{ type: String }],
messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }]
},
parentMessageId: {
type: String,
required: true
},
title: {
type: String,
default: 'New conversation'
},
conversationSignature: {
type: String
},
clientId: {
type: String
},
invocationId: {
type: String
},
chatGptLabel: {
type: String
},
promptPrefix: {
type: String
},
model: {
type: String
},
suggestions: [{ type: String }],
messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }],
created: {
type: Date,
default: Date.now
}
});
{ timestamps: true }
);
// convoSchema.plugin(mongoMeili, {
// host: process.env.MEILI_HOST,
// apiKey: process.env.MEILI_KEY,
// indexName: 'convos', // Will get created automatically if it doesn't exist already
// primaryKey: 'conversationId'
// });
const Conversation =
mongoose.models.Conversation || mongoose.model('Conversation', convoSchema);
const getConvo = async (user, conversationId) => {
try {
return await Conversation.findOne({ user, conversationId }).exec();
} catch (error) {
console.log(error);
return { message: 'Error getting single conversation' };
}
};
module.exports = {
saveConvo: async ({ conversationId, title, ...convo }) => {
Conversation,
saveConvo: async (user, { conversationId, newConversationId, title, ...convo }) => {
try {
const messages = await getMessages({ conversationId });
const update = { ...convo, messages };
if (title) {
update.title = title;
update.user = user;
}
if (newConversationId) {
update.conversationId = newConversationId;
}
if (!update.jailbreakConversationId) {
update.jailbreakConversationId = null;
}
if (update.model !== 'chatgptCustom' && update.chatGptLabel && update.promptPrefix) {
console.log('Validation error: resetting chatgptCustom fields', update);
update.chatGptLabel = null;
update.promptPrefix = null;
}
return await Conversation.findOneAndUpdate(
{ conversationId },
{ conversationId: conversationId, user },
{ $set: update },
{ new: true, upsert: true }
).exec();
@@ -63,45 +106,108 @@ module.exports = {
return { message: 'Error saving conversation' };
}
},
updateConvo: async ({ conversationId, ...update }) => {
updateConvo: async (user, { conversationId, ...update }) => {
try {
return await Conversation.findOneAndUpdate({ conversationId }, update, {
new: true
}).exec();
return await Conversation.findOneAndUpdate(
{ conversationId: conversationId, user },
update,
{
new: true
}
).exec();
} catch (error) {
console.log(error);
return { message: 'Error updating conversation' };
}
},
// getConvos: async () => await Conversation.find({}).sort({ created: -1 }).exec(),
getConvos: async (pageNumber = 1, pageSize = 12) => {
getConvosByPage: async (user, pageNumber = 1, pageSize = 12) => {
try {
const skip = (pageNumber - 1) * pageSize;
// const limit = pageNumber * pageSize;
const conversations = await Conversation.find({})
.sort({ created: -1 })
.skip(skip)
// .limit(limit)
const totalConvos = (await Conversation.countDocuments({ user })) || 1;
const totalPages = Math.ceil(totalConvos / pageSize);
const convos = await Conversation.find({ user })
.sort({ createdAt: -1, created: -1 })
.skip((pageNumber - 1) * pageSize)
.limit(pageSize)
.exec();
return conversations;
return { conversations: convos, pages: totalPages, pageNumber, pageSize };
} catch (error) {
console.log(error);
return { message: 'Error getting conversations' };
}
},
getConvo: async (conversationId) => {
getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 12) => {
try {
return await Conversation.findOne({ conversationId }).exec();
if (!convoIds || convoIds.length === 0) {
return { conversations: [], pages: 1, pageNumber, pageSize };
}
const cache = {};
const promises = [];
// will handle a syncing solution soon
const deletedConvoIds = [];
convoIds.forEach((convo) =>
promises.push(
Conversation.findOne({
user,
conversationId: convo.conversationId
}).exec()
)
);
const results = (await Promise.all(promises)).filter((convo, i) => {
if (!convo) {
deletedConvoIds.push(convoIds[i].conversationId);
return false;
} else {
const page = Math.floor(i / pageSize) + 1;
if (!cache[page]) {
cache[page] = [];
}
cache[page].push(convo);
return true;
}
});
// const startIndex = (pageNumber - 1) * pageSize;
// const convos = results.slice(startIndex, startIndex + pageSize);
const totalPages = Math.ceil(results.length / pageSize);
cache.pages = totalPages;
cache.pageSize = pageSize;
return {
cache,
conversations: cache[pageNumber] || [],
pages: totalPages || 1,
pageNumber,
pageSize,
// will handle a syncing solution soon
filter: new Set(deletedConvoIds),
};
} catch (error) {
console.log(error);
return { message: 'Error getting single conversation' };
return { message: 'Error fetching conversations' };
}
},
deleteConvos: async (filter) => {
let deleteCount = await Conversation.deleteMany(filter).exec();
getConvo,
/* chore: this method is not properly error handled */
getConvoTitle: async (user, conversationId) => {
try {
const convo = await getConvo(user, conversationId);
/* ChatGPT Browser was triggering error here due to convo being saved later */
if (convo && !convo.title) {
return null;
} else {
// TypeError: Cannot read properties of null (reading 'title')
return convo?.title || 'New Chat';
}
} catch (error) {
console.log(error);
return 'Error getting conversation title';
}
},
deleteConvos: async (user, filter) => {
let deleteCount = await Conversation.deleteMany({ ...filter, user }).exec();
deleteCount.messages = await deleteMessages(filter);
return deleteCount;
}

View File

@@ -12,20 +12,20 @@ const customGptSchema = mongoose.Schema({
type: String,
required: true
},
created: {
type: Date,
default: Date.now
}
});
user: {
type: String
},
}, { timestamps: true });
const CustomGpt = mongoose.models.CustomGpt || mongoose.model('CustomGpt', customGptSchema);
const createCustomGpt = async ({ chatGptLabel, promptPrefix, value }) => {
const createCustomGpt = async ({ chatGptLabel, promptPrefix, value, user }) => {
try {
await CustomGpt.create({
chatGptLabel,
promptPrefix,
value
value,
user
});
return { chatGptLabel, promptPrefix, value };
} catch (error) {
@@ -35,22 +35,22 @@ const createCustomGpt = async ({ chatGptLabel, promptPrefix, value }) => {
};
module.exports = {
getCustomGpts: async (filter) => {
getCustomGpts: async (user, filter) => {
try {
return await CustomGpt.find(filter).exec();
return await CustomGpt.find({ ...filter, user }).exec();
} catch (error) {
console.error(error);
return { customGpt: 'Error getting customGpts' };
}
},
updateCustomGpt: async ({ value, ...update }) => {
updateCustomGpt: async (user, { value, ...update }) => {
try {
const customGpt = await CustomGpt.findOne({ value }).exec();
const customGpt = await CustomGpt.findOne({ value, user }).exec();
if (!customGpt) {
return await createCustomGpt({ value, ...update });
return await createCustomGpt({ value, ...update, user });
} else {
return await CustomGpt.findOneAndUpdate({ value }, update, {
return await CustomGpt.findOneAndUpdate({ value, user }, update, {
new: true,
upsert: true
}).exec();
@@ -60,9 +60,9 @@ module.exports = {
return { message: 'Error updating customGpt' };
}
},
updateByLabel: async ({ prevLabel, ...update }) => {
updateByLabel: async (user, { prevLabel, ...update }) => {
try {
return await CustomGpt.findOneAndUpdate({ chatGptLabel: prevLabel }, update, {
return await CustomGpt.findOneAndUpdate({ chatGptLabel: prevLabel, user }, update, {
new: true,
upsert: true
}).exec();
@@ -71,9 +71,9 @@ module.exports = {
return { message: 'Error updating customGpt' };
}
},
deleteCustomGpts: async (filter) => {
deleteCustomGpts: async (user, filter) => {
try {
return await CustomGpt.deleteMany(filter).exec();
return await CustomGpt.deleteMany({ ...filter, user }).exec();
} catch (error) {
console.error(error);
return { customGpt: 'Error deleting customGpts' };

View File

@@ -1,14 +1,18 @@
const mongoose = require('mongoose');
const mongoMeili = require('../lib/db/mongoMeili');
const messageSchema = mongoose.Schema({
id: {
messageId: {
type: String,
unique: true,
required: true
required: true,
index: true,
meiliIndex: true
},
conversationId: {
type: String,
required: true
required: true,
meiliIndex: true
},
conversationSignature: {
type: String,
@@ -26,39 +30,90 @@ const messageSchema = mongoose.Schema({
},
sender: {
type: String,
required: true
required: true,
meiliIndex: true
},
text: {
type: String,
required: true
required: true,
meiliIndex: true
},
created: {
type: Date,
default: Date.now
isCreatedByUser: {
type: Boolean,
required: true,
default: false
},
error: {
type: Boolean,
default: false
},
_meiliIndex: {
type: Boolean,
required: false,
select: false,
default: false
}
});
}, { timestamps: true });
// messageSchema.plugin(mongoMeili, {
// host: process.env.MEILI_HOST,
// apiKey: process.env.MEILI_KEY,
// indexName: 'messages', // Will get created automatically if it doesn't exist already
// primaryKey: 'messageId',
// });
const Message = mongoose.models.Message || mongoose.model('Message', messageSchema);
module.exports = {
saveMessage: async ({ id, conversationId, parentMessageId, sender, text }) => {
messageSchema,
Message,
saveMessage: async ({ messageId, conversationId, parentMessageId, sender, text, isCreatedByUser=false, error }) => {
try {
await Message.create({
id,
await Message.findOneAndUpdate({ messageId }, {
conversationId,
parentMessageId,
sender,
text
});
return { id, conversationId, parentMessageId, sender, text };
text,
isCreatedByUser,
error
}, { upsert: true, new: true });
return { messageId, conversationId, parentMessageId, sender, text, isCreatedByUser };
} catch (error) {
console.error(error);
return { message: 'Error saving message' };
}
},
saveBingMessage: async ({ messageId, oldMessageId = messageId, conversationId, parentMessageId, sender, text, isCreatedByUser=false, error }) => {
try {
await Message.findOneAndUpdate({ messageId: oldMessageId }, {
messageId,
conversationId,
parentMessageId,
sender,
text,
isCreatedByUser,
error
}, { upsert: true, new: true });
return { messageId, conversationId, parentMessageId, sender, text, isCreatedByUser };
} catch (error) {
console.error(error);
return { message: 'Error saving message' };
}
},
deleteMessagesSince: async ({ messageId, conversationId }) => {
try {
const message = await Message.findOne({ messageId }).exec()
if (message)
return await Message.find({ conversationId }).deleteMany({ createdAt: { $gt: message.createdAt } }).exec();
} catch (error) {
console.error(error);
return { message: 'Error deleting messages' };
}
},
getMessages: async (filter) => {
try {
return await Message.find(filter).exec()
return await Message.find(filter).sort({createdAt: 1}).exec()
} catch (error) {
console.error(error);
return { message: 'Error getting messages' };

View File

@@ -12,11 +12,7 @@ const promptSchema = mongoose.Schema({
category: {
type: String,
},
created: {
type: Date,
default: Date.now
}
});
}, { timestamps: true });
const Prompt = mongoose.models.Prompt || mongoose.model('Prompt', promptSchema);

View File

@@ -1,10 +1,14 @@
const { saveMessage, deleteMessages } = require('./Message');
const { getMessages, saveMessage, saveBingMessage, deleteMessagesSince, deleteMessages } = require('./Message');
const { getCustomGpts, updateCustomGpt, updateByLabel, deleteCustomGpts } = require('./CustomGpt');
const { getConvo, saveConvo } = require('./Conversation');
const { getConvoTitle, getConvo, saveConvo } = require('./Conversation');
module.exports = {
getMessages,
saveMessage,
saveBingMessage,
deleteMessagesSince,
deleteMessages,
getConvoTitle,
getConvo,
saveConvo,
getCustomGpts,

2511
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,14 +21,24 @@
"dependencies": {
"@keyv/mongo": "^2.1.8",
"@vscode/vscode-languagedetection": "^1.0.22",
"@waylaidwanderer/chatgpt-api": "^1.15.1",
"@waylaidwanderer/chatgpt-api": "^1.31.6",
"axios": "^1.3.4",
"chatgpt-latest": "npm:@waylaidwanderer/chatgpt-api@^1.31.6",
"cors": "^2.8.5",
"crypto": "^1.0.1",
"dotenv": "^16.0.3",
"eslint": "^8.36.0",
"express": "^4.18.2",
"express-session": "^1.17.3",
"html": "^1.0.0",
"keyv": "^4.5.2",
"keyv-file": "^0.2.0",
"lodash": "^4.17.21",
"meilisearch": "^0.31.1",
"mongomeili": "^0.1.8",
"mongoose": "^6.9.0",
"openai": "^3.1.0"
"openai": "^3.1.0",
"sanitize": "^2.1.2"
},
"devDependencies": {
"nodemon": "^2.0.20",

View File

@@ -1,28 +1,70 @@
const express = require('express');
const dbConnect = require('../models/dbConnect');
const session = require('express-session')
const connectDb = require('../lib/db/connectDb');
const migrateDb = require('../lib/db/migrateDb');
const path = require('path');
const cors = require('cors');
const routes = require('./routes');
const app = express();
const port = process.env.PORT || 3080;
const host = process.env.HOST || 'localhost'
const userSystemEnabled = process.env.ENABLE_USER_SYSTEM || false
const projectPath = path.join(__dirname, '..', '..', 'client');
dbConnect().then(() => console.log('Connected to MongoDB'));
connectDb().then(() => {
console.log('Connected to MongoDB');
migrateDb();
});
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(projectPath, 'public')));
app.set('trust proxy', 1) // trust first proxy
app.use(session({
secret: 'chatgpt-clone-random-secrect',
resave: false,
saveUninitialized: true,
}))
app.get('/', function (req, res) {
console.log(path.join(projectPath, 'public', 'index.html'));
res.sendFile(path.join(projectPath, 'public', 'index.html'));
/* chore: potential redirect error here, can only comment out this block;
comment back in if using auth routes i guess */
// app.get('/', routes.authenticatedOrRedirect, function (req, res) {
// console.log(path.join(projectPath, 'public', 'index.html'));
// res.sendFile(path.join(projectPath, 'public', 'index.html'));
// });
app.get('/api/me', function (req, res) {
if (userSystemEnabled) {
const user = req?.session?.user
if (user)
res.send(JSON.stringify({username: user?.username, display: user?.display}));
else
res.send(JSON.stringify(null));
} else {
res.send(JSON.stringify({username: 'anonymous_user', display: 'Anonymous User'}));
}
});
app.use('/api/ask', routes.ask);
app.use('/api/messages', routes.messages);
app.use('/api/convos', routes.convos);
app.use('/api/customGpts', routes.customGpts);
app.use('/api/prompts', routes.prompts);
app.use('/api/search', routes.authenticatedOr401, routes.search);
app.use('/api/ask', routes.authenticatedOr401, routes.ask);
app.use('/api/messages', routes.authenticatedOr401, routes.messages);
app.use('/api/convos', routes.authenticatedOr401, routes.convos);
app.use('/api/customGpts', routes.authenticatedOr401, routes.customGpts);
app.use('/api/prompts', routes.authenticatedOr401, routes.prompts);
app.use('/auth', routes.auth);
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
app.get('/api/models', function (req, res) {
const hasOpenAI = !!process.env.OPENAI_KEY;
const hasChatGpt = !!process.env.CHATGPT_TOKEN;
const hasBing = !!process.env.BING_TOKEN;
res.send(JSON.stringify({ hasOpenAI, hasChatGpt, hasBing }));
});
app.listen(port, host, () => {
if (host=='0.0.0.0')
console.log(`Server listening on all interface at port ${port}. Use http://localhost:${port} to access it`);
else
console.log(`Server listening at http://${host=='0.0.0.0'?'localhost':host}:${port}`);
});

View File

@@ -2,36 +2,79 @@ const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const askBing = require('./askBing');
const {
titleConvo,
askClient,
browserClient,
customClient,
detectCode
} = require('../../app/');
const { getConvo, saveMessage, deleteMessages, saveConvo } = require('../../models');
const { handleError, sendMessage } = require('./handlers');
const askSydney = require('./askSydney');
const { titleConvo, askClient, browserClient, customClient } = require('../../app/');
const { getConvo, saveMessage, getConvoTitle, saveConvo } = require('../../models');
const { handleError, sendMessage, createOnProgress, handleText } = require('./handlers');
const { getMessages } = require('../../models/Message');
router.use('/bing', askBing);
router.use('/sydney', askSydney);
router.post('/', async (req, res) => {
let { model, text, parentMessageId, conversationId, chatGptLabel, promptPrefix } = req.body;
if (!text.trim().includes(' ') && text.length < 5) {
return handleError(res, 'Prompt empty or too short');
let { model, text, overrideParentMessageId=null, parentMessageId, conversationId: oldConversationId, ...convo } = req.body;
if (text.length === 0) {
return handleError(res, { text: 'Prompt empty or too short' });
}
console.log('model:', model, 'oldConvoId:', oldConversationId);
const conversationId = oldConversationId || crypto.randomUUID();
console.log('conversationId after old:', conversationId);
const userMessageId = crypto.randomUUID();
let userMessage = { id: userMessageId, sender: 'User', text };
const userParentMessageId = parentMessageId || '00000000-0000-0000-0000-000000000000';
let userMessage = {
messageId: userMessageId,
sender: 'User',
text,
parentMessageId: userParentMessageId,
conversationId,
isCreatedByUser: true
};
console.log('ask log', {
model,
...userMessage,
parentMessageId,
conversationId,
chatGptLabel,
promptPrefix
...convo
});
// Chore: This creates a loose a stranded initial message for chatgptBrowser
if (!overrideParentMessageId) {
await saveMessage(userMessage);
}
if (!overrideParentMessageId && model !== 'chatgptBrowser') {
await saveConvo(req?.session?.user?.username, { ...userMessage, model, ...convo });
}
return await ask({
userMessage,
model,
convo,
preSendRequest: true,
overrideParentMessageId,
req,
res
});
});
const ask = async ({
userMessage,
overrideParentMessageId = null,
model,
convo,
preSendRequest = true,
req,
res
}) => {
let {
text,
parentMessageId: userParentMessageId,
conversationId,
messageId: userMessageId
} = userMessage;
let client;
if (model === 'chatgpt') {
@@ -42,15 +85,6 @@ router.post('/', async (req, res) => {
client = browserClient;
}
if (model === 'chatgptCustom' && !chatGptLabel && conversationId) {
const convo = await getConvo({ conversationId });
if (convo) {
console.log('found convo for custom gpt', { convo })
chatGptLabel = convo.chatGptLabel;
promptPrefix = convo.promptPrefix;
}
}
res.writeHead(200, {
Connection: 'keep-alive',
'Content-Type': 'text/event-stream',
@@ -59,54 +93,40 @@ router.post('/', async (req, res) => {
'X-Accel-Buffering': 'no'
});
if (preSendRequest) sendMessage(res, { message: userMessage, created: true });
try {
let i = 0;
let tokens = '';
const progressCallback = async (partial) => {
if (i === 0 && typeof partial === 'object') {
userMessage.parentMessageId = parentMessageId ? parentMessageId : partial.id;
userMessage.conversationId = conversationId ? conversationId : partial.conversationId;
await saveMessage(userMessage);
sendMessage(res, { ...partial, initial: true });
i++;
}
const progressCallback = createOnProgress();
if (typeof partial === 'object') {
sendMessage(res, { ...partial, message: true });
} else {
tokens += partial === text ? '' : partial;
if (tokens.includes('[DONE]')) {
tokens = tokens.replace('[DONE]', '');
}
// tokens = await detectCode(tokens);
sendMessage(res, { text: tokens, message: true, initial: i === 0 ? true : false });
i++;
}
};
const abortController = new AbortController();
res.on('close', () => {
console.log('The client has disconnected.');
// 执行其他操作
abortController.abort();
})
let gptResponse = await client({
text,
progressCallback,
onProgress: progressCallback.call(null, model, { res, text }),
convo: {
parentMessageId,
conversationId
parentMessageId: userParentMessageId,
conversationId,
...convo
},
chatGptLabel,
promptPrefix
...convo,
abortController
});
console.log('CLIENT RESPONSE', gptResponse);
gptResponse.text = gptResponse.response;
if (!gptResponse.parentMessageId) {
gptResponse.text = gptResponse.response;
gptResponse.id = gptResponse.messageId;
gptResponse.parentMessageId = gptResponse.messageId;
userMessage.parentMessageId = parentMessageId ? parentMessageId : gptResponse.messageId;
userMessage.conversationId = conversationId
? conversationId
: gptResponse.conversationId;
await saveMessage(userMessage);
// gptResponse.id = gptResponse.messageId;
gptResponse.parentMessageId = overrideParentMessageId || userMessageId;
// userMessage.conversationId = conversationId
// ? conversationId
// : gptResponse.conversationId;
// await saveMessage(userMessage);
delete gptResponse.response;
}
@@ -115,37 +135,75 @@ router.post('/', async (req, res) => {
gptResponse.text.toLowerCase().includes('no response') ||
gptResponse.text.toLowerCase().includes('no answer')
) {
return handleError(res, 'Prompt empty or too short');
}
if (!parentMessageId) {
gptResponse.title = await titleConvo({
model,
message: text,
response: JSON.stringify(gptResponse.text)
await saveMessage({
messageId: crypto.randomUUID(),
sender: model,
conversationId,
parentMessageId: overrideParentMessageId || userMessageId,
error: true,
text: 'Prompt empty or too short'
});
}
gptResponse.sender = model === 'chatgptCustom' ? chatGptLabel : model;
gptResponse.final = true;
gptResponse.text = await detectCode(gptResponse.text);
if (chatGptLabel?.length > 0 && model === 'chatgptCustom') {
gptResponse.chatGptLabel = chatGptLabel;
return handleError(res, { text: 'Prompt empty or too short' });
}
if (promptPrefix?.length > 0 && model === 'chatgptCustom') {
gptResponse.promptPrefix = promptPrefix;
gptResponse.sender = model === 'chatgptCustom' ? convo.chatGptLabel : model;
gptResponse.model = model;
// gptResponse.final = true;
gptResponse.text = await handleText(gptResponse);
if (convo.chatGptLabel?.length > 0 && model === 'chatgptCustom') {
gptResponse.chatGptLabel = convo.chatGptLabel;
}
if (convo.promptPrefix?.length > 0 && model === 'chatgptCustom') {
gptResponse.promptPrefix = convo.promptPrefix;
}
// override the parentMessageId, for the regeneration.
gptResponse.parentMessageId = overrideParentMessageId || userMessageId;
/* this is a hacky solution to get the browserClient working right, will refactor later */
if (model === 'chatgptBrowser' && userParentMessageId.startsWith('000')) {
await saveMessage({ ...userMessage, conversationId: gptResponse.conversationId });
}
await saveMessage(gptResponse);
await saveConvo(gptResponse);
sendMessage(res, gptResponse);
await saveConvo(req?.session?.user?.username, gptResponse);
sendMessage(res, {
title: await getConvoTitle(req?.session?.user?.username, conversationId),
final: true,
requestMessage: userMessage,
responseMessage: gptResponse
});
res.end();
if (userParentMessageId == '00000000-0000-0000-0000-000000000000') {
const title = await titleConvo({ model, text, response: gptResponse });
await saveConvo(
req?.session?.user?.username,
{
/* again, for sake of browser client, will soon refactor */
conversationId: model === 'chatgptBrowser' ? gptResponse.conversationId : conversationId,
title
}
);
}
} catch (error) {
console.log(error);
await deleteMessages({ id: userMessageId });
handleError(res, error.message);
// await deleteMessages({ messageId: userMessageId });
const errorMessage = {
messageId: crypto.randomUUID(),
sender: model,
conversationId,
parentMessageId: overrideParentMessageId || userMessageId,
error: true,
text: error.message
};
await saveMessage(errorMessage);
handleError(res, errorMessage);
}
});
};
module.exports = router;

View File

@@ -2,19 +2,75 @@ const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const { titleConvo, askBing } = require('../../app/');
const { saveMessage, deleteMessages, saveConvo } = require('../../models');
const { handleError, sendMessage } = require('./handlers');
const { saveBingMessage, getConvoTitle, saveConvo } = require('../../models');
const { handleError, sendMessage, createOnProgress, handleText } = require('./handlers');
router.post('/', async (req, res) => {
const { model, text, ...convo } = req.body;
if (!text.trim().includes(' ') && text.length < 5) {
return handleError(res, 'Prompt empty or too short');
const {
model,
text,
overrideParentMessageId=null,
parentMessageId,
conversationId: oldConversationId,
...convo
} = req.body;
if (text.length === 0) {
return handleError(res, { text: 'Prompt empty or too short' });
}
const userMessageId = crypto.randomUUID();
let userMessage = { id: userMessageId, sender: 'User', text };
const conversationId = oldConversationId || crypto.randomUUID();
const isNewConversation = !oldConversationId;
console.log('ask log', { model, ...userMessage, ...convo });
const userMessageId = convo.messageId;
const userParentMessageId = parentMessageId || '00000000-0000-0000-0000-000000000000';
let userMessage = {
messageId: userMessageId,
sender: 'User',
text,
parentMessageId: userParentMessageId,
conversationId,
isCreatedByUser: true
};
console.log('ask log', {
model,
...convo,
...userMessage
});
if (!overrideParentMessageId) {
await saveBingMessage(userMessage);
await saveConvo(req?.session?.user?.username, { model, ...convo, ...userMessage });
}
return await ask({
isNewConversation,
userMessage,
model,
convo,
preSendRequest: true,
overrideParentMessageId,
req,
res
});
});
const ask = async ({
isNewConversation,
overrideParentMessageId = null,
userMessage,
model,
convo,
preSendRequest = true,
req,
res
}) => {
let {
text,
parentMessageId: userParentMessageId,
conversationId,
messageId: userMessageId
} = userMessage;
res.writeHead(200, {
Connection: 'keep-alive',
@@ -24,49 +80,109 @@ router.post('/', async (req, res) => {
'X-Accel-Buffering': 'no'
});
if (preSendRequest) sendMessage(res, { message: userMessage, created: true });
try {
let tokens = '';
const progressCallback = async (partial) => {
tokens += partial === text ? '' : partial;
// tokens = appendCode(tokens);
sendMessage(res, { text: tokens, message: true });
};
const progressCallback = createOnProgress();
const abortController = new AbortController();
res.on('close', () => {
console.log('The client has disconnected.');
// 执行其他操作
abortController.abort();
})
let response = await askBing({
text,
progressCallback,
convo
onProgress: progressCallback.call(null, model, {
res,
text,
parentMessageId: overrideParentMessageId || userMessageId
}),
convo: {
...convo,
parentMessageId: userParentMessageId,
conversationId
},
abortController
});
console.log('CLIENT RESPONSE');
console.dir(response, { depth: null });
console.log('BING RESPONSE', response);
// console.dir(response, { depth: null });
userMessage.conversationSignature =
convo.conversationSignature || response.conversationSignature;
userMessage.conversationId = convo.conversationId || response.conversationId;
userMessage.conversationId = response.conversationId || conversationId;
userMessage.invocationId = response.invocationId;
await saveMessage(userMessage);
userMessage.messageId = response.details.requestId || userMessageId;
if (!overrideParentMessageId)
await saveBingMessage({ oldMessageId: userMessageId, ...userMessage });
if (!convo.conversationSignature) {
response.title = await titleConvo(text, response.response, model);
}
// Bing API will not use our conversationId at the first time,
// so change the placeholder conversationId to the real one.
// Attition: the api will also create new conversationId while using invalid userMessage.parentMessageId,
// but in this situation, don't change the conversationId, but create new convo.
if (conversationId != userMessage.conversationId && isNewConversation)
await saveConvo(
req?.session?.user?.username,
{
conversationId: conversationId,
newConversationId: userMessage.conversationId
}
);
conversationId = userMessage.conversationId;
response.text = response.response;
response.id = response.details.messageId;
response.text = response.response || response.details.spokenText || '**Bing refused to answer.**';
// delete response.response;
// response.id = response.details.messageId;
response.suggestions =
response.details.suggestedResponses &&
response.details.suggestedResponses.map((s) => s.text);
response.sender = model;
response.final = true;
await saveMessage(response);
await saveConvo(response);
sendMessage(res, response);
// response.final = true;
response.messageId = response.details.messageId;
// override the parentMessageId, for the regeneration.
response.parentMessageId =
overrideParentMessageId || response.details.requestId || userMessageId;
response.text = await handleText(response, true);
await saveBingMessage(response);
await saveConvo(req?.session?.user?.username, { model, chatGptLabel: null, promptPrefix: null, ...convo, ...response });
sendMessage(res, {
title: await getConvoTitle(req?.session?.user?.username, conversationId),
final: true,
requestMessage: userMessage,
responseMessage: response
});
res.end();
if (userParentMessageId == '00000000-0000-0000-0000-000000000000') {
const title = await titleConvo({ model, text, response });
await saveConvo(
req?.session?.user?.username,
{
conversationId,
title
}
);
}
} catch (error) {
console.log(error);
await deleteMessages({ id: userMessageId });
handleError(res, error.message);
// await deleteMessages({ messageId: userMessageId });
const errorMessage = {
messageId: crypto.randomUUID(),
sender: model,
conversationId,
parentMessageId: overrideParentMessageId || userMessageId,
error: true,
text: error.message
};
await saveBingMessage(errorMessage);
handleError(res, errorMessage);
}
});
};
module.exports = router;
module.exports = router;

View File

@@ -0,0 +1,200 @@
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const { titleConvo, askSydney } = require('../../app/');
const { saveBingMessage, saveConvo, getConvoTitle } = require('../../models');
const { handleError, sendMessage, createOnProgress, handleText } = require('./handlers');
router.post('/', async (req, res) => {
const {
model,
text,
overrideParentMessageId=null,
parentMessageId,
conversationId: oldConversationId,
...convo
} = req.body;
if (text.length === 0) {
return handleError(res, { text: 'Prompt empty or too short' });
}
const conversationId = oldConversationId || crypto.randomUUID();
const isNewConversation = !oldConversationId;
const userMessageId = convo.messageId;
const userParentMessageId = parentMessageId || '00000000-0000-0000-0000-000000000000';
let userMessage = {
messageId: userMessageId,
sender: 'User',
text,
parentMessageId: userParentMessageId,
conversationId,
isCreatedByUser: true
};
console.log('ask log', {
model,
...convo,
...userMessage
});
if (!overrideParentMessageId) {
await saveBingMessage(userMessage);
await saveConvo(req?.session?.user?.username, { model, ...convo, ...userMessage });
}
return await ask({
isNewConversation,
userMessage,
model,
convo,
preSendRequest: true,
overrideParentMessageId,
req,
res
});
});
const ask = async ({
isNewConversation,
overrideParentMessageId = null,
userMessage,
model,
convo,
preSendRequest = true,
req,
res
}) => {
let {
text,
parentMessageId: userParentMessageId,
conversationId,
messageId: userMessageId
} = userMessage;
res.writeHead(200, {
Connection: 'keep-alive',
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no'
});
if (preSendRequest) sendMessage(res, { message: userMessage, created: true });
try {
const progressCallback = createOnProgress();
const abortController = new AbortController();
res.on('close', () => {
console.log('The client has disconnected.');
// 执行其他操作
abortController.abort();
})
let response = await askSydney({
text,
onProgress: progressCallback.call(null, model, {
res,
text,
parentMessageId: overrideParentMessageId || userMessageId
}),
convo: {
...convo,
parentMessageId: userParentMessageId,
conversationId
},
abortController
});
console.log('SYDNEY RESPONSE', response);
// console.dir(response, { depth: null });
userMessage.conversationSignature =
convo.conversationSignature || response.conversationSignature;
userMessage.conversationId = response.conversationId || conversationId;
userMessage.invocationId = response.invocationId;
userMessage.messageId = response.parentMessageId || userMessageId;
// Unlike gpt and bing, Sydney will never accept our given userMessage.messageId, it will generate its own one.
if (!overrideParentMessageId)
await saveBingMessage({ oldMessageId: userMessageId, ...userMessage });
// Save sydney response
// response.id = response.messageId;
response.invocationId = convo.invocationId ? convo.invocationId + 1 : 1;
response.conversationId = conversationId ? conversationId : crypto.randomUUID();
response.conversationSignature = convo.conversationSignature
? convo.conversationSignature
: crypto.randomUUID();
response.text = response.response || response.details.spokenText || '**Bing refused to answer.**';
// delete response.response;
response.suggestions =
response.details.suggestedResponses &&
response.details.suggestedResponses.map((s) => s.text);
response.sender = model;
// response.final = true;
// override the parentMessageId, for the regeneration.
response.parentMessageId =
overrideParentMessageId || response.parentMessageId || userMessageId;
// Save user message
userMessage.conversationId = response.conversationId || conversationId;
if (!overrideParentMessageId)
await saveBingMessage(userMessage);
// Bing API will not use our conversationId at the first time,
// so change the placeholder conversationId to the real one.
// Attition: the api will also create new conversationId while using invalid userMessage.parentMessageId,
// but in this situation, don't change the conversationId, but create new convo.
if (conversationId != userMessage.conversationId && isNewConversation)
await saveConvo(
req?.session?.user?.username,
{
conversationId: conversationId,
newConversationId: userMessage.conversationId
}
);
conversationId = userMessage.conversationId;
response.text = await handleText(response, true);
// Save sydney response & convo, then send
await saveBingMessage(response);
await saveConvo(req?.session?.user?.username, { model, chatGptLabel: null, promptPrefix: null, ...convo, ...response });
sendMessage(res, {
title: await getConvoTitle(req?.session?.user?.username, conversationId),
final: true,
requestMessage: userMessage,
responseMessage: response
});
res.end();
if (userParentMessageId == '00000000-0000-0000-0000-000000000000') {
const title = await titleConvo({ model, text, response });
await saveConvo(
req?.session?.user?.username,
{
conversationId,
title
}
);
}
} catch (error) {
console.log(error);
// await deleteMessages({ messageId: userMessageId });
const errorMessage = {
messageId: crypto.randomUUID(),
sender: model,
conversationId,
parentMessageId: overrideParentMessageId || userMessageId,
error: true,
text: error.message
};
await saveBingMessage(errorMessage);
handleError(res, errorMessage);
}
};
module.exports = router;

46
api/server/routes/auth.js Normal file
View File

@@ -0,0 +1,46 @@
const express = require('express');
const router = express.Router();
const authYourLogin = require('./authYourLogin');
const userSystemEnabled = process.env.ENABLE_USER_SYSTEM || false
router.get('/login', function (req, res) {
if (userSystemEnabled)
res.redirect('/auth/your_login_page')
else
res.redirect('/')
})
router.get('/logout', function (req, res) {
// clear the session
req.session.user = null
req.session.save(function (error) {
if (userSystemEnabled)
res.redirect('/auth/your_login_page/logout')
else
res.redirect('/')
})
})
const authenticatedOr401 = (req, res, next) => {
if (userSystemEnabled) {
const user = req?.session?.user;
if (user) next();
else res.status(401).end();
} else next();
}
const authenticatedOrRedirect = (req, res, next) => {
if (userSystemEnabled) {
const user = req?.session?.user;
if (user) next();
else res.redirect('/auth/login').end();
} else next();
}
if (userSystemEnabled)
router.use('/your_login_page', authYourLogin);
module.exports = { router, authenticatedOr401, authenticatedOrRedirect };

View File

@@ -0,0 +1,40 @@
const express = require('express');
const router = express.Router();
// WARNING!
// THIS IS NOT A READY TO USE USER SYSTEM
// PLEASE IMPLEMENT YOUR OWN USER SYSTEM
const userSystemEnabled = process.env.ENABLE_USER_SYSTEM || false
// Logout
router.get('/logout', (req, res) => {
// Do anything you want
console.warn('logout not implemented!')
// finish
res.redirect('/')
});
// Login
router.get('/', async (req, res) => {
// Do anything you want
console.warn('login not implemented! Automatic passed as sample user')
// save the user info into session
// username will be used in db
// display will be used in UI
req.session.user = {
username: 'sample_user',
display: 'Sample User',
}
req.session.save(function (error) {
if (error) {
console.log(error);
res.send(`<h1>Login Failed. An error occurred. Please see the server logs for details.</h1>`);
} else res.redirect('/')
})
});
module.exports = router;

View File

@@ -1,10 +1,45 @@
const express = require('express');
const router = express.Router();
const { getConvos, deleteConvos, updateConvo } = require('../../models/Conversation');
const { titleConvo } = require('../../app/');
const { getConvo, saveConvo, getConvoTitle } = require('../../models');
const { getConvosByPage, deleteConvos, updateConvo } = require('../../models/Conversation');
const { getMessages } = require('../../models/Message');
router.get('/', async (req, res) => {
const pageNumber = req.query.pageNumber || 1;
res.status(200).send(await getConvos(pageNumber));
res.status(200).send(await getConvosByPage(req?.session?.user?.username, pageNumber));
});
router.get('/:conversationId', async (req, res) => {
const { conversationId } = req.params;
const convo = await getConvo(req?.session?.user?.username, conversationId);
res.status(200).send(convo.toObject());
});
router.post('/gen_title', async (req, res) => {
const { conversationId } = req.body.arg;
const convo = await getConvo(req?.session?.user?.username, conversationId)
const firstMessage = (await getMessages({ conversationId }))[0]
const secondMessage = (await getMessages({ conversationId }))[1]
const title = convo.jailbreakConversationId
? await getConvoTitle(req?.session?.user?.username, conversationId)
: await titleConvo({
model: convo?.model,
message: firstMessage?.text,
response: JSON.stringify(secondMessage?.text || '')
});
await saveConvo(
req?.session?.user?.username,
{
conversationId,
title
}
)
res.status(200).send(title);
});
router.post('/clear', async (req, res) => {
@@ -15,7 +50,7 @@ router.post('/clear', async (req, res) => {
}
try {
const dbResponse = await deleteConvos(filter);
const dbResponse = await deleteConvos(req?.session?.user?.username, filter);
res.status(201).send(dbResponse);
} catch (error) {
console.error(error);
@@ -27,7 +62,7 @@ router.post('/update', async (req, res) => {
const update = req.body.arg;
try {
const dbResponse = await updateConvo(update);
const dbResponse = await updateConvo(req?.session?.user?.username, update);
res.status(201).send(dbResponse);
} catch (error) {
console.error(error);

View File

@@ -1,9 +1,14 @@
const express = require('express');
const router = express.Router();
const { getCustomGpts, updateCustomGpt, updateByLabel, deleteCustomGpts } = require('../../models');
const {
getCustomGpts,
updateCustomGpt,
updateByLabel,
deleteCustomGpts
} = require('../../models');
router.get('/', async (req, res) => {
const models = (await getCustomGpts()).map(model => {
const models = (await getCustomGpts(req?.session?.user?.username)).map((model) => {
model = model.toObject();
model._id = model._id.toString();
return model;
@@ -15,8 +20,14 @@ router.post('/delete', async (req, res) => {
const { arg } = req.body;
try {
const dbResponse = await deleteCustomGpts(arg);
res.status(201).send(dbResponse);
await deleteCustomGpts(req?.session?.user?.username, arg);
const models = (await getCustomGpts(req?.session?.user?.username)).map((model) => {
model = model.toObject();
model._id = model._id.toString();
return model;
});
res.status(201).send(models);
// res.status(201).send(dbResponse);
} catch (error) {
console.error(error);
res.status(500).send(error);
@@ -45,7 +56,7 @@ router.post('/', async (req, res) => {
}
try {
const dbResponse = await setter(update);
const dbResponse = await setter(req?.session?.user?.username, update);
res.status(201).send(dbResponse);
} catch (error) {
console.error(error);

View File

@@ -1,5 +1,12 @@
const handleError = (res, errorMessage) => {
res.status(500).write(`event: error\ndata: ${errorMessage}`);
const _ = require('lodash');
const citationRegex = /\[\^\d+?\^]/g;
const backtick = /(?<!`)[`](?!`)/g;
// const singleBacktick = /(?<!`)[`](?!`)/;
const cursorDefault = '<span class="result-streaming">█</span>';
const { getCitations, citeText } = require('../../app/');
const handleError = (res, message) => {
res.write(`event: error\ndata: ${JSON.stringify(message)}\n\n`);
res.end();
};
@@ -10,4 +17,80 @@ const sendMessage = (res, message) => {
res.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
};
module.exports = { handleError, sendMessage };
const createOnProgress = () => {
let i = 0;
let code = '';
let tokens = '';
let precode = '';
let blockCount = 0;
let codeBlock = false;
let cursor = cursorDefault;
const progressCallback = async (partial, { res, text, bing = false, ...rest }) => {
let chunk = partial === text ? '' : partial;
tokens += chunk;
precode += chunk;
tokens = tokens.replaceAll('[DONE]', '');
if (codeBlock) {
code += chunk;
}
if (precode.includes('```') && codeBlock) {
codeBlock = false;
cursor = cursorDefault;
precode = precode.replace(/```/g, '');
code = '';
}
if (precode.includes('```') && code === '') {
precode = precode.replace(/```/g, '');
codeBlock = true;
blockCount++;
cursor = blockCount > 1 ? '█\n\n```' : '█\n\n';
}
const backticks = precode.match(backtick);
if (backticks && !codeBlock && cursor === cursorDefault) {
precode = precode.replace(backtick, '');
cursor = '█';
}
if (tokens.match(/^\n/)) {
tokens = tokens.replace(/^\n/, '');
}
if (bing) {
tokens = citeText(tokens, true);
}
sendMessage(res, { text: tokens + cursor, message: true, initial: i === 0, ...rest });
i++;
};
const onProgress = (model, opts) => {
const bingModels = new Set(['bingai', 'sydney']);
return _.partialRight(progressCallback, { ...opts, bing: bingModels.has(model) });
};
return onProgress;
};
const handleText = async (response, bing = false) => {
let { text } = response;
// text = await detectCode(text);
response.text = text;
if (bing) {
// const hasCitations = response.response.match(citationRegex)?.length > 0;
const links = getCitations(response);
if (response.text.match(citationRegex)?.length > 0) {
text = citeText(response);
}
text += links?.length > 0 ? `\n<small>${links}</small>` : '';
}
return text;
};
module.exports = { handleError, sendMessage, createOnProgress, handleText };

View File

@@ -2,6 +2,8 @@ const ask = require('./ask');
const messages = require('./messages');
const convos = require('./convos');
const customGpts = require('./customGpts');
const prompts = require('./prompts');
const prompts = require('./prompts');
const search = require('./search');
const { router: auth, authenticatedOr401, authenticatedOrRedirect } = require('./auth');
module.exports = { ask, messages, convos, customGpts, prompts };
module.exports = { search, ask, messages, convos, customGpts, prompts, auth, authenticatedOr401, authenticatedOrRedirect };

View File

@@ -0,0 +1,81 @@
const express = require('express');
const router = express.Router();
const { Message } = require('../../models/Message');
const { Conversation, getConvosQueried } = require('../../models/Conversation');
const { reduceMessages, reduceHits } = require('../../lib/utils/reduceHits');
// const { MeiliSearch } = require('meilisearch');
const cache = new Map();
router.get('/sync', async function (req, res) {
await Message.syncWithMeili();
await Conversation.syncWithMeili();
res.send('synced');
});
router.get('/', async function (req, res) {
try {
const user = req?.session?.user?.username;
const { q } = req.query;
const pageNumber = req.query.pageNumber || 1;
const key = `${user || ''}${q}`;
if (cache.has(key)) {
console.log('cache hit', key);
const cached = cache.get(key);
const { pages, pageSize } = cached;
res.status(200).send({ conversations: cached[pageNumber], pages, pageNumber, pageSize });
return;
} else {
cache.clear();
}
// const message = await Message.meiliSearch(q);
const messages = (
await Message.meiliSearch(
q,
{
attributesToHighlight: ['text'],
highlightPreTag: '**',
highlightPostTag: '**'
},
true
)
).hits.map((message) => {
const { _formatted, ...rest } = message;
return {
...rest,
searchResult: true,
text: _formatted.text
};
});
const titles = (await Conversation.meiliSearch(q)).hits;
const sortedHits = reduceHits(messages, titles);
const result = await getConvosQueried(user, sortedHits, pageNumber);
cache.set(q, result.cache);
delete result.cache;
result.messages = messages.filter((message) => !result.filter.has(message.conversationId));
// console.log(result, messages.length);
res.status(200).send(result);
} catch (error) {
console.log(error);
res.status(500).send({ message: 'Error searching' });
}
});
router.get('/clear', async function (req, res) {
await Message.resetIndex();
res.send('cleared');
});
router.get('/test', async function (req, res) {
const { q } = req.query;
const messages = (
await Message.meiliSearch(q, { attributesToHighlight: ['text'] }, true)
).hits.map((message) => {
const { _formatted, ...rest } = message;
return { ...rest, searchResult: true, text: _formatted.text };
});
res.send(messages);
});
module.exports = router;

View File

@@ -1,2 +0,0 @@
/node_modules
.env

30
client/.eslintrc.js Normal file
View File

@@ -0,0 +1,30 @@
module.exports = {
"env": {
"browser": true,
"es2021": true,
"node": true,
"commonjs": true,
"es6": true,
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"overrides": [
],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"react"
],
"rules": {
'react/prop-types': ['off'],
'react/display-name': ['off'],
}
}

View File

@@ -1,22 +0,0 @@
# Stage 1
FROM node:19-alpine as builder
WORKDIR /client
# copy package.json into the container at /client
COPY package*.json /client/
# install dependencies
RUN npm install
# Copy the current directory contents into the container at /client
COPY . /client/
# Run the app when the container launches
CMD ["npm", "run", "build"]
# Stage 2
FROM nginx:stable-alpine
WORKDIR /usr/share/nginx/html
RUN rm -rf ./*
COPY --from=builder /client/public /usr/share/nginx/html
# Add your nginx.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
ENTRYPOINT ["nginx", "-g", "daemon off;"]
# docker build -t react-client .

View File

@@ -5,6 +5,7 @@ import { store } from './src/store';
import { ThemeProvider } from './src/hooks/ThemeContext';
import App from './src/App';
import './src/style.css';
import './src/mobile.css'
const container = document.getElementById('root');
const root = createRoot(container);

2663
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,17 +30,27 @@
"clsx": "^1.2.1",
"crypto-browserify": "^3.12.0",
"highlight.js": "^11.7.0",
"lodash": "^4.17.21",
"lucide-react": "^0.113.0",
"markdown-to-jsx": "^7.1.9",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-highlight": "^0.15.0",
"react-lazy-load": "^4.0.1",
"react-markdown": "^8.0.5",
"react-redux": "^8.0.5",
"react-string-replace": "^1.1.0",
"react-textarea-autosize": "^8.4.0",
"react-transition-group": "^4.4.5",
"rehype-highlight": "^6.0.0",
"rehype-katex": "^6.0.2",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"swr": "^2.0.3",
"tailwind-merge": "^1.9.1",
"tailwindcss-animate": "^1.0.5",
"url": "^0.11.0"
"url": "^0.11.0",
"uuidv4": "^6.2.13"
},
"devDependencies": {
"@babel/cli": "^7.20.7",

View File

@@ -2,6 +2,7 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="theme-color" content="#343541">
<title>ChatGPT Clone</title>
<link
rel="shortcut icon"

View File

@@ -1,35 +1,69 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import Messages from './components/Messages';
import Landing from './components/Main/Landing';
import TextChat from './components/Main/TextChat';
import Nav from './components/Nav';
import MobileNav from './components/Nav/MobileNav';
import useDocumentTitle from '~/hooks/useDocumentTitle';
import { useSelector } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import { setUser } from './store/userReducer';
import axios from 'axios';
const App = () => {
const { messages } = useSelector((state) => state.messages);
const dispatch = useDispatch();
const { messages, messageTree } = useSelector((state) => state.messages);
const { user } = useSelector((state) => state.user);
const { title } = useSelector((state) => state.convo);
const [ navVisible, setNavVisible ]= useState(false)
useDocumentTitle(title);
return (
<div className="flex h-screen">
<Nav />
<div className="flex h-full w-full flex-1 flex-col bg-gray-50 md:pl-[260px]">
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white dark:bg-gray-800">
<MobileNav />
{messages.length === 0 ? (
<Landing title={title} />
) : (
<Messages
messages={messages}
/>
)}
<TextChat messages={messages} />
useEffect(async () => {
try {
const response = await axios.get('/api/me', {
timeout: 1000,
withCredentials: true
});
const user = response.data;
if (user) {
dispatch(setUser(user));
} else {
console.log('Not login!');
window.location.href = '/auth/login';
}
} catch (error) {
console.error(error);
console.log('Not login!');
window.location.href = '/auth/login';
}
}, [])
if (user)
return (
<div className="flex h-screen">
<Nav navVisible={navVisible} setNavVisible={setNavVisible} />
<div className="flex h-full w-full flex-1 flex-col bg-gray-50 md:pl-[260px]">
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white dark:bg-gray-800">
<MobileNav setNavVisible={setNavVisible} />
{messages.length === 0 && title.toLowerCase() === 'chatgpt clone' ? (
<Landing title={title} />
) : (
<Messages
messages={messages}
messageTree={messageTree}
/>
)}
<TextChat messages={messages} />
</div>
</div>
</div>
</div>
);
);
else
return (
<div className="flex h-screen">
</div>
)
};
export default App;

View File

@@ -3,45 +3,63 @@ import RenameButton from './RenameButton';
import DeleteButton from './DeleteButton';
import { useSelector, useDispatch } from 'react-redux';
import { setConversation } from '~/store/convoSlice';
import { setCustomGpt, setModel, setCustomModel } from '~/store/submitSlice';
import { setMessages } from '~/store/messageSlice';
import { setSubmission, setStopStream, setCustomGpt, setModel, setCustomModel } from '~/store/submitSlice';
import { setMessages, setEmptyMessage } from '~/store/messageSlice';
import { setText } from '~/store/textSlice';
import manualSWR from '~/utils/fetchers';
import ConvoIcon from '../svg/ConvoIcon';
import { refreshConversation } from '../../store/convoSlice';
export default function Conversation({
id,
model,
parentMessageId,
conversationId,
title = 'New conversation',
bingData,
title,
chatGptLabel = null,
promptPrefix = null,
bingData,
retainView,
}) {
const [renaming, setRenaming] = useState(false);
const [titleInput, setTitleInput] = useState(title);
const { modelMap } = useSelector((state) => state.models);
const { stopStream } = useSelector((state) => state.submit);
const inputRef = useRef(null);
const dispatch = useDispatch();
const { trigger } = manualSWR(`http://localhost:3080/api/messages/${id}`, 'get');
const rename = manualSWR(`http://localhost:3080/api/convos/update`, 'post');
const { trigger } = manualSWR(`/api/messages/${id}`, 'get');
const rename = manualSWR(`/api/convos/update`, 'post');
const clickHandler = async () => {
if (conversationId === id) {
return;
}
if (!stopStream) {
dispatch(setStopStream(true));
dispatch(setSubmission({}));
}
dispatch(setEmptyMessage());
const convo = { title, error: false, conversationId: id, chatGptLabel, promptPrefix };
if (bingData) {
const { conversationSignature, clientId, invocationId } = bingData;
const {
parentMessageId,
conversationSignature,
jailbreakConversationId,
clientId,
invocationId
} = bingData;
dispatch(
setConversation({
...convo,
parentMessageId: null,
parentMessageId,
jailbreakConversationId,
conversationSignature,
clientId,
invocationId
invocationId,
latestMessage: null
})
);
} else {
@@ -49,9 +67,11 @@ export default function Conversation({
setConversation({
...convo,
parentMessageId,
jailbreakConversationId: null,
conversationSignature: null,
clientId: null,
invocationId: null
invocationId: null,
latestMessage: null
})
);
}
@@ -59,24 +79,28 @@ export default function Conversation({
if (chatGptLabel) {
dispatch(setModel('chatgptCustom'));
dispatch(setCustomModel(chatGptLabel.toLowerCase()));
} else {
dispatch(setModel(data[1].sender));
}
if (modelMap[data[1].sender.toLowerCase()]) {
console.log('sender', data[1].sender);
dispatch(setCustomModel(data[1].sender.toLowerCase()));
} else {
dispatch(setModel(model));
dispatch(setCustomModel(null));
}
// if (modelMap[chatGptLabel.toLowerCase()]) {
// console.log('custom model', chatGptLabel);
// dispatch(setCustomModel(chatGptLabel.toLowerCase()));
// } else {
// dispatch(setCustomModel(null));
// }
dispatch(setMessages(data));
dispatch(setCustomGpt(convo));
dispatch(setText(''));
dispatch(setStopStream(false));
};
const renameHandler = (e) => {
e.preventDefault();
setTitleInput(title);
setRenaming(true);
setTimeout(() => {
inputRef.current.focus();
@@ -94,7 +118,10 @@ export default function Conversation({
if (titleInput === title) {
return;
}
rename.trigger({ conversationId, title: titleInput });
rename.trigger({ conversationId, title: titleInput })
.then(() => {
dispatch(refreshConversation())
});
};
const handleKeyDown = (e) => {
@@ -131,7 +158,7 @@ export default function Conversation({
onKeyDown={handleKeyDown}
/>
) : (
titleInput
title
)}
</div>
{conversationId === id ? (
@@ -146,6 +173,7 @@ export default function Conversation({
conversationId={id}
renaming={renaming}
cancelHandler={cancelHandler}
retainView={retainView}
/>
</div>
) : (

View File

@@ -3,18 +3,21 @@ import TrashIcon from '../svg/TrashIcon';
import CrossIcon from '../svg/CrossIcon';
import manualSWR from '~/utils/fetchers';
import { useDispatch } from 'react-redux';
import { setConversation, removeConvo } from '~/store/convoSlice';
import { setNewConvo, removeConvo } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
import { setSubmission } from '~/store/submitSlice';
export default function DeleteButton({ conversationId, renaming, cancelHandler }) {
export default function DeleteButton({ conversationId, renaming, cancelHandler, retainView }) {
const dispatch = useDispatch();
const { trigger } = manualSWR(
`http://localhost:3080/api/convos/clear`,
`/api/convos/clear`,
'post',
() => {
dispatch(setMessages([]));
dispatch(removeConvo(conversationId));
dispatch(setConversation({ title: 'New chat', conversationId: null, parentMessageId: null }));
dispatch(setNewConvo());
dispatch(setSubmission({}));
retainView();
}
);

View File

@@ -0,0 +1,36 @@
import React from 'react';
export default function Pages({ pageNumber, pages, nextPage, previousPage }) {
const clickHandler = (func) => async (e) => {
e.preventDefault();
await func();
};
return (
<div className="m-auto mt-4 mb-2 flex items-center justify-center gap-2">
<button
onClick={clickHandler(previousPage)}
className={
'btn btn-small bg-transition m-auto flex gap-2 transition hover:bg-gray-800 disabled:text-gray-300 dark:text-white dark:disabled:text-gray-400' +
(pageNumber <= 1 ? ' hidden-visibility' : '')
}
disabled={pageNumber <= 1}
>
&lt;&lt;
</button>
<span className="flex-none text-gray-400">
{pageNumber} / {pages}
</span>
<button
onClick={clickHandler(nextPage)}
className={
'btn btn-small bg-transition m-auto flex gap-2 transition hover:bg-gray-800 disabled:text-gray-300 dark:text-white dark:disabled:text-gray-400' +
(pageNumber >= pages ? ' hidden-visibility' : '')
}
disabled={pageNumber >= pages}
>
&gt;&gt;
</button>
</div>
);
}

View File

@@ -1,11 +1,7 @@
import React from 'react';
import Conversation from './Conversation';
export default function Conversations({ conversations, conversationId, showMore }) {
const clickHandler = async (e) => {
e.preventDefault();
await showMore();
};
export default function Conversations({ conversations, conversationId, moveToTop }) {
return (
<>
@@ -14,7 +10,9 @@ export default function Conversations({ conversations, conversationId, showMore
conversations.map((convo) => {
const bingData = convo.conversationSignature
? {
jailbreakConversationId: convo.jailbreakConversationId,
conversationSignature: convo.conversationSignature,
parentMessageId: convo.parentMessageId || null,
clientId: convo.clientId,
invocationId: convo.invocationId
}
@@ -24,23 +22,17 @@ export default function Conversations({ conversations, conversationId, showMore
<Conversation
key={convo.conversationId}
id={convo.conversationId}
model={convo.model}
parentMessageId={convo.parentMessageId}
title={convo.title}
conversationId={conversationId}
chatGptLabel={convo.chatGptLabel}
promptPrefix={convo.promptPrefix}
bingData={bingData}
retainView={moveToTop}
/>
);
})}
{conversations && conversations.length >= 12 && conversations.length % 12 === 0 && (
<button
onClick={clickHandler}
className="btn btn-dark btn-small m-auto mb-2 flex justify-center gap-2"
>
Show more
</button>
)}
</>
);
}

View File

@@ -2,7 +2,7 @@ import React from 'react';
export default function Footer() {
return (
<div className="px-3 pt-2 pb-3 text-center text-xs text-black/50 dark:text-white/50 md:px-4 md:pt-3 md:pb-6">
<div className="hidden md:block px-3 pt-2 pb-1 text-center text-xs text-black/50 dark:text-white/50 md:px-4 md:pt-3 md:pb-4">
<a
href="https://github.com/danny-avila/chatgpt-clone"
target="_blank"

View File

@@ -26,9 +26,9 @@ export default function Landing({ title }) {
};
return (
<div className="flex h-full flex-col items-center overflow-y-auto text-sm dark:bg-gray-800">
<div className="flex pt-10 md:pt-0 h-full flex-col items-center overflow-y-auto text-sm dark:bg-gray-800">
<div className="w-full px-6 text-gray-800 dark:text-gray-100 md:flex md:max-w-2xl md:flex-col lg:max-w-3xl">
<h1 className="mt-6 ml-auto mr-auto mb-10 flex items-center justify-center gap-2 text-center text-4xl font-semibold sm:mt-[20vh] sm:mb-16">
<h1 className="mt-6 ml-auto mr-auto mb-10 flex items-center justify-center gap-2 text-center text-4xl font-semibold md:mt-[20vh] sm:mb-16">
ChatGPT Clone
</h1>
<div className="items-start gap-3.5 text-center md:flex">

View File

@@ -1,8 +1,10 @@
import React from 'react';
import { useSelector } from 'react-redux';
export default function SubmitButton({ submitMessage }) {
const { isSubmitting, disabled } = useSelector((state) => state.submit);
export default function SubmitButton({ submitMessage, disabled }) {
const { isSubmitting } = useSelector((state) => state.submit);
const { error, latestMessage } = useSelector((state) => state.convo);
const clickHandler = (e) => {
e.preventDefault();
submitMessage();
@@ -10,9 +12,12 @@ export default function SubmitButton({ submitMessage }) {
if (isSubmitting) {
return (
<button className="absolute bottom-1.5 right-1 rounded-md p-1 text-gray-500 hover:bg-gray-100 disabled:bottom-0.5 disabled:hover:bg-transparent dark:hover:bg-gray-900 dark:hover:text-gray-400 dark:disabled:hover:bg-transparent md:bottom-2.5 md:right-2 md:disabled:bottom-1">
<button
className="absolute bottom-0 right-1 h-[100%] w-[30px] rounded-md p-1 text-gray-500 hover:bg-gray-100 disabled:hover:bg-transparent dark:hover:bg-gray-900 dark:hover:text-gray-400 dark:disabled:hover:bg-transparent md:right-2"
disabled
>
<div className="text-2xl">
<span >·</span>
<span>·</span>
<span className="blink">·</span>
<span className="blink2">·</span>
</div>
@@ -23,28 +28,30 @@ export default function SubmitButton({ submitMessage }) {
<button
onClick={clickHandler}
disabled={disabled}
className="absolute bottom-1.5 right-1 rounded-md p-1 text-gray-500 hover:bg-gray-100 disabled:hover:bg-transparent dark:hover:bg-gray-900 dark:hover:text-gray-400 dark:disabled:hover:bg-transparent md:bottom-2.5 md:right-2"
className="group absolute bottom-0 right-0 flex h-[100%] w-[50px] items-center justify-center bg-transparent p-1 text-gray-500"
>
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-1 h-4 w-4"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="22"
y1="2"
x2="11"
y2="13"
/>
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
<div className="m-1 mr-0 rounded-md p-2 pt-[10px] pb-[10px] group-hover:bg-gray-100 group-disabled:hover:bg-transparent dark:group-hover:bg-gray-900 dark:group-hover:text-gray-400 dark:group-disabled:hover:bg-transparent">
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-1 h-4 w-4 "
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="22"
y1="2"
x2="11"
y2="13"
/>
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
</div>
</button>
);
}

View File

@@ -1,140 +1,318 @@
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { SSE } from '~/utils/sse';
import SubmitButton from './SubmitButton';
import Regenerate from './Regenerate';
import ModelMenu from '../Models/ModelMenu';
import Footer from './Footer';
import TextareaAutosize from 'react-textarea-autosize';
import handleSubmit from '~/utils/handleSubmit';
import createPayload from '~/utils/createPayload';
import resetConvo from '~/utils/resetConvo';
import RegenerateIcon from '../svg/RegenerateIcon';
import StopGeneratingIcon from '../svg/StopGeneratingIcon';
import { useSelector, useDispatch } from 'react-redux';
import { setConversation, setError } from '~/store/convoSlice';
import {
setConversation,
setNewConvo,
setError,
refreshConversation
} from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
import { setSubmitState } from '~/store/submitSlice';
import { setSubmitState, toggleCursor } from '~/store/submitSlice';
import { setText } from '~/store/textSlice';
import { useMessageHandler } from '../../utils/handleSubmit';
export default function TextChat({ messages }) {
const [errorMessage, setErrorMessage] = useState('');
const inputRef = useRef(null);
const isComposing = useRef(false);
const dispatch = useDispatch();
const { user } = useSelector((state) => state.user);
const convo = useSelector((state) => state.convo);
const { initial } = useSelector((state) => state.models);
const { isSubmitting, disabled, model, chatGptLabel, promptPrefix } = useSelector(
(state) => state.submit
);
const { isSubmitting, stopStream, submission, disabled, model, chatGptLabel, promptPrefix } =
useSelector((state) => state.submit);
const { text } = useSelector((state) => state.text);
const { error } = convo;
const isCustomModel = model === 'chatgptCustom' || !initial[model];
const { error, latestMessage } = convo;
const { ask, regenerate, stopGenerating } = useMessageHandler();
const submitMessage = () => {
if (error) {
dispatch(setError(false));
const isNotAppendable = latestMessage?.cancelled || latestMessage?.error;
// auto focus to input, when enter a conversation.
useEffect(() => {
inputRef.current?.focus();
}, [convo?.conversationId]);
const messageHandler = (data, currentState, currentMsg) => {
const { messages, _currentMsg, message, sender, isRegenerate } = currentState;
if (isRegenerate)
dispatch(
setMessages([
...messages,
{
sender,
text: data,
parentMessageId: message?.overrideParentMessageId,
messageId: message?.overrideParentMessageId + '_',
submitting: true
}
])
);
else
dispatch(
setMessages([
...messages,
currentMsg,
{
sender,
text: data,
parentMessageId: currentMsg?.messageId,
messageId: currentMsg?.messageId + '_',
submitting: true
}
])
);
};
const cancelHandler = (data, currentState, currentMsg) => {
const { messages, _currentMsg, message, sender, isRegenerate } = currentState;
if (isRegenerate)
dispatch(
setMessages([
...messages,
{
sender,
text: data,
parentMessageId: message?.overrideParentMessageId,
messageId: message?.overrideParentMessageId + '_',
cancelled: true
}
])
);
else
dispatch(
setMessages([
...messages,
currentMsg,
{
sender,
text: data,
parentMessageId: currentMsg?.messageId,
messageId: currentMsg?.messageId + '_',
cancelled: true
}
])
);
};
const createdHandler = (data, currentState, currentMsg) => {
const { conversationId } = currentMsg;
dispatch(
setConversation({
conversationId,
latestMessage: null
})
);
};
const convoHandler = (data, currentState, currentMsg) => {
const { requestMessage, responseMessage } = data;
const { conversationId } = requestMessage;
const { messages, _currentMsg, message, isCustomModel, sender, isRegenerate } =
currentState;
const { model, chatGptLabel, promptPrefix } = message;
if (isRegenerate) dispatch(setMessages([...messages, responseMessage]));
else dispatch(setMessages([...messages, requestMessage, responseMessage]));
dispatch(setSubmitState(false));
const isBing = model === 'bingai' || model === 'sydney';
// refresh title
if (requestMessage.parentMessageId == '00000000-0000-0000-0000-000000000000') {
setTimeout(() => {
dispatch(refreshConversation());
}, 2000);
// in case it takes too long.
setTimeout(() => {
dispatch(refreshConversation());
}, 5000);
}
if (!!isSubmitting || text.trim() === '') {
if (!isBing && convo.conversationId === null && convo.parentMessageId === null) {
const { title } = data;
const { conversationId, messageId } = responseMessage;
dispatch(
setConversation({
title,
conversationId,
parentMessageId: messageId,
jailbreakConversationId: null,
conversationSignature: null,
clientId: null,
invocationId: null,
chatGptLabel: model === isCustomModel ? chatGptLabel : null,
promptPrefix: model === isCustomModel ? promptPrefix : null,
latestMessage: null
})
);
} else if (model === 'bingai') {
console.log('Bing data:', data);
const { title } = data;
const { conversationSignature, clientId, conversationId, invocationId, parentMessageId } =
responseMessage;
dispatch(
setConversation({
title,
parentMessageId,
conversationSignature,
clientId,
conversationId,
invocationId,
latestMessage: null
})
);
} else if (model === 'sydney') {
const { title } = data;
const {
jailbreakConversationId,
parentMessageId,
conversationSignature,
clientId,
conversationId,
invocationId
} = responseMessage;
dispatch(
setConversation({
title,
jailbreakConversationId,
parentMessageId,
conversationSignature,
clientId,
conversationId,
invocationId,
latestMessage: null
})
);
}
};
const errorHandler = (data, currentState, currentMsg) => {
const { initialResponse, messages, _currentMsg, message } = currentState;
console.log('Error:', data);
const errorResponse = {
...data,
error: true,
parentMessageId: currentMsg?.messageId
};
setErrorMessage(data?.text);
dispatch(setSubmitState(false));
dispatch(setMessages([...messages, currentMsg, errorResponse]));
dispatch(setText(message?.text));
dispatch(setError(true));
return;
};
const submitMessage = () => {
ask({ text });
};
useEffect(() => {
inputRef.current?.focus();
if (Object.keys(submission).length === 0) {
return;
}
dispatch(setSubmitState(true));
const message = text.trim();
const currentMsg = { sender: 'User', text: message, current: true };
const sender = model === 'chatgptCustom' ? chatGptLabel : model;
const initialResponse = { sender, text: '' };
dispatch(setMessages([...messages, currentMsg, initialResponse]));
dispatch(setText(''));
const messageHandler = (data) => {
dispatch(setMessages([...messages, currentMsg, { sender, text: data }]));
};
const convoHandler = (data) => {
dispatch(
setMessages([...messages, currentMsg, { sender, text: data.text || data.response }])
);
if (
model !== 'bingai' &&
convo.conversationId === null &&
convo.parentMessageId === null
) {
const { title, conversationId, id } = data;
dispatch(
setConversation({
title,
conversationId,
parentMessageId: id,
conversationSignature: null,
clientId: null,
invocationId: null,
chatGptLabel: model === isCustomModel ? chatGptLabel : null,
promptPrefix: model === isCustomModel ? promptPrefix : null
})
);
} else if (
model === 'bingai' &&
convo.conversationId === null &&
convo.invocationId === null
) {
const { title, conversationSignature, clientId, conversationId, invocationId } = data;
dispatch(
setConversation({
title,
conversationSignature,
clientId,
conversationId,
invocationId,
parentMessageId: null
})
);
const currentState = submission;
let currentMsg = { ...currentState.message };
let latestResponseText = '';
const { server, payload } = createPayload(submission);
const onMessage = (e) => {
if (stopStream) {
return;
}
dispatch(setSubmitState(false));
const data = JSON.parse(e.data);
if (data.final) {
convoHandler(data, currentState, currentMsg);
dispatch(toggleCursor());
console.log('final', data);
}
if (data.created) {
currentMsg = data.message;
createdHandler(data, currentState, currentMsg);
} else {
let text = data.text || data.response;
if (data.initial) {
console.log(data);
dispatch(toggleCursor());
}
if (data.message) {
latestResponseText = text;
messageHandler(text, currentState, currentMsg);
}
// console.log('dataStream', data);
}
};
// const convoHandler = (data) => {
// const { conversationId, id, invocationId } = data;
// const conversationData = {
// title: data.title,
// conversationId,
// parentMessageId:
// model !== 'bingai' && !convo.conversationId && !convo.parentMessageId ? id : null,
// conversationSignature:
// model === 'bingai' && !convo.conversationId ? data.conversationSignature : null,
// clientId: model === 'bingai' && !convo.conversationId ? data.clientId : null,
// // invocationId: model === 'bingai' && !convo.conversationId ? data.invocationId : null
// invocationId: invocationId ? invocationId : null
// };
// dispatch(setMessages([...messages, currentMsg, { sender: model, text: data.text || data.response }]));
// dispatch(setConversation(conversationData));
// dispatch(setSubmitState(false));
// };
const events = new SSE(server, {
payload: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' }
});
const errorHandler = (event) => {
console.log('Error:', event);
const errorResponse = {
...initialResponse,
text: `An error occurred. Please try again in a few moments.\n\nError message: ${event.data}`,
error: true
};
setErrorMessage(event.data);
dispatch(setSubmitState(false));
dispatch(setMessages([...messages.slice(0, -2), currentMsg, errorResponse]));
dispatch(setText(message));
dispatch(setError(true));
return;
events.onopen = function () {
console.log('connection is opened');
};
const submission = {
model,
text: message,
convo,
messageHandler,
convoHandler,
errorHandler,
chatGptLabel,
promptPrefix
events.onmessage = onMessage;
events.oncancel = (e) => {
dispatch(toggleCursor(true));
cancelHandler(latestResponseText, currentState, currentMsg);
};
console.log('User Input:', message);
handleSubmit(submission);
events.onerror = function (e) {
console.log('error in opening conn.');
events.close();
const data = JSON.parse(e.data);
dispatch(toggleCursor(true));
errorHandler(data, currentState, currentMsg);
};
events.stream();
return () => {
events.removeEventListener('message', onMessage);
dispatch(toggleCursor(true));
const isCancelled = events.readyState <= 1;
events.close();
if (isCancelled) {
const e = new Event('cancel');
events.dispatchEvent(e);
}
};
}, [submission]);
const handleRegenerate = () => {
if (latestMessage && !latestMessage?.isCreatedByUser) regenerate(latestMessage);
};
const handleStopGenerating = () => {
stopGenerating();
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
}
if (e.key === 'Enter' && !e.shiftKey) {
if (!isComposing.current) submitMessage();
}
};
const handleKeyUp = (e) => {
@@ -145,17 +323,22 @@ export default function TextChat({ messages }) {
if (isSubmitting) {
return;
}
};
if (e.key === 'Enter' && !e.shiftKey) {
submitMessage();
}
const handleCompositionStart = (e) => {
isComposing.current = true;
};
const handleCompositionEnd = (e) => {
isComposing.current = false;
};
const changeHandler = (e) => {
const { value } = e.target;
if (isSubmitting && (value === '' || value === '\n')) {
return;
}
// if (isSubmitting && (value === '' || value === '\n')) {
// return;
// }
dispatch(setText(value));
};
@@ -164,41 +347,77 @@ export default function TextChat({ messages }) {
dispatch(setError(false));
};
const isSearchView = messages?.[0]?.searchResult === true;
const getPlaceholderText = () => {
if (isSearchView) {
return 'Click a message to open its conversation.'
}
if (disabled) {
return 'Choose another model or customize GPT again';
}
if (isNotAppendable) {
return 'Edit your message or Regenerate.'
}
return '';
};
return (
<div className="md:bg-vert-light-gradient dark:md:bg-vert-dark-gradient absolute bottom-0 left-0 w-full border-t bg-white dark:border-white/20 dark:bg-gray-800 md:border-t-0 md:border-transparent md:!bg-transparent md:dark:border-transparent">
<form className="stretch mx-2 flex flex-row gap-3 pt-2 last:mb-2 md:last:mb-6 lg:mx-auto lg:max-w-3xl lg:pt-6">
<div className="input-panel md:bg-vert-light-gradient dark:md:bg-vert-dark-gradient fixed bottom-0 left-0 w-full border-t bg-white py-2 dark:border-white/20 dark:bg-gray-800 md:absolute md:border-t-0 md:border-transparent md:bg-transparent md:dark:border-transparent md:dark:bg-transparent">
<form className="stretch mx-2 flex flex-row gap-3 last:mb-2 md:pt-2 md:last:mb-6 lg:mx-auto lg:max-w-3xl lg:pt-6">
<div className="relative flex h-full flex-1 md:flex-col">
<div className="ml-1 mt-1.5 flex justify-center gap-0 md:m-auto md:mb-2 md:w-full md:gap-2" />
{error ? (
<Regenerate
submitMessage={submitMessage}
tryAgain={tryAgain}
errorMessage={errorMessage}
<span className="order-last ml-1 flex justify-center gap-0 md:order-none md:m-auto md:mb-2 md:w-full md:gap-2">
{isSubmitting && !isSearchView ? (
<button
onClick={handleStopGenerating}
className="input-panel-button btn btn-neutral flex justify-center gap-2 border-0 md:border"
type="button"
>
<StopGeneratingIcon />
<span className="hidden md:block">Stop generating</span>
</button>
) : latestMessage && !latestMessage?.isCreatedByUser && !isSearchView ? (
<button
onClick={handleRegenerate}
className="input-panel-button btn btn-neutral flex justify-center gap-2 border-0 md:border"
type="button"
>
<RegenerateIcon />
<span className="hidden md:block">Regenerate response</span>
</button>
) : null}
</span>
<div
className={`relative flex flex-grow flex-col rounded-md border border-black/10 ${
disabled ? 'bg-gray-100' : 'bg-white'
} py-2 shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:border-gray-900/50 ${
disabled ? 'dark:bg-gray-900' : 'dark:bg-gray-700'
} dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] md:py-3 md:pl-4`}
>
<ModelMenu />
<TextareaAutosize
tabIndex="0"
autoFocus
ref={inputRef}
// style={{maxHeight: '200px', height: '24px', overflowY: 'hidden'}}
rows="1"
value={disabled || isNotAppendable ? '' : text}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
onChange={changeHandler}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
placeholder={getPlaceholderText()}
disabled={disabled || isNotAppendable}
className="m-0 h-auto max-h-52 resize-none overflow-auto border-0 bg-transparent p-0 pl-12 pr-8 leading-6 placeholder:text-sm placeholder:text-gray-600 dark:placeholder:text-gray-500 focus:outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:pl-8"
/>
) : (
<div
className={`relative flex w-full flex-grow flex-col rounded-md border border-black/10 ${
disabled ? 'bg-gray-100' : 'bg-white'
} py-2 shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:border-gray-900/50 ${
disabled ? 'dark:bg-gray-900' : 'dark:bg-gray-700'
} dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] md:py-3 md:pl-4`}
>
<ModelMenu />
<TextareaAutosize
tabIndex="0"
// style={{maxHeight: '200px', height: '24px', overflowY: 'hidden'}}
rows="1"
value={text}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
onChange={changeHandler}
placeholder={disabled ? 'Choose another model or customize GPT again' : ''}
disabled={disabled}
className="m-0 h-auto max-h-52 resize-none overflow-auto border-0 bg-transparent p-0 pl-9 pr-8 leading-6 focus:outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:pl-8"
/>
<SubmitButton submitMessage={submitMessage} />
</div>
)}
<SubmitButton
submitMessage={submitMessage}
disabled={disabled || isNotAppendable}
/>
</div>
</div>
</form>
<Footer />

View File

@@ -0,0 +1,57 @@
import React, { useRef, useState } from 'react';
import Clipboard from '~/components/svg/Clipboard';
import CheckMark from '~/components/svg/CheckMark';
const CodeBlock = ({ lang, codeChildren }) => {
const codeRef = useRef(null);
return (
<div className="rounded-md bg-black">
<CodeBar
lang={lang}
codeRef={codeRef}
/>
<div className="overflow-y-auto p-4">
<code
ref={codeRef}
className={`hljs !whitespace-pre language-${lang}`}
>
{codeChildren}
</code>
</div>
</div>
);
};
const CodeBar = React.memo(({ lang, codeRef }) => {
const [isCopied, setIsCopied] = useState(false);
return (
<div className="relative flex items-center rounded-tl-md rounded-tr-md bg-gray-800 px-4 py-2 font-sans text-xs text-gray-200">
<span className="">{lang}</span>
<button
className="ml-auto flex gap-2"
onClick={async () => {
const codeString = codeRef.current?.textContent;
if (codeString)
navigator.clipboard.writeText(codeString).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 3000);
});
}}
>
{isCopied ? (
<>
<CheckMark />
Copied!
</>
) : (
<>
<Clipboard />
Copy code
</>
)}
</button>
</div>
);
});
export default CodeBlock;

View File

@@ -0,0 +1,85 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import rehypeHighlight from 'rehype-highlight';
import remarkMath from 'remark-math';
import remarkGfm from 'remark-gfm';
import CodeBlock from './CodeBlock';
import { langSubset } from '~/utils/languages';
const Content = React.memo(({ content }) => {
return (
<>
<ReactMarkdown
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset
}
]
]}
linkTarget="_new"
components={{
code,
p,
}}
>
{content}
</ReactMarkdown>
</>
);
});
const code = React.memo((props) => {
const { inline, className, children } = props;
const match = /language-(\w+)/.exec(className || '');
const lang = match && match[1];
if (inline) {
return <code className={className}>{children}</code>;
} else {
return (
<CodeBlock
lang={lang || 'text'}
codeChildren={children}
/>
);
}
});
const p = React.memo((props) => {
return <p className="whitespace-pre-wrap ">{props?.children}</p>;
});
const blinker = ({ node }) => {
if (node.type === 'text' && node.value === '█') {
return <span className="result-streaming">{node.value}</span>;
}
return null;
};
const em = React.memo(({ node, ...props }) => {
if (
props.children[0] &&
typeof props.children[0] === 'string' &&
props.children[0].startsWith('^')
) {
return <sup>{props.children[0].substring(1)}</sup>;
}
if (
props.children[0] &&
typeof props.children[0] === 'string' &&
props.children[0].startsWith('~')
) {
return <sub>{props.children[0].substring(1)}</sub>;
}
return <i {...props} />;
});
export default Content;

View File

@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import Clipboard from '../svg/Clipboard';
import CheckMark from '../svg/CheckMark';
import Clipboard from '~/components/svg/Clipboard';
import CheckMark from '~/components/svg/CheckMark';
export default function Embed({ children, language = '', code, matched }) {
const Embed = React.memo(({ children, lang = '', code, matched }) => {
const [buttonText, setButtonText] = useState('Copy code');
const isClicked = buttonText === 'Copy code';
@@ -18,7 +18,7 @@ export default function Embed({ children, language = '', code, matched }) {
<pre>
<div className="mb-4 rounded-md bg-black">
<div className="relative flex items-center rounded-tl-md rounded-tr-md bg-gray-800 px-4 py-2 font-sans text-xs text-gray-200">
<span className="">{language === 'javascript' && !matched ? '' : language}</span>
<span className="">{lang === 'javascript' && !matched ? '' : lang}</span>
<button
className="ml-auto flex gap-2"
onClick={clickHandler}
@@ -32,4 +32,6 @@ export default function Embed({ children, language = '', code, matched }) {
</div>
</pre>
);
}
});
export default Embed;

View File

@@ -0,0 +1,32 @@
import React, { useState, useEffect } from 'react';
import Highlighter from 'react-highlight';
import hljs from 'highlight.js';
import { languages } from '~/utils/languages';
const Highlight = React.memo(({ language, code }) => {
const [highlightedCode, setHighlightedCode] = useState(code);
const lang = language ? language : 'javascript';
useEffect(() => {
setHighlightedCode(hljs.highlight(code, { language: lang }).value);
}, [code, lang]);
return (
<pre>
{!highlightedCode ? (
// <code className={`hljs !whitespace-pre language-${lang ? lang: 'javascript'}`}>
<Highlighter className={`hljs !whitespace-pre language-${lang ? lang : 'javascript'}`}>
{code}
</Highlighter>
) : (
<code
className={`hljs language-${lang}`}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
)}
</pre>
);
});
export default Highlight;

View File

@@ -0,0 +1,15 @@
import React from 'react';
export default function TabLink(a) {
return (
<a
href={a.href}
title={a.title}
className={a.className}
target="_blank"
rel="noopener noreferrer"
>
{a.children}
</a>
);
}

View File

@@ -1,11 +1,23 @@
import React from 'react';
import TabLink from './TabLink';
import Markdown from 'markdown-to-jsx';
import Embed from './Embed';
import Highlight from './Highlight';
import regexSplit from '~/utils/regexSplit';
import { wrapperRegex } from '~/utils';
const { codeRegex, inLineRegex, markupRegex, languageMatch, newLineMatch } = wrapperRegex;
const mdOptions = { wrapper: React.Fragment, forceWrapper: true };
const mdOptions = {
wrapper: React.Fragment,
forceWrapper: true,
overrides: {
a: {
component: TabLink,
// props: {
// className: 'foo'
// }
}
}
};
const inLineWrap = (parts) => {
let previousElement = null;
@@ -34,8 +46,9 @@ const inLineWrap = (parts) => {
});
};
export default function TextWrapper({ text }) {
export default function TextWrapper({ text, generateCursor }) {
let embedTest = false;
let result = null;
// to match unenclosed code blocks
if (text.match(/```/g)?.length === 1) {
@@ -125,13 +138,23 @@ export default function TextWrapper({ text }) {
}
});
return <>{codeParts}</>; // return the wrapped text
// return <>{codeParts}</>; // return the wrapped text
result = <>{codeParts}</>;
} else if (text.match(markupRegex)) {
// map over the parts and wrap any text between tildes with <code> tags
const parts = text.split(markupRegex);
const codeParts = inLineWrap(parts);
return <>{codeParts}</>; // return the wrapped text
// return <>{codeParts}</>; // return the wrapped text
result = <>{codeParts}</>;
} else {
return <Markdown options={mdOptions}>{text}</Markdown>;
// return <Markdown options={mdOptions}>{text}</Markdown>;
result = <Markdown options={mdOptions}>{text}</Markdown>;
}
return (
<>
{result}
{generateCursor()}
</>
);
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import TextWrapper from './TextWrapper';
import Content from './Content';
const Wrapper = React.memo(({ text, generateCursor, isCreatedByUser, searchResult }) => {
if (!isCreatedByUser && searchResult) {
return (
<Content
content={text}
generateCursor={generateCursor}
/>
);
} else if (!isCreatedByUser && !searchResult) {
return (
<TextWrapper
text={text}
generateCursor={generateCursor}
/>
);
} else if (isCreatedByUser) {
return <>{text}</>;
}
});
export default Wrapper;

View File

@@ -1,16 +0,0 @@
import React, { useState, useEffect } from 'react';
import hljs from 'highlight.js';
export default function Highlight({language, code}) {
const [highlightedCode, setHighlightedCode] = useState(code);
useEffect(() => {
setHighlightedCode(hljs.highlight(code, { language }).value);
}, [code, language]);
return (
<pre>
<code className={`language-${language}`} dangerouslySetInnerHTML={{__html: highlightedCode}}/>
</pre>
);
}

View File

@@ -2,18 +2,24 @@ import React from 'react';
// import Clipboard from '../svg/Clipboard';
import EditIcon from '../svg/EditIcon';
export default function HoverButtons({ user }) {
return (
export default function HoverButtons({ visible, onClick, model }) {
const isBing = model === 'bingai' || model === 'sydney';
const enabled = !isBing;
<div className="visible mt-2 flex justify-center gap-3 self-end text-gray-400 md:gap-4 lg:absolute lg:top-0 lg:right-0 lg:mt-0 lg:translate-x-full lg:gap-1 lg:self-center lg:pl-2">
{user && (
<button className="rounded-md p-1 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400">
return (
<div className="visible mt-2 flex justify-center gap-3 self-end text-gray-400 md:gap-4 lg:absolute lg:top-0 lg:right-0 lg:mt-0 lg:translate-x-full lg:gap-1 lg:self-center lg:pl-2">
{(visible&&enabled)?(
<>
<button className="resubmit-edit-button rounded-md p-1 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible"
onClick={onClick}>
{/* <button className="rounded-md p-1 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400"> */}
<EditIcon />
</button>
)}
{/* <button className="rounded-md p-1 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400">
</>
):null}
{/* <button className="rounded-md p-1 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400">
<Clipboard />
</button> */}
</div>
</div>
);
}
}

View File

@@ -1,28 +1,68 @@
import React, { useState, useEffect } from 'react';
import TextWrapper from './TextWrapper';
import { useSelector } from 'react-redux';
import GPTIcon from '../svg/GPTIcon';
import BingIcon from '../svg/BingIcon';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import Wrapper from './Content/Wrapper';
import MultiMessage from './MultiMessage';
import { useSelector, useDispatch } from 'react-redux';
import HoverButtons from './HoverButtons';
import SiblingSwitch from './SiblingSwitch';
import { setConversation, setLatestMessage } from '~/store/convoSlice';
import { setModel, setCustomModel, setCustomGpt, toggleCursor, setDisabled } from '~/store/submitSlice';
import { setMessages } from '~/store/messageSlice';
import { fetchById } from '~/utils/fetchers';
import { getIconOfModel } from '~/utils';
import { useMessageHandler } from '~/utils/handleSubmit';
export default function Message({
sender,
text,
last = false,
error = false,
scrollToBottom
message,
messages,
scrollToBottom,
currentEditId,
setCurrentEditId,
siblingIdx,
siblingCount,
setSiblingIdx
}) {
const { isSubmitting } = useSelector((state) => state.submit);
const { isSubmitting, model, chatGptLabel, cursor, promptPrefix } = useSelector(
(state) => state.submit
);
const [abortScroll, setAbort] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const notUser = sender.toLowerCase() !== 'user';
const blinker = isSubmitting && last && notUser;
const { sender, text, searchResult, isCreatedByUser, error, submitting } = message;
const textEditor = useRef(null);
// const { convos } = useSelector((state) => state.convo);
const last = !message?.children?.length;
const edit = message.messageId == currentEditId;
const { ask } = useMessageHandler();
const dispatch = useDispatch();
// const notUser = !isCreatedByUser; // sender.toLowerCase() !== 'user';
// const blinker = submitting && isSubmitting && last && !isCreatedByUser;
const blinker = submitting && isSubmitting;
const generateCursor = useCallback(() => {
if (!blinker) {
return '';
}
if (!cursor) {
return '';
}
return <span className="result-streaming"></span>;
}, [blinker, cursor]);
useEffect(() => {
if (blinker && !abortScroll) {
scrollToBottom();
}
}, [isSubmitting, text, blinker, scrollToBottom, abortScroll]);
}, [isSubmitting, blinker, text, scrollToBottom]);
useEffect(() => {
if (last) {
// TODO: stop using conversation.parentMessageId and remove it.
dispatch(setConversation({ parentMessageId: message?.messageId }));
dispatch(setLatestMessage({ ...message }));
}
}, [last, message]);
const enterEdit = (cancel) => setCurrentEditId(cancel ? -1 : message.messageId);
const handleWheel = () => {
if (blinker) {
@@ -32,85 +72,160 @@ export default function Message({
}
};
const handleMouseOver = () => {
setIsHovering(true);
};
const handleMouseOut = () => {
setIsHovering(false);
};
const props = {
className:
'w-full border-b border-black/10 dark:border-gray-900/50 text-gray-800 bg-white dark:text-gray-100 group dark:bg-gray-800'
};
const bgColors = {
chatgpt: 'rgb(16, 163, 127)',
chatgptBrowser: 'rgb(25, 207, 207)',
bingai: ''
};
const icon = getIconOfModel({
sender,
isCreatedByUser,
model,
searchResult,
chatGptLabel,
promptPrefix,
error
});
let icon = `${sender}:`;
let backgroundColor = bgColors[sender];
if (notUser) {
if (!isCreatedByUser)
props.className =
'w-full border-b border-black/10 bg-gray-50 dark:border-gray-900/50 text-gray-800 dark:text-gray-100 group bg-gray-100 dark:bg-[#444654]';
if (message.bg && searchResult) {
props.className = message.bg + ' cursor-pointer';
}
if ((notUser && backgroundColor) || sender === 'bingai') {
icon = (
<div
style={{ backgroundColor }}
className="relative flex h-[30px] w-[30px] items-center justify-center rounded-sm p-1 text-white"
>
{sender === 'bingai' ? <BingIcon /> : <GPTIcon />}
{error && (
<span className="absolute right-0 top-[20px] -mr-2 flex h-4 w-4 items-center justify-center rounded-full border border-white bg-red-500 text-[10px] text-white">
!
</span>
)}
</div>
);
}
// const wrapText = (text) => <TextWrapper text={text} generateCursor={generateCursor}/>;
const wrapText = (text) => <TextWrapper text={text} />;
const resubmitMessage = () => {
const text = textEditor.current.innerText;
ask({
text,
parentMessageId: message?.parentMessageId,
conversationId: message?.conversationId
});
setSiblingIdx(siblingCount - 1);
enterEdit(true);
};
const clickSearchResult = async () => {
if (!searchResult) return;
dispatch(setMessages([]));
const convoResponse = await fetchById('convos', message.conversationId);
const convo = convoResponse.data;
if (convo?.chatGptLabel) {
dispatch(setModel('chatgptCustom'));
dispatch(setCustomModel(convo.chatGptLabel.toLowerCase()));
} else {
dispatch(setModel(convo.model));
dispatch(setCustomModel(null));
}
dispatch(setCustomGpt(convo));
dispatch(setConversation(convo));
const { data } = await fetchById('messages', message.conversationId);
dispatch(setMessages(data));
dispatch(setDisabled(false));
};
return (
<div
{...props}
onWheel={handleWheel}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
>
<div className="m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
<strong className="relative flex w-[30px] flex-col items-end text-right">
{icon}
</strong>
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 whitespace-pre-wrap md:gap-3 lg:w-[calc(100%-115px)]">
<div className="flex flex-grow flex-col gap-3">
{error ? (
<div className="flex flex min-h-[20px] flex-row flex-col items-start gap-4 gap-2 whitespace-pre-wrap text-red-500">
<div className="rounded-md border border-red-500 bg-red-500/10 py-2 px-3 text-sm text-gray-600 dark:text-gray-100">
{text}
</div>
</div>
<>
<div
{...props}
onWheel={handleWheel}
onClick={clickSearchResult}
>
<div className="relative m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
<div className="relative flex h-[30px] w-[30px] flex-col items-end text-right text-xs md:text-sm">
{typeof icon === 'string' && icon.match(/[^\u0000-\u007F]+/) ? (
<span className=" direction-rtl w-40 overflow-x-scroll">{icon}</span>
) : (
<div className="flex min-h-[20px] flex-col items-start gap-4 whitespace-pre-wrap">
{/* <div className={`${blinker ? 'result-streaming' : ''} markdown prose dark:prose-invert light w-full break-words`}> */}
<div className="markdown prose dark:prose-invert light w-full break-words">
{notUser ? wrapText(text) : text}
{blinker && <span className="result-streaming"></span>}
</div>
</div>
icon
)}
<div className="sibling-switch invisible absolute left-0 top-2 -ml-4 flex -translate-x-full items-center justify-center gap-1 text-xs group-hover:visible">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
</div>
</div>
<div className="flex justify-between">
{isHovering && <HoverButtons user={!notUser} />}
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 whitespace-pre-wrap md:gap-3 lg:w-[calc(100%-115px)]">
<div className="flex flex-grow flex-col gap-3">
{error ? (
<div className="flex flex min-h-[20px] flex-grow flex-col items-start gap-4 gap-2 whitespace-pre-wrap text-red-500">
<div className="rounded-md border border-red-500 bg-red-500/10 py-2 px-3 text-sm text-gray-600 dark:text-gray-100">
{`An error occurred. Please try again in a few moments.\n\nError message: ${text}`}
</div>
</div>
) : edit ? (
<div className="flex min-h-[20px] flex-grow flex-col items-start gap-4 whitespace-pre-wrap">
{/* <div className={`${blinker ? 'result-streaming' : ''} markdown prose dark:prose-invert light w-full break-words`}> */}
<div
className="markdown prose dark:prose-invert light w-full break-words border-none focus:outline-none"
contentEditable={true}
ref={textEditor}
suppressContentEditableWarning={true}
>
{text}
</div>
<div className="mt-2 flex w-full justify-center text-center">
<button
className="btn btn-primary relative mr-2"
disabled={isSubmitting}
onClick={resubmitMessage}
>
Save & Submit
</button>
<button
className="btn btn-neutral relative"
onClick={() => enterEdit(true)}
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex min-h-[20px] flex-grow flex-col items-start gap-4 whitespace-pre-wrap">
{/* <div className={`${blinker ? 'result-streaming' : ''} markdown prose dark:prose-invert light w-full break-words`}> */}
<div className="markdown prose dark:prose-invert light w-full break-words">
<Wrapper
text={text}
generateCursor={generateCursor}
isCreatedByUser={isCreatedByUser}
searchResult={searchResult}
/>
</div>
</div>
)}
</div>
<HoverButtons
model={model}
visible={!error && isCreatedByUser && !edit && !searchResult}
onClick={() => enterEdit()}
/>
<div className="sibling-switch-container flex justify-between">
<div className="flex items-center justify-center gap-1 self-center pt-2 text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
</div>
</div>
</div>
</div>
</div>
</div>
<MultiMessage
messageList={message.children}
messages={messages}
scrollToBottom={scrollToBottom}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
/>
</>
);
}

View File

@@ -0,0 +1,46 @@
import React, { useEffect, useState } from 'react';
import Message from './Message';
export default function MultiMessage({
messageList,
messages,
scrollToBottom,
currentEditId,
setCurrentEditId,
}) {
const [siblingIdx, setSiblingIdx] = useState(0);
const setSiblingIdxRev = (value) => {
setSiblingIdx(messageList?.length - value - 1);
};
useEffect(() => {
// reset siblingIdx when changes, mostly a new message is submitting.
setSiblingIdx(0);
}, [messageList?.length])
// if (!messageList?.length) return null;
if (!(messageList && messageList.length)) {
return null;
}
if (siblingIdx >= messageList?.length) {
setSiblingIdx(0);
return null;
}
const message = messageList[messageList.length - siblingIdx - 1];
return (
<Message
key={message.messageId}
message={message}
messages={messages}
scrollToBottom={scrollToBottom}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
siblingIdx={messageList.length - siblingIdx - 1}
siblingCount={messageList.length}
setSiblingIdx={setSiblingIdxRev}
/>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
export default function SiblingSwitch({
siblingIdx,
siblingCount,
setSiblingIdx
}) {
const previous = () => {
setSiblingIdx(siblingIdx - 1);
}
const next = () => {
setSiblingIdx(siblingIdx + 1);
}
return siblingCount > 1 ? (
<>
<button className="dark:text-white disabled:text-gray-300 dark:disabled:text-gray-400" onClick={previous} disabled={siblingIdx==0}>
<svg stroke="currentColor" fill="none" strokeWidth="1.5" viewBox="0 0 24 24" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<span className="flex-grow flex-shrink-0">{siblingIdx + 1}/{siblingCount}</span>
<button className="dark:text-white disabled:text-gray-300 dark:disabled:text-gray-400" onClick={next} disabled={siblingIdx==siblingCount-1}>
<svg stroke="currentColor" fill="none" strokeWidth="1.5" viewBox="0 0 24 24" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button>
</>
):null;
}

View File

@@ -1,36 +1,51 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import Spinner from '../svg/Spinner';
import { throttle } from 'lodash';
import { CSSTransition } from 'react-transition-group';
import ScrollToBottom from './ScrollToBottom';
import Message from './Message';
import MultiMessage from './MultiMessage';
import { useSelector } from 'react-redux';
const Messages = ({ messages }) => {
export default function Messages({ messages, messageTree }) {
const [currentEditId, setCurrentEditId] = useState(-1);
const { conversationId } = useSelector((state) => state.convo);
const { model, customModel, chatGptLabel } = useSelector((state) => state.submit);
const { models } = useSelector((state) => state.models);
const [showScrollButton, setShowScrollButton] = useState(false);
const scrollableRef = useRef(null);
const messagesEndRef = useRef(null);
const modelName = models.find((element) => element.model == model)?.name;
useEffect(() => {
const timeoutId = setTimeout(() => {
const scrollable = scrollableRef.current;
const hasScrollbar = scrollable.scrollHeight > scrollable.clientHeight;
const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current;
const diff = Math.abs(scrollHeight - scrollTop);
const percent = Math.abs(clientHeight - diff ) / clientHeight;
const hasScrollbar = scrollHeight > clientHeight && percent > 0.2;
setShowScrollButton(hasScrollbar);
}, 650);
// Add a listener on the window object
window.addEventListener('scroll', handleScroll);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('scroll', handleScroll);
};
}, [messages]);
const scrollToBottom = () => {
const scrollToBottom = useCallback(throttle(() => {
console.log('scrollToBottom');
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
setShowScrollButton(false);
};
}, 750, { leading: true }), [messagesEndRef]);
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current;
const diff = Math.abs(scrollHeight - scrollTop);
const bottom =
diff === clientHeight || (diff <= clientHeight + 25 && diff >= clientHeight - 25);
if (bottom) {
const percent = Math.abs(clientHeight - diff ) / clientHeight;
if (percent <= 0.2) {
setShowScrollButton(false);
} else {
setShowScrollButton(true);
@@ -50,35 +65,41 @@ const Messages = ({ messages }) => {
return (
<div
className="flex-1 overflow-y-auto "
className="flex-1 overflow-y-auto pt-10 md:pt-0"
ref={scrollableRef}
onScroll={debouncedHandleScroll}
>
{/* <div className="flex-1 overflow-hidden"> */}
<div className="h-full dark:gpt-dark-gray">
<div className="flex h-full flex-col items-center text-sm dark:gpt-dark-gray">
{messages.map((message, i) => (
<Message
key={i}
sender={message.sender}
text={message.text}
last={i === messages.length - 1}
error={message.error ? true : false}
scrollToBottom={i === messages.length - 1 ? scrollToBottom : null}
/>
))}
<CSSTransition
in={showScrollButton}
timeout={400}
classNames="scroll-down"
unmountOnExit={false}
// appear
>
{() => showScrollButton && <ScrollToBottom scrollHandler={scrollHandler} />}
</CSSTransition>
<div className="dark:gpt-dark-gray h-full">
<div className="dark:gpt-dark-gray flex h-full flex-col items-center text-sm">
<div className="flex w-full items-center justify-center gap-1 border-b border-black/10 bg-gray-50 p-3 text-sm text-gray-500 dark:border-gray-900/50 dark:bg-gray-700 dark:text-gray-300">
Model: {modelName} {customModel ? `(${customModel})` : null}
</div>
{(messageTree.length === 0) ? (
<Spinner />
) : (
<>
<MultiMessage
key={conversationId} // avoid internal state mixture
messageList={messageTree}
messages={messages}
scrollToBottom={scrollToBottom}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
/>
<CSSTransition
in={showScrollButton}
timeout={400}
classNames="scroll-down"
unmountOnExit={false}
// appear
>
{() => showScrollButton && <ScrollToBottom scrollHandler={scrollHandler} />}
</CSSTransition>
</>
)}
<div
className="group h-32 w-full flex-shrink-0 dark:border-gray-900/50 dark:gpt-dark-gray md:h-48"
className="dark:gpt-dark-gray group h-32 w-full flex-shrink-0 dark:border-gray-900/50 md:h-48"
ref={messagesEndRef}
/>
</div>
@@ -86,6 +107,4 @@ const Messages = ({ messages }) => {
{/* </div> */}
</div>
);
};
export default Messages;
}

View File

@@ -4,12 +4,16 @@ import ModelItem from './ModelItem';
export default function MenuItems({ models, onSelect }) {
return (
<>
{models.map((modelItem, i) => (
{models.map((modelItem) => (
<ModelItem
key={i}
key={modelItem._id}
id={modelItem._id}
modelName={modelItem.name}
value={modelItem.value}
model={modelItem.model || 'chatgptCustom'}
onSelect={onSelect}
chatGptLabel={modelItem.chatGptLabel}
promptPrefix={modelItem.promptPrefix}
/>
))}
</>

View File

@@ -1,7 +1,8 @@
import React, { useState, useRef } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { useSelector, useDispatch } from 'react-redux';
import { setModel, setCustomGpt } from '~/store/submitSlice';
import { setSubmission, setModel, setCustomGpt } from '~/store/submitSlice';
import { setNewConvo } from '~/store/convoSlice';
import manualSWR from '~/utils/fetchers';
import { Button } from '../ui/Button.tsx';
import { Input } from '../ui/Input.tsx';
@@ -24,9 +25,9 @@ export default function ModelDialog({ mutate, setModelSave, handleSaveState }) {
const [saveText, setSaveText] = useState('Save');
const [required, setRequired] = useState(false);
const inputRef = useRef(null);
const updateCustomGpt = manualSWR(`http://localhost:3080/api/customGpts/`, 'post');
const updateCustomGpt = manualSWR(`/api/customGpts/`, 'post');
const submitHandler = (e) => {
const selectHandler = (e) => {
if (chatGptLabel.length === 0) {
e.preventDefault();
setRequired(true);
@@ -36,7 +37,9 @@ export default function ModelDialog({ mutate, setModelSave, handleSaveState }) {
dispatch(setCustomGpt({ chatGptLabel, promptPrefix }));
dispatch(setModel('chatgptCustom'));
handleSaveState(chatGptLabel.toLowerCase());
// dispatch(setDisabled(false));
// Set new conversation
dispatch(setNewConvo());
dispatch(setSubmission({}));
};
const saveHandler = (e) => {
@@ -137,10 +140,10 @@ export default function ModelDialog({ mutate, setModelSave, handleSaveState }) {
{saveText}
</Button>
<DialogClose
onClick={submitHandler}
onClick={selectHandler}
className="inline-flex h-10 items-center justify-center rounded-md border-none bg-gray-900 py-2 px-4 text-sm font-semibold text-white transition-colors hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-200 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900"
>
Submit
Select
</DialogClose>
</DialogFooter>
</DialogContent>

View File

@@ -1,13 +1,16 @@
import React, { useState, useRef } from 'react';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { DropdownMenuRadioItem } from '../ui/DropdownMenu.tsx';
import { setModels } from '~/store/modelSlice';
import { Circle } from 'lucide-react';
import { DialogTrigger } from '../ui/Dialog.tsx';
import RenameButton from '../Conversations/RenameButton';
import TrashIcon from '../svg/TrashIcon';
import manualSWR from '~/utils/fetchers';
import { getIconOfModel } from '../../utils';
export default function ModelItem({ modelName, value, onSelect }) {
export default function ModelItem({ modelName, value, model, onSelect, id, chatGptLabel, promptPrefix }) {
const dispatch = useDispatch();
const { customModel } = useSelector((state) => state.submit);
const { initial } = useSelector((state) => state.models);
const [isHovering, setIsHovering] = useState(false);
@@ -15,8 +18,17 @@ export default function ModelItem({ modelName, value, onSelect }) {
const [currentName, setCurrentName] = useState(modelName);
const [modelInput, setModelInput] = useState(modelName);
const inputRef = useRef(null);
const rename = manualSWR(`http://localhost:3080/api/customGpts`, 'post');
const deleteCustom = manualSWR(`http://localhost:3080/api/customGpts/delete`, 'post');
const rename = manualSWR(`/api/customGpts`, 'post');
const deleteCustom = manualSWR(`/api/customGpts/delete`, 'post', (res) => {
const fetchedModels = res.data.map((modelItem) => ({
...modelItem,
name: modelItem.chatGptLabel
}));
dispatch(setModels(fetchedModels));
});
const icon = getIconOfModel({ size: 20, sender: modelName, isCreatedByUser: false, model, chatGptLabel, promptPrefix, error: false, className: "mr-2" });
if (value === 'chatgptCustom') {
return (
@@ -25,25 +37,27 @@ export default function ModelItem({ modelName, value, onSelect }) {
value={value}
className="dark:font-semibold dark:text-gray-100 dark:hover:bg-gray-800"
>
{icon}
{modelName}
<sup>$</sup>
</DropdownMenuRadioItem>
</DialogTrigger>
);
}
}
if (initial[value]) {
if (initial[value])
return (
<DropdownMenuRadioItem
value={value}
className="dark:font-semibold dark:text-gray-100 dark:hover:bg-gray-800"
>
{icon}
{modelName}
{value === 'chatgpt' && <sup>$</sup>}
</DropdownMenuRadioItem>
);
}
const handleMouseOver = () => {
setIsHovering(true);
};
@@ -77,8 +91,7 @@ export default function ModelItem({ modelName, value, onSelect }) {
const onDelete = async (e) => {
e.preventDefault();
await deleteCustom.trigger({ value: currentName.toLowerCase() });
// await mutate();
await deleteCustom.trigger({ _id: id });
onSelect('chatgpt', true);
};
@@ -90,63 +103,66 @@ export default function ModelItem({ modelName, value, onSelect }) {
const buttonClass = {
className:
'z-50 rounded-md m-0 text-gray-400 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
'invisible group-hover:visible z-50 rounded-md m-0 text-gray-400 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
};
const itemClass = {
className:
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none hover:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:hover:bg-slate-700 dark:font-semibold dark:text-gray-100 dark:hover:bg-gray-800'
'relative flex group cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none hover:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:hover:bg-slate-700 dark:font-semibold dark:text-gray-100 dark:hover:bg-gray-800'
};
const showButtons = isHovering && !initial[value];
return (
<span
value={value}
className={itemClass.className}
onClick={(e) => {
if (isHovering) {
return;
}
onSelect(value, true);
}}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
>
{customModel === value && (
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Circle className="h-2 w-2 fill-current" />
</span>
)}
{icon}
{renaming === true ? (
<input
ref={inputRef}
key={id}
type="text"
className="pointer-events-auto z-50 m-0 mr-2 w-3/4 border border-blue-500 bg-transparent p-0 text-sm leading-tight outline-none"
value={modelInput}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setModelInput(e.target.value)}
onBlur={onRename}
// onBlur={onRename}
onKeyDown={handleKeyDown}
/>
) : (
modelInput
<div className=" overflow-hidden">{modelInput}</div>
)}
{value === 'chatgpt' && <sup>$</sup>}
{showButtons && (
<>
<RenameButton
twcss={`ml-auto mr-2 ${buttonClass.className}`}
onRename={onRename}
renaming={renaming}
renameHandler={renameHandler}
/>
<button
{...buttonClass}
onClick={onDelete}
>
<TrashIcon />
</button>
</>
)}
<RenameButton
twcss={`ml-auto mr-2 ${buttonClass.className}`}
onRename={onRename}
renaming={renaming}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
renameHandler={renameHandler}
/>
<button
{...buttonClass}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
onClick={onDelete}
>
<TrashIcon />
</button>
</span>
);
}

View File

@@ -1,14 +1,22 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { useSelector, useDispatch } from 'react-redux';
import { setModel, setDisabled, setCustomGpt, setCustomModel } from '~/store/submitSlice';
import { setConversation } from '~/store/convoSlice';
import {
setSubmission,
setModel,
setDisabled,
setCustomGpt,
setCustomModel
} from '~/store/submitSlice';
import { setNewConvo } from '~/store/convoSlice';
import ModelDialog from './ModelDialog';
import MenuItems from './MenuItems';
import manualSWR from '~/utils/fetchers';
import { setModels } from '~/store/modelSlice';
import GPTIcon from '../svg/GPTIcon';
import BingIcon from '../svg/BingIcon';
import { swr } from '~/utils/fetchers';
import { setModels, setInitial } from '~/store/modelSlice';
import { setMessages } from '~/store/messageSlice';
import { setText } from '~/store/textSlice';
import { Button } from '../ui/Button.tsx';
import { getIconOfModel } from '../../utils';
import {
DropdownMenu,
@@ -25,68 +33,108 @@ export default function ModelMenu() {
const dispatch = useDispatch();
const [modelSave, setModelSave] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const { model, customModel } = useSelector((state) => state.submit);
const { model, customModel, promptPrefix, chatGptLabel } = useSelector((state) => state.submit);
const { models, modelMap, initial } = useSelector((state) => state.models);
const { trigger } = manualSWR(`http://localhost:3080/api/customGpts`, 'get', (res) => {
const { data, isLoading, mutate } = swr(`/api/customGpts`, (res) => {
const fetchedModels = res.map((modelItem) => ({
...modelItem,
name: modelItem.chatGptLabel
name: modelItem.chatGptLabel,
model: 'chatgptCustom'
}));
dispatch(setModels(fetchedModels));
});
useEffect(() => {
trigger();
const lastSelected = JSON.parse(localStorage.getItem('model'));
if (lastSelected && lastSelected !== 'chatgptCustom' && initial[lastSelected]) {
dispatch(setModel(lastSelected));
mutate();
try {
const lastSelected = JSON.parse(localStorage.getItem('model'));
if (lastSelected === 'chatgptCustom') {
return;
} else if (initial[lastSelected]) {
dispatch(setModel(lastSelected));
}
} catch (err) {
console.log(err);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
axios.get('/api/models', {
timeout: 1000,
withCredentials: true
}).then((res) => {
return res.data
}).then((data) => {
const initial = {chatgpt: data?.hasOpenAI, chatgptCustom: data?.hasOpenAI, bingai: data?.hasBing, sydney: data?.hasBing, chatgptBrowser: data?.hasChatGpt}
dispatch(setInitial(initial))
// TODO, auto reset default model
if (data?.hasOpenAI) {
dispatch(setModel('chatgpt'));
dispatch(setDisabled(false));
dispatch(setCustomModel(null));
dispatch(setCustomGpt({ chatGptLabel: null, promptPrefix: null }));
} else if (data?.hasBing) {
dispatch(setModel('bingai'));
dispatch(setDisabled(false));
dispatch(setCustomModel(null));
dispatch(setCustomGpt({ chatGptLabel: null, promptPrefix: null }));
} else if (data?.hasChatGpt) {
dispatch(setModel('chatgptBrowser'));
dispatch(setDisabled(false));
dispatch(setCustomModel(null));
dispatch(setCustomGpt({ chatGptLabel: null, promptPrefix: null }));
} else {
dispatch(setDisabled(true));
}
}).catch((error) => {
console.error(error)
console.log('Not login!')
window.location.href = "/auth/login";
})
}, [])
useEffect(() => {
localStorage.setItem('model', JSON.stringify(model));
}, [model]);
const onChange = (value, custom = false) => {
const filteredModels = models.filter(({model, _id }) => initial[model] );
const onChange = (value) => {
if (!value) {
return;
} else if (value === model) {
return;
} else if (value === 'chatgptCustom') {
// dispatch(setMessages([]));
// return;
} else if (initial[value]) {
dispatch(setModel(value));
dispatch(setDisabled(false));
dispatch(setCustomModel(null));
if (custom) {
trigger();
}
dispatch(setCustomGpt({ chatGptLabel: null, promptPrefix: null }));
} else if (!initial[value]) {
const chatGptLabel = modelMap[value]?.chatGptLabel;
const promptPrefix = modelMap[value]?.promptPrefix;
dispatch(setCustomGpt({ chatGptLabel, promptPrefix }));
dispatch(setModel('chatgptCustom'));
dispatch(setCustomModel(value));
if (custom) {
setMenuOpen((prevOpen) => !prevOpen);
}
setMenuOpen(false);
} else if (!modelMap[value]) {
dispatch(setCustomModel(null));
}
// Set new conversation
dispatch(
setConversation({
title: 'New Chat',
error: false,
conversationId: null,
parentMessageId: null
})
);
dispatch(setText(''));
dispatch(setMessages([]));
dispatch(setNewConvo());
dispatch(setSubmission({}));
};
const onOpenChange = (open) => {
mutate();
if (!open) {
setModelSave(false);
}
@@ -126,8 +174,9 @@ export default function ModelMenu() {
'dark:disabled:hover:bg-transparent'
];
const isBing = model === 'bingai' || model === 'sydney';
const colorProps = model === 'chatgpt' ? chatgptColorProps : defaultColorProps;
const icon = model === 'bingai' ? <BingIcon button={true} /> : <GPTIcon button={true} />;
const icon = getIconOfModel({ size: 32, sender: chatGptLabel || model, isCreatedByUser: false, model, chatGptLabel, promptPrefix, error: false, button: true});
return (
<Dialog onOpenChange={onOpenChange}>
@@ -139,14 +188,14 @@ export default function ModelMenu() {
<Button
variant="outline"
// style={{backgroundColor: 'rgb(16, 163, 127)'}}
className={`absolute bottom-0.5 rounded-md border-0 p-1 pl-2 outline-none ${colorProps.join(
className={`absolute top-[0.25px] items-center mb-0 rounded-md border-0 p-1 ml-1 md:ml-0 outline-none ${colorProps.join(
' '
)} focus:ring-0 focus:ring-offset-0 disabled:bottom-0.5 dark:data-[state=open]:bg-opacity-50 md:bottom-1 md:left-2 md:pl-1 md:disabled:bottom-1`}
)} focus:ring-0 focus:ring-offset-0 disabled:top-[0.25px] dark:data-[state=open]:bg-opacity-50 md:top-1 md:left-1 md:pl-1 md:disabled:top-1`}
>
{icon}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 dark:bg-gray-700">
<DropdownMenuContent className="w-56 dark:bg-gray-700" onCloseAutoFocus={(event) => event.preventDefault()}>
<DropdownMenuLabel className="dark:text-gray-300">Select a Model</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
@@ -154,15 +203,17 @@ export default function ModelMenu() {
onValueChange={onChange}
className="overflow-y-auto"
>
<MenuItems
models={models}
onSelect={onChange}
/>
{filteredModels.length?
<MenuItems
models={filteredModels}
onSelect={onChange}
/>:<DropdownMenuLabel className="dark:text-gray-300">No model available.</DropdownMenuLabel>
}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<ModelDialog
mutate={trigger}
mutate={mutate}
modelMap={modelMap}
setModelSave={setModelSave}
handleSaveState={handleSaveState}

View File

@@ -3,24 +3,19 @@ import TrashIcon from '../svg/TrashIcon';
import { useSWRConfig } from 'swr';
import manualSWR from '~/utils/fetchers';
import { useDispatch } from 'react-redux';
import { setConversation, removeAll } from '~/store/convoSlice';
import { setNewConvo, removeAll } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
import { setSubmission } from '~/store/submitSlice';
export default function ClearConvos() {
const dispatch = useDispatch();
const { mutate } = useSWRConfig();
const { trigger } = manualSWR(`http://localhost:3080/api/convos/clear`, 'post', () => {
const { trigger } = manualSWR(`/api/convos/clear`, 'post', () => {
dispatch(setMessages([]));
dispatch(
setConversation({
error: false,
title: 'New chat',
conversationId: null,
parentMessageId: null
})
);
mutate(`http://localhost:3080/api/convos`);
dispatch(setNewConvo());
dispatch(setSubmission({}));
mutate(`/api/convos`);
});
const clickHandler = () => {

View File

@@ -0,0 +1,23 @@
import React, { useState, useContext } from 'react';
import { useSelector } from 'react-redux';
import LogOutIcon from '../svg/LogOutIcon';
export default function Logout() {
const { user } = useSelector((state) => state.user);
const clickHandler = () => {
window.location.href = "/auth/logout";
};
return (
<a
className="flex cursor-pointer items-center gap-3 rounded-md py-3 px-3 text-sm text-white transition-colors duration-200 hover:bg-gray-500/10"
onClick={clickHandler}
>
<LogOutIcon />
{user?.display || user?.username || 'USER'}
<small>Log out</small>
</a>
);
}

View File

@@ -1,11 +1,33 @@
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { setNewConvo } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
import { setSubmission } from '~/store/submitSlice';
import { setText } from '~/store/textSlice';
export default function MobileNav({ setNavVisible }) {
const dispatch = useDispatch();
const { conversationId, convos, title } = useSelector((state) => state.convo);
const toggleNavVisible = () => {
setNavVisible((prev) => {
return !prev
})
}
const newConvo = () => {
dispatch(setText(''));
dispatch(setMessages([]));
dispatch(setNewConvo());
dispatch(setSubmission({}));
}
export default function MobileNav({ title = 'New Chat' }) {
return (
<div className="sticky top-0 z-10 flex items-center border-b border-white/20 bg-gray-800 pl-1 pt-1 text-gray-200 sm:pl-3 md:hidden">
<div className="fixed top-0 left-0 right-0 z-10 flex items-center border-b border-white/20 bg-gray-800 pl-1 pt-1 text-gray-200 sm:pl-3 md:hidden">
<button
type="button"
className="-ml-0.5 -mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-md hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white dark:hover:text-white"
onClick={toggleNavVisible}
>
<span className="sr-only">Open sidebar</span>
<svg
@@ -40,10 +62,11 @@ export default function MobileNav({ title = 'New Chat' }) {
/>
</svg>
</button>
<h1 className="flex-1 text-center text-base font-normal">{title}</h1>
<h1 className="flex-1 text-center text-base font-normal">{title || 'New Chat'}</h1>
<button
type="button"
className="px-3"
onClick={newConvo}
>
<svg
stroke="currentColor"

View File

@@ -1,18 +1,22 @@
import React from 'react';
import NavLink from './NavLink';
import LogOutIcon from '../svg/LogOutIcon';
import SearchBar from './SearchBar';
import ClearConvos from './ClearConvos';
import DarkMode from './DarkMode';
import Logout from './Logout';
export default function NavLinks() {
export default function NavLinks({ fetch, onSearchSuccess, clearSearch }) {
return (
<>
{/* <SearchBar fetch={fetch} onSuccess={onSearchSuccess} clearSearch={clearSearch}/> */}
<ClearConvos />
<DarkMode />
<NavLink
<Logout />
{/* <NavLink
svg={LogOutIcon}
text="Log out"
/>
/> */}
</>
);
}

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { setConversation } from '~/store/convoSlice';
import { setNewConvo } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
import { setSubmission } from '~/store/submitSlice';
import { setText } from '~/store/textSlice';
export default function NewChat() {
@@ -10,7 +11,8 @@ export default function NewChat() {
const clickHandler = () => {
dispatch(setText(''));
dispatch(setMessages([]));
dispatch(setConversation({ title: 'New Chat', error: false, conversationId: null, parentMessageId: null }));
dispatch(setNewConvo());
dispatch(setSubmission({}));
};
return (

View File

@@ -0,0 +1,60 @@
import React, { useState, useCallback } from 'react';
import { debounce } from 'lodash';
import { useDispatch } from 'react-redux';
import { Search } from 'lucide-react';
import { setQuery } from '~/store/searchSlice';
export default function SearchBar({ fetch, clearSearch }) {
const dispatch = useDispatch();
const [inputValue, setInputValue] = useState('');
const debouncedChangeHandler = useCallback(
debounce((q) => {
dispatch(setQuery(q));
if (q.length > 0) {
fetch(q, 1);
}
}, 750),
[dispatch]
);
const handleKeyUp = (e) => {
const { value } = e.target;
if (e.keyCode === 8 && value === '') {
// Value after clearing input: ""
console.log(`Value after clearing input: "${value}"`);
dispatch(setQuery(''));
clearSearch();
}
};
const changeHandler = (e) => {
let q = e.target.value;
setInputValue(q);
q = q.trim();
if (q === '') {
dispatch(setQuery(''));
clearSearch();
} else {
debouncedChangeHandler(q);
}
};
return (
<div className="flex cursor-pointer items-center gap-3 rounded-md py-3 px-3 text-sm text-white transition-colors duration-200 hover:bg-gray-500/10">
{<Search className="h-4 w-4" />}
<input
// ref={inputRef}
type="text"
className="m-0 mr-0 w-full border-none bg-transparent p-0 text-sm leading-tight outline-none"
value={inputValue}
onChange={changeHandler}
placeholder="Search messages"
onKeyUp={handleKeyUp}
// onBlur={onRename}
/>
</div>
);
}

View File

@@ -1,40 +1,105 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import _ from 'lodash';
import NewChat from './NewChat';
import Spinner from '../svg/Spinner';
import Pages from '../Conversations/Pages';
import Conversations from '../Conversations';
import NavLinks from './NavLinks';
import useDidMountEffect from '~/hooks/useDidMountEffect';
import { swr } from '~/utils/fetchers';
import { searchFetcher, swr } from '~/utils/fetchers';
import { useDispatch, useSelector } from 'react-redux';
import { incrementPage, setConvos } from '~/store/convoSlice';
import { setConvos, setNewConvo, refreshConversation } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
import { setDisabled } from '~/store/submitSlice';
export default function Nav() {
export default function Nav({ navVisible, setNavVisible }) {
const dispatch = useDispatch();
const [isHovering, setIsHovering] = useState(false);
const { conversationId, convos, pageNumber } = useSelector((state) => state.convo);
const onSuccess = (data) => {
dispatch(setConvos(data));
const [isFetching, setIsFetching] = useState(false);
const [pages, setPages] = useState(1);
const [pageNumber, setPage] = useState(1);
const { search, query } = useSelector((state) => state.search);
const { conversationId, convos, refreshConvoHint } = useSelector((state) => state.convo);
const onSuccess = (data, searchFetch = false) => {
if (search) {
return;
}
const { conversations, pages } = data;
if (pageNumber > pages) {
setPage(pages);
} else {
dispatch(setConvos({ convos: conversations, searchFetch }));
setPages(pages);
}
};
const { data, isLoading, mutate } = swr(
`http://localhost:3080/api/convos?pageNumber=${pageNumber}`,
onSuccess
);
const onSearchSuccess = (data, expectedPage) => {
const res = data;
dispatch(setConvos({ convos: res.conversations, searchFetch: true }));
if (expectedPage) {
setPage(expectedPage);
}
setPage(res.pageNumber);
setPages(res.pages);
setIsFetching(false);
if (res.messages) {
dispatch(setMessages(res.messages));
dispatch(setDisabled(true));
}
};
const fetch = useCallback(_.partialRight(searchFetcher.bind(null, () => setIsFetching(true)), onSearchSuccess), [dispatch]);
const clearSearch = () => {
setPage(1);
dispatch(refreshConversation());
dispatch(setNewConvo());
dispatch(setMessages([]));
dispatch(setDisabled(false));
};
const { data, isLoading, mutate } = swr(`/api/convos?pageNumber=${pageNumber}`, onSuccess, {
revalidateOnMount: false,
});
const containerRef = useRef(null);
const scrollPositionRef = useRef(null);
const showMore = async (increment = true) => {
if (increment) {
const container = containerRef.current;
if (container) {
scrollPositionRef.current = container.scrollTop;
}
dispatch(incrementPage());
const moveToTop = () => {
const container = containerRef.current;
if (container) {
scrollPositionRef.current = container.scrollTop;
}
await mutate();
};
useDidMountEffect(() => mutate(), [conversationId]);
const nextPage = async () => {
moveToTop();
if (!search) {
setPage((prev) => prev + 1);
await mutate();
} else {
await fetch(query, +pageNumber + 1);
}
};
const previousPage = async () => {
moveToTop();
if (!search) {
setPage((prev) => prev - 1);
await mutate();
} else {
await fetch(query, +pageNumber - 1);
}
};
useEffect(() => {
if (!search) {
mutate();
}
}, [pageNumber, conversationId, refreshConvoHint]);
useEffect(() => {
const container = containerRef.current;
@@ -47,42 +112,105 @@ export default function Nav() {
}
}, [data]);
useEffect(() => {
setNavVisible(false);
}, [conversationId]);
const toggleNavVisible = () => {
setNavVisible((prev) => {
return !prev;
});
};
const containerClasses =
isLoading && pageNumber === 1
? 'flex flex-col gap-2 text-gray-100 text-sm h-full justify-center items-center'
: 'flex flex-col gap-2 text-gray-100 text-sm';
return (
<div className="dark hidden bg-gray-900 md:fixed md:inset-y-0 md:flex md:w-[260px] md:flex-col">
<div className="flex h-full min-h-0 flex-col ">
<div className="scrollbar-trigger flex h-full w-full flex-1 items-start border-white/20">
<nav className="flex h-full flex-1 flex-col space-y-1 p-2">
<NewChat />
<div
className={`-mr-2 flex-1 flex-col overflow-y-auto ${
isHovering ? '' : 'scrollbar-transparent'
} border-b border-white/20`}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
ref={containerRef}
>
<div className={containerClasses}>
{isLoading && pageNumber === 1 ? (
<Spinner />
) : (
<Conversations
conversations={convos}
conversationId={conversationId}
showMore={showMore}
<>
<div
className={
'nav dark bg-gray-900 md:fixed md:inset-y-0 md:flex md:w-[260px] md:flex-col' +
(navVisible ? ' active' : '')
}
>
<div className="flex h-full min-h-0 flex-col ">
<div className="scrollbar-trigger flex h-full w-full flex-1 items-start border-white/20">
<nav className="flex h-full flex-1 flex-col space-y-1 p-2">
<NewChat />
<div
className={`flex-1 flex-col overflow-y-auto ${
isHovering ? '' : 'scrollbar-transparent'
} border-b border-white/20`}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
ref={containerRef}
>
<div className={containerClasses}>
{/* {(isLoading && pageNumber === 1) ? ( */}
{(isLoading && pageNumber === 1) || (isFetching) ? (
<Spinner />
) : (
<Conversations
conversations={convos}
conversationId={conversationId}
moveToTop={moveToTop}
/>
)}
<Pages
pageNumber={pageNumber}
pages={pages}
nextPage={nextPage}
previousPage={previousPage}
/>
)}
</div>
</div>
</div>
<NavLinks />
</nav>
<NavLinks
fetch={fetch}
onSearchSuccess={onSearchSuccess}
clearSearch={clearSearch}
/>
</nav>
</div>
</div>
<button
type="button"
className="nav-close-button -ml-0.5 -mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-md text-white hover:text-gray-900 hover:text-white focus:outline-none focus:ring-white"
onClick={toggleNavVisible}
>
<span className="sr-only">Open sidebar</span>
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-6 w-6"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="3"
y1="6"
x2="15"
y2="18"
/>
<line
x1="3"
y1="18"
x2="15"
y2="6"
/>
</svg>
</button>
</div>
</div>
<div
className={'nav-mask' + (navVisible ? ' active' : '')}
onClick={toggleNavVisible}
></div>
</>
);
}

View File

@@ -1,10 +1,10 @@
import React from 'react';
export default function BingIcon() {
export default function BingIcon({ size=25 }) {
return (
<svg
width="25"
height="25"
width={size}
height={size}
viewBox="0 0 56 56"
fill="none"
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,9 +1,9 @@
import React from 'react';
export default function GPTIcon({ button = false, menu = false }) {
export default function GPTIcon({ button = false, menu = false, size=25 }) {
let unit = '41';
let height = unit;
let width = unit;
let height = size;
let width = size;
let boxSize = '6';
if (button) {
// unit = '45';
@@ -20,7 +20,6 @@ export default function GPTIcon({ button = false, menu = false }) {
fill="none"
xmlns="http://www.w3.org/2000/svg"
strokeWidth="1.5"
className={`h-${boxSize} w-${boxSize}`}
>
<path
d="M37.5324 16.8707C37.9808 15.5241 38.1363 14.0974 37.9886 12.6859C37.8409 11.2744 37.3934 9.91076 36.676 8.68622C35.6126 6.83404 33.9882 5.3676 32.0373 4.4985C30.0864 3.62941 27.9098 3.40259 25.8215 3.85078C24.8796 2.7893 23.7219 1.94125 22.4257 1.36341C21.1295 0.785575 19.7249 0.491269 18.3058 0.500197C16.1708 0.495044 14.0893 1.16803 12.3614 2.42214C10.6335 3.67624 9.34853 5.44666 8.6917 7.47815C7.30085 7.76286 5.98686 8.3414 4.8377 9.17505C3.68854 10.0087 2.73073 11.0782 2.02839 12.312C0.956464 14.1591 0.498905 16.2988 0.721698 18.4228C0.944492 20.5467 1.83612 22.5449 3.268 24.1293C2.81966 25.4759 2.66413 26.9026 2.81182 28.3141C2.95951 29.7256 3.40701 31.0892 4.12437 32.3138C5.18791 34.1659 6.8123 35.6322 8.76321 36.5013C10.7141 37.3704 12.8907 37.5973 14.9789 37.1492C15.9208 38.2107 17.0786 39.0587 18.3747 39.6366C19.6709 40.2144 21.0755 40.5087 22.4946 40.4998C24.6307 40.5054 26.7133 39.8321 28.4418 38.5772C30.1704 37.3223 31.4556 35.5506 32.1119 33.5179C33.5027 33.2332 34.8167 32.6547 35.9659 31.821C37.115 30.9874 38.0728 29.9178 38.7752 28.684C39.8458 26.8371 40.3023 24.6979 40.0789 22.5748C39.8556 20.4517 38.9639 18.4544 37.5324 16.8707ZM22.4978 37.8849C20.7443 37.8874 19.0459 37.2733 17.6994 36.1501C17.7601 36.117 17.8666 36.0586 17.936 36.0161L25.9004 31.4156C26.1003 31.3019 26.2663 31.137 26.3813 30.9378C26.4964 30.7386 26.5563 30.5124 26.5549 30.2825V19.0542L29.9213 20.998C29.9389 21.0068 29.9541 21.0198 29.9656 21.0359C29.977 21.052 29.9842 21.0707 29.9867 21.0902V30.3889C29.9842 32.375 29.1946 34.2791 27.7909 35.6841C26.3872 37.0892 24.4838 37.8806 22.4978 37.8849ZM6.39227 31.0064C5.51397 29.4888 5.19742 27.7107 5.49804 25.9832C5.55718 26.0187 5.66048 26.0818 5.73461 26.1244L13.699 30.7248C13.8975 30.8408 14.1233 30.902 14.3532 30.902C14.583 30.902 14.8088 30.8408 15.0073 30.7248L24.731 25.1103V28.9979C24.7321 29.0177 24.7283 29.0376 24.7199 29.0556C24.7115 29.0736 24.6988 29.0893 24.6829 29.1012L16.6317 33.7497C14.9096 34.7416 12.8643 35.0097 10.9447 34.4954C9.02506 33.9811 7.38785 32.7263 6.39227 31.0064ZM4.29707 13.6194C5.17156 12.0998 6.55279 10.9364 8.19885 10.3327C8.19885 10.4013 8.19491 10.5228 8.19491 10.6071V19.808C8.19351 20.0378 8.25334 20.2638 8.36823 20.4629C8.48312 20.6619 8.64893 20.8267 8.84863 20.9404L18.5723 26.5542L15.206 28.4979C15.1894 28.5089 15.1703 28.5155 15.1505 28.5173C15.1307 28.5191 15.1107 28.516 15.0924 28.5082L7.04046 23.8557C5.32135 22.8601 4.06716 21.2235 3.55289 19.3046C3.03862 17.3858 3.30624 15.3413 4.29707 13.6194ZM31.955 20.0556L22.2312 14.4411L25.5976 12.4981C25.6142 12.4872 25.6333 12.4805 25.6531 12.4787C25.6729 12.4769 25.6928 12.4801 25.7111 12.4879L33.7631 17.1364C34.9967 17.849 36.0017 18.8982 36.6606 20.1613C37.3194 21.4244 37.6047 22.849 37.4832 24.2684C37.3617 25.6878 36.8382 27.0432 35.9743 28.1759C35.1103 29.3086 33.9415 30.1717 32.6047 30.6641C32.6047 30.5947 32.6047 30.4733 32.6047 30.3889V21.188C32.6066 20.9586 32.5474 20.7328 32.4332 20.5338C32.319 20.3348 32.154 20.1698 31.955 20.0556ZM35.3055 15.0128C35.2464 14.9765 35.1431 14.9142 35.069 14.8717L27.1045 10.2712C26.906 10.1554 26.6803 10.0943 26.4504 10.0943C26.2206 10.0943 25.9948 10.1554 25.7963 10.2712L16.0726 15.8858V11.9982C16.0715 11.9783 16.0753 11.9585 16.0837 11.9405C16.0921 11.9225 16.1048 11.9068 16.1207 11.8949L24.1719 7.25025C25.4053 6.53903 26.8158 6.19376 28.2383 6.25482C29.6608 6.31589 31.0364 6.78077 32.2044 7.59508C33.3723 8.40939 34.2842 9.53945 34.8334 10.8531C35.3826 12.1667 35.5464 13.6095 35.3055 15.0128ZM14.2424 21.9419L10.8752 19.9981C10.8576 19.9893 10.8423 19.9763 10.8309 19.9602C10.8195 19.9441 10.8122 19.9254 10.8098 19.9058V10.6071C10.8107 9.18295 11.2173 7.78848 11.9819 6.58696C12.7466 5.38544 13.8377 4.42659 15.1275 3.82264C16.4173 3.21869 17.8524 2.99464 19.2649 3.1767C20.6775 3.35876 22.0089 3.93941 23.1034 4.85067C23.0427 4.88379 22.937 4.94215 22.8668 4.98473L14.9024 9.58517C14.7025 9.69878 14.5366 9.86356 14.4215 10.0626C14.3065 10.2616 14.2466 10.4877 14.2479 10.7175L14.2424 21.9419ZM16.071 17.9991L20.4018 15.4978L24.7325 17.9975V22.9985L20.4018 25.4983L16.071 22.9985V17.9991Z"

View File

@@ -0,0 +1,7 @@
import React from 'react';
export default function StopGeneratingIcon() {
return (
<svg stroke="currentColor" fill="none" strokeWidth="1.5" viewBox="0 0 24 24" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></svg>
);
}

89
client/src/mobile.css Normal file
View File

@@ -0,0 +1,89 @@
.nav-mask {
position: fixed;
z-index: 998;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(86, 88, 105, 0.75);
padding-left: 420px;
padding-top: 12px;
opacity: 0;
transition: all 0.5s;
pointer-events: none;
}
.nav {
transition: all 0.5s;
}
.nav-close-button {
display: none;
}
@media (min-width: 1024px) {
.sibling-switch-container {
display: none;
}
}
@media (max-width: 1024px) {
/* .sibling-switch {
left: 114px;
top: unset;
bottom: 4px;
visibility: visible;
z-index: 2;
} */
.sibling-switch {
display: none;
}
.resubmit-edit-button {
display: block;
visibility: visible;
}
}
@media (max-width: 767px) {
.input-panel-button {
border: 0;
}
.input-panel-button svg {
width: 16px;
height: 16px;
}
.input-panel {
}
.nav-close-button {
display: block;
position: absolute;
left: 100%;
top: 12px;
margin-left: 20px;
}
.nav {
position: fixed;
z-index: 999;
left: calc(-100%);
top: 0;
bottom: 0;
max-width: 320px;
width: calc(100% - 60px);
opacity: 0;
}
.nav.active {
left: 0;
opacity: 1;
}
.nav-mask.active {
opacity: 1;
pointer-events: auto;
}
}

View File

@@ -5,6 +5,7 @@ const initialState = {
title: 'ChatGPT Clone',
conversationId: null,
parentMessageId: null,
jailbreakConversationId: null,
conversationSignature: null,
clientId: null,
invocationId: null,
@@ -12,6 +13,10 @@ const initialState = {
promptPrefix: null,
convosLoading: false,
pageNumber: 1,
pages: 1,
refreshConvoHint: 0,
search: false,
latestMessage: null,
convos: []
};
@@ -19,33 +24,80 @@ const currentSlice = createSlice({
name: 'convo',
initialState,
reducers: {
refreshConversation: (state, action) => {
state.refreshConvoHint = state.refreshConvoHint + 1;
},
setConversation: (state, action) => {
return { ...state, ...action.payload };
// return { ...state, ...action.payload };
for (const key in action.payload) {
if (Object.hasOwnProperty.call(action.payload, key)) {
state[key] = action.payload[key];
}
}
},
setError: (state, action) => {
state.error = action.payload;
},
incrementPage: (state) => {
increasePage: (state) => {
state.pageNumber = state.pageNumber + 1;
},
decreasePage: (state) => {
state.pageNumber = state.pageNumber - 1;
},
setPage: (state, action) => {
state.pageNumber = action.payload;
},
setNewConvo: (state) => {
state.error = false;
state.title = 'ChatGPT Clone';
state.conversationId = null;
state.parentMessageId = null;
state.jailbreakConversationId = null;
state.conversationSignature = null;
state.clientId = null;
state.invocationId = null;
state.chatGptLabel = null;
state.promptPrefix = null;
state.convosLoading = false;
state.latestMessage = null;
},
setConvos: (state, action) => {
const newConvos = action.payload.filter((convo) => {
return !state.convos.some((c) => c.conversationId === convo.conversationId);
});
state.convos = [...state.convos, ...newConvos].sort(
(a, b) => new Date(b.created) - new Date(a.created)
);
const { convos, searchFetch } = action.payload;
if (searchFetch) {
state.convos = convos;
} else {
state.convos = convos.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
},
setPages: (state, action) => {
state.pages = action.payload;
},
removeConvo: (state, action) => {
state.convos = state.convos.filter((convo) => convo.conversationId !== action.payload);
},
removeAll: (state) => {
state.convos = [];
},
setLatestMessage: (state, action) => {
state.latestMessage = action.payload;
}
}
});
export const { setConversation, setConvos, setError, incrementPage, removeConvo, removeAll } =
currentSlice.actions;
export const {
refreshConversation,
setConversation,
setPages,
setConvos,
setNewConvo,
setError,
increasePage,
decreasePage,
setPage,
removeConvo,
removeAll,
setLatestMessage
} = currentSlice.actions;
export default currentSlice.reducer;

View File

@@ -1,10 +1,12 @@
import { configureStore } from '@reduxjs/toolkit';
import convoReducer from './convoSlice.js';
import messageReducer from './messageSlice.js'
import modelReducer from './modelSlice.js'
import submitReducer from './submitSlice.js'
import textReducer from './textSlice.js'
import messageReducer from './messageSlice.js';
import modelReducer from './modelSlice.js';
import submitReducer from './submitSlice.js';
import textReducer from './textSlice.js';
import userReducer from './userReducer.js';
import searchReducer from './searchSlice.js';
export const store = configureStore({
reducer: {
@@ -13,6 +15,8 @@ export const store = configureStore({
models: modelReducer,
text: textReducer,
submit: submitReducer,
user: userReducer,
search: searchReducer
},
devTools: true,
});
devTools: true
});

View File

@@ -1,7 +1,9 @@
import { createSlice } from '@reduxjs/toolkit';
import buildTree from '~/utils/buildTree';
const initialState = {
messages: [],
messageTree: []
};
const currentSlice = createSlice({
@@ -10,10 +12,24 @@ const currentSlice = createSlice({
reducers: {
setMessages: (state, action) => {
state.messages = action.payload;
const groupAll = action.payload[0]?.searchResult;
if (groupAll) console.log('grouping all messages');
state.messageTree = buildTree(action.payload, groupAll);
},
setEmptyMessage: (state) => {
state.messages = [
{
messageId: '1',
conversationId: '1',
parentMessageId: '1',
sender: '',
text: ''
}
]
},
}
});
export const { setMessages, setSubmitState } = currentSlice.actions;
export const { setMessages, setEmptyMessage } = currentSlice.actions;
export default currentSlice.reducer;

View File

@@ -5,27 +5,37 @@ const initialState = {
{
_id: '0',
name: 'ChatGPT',
value: 'chatgpt'
value: 'chatgpt',
model: 'chatgpt'
},
{
_id: '1',
name: 'CustomGPT',
value: 'chatgptCustom'
value: 'chatgptCustom',
model: 'chatgptCustom'
},
{
_id: '2',
name: 'BingAI',
value: 'bingai'
}
// {
// _id: '3',
// name: 'ChatGPT',
// value: 'chatgptBrowser'
// }
value: 'bingai',
model: 'bingai'
},
{
_id: '3',
name: 'Sydney',
value: 'sydney',
model: 'sydney'
},
{
_id: '4',
name: 'ChatGPT',
value: 'chatgptBrowser',
model: 'chatgptBrowser'
},
],
modelMap: {},
// initial: { chatgpt: true, chatgptCustom: true, bingai: true, chatgptBrowser: true }
initial: { chatgpt: true, chatgptCustom: true, bingai: true, }
initial: { chatgpt: false, chatgptCustom: false, bingai: false, sydney: false, chatgptBrowser: false }
// initial: { chatgpt: true, chatgptCustom: true, bingai: true, }
};
const currentSlice = createSlice({
@@ -40,15 +50,19 @@ const currentSlice = createSlice({
models.slice(initialState.models.length).forEach((modelItem) => {
modelMap[modelItem.value] = {
chatGptLabel: modelItem.chatGptLabel,
promptPrefix: modelItem.promptPrefix
promptPrefix: modelItem.promptPrefix,
model: 'chatgptCustom'
};
});
state.modelMap = modelMap;
},
setInitial: (state, action) => {
state.initial = action.payload;
}
}
});
export const { setModels } = currentSlice.actions;
export const { setModels, setInitial } = currentSlice.actions;
export default currentSlice.reducer;

View File

@@ -0,0 +1,30 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
search: false,
query: '',
};
const currentSlice = createSlice({
name: 'search',
initialState,
reducers: {
setSearchState: (state, action) => {
state.search = action.payload;
},
setQuery: (state, action) => {
const q = action.payload;
state.query = q;
if (q === '') {
state.search = false;
} else if (q?.length > 0 && !state.search) {
state.search = true;
}
},
}
});
export const { setSearchState, setQuery } = currentSlice.actions;
export default currentSlice.reducer;

View File

@@ -2,11 +2,14 @@ import { createSlice } from '@reduxjs/toolkit';
const initialState = {
isSubmitting: false,
disabled: false,
submission: {},
stopStream: false,
disabled: true,
model: 'chatgpt',
promptPrefix: '',
chatGptLabel: '',
customModel: null
promptPrefix: null,
chatGptLabel: null,
customModel: null,
cursor: true,
};
const currentSlice = createSlice({
@@ -16,12 +19,28 @@ const currentSlice = createSlice({
setSubmitState: (state, action) => {
state.isSubmitting = action.payload;
},
setSubmission: (state, action) => {
state.submission = action.payload;
if (Object.keys(action.payload).length === 0) {
state.isSubmitting = false;
}
},
setStopStream: (state, action) => {
state.stopStream = action.payload;
},
setDisabled: (state, action) => {
state.disabled = action.payload;
},
setModel: (state, action) => {
state.model = action.payload;
},
toggleCursor: (state, action) => {
if (action.payload) {
state.cursor = action.payload;
} else {
state.cursor = !state.cursor;
}
},
setCustomGpt: (state, action) => {
state.promptPrefix = action.payload.promptPrefix;
state.chatGptLabel = action.payload.chatGptLabel;
@@ -32,7 +51,7 @@ const currentSlice = createSlice({
}
});
export const { setSubmitState, setDisabled, setModel, setCustomGpt, setCustomModel } =
export const { toggleCursor, setSubmitState, setSubmission, setStopStream, setDisabled, setModel, setCustomGpt, setCustomModel } =
currentSlice.actions;
export default currentSlice.reducer;

View File

@@ -0,0 +1,19 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
user: null,
};
const currentSlice = createSlice({
name: 'user',
initialState,
reducers: {
setUser: (state, action) => {
state.user = action.payload;
},
}
});
export const { setUser } = currentSlice.actions;
export default currentSlice.reducer;

View File

@@ -7,6 +7,37 @@
outline: 1px solid limegreen !important;
} */
/* p small {
opacity: 0;
animation: fadeIn 3s ease forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} */
/* .LazyLoad {
opacity: 0;
transition: all 1s ease-in-out;
} */
p > small {
opacity: 0;
animation: fadein 3s forwards;
}
@keyframes fadein {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
blockquote, dd, dl, fieldset, figure, h1, h2, h3, h4, h5, h6, hr, p, pre {
margin: 0;
}
@@ -1225,7 +1256,6 @@ html {
vertical-align: baseline;
}
/* .result-streaming>:not(ol):not(ul):not(pre):last-child:after,
.result-streaming>ol:last-child li:last-child:after,
.result-streaming>pre:last-child code:after,
@@ -1850,3 +1880,6 @@ button.scroll-convo {
background-color:hsla(0,0%,100%,.4)
}
}
.hidden-visibility {
visibility: hidden;
}

2086
client/src/style2.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
const even = 'w-full border-b border-black/10 dark:border-gray-900/50 text-gray-800 bg-white dark:text-gray-100 group dark:bg-gray-800 hover:bg-gray-100/25 hover:text-gray-700 dark:hover:bg-[#32343e] dark:hover:text-gray-200';
const odd = 'w-full border-b border-black/10 bg-gray-50 dark:border-gray-900/50 text-gray-800 dark:text-gray-100 group bg-gray-100 dark:bg-[#444654] hover:bg-gray-100/40 hover:text-gray-700 dark:hover:bg-[#3b3d49] dark:hover:text-gray-200';
export default function buildTree(messages, groupAll = false) {
let messageMap = {};
let rootMessages = [];
if (!groupAll) {
// Traverse the messages array and store each element in messageMap.
messages.forEach((message) => {
messageMap[message.messageId] = { ...message, children: [] };
const parentMessage = messageMap[message.parentMessageId];
if (parentMessage) parentMessage.children.push(messageMap[message.messageId]);
else rootMessages.push(messageMap[message.messageId]);
});
return rootMessages;
}
// Group all messages into one tree
let parentId = null;
messages.forEach((message, i) => {
messageMap[message.messageId] = { ...message, bg: i % 2 === 0 ? even : odd, children: [] };
const currentMessage = messageMap[message.messageId];
const parentMessage = messageMap[parentId];
if (parentMessage) parentMessage.children.push(currentMessage);
else rootMessages.push(currentMessage);
parentId = message.messageId;
});
return rootMessages;
}

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