212 Commits

Author SHA1 Message Date
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
29 changed files with 4501 additions and 834 deletions

View File

@@ -1,10 +1,14 @@
> 前排提示:滥用 API 或在不支持的地区调用 API 有被封号的风险 <https://github.com/zhayujie/chatgpt-on-wechat/issues/423> > 前排提示:滥用 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 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) ![build status](https://github.com/heimoshuiyu/chatgpt-api-web/actions/workflows/pages.yml/badge.svg)
@@ -12,11 +16,19 @@
- API 调用速度更快更稳定 - API 调用速度更快更稳定
- 对话记录、API 密钥等使用浏览器的 localStorage 保存在本地 - 对话记录、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 - 可以为不同对话设置不同 APIKEY
- 小(整个网页 30k 左右) - 小(整个网页 30k 左右)
- 可以设置不同的 API Endpoint方便墙内人士使用反向代理转发 API 请求) - 可以设置不同的 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` 保存网页,然后双击打开 - 从 [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 默认为空 - `key`: OPENAI API KEY 默认为空
- `sys`: system message 默认为 "你是一个猫娘,你要模仿猫娘的语气说话" - `sys`: system message 默认为 "你是一个猫娘,你要模仿猫娘的语气说话"
- `api`: API Endpoint 默认为 `https://api.openai.com/v1/chat/completions` - `api`: API Endpoint 默认为 `https://api.openai.com/v1/chat/completions`
- `mode`: `fetch``stream` 模式stream 模式下可以动态看到 api 返回的数据,但无法得知 token 数量,只能进行估算,在 token 数量过多时可能会裁切过多或过少历史消息 - `mode`: `fetch``stream` 模式stream 模式下可以动态看到 api 返回的数据,但无法得知 token 数量,只能进行估算,在 token 数量过多时可能会裁切过多或过少历史消息
- `dev`: true / false 开发模式,这个模式下可以看到并调整更多参数
- `temp`: 温度,默认 0.7
- `whisper-api`: Whisper 语音转文字服务 API, 只有设置了此值后才会显示语音转文字按钮
- `whisper-key`: 用于 Whisper 服务的 key如果留空则默认使用上方的 OPENAI API KEY
例如 `http://localhost:1234/?key=xxxx&api=xxxx` 那么 **新创建** 的会话将会使用该默认 API 和 API Endpoint 例如 `http://localhost:1234/?key=xxxx&api=xxxx` 那么 **新创建** 的会话将会使用该默认 API 和 API Endpoint

View File

@@ -2,7 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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> <title>ChatGPT API Web</title>
</head> </head>
<body> <body>

View File

@@ -9,15 +9,19 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"autoprefixer": "^10.4.14", "@types/ungap__structured-clone": "^0.3.1",
"postcss": "^8.4.21", "@ungap/structured-clone": "^1.2.0",
"preact": "^10.11.3", "autoprefixer": "^10.4.16",
"sakura.css": "^1.4.1", "idb": "^7.1.1",
"tailwindcss": "^3.2.7" "postcss": "^8.4.31",
"preact": "^10.18.1",
"preact-markdown": "^2.1.0",
"sakura.css": "^1.5.0",
"tailwindcss": "^3.3.4"
}, },
"devDependencies": { "devDependencies": {
"@preact/preset-vite": "^2.5.0", "@preact/preset-vite": "^2.6.0",
"typescript": "^4.9.3", "typescript": "^5.2.2",
"vite": "^4.1.0" "vite": "^4.5.0"
} }
} }

View File

@@ -0,0 +1,3 @@
const CHATGPT_API_WEB_VERSION = "v2.1.0";
export default CHATGPT_API_WEB_VERSION;

323
src/addImage.tsx Normal file
View File

@@ -0,0 +1,323 @@
import { useState } from "preact/hooks";
import { ChatStore } from "./app";
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-white rounded p-2 z-20"
onClick={(event) => {
event.stopPropagation();
}}
>
<h2>Add Images</h2>
<span>
<button
className="disabled:line-through disabled:bg-slate-500 rounded m-1 p-1 border-2 bg-cyan-400 hover:bg-cyan-600"
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="disabled:line-through disabled:bg-slate-500 rounded m-1 p-1 border-2 bg-cyan-400 hover:bg-cyan-600"
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>
{chatStore.image_gen_api && chatStore.image_gen_key && (
<div className="flex flex-col">
<hr className="my-2" />
<h3>Generate Image</h3>
<span className="flex flex-row justify-between m-1 p-1">
<label>Prompt: </label>
<textarea
className="border rounded border-gray-400"
value={imageGenPrompt}
onChange={(e: any) => {
setImageGenPrompt(e.target.value);
}}
/>
</span>
<span className="flex flex-row justify-between m-1 p-1">
<label>Model: </label>
<select
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 m-1 p-1">
<label>n: </label>
<input
value={imageGenN}
type="number"
min={1}
max={10}
onChange={(e: any) => setImageGenN(parseInt(e.target.value))}
/>
</span>
<span className="flex flex-row justify-between m-1 p-1">
<label>Quality: </label>
<select
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 m-1 p-1">
<label>Response Format: </label>
<select
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 m-1 p-1">
<label>Size: </label>
<select
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 m-1 p-1">
<label>Style (only dall-e-3): </label>
<select
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 m-1 p-1">
<button
className="bg-sky-400 m-1 p-1 rounded disabled:bg-slate-500"
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,
});
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,124 +1,340 @@
import { IDBPDatabase, openDB } from "idb";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import "./global.css"; import "./global.css";
import { Message } from "./chatgpt"; import { calculate_token_length, Message } from "./chatgpt";
import getDefaultParams from "./getDefaultParam"; import getDefaultParams from "./getDefaultParam";
import ChatBOX from "./chatbox"; import ChatBOX from "./chatbox";
import models from "./models";
import { Tr, langCodeContext, LANG_OPTIONS } from "./translate";
import CHATGPT_API_WEB_VERSION from "./CHATGPT_API_WEB_VERSION";
export interface ChatStoreMessage extends Message {
hide: boolean;
token: number;
example: boolean;
audio: Blob | null;
}
export interface TemplateAPI {
name: string;
key: string;
endpoint: string;
}
export interface TemplateTools {
name: string;
toolsString: string;
}
export interface ChatStore { export interface ChatStore {
chatgpt_api_web_version: string;
systemMessageContent: string; systemMessageContent: string;
history: Message[]; toolsString: string;
history: ChatStoreMessage[];
postBeginIndex: number; postBeginIndex: number;
tokenMargin: number; tokenMargin: number;
totalTokens: number; totalTokens: number;
maxTokens: number; maxTokens: number;
maxGenTokens: number;
maxGenTokens_enabled: boolean;
apiKey: string; apiKey: string;
apiEndpoint: string; apiEndpoint: string;
streamMode: boolean; streamMode: boolean;
model: string;
responseModelName: 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;
} }
const _defaultAPIEndpoint = "https://api.openai.com/v1/chat/completions"; const _defaultAPIEndpoint = "https://api.openai.com/v1/chat/completions";
const newChatStore = ( export const newChatStore = (
apiKey = "", apiKey = "",
systemMessageContent = "你是一个有用的人工智能助理", systemMessageContent = "",
apiEndpoint = _defaultAPIEndpoint, apiEndpoint = _defaultAPIEndpoint,
streamMode = true streamMode = true,
model = "gpt-3.5-turbo-1106",
temperature = 0.7,
dev = false,
whisper_api = "https://api.openai.com/v1/audio/transcriptions",
whisper_key = "",
tts_api = "https://api.openai.com/v1/audio/speech",
tts_key = "",
tts_speed = 1.0,
tts_speed_enabled = false,
tts_format = "mp3",
toolsString = "",
image_gen_api = "https://api.openai.com/v1/images/generations",
image_gen_key = "",
json_mode = false
): ChatStore => { ): ChatStore => {
return { return {
chatgpt_api_web_version: CHATGPT_API_WEB_VERSION,
systemMessageContent: getDefaultParams("sys", systemMessageContent), systemMessageContent: getDefaultParams("sys", systemMessageContent),
toolsString,
history: [], history: [],
postBeginIndex: 0, postBeginIndex: 0,
tokenMargin: 1024, tokenMargin: 1024,
totalTokens: 0, totalTokens: 0,
maxTokens: 4096, maxTokens: getDefaultParams("max", models[getDefaultParams("model", model)]?.maxToken ?? 2048),
maxGenTokens: 2048,
maxGenTokens_enabled: true,
apiKey: getDefaultParams("key", apiKey), apiKey: getDefaultParams("key", apiKey),
apiEndpoint: getDefaultParams("api", apiEndpoint), apiEndpoint: getDefaultParams("api", apiEndpoint),
streamMode: getDefaultParams("mode", streamMode), streamMode: getDefaultParams("mode", streamMode),
model: getDefaultParams("model", model),
responseModelName: "",
cost: 0,
temperature: getDefaultParams("temp", temperature),
temperature_enabled: true,
top_p: 1,
top_p_enabled: false,
presence_penalty: 0,
frequency_penalty: 0,
develop_mode: getDefaultParams("dev", dev),
whisper_api: getDefaultParams("whisper-api", whisper_api),
whisper_key: getDefaultParams("whisper-key", whisper_key),
tts_api: getDefaultParams("tts-api", tts_api),
tts_key: getDefaultParams("tts-key", tts_key),
tts_voice: "alloy",
tts_speed: tts_speed,
tts_speed_enabled: tts_speed_enabled,
image_gen_api: image_gen_api,
image_gen_key: image_gen_key,
json_mode: json_mode,
tts_format: tts_format,
}; };
}; };
const STORAGE_NAME = "chatgpt-api-web"; export const STORAGE_NAME = "chatgpt-api-web";
const STORAGE_NAME_SELECTED = `${STORAGE_NAME}-selected`; const STORAGE_NAME_SELECTED = `${STORAGE_NAME}-selected`;
const STORAGE_NAME_INDEXES = `${STORAGE_NAME}-indexes`; const STORAGE_NAME_INDEXES = `${STORAGE_NAME}-indexes`;
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`;
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`);
}
export function App() { 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 // init selected index
const [selectedChatIndex, setSelectedChatIndex] = useState( const [selectedChatIndex, setSelectedChatIndex] = useState(
parseInt(localStorage.getItem(STORAGE_NAME_SELECTED) ?? "0") parseInt(localStorage.getItem(STORAGE_NAME_SELECTED) ?? "1")
); );
console.log("selectedChatIndex", selectedChatIndex);
useEffect(() => { useEffect(() => {
console.log("set selected chat index", selectedChatIndex); console.log("set selected chat index", selectedChatIndex);
localStorage.setItem(STORAGE_NAME_SELECTED, `${selectedChatIndex}`); localStorage.setItem(STORAGE_NAME_SELECTED, `${selectedChatIndex}`);
}, [selectedChatIndex]); }, [selectedChatIndex]);
const getChatStoreByIndex = (index: number): ChatStore => { const db = openDB<ChatStore>(STORAGE_NAME, 1, {
const key = `${STORAGE_NAME}-${index}`; upgrade(db) {
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); const val = localStorage.getItem(key);
if (val === null) return newChatStore(); if (val === null) continue;
return JSON.parse(val) as ChatStore; store.add(JSON.parse(val));
keyCount += 1;
}
setSelectedChatIndex(keyCount);
if (keyCount > 0) {
alert(
"v2.0.0 Update: Imported chat history from localStorage to indexedDB. 🎉"
);
}
},
});
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 = "gpt-3.5-turbo";
if (ret.responseModelName === undefined) ret.responseModelName = "";
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( const [chatStore, _setChatStore] = useState(newChatStore());
getChatStoreByIndex(selectedChatIndex) 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
); );
const setChatStore = (cs: ChatStore) => { for (const msg of chatStore.history
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex)) {
chatStore.totalTokens += msg.token;
}
console.log("saved chat", selectedChatIndex, chatStore); console.log("saved chat", selectedChatIndex, chatStore);
localStorage.setItem( (await db).put(STORAGE_NAME, chatStore, selectedChatIndex);
`${STORAGE_NAME}-${selectedChatIndex}`,
JSON.stringify(cs) _setChatStore(chatStore);
);
_setChatStore(cs);
}; };
useEffect(() => { useEffect(() => {
_setChatStore(getChatStoreByIndex(selectedChatIndex)); const run = async () => {
_setChatStore(await getChatStoreByIndex(selectedChatIndex));
};
run();
}, [selectedChatIndex]); }, [selectedChatIndex]);
return ( // all chat store indexes
<div className="flex text-sm h-screen bg-slate-200 dark:bg-slate-800 dark:text-white"> const [allChatStoreIndexes, setAllChatStoreIndexes] = useState<IDBValidKey>(
<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" const handleNewChatStore = async () => {
onClick={() => { const newKey = await (
const max = Math.max(...allChatStoreIndexes); await db
const next = max + 1; ).add(
console.log("save next chat", next); STORAGE_NAME,
localStorage.setItem(
`${STORAGE_NAME}-${next}`,
JSON.stringify(
newChatStore( newChatStore(
chatStore.apiKey, chatStore.apiKey,
chatStore.systemMessageContent, chatStore.systemMessageContent,
chatStore.apiEndpoint, chatStore.apiEndpoint,
chatStore.streamMode chatStore.streamMode,
) chatStore.model,
chatStore.temperature,
!!chatStore.develop_mode,
chatStore.whisper_api,
chatStore.whisper_key,
chatStore.tts_api,
chatStore.tts_key,
chatStore.tts_speed,
chatStore.tts_speed_enabled,
chatStore.tts_format,
chatStore.toolsString,
chatStore.image_gen_api,
chatStore.image_gen_key,
chatStore.json_mode
) )
); );
allChatStoreIndexes.push(next); setSelectedChatIndex(newKey as number);
setAllChatStoreIndexes([...allChatStoreIndexes]); setAllChatStoreIndexes(await (await db).getAllKeys(STORAGE_NAME));
setSelectedChatIndex(next); };
}}
// 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')
handleNewChatStore();
}
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 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={handleNewChatStore}
> >
NEW {Tr("NEW")}
</button> </button>
<ul> <ul>
{allChatStoreIndexes {(allChatStoreIndexes as number[])
.slice() .slice()
.reverse() .reverse()
.map((i) => { .map((i) => {
@@ -126,8 +342,7 @@ export function App() {
return ( return (
<li> <li>
<button <button
className={`w-full my-1 p-1 rounded hover:bg-blue-300 ${ className={`w-full my-1 p-1 rounded hover:bg-blue-500 ${i === selectedChatIndex ? "bg-blue-500" : "bg-blue-200"
i === selectedChatIndex ? "bg-blue-500" : "bg-blue-200"
}`} }`}
onClick={() => { onClick={() => {
setSelectedChatIndex(i); setSelectedChatIndex(i);
@@ -142,40 +357,57 @@ export function App() {
</div> </div>
<button <button
className="rounded bg-rose-400 p-1 my-1 w-full" className="rounded bg-rose-400 p-1 my-1 w-full"
onClick={() => { onClick={async () => {
if (!confirm("Are you sure you want to delete this chat history?")) if (!confirm("Are you sure you want to delete this chat history?"))
return; return;
console.log("remove item", `${STORAGE_NAME}-${selectedChatIndex}`); console.log("remove item", `${STORAGE_NAME}-${selectedChatIndex}`);
localStorage.removeItem(`${STORAGE_NAME}-${selectedChatIndex}`); (await db).delete(STORAGE_NAME, selectedChatIndex);
const newAllChatStoreIndexes = [ const newAllChatStoreIndexes = await (
...allChatStoreIndexes.filter((v) => v !== selectedChatIndex), await db
]; ).getAllKeys(STORAGE_NAME);
if (newAllChatStoreIndexes.length === 0) { if (newAllChatStoreIndexes.length === 0) {
newAllChatStoreIndexes.push(0); handleNewChatStore();
setChatStore( return;
newChatStore(
chatStore.apiKey,
chatStore.systemMessageContent,
chatStore.apiEndpoint,
chatStore.streamMode
)
);
} }
// find nex selected chat index // find nex selected chat index
const next = const next =
newAllChatStoreIndexes[newAllChatStoreIndexes.length - 1]; newAllChatStoreIndexes[newAllChatStoreIndexes.length - 1];
console.log("next is", next); console.log("next is", next);
setSelectedChatIndex(next); setSelectedChatIndex(next as number);
setAllChatStoreIndexes(newAllChatStoreIndexes);
setAllChatStoreIndexes([...newAllChatStoreIndexes]);
}} }}
> >
DEL {Tr("DEL")}
</button> </button>
{chatStore.develop_mode && (
<button
className="rounded bg-rose-800 p-1 my-1 w-full text-white"
onClick={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();
}}
>
{Tr("CLS")}
</button>
)}
</div> </div>
<ChatBOX chatStore={chatStore} setChatStore={setChatStore} /> <ChatBOX
chatStore={chatStore}
setChatStore={setChatStore}
selectedChatIndex={selectedChatIndex}
setSelectedChatIndex={setSelectedChatIndex}
/>
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,77 @@
export interface Message { export interface ImageURL {
role: "system" | "user" | "assistant"; url: string;
content: 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;
}
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 { export interface ChunkMessage {
model: string;
choices: { choices: {
delta: { role: "assitant" | undefined; content: string | undefined }; delta: { role: "assitant" | undefined; content: string | undefined };
}[]; }[];
} }
export interface FetchResponse { export interface FetchResponse {
error?: any;
id: string; id: string;
object: string; object: string;
created: number; created: number;
@@ -26,66 +88,221 @@ export interface FetchResponse {
}[]; }[];
} }
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 { class Chat {
OPENAI_API_KEY: string; OPENAI_API_KEY: string;
messages: Message[]; messages: Message[];
sysMessageContent: string; sysMessageContent: string;
toolsString: string;
total_tokens: number; total_tokens: number;
max_tokens: number; max_tokens: number;
max_gen_tokens: number;
enable_max_gen_tokens: boolean;
tokens_margin: number; tokens_margin: number;
apiEndpoint: string; 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( constructor(
OPENAI_API_KEY: string | undefined, OPENAI_API_KEY: string | undefined,
{ {
systemMessage = "你是一个有用的人工智能助理", systemMessage = "",
toolsString = "",
max_tokens = 4096, max_tokens = 4096,
max_gen_tokens = 2048,
enable_max_gen_tokens = true,
tokens_margin = 1024, tokens_margin = 1024,
apiEndPoint = "https://api.openai.com/v1/chat/completions", apiEndPoint = "https://api.openai.com/v1/chat/completions",
model = "gpt-3.5-turbo",
temperature = 0.7,
enable_temperature = true,
top_p = 1,
enable_top_p = false,
presence_penalty = 0,
frequency_penalty = 0,
json_mode = false,
} = {} } = {}
) { ) {
if (OPENAI_API_KEY === undefined) { this.OPENAI_API_KEY = OPENAI_API_KEY ?? "";
throw "OPENAI_API_KEY is undefined";
}
this.OPENAI_API_KEY = OPENAI_API_KEY;
this.messages = []; this.messages = [];
this.total_tokens = 0; this.total_tokens = calculate_token_length(systemMessage);
this.max_tokens = max_tokens; 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.tokens_margin = tokens_margin;
this.sysMessageContent = systemMessage; this.sysMessageContent = systemMessage;
this.toolsString = toolsString;
this.apiEndpoint = apiEndPoint; 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) {
// 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",
};
}
// 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, { return fetch(this.apiEndpoint, {
method: "POST", method: "POST",
headers: { headers,
Authorization: `Bearer ${this.OPENAI_API_KEY}`, body: JSON.stringify(body),
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-3.5-turbo",
messages: [
{ role: "system", content: this.sysMessageContent },
...this.messages,
],
stream,
}),
}); });
} }
async fetch(): Promise<FetchResponse> { async fetch(): Promise<FetchResponse> {
const resp = await this._fetch(); const resp = await this._fetch();
return await resp.json(); const j = await resp.json();
if (j.error !== undefined) {
throw JSON.stringify(j.error);
}
return j;
} }
async say(content: string): Promise<string> { async *processStreamResponse(resp: Response) {
this.messages.push({ role: "user", content }); const reader = resp?.body?.pipeThrough(new TextDecoderStream()).getReader();
await this.complete(); if (reader === undefined) {
return this.messages.slice(-1)[0].content; 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;
}
}
}
} }
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; this.total_tokens = resp?.usage?.total_tokens ?? 0;
if (resp?.choices[0]?.message) { if (resp?.choices[0]?.message) {
this.messages.push(resp?.choices[0]?.message); this.messages.push(resp?.choices[0]?.message);
@@ -97,33 +314,26 @@ class Chat {
this.forgetSomeMessages(); this.forgetSomeMessages();
} }
return ( let content = resp.choices[0].message?.content ?? "";
resp?.choices[0]?.message?.content ?? `Error: ${JSON.stringify(resp)}` if (
); !resp.choices[0]?.message?.content &&
!resp.choices[0]?.message?.tool_calls
) {
content = `Unparsed response: ${JSON.stringify(resp)}`;
} }
async complete(): Promise<string> { return {
const resp = await this.fetch(); role: "assistant",
return this.processFetchResponse(resp); content,
tool_calls: resp?.choices[0]?.message?.tool_calls,
};
} }
completeWithSteam() { calculate_token_length(content: string | MessageDetail[]): number {
this.total_tokens = this.messages return calculate_token_length(content);
.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 user(...messages: (string | MessageDetail[])[]) {
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[]) {
for (const msg of messages) { for (const msg of messages) {
this.messages.push({ role: "user", content: msg }); this.messages.push({ role: "user", content: msg });
this.total_tokens += this.calculate_token_length(msg); this.total_tokens += this.calculate_token_length(msg);
@@ -131,7 +341,7 @@ class Chat {
} }
} }
assistant(...messages: string[]) { assistant(...messages: (string | MessageDetail[])[]) {
for (const msg of messages) { for (const msg of messages) {
this.messages.push({ role: "assistant", content: msg }); this.messages.push({ role: "assistant", content: msg });
this.total_tokens += this.calculate_token_length(msg); this.total_tokens += this.calculate_token_length(msg);

70
src/editMessage.tsx Normal file
View File

@@ -0,0 +1,70 @@
import { Tr, langCodeContext, LANG_OPTIONS, tr } from "./translate";
import { useState, useEffect, StateUpdater } from "preact/hooks";
import { ChatStore, ChatStoreMessage } from "./app";
import { calculate_token_length, getMessageText } from "./chatgpt";
import { isVailedJSON } from "./message";
import { EditMessageString } from "./editMessageString";
import { EditMessageDetail } from "./editMessageDetail";
interface EditMessageProps {
chat: ChatStoreMessage;
chatStore: ChatStore;
setShowEdit: 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={() => {
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 "./app";
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-between">
{mdt.type === "text" ? (
<textarea
className={"w-full"}
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>
) : (
<>
<img
className="max-h-32 max-w-xs cursor-pointer"
src={mdt.image_url?.url}
onClick={() => {
window.open(mdt.image_url?.url, "_blank");
}}
/>
<button
className="bg-blue-300 p-1 rounded"
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"
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"
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>
</>
)}
<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 "./app";
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

@@ -7,10 +7,12 @@ function getDefaultParams(param: any, val: any) {
if (typeof val === "string") { if (typeof val === "string") {
return get ?? val; return get ?? val;
} else if (typeof val === "number") { } else if (typeof val === "number") {
return parseInt(get ?? `${val}`); return parseFloat(get ?? `${val}`);
} else if (typeof val === "boolean") { } else if (typeof val === "boolean") {
if (get === "stream") return true; if (get === "stream") return true;
if (get === "fetch") return false; if (get === "fetch") return false;
if (get === "true") return true;
if (get === "false") return false;
return val; return val;
} }
} }

View File

@@ -2,6 +2,12 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
html,
body,
#app {
height: 100%;
}
/* Hide scrollbar for webkit based browsers */ /* Hide scrollbar for webkit based browsers */
::-webkit-scrollbar { ::-webkit-scrollbar {
display: none; display: none;
@@ -22,6 +28,113 @@ body::-webkit-scrollbar {
display: none; display: none;
} }
p.message-content { .message-content {
white-space: pre-wrap; white-space: pre-wrap;
} }
.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;
}

81
src/listAPIs.tsx Normal file
View File

@@ -0,0 +1,81 @@
import { ChatStore, TemplateAPI } from "./app";
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 bg-white my-3 text-left dark:text-black">
<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-red-600"
: "bg-red-400"
} 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>
<hr className="mt-2" />
<span className="flex justify-between">
<button
onClick={() => {
const name = prompt(`Give **${label}** template a name`);
if (!name) {
return;
}
t.name = name;
setTmps(structuredClone(tmps));
}}
>
🖋
</button>
<button
onClick={() => {
if (
!confirm(
`Are you sure to delete this **${label}** template?`
)
) {
return;
}
tmps.splice(index, 1);
setTmps(structuredClone(tmps));
}}
>
</button>
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { ChatStore, TemplateTools } from "./app";
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-red-600"
: "bg-red-400"
} 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>
<hr className="mt-2" />
<span className="flex justify-between">
<button
onClick={() => {
const name = prompt(`Give **tools** template a name`);
if (!name) {
return;
}
t.name = name;
setTemplateTools(structuredClone(templateTools));
}}
>
🖋
</button>
<button
onClick={() => {
if (
!confirm(`Are you sure to delete this **tools** template?`)
) {
return;
}
templateTools.splice(index, 1);
setTemplateTools(structuredClone(templateTools));
}}
>
</button>
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,4 +1,52 @@
import { render } from 'preact' import { render } from "preact";
import { App } from './app' import { App } from "./app";
import { useState, useEffect } from "preact/hooks";
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(() => {
// 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,209 @@
import { ChatStore } from "./app"; import { Tr, langCodeContext, LANG_OPTIONS } from "./translate";
import { useState, useEffect, StateUpdater } from "preact/hooks";
import { ChatStore, ChatStoreMessage } from "./app";
import { calculate_token_length, getMessageText } from "./chatgpt";
import Markdown from "preact-markdown";
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";
export const isVailedJSON = (str: string): boolean => {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
};
interface Props { interface Props {
messageIndex: number; messageIndex: number;
chatStore: ChatStore; chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void; setChatStore: (cs: ChatStore) => void;
update_total_tokens: () => void;
} }
export default function Message(props: Props) { export default function Message(props: Props) {
const { chatStore, messageIndex, setChatStore } = props; const { chatStore, messageIndex, setChatStore } = props;
const chat = chatStore.history[messageIndex]; const chat = chatStore.history[messageIndex];
const [showEdit, setShowEdit] = useState(false);
const [showCopiedHint, setShowCopiedHint] = useState(false);
const [renderMarkdown, setRenderWorkdown] = useState(false);
const DeleteIcon = () => ( const DeleteIcon = () => (
<button <button
className={`absolute bottom-0 ${
chat.role === "user" ? "left-0" : "right-0"
}`}
onClick={() => { onClick={() => {
if ( chatStore.history[messageIndex].hide =
confirm( !chatStore.history[messageIndex].hide;
`Are you sure to delete this message?\n${chat.content.slice(
0, //chatStore.totalTokens =
39 chatStore.totalTokens = 0;
)}...` for (const i of chatStore.history
) .filter(({ hide }) => !hide)
) { .slice(chatStore.postBeginIndex)
chatStore.history.splice(messageIndex, 1); .map(({ token }) => token)) {
chatStore.postBeginIndex = Math.max(chatStore.postBeginIndex - 1, 0); chatStore.totalTokens += i;
setChatStore({ ...chatStore });
} }
setChatStore({ ...chatStore });
}} }}
> >
🗑 🗑
</button> </button>
); );
const CopiedHint = () => (
<span
className={
"bg-purple-400 p-1 rounded shadow-md absolute z-20 left-1/2 top-3/4 transform -translate-x-1/2 -translate-y-1/2"
}
>
{Tr("Message copied to clipboard!")}
</span>
);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setShowCopiedHint(true);
setTimeout(() => setShowCopiedHint(false), 1000);
};
const CopyIcon = ({ textToCopy }: { textToCopy: string }) => {
return ( return (
<>
<button
onClick={() => {
copyToClipboard(textToCopy);
}}
>
📋
</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 bg-slate-800 dark:bg-white" />
<span className="absolute px-3 bg-slate-800 text-white rounded p-1 dark:bg-white dark:text-black">
Above messages are "forgotten"
</span>
</div>
)}
<div <div
className={`flex ${ className={`flex ${
chat.role === "assistant" ? "justify-start" : "justify-end" chat.role === "assistant" ? "justify-start" : "justify-end"
}`} }`}
> >
<div>
<div <div
className={`relative w-fit p-2 rounded my-2 ${ className={`w-fit p-2 rounded my-2 ${
chat.role === "assistant" chat.role === "assistant"
? "bg-white dark:bg-gray-700 dark:text-white" ? "bg-white dark:bg-gray-700 dark:text-white"
: "bg-green-400" : "bg-green-400"
}`} } ${chat.hide ? "opacity-50" : ""}`}
> >
<p className="message-content">{chat.content}</p> {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 && getMessageText(chat)
}
</div>
)}
<hr className="mt-2" />
<TTSPlay chat={chat} />
<div className="w-full flex justify-between">
<DeleteIcon /> <DeleteIcon />
<button onClick={() => setShowEdit(true)}>🖋</button>
{chatStore.tts_api && chatStore.tts_key && (
<TTSButton
chatStore={chatStore}
chat={chat}
setChatStore={setChatStore}
/>
)}
<CopyIcon textToCopy={getMessageText(chat)} />
</div> </div>
</div> </div>
{showEdit && (
<EditMessage
setShowEdit={setShowEdit}
chat={chat}
chatStore={chatStore}
setChatStore={setChatStore}
/>
)}
{showCopiedHint && <CopiedHint />}
{chatStore.develop_mode && (
<div>
<span className="dark:text-white">token</span>
<input
value={chat.token}
className="w-20"
onChange={(event: any) => {
chat.token = parseInt(event.target.value);
props.update_total_tokens();
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 });
}}
>
</button>
<span
onClick={(event: any) => {
chat.example = !chat.example;
setChatStore({ ...chatStore });
}}
>
<label className="dark:text-white">{Tr("example")}</label>
<input type="checkbox" checked={chat.example} />
</span>
<span
onClick={(event: any) => setRenderWorkdown(!renderMarkdown)}
>
<label className="dark:text-white">{Tr("render")}</label>
<input type="checkbox" checked={renderMarkdown} />
</span>
</div>
)}
</div>
</div>
</>
); );
} }

35
src/messageDetail.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { ChatStoreMessage } from "./app";
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) + "... (deleted)"
) : renderMarkdown ? (
// @ts-ignore
<Markdown markdown={mdt.text} />
) : (
mdt.text
)
) : (
<img
className="cursor-pointer max-w-xs max-h-32 p-1"
src={mdt.image_url?.url}
onClick={() => {
window.open(mdt.image_url?.url, "_blank");
}}
/>
)
)}
</div>
);
}

12
src/messageHide.tsx Normal file
View File

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

45
src/messageToolCall.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { ChatStoreMessage } from "./app";
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 "./app";
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>
);
}

72
src/models.ts Normal file
View File

@@ -0,0 +1,72 @@
interface Model {
maxToken: number;
price: {
prompt: number;
completion: number;
};
}
const models: Record<string, Model> = {
"gpt-3.5-turbo-1106": {
maxToken: 16385,
price: { prompt: 0.001 / 1000, completion: 0.002 / 1000 },
},
"gpt-3.5-turbo": {
maxToken: 4096,
price: { prompt: 0.0015 / 1000, completion: 0.002 / 1000 },
},
"gpt-3.5-turbo-16k": {
maxToken: 16385,
price: { prompt: 0.003 / 1000, completion: 0.004 / 1000 },
},
"gpt-3.5-turbo-0613": {
maxToken: 4096,
price: { prompt: 0.0015 / 1000, completion: 0.002 / 1000 },
},
"gpt-3.5-turbo-16k-0613": {
maxToken: 16385,
price: { prompt: 0.003 / 1000, completion: 0.004 / 1000 },
},
"gpt-3.5-turbo-0301": {
maxToken: 4096,
price: { prompt: 0.0015 / 1000, completion: 0.002 / 1000 },
},
"gpt-4-0125-preview": {
maxToken: 128000,
price: { prompt: 0.01 / 1000, completion: 0.03 / 1000 },
},
"gpt-4-turbo-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-4": {
maxToken: 8192,
price: { prompt: 0.03 / 1000, completion: 0.06 / 1000 },
},
"gpt-4-0613": {
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-32k-0613": {
maxToken: 8192,
price: { prompt: 0.06 / 1000, completion: 0.12 / 1000 },
},
};
export default models;

39
src/setAPIsTemplate.tsx Normal file
View File

@@ -0,0 +1,39 @@
import { TemplateAPI } from "./app";
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="p-1 m-1 rounded bg-blue-300"
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>
);
}

View File

@@ -1,5 +1,30 @@
import { StateUpdater } from "preact/hooks"; import { createRef } from "preact";
import { ChatStore } from "./app"; import { StateUpdater, useContext, useEffect, useState } from "preact/hooks";
import {
ChatStore,
TemplateAPI,
TemplateTools,
clearTotalCost,
getTotalCost,
} from "./app";
import models from "./models";
import { TemplateChatStore } from "./chatbox";
import { tr, Tr, langCodeContext, LANG_OPTIONS } from "./translate";
import p from "preact-markdown";
import { isVailedJSON } from "./message";
import { SetAPIsTemplate } from "./setAPIsTemplate";
import { autoHeight } from "./textarea";
import getDefaultParams from "./getDefaultParam";
const TTS_VOICES: string[] = [
"alloy",
"echo",
"fable",
"onyx",
"nova",
"shimmer",
];
const TTS_FORMAT: string[] = ["mp3", "opus", "aac", "flac"];
const Help = (props: { children: any; help: string }) => { const Help = (props: { children: any; help: string }) => {
return ( return (
@@ -17,16 +42,107 @@ const Help = (props: { children: any; help: string }) => {
); );
}; };
const Input = (props: { const SelectModel = (props: {
chatStore: ChatStore; chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void; setChatStore: (cs: ChatStore) => void;
field: "apiKey" | "systemMessageContent" | "apiEndpoint"; help: string;
}) => {
let shouldIUseCustomModel: boolean = true
for (const model in models) {
if (props.chatStore.model === model) {
shouldIUseCustomModel = false
}
}
const [useCustomModel, setUseCustomModel] = useState(shouldIUseCustomModel);
return (
<Help help={props.help}>
<label className="m-2 p-2">Model</label>
<span onClick={() => {
setUseCustomModel(!useCustomModel);
}} className="m-2 p-2">
<label>{Tr("Custom")}</label>
<input className="" type="checkbox" checked={useCustomModel} />
</span>
{
useCustomModel ?
<input
className="m-2 p-2 border rounded focus w-32 md:w-fit"
value={props.chatStore.model} onChange={(event: any) => {
const model = event.target.value as string;
props.chatStore.model = model;
props.setChatStore({ ...props.chatStore });
}} /> : <select
className="m-2 p-2"
value={props.chatStore.model}
onChange={(event: any) => {
const model = event.target.value as string;
props.chatStore.model = model;
props.chatStore.maxTokens = getDefaultParams('max', models[model].maxToken);
props.setChatStore({ ...props.chatStore });
}}
>
{Object.keys(models).map((opt) => (
<option value={opt}>{opt}</option>
))}
</select>
}
</Help>
);
};
const LongInput = (props: {
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
field: "systemMessageContent" | "toolsString";
help: string; help: string;
}) => { }) => {
return (
<Help help={props.help}>
<textarea
className="m-2 p-2 border rounded focus w-full"
value={props.chatStore[props.field]}
onChange={(event: any) => {
props.chatStore[props.field] = event.target.value;
props.setChatStore({ ...props.chatStore });
autoHeight(event.target);
}}
onKeyPress={(event: any) => {
autoHeight(event.target);
}}
></textarea>
</Help>
);
};
const Input = (props: {
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
field:
| "apiKey"
| "apiEndpoint"
| "whisper_api"
| "whisper_key"
| "tts_api"
| "tts_key"
| "image_gen_api"
| "image_gen_key";
help: string;
}) => {
const [hideInput, setHideInput] = useState(true);
return ( return (
<Help help={props.help}> <Help help={props.help}>
<label className="m-2 p-2">{props.field}</label> <label className="m-2 p-2">{props.field}</label>
<button
className="p-2"
onClick={() => {
setHideInput(!hideInput);
console.log("clicked", hideInput);
}}
>
{hideInput ? "👀" : "🙈"}
</button>
<input <input
type={hideInput ? "password" : "text"}
className="m-2 p-2 border rounded focus w-32 md:w-fit" className="m-2 p-2 border rounded focus w-32 md:w-fit"
value={props.chatStore[props.field]} value={props.chatStore[props.field]}
onChange={(event: any) => { onChange={(event: any) => {
@@ -37,24 +153,117 @@ const Input = (props: {
</Help> </Help>
); );
}; };
const Slicer = (props: {
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
field: "temperature" | "top_p" | "tts_speed";
help: string;
min: number;
max: number;
}) => {
const enable_filed_name: "temperature_enabled" | "top_p_enabled" =
`${props.field}_enabled` as any;
const enabled = props.chatStore[enable_filed_name];
if (enabled === null || enabled === undefined) {
if (props.field === "temperature") {
props.chatStore[enable_filed_name] = true;
}
if (props.field === "top_p") {
props.chatStore[enable_filed_name] = false;
}
}
const setEnabled = (state: boolean) => {
props.chatStore[enable_filed_name] = state;
props.setChatStore({ ...props.chatStore });
};
return (
<Help help={props.help}>
<span>
<label className="m-2 p-2">{props.field}</label>
<input
type="checkbox"
checked={props.chatStore[enable_filed_name]}
onClick={() => {
setEnabled(!enabled);
}}
/>
</span>
<input
disabled={!enabled}
className="m-2 p-2 border rounded focus w-16"
type="range"
min={props.min}
max={props.max}
step="0.01"
value={props.chatStore[props.field]}
onChange={(event: any) => {
const value = parseFloat(event.target.value);
props.chatStore[props.field] = value;
props.setChatStore({ ...props.chatStore });
}}
/>
<input
disabled={!enabled}
className="m-2 p-2 border rounded focus w-28"
type="number"
value={props.chatStore[props.field]}
onChange={(event: any) => {
const value = parseFloat(event.target.value);
props.chatStore[props.field] = value;
props.setChatStore({ ...props.chatStore });
}}
/>
</Help>
);
};
const Number = (props: { const Number = (props: {
chatStore: ChatStore; chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void; setChatStore: (cs: ChatStore) => void;
field: "totalTokens" | "maxTokens" | "tokenMargin" | "postBeginIndex"; field:
| "totalTokens"
| "maxTokens"
| "maxGenTokens"
| "tokenMargin"
| "postBeginIndex"
| "presence_penalty"
| "frequency_penalty";
readOnly: boolean; readOnly: boolean;
help: string; help: string;
}) => { }) => {
return ( return (
<Help help={props.help}> <Help help={props.help}>
<span>
<label className="m-2 p-2">{props.field}</label> <label className="m-2 p-2">{props.field}</label>
{props.field === "maxGenTokens" && (
<input
type="checkbox"
checked={props.chatStore.maxGenTokens_enabled}
onChange={() => {
const newChatStore = { ...props.chatStore };
newChatStore.maxGenTokens_enabled =
!newChatStore.maxGenTokens_enabled;
props.setChatStore({ ...newChatStore });
}}
/>
)}
</span>
<input <input
readOnly={props.readOnly} readOnly={props.readOnly}
disabled={
props.field === "maxGenTokens" &&
!props.chatStore.maxGenTokens_enabled
}
type="number" type="number"
className="m-2 p-2 border rounded focus w-28" className="m-2 p-2 border rounded focus w-28"
value={props.chatStore[props.field]} value={props.chatStore[props.field]}
onChange={(event: any) => { onChange={(event: any) => {
console.log("type", typeof event.target.value); console.log("type", typeof event.target.value);
let newNumber = parseInt(event.target.value); let newNumber = parseFloat(event.target.value);
if (newNumber < 0) newNumber = 0; if (newNumber < 0) newNumber = 0;
props.chatStore[props.field] = newNumber; props.chatStore[props.field] = newNumber;
props.setChatStore({ ...props.chatStore }); props.setChatStore({ ...props.chatStore });
@@ -66,7 +275,7 @@ const Number = (props: {
const Choice = (props: { const Choice = (props: {
chatStore: ChatStore; chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void; setChatStore: (cs: ChatStore) => void;
field: "streamMode"; field: "streamMode" | "develop_mode" | "json_mode";
help: string; help: string;
}) => { }) => {
return ( return (
@@ -88,31 +297,149 @@ const Choice = (props: {
export default (props: { export default (props: {
chatStore: ChatStore; chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void; setChatStore: (cs: ChatStore) => void;
show: boolean;
setShow: StateUpdater<boolean>; setShow: StateUpdater<boolean>;
selectedChatStoreIndex: number;
templates: TemplateChatStore[];
setTemplates: (templates: TemplateChatStore[]) => void;
templateAPIs: TemplateAPI[];
setTemplateAPIs: (templateAPIs: TemplateAPI[]) => void;
templateAPIsWhisper: TemplateAPI[];
setTemplateAPIsWhisper: (templateAPIs: TemplateAPI[]) => void;
templateAPIsTTS: TemplateAPI[];
setTemplateAPIsTTS: (templateAPIs: TemplateAPI[]) => void;
templateAPIsImageGen: TemplateAPI[];
setTemplateAPIsImageGen: (templateAPIs: TemplateAPI[]) => void;
templateTools: TemplateTools[];
setTemplateTools: (templateTools: TemplateTools[]) => void;
}) => { }) => {
if (!props.show) return <div></div>; let link =
const link =
location.protocol + location.protocol +
"//" + "//" +
location.host + location.host +
location.pathname + location.pathname +
`?key=${encodeURIComponent( `?key=${encodeURIComponent(
props.chatStore.apiKey props.chatStore.apiKey
)}&api=${encodeURIComponent(props.chatStore.apiEndpoint)}&mode=${ )}&api=${encodeURIComponent(props.chatStore.apiEndpoint)}&mode=${props.chatStore.streamMode ? "stream" : "fetch"
props.chatStore.streamMode ? "stream" : "fetch" }&model=${props.chatStore.model}&sys=${encodeURIComponent(
}&sys=${encodeURIComponent(props.chatStore.systemMessageContent)}`; props.chatStore.systemMessageContent
)}`;
if (props.chatStore.develop_mode) {
link = link + `&dev=true`;
}
const importFileRef = createRef();
const [totalCost, setTotalCost] = useState(getTotalCost());
// @ts-ignore
const { langCode, setLangCode } = useContext(langCodeContext);
useEffect(() => {
const handleKeyPress = (event: any) => {
if (event.keyCode === 27) {
// keyCode for ESC key is 27
props.setShow(false);
}
};
document.addEventListener("keydown", handleKeyPress);
return () => {
document.removeEventListener("keydown", handleKeyPress);
};
}, []); // The empty dependency array ensures that the effect runs only once
return ( return (
<div className="left-0 top-0 overflow-scroll flex justify-center absolute w-screen h-screen bg-black bg-opacity-50 z-10"> <div
<div className="m-2 p-2 bg-white rounded-lg h-fit"> onClick={() => props.setShow(false)}
<h3 className="text-xl">Settings</h3> 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-white rounded-lg h-fit lg:w-2/3 z-20"
>
<h3 className="text-xl text-center flex justify-between">
<span>{Tr("Settings")}</span>
<select>
{Object.keys(LANG_OPTIONS).map((opt) => (
<option
value={opt}
selected={opt === (langCodeContext as any).langCode}
onClick={(event: any) => {
console.log("set lang code", event.target.value);
setLangCode(event.target.value);
}}
>
{LANG_OPTIONS[opt].name}
</option>
))}
</select>
</h3>
<hr />
<div className="flex justify-between">
<button
className="p-2 m-2 rounded bg-purple-600 text-white"
onClick={() => {
navigator.clipboard.writeText(link);
alert(tr(`Copied link:`, langCode) + `${link}`);
}}
>
{Tr("Copy Setting Link")}
</button>
<button
className="p-2 m-2 rounded bg-rose-600 text-white"
onClick={() => {
if (!confirm(tr("Are you sure to clear all history?", langCode)))
return;
props.chatStore.history = props.chatStore.history.filter(
(msg) => msg.example && !msg.hide
);
props.chatStore.postBeginIndex = 0;
props.setChatStore({ ...props.chatStore });
}}
>
{Tr("Clear History")}
</button>
<button
className="p-2 m-2 rounded bg-cyan-600 text-white"
onClick={() => {
props.setShow(false);
}}
>
{Tr("Close")}
</button>
</div>
<p className="m-2 p-2">
{Tr("Total cost in this session")} ${props.chatStore.cost.toFixed(4)}
</p>
<hr /> <hr />
<div className="box"> <div className="box">
<Input <LongInput
field="systemMessageContent" field="systemMessageContent"
help="系统消息用于指示ChatGPT的角色和一些前置条件例如“你是一个有帮助的人工智能助理”或者“你是一个专业英语翻译把我的话全部翻译成英语”详情参考 OPEAN AI API 文档" help="系统消息用于指示ChatGPT的角色和一些前置条件例如“你是一个有帮助的人工智能助理”或者“你是一个专业英语翻译把我的话全部翻译成英语”详情参考 OPEAN AI API 文档"
{...props} {...props}
/> />
<span>
Valied JSON:{" "}
{isVailedJSON(props.chatStore.toolsString) ? "🆗" : "❌"}
</span>
<LongInput
field="toolsString"
help="function call tools, should be valied json format in list"
{...props}
/>
<div className="relative border-slate-300 border rounded">
<div className="flex justify-between">
<strong className="p-1 m-1">Chat API</strong>
<SetAPIsTemplate
label="Chat API"
endpoint={props.chatStore.apiEndpoint}
APIkey={props.chatStore.apiKey}
tmps={props.templateAPIs}
setTmps={props.setTemplateAPIs}
/>
</div>
<hr />
<Input <Input
field="apiKey" field="apiKey"
help="OPEN AI API 密钥,请勿泄漏此密钥" help="OPEN AI API 密钥,请勿泄漏此密钥"
@@ -123,14 +450,37 @@ export default (props: {
help="API 端点,方便在不支持的地区使用反向代理服务,默认为 https://api.openai.com/v1/chat/completions" help="API 端点,方便在不支持的地区使用反向代理服务,默认为 https://api.openai.com/v1/chat/completions"
{...props} {...props}
/> />
</div>
<SelectModel
help="模型,默认 3.5。不同模型性能和定价也不同,请参考 API 文档。"
{...props}
/>
<Slicer
field="temperature"
min={0}
max={2}
help="温度,数值越大模型生成文字的随机性越高。"
{...props}
/>
<Choice <Choice
field="streamMode" field="streamMode"
help="流模式,使用 stream mode 将可以动态看到生成内容,但无法准确计算 token 数量,在 token 数量过多时可能会裁切过多或过少历史消息" help="流模式,使用 stream mode 将可以动态看到生成内容,但无法准确计算 token 数量,在 token 数量过多时可能会裁切过多或过少历史消息"
{...props} {...props}
/> />
<Choice
field="develop_mode"
help="开发者模式,开启后会显示更多选项及功能"
{...props}
/>
<Number <Number
field="maxTokens" field="maxTokens"
help="最大 token 数量,这个详情参考 OPENAI API 文档" help="最大上下文 token 数量。此值会根据选择的模型自动设置。"
readOnly={false}
{...props}
/>
<Number
field="maxGenTokens"
help="最大生成 Tokens 数量,可选值。"
readOnly={false} readOnly={false}
{...props} {...props}
/> />
@@ -140,10 +490,11 @@ export default (props: {
readOnly={false} readOnly={false}
{...props} {...props}
/> />
<Choice field="json_mode" help="JSON Mode" {...props} />
<Number <Number
field="postBeginIndex" field="postBeginIndex"
help="指示发送 API 请求时要”忘记“多少历史消息" help="指示发送 API 请求时要”忘记“多少历史消息"
readOnly={false} readOnly={true}
{...props} {...props}
/> />
<Number <Number
@@ -152,44 +503,291 @@ export default (props: {
readOnly={true} readOnly={true}
{...props} {...props}
/> />
<Slicer
field="top_p"
min={0}
max={1}
help="Top P 采样方法。建议与温度采样方法二选一,不要同时开启。"
{...props}
/>
<Number
field="presence_penalty"
help="存在惩罚度"
readOnly={false}
{...props}
/>
<Number
field="frequency_penalty"
help="频率惩罚度"
readOnly={false}
{...props}
/>
<div className="relative border-slate-300 border rounded">
<div className="flex justify-between">
<strong className="p-1 m-1">Whisper API</strong>
<SetAPIsTemplate
label="Whisper API"
endpoint={props.chatStore.whisper_api}
APIkey={props.chatStore.whisper_key}
tmps={props.templateAPIsWhisper}
setTmps={props.setTemplateAPIsWhisper}
/>
</div> </div>
<hr /> <hr />
<Input
field="whisper_key"
help="用于 Whisper 服务的 key默认为 上方使用的OPENAI key可在此单独配置专用key"
{...props}
/>
<Input
field="whisper_api"
help="Whisper 语言转文字服务填入此api才会开启默认为 https://api.openai.com/v1/audio/transriptions"
{...props}
/>
</div>
<div className="relative border-slate-300 border rounded mt-1">
<div className="flex justify-between"> <div className="flex justify-between">
<button <strong className="p-1 m-1">TTS API</strong>
className="p-2 m-2 rounded bg-purple-600 text-white" <SetAPIsTemplate
onClick={() => { label="TTS API"
navigator.clipboard.writeText(link); endpoint={props.chatStore.tts_api}
alert(`Copied link: ${link}`); APIkey={props.chatStore.tts_key}
}} tmps={props.templateAPIsTTS}
> setTmps={props.setTemplateAPIsTTS}
Copy Link />
</button> </div>
<button <hr />
className="p-2 m-2 rounded bg-rose-600 text-white" <Input field="tts_key" help="tts service api key" {...props} />
onClick={() => { <Input
if ( field="tts_api"
!confirm( help="tts api, eg. https://api.openai.com/v1/audio/speech"
`Are you sure to clear all ${props.chatStore.history.length} messages?` {...props}
) />
) </div>
return; <Help help="tts voice style">
props.chatStore.history = []; <label className="m-2 p-2">TTS Voice</label>
props.chatStore.postBeginIndex = 0; <select
props.chatStore.totalTokens = 0; className="m-2 p-2"
value={props.chatStore.tts_voice}
onChange={(event: any) => {
const voice = event.target.value as string;
props.chatStore.tts_voice = voice;
props.setChatStore({ ...props.chatStore }); props.setChatStore({ ...props.chatStore });
}} }}
> >
Clear History {TTS_VOICES.map((opt) => (
</button> <option value={opt}>{opt}</option>
<button ))}
className="p-2 m-2 rounded bg-cyan-600 text-white" </select>
onClick={() => { </Help>
props.setShow(false); <Slicer
min={0.25}
max={4.0}
field="tts_speed"
help={"TTS Speed"}
{...props}
/>
<Help help="tts response format">
<label className="m-2 p-2">TTS Format</label>
<select
className="m-2 p-2"
value={props.chatStore.tts_format}
onChange={(event: any) => {
const format = event.target.value as string;
props.chatStore.tts_format = format;
props.setChatStore({ ...props.chatStore });
}} }}
> >
Close {TTS_FORMAT.map((opt) => (
<option value={opt}>{opt}</option>
))}
</select>
</Help>
<div className="relative border-slate-300 border rounded">
<div className="flex justify-between">
<strong className="p-1 m-1">Image Gen API</strong>
<SetAPIsTemplate
label="Image Gen API"
endpoint={props.chatStore.image_gen_api}
APIkey={props.chatStore.image_gen_key}
tmps={props.templateAPIsImageGen}
setTmps={props.setTemplateAPIsImageGen}
/>
</div>
<hr />
<Input
field="image_gen_key"
help="image generation service api key"
{...props}
/>
<Input
field="image_gen_api"
help="DALL image gen key, eg. https://api.openai.com/v1/images/generations"
{...props}
/>
</div>
<div className="flex justify-between">
<p className="m-2 p-2">
{Tr("Accumulated cost in all sessions")} ${totalCost.toFixed(4)}
</p>
<button
className="p-2 m-2 rounded bg-emerald-500"
onClick={() => {
clearTotalCost();
setTotalCost(getTotalCost());
}}
>
{Tr("Reset")}
</button> </button>
</div> </div>
<div className="flex justify-evenly flex-wrap">
{props.chatStore.toolsString.trim() && (
<button
className="p-2 m-2 rounded bg-blue-300"
onClick={() => {
const name = prompt(`Give this **Tools** template a name:`);
if (!name) {
alert("No template name specified");
return;
}
const newToolsTmp: TemplateTools = {
name,
toolsString: props.chatStore.toolsString,
};
props.templateTools.push(newToolsTmp);
props.setTemplateTools([...props.templateTools]);
}}
>
{Tr(`Save Tools`)}
</button>
)}
</div>
<p className="flex justify-evenly">
<button
className="p-2 m-2 rounded bg-amber-500"
onClick={() => {
let dataStr =
"data:text/json;charset=utf-8," +
encodeURIComponent(
JSON.stringify(props.chatStore, null, "\t")
);
let downloadAnchorNode = document.createElement("a");
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute(
"download",
`chatgpt-api-web-${props.selectedChatStoreIndex}.json`
);
document.body.appendChild(downloadAnchorNode); // required for firefox
downloadAnchorNode.click();
downloadAnchorNode.remove();
}}
>
{Tr("Export")}
</button>
<button
className="p-2 m-2 rounded bg-amber-500"
onClick={() => {
const name = prompt(tr("Give this template a name:", langCode));
if (!name) {
alert(tr("No template name specified", langCode));
return;
}
const tmp: ChatStore = structuredClone(props.chatStore);
tmp.history = tmp.history.filter((h) => h.example);
// clear api because it is stored in the API template
tmp.apiEndpoint = "";
tmp.apiKey = "";
tmp.whisper_api = "";
tmp.whisper_key = "";
tmp.tts_api = "";
tmp.tts_key = "";
tmp.image_gen_api = "";
tmp.image_gen_key = "";
// @ts-ignore
tmp.name = name;
props.templates.push(tmp as TemplateChatStore);
props.setTemplates([...props.templates]);
}}
>
{Tr("As template")}
</button>
<button
className="p-2 m-2 rounded bg-amber-500"
onClick={() => {
if (
!confirm(
tr(
"This will OVERWRITE the current chat history! Continue?",
langCode
)
)
)
return;
console.log("importFileRef", importFileRef);
importFileRef.current.click();
}}
>
Import
</button>
<input
className="hidden"
ref={importFileRef}
type="file"
onChange={() => {
const file = importFileRef.current.files[0];
console.log("file to import", file);
if (!file || file.type !== "application/json") {
alert(tr("Please select a json file", langCode));
return;
}
const reader = new FileReader();
reader.onload = () => {
console.log("import content", reader.result);
if (!reader) {
alert(tr("Empty file", langCode));
return;
}
try {
const newChatStore: ChatStore = JSON.parse(
reader.result as string
);
if (!newChatStore.chatgpt_api_web_version) {
throw tr(
"This is not an exported chatgpt-api-web chatstore file. The key 'chatgpt_api_web_version' is missing!",
langCode
);
}
props.setChatStore({ ...newChatStore });
} catch (e) {
alert(
tr(`Import error on parsing json:`, langCode) + `${e}`
);
}
};
reader.readAsText(file);
}}
/>
</p>
<p className="text-center m-2 p-2">
chatgpt-api-web ChatStore {Tr("Version")}{" "}
{props.chatStore.chatgpt_api_web_version}
</p>
<p>
{Tr("Documents and source code are avaliable here")}:{" "}
<a
className="underline"
href="https://github.com/heimoshuiyu/chatgpt-api-web"
target="_blank"
>
github.com/heimoshuiyu/chatgpt-api-web
</a>
</p>
</div>
<hr />
</div> </div>
</div> </div>
); );

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 "./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;

84
src/tts.tsx Normal file
View File

@@ -0,0 +1,84 @@
import { useMemo, useState } from "preact/hooks";
import { ChatStore, ChatStoreMessage, addTotalCost } from "./app";
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 ? "🤔" : "🔈"}
</button>
);
}

1139
yarn.lock

File diff suppressed because it is too large Load Diff