306 Commits

Author SHA1 Message Date
626f406711 Revert "Deprecated max_token to max-completion_tokens"
All checks were successful
Build static content / build (push) Successful in 7m54s
This reverts commit 9dd4d99e54.
2024-12-08 17:04:28 +08:00
9dd4d99e54 Deprecated max_token to max-completion_tokens 2024-12-08 16:46:09 +08:00
5039bdfca8 temperature default to 1 2024-12-08 16:41:42 +08:00
64f1e3d70e update models 2024-12-08 16:41:02 +08:00
b98900873c more options on new chat store 2024-12-08 16:25:55 +08:00
400ebafc37 format code 2024-12-08 16:19:20 +08:00
e7c26560bb store response_model_name by message 2024-12-08 16:19:04 +08:00
6aca74a7b4 remove: update_total_token()
All checks were successful
Build static content / build (push) Successful in 8m10s
2024-10-15 18:20:21 +08:00
a763355420 rename: tsx 2024-10-15 18:01:13 +08:00
8122f6d8bf refac: pages/addToolMsg.tx 2024-10-15 18:00:25 +08:00
31c49ff888 refac: components/WhisperButton.tsx 2024-10-15 17:52:32 +08:00
fd5f87f845 refac: components/StatusBar.tsx 2024-10-15 17:40:52 +08:00
587d0ba57d rename components 2024-10-15 17:34:01 +08:00
795cb16ed4 refac: componets/versionHint.tsx 2024-10-15 17:32:33 +08:00
47d96198e8 refac: components/templates.tsx 2024-10-15 17:27:56 +08:00
eb8a6dc8ed move chatbox.tsx to pages/ 2024-10-15 17:20:28 +08:00
32866d9a7f move: app.tsx to pages/ 2024-10-15 17:14:44 +08:00
9cfb09a5ac refac: app.tsx 2024-10-15 17:13:21 +08:00
7196799625 remove; buildFieldForSearch 2024-10-15 16:48:18 +08:00
915987cbfe refac: @/indexed/upgrade 2024-10-15 16:47:03 +08:00
9855027876 refac: @/utils/buildForSearch.ts 2024-10-15 15:11:21 +08:00
2670183343 refact: @/utils/totalCost.tx 2024-10-15 15:07:05 +08:00
ad291bd72e refac: move const STORAGE_NAME 2024-10-15 15:04:27 +08:00
1fbd4ee87b refac: export getDefaultParam 2024-10-15 15:01:51 +08:00
af2ae82e74 refac: newChatStore to use options 2024-10-15 14:59:20 +08:00
9e74e419c9 bump gitlab ci node to version 20.x 2024-10-15 14:57:57 +08:00
ee9da49f70 refac: models newChatStore 2024-10-15 10:34:35 +08:00
dccf4827c9 refac: @/types/chatstore.ts 2024-10-15 10:18:20 +08:00
04bac03fd7 move chatstore to @types/chatsotre.ts 2024-10-15 09:44:40 +08:00
f0f040c42c use @ import alias
All checks were successful
Build static content / build (push) Successful in 10m51s
2024-10-14 18:09:07 +08:00
1c3c94bae4 rename class to className 2024-10-14 17:50:55 +08:00
f5d43ec4b9 fix: type hiint dispatch stateupdater 2024-10-14 17:50:37 +08:00
6df6ad031a update dependency 2024-10-14 17:19:13 +08:00
3cc80fd8fe refactor: disable maxGenTokens feature in newChatStore 2024-09-19 09:40:00 +08:00
e09036860f add model gpt-4o-2024-08-06 2024-08-09 09:30:26 +08:00
49537a0d58 add word-wrap: anywhere 2024-08-06 10:39:57 +08:00
243f1a5ea5 Update ChatBOX component to display total tokens in the short status bar 2024-08-02 16:41:46 +08:00
b3a2988907 fix overflow-hidden class from navbar 2024-08-02 16:29:53 +08:00
4e2ac186d5 show model price in long status bar 2024-08-02 16:17:16 +08:00
41e3026ac5 bring the long status bar back 2024-08-02 11:19:37 +08:00
c123f9454a set chat bubble width to max 2024-07-30 18:54:32 +08:00
c473fd496e fix settings window overflow 2024-07-26 17:02:09 +08:00
6a848580f6 Update navbar model name text size 2024-07-26 16:57:34 +08:00
91f7043b7c replace class with className, clean style 2024-07-26 16:51:29 +08:00
2b430bd395 Update navbar styling in ChatBOX component 2024-07-26 16:42:01 +08:00
46c8a87a06 remove 0613 model 2024-07-26 16:21:31 +08:00
fb48723d34 Adjust styling and add line height to textarea 2024-07-24 10:55:45 +08:00
370a680d94 update model list
- add gpt-4o-mini
- remove EOL models
- set default model to gpt-4o-mini
2024-07-19 09:34:22 +08:00
9417b99ad4 fix prompt wrapping issue 2024-07-18 10:34:17 +08:00
63b2f41b97 click prompt to open settings 2024-07-18 10:32:39 +08:00
44f5d28565 click status bar model name to open settings 2024-07-18 10:30:53 +08:00
b3d84ea454 chatbox width: 100%
this fix the issue of the sidebar width changing.
2024-07-18 09:37:04 +08:00
b9fdfb8905 hide prompt if systemMessageContent is empty 2024-07-18 09:34:09 +08:00
3328b3e94d fix: message word-wrap and develop mode UI 2024-07-18 09:31:29 +08:00
603ec23d24 Merge pull request #2 from ecwu/master
Refactor UI Design with DaisyUI
2024-07-18 09:14:06 +08:00
ecwu
08a7670509 fix type problem 2024-07-18 00:21:17 +08:00
ecwu
3ee5cd32bc redesign top stats with a navbar 2024-07-18 00:18:20 +08:00
ecwu
9298839b4f fix over tight textarea for text input 2024-07-17 23:38:51 +08:00
ecwu
9d0f93ecf6 fix overflow api card 2024-07-17 11:34:39 +08:00
ecwu
c2c17e5956 refine setting layout 2024-07-17 11:25:09 +08:00
ecwu
415fb934ae clean up setting panel 2024-07-17 02:06:13 +08:00
ecwu
148d912be5 refine search panel 2024-07-17 00:30:17 +08:00
ecwu
52d8c3280e adding new stat panel 2024-07-17 00:17:26 +08:00
ecwu
a45785c607 fix absolute main.tsx path 2024-07-16 23:06:35 +08:00
ecwu
8c17b842b2 fix absolute main.tsx path 2024-07-16 23:04:07 +08:00
ecwu
4bf3e02962 reform the message box with bubble style 2024-07-16 23:01:18 +08:00
ecwu
0ae53ff954 reconstitution ui with daisyui 2024-07-16 21:51:58 +08:00
4079ec77f9 Fix storage upgrade alert condition 2024-06-03 16:13:24 +08:00
6e647d9181 fix delta is undefined 2024-05-22 15:41:56 +08:00
4162866fd6 Add autoFocus to search input and improve code formatting 2024-05-15 18:22:52 +08:00
09f6e5b490 Update models in models.ts 2024-05-15 15:57:42 +08:00
6247d75234 Convert search query and content to lowercase 2024-05-15 00:59:57 +08:00
4dd29af256 Refactor search.tsx to use preact/hooks 2024-05-14 19:06:06 +08:00
245db574f8 Add query and preview to ChatStoreSearchResult 2024-05-14 19:04:12 +08:00
2386e6f2e9 Update storage version to 11 and add alert during upgrade 2024-05-14 18:56:40 +08:00
117fce390c Add search function 2024-05-14 18:51:41 +08:00
8e1e82cf4b Update AddImage component UI 2024-05-14 09:40:27 +08:00
c0ec74638a Refactor chat message type switching logic 2024-05-14 09:35:33 +08:00
d4da4c3e32 Update headings and styles, and fix image layout in chatbox and editMessageDetail components 2024-05-14 09:34:12 +08:00
2a21985a17 add gpt-4o models 2024-05-14 09:26:07 +08:00
f0c16a3cd1 localStorage follow
All checks were successful
Build static content / build (push) Successful in 3m8s
2024-03-30 11:40:34 +08:00
f54b192616 add follow scroll option 2024-03-30 11:37:19 +08:00
b20de667a4 fix: set logprobs to default false 2024-03-16 18:32:39 +08:00
a76cf224f6 new button w-full
All checks were successful
Build static content / build (push) Successful in 5m8s
2024-03-16 15:15:31 +08:00
943cb5f392 add gitea action
All checks were successful
Build static content / build (push) Successful in 4m30s
2024-03-07 02:08:22 +08:00
74b60b4e95 fix: logprobs in vision model not permit 2024-02-24 09:47:44 +08:00
24aba9ae07 fix: old version logprobs to false 2024-02-23 19:46:09 +08:00
4b1f81f72b fix: build 2024-02-23 19:04:41 +08:00
2224d2e5ed fix: old version logprobs to true 2024-02-23 19:02:08 +08:00
c9c51a85cf support logprobs 2024-02-23 19:00:20 +08:00
d01d7c747b change default model to gpt-3.5-turbo-0125 2024-02-20 15:28:43 +08:00
159d0615c9 fix: new chatStore of param with old chatStore 2024-02-18 22:31:41 +08:00
e8650e2c7e add gpt-3.5-turbo-0125 2024-02-10 15:28:41 +08:00
7f20e9b35f change default model to gpt-3.5-turbo 2024-02-10 11:48:04 +08:00
1391f3f26f v2.1.0 2024-01-30 10:41:13 +08:00
0f97ce61ef move source code link to settings 2024-01-30 10:22:45 +08:00
cb3abe33e9 langCode from localStorage 2024-01-30 10:19:08 +08:00
7130e8d163 change message type 2024-01-30 10:09:36 +08:00
a35c392728 fix: hide "add a tool call" 2024-01-30 10:01:51 +08:00
55d8db1217 Revert "upgrade dependencies"
This reverts commit ab341f3893.
2024-01-29 15:07:16 +08:00
fcd3137a1e update openai models 2024-01-26 15:48:29 +08:00
ab341f3893 upgrade dependencies 2024-01-26 15:48:22 +08:00
7a8a8fd64e fix: params on starting to create chaStore 2024-01-25 15:35:43 +08:00
ef3595cee2 fix: max param on newChatStore() 2024-01-25 15:08:08 +08:00
24c37025b3 allow custom model 2024-01-23 15:31:50 +08:00
78f0b1000a change: apply max url query in settings 2024-01-19 22:15:32 +08:00
e3162cac0d add: autofocus on input textarea 2024-01-17 09:48:22 +08:00
a4d3091e06 fix: send button auto height 2024-01-16 17:55:46 +08:00
0eabcc0d0a show all saved apis 2024-01-16 17:48:41 +08:00
36f2ce57d5 rename: assistant to ai 2024-01-16 17:39:43 +08:00
b6ae82aa9c fix: ctrl+enter trigger auto hegiht 2024-01-16 10:21:41 +08:00
67a8140a01 fix: auto hegiht for textarea 2024-01-14 14:33:05 +08:00
324e374884 add markdown style 2024-01-12 17:59:07 +08:00
4f3f6bd544 change textarea auto height method 2024-01-12 17:17:48 +08:00
18a7c7b5d7 rearrange setting fields 2024-01-12 16:57:25 +08:00
e7c5d9a8fd use emoji instead of hide 2024-01-12 16:50:34 +08:00
e097067d78 settings: move image gen api button 2024-01-12 16:39:34 +08:00
abe9d9532c settings: move tts button 2024-01-12 16:34:30 +08:00
ed59811819 settings: move whisper api button 2024-01-12 16:30:25 +08:00
9157992ee1 settings: move save chat api button 2024-01-12 16:27:02 +08:00
d81be86947 args: max 2024-01-10 10:21:46 +08:00
68a6ba40e5 fix: show save api template button 2023-12-26 17:36:49 +08:00
54abddc517 skip when choices is empty 2023-12-22 17:14:28 +08:00
54f2843677 user can send message without defining an api key 2023-12-22 17:11:11 +08:00
59d31e356b disable auto change stream mode 2023-12-22 17:03:36 +08:00
fe1a796daa fix: edit/del template will trigger set template 2023-12-22 13:02:51 +08:00
8542a44817 change image gen role to assistant 2023-12-22 12:52:11 +08:00
6a77ddc21a update help content 2023-12-21 13:23:09 +08:00
b1e15e8cb0 change max temperature to 2 2023-12-20 22:44:16 +08:00
639c3d5877 type: whisper api key 2023-12-15 23:43:16 +08:00
09d43ed566 fix: tools call and function call 2023-12-14 17:35:31 +08:00
9a42e2758c opt: tts play re-render 2023-12-07 01:14:58 +08:00
3f2893d1bf fix: audio type 2023-11-30 12:04:03 +08:00
92252f66ed support tts format 2023-11-30 12:01:15 +08:00
97a75ce35f save tts audio 2023-11-30 11:47:29 +08:00
647098ef83 support json_mode 2023-11-27 11:42:56 +08:00
34d4827580 fix: undefind from template 2023-11-16 10:10:56 +08:00
55608378b6 save tool templates 2023-11-11 13:36:29 +08:00
8c877cb6a8 add default api endpoint 2023-11-11 13:22:07 +08:00
f3953693fd save whisper / tts / image gen api 2023-11-11 13:16:55 +08:00
a72e98ad25 add try catch alert to imageGen 2023-11-10 22:14:47 +08:00
bd6edb4ca8 show image gen button 2023-11-10 22:13:19 +08:00
adeadd3586 add image gen api 2023-11-10 22:10:16 +08:00
35f1e9f052 add: add & delete tool_call 2023-11-10 20:59:47 +08:00
b7f519a679 split editMessage code 2023-11-10 20:51:51 +08:00
e9f8183d66 show tool status 2023-11-10 20:38:29 +08:00
42094c1eab show content with tool call 2023-11-10 20:35:44 +08:00
8f9d508a18 render tool reponse 2023-11-10 20:33:24 +08:00
1217513ae3 fix undefined on tool_calls 2023-11-10 20:10:29 +08:00
920ab2ea26 Merge branch 'tool-call' 2023-11-10 19:51:07 +08:00
aec8f5d40f reset current reset toolsString 2023-11-10 19:50:41 +08:00
dd56f1b94a message edit tool json 2023-11-10 19:49:34 +08:00
94f9434fe5 fix: tool_calls body 2023-11-10 19:16:34 +08:00
f8cd357e81 inherit toolsString 2023-11-10 17:27:28 +08:00
ebf00353f6 fix: render del bug 2023-11-10 17:26:27 +08:00
33f4ab7b42 fix copy render msg 2023-11-10 17:12:57 +08:00
626e7780f8 fix tool-call streaming mode 2023-11-10 12:22:28 +08:00
856976c03c fix fetch resp msg 2023-11-10 11:29:18 +08:00
6aaf7beb5b add <hr /> above tool_call msg 2023-11-10 11:17:22 +08:00
e7e3e693ef tool call WIP 2023-11-09 17:48:04 +08:00
81660d563f add support for tool call (function call) 2023-11-09 17:04:27 +08:00
4b7d601840 fix user input msg content type 2023-11-09 13:42:33 +08:00
1033298187 fix: edit msg token length 2023-11-09 13:16:13 +08:00
415b7a02d8 show img button where model is vision 2023-11-08 20:23:28 +08:00
72c3f337a7 update readme 2023-11-08 20:02:24 +08:00
0c8938dea5 better cound token 2023-11-08 19:40:38 +08:00
e46e299094 default to high quality image 2023-11-08 19:29:12 +08:00
0d27de52a3 change vision image_url format 2023-11-08 19:21:56 +08:00
b107aca639 allow send empty message with image 2023-11-08 18:37:39 +08:00
8b6ceb36f1 add gpt-4-1106-vision-preview model 2023-11-08 18:36:24 +08:00
7946ab236d support vision 2023-11-08 18:28:48 +08:00
ed090136ac add param max_gen_tokens 2023-11-08 16:16:15 +08:00
9142665585 support message detail interface 2023-11-08 15:55:52 +08:00
11b835460b alert success only if migrated 2023-11-08 15:22:13 +08:00
053495254b v2.0.0 migrate to indexedDB 2023-11-08 13:54:51 +08:00
ed32f836ba Merge remote-tracking branch 'github/master' 2023-11-07 14:12:30 +08:00
15054eec85 support tts 2023-11-07 13:50:49 +08:00
63a8331250 add gpt-4 preview models 2023-11-07 11:53:54 +08:00
6d6fb2a462 update 1106 model 2023-11-07 11:23:57 +08:00
2bb09c8bfc change default model to gpt-3.5-turbo-1106 2023-11-07 11:14:23 +08:00
44e812bdb7 Revert "select audio device"
This reverts commit 6fa960a3c8.
2023-11-03 23:51:31 +08:00
6fa960a3c8 select audio device 2023-11-03 21:23:34 +08:00
a4366f800a fix: stop audio stream 2023-11-03 20:48:57 +08:00
6bf6fb9455 support enable/disable temperature and top_p 2023-11-03 14:01:16 +08:00
40081f81f7 fix: import 2023-11-03 12:00:32 +08:00
9c2a30d23d add reset current to prompt 2023-11-03 11:44:03 +08:00
cc143f6796 fix p children warning 2023-10-25 14:33:10 +08:00
abeccd6aa9 button to delete all chatstore 2023-10-25 14:32:56 +08:00
695221d912 auto select language code 2023-10-25 14:09:45 +08:00
fc72a5b7c1 show api templates in development mode 2023-10-25 14:05:57 +08:00
e19431b833 optimize setting layout 2023-10-25 11:43:42 +08:00
d30b2e3d5a set default temperature to 0.7 2023-10-25 11:37:58 +08:00
8aa6ea1a93 upgrade dependencies 2023-10-25 11:37:22 +08:00
2e8e8e008c i18n 2023-10-25 11:35:17 +08:00
717c76f4dd fix: application/json, ignore empty input 2023-10-24 22:59:56 +08:00
e1172600c1 fix: ignore empty system message 2023-10-24 22:53:31 +08:00
aba4e42202 remove default system message 2023-10-24 22:45:22 +08:00
83833187f0 render api templates if exitst 2023-10-24 22:39:45 +08:00
2bd36d4b34 v1.6.0 save api as template 2023-10-24 20:54:53 +08:00
272cc31b7c user not scaleable 2023-10-08 10:19:36 +08:00
598575f29b Rewrite stream parse logic 2023-09-15 17:54:36 +08:00
49eae4a2b9 fix: parse stream error 2023-09-15 17:09:39 +08:00
47af34c061 fix whisper join 2023-08-25 19:17:36 +08:00
e5d11cdb31 fix opanai whisper reponse tyep 2023-08-25 19:13:29 +08:00
90d72a521d fix ogx -> ogg 2023-08-25 19:09:44 +08:00
75d5291589 try fetch whsiper API 2023-08-25 18:56:34 +08:00
603d1653b3 fix typo 2023-08-25 18:56:16 +08:00
290508e777 update readme 2023-08-25 18:42:41 +08:00
b7ae9e0838 fix mic button animation and prompt 2023-08-25 18:38:08 +08:00
cdb0c9a1b5 support whisper stt 2023-08-25 18:19:10 +08:00
687ebf790c hide api key and endpoint 2023-08-25 10:33:06 +08:00
b0d6484d74 fix: stream response model name and cost 2023-08-25 10:21:45 +08:00
992d8f39ce inherite temperature 2023-08-25 10:13:11 +08:00
968e6602f7 async generator stream 2023-08-10 18:36:29 +08:00
682094485a update readme 2023-08-06 17:41:02 +08:00
50fa7b41d3 change input to hide default 2023-08-06 17:26:27 +08:00
c90e32d74f change default temperature from 1.0 to 0.7
When developing prompt, it is generally better use a lower temperature
from beginning
2023-07-27 11:32:36 +08:00
b5ec1fe518 press esc to close settings window 2023-07-27 11:09:04 +08:00
69e1c013b3 press esc to exit edit message 2023-07-27 11:07:36 +08:00
120bdddc3b esc to close edit message window 2023-07-27 10:45:44 +08:00
196e12a4fd click elsewhere to close edit message window 2023-07-27 10:42:17 +08:00
c84be818e9 fix edit message window opacity 2023-07-27 10:32:32 +08:00
5f0481fece fix re-generate only delete assistant message 2023-07-27 10:26:55 +08:00
1c34c123aa move edit to the bottom of message 2023-07-20 11:44:17 +08:00
dd6da3a55c disable re-generate and completion when generating 2023-07-20 11:41:52 +08:00
8d62bfae77 fix systemMessage token 2023-07-20 11:37:10 +08:00
1270568a08 fix clear history with example 2023-07-20 11:36:07 +08:00
5f4b360b05 update token when setChatStore 2023-07-20 11:34:15 +08:00
dba39c771f polify structure clone 2023-07-20 02:18:15 +08:00
bef955dd7d fix clone 2023-07-20 02:11:15 +08:00
6acf87807f fix template default params 2023-07-20 02:01:57 +08:00
4caf8ba669 add template 2023-07-20 01:34:33 +08:00
7ad28386b7 move accumulate cost to bottom 2023-07-20 00:41:00 +08:00
28aec2405d Update models.ts fix price 2023-07-18 17:12:52 +08:00
a5b807d411 move setting button to top, lg:w-2/3 2023-07-18 14:27:11 +08:00
5f7c41d4cc hide message-content class if render markdown 2023-07-14 16:05:32 +08:00
e358a02042 inherit dev mode 2023-07-14 15:58:42 +08:00
9e520d9b64 render markdown by default 2023-07-14 15:44:13 +08:00
08c087468b quick hide input 2023-07-13 19:31:52 +08:00
ab5eab1a9b chatgpt.ts add role type check 2023-07-13 18:18:08 +08:00
f8ce1d3915 setInputMsg("") after click assistant or user 2023-07-13 18:03:21 +08:00
488e441635 show re-generate and compeletion based on history length 2023-07-13 18:02:13 +08:00
9a132b007e fix set chat example 2023-07-12 18:24:33 +08:00
d2f21f44bb fix dev msg options in dark mode 2023-07-12 00:44:06 +08:00
32bf692386 support example_user and example_assistant 2023-07-12 00:40:03 +08:00
1b558a8194 fix icon sytle 2023-07-08 20:54:33 +08:00
af630f53d0 copy to clipboard and fix del icon style 2023-07-08 20:45:53 +08:00
5fa6d4182a fix args copy 2023-07-08 15:14:46 +08:00
feecd6582d esgimate token length after editing 2023-07-08 14:45:21 +08:00
4ce23bb1eb move dev buttons to down 2023-07-08 14:40:26 +08:00
1cac4a77c0 calculate response token in stream mode 2023-07-08 14:35:10 +08:00
f2129f6a67 fix edit message z-index 10 2023-07-08 14:24:32 +08:00
86699511df edit message token 2023-07-08 14:23:56 +08:00
e2f78987a3 setShowRetry(false) on success 2023-07-08 14:17:30 +08:00
8fb17ba3f8 fix bug on completion with empty history 2023-07-08 14:16:51 +08:00
1b8eeb0c86 fix calculate user token in hide state 2023-07-08 14:03:45 +08:00
9d34189c96 regenerate and complte function 2023-07-08 10:07:10 +08:00
22b4d59b1c v1.4.0 warning 2023-07-08 09:56:41 +08:00
c0566f3105 better way to edit message content 2023-07-07 19:11:01 +08:00
1c14e413bb fix develop mode style 2023-07-07 18:57:54 +08:00
1da4d38799 1.4.0 dev mode support more ops and args 2023-07-07 18:20:14 +08:00
ecfa32f75e chatgpt.ts support more args 2023-07-07 11:06:30 +08:00
7553cf41cf update packages 2023-07-03 10:17:59 +08:00
66ab8d4978 handle partial chunk 2023-06-19 20:33:18 +08:00
da31f32fcb Update chatbox.tsx handle "data:{xxxx}" 2023-06-19 20:11:09 +08:00
280545c224 accumulated cost 2023-06-14 13:14:28 +08:00
7ded1c8522 change default system message to "follow my instructions carefully" 2023-06-14 12:47:53 +08:00
e76f087776 fix generating message style 2023-06-14 12:45:11 +08:00
67e12e6933 change default model to gpt-3.5-turbo-0613 2023-06-14 12:16:22 +08:00
4860c6dff3 inherit chatStore.model 2023-06-14 12:14:57 +08:00
e03160d04d openai model update 2023-06-14 12:10:18 +08:00
b46b550a70 recognize text/event-stream, charset=utf-8 2023-04-19 16:21:05 +08:00
8f1a327ea0 fix total token in stream mode 2023-04-04 00:39:58 +08:00
8c049c9ee9 handle response error 2023-04-03 17:46:07 +08:00
528eb0a300 永远显示提示<p> 2023-04-03 17:46:07 +08:00
d5d077f39c Update README.md 2023-04-02 13:01:38 +08:00
b4244d3900 现在我觉得它不灵车了 2023-04-01 19:57:59 +08:00
8f3d69d2a2 fix todo: estimate user's token 2023-04-01 12:35:26 +08:00
11d9b09e36 prevent undefined on new models 2023-03-31 15:00:57 +08:00
464e417537 switch model with token 2023-03-31 05:14:44 +08:00
5a328db87d cost in stream mode 2023-03-31 05:09:25 +08:00
3b09abaf66 header 2023-03-31 05:00:52 +08:00
05f57f29e5 show cost 2023-03-31 04:50:49 +08:00
11afa12b09 fix show version warning 2023-03-31 04:41:08 +08:00
26f9632f41 record each message's token and hide status, calc postBeginIndex based on token 2023-03-31 04:16:23 +08:00
bdfe03699f support import chat store 2023-03-30 14:33:54 +08:00
fecfc24519 support export as json 2023-03-30 13:56:03 +08:00
07885c681c show response model name 2023-03-30 13:39:19 +08:00
faac2303df gpt-4 内测提示 2023-03-30 13:01:28 +08:00
fc17d6ba15 fix copy max_token from chaStore to client 2023-03-29 16:42:57 +08:00
3de689a796 use gpt-3.5-turbo as default new ChatStore 2023-03-29 15:51:04 +08:00
35ee9cab0e highlight status bar 2023-03-29 15:48:49 +08:00
5fc2c62b4f select chat index after fetch 2023-03-29 15:37:36 +08:00
c31c6cd84a set maxToken based on model 2023-03-29 13:02:48 +08:00
6406993e83 handle create new chatstore on diff model 2023-03-29 12:51:31 +08:00
2d7edeb5b0 support gpt-4 2023-03-29 12:45:59 +08:00
1158fdca38 update stream mode based on response 2023-03-28 21:18:30 +08:00
7c34379ecb calculate token and forget some message 2023-03-28 21:12:34 +08:00
26a66d112b create chatStore if not equal params 2023-03-27 13:43:06 +08:00
e791367d2d break all text 2023-03-27 13:18:30 +08:00
30abf3ed15 auto create new chatStore if there any params in URL 2023-03-27 13:15:23 +08:00
146f34a22d 更好的提示 2023-03-26 21:00:28 +08:00
d5a8799fde issue caused by height: 100vh 2023-03-26 19:30:03 +08:00
241a93b151 更改默认 system message 2023-03-26 18:36:51 +08:00
a4b762586c 更好的文档和提示 2023-03-26 14:04:53 +08:00
700c424d64 更好的提示 2023-03-26 00:11:11 +08:00
49 changed files with 6225 additions and 1246 deletions

View File

@@ -0,0 +1,27 @@
name: Build static content
on:
# Runs on pushes targeting the default branch
push:
branches: ["master"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: 20.x
cache: 'npm'
- run: npm install
- run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: dist-files
path: './dist/'

View File

@@ -1,10 +1,14 @@
> 前排提示:滥用 API 或在不支持的地区调用 API 有被封号的风险 <https://github.com/zhayujie/chatgpt-on-wechat/issues/423>
>
> 建议自行搭建代理中转 API 请求,然后更改对话设置中的 API Endpoint 参数使用中转
>
> 具体反向代理搭建教程请参阅此 [>>Wiki 页面<<](https://github.com/heimoshuiyu/chatgpt-api-web/wiki)
# ChatGPT API WEB
> 灵车东西,做着玩儿的
ChatGPT API WEB 是为 ChatGPT 的日常用户和 Prompt 工程师设计的项目。它让你方便地在 PC 和移动端浏览器上使用 ChatGPT并根据需要调整系统 Prompt 和修改 OpenAI 接口参数。你还可以重复生成、编辑消息(包括用户消息与 AI 消息),以更好地与 ChatGPT 进行交互。
一个简单的网页,调用 OPENAI ChatGPT 进行对话
无论你是 ChatGPT 的一般用户、想要定制 ChatGPT 的用户,还是 Prompt 工程师,这个项目都能满足你的需求
![build status](https://github.com/heimoshuiyu/chatgpt-api-web/actions/workflows/pages.yml/badge.svg)
@@ -12,11 +16,19 @@
- API 调用速度更快更稳定
- 对话记录、API 密钥等使用浏览器的 localStorage 保存在本地
- 可删除对话消息
- 可以设置 system message (如:"你是一个猫娘" 或 "你是一个有用的助理" 或 "将我的话翻译成英语",参见官方 [API 文档](https://platform.openai.com/docs/guides/chat))
-编辑并删除对话消息
- 可以导入/导出整个历史对话记录
- 可以设置 system message (参见官方 [API 文档](https://platform.openai.com/docs/guides/chat)) 例如:
- > You are a helpful assistant
- > 你是一个专业英语翻译,把我说的话翻译成英语,为了保持通顺连贯可以适当修改内容。
- > 根据我的要求撰写并修改商业文案
- > ~~你是一个猫娘,你要用猫娘的语气说话~~
- 可以为不同对话设置不同 APIKEY
- 小(整个网页 30k 左右)
- 可以设置不同的 API Endpoint方便墙内人士使用反向代理转发 API 请求)
- 支持 Whisper 语音转文字输入,将会使用历史对话记录和当前输入框内的文本作为 Prompt提高专有名词识别率
- 支持 TTS API
- 支持 GPT-4v 图片输入
## 屏幕截图
@@ -30,12 +42,22 @@
- 从 [release](https://github.com/heimoshuiyu/chatgpt-api-web/releases) 下载网页文件,或在 [github pages](https://heimoshuiyu.github.io/chatgpt-api-web/) 按 `ctrl+s` 保存网页,然后双击打开
- 自行编译构建网页
### 默认参数继承
新建会话将会使用 URL 中设置的默认参数。
如果 URL 没有设置该参数,则使用 **目前选中的会话** 的参数
### 更改默认参数
- `key`: OPENAI API KEY 默认为空
- `sys`: system message 默认为 "你是一个猫娘,你要模仿猫娘的语气说话"
- `api`: API Endpoint 默认为 `https://api.openai.com/v1/chat/completions`
- `mode`: `fetch``stream` 模式stream 模式下可以动态看到 api 返回的数据,但无法得知 token 数量,只能进行估算,在 token 数量过多时可能会裁切过多或过少历史消息
- `dev`: true / false 开发模式,这个模式下可以看到并调整更多参数
- `temp`: 温度,默认 1
- `whisper-api`: Whisper 语音转文字服务 API, 只有设置了此值后才会显示语音转文字按钮
- `whisper-key`: 用于 Whisper 服务的 key如果留空则默认使用上方的 OPENAI API KEY
例如 `http://localhost:1234/?key=xxxx&api=xxxx` 那么 **新创建** 的会话将会使用该默认 API 和 API Endpoint
@@ -48,4 +70,4 @@ yarn install
yarn build
```
构建产物在 `dist` 文件夹中
构建产物在 `dist` 文件夹中

View File

@@ -1,8 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<html data-theme="cupcake" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<meta
name="description"
content="A simple API playground for OpenAI ChatGPT API"
/>
<title>ChatGPT API Web</title>
</head>
<body>

View File

@@ -9,15 +9,22 @@
"preview": "vite preview"
},
"dependencies": {
"autoprefixer": "^10.4.14",
"postcss": "^8.4.21",
"preact": "^10.11.3",
"sakura.css": "^1.4.1",
"tailwindcss": "^3.2.7"
"@heroicons/react": "^2.1.5",
"@types/ungap__structured-clone": "^1.2.0",
"@ungap/structured-clone": "^1.2.0",
"autoprefixer": "^10.4.20",
"idb": "^8.0.0",
"postcss": "^8.4.47",
"preact": "^10.24.3",
"preact-markdown": "^2.1.0",
"sakura.css": "^1.5.0",
"tailwindcss": "^3.4.13"
},
"devDependencies": {
"@preact/preset-vite": "^2.5.0",
"typescript": "^4.9.3",
"vite": "^4.1.0"
"@preact/preset-vite": "^2.9.1",
"daisyui": "^4.12.13",
"theme-change": "^2.5.0",
"typescript": "^5.6.3",
"vite": "^5.4.8"
}
}

341
src/addImage.tsx Normal file
View File

@@ -0,0 +1,341 @@
import { useState } from "preact/hooks";
import { ChatStore } from "@/types/chatstore";
import { MessageDetail } from "@/chatgpt";
import { Tr } from "@/translate";
interface Props {
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
images: MessageDetail[];
setShowAddImage: (se: boolean) => void;
setImages: (images: MessageDetail[]) => void;
}
interface ImageResponse {
url?: string;
b64_json?: string;
revised_prompt: string;
}
export function AddImage({
chatStore,
setChatStore,
setShowAddImage,
setImages,
images,
}: Props) {
const [enableHighResolution, setEnableHighResolution] = useState(true);
const [imageGenPrompt, setImageGenPrompt] = useState("");
const [imageGenModel, setImageGenModel] = useState("dall-e-3");
const [imageGenN, setImageGenN] = useState(1);
const [imageGenQuality, setImageGEnQuality] = useState("standard");
const [imageGenResponseFormat, setImageGenResponseFormat] =
useState("b64_json");
const [imageGenSize, setImageGenSize] = useState("1024x1024");
const [imageGenStyle, setImageGenStyle] = useState("vivid");
const [imageGenGenerating, setImageGenGenerating] = useState(false);
useState("b64_json");
return (
<div
className="absolute z-10 bg-black bg-opacity-50 w-full h-full flex justify-center items-center left-0 top-0 overflow-scroll"
onClick={() => {
setShowAddImage(false);
}}
>
<div
className="bg-base-200 p-2 z-20"
onClick={(event) => {
event.stopPropagation();
}}
>
<div className="flex justify-between items-center p-1">
<h3>Add Images</h3>
<button
className="btn btn-sm btn-neutral"
onClick={() => {
setShowAddImage(false);
}}
>
Done
</button>
</div>
<span className="">
<button
className="btn btn-secondary btn-sm disabled:btn-disabled"
onClick={() => {
const image_url = prompt("Image URL");
if (!image_url) {
return;
}
setImages([
...images,
{
type: "image_url",
image_url: {
url: image_url,
detail: enableHighResolution ? "high" : "low",
},
},
]);
}}
>
Add from URL
</button>
<button
className="btn btn-primary btn-sm disabled:btn-disabled"
onClick={() => {
// select file and load it to base64 image URL format
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onloadend = () => {
const base64data = reader.result;
setImages([
...images,
{
type: "image_url",
image_url: {
url: String(base64data),
detail: enableHighResolution ? "high" : "low",
},
},
]);
};
};
input.click();
}}
>
Add from local file
</button>
<span
onClick={() => {
setEnableHighResolution(!enableHighResolution);
}}
>
<label>High resolution</label>
<input type="checkbox" checked={enableHighResolution} />
</span>
</span>
<div className="divider"></div>
{chatStore.image_gen_api && chatStore.image_gen_key && (
<div className="flex flex-col">
<h3>Generate Image</h3>
<span className="flex flex-col justify-between m-1 p-1">
<label>Prompt: </label>
<textarea
className="textarea textarea-sm textarea-bordered"
value={imageGenPrompt}
onChange={(e: any) => {
setImageGenPrompt(e.target.value);
}}
/>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Model: </label>
<select
className="select select-sm select-bordered"
value={imageGenModel}
onChange={(e: any) => {
setImageGenModel(e.target.value);
}}
>
<option value="dall-e-3">DALL-E 3</option>
<option value="dall-e-2">DALL-E 2</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>n: </label>
<input
className="input input-sm input-bordered"
value={imageGenN}
type="number"
min={1}
max={10}
onChange={(e: any) => setImageGenN(parseInt(e.target.value))}
/>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Quality: </label>
<select
className="select select-sm select-bordered"
value={imageGenQuality}
onChange={(e: any) => setImageGEnQuality(e.target.value)}
>
<option value="hd">HD</option>
<option value="standard">Standard</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Response Format: </label>
<select
className="select select-sm select-bordered"
value={imageGenResponseFormat}
onChange={(e: any) => setImageGenResponseFormat(e.target.value)}
>
<option value="b64_json">b64_json</option>
<option value="url">url</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Size: </label>
<select
className="select select-sm select-bordered"
value={imageGenSize}
onChange={(e: any) => setImageGenSize(e.target.value)}
>
<option value="256x256">256x256 (dall-e-2)</option>
<option value="512x512">512x512 (dall-e-2)</option>
<option value="1024x1024">1024x1024 (dall-e-2/3)</option>
<option value="1792x1024">1792x1024 (dall-e-3)</option>
<option value="1024x1792">1024x1792 (dall-e-3)</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Style (only dall-e-3): </label>
<select
className="select select-sm select-bordered"
value={imageGenStyle}
onChange={(e: any) => setImageGenStyle(e.target.value)}
>
<option value="vivid">vivid</option>
<option value="natural">natural</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<button
className="btn btn-primary btn-sm"
disabled={imageGenGenerating}
onClick={async () => {
try {
setImageGenGenerating(true);
const body: any = {
prompt: imageGenPrompt,
model: imageGenModel,
n: imageGenN,
quality: imageGenQuality,
response_format: imageGenResponseFormat,
size: imageGenSize,
};
if (imageGenModel === "dall-e-3") {
body.style = imageGenStyle;
}
const resp: ImageResponse[] = (
await fetch(chatStore.image_gen_api, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${chatStore.image_gen_key}`,
},
body: JSON.stringify(body),
}).then((resp) => resp.json())
).data;
console.log("image gen resp", resp);
for (const image of resp) {
let url = "";
if (image.url) url = image.url;
if (image.b64_json)
url = "data:image/png;base64," + image.b64_json;
if (!url) continue;
chatStore.history.push({
role: "assistant",
content: [
{
type: "image_url",
image_url: {
url,
detail: "low",
},
},
{
type: "text",
text: image.revised_prompt,
},
],
hide: false,
token: 65,
example: false,
audio: null,
logprobs: null,
response_model_name: imageGenModel,
});
setChatStore({ ...chatStore });
}
} catch (e) {
console.error(e);
alert("Failed to generate image: " + e);
} finally {
setImageGenGenerating(false);
}
}}
>
{Tr("Generate")}
</button>
</span>
</div>
)}
<div className="flex flex-wrap">
{images.map((image, index) => (
<div className="flex flex-col">
{image.type === "image_url" && (
<img
className="rounded m-1 p-1 border-2 border-gray-400 w-32"
src={image.image_url?.url}
/>
)}
<span className="flex justify-between">
<button
onClick={() => {
const image_url = prompt("Image URL");
if (!image_url) {
return;
}
images[index].image_url = {
url: image_url,
detail: enableHighResolution ? "high" : "low",
};
setImages([...images]);
}}
>
🖋
</button>
<span
onClick={() => {
if (image.image_url === undefined) return;
image.image_url.detail =
image.image_url?.detail === "low" ? "high" : "low";
setImages([...images]);
}}
>
<label>HiRes</label>
<input
type="checkbox"
checked={image.image_url?.detail === "high"}
/>
</span>
<button
onClick={() => {
if (!confirm("Are you sure to delete this image?")) {
return;
}
images.splice(index, 1);
setImages([...images]);
}}
>
</button>
</span>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,181 +0,0 @@
import { useEffect, useState } from "preact/hooks";
import "./global.css";
import { Message } from "./chatgpt";
import getDefaultParams from "./getDefaultParam";
import ChatBOX from "./chatbox";
export interface ChatStore {
systemMessageContent: string;
history: Message[];
postBeginIndex: number;
tokenMargin: number;
totalTokens: number;
maxTokens: number;
apiKey: string;
apiEndpoint: string;
streamMode: boolean;
}
const _defaultAPIEndpoint = "https://api.openai.com/v1/chat/completions";
const newChatStore = (
apiKey = "",
systemMessageContent = "你是一个有用的人工智能助理",
apiEndpoint = _defaultAPIEndpoint,
streamMode = true
): ChatStore => {
return {
systemMessageContent: getDefaultParams("sys", systemMessageContent),
history: [],
postBeginIndex: 0,
tokenMargin: 1024,
totalTokens: 0,
maxTokens: 4096,
apiKey: getDefaultParams("key", apiKey),
apiEndpoint: getDefaultParams("api", apiEndpoint),
streamMode: getDefaultParams("mode", streamMode),
};
};
const STORAGE_NAME = "chatgpt-api-web";
const STORAGE_NAME_SELECTED = `${STORAGE_NAME}-selected`;
const STORAGE_NAME_INDEXES = `${STORAGE_NAME}-indexes`;
export function App() {
// init indexes
const initAllChatStoreIndexes: number[] = JSON.parse(
localStorage.getItem(STORAGE_NAME_INDEXES) ?? "[0]"
);
const [allChatStoreIndexes, setAllChatStoreIndexes] = useState(
initAllChatStoreIndexes
);
useEffect(() => {
if (allChatStoreIndexes.length === 0) allChatStoreIndexes.push(0);
console.log("saved all chat store indexes", allChatStoreIndexes);
localStorage.setItem(
STORAGE_NAME_INDEXES,
JSON.stringify(allChatStoreIndexes)
);
}, [allChatStoreIndexes]);
// init selected index
const [selectedChatIndex, setSelectedChatIndex] = useState(
parseInt(localStorage.getItem(STORAGE_NAME_SELECTED) ?? "0")
);
useEffect(() => {
console.log("set selected chat index", selectedChatIndex);
localStorage.setItem(STORAGE_NAME_SELECTED, `${selectedChatIndex}`);
}, [selectedChatIndex]);
const getChatStoreByIndex = (index: number): ChatStore => {
const key = `${STORAGE_NAME}-${index}`;
const val = localStorage.getItem(key);
if (val === null) return newChatStore();
return JSON.parse(val) as ChatStore;
};
const [chatStore, _setChatStore] = useState(
getChatStoreByIndex(selectedChatIndex)
);
const setChatStore = (cs: ChatStore) => {
console.log("saved chat", selectedChatIndex, chatStore);
localStorage.setItem(
`${STORAGE_NAME}-${selectedChatIndex}`,
JSON.stringify(cs)
);
_setChatStore(cs);
};
useEffect(() => {
_setChatStore(getChatStoreByIndex(selectedChatIndex));
}, [selectedChatIndex]);
return (
<div className="flex text-sm h-screen bg-slate-200 dark:bg-slate-800 dark:text-white">
<div className="flex flex-col h-full p-2 border-r-indigo-500 border-2 dark:border-slate-800 dark:border-r-indigo-500 dark:text-black">
<div className="grow overflow-scroll">
<button
className="bg-violet-300 p-1 rounded hover:bg-violet-400"
onClick={() => {
const max = Math.max(...allChatStoreIndexes);
const next = max + 1;
console.log("save next chat", next);
localStorage.setItem(
`${STORAGE_NAME}-${next}`,
JSON.stringify(
newChatStore(
chatStore.apiKey,
chatStore.systemMessageContent,
chatStore.apiEndpoint,
chatStore.streamMode
)
)
);
allChatStoreIndexes.push(next);
setAllChatStoreIndexes([...allChatStoreIndexes]);
setSelectedChatIndex(next);
}}
>
NEW
</button>
<ul>
{allChatStoreIndexes
.slice()
.reverse()
.map((i) => {
// reverse
return (
<li>
<button
className={`w-full my-1 p-1 rounded hover:bg-blue-300 ${
i === selectedChatIndex ? "bg-blue-500" : "bg-blue-200"
}`}
onClick={() => {
setSelectedChatIndex(i);
}}
>
{i}
</button>
</li>
);
})}
</ul>
</div>
<button
className="rounded bg-rose-400 p-1 my-1 w-full"
onClick={() => {
if (!confirm("Are you sure you want to delete this chat history?"))
return;
console.log("remove item", `${STORAGE_NAME}-${selectedChatIndex}`);
localStorage.removeItem(`${STORAGE_NAME}-${selectedChatIndex}`);
const newAllChatStoreIndexes = [
...allChatStoreIndexes.filter((v) => v !== selectedChatIndex),
];
if (newAllChatStoreIndexes.length === 0) {
newAllChatStoreIndexes.push(0);
setChatStore(
newChatStore(
chatStore.apiKey,
chatStore.systemMessageContent,
chatStore.apiEndpoint,
chatStore.streamMode
)
);
}
// find nex selected chat index
const next =
newAllChatStoreIndexes[newAllChatStoreIndexes.length - 1];
console.log("next is", next);
setSelectedChatIndex(next);
setAllChatStoreIndexes([...newAllChatStoreIndexes]);
}}
>
DEL
</button>
</div>
<ChatBOX chatStore={chatStore} setChatStore={setChatStore} />
</div>
);
}

View File

@@ -1,254 +0,0 @@
import { createRef } from "preact";
import { useEffect, useState } from "preact/hooks";
import type { ChatStore } from "./app";
import ChatGPT, { ChunkMessage, FetchResponse } from "./chatgpt";
import Message from "./message";
import Settings from "./settings";
export default function ChatBOX(props: {
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
}) {
const { chatStore, setChatStore } = props;
// prevent error
if (chatStore === undefined) return <div></div>;
const [inputMsg, setInputMsg] = useState("");
const [showGenerating, setShowGenerating] = useState(false);
const [generatingMessage, setGeneratingMessage] = useState("");
const [showRetry, setShowRetry] = useState(false);
const messagesEndRef = createRef();
useEffect(() => {
console.log("ref", messagesEndRef);
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}, [showRetry, showGenerating, generatingMessage]);
const client = new ChatGPT(chatStore.apiKey);
const _completeWithStreamMode = async (response: Response) => {
// call api, return reponse text
console.log("response", response);
const reader = response.body?.getReader();
const allChunkMessage: string[] = [];
new ReadableStream({
async start() {
while (true) {
let responseDone = false;
let state = await reader?.read();
let done = state?.done;
let value = state?.value;
if (done) break;
let text = new TextDecoder().decode(value);
// console.log("text:", text);
const lines = text
.trim()
.split("\n")
.map((line) => line.trim())
.filter((i) => {
if (!i) return false;
if (i === "data: [DONE]") {
responseDone = true;
return false;
}
return true;
});
console.log("lines", lines);
const jsons: ChunkMessage[] = lines
.map((line) => {
return JSON.parse(line.trim().slice("data: ".length));
})
.filter((i) => i);
// console.log("jsons", jsons);
const chunkText = jsons
.map((j) => j.choices[0].delta.content ?? "")
.join("");
// console.log("chunk text", chunkText);
allChunkMessage.push(chunkText);
setShowGenerating(true);
setGeneratingMessage(allChunkMessage.join(""));
if (responseDone) break;
}
setShowGenerating(false);
// console.log("push to history", allChunkMessage);
chatStore.history.push({
role: "assistant",
content: allChunkMessage.join(""),
});
// manually copy status from client to chatStore
chatStore.maxTokens = client.max_tokens;
chatStore.tokenMargin = client.tokens_margin;
chatStore.totalTokens =
client.total_tokens +
39 +
client.calculate_token_length(allChunkMessage.join(""));
setChatStore({ ...chatStore });
setGeneratingMessage("");
setShowGenerating(false);
},
});
};
const _completeWithFetchMode = async (response: Response) => {
const data = (await response.json()) as FetchResponse;
const content = client.processFetchResponse(data);
chatStore.history.push({ role: "assistant", content });
setShowGenerating(false);
};
// wrap the actuall complete api
const complete = async () => {
// manually copy status from chatStore to client
client.apiEndpoint = chatStore.apiEndpoint;
client.sysMessageContent = chatStore.systemMessageContent;
client.messages = chatStore.history.slice(chatStore.postBeginIndex);
try {
setShowGenerating(true);
const response = await client._fetch(chatStore.streamMode);
const contentType = response.headers.get("content-type");
if (contentType === "text/event-stream") {
await _completeWithStreamMode(response);
} else if (contentType === "application/json") {
await _completeWithFetchMode(response);
} else {
throw `unknown response content type ${contentType}`;
}
// manually copy status from client to chatStore
chatStore.maxTokens = client.max_tokens;
chatStore.tokenMargin = client.tokens_margin;
chatStore.totalTokens = client.total_tokens;
// when total token > max token - margin token:
// ChatGPT will "forgot" some historical message
// so client.message.length will be less than chatStore.history.length
chatStore.postBeginIndex =
chatStore.history.length - client.messages.length;
console.log("postBeginIndex", chatStore.postBeginIndex);
setChatStore({ ...chatStore });
} catch (error) {
setShowRetry(true);
alert(error);
} finally {
setShowGenerating(false);
}
};
// when user click the "send" button or ctrl+Enter in the textarea
const send = async (msg = "") => {
const inputMsg = msg;
if (!inputMsg) {
console.log("empty message");
return;
}
chatStore.history.push({ role: "user", content: inputMsg.trim() });
setChatStore({ ...chatStore });
setInputMsg("");
await complete();
};
const [showSettings, setShowSettings] = useState(false);
return (
<div className="grow flex flex-col p-2 dark:text-black">
<Settings
chatStore={chatStore}
setChatStore={setChatStore}
show={showSettings}
setShow={setShowSettings}
/>
<p
className="cursor-pointer dark:text-white"
onClick={() => setShowSettings(true)}
>
<div>
<button className="underline">
{chatStore.systemMessageContent.length > 16
? chatStore.systemMessageContent.slice(0, 16) + ".."
: chatStore.systemMessageContent}
</button>{" "}
<button className="underline">
{chatStore.streamMode ? "STREAM" : "FETCH"}
</button>
</div>
<div className="text-xs">
<span>Total: {chatStore.totalTokens}</span>{" "}
<span>Max: {chatStore.maxTokens}</span>{" "}
<span>Margin: {chatStore.tokenMargin}</span>{" "}
<span>Message: {chatStore.history.length}</span>{" "}
<span>Cut: {chatStore.postBeginIndex}</span>
</div>
</p>
<div className="grow overflow-scroll">
{!chatStore.apiKey && (
<p className="opacity-60 p-6 rounded bg-white my-3 text-left dark:text-black">
(OPENAI) API KEY
</p>
)}
{!chatStore.apiEndpoint && (
<p className="opacity-60 p-6 rounded bg-white my-3 text-left dark:text-black">
API Endpoint
</p>
)}
{chatStore.history.length === 0 && (
<p className="opacity-60 p-6 rounded bg-white my-3 text-left dark:text-black">
</p>
)}
{chatStore.history.map((_, messageIndex) => (
<Message
chatStore={chatStore}
setChatStore={setChatStore}
messageIndex={messageIndex}
/>
))}
{showGenerating && (
<p className="p-2 my-2 animate-pulse dark:text-white">
{generatingMessage
? generatingMessage.split("\n").map((line) => <p>{line}</p>)
: "生成中,请保持网络稳定"}
...
</p>
)}
{showRetry && (
<p className="text-right p-2 my-2 dark:text-white">
<button
className="p-1 rounded bg-rose-500"
onClick={async () => {
setShowRetry(false);
await complete();
}}
>
Retry
</button>
</p>
)}
<div ref={messagesEndRef}></div>
</div>
<div className="flex justify-between">
<textarea
rows={Math.min(10, (inputMsg.match(/\n/g) || []).length + 2)}
value={inputMsg}
onChange={(event: any) => setInputMsg(event.target.value)}
onKeyPress={(event: any) => {
console.log(event);
if (event.ctrlKey && event.code === "Enter") {
send(event.target.value);
setInputMsg("");
return;
}
setInputMsg(event.target.value);
}}
className="rounded grow m-1 p-1 border-2 border-gray-400 w-0"
placeholder="Type here..."
></textarea>
<button
className="disabled:line-through disabled:bg-slate-500 rounded m-1 p-1 border-2 bg-cyan-400 hover:bg-cyan-600"
disabled={showGenerating || !chatStore.apiKey}
onClick={() => {
send(inputMsg);
}}
>
Send
</button>
</div>
</div>
);
}

View File

@@ -1,15 +1,89 @@
export interface Message {
role: "system" | "user" | "assistant";
content: string;
import { DefaultModel } from "@/const";
export interface ImageURL {
url: string;
detail: "low" | "high";
}
export interface MessageDetail {
type: "text" | "image_url";
text?: string;
image_url?: ImageURL;
}
export interface ToolCall {
index: number;
id?: string;
type: string;
function: {
name: string;
arguments: string;
};
}
export interface Message {
role: "system" | "user" | "assistant" | "tool";
content: string | MessageDetail[];
name?: "example_user" | "example_assistant";
tool_calls?: ToolCall[];
tool_call_id?: string;
}
interface Delta {
role?: string;
content?: string;
tool_calls?: ToolCall[];
}
interface Choices {
index: number;
delta: Delta;
finish_reason: string | null;
logprobs: Logprobs | null;
}
export interface Logprobs {
content: LogprobsContent[];
}
interface LogprobsContent {
token: string;
logprob: number;
}
export interface StreamingResponseChunk {
id: string;
object: string;
created: number;
model: string;
system_fingerprint: string;
choices: Choices[];
}
export const getMessageText = (message: Message): string => {
if (typeof message.content === "string") {
// function call message
if (message.tool_calls) {
return message.tool_calls
.map((tc) => {
return `Tool Call ID: ${tc.id}\nType: ${tc.type}\nFunction: ${tc.function.name}\nArguments: ${tc.function.arguments}}`;
})
.join("\n");
}
return message.content;
}
return message.content
.filter((c) => c.type === "text")
.map((c) => c?.text)
.join("\n");
};
export interface ChunkMessage {
model: string;
choices: {
delta: { role: "assitant" | undefined; content: string | undefined };
}[];
}
export interface FetchResponse {
error?: any;
id: string;
object: string;
created: number;
@@ -23,69 +97,219 @@ export interface FetchResponse {
message: Message | undefined;
finish_reason: "stop" | "length";
index: number | undefined;
logprobs: Logprobs | null;
}[];
}
function calculate_token_length_from_text(text: string): number {
const totalCount = text.length;
const chineseCount = text.match(/[\u00ff-\uffff]|\S+/g)?.length ?? 0;
const englishCount = totalCount - chineseCount;
const tokenLength = englishCount / 4 + (chineseCount * 4) / 3;
return ~~tokenLength;
}
// https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them
export function calculate_token_length(
content: string | MessageDetail[],
): number {
if (typeof content === "string") {
return calculate_token_length_from_text(content);
}
let tokens = 0;
for (const m of content) {
if (m.type === "text") {
tokens += calculate_token_length_from_text(m.text ?? "");
}
if (m.type === "image_url") {
tokens += m.image_url?.detail === "high" ? 65 * 4 : 65;
}
}
return tokens;
}
class Chat {
OPENAI_API_KEY: string;
messages: Message[];
sysMessageContent: string;
toolsString: string;
total_tokens: number;
max_tokens: number;
max_gen_tokens: number;
enable_max_gen_tokens: boolean;
tokens_margin: number;
apiEndpoint: string;
model: string;
temperature: number;
enable_temperature: boolean;
top_p: number;
enable_top_p: boolean;
presence_penalty: number;
frequency_penalty: number;
json_mode: boolean;
constructor(
OPENAI_API_KEY: string | undefined,
{
systemMessage = "你是一个有用的人工智能助理",
systemMessage = "",
toolsString = "",
max_tokens = 4096,
max_gen_tokens = 2048,
enable_max_gen_tokens = true,
tokens_margin = 1024,
apiEndPoint = "https://api.openai.com/v1/chat/completions",
} = {}
model = DefaultModel,
temperature = 1,
enable_temperature = true,
top_p = 1,
enable_top_p = false,
presence_penalty = 0,
frequency_penalty = 0,
json_mode = false,
} = {},
) {
if (OPENAI_API_KEY === undefined) {
throw "OPENAI_API_KEY is undefined";
}
this.OPENAI_API_KEY = OPENAI_API_KEY;
this.OPENAI_API_KEY = OPENAI_API_KEY ?? "";
this.messages = [];
this.total_tokens = 0;
this.total_tokens = calculate_token_length(systemMessage);
this.max_tokens = max_tokens;
this.max_gen_tokens = max_gen_tokens;
this.enable_max_gen_tokens = enable_max_gen_tokens;
this.tokens_margin = tokens_margin;
this.sysMessageContent = systemMessage;
this.toolsString = toolsString;
this.apiEndpoint = apiEndPoint;
this.model = model;
this.temperature = temperature;
this.enable_temperature = enable_temperature;
this.top_p = top_p;
this.enable_top_p = enable_top_p;
this.presence_penalty = presence_penalty;
this.frequency_penalty = frequency_penalty;
this.json_mode = json_mode;
}
_fetch(stream = false) {
_fetch(stream = false, logprobs = false) {
// perform role type check
let hasNonSystemMessage = false;
for (const msg of this.messages) {
if (msg.role === "system" && !hasNonSystemMessage) {
continue;
}
if (!hasNonSystemMessage) {
hasNonSystemMessage = true;
continue;
}
if (msg.role === "system") {
console.log(
"Warning: detected system message in the middle of history",
);
}
}
for (const msg of this.messages) {
if (msg.name && msg.role !== "system") {
console.log(
"Warning: detected message where name field set but role is system",
);
}
}
const messages = [];
if (this.sysMessageContent.trim()) {
messages.push({ role: "system", content: this.sysMessageContent });
}
messages.push(...this.messages);
const body: any = {
model: this.model,
messages,
stream,
presence_penalty: this.presence_penalty,
frequency_penalty: this.frequency_penalty,
};
if (this.enable_temperature) {
body["temperature"] = this.temperature;
}
if (this.enable_top_p) {
body["top_p"] = this.top_p;
}
if (this.enable_max_gen_tokens) {
body["max_tokens"] = this.max_gen_tokens;
}
if (this.json_mode) {
body["response_format"] = {
type: "json_object",
};
}
if (logprobs) {
body["logprobs"] = true;
}
// parse toolsString to function call format
const ts = this.toolsString.trim();
if (ts) {
try {
const fcList: any[] = JSON.parse(ts);
body["tools"] = fcList;
} catch (e) {
console.log("toolsString parse error");
throw (
"Function call toolsString parse error, not a valied json list: " + e
);
}
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (this.OPENAI_API_KEY) {
headers["Authorization"] = `Bearer ${this.OPENAI_API_KEY}`;
}
return fetch(this.apiEndpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${this.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-3.5-turbo",
messages: [
{ role: "system", content: this.sysMessageContent },
...this.messages,
],
stream,
}),
headers,
body: JSON.stringify(body),
});
}
async fetch(): Promise<FetchResponse> {
const resp = await this._fetch();
return await resp.json();
async *processStreamResponse(resp: Response) {
const reader = resp?.body?.pipeThrough(new TextDecoderStream()).getReader();
if (reader === undefined) {
console.log("reader is undefined");
return;
}
let receiving = true;
let buffer = "";
while (receiving) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
console.log("begin buffer", buffer);
if (!buffer.includes("\n")) continue;
const lines = buffer
.trim()
.split("\n")
.filter((line) => line.trim())
.map((line) => line.trim());
buffer = "";
for (const line of lines) {
console.log("line", line);
try {
const jsonStr = line.slice("data:".length).trim();
const json = JSON.parse(jsonStr) as StreamingResponseChunk;
yield json;
} catch (e) {
console.log(`Chunk parse error at: ${line}`);
buffer += line;
}
}
}
}
async say(content: string): Promise<string> {
this.messages.push({ role: "user", content });
await this.complete();
return this.messages.slice(-1)[0].content;
}
processFetchResponse(resp: FetchResponse): string {
processFetchResponse(resp: FetchResponse): Message {
if (resp.error !== undefined) {
throw JSON.stringify(resp.error);
}
this.total_tokens = resp?.usage?.total_tokens ?? 0;
if (resp?.choices[0]?.message) {
this.messages.push(resp?.choices[0]?.message);
@@ -97,33 +321,26 @@ class Chat {
this.forgetSomeMessages();
}
return (
resp?.choices[0]?.message?.content ?? `Error: ${JSON.stringify(resp)}`
);
let content = resp.choices[0].message?.content ?? "";
if (
!resp.choices[0]?.message?.content &&
!resp.choices[0]?.message?.tool_calls
) {
content = `Unparsed response: ${JSON.stringify(resp)}`;
}
return {
role: "assistant",
content,
tool_calls: resp?.choices[0]?.message?.tool_calls,
};
}
async complete(): Promise<string> {
const resp = await this.fetch();
return this.processFetchResponse(resp);
calculate_token_length(content: string | MessageDetail[]): number {
return calculate_token_length(content);
}
completeWithSteam() {
this.total_tokens = this.messages
.map((msg) => this.calculate_token_length(msg.content) + 20)
.reduce((a, v) => a + v);
return this._fetch(true);
}
// https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them
calculate_token_length(content: string): number {
const totalCount = content.length;
const chineseCount = content.match(/[\u00ff-\uffff]|\S+/g)?.length ?? 0;
const englishCount = totalCount - chineseCount;
const tokenLength = englishCount / 4 + (chineseCount * 4) / 3;
return ~~tokenLength;
}
user(...messages: string[]) {
user(...messages: (string | MessageDetail[])[]) {
for (const msg of messages) {
this.messages.push({ role: "user", content: msg });
this.total_tokens += this.calculate_token_length(msg);
@@ -131,7 +348,7 @@ class Chat {
}
}
assistant(...messages: string[]) {
assistant(...messages: (string | MessageDetail[])[]) {
for (const msg of messages) {
this.messages.push({ role: "assistant", content: msg });
this.total_tokens += this.calculate_token_length(msg);

View File

@@ -0,0 +1,204 @@
import {
CubeIcon,
BanknotesIcon,
ChatBubbleLeftEllipsisIcon,
ScissorsIcon,
SwatchIcon,
SparklesIcon,
} from "@heroicons/react/24/outline";
import { ChatStore } from "@/types/chatstore";
import { models } from "@/types/models";
import { Tr } from "@/translate";
import { getTotalCost } from "@/utils/totalCost";
const StatusBar = (props: {
chatStore: ChatStore;
setShowSettings: (show: boolean) => void;
setShowSearch: (show: boolean) => void;
}) => {
const { chatStore, setShowSettings, setShowSearch } = props;
return (
<div className="navbar bg-base-100 p-0">
<div className="navbar-start">
<div className="dropdown lg:hidden">
<div tabindex={0} role="button" className="btn btn-ghost btn-circle">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h7"
/>
</svg>
</div>
<ul
tabindex={0}
className="menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"
>
<li>
<p>
<ChatBubbleLeftEllipsisIcon className="h-4 w-4" />
Tokens: {chatStore.totalTokens}/{chatStore.maxTokens}
</p>
</li>
<li>
<p>
<ScissorsIcon className="h-4 w-4" />
Cut:
{chatStore.postBeginIndex}/
{chatStore.history.filter(({ hide }) => !hide).length}
</p>
</li>
<li>
<p>
<BanknotesIcon className="h-4 w-4" />
Cost: ${chatStore.cost.toFixed(4)}
</p>
</li>
</ul>
</div>
</div>
<div
className="navbar-center cursor-pointer py-1"
onClick={() => {
setShowSettings(true);
}}
>
{/* the long staus bar */}
<div className="stats shadow hidden lg:inline-grid">
<div className="stat">
<div className="stat-figure text-secondary">
<CubeIcon className="h-10 w-10" />
</div>
<div className="stat-title">Model</div>
<div className="stat-value text-base">{chatStore.model}</div>
<div className="stat-desc">
{models[chatStore.model]?.price?.prompt * 1000 * 1000} $/M tokens
</div>
</div>
<div className="stat">
<div className="stat-figure text-secondary">
<SwatchIcon className="h-10 w-10" />
</div>
<div className="stat-title">Mode</div>
<div className="stat-value text-base">
{chatStore.streamMode ? Tr("STREAM") : Tr("FETCH")}
</div>
<div className="stat-desc">STREAM/FETCH</div>
</div>
<div className="stat">
<div className="stat-figure text-secondary">
<ChatBubbleLeftEllipsisIcon className="h-10 w-10" />
</div>
<div className="stat-title">Tokens</div>
<div className="stat-value text-base">{chatStore.totalTokens}</div>
<div className="stat-desc">Max: {chatStore.maxTokens}</div>
</div>
<div className="stat">
<div className="stat-figure text-secondary">
<ScissorsIcon className="h-10 w-10" />
</div>
<div className="stat-title">Cut</div>
<div className="stat-value text-base">
{chatStore.postBeginIndex}
</div>
<div className="stat-desc">
Max: {chatStore.history.filter(({ hide }) => !hide).length}
</div>
</div>
<div className="stat">
<div className="stat-figure text-secondary">
<BanknotesIcon className="h-10 w-10" />
</div>
<div className="stat-title">Cost</div>
<div className="stat-value text-base">
${chatStore.cost.toFixed(4)}
</div>
<div className="stat-desc">
Accumulated: ${getTotalCost().toFixed(2)}
</div>
</div>
</div>
{/* the short status bar */}
<div className="indicator lg:hidden">
{chatStore.totalTokens !== 0 && (
<span className="indicator-item badge badge-primary">
Tokens: {chatStore.totalTokens}
</span>
)}
<a className="btn btn-ghost text-base sm:text-xl p-0">
<SparklesIcon className="h-4 w-4 hidden sm:block" />
{chatStore.model}
</a>
</div>
</div>
<div className="navbar-end">
<button
className="btn btn-ghost btn-circle"
onClick={(event) => {
// stop propagation to parent
event.stopPropagation();
setShowSearch(true);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</button>
<button
className="btn btn-ghost btn-circle hidden sm:block"
onClick={() => setShowSettings(true)}
>
<div className="indicator">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
className="h-6 w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
<span className="badge badge-xs badge-primary indicator-item"></span>
</div>
</button>
</div>
</div>
);
};
export default StatusBar;

View File

@@ -0,0 +1,113 @@
import { TemplateChatStore } from "@/types/chatstore";
import { ChatStore } from "@/types/chatstore";
import { getDefaultParams } from "@/utils/getDefaultParam";
const Templates = (props: {
templates: TemplateChatStore[];
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
setTemplates: (templates: TemplateChatStore[]) => void;
}) => {
const { templates, chatStore, setChatStore, setTemplates } = props;
return (
<>
{templates.map((t, index) => (
<div
className="cursor-pointer rounded bg-green-400 w-fit p-2 m-1 flex flex-col"
onClick={() => {
const newChatStore: ChatStore = structuredClone(t);
// @ts-ignore
delete newChatStore.name;
if (!newChatStore.apiEndpoint) {
newChatStore.apiEndpoint = getDefaultParams(
"api",
chatStore.apiEndpoint,
);
}
if (!newChatStore.apiKey) {
newChatStore.apiKey = getDefaultParams("key", chatStore.apiKey);
}
if (!newChatStore.whisper_api) {
newChatStore.whisper_api = getDefaultParams(
"whisper-api",
chatStore.whisper_api,
);
}
if (!newChatStore.whisper_key) {
newChatStore.whisper_key = getDefaultParams(
"whisper-key",
chatStore.whisper_key,
);
}
if (!newChatStore.tts_api) {
newChatStore.tts_api = getDefaultParams(
"tts-api",
chatStore.tts_api,
);
}
if (!newChatStore.tts_key) {
newChatStore.tts_key = getDefaultParams(
"tts-key",
chatStore.tts_key,
);
}
if (!newChatStore.image_gen_api) {
newChatStore.image_gen_api = getDefaultParams(
"image-gen-api",
chatStore.image_gen_api,
);
}
if (!newChatStore.image_gen_key) {
newChatStore.image_gen_key = getDefaultParams(
"image-gen-key",
chatStore.image_gen_key,
);
}
newChatStore.cost = 0;
// manage undefined value because of version update
newChatStore.toolsString = newChatStore.toolsString || "";
setChatStore({ ...newChatStore });
}}
>
<span className="w-full text-center">{t.name}</span>
<hr className="mt-2" />
<span className="flex justify-between">
<button
onClick={(event) => {
// prevent triggert other event
event.stopPropagation();
const name = prompt("Give template a name");
if (!name) {
return;
}
t.name = name;
setTemplates(structuredClone(templates));
}}
>
🖋
</button>
<button
onClick={(event) => {
// prevent triggert other event
event.stopPropagation();
if (!confirm("Are you sure to delete this template?")) {
return;
}
templates.splice(index, 1);
setTemplates(structuredClone(templates));
}}
>
</button>
</span>
</div>
))}
</>
);
};
export default Templates;

View File

@@ -0,0 +1,49 @@
import { ChatStore } from "@/types/chatstore";
import { Tr } from "@/translate";
const VersionHint = (props: { chatStore: ChatStore }) => {
const { chatStore } = props;
return (
<>
{chatStore.chatgpt_api_web_version < "v1.3.0" && (
<p className="p-2 my-2 text-center dark:text-white">
<br />
{Tr("Warning: current chatStore version")}:{" "}
{chatStore.chatgpt_api_web_version} {"< v1.3.0"}
<br />
v1.3.0
使
<br />
</p>
)}
{chatStore.chatgpt_api_web_version < "v1.4.0" && (
<p className="p-2 my-2 text-center dark:text-white">
<br />
{Tr("Warning: current chatStore version")}:{" "}
{chatStore.chatgpt_api_web_version} {"< v1.4.0"}
<br />
v1.4.0 使
<br />
</p>
)}
{chatStore.chatgpt_api_web_version < "v1.6.0" && (
<p className="p-2 my-2 text-center dark:text-white">
<br />
{chatStore.chatgpt_api_web_version}
{Tr("Warning: current chatStore version")}:{" "}
{chatStore.chatgpt_api_web_version} {"< v1.6.0"}
<br />
v1.6.0 apiKey apiEndpoint
使
<br />
</p>
)}
</>
);
};
export default VersionHint;

View File

@@ -0,0 +1,132 @@
import { createRef } from "preact";
import { ChatStore } from "@/types/chatstore";
import { StateUpdater, useEffect, useState, Dispatch } from "preact/hooks";
const WhisperButton = (props: {
chatStore: ChatStore;
inputMsg: string;
setInputMsg: Dispatch<StateUpdater<string>>;
}) => {
const { chatStore, inputMsg, setInputMsg } = props;
const mediaRef = createRef();
const [isRecording, setIsRecording] = useState("Mic");
return (
<button
className={`btn disabled:line-through disabled:btn-neutral disabled:text-white m-1 p-1 ${
isRecording === "Recording" ? "btn-error" : "btn-success"
} ${isRecording !== "Mic" ? "animate-pulse" : ""}`}
disabled={isRecording === "Transcribing"}
ref={mediaRef}
onClick={async () => {
if (isRecording === "Recording") {
// @ts-ignore
window.mediaRecorder.stop();
setIsRecording("Transcribing");
return;
}
// build prompt
const prompt = [chatStore.systemMessageContent]
.concat(
chatStore.history
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex)
.map(({ content }) => {
if (typeof content === "string") {
return content;
} else {
return content.map((c) => c?.text).join(" ");
}
}),
)
.concat([inputMsg])
.join(" ");
console.log({ prompt });
setIsRecording("Recording");
console.log("start recording");
try {
const mediaRecorder = new MediaRecorder(
await navigator.mediaDevices.getUserMedia({
audio: true,
}),
{ audioBitsPerSecond: 64 * 1000 },
);
// mount mediaRecorder to ref
// @ts-ignore
window.mediaRecorder = mediaRecorder;
mediaRecorder.start();
const audioChunks: Blob[] = [];
mediaRecorder.addEventListener("dataavailable", (event) => {
audioChunks.push(event.data);
});
mediaRecorder.addEventListener("stop", async () => {
// Stop the MediaRecorder
mediaRecorder.stop();
// Stop the media stream
mediaRecorder.stream.getTracks()[0].stop();
setIsRecording("Transcribing");
const audioBlob = new Blob(audioChunks);
const audioUrl = URL.createObjectURL(audioBlob);
console.log({ audioUrl });
const audio = new Audio(audioUrl);
// audio.play();
const reader = new FileReader();
reader.readAsDataURL(audioBlob);
// file-like object with mimetype
const blob = new Blob([audioBlob], {
type: "application/octet-stream",
});
reader.onloadend = async () => {
try {
const base64data = reader.result;
// post to openai whisper api
const formData = new FormData();
// append file
formData.append("file", blob, "audio.ogg");
formData.append("model", "whisper-1");
formData.append("response_format", "text");
formData.append("prompt", prompt);
const response = await fetch(chatStore.whisper_api, {
method: "POST",
headers: {
Authorization: `Bearer ${
chatStore.whisper_key || chatStore.apiKey
}`,
},
body: formData,
});
const text = await response.text();
setInputMsg(inputMsg ? inputMsg + " " + text : text);
} catch (error) {
alert(error);
console.log(error);
} finally {
setIsRecording("Mic");
}
};
});
} catch (error) {
alert(error);
console.log(error);
setIsRecording("Mic");
}
}}
>
{isRecording}
</button>
);
};
export default WhisperButton;

14
src/const.ts Normal file
View File

@@ -0,0 +1,14 @@
export const DefaultAPIEndpoint = "https://api.openai.com/v1/chat/completions";
export const CHATGPT_API_WEB_VERSION = "v2.1.0";
export const DefaultModel = "gpt-4o-mini";
export const STORAGE_NAME = "chatgpt-api-web";
export const STORAGE_NAME_SELECTED = `${STORAGE_NAME}-selected`;
export const STORAGE_NAME_INDEXES = `${STORAGE_NAME}-indexes`;
export const STORAGE_NAME_TOTALCOST = `${STORAGE_NAME}-totalcost`;
export const STORAGE_NAME_TEMPLATE = `${STORAGE_NAME}-template`;
export const STORAGE_NAME_TEMPLATE_API = `${STORAGE_NAME_TEMPLATE}-api`;
export const STORAGE_NAME_TEMPLATE_API_WHISPER = `${STORAGE_NAME_TEMPLATE}-api-whisper`;
export const STORAGE_NAME_TEMPLATE_API_TTS = `${STORAGE_NAME_TEMPLATE}-api-tts`;
export const STORAGE_NAME_TEMPLATE_API_IMAGE_GEN = `${STORAGE_NAME_TEMPLATE}-api-image-gen`;
export const STORAGE_NAME_TEMPLATE_TOOLS = `${STORAGE_NAME_TEMPLATE}-tools`;

80
src/editMessage.tsx Normal file
View File

@@ -0,0 +1,80 @@
import { useState, useEffect, StateUpdater, Dispatch } from "preact/hooks";
import { Tr, langCodeContext, LANG_OPTIONS, tr } from "@/translate";
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
import { EditMessageString } from "@/editMessageString";
import { EditMessageDetail } from "@/editMessageDetail";
interface EditMessageProps {
chat: ChatStoreMessage;
chatStore: ChatStore;
setShowEdit: Dispatch<StateUpdater<boolean>>;
setChatStore: (cs: ChatStore) => void;
}
export function EditMessage(props: EditMessageProps) {
const { setShowEdit, chat, setChatStore, chatStore } = props;
return (
<div
className={
"absolute bg-black bg-opacity-50 w-full h-full top-0 left-0 rounded z-10 overflow-scroll"
}
onClick={() => setShowEdit(false)}
>
<div
className="m-10 p-2 bg-white rounded"
onClick={(event: any) => {
event.stopPropagation();
}}
>
{typeof chat.content === "string" ? (
<EditMessageString
chat={chat}
chatStore={chatStore}
setChatStore={setChatStore}
setShowEdit={setShowEdit}
/>
) : (
<EditMessageDetail
chat={chat}
chatStore={chatStore}
setChatStore={setChatStore}
setShowEdit={setShowEdit}
/>
)}
<div className={"w-full flex justify-center"}>
{chatStore.develop_mode && (
<button
className="w-full m-2 p-1 rounded bg-red-500"
onClick={() => {
const confirm = window.confirm(
"Change message type will clear the content, are you sure?",
);
if (!confirm) return;
if (typeof chat.content === "string") {
chat.content = [];
} else {
chat.content = "";
}
setChatStore({ ...chatStore });
}}
>
Switch to{" "}
{typeof chat.content === "string"
? "media message"
: "string message"}
</button>
)}
<button
className={"w-full m-2 p-1 rounded bg-purple-500"}
onClick={() => {
setShowEdit(false);
}}
>
{Tr("Close")}
</button>
</div>
</div>
</div>
);
}

161
src/editMessageDetail.tsx Normal file
View File

@@ -0,0 +1,161 @@
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
import { calculate_token_length } from "@/chatgpt";
import { Tr } from "@/translate";
interface Props {
chat: ChatStoreMessage;
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
setShowEdit: (se: boolean) => void;
}
export function EditMessageDetail({
chat,
chatStore,
setChatStore,
setShowEdit,
}: Props) {
if (typeof chat.content !== "object") return <div>error</div>;
return (
<div
className={"w-full h-full flex flex-col overflow-scroll"}
onClick={(event) => event.stopPropagation()}
>
{chat.content.map((mdt, index) => (
<div className={"w-full p-2 px-4"}>
<div className="flex justify-center">
{mdt.type === "text" ? (
<textarea
className={"w-full border p-1 rounded"}
value={mdt.text}
onChange={(event: any) => {
if (typeof chat.content === "string") return;
chat.content[index].text = event.target.value;
chat.token = calculate_token_length(chat.content);
console.log("calculated token length", chat.token);
setChatStore({ ...chatStore });
}}
onKeyPress={(event: any) => {
if (event.keyCode == 27) {
setShowEdit(false);
}
}}
></textarea>
) : (
<div className="border p-1 rounded">
<img
className="max-h-32 max-w-xs cursor-pointer m-2"
src={mdt.image_url?.url}
onClick={() => {
window.open(mdt.image_url?.url, "_blank");
}}
/>
<button
className="bg-blue-300 p-1 rounded m-1"
onClick={() => {
const image_url = prompt("image url", mdt.image_url?.url);
if (image_url) {
if (typeof chat.content === "string") return;
const obj = chat.content[index].image_url;
if (obj === undefined) return;
obj.url = image_url;
setChatStore({ ...chatStore });
}
}}
>
{Tr("Edit URL")}
</button>
<button
className="bg-blue-300 p-1 rounded m-1"
onClick={() => {
// select file and load it to base64 image URL format
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = (event) => {
const file = (event.target as HTMLInputElement)
.files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onloadend = () => {
const base64data = reader.result;
if (!base64data) return;
if (typeof chat.content === "string") return;
const obj = chat.content[index].image_url;
if (obj === undefined) return;
obj.url = String(base64data);
setChatStore({ ...chatStore });
};
};
input.click();
}}
>
{Tr("Upload")}
</button>
<span
className="bg-blue-300 p-1 rounded m-1"
onClick={() => {
if (typeof chat.content === "string") return;
const obj = chat.content[index].image_url;
if (obj === undefined) return;
obj.detail = obj.detail === "high" ? "low" : "high";
chat.token = calculate_token_length(chat.content);
setChatStore({ ...chatStore });
}}
>
<label>High Resolution</label>
<input
type="checkbox"
checked={mdt.image_url?.detail === "high"}
/>
</span>
</div>
)}
<button
onClick={() => {
if (typeof chat.content === "string") return;
chat.content.splice(index, 1);
chat.token = calculate_token_length(chat.content);
setChatStore({ ...chatStore });
}}
>
</button>
</div>
</div>
))}
<button
className={"m-2 p-1 rounded bg-green-500"}
onClick={() => {
if (typeof chat.content === "string") return;
chat.content.push({
type: "text",
text: "",
});
setChatStore({ ...chatStore });
}}
>
{Tr("Add text")}
</button>
<button
className={"m-2 p-1 rounded bg-green-500"}
onClick={() => {
if (typeof chat.content === "string") return;
chat.content.push({
type: "image_url",
image_url: {
url: "",
detail: "high",
},
});
setChatStore({ ...chatStore });
}}
>
{Tr("Add image")}
</button>
</div>
);
}

119
src/editMessageString.tsx Normal file
View File

@@ -0,0 +1,119 @@
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
import { isVailedJSON } from "@/message";
import { calculate_token_length } from "@/chatgpt";
import { Tr } from "@/translate";
interface Props {
chat: ChatStoreMessage;
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
setShowEdit: (se: boolean) => void;
}
export function EditMessageString({
chat,
chatStore,
setChatStore,
setShowEdit,
}: Props) {
if (typeof chat.content !== "string") return <div>error</div>;
return (
<div className="flex flex-col">
{chat.tool_call_id && (
<span className="my-2">
<label>tool_call_id: </label>
<input
className="rounded border border-gray-400"
value={chat.tool_call_id}
onChange={(event: any) => {
chat.tool_call_id = event.target.value;
setChatStore({ ...chatStore });
}}
/>
</span>
)}
{chat.tool_calls &&
chat.tool_calls.map((tool_call) => (
<div className="flex flex-col w-full">
<span className="my-2 w-full">
<label>Tool Call ID: </label>
<input
value={tool_call.id}
className="rounded border border-gray-400"
/>
</span>
<span className="my-2 w-full">
<label>Function: </label>
<input
value={tool_call.function.name}
className="rounded border border-gray-400"
/>
</span>
<span className="my-2">
<label>Arguments: </label>
<span className="underline">
Vailed JSON:{" "}
{isVailedJSON(tool_call.function.arguments) ? "🆗" : "❌"}
</span>
<textarea
className="rounded border border-gray-400 w-full h-32 my-2"
value={tool_call.function.arguments}
onChange={(event: any) => {
tool_call.function.arguments = event.target.value.trim();
setChatStore({ ...chatStore });
}}
></textarea>
</span>
<span className="flex flex-col my-2 justify-between">
<button
className="bg-red-300 text-black p-1 rounded"
onClick={() => {
if (!chat.tool_calls) return;
chat.tool_calls = chat.tool_calls.filter(
(tc) => tc.id !== tool_call.id,
);
setChatStore({ ...chatStore });
}}
>
{Tr("Delete this tool call")}
</button>
</span>
<hr className="my-2" />
<span className="flex flex-col my-2 justify-between">
<button
className="bg-blue-300 text-black p-1 rounded"
onClick={() => {
if (!chat.tool_calls) return;
chat.tool_calls.push({
type: "function",
index: chat.tool_calls.length,
id: "",
function: {
name: "",
arguments: "",
},
});
setChatStore({ ...chatStore });
}}
>
{Tr("Add a tool call")}
</button>
</span>
</div>
))}
<textarea
className="rounded border border-gray-400 w-full h-32 my-2"
value={chat.content}
onChange={(event: any) => {
chat.content = event.target.value;
chat.token = calculate_token_length(chat.content);
setChatStore({ ...chatStore });
}}
onKeyPress={(event: any) => {
if (event.keyCode == 27) {
setShowEdit(false);
}
}}
></textarea>
</div>
);
}

View File

@@ -1,18 +0,0 @@
function getDefaultParams(param: string, val: string): string;
function getDefaultParams(param: string, val: number): number;
function getDefaultParams(param: string, val: boolean): boolean;
function getDefaultParams(param: any, val: any) {
const queryParameters = new URLSearchParams(window.location.search);
const get = queryParameters.get(param);
if (typeof val === "string") {
return get ?? val;
} else if (typeof val === "number") {
return parseInt(get ?? `${val}`);
} else if (typeof val === "boolean") {
if (get === "stream") return true;
if (get === "fetch") return false;
return val;
}
}
export default getDefaultParams;

View File

@@ -2,6 +2,12 @@
@tailwind components;
@tailwind utilities;
html,
body,
#app {
height: 100%;
}
/* Hide scrollbar for webkit based browsers */
::-webkit-scrollbar {
display: none;
@@ -22,6 +28,124 @@ body::-webkit-scrollbar {
display: none;
}
p.message-content {
.message-content {
white-space: pre-wrap;
word-wrap: anywhere;
}
.markup > h2 {
padding-bottom: 0.3em;
font-size: 1.5em;
border-bottom: 1px solid var(--borderColor-muted, var(--color-border-muted));
margin-top: 24px;
margin-bottom: 16px;
line-height: 1.25;
}
.markup > h2::after {
content: "";
display: block;
height: 1px;
width: 100%;
background: gray;
}
.markup > h1 {
margin-top: 0;
padding-bottom: 0.3em;
font-size: 2em;
border-bottom: 1px solid var(--borderColor-muted, var(--color-border-muted));
margin-bottom: 16px;
line-height: 1.25;
}
.markup > h1::after {
content: "";
display: block;
height: 1px;
width: 100%;
background: gray;
}
.markup > p {
margin-top: 0;
margin-bottom: 16px;
}
.markup > code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
white-space: break-space;
background-color: rgba(175, 184, 193, 0.2);
border-radius: 6px;
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
}
.markup > pre {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
color: #1f2328;
background-color: #f6f8fa;
border-radius: 6px;
}
.markup table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-family: "Arial", sans-serif;
color: #333;
background-color: #f8f8f8;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.1);
}
.markup th,
.markup td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.markup thead th {
background-color: #4caf50;
color: white;
font-weight: bold;
border-bottom: 2px solid #ddd;
}
.markup tbody tr:nth-child(even) {
background-color: #f2f2f2;
}
.markup tbody tr:hover {
background-color: #e9e9e9;
}
.markup tbody td {
position: relative;
}
.markup tbody td:hover::after {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: #f5f5f5;
z-index: -1;
}
.stat {
padding: 0.39rem;
}

24
src/indexedDB/upgrade.ts Normal file
View File

@@ -0,0 +1,24 @@
import { STORAGE_NAME } from "@/const";
import { ChatStore } from "@/types/chatstore";
import { IDBPDatabase, IDBPTransaction, StoreNames } from "idb";
import { upgradeV1 } from "@/indexedDB/v1";
import { upgradeV11 } from "./v11";
export async function upgrade(
db: IDBPDatabase<ChatStore>,
oldVersion: number,
newVersion: number,
transaction: IDBPTransaction<
ChatStore,
StoreNames<ChatStore>[],
"versionchange"
>,
) {
if (oldVersion < 1) {
upgradeV1(db, oldVersion, newVersion, transaction);
}
if (oldVersion < 11) {
upgradeV11(db, oldVersion, newVersion, transaction);
}
}

38
src/indexedDB/v1.ts Normal file
View File

@@ -0,0 +1,38 @@
import { STORAGE_NAME, STORAGE_NAME_INDEXES } from "@/const";
import { ChatStore } from "@/types/chatstore";
import { IDBPDatabase, IDBPTransaction, StoreNames } from "idb";
export async function upgradeV1(
db: IDBPDatabase<ChatStore>,
oldVersion: number,
newVersion: number,
transaction: IDBPTransaction<
ChatStore,
StoreNames<ChatStore>[],
"versionchange"
>,
) {
const store = db.createObjectStore(STORAGE_NAME, {
autoIncrement: true,
});
// copy from localStorage to indexedDB
const allChatStoreIndexes: number[] = JSON.parse(
localStorage.getItem(STORAGE_NAME_INDEXES) ?? "[]",
);
let keyCount = 0;
for (const i of allChatStoreIndexes) {
console.log("importing chatStore from localStorage", i);
const key = `${STORAGE_NAME}-${i}`;
const val = localStorage.getItem(key);
if (val === null) continue;
store.add(JSON.parse(val));
keyCount += 1;
}
// setSelectedChatIndex(keyCount);
if (keyCount > 0) {
alert(
"v2.0.0 Update: Imported chat history from localStorage to indexedDB. 🎉",
);
}
}

25
src/indexedDB/v11.ts Normal file
View File

@@ -0,0 +1,25 @@
import { ChatStore } from "@/types/chatstore";
import { STORAGE_NAME } from "@/const";
import { IDBPDatabase, IDBPTransaction, StoreNames } from "idb";
export async function upgradeV11(
db: IDBPDatabase<ChatStore>,
oldVersion: number,
newVersion: number,
transaction: IDBPTransaction<
ChatStore,
StoreNames<ChatStore>[],
"versionchange"
>,
) {
if (oldVersion < 11 && oldVersion >= 1) {
alert("Start upgrading storage, just a sec... (Click OK to continue)");
}
if (
transaction
.objectStore(STORAGE_NAME)
.indexNames.contains("contents_for_index")
) {
transaction.objectStore(STORAGE_NAME).deleteIndex("contents_for_index");
}
}

82
src/listAPIs.tsx Normal file
View File

@@ -0,0 +1,82 @@
import { ChatStore, TemplateAPI } from "@/types/chatstore";
import { Tr } from "@/translate";
interface Props {
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
tmps: TemplateAPI[];
setTmps: (tmps: TemplateAPI[]) => void;
label: string;
apiField: string;
keyField: string;
}
export function ListAPIs({
tmps,
setTmps,
chatStore,
setChatStore,
label,
apiField,
keyField,
}: Props) {
return (
<div className="break-all opacity-80 p-3 rounded base-200 my-3 text-left">
<h2>{Tr(`Saved ${label} templates`)}</h2>
<hr className="my-2" />
<div className="flex flex-wrap">
{tmps.map((t, index) => (
<div
className={`cursor-pointer rounded ${
// @ts-ignore
chatStore[apiField] === t.endpoint &&
// @ts-ignore
chatStore[keyField] === t.key
? "bg-info"
: "bg-base-300"
} w-fit p-2 m-1 flex flex-col`}
onClick={() => {
// @ts-ignore
chatStore[apiField] = t.endpoint;
// @ts-ignore
chatStore[keyField] = t.key;
setChatStore({ ...chatStore });
}}
>
<span className="w-full text-center">{t.name}</span>
<span className="flex justify-between gap-x-2">
<button
className="link"
onClick={() => {
const name = prompt(`Give **${label}** template a name`);
if (!name) {
return;
}
t.name = name;
setTmps(structuredClone(tmps));
}}
>
Edit
</button>
<button
className="link"
onClick={() => {
if (
!confirm(
`Are you sure to delete this **${label}** template?`,
)
) {
return;
}
tmps.splice(index, 1);
setTmps(structuredClone(tmps));
}}
>
Delete
</button>
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { ChatStore, TemplateTools } from "@/types/chatstore";
import { Tr } from "@/translate";
interface Props {
templateTools: TemplateTools[];
setTemplateTools: (tmps: TemplateTools[]) => void;
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
}
export function ListToolsTempaltes({
chatStore,
templateTools,
setTemplateTools,
setChatStore,
}: Props) {
return (
<div className="break-all opacity-80 p-3 rounded bg-white my-3 text-left dark:text-black">
<h2>
<span>{Tr(`Saved tools templates`)}</span>
<button
className="mx-2 underline cursor-pointer"
onClick={() => {
chatStore.toolsString = "";
setChatStore({ ...chatStore });
}}
>
{Tr(`Clear`)}
</button>
</h2>
<hr className="my-2" />
<div className="flex flex-wrap">
{templateTools.map((t, index) => (
<div
className={`cursor-pointer rounded ${
chatStore.toolsString === t.toolsString
? "bg-info"
: "bg-base-300"
} w-fit p-2 m-1 flex flex-col`}
onClick={() => {
chatStore.toolsString = t.toolsString;
setChatStore({ ...chatStore });
}}
>
<span className="w-full text-center">{t.name}</span>
<span className="flex justify-between gap-x-2">
<button
className="link"
onClick={() => {
const name = prompt(`Give **tools** template a name`);
if (!name) {
return;
}
t.name = name;
setTemplateTools(structuredClone(templateTools));
}}
>
Edit
</button>
<button
className="link"
onClick={() => {
if (
!confirm(`Are you sure to delete this **tools** template?`)
) {
return;
}
templateTools.splice(index, 1);
setTemplateTools(structuredClone(templateTools));
}}
>
Delete
</button>
</span>
</div>
))}
</div>
</div>
);
}

14
src/logprob.tsx Normal file
View File

@@ -0,0 +1,14 @@
const logprobToColor = (logprob: number) => {
// 将logprob转换为百分比
const percent = Math.exp(logprob) * 100;
// 计算颜色值
// 绿色的RGB值为(0, 255, 0)红色的RGB值为(255, 0, 0)
const red = Math.round(255 * (1 - percent / 100));
const green = Math.round(255 * (percent / 100));
const color = `rgba(${red}, ${green}, 0, 0.5)`;
return color;
};
export default logprobToColor;

View File

@@ -1,4 +1,54 @@
import { render } from 'preact'
import { App } from './app'
import { themeChange } from "theme-change";
import { render } from "preact";
import { useState, useEffect } from "preact/hooks";
import { App } from "@/pages/App";
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
render(<App />, document.getElementById('app') as HTMLElement)
function Base() {
const [langCode, _setLangCode] = useState("en-US");
const setLangCode = (langCode: string) => {
_setLangCode(langCode);
if (!localStorage) return;
localStorage.setItem("chatgpt-api-web-lang", langCode);
};
// select language
useEffect(() => {
themeChange(false);
// query localStorage
if (localStorage) {
const lang = localStorage.getItem("chatgpt-api-web-lang");
if (lang) {
console.log(`query langCode ${lang} from localStorage`);
_setLangCode(lang);
return;
}
}
const browserCode = window.navigator.language;
for (const key in LANG_OPTIONS) {
for (const i in LANG_OPTIONS[key].matches) {
const code = LANG_OPTIONS[key].matches[i];
if (code === browserCode) {
console.log(`set langCode to "${code}"`);
setLangCode(key);
return;
}
}
}
// fallback to english
console.log('fallback langCode to "en-US"');
setLangCode("en-US");
}, []);
return (
/* @ts-ignore */
<langCodeContext.Provider value={{ langCode, setLangCode }}>
<App />
</langCodeContext.Provider>
);
}
render(<Base />, document.getElementById("app") as HTMLElement);

View File

@@ -1,52 +1,261 @@
import { ChatStore } from "./app";
import { XMarkIcon } from "@heroicons/react/24/outline";
import Markdown from "preact-markdown";
import { useState, useEffect, StateUpdater } from "preact/hooks";
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
import { calculate_token_length, getMessageText } from "@/chatgpt";
import TTSButton, { TTSPlay } from "@/tts";
import { MessageHide } from "@/messageHide";
import { MessageDetail } from "@/messageDetail";
import { MessageToolCall } from "@/messageToolCall";
import { MessageToolResp } from "@/messageToolResp";
import { EditMessage } from "@/editMessage";
import logprobToColor from "@/logprob";
export const isVailedJSON = (str: string): boolean => {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
};
interface Props {
messageIndex: number;
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
}
export default function Message(props: Props) {
const { chatStore, messageIndex, setChatStore } = props;
const chat = chatStore.history[messageIndex];
const [showEdit, setShowEdit] = useState(false);
const [showCopiedHint, setShowCopiedHint] = useState(false);
const [renderMarkdown, setRenderWorkdown] = useState(false);
const [renderColor, setRenderColor] = useState(false);
const DeleteIcon = () => (
<button
className={`absolute bottom-0 ${
chat.role === "user" ? "left-0" : "right-0"
}`}
onClick={() => {
if (
confirm(
`Are you sure to delete this message?\n${chat.content.slice(
0,
39
)}...`
)
) {
chatStore.history.splice(messageIndex, 1);
chatStore.postBeginIndex = Math.max(chatStore.postBeginIndex - 1, 0);
setChatStore({ ...chatStore });
chatStore.history[messageIndex].hide =
!chatStore.history[messageIndex].hide;
//chatStore.totalTokens =
chatStore.totalTokens = 0;
for (const i of chatStore.history
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex)
.map(({ token }) => token)) {
chatStore.totalTokens += i;
}
setChatStore({ ...chatStore });
}}
>
🗑
Delete
</button>
);
return (
<div
className={`flex ${
chat.role === "assistant" ? "justify-start" : "justify-end"
}`}
>
<div
className={`relative w-fit p-2 rounded my-2 ${
chat.role === "assistant"
? "bg-white dark:bg-gray-700 dark:text-white"
: "bg-green-400"
}`}
const CopiedHint = () => (
<div role="alert" className="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="stroke-info h-6 w-6 shrink-0"
>
<p className="message-content">{chat.content}</p>
<DeleteIcon />
</div>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>{Tr("Message copied to clipboard!")}</span>
</div>
);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setShowCopiedHint(true);
setTimeout(() => setShowCopiedHint(false), 1000);
};
const CopyIcon = ({ textToCopy }: { textToCopy: string }) => {
return (
<>
<button
onClick={() => {
copyToClipboard(textToCopy);
}}
>
Copy
</button>
</>
);
};
return (
<>
{chatStore.postBeginIndex !== 0 &&
!chatStore.history[messageIndex].hide &&
chatStore.postBeginIndex ===
chatStore.history.slice(0, messageIndex).filter(({ hide }) => !hide)
.length && (
<div className="flex items-center relative justify-center">
<hr className="w-full h-px my-4 border-0" />
<span className="absolute px-3 rounded p-1">
Above messages are "forgotten"
</span>
</div>
)}
<div
className={`flex ${
chat.role === "assistant" ? "justify-start" : "justify-end"
}`}
>
<div className={`w-full`}>
<div
className={`chat min-w-16 p-2 my-2 ${
chat.role === "assistant" ? "chat-start" : "chat-end"
} ${chat.hide ? "opacity-50" : ""}`}
>
<div
className={`chat-bubble max-w-full ${
chat.role === "assistant"
? renderColor
? "chat-bubble-neutral"
: "chat-bubble-secondary"
: "chat-bubble-primary"
}`}
>
{chat.hide ? (
<MessageHide chat={chat} />
) : typeof chat.content !== "string" ? (
<MessageDetail chat={chat} renderMarkdown={renderMarkdown} />
) : chat.tool_calls ? (
<MessageToolCall
chat={chat}
copyToClipboard={copyToClipboard}
/>
) : chat.role === "tool" ? (
<MessageToolResp
chat={chat}
copyToClipboard={copyToClipboard}
/>
) : renderMarkdown ? (
// @ts-ignore
<Markdown markdown={getMessageText(chat)} />
) : (
<div className="message-content">
{
// only show when content is string or list of message
// this check is used to avoid rendering tool call
chat.content &&
(chat.logprobs && renderColor
? chat.logprobs.content
.filter((c) => c.token)
.map((c) => (
<div
style={{
backgroundColor: logprobToColor(c.logprob),
display: "inline",
}}
>
{c.token}
</div>
))
: getMessageText(chat))
}
</div>
)}
</div>
<div className="chat-footer opacity-50 flex gap-x-2">
<DeleteIcon />
<button onClick={() => setShowEdit(true)}>Edit</button>
<CopyIcon textToCopy={getMessageText(chat)} />
{chatStore.tts_api && chatStore.tts_key && (
<TTSButton
chatStore={chatStore}
chat={chat}
setChatStore={setChatStore}
/>
)}
<TTSPlay chat={chat} />
{chat.response_model_name && (
<>
<span className="opacity-50">{chat.response_model_name}</span>
<hr />
</>
)}
</div>
</div>
{showEdit && (
<EditMessage
setShowEdit={setShowEdit}
chat={chat}
chatStore={chatStore}
setChatStore={setChatStore}
/>
)}
{showCopiedHint && <CopiedHint />}
{chatStore.develop_mode && (
<div
className={`gap-1 chat-end flex ${
chat.role === "assistant" ? "justify-start" : "justify-end"
}`}
>
<span className="">token</span>
<input
value={chat.token}
className="input input-bordered input-xs w-16"
onChange={(event: any) => {
chat.token = parseInt(event.target.value);
setChatStore({ ...chatStore });
}}
/>
<button
onClick={() => {
chatStore.history.splice(messageIndex, 1);
chatStore.postBeginIndex = Math.max(
chatStore.postBeginIndex - 1,
0,
);
//chatStore.totalTokens =
chatStore.totalTokens = 0;
for (const i of chatStore.history
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex)
.map(({ token }) => token)) {
chatStore.totalTokens += i;
}
setChatStore({ ...chatStore });
}}
>
<XMarkIcon className="w-4 h-4" />
</button>
<span
onClick={(event: any) => {
chat.example = !chat.example;
setChatStore({ ...chatStore });
}}
>
<label className="">{Tr("example")}</label>
<input type="checkbox" checked={chat.example} />
</span>
<span
onClick={(event: any) => setRenderWorkdown(!renderMarkdown)}
>
<label className="">{Tr("render")}</label>
<input type="checkbox" checked={renderMarkdown} />
</span>
<span onClick={(event: any) => setRenderColor(!renderColor)}>
<label className="">{Tr("color")}</label>
<input type="checkbox" checked={renderColor} />
</span>
</div>
)}
</div>
</div>
</>
);
}

35
src/messageDetail.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { ChatStoreMessage } from "@/types/chatstore";
interface Props {
chat: ChatStoreMessage;
renderMarkdown: boolean;
}
export function MessageDetail({ chat, renderMarkdown }: Props) {
if (typeof chat.content === "string") {
return <div></div>;
}
return (
<div>
{chat.content.map((mdt) =>
mdt.type === "text" ? (
chat.hide ? (
mdt.text?.split("\n")[0].slice(0, 16) + " ..."
) : renderMarkdown ? (
// @ts-ignore
<Markdown markdown={mdt.text} />
) : (
mdt.text
)
) : (
<img
className="my-2 rounded-md max-w-64 max-h-64"
src={mdt.image_url?.url}
onClick={() => {
window.open(mdt.image_url?.url, "_blank");
}}
/>
),
)}
</div>
);
}

10
src/messageHide.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { ChatStoreMessage } from "@/types/chatstore";
import { getMessageText } from "@/chatgpt";
interface Props {
chat: ChatStoreMessage;
}
export function MessageHide({ chat }: Props) {
return <div>{getMessageText(chat).split("\n")[0].slice(0, 18)} ...</div>;
}

45
src/messageToolCall.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { ChatStoreMessage } from "@/types/chatstore";
interface Props {
chat: ChatStoreMessage;
copyToClipboard: (text: string) => void;
}
export function MessageToolCall({ chat, copyToClipboard }: Props) {
return (
<div className="message-content">
{chat.tool_calls?.map((tool_call) => (
<div className="bg-blue-300 dark:bg-blue-800 p-1 rounded my-1">
<strong>
Tool Call ID:{" "}
<span
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
onClick={() => copyToClipboard(String(tool_call.id))}
>
{tool_call?.id}
</span>
</strong>
<p>Type: {tool_call?.type}</p>
<p>
Function:
<span
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
onClick={() => copyToClipboard(tool_call.function.name)}
>
{tool_call.function.name}
</span>
</p>
<p>
Arguments:
<span
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
onClick={() => copyToClipboard(tool_call.function.arguments)}
>
{tool_call.function.arguments}
</span>
</p>
</div>
))}
{chat.content}
</div>
);
}

24
src/messageToolResp.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { ChatStoreMessage } from "@/types/chatstore";
interface Props {
chat: ChatStoreMessage;
copyToClipboard: (text: string) => void;
}
export function MessageToolResp({ chat, copyToClipboard }: Props) {
return (
<div className="message-content">
<div className="bg-blue-300 dark:bg-blue-800 p-1 rounded my-1">
<strong>
Tool Response ID:{" "}
<span
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
onClick={() => copyToClipboard(String(chat.tool_call_id))}
>
{chat.tool_call_id}
</span>
</strong>
<p>{chat.content}</p>
</div>
</div>
);
}

93
src/pages/AddToolMsg.tsx Normal file
View File

@@ -0,0 +1,93 @@
import { useState } from "preact/hooks";
import { Dispatch, StateUpdater } from "preact/hooks";
import { Tr } from "@/translate";
import { calculate_token_length } from "@/chatgpt";
import { ChatStore } from "@/types/chatstore";
const AddToolMsg = (props: {
setShowAddToolMsg: Dispatch<StateUpdater<boolean>>;
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
}) => {
const { setShowAddToolMsg, chatStore, setChatStore } = props;
const [newToolCallID, setNewToolCallID] = useState("");
const [newToolContent, setNewToolContent] = useState("");
return (
<div
className="absolute z-10 bg-black bg-opacity-50 w-full h-full flex justify-center items-center left-0 top-0 overflow-scroll"
onClick={() => {
setShowAddToolMsg(false);
}}
>
<div
className="bg-white rounded p-2 z-20 flex flex-col"
onClick={(event) => {
event.stopPropagation();
}}
>
<h2>Add Tool Message</h2>
<hr className="my-2" />
<span>
<label>tool_call_id</label>
<input
className="rounded m-1 p-1 border-2 border-gray-400"
type="text"
value={newToolCallID}
onChange={(event: any) => setNewToolCallID(event.target.value)}
/>
</span>
<span>
<label>Content</label>
<textarea
className="rounded m-1 p-1 border-2 border-gray-400"
rows={5}
value={newToolContent}
onChange={(event: any) => setNewToolContent(event.target.value)}
></textarea>
</span>
<span className={`flex justify-between p-2`}>
<button
className="btn btn-info m-1 p-1"
onClick={() => setShowAddToolMsg(false)}
>
{Tr("Cancle")}
</button>
<button
className="rounded m-1 p-1 border-2 bg-cyan-400 hover:bg-cyan-600"
onClick={() => {
if (!newToolCallID.trim()) {
alert("tool_call_id is empty");
return;
}
if (!newToolContent.trim()) {
alert("content is empty");
return;
}
chatStore.history.push({
role: "tool",
tool_call_id: newToolCallID.trim(),
content: newToolContent.trim(),
token: calculate_token_length(newToolContent),
hide: false,
example: false,
audio: null,
logprobs: null,
response_model_name: null,
});
setChatStore({ ...chatStore });
setNewToolCallID("");
setNewToolContent("");
setShowAddToolMsg(false);
}}
>
{Tr("Add")}
</button>
</span>
</div>
</div>
);
};
export default AddToolMsg;

235
src/pages/App.tsx Normal file
View File

@@ -0,0 +1,235 @@
import { openDB } from "idb";
import { useEffect, useState } from "preact/hooks";
import "@/global.css";
import { calculate_token_length } from "@/chatgpt";
import { getDefaultParams } from "@/utils/getDefaultParam";
import ChatBOX from "@/pages/Chatbox";
import { DefaultModel } from "@/const";
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
import { ChatStore } from "@/types/chatstore";
import { newChatStore } from "@/types/newChatstore";
import { STORAGE_NAME, STORAGE_NAME_SELECTED } from "@/const";
import { upgrade } from "@/indexedDB/upgrade";
export function App() {
// init selected index
const [selectedChatIndex, setSelectedChatIndex] = useState(
parseInt(localStorage.getItem(STORAGE_NAME_SELECTED) ?? "1"),
);
console.log("selectedChatIndex", selectedChatIndex);
useEffect(() => {
console.log("set selected chat index", selectedChatIndex);
localStorage.setItem(STORAGE_NAME_SELECTED, `${selectedChatIndex}`);
}, [selectedChatIndex]);
const db = openDB<ChatStore>(STORAGE_NAME, 11, {
upgrade,
});
const getChatStoreByIndex = async (index: number): Promise<ChatStore> => {
const ret: ChatStore = await (await db).get(STORAGE_NAME, index);
if (ret === null || ret === undefined) return newChatStore({});
// handle read from old version chatstore
if (ret.maxGenTokens === undefined) ret.maxGenTokens = 2048;
if (ret.maxGenTokens_enabled === undefined) ret.maxGenTokens_enabled = true;
if (ret.model === undefined) ret.model = DefaultModel;
if (ret.toolsString === undefined) ret.toolsString = "";
if (ret.chatgpt_api_web_version === undefined)
// this is from old version becasue it is undefined,
// so no higher than v1.3.0
ret.chatgpt_api_web_version = "v1.2.2";
for (const message of ret.history) {
if (message.hide === undefined) message.hide = false;
if (message.token === undefined)
message.token = calculate_token_length(message.content);
}
if (ret.cost === undefined) ret.cost = 0;
return ret;
};
const [chatStore, _setChatStore] = useState(newChatStore({}));
const setChatStore = async (chatStore: ChatStore) => {
console.log("recalculate postBeginIndex");
const max = chatStore.maxTokens - chatStore.tokenMargin;
let sum = 0;
chatStore.postBeginIndex = chatStore.history.filter(
({ hide }) => !hide,
).length;
for (const msg of chatStore.history
.filter(({ hide }) => !hide)
.slice()
.reverse()) {
if (sum + msg.token > max) break;
sum += msg.token;
chatStore.postBeginIndex -= 1;
}
chatStore.postBeginIndex =
chatStore.postBeginIndex < 0 ? 0 : chatStore.postBeginIndex;
// manually estimate token
chatStore.totalTokens = calculate_token_length(
chatStore.systemMessageContent,
);
for (const msg of chatStore.history
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex)) {
chatStore.totalTokens += msg.token;
}
console.log("saved chat", selectedChatIndex, chatStore);
(await db).put(STORAGE_NAME, chatStore, selectedChatIndex);
// update total tokens
chatStore.totalTokens = calculate_token_length(
chatStore.systemMessageContent,
);
for (const msg of chatStore.history
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex)) {
chatStore.totalTokens += msg.token;
}
_setChatStore(chatStore);
};
useEffect(() => {
const run = async () => {
_setChatStore(await getChatStoreByIndex(selectedChatIndex));
};
run();
}, [selectedChatIndex]);
// all chat store indexes
const [allChatStoreIndexes, setAllChatStoreIndexes] = useState<IDBValidKey>(
[],
);
const handleNewChatStoreWithOldOne = async (chatStore: ChatStore) => {
const newKey = await (await db).add(STORAGE_NAME, newChatStore(chatStore));
setSelectedChatIndex(newKey as number);
setAllChatStoreIndexes(await (await db).getAllKeys(STORAGE_NAME));
};
const handleNewChatStore = async () => {
return handleNewChatStoreWithOldOne(chatStore);
};
const handleDEL = async () => {
if (!confirm("Are you sure you want to delete this chat history?")) return;
console.log("remove item", `${STORAGE_NAME}-${selectedChatIndex}`);
(await db).delete(STORAGE_NAME, selectedChatIndex);
const newAllChatStoreIndexes = await (await db).getAllKeys(STORAGE_NAME);
if (newAllChatStoreIndexes.length === 0) {
handleNewChatStore();
return;
}
// find nex selected chat index
const next = newAllChatStoreIndexes[newAllChatStoreIndexes.length - 1];
console.log("next is", next);
setSelectedChatIndex(next as number);
setAllChatStoreIndexes(newAllChatStoreIndexes);
};
const handleCLS = async () => {
if (!confirm("Are you sure you want to delete **ALL** chat history?"))
return;
await (await db).clear(STORAGE_NAME);
setAllChatStoreIndexes([]);
setSelectedChatIndex(1);
window.location.reload();
};
// if there are any params in URL, create a new chatStore
useEffect(() => {
const run = async () => {
const chatStore = await getChatStoreByIndex(selectedChatIndex);
const api = getDefaultParams("api", "");
const key = getDefaultParams("key", "");
const sys = getDefaultParams("sys", "");
const mode = getDefaultParams("mode", "");
const model = getDefaultParams("model", "");
const max = getDefaultParams("max", 0);
console.log("max is", max, "chatStore.max is", chatStore.maxTokens);
// only create new chatStore if the params in URL are NOT
// equal to the current selected chatStore
if (
(api && api !== chatStore.apiEndpoint) ||
(key && key !== chatStore.apiKey) ||
(sys && sys !== chatStore.systemMessageContent) ||
(mode && mode !== (chatStore.streamMode ? "stream" : "fetch")) ||
(model && model !== chatStore.model) ||
(max !== 0 && max !== chatStore.maxTokens)
) {
console.log("create new chatStore because of params in URL");
handleNewChatStoreWithOldOne(chatStore);
}
await db;
const allidx = await (await db).getAllKeys(STORAGE_NAME);
if (allidx.length === 0) {
handleNewChatStore();
}
setAllChatStoreIndexes(await (await db).getAllKeys(STORAGE_NAME));
};
run();
}, []);
return (
<div className="flex text-sm h-full">
<div className="flex flex-col h-full p-2 bg-primary">
<div className="grow overflow-scroll">
<button
className="btn btn-sm btn-info p-1 my-1 w-full"
onClick={handleNewChatStore}
>
{Tr("NEW")}
</button>
<ul className="pt-2">
{(allChatStoreIndexes as number[])
.slice()
.reverse()
.map((i) => {
// reverse
return (
<li>
<button
className={`w-full my-1 p-1 btn btn-sm ${
i === selectedChatIndex ? "btn-accent" : "btn-secondary"
}`}
onClick={() => setSelectedChatIndex(i)}
>
{i}
</button>
</li>
);
})}
</ul>
</div>
<div>
<button
className="btn btn-warning btn-sm p-1 my-1 w-full"
onClick={async () => handleDEL()}
>
{Tr("DEL")}
</button>
{chatStore.develop_mode && (
<button
className="btn btn-sm btn-warning p-1 my-1 w-full"
onClick={async () => handleCLS()}
>
{Tr("CLS")}
</button>
)}
</div>
</div>
<ChatBOX
db={db}
chatStore={chatStore}
setChatStore={setChatStore}
selectedChatIndex={selectedChatIndex}
setSelectedChatIndex={setSelectedChatIndex}
/>
</div>
);
}

795
src/pages/Chatbox.tsx Normal file
View File

@@ -0,0 +1,795 @@
import { IDBPDatabase } from "idb";
import { createRef } from "preact";
import { StateUpdater, useEffect, useState, Dispatch } from "preact/hooks";
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
import {
STORAGE_NAME_TEMPLATE,
STORAGE_NAME_TEMPLATE_API,
STORAGE_NAME_TEMPLATE_API_IMAGE_GEN,
STORAGE_NAME_TEMPLATE_API_TTS,
STORAGE_NAME_TEMPLATE_API_WHISPER,
STORAGE_NAME_TEMPLATE_TOOLS,
} from "@/const";
import { addTotalCost, getTotalCost } from "@/utils/totalCost";
import ChatGPT, {
calculate_token_length,
FetchResponse,
Message as MessageType,
MessageDetail,
ToolCall,
Logprobs,
} from "@/chatgpt";
import {
ChatStore,
ChatStoreMessage,
TemplateChatStore,
TemplateAPI,
TemplateTools,
} from "../types/chatstore";
import Message from "@/message";
import { models } from "@/types/models";
import Settings from "@/settings";
import { AddImage } from "@/addImage";
import { ListAPIs } from "@/listAPIs";
import { ListToolsTempaltes } from "@/listToolsTemplates";
import { autoHeight } from "@/textarea";
import Search from "@/search";
import Templates from "@/components/Templates";
import VersionHint from "@/components/VersionHint";
import StatusBar from "@/components/StatusBar";
import WhisperButton from "@/components/WhisperButton";
import AddToolMsg from "./AddToolMsg";
export default function ChatBOX(props: {
db: Promise<IDBPDatabase<ChatStore>>;
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
selectedChatIndex: number;
setSelectedChatIndex: Dispatch<StateUpdater<number>>;
}) {
const { chatStore, setChatStore } = props;
// prevent error
if (chatStore === undefined) return <div></div>;
const [inputMsg, setInputMsg] = useState("");
const [images, setImages] = useState<MessageDetail[]>([]);
const [showAddImage, setShowAddImage] = useState(false);
const [showGenerating, setShowGenerating] = useState(false);
const [generatingMessage, setGeneratingMessage] = useState("");
const [showRetry, setShowRetry] = useState(false);
const [showAddToolMsg, setShowAddToolMsg] = useState(false);
const [showSearch, setShowSearch] = useState(false);
let default_follow = localStorage.getItem("follow");
if (default_follow === null) {
default_follow = "true";
}
const [follow, _setFollow] = useState(default_follow === "true");
const setFollow = (follow: boolean) => {
console.log("set follow", follow);
localStorage.setItem("follow", follow.toString());
_setFollow(follow);
};
const messagesEndRef = createRef();
useEffect(() => {
if (follow) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [showRetry, showGenerating, generatingMessage]);
const client = new ChatGPT(chatStore.apiKey);
const _completeWithStreamMode = async (response: Response) => {
let responseTokenCount = 0;
const allChunkMessage: string[] = [];
const allChunkTool: ToolCall[] = [];
setShowGenerating(true);
const logprobs: Logprobs = {
content: [],
};
let response_model_name: string | null = null;
for await (const i of client.processStreamResponse(response)) {
response_model_name = i.model;
responseTokenCount += 1;
const c = i.choices[0];
// skip if choice is empty (e.g. azure)
if (!c) continue;
const logprob = c?.logprobs?.content[0]?.logprob;
if (logprob !== undefined) {
logprobs.content.push({
token: c?.delta?.content ?? "",
logprob,
});
console.log(c?.delta?.content, logprob);
}
allChunkMessage.push(c?.delta?.content ?? "");
const tool_calls = c?.delta?.tool_calls;
if (tool_calls) {
for (const tool_call of tool_calls) {
// init
if (tool_call.id) {
allChunkTool.push({
id: tool_call.id,
type: tool_call.type,
index: tool_call.index,
function: {
name: tool_call.function.name,
arguments: "",
},
});
continue;
}
// update tool call arguments
const tool = allChunkTool.find(
(tool) => tool.index === tool_call.index,
);
if (!tool) {
console.log("tool (by index) not found", tool_call.index);
continue;
}
tool.function.arguments += tool_call.function.arguments;
}
}
setGeneratingMessage(
allChunkMessage.join("") +
allChunkTool.map((tool) => {
return `Tool Call ID: ${tool.id}\nType: ${tool.type}\nFunction: ${tool.function.name}\nArguments: ${tool.function.arguments}`;
}),
);
}
setShowGenerating(false);
const content = allChunkMessage.join("");
// estimate cost
let cost = 0;
if (response_model_name) {
cost +=
responseTokenCount *
(models[response_model_name]?.price?.completion ?? 0);
let sum = 0;
for (const msg of chatStore.history
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex)) {
sum += msg.token;
}
cost += sum * (models[response_model_name]?.price?.prompt ?? 0);
}
console.log("cost", cost);
chatStore.cost += cost;
addTotalCost(cost);
console.log("save logprobs", logprobs);
const newMsg: ChatStoreMessage = {
role: "assistant",
content,
hide: false,
token: responseTokenCount,
example: false,
audio: null,
logprobs,
response_model_name,
};
if (allChunkTool.length > 0) newMsg.tool_calls = allChunkTool;
chatStore.history.push(newMsg);
// manually copy status from client to chatStore
chatStore.maxTokens = client.max_tokens;
chatStore.tokenMargin = client.tokens_margin;
setChatStore({ ...chatStore });
setGeneratingMessage("");
setShowGenerating(false);
};
const _completeWithFetchMode = async (response: Response) => {
const data = (await response.json()) as FetchResponse;
if (data.model) {
let cost = 0;
cost +=
(data.usage.prompt_tokens ?? 0) *
(models[data.model]?.price?.prompt ?? 0);
cost +=
(data.usage.completion_tokens ?? 0) *
(models[data.model]?.price?.completion ?? 0);
chatStore.cost += cost;
addTotalCost(cost);
}
const msg = client.processFetchResponse(data);
// estimate user's input message token
let aboveToken = 0;
for (const msg of chatStore.history
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex, -1)) {
aboveToken += msg.token;
}
if (data.usage.prompt_tokens) {
const userMessageToken = data.usage.prompt_tokens - aboveToken;
console.log("set user message token");
if (chatStore.history.filter((msg) => !msg.hide).length > 0) {
chatStore.history.filter((msg) => !msg.hide).slice(-1)[0].token =
userMessageToken;
}
}
chatStore.history.push({
role: "assistant",
content: msg.content,
tool_calls: msg.tool_calls,
hide: false,
token:
data.usage.completion_tokens ?? calculate_token_length(msg.content),
example: false,
audio: null,
logprobs: data.choices[0]?.logprobs,
response_model_name: data.model,
});
setShowGenerating(false);
};
// wrap the actuall complete api
const complete = async () => {
// manually copy status from chatStore to client
client.apiEndpoint = chatStore.apiEndpoint;
client.sysMessageContent = chatStore.systemMessageContent;
client.toolsString = chatStore.toolsString;
client.tokens_margin = chatStore.tokenMargin;
client.temperature = chatStore.temperature;
client.enable_temperature = chatStore.temperature_enabled;
client.top_p = chatStore.top_p;
client.enable_top_p = chatStore.top_p_enabled;
client.frequency_penalty = chatStore.frequency_penalty;
client.presence_penalty = chatStore.presence_penalty;
client.json_mode = chatStore.json_mode;
client.messages = chatStore.history
// only copy non hidden message
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex)
// only copy content and role attribute to client for posting
.map(({ content, role, example, tool_call_id, tool_calls }) => {
const ret: MessageType = {
content,
role,
tool_calls,
};
if (example) {
ret.name =
ret.role === "assistant" ? "example_assistant" : "example_user";
ret.role = "system";
}
if (tool_call_id) ret.tool_call_id = tool_call_id;
return ret;
});
client.model = chatStore.model;
client.max_tokens = chatStore.maxTokens;
client.max_gen_tokens = chatStore.maxGenTokens;
client.enable_max_gen_tokens = chatStore.maxGenTokens_enabled;
try {
setShowGenerating(true);
const response = await client._fetch(
chatStore.streamMode,
chatStore.logprobs,
);
const contentType = response.headers.get("content-type");
if (contentType?.startsWith("text/event-stream")) {
await _completeWithStreamMode(response);
} else if (contentType?.startsWith("application/json")) {
await _completeWithFetchMode(response);
} else {
throw `unknown response content type ${contentType}`;
}
// manually copy status from client to chatStore
chatStore.maxTokens = client.max_tokens;
chatStore.tokenMargin = client.tokens_margin;
chatStore.totalTokens = client.total_tokens;
console.log("postBeginIndex", chatStore.postBeginIndex);
setShowRetry(false);
setChatStore({ ...chatStore });
} catch (error) {
setShowRetry(true);
alert(error);
} finally {
setShowGenerating(false);
props.setSelectedChatIndex(props.selectedChatIndex);
}
};
// when user click the "send" button or ctrl+Enter in the textarea
const send = async (msg = "", call_complete = true) => {
const inputMsg = msg.trim();
if (!inputMsg && images.length === 0) {
console.log("empty message");
return;
}
let content: string | MessageDetail[] = inputMsg;
if (images.length > 0) {
content = images;
}
if (images.length > 0 && inputMsg.trim()) {
content = [{ type: "text", text: inputMsg }, ...images];
}
chatStore.history.push({
role: "user",
content,
hide: false,
token: calculate_token_length(inputMsg.trim()),
example: false,
audio: null,
logprobs: null,
response_model_name: null,
});
// manually calculate token length
chatStore.totalTokens +=
calculate_token_length(inputMsg.trim()) + calculate_token_length(images);
client.total_tokens = chatStore.totalTokens;
setChatStore({ ...chatStore });
setInputMsg("");
setImages([]);
if (call_complete) {
await complete();
}
};
const [showSettings, setShowSettings] = useState(false);
const [templates, _setTemplates] = useState(
JSON.parse(
localStorage.getItem(STORAGE_NAME_TEMPLATE) || "[]",
) as TemplateChatStore[],
);
const [templateAPIs, _setTemplateAPIs] = useState(
JSON.parse(
localStorage.getItem(STORAGE_NAME_TEMPLATE_API) || "[]",
) as TemplateAPI[],
);
const [templateAPIsWhisper, _setTemplateAPIsWhisper] = useState(
JSON.parse(
localStorage.getItem(STORAGE_NAME_TEMPLATE_API_WHISPER) || "[]",
) as TemplateAPI[],
);
const [templateAPIsTTS, _setTemplateAPIsTTS] = useState(
JSON.parse(
localStorage.getItem(STORAGE_NAME_TEMPLATE_API_TTS) || "[]",
) as TemplateAPI[],
);
const [templateAPIsImageGen, _setTemplateAPIsImageGen] = useState(
JSON.parse(
localStorage.getItem(STORAGE_NAME_TEMPLATE_API_IMAGE_GEN) || "[]",
) as TemplateAPI[],
);
const [toolsTemplates, _setToolsTemplates] = useState(
JSON.parse(
localStorage.getItem(STORAGE_NAME_TEMPLATE_TOOLS) || "[]",
) as TemplateTools[],
);
const setTemplates = (templates: TemplateChatStore[]) => {
localStorage.setItem(STORAGE_NAME_TEMPLATE, JSON.stringify(templates));
_setTemplates(templates);
};
const setTemplateAPIs = (templateAPIs: TemplateAPI[]) => {
localStorage.setItem(
STORAGE_NAME_TEMPLATE_API,
JSON.stringify(templateAPIs),
);
_setTemplateAPIs(templateAPIs);
};
const setTemplateAPIsWhisper = (templateAPIWhisper: TemplateAPI[]) => {
localStorage.setItem(
STORAGE_NAME_TEMPLATE_API_WHISPER,
JSON.stringify(templateAPIWhisper),
);
_setTemplateAPIsWhisper(templateAPIWhisper);
};
const setTemplateAPIsTTS = (templateAPITTS: TemplateAPI[]) => {
localStorage.setItem(
STORAGE_NAME_TEMPLATE_API_TTS,
JSON.stringify(templateAPITTS),
);
_setTemplateAPIsTTS(templateAPITTS);
};
const setTemplateAPIsImageGen = (templateAPIImageGen: TemplateAPI[]) => {
localStorage.setItem(
STORAGE_NAME_TEMPLATE_API_IMAGE_GEN,
JSON.stringify(templateAPIImageGen),
);
_setTemplateAPIsImageGen(templateAPIImageGen);
};
const setTemplateTools = (templateTools: TemplateTools[]) => {
localStorage.setItem(
STORAGE_NAME_TEMPLATE_TOOLS,
JSON.stringify(templateTools),
);
_setToolsTemplates(templateTools);
};
const userInputRef = createRef();
return (
<div className="grow flex flex-col p-2 w-full">
{showSettings && (
<Settings
chatStore={chatStore}
setChatStore={setChatStore}
setShow={setShowSettings}
selectedChatStoreIndex={props.selectedChatIndex}
templates={templates}
setTemplates={setTemplates}
templateAPIs={templateAPIs}
setTemplateAPIs={setTemplateAPIs}
templateAPIsWhisper={templateAPIsWhisper}
setTemplateAPIsWhisper={setTemplateAPIsWhisper}
templateAPIsTTS={templateAPIsTTS}
setTemplateAPIsTTS={setTemplateAPIsTTS}
templateAPIsImageGen={templateAPIsImageGen}
setTemplateAPIsImageGen={setTemplateAPIsImageGen}
templateTools={toolsTemplates}
setTemplateTools={setTemplateTools}
/>
)}
{showSearch && (
<Search
setSelectedChatIndex={props.setSelectedChatIndex}
db={props.db}
chatStore={chatStore}
setShow={setShowSearch}
/>
)}
<StatusBar
chatStore={chatStore}
setShowSettings={setShowSettings}
setShowSearch={setShowSearch}
/>
<div className="grow overflow-scroll">
{!chatStore.apiKey && (
<p className="bg-base-200 p-6 rounded my-3 text-left">
{Tr("Please click above to set")} (OpenAI) API KEY
</p>
)}
{!chatStore.apiEndpoint && (
<p className="bg-base-200 p-6 rounded my-3 text-left">
{Tr("Please click above to set")} API Endpoint
</p>
)}
{templateAPIs.length > 0 && (
<ListAPIs
label="API"
tmps={templateAPIs}
setTmps={setTemplateAPIs}
chatStore={chatStore}
setChatStore={setChatStore}
apiField="apiEndpoint"
keyField="apiKey"
/>
)}
{templateAPIsWhisper.length > 0 && (
<ListAPIs
label="Whisper API"
tmps={templateAPIsWhisper}
setTmps={setTemplateAPIsWhisper}
chatStore={chatStore}
setChatStore={setChatStore}
apiField="whisper_api"
keyField="whisper_key"
/>
)}
{templateAPIsTTS.length > 0 && (
<ListAPIs
label="TTS API"
tmps={templateAPIsTTS}
setTmps={setTemplateAPIsTTS}
chatStore={chatStore}
setChatStore={setChatStore}
apiField="tts_api"
keyField="tts_key"
/>
)}
{templateAPIsImageGen.length > 0 && (
<ListAPIs
label="Image Gen API"
tmps={templateAPIsImageGen}
setTmps={setTemplateAPIsImageGen}
chatStore={chatStore}
setChatStore={setChatStore}
apiField="image_gen_api"
keyField="image_gen_key"
/>
)}
{toolsTemplates.length > 0 && (
<ListToolsTempaltes
templateTools={toolsTemplates}
setTemplateTools={setTemplateTools}
chatStore={chatStore}
setChatStore={setChatStore}
/>
)}
{chatStore.history.filter((msg) => !msg.example).length == 0 && (
<div className="bg-base-200 break-all p-3 my-3 text-left">
<h2>
<span>{Tr("Saved prompt templates")}</span>
<button
className="mx-2 underline cursor-pointer"
onClick={() => {
chatStore.systemMessageContent = "";
chatStore.toolsString = "";
chatStore.history = [];
setChatStore({ ...chatStore });
}}
>
{Tr("Reset Current")}
</button>
</h2>
<div className="divider"></div>
<div className="flex flex-wrap">
<Templates
templates={templates}
setTemplates={setTemplates}
chatStore={chatStore}
setChatStore={setChatStore}
/>
</div>
</div>
)}
{chatStore.history.length === 0 && (
<p className="break-all opacity-60 p-6 rounded bg-white my-3 text-left dark:text-black">
{Tr("No chat history here")}
<br />{Tr("Model")}: {chatStore.model}
<br />{Tr("Click above to change the settings of this chat")}
<br />{Tr("Click the conor to create a new chat")}
<br />
{Tr(
"All chat history and settings are stored in the local browser",
)}
<br />
</p>
)}
{chatStore.systemMessageContent.trim() && (
<div className="chat chat-start">
<div className="chat-header">Prompt</div>
<div
className="chat-bubble chat-bubble-accent cursor-pointer message-content"
onClick={() => setShowSettings(true)}
>
{chatStore.systemMessageContent}
</div>
</div>
)}
{chatStore.history.map((_, messageIndex) => (
<Message
chatStore={chatStore}
setChatStore={setChatStore}
messageIndex={messageIndex}
/>
))}
{showGenerating && (
<p className="p-2 my-2 animate-pulse message-content">
{generatingMessage || Tr("Generating...")}
...
</p>
)}
<p className="text-center">
{chatStore.history.length > 0 && (
<button
className="btn btn-sm btn-warning disabled:line-through disabled:btn-neutral disabled:text-white m-2 p-2"
disabled={showGenerating}
onClick={async () => {
const messageIndex = chatStore.history.length - 1;
if (chatStore.history[messageIndex].role === "assistant") {
chatStore.history[messageIndex].hide = true;
}
//chatStore.totalTokens =
setChatStore({ ...chatStore });
await complete();
}}
>
{Tr("Re-Generate")}
</button>
)}
{chatStore.develop_mode && chatStore.history.length > 0 && (
<button
className="btn btn-outline btn-sm btn-warning disabled:line-through disabled:bg-neural"
disabled={showGenerating}
onClick={async () => {
await complete();
}}
>
{Tr("Completion")}
</button>
)}
</p>
<p className="p-2 my-2 text-center opacity-50 dark:text-white">
{chatStore.postBeginIndex !== 0 && (
<>
<br />
{Tr("Info: chat history is too long, forget messages")}:{" "}
{chatStore.postBeginIndex}
</>
)}
</p>
<VersionHint chatStore={chatStore} />
{showRetry && (
<p className="text-right p-2 my-2 dark:text-white">
<button
className="p-1 rounded bg-rose-500"
onClick={async () => {
setShowRetry(false);
await complete();
}}
>
{Tr("Retry")}
</button>
</p>
)}
<div ref={messagesEndRef}></div>
</div>
{images.length > 0 && (
<div className="flex flex-wrap">
{images.map((image, index) => (
<div className="flex flex-col">
{image.type === "image_url" && (
<img
className="rounded m-1 p-1 border-2 border-gray-400 max-h-32 max-w-xs"
src={image.image_url?.url}
/>
)}
</div>
))}
</div>
)}
{generatingMessage && (
<span
className="p-2 m-2 rounded bg-white dark:text-black dark:bg-white dark:bg-opacity-50"
style={{ textAlign: "right" }}
onClick={() => {
setFollow(!follow);
}}
>
<label>Follow</label>
<input type="checkbox" checked={follow} />
</span>
)}
<div className="flex justify-between my-1">
<button
className="btn btn-primary disabled:line-through disabled:text-white disabled:bg-neutral m-1 p-1"
disabled={showGenerating || !chatStore.apiKey}
onClick={() => {
setShowAddImage(!showAddImage);
}}
>
Image
</button>
{showAddImage && (
<AddImage
chatStore={chatStore}
setChatStore={setChatStore}
setShowAddImage={setShowAddImage}
images={images}
setImages={setImages}
/>
)}
<textarea
autofocus
value={inputMsg}
ref={userInputRef}
onChange={(event: any) => {
setInputMsg(event.target.value);
autoHeight(event.target);
}}
onKeyPress={(event: any) => {
console.log(event);
if (event.ctrlKey && event.code === "Enter") {
send(event.target.value, true);
setInputMsg("");
event.target.value = "";
autoHeight(event.target);
return;
}
autoHeight(event.target);
setInputMsg(event.target.value);
}}
className="textarea textarea-bordered textarea-sm grow w-0"
style={{
lineHeight: "1.39",
}}
placeholder="Type here..."
></textarea>
<button
className="btn btn-primary disabled:btn-neutral disabled:line-through m-1 p-1"
disabled={showGenerating}
onClick={() => {
send(inputMsg, true);
userInputRef.current.value = "";
autoHeight(userInputRef.current);
}}
>
{Tr("Send")}
</button>
{chatStore.whisper_api && chatStore.whisper_key && (
<WhisperButton
chatStore={chatStore}
inputMsg={inputMsg}
setInputMsg={setInputMsg}
/>
)}
{chatStore.develop_mode && (
<button
className="btn disabled:line-through disabled:btn-neutral disabled:text-white m-1 p-1"
disabled={showGenerating || !chatStore.apiKey}
onClick={() => {
chatStore.history.push({
role: "assistant",
content: inputMsg,
token:
calculate_token_length(inputMsg) +
calculate_token_length(images),
hide: false,
example: false,
audio: null,
logprobs: null,
response_model_name: null,
});
setInputMsg("");
setChatStore({ ...chatStore });
}}
>
{Tr("AI")}
</button>
)}
{chatStore.develop_mode && (
<button
className="btn disabled:line-through disabled:btn-neutral disabled:text-white m-1 p-1"
disabled={showGenerating || !chatStore.apiKey}
onClick={() => {
send(inputMsg, false);
}}
>
{Tr("User")}
</button>
)}
{chatStore.develop_mode && (
<button
className="btn disabled:line-through disabled:btn-neutral disabled:text-white m-1 p-1"
disabled={showGenerating || !chatStore.apiKey}
onClick={() => {
setShowAddToolMsg(true);
}}
>
{Tr("Tool")}
</button>
)}
{showAddToolMsg && (
<AddToolMsg
chatStore={chatStore}
setChatStore={setChatStore}
setShowAddToolMsg={setShowAddToolMsg}
/>
)}
</div>
</div>
);
}

180
src/search.tsx Normal file
View File

@@ -0,0 +1,180 @@
import { IDBPDatabase } from "idb";
import { StateUpdater, useRef, useState, Dispatch } from "preact/hooks";
import { ChatStore } from "@/types/chatstore";
interface ChatStoreSearchResult {
key: IDBValidKey;
cs: ChatStore;
query: string;
preview: string;
}
export default function Search(props: {
db: Promise<IDBPDatabase<ChatStore>>;
setSelectedChatIndex: Dispatch<StateUpdater<number>>;
chatStore: ChatStore;
setShow: (show: boolean) => void;
}) {
const [searchResult, setSearchResult] = useState<ChatStoreSearchResult[]>([]);
const [searching, setSearching] = useState<boolean>(false);
const [searchingNow, setSearchingNow] = useState<number>(0);
const [pageIndex, setPageIndex] = useState<number>(0);
const searchAbortRef = useRef<AbortController | null>(null);
return (
<div
onClick={() => props.setShow(false)}
className="left-0 top-0 overflow-scroll flex justify-center absolute w-screen h-full bg-black bg-opacity-50 z-10"
>
<div
onClick={(event: any) => {
event.stopPropagation();
}}
className="m-2 p-2 bg-base-300 rounded-lg h-fit w-2/3 z-20"
>
<div className="flex justify-between">
<span className="m-1 p-1 font-bold">Search</span>
<button
className="m-1 p-1 btn btn-sm btn-secondary"
onClick={() => props.setShow(false)}
>
Close
</button>
</div>
<div>
<input
autoFocus
className="input input-bordered w-full border"
type="text"
placeholder="Type Something..."
onInput={async (event: any) => {
const query = event.target.value.trim().toLowerCase();
if (!query) {
setSearchResult([]);
return;
}
// abort previous search
if (searchAbortRef.current) {
searchAbortRef.current.abort();
}
// Create a new AbortController for the new operation
const abortController = new AbortController();
searchAbortRef.current = abortController;
const signal = abortController.signal;
setSearching(true);
const db = await props.db;
const resultKeys = await db.getAllKeys("chatgpt-api-web");
const result: ChatStoreSearchResult[] = [];
for (const key of resultKeys) {
// abort the operation if the signal is set
if (signal.aborted) {
return;
}
const now = Math.floor(
(result.length / resultKeys.length) * 100,
);
if (now !== searchingNow) setSearchingNow(now);
const value: ChatStore = await db.get("chatgpt-api-web", key);
const content = value.contents_for_index
.join(" ")
.toLowerCase();
if (content.includes(query)) {
const beginIndex: number = content.indexOf(query);
const preview = content.slice(
Math.max(0, beginIndex - 100),
Math.min(content.length, beginIndex + 239),
);
result.push({
key,
cs: value,
query: query,
preview: preview,
});
}
}
// sort by key desc
result.sort((a, b) => {
if (a.key < b.key) {
return 1;
}
if (a.key > b.key) {
return -1;
}
return 0;
});
console.log(result);
setPageIndex(0);
setSearchResult(result);
setSearching(false);
}}
/>
</div>
{searching && <div>Searching {searchingNow}%...</div>}
<div>
{searchResult
.slice(pageIndex * 10, (pageIndex + 1) * 10)
.map((result: ChatStoreSearchResult) => {
return (
<div
className="flex justify-start p-1 m-1 rounded border bg-base-200 cursor-pointer"
key={result.key}
onClick={() => {
props.setSelectedChatIndex(parseInt(result.key.toString()));
props.setShow(false);
}}
>
<div className="m-1 p-1 font-bold">{result.key}</div>
<div className="m-1 p-1">{result.preview}</div>
</div>
);
})}
</div>
{searchResult.length > 0 && (
<div className="flex justify-center my-2">
<div className="join">
<button
className="join-item btn btn-sm"
disabled={pageIndex === 0}
onClick={() => {
if (pageIndex === 0) {
return;
}
setPageIndex(pageIndex - 1);
}}
>
«
</button>
<button className="join-item btn btn-sm">
Page {pageIndex + 1} /{" "}
{Math.floor(searchResult.length / 10) + 1}
</button>
<button
className="join-item btn btn-sm"
disabled={pageIndex === Math.floor(searchResult.length / 10)}
onClick={() => {
if (pageIndex === Math.floor(searchResult.length / 10)) {
return;
}
setPageIndex(pageIndex + 1);
}}
>
»
</button>
</div>
</div>
)}
</div>
</div>
);
}

39
src/setAPIsTemplate.tsx Normal file
View File

@@ -0,0 +1,39 @@
import { TemplateAPI } from "@/types/chatstore";
import { Tr } from "@/translate";
interface Props {
tmps: TemplateAPI[];
setTmps: (tmps: TemplateAPI[]) => void;
label: string;
endpoint: string;
APIkey: string;
}
export function SetAPIsTemplate({
endpoint,
APIkey,
tmps,
setTmps,
label,
}: Props) {
return (
<button
className="btn btn-primary btn-sm mt-3"
onClick={() => {
const name = prompt(`Give this **${label}** template a name:`);
if (!name) {
alert("No template name specified");
return;
}
const tmp: TemplateAPI = {
name,
endpoint,
key: APIkey,
};
tmps.push(tmp);
setTmps([...tmps]);
}}
>
{Tr(`Save ${label}`)}
</button>
);
}

File diff suppressed because it is too large Load Diff

9
src/textarea.tsx Normal file
View File

@@ -0,0 +1,9 @@
export const autoHeight = (target: any) => {
target.style.height = "auto";
// max 70% of screen height
target.style.height = `${Math.min(
target.scrollHeight,
window.innerHeight * 0.7,
)}px`;
console.log("set auto height", target.style.height);
};

51
src/translate/index.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { createContext } from "preact";
import MAP_zh_CN from "@/translate/zh_CN";
interface LangOption {
name: string;
langMap: Record<string, string>;
matches: string[];
}
const LANG_OPTIONS: Record<string, LangOption> = {
"en-US": {
name: "English",
langMap: {},
matches: ["en-US", "en"],
},
"zh-CN": {
name: "中文(简体)",
langMap: MAP_zh_CN,
matches: ["zh-CN", "zh"],
},
};
const langCodeContext = createContext("en-US");
function tr(text: string, langCode: "en-US" | "zh-CN") {
const option = LANG_OPTIONS[langCode];
if (option === undefined) {
return text;
}
const langMap = LANG_OPTIONS[langCode].langMap;
const translatedText = langMap[text.toLowerCase()];
if (translatedText === undefined) {
return text;
}
return translatedText;
}
function Tr(text: string) {
return (
<langCodeContext.Consumer>
{/* @ts-ignore */}
{({ langCode }) => {
return tr(text, langCode);
}}
</langCodeContext.Consumer>
);
}
export { tr, Tr, LANG_OPTIONS, langCodeContext };

59
src/translate/zh_CN.ts Normal file
View File

@@ -0,0 +1,59 @@
const LANG_MAP: Record<string, string> = {
settings: "设置",
model: "模型",
"copy setting link": "复制设置链接",
"are you sure to clear all history?": "确定要清除所有历史记录吗?",
"clear history": "清除历史记录",
new: "新",
del: "删",
cut: "遗忘",
"please click above to set": "请点击上方进行设置",
cost: "消费",
stream: "流式返回",
fetch: "一次获取",
"saved api templates": "已保存的 API 模板",
"saved prompt templates": "已保存的提示模板",
"no chat history here": "暂无历史对话记录",
"click above to change the settings of this chat":
"点击上方更改此对话的参数(请勿泄漏)",
"click the NEW to create a new chat": "点击左上角 NEW 新建对话",
"all chat history and settings are stored in the local browser":
"所有历史对话与参数储存在浏览器本地",
"documents and source code are avaliable here":
"详细文档与源代码可在此处获取",
"generating...": "生成中,请保持网络稳定...",
"re-generate": "重新生成",
completion: "补全",
"generated by": "生成模型: ",
"info: chat history is too long, forget messages":
"提示:对话历史过长,遗忘消息数量",
"warning: current chatstore version": "警告:当前会话版本",
retry: "重试",
send: "发送",
assistant: "AI消息",
user: "用户消息",
close: "关闭",
"message copied to clipboard": "消息已复制到剪贴板",
"total cost in this session": "本次会话总消费",
"accumulated cost in all sessions": "所有会话总消费",
export: "导出",
"give this template a name:": "给此模板命名:",
"no template name specified": "未指定模板名称",
"as template": "保存为会话模板",
"as api template": "保存为 API 模板",
"this will overwrite the current chat history! continue?":
"此操作将覆盖当前会话历史!继续?",
"please select a json file": "请选择一个 JSON 文件",
"empty file": "警告: 空文件",
"this is not an exported chatgpt-api-web chatstore file. the key 'chatgpt_api_web_version' is missing!":
"此文件不是 chatgpt-api-web 导出的会话文件,缺少 chatgpt_api_web_version 键值!",
"import error on parsing json": "JSON 解析错误",
version: "版本",
"copied link:": "已复制链接:",
reset: "重置",
example: "示例",
render: "渲染",
"reset current": "清空当前会话",
};
export default LANG_MAP;

91
src/tts.tsx Normal file
View File

@@ -0,0 +1,91 @@
import { SpeakerWaveIcon } from "@heroicons/react/24/outline";
import { useMemo, useState } from "preact/hooks";
import { addTotalCost } from "@/utils/totalCost";
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
import { Message, getMessageText } from "@/chatgpt";
interface TTSProps {
chatStore: ChatStore;
chat: ChatStoreMessage;
setChatStore: (cs: ChatStore) => void;
}
interface TTSPlayProps {
chat: ChatStoreMessage;
}
export function TTSPlay(props: TTSPlayProps) {
const src = useMemo(() => {
if (props.chat.audio instanceof Blob) {
return URL.createObjectURL(props.chat.audio);
}
return "";
}, [props.chat.audio]);
if (props.chat.hide) {
return <></>;
}
if (props.chat.audio instanceof Blob) {
return <audio className="w-full" src={src} controls />;
}
return <></>;
}
export default function TTSButton(props: TTSProps) {
const [generating, setGenerating] = useState(false);
return (
<button
onClick={() => {
const api = props.chatStore.tts_api;
const api_key = props.chatStore.tts_key;
const model = "tts-1";
const input = getMessageText(props.chat);
const voice = props.chatStore.tts_voice;
const body: Record<string, any> = {
model,
input,
voice,
response_format: props.chatStore.tts_format || "mp3",
};
if (props.chatStore.tts_speed_enabled) {
body["speed"] = props.chatStore.tts_speed;
}
setGenerating(true);
fetch(api, {
method: "POST",
headers: {
Authorization: `Bearer ${api_key}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
.then((response) => response.blob())
.then((blob) => {
// update price
const cost = (input.length * 0.015) / 1000;
props.chatStore.cost += cost;
addTotalCost(cost);
props.setChatStore({ ...props.chatStore });
// save blob
props.chat.audio = blob;
props.setChatStore({ ...props.chatStore });
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.play();
})
.finally(() => {
setGenerating(false);
});
}}
>
{generating ? (
<span className="loading loading-dots loading-xs"></span>
) : (
<SpeakerWaveIcon className="h-4 w-4" />
)}
</button>
);
}

72
src/types/chatstore.ts Normal file
View File

@@ -0,0 +1,72 @@
import { Logprobs, Message } from "@/chatgpt";
/**
* ChatStore is the main object of the chatgpt-api-web,
* stored in IndexedDB and passed across various components.
* It contains all the information needed for a conversation.
*/
export interface ChatStore {
chatgpt_api_web_version: string;
systemMessageContent: string;
toolsString: string;
history: ChatStoreMessage[];
postBeginIndex: number;
tokenMargin: number;
totalTokens: number;
maxTokens: number;
maxGenTokens: number;
maxGenTokens_enabled: boolean;
apiKey: string;
apiEndpoint: string;
streamMode: boolean;
model: string;
cost: number;
temperature: number;
temperature_enabled: boolean;
top_p: number;
top_p_enabled: boolean;
presence_penalty: number;
frequency_penalty: number;
develop_mode: boolean;
whisper_api: string;
whisper_key: string;
tts_api: string;
tts_key: string;
tts_voice: string;
tts_speed: number;
tts_speed_enabled: boolean;
tts_format: string;
image_gen_api: string;
image_gen_key: string;
json_mode: boolean;
logprobs: boolean;
contents_for_index: string[];
}
export interface TemplateChatStore extends ChatStore {
name: string;
}
export interface TemplateAPI {
name: string;
key: string;
endpoint: string;
}
export interface TemplateTools {
name: string;
toolsString: string;
}
/**
* ChatStoreMessage extends the Message type defined by OpenAI.
* It adds more fields to be stored within the ChatStore structure.
*/
export interface ChatStoreMessage extends Message {
hide: boolean;
token: number;
example: boolean;
audio: Blob | null;
logprobs: Logprobs | null;
response_model_name: string | null;
}

114
src/types/models.ts Normal file
View File

@@ -0,0 +1,114 @@
interface Model {
maxToken: number;
price: {
prompt: number;
completion: number;
};
}
export const models: Record<string, Model> = {
"gpt-4o": {
maxToken: 128000,
price: { prompt: 0.005 / 1000, completion: 0.015 / 1000 },
},
"gpt-4o-2024-11-20": {
maxToken: 128000,
price: { prompt: 0.0025 / 1000, completion: 0.01 / 1000 },
},
"gpt-4o-2024-08-06": {
maxToken: 128000,
price: { prompt: 0.0025 / 1000, completion: 0.01 / 1000 },
},
"gpt-4o-2024-05-13": {
maxToken: 128000,
price: { prompt: 0.005 / 1000, completion: 0.015 / 1000 },
},
"gpt-4o-mini": {
maxToken: 128000,
price: { prompt: 0.15 / 1000 / 1000, completion: 0.6 / 1000 / 1000 },
},
"gpt-4o-mini-2024-07-18": {
maxToken: 128000,
price: { prompt: 0.15 / 1000 / 1000, completion: 0.6 / 1000 / 1000 },
},
"o1-preview": {
maxToken: 128000,
price: { prompt: 15 / 1000 / 1000, completion: 60 / 1000 / 1000 },
},
"o1-preview-2024-09-12": {
maxToken: 128000,
price: { prompt: 15 / 1000 / 1000, completion: 60 / 1000 / 1000 },
},
"o1-mini": {
maxToken: 128000,
price: { prompt: 3 / 1000 / 1000, completion: 12 / 1000 / 1000 },
},
"o1-mini-2024-09-12": {
maxToken: 128000,
price: { prompt: 3 / 1000 / 1000, completion: 12 / 1000 / 1000 },
},
"chatgpt-4o-latest": {
maxToken: 128000,
price: { prompt: 0.005 / 1000, completion: 0.015 / 1000 },
},
"gpt-4-turbo": {
maxToken: 128000,
price: { prompt: 0.01 / 1000, completion: 0.03 / 1000 },
},
"gpt-4-turbo-2024-04-09": {
maxToken: 128000,
price: { prompt: 0.01 / 1000, completion: 0.03 / 1000 },
},
"gpt-4": {
maxToken: 8192,
price: { prompt: 0.03 / 1000, completion: 0.06 / 1000 },
},
"gpt-4-32k": {
maxToken: 8192,
price: { prompt: 0.06 / 1000, completion: 0.12 / 1000 },
},
"gpt-4-0125-preview": {
maxToken: 128000,
price: { prompt: 0.01 / 1000, completion: 0.03 / 1000 },
},
"gpt-4-1106-preview": {
maxToken: 128000,
price: { prompt: 0.01 / 1000, completion: 0.03 / 1000 },
},
"gpt-4-vision-preview": {
maxToken: 128000,
price: { prompt: 0.01 / 1000, completion: 0.03 / 1000 },
},
"gpt-4-1106-vision-preview": {
maxToken: 128000,
price: { prompt: 0.01 / 1000, completion: 0.03 / 1000 },
},
"gpt-3.5-turbo": {
maxToken: 4096,
price: { prompt: 0.0015 / 1000, completion: 0.002 / 1000 },
},
"gpt-3.5-turbo-0125": {
maxToken: 16385,
price: { prompt: 0.0005 / 1000, completion: 0.0015 / 1000 },
},
"gpt-3.5-turbo-instruct": {
maxToken: 16385,
price: { prompt: 0.5 / 1000 / 1000, completion: 2 / 1000 / 1000 },
},
"gpt-3.5-turbo-1106": {
maxToken: 16385,
price: { prompt: 0.001 / 1000, completion: 0.002 / 1000 },
},
"gpt-3.5-turbo-0613": {
maxToken: 16385,
price: { prompt: 1.5 / 1000 / 1000, completion: 2 / 1000 / 1000 },
},
"gpt-3.5-turbo-16k-0613": {
maxToken: 16385,
price: { prompt: 0.003 / 1000, completion: 0.004 / 1000 },
},
"gpt-3.5-turbo-0301": {
maxToken: 16385,
price: { prompt: 1.5 / 1000 / 1000, completion: 2 / 1000 / 1000 },
},
};

93
src/types/newChatstore.ts Normal file
View File

@@ -0,0 +1,93 @@
import {
DefaultAPIEndpoint,
DefaultModel,
CHATGPT_API_WEB_VERSION,
} from "@/const";
import { getDefaultParams } from "@/utils/getDefaultParam";
import { ChatStore } from "@/types/chatstore";
import { models } from "@/types/models";
interface NewChatStoreOptions {
apiKey?: string;
systemMessageContent?: string;
apiEndpoint?: string;
streamMode?: boolean;
model?: string;
temperature?: number;
temperature_enabled?: boolean;
top_p?: number;
top_p_enabled?: boolean;
presence_penalty?: number;
frequency_penalty?: number;
dev?: boolean;
whisper_api?: string;
whisper_key?: string;
tts_api?: string;
tts_key?: string;
tts_voice?: string;
tts_speed?: number;
tts_speed_enabled?: boolean;
tts_format?: string;
toolsString?: string;
image_gen_api?: string;
image_gen_key?: string;
json_mode?: boolean;
logprobs?: boolean;
}
export const newChatStore = (options: NewChatStoreOptions): ChatStore => {
return {
chatgpt_api_web_version: CHATGPT_API_WEB_VERSION,
systemMessageContent: getDefaultParams(
"sys",
options.systemMessageContent ?? ""
),
toolsString: options.toolsString ?? "",
history: [],
postBeginIndex: 0,
tokenMargin: 1024,
totalTokens: 0,
maxTokens: getDefaultParams(
"max",
models[getDefaultParams("model", options.model ?? DefaultModel)]
?.maxToken ?? 2048
),
maxGenTokens: 2048,
maxGenTokens_enabled: false,
apiKey: getDefaultParams("key", options.apiKey ?? ""),
apiEndpoint: getDefaultParams(
"api",
options.apiEndpoint ?? DefaultAPIEndpoint
),
streamMode: getDefaultParams("mode", options.streamMode ?? true),
model: getDefaultParams("model", options.model ?? DefaultModel),
cost: 0,
temperature: getDefaultParams("temp", options.temperature ?? 1),
temperature_enabled: options.temperature_enabled ?? true,
top_p: options.top_p ?? 1,
top_p_enabled: options.top_p_enabled ?? false,
presence_penalty: options.presence_penalty ?? 0,
frequency_penalty: options.frequency_penalty ?? 0,
develop_mode: getDefaultParams("dev", options.dev ?? false),
whisper_api: getDefaultParams(
"whisper-api",
options.whisper_api ?? "https://api.openai.com/v1/audio/transcriptions"
),
whisper_key: getDefaultParams("whisper-key", options.whisper_key ?? ""),
tts_api: getDefaultParams(
"tts-api",
options.tts_api ?? "https://api.openai.com/v1/audio/speech"
),
tts_key: getDefaultParams("tts-key", options.tts_key ?? ""),
tts_voice: options.tts_voice ?? "alloy",
tts_speed: options.tts_speed ?? 1.0,
tts_speed_enabled: options.tts_speed_enabled ?? false,
image_gen_api:
options.image_gen_api ?? "https://api.openai.com/v1/images/generations",
image_gen_key: options.image_gen_key ?? "",
json_mode: options.json_mode ?? false,
tts_format: options.tts_format ?? "mp3",
logprobs: options.logprobs ?? false,
contents_for_index: [],
};
};

View File

@@ -0,0 +1,19 @@
export function getDefaultParams(param: string, val: string): string;
export function getDefaultParams(param: string, val: number): number;
export function getDefaultParams(param: string, val: boolean): boolean;
export function getDefaultParams(param: any, val: any) {
const queryParameters = new URLSearchParams(window.location.search);
const get = queryParameters.get(param);
if (typeof val === "string") {
return get ?? val;
} else if (typeof val === "number") {
return parseFloat(get ?? `${val}`);
} else if (typeof val === "boolean") {
if (get === "stream") return true;
if (get === "fetch") return false;
if (get === "true") return true;
if (get === "false") return false;
return val;
}
}

18
src/utils/totalCost.ts Normal file
View File

@@ -0,0 +1,18 @@
import { STORAGE_NAME_TOTALCOST } from "@/const";
export function addTotalCost(cost: number) {
let totalCost = getTotalCost();
totalCost += cost;
localStorage.setItem(STORAGE_NAME_TOTALCOST, `${totalCost}`);
}
export function getTotalCost(): number {
let totalCost = parseFloat(
localStorage.getItem(STORAGE_NAME_TOTALCOST) ?? "0",
);
return totalCost;
}
export function clearTotalCost() {
localStorage.setItem(STORAGE_NAME_TOTALCOST, `0`);
}

View File

@@ -1,8 +1,42 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
daisyui: {
themes: ["light",
"dark",
"cupcake",
"bumblebee",
"emerald",
"corporate",
"synthwave",
"retro",
"cyberpunk",
"valentine",
"halloween",
"garden",
"forest",
"aqua",
"lofi",
"pastel",
"fantasy",
"wireframe",
"black",
"luxury",
"dracula",
"cmyk",
"autumn",
"business",
"acid",
"lemonade",
"night",
"coffee",
"winter",
"dim",
"nord",
"sunset",],
},
theme: {
extend: {},
},
plugins: [],
plugins: [require('daisyui')],
};

View File

@@ -1,5 +1,9 @@
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@/*": ["*"]
},
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],

View File

@@ -1,8 +1,14 @@
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [preact()],
base: './',
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
}
})

1572
yarn.lock

File diff suppressed because it is too large Load Diff