Compare commits

...

57 Commits

Author SHA1 Message Date
lencx
6e2d395156 v0.6.0 2022-12-23 19:00:26 +08:00
lencx
990aa31437 v0.6.0 2022-12-23 19:00:16 +08:00
lencx
a73d203983 readme 2022-12-23 18:58:10 +08:00
lencx
2a9fba7d27 chore: menu sync 2022-12-23 18:52:56 +08:00
lencx
e4be2bc2f3 readme 2022-12-23 17:51:41 +08:00
lencx
389e00a5e0 chore: sync 2022-12-23 15:27:05 +08:00
lencx
2be560e69a chore: sync record 2022-12-23 02:23:36 +08:00
lencx
6abe7c783e chore: sync 2022-12-23 00:44:08 +08:00
lencx
8319eae519 chore: sync 2022-12-23 00:43:58 +08:00
lencx
921d670f53 feat: the slash command is triggered by the enter key 2022-12-22 22:09:54 +08:00
lencx
39a8d8d297 fix: windows conf (#58) 2022-12-22 09:06:19 +08:00
lencx
2d826c90a0 chore: sync 2022-12-22 08:59:58 +08:00
lencx
d513a50e27 chore: sync 2022-12-21 14:00:42 +08:00
lencx
878bb6c265 feat: optimize pdf size 2022-12-20 20:04:57 +08:00
lencx
69f1968e88 chore: model 2022-12-20 14:49:56 +08:00
lencx
3a0ee7d4d6 Merge pull request #53 from lencx/dev 2022-12-20 01:31:53 +08:00
lencx
5dd671c98e v0.5.1 2022-12-20 01:14:36 +08:00
lencx
75a7b9c78d readme 2022-12-20 01:14:26 +08:00
lencx
47a3bace5b chore: optim 2022-12-19 23:09:17 +08:00
lencx
8966ebbd03 Merge pull request #46 from lencx/dev 2022-12-19 03:12:07 +08:00
lencx
3fe04a244a v0.5.0 2022-12-19 02:57:15 +08:00
lencx
c54aec88c0 feat: chatgpt-prompts sync 2022-12-19 02:56:53 +08:00
lencx
02fb4dd3b7 chore: windows conf 2022-12-18 13:30:27 +08:00
lencx
028ef8bae8 Merge pull request #44 from lencx/fix 2022-12-18 12:11:05 +08:00
lencx
e86bf42cc1 v0.4.2 2022-12-18 11:52:49 +08:00
lencx
09b8643d99 feat: add log 2022-12-18 11:52:37 +08:00
lencx
c07fd1e0b8 chore: add log 2022-12-18 11:50:34 +08:00
lencx
1b71bf8f26 Merge pull request #40 from lencx/fix 2022-12-17 21:51:09 +08:00
lencx
4366b8ee8a readme 2022-12-17 21:33:23 +08:00
lencx
7505311a2c v0.4.1 2022-12-17 21:31:26 +08:00
lencx
680100801f fix: tray window style optimization (#39) 2022-12-17 21:30:45 +08:00
lencx
ee0836cb07 readme 2022-12-17 20:04:50 +08:00
lencx
91cebe82db Merge pull request #38 from lencx/dev 2022-12-17 18:12:09 +08:00
lencx
7fea0aa395 v0.4.0 2022-12-17 17:39:21 +08:00
lencx
d554dcda80 readme 2022-12-17 17:39:10 +08:00
lencx
2393d9d555 chore: doc 2022-12-17 17:36:47 +08:00
lencx
24a7d60257 chore: add chatgpt gif 2022-12-17 17:31:29 +08:00
lencx
b84f45f932 chore: add json 2022-12-17 17:25:47 +08:00
lencx
e524f12b6a chore: doc 2022-12-17 16:37:19 +08:00
lencx
4df09113b5 chore: chatgpt prompts & tray menu 2022-12-17 16:23:53 +08:00
lencx
1e7c0fe02a feat: chatgpt prompts 2022-12-17 14:29:46 +08:00
lencx
47c9072f40 feat: chatgpt prompts 2022-12-16 21:23:46 +08:00
lencx
3318bfb23f feat: chatgpt prompts 2022-12-16 20:16:34 +08:00
lencx
20105d54be feat: chatgpt prompts 2022-12-16 19:58:40 +08:00
lencx
305e784145 ignore 2022-12-16 18:43:41 +08:00
lencx
647a89fdf8 feat: hide dock icon (#35) 2022-12-16 11:43:29 +08:00
lencx
7da70733a3 chore: chatgpt prompts 2022-12-15 21:46:15 +08:00
lencx
0649723c3c fix: add dep (#34) 2022-12-15 21:33:25 +08:00
lencx
373097a54b add discord 2022-12-15 19:46:19 +08:00
lencx
5a9a03aeab readme 2022-12-15 19:43:14 +08:00
lencx
248d115c8a readme 2022-12-15 17:16:12 +08:00
lencx
8f1ef2f306 readme 2022-12-15 17:12:56 +08:00
lencx
fd6edb7225 fix: download links (#33) 2022-12-15 17:02:54 +08:00
lencx
38b3f77eb4 Merge pull request #32 from lencx/dev 2022-12-15 13:53:37 +08:00
lencx
cfa88473c0 readme 2022-12-15 13:51:26 +08:00
lencx
d1f10672fa readme 2022-12-15 13:31:54 +08:00
lencx
4d698eabba v0.3.0 2022-12-15 13:30:43 +08:00
54 changed files with 2465 additions and 215 deletions

1
.gitattributes vendored
View File

@@ -1,3 +1,4 @@
*.js linguist-vendored
*.tsx linguist-vendored
*.scss linguist-vendored
src/**/*.ts linguist-vendored

View File

@@ -6,8 +6,10 @@
> ChatGPT 桌面应用
[![English badge](https://img.shields.io/badge/%E8%8B%B1%E6%96%87-English-blue)](./README.md)
[![中文 badge](https://img.shields.io/badge/%E4%B8%AD%E6%96%87-Traditional%20Chinese-blue)](./README-ZH.md)
[![简体中文 badge](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-Simplified%20Chinese-blue)](./README-ZH_CN.md)
[![ChatGPT downloads](https://img.shields.io/github/downloads/lencx/ChatGPT/total.svg?style=flat-square)](https://github.com/lencx/ChatGPT/releases)
[![chat](https://img.shields.io/badge/chat-discord-blue?style=flat&logo=discord)](https://discord.gg/aPhCRf4zZr)
[![lencx](https://img.shields.io/twitter/follow/lencx_.svg?style=social)](https://twitter.com/lencx_)
[Awesome ChatGPT](./AWESOME.md)
@@ -20,9 +22,9 @@
**最新版:**
- `Mac`: [ChatGPT_0.2.1_x64.dmg](https://github.com/lencx/ChatGPT/releases/download/v0.2.1/ChatGPT_0.2.1_x64.dmg)
- `Linux`: [chat-gpt_0.2.1_amd64.deb](https://github.com/lencx/ChatGPT/releases/download/v0.2.1/chat-gpt_0.2.1_amd64.deb)
- `Windows`: [ChatGPT_0.2.1_x64_en-US.msi](https://github.com/lencx/ChatGPT/releases/download/v0.2.1/ChatGPT_0.2.1_x64_en-US.msi)
- `Mac`: [ChatGPT_0.6.0_x64.dmg](https://github.com/lencx/ChatGPT/releases/download/v0.6.0/ChatGPT_0.6.0_x64.dmg)
- `Linux`: [chat-gpt_0.6.0_amd64.deb](https://github.com/lencx/ChatGPT/releases/download/v0.6.0/chat-gpt_0.6.0_amd64.deb)
- `Windows`: [ChatGPT_0.6.0_x64_en-US.msi](https://github.com/lencx/ChatGPT/releases/download/v0.6.0/ChatGPT_0.6.0_x64_en-US.msi)
[其他版本...](https://github.com/lencx/ChatGPT/releases)
@@ -32,18 +34,36 @@
Easily install with _[Homebrew](https://brew.sh) ([Cask](https://docs.brew.sh/Cask-Cookbook)):_
~~~ sh
```sh
brew tap lencx/chatgpt https://github.com/lencx/ChatGPT.git
brew install --cask chatgpt --no-quarantine
~~~
```
Also, if you keep a _[Brewfile](https://github.com/Homebrew/homebrew-bundle#usage)_, you can add something like this:
~~~ rb
```rb
repo = "lencx/chatgpt"
tap repo, "https://github.com/#{repo}.git"
cask "popcorn-time", args: { "no-quarantine": true }
~~~
```
## 📢 公告
这是一个令人兴奋的重大更新。像 `Telegram 机器人指令` 那样工作,帮助你快速填充自定模型,来让 ChatGPT 按照你想要的方式去工作。这个项目倾注了我大量业余时间,如果它对你有所帮助,宣传转发,或者 star 都是对我的巨大鼓励。我希望我可以持续更新下去,加入更多有趣的功能。
### 如何使用指令?
你可以从 [awesome-chatgpt-prompts](https://github.com/f/awesome-chatgpt-prompts) 来寻找有趣的功能来导入到应用。
![chat cmd](./assets/chat-cmd-1.png)
![chat cmd](./assets/chat-cmd-2.png)
数据导入完成后,可以重新启动应用来使配置生效(`Menu -> Preferences -> Restart ChatGPT`)。
在 ChatGPT 文本输入区域,键入 `/` 开头的字符,则会弹出指令提示,按下空格键,它会默认将命令关联的文本填充到输入区域(注意:如果包含多个指令提示,它只会选择第一个作为填充,你可以持续输入,直到第一个提示命令为你想要时,再按下空格键。或者使用鼠标来点击多条指令中的某一个)。填充完成后,你只需要按下回车键即可。斜杠命令下,使用 TAB 键修改 `{q}` 标签内容(仅支持单个修改 [#54](https://github.com/lencx/ChatGPT/issues/54))。
![chatgpt](assets/chatgpt.gif)
![chatgpt-cmd](assets/chatgpt-cmd.gif)
## ✨ 功能概览
@@ -53,16 +73,19 @@ cask "popcorn-time", args: { "no-quarantine": true }
- 丰富的快捷键
- 系统托盘悬浮窗
- 应用菜单功能强大
- 支持斜杠命令及其配置(可手动配置或从文件同步)
### 菜单项
- **Preferences (喜好)**
- `Theme` - `Light`, `Dark` (仅支持 macOS 和 Windows)
- `Always On Top`: 窗口置顶
- `Stay On Top`: 窗口置顶
- `Titlebar`: 是否显示 `Titlebar`,仅 macOS 支持
- `Inject Script`: 用于修改网站的用户自定义脚本
- `Hide Dock Icon` ([#35](https://github.com/lencx/ChatGPT/issues/35)): 隐藏 Dock 中的应用图标 (仅 macOS 支持)
- 系统图盘右键单击打开菜单,然后在菜单项中点击 `Show Dock Icon` 可以重新将应用图标显示在 Dock`SystemTrayMenu -> Show Dock Icon`
- `Control Center`: ChatGPT 应用的控制中心,它将为应用提供无限的可能
- 设置 `Theme``Always on Top``Titlebar`
- 设置 `Theme``Stay On Top``Titlebar`
- `User Agent` ([#17](https://github.com/lencx/ChatGPT/issues/17)): 自定义 `user agent` 防止网站安全检测,默认值为空
- `Switch Origin` ([#14](https://github.com/lencx/ChatGPT/issues/14)): 切换网站源地址,默认为 `https://chat.openai.com`。需要注意的是镜像网站的 UI 需要和原网站一致,否则可能会导致某些功能不工作
- `Go to Config`: 打开 ChatGPT 配置目录 (`path: ~/.chatgpt/*`)
@@ -76,22 +99,67 @@ cask "popcorn-time", args: { "no-quarantine": true }
- `Report Bug`: 报告 BUG 或反馈建议
- `Toggle Developer Tools`: 网站调试工具,调试页面或脚本可能需要
## 应用配置
| 平台 | 路径 |
| ------- | ------------------------- |
| Linux | `/home/lencx/.chatgpt` |
| macOS | `/Users/lencx/.chatgpt` |
| Windows | `C:\Users\lencx\.chatgpt` |
- `[.chatgpt]` - 应用配置根路径
- `chat.conf.json` - 应用喜好配置
- `chat.model.json` - ChatGPT 输入提示,通过斜杠命令来快速完成输入,主要包含三部分:
- `user_custom` - 需要手动录入 (**Control Conter -> Language Model -> User Custom**)
- `sync_prompts` - 从 [f/awesome-chatgpt-prompts](https://github.com/f/awesome-chatgpt-prompts) 同步数据 (**Control Conter -> Language Model -> Sync Prompts**)
- `sync_custom` - 同步自定义的 json 或 csv 文件数据,支持本地和远程 (**Control Conter -> Language Model -> Sync Custom**)
- `chat.model.cmd.json` - 过滤(是否启用)和排序处理后的斜杠命令数据
- `[cache_model]` - 缓存同步或录入的数据
- `chatgpt_prompts.json` - 缓存 `sync_prompts` 数据
- `user_custom.json` - 缓存 `user_custom` 数据
- `ae6cf32a6f8541b499d6bfe549dbfca3.json` - 随机生成的文件名,缓存 `sync_custom` 数据
- `4f695d3cfbf8491e9b1f3fab6d85715c.json` - 随机生成的文件名,缓存 `sync_custom` 数据
- `bd1b96f15a1644f7bd647cc53073ff8f.json` - 随机生成的文件名,缓存 `sync_custom` 数据
### Sync Custom
目前同步自定文件仅支持 json 和 csv且需要满足以下格式否则会导致应用异常
> JSON 格式
```json
[
{
"cmd": "a",
"act": "aa",
"prompt": "aaa aaa aaa"
},
{
"cmd": "b",
"act": "bb",
"prompt": "bbb bbb bbb"
}
]
```
> CSV 格式
```csv
"cmd","act","prompt"
"a","aa","aaa aaa aaa"
"b","bb","bbb bbb bbb"
```
## 👀 预览
<img width="320" src="./assets/install.png" alt="install"> <img width="320" src="./assets/chat-control-center.png" alt="chat control center">
<img width="320" src="./assets/install.png" alt="install"> <img width="320" src="./assets/control-center.png" alt="control center">
<img width="320" src="./assets/export.png" alt="export"> <img width="320" src="./assets/tray.png" alt="tray">
<img width="320" src="./assets/chat-tray-login.png" alt="chat tray login"> <img width="320" src="./assets/auto-update.png" alt="auto update">
<img width="320" src="./assets/tray-login.png" alt="tray login"> <img width="320" src="./assets/auto-update.png" alt="auto update">
---
<a href="https://www.buymeacoffee.com/lencx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-blue.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
## 国内用户
国内用户如果遇到使用问题或者想交流 ChatGPT 技巧,可以关注公众号“浮之静”,发送 “chat” 进群参与讨论。如果对 tauri 开发应用感兴趣可以关注公众号后回复 “tauri” 进技术开发群(想私聊的也可以关注公众号,来添加微信)。
<img width="180" src="https://user-images.githubusercontent.com/16164244/207228300-ea5c4688-c916-4c55-a8c3-7f862888f351.png"> <img width="200" src="https://user-images.githubusercontent.com/16164244/207228025-117b5f77-c5d2-48c2-a070-774b7a1596f2.png">
## ❓ 常见问题
### 不能打开 ChatGPT
@@ -144,11 +212,18 @@ yarn build
## ❤️ 感谢
- 分享按钮的代码从 [@liady](https://github.com/liady) 的插件获得,并做了一些本地化修改
- 感谢 [Awesome ChatGPT Prompts](https://github.com/f/awesome-chatgpt-prompts) 项目为这个应用自定义指令功能所带来的启发
---
[![Star History Chart](https://api.star-history.com/svg?repos=lencx/chatgpt&type=Date)](https://star-history.com/#lencx/chatgpt&Date)
## 中国用户
国内用户如果遇到使用问题或者想交流 ChatGPT 技巧,可以关注公众号“浮之静”,发送 “chat” 进群参与讨论。公众号会更新[《Tauri 系列》](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MzIzNjE2NTI3NQ==&action=getalbum&album_id=2593843659863752704)文章,技术思考等等,如果对 tauri 开发应用感兴趣可以关注公众号后回复 “tauri” 进技术开发群(想私聊的也可以关注公众号,来添加微信)。开源不易,如果这个项目对你有帮助可以分享给更多人,或者微信扫码打赏。
<img width="180" src="https://user-images.githubusercontent.com/16164244/207228300-ea5c4688-c916-4c55-a8c3-7f862888f351.png"> <img width="200" src="https://user-images.githubusercontent.com/16164244/207228025-117b5f77-c5d2-48c2-a070-774b7a1596f2.png">
## License
Apache License

111
README.md
View File

@@ -6,10 +6,14 @@
> ChatGPT Desktop Application
[![English badge](https://img.shields.io/badge/%E8%8B%B1%E6%96%87-English-blue)](./README.md)
[![中文 badge](https://img.shields.io/badge/%E4%B8%AD%E6%96%87-Traditional%20Chinese-blue)](./README-ZH.md)
[![简体中文 badge](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-Simplified%20Chinese-blue)](./README-ZH_CN.md)
[![ChatGPT downloads](https://img.shields.io/github/downloads/lencx/ChatGPT/total.svg?style=flat-square)](https://github.com/lencx/ChatGPT/releases)
[![chat](https://img.shields.io/badge/chat-discord-blue?style=flat&logo=discord)](https://discord.gg/aPhCRf4zZr)
[![lencx](https://img.shields.io/twitter/follow/lencx_.svg?style=social)](https://twitter.com/lencx_)
<!-- [![中文版 badge](https://img.shields.io/badge/%E4%B8%AD%E6%96%87-Traditional%20Chinese-blue)](./README-ZH.md) -->
[Awesome ChatGPT](./AWESOME.md)
## 📦 Downloads
@@ -20,9 +24,9 @@
**Latest:**
- `Mac`: [ChatGPT_0.2.1_x64.dmg](https://github.com/lencx/ChatGPT/releases/download/v0.2.1/ChatGPT_0.2.1_x64.dmg)
- `Linux`: [chat-gpt_0.2.1_amd64.deb](https://github.com/lencx/ChatGPT/releases/download/v0.2.1/chat-gpt_0.2.1_amd64.deb)
- `Windows`: [ChatGPT_0.2.1_x64_en-US.msi](https://github.com/lencx/ChatGPT/releases/download/v0.2.1/ChatGPT_0.2.1_x64_en-US.msi)
- `Mac`: [ChatGPT_0.6.0_x64.dmg](https://github.com/lencx/ChatGPT/releases/download/v0.6.0/ChatGPT_0.6.0_x64.dmg)
- `Linux`: [chat-gpt_0.6.0_amd64.deb](https://github.com/lencx/ChatGPT/releases/download/v0.6.0/chat-gpt_0.6.0_amd64.deb)
- `Windows`: [ChatGPT_0.6.0_x64_en-US.msi](https://github.com/lencx/ChatGPT/releases/download/v0.6.0/ChatGPT_0.6.0_x64_en-US.msi)
[Other version...](https://github.com/lencx/ChatGPT/releases)
@@ -32,18 +36,36 @@
Easily install with _[Homebrew](https://brew.sh) ([Cask](https://docs.brew.sh/Cask-Cookbook)):_
~~~ sh
```sh
brew tap lencx/chatgpt https://github.com/lencx/ChatGPT.git
brew install --cask chatgpt --no-quarantine
~~~
```
Also, if you keep a _[Brewfile](https://github.com/Homebrew/homebrew-bundle#usage)_, you can add something like this:
~~~ rb
```rb
repo = "lencx/chatgpt"
tap repo, "https://github.com/#{repo}.git"
cask "popcorn-time", args: { "no-quarantine": true }
~~~
```
## 📢 Announcement
This is a major and exciting update. It works like a `Telegram bot command` and helps you quickly populate custom models to make chatgpt work the way you want it to. This project has taken a lot of my spare time, so if it helps you, please help spread the word or star it would be a great encouragement to me. I hope I can keep updating it and adding more interesting features.
### How does it work?
You can look at [awesome-chatgpt-prompts](https://github.com/f/awesome-chatgpt-prompts) to find interesting features to import into the app.
![chat cmd](./assets/chat-cmd-1.png)
![chat cmd](./assets/chat-cmd-2.png)
After the data import is done, you can restart the app to make the configuration take effect (`Menu -> Preferences -> Restart ChatGPT`).
In the chatgpt text input area, type a character starting with `/` to bring up the command prompt, press the spacebar, and it will fill the input area with the text associated with the command by default (note: if it contains multiple command prompts, it will only select the first one as the fill, you can keep typing until the first prompted command is the one you want, then press the spacebar. Or use the mouse to click on one of the multiple commands). When the fill is complete, you simply press the Enter key. Under the slash command, use the tab key to modify the contents of the `{q}` tag (only single changes are supported [#54](https://github.com/lencx/ChatGPT/issues/54)).
![chatgpt](assets/chatgpt.gif)
![chatgpt-cmd](assets/chatgpt-cmd.gif)
## ✨ Features
@@ -53,16 +75,19 @@ cask "popcorn-time", args: { "no-quarantine": true }
- Common shortcut keys
- System tray hover window
- Powerful menu items
- Support for slash commands and their configuration (can be configured manually or synchronized from a file)
### MenuItem
- **Preferences**
- `Theme` - `Light`, `Dark` (Only macOS and Windows are supported).
- `Always on Top`: The window is always on top of other windows.
- `Stay On Top`: The window is stay on top of other windows.
- `Titlebar`: Whether to display the titlebar, supported by macOS only.
- `Hide Dock Icon` ([#35](https://github.com/lencx/ChatGPT/issues/35)): Hide application icons from the Dock(support macOS only).
- Right-click on the SystemTray to open the menu, then click `Show Dock Icon` in the menu item to re-display the application icon in the Dock (`SystemTrayMenu -> Show Dock Icon`).
- `Inject Script`: Using scripts to modify pages.
- `Control Center`: The control center of ChatGPT application, it will give unlimited imagination to the application.
- `Theme`, `Always on Top`, `Titlebar`, ...
- `Theme`, `Stay On Top`, `Titlebar`, ...
- `User Agent` ([#17](https://github.com/lencx/ChatGPT/issues/17)): Custom `user agent`, which may be required in some scenarios. The default value is the empty string.
- `Switch Origin` ([#14](https://github.com/lencx/ChatGPT/issues/14)): Switch the site source address, the default is `https://chat.openai.com`, please make sure the mirror site UI is the same as the original address. Otherwise, some functions may not be available.
- `Go to Config`: Open the configuration file directory (`path: ~/.chatgpt/*`).
@@ -76,17 +101,68 @@ cask "popcorn-time", args: { "no-quarantine": true }
- `Report Bug`: Report a bug or give feedback.
- `Toggle Developer Tools`: Developer debugging tools.
## Application Configuration
| Platform | Path |
| -------- | ------------------------- |
| Linux | `/home/lencx/.chatgpt` |
| macOS | `/Users/lencx/.chatgpt` |
| Windows | `C:\Users\lencx\.chatgpt` |
- `[.chatgpt]` - application configuration root folder
- `chat.conf.json` - preferences configuration
- `chat.model.json` - prompts configurationcontains three parts:
- `user_custom` - Requires manual data entry (**Control Conter -> Language Model -> User Custom**)
- `sync_prompts` - Synchronizing data from [f/awesome-chatgpt-prompts](https://github.com/f/awesome-chatgpt-prompts) (**Control Conter -> Language Model -> Sync Prompts**)
- `sync_custom` - Synchronize custom json and csv file data, support local and remote (**Control Conter -> Language Model -> Sync Custom**)
- `chat.model.cmd.json` - filtered (whether to enable) and sorted slash commands
- `[cache_model]` - caching model data
- `chatgpt_prompts.json` - Cache `sync_prompts` data
- `user_custom.json` - Cache `user_custom` data
- `ae6cf32a6f8541b499d6bfe549dbfca3.json` - Randomly generated file names, cache `sync_custom` data
- `4f695d3cfbf8491e9b1f3fab6d85715c.json` - Randomly generated file names, cache `sync_custom` data
- `bd1b96f15a1644f7bd647cc53073ff8f.json` - Randomly generated file names, cache `sync_custom` data
### Sync Custom
Currently, only json and csv are supported for synchronizing custom files, and the following formats need to be met, otherwise the application will be abnormal
> JSON format:
```json
[
{
"cmd": "a",
"act": "aa",
"prompt": "aaa aaa aaa"
},
{
"cmd": "b",
"act": "bb",
"prompt": "bbb bbb bbb"
}
]
```
> CSV format
```csv
"cmd","act","prompt"
"a","aa","aaa aaa aaa"
"b","bb","bbb bbb bbb"
```
## TODO
- Web access capability ([#20](https://github.com/lencx/ChatGPT/issues/20))
- Shortcut command typing chatgpt prompt
- `Control Center` - Feature Enhancements
- ...
## 👀 Preview
<img width="320" src="./assets/install.png" alt="install"> <img width="320" src="./assets/chat-control-center.png" alt="chat control center">
<img width="320" src="./assets/install.png" alt="install"> <img width="320" src="./assets/control-center.png" alt="control center">
<img width="320" src="./assets/export.png" alt="export"> <img width="320" src="./assets/tray.png" alt="tray">
<img width="320" src="./assets/chat-tray-login.png" alt="chat tray login"> <img width="320" src="./assets/auto-update.png" alt="auto update">
<img width="320" src="./assets/tray-login.png" alt="tray login"> <img width="320" src="./assets/auto-update.png" alt="auto update">
---
@@ -98,7 +174,7 @@ cask "popcorn-time", args: { "no-quarantine": true }
If you cannot open the application after the upgrade, please try to clear the configuration file, which is in the `~/.chatgpt/*` directory.
## Out of sync login status between multiple windows
### Out of sync login status between multiple windows
If you have already logged in in the main window, but the system tray window shows that you are not logged in, you can fix it by restarting the application (`Menu -> Preferences -> Restart ChatGPT`).
@@ -142,11 +218,18 @@ yarn build
## ❤️ Thanks
- The core implementation of the share button code was copied from the [@liady](https://github.com/liady) extension with some modifications.
- Thanks to the [Awesome ChatGPT Prompts](https://github.com/f/awesome-chatgpt-prompts) repository for inspiring the custom command function for this application.
---
[![Star History Chart](https://api.star-history.com/svg?repos=lencx/chatgpt&type=Date)](https://star-history.com/#lencx/chatgpt&Date)
## 中国用户
国内用户如果遇到使用问题或者想交流 ChatGPT 技巧,可以关注公众号“浮之静”,发送 “chat” 进群参与讨论。公众号会更新[《Tauri 系列》](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MzIzNjE2NTI3NQ==&action=getalbum&album_id=2593843659863752704)文章,技术思考等等,如果对 tauri 开发应用感兴趣可以关注公众号后回复 “tauri” 进技术开发群(想私聊的也可以关注公众号,来添加微信)。开源不易,如果这个项目对你有帮助可以分享给更多人,或者微信扫码打赏。
<img width="180" src="https://user-images.githubusercontent.com/16164244/207228300-ea5c4688-c916-4c55-a8c3-7f862888f351.png"> <img width="200" src="https://user-images.githubusercontent.com/16164244/207228025-117b5f77-c5d2-48c2-a070-774b7a1596f2.png">
## License
Apache License

View File

@@ -1,5 +1,40 @@
# UPDATE LOG
## v0.6.0
fix:
- windows show Chinese when upgrading
feat:
- optimize the generated pdf file size
- menu added `Sync Prompts`
- `Control Center` added `Sync Custom`
- the slash command is triggered by the enter key
- under the slash command, use the tab key to modify the contents of the `{q}` tag (only single changes are supported (https://github.com/lencx/ChatGPT/issues/54)
## v0.5.1
some optimization
## v0.5.0
feat: `Control Center` added `chatgpt-prompts` synchronization
## v0.4.2
add chatgpt log (path: `~/.chatgpt/chatgpt.log`)
## v0.4.1
fix:
- tray window style optimization
## v0.4.0
feat:
- customize the ChatGPT prompts command (https://github.com/lencx/ChatGPT#-announcement)
- menu enhancement: hide application icons from the Dock (support macOS only)
## v0.3.0
fix: can't open ChatGPT
@@ -36,7 +71,7 @@ feat: tray window
## v0.1.6
feat:
- always on top
- stay on top
- export ChatGPT history
## v0.1.5

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 773 KiB

BIN
assets/chat-cmd-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
assets/chat-cmd-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
assets/chatgpt-cmd.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

BIN
assets/chatgpt.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 MiB

View File

Before

Width:  |  Height:  |  Size: 500 KiB

After

Width:  |  Height:  |  Size: 500 KiB

View File

Before

Width:  |  Height:  |  Size: 989 KiB

After

Width:  |  Height:  |  Size: 989 KiB

3
chat.model.md Normal file
View File

@@ -0,0 +1,3 @@
# ChatGPT Model
- [Awesome ChatGPT Prompts](https://github.com/f/awesome-chatgpt-prompts)

View File

@@ -30,12 +30,15 @@
"url": "https://github.com/lencx/ChatGPT"
},
"dependencies": {
"@ant-design/icons": "^4.8.0",
"@tauri-apps/api": "^1.2.0",
"antd": "^5.0.6",
"antd": "^5.1.0",
"dayjs": "^1.11.7",
"lodash": "^4.17.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.4.5"
"react-router-dom": "^6.4.5",
"uuid": "^9.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^1.2.2",
@@ -44,6 +47,7 @@
"@types/node": "^18.7.10",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@types/uuid": "^9.0.0",
"@vitejs/plugin-react": "^3.0.0",
"sass": "^1.56.2",
"typescript": "^4.9.4",

11
scripts/download.js vendored
View File

@@ -2,8 +2,8 @@ const fs = require('fs');
const argv = process.argv.slice(2);
async function init() {
const content = fs.readFileSync('README.md', 'utf8').split('\n');
async function rewrite(filename) {
const content = fs.readFileSync(filename, 'utf8').split('\n');
const startRe = /<!-- download start -->/;
const endRe = /<!-- download end -->/;
@@ -20,7 +20,12 @@ async function init() {
}
}
fs.writeFileSync('README.md', content.join('\n'), 'utf8');
fs.writeFileSync(filename, content.join('\n'), 'utf8');
}
async function init() {
rewrite('README.md');
rewrite('README-ZH_CN.md');
}
init().catch(console.error);

View File

@@ -19,6 +19,17 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2.2", features = ["api-all", "devtools", "system-tray", "updater"] }
tauri-plugin-positioner = { version = "1.0.4", features = ["system-tray"] }
log = "0.4.17"
csv = "1.1.6"
thiserror = "1.0.38"
walkdir = "2.3.2"
# tokio = { version = "1.23.0", features = ["macros"] }
# reqwest = "0.11.13"
[dependencies.tauri-plugin-log]
git = "https://github.com/tauri-apps/tauri-plugin-log"
branch = "dev"
features = ["colored"]
[features]
# by default Tauri runs in production mode

View File

@@ -1,5 +1,5 @@
use crate::{conf::ChatConfJson, utils};
use std::fs;
use std::{fs, path::PathBuf};
use tauri::{api, command, AppHandle, Manager};
#[command]
@@ -59,3 +59,72 @@ pub fn form_msg(app: AppHandle, label: &str, title: &str, msg: &str) {
let win = app.app_handle().get_window(label);
tauri::api::dialog::message(win.as_ref(), title, msg);
}
#[command]
pub fn open_file(path: PathBuf) {
utils::open_file(path);
}
#[command]
pub fn get_chat_model_cmd() -> serde_json::Value {
let path = utils::chat_root().join("chat.model.cmd.json");
let content = fs::read_to_string(path).unwrap_or_else(|_| r#"{"data":[]}"#.to_string());
serde_json::from_str(&content).unwrap()
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct PromptRecord {
pub cmd: Option<String>,
pub act: String,
pub prompt: String,
}
#[command]
pub fn parse_prompt(data: String) -> Vec<PromptRecord> {
let mut rdr = csv::Reader::from_reader(data.as_bytes());
let mut list = vec![];
for result in rdr.deserialize() {
let record: PromptRecord = result.unwrap();
list.push(record);
}
list
}
#[command]
pub fn window_reload(app: AppHandle, label: &str) {
app.app_handle()
.get_window(label)
.unwrap()
.eval("window.location.reload()")
.unwrap();
}
use walkdir::WalkDir;
use utils::chat_root;
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct ModelRecord {
pub cmd: String,
pub act: String,
pub prompt: String,
pub tags: Vec<String>,
pub enable: bool,
}
#[command]
pub fn cmd_list() -> Vec<ModelRecord> {
let mut list = vec![];
for entry in WalkDir::new(chat_root().join("cache_model")).into_iter().filter_map(|e| e.ok()) {
let file = fs::read_to_string(entry.path().display().to_string());
if let Ok(v) = file {
let data: Vec<ModelRecord> = serde_json::from_str(&v).unwrap_or_else(|_| vec![]);
let enable_list = data.into_iter()
.filter(|v| v.enable);
list.extend(enable_list)
}
}
// dbg!(&list);
list.sort_by(|a, b| a.cmd.len().cmp(&b.cmd.len()));
list
}

View File

@@ -0,0 +1,123 @@
// https://github.com/tauri-apps/tauri-plugin-fs-extra/blob/dev/src/lib.rs
// Copyright 2019-2021 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use serde::{ser::Serializer, Serialize};
use std::{
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
use tauri::command;
#[cfg(unix)]
use std::os::unix::fs::{MetadataExt, PermissionsExt};
#[cfg(windows)]
use std::os::windows::fs::MetadataExt;
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Permissions {
readonly: bool,
#[cfg(unix)]
mode: u32,
}
#[cfg(unix)]
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct UnixMetadata {
dev: u64,
ino: u64,
mode: u32,
nlink: u64,
uid: u32,
gid: u32,
rdev: u64,
blksize: u64,
blocks: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Metadata {
accessed_at_ms: u64,
created_at_ms: u64,
modified_at_ms: u64,
is_dir: bool,
is_file: bool,
is_symlink: bool,
size: u64,
permissions: Permissions,
#[cfg(unix)]
#[serde(flatten)]
unix: UnixMetadata,
#[cfg(windows)]
file_attributes: u32,
}
fn system_time_to_ms(time: std::io::Result<SystemTime>) -> u64 {
time.map(|t| {
let duration_since_epoch = t.duration_since(UNIX_EPOCH).unwrap();
duration_since_epoch.as_millis() as u64
})
.unwrap_or_default()
}
#[command]
pub async fn metadata(path: PathBuf) -> Result<Metadata> {
let metadata = std::fs::metadata(path)?;
let file_type = metadata.file_type();
let permissions = metadata.permissions();
Ok(Metadata {
accessed_at_ms: system_time_to_ms(metadata.accessed()),
created_at_ms: system_time_to_ms(metadata.created()),
modified_at_ms: system_time_to_ms(metadata.modified()),
is_dir: file_type.is_dir(),
is_file: file_type.is_file(),
is_symlink: file_type.is_symlink(),
size: metadata.len(),
permissions: Permissions {
readonly: permissions.readonly(),
#[cfg(unix)]
mode: permissions.mode(),
},
#[cfg(unix)]
unix: UnixMetadata {
dev: metadata.dev(),
ino: metadata.ino(),
mode: metadata.mode(),
nlink: metadata.nlink(),
uid: metadata.uid(),
gid: metadata.gid(),
rdev: metadata.rdev(),
blksize: metadata.blksize(),
blocks: metadata.blocks(),
},
#[cfg(windows)]
file_attributes: metadata.file_attributes(),
})
}
// #[command]
// pub async fn exists(path: PathBuf) -> bool {
// path.exists()
// }

View File

@@ -3,15 +3,15 @@ use crate::{
utils,
};
use tauri::{
utils::assets::EmbeddedAssets, AboutMetadata, AppHandle, Context, CustomMenuItem, Manager,
Menu, MenuItem, Submenu, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowMenuEvent,
AboutMetadata, AppHandle, CustomMenuItem, Manager, Menu, MenuItem, Submenu, SystemTray,
SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem, WindowMenuEvent,
};
use tauri_plugin_positioner::{on_tray_event, Position, WindowExt};
// --- Menu
pub fn init(context: &Context<EmbeddedAssets>) -> Menu {
pub fn init() -> Menu {
let chat_conf = ChatConfJson::get_chat_conf();
let name = &context.package_info().name;
let name = "ChatGPT";
let app_menu = Submenu::new(
name,
Menu::new()
@@ -25,18 +25,18 @@ pub fn init(context: &Context<EmbeddedAssets>) -> Menu {
.add_native_item(MenuItem::Quit),
);
let always_on_top = CustomMenuItem::new("always_on_top".to_string(), "Always on Top")
.accelerator("CmdOrCtrl+T");
let stay_on_top =
CustomMenuItem::new("stay_on_top".to_string(), "Stay On Top").accelerator("CmdOrCtrl+T");
let titlebar =
CustomMenuItem::new("titlebar".to_string(), "Titlebar").accelerator("CmdOrCtrl+B");
let theme_light = CustomMenuItem::new("theme_light".to_string(), "Light");
let theme_dark = CustomMenuItem::new("theme_dark".to_string(), "Dark");
let is_dark = chat_conf.theme == "Dark";
let always_on_top_menu = if chat_conf.always_on_top {
always_on_top.selected()
let stay_on_top_menu = if chat_conf.stay_on_top {
stay_on_top.selected()
} else {
always_on_top
stay_on_top
};
let titlebar_menu = if chat_conf.titlebar {
titlebar.selected()
@@ -47,6 +47,10 @@ pub fn init(context: &Context<EmbeddedAssets>) -> Menu {
let preferences_menu = Submenu::new(
"Preferences",
Menu::with_items([
CustomMenuItem::new("control_center".to_string(), "Control Center")
.accelerator("CmdOrCtrl+Shift+P")
.into(),
MenuItem::Separator.into(),
Submenu::new(
"Theme",
Menu::new()
@@ -62,16 +66,16 @@ pub fn init(context: &Context<EmbeddedAssets>) -> Menu {
}),
)
.into(),
always_on_top_menu.into(),
stay_on_top_menu.into(),
#[cfg(target_os = "macos")]
titlebar_menu.into(),
MenuItem::Separator.into(),
#[cfg(target_os = "macos")]
CustomMenuItem::new("hide_dock_icon".to_string(), "Hide Dock Icon").into(),
CustomMenuItem::new("inject_script".to_string(), "Inject Script")
.accelerator("CmdOrCtrl+J")
.into(),
CustomMenuItem::new("control_center".to_string(), "Control Center")
.accelerator("CmdOrCtrl+Shift+P")
.into(),
MenuItem::Separator.into(),
CustomMenuItem::new("sync_prompts".to_string(), "Sync Prompts").into(),
MenuItem::Separator.into(),
CustomMenuItem::new("go_conf".to_string(), "Go to Config")
.accelerator("CmdOrCtrl+Shift+G")
@@ -119,7 +123,6 @@ pub fn init(context: &Context<EmbeddedAssets>) -> Menu {
CustomMenuItem::new("scroll_bottom".to_string(), "Scroll to Bottom of Screen")
.accelerator("CmdOrCtrl+Down"),
)
.add_native_item(MenuItem::Zoom)
.add_native_item(MenuItem::Separator)
.add_item(
CustomMenuItem::new("reload".to_string(), "Refresh the Screen")
@@ -127,9 +130,20 @@ pub fn init(context: &Context<EmbeddedAssets>) -> Menu {
),
);
let window_menu = Submenu::new(
"Window",
Menu::new()
.add_native_item(MenuItem::Minimize)
.add_native_item(MenuItem::Zoom),
);
let help_menu = Submenu::new(
"Help",
Menu::new()
.add_item(CustomMenuItem::new(
"chatgpt_log".to_string(),
"ChatGPT Log",
))
.add_item(CustomMenuItem::new("update_log".to_string(), "Update Log"))
.add_item(CustomMenuItem::new("report_bug".to_string(), "Report Bug"))
.add_item(
@@ -143,6 +157,7 @@ pub fn init(context: &Context<EmbeddedAssets>) -> Menu {
.add_submenu(preferences_menu)
.add_submenu(edit_menu)
.add_submenu(view_menu)
.add_submenu(window_menu)
.add_submenu(help_menu)
}
@@ -165,6 +180,24 @@ pub fn menu_handler(event: WindowMenuEvent<tauri::Wry>) {
"go_conf" => utils::open_file(utils::chat_root()),
"clear_conf" => utils::clear_conf(&app),
"awesome" => open(&app, conf::AWESOME_URL.to_string()),
"sync_prompts" => {
tauri::api::dialog::ask(
app.get_window("main").as_ref(),
"Sync Prompts",
"Data sync will enable all prompts, are you sure you want to sync?",
move |is_restart| {
if is_restart {
app.get_window("main")
.unwrap()
.eval("window.__sync_prompts && window.__sync_prompts()")
.unwrap()
}
},
);
}
"hide_dock_icon" => {
ChatConfJson::amend(&serde_json::json!({ "hide_dock_icon": true }), Some(app)).unwrap()
}
"titlebar" => {
let chat_conf = conf::ChatConfJson::get_chat_conf();
ChatConfJson::amend(
@@ -182,19 +215,15 @@ pub fn menu_handler(event: WindowMenuEvent<tauri::Wry>) {
};
ChatConfJson::amend(&serde_json::json!({ "theme": theme }), Some(app)).unwrap();
}
"always_on_top" => {
let mut always_on_top = state.always_on_top.lock().unwrap();
*always_on_top = !*always_on_top;
"stay_on_top" => {
let mut stay_on_top = state.stay_on_top.lock().unwrap();
*stay_on_top = !*stay_on_top;
menu_handle
.get_item(menu_id)
.set_selected(*always_on_top)
.unwrap();
win.set_always_on_top(*always_on_top).unwrap();
ChatConfJson::amend(
&serde_json::json!({ "always_on_top": *always_on_top }),
None,
)
.set_selected(*stay_on_top)
.unwrap();
win.set_always_on_top(*stay_on_top).unwrap();
ChatConfJson::amend(&serde_json::json!({ "stay_on_top": *stay_on_top }), None).unwrap();
}
// View
"reload" => win.eval("window.location.reload()").unwrap(),
@@ -218,6 +247,7 @@ pub fn menu_handler(event: WindowMenuEvent<tauri::Wry>) {
)
.unwrap(),
// Help
"chatgpt_log" => utils::open_file(utils::chat_root().join("chatgpt.log")),
"update_log" => open(&app, conf::UPDATE_LOG_URL.to_string()),
"report_bug" => open(&app, conf::ISSUES_URL.to_string()),
"dev_tools" => {
@@ -230,24 +260,67 @@ pub fn menu_handler(event: WindowMenuEvent<tauri::Wry>) {
// --- SystemTray Menu
pub fn tray_menu() -> SystemTray {
SystemTray::new().with_menu(SystemTrayMenu::new())
SystemTray::new().with_menu(
SystemTrayMenu::new()
.add_item(CustomMenuItem::new(
"control_center".to_string(),
"Control Center",
))
.add_item(CustomMenuItem::new(
"show_dock_icon".to_string(),
"Show Dock Icon",
))
.add_item(CustomMenuItem::new(
"hide_dock_icon".to_string(),
"Hide Dock Icon",
))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("quit".to_string(), "Quit ChatGPT")),
)
}
// --- SystemTray Event
pub fn tray_handler(handle: &AppHandle, event: SystemTrayEvent) {
let core_win = handle.get_window("core").unwrap();
on_tray_event(handle, &event);
if let SystemTrayEvent::LeftClick { .. } = event {
core_win.minimize().unwrap();
let mini_win = handle.get_window("mini").unwrap();
mini_win.move_window(Position::TrayCenter).unwrap();
let app = handle.clone();
if mini_win.is_visible().unwrap() {
mini_win.hide().unwrap();
} else {
mini_win.show().unwrap();
match event {
SystemTrayEvent::LeftClick { .. } => {
let chat_conf = conf::ChatConfJson::get_chat_conf();
if !chat_conf.hide_dock_icon {
let core_win = handle.get_window("core").unwrap();
core_win.minimize().unwrap();
}
let tray_win = handle.get_window("tray").unwrap();
tray_win.move_window(Position::TrayCenter).unwrap();
if tray_win.is_visible().unwrap() {
tray_win.hide().unwrap();
} else {
tray_win.show().unwrap();
}
}
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
"control_center" => app.get_window("main").unwrap().show().unwrap(),
"restart" => tauri::api::process::restart(&handle.env()),
"show_dock_icon" => {
ChatConfJson::amend(&serde_json::json!({ "hide_dock_icon": false }), Some(app))
.unwrap();
}
"hide_dock_icon" => {
let chat_conf = conf::ChatConfJson::get_chat_conf();
if !chat_conf.hide_dock_icon {
ChatConfJson::amend(&serde_json::json!({ "hide_dock_icon": true }), Some(app))
.unwrap();
}
}
"quit" => std::process::exit(0),
_ => (),
},
_ => (),
}
}

View File

@@ -1,4 +1,5 @@
pub mod cmd;
pub mod fs_extra;
pub mod menu;
pub mod setup;
pub mod window;

View File

@@ -5,40 +5,57 @@ pub fn init(app: &mut App) -> std::result::Result<(), Box<dyn std::error::Error>
let chat_conf = ChatConfJson::get_chat_conf();
let url = chat_conf.origin.to_string();
let theme = ChatConfJson::theme();
window::mini_window(&app.app_handle());
let handle = app.app_handle();
std::thread::spawn(move || {
window::tray_window(&handle);
});
if chat_conf.hide_dock_icon {
#[cfg(target_os = "macos")]
WindowBuilder::new(app, "core", WindowUrl::App(url.into()))
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
} else {
let app = app.handle();
std::thread::spawn(move || {
#[cfg(target_os = "macos")]
WindowBuilder::new(&app, "core", WindowUrl::App(url.into()))
.title("ChatGPT")
.resizable(true)
.fullscreen(false)
.inner_size(800.0, 600.0)
.hidden_title(true)
.theme(theme)
.always_on_top(chat_conf.always_on_top)
.always_on_top(chat_conf.stay_on_top)
.title_bar_style(ChatConfJson::titlebar())
.initialization_script(&utils::user_script())
.initialization_script(include_str!("../assets/html2canvas.js"))
.initialization_script(include_str!("../assets/jspdf.js"))
.initialization_script(include_str!("../assets/core.js"))
.initialization_script(include_str!("../assets/export.js"))
.initialization_script(include_str!("../assets/cmd.js"))
.user_agent(&chat_conf.ua_window)
.build()?;
.build()
.unwrap();
#[cfg(not(target_os = "macos"))]
WindowBuilder::new(app, "core", WindowUrl::App(url.into()))
WindowBuilder::new(&app, "core", WindowUrl::App(url.into()))
.title("ChatGPT")
.resizable(true)
.fullscreen(false)
.inner_size(800.0, 600.0)
.theme(theme)
.always_on_top(chat_conf.always_on_top)
.always_on_top(chat_conf.stay_on_top)
.initialization_script(&utils::user_script())
.initialization_script(include_str!("../assets/html2canvas.js"))
.initialization_script(include_str!("../assets/jspdf.js"))
.initialization_script(include_str!("../assets/core.js"))
.initialization_script(include_str!("../assets/export.js"))
.initialization_script(include_str!("../assets/cmd.js"))
.user_agent(&chat_conf.ua_window)
.build()?;
.build()
.unwrap();
});
}
Ok(())
}

View File

@@ -1,11 +1,14 @@
use crate::{conf, utils};
use tauri::{utils::config::WindowUrl, window::WindowBuilder};
pub fn mini_window(handle: &tauri::AppHandle) {
pub fn tray_window(handle: &tauri::AppHandle) {
let chat_conf = conf::ChatConfJson::get_chat_conf();
let theme = conf::ChatConfJson::theme();
let app = handle.clone();
WindowBuilder::new(handle, "mini", WindowUrl::App(chat_conf.origin.into()))
std::thread::spawn(move || {
WindowBuilder::new(&app, "tray", WindowUrl::App(chat_conf.origin.into()))
.title("ChatGPT")
.resizable(false)
.fullscreen(false)
.inner_size(360.0, 540.0)
@@ -17,9 +20,11 @@ pub fn mini_window(handle: &tauri::AppHandle) {
.initialization_script(include_str!("../assets/jspdf.js"))
.initialization_script(include_str!("../assets/core.js"))
.initialization_script(include_str!("../assets/export.js"))
.initialization_script(include_str!("../assets/cmd.js"))
.user_agent(&chat_conf.ua_tray)
.build()
.unwrap()
.hide()
.unwrap();
});
}

212
src-tauri/src/assets/cmd.js vendored Normal file
View File

@@ -0,0 +1,212 @@
// *** Core Script - CMD ***
function init() {
const styleDom = document.createElement('style');
styleDom.innerHTML = `form {
position: relative;
}
.chat-model-cmd-list {
position: absolute;
bottom: 60px;
max-height: 100px;
overflow: auto;
z-index: 9999;
}
.chat-model-cmd-list>div {
border: solid 2px #d8d8d8;
border-radius: 5px;
background-color: #fff;
}
.chat-model-cmd-list .cmd-item {
font-size: 12px;
border-bottom: solid 1px #888;
padding: 2px 4px;
display: flex;
user-select: none;
cursor: pointer;
}
.chat-model-cmd-list .cmd-item:last-child {
border-bottom: none;
}
.chat-model-cmd-list .cmd-item b {
display: inline-block;
width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 4px;
margin-right: 10px;
color: #2a2a2a;
}
.chat-model-cmd-list .cmd-item i {
width: 100%;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
color: #888;
}`;
document.head.append(styleDom);
if (window.formInterval) {
clearInterval(window.formInterval);
}
window.formInterval = setInterval(() => {
const form = document.querySelector("form");
if (!form) return;
clearInterval(window.formInterval);
cmdTip();
}, 200);
}
async function cmdTip() {
const chatModelJson = await invoke('get_chat_model_cmd') || {};
const data = chatModelJson.data;
if (data.length <= 0) return;
const modelDom = document.createElement('div');
modelDom.classList.add('chat-model-cmd-list');
// fix: tray window
if (__TAURI_METADATA__.__currentWindow.label === 'tray') {
modelDom.style.bottom = '40px';
}
document.querySelector('form').appendChild(modelDom);
const itemDom = (v) => `<div class="cmd-item" title="${v.prompt}" data-prompt="${encodeURIComponent(v.prompt)}"><b title="${v.cmd}">/${v.cmd}</b><i>${v.act}</i></div>`;
const searchInput = document.querySelector('form textarea');
// Enter a command starting with `/` and press a space to automatically fill `chatgpt prompt`.
// If more than one command appears in the search results, the first one will be used by default.
searchInput.addEventListener('keydown', (event) => {
if (!window.__CHAT_MODEL_CMD_PROMPT__) {
return;
}
// feat: https://github.com/lencx/ChatGPT/issues/54
if (event.keyCode === 9 && !window.__CHAT_MODEL_STATUS__) {
const strGroup = window.__CHAT_MODEL_CMD_PROMPT__.match(/\{([^{}]*)\}/) || [];
if (strGroup[1]) {
searchInput.value = `/${window.__CHAT_MODEL_CMD__}` + ` {${strGroup[1]}}` + ' |-> ';
window.__CHAT_MODEL_STATUS__ = 1;
}
event.preventDefault();
}
if (window.__CHAT_MODEL_STATUS__ === 1 && event.keyCode === 9) {
const data = searchInput.value.split('|->');
if (data[1]?.trim()) {
window.__CHAT_MODEL_CMD_PROMPT__ = window.__CHAT_MODEL_CMD_PROMPT__?.replace(/\{([^{}]*)\}/, `{${data[1]?.trim()}}`);
window.__CHAT_MODEL_STATUS__ = 2;
}
event.preventDefault();
}
// input text
if (window.__CHAT_MODEL_STATUS__ === 2 && event.keyCode === 9) {
console.log('«110» /src/assets/cmd.js ~> ', __CHAT_MODEL_STATUS__);
searchInput.value = window.__CHAT_MODEL_CMD_PROMPT__;
modelDom.innerHTML = '';
delete window.__CHAT_MODEL_STATUS__;
event.preventDefault();
}
// type in a space to complete the fill
if (event.keyCode === 32) {
searchInput.value = window.__CHAT_MODEL_CMD_PROMPT__;
modelDom.innerHTML = '';
delete window.__CHAT_MODEL_CMD_PROMPT__;
}
// send
if (event.keyCode === 13 && window.__CHAT_MODEL_CMD_PROMPT__) {
const data = searchInput.value.split('|->');
if (data[1]?.trim()) {
window.__CHAT_MODEL_CMD_PROMPT__ = window.__CHAT_MODEL_CMD_PROMPT__?.replace(/\{([^{}]*)\}/, `{${data[1]?.trim()}}`);
}
searchInput.value = window.__CHAT_MODEL_CMD_PROMPT__;
modelDom.innerHTML = '';
delete window.__CHAT_MODEL_CMD_PROMPT__;
delete window.__CHAT_MODEL_CMD__;
delete window.__CHAT_MODEL_STATUS__;
event.preventDefault();
}
});
searchInput.addEventListener('input', (event) => {
if (searchInput.value === '') {
delete window.__CHAT_MODEL_CMD_PROMPT__;
delete window.__CHAT_MODEL_CMD__;
delete window.__CHAT_MODEL_STATUS__;
}
if (window.__CHAT_MODEL_STATUS__) return;
const query = searchInput.value;
if (!query || !/^\//.test(query)) {
modelDom.innerHTML = '';
return;
}
// all cmd result
if (query === '/') {
modelDom.innerHTML = `<div>${data.map(itemDom).join('')}</div>`;
window.__CHAT_MODEL_CMD_PROMPT__ = data[0]?.prompt.trim();
window.__CHAT_MODEL_CMD__ = data[0]?.cmd.trim();
return;
}
const result = data.filter(i => new RegExp(query.substring(1)).test(i.cmd));
if (result.length > 0) {
modelDom.innerHTML = `<div>${result.map(itemDom).join('')}</div>`;
window.__CHAT_MODEL_CMD_PROMPT__ = result[0]?.prompt.trim();
window.__CHAT_MODEL_CMD__ = result[0]?.cmd.trim();
} else {
modelDom.innerHTML = '';
delete window.__CHAT_MODEL_CMD_PROMPT__;
delete window.__CHAT_MODEL_CMD__;
delete window.__CHAT_MODEL_STATUS__;
}
}, {
capture: false,
passive: true,
once: false
});
if (window.searchInterval) {
clearInterval(window.searchInterval);
}
window.searchInterval = setInterval(() => {
// The `chatgpt prompt` fill can be done by clicking on the event.
const searchDom = document.querySelector("form .chat-model-cmd-list>div");
if (!searchDom) return;
searchDom.addEventListener('click', (event) => {
// .cmd-item
const item = event.target.closest("div");
if (item) {
const val = decodeURIComponent(item.getAttribute('data-prompt'));
searchInput.value = val;
document.querySelector('form textarea').focus();
window.__CHAT_MODEL_CMD_PROMPT__ = val;
modelDom.innerHTML = '';
}
}, {
capture: false,
passive: true,
once: false
});
}, 200);
}
if (
document.readyState === "complete" ||
document.readyState === "interactive"
) {
init();
} else {
document.addEventListener("DOMContentLoaded", init);
}

View File

@@ -41,7 +41,7 @@ window.invoke = invoke;
window.transformCallback = transformCallback;
async function init() {
if (__TAURI_METADATA__.__currentWindow.label === 'mini') {
if (__TAURI_METADATA__.__currentWindow.label === 'tray') {
document.getElementsByTagName('html')[0].style['font-size'] = '70%';
}

View File

@@ -1,6 +1,7 @@
// *** Core Script - Export ***
// @ref: https://github.com/liady/ChatGPT-pdf
const buttonOuterHTMLFallback = `<button class="btn flex justify-center gap-2 btn-neutral" id="download-png-button">Try Again</button>`;
async function init() {
const chatConf = await invoke('get_chat_conf') || {};
if (window.buttonsInterval) {
@@ -11,14 +12,15 @@ async function init() {
if (!actionsArea) {
return;
}
const buttons = actionsArea.querySelectorAll("button");
const hasTryAgainButton = Array.from(buttons).some((button) => {
return !button.id?.includes("download");
});
if (hasTryAgainButton && buttons.length === 1) {
const TryAgainButton = actionsArea.querySelector("button");
if (shouldAddButtons(actionsArea)) {
let TryAgainButton = actionsArea.querySelector("button");
if (!TryAgainButton) {
const parentNode = document.createElement("div");
parentNode.innerHTML = buttonOuterHTMLFallback;
TryAgainButton = parentNode.querySelector("button");
}
addActionsButtons(actionsArea, TryAgainButton, chatConf);
} else if (!hasTryAgainButton) {
} else if (shouldRemoveButtons()) {
removeButtons();
}
}, 200);
@@ -29,32 +31,42 @@ const Format = {
PDF: "pdf",
};
function addActionsButtons(actionsArea, TryAgainButton, chatConf) {
const downloadButton = TryAgainButton.cloneNode(true);
downloadButton.id = "download-png-button";
downloadButton.innerText = "Generate PNG";
downloadButton.onclick = () => {
downloadThread();
};
actionsArea.appendChild(downloadButton);
const downloadPdfButton = TryAgainButton.cloneNode(true);
downloadPdfButton.id = "download-pdf-button";
downloadPdfButton.innerText = "Download PDF";
downloadPdfButton.onclick = () => {
downloadThread({ as: Format.PDF });
};
actionsArea.appendChild(downloadPdfButton);
if (new RegExp('//chat.openai.com').test(chatConf.origin)) {
const exportHtml = TryAgainButton.cloneNode(true);
exportHtml.id = "download-html-button";
exportHtml.innerText = "Share Link";
exportHtml.onclick = () => {
sendRequest();
};
actionsArea.appendChild(exportHtml);
function shouldRemoveButtons() {
const isOpenScreen = document.querySelector("h1.text-4xl");
if(isOpenScreen){
return true;
}
const inConversation = document.querySelector("form button>div");
if(inConversation){
return true;
}
return false;
}
function shouldAddButtons(actionsArea) {
// first, check if there's a "Try Again" button and no other buttons
const buttons = actionsArea.querySelectorAll("button");
const hasTryAgainButton = Array.from(buttons).some((button) => {
return !button.id?.includes("download");
});
if (hasTryAgainButton && buttons.length === 1) {
return true;
}
// otherwise, check if open screen is not visible
const isOpenScreen = document.querySelector("h1.text-4xl");
if (isOpenScreen) {
return false;
}
// check if the conversation is finished and there are no share buttons
const finishedConversation = document.querySelector("form button>svg");
const hasShareButtons = actionsArea.querySelectorAll("button[share-ext]");
if (finishedConversation && !hasShareButtons.length) {
return true;
}
return false;
}
function removeButtons() {
@@ -72,6 +84,33 @@ function removeButtons() {
}
}
function addActionsButtons(actionsArea, TryAgainButton) {
const downloadButton = TryAgainButton.cloneNode(true);
downloadButton.id = "download-png-button";
downloadButton.setAttribute("share-ext", "true");
downloadButton.innerText = "Generate PNG";
downloadButton.onclick = () => {
downloadThread();
};
actionsArea.appendChild(downloadButton);
const downloadPdfButton = TryAgainButton.cloneNode(true);
downloadPdfButton.id = "download-pdf-button";
downloadButton.setAttribute("share-ext", "true");
downloadPdfButton.innerText = "Download PDF";
downloadPdfButton.onclick = () => {
downloadThread({ as: Format.PDF });
};
actionsArea.appendChild(downloadPdfButton);
const exportHtml = TryAgainButton.cloneNode(true);
exportHtml.id = "download-html-button";
downloadButton.setAttribute("share-ext", "true");
exportHtml.innerText = "Share Link";
exportHtml.onclick = () => {
sendRequest();
};
actionsArea.appendChild(exportHtml);
}
function downloadThread({ as = Format.PNG } = {}) {
const elements = new Elements();
elements.fixLocation();
@@ -113,7 +152,7 @@ function handlePdf(imgData, canvas, pixelRatio) {
]);
var pdfWidth = pdf.internal.pageSize.getWidth();
var pdfHeight = pdf.internal.pageSize.getHeight();
pdf.addImage(imgData, "PNG", 0, 0, pdfWidth, pdfHeight);
pdf.addImage(imgData, "PNG", 0, 0, pdfWidth, pdfHeight, '', 'FAST');
const data = pdf.__private__.getArrayBuffer(pdf.__private__.buildDocument());
invoke('download', { name: `chatgpt-${Date.now()}.pdf`, blob: Array.from(new Uint8Array(data)) });

View File

@@ -14,18 +14,20 @@ pub const ISSUES_URL: &str = "https://github.com/lencx/ChatGPT/issues";
pub const UPDATE_LOG_URL: &str = "https://github.com/lencx/ChatGPT/blob/main/UPDATE_LOG.md";
pub const AWESOME_URL: &str = "https://github.com/lencx/ChatGPT/blob/main/AWESOME.md";
pub const DEFAULT_CHAT_CONF: &str = r#"{
"always_on_top": false,
"stay_on_top": false,
"theme": "Light",
"titlebar": true,
"hide_dock_icon": false,
"default_origin": "https://chat.openai.com",
"origin": "https://chat.openai.com",
"ua_window": "",
"ua_tray": ""
}"#;
pub const DEFAULT_CHAT_CONF_MAC: &str = r#"{
"always_on_top": false,
"stay_on_top": false,
"theme": "Light",
"titlebar": false,
"hide_dock_icon": false,
"default_origin": "https://chat.openai.com",
"origin": "https://chat.openai.com",
"ua_window": "",
@@ -33,22 +35,27 @@ pub const DEFAULT_CHAT_CONF_MAC: &str = r#"{
}"#;
pub struct ChatState {
pub always_on_top: Mutex<bool>,
pub stay_on_top: Mutex<bool>,
}
impl ChatState {
pub fn default(chat_conf: ChatConfJson) -> Self {
ChatState {
always_on_top: Mutex::new(chat_conf.always_on_top),
stay_on_top: Mutex::new(chat_conf.stay_on_top),
}
}
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct ChatConfJson {
// support macOS only
pub titlebar: bool,
pub always_on_top: bool,
pub hide_dock_icon: bool,
// macOS and Windows
pub theme: String,
pub stay_on_top: bool,
pub default_origin: String,
pub origin: String,
pub ua_window: String,

View File

@@ -7,15 +7,47 @@ mod app;
mod conf;
mod utils;
use app::{cmd, menu, setup};
use app::{cmd, fs_extra, menu, setup};
use conf::{ChatConfJson, ChatState};
use tauri::api::path;
use tauri_plugin_log::{
fern::colors::{Color, ColoredLevelConfig},
LogTarget, LoggerBuilder,
};
fn main() {
ChatConfJson::init();
// If the file does not exist, creating the file will block menu synchronization
utils::create_chatgpt_prompts();
let chat_conf = ChatConfJson::get_chat_conf();
let context = tauri::generate_context!();
let colors = ColoredLevelConfig {
error: Color::Red,
warn: Color::Yellow,
debug: Color::Blue,
info: Color::BrightGreen,
trace: Color::Cyan,
};
tauri::Builder::default()
// https://github.com/tauri-apps/tauri/pull/2736
.plugin(
LoggerBuilder::new()
.level(if cfg!(debug_assertions) {
log::LevelFilter::Debug
} else {
log::LevelFilter::Trace
})
.with_colors(colors)
.targets([
// LogTarget::LogDir,
// LOG PATH: ~/.chatgpt/ChatGPT.log
LogTarget::Folder(path::home_dir().unwrap().join(".chatgpt")),
LogTarget::Stdout,
LogTarget::Webview,
])
.build(),
)
.manage(ChatState::default(chat_conf))
.invoke_handler(tauri::generate_handler![
cmd::drag_window,
@@ -26,10 +58,16 @@ fn main() {
cmd::form_cancel,
cmd::form_confirm,
cmd::form_msg,
cmd::open_file,
cmd::get_chat_model_cmd,
cmd::parse_prompt,
cmd::window_reload,
cmd::cmd_list,
fs_extra::metadata,
])
.setup(setup::init)
.plugin(tauri_plugin_positioner::init())
.menu(menu::init(&context))
.menu(menu::init())
.system_tray(menu::tray_menu())
.on_menu_event(menu::menu_handler)
.on_system_tray_event(menu::tray_handler)

View File

@@ -1,4 +1,5 @@
use anyhow::Result;
use log::info;
use std::{
fs::{self, File},
path::{Path, PathBuf},
@@ -29,6 +30,14 @@ pub fn create_file(path: &Path) -> Result<File> {
File::create(path).map_err(Into::into)
}
pub fn create_chatgpt_prompts() {
let sync_file = chat_root().join("cache_model").join("chatgpt_prompts.json");
if !exists(&sync_file) {
create_file(&sync_file).unwrap();
fs::write(&sync_file, "[]").unwrap();
}
}
pub fn script_path() -> PathBuf {
let script_file = chat_root().join("main.js");
if !exists(&script_file) {
@@ -48,6 +57,7 @@ pub fn user_script() -> String {
}
pub fn open_file(path: PathBuf) {
info!("open_file: {}", path.to_string_lossy());
#[cfg(target_os = "macos")]
Command::new("open").arg("-R").arg(path).spawn().unwrap();

View File

@@ -7,15 +7,29 @@
},
"package": {
"productName": "ChatGPT",
"version": "0.3.0"
"version": "0.6.0"
},
"tauri": {
"allowlist": {
"all": true
"all": true,
"http": {
"all": true,
"scope": [
"https://**",
"http://**"
]
},
"fs": {
"all": true,
"scope": [
"$HOME/.chatgpt/*"
]
}
},
"systemTray": {
"iconPath": "icons/tray-icon.png",
"iconAsTemplate": true
"iconAsTemplate": true,
"menuOnLeftClick": false
},
"bundle": {
"active": true,
@@ -44,13 +58,13 @@
"shortDescription": "ChatGPT",
"targets": "all",
"windows": {
"webviewInstallMode": {
"silent": true,
"type": "downloadBootstrapper"
},
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
"timestampUrl": "",
"webviewInstallMode": {
"silent": true,
"type": "embedBootstrapper"
}
}
},
"security": {
@@ -71,7 +85,9 @@
"title": "ChatGPT",
"visible": false,
"width": 800,
"height": 600
"height": 600,
"minWidth": 800,
"minHeight": 600
}
]
}

98
src/components/Tags/index.tsx vendored Normal file
View File

@@ -0,0 +1,98 @@
import { FC, useEffect, useRef, useState } from 'react';
import { PlusOutlined } from '@ant-design/icons';
import { Input, Tag } from 'antd';
import type { InputRef } from 'antd';
import { DISABLE_AUTO_COMPLETE } from '@/utils';
interface TagsProps {
value?: string[];
onChange?: (v: string[]) => void;
}
const Tags: FC<TagsProps> = ({ value = [], onChange }) => {
const [tags, setTags] = useState<string[]>(value);
const [inputVisible, setInputVisible] = useState<boolean>(false);
const [inputValue, setInputValue] = useState('');
const inputRef = useRef<InputRef>(null);
useEffect(() => {
setTags(value);
}, [value])
useEffect(() => {
if (inputVisible) {
inputRef.current?.focus();
}
}, [inputVisible]);
const handleClose = (removedTag: string) => {
const newTags = tags.filter((tag) => tag !== removedTag);
setTags(newTags);
};
const showInput = () => {
setInputVisible(true);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleInputConfirm = () => {
if (inputValue && tags.indexOf(inputValue) === -1) {
const val = [...tags, inputValue];
setTags(val);
onChange && onChange(val);
}
setInputVisible(false);
setInputValue('');
};
const forMap = (tag: string) => {
const tagElem = (
<Tag
closable
onClose={(e) => {
e.preventDefault();
handleClose(tag);
}}
>
{tag}
</Tag>
);
return (
<span key={tag} style={{ display: 'inline-block' }}>
{tagElem}
</span>
);
};
const tagChild = tags.map(forMap);
return (
<>
<span style={{ marginBottom: 16 }}>{tagChild}</span>
{inputVisible && (
<Input
ref={inputRef}
type="text"
size="small"
style={{ width: 78 }}
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputConfirm}
onPressEnter={handleInputConfirm}
{...DISABLE_AUTO_COMPLETE}
/>
)}
{!inputVisible && (
<Tag onClick={showInput} className="chat-tag-new">
<PlusOutlined /> New Tag
</Tag>
)}
</>
);
};
export default Tags;

53
src/hooks/useChatModel.ts vendored Normal file
View File

@@ -0,0 +1,53 @@
import { useState, useEffect } from 'react';
import { clone } from 'lodash';
import { invoke } from '@tauri-apps/api';
import { CHAT_MODEL_JSON, CHAT_MODEL_CMD_JSON, readJSON, writeJSON } from '@/utils';
import useInit from '@/hooks/useInit';
export default function useChatModel(key: string, file = CHAT_MODEL_JSON) {
const [modelJson, setModelJson] = useState<Record<string, any>>({});
useInit(async () => {
const data = await readJSON(file, {
defaultVal: { name: 'ChatGPT Model', [key]: null },
});
setModelJson(data);
});
const modelSet = async (data: Record<string, any>[]|Record<string, any>) => {
const oData = clone(modelJson);
oData[key] = data;
await writeJSON(file, oData);
setModelJson(oData);
}
return { modelJson, modelSet, modelData: modelJson?.[key] || [] };
}
export function useCacheModel(file = '') {
const [modelCacheJson, setModelCacheJson] = useState<Record<string, any>[]>([]);
useEffect(() => {
if (!file) return;
(async () => {
const data = await readJSON(file, { isRoot: true, isList: true });
setModelCacheJson(data);
})();
}, [file]);
const modelCacheSet = async (data: Record<string, any>[], newFile = '') => {
await writeJSON(newFile ? newFile : file, data, { isRoot: true });
setModelCacheJson(data);
await modelCacheCmd();
}
const modelCacheCmd = async () => {
// Generate the `chat.model.cmd.json` file and refresh the page for the slash command to take effect.
const list = await invoke('cmd_list');
await writeJSON(CHAT_MODEL_CMD_JSON, { name: 'ChatGPT CMD', last_updated: Date.now(), data: list });
await invoke('window_reload', { label: 'core' });
};
return { modelCacheJson, modelCacheSet, modelCacheCmd };
}

44
src/hooks/useColumns.ts vendored Normal file
View File

@@ -0,0 +1,44 @@
import { useState, useCallback } from 'react';
export default function useColumns(columns: any[] = []) {
const [opType, setOpType] = useState('');
const [opRecord, setRecord] = useState<Record<string|symbol, any> | null>(null);
const [opTime, setNow] = useState<number | null>(null);
const [opExtra, setExtra] = useState<any>(null);
const handleRecord = useCallback((row: Record<string, any> | null, type: string) => {
setOpType(type);
setRecord(row);
setNow(Date.now());
}, []);
const resetRecord = useCallback(() => {
setRecord(null);
setOpType('');
setNow(Date.now());
}, []);
const opNew = useCallback(() => handleRecord(null, 'new'), [handleRecord]);
const cols = columns.map((i: any) => {
if (i.render) {
const opRender = i.render;
i.render = (text: string, row: Record<string, any>) => {
return opRender(text, row, { setRecord: handleRecord, setExtra });
};
}
return i;
});
return {
opTime,
opType,
opNew,
columns: cols,
opRecord,
setRecord: handleRecord,
resetRecord,
setExtra,
opExtra,
};
}

57
src/hooks/useData.ts vendored Normal file
View File

@@ -0,0 +1,57 @@
import { useState, useEffect } from 'react';
import { v4 } from 'uuid';
export const safeKey = Symbol('chat-id');
export default function useData(oData: any[]) {
const [opData, setData] = useState<any[]>([]);
useEffect(() => {
opInit(oData);
}, [])
const opAdd = (val: any) => {
const v = [val, ...opData];
setData(v);
return v;
};
const opInit = (val: any[] = []) => {
if (!val || !Array.isArray(val)) return;
console.log('«20» /src/hooks/useData.ts ~> ', val);
const nData = val.map(i => ({ [safeKey]: v4(), ...i }));
setData(nData);
};
const opRemove = (id: string) => {
const nData = opData.filter(i => i[safeKey] !== id);
setData(nData);
return nData;
};
const opReplace = (id: string, data: any) => {
const nData = [...opData];
const idx = opData.findIndex(v => v[safeKey] === id);
nData[idx] = data;
setData(nData);
return nData;
};
const opReplaceItems = (ids: string[], data: any) => {
const nData = [...opData];
let count = 0;
for (let i = 0; i < nData.length; i++) {
const v = nData[i];
if (ids.includes(v[safeKey])) {
count++;
nData[i] = { ...v, ...data };
}
if (count === ids.length) break;
}
setData(nData);
return nData;
};
return { opSafeKey: safeKey, opInit, opReplace, opAdd, opRemove, opData, opReplaceItems };
}

34
src/hooks/useEvent.ts vendored Normal file
View File

@@ -0,0 +1,34 @@
import { invoke, path, http, fs, dialog } from '@tauri-apps/api';
import useInit from '@/hooks/useInit';
import useChatModel, { useCacheModel } from '@/hooks/useChatModel';
import { GITHUB_PROMPTS_CSV_URL, chatRoot, genCmd } from '@/utils';
export default function useEvent() {
const { modelSet } = useChatModel('sync_prompts');
const { modelCacheSet } = useCacheModel();
// Using `emit` and `listen` will be triggered multiple times in development mode.
// So here we use `eval` to call `__sync_prompt`
useInit(() => {
(window as any).__sync_prompts = async () => {
const res = await http.fetch(GITHUB_PROMPTS_CSV_URL, {
method: 'GET',
responseType: http.ResponseType.Text,
});
const data = (res.data || '') as string;
if (res.ok) {
const file = await path.join(await chatRoot(), 'cache_model', 'chatgpt_prompts.json');
const list: Record<string, string>[] = await invoke('parse_prompt', { data });
const fmtList = list.map(i => ({ ...i, cmd: i.cmd ? i.cmd : genCmd(i.act), enable: true, tags: ['chatgpt-prompts'] }));
await modelCacheSet(fmtList, file);
modelSet({
id: 'chatgpt_prompts',
last_updated: Date.now(),
});
dialog.message('ChatGPT Prompts data has been synchronized!');
} else {
dialog.message('ChatGPT Prompts data sync failed, please try again!');
}
}
})
}

12
src/hooks/useInit.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
import { useRef, useEffect } from 'react';
// fix: Two interface requests will be made in development mode
export default function useInit(callback: () => void) {
const isInit = useRef(true);
useEffect(() => {
if (isInit.current) {
callback();
isInit.current = false;
}
})
}

37
src/hooks/useTable.tsx vendored Normal file
View File

@@ -0,0 +1,37 @@
import React, { useState } from 'react';
import { Table } from 'antd';
import type { TableRowSelection } from 'antd/es/table/interface';
import { safeKey } from '@/hooks/useData';
export default function useTableRowSelection() {
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [selectedRowIDs, setSelectedRowIDs] = useState<string[]>([]);
const onSelectChange = (newSelectedRowKeys: React.Key[], selectedRows: Record<string|symbol, any>) => {
const keys = selectedRows.map((i: any) => i[safeKey]);
setSelectedRowIDs(keys);
setSelectedRowKeys(newSelectedRowKeys);
};
const rowSelection: TableRowSelection<Record<string, any>> = {
selectedRowKeys,
onChange: onSelectChange,
selections: [
Table.SELECTION_ALL,
Table.SELECTION_INVERT,
Table.SELECTION_NONE,
],
};
return { rowSelection, selectedRowIDs };
}
export const TABLE_PAGINATION = {
hideOnSinglePage: true,
showSizeChanger: true,
showQuickJumper: true,
defaultPageSize: 5,
pageSizeOptions: [5, 10, 15, 20],
showTotal: (total: number) => <span>Total {total} items</span>,
};

View File

@@ -8,11 +8,19 @@
}
}
.ant-layout-sider-trigger {
user-select: none;
-webkit-user-select: none;
}
.chat-container {
padding: 20px;
overflow: hidden;
}
.ant-menu {
user-select: none;
-webkit-user-select: none;
.ant-menu-item {
background-color: #f8f8f8;
}

40
src/layout/index.tsx vendored
View File

@@ -1,9 +1,8 @@
import { FC, useState } from 'react';
import { Layout, Menu } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import Routes, { menuItems } from '@/routes';
import './index.scss';
const { Content, Footer, Sider } = Layout;
@@ -14,16 +13,43 @@ interface ChatLayoutProps {
const ChatLayout: FC<ChatLayoutProps> = ({ children }) => {
const [collapsed, setCollapsed] = useState(false);
const location = useLocation();
const go = useNavigate();
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider theme="light" collapsible collapsed={collapsed} onCollapse={(value) => setCollapsed(value)}>
<Layout style={{ minHeight: '100vh' }} hasSider>
<Sider
theme="light"
collapsible
collapsed={collapsed}
onCollapse={(value) => setCollapsed(value)}
style={{
overflow: 'auto',
height: '100vh',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
zIndex: 999,
}}
>
<div className="chat-logo"><img src="/logo.png" /></div>
<Menu defaultSelectedKeys={['/']} mode="vertical" items={menuItems} onClick={(i) => go(i.key)} />
<Menu
defaultSelectedKeys={[location.pathname]}
mode="inline"
inlineIndent={12}
items={menuItems}
defaultOpenKeys={['/model']}
onClick={(i) => go(i.key)}
/>
</Sider>
<Layout className="chat-layout">
<Content className="chat-container">
<Layout className="chat-layout" style={{ marginLeft: collapsed ? 80 : 200, transition: 'margin-left 300ms ease-out' }}>
<Content
className="chat-container"
style={{
overflow: 'inherit'
}}
>
<Routes />
</Content>
<Footer style={{ textAlign: 'center' }}>

46
src/main.scss vendored
View File

@@ -18,3 +18,49 @@ html, body {
padding: 0;
margin: 0;
}
.ant-table-selection-column {
width: 50px !important;
min-width: 50px !important;
}
.chat-prompts-val {
display: inline-block;
width: 100%;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.chat-add-btn {
margin-bottom: 5px;
}
.chat-prompts-tags {
.ant-tag {
margin: 2px;
}
}
.chat-sync-path {
font-size: 12px;
font-weight: 500;
color: #888;
margin-bottom: 5px;
line-height: 16px;
span {
display: inline-block;
// background-color: #d8d8d8;
color: #4096ff;
padding: 0 8px;
height: 20px;
line-height: 20px;
border-radius: 4px;
cursor: pointer;
text-decoration: underline;
}
}

14
src/main.tsx vendored
View File

@@ -2,15 +2,23 @@ import { StrictMode, Suspense } from 'react';
import { BrowserRouter } from 'react-router-dom';
import ReactDOM from 'react-dom/client';
import useEvent from '@/hooks/useEvent';
import Layout from '@/layout';
import './main.scss';
const App = () => {
useEvent();
return (
<BrowserRouter>
<Layout/>
</BrowserRouter>
);
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<StrictMode>
<Suspense fallback={null}>
<BrowserRouter>
<Layout/>
</BrowserRouter>
<App />
</Suspense>
</StrictMode>
);

66
src/routes.tsx vendored
View File

@@ -1,20 +1,33 @@
import { useRoutes } from 'react-router-dom';
import {
DesktopOutlined,
BulbOutlined
BulbOutlined,
SyncOutlined,
FileSyncOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { RouteObject } from 'react-router-dom';
import type { MenuProps } from 'antd';
import General from '@view/General';
import ChatGPTPrompts from '@view/ChatGPTPrompts';
import UserCustom from '@/view/model/UserCustom';
import SyncPrompts from '@/view/model/SyncPrompts';
import SyncCustom from '@/view/model/SyncCustom';
import SyncRecord from '@/view/model/SyncRecord';
export type ChatRouteObject = {
export type ChatRouteMetaObject = {
label: string;
icon?: React.ReactNode,
};
export const routes: Array<RouteObject & { meta: ChatRouteObject }> = [
type ChatRouteObject = {
path: string;
element?: JSX.Element;
hideMenu?: boolean;
meta?: ChatRouteMetaObject;
children?: ChatRouteObject[];
}
export const routes: Array<ChatRouteObject> = [
{
path: '/',
element: <General />,
@@ -24,19 +37,54 @@ export const routes: Array<RouteObject & { meta: ChatRouteObject }> = [
},
},
{
path: '/chatgpt-prompts',
element: <ChatGPTPrompts />,
path: '/model',
meta: {
label: 'ChatGPT Prompts',
label: 'Language Model',
icon: <BulbOutlined />,
},
children: [
{
path: 'user-custom',
element: <UserCustom />,
meta: {
label: 'User Custom',
icon: <UserOutlined />,
},
},
{
path: 'sync-prompts',
element: <SyncPrompts />,
meta: {
label: 'Sync Prompts',
icon: <SyncOutlined />,
},
},
{
path: 'sync-custom',
element: <SyncCustom />,
meta: {
label: 'Sync Custom',
icon: <FileSyncOutlined />,
},
},
{
path: 'sync-custom/:id',
element: <SyncRecord />,
hideMenu: true,
},
]
},
];
type MenuItem = Required<MenuProps>['items'][number];
export const menuItems: MenuItem[] = routes.map(i => ({
export const menuItems: MenuItem[] = routes
.filter((j) => !j.hideMenu)
.map(i => ({
...i.meta,
key: i.path || '',
children: i?.children
?.filter((j) => !j.hideMenu)
?.map((j) => ({ ...j.meta, key: `${i.path}/${j.path}` || ''})),
}));
export default () => {

68
src/utils.ts vendored Normal file
View File

@@ -0,0 +1,68 @@
import { readTextFile, writeTextFile, exists, createDir, BaseDirectory } from '@tauri-apps/api/fs';
import { homeDir, join, dirname } from '@tauri-apps/api/path';
import dayjs from 'dayjs';
export const CHAT_MODEL_JSON = 'chat.model.json';
export const CHAT_MODEL_CMD_JSON = 'chat.model.cmd.json';
export const CHAT_PROMPTS_CSV = 'chat.prompts.csv';
export const GITHUB_PROMPTS_CSV_URL = 'https://raw.githubusercontent.com/f/awesome-chatgpt-prompts/main/prompts.csv';
export const DISABLE_AUTO_COMPLETE = {
autoCapitalize: 'off',
autoComplete: 'off',
spellCheck: false
};
export const chatRoot = async () => {
return join(await homeDir(), '.chatgpt')
}
export const chatModelPath = async (): Promise<string> => {
return join(await chatRoot(), CHAT_MODEL_JSON);
}
// export const chatModelSyncPath = async (): Promise<string> => {
// return join(await chatRoot(), CHAT_MODEL_SYNC_JSON);
// }
export const chatPromptsPath = async (): Promise<string> => {
return join(await chatRoot(), CHAT_PROMPTS_CSV);
}
type readJSONOpts = { defaultVal?: Record<string, any>, isRoot?: boolean, isList?: boolean };
export const readJSON = async (path: string, opts: readJSONOpts = {}) => {
const { defaultVal = {}, isRoot = false, isList = false } = opts;
const root = await chatRoot();
const file = await join(isRoot ? '' : root, path);
if (!await exists(file)) {
await createDir(await dirname(file), { recursive: true });
await writeTextFile(file, isList ? '[]' : JSON.stringify({
name: 'ChatGPT',
link: 'https://github.com/lencx/ChatGPT',
...defaultVal,
}, null, 2))
}
try {
return JSON.parse(await readTextFile(file));
} catch(e) {
return {};
}
}
type writeJSONOpts = { dir?: string, isRoot?: boolean };
export const writeJSON = async (path: string, data: Record<string, any>, opts: writeJSONOpts = {}) => {
const { isRoot = false, dir = '' } = opts;
const root = await chatRoot();
const file = await join(isRoot ? '' : root, path);
if (isRoot && !await exists(await dirname(file))) {
await createDir(await join('.chatgpt', dir), { dir: BaseDirectory.Home });
}
await writeTextFile(file, JSON.stringify(data, null, 2));
}
export const fmtDate = (date: any) => dayjs(date).format('YYYY-MM-DD HH:mm:ss');
export const genCmd = (act: string) => act.replace(/\s+|\/+/g, '_').replace(/[^\d\w]/g, '').toLocaleLowerCase();

View File

@@ -1,7 +0,0 @@
export default function Dashboard() {
return (
<div>
TODO: ChatGPT Prompts
</div>
)
}

16
src/view/General.tsx vendored
View File

@@ -7,6 +7,8 @@ import { ask } from '@tauri-apps/api/dialog';
import { relaunch } from '@tauri-apps/api/process';
import { clone, omit, isEqual } from 'lodash';
import { DISABLE_AUTO_COMPLETE } from '@/utils';
const OriginLabel = ({ url }: { url: string }) => {
return (
<span>
@@ -15,12 +17,6 @@ const OriginLabel = ({ url }: { url: string }) => {
)
}
const disableAuto = {
autoCapitalize: 'off',
autoComplete: 'off',
spellCheck: false
}
export default function General() {
const [form] = Form.useForm();
const [platformInfo, setPlatform] = useState<string>('');
@@ -72,7 +68,7 @@ export default function General() {
<Radio value="Dark">Dark</Radio>
</Radio.Group>
</Form.Item>
<Form.Item label="Always on Top" name="always_on_top" valuePropName="checked">
<Form.Item label="Stay On Top" name="stay_on_top" valuePropName="checked">
<Switch />
</Form.Item>
{platformInfo === 'darwin' && (
@@ -81,13 +77,13 @@ export default function General() {
</Form.Item>
)}
<Form.Item label={<OriginLabel url={chatConf?.default_origin} />} name="origin">
<Input placeholder="https://chat.openai.com" {...disableAuto} />
<Input placeholder="https://chat.openai.com" {...DISABLE_AUTO_COMPLETE} />
</Form.Item>
<Form.Item label="User Agent (Window)" name="ua_window">
<Input.TextArea autoSize={{ minRows: 4, maxRows: 4 }} {...disableAuto} placeholder="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" />
<Input.TextArea autoSize={{ minRows: 4, maxRows: 4 }} {...DISABLE_AUTO_COMPLETE} placeholder="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" />
</Form.Item>
<Form.Item label="User Agent (SystemTray)" name="ua_tray">
<Input.TextArea autoSize={{ minRows: 4, maxRows: 4 }} {...disableAuto} placeholder="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" />
<Input.TextArea autoSize={{ minRows: 4, maxRows: 4 }} {...DISABLE_AUTO_COMPLETE} placeholder="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" />
</Form.Item>
<Form.Item>
<Space size={20}>

105
src/view/model/SyncCustom/Form.tsx vendored Normal file
View File

@@ -0,0 +1,105 @@
import { useEffect, useState, ForwardRefRenderFunction, useImperativeHandle, forwardRef } from 'react';
import { Form, Input, Select, Tooltip } from 'antd';
import { v4 } from 'uuid';
import type { FormProps } from 'antd';
import { DISABLE_AUTO_COMPLETE, chatRoot } from '@/utils';
import useInit from '@/hooks/useInit';
interface SyncFormProps {
record?: Record<string|symbol, any> | null;
}
const initFormValue = {
act: '',
enable: true,
tags: [],
prompt: '',
};
const SyncForm: ForwardRefRenderFunction<FormProps, SyncFormProps> = ({ record }, ref) => {
const [form] = Form.useForm();
useImperativeHandle(ref, () => ({ form }));
const [root, setRoot] = useState('');
useInit(async () => {
setRoot(await chatRoot());
});
useEffect(() => {
if (record) {
form.setFieldsValue(record);
}
}, [record]);
const pathOptions = (
<Form.Item noStyle name="protocol" initialValue="https">
<Select>
<Select.Option value="local">{root}</Select.Option>
<Select.Option value="http">http://</Select.Option>
<Select.Option value="https">https://</Select.Option>
</Select>
</Form.Item>
);
const extOptions = (
<Form.Item noStyle name="ext" initialValue="json">
<Select>
<Select.Option value="csv">.csv</Select.Option>
<Select.Option value="json">.json</Select.Option>
</Select>
</Form.Item>
);
const jsonTip = (
<Tooltip
title={<pre>{JSON.stringify([
{ cmd: '', act: '', prompt: '' },
{ cmd: '', act: '', prompt: '' },
], null, 2)}</pre>}
>
<a>JSON</a>
</Tooltip>
);
const csvTip = (
<Tooltip
title={<pre>{`"cmd","act","prompt"
"cmd","act","prompt"
"cmd","act","prompt"
"cmd","act","prompt"`}</pre>}
>
<a>CSV</a>
</Tooltip>
);
return (
<>
<Form
form={form}
labelCol={{ span: 4 }}
initialValues={initFormValue}
>
<Form.Item
label="Name"
name="name"
rules={[{ required: true, message: 'Please input name!' }]}
>
<Input placeholder="Please input name" {...DISABLE_AUTO_COMPLETE} />
</Form.Item>
<Form.Item
label="PATH"
name="path"
rules={[{ required: true, message: 'Please input path!' }]}
>
<Input placeholder="YOUR_PATH" addonBefore={pathOptions} addonAfter={extOptions} {...DISABLE_AUTO_COMPLETE} />
</Form.Item>
<Form.Item style={{ display: 'none' }} name="id" initialValue={v4().replace(/-/g, '')}><input /></Form.Item>
</Form>
<div className="tip">
<p>The file supports only {csvTip} and {jsonTip} formats.</p>
</div>
</>
)
}
export default forwardRef(SyncForm);

81
src/view/model/SyncCustom/config.tsx vendored Normal file
View File

@@ -0,0 +1,81 @@
import { useState } from 'react';
import { Tag, Space, Popconfirm } from 'antd';
import { HistoryOutlined } from '@ant-design/icons';
import { shell, path } from '@tauri-apps/api';
import { Link } from 'react-router-dom';
import useInit from '@/hooks/useInit';
import { chatRoot, fmtDate } from '@/utils';
export const syncColumns = () => [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 100,
},
{
title: 'Protocol',
dataIndex: 'protocol',
key: 'protocol',
width: 80,
render: (v: string) => <Tag>{v}</Tag>,
},
{
title: 'PATH',
dataIndex: 'path',
key: 'path',
width: 180,
render: (_: string, row: any) => <RenderPath row={row} />
},
{
title: 'Last updated',
dataIndex: 'last_updated',
key: 'last_updated',
width: 140,
render: (v: number) => (
<div style={{ textAlign: 'center' }}>
<HistoryOutlined style={{ marginRight: 5, color: v ? '#52c41a' : '#ff4d4f' }} />
{ v ? fmtDate(v) : ''}
</div>
),
},
{
title: 'Action',
fixed: 'right',
width: 150,
render: (_: any, row: any, actions: any) => {
return (
<Space>
<a onClick={() => actions.setRecord(row, 'sync')}>Sync</a>
{row.last_updated && <Link to={`${row.id}`} state={row}>View</Link>}
<a onClick={() => actions.setRecord(row, 'edit')}>Edit</a>
<Popconfirm
title="Are you sure to delete this path?"
onConfirm={() => actions.setRecord(row, 'delete')}
okText="Yes"
cancelText="No"
>
<a>Delete</a>
</Popconfirm>
</Space>
)
}
}
];
const RenderPath = ({ row }: any) => {
const [filePath, setFilePath] = useState('');
useInit(async () => {
setFilePath(await getPath(row));
})
return <a onClick={() => shell.open(filePath)}>{filePath}</a>
};
export const getPath = async (row: any) => {
if (!/^http/.test(row.protocol)) {
return await path.join(await chatRoot(), row.path) + `.${row.ext}`;
} else {
return `${row.protocol}://${row.path}.${row.ext}`;
}
}

140
src/view/model/SyncCustom/index.tsx vendored Normal file
View File

@@ -0,0 +1,140 @@
import { useState, useRef, useEffect } from 'react';
import { Table, Modal, Button, message } from 'antd';
import { invoke, http, path, fs } from '@tauri-apps/api';
import useData from '@/hooks/useData';
import useChatModel, { useCacheModel } from '@/hooks/useChatModel';
import useColumns from '@/hooks/useColumns';
import { TABLE_PAGINATION } from '@/hooks/useTable';
import { CHAT_MODEL_JSON, chatRoot, readJSON, genCmd } from '@/utils';
import { syncColumns, getPath } from './config';
import SyncForm from './Form';
const setTag = (data: Record<string, any>[]) => data.map((i) => ({ ...i, tags: ['user-sync'], enable: true }))
export default function SyncCustom() {
const [isVisible, setVisible] = useState(false);
const { modelData, modelSet } = useChatModel('sync_custom', CHAT_MODEL_JSON);
const { modelCacheCmd, modelCacheSet } = useCacheModel();
const { opData, opInit, opAdd, opRemove, opReplace, opSafeKey } = useData([]);
const { columns, ...opInfo } = useColumns(syncColumns());
const formRef = useRef<any>(null);
const hide = () => {
setVisible(false);
opInfo.resetRecord();
};
useEffect(() => {
if (modelData.length <= 0) return;
opInit(modelData);
}, [modelData]);
useEffect(() => {
if (!opInfo.opType) return;
if (opInfo.opType === 'sync') {
const filename = `${opInfo?.opRecord?.id}.json`;
handleSync(filename).then(() => {
const data = opReplace(opInfo?.opRecord?.[opSafeKey], { ...opInfo?.opRecord, last_updated: Date.now() });
modelSet(data);
opInfo.resetRecord();
});
}
if (['edit', 'new'].includes(opInfo.opType)) {
setVisible(true);
}
if (['delete'].includes(opInfo.opType)) {
const data = opRemove(opInfo?.opRecord?.[opSafeKey]);
modelSet(data);
opInfo.resetRecord();
}
}, [opInfo.opType, formRef]);
const handleSync = async (filename: string) => {
const record = opInfo?.opRecord;
const isJson = /json$/.test(record?.ext);
const file = await path.join(await chatRoot(), 'cache_model', filename);
const filePath = await getPath(record);
// https or http
if (/^http/.test(record?.protocol)) {
const res = await http.fetch(filePath, {
method: 'GET',
responseType: isJson ? 1 : 2,
});
if (res.ok) {
if (isJson) {
// parse json
await modelCacheSet(setTag(Array.isArray(res?.data) ? res?.data : []), file);
} else {
// parse csv
const list: Record<string, string>[] = await invoke('parse_prompt', { data: res?.data });
const fmtList = list.map(i => ({ ...i, cmd: i.cmd ? i.cmd : genCmd(i.act), enable: true, tags: ['user-sync'] }));
await modelCacheSet(fmtList, file);
}
await modelCacheCmd();
message.success('ChatGPT Prompts data has been synchronized!');
} else {
message.error('ChatGPT Prompts data sync failed, please try again!');
}
return;
}
// local
if (isJson) {
// parse json
const data = await readJSON(filePath, { isRoot: true });
await modelCacheSet(setTag(Array.isArray(data) ? data : []), file);
} else {
// parse csv
const data = await fs.readTextFile(filePath);
const list: Record<string, string>[] = await invoke('parse_prompt', { data });
const fmtList = list.map(i => ({ ...i, cmd: i.cmd ? i.cmd : genCmd(i.act), enable: true, tags: ['user-sync'] }));
await modelCacheSet(fmtList, file);
}
await modelCacheCmd();
};
const handleOk = () => {
formRef.current?.form?.validateFields()
.then((vals: Record<string, any>) => {
let data = [];
switch (opInfo.opType) {
case 'new': data = opAdd(vals); break;
case 'edit': data = opReplace(opInfo?.opRecord?.[opSafeKey], vals); break;
default: break;
}
modelSet(data);
hide();
})
};
return (
<div>
<Button
className="chat-add-btn"
type="primary"
onClick={opInfo.opNew}
>
Add PATH
</Button>
<Table
key="id"
rowKey="name"
columns={columns}
scroll={{ x: 800 }}
dataSource={opData}
pagination={TABLE_PAGINATION}
/>
<Modal
open={isVisible}
onCancel={hide}
title="Model PATH"
onOk={handleOk}
destroyOnClose
maskClosable={false}
>
<SyncForm ref={formRef} record={opInfo?.opRecord} />
</Modal>
</div>
)
}

47
src/view/model/SyncPrompts/config.tsx vendored Normal file
View File

@@ -0,0 +1,47 @@
import { Switch, Tag, Tooltip } from 'antd';
import { genCmd } from '@/utils';
export const syncColumns = () => [
{
title: '/{cmd}',
dataIndex: 'cmd',
fixed: 'left',
// width: 120,
key: 'cmd',
render: (_: string, row: Record<string, string>) => (
<Tag color="#2a2a2a">/{genCmd(row.act)}</Tag>
),
},
{
title: 'Act',
dataIndex: 'act',
key: 'act',
// width: 200,
},
{
title: 'Tags',
dataIndex: 'tags',
key: 'tags',
// width: 150,
render: () => <Tag>chatgpt-prompts</Tag>,
},
{
title: 'Enable',
dataIndex: 'enable',
key: 'enable',
// width: 80,
render: (v: boolean = false, row: Record<string, any>, action: Record<string, any>) => (
<Switch checked={v} onChange={(v) => action.setRecord({ ...row, enable: v }, 'enable')} />
),
},
{
title: 'Prompt',
dataIndex: 'prompt',
key: 'prompt',
// width: 300,
render: (v: string) => (
<Tooltip overlayInnerStyle={{ width: 350 }} title={v}><span className="chat-prompts-val">{v}</span></Tooltip>
),
},
];

12
src/view/model/SyncPrompts/index.scss vendored Normal file
View File

@@ -0,0 +1,12 @@
.chat-table-tip, .chat-table-btns {
display: flex;
justify-content: space-between;
}
.chat-table-btns {
margin-bottom: 5px;
.num {
margin-left: 10px;
}
}

109
src/view/model/SyncPrompts/index.tsx vendored Normal file
View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { Table, Button, message, Popconfirm } from 'antd';
import { invoke, http, path, shell } from '@tauri-apps/api';
import useInit from '@/hooks/useInit';
import useData from '@/hooks/useData';
import useColumns from '@/hooks/useColumns';
import useChatModel, { useCacheModel } from '@/hooks/useChatModel';
import useTable, { TABLE_PAGINATION } from '@/hooks/useTable';
import { fmtDate, chatRoot, GITHUB_PROMPTS_CSV_URL, genCmd } from '@/utils';
import { syncColumns } from './config';
import './index.scss';
const promptsURL = 'https://github.com/f/awesome-chatgpt-prompts/blob/main/prompts.csv';
export default function SyncPrompts() {
const { rowSelection, selectedRowIDs } = useTable();
const [jsonPath, setJsonPath] = useState('');
const { modelJson, modelSet } = useChatModel('sync_prompts');
const { modelCacheJson, modelCacheSet } = useCacheModel(jsonPath);
const { opData, opInit, opReplace, opReplaceItems, opSafeKey } = useData([]);
const { columns, ...opInfo } = useColumns(syncColumns());
const lastUpdated = modelJson?.sync_prompts?.last_updated;
const selectedItems = rowSelection.selectedRowKeys || [];
useInit(async () => {
setJsonPath(await path.join(await chatRoot(), 'cache_model', 'chatgpt_prompts.json'));
});
useEffect(() => {
if (modelCacheJson.length <= 0) return;
opInit(modelCacheJson);
}, [modelCacheJson.length]);
const handleSync = async () => {
const res = await http.fetch(GITHUB_PROMPTS_CSV_URL, {
method: 'GET',
responseType: http.ResponseType.Text,
});
const data = (res.data || '') as string;
if (res.ok) {
// const content = data.replace(/"(\s+)?,(\s+)?"/g, '","');
const list: Record<string, string>[] = await invoke('parse_prompt', { data });
const fmtList = list.map(i => ({ ...i, cmd: i.cmd ? i.cmd : genCmd(i.act), enable: true, tags: ['chatgpt-prompts'] }));
await modelCacheSet(fmtList);
opInit(fmtList);
modelSet({
id: 'chatgpt_prompts',
last_updated: Date.now(),
});
message.success('ChatGPT Prompts data has been synchronized!');
} else {
message.error('ChatGPT Prompts data sync failed, please try again!');
}
};
useEffect(() => {
if (opInfo.opType === 'enable') {
const data = opReplace(opInfo?.opRecord?.[opSafeKey], opInfo?.opRecord);
modelCacheSet(data);
}
}, [opInfo.opTime]);
const handleEnable = (isEnable: boolean) => {
const data = opReplaceItems(selectedRowIDs, { enable: isEnable })
modelCacheSet(data);
};
return (
<div>
<div className="chat-table-btns">
<div>
{selectedItems.length > 0 && (
<>
<Button type="primary" onClick={() => handleEnable(true)}>Enable</Button>
<Button onClick={() => handleEnable(false)}>Disable</Button>
<span className="num">Selected {selectedItems.length} items</span>
</>
)}
</div>
<Popconfirm
title={<span>Data sync will enable all prompts,<br/>are you sure you want to sync?</span>}
placement="topLeft"
onConfirm={handleSync}
okText="Yes"
cancelText="No"
>
<Button type="primary">Sync</Button>
</Popconfirm>
</div>
<div className="chat-table-tip">
<div className="chat-sync-path">
<div>PATH: <a onClick={() => shell.open(promptsURL)} target="_blank" title={promptsURL}>f/awesome-chatgpt-prompts/prompts.csv</a></div>
<div>CACHE: <a onClick={() => shell.open(jsonPath)} target="_blank" title={jsonPath}>{jsonPath}</a></div>
</div>
{lastUpdated && <span style={{ marginLeft: 10, color: '#888', fontSize: 12 }}>Last updated on {fmtDate(lastUpdated)}</span>}
</div>
<Table
key={lastUpdated}
rowKey="act"
columns={columns}
scroll={{ x: 'auto' }}
dataSource={opData}
rowSelection={rowSelection}
pagination={TABLE_PAGINATION}
/>
</div>
)
}

47
src/view/model/SyncRecord/config.tsx vendored Normal file
View File

@@ -0,0 +1,47 @@
import { Switch, Tag, Tooltip } from 'antd';
import { genCmd } from '@/utils';
export const syncColumns = () => [
{
title: '/{cmd}',
dataIndex: 'cmd',
fixed: 'left',
// width: 120,
key: 'cmd',
render: (_: string, row: Record<string, string>) => (
<Tag color="#2a2a2a">/{genCmd(row.act)}</Tag>
),
},
{
title: 'Act',
dataIndex: 'act',
key: 'act',
// width: 200,
},
{
title: 'Tags',
dataIndex: 'tags',
key: 'tags',
// width: 150,
render: () => <Tag>chatgpt-prompts</Tag>,
},
{
title: 'Enable',
dataIndex: 'enable',
key: 'enable',
// width: 80,
render: (v: boolean = false, row: Record<string, any>, action: Record<string, any>) => (
<Switch checked={v} onChange={(v) => action.setRecord({ ...row, enable: v }, 'enable')} />
),
},
{
title: 'Prompt',
dataIndex: 'prompt',
key: 'prompt',
// width: 300,
render: (v: string) => (
<Tooltip overlayInnerStyle={{ width: 350 }} title={v}><span className="chat-prompts-val">{v}</span></Tooltip>
),
},
];

85
src/view/model/SyncRecord/index.tsx vendored Normal file
View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { Table, Button } from 'antd';
import { shell, path } from '@tauri-apps/api';
import useColumns from '@/hooks/useColumns';
import useData from '@/hooks/useData';
import { useCacheModel } from '@/hooks/useChatModel';
import useTable, { TABLE_PAGINATION } from '@/hooks/useTable';
import { fmtDate, chatRoot } from '@/utils';
import { getPath } from '@/view/model/SyncCustom/config';
import { syncColumns } from './config';
import useInit from '@/hooks/useInit';
export default function SyncRecord() {
const location = useLocation();
const [filePath, setFilePath] = useState('');
const [jsonPath, setJsonPath] = useState('');
const state = location?.state;
const { rowSelection, selectedRowIDs } = useTable();
const { modelCacheJson, modelCacheSet } = useCacheModel(jsonPath);
const { opData, opInit, opReplace, opReplaceItems, opSafeKey } = useData([]);
const { columns, ...opInfo } = useColumns(syncColumns());
const selectedItems = rowSelection.selectedRowKeys || [];
useInit(async () => {
setFilePath(await getPath(state));
setJsonPath(await path.join(await chatRoot(), 'cache_model', `${state?.id}.json`));
})
useEffect(() => {
if (modelCacheJson.length <= 0) return;
opInit(modelCacheJson);
}, [modelCacheJson.length]);
useEffect(() => {
if (opInfo.opType === 'enable') {
const data = opReplace(opInfo?.opRecord?.[opSafeKey], opInfo?.opRecord);
modelCacheSet(data);
}
}, [opInfo.opTime]);
const handleEnable = (isEnable: boolean) => {
const data = opReplaceItems(selectedRowIDs, { enable: isEnable })
modelCacheSet(data);
};
return (
<div>
<div className="chat-table-btns">
<div>
<Button shape="round" icon={<ArrowLeftOutlined />} onClick={() => history.back()} />
</div>
<div>
{selectedItems.length > 0 && (
<>
<Button type="primary" onClick={() => handleEnable(true)}>Enable</Button>
<Button onClick={() => handleEnable(false)}>Disable</Button>
<span className="num">Selected {selectedItems.length} items</span>
</>
)}
</div>
</div>
<div className="chat-table-tip">
<div className="chat-sync-path">
<div>PATH: <a onClick={() => shell.open(filePath)} target="_blank" title={filePath}>{filePath}</a></div>
<div>CACHE: <a onClick={() => shell.open(jsonPath)} target="_blank" title={jsonPath}>{jsonPath}</a></div>
</div>
{state?.last_updated && <span style={{ marginLeft: 10, color: '#888', fontSize: 12 }}>Last updated on {fmtDate(state?.last_updated)}</span>}
</div>
<Table
key="prompt"
rowKey="act"
columns={columns}
scroll={{ x: 'auto' }}
dataSource={opData}
rowSelection={rowSelection}
pagination={TABLE_PAGINATION}
/>
</div>
)
}

66
src/view/model/UserCustom/Form.tsx vendored Normal file
View File

@@ -0,0 +1,66 @@
import { useEffect, ForwardRefRenderFunction, useImperativeHandle, forwardRef } from 'react';
import { Form, Input, Switch } from 'antd';
import type { FormProps } from 'antd';
import Tags from '@comps/Tags';
import { DISABLE_AUTO_COMPLETE } from '@/utils';
interface UserCustomFormProps {
record?: Record<string|symbol, any> | null;
}
const initFormValue = {
act: '',
enable: true,
tags: [],
prompt: '',
};
const UserCustomForm: ForwardRefRenderFunction<FormProps, UserCustomFormProps> = ({ record }, ref) => {
const [form] = Form.useForm();
useImperativeHandle(ref, () => ({ form }));
useEffect(() => {
if (record) {
form.setFieldsValue(record);
}
}, [record]);
return (
<Form
form={form}
labelCol={{ span: 4 }}
initialValues={initFormValue}
>
<Form.Item
label="/{cmd}"
name="cmd"
rules={[{ required: true, message: 'Please input {cmd}!' }]}
>
<Input placeholder="Please input {cmd}" {...DISABLE_AUTO_COMPLETE} />
</Form.Item>
<Form.Item
label="Act"
name="act"
rules={[{ required: true, message: 'Please input act!' }]}
>
<Input placeholder="Please input act" {...DISABLE_AUTO_COMPLETE} />
</Form.Item>
<Form.Item label="Tags" name="tags">
<Tags value={record?.tags} />
</Form.Item>
<Form.Item label="Enable" name="enable" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
label="Prompt"
name="prompt"
rules={[{ required: true, message: 'Please input prompt!' }]}
>
<Input.TextArea rows={4} placeholder="Please input prompt" {...DISABLE_AUTO_COMPLETE} />
</Form.Item>
</Form>
)
}
export default forwardRef(UserCustomForm);

64
src/view/model/UserCustom/config.tsx vendored Normal file
View File

@@ -0,0 +1,64 @@
import { Tag, Switch, Tooltip, Space, Popconfirm } from 'antd';
export const modelColumns = () => [
{
title: '/{cmd}',
dataIndex: 'cmd',
fixed: 'left',
width: 120,
key: 'cmd',
render: (v: string) => <Tag color="#2a2a2a">/{v}</Tag>
},
{
title: 'Act',
dataIndex: 'act',
key: 'act',
width: 200,
},
{
title: 'Tags',
dataIndex: 'tags',
key: 'tags',
width: 150,
render: (v: string[]) => (
<span className="chat-prompts-tags">{v?.map(i => <Tag key={i}>{i}</Tag>)}</span>
),
},
{
title: 'Enable',
dataIndex: 'enable',
key: 'enable',
width: 80,
render: (v: boolean = false, row: Record<string, any>, action: Record<string, any>) => (
<Switch checked={v} onChange={(v) => action.setRecord({ ...row, enable: v }, 'enable')} />
),
},
{
title: 'Prompt',
dataIndex: 'prompt',
key: 'prompt',
width: 300,
render: (v: string) => (
<Tooltip overlayInnerStyle={{ width: 350 }} title={v}><span className="chat-prompts-val">{v}</span></Tooltip>
),
},
{
title: 'Action',
key: 'action',
fixed: 'right',
width: 120,
render: (_: any, row: any, actions: any) => (
<Space size="middle">
<a onClick={() => actions.setRecord(row, 'edit')}>Edit</a>
<Popconfirm
title="Are you sure to delete this model?"
onConfirm={() => actions.setRecord(row, 'delete')}
okText="Yes"
cancelText="No"
>
<a>Delete</a>
</Popconfirm>
</Space>
),
}
];

139
src/view/model/UserCustom/index.tsx vendored Normal file
View File

@@ -0,0 +1,139 @@
import { useState, useRef, useEffect } from 'react';
import { Table, Button, Modal, message } from 'antd';
import { shell, path } from '@tauri-apps/api';
import useInit from '@/hooks/useInit';
import useData from '@/hooks/useData';
import useChatModel, { useCacheModel } from '@/hooks/useChatModel';
import useColumns from '@/hooks/useColumns';
import useTable, { TABLE_PAGINATION } from '@/hooks/useTable';
import { chatRoot, fmtDate } from '@/utils';
import { modelColumns } from './config';
import UserCustomForm from './Form';
export default function LanguageModel() {
const { rowSelection, selectedRowIDs } = useTable();
const [isVisible, setVisible] = useState(false);
const [jsonPath, setJsonPath] = useState('');
const { modelJson, modelSet } = useChatModel('user_custom');
const { modelCacheJson, modelCacheSet } = useCacheModel(jsonPath);
const { opData, opInit, opReplaceItems, opAdd, opRemove, opReplace, opSafeKey } = useData([]);
const { columns, ...opInfo } = useColumns(modelColumns());
const lastUpdated = modelJson?.user_custom?.last_updated;
const selectedItems = rowSelection.selectedRowKeys || [];
const formRef = useRef<any>(null);
useInit(async () => {
setJsonPath(await path.join(await chatRoot(), 'cache_model', 'user_custom.json'));
});
useEffect(() => {
if (modelCacheJson.length <= 0) return;
opInit(modelCacheJson);
}, [modelCacheJson.length]);
useEffect(() => {
if (!opInfo.opType) return;
if (['edit', 'new'].includes(opInfo.opType)) {
setVisible(true);
}
if (['delete'].includes(opInfo.opType)) {
const data = opRemove(opInfo?.opRecord?.[opSafeKey]);
modelCacheSet(data);
opInfo.resetRecord();
}
}, [opInfo.opType, formRef]);
useEffect(() => {
if (opInfo.opType === 'enable') {
const data = opReplace(opInfo?.opRecord?.[opSafeKey], opInfo?.opRecord);
modelCacheSet(data);
}
}, [opInfo.opTime])
useEffect(() => {
if (opInfo.opType === 'enable') {
const data = opReplace(opInfo?.opRecord?.[opSafeKey], opInfo?.opRecord);
modelCacheSet(data);
}
}, [opInfo.opTime]);
const handleEnable = (isEnable: boolean) => {
const data = opReplaceItems(selectedRowIDs, { enable: isEnable })
modelCacheSet(data);
};
const hide = () => {
setVisible(false);
opInfo.resetRecord();
};
const handleOk = () => {
formRef.current?.form?.validateFields()
.then(async (vals: Record<string, any>) => {
if (modelCacheJson.map((i: any) => i.cmd).includes(vals.cmd) && opInfo?.opRecord?.cmd !== vals.cmd) {
message.warning(`"cmd: /${vals.cmd}" already exists, please change the "${vals.cmd}" name and resubmit.`);
return;
}
let data = [];
switch (opInfo.opType) {
case 'new': data = opAdd(vals); break;
case 'edit': data = opReplace(opInfo?.opRecord?.[opSafeKey], vals); break;
default: break;
}
await modelCacheSet(data);
opInit(data);
modelSet({
id: 'user_custom',
last_updated: Date.now(),
});
hide();
})
};
const modalTitle = `${({ new: 'Create', edit: 'Edit' })[opInfo.opType]} Model`;
return (
<div>
<div className="chat-table-btns">
<Button className="chat-add-btn" type="primary" onClick={opInfo.opNew}>Add Model</Button>
<div>
{selectedItems.length > 0 && (
<>
<Button type="primary" onClick={() => handleEnable(true)}>Enable</Button>
<Button onClick={() => handleEnable(false)}>Disable</Button>
<span className="num">Selected {selectedItems.length} items</span>
</>
)}
</div>
</div>
{/* <div className="chat-model-path">PATH: <span onClick={handleOpenFile}>{modelPath}</span></div> */}
<div className="chat-table-tip">
<div className="chat-sync-path">
<div>CACHE: <a onClick={() => shell.open(jsonPath)} title={jsonPath}>{jsonPath}</a></div>
</div>
{lastUpdated && <span style={{ marginLeft: 10, color: '#888', fontSize: 12 }}>Last updated on {fmtDate(lastUpdated)}</span>}
</div>
<Table
key={lastUpdated}
rowKey="cmd"
columns={columns}
scroll={{ x: 'auto' }}
dataSource={opData}
rowSelection={rowSelection}
pagination={TABLE_PAGINATION}
/>
<Modal
open={isVisible}
onCancel={hide}
title={modalTitle}
onOk={handleOk}
destroyOnClose
maskClosable={false}
>
<UserCustomForm record={opInfo?.opRecord} ref={formRef} />
</Modal>
</div>
)
}