48 Commits

Author SHA1 Message Date
ayang
9ee6b9a6c9 feat: add file upload failure handling and alert message 2025-05-16 14:32:11 +08:00
ayang
24b1758b11 refactor: enabling the InputExtra component 2025-05-15 15:50:03 +08:00
Medcl
ac21074db6 fix: loading chat history for potential empty attachments (#516)
* fix: loading chat history for potential empty attachments

* chore: update release notes
2025-05-15 15:38:46 +08:00
BiggerRain
496ae025d8 style: chat input icons show (#515)
* style: chat input icons show

* style: chat input icons show

* docs: update notes
2025-05-15 15:26:49 +08:00
SteveLauC
ac5a196746 refactor: store setting allowSelfSignature in backend (#512)
* refactor: store setting allowSelfSignature in backend

* refactor: store setting allowSelfSignature in backend

* refactor: only reinit client when config gets updated

* refactor: docking api

* unused import cleanup

---------

Co-authored-by: ayang <473033518@qq.com>
Co-authored-by: ayangweb <75017711+ayangweb@users.noreply.github.com>
2025-05-15 09:17:03 +08:00
BiggerRain
aa99588001 style: modify the style of the search input box (#513)
* style: modify the style of the search input box

* build: build error
2025-05-15 08:54:42 +08:00
BiggerRain
163df77e8a fix: fixed the newly created session has no title when it is deleted (#511)
* fix: fixed the issue that the newly created session has no title when it is deleted

* docs: update notes
2025-05-14 16:14:57 +08:00
ayangweb
21509f35e5 refactor: optimize the style problem of icons (#510) 2025-05-14 16:09:51 +08:00
ayangweb
7bf59aa259 feat: add option to allow self-signed certificates (#509)
* feat: add option to allow self-signed certificates

* docs: update changelog
2025-05-14 16:00:40 +08:00
ayangweb
4aa377e486 refactor: optimized the modification operation of the numeric input box (#508)
* refactor: optimized the modification operation of the numeric input box

* docs: update changelog
2025-05-14 15:03:17 +08:00
ayangweb
feb716039c refactor: changing the timing of app list loading (#507) 2025-05-14 11:49:53 +08:00
BiggerRain
448d2a6069 refactor: optimizing the code (#505)
* refactor: optimizing the code

* docs: update notes
2025-05-14 10:59:18 +08:00
Medcl
c31a4aa52a feat: websocket support self-signed TLS (#504)
* feat: websocket support self-signed TLS

* chore: update release notes

* chore: remove unused comments
2025-05-14 10:07:49 +08:00
SteveLauC
73ac29ef3b refactor: fetch app list in settings in real time (#498) 2025-05-13 18:16:40 +08:00
Medcl
3cd73f13ab chore: update readme (#503) 2025-05-13 18:13:39 +08:00
Medcl
95ccbaec3e fix: several issues around search (#502)
* fix: several issues around search

* chore: update release notes
2025-05-13 18:12:57 +08:00
BiggerRain
d52ce481f9 feat: the search input box supports multi-line input (#501)
* feat: the search input box supports multi-line input

* docs: update notes
2025-05-13 16:26:54 +08:00
BiggerRain
573e1cf038 chore: add clear monitoring & cache calculation to optimize performance (#500)
* chore: add clear monitoring & cache calculation to optimize performance

* docs: update notes
2025-05-13 14:07:06 +08:00
BiggerRain
5162604cfd chore: UpdateApp component loading location (#499)
* chore: UpdateApp component loading location

* docs: update notes
2025-05-13 11:40:29 +08:00
ayangweb
e38053682d refactor: optimize styling issues with chat content (#497)
* refactor: optimize styling issues with chat content

* style: changing the import order
2025-05-13 11:24:07 +08:00
BiggerRain
018ec9e4ed chore: greetings show hidden logic (#496)
* chore: greetings show hidden logic

* docs: update notes
2025-05-13 11:01:05 +08:00
BiggerRain
f9e5c6cc28 chore: search and MCP show hidden logic (#494)
* chore: enabled_by_default search & MCP

* chore: add enabled param judge

* chore: add enabled param judge

* chore: add enabled param judge

* docs: update notes

---------

Co-authored-by: ayangweb <75017711+ayangweb@users.noreply.github.com>
2025-05-13 10:44:49 +08:00
ayangweb
6bb64e92d9 feat: the chat content has added a button to return to the bottom (#495)
* feat: the chat content has added a button to return to the bottom

* docs: update changelog

* refactor: optimization effect
2025-05-13 10:36:02 +08:00
SteveLauC
7962c329c7 chore: add ~/Applications to search path on macOS (#493)
* chore: add ~/Applications to search path on macOS

* changelog entry
2025-05-12 18:47:25 +08:00
ayangweb
dd6bd2093d refactor: optimize the style of the sidebar (#492)
* refactor: optimize the style of the sidebar

* refactor: adjust z-index value to increase sidebar hierarchy
2025-05-12 18:08:50 +08:00
SteveLauC
25d998a41c fix: duplicate flatpak applications (#491) 2025-05-12 17:47:16 +08:00
BiggerRain
3cfb03dd49 feat: the chat input box supports multi-line input (#490)
* chore: chat input

* feat: the chat input box supports multi-line input

* docs: update notes

* chore: remove env record

* chore: remove debug
2025-05-12 16:03:49 +08:00
SteveLauC
386b9cc48b fix: panic caused memory allocation failure on Linux (#489) 2025-05-12 15:27:49 +08:00
ayangweb
006b679386 refactor: refactor the content style of the extended page (#488) 2025-05-12 14:55:36 +08:00
SteveLauC
d47fb3cbc6 refactor: set up tauri-plugin-log as the logger (#487)
* refactor: set up tauri-plugins-log as the logger

* refactor: captures the front-end promise and outputs it to the log

---------

Co-authored-by: ayang <473033518@qq.com>
2025-05-12 09:33:37 +08:00
SteveLauC
26f71cff08 chore: remove dependency pizza engine (#486)
* chore: remove dep pizza engine

* style: fmt
2025-05-11 14:44:16 +08:00
SteveLauC
ae8f95e19c chore: use ssh instead of https to pull pizza_engine (#485) 2025-05-09 18:58:47 +08:00
Medcl
4c49daf510 chore: refine wording for search failure (#484)
* chore: refine wording on search failure

* chore: update release notes
2025-05-09 18:14:18 +08:00
SteveLauC
8d2528e521 refactor: use pizza_engine for app search (#346)
* refactor: use pizza_engine for app search

* refactor: do not break the build when pizza_engine is unavailable
2025-05-09 17:54:58 +08:00
ayangweb
4895322397 feat: history list add put away button (#482)
* feat: history list add put away button

* docs: update changelog
2025-05-09 16:17:02 +08:00
BiggerRain
a8a4d435fc chore: debug datasource component (#483) 2025-05-09 16:16:12 +08:00
ayangweb
1c0335feb4 fix: fix the focusing problem of the input box in windows (#481) 2025-05-07 18:09:19 +08:00
ayangweb
8498578425 feat: support for snapshot version updates (#480)
* feat: support for snapshot version updates

* docs: update changelog
2025-05-07 16:43:44 +08:00
Hardy
326e161505 chore: add github action build arm64 platform (#479)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-05-07 10:50:39 +08:00
BiggerRain
e96e6b4a89 build: solve build error (#477)
* build: solve build error

* build: solve build error
2025-05-01 14:46:50 +08:00
BiggerRain
853ea38058 fix: solve the problem of modifying the assistant in the chat (#476)
* refactor: refactored chat code

* fix: Solve the problem of modifying the assistant in the chat

* docs: update notes

* docs: update notes
2025-04-30 16:24:14 +08:00
BiggerRain
4e127f8cdc chore: adjust list error message (#475)
* chore: adjust list error message

* docs: update notes
2025-04-30 09:01:31 +08:00
ayangweb
51ada19d42 refactor: optimize the mode display of the first launched window (#474) 2025-04-29 19:07:03 +08:00
ayangweb
86f3741302 docs: update changelog (#473) 2025-04-29 17:47:14 +08:00
ayangweb
bb50b150c0 feat: supports Shift + Enter input box line feeds (#472) 2025-04-29 17:44:17 +08:00
ayangweb
a092354fee feat: supports setting of out-of-focus transparency on top (#470)
* feat: supports setting of out-of-focus transparency on top

* docs: update changelog

* refactor: optimize translation content

* docs: update changelog
2025-04-29 17:08:01 +08:00
SteveLauC
2ffbb79358 docs: document how to install Coco app on Ubuntu (#471) 2025-04-29 17:05:16 +08:00
ayangweb
661b5d1b77 feat: check or enter to close the list of assistants (#469)
* feat: check or enter to close the list of assistants

* docs: update changelog
2025-04-29 15:16:46 +08:00
108 changed files with 6653 additions and 3653 deletions

View File

@@ -50,6 +50,8 @@ jobs:
- platform: "ubuntu-22.04" - platform: "ubuntu-22.04"
target: "x86_64-unknown-linux-gnu" target: "x86_64-unknown-linux-gnu"
- platform: "ubuntu-22.04-arm"
target: "aarch64-unknown-linux-gnu"
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
steps: steps:
@@ -67,10 +69,10 @@ jobs:
run: rustup target add ${{ matrix.target }} run: rustup target add ${{ matrix.target }}
- name: Install dependencies (ubuntu only) - name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04' if: startsWith(matrix.platform, 'ubuntu-22.04')
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Install Rust stable - name: Install Rust stable
run: rustup toolchain install stable run: rustup toolchain install stable

1
.gitignore vendored
View File

@@ -25,3 +25,4 @@ src/components/web
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.env

View File

@@ -76,3 +76,6 @@ clean-rebuild:
@echo "Cleaning up and rebuilding..." @echo "Cleaning up and rebuilding..."
rm -rf node_modules rm -rf node_modules
$(MAKE) dev-build $(MAKE) dev-build
add-dep-pizza-engine:
cd src-tauri && cargo add --git ssh://git@github.com/infinilabs/pizza.git pizza-engine --features query_string_parser,persistence

View File

@@ -93,6 +93,12 @@ pnpm tauri build
- [Coco Server Documentation](https://docs.infinilabs.com/coco-server/main/) - [Coco Server Documentation](https://docs.infinilabs.com/coco-server/main/)
- [Tauri Documentation](https://tauri.app/) - [Tauri Documentation](https://tauri.app/)
## Contributors
<a href="https://github.com/infinilabs/coco-app/graphs/contributors">
<img src="https://contrib.rocks/image?repo=infinilabs/coco-app" />
</a>
## 📄 License ## 📄 License
Coco AI is an open-source project licensed under the [MIT License](LICENSE). You can freely use, modify, and Coco AI is an open-source project licensed under the [MIT License](LICENSE). You can freely use, modify, and

View File

@@ -0,0 +1,38 @@
---
weight: 10
title: "Ubuntu"
asciinema: true
---
# Ubuntu
> NOTE: Coco app only works fully under [X11][x11_protocol].
>
> Don't know if you running X11 or not? take a look at this [question][if_x11]!
[x11_protocol]: https://en.wikipedia.org/wiki/X_Window_System
[if_x11]: https://unix.stackexchange.com/q/202891/498440
## Goto [https://coco.rs/](https://coco.rs/)
## Download the package
Download the package of your architecture, it should be put in your `Downloads` directory
and look like this:
```sh
$ cd ~/Downloads
$ ls
Coco-AI-x.y.z-bbbb-deb-linux-amd64.zip
# or Coco-AI-x.y.z-bbbb-deb-linux-arm64.zip depending on your architecture
```
## Install it
Unzip and install it
```
$ unzip Coco-AI-x.y.z-bbbb-deb-linux-amd64.zip
$ sudo dpkg -i Coco-AI-x.y.z-bbbb-deb-linux-amd64.deb
```

View File

@@ -13,10 +13,39 @@ Information about release notes of Coco Server is provided here.
### 🚀 Features ### 🚀 Features
- feat: check or enter to close the list of assistants #469
- feat: add dimness settings for pinned window #470
- feat: supports Shift + Enter input box line feeds #472
- feat: support for snapshot version updates #480
- feat: history list add put away button #482
- feat: the chat input box supports multi-line input #490
- feat: add `~/Applications` to the search path #493
- feat: the chat content has added a button to return to the bottom #495
- feat: the search input box supports multi-line input #501
- feat: websocket support self-signed TLS #504
- feat: add option to allow self-signed certificates #509
### 🐛 Bug fix ### 🐛 Bug fix
- fix: several issues around search #502
- fix: fixed the newly created session has no title when it is deleted #511
- fix: loading chat history for potential empty attachments
### ✈️ Improvements ### ✈️ Improvements
- chore: adjust list error message #475
- fix: solve the problem of modifying the assistant in the chat #476
- chore: refine wording on search failure
- choresearch and MCP show hidden logic #494
- chore: greetings show hidden logic #496
- refactor: fetch app list in settings in real time #498
- chore: UpdateApp component loading location #499
- chore: add clear monitoring & cache calculation to optimize performance #500
- refactor: optimizing the code #505
- refactor: optimized the modification operation of the numeric input box #508
- style: modify the style of the search input box #513
- style: chat input icons show #515
## 0.4.0 (2025-04-27) ## 0.4.0 (2025-04-27)
### Breaking changes ### Breaking changes

View File

@@ -19,36 +19,37 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.0.0", "@ant-design/icons": "^6.0.0",
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.2",
"@tauri-apps/api": "^2.4.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "~2.2.0", "@tauri-apps/plugin-autostart": "~2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.0", "@tauri-apps/plugin-deep-link": "^2.2.1",
"@tauri-apps/plugin-dialog": "^2.2.0", "@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-global-shortcut": "~2.0.0", "@tauri-apps/plugin-global-shortcut": "~2.0.0",
"@tauri-apps/plugin-http": "~2.0.2", "@tauri-apps/plugin-http": "~2.0.2",
"@tauri-apps/plugin-log": "~2.4.0",
"@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.0", "@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.0", "@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.6.1", "@tauri-apps/plugin-updater": "github:infinilabs/tauri-plugin-updater#v2",
"@tauri-apps/plugin-websocket": "~2.3.0", "@tauri-apps/plugin-websocket": "~2.3.0",
"@tauri-apps/plugin-window": "2.0.0-alpha.1", "@tauri-apps/plugin-window": "2.0.0-alpha.1",
"@wavesurfer/react": "^1.0.9", "@wavesurfer/react": "^1.0.11",
"ahooks": "^3.8.4", "ahooks": "^3.8.4",
"axios": "^1.8.4", "axios": "^1.9.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.7", "dotenv": "^16.5.0",
"filesize": "^10.1.6", "filesize": "^10.1.6",
"i18next": "^23.16.8", "i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.461.0", "lucide-react": "^0.461.0",
"mermaid": "^11.5.0", "mermaid": "^11.6.0",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.6.1", "react-hotkeys-hook": "^4.6.2",
"react-i18next": "^15.4.1", "react-i18next": "^15.5.1",
"react-markdown": "^9.1.0", "react-markdown": "^9.1.0",
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.0",
"react-window": "^1.8.11", "react-window": "^1.8.11",
@@ -58,25 +59,25 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"tauri-plugin-fs-pro-api": "^2.4.0", "tauri-plugin-fs-pro-api": "^2.4.0",
"tauri-plugin-macos-permissions-api": "^2.2.0", "tauri-plugin-macos-permissions-api": "^2.3.0",
"tauri-plugin-screenshots-api": "^2.1.0", "tauri-plugin-screenshots-api": "^2.2.0",
"tauri-plugin-windows-version-api": "^2.0.0", "tauri-plugin-windows-version-api": "^2.0.0",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"wavesurfer.js": "^7.9.3", "wavesurfer.js": "^7.9.5",
"zustand": "^5.0.3" "zustand": "^5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.4.0", "@tauri-apps/cli": "^2.5.0",
"@types/dom-speech-recognition": "^0.0.4", "@types/dom-speech-recognition": "^0.0.4",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^22.13.11", "@types/node": "^22.15.17",
"@types/react": "^18.3.19", "@types/react": "^18.3.21",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.7",
"@types/react-katex": "^3.0.4", "@types/react-katex": "^3.0.4",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"immer": "^10.1.1", "immer": "^10.1.1",
@@ -85,8 +86,8 @@
"sass": "^1.87.0", "sass": "^1.87.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsup": "^8.4.0", "tsup": "^8.4.0",
"tsx": "^4.19.3", "tsx": "^4.19.4",
"typescript": "^5.8.2", "typescript": "^5.8.3",
"vite": "^5.4.14" "vite": "^5.4.19"
} }
} }

1756
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

1656
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,26 @@ tauri-build = { version = "2", features = ["default"] }
default = ["desktop"] default = ["desktop"]
desktop = [] desktop = []
cargo-clippy = [] cargo-clippy = []
# If enabled, code that relies on pizza_engine will be activated.
#
# Only do this if:
# 1. Pizza engine is listed in the `dependencies` section
#
# ```toml
# [dependencies]
# pizza-engine = { git = "ssh://git@github.com/infinilabs/pizza.git", features = ["query_string_parser", "persistence"] }
# ```
#
# 2. It is a private repo, you have access to it.
#
# So, for external contributors, do NOT enable this feature.
#
# Previously, We listed it in the dependencies and marked it optional, but cargo
# would fetch all the dependencies regardless of wheterh they are optional or not,
# so we removed it.
#
# https://github.com/rust-lang/cargo/issues/4544#issuecomment-1906902755
use_pizza_engine = []
[dependencies] [dependencies]
pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "main" } pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "main" }
@@ -37,17 +57,15 @@ tauri-plugin-store = "2.2.0"
tauri-plugin-os = "2" tauri-plugin-os = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-drag = "2" tauri-plugin-drag = "2"
tauri-plugin-macos-permissions = "2" tauri-plugin-macos-permissions = "2"
tauri-plugin-fs-pro = "2" tauri-plugin-fs-pro = "2"
tauri-plugin-screenshots = "2" tauri-plugin-screenshots = "2"
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "fb8f475993a2a774ce08d7a58f9f2ac264248a24" } applications = { git = "https://github.com/infinilabs/applications-rs", rev = "7bb507e6b12f73c96f3a52f0578d0246a689f381" }
tokio-native-tls = "0.3" # For wss connections tokio-native-tls = "0.3" # For wss connections
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.20", features = ["rustls-tls-webpki-roots"] } tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
hyper = { version = "0.14", features = ["client"] } hyper = { version = "0.14", features = ["client"] }
reqwest = { version = "0.12", features = ["json", "multipart"] } reqwest = { version = "0.12", features = ["json", "multipart"] }
futures = "0.3.31" futures = "0.3.31"
@@ -62,19 +80,19 @@ hostname = "0.3"
plist = "1.7" plist = "1.7"
base64 = "0.13" base64 = "0.13"
walkdir = "2" walkdir = "2"
fuzzy_prefix_search = "0.2"
log = "0.4" log = "0.4"
futures-util = "0.3.31" futures-util = "0.3.31"
url = "2.5.2" url = "2.5.2"
http = "1.1.0" http = "1.1.0"
tungstenite = "0.24.0" tungstenite = "0.24.0"
env_logger = "0.11.5"
tokio-util = "0.7.14" tokio-util = "0.7.14"
tauri-plugin-windows-version = "2" tauri-plugin-windows-version = "2"
meval = "0.2" meval = "0.2"
chinese-number = "0.7" chinese-number = "0.7"
num2words = "1" num2words = "1"
tauri-plugin-log = "2"
chrono = "0.4.41"
[target."cfg(target_os = \"macos\")".dependencies] [target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" } tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
@@ -82,7 +100,6 @@ tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies] [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] } tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
[profile.dev] [profile.dev]
incremental = true # Compile your binary in smaller steps. incremental = true # Compile your binary in smaller steps.
@@ -96,4 +113,7 @@ strip = true # Ensures debug symbols are removed.
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "^2.2" tauri-plugin-autostart = "^2.2"
tauri-plugin-global-shortcut = "2" tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = { git = "https://github.com/infinilabs/plugins-workspace", branch = "v2" }
[target."cfg(target_os = \"windows\")".dependencies]
enigo="0.3"

View File

@@ -70,6 +70,7 @@
"core:window:allow-set-theme", "core:window:allow-set-theme",
"process:default", "process:default",
"updater:default", "updater:default",
"windows-version:default" "windows-version:default",
"log:default"
] ]
} }

View File

@@ -0,0 +1,2 @@
[toolchain]
channel = "nightly-2024-10-29"

View File

@@ -55,39 +55,3 @@ pub struct Document {
pub owner: Option<UserInfo>, pub owner: Option<UserInfo>,
pub last_updated_by: Option<EditorInfo>, pub last_updated_by: Option<EditorInfo>,
} }
impl Document {
pub fn new(
source: Option<DataSourceReference>,
id: String,
category: String,
name: String,
url: String,
) -> Self {
Self {
id,
created: None,
updated: None,
source,
r#type: None,
category: Some(category),
subcategory: None,
categories: None,
rich_categories: None,
title: Some(name),
summary: None,
lang: None,
content: None,
icon: None,
thumbnail: None,
cover: None,
tags: None,
url: Some(url),
size: None,
metadata: None,
payload: None,
owner: None,
last_updated_by: None,
}
}
}

View File

@@ -24,7 +24,9 @@ pub async fn get_response_body_text(response: Response) -> Result<String, String
let body = response let body = response
.text() .text()
.await .await
.map_err(|e| format!("Failed to read response body: {}", e))?; .map_err(|e| format!("Failed to read response body: {}, code: {}", e, status))?;
log::debug!("Response status: {}, body: {}", status, &body);
if status < 200 || status >= 400 { if status < 200 || status >= 400 {
// Try to parse the error body // Try to parse the error body

View File

@@ -4,6 +4,7 @@ mod common;
mod local; mod local;
mod search; mod search;
mod server; mod server;
mod settings;
mod setup; mod setup;
mod shortcut; mod shortcut;
mod util; mod util;
@@ -15,7 +16,9 @@ use crate::server::servers::{load_or_insert_default_server, load_servers_token};
use autostart::{change_autostart, enable_autostart}; use autostart::{change_autostart, enable_autostart};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::OnceLock;
use tauri::async_runtime::block_on; use tauri::async_runtime::block_on;
use tauri::plugin::TauriPlugin;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use tauri::ActivationPolicy; use tauri::ActivationPolicy;
use tauri::{ use tauri::{
@@ -30,6 +33,10 @@ lazy_static! {
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None); static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
} }
/// To allow us to access tauri's `AppHandle` when its context is inaccessible,
/// store it globally. It will be set in `init()`.
pub(crate) static GLOBAL_TAURI_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
#[tauri::command] #[tauri::command]
async fn change_window_height(handle: AppHandle, height: u32) { async fn change_window_height(handle: AppHandle, height: u32) {
let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap(); let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
@@ -55,8 +62,6 @@ struct Payload {
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let ctx = tauri::generate_context!(); let ctx = tauri::generate_context!();
// Initialize logger
env_logger::init();
let mut app_builder = tauri::Builder::default(); let mut app_builder = tauri::Builder::default();
@@ -83,7 +88,8 @@ pub fn run() {
.plugin(tauri_plugin_screenshots::init()) .plugin(tauri_plugin_screenshots::init())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_windows_version::init()); .plugin(tauri_plugin_windows_version::init())
.plugin(set_up_tauri_logger());
// Conditional compilation for macOS // Conditional compilation for macOS
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -134,12 +140,31 @@ pub fn run() {
server::attachment::get_attachment, server::attachment::get_attachment,
server::attachment::delete_attachment, server::attachment::delete_attachment,
server::transcription::transcription, server::transcription::transcription,
local::application::get_default_search_paths,
local::application::list_app_with_metadata_in,
util::open, util::open,
server::system_settings::get_system_settings server::system_settings::get_system_settings,
simulate_mouse_click,
local::get_disabled_local_query_sources,
local::enable_local_query_source,
local::disable_local_query_source,
local::application::get_app_list,
local::application::get_app_search_path,
local::application::get_app_metadata,
local::application::set_app_alias,
local::application::register_app_hotkey,
local::application::unregister_app_hotkey,
local::application::disable_app_search,
local::application::enable_app_search,
local::application::add_app_search_path,
local::application::remove_app_search_path,
settings::set_allow_self_signature,
settings::get_allow_self_signature,
]) ])
.setup(|app| { .setup(|app| {
let app_handle = app.handle().clone();
GLOBAL_TAURI_APP_HANDLE
.set(app_handle.clone())
.expect("variable already initialized");
let registry = SearchSourceRegistry::default(); let registry = SearchSourceRegistry::default();
app.manage(registry); // Store registry in Tauri's app state app.manage(registry); // Store registry in Tauri's app state
@@ -233,31 +258,20 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
crate::server::servers::try_register_server_to_search_source(app_handle.clone(), &server) crate::server::servers::try_register_server_to_search_source(app_handle.clone(), &server)
.await; .await;
} }
}
async fn init_app_search_source<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> { local::start_pizza_engine_runtime();
let application_search =
local::application::ApplicationSearchSource::new(app_handle.clone(), 1000f64).await?;
let calculator_search = local::calculator::CalculatorSource::new(2000f64);
// Register the application search source
let registry = app_handle.state::<SearchSourceRegistry>();
registry.register_source(application_search).await;
registry.register_source(calculator_search).await;
Ok(())
} }
#[tauri::command] #[tauri::command]
async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) { async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
if let Some(window) = app_handle.get_window(MAIN_WINDOW_LABEL) { if let Some(window) = app_handle.get_window(MAIN_WINDOW_LABEL) {
let _ = app_handle.emit("show-coco", ());
move_window_to_active_monitor(&window); move_window_to_active_monitor(&window);
let _ = window.show(); let _ = window.show();
let _ = window.unminimize(); let _ = window.unminimize();
let _ = window.set_focus(); let _ = window.set_focus();
let _ = app_handle.emit("show-coco", ());
} }
} }
@@ -401,7 +415,7 @@ fn open_settings(app: &tauri::AppHandle) {
#[tauri::command] #[tauri::command]
async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> { async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> {
init_app_search_source(&app_handle).await?; local::init_local_search_source(&app_handle).await?;
let _ = server::connector::refresh_all_connectors(&app_handle).await; let _ = server::connector::refresh_all_connectors(&app_handle).await;
let _ = server::datasource::refresh_all_datasources(&app_handle).await; let _ = server::datasource::refresh_all_datasources(&app_handle).await;
@@ -412,3 +426,98 @@ async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(
async fn show_settings(app_handle: AppHandle) { async fn show_settings(app_handle: AppHandle) {
open_settings(&app_handle); open_settings(&app_handle);
} }
#[tauri::command]
async fn simulate_mouse_click<R: Runtime>(window: WebviewWindow<R>, is_chat_mode: bool) {
#[cfg(target_os = "windows")]
{
use enigo::{Button, Coordinate, Direction, Enigo, Mouse, Settings};
use std::{thread, time::Duration};
if let Ok(mut enigo) = Enigo::new(&Settings::default()) {
// Save the current mouse position
if let Ok((original_x, original_y)) = enigo.location() {
// Retrieve the window's outer position (top-left corner)
if let Ok(position) = window.outer_position() {
// Retrieve the window's inner size (client area)
if let Ok(size) = window.inner_size() {
// Calculate the center position of the title bar
let x = position.x + (size.width as i32 / 2);
let y = if is_chat_mode {
position.y + size.height as i32 - 50
} else {
position.y + 30
};
// Move the mouse cursor to the calculated position
if enigo.move_mouse(x, y, Coordinate::Abs).is_ok() {
// // Simulate a left mouse click
let _ = enigo.button(Button::Left, Direction::Click);
// let _ = enigo.button(Button::Left, Direction::Release);
thread::sleep(Duration::from_millis(100));
// Move the mouse cursor back to the original position
let _ = enigo.move_mouse(original_x, original_y, Coordinate::Abs);
}
}
}
}
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = window;
let _ = is_chat_mode;
}
}
/// Log format:
///
/// ```text
/// [time] [log level] [file module:line] message
/// ```
///
/// Example:
///
///
/// ```text
/// [05-11 17:00:00] [INF] [coco_lib:625] Coco-AI started
/// ```
fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
use log::Level;
fn format_log_level(level: Level) -> &'static str {
match level {
Level::Trace => "TRC",
Level::Debug => "DBG",
Level::Info => "INF",
Level::Warn => "WAR",
Level::Error => "ERR",
}
}
fn format_target_and_line(record: &log::Record) -> String {
let mut str = record.target().to_string();
if let Some(line) = record.line() {
str.push(':');
str.push_str(&line.to_string());
}
str
}
tauri_plugin_log::Builder::new()
.format(|out, message, record| {
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
let level = format_log_level(record.level());
let target_and_line = format_target_and_line(record);
out.finish(format_args!(
"[{}] [{}] [{}] {}",
now, level, target_and_line, message
));
})
.level(log::LevelFilter::Debug)
.build()
}

View File

@@ -1,313 +0,0 @@
use crate::common::document::{DataSourceReference, Document};
use crate::common::error::SearchError;
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
use crate::common::traits::SearchSource;
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
use applications::App;
use async_trait::async_trait;
use fuzzy_prefix_search::Trie;
use std::collections::HashMap;
use std::path::PathBuf;
use tauri::{AppHandle, Runtime};
use tauri_plugin_fs_pro::{icon, metadata, name, IconOptions};
const DATA_SOURCE_ID: &str = "Applications";
#[tauri::command]
pub fn get_default_search_paths() -> Vec<String> {
#[cfg(target_os = "macos")]
return vec![
"/Applications".into(),
"/System/Applications".into(),
"/System/Library/CoreServices".into(),
];
#[cfg(not(target_os = "macos"))]
{
let paths = applications::get_default_search_paths();
let mut ret = Vec::with_capacity(paths.len());
for search_path in paths {
let path_string = search_path
.into_os_string()
.into_string()
.expect("path should be UTF-8 encoded");
ret.push(path_string);
}
ret
}
}
/// Helper function to return `app`'s path.
///
/// * Windows: return the path to application's exe
/// * macOS: return the path to the `.app` bundle
/// * Linux: return the path to the `.desktop` file
fn get_app_path(app: &App) -> PathBuf {
if cfg!(target_os = "windows") {
assert!(
app.icon_path.is_some(),
"we only accept Applications with icons"
);
app.app_path_exe
.as_ref()
.expect("icon is Some, exe path should be Some as well")
.to_path_buf()
} else {
app.app_desktop_path.clone()
}
}
/// Helper function to return `app`'s path.
///
/// * Windows/macOS: extract `app_path`'s file name and remove the file extension
/// * Linux: return the name specified in `.desktop` file
async fn get_app_name(app: &App) -> String {
if cfg!(target_os = "linux") {
app.name.clone()
} else {
let app_path = get_app_path(app);
name(app_path.clone()).await
}
}
/// Helper function to return an absolute path to `app`'s icon.
///
/// On macOS/Windows, we cache icons in our data directory using the `icon()` function.
async fn get_app_icon_path<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app: &App,
) -> Result<PathBuf, String> {
if cfg!(target_os = "linux") {
let icon_path = app
.icon_path
.as_ref()
.expect("We only accept applications with icons")
.to_path_buf();
Ok(icon_path)
} else {
let app_path = get_app_path(app);
let options = IconOptions {
size: Some(256),
save_path: None,
};
icon(tauri_app_handle.clone(), app_path, Some(options))
.await
.map_err(|err| err.to_string())
}
}
/// Return all the Apps found under `search_path`.
///
/// Note: apps with no icons will be filtered out.
fn list_app_in(search_path: Vec<String>) -> Result<Vec<App>, String> {
let search_path = search_path
.into_iter()
.map(PathBuf::from)
.collect::<Vec<_>>();
let apps = applications::get_all_apps(&search_path).map_err(|err| err.to_string())?;
Ok(apps
.into_iter()
.filter(|app| app.icon_path.is_some())
.collect())
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppMetadata {
name: String,
r#where: PathBuf,
size: u64,
icon: PathBuf,
created: u128,
modified: u128,
last_opened: u128,
}
/// List apps that are in the `search_path`.
///
/// Different from `list_app_in()`, every app is JSON object containing its metadata, e.g.:
///
/// ```json
/// {
/// "name": "Finder",
/// "where": "/System/Library/CoreServices",
/// "size": 49283072,
/// "icon": "/xxx.png",
/// "created": 1744625204,
/// "modified": 1744625204,
/// "lastOpened": 1744625250
/// }
/// ```
#[tauri::command]
pub async fn list_app_with_metadata_in<R: Runtime>(
app_handle: AppHandle<R>,
search_path: Vec<String>,
) -> Result<Vec<AppMetadata>, String> {
let apps = list_app_in(search_path)?;
let mut apps_with_meta = Vec::with_capacity(apps.len());
// name version where Type(hardcoded Application) Size Created Modify
for app in apps.iter() {
let app_path = get_app_path(app);
let app_name = get_app_name(app).await;
let app_path_where = {
let mut app_path_clone = app_path.clone();
let truncated = app_path_clone.pop();
if !truncated {
panic!("every app file should live somewhere");
}
app_path_clone
};
let icon = get_app_icon_path(&app_handle, app).await?;
let raw_app_metadata = metadata(app_path.clone(), None).await?;
let app_metadata = AppMetadata {
name: app_name,
r#where: app_path_where,
size: raw_app_metadata.size,
icon,
created: raw_app_metadata.created_at,
modified: raw_app_metadata.modified_at,
last_opened: raw_app_metadata.accessed_at,
};
apps_with_meta.push(app_metadata);
}
Ok(apps_with_meta)
}
pub struct ApplicationSearchSource {
base_score: f64,
// app name -> app icon path
icons: HashMap<String, PathBuf>,
application_paths: Trie<PathBuf>,
}
impl ApplicationSearchSource {
pub async fn new<R: Runtime>(
app_handle: AppHandle<R>,
base_score: f64,
) -> Result<Self, String> {
let application_paths = Trie::new();
let mut icons = HashMap::new();
let default_search_path = get_default_search_paths();
let apps = list_app_in(default_search_path)?;
for app in &apps {
let app_path = get_app_path(app);
let app_name = get_app_name(app).await;
let app_icon_path = get_app_icon_path(&app_handle, app).await?;
if app_name.is_empty() || app_name.eq("Coco-AI") {
continue;
}
application_paths.insert(&app_name, app_path);
icons.insert(app_name, app_icon_path);
}
Ok(ApplicationSearchSource {
base_score,
icons,
application_paths,
})
}
}
#[async_trait]
impl SearchSource for ApplicationSearchSource {
fn get_type(&self) -> QuerySource {
QuerySource {
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
name: hostname::get()
.unwrap_or("My Computer".into())
.to_string_lossy()
.into(),
id: DATA_SOURCE_ID.into(),
}
}
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
let query_string = query
.query_strings
.get("query")
.unwrap_or(&"".to_string())
.to_lowercase();
if query_string.is_empty() {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
}
let mut total_hits = 0;
let mut hits = Vec::new();
let query_string_len = query_string.len();
let mut results = self
.application_paths
.search_within_distance_scored(&query_string, query_string_len - 1);
// Check for NaN or extreme score values and handle them properly
results.sort_by(|a, b| {
// If either score is NaN, consider them equal (you can customize this logic as needed)
if a.score.is_nan() || b.score.is_nan() {
std::cmp::Ordering::Equal
} else {
// Otherwise, compare the scores as usual
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
}
});
if !results.is_empty() {
for result in results {
let app_name = result.word;
let app_path = result.data.first().unwrap().clone();
let app_path_string = app_path.to_string_lossy().into_owned();
total_hits += 1;
let mut doc = Document::new(
Some(DataSourceReference {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some(DATA_SOURCE_ID.into()),
id: Some(DATA_SOURCE_ID.into()),
icon: None,
}),
app_path_string.clone(),
"Application".to_string(),
app_name.clone(),
app_path_string.clone(),
);
// Attach icon if available
if let Some(icon_path) = self.icons.get(app_name.as_str()) {
doc.icon = Some(icon_path.as_os_str().to_str().unwrap().to_string());
}
hits.push((doc, self.base_score + result.score as f64));
}
}
Ok(QueryResponse {
source: self.get_type(),
hits,
total_hits,
})
}
}

View File

@@ -0,0 +1,38 @@
use serde::Serialize;
#[cfg(feature = "use_pizza_engine")]
mod with_feature;
#[cfg(not(feature = "use_pizza_engine"))]
mod without_feature;
#[cfg(feature = "use_pizza_engine")]
pub use with_feature::*;
#[cfg(not(feature = "use_pizza_engine"))]
pub use without_feature::*;
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AppEntry {
path: String,
name: String,
icon_path: String,
alias: String,
hotkey: String,
is_disabled: bool,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppMetadata {
name: String,
r#where: String,
size: u64,
icon: String,
created: u128,
modified: u128,
last_opened: u128,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
use crate::common::error::SearchError;
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
use crate::common::traits::SearchSource;
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
use async_trait::async_trait;
use tauri::{AppHandle, Runtime};
use super::AppEntry;
use super::AppMetadata;
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
pub struct ApplicationSearchSource;
impl ApplicationSearchSource {
pub async fn init<R: Runtime>(_app_handle: AppHandle<R>) -> Result<(), String> {
Ok(())
}
}
#[async_trait]
impl SearchSource for ApplicationSearchSource {
fn get_type(&self) -> QuerySource {
QuerySource {
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
name: hostname::get()
.unwrap_or("My Computer".into())
.to_string_lossy()
.into(),
id: QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into(),
}
}
async fn search(&self, _query: SearchQuery) -> Result<QueryResponse, SearchError> {
Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
})
}
}
#[tauri::command]
pub async fn set_app_alias(_app_path: String, _alias: String) -> Result<(), String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}
#[tauri::command]
pub async fn register_app_hotkey<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_app_path: String,
_hotkey: String,
) -> Result<(), String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}
#[tauri::command]
pub async fn unregister_app_hotkey<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_app_path: String,
) -> Result<(), String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}
#[tauri::command]
pub async fn disable_app_search<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_app_path: String,
) -> Result<(), String> {
// no-op
Ok(())
}
#[tauri::command]
pub async fn enable_app_search<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_app_path: String,
) -> Result<(), String> {
// no-op
Ok(())
}
#[tauri::command]
pub async fn add_app_search_path<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_search_path: String,
) -> Result<(), String> {
// no-op
Ok(())
}
#[tauri::command]
pub async fn remove_app_search_path<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_search_path: String,
) -> Result<(), String> {
// no-op
Ok(())
}
#[tauri::command]
pub async fn get_app_search_path<R: Runtime>(_tauri_app_handle: AppHandle<R>) -> Vec<String> {
// Return an empty list
Vec::new()
}
#[tauri::command]
pub async fn get_app_list<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
) -> Result<Vec<AppEntry>, String> {
// Return an empty list
Ok(Vec::new())
}
#[tauri::command]
pub async fn get_app_metadata<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_app_path: String,
) -> Result<AppMetadata, String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}

View File

@@ -11,7 +11,7 @@ use num2words::Num2Words;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
const DATA_SOURCE_ID: &str = "Calculator"; pub(crate) const DATA_SOURCE_ID: &str = "Calculator";
pub struct CalculatorSource { pub struct CalculatorSource {
base_score: f64, base_score: f64,

View File

@@ -2,4 +2,163 @@ pub mod application;
pub mod calculator; pub mod calculator;
pub mod file_system; pub mod file_system;
use std::any::Any;
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::common::register::SearchSourceRegistry;
use serde_json::Value as Json;
use tauri::{AppHandle, Manager, Runtime};
use tauri_plugin_store::StoreExt;
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local"; pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
pub const TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE: &str = "local_query_source_enabled_state";
trait SearchSourceState {
#[cfg_attr(not(feature = "use_pizza_engine"), allow(unused))]
fn as_mut_any(&mut self) -> &mut dyn Any;
}
#[async_trait::async_trait(?Send)]
trait Task: Send + Sync {
fn search_source_id(&self) -> &'static str;
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>);
}
static RUNTIME_TX: OnceLock<tokio::sync::mpsc::UnboundedSender<Box<dyn Task>>> = OnceLock::new();
pub(crate) fn start_pizza_engine_runtime() {
std::thread::spawn(|| {
let rt = tokio::runtime::Runtime::new().unwrap();
let main = async {
let mut states: HashMap<String, Option<Box<dyn SearchSourceState>>> = HashMap::new();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
RUNTIME_TX.set(tx).unwrap();
while let Some(mut task) = rx.recv().await {
let opt_search_source_state = match states.entry(task.search_source_id().into()) {
Entry::Occupied(o) => o.into_mut(),
Entry::Vacant(v) => v.insert(None),
};
task.exec(opt_search_source_state).await;
}
};
rt.block_on(main);
});
}
pub(crate) async fn init_local_search_source<R: Runtime>(
app_handle: &AppHandle<R>,
) -> Result<(), String> {
let enabled_status_store = app_handle
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
.map_err(|e| e.to_string())?;
if enabled_status_store.is_empty() {
enabled_status_store.set(
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME,
Json::Bool(true),
);
enabled_status_store.set(calculator::DATA_SOURCE_ID, Json::Bool(true));
}
let registry = app_handle.state::<SearchSourceRegistry>();
application::ApplicationSearchSource::init(app_handle.clone()).await?;
for (id, enabled) in enabled_status_store.entries() {
let enabled = match enabled {
Json::Bool(b) => b,
_ => unreachable!("enabled state should be stored as a boolean"),
};
if enabled {
if id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
registry
.register_source(application::ApplicationSearchSource)
.await;
}
if id == calculator::DATA_SOURCE_ID {
let calculator_search = calculator::CalculatorSource::new(2000f64);
registry.register_source(calculator_search).await;
}
}
}
Ok(())
}
#[tauri::command]
pub async fn get_disabled_local_query_sources<R: Runtime>(app_handle: AppHandle<R>) -> Vec<String> {
let enabled_status_store = app_handle
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
.unwrap_or_else(|e| {
panic!(
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
)
});
let mut disabled_local_query_sources = Vec::new();
for (id, enabled) in enabled_status_store.entries() {
let enabled = match enabled {
Json::Bool(b) => b,
_ => unreachable!("enabled state should be stored as a boolean"),
};
if !enabled {
disabled_local_query_sources.push(id);
}
}
disabled_local_query_sources
}
#[tauri::command]
pub async fn enable_local_query_source<R: Runtime>(
app_handle: AppHandle<R>,
query_source_id: String,
) {
let registry = app_handle.state::<SearchSourceRegistry>();
if query_source_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
let application_search = application::ApplicationSearchSource;
registry.register_source(application_search).await;
}
if query_source_id == calculator::DATA_SOURCE_ID {
let calculator_search = calculator::CalculatorSource::new(2000f64);
registry.register_source(calculator_search).await;
}
let enabled_status_store = app_handle
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
.unwrap_or_else(|e| {
panic!(
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
)
});
enabled_status_store.set(query_source_id, Json::Bool(true));
}
#[tauri::command]
pub async fn disable_local_query_source<R: Runtime>(
app_handle: AppHandle<R>,
query_source_id: String,
) {
let registry = app_handle.state::<SearchSourceRegistry>();
registry.remove_source(&query_source_id).await;
let enabled_status_store = app_handle
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
.unwrap_or_else(|e| {
panic!(
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
)
});
enabled_status_store.set(query_source_id, Json::Bool(false));
}

View File

@@ -82,6 +82,18 @@ pub async fn query_coco_fusion<R: Runtime>(
.push((query_hit, score)); .push((query_hit, score));
} }
} }
Ok(Ok(Err(err))) => {
failed_requests.push(FailedRequest {
source: QuerySource {
r#type: "N/A".into(),
name: "N/A".into(),
id: "N/A".into(),
},
status: 0,
error: Some(err.to_string()),
reason: None,
});
}
Ok(Err(err)) => { Ok(Err(err)) => {
failed_requests.push(FailedRequest { failed_requests.push(FailedRequest {
source: QuerySource { source: QuerySource {
@@ -95,7 +107,7 @@ pub async fn query_coco_fusion<R: Runtime>(
}); });
} }
// Timeout reached, skip this request // Timeout reached, skip this request
Ok(_) => { _ => {
failed_requests.push(FailedRequest { failed_requests.push(FailedRequest {
source: QuerySource { source: QuerySource {
r#type: "N/A".into(), r#type: "N/A".into(),
@@ -103,19 +115,7 @@ pub async fn query_coco_fusion<R: Runtime>(
id: "N/A".into(), id: "N/A".into(),
}, },
status: 0, status: 0,
error: Some("Query source timed out".to_string()), error: Some(format!("{:?}", &result)),
reason: None,
});
}
Err(_) => {
failed_requests.push(FailedRequest {
source: QuerySource {
r#type: "N/A".into(),
name: "N/A".into(),
id: "N/A".into(),
},
status: 0,
error: Some("Task panicked".to_string()),
reason: None, reason: None,
}); });
} }

View File

@@ -40,14 +40,14 @@ pub struct AttachmentHit {
pub struct AttachmentHits { pub struct AttachmentHits {
pub total: Value, pub total: Value,
pub max_score: Option<f64>, pub max_score: Option<f64>,
pub hits: Vec<AttachmentHit>, pub hits: Option<Vec<AttachmentHit>>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct GetAttachmentResponse { pub struct GetAttachmentResponse {
pub took: u32, pub took: u32,
pub timed_out: bool, pub timed_out: bool,
pub _shards: Value, pub _shards: Option<Value>,
pub hits: AttachmentHits, pub hits: AttachmentHits,
} }
@@ -119,7 +119,7 @@ pub async fn get_attachment(
.map_err(|e| format!("Request error: {}", e))?; .map_err(|e| format!("Request error: {}", e))?;
let body = get_response_body_text(response).await?; let body = get_response_body_text(response).await?;
serde_json::from_str::<GetAttachmentResponse>(&body) serde_json::from_str::<GetAttachmentResponse>(&body)
.map_err(|e| format!("Failed to parse attachment response: {}", e)) .map_err(|e| format!("Failed to parse attachment response: {}", e))
} }

View File

@@ -34,6 +34,10 @@ pub async fn refresh_all_connectors<R: Runtime>(app_handle: &AppHandle<R>) -> Re
// Collect all the tasks for fetching and refreshing connectors // Collect all the tasks for fetching and refreshing connectors
let mut server_map = HashMap::new(); let mut server_map = HashMap::new();
for server in servers { for server in servers {
if !server.enabled {
continue;
}
// dbg!("start fetch connectors for server: {}", &server.id); // dbg!("start fetch connectors for server: {}", &server.id);
let connectors = match get_connectors_by_server(app_handle.clone(), server.id.clone()).await let connectors = match get_connectors_by_server(app_handle.clone(), server.id.clone()).await
{ {

View File

@@ -32,7 +32,7 @@ pub fn save_datasource_to_cache(server_id: &str, datasources: Vec<DataSource>) {
#[allow(dead_code)] #[allow(dead_code)]
pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, DataSource>> { pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, DataSource>> {
let cache = DATASOURCE_CACHE.read().unwrap(); // Acquire read lock let cache = DATASOURCE_CACHE.read().unwrap(); // Acquire read lock
// dbg!("cache: {:?}", &cache); // dbg!("cache: {:?}", &cache);
let server_cache = cache.get(server_id)?; // Get the server's cache let server_cache = cache.get(server_id)?; // Get the server's cache
Some(server_cache.clone()) Some(server_cache.clone())
} }
@@ -47,6 +47,10 @@ pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) ->
for server in servers { for server in servers {
// dbg!("fetch datasources for server: {}", &server.id); // dbg!("fetch datasources for server: {}", &server.id);
if !server.enabled {
continue;
}
// Attempt to get datasources by server, and continue even if it fails // Attempt to get datasources by server, and continue even if it fails
let connectors = match datasource_search(server.id.as_str(), None).await { let connectors = match datasource_search(server.id.as_str(), None).await {
Ok(connectors) => { Ok(connectors) => {
@@ -130,8 +134,8 @@ pub async fn datasource_search(
None, None,
Some(reqwest::Body::from(body.to_string())), Some(reqwest::Body::from(body.to_string())),
) )
.await .await
.map_err(|e| format!("Error fetching datasource: {}", e))?; .map_err(|e| format!("Error fetching datasource: {}", e))?;
// Parse the search results from the response // Parse the search results from the response
let datasources: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| { let datasources: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
@@ -186,8 +190,8 @@ pub async fn mcp_server_search(
None, None,
Some(reqwest::Body::from(body.to_string())), Some(reqwest::Body::from(body.to_string())),
) )
.await .await
.map_err(|e| format!("Error fetching datasource: {}", e))?; .map_err(|e| format!("Error fetching datasource: {}", e))?;
// Parse the search results from the response // Parse the search results from the response
let mcp_server: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| { let mcp_server: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {

View File

@@ -7,15 +7,24 @@ use std::time::Duration;
use tauri_plugin_store::JsonValue; use tauri_plugin_store::JsonValue;
use tokio::sync::Mutex; use tokio::sync::Mutex;
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| { pub(crate) fn new_reqwest_http_client(accept_invalid_certs: bool) -> Client {
let client = Client::builder() Client::builder()
.read_timeout(Duration::from_secs(3)) // Set a timeout of 3 second .read_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
.connect_timeout(Duration::from_secs(3)) // Set a timeout of 3 second .connect_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
.timeout(Duration::from_secs(10)) // Set a timeout of 10 seconds .timeout(Duration::from_secs(10)) // Set a timeout of 10 seconds
.danger_accept_invalid_certs(true) // example for self-signed certificates .danger_accept_invalid_certs(accept_invalid_certs) // allow self-signed certificates
.build() .build()
.expect("Failed to build client"); .expect("Failed to build client")
Mutex::new(client) }
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
let allow_self_signature = crate::settings::_get_allow_self_signature(
crate::GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app store not set")
.clone(),
);
Mutex::new(new_reqwest_http_client(allow_self_signature))
}); });
pub struct HttpClient; pub struct HttpClient;
@@ -35,6 +44,14 @@ impl HttpClient {
headers: Option<HashMap<String, String>>, headers: Option<HashMap<String, String>>,
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
log::debug!(
"Sending Request: {}, query_params: {:?}, header: {:?}, body: {:?}",
&url,
&query_params,
&headers,
&body
);
let request_builder = let request_builder =
Self::get_request_builder(method, url, headers, query_params, body).await; Self::get_request_builder(method, url, headers, query_params, body).await;
@@ -42,6 +59,14 @@ impl HttpClient {
dbg!("Failed to send request: {}", &e); dbg!("Failed to send request: {}", &e);
format!("Failed to send request: {}", e) format!("Failed to send request: {}", e)
})?; })?;
log::debug!(
"Request: {}, Response status: {:?}, header: {:?}",
&url,
&response.status(),
&response.headers()
);
Ok(response) Ok(response)
} }
@@ -140,9 +165,12 @@ impl HttpClient {
headers.insert("X-API-TOKEN".to_string(), t); headers.insert("X-API-TOKEN".to_string(), t);
} }
// dbg!(&server_id); log::debug!(
// dbg!(&url); "Sending request to server: {}, url: {}, headers: {:?}",
// dbg!(&headers); &server_id,
&url,
&headers
);
Self::send_raw_request(method, &url, query_params, Some(headers), body).await Self::send_raw_request(method, &url, query_params, Some(headers), body).await
} else { } else {
@@ -184,7 +212,7 @@ impl HttpClient {
query_params, query_params,
body, body,
) )
.await .await
} }
// Convenience method for PUT requests // Convenience method for PUT requests
@@ -204,7 +232,7 @@ impl HttpClient {
query_params, query_params,
body, body,
) )
.await .await
} }
// Convenience method for DELETE requests // Convenience method for DELETE requests
@@ -223,6 +251,6 @@ impl HttpClient {
query_params, query_params,
None, None,
) )
.await .await
} }
} }

View File

@@ -5,12 +5,11 @@ use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery,
use crate::common::server::Server; use crate::common::server::Server;
use crate::common::traits::SearchSource; use crate::common::traits::SearchSource;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use crate::server::servers::get_server_token;
use async_trait::async_trait; use async_trait::async_trait;
// use futures::stream::StreamExt; // use futures::stream::StreamExt;
use ordered_float::OrderedFloat; use ordered_float::OrderedFloat;
use reqwest::{Client, Method, RequestBuilder};
use std::collections::HashMap; use std::collections::HashMap;
use tauri_plugin_store::JsonValue;
// use std::hash::Hash; // use std::hash::Hash;
#[allow(dead_code)] #[allow(dead_code)]
@@ -74,45 +73,11 @@ const COCO_SERVERS: &str = "coco-servers";
pub struct CocoSearchSource { pub struct CocoSearchSource {
server: Server, server: Server,
client: Client,
} }
impl CocoSearchSource { impl CocoSearchSource {
pub fn new(server: Server, client: Client) -> Self { pub fn new(server: Server) -> Self {
CocoSearchSource { server, client } CocoSearchSource { server }
}
async fn build_request_from_query(
&self,
query: &SearchQuery,
) -> Result<RequestBuilder, String> {
self.build_request(query.from, query.size, &query.query_strings)
.await
}
async fn build_request(
&self,
from: u64,
size: u64,
query_strings: &HashMap<String, String>,
) -> Result<RequestBuilder, String> {
let url = HttpClient::join_url(&self.server.endpoint, "/query/_search");
let mut request_builder = self.client.request(Method::GET, url);
if !self.server.public {
if let Some(token) = get_server_token(&self.server.id)
.await?
.map(|t| t.access_token)
{
request_builder = request_builder.header("X-API-TOKEN", token);
}
}
let result = request_builder
.query(&[("from", &from.to_string()), ("size", &size.to_string())])
.query(query_strings);
Ok(result)
} }
} }
@@ -127,17 +92,22 @@ impl SearchSource for CocoSearchSource {
} }
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> { async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
// Build the request from the provided query let url = "/query/_search";
let request_builder = self
.build_request_from_query(&query)
.await
.map_err(|e| SearchError::InternalError(e.to_string()))?;
// Send the HTTP request and handle errors let mut query_args: HashMap<String, JsonValue> = HashMap::new();
let response = request_builder query_args.insert("from".into(), JsonValue::Number(query.from.into()));
.send() query_args.insert("size".into(), JsonValue::Number(query.size.into()));
for (key, value) in query.query_strings {
query_args.insert(key, JsonValue::String(value));
}
let response = HttpClient::get(
&self.server.id,
&url,
Some(query_args),
)
.await .await
.map_err(|e| SearchError::HttpError(format!("Failed to send search request: {}", e)))?; .map_err(|e| SearchError::HttpError(format!("Error to send search request: {}", e)))?;
// Use the helper function to parse the response body // Use the helper function to parse the response body
let response_body = get_response_body_text(response) let response_body = get_response_body_text(response)

View File

@@ -7,7 +7,7 @@ use crate::server::http_client::HttpClient;
use crate::server::search::CocoSearchSource; use crate::server::search::CocoSearchSource;
use crate::COCO_TAURI_STORE; use crate::COCO_TAURI_STORE;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use reqwest::{Client, Method}; use reqwest::Method;
use serde_json::from_value; use serde_json::from_value;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use std::collections::HashMap; use std::collections::HashMap;
@@ -447,7 +447,7 @@ pub async fn try_register_server_to_search_source(
) { ) {
if server.enabled { if server.enabled {
let registry = app_handle.state::<SearchSourceRegistry>(); let registry = app_handle.state::<SearchSourceRegistry>();
let source = CocoSearchSource::new(server.clone(), Client::new()); let source = CocoSearchSource::new(server.clone());
registry.register_source(source).await; registry.register_source(source).await;
} }
} }

View File

@@ -2,14 +2,14 @@ use crate::server::servers::{get_server_by_id, get_server_token};
use futures::StreamExt; use futures::StreamExt;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter, Runtime};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::handshake::client::generate_key; use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::{connect_async, MaybeTlsStream}; use tokio_tungstenite::{connect_async_tls_with_config, Connector};
#[derive(Default)] #[derive(Default)]
pub struct WebSocketManager { pub struct WebSocketManager {
connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>, connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>,
@@ -22,9 +22,15 @@ struct WebSocketInstance {
fn convert_to_websocket(endpoint: &str) -> Result<String, String> { fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?; let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?;
let ws_protocol = if url.scheme() == "https" { "wss://" } else { "ws://" }; let ws_protocol = if url.scheme() == "https" {
"wss://"
} else {
"ws://"
};
let host = url.host_str().ok_or("No host found in URL")?; let host = url.host_str().ok_or("No host found in URL")?;
let port = url.port_or_known_default().unwrap_or(if url.scheme() == "https" { 443 } else { 80 }); let port = url
.port_or_known_default()
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
let ws_endpoint = if port == 80 || port == 443 { let ws_endpoint = if port == 80 || port == 443 {
format!("{}{}{}", ws_protocol, host, "/ws") format!("{}{}{}", ws_protocol, host, "/ws")
@@ -35,7 +41,8 @@ fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
} }
#[tauri::command] #[tauri::command]
pub async fn connect_to_server( pub async fn connect_to_server<R: Runtime>(
tauri_app_handle: AppHandle<R>,
id: String, id: String,
client_id: String, client_id: String,
state: tauri::State<'_, WebSocketManager>, state: tauri::State<'_, WebSocketManager>,
@@ -54,16 +61,43 @@ pub async fn connect_to_server(
tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint) tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint)
.map_err(|e| format!("Failed to create WebSocket request: {}", e))?; .map_err(|e| format!("Failed to create WebSocket request: {}", e))?;
request.headers_mut().insert("Connection", "Upgrade".parse().unwrap()); request
request.headers_mut().insert("Upgrade", "websocket".parse().unwrap()); .headers_mut()
request.headers_mut().insert("Sec-WebSocket-Version", "13".parse().unwrap()); .insert("Connection", "Upgrade".parse().unwrap());
request.headers_mut().insert("Sec-WebSocket-Key", generate_key().parse().unwrap()); request
.headers_mut()
.insert("Upgrade", "websocket".parse().unwrap());
request
.headers_mut()
.insert("Sec-WebSocket-Version", "13".parse().unwrap());
request
.headers_mut()
.insert("Sec-WebSocket-Key", generate_key().parse().unwrap());
if let Some(token) = token { if let Some(token) = token {
request.headers_mut().insert("X-API-TOKEN", token.parse().unwrap()); request
.headers_mut()
.insert("X-API-TOKEN", token.parse().unwrap());
} }
let (ws_stream, _) = connect_async(request).await.map_err(|e| format!("WebSocket error: {:?}", e))?; let allow_self_signature =
crate::settings::get_allow_self_signature(tauri_app_handle.clone()).await;
let tls_connector = tokio_native_tls::native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(allow_self_signature)
.build()
.map_err(|e| format!("TLS build error: {:?}", e))?;
let connector = Connector::NativeTls(tls_connector.into());
let (ws_stream, _) = connect_async_tls_with_config(
request,
None, // WebSocketConfig
true, // disable_nagle
Some(connector), // Connector
)
.await
.map_err(|e| format!("WebSocket TLS error: {:?}", e))?;
let (cancel_tx, mut cancel_rx) = mpsc::channel(1); let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
let instance = Arc::new(WebSocketInstance { let instance = Arc::new(WebSocketInstance {
@@ -112,9 +146,11 @@ pub async fn connect_to_server(
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
pub async fn disconnect(client_id: String, state: tauri::State<'_, WebSocketManager>) -> Result<(), String> { pub async fn disconnect(
client_id: String,
state: tauri::State<'_, WebSocketManager>,
) -> Result<(), String> {
let instance = { let instance = {
let mut connections = state.connections.lock().await; let mut connections = state.connections.lock().await;
connections.remove(&client_id) connections.remove(&client_id)
@@ -129,4 +165,4 @@ pub async fn disconnect(client_id: String, state: tauri::State<'_, WebSocketMana
} }
Ok(()) Ok(())
} }

72
src-tauri/src/settings.rs Normal file
View File

@@ -0,0 +1,72 @@
use crate::COCO_TAURI_STORE;
use serde_json::Value as Json;
use tauri::{AppHandle, Runtime};
use tauri_plugin_store::StoreExt;
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
#[tauri::command]
pub async fn set_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>, value: bool) {
use crate::server::http_client;
let store = tauri_app_handle
.store(COCO_TAURI_STORE)
.unwrap_or_else(|e| {
panic!(
"store [{}] not found/loaded, error [{}]",
COCO_TAURI_STORE, e
)
});
let old_value = match store
.get(SETTINGS_ALLOW_SELF_SIGNATURE)
.expect("should be initialized upon first get call")
{
Json::Bool(b) => b,
_ => unreachable!(
"{} should be stored in a boolean",
SETTINGS_ALLOW_SELF_SIGNATURE
),
};
if old_value == value {
return;
}
store.set(SETTINGS_ALLOW_SELF_SIGNATURE, value);
let mut guard = http_client::HTTP_CLIENT.lock().await;
*guard = http_client::new_reqwest_http_client(value)
}
/// Synchronous version of `async get_allow_self_signature()`.
pub fn _get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) -> bool {
let store = tauri_app_handle
.store(COCO_TAURI_STORE)
.unwrap_or_else(|e| {
panic!(
"store [{}] not found/loaded, error [{}]",
COCO_TAURI_STORE, e
)
});
if !store.has(SETTINGS_ALLOW_SELF_SIGNATURE) {
// default to false
store.set(SETTINGS_ALLOW_SELF_SIGNATURE, false);
}
match store
.get(SETTINGS_ALLOW_SELF_SIGNATURE)
.expect("should be Some")
{
Json::Bool(b) => b,
_ => unreachable!(
"{} should be stored in a boolean",
SETTINGS_ALLOW_SELF_SIGNATURE
),
}
}
#[tauri::command]
pub async fn get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) -> bool {
_get_allow_self_signature(tauri_app_handle)
}

View File

@@ -20,7 +20,7 @@ pub use linux::*;
pub fn default(app: &mut App, main_window: WebviewWindow, settings_window: WebviewWindow) { pub fn default(app: &mut App, main_window: WebviewWindow, settings_window: WebviewWindow) {
// Development mode automatically opens the console: https://tauri.app/develop/debug // Development mode automatically opens the console: https://tauri.app/develop/debug
#[cfg(any(dev, debug_assertions))] #[cfg(all(dev, debug_assertions))]
main_window.open_devtools(); main_window.open_devtools();
platform(app, main_window.clone(), settings_window.clone()); platform(app, main_window.clone(), settings_window.clone());

View File

@@ -59,6 +59,7 @@ export const handleApiError = (error: any) => {
message = error.message; message = error.message;
} }
console.error(error);
addError(message, "error"); addError(message, "error");
return error; return error;
}; };

View File

@@ -28,10 +28,10 @@ async function invokeWithErrorHandler<T>(
if (result && typeof result === "object" && "failed" in result) { if (result && typeof result === "object" && "failed" in result) {
const failedResult = result as any; const failedResult = result as any;
if (failedResult.failed?.length > 0) { if (failedResult.failed?.length > 0 && failedResult?.hits?.length == 0) {
failedResult.failed.forEach((error: any) => { failedResult.failed.forEach((error: any) => {
// addError(error.error, 'error'); addError(error.error, 'error');
console.error(error.error); // console.error(error.error);
}); });
} }
} }

View File

@@ -51,6 +51,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
const aiAssistant = useShortcutsStore((state) => state.aiAssistant); const aiAssistant = useShortcutsStore((state) => state.aiAssistant);
const [assistants, setAssistants] = useState<any[]>([]); const [assistants, setAssistants] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const popoverButtonRef = useRef<HTMLButtonElement>(null); const popoverButtonRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
@@ -195,25 +196,41 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
setTimeout(() => setIsRefreshing(false), 1000); setTimeout(() => setIsRefreshing(false), 1000);
}; };
useKeyPress(["uparrow", "downarrow"], (_, key) => { useKeyPress(
const isClose = isNil(popoverButtonRef.current?.dataset["open"]); ["uparrow", "downarrow", "enter"],
const index = assistants.findIndex( (event, key) => {
(item) => item._id === currentAssistant?._id const isClose = isNil(popoverButtonRef.current?.dataset["open"]);
);
const length = assistants.length;
if (isClose || length <= 1) return; if (isClose) return;
let nextIndex = index; event.stopPropagation();
event.preventDefault();
if (key === "uparrow") { if (key === "enter") {
nextIndex = index > 0 ? index - 1 : length - 1; return popoverButtonRef.current?.click();
} else { }
nextIndex = index < length - 1 ? index + 1 : 0;
const index = assistants.findIndex(
(item) => item._id === currentAssistant?._id
);
const length = assistants.length;
if (length <= 1) return;
let nextIndex = index;
if (key === "uparrow") {
nextIndex = index > 0 ? index - 1 : length - 1;
} else {
nextIndex = index < length - 1 ? index + 1 : 0;
}
setCurrentAssistant(assistants[nextIndex]);
},
{
target: popoverRef,
} }
);
setCurrentAssistant(assistants[nextIndex]);
});
const handlePrev = useCallback(() => { const handlePrev = useCallback(() => {
if (pagination.current <= 1) return; if (pagination.current <= 1) return;
@@ -231,7 +248,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
return ( return (
<div className="relative"> <div className="relative">
<Popover> <Popover ref={popoverRef}>
<PopoverButton <PopoverButton
ref={popoverButtonRef} ref={popoverButtonRef}
className="h-6 p-1 px-1.5 flex items-center gap-1 rounded-full bg-white dark:bg-[#202126] text-sm/6 font-semibold text-gray-800 dark:text-[#d8d8d8] border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none" className="h-6 p-1 px-1.5 flex items-center gap-1 rounded-full bg-white dark:bg-[#202126] text-sm/6 font-semibold text-gray-800 dark:text-[#d8d8d8] border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
@@ -326,6 +343,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
)} )}
onClick={() => { onClick={() => {
setCurrentAssistant(assistant); setCurrentAssistant(assistant);
popoverButtonRef.current?.click();
}} }}
> >
<div className="flex items-center justify-center size-6 bg-white border border-[#E6E6E6] rounded-full overflow-hidden"> <div className="flex items-center justify-center size-6 bg-white border border-[#E6E6E6] rounded-full overflow-hidden">

View File

@@ -129,7 +129,7 @@ const ChatAI = memo(
const dealMsgRef = useRef<((msg: string) => void) | null>(null); const dealMsgRef = useRef<((msg: string) => void) | null>(null);
const clientId = isChatPage ? "standalone" : "popup"; const clientId = isChatPage ? "standalone" : "popup";
const { reconnect, disconnectWS, updateDealMsg } = useWebSocket({ const { reconnect, updateDealMsg } = useWebSocket({
clientId, clientId,
connected, connected,
setConnected, setConnected,
@@ -164,10 +164,10 @@ const ChatAI = memo(
isMCPActive, isMCPActive,
changeInput, changeInput,
websocketSessionId, websocketSessionId,
showChatHistory, showChatHistory
); );
const { dealMsg, messageTimeoutRef } = useMessageHandler( const { dealMsg } = useMessageHandler(
curIdRef, curIdRef,
setCurChatEnd, setCurChatEnd,
setTimedoutShow, setTimedoutShow,
@@ -184,24 +184,18 @@ const ChatAI = memo(
}, [dealMsg, updateDealMsg]); }, [dealMsg, updateDealMsg]);
const clearChat = useCallback(() => { const clearChat = useCallback(() => {
console.log("clearChat"); //console.log("clearChat");
setTimedoutShow(false); setTimedoutShow(false);
chatClose(activeChat); chatClose(activeChat);
setActiveChat(undefined); setActiveChat(undefined);
setCurChatEnd(true); setCurChatEnd(true);
clearChatPage && clearChatPage(); clearChatPage && clearChatPage();
}, [ }, [activeChat, chatClose]);
activeChat,
chatClose,
clearChatPage,
setCurChatEnd,
setTimedoutShow,
]);
const init = useCallback( const init = useCallback(
async (value: string) => { async (value: string) => {
try { try {
console.log("init", isLogin, curChatEnd, activeChat?._id); //console.log("init", isLogin, curChatEnd, activeChat?._id);
if (!isLogin) { if (!isLogin) {
addError("Please login to continue chatting"); addError("Please login to continue chatting");
return; return;
@@ -222,7 +216,7 @@ const ChatAI = memo(
[ [
isLogin, isLogin,
curChatEnd, curChatEnd,
activeChat, activeChat?._id,
createNewChat, createNewChat,
handleSendMessage, handleSendMessage,
websocketSessionId, websocketSessionId,
@@ -234,21 +228,6 @@ const ChatAI = memo(
createChatWindow(createWin); createChatWindow(createWin);
}, [createChatWindow, createWin]); }, [createChatWindow, createWin]);
useEffect(() => {
setCurChatEnd(true);
return () => {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
Promise.resolve().then(() => {
chatClose(activeChat);
setActiveChat(undefined);
setCurChatEnd(true);
disconnectWS();
});
};
}, [chatClose, setCurChatEnd]);
const onSelectChat = useCallback( const onSelectChat = useCallback(
async (chat: Chat) => { async (chat: Chat) => {
setTimedoutShow(false); setTimedoutShow(false);
@@ -260,33 +239,28 @@ const ChatAI = memo(
chatHistory(response); chatHistory(response);
} }
}, },
[ [cancelChat, activeChat, chatClose, openSessionChat, chatHistory]
clearAllChunkData,
cancelChat,
activeChat,
chatClose,
openSessionChat,
chatHistory,
]
); );
const deleteChat = useCallback( const deleteChat = useCallback(
(chatId: string) => { (chatId: string) => {
handleDelete(chatId); handleDelete(chatId);
setChats((prev) => prev.filter((chat) => chat._id !== chatId)); setChats((prev) => {
const updatedChats = prev.filter((chat) => chat._id !== chatId);
if (activeChat?._id === chatId) { if (activeChat?._id === chatId) {
const remainingChats = chats.filter((chat) => chat._id !== chatId); if (updatedChats.length > 0) {
setActiveChat(updatedChats[0]);
if (remainingChats.length > 0) { } else {
setActiveChat(remainingChats[0]); init("");
} else { }
init("");
} }
}
return updatedChats;
});
}, },
[activeChat, chats, init, setActiveChat] [activeChat?._id, handleDelete, init]
); );
const handleOutsideClick = useCallback((e: MouseEvent) => { const handleOutsideClick = useCallback((e: MouseEvent) => {
@@ -317,43 +291,38 @@ const ChatAI = memo(
!isSidebarOpenChat && getChatHistory(); !isSidebarOpenChat && getChatHistory();
}, [isSidebarOpenChat, setIsSidebarOpen, getChatHistory]); }, [isSidebarOpenChat, setIsSidebarOpen, getChatHistory]);
const renameChat = (chatId: string, title: string) => { const renameChat = useCallback(
setChats((prev) => { (chatId: string, title: string) => {
const updatedChats = prev.map((item) => { setChats((prev) => {
if (item._id !== chatId) return item; const chatIndex = prev.findIndex((chat) => chat._id === chatId);
if (chatIndex === -1) return prev;
return { ...item, _source: { ...item._source, title } }; const modifiedChat = {
...prev[chatIndex],
_source: { ...prev[chatIndex]._source, title },
};
const result = [...prev];
result.splice(chatIndex, 1);
return [modifiedChat, ...result];
}); });
const modifiedChat = updatedChats.find((item) => { if (activeChat?._id === chatId) {
return item._id === chatId; setActiveChat((prev) => {
}); if (!prev) return prev;
return { ...prev, _source: { ...prev._source, title } };
if (!modifiedChat) { });
return updatedChats;
} }
return [ handleRename(chatId, title);
modifiedChat, },
...updatedChats.filter((item) => item._id !== chatId), [activeChat?._id, handleRename]
]; );
});
if (activeChat?._id === chatId) {
setActiveChat((prev) => {
if (!prev) return prev;
return { ...prev, _source: { ...prev._source, title } };
});
}
handleRename(chatId, title);
};
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
className={`h-full flex flex-col rounded-md relative`} className={`flex flex-col rounded-md relative h-full overflow-hidden`}
> >
{showChatHistory && !setIsSidebarOpen && ( {showChatHistory && !setIsSidebarOpen && (
<ChatSidebar <ChatSidebar
@@ -382,6 +351,7 @@ const ChatAI = memo(
showChatHistory={showChatHistory} showChatHistory={showChatHistory}
assistantIDs={assistantIDs} assistantIDs={assistantIDs}
/> />
{isLogin ? ( {isLogin ? (
<ChatContent <ChatContent
activeChat={activeChat} activeChat={activeChat}

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect } from "react"; import { useRef, useEffect, UIEvent, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChatMessage } from "@/components/ChatMessage"; import { ChatMessage } from "@/components/ChatMessage";
@@ -11,6 +11,8 @@ import type { Chat, IChunkData } from "./types";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import SessionFile from "./SessionFile"; import SessionFile from "./SessionFile";
import Splash from "./Splash"; import Splash from "./Splash";
import { ArrowDown } from "lucide-react";
import clsx from "clsx";
interface ChatContentProps { interface ChatContentProps {
activeChat?: Chat; activeChat?: Chat;
@@ -52,15 +54,16 @@ export const ChatContent = ({
useEffect(() => { useEffect(() => {
setCurrentSessionId(activeChat?._id); setCurrentSessionId(activeChat?._id);
}, [activeChat]); }, [activeChat?._id]);
const { t } = useTranslation(); const { t } = useTranslation();
const uploadFiles = useChatStore((state) => state.uploadFiles); const uploadFiles = useChatStore((state) => state.uploadFiles);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const { scrollToBottom } = useChatScroll(messagesEndRef); const { scrollToBottom } = useChatScroll(messagesEndRef);
const scrollRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
@@ -81,10 +84,25 @@ export const ChatContent = ({
}; };
}, [scrollToBottom]); }, [scrollToBottom]);
const allMessages = activeChat?.messages || [];
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const { scrollHeight, scrollTop, clientHeight } =
event.currentTarget as HTMLDivElement;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
setIsAtBottom(isAtBottom);
};
return ( return (
<div className="relative flex flex-col h-full justify-between overflow-hidden"> <div className="flex-1 overflow-hidden flex flex-col justify-between relative">
<div className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative"> <div
<Greetings /> ref={scrollRef}
className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative"
onScroll={handleScroll}
>
{(!activeChat || activeChat?.messages?.length === 0) && <Greetings />}
{activeChat?.messages?.map((message, index) => ( {activeChat?.messages?.map((message, index) => (
<ChatMessage <ChatMessage
@@ -94,6 +112,7 @@ export const ChatContent = ({
onResend={handleSendMessage} onResend={handleSendMessage}
/> />
))} ))}
{(!curChatEnd || {(!curChatEnd ||
query_intent || query_intent ||
tools || tools ||
@@ -109,6 +128,8 @@ export const ChatContent = ({
_id: "current", _id: "current",
_source: { _source: {
type: "assistant", type: "assistant",
assistant_id:
allMessages[allMessages.length - 1]?._source?.assistant_id,
message: "", message: "",
question: Question, question: Question,
}, },
@@ -125,6 +146,7 @@ export const ChatContent = ({
loadingStep={loadingStep} loadingStep={loadingStep}
/> />
) : null} ) : null}
{timedoutShow ? ( {timedoutShow ? (
<ChatMessage <ChatMessage
key={"timedout"} key={"timedout"}
@@ -152,6 +174,23 @@ export const ChatContent = ({
{sessionId && <SessionFile sessionId={sessionId} />} {sessionId && <SessionFile sessionId={sessionId} />}
<Splash /> <Splash />
<button
className={clsx(
"absolute right-4 bottom-4 flex items-center justify-center size-8 border bg-white rounded-full shadow dark:border-[#272828] dark:bg-black dark:shadow-white/15",
{
hidden: isAtBottom,
}
)}
onClick={() => {
scrollRef.current?.scrollTo({
top: scrollRef.current?.scrollHeight,
behavior: "smooth",
});
}}
>
<ArrowDown className="size-5" />
</button>
</div> </div>
); );
}; };

View File

@@ -12,7 +12,7 @@ interface ChatSidebarProps {
// onNewChat: () => void; // onNewChat: () => void;
onSelectChat: (chat: any) => void; onSelectChat: (chat: any) => void;
onDeleteChat: (chatId: string) => void; onDeleteChat: (chatId: string) => void;
fetchChatHistory: () => Promise<void>; fetchChatHistory: () => void;
onSearch: (keyword: string) => void; onSearch: (keyword: string) => void;
onRename: (chat: any, title: string) => void; onRename: (chat: any, title: string) => void;
} }
@@ -32,7 +32,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
<div <div
data-sidebar data-sidebar
className={` className={`
h-[calc(100%+90px)] absolute top-0 left-0 z-10 w-64 h-screen fixed top-0 left-0 z-100 w-64
transform transition-all duration-300 ease-in-out transform transition-all duration-300 ease-in-out
${isSidebarOpen ? "translate-x-0" : "-translate-x-full"} ${isSidebarOpen ? "translate-x-0" : "-translate-x-full"}
bg-gray-100 dark:bg-gray-800 bg-gray-100 dark:bg-gray-800

View File

@@ -4,10 +4,11 @@ import { X } from "lucide-react";
import { useAsyncEffect } from "ahooks"; import { useAsyncEffect } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useChatStore } from "@/stores/chatStore"; import { UploadFile, useChatStore } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import FileIcon from "../Common/Icons/FileIcon"; import FileIcon from "../Common/Icons/FileIcon";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import Tooltip2 from "../Common/Tooltip2";
interface FileListProps { interface FileListProps {
sessionId: string; sessionId: string;
@@ -39,29 +40,42 @@ const FileList = (props: FileListProps) => {
if (uploaded) continue; if (uploaded) continue;
const attachmentIds: any = await platformAdapter.commands( try {
"upload_attachment", const attachmentIds: any = await platformAdapter.commands(
{ "upload_attachment",
serverId, {
sessionId, serverId,
filePaths: [path], sessionId,
filePaths: [path],
}
);
if (!attachmentIds) {
throw new Error("Failed to get attachment id");
} else {
Object.assign(item, {
uploaded: true,
attachmentId: attachmentIds[0],
});
} }
);
if (!attachmentIds) continue; setUploadFiles(uploadFiles);
} catch (error) {
Object.assign(item, { Object.assign(item, {
uploaded: true, uploadFailed: true,
attachmentId: attachmentIds[0], failedMessage: String(error),
}); });
}
setUploadFiles(uploadFiles);
} }
}, [uploadFiles]); }, [uploadFiles]);
const deleteFile = async (id: string, attachmentId: string) => { const deleteFile = async (file: UploadFile) => {
const { id, uploadFailed, attachmentId } = file;
setUploadFiles(uploadFiles.filter((file) => file.id !== id)); setUploadFiles(uploadFiles.filter((file) => file.id !== id));
if (uploadFailed) return;
platformAdapter.commands("delete_attachment", { platformAdapter.commands("delete_attachment", {
serverId, serverId,
id: attachmentId, id: attachmentId,
@@ -71,16 +85,25 @@ const FileList = (props: FileListProps) => {
return ( return (
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm"> <div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
{uploadFiles.map((file) => { {uploadFiles.map((file) => {
const { id, name, extname, size, uploaded, attachmentId } = file; const {
id,
name,
extname,
size,
uploaded,
attachmentId,
uploadFailed,
failedMessage,
} = file;
return ( return (
<div key={id} className="w-1/3 px-1"> <div key={id} className="w-1/3 px-1">
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]"> <div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
{attachmentId && ( {(uploadFailed || attachmentId) && (
<div <div
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 " className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
onClick={() => { onClick={() => {
deleteFile(id, attachmentId); deleteFile(file);
}} }}
> >
<X className="size-[10px] text-white" /> <X className="size-[10px] text-white" />
@@ -94,16 +117,24 @@ const FileList = (props: FileListProps) => {
{name} {name}
</div> </div>
<div className="text-xs text-[#999999]"> <div className="text-xs">
{uploaded ? ( {uploadFailed && failedMessage ? (
<div className="flex gap-2"> <Tooltip2 content={failedMessage}>
{extname && <span>{extname}</span>} <span className="text-red-500">Upload Failed</span>
<span> </Tooltip2>
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
</div>
) : ( ) : (
<span>{t("assistant.fileList.uploading")}</span> <div className="text-[#999]">
{uploaded ? (
<div className="flex gap-2">
{extname && <span>{extname}</span>}
<span>
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
</div>
) : (
<span>{t("assistant.fileList.uploading")}</span>
)}
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -78,7 +78,7 @@ export function ServerList({
fetchServers(true); fetchServers(true);
const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => { const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => {
console.log("Login or Logout:", currentService, event.payload); //console.log("Login or Logout:", currentService, event.payload);
if (event.payload !== isLogin) { if (event.payload !== isLogin) {
setIsLogin(!!event.payload); setIsLogin(!!event.payload);
} }

View File

@@ -1,173 +1,174 @@
import clsx from "clsx"; import clsx from "clsx";
import { filesize } from "filesize"; import {filesize} from "filesize";
import { Files, Trash2, X } from "lucide-react"; import {Files, Trash2, X} from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import {useEffect, useMemo, useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { useConnectStore } from "@/stores/connectStore"; import {useConnectStore} from "@/stores/connectStore";
import Checkbox from "@/components/Common/Checkbox"; import Checkbox from "@/components/Common/Checkbox";
import FileIcon from "@/components/Common/Icons/FileIcon"; import FileIcon from "@/components/Common/Icons/FileIcon";
import { AttachmentHit } from "@/types/commands"; import {AttachmentHit} from "@/types/commands";
import { useAppStore } from "@/stores/appStore"; import {useAppStore} from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
interface SessionFileProps { interface SessionFileProps {
sessionId: string; sessionId: string;
} }
const SessionFile = (props: SessionFileProps) => { const SessionFile = (props: SessionFileProps) => {
const { sessionId } = props; const {sessionId} = props;
const { t } = useTranslation(); const {t} = useTranslation();
const isTauri = useAppStore((state) => state.isTauri); const isTauri = useAppStore((state) => state.isTauri);
const currentService = useConnectStore((state) => state.currentService); const currentService = useConnectStore((state) => state.currentService);
const [uploadedFiles, setUploadedFiles] = useState<AttachmentHit[]>([]); const [uploadedFiles, setUploadedFiles] = useState<AttachmentHit[]>([]);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [checkList, setCheckList] = useState<string[]>([]); const [checkList, setCheckList] = useState<string[]>([]);
const serverId = useMemo(() => { const serverId = useMemo(() => {
return currentService.id; return currentService.id;
}, [currentService]); }, [currentService]);
useEffect(() => { useEffect(() => {
setUploadedFiles([]); setUploadedFiles([]);
getUploadedFiles(); getUploadedFiles();
}, [sessionId]); }, [sessionId]);
const getUploadedFiles = async () => { const getUploadedFiles = async () => {
if (isTauri) { if (isTauri) {
const response: any = await platformAdapter.commands("get_attachment", { const response: any = await platformAdapter.commands("get_attachment", {
serverId, serverId,
sessionId, sessionId,
}); });
setUploadedFiles(response.hits.hits); setUploadedFiles(response?.hits?.hits ?? []);
} else { } else {
} }
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
let result; let result;
if (isTauri) { if (isTauri) {
result = await platformAdapter.commands("delete_attachment", { result = await platformAdapter.commands("delete_attachment", {
serverId, serverId,
id, id,
}); });
} else { } else {
} }
if (!result) return; if (!result) return;
getUploadedFiles(); getUploadedFiles();
}; };
const handleCheckAll = (checked: boolean) => { const handleCheckAll = (checked: boolean) => {
if (checked) { if (checked) {
setCheckList(uploadedFiles.map((item) => item._source.id)); setCheckList(uploadedFiles?.map((item) => item?._source?.id));
} else { } else {
setCheckList([]); setCheckList([]);
} }
}; };
const handleCheck = (checked: boolean, id: string) => { const handleCheck = (checked: boolean, id: string) => {
if (checked) { if (checked) {
setCheckList([...checkList, id]); setCheckList([...checkList, id]);
} else { } else {
setCheckList(checkList.filter((item) => item !== id)); setCheckList(checkList.filter((item) => item !== id));
} }
}; };
return ( return (
<div <div
className={clsx("select-none", { className={clsx("select-none", {
hidden: uploadedFiles.length === 0, hidden: uploadedFiles?.length === 0,
})} })}
> >
<div <div
className="absolute top-4 right-4 flex items-center justify-center size-8 rounded-lg bg-[#0072FF] cursor-pointer" className="absolute top-4 right-4 flex items-center justify-center size-8 rounded-lg bg-[#0072FF] cursor-pointer"
onClick={() => { onClick={() => {
setVisible(true); setVisible(true);
}} }}
> >
<Files className="size-5 text-white" /> <Files className="size-5 text-white"/>
<div className="absolute -top-2 -right-2 flex items-center justify-center min-w-4 h-4 px-1 text-white text-xs rounded-full bg-[#3DB954]"> <div
{uploadedFiles.length} className="absolute -top-2 -right-2 flex items-center justify-center min-w-4 h-4 px-1 text-white text-xs rounded-full bg-[#3DB954]">
</div> {uploadedFiles?.length}
</div> </div>
</div>
<div <div
className={clsx( className={clsx(
"absolute inset-0 flex flex-col p-4 bg-white dark:bg-black", "absolute inset-0 flex flex-col p-4 bg-white dark:bg-black",
{ {
hidden: !visible, hidden: !visible,
} }
)} )}
> >
<X <X
className="absolute top-4 right-4 size-5 text-[#999] cursor-pointer" className="absolute top-4 right-4 size-5 text-[#999] cursor-pointer"
onClick={() => { onClick={() => {
setVisible(false); setVisible(false);
}} }}
/> />
<div className="mb-2 text-sm text-[#333] dark:text-[#D8D8D8] font-bold"> <div className="mb-2 text-sm text-[#333] dark:text-[#D8D8D8] font-bold">
{t("assistant.sessionFile.title")} {t("assistant.sessionFile.title")}
</div> </div>
<div className="flex items-center justify-between pr-2"> <div className="flex items-center justify-between pr-2">
<span className="text-sm text-[#999]"> <span className="text-sm text-[#999]">
{t("assistant.sessionFile.description")} {t("assistant.sessionFile.description")}
</span> </span>
<Checkbox <Checkbox
indeterminate indeterminate
checked={checkList.length === uploadedFiles.length} checked={checkList?.length === uploadedFiles?.length}
onChange={handleCheckAll} onChange={handleCheckAll}
/> />
</div> </div>
<ul className="flex-1 overflow-auto flex flex-col gap-2 mt-6"> <ul className="flex-1 overflow-auto flex flex-col gap-2 mt-6 p-0">
{uploadedFiles.map((item) => { {uploadedFiles?.map((item) => {
const { id, name, icon, size } = item._source; const {id, name, icon, size} = item._source;
return ( return (
<li <li
key={id} key={id}
className="flex items-center justify-between min-h-12 px-2 rounded-[4px] bg-[#ededed] dark:bg-[#202126]" className="flex items-center justify-between min-h-12 px-2 rounded-[4px] bg-[#ededed] dark:bg-[#202126]"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileIcon extname={icon} /> <FileIcon extname={icon}/>
<div> <div>
<div className="text-sm leading-4 text-[#333] dark:text-[#D8D8D8]"> <div className="text-sm leading-4 text-[#333] dark:text-[#D8D8D8]">
{name} {name}
</div> </div>
<div className="text-xs text-[#999]"> <div className="text-xs text-[#999]">
<span>{icon}</span> <span>{icon}</span>
<span className="pl-2"> <span className="pl-2">
{filesize(size, { standard: "jedec", spacer: "" })} {filesize(size, {standard: "jedec", spacer: ""})}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Trash2 <Trash2
className="size-4 text-[#999] cursor-pointer" className="size-4 text-[#999] cursor-pointer"
onClick={() => handleDelete(id)} onClick={() => handleDelete(id)}
/> />
<Checkbox <Checkbox
checked={checkList.includes(id)} checked={checkList.includes(id)}
onChange={(checked) => handleCheck(checked, id)} onChange={(checked) => handleCheck(checked, id)}
/> />
</div> </div>
</li> </li>
); );
})} })}
</ul> </ul>
</div> </div>
</div> </div>
); );
}; };
export default SessionFile; export default SessionFile;

View File

@@ -96,7 +96,7 @@ const Splash = () => {
return ( return (
visibleStartPage && ( visibleStartPage && (
<div className="absolute inset-0 flex flex-col items-center px-6 pt-6 text-[#333] dark:text-white select-none"> <div className="absolute inset-0 flex flex-col items-center px-6 pt-6 text-[#333] dark:text-white select-none overflow-y-auto custom-scrollbar">
<CircleX <CircleX
className="absolute top-3 right-3 size-4 text-[#999] cursor-pointer" className="absolute top-3 right-3 size-4 text-[#999] cursor-pointer"
onClick={() => { onClick={() => {

View File

@@ -15,7 +15,7 @@ export const CallTools = ({ Detail, ChunkData, loading }: CallToolsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false); const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [Data, setData] = useState(""); const [data, setData] = useState("");
useEffect(() => { useEffect(() => {
if (!Detail?.description) return; if (!Detail?.description) return;
@@ -25,7 +25,7 @@ export const CallTools = ({ Detail, ChunkData, loading }: CallToolsProps) => {
useEffect(() => { useEffect(() => {
if (!ChunkData?.message_chunk) return; if (!ChunkData?.message_chunk) return;
setData(ChunkData?.message_chunk); setData(ChunkData?.message_chunk);
}, [ChunkData?.message_chunk, Data]); }, [ChunkData?.message_chunk, data]);
// Must be after hooks !!! // Must be after hooks !!!
if (!ChunkData && !Detail) return null; if (!ChunkData && !Detail) return null;
@@ -62,11 +62,11 @@ export const CallTools = ({ Detail, ChunkData, loading }: CallToolsProps) => {
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]"> <div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2"> <div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<Markdown <Markdown
content={Data || ""} content={data || ""}
loading={loading} loading={loading}
onDoubleClickCapture={() => {}} onDoubleClickCapture={() => {}}
/> />
{/* {Data?.split("\n").map( {/* {data?.split("\n").map(
(paragraph, idx) => (paragraph, idx) =>
paragraph.trim() && ( paragraph.trim() && (
<p key={idx} className="text-sm"> <p key={idx} className="text-sm">

View File

@@ -20,7 +20,7 @@ export const DeepRead = ({
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false); const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [Data, setData] = useState<string[]>([]); const [data, setData] = useState<string[]>([]);
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
useEffect(() => { useEffect(() => {
@@ -71,7 +71,7 @@ export const DeepRead = ({
ChunkData?.chunk_type || Detail?.type ChunkData?.chunk_type || Detail?.type
}`, }`,
{ {
count: Number(Data.length), count: Number(data.length),
} }
)} )}
</span> </span>
@@ -87,7 +87,7 @@ export const DeepRead = ({
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]"> <div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2"> <div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-3 text-xs"> <div className="mb-4 space-y-3 text-xs">
{Data?.map((item) => ( {data?.map((item) => (
<div key={item} className="flex flex-col gap-2"> <div key={item} className="flex flex-col gap-2">
<div className="text-xs text-[#999999] dark:text-[#808080]"> <div className="text-xs text-[#999999] dark:text-[#808080]">
- {item} - {item}

View File

@@ -129,17 +129,17 @@ export const FetchSource = ({
className="group flex items-center p-2 hover:bg-[#F7F7F7] dark:hover:bg-[#2C2C2C] border-b border-[#E6E6E6] dark:border-[#272626] last:border-b-0 cursor-pointer transition-colors" className="group flex items-center p-2 hover:bg-[#F7F7F7] dark:hover:bg-[#2C2C2C] border-b border-[#E6E6E6] dark:border-[#272626] last:border-b-0 cursor-pointer transition-colors"
> >
<div className="w-full flex items-center gap-2"> <div className="w-full flex items-center gap-2">
<div className="w-full md:w-[75%] flex items-center gap-1"> <div className="w-[75%] mobile:w-full flex items-center gap-1">
<Globe className="w-3 h-3 flex-shrink-0" /> <Globe className="w-3 h-3 flex-shrink-0" />
<div className="text-xs text-[#333333] dark:text-[#D8D8D8] truncate font-normal group-hover:text-[#0072FF] dark:group-hover:text-[#0072FF]"> <div className="text-xs text-[#333333] dark:text-[#D8D8D8] truncate font-normal group-hover:text-[#0072FF] dark:group-hover:text-[#0072FF]">
{item.title || item.category} {item.title || item.category}
</div> </div>
</div> </div>
<div <div
className={`flex mobile:hidden w-[25%] items-center justify-end gap-2`} className={`flex-1 mobile:hidden flex items-center justify-end gap-2`}
> >
<span className="text-xs text-[#999999] dark:text-[#999999] truncate"> <span className="text-xs text-[#999999] dark:text-[#999999] truncate">
{item.source?.name} {item.source?.name || item?.category}
</span> </span>
<SquareArrowOutUpRight className="w-3 h-3 text-[#999999] dark:text-[#999999] flex-shrink-0" /> <SquareArrowOutUpRight className="w-3 h-3 text-[#999999] dark:text-[#999999] flex-shrink-0" />
</div> </div>

View File

@@ -26,7 +26,7 @@ export const PickSource = ({
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false); const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [Data, setData] = useState<IData[]>([]); const [data, setData] = useState<IData[]>([]);
useEffect(() => { useEffect(() => {
if (!Detail?.payload) return; if (!Detail?.payload) return;
@@ -90,7 +90,7 @@ export const PickSource = ({
ChunkData?.chunk_type || Detail.type ChunkData?.chunk_type || Detail.type
}`, }`,
{ {
count: Data?.length, count: data?.length,
} }
)} )}
</span> </span>
@@ -106,7 +106,7 @@ export const PickSource = ({
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]"> <div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2"> <div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-3 text-xs"> <div className="mb-4 space-y-3 text-xs">
{Data?.map((item) => ( {data?.map((item) => (
<div <div
key={item.id} key={item.id}
className="p-3 rounded-lg border border-[#E6E6E6] dark:border-[#272626] bg-white dark:bg-[#1E1E1E] hover:bg-gray-50 dark:hover:bg-[#2C2C2C] transition-colors" className="p-3 rounded-lg border border-[#E6E6E6] dark:border-[#272626] bg-white dark:bg-[#1E1E1E] hover:bg-gray-50 dark:hover:bg-[#2C2C2C] transition-colors"

View File

@@ -24,7 +24,7 @@ const PrevSuggestion: FC<PrevSuggestionProps> = (props) => {
}, [JSON.stringify(currentAssistant)]); }, [JSON.stringify(currentAssistant)]);
return ( return (
<ul className="absolute left-2 bottom-2 flex flex-col gap-2"> <ul className="absolute left-2 bottom-2 flex flex-col gap-2 p-0">
{list.map((item) => { {list.map((item) => {
return ( return (
<li <li

View File

@@ -30,7 +30,7 @@ export const QueryIntent = ({
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false); const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [Data, setData] = useState<IQueryData | null>(null); const [data, setData] = useState<IQueryData | null>(null);
useEffect(() => { useEffect(() => {
if (!Detail?.payload) return; if (!Detail?.payload) return;
@@ -100,13 +100,13 @@ export const QueryIntent = ({
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]"> <div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2"> <div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-2 text-xs"> <div className="mb-4 space-y-2 text-xs">
{Data?.keyword ? ( {data?.keyword ? (
<div className="flex gap-1"> <div className="flex gap-1">
<span className="text-[#999999]"> <span className="text-[#999999]">
- {t("assistant.message.steps.keywords")} - {t("assistant.message.steps.keywords")}
</span> </span>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{Data?.keyword?.map((keyword, index) => ( {data?.keyword?.map((keyword, index) => (
<span <span
key={keyword + index} key={keyword + index}
className="text-[#333333] dark:text-[#D8D8D8]" className="text-[#333333] dark:text-[#D8D8D8]"
@@ -118,33 +118,33 @@ export const QueryIntent = ({
</div> </div>
</div> </div>
) : null} ) : null}
{Data?.category ? ( {data?.category ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-[#999999]"> <span className="text-[#999999]">
- {t("assistant.message.steps.questionType")} - {t("assistant.message.steps.questionType")}
</span> </span>
<span className="text-[#333333] dark:text-[#D8D8D8]"> <span className="text-[#333333] dark:text-[#D8D8D8]">
{Data?.category} {data?.category}
</span> </span>
</div> </div>
) : null} ) : null}
{Data?.intent ? ( {data?.intent ? (
<div className="flex items-start gap-1"> <div className="flex items-start gap-1">
<span className="text-[#999999]"> <span className="text-[#999999]">
- {t("assistant.message.steps.userIntent")} - {t("assistant.message.steps.userIntent")}
</span> </span>
<div className="flex-1 text-[#333333] dark:text-[#D8D8D8]"> <div className="flex-1 text-[#333333] dark:text-[#D8D8D8]">
{Data?.intent} {data?.intent}
</div> </div>
</div> </div>
) : null} ) : null}
{Data?.query ? ( {data?.query ? (
<div className="flex items-start gap-1"> <div className="flex items-start gap-1">
<span className="text-[#999999]"> <span className="text-[#999999]">
- {t("assistant.message.steps.relatedQuestions")} - {t("assistant.message.steps.relatedQuestions")}
</span> </span>
<div className="flex-1 flex flex-col text-[#333333] dark:text-[#D8D8D8]"> <div className="flex-1 flex flex-col text-[#333333] dark:text-[#D8D8D8]">
{Data?.query?.map((question, qIndex) => ( {data?.query?.map((question, qIndex) => (
<span key={question + qIndex}>- {question}</span> <span key={question + qIndex}>- {question}</span>
))} ))}
</div> </div>

View File

@@ -14,7 +14,7 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isThinkingExpanded, setIsThinkingExpanded] = useState(true); const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
const [Data, setData] = useState(""); const [data, setData] = useState("");
useEffect(() => { useEffect(() => {
if (!Detail?.description) return; if (!Detail?.description) return;
@@ -24,7 +24,7 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
useEffect(() => { useEffect(() => {
if (!ChunkData?.message_chunk) return; if (!ChunkData?.message_chunk) return;
setData(ChunkData?.message_chunk); setData(ChunkData?.message_chunk);
}, [ChunkData?.message_chunk, Data]); }, [ChunkData?.message_chunk, data]);
// Must be after hooks !!! // Must be after hooks !!!
if (!ChunkData && !Detail) return null; if (!ChunkData && !Detail) return null;
@@ -59,7 +59,7 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
{isThinkingExpanded && ( {isThinkingExpanded && (
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]"> <div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2"> <div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
{Data?.split("\n").map( {data?.split("\n").map(
(paragraph, idx) => (paragraph, idx) =>
paragraph.trim() && ( paragraph.trim() && (
<p key={idx} className="text-sm"> <p key={idx} className="text-sm">

View File

@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import clsx from "clsx";
import { CopyButton } from "@/components/Common/CopyButton"; import { CopyButton } from "@/components/Common/CopyButton";
@@ -15,7 +16,13 @@ export const UserMessage = ({ messageContent }: UserMessageProps) => {
onMouseEnter={() => setShowCopyButton(true)} onMouseEnter={() => setShowCopyButton(true)}
onMouseLeave={() => setShowCopyButton(false)} onMouseLeave={() => setShowCopyButton(false)}
> >
{showCopyButton && <CopyButton textToCopy={messageContent} />} <div
className={clsx("size-6 transition", {
"opacity-0": !showCopyButton,
})}
>
<CopyButton textToCopy={messageContent} />
</div>
<div <div
className="px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer select-none" className="px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer select-none"
onDoubleClick={(e) => { onDoubleClick={(e) => {

View File

@@ -679,11 +679,14 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
.markdown-body p br,
.markdown-body table td br, .markdown-body table td br,
.markdown-body table th br { .markdown-body table th br {
display: block; display: block !important;
content: ""; content: "" !important;
margin-top: 8px; margin-top: 8px !important;
height: 1px !important;
background-color: transparent !important;
} }
.markdown-body table tr { .markdown-body table tr {

View File

@@ -0,0 +1,44 @@
import { useState } from "react";
import { CircleAlert, Bolt, X, Ellipsis } from "lucide-react";
import { useTranslation } from "react-i18next";
import platformAdapter from "@/utils/platformAdapter";
import Tooltip from "@/components/Common/Tooltip";
interface ErrorSearchProps {
isError: any[];
}
const ErrorSearch = ({
isError,
}: ErrorSearchProps) => {
const { t } = useTranslation();
const [showError, setShowError] = useState<boolean>(isError?.length > 0);
if (!showError) return null;
return (
<div className="flex items-center gap-2 text-sm text-[#333] dark:text-[#666] p-2">
<CircleAlert className="text-[#FF0000] size-3" />
{t("search.list.failures")}
<Tooltip content={isError} position="bottom">
<Ellipsis className="dark:text-white size-3 cursor-pointer" />
</Tooltip>
<Bolt
className="dark:text-white size-3 cursor-pointer"
onClick={() => {
platformAdapter.emitEvent("open_settings", "connect");
}}
/>
<X
className="text-[#666] size-4 cursor-pointer"
onClick={() => setShowError(false)}
/>
</div>
);
};
export default ErrorSearch;

View File

@@ -14,12 +14,20 @@ import { FC, useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter"; import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import clsx from "clsx"; import clsx from "clsx";
import { Ellipsis, Pencil, RefreshCcw, Search, Trash2 } from "lucide-react"; import {
Ellipsis,
PanelLeftClose,
Pencil,
RefreshCcw,
Search,
Trash2,
} from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import VisibleKey from "../VisibleKey"; import VisibleKey from "../VisibleKey";
import { Chat } from "@/components/Assistant/types"; import { Chat } from "@/components/Assistant/types";
import NoDataImage from "../NoDataImage"; import NoDataImage from "../NoDataImage";
import { closeHistoryPanel } from "@/utils";
dayjs.extend(isSameOrAfter); dayjs.extend(isSameOrAfter);
@@ -28,7 +36,7 @@ interface HistoryListProps {
list: Chat[]; list: Chat[];
active?: Chat; active?: Chat;
onSearch: (keyword: string) => void; onSearch: (keyword: string) => void;
onRefresh: () => Promise<void>; onRefresh: () => void;
onSelect: (chat: Chat) => void; onSelect: (chat: Chat) => void;
onRename: (chatId: string, title: string) => void; onRename: (chatId: string, title: string) => void;
onRemove: (chatId: string) => void; onRemove: (chatId: string) => void;
@@ -159,10 +167,10 @@ const HistoryList: FC<HistoryListProps> = (props) => {
ref={listRef} ref={listRef}
id={id} id={id}
className={clsx( className={clsx(
"flex flex-col h-full overflow-auto px-3 py-2 text-sm bg-[#F3F4F6] dark:bg-[#1F2937] custom-scrollbar" "flex flex-col h-screen text-sm bg-[#F3F4F6] dark:bg-[#1F2937]"
)} )}
> >
<div className="flex gap-1 children:h-8"> <div className="flex gap-1 p-2 border-b dark:border-[#343D4D]">
<div className="flex-1 flex items-center gap-2 px-2 rounded-lg border transition border-[#E6E6E6] bg-[#F8F9FA] dark:bg-[#2B3444] dark:border-[#343D4D] focus-within:border-[#0061FF]"> <div className="flex-1 flex items-center gap-2 px-2 rounded-lg border transition border-[#E6E6E6] bg-[#F8F9FA] dark:bg-[#2B3444] dark:border-[#343D4D] focus-within:border-[#0061FF]">
<VisibleKey <VisibleKey
shortcut="F" shortcut="F"
@@ -198,202 +206,219 @@ const HistoryList: FC<HistoryListProps> = (props) => {
</div> </div>
</div> </div>
{list.length > 0 ? ( <div className="flex-1 px-2 overflow-auto custom-scrollbar">
<> {list.length > 0 ? (
<div className="mt-6"> <>
{Object.entries(sortedList).map(([label, list]) => { <div className="mt-6">
return ( {Object.entries(sortedList).map(([label, list]) => {
<div key={label}> return (
<span className="text-xs text-[#999] px-3">{t(label)}</span> <div key={label}>
<span className="text-xs text-[#999] px-3">{t(label)}</span>
<ul> <ul className="p-0">
{list.map((item) => { {list.map((item) => {
const { _id, _source } = item; const { _id, _source } = item;
const isActive = _id === active?._id; const isActive = _id === active?._id;
const title = _source?.title ?? _id; const title = _source?.title ?? _id;
return ( return (
<li <li
key={_id} key={_id}
id={_id} id={_id}
className={clsx( className={clsx(
"flex items-center mt-1 h-10 rounded-lg cursor-pointer hover:bg-[#EDEDED] dark:hover:bg-[#353F4D] transition", "flex items-center mt-1 h-10 rounded-lg cursor-pointer hover:bg-[#EDEDED] dark:hover:bg-[#353F4D] transition",
{ {
"!bg-[#E5E7EB] dark:!bg-[#2B3444]": isActive, "!bg-[#E5E7EB] dark:!bg-[#2B3444]": isActive,
} }
)}
onClick={() => {
if (!isActive) {
setIsEdit(false);
}
onSelect(item);
}}
>
<div
className={clsx("w-1 h-6 rounded-sm bg-[#0072FF]", {
"opacity-0": _id !== active?._id,
})}
/>
<div className="flex-1 flex items-center justify-between gap-2 px-2 overflow-hidden">
{isEdit && isActive ? (
<Input
autoFocus
defaultValue={title}
className="flex-1 -mx-px outline-none bg-transparent border border-[#0061FF] rounded-[4px]"
onKeyDown={(event) => {
if (event.key !== "Enter") return;
onRename(item._id, event.currentTarget.value);
setIsEdit(false);
}}
onBlur={(event) => {
onRename(item._id, event.target.value);
setIsEdit(false);
}}
/>
) : (
<span className="truncate">{title}</span>
)} )}
onClick={() => {
if (!isActive) {
setIsEdit(false);
}
<div className="flex items-center gap-2"> onSelect(item);
{isActive && !isEdit && ( }}
<VisibleKey >
shortcut="↑↓" <div
rootClassName="w-6" className={clsx(
shortcutClassName="w-6" "w-1 h-6 rounded-sm bg-[#0072FF]",
{
"opacity-0": _id !== active?._id,
}
)}
/>
<div className="flex-1 flex items-center justify-between gap-2 px-2 overflow-hidden">
{isEdit && isActive ? (
<Input
autoFocus
defaultValue={title}
className="flex-1 -mx-px outline-none bg-transparent border border-[#0061FF] rounded-[4px]"
onKeyDown={(event) => {
if (event.key !== "Enter") return;
onRename(
item._id,
event.currentTarget.value
);
setIsEdit(false);
}}
onBlur={(event) => {
onRename(item._id, event.target.value);
setIsEdit(false);
}}
/> />
) : (
<span className="truncate">{title}</span>
)} )}
<Popover> <div className="flex items-center gap-2">
{isActive && !isEdit && ( {isActive && !isEdit && (
<PopoverButton <VisibleKey
ref={moreButtonRef} shortcut="↑↓"
className="flex gap-2" rootClassName="w-6"
> shortcutClassName="w-6"
<VisibleKey />
shortcut="O"
onKeyPress={() => {
moreButtonRef.current?.click();
}}
>
<Ellipsis className="size-4 text-[#979797]" />
</VisibleKey>
</PopoverButton>
)} )}
<PopoverPanel <Popover>
anchor="bottom" {isActive && !isEdit && (
className="flex flex-col rounded-lg shadow-md z-100 bg-white dark:bg-[#202126] p-1 border border-black/2 dark:border-white/10" <PopoverButton
onClick={(event) => { ref={moreButtonRef}
event.stopPropagation(); className="flex gap-2"
}} >
> <VisibleKey
{menuItems.map((menuItem) => { shortcut="O"
const { onKeyPress={() => {
label, moreButtonRef.current?.click();
icon: Icon, }}
shortcut,
iconColor,
onClick,
} = menuItem;
return (
<button
key={label}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-[#EDEDED] dark:hover:bg-[#2B2C31] transition"
onClick={onClick}
> >
<VisibleKey <Ellipsis className="size-4 text-[#979797]" />
shortcut={shortcut} </VisibleKey>
onKeyPress={onClick} </PopoverButton>
)}
<PopoverPanel
anchor="bottom"
className="flex flex-col rounded-lg shadow-md z-100 bg-white dark:bg-[#202126] p-1 border border-black/2 dark:border-white/10"
onClick={(event) => {
event.stopPropagation();
}}
>
{menuItems.map((menuItem) => {
const {
label,
icon: Icon,
shortcut,
iconColor,
onClick,
} = menuItem;
return (
<button
key={label}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-[#EDEDED] dark:hover:bg-[#2B2C31] transition"
onClick={onClick}
> >
<Icon <VisibleKey
className="size-4" shortcut={shortcut}
style={{ onKeyPress={onClick}
color: iconColor, >
}} <Icon
/> className="size-4"
</VisibleKey> style={{
color: iconColor,
}}
/>
</VisibleKey>
<span>{t(label)}</span> <span>{t(label)}</span>
</button> </button>
); );
})} })}
</PopoverPanel> </PopoverPanel>
</Popover> </Popover>
</div>
</div> </div>
</div> </li>
</li> );
); })}
})} </ul>
</ul> </div>
</div> );
); })}
})}
</div>
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-1000"
>
<div
id="headlessui-popover-panel:delete-history"
className="fixed inset-0 flex items-center justify-center w-screen"
>
<DialogPanel className="flex flex-col justify-between w-[360px] h-[160px] p-3 text-[#333] dark:text-white/90 border border-[#e6e6e6] bg-white dark:bg-[#202126] dark:border-white/10 shadow-xl rounded-lg">
<div className="flex flex-col gap-3">
<DialogTitle className="text-base font-bold">
{t("history_list.delete_modal.title")}
</DialogTitle>
<Description className="text-sm">
{t("history_list.delete_modal.description", {
replace: [active?._source?.title || active?._id],
})}
</Description>
</div>
<div className="flex gap-4 self-end">
<VisibleKey
shortcut="N"
shortcutClassName="left-[unset] right-0"
onKeyPress={() => setIsOpen(false)}
>
<button
className="h-8 px-4 text-sm text-[#666666] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border border-[#E6E6E6] dark:border-white/10 rounded-lg"
onClick={() => setIsOpen(false)}
>
{t("history_list.delete_modal.button.cancel")}
</button>
</VisibleKey>
<VisibleKey
shortcut="Y"
shortcutClassName="left-[unset] right-0"
onKeyPress={handleRemove}
>
<button
className="h-8 px-4 text-sm text-white bg-[#EF4444] rounded-lg"
onClick={handleRemove}
>
{t("history_list.delete_modal.button.delete")}
</button>
</VisibleKey>
</div>
</DialogPanel>
</div> </div>
</Dialog>
</> <Dialog
) : ( open={isOpen}
<div className="flex items-center justify-center flex-1"> onClose={() => setIsOpen(false)}
<NoDataImage /> className="relative z-1000"
</div> >
)} <div
id="headlessui-popover-panel:delete-history"
className="fixed inset-0 flex items-center justify-center w-screen"
>
<DialogPanel className="flex flex-col justify-between w-[360px] h-[160px] p-3 text-[#333] dark:text-white/90 border border-[#e6e6e6] bg-white dark:bg-[#202126] dark:border-white/10 shadow-xl rounded-lg">
<div className="flex flex-col gap-3">
<DialogTitle className="text-base font-bold">
{t("history_list.delete_modal.title")}
</DialogTitle>
<Description className="text-sm">
{t("history_list.delete_modal.description", {
replace: [active?._source?.title || active?._source?.message || active?._id],
})}
</Description>
</div>
<div className="flex gap-4 self-end">
<VisibleKey
shortcut="N"
shortcutClassName="left-[unset] right-0"
onKeyPress={() => setIsOpen(false)}
>
<button
className="h-8 px-4 text-sm text-[#666666] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border border-[#E6E6E6] dark:border-white/10 rounded-lg"
onClick={() => setIsOpen(false)}
>
{t("history_list.delete_modal.button.cancel")}
</button>
</VisibleKey>
<VisibleKey
shortcut="Y"
shortcutClassName="left-[unset] right-0"
onKeyPress={handleRemove}
>
<button
className="h-8 px-4 text-sm text-white bg-[#EF4444] rounded-lg"
onClick={handleRemove}
>
{t("history_list.delete_modal.button.delete")}
</button>
</VisibleKey>
</div>
</DialogPanel>
</div>
</Dialog>
</>
) : (
<div className="flex items-center justify-center flex-1 pt-8">
<NoDataImage />
</div>
)}
</div>
<div className="flex justify-end p-2 border-t dark:border-[#343D4D]">
<VisibleKey shortcut="Esc" shortcutClassName="w-7">
<PanelLeftClose
className="size-4 text-black/80 dark:text-white/80 cursor-pointer"
onClick={closeHistoryPanel}
/>
</VisibleKey>
</div>
</div> </div>
); );
}; };

View File

@@ -1,8 +1,9 @@
import React, { useState } from "react"; import React, { useState } from "react";
import "./style.css"; import "./style.css";
interface TooltipProps { interface TooltipProps {
content: string; content: string | any[];
position?: "top" | "bottom" | "left" | "right"; position?: "top" | "bottom" | "left" | "right";
children: React.ReactNode; children: React.ReactNode;
} }
@@ -17,6 +18,21 @@ const Tooltip: React.FC<TooltipProps> = ({
const handleMouseEnter = () => setVisible(true); const handleMouseEnter = () => setVisible(true);
const handleMouseLeave = () => setVisible(false); const handleMouseLeave = () => setVisible(false);
const renderContent = () => {
if (Array.isArray(content)) {
return (
<ul className="list-none p-0 m-0">
{content.map((item, index) => (
<li key={index} className="py-1">
{item?.error || item}
</li>
))}
</ul>
);
}
return content;
};
return ( return (
<div <div
className="tooltip-container" className="tooltip-container"
@@ -25,7 +41,9 @@ const Tooltip: React.FC<TooltipProps> = ({
> >
{children} {children}
{visible && ( {visible && (
<div className={`tooltip-box tooltip-${position}`}>{content}</div> <div className={`tooltip-box tooltip-${position}`}>
{renderContent()}
</div>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,42 @@
import {
Popover,
PopoverButton,
PopoverPanel,
PopoverPanelProps,
} from "@headlessui/react";
import { useBoolean } from "ahooks";
import clsx from "clsx";
import { FC, ReactNode } from "react";
interface Tooltip2Props extends PopoverPanelProps {
content: string;
children: ReactNode;
}
const Tooltip2: FC<Tooltip2Props> = (props) => {
const { content, children, anchor = "top", ...rest } = props;
const [visible, { setTrue, setFalse }] = useBoolean(false);
return (
<Popover>
<PopoverButton onMouseOver={setTrue} onMouseOut={setFalse}>
{children}
</PopoverButton>
<PopoverPanel
{...rest}
static
anchor={anchor}
className={clsx(
"fixed z-1000 p-2 rounded-md text-xs text-white bg-black/75 hidden",
{
"!block": visible,
}
)}
>
{content}
</PopoverPanel>
</Popover>
);
};
export default Tooltip2;

View File

@@ -1,5 +1,11 @@
import { useBoolean } from "ahooks"; import { useBoolean } from "ahooks";
import { useRef, useImperativeHandle, forwardRef, KeyboardEvent } from "react"; import {
useRef,
useImperativeHandle,
forwardRef,
KeyboardEvent,
useEffect,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface AutoResizeTextareaProps { interface AutoResizeTextareaProps {
@@ -8,61 +14,86 @@ interface AutoResizeTextareaProps {
handleKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void; handleKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
connected: boolean; connected: boolean;
chatPlaceholder?: string; chatPlaceholder?: string;
onLineCountChange?: (lineCount: number) => void;
} }
// Forward ref to allow parent to interact with this component // Forward ref to allow parent to interact with this component
const AutoResizeTextarea = forwardRef< const AutoResizeTextarea = forwardRef<
{ reset: () => void; focus: () => void }, { reset: () => void; focus: () => void },
AutoResizeTextareaProps AutoResizeTextareaProps
>(({ input, setInput, handleKeyDown, connected, chatPlaceholder }, ref) => { >(
const { t } = useTranslation(); (
const textareaRef = useRef<HTMLTextAreaElement>(null); {
const [isComposition, { setTrue, setFalse }] = useBoolean(); input,
setInput,
// Expose methods to the parent via ref handleKeyDown,
useImperativeHandle(ref, () => ({ connected,
reset: () => { chatPlaceholder,
setInput(""); onLineCountChange,
}, },
focus: () => { ref
textareaRef.current?.focus(); ) => {
}, const { t } = useTranslation();
})); const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isComposition, { setTrue, setFalse }] = useBoolean();
const handleKeyPress = (event: KeyboardEvent<HTMLTextAreaElement>) => { // Expose methods to the parent via ref
if (isComposition) return; useImperativeHandle(ref, () => ({
reset: () => {
setInput("");
},
focus: () => {
textareaRef.current?.focus();
},
}));
handleKeyDown?.(event); const handleKeyPress = (event: KeyboardEvent<HTMLTextAreaElement>) => {
}; if (isComposition) return;
return ( handleKeyDown?.(event);
<textarea };
ref={textareaRef}
autoFocus useEffect(() => {
autoComplete="off" if (textareaRef.current) {
autoCapitalize="none" textareaRef.current.style.height = "auto";
spellCheck="false" const newHeight = Math.min(textareaRef.current.scrollHeight, 15 * 16); // 15rem ≈ 15 * 16px
className="text-base flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent" textareaRef.current.style.height = `${newHeight}px`;
placeholder={
connected ? chatPlaceholder || t("search.textarea.placeholder") : "" const lineHeight = 24; // 1.5rem = 24px
const lineCount = Math.ceil(newHeight / lineHeight);
onLineCountChange?.(lineCount);
} }
aria-label={t("search.textarea.ariaLabel")} }, [input]);
value={input}
onChange={(e) => setInput(e.target.value)} return (
onKeyDown={handleKeyPress} <textarea
onCompositionStart={setTrue} ref={textareaRef}
onCompositionEnd={() => { autoFocus
setTimeout(setFalse, 0); autoComplete="off"
}} autoCapitalize="none"
rows={1} spellCheck="false"
style={{ className="text-base flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent custom-scrollbar"
resize: "none", // Prevent manual resize placeholder={
overflow: "auto", // Enable scrollbars when needed connected ? chatPlaceholder || t("search.textarea.placeholder") : ""
maxHeight: "4.5rem", // Limit height to 3 rows (3 * 1.5 line-height) }
lineHeight: "1.5rem", // Line height to match row height aria-label={t("search.textarea.ariaLabel")}
}} value={input}
/> onChange={(e) => setInput(e.target.value)}
); onKeyDown={handleKeyPress}
}); onCompositionStart={setTrue}
onCompositionEnd={() => {
setTimeout(setFalse, 0);
}}
rows={1}
style={{
resize: "none", // Prevent manual resize
overflow: "auto", // Enable scrollbars when needed
maxHeight: "13.5rem", // Limit height to 9 rows (9 * 1.5 line-height)
lineHeight: "1.5rem", // Line height to match row height
}}
/>
);
}
);
export default AutoResizeTextarea; export default AutoResizeTextarea;

View File

@@ -0,0 +1,70 @@
import React from "react";
import { Send } from "lucide-react";
import StopIcon from "@/icons/Stop";
interface ChatIconsProps {
lineCount: number;
isChatMode: boolean;
curChatEnd: boolean;
inputValue: string;
onSend: (value: string) => void;
disabledChange: () => void;
}
const ChatIcons: React.FC<ChatIconsProps> = ({
lineCount,
isChatMode,
curChatEnd,
inputValue,
onSend,
disabledChange,
}) => {
const renderSendButton = () => {
if (!isChatMode) return null;
if (curChatEnd) {
return (
<button
className={`ml-1 p-1 ${
inputValue ? "bg-[#0072FF]" : "bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]"
} rounded-full transition-colors`}
type="submit"
onClick={() => onSend(inputValue.trim())}
>
<Send className="w-4 h-4 text-white" />
</button>
);
}
if (!curChatEnd) {
return (
<button
className={`ml-1 px-1 bg-[#0072FF] rounded-full transition-colors`}
type="submit"
onClick={() => disabledChange()}
>
<StopIcon
size={16}
className="w-4 h-4 text-white"
aria-label="Stop message"
/>
</button>
);
}
return null;
};
return (
<>
{lineCount === 1 ? (
renderSendButton()
) : (
<div className="w-full flex justify-end mt-1">{renderSendButton()}</div>
)}
</>
);
};
export default ChatIcons;

View File

@@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import VisibleKey from "@/components/Common/VisibleKey";
interface ConnectionErrorProps {
reconnect: () => void;
connected: boolean;
}
export default function ConnectionError({
reconnect,
connected,
}: ConnectionErrorProps) {
const { t } = useTranslation();
const [reconnectCountdown, setReconnectCountdown] = useState<number>(0);
useEffect(() => {
if (!reconnectCountdown || connected) {
setReconnectCountdown(0);
return;
}
if (reconnectCountdown > 0) {
const timer = setTimeout(() => {
setReconnectCountdown(reconnectCountdown - 1);
}, 1000);
return () => clearTimeout(timer);
}
}, [reconnectCountdown, connected]);
return (
<div className="absolute top-0 right-0 bottom-0 left-0 px-2 py-4 bg-[rgba(238,238,238,0.98)] dark:bg-[rgba(32,33,38,0.9)] backdrop-blur-[2px] rounded-md font-normal text-xs text-gray-400 flex items-center gap-4 z-10">
{t("search.input.connectionError")}
<div
className="px-1 h-[24px] text-[#0061FF] font-normal text-xs flex items-center justify-center cursor-pointer underline"
onClick={() => {
reconnect();
setReconnectCountdown(10);
}}
>
{reconnectCountdown > 0 ? (
`${t("search.input.connecting")}(${reconnectCountdown}s)`
) : (
<VisibleKey
shortcut="R"
onKeyPress={() => {
reconnect();
setReconnectCountdown(10);
}}
>
{t("search.input.reconnect")}
</VisibleKey>
)}
</div>
</div>
);
}

View File

@@ -206,7 +206,7 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
> >
<div className="text-[#999] dark:text-[#666] truncate">{title}</div> <div className="text-[#999] dark:text-[#666] truncate">{title}</div>
<ul className="flex flex-col -mx-2"> <ul className="flex flex-col -mx-2 p-0">
{searchMenus.map((item, index) => { {searchMenus.map((item, index) => {
const { name, icon, keys, clickEvent } = item; const { name, icon, keys, clickEvent } = item;

View File

@@ -35,7 +35,9 @@ export const DocumentList: React.FC<DocumentListProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const sourceData = useSearchStore((state) => state.sourceData); const sourceData = useSearchStore((state) => state.sourceData);
const isTauri = useAppStore((state) => state.isTauri); const isTauri = useAppStore((state) => state.isTauri);
const queryTimeout = useConnectStore((state) => state.querySourceTimeout); const querySourceTimeout = useConnectStore((state) => {
return state.querySourceTimeout;
});
const [selectedItem, setSelectedItem] = useState<number | null>(null); const [selectedItem, setSelectedItem] = useState<number | null>(null);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -43,6 +45,11 @@ export const DocumentList: React.FC<DocumentListProps> = ({
const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const [isKeyboardMode, setIsKeyboardMode] = useState(false); const [isKeyboardMode, setIsKeyboardMode] = useState(false);
const querySourceTimeoutRef = useRef(querySourceTimeout);
useEffect(() => {
querySourceTimeoutRef.current = querySourceTimeout;
}, [querySourceTimeout]);
const { data, loading } = useInfiniteScroll( const { data, loading } = useInfiniteScroll(
async (d) => { async (d) => {
const from = d?.list?.length || 0; const from = d?.list?.length || 0;
@@ -65,7 +72,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
from: from, from: from,
size: PAGE_SIZE, size: PAGE_SIZE,
queryStrings: queryStrings, queryStrings: queryStrings,
queryTimeout, queryTimeout: querySourceTimeoutRef.current,
}); });
} else { } else {
let url = `/query/_search?query=${queryStrings.query}&datasource=${queryStrings.datasource}&from=${from}&size=${PAGE_SIZE}`; let url = `/query/_search?query=${queryStrings.query}&datasource=${queryStrings.datasource}&from=${from}&size=${PAGE_SIZE}`;

View File

@@ -1,5 +1,12 @@
import { useEffect, useRef, useState, useCallback, MouseEvent } from "react"; import {
import { CircleAlert, Bolt, X, ArrowBigRight } from "lucide-react"; useEffect,
useRef,
useState,
useCallback,
MouseEvent,
useMemo,
} from "react";
import { ArrowBigRight } from "lucide-react";
import { isNil } from "lodash-es"; import { isNil } from "lodash-es";
import { useDebounceFn, useUnmount } from "ahooks"; import { useDebounceFn, useUnmount } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -14,22 +21,22 @@ import { copyToClipboard, OpenURLWithBrowser } from "@/utils/index";
import VisibleKey from "@/components/Common/VisibleKey"; import VisibleKey from "@/components/Common/VisibleKey";
import Calculator from "./Calculator"; import Calculator from "./Calculator";
import { useShortcutsStore } from "@/stores/shortcutsStore"; import { useShortcutsStore } from "@/stores/shortcutsStore";
import platformAdapter from "@/utils/platformAdapter"; import ErrorSearch from "@/components/Common/ErrorNotification/ErrorSearch";
type ISearchData = Record<string, any[]>; type ISearchData = Record<string, any[]>;
interface DropdownListProps { interface DropdownListProps {
suggests: any[]; suggests: any[];
SearchData: ISearchData; searchData: ISearchData;
IsError: boolean; isError: any[];
isSearchComplete: boolean; isSearchComplete: boolean;
isChatMode: boolean; isChatMode: boolean;
} }
function DropdownList({ function DropdownList({
suggests, suggests,
SearchData, searchData,
IsError, isError,
isChatMode, isChatMode,
}: DropdownListProps) { }: DropdownListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -39,7 +46,6 @@ function DropdownList({
const setSourceData = useSearchStore((state) => state.setSourceData); const setSourceData = useSearchStore((state) => state.setSourceData);
const [showError, setShowError] = useState<boolean>(IsError);
const [selectedItem, setSelectedItem] = useState<number | null>(null); const [selectedItem, setSelectedItem] = useState<number | null>(null);
const [selectedName, setSelectedName] = useState<string>(""); const [selectedName, setSelectedName] = useState<string>("");
const [showIndex, setShowIndex] = useState<boolean>(false); const [showIndex, setShowIndex] = useState<boolean>(false);
@@ -83,7 +89,7 @@ function DropdownList({
setSelectedItem(null); setSelectedItem(null);
run(); run();
}, [SearchData]); }, [searchData]);
const openPopover = useShortcutsStore((state) => state.openPopover); const openPopover = useShortcutsStore((state) => state.openPopover);
@@ -123,7 +129,7 @@ function DropdownList({
goToTwoPage(item); goToTwoPage(item);
} }
if (e.key === "Enter" && selectedItem !== null) { if (e.key === "Enter" && !e.shiftKey && selectedItem !== null) {
// console.log("Enter key pressed", selectedItem); // console.log("Enter key pressed", selectedItem);
const item = globalItemIndexMap[selectedItem]; const item = globalItemIndexMap[selectedItem];
if (item?.url) { if (item?.url) {
@@ -192,32 +198,35 @@ function DropdownList({
setVisibleContextMenu(true); setVisibleContextMenu(true);
}; };
const memoizedCallbacks = useMemo(() => {
return {
onMouseEnter: (index: number) => () => setSelectedItem(index),
onItemClick: (item: any) => () => {
if (item?.url) {
OpenURLWithBrowser(item.url);
}
},
goToTwoPage: (item: any) => () => setSourceData(item),
};
}, []);
const showHeader = useMemo(
() => Object.entries(searchData).length < 5,
[searchData]
);
return ( return (
<div <div
ref={containerRef} ref={containerRef}
data-tauri-drag-region data-tauri-drag-region
className="h-full w-full p-2 flex flex-col overflow-y-auto custom-scrollbar focus:outline-none" className="h-full w-full p-2 flex flex-col overflow-y-auto custom-scrollbar focus:outline-none"
tabIndex={0} tabIndex={0}
role="listbox"
aria-label={t("search.header.results")}
> >
{showError && ( <ErrorSearch isError={isError} />
<div className="flex items-center gap-2 text-sm text-[#333] dark:text-[#666] p-2">
<CircleAlert className="text-[#FF0000] size-3" />
{t("search.list.failures")}
<Bolt
className="dark:text-white size-3 cursor-pointer"
onClick={() => {
platformAdapter.emitEvent("open_settings", "connect");
}}
/>
<X
className="text-[#666] size-4 cursor-pointer"
onClick={() => setShowError(false)}
/>
</div>
)}
{Object.entries(SearchData).map(([sourceName, items]) => {
const showHeader = Object.entries(SearchData).length < 5;
{Object.entries(searchData).map(([sourceName, items]) => {
return ( return (
<div key={sourceName}> <div key={sourceName}>
{showHeader && ( {showHeader && (
@@ -261,7 +270,12 @@ function DropdownList({
{hideArrowRight(item) ? ( {hideArrowRight(item) ? (
<div <div
ref={(el) => (itemRefs.current[currentIndex] = el)} ref={(el) => (itemRefs.current[currentIndex] = el)}
onMouseEnter={() => setSelectedItem(currentIndex)} onMouseEnter={memoizedCallbacks.onMouseEnter(
currentIndex
)}
role="option"
aria-selected={isSelected}
id={`search-item-${currentIndex}`}
> >
<Calculator item={item} isSelected={isSelected} /> <Calculator item={item} isSelected={isSelected} />
</div> </div>
@@ -271,7 +285,9 @@ function DropdownList({
isSelected={isSelected} isSelected={isSelected}
currentIndex={currentIndex} currentIndex={currentIndex}
showIndex={showIndex} showIndex={showIndex}
onMouseEnter={() => setSelectedItem(currentIndex)} onMouseEnter={memoizedCallbacks.onMouseEnter(
currentIndex
)}
onItemClick={() => { onItemClick={() => {
if (item?.url) { if (item?.url) {
OpenURLWithBrowser(item?.url); OpenURLWithBrowser(item?.url);

View File

@@ -1,13 +1,12 @@
import { ArrowBigLeft, Search, Send, Brain } from "lucide-react"; import { Brain } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import clsx from "clsx"; import clsx from "clsx";
import { useBoolean, useKeyPress } from "ahooks"; import { useKeyPress } from "ahooks";
import ChatSwitch from "@/components/Common/ChatSwitch"; import ChatSwitch from "@/components/Common/ChatSwitch";
import AutoResizeTextarea from "./AutoResizeTextarea"; import AutoResizeTextarea from "./AutoResizeTextarea";
import { useChatStore } from "@/stores/chatStore"; import { useChatStore } from "@/stores/chatStore";
import StopIcon from "@/icons/Stop";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { metaOrCtrlKey } from "@/utils/keyboardUtils"; import { metaOrCtrlKey } from "@/utils/keyboardUtils";
@@ -15,11 +14,14 @@ import SearchPopover from "./SearchPopover";
import MCPPopover from "./MCPPopover"; import MCPPopover from "./MCPPopover";
// import AudioRecording from "../AudioRecording"; // import AudioRecording from "../AudioRecording";
import { DataSource } from "@/types/commands"; import { DataSource } from "@/types/commands";
// import InputExtra from "./InputExtra"; import InputExtra from "./InputExtra";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { useShortcutsStore } from "@/stores/shortcutsStore"; import { useShortcutsStore } from "@/stores/shortcutsStore";
import Copyright from "@/components/Common/Copyright"; import Copyright from "@/components/Common/Copyright";
import VisibleKey from "@/components/Common/VisibleKey"; import VisibleKey from "@/components/Common/VisibleKey";
import ConnectionError from "./ConnectionError";
import SearchIcons from "./SearchIcons";
import ChatIcons from "./ChatIcons";
interface ChatInputProps { interface ChatInputProps {
onSend: (message: string) => void; onSend: (message: string) => void;
@@ -93,6 +95,15 @@ export default function ChatInput({
hasModules = [], hasModules = [],
searchPlaceholder, searchPlaceholder,
chatPlaceholder, chatPlaceholder,
checkScreenPermission,
requestScreenPermission,
getScreenMonitors,
getScreenWindows,
captureWindowScreenshot,
captureMonitorScreenshot,
openFileDialog,
getFileMetadata,
getFileIcon,
}: ChatInputProps) { }: ChatInputProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -103,43 +114,24 @@ export default function ChatInput({
const sourceData = useSearchStore((state) => state.sourceData); const sourceData = useSearchStore((state) => state.sourceData);
const setSourceData = useSearchStore((state) => state.setSourceData); const setSourceData = useSearchStore((state) => state.setSourceData);
// const sessionId = useConnectStore((state) => state.currentSessionId); const sessionId = useConnectStore((state) => state.currentSessionId);
const modifierKey = useShortcutsStore((state) => state.modifierKey); const modifierKey = useShortcutsStore((state) => state.modifierKey);
const modeSwitch = useShortcutsStore((state) => state.modeSwitch); const modeSwitch = useShortcutsStore((state) => state.modeSwitch);
const returnToInput = useShortcutsStore((state) => state.returnToInput); const returnToInput = useShortcutsStore((state) => state.returnToInput);
const deepThinking = useShortcutsStore((state) => state.deepThinking); const deepThinking = useShortcutsStore((state) => state.deepThinking);
const [isComposition, { setTrue, setFalse }] = useBoolean();
useEffect(() => { useEffect(() => {
return () => { return () => {
changeInput(""); changeInput("");
setSourceData(undefined); setSourceData(undefined);
setIsCommandPressed(false);
pressedKeys.clear(); pressedKeys.clear();
}; };
}, []); }, []);
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<{ reset: () => void; focus: () => void }>(null); const textareaRef = useRef<{ reset: () => void; focus: () => void }>(null);
const { curChatEnd, connected } = useChatStore(); const { curChatEnd, connected } = useChatStore();
const [reconnectCountdown, setReconnectCountdown] = useState<number>(0);
useEffect(() => {
if (!reconnectCountdown || connected) {
setReconnectCountdown(0);
return;
}
if (reconnectCountdown > 0) {
const timer = setTimeout(() => {
setReconnectCountdown(reconnectCountdown - 1);
}, 1000);
return () => clearTimeout(timer);
}
}, [reconnectCountdown, connected]);
const [_isCommandPressed, setIsCommandPressed] = useState(false);
const setModifierKeyPressed = useShortcutsStore((state) => { const setModifierKeyPressed = useShortcutsStore((state) => {
return state.setModifierKeyPressed; return state.setModifierKeyPressed;
}); });
@@ -148,7 +140,6 @@ export default function ChatInput({
useEffect(() => { useEffect(() => {
const handleFocus = () => { const handleFocus = () => {
setBlurred(false); setBlurred(false);
setIsCommandPressed(false);
setModifierKeyPressed(false); setModifierKeyPressed(false);
}; };
@@ -160,12 +151,8 @@ export default function ChatInput({
}, []); }, []);
const handleToggleFocus = useCallback(() => { const handleToggleFocus = useCallback(() => {
if (isChatMode) { textareaRef.current?.focus();
textareaRef.current?.focus(); }, [textareaRef]);
} else {
inputRef.current?.focus();
}
}, [isChatMode, textareaRef, inputRef]);
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
const trimmedValue = inputValue.trim(); const trimmedValue = inputValue.trim();
@@ -188,16 +175,11 @@ export default function ChatInput({
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
pressedKeys.add(e.key); pressedKeys.add(e.key);
if (e.key === metaOrCtrlKey()) {
setIsCommandPressed(true);
}
if (pressedKeys.has(metaOrCtrlKey())) { if (pressedKeys.has(metaOrCtrlKey())) {
// e.preventDefault(); // e.preventDefault();
switch (e.code) { switch (e.code) {
case "Comma": case "Comma":
setIsCommandPressed(false); console.log("Comma");
break;
break; break;
case "ArrowLeft": case "ArrowLeft":
setSourceData(undefined); setSourceData(undefined);
@@ -230,7 +212,6 @@ export default function ChatInput({
isChatMode, isChatMode,
handleSubmit, handleSubmit,
setSourceData, setSourceData,
setIsCommandPressed,
disabledChange, disabledChange,
curChatEnd, curChatEnd,
visibleContextMenu, visibleContextMenu,
@@ -240,10 +221,19 @@ export default function ChatInput({
const handleKeyUp = useCallback((e: KeyboardEvent) => { const handleKeyUp = useCallback((e: KeyboardEvent) => {
pressedKeys.delete(e.key); pressedKeys.delete(e.key);
if (e.key === metaOrCtrlKey()) { if (e.key === metaOrCtrlKey()) {
setIsCommandPressed(false);
} }
}, []); }, []);
const handleInputChange = useCallback(
(value: string) => {
changeInput(value);
if (!isChatMode) {
onSend(value);
}
},
[changeInput, isChatMode, onSend]
);
useEffect(() => { useEffect(() => {
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp); window.addEventListener("keyup", handleKeyUp);
@@ -258,11 +248,7 @@ export default function ChatInput({
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
setupWindowFocusListener(() => { setupWindowFocusListener(() => {
if (isChatMode) { textareaRef.current?.focus();
textareaRef.current?.focus();
} else {
inputRef.current?.focus();
}
}).then((unlistener) => { }).then((unlistener) => {
unlisten = unlistener; unlisten = unlistener;
}); });
@@ -272,11 +258,9 @@ export default function ChatInput({
}; };
}, [isChatMode]); }, [isChatMode]);
const DeepThinkClick = () => { const [lineCount, setLineCount] = useState(1);
setIsDeepThinkActive();
};
const source = currentAssistant?._source const source = currentAssistant?._source;
return ( return (
<div className={`w-full relative`}> <div className={`w-full relative`}>
@@ -284,111 +268,91 @@ export default function ChatInput({
className={`p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded-md transition-all relative overflow-hidden`} className={`p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded-md transition-all relative overflow-hidden`}
> >
<div className="flex flex-wrap gap-2 flex-1 items-center relative"> <div className="flex flex-wrap gap-2 flex-1 items-center relative">
{!isChatMode && !sourceData ? ( {lineCount === 1 && (
<Search className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8]" /> <SearchIcons
) : !isChatMode && sourceData ? ( lineCount={lineCount}
<ArrowBigLeft isChatMode={isChatMode}
className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8] cursor-pointer" sourceData={sourceData}
onClick={() => setSourceData(undefined)} setSourceData={setSourceData}
/>
) : null}
{isChatMode ? (
<AutoResizeTextarea
ref={textareaRef}
input={inputValue}
setInput={(value: string) => {
changeInput(value);
}}
connected={connected}
handleKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter") return;
e.preventDefault();
handleSubmit();
}}
chatPlaceholder={chatPlaceholder}
/>
) : (
<input
ref={inputRef}
type="text"
autoFocus
autoComplete="off"
autoCapitalize="none"
spellCheck="false"
className="text-base font-normal flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent"
placeholder={
searchPlaceholder || t("search.input.searchPlaceholder")
}
value={inputValue}
onCompositionStart={setTrue}
onCompositionEnd={() => {
setTimeout(setFalse, 0);
}}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
if (isComposition) {
event.stopPropagation();
}
}}
onChange={(e) => {
onSend(e.target.value);
}}
/> />
)} )}
<AutoResizeTextarea
ref={textareaRef}
input={inputValue}
setInput={handleInputChange}
connected={connected}
handleKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const { key, shiftKey } = e;
if (key !== "Enter" || shiftKey) return;
e.preventDefault();
handleSubmit();
if (!isChatMode) {
onSend(inputValue);
}
}}
chatPlaceholder={
isChatMode
? chatPlaceholder
: searchPlaceholder || t("search.input.searchPlaceholder")
}
onLineCountChange={setLineCount}
/>
{lineCount > 1 && (
<SearchIcons
lineCount={lineCount}
isChatMode={isChatMode}
sourceData={sourceData}
setSourceData={setSourceData}
/>
)}
<ChatIcons
lineCount={lineCount}
isChatMode={isChatMode}
curChatEnd={curChatEnd}
inputValue={inputValue}
onSend={onSend}
disabledChange={disabledChange}
/>
{showTooltip && !isChatMode && sourceData && ( {showTooltip && !isChatMode && sourceData && (
<div className="absolute -top-[5px] left-2"> <div
className={`absolute ${
lineCount === 1 ? "-top-[5px]" : "top-[calc(100%-25px)]"
} left-2`}
>
<VisibleKey shortcut="←" /> <VisibleKey shortcut="←" />
</div> </div>
)} )}
{showTooltip && ( {showTooltip && (
<div <div
className={clsx("absolute -top-[5px] left-2", { className={clsx(
"left-8": !isChatMode && sourceData, `absolute ${
})} lineCount === 1 ? "-top-[5px]" : "top-[calc(100%-25px)]"
} left-2`,
{
"left-8": !isChatMode && sourceData,
}
)}
> >
<VisibleKey shortcut={returnToInput} /> <VisibleKey shortcut={returnToInput} />
</div> </div>
)} )}
</div>
{/* <AudioRecording {/* <AudioRecording
key={isChatMode ? "chat" : "search"} key={isChatMode ? "chat" : "search"}
onChange={(text) => { onChange={(text) => {
changeInput(inputValue + text); changeInput(inputValue + text);
}} }}
/> */} /> */}
{isChatMode && curChatEnd ? ( {/* {showTooltip && isChatMode && isCommandPressed ? (
<button
className={`ml-1 p-1 ${
inputValue
? "bg-[#0072FF]"
: "bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]"
} rounded-full transition-colors`}
type="submit"
onClick={() => onSend(inputValue.trim())}
>
<Send className="w-4 h-4 text-white" />
</button>
) : null}
{isChatMode && !curChatEnd ? (
<button
className={`ml-1 px-1 bg-[#0072FF] rounded-full transition-colors`}
type="submit"
onClick={() => disabledChange()}
>
<StopIcon
size={16}
className="w-4 h-4 text-white"
aria-label="Stop message"
/>
</button>
) : null}
{/* {showTooltip && isChatMode && isCommandPressed ? (
<div <div
className={`absolute right-10 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]`} className={`absolute right-10 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]`}
> >
@@ -396,47 +360,29 @@ export default function ChatInput({
</div> </div>
) : null} */} ) : null} */}
{showTooltip && isChatMode && ( {showTooltip && isChatMode && (
<div className="absolute top-[2px] right-[18px]">
<VisibleKey shortcut="↩︎" />
</div>
)}
{!connected && isChatMode ? (
<div className="absolute top-0 right-0 bottom-0 left-0 px-2 py-4 bg-[rgba(238,238,238,0.98)] dark:bg-[rgba(32,33,38,0.9)] backdrop-blur-[2px] rounded-md font-normal text-xs text-gray-400 flex items-center gap-4 z-10">
{t("search.input.connectionError")}
<div <div
className="px-1 h-[24px] text-[#0061FF] font-normal text-xs flex items-center justify-center cursor-pointer underline" className={`absolute ${
onClick={() => { lineCount === 1 ? "-top-[5px]" : "top-[calc(100%-30px)]"
reconnect(); } right-[12px]`}
setReconnectCountdown(10);
}}
> >
{reconnectCountdown > 0 ? ( <VisibleKey shortcut="↩︎" />
`${t("search.input.connecting")}(${reconnectCountdown}s)`
) : (
<VisibleKey
shortcut="R"
onKeyPress={() => {
reconnect();
setReconnectCountdown(10);
}}
>
{t("search.input.reconnect")}
</VisibleKey>
)}
</div> </div>
</div> )}
) : null}
{!connected && isChatMode ? (
<ConnectionError reconnect={reconnect} connected={connected} />
) : null}
</div>
</div> </div>
<div <div
data-tauri-drag-region data-tauri-drag-region
className="flex justify-between items-center py-2" className="flex justify-between items-center pt-2"
> >
{isChatMode ? ( {isChatMode ? (
<div className="flex gap-2 text-[12px] leading-3 text-[#333] dark:text-[#d8d8d8]"> <div className="flex gap-2 text-[12px] leading-3 text-[#333] dark:text-[#d8d8d8]">
{/* {sessionId && ( {sessionId && (
<InputExtra <InputExtra
checkScreenPermission={checkScreenPermission} checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission} requestScreenPermission={requestScreenPermission}
@@ -448,7 +394,7 @@ export default function ChatInput({
getFileMetadata={getFileMetadata} getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon} getFileIcon={getFileIcon}
/> />
)} */} )}
{source?.type === "deep_think" && source?.config?.visible && ( {source?.type === "deep_think" && source?.config?.visible && (
<button <button
@@ -458,9 +404,12 @@ export default function ChatInput({
"!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive, "!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive,
} }
)} )}
onClick={DeepThinkClick} onClick={setIsDeepThinkActive}
> >
<VisibleKey shortcut={deepThinking} onKeyPress={DeepThinkClick}> <VisibleKey
shortcut={deepThinking}
onKeyPress={setIsDeepThinkActive}
>
<Brain <Brain
className={`size-3 ${ className={`size-3 ${
isDeepThinkActive isDeepThinkActive
@@ -481,7 +430,7 @@ export default function ChatInput({
</button> </button>
)} )}
{source?.datasource?.visible && ( {source?.datasource?.enabled && source?.datasource?.visible && (
<SearchPopover <SearchPopover
isSearchActive={isSearchActive} isSearchActive={isSearchActive}
setIsSearchActive={setIsSearchActive} setIsSearchActive={setIsSearchActive}
@@ -489,7 +438,7 @@ export default function ChatInput({
/> />
)} )}
{source?.mcp_servers?.visible && ( {source?.mcp_servers?.enabled && source?.mcp_servers?.visible && (
<MCPPopover <MCPPopover
isMCPActive={isMCPActive} isMCPActive={isMCPActive}
setIsMCPActive={setIsMCPActive} setIsMCPActive={setIsMCPActive}
@@ -497,9 +446,9 @@ export default function ChatInput({
/> />
)} )}
{!source?.datasource?.visible && {!(source?.datasource?.enabled && source?.datasource?.visible) &&
(source?.type !== "deep_think" || !source?.config?.visible) && (source?.type !== "deep_think" || !source?.config?.visible) &&
!source?.mcp_servers?.visible ? ( !(source?.mcp_servers?.enabled && source?.mcp_servers?.visible) ? (
<div className="px-[9px]"> <div className="px-[9px]">
<Copyright /> <Copyright />
</div> </div>

View File

@@ -73,6 +73,7 @@ const InputExtra = ({
const modifierKeyPressed = useShortcutsStore((state) => { const modifierKeyPressed = useShortcutsStore((state) => {
return state.modifierKeyPressed; return state.modifierKeyPressed;
}); });
const addError = useAppStore((state) => state.addError);
const state = useReactive<State>({ const state = useReactive<State>({
screenshotableMonitors: [], screenshotableMonitors: [],
@@ -104,6 +105,8 @@ const InputExtra = ({
const stat = await getFileMetadata(path); const stat = await getFileMetadata(path);
if (stat.size / 1024 / 1024 > 100) { if (stat.size / 1024 / 1024 > 100) {
addError(t("search.input.uploadFileHints.maxSize"));
continue; continue;
} }
@@ -184,8 +187,8 @@ const InputExtra = ({
return ( return (
<Menu> <Menu>
<MenuButton className="size-6"> <MenuButton as="div" className="size-6">
<Tooltip content="支持截图、上传文件,最多 50个单个文件最大 100 MB。"> <Tooltip content={t("search.input.uploadFileHints.tooltip")}>
<div className="size-full flex justify-center items-center rounded-lg transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]"> <div className="size-full flex justify-center items-center rounded-lg transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<Plus <Plus
className={clsx("size-5", { className={clsx("size-5", {

View File

@@ -24,7 +24,7 @@ import NoDataImage from "@/components/Common/NoDataImage";
import PopoverInput from "../Common/PopoverInput"; import PopoverInput from "../Common/PopoverInput";
import FontIcon from "../Common/Icons/FontIcon"; import FontIcon from "../Common/Icons/FontIcon";
interface SearchPopoverProps { interface MCPPopoverProps {
isMCPActive: boolean; isMCPActive: boolean;
setIsMCPActive: () => void; setIsMCPActive: () => void;
getMCPByServer: ( getMCPByServer: (
@@ -37,11 +37,11 @@ interface SearchPopoverProps {
) => Promise<DataSource[]>; ) => Promise<DataSource[]>;
} }
export default function SearchPopover({ export default function MCPPopover({
isMCPActive, isMCPActive,
setIsMCPActive, setIsMCPActive,
getMCPByServer, getMCPByServer,
}: SearchPopoverProps) { }: MCPPopoverProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { connected } = useChatStore(); const { connected } = useChatStore();
@@ -261,7 +261,7 @@ export default function SearchPopover({
</div> </div>
{visibleList.length > 0 ? ( {visibleList.length > 0 ? (
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2 p-0">
{visibleList?.map((item, index) => { {visibleList?.map((item, index) => {
const { id, name } = item; const { id, name } = item;

View File

@@ -45,15 +45,22 @@ function Search({
setWindowAlwaysOnTop, setWindowAlwaysOnTop,
}: SearchProps) { }: SearchProps) {
const sourceData = useSearchStore((state) => state.sourceData); const sourceData = useSearchStore((state) => state.sourceData);
const queryTimeout = useConnectStore((state) => state.querySourceTimeout); const querySourceTimeout = useConnectStore((state) => {
return state.querySourceTimeout;
});
const [IsError, setIsError] = useState<boolean>(false); const [isError, setIsError] = useState<any[]>([]);
const [suggests, setSuggests] = useState<any[]>([]); const [suggests, setSuggests] = useState<any[]>([]);
const [SearchData, setSearchData] = useState<any>({}); const [searchData, setSearchData] = useState<any>({});
const [isSearchComplete, setIsSearchComplete] = useState(false); const [isSearchComplete, setIsSearchComplete] = useState(false);
const mainWindowRef = useRef<HTMLDivElement>(null); const mainWindowRef = useRef<HTMLDivElement>(null);
const querySourceTimeoutRef = useRef(querySourceTimeout);
useEffect(() => {
querySourceTimeoutRef.current = querySourceTimeout;
}, [querySourceTimeout]);
const getSuggest = useCallback( const getSuggest = useCallback(
async (searchInput: string) => { async (searchInput: string) => {
if (!searchInput) return; if (!searchInput) return;
@@ -65,11 +72,11 @@ function Search({
from: 0, from: 0,
size: 10, size: 10,
queryStrings: { query: searchInput }, queryStrings: { query: searchInput },
queryTimeout: queryTimeout, queryTimeout: querySourceTimeoutRef.current,
}); });
if (response && typeof response === "object" && "failed" in response) { if (response && typeof response === "object" && "failed" in response) {
const failedResult = response as any; const failedResult = response as any;
setIsError(!!failedResult.failed?.length); setIsError(failedResult.failed || []);
} }
} else { } else {
const [error, res]: any = await Get( const [error, res]: any = await Get(
@@ -130,13 +137,13 @@ function Search({
<div ref={mainWindowRef} className={`h-full pb-10 w-full relative`}> <div ref={mainWindowRef} className={`h-full pb-10 w-full relative`}>
{/* Search Results Panel */} {/* Search Results Panel */}
{suggests.length > 0 ? ( {suggests.length > 0 ? (
sourceData ? ( sourceData ? (
<SearchResults input={input} isChatMode={isChatMode} /> <SearchResults input={input} isChatMode={isChatMode} />
) : ( ) : (
<DropdownList <DropdownList
suggests={suggests} suggests={suggests}
SearchData={SearchData} searchData={searchData}
IsError={IsError} isError={isError}
isSearchComplete={isSearchComplete} isSearchComplete={isSearchComplete}
isChatMode={isChatMode} isChatMode={isChatMode}
/> />

View File

@@ -0,0 +1,34 @@
import { ArrowBigLeft, Search } from "lucide-react";
interface SearchIconsProps {
lineCount: number;
isChatMode: boolean;
sourceData: any;
setSourceData: (data: any | undefined) => void;
}
export default function SearchIcons({
lineCount,
isChatMode,
sourceData,
setSourceData,
}: SearchIconsProps) {
if (isChatMode) {
return null;
}
const iconContent = !sourceData ? (
<Search className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8]" />
) : (
<ArrowBigLeft
className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8] cursor-pointer"
onClick={() => setSourceData(undefined)}
/>
);
if (lineCount === 1) {
return <>{iconContent}</>;
} else {
return <div className="w-full flex items-center">{iconContent}</div>;
}
}

View File

@@ -46,6 +46,9 @@ const SearchListItem: React.FC<SearchListItemProps> = React.memo(
"gap-7 mobile:gap-1": showListRight, "gap-7 mobile:gap-1": showListRight,
} }
)} )}
role="option"
aria-selected={isSelected}
id={`search-item-${currentIndex}`}
> >
<div <div
className={`${ className={`${

View File

@@ -266,7 +266,7 @@ export default function SearchPopover({
</div> </div>
{visibleList.length > 0 ? ( {visibleList.length > 0 ? (
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2 p-0">
{visibleList?.map((item, index) => { {visibleList?.map((item, index) => {
const { id, name } = item; const { id, name } = item;

View File

@@ -6,6 +6,7 @@ import {
Suspense, Suspense,
memo, memo,
useState, useState,
useMemo,
} from "react"; } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { useMount } from "ahooks"; import { useMount } from "ahooks";
@@ -13,7 +14,6 @@ import { useMount } from "ahooks";
import Search from "@/components/Search/Search"; import Search from "@/components/Search/Search";
import InputBox from "@/components/Search/InputBox"; import InputBox from "@/components/Search/InputBox";
import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat"; import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat";
import UpdateApp from "@/components/UpdateApp";
import { isLinux, isWin } from "@/utils/platform"; import { isLinux, isWin } from "@/utils/platform";
import { appReducer, initialAppState } from "@/reducers/appReducer"; import { appReducer, initialAppState } from "@/reducers/appReducer";
import { useWindowEvents } from "@/hooks/useWindowEvents"; import { useWindowEvents } from "@/hooks/useWindowEvents";
@@ -25,6 +25,7 @@ import { DataSource } from "@/types/commands";
import { useThemeStore } from "@/stores/themeStore"; import { useThemeStore } from "@/stores/themeStore";
import { Get } from "@/api/axiosRequest"; import { Get } from "@/api/axiosRequest";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { useAppearanceStore } from "@/stores/appearanceStore";
interface SearchChatProps { interface SearchChatProps {
isTauri?: boolean; isTauri?: boolean;
@@ -60,11 +61,13 @@ function SearchChat({
}: SearchChatProps) { }: SearchChatProps) {
const currentAssistant = useConnectStore((state) => state.currentAssistant); const currentAssistant = useConnectStore((state) => state.currentAssistant);
const source = currentAssistant?._source;
const customInitialState = { const customInitialState = {
...initialAppState, ...initialAppState,
isDeepThinkActive: currentAssistant?._source?.type === "deep_think", isDeepThinkActive: source?.type === "deep_think",
isSearchActive: currentAssistant?._source?.datasource?.enabled === true, isSearchActive: source?.datasource?.enabled_by_default === true,
isMCPActive: currentAssistant?._source?.mcp_servers?.enabled === true, isMCPActive: source?.mcp_servers?.enabled_by_default === true,
}; };
const [state, dispatch] = useReducer(appReducer, customInitialState); const [state, dispatch] = useReducer(appReducer, customInitialState);
@@ -89,32 +92,46 @@ function SearchChat({
const setTheme = useThemeStore((state) => state.setTheme); const setTheme = useThemeStore((state) => state.setTheme);
const isChatModeRef = useRef(false);
useEffect(() => {
isChatModeRef.current = isChatMode;
}, [isChatMode]);
useMount(async () => { useMount(async () => {
const isWin10 = await platformAdapter.isWindows10(); const isWin10 = await platformAdapter.isWindows10();
setIsWin10(isWin10); setIsWin10(isWin10);
const unlisten = platformAdapter.listenEvent("show-coco", () => {
console.log("show-coco");
platformAdapter.invokeBackend("simulate_mouse_click", {
isChatMode: isChatModeRef.current,
});
});
return () => {
// Cleanup logic if needed
unlisten.then((fn) => fn());
};
}); });
useEffect(() => { useEffect(() => {
let mounted = true;
const init = async () => { const init = async () => {
if (!mounted) return;
await initializeListeners(); await initializeListeners();
await initializeListeners_auth(); await initializeListeners_auth();
await platformAdapter.invokeBackend("get_app_search_source"); await platformAdapter.invokeBackend("get_app_search_source");
if (theme && mounted) {
setTheme(theme);
}
}; };
init(); init();
return () => {
mounted = false;
};
}, []); }, []);
useEffect(() => {
if (!theme) return;
setTheme(theme);
}, [theme]);
const chatAIRef = useRef<ChatAIRef>(null); const chatAIRef = useRef<ChatAIRef>(null);
const changeMode = useCallback(async (value: boolean) => { const changeMode = useCallback(async (value: boolean) => {
@@ -173,6 +190,17 @@ function SearchChat({
return platformAdapter.setAlwaysOnTop(isPinned); return platformAdapter.setAlwaysOnTop(isPinned);
}, []); }, []);
const assistantConfig = useMemo(() => {
return {
datasourceEnabled: source?.datasource?.enabled,
datasourceVisible: source?.datasource?.visible,
datasourceIds: source?.datasource?.ids,
mcpEnabled: source?.mcp_servers?.enabled,
mcpVisible: source?.mcp_servers?.visible,
mcpIds: source?.mcp_servers?.ids,
};
}, [currentAssistant]);
const getDataSourcesByServer = useCallback( const getDataSourcesByServer = useCallback(
async ( async (
serverId: string, serverId: string,
@@ -182,6 +210,13 @@ function SearchChat({
query?: string; query?: string;
} }
): Promise<DataSource[]> => { ): Promise<DataSource[]> => {
if (
!(
assistantConfig.datasourceEnabled && assistantConfig.datasourceVisible
)
) {
return [];
}
let response: any; let response: any;
if (isTauri) { if (isTauri) {
response = await platformAdapter.invokeBackend("datasource_search", { response = await platformAdapter.invokeBackend("datasource_search", {
@@ -202,13 +237,13 @@ function SearchChat({
}; };
}); });
} }
let ids = currentAssistant?._source?.datasource?.ids; let ids = assistantConfig.datasourceIds;
if (Array.isArray(ids) && ids.length > 0 && !ids.includes("*")) { if (Array.isArray(ids) && ids.length > 0 && !ids.includes("*")) {
response = response?.filter((item: any) => ids.includes(item.id)); response = response?.filter((item: any) => ids.includes(item.id));
} }
return response || []; return response || [];
}, },
[JSON.stringify(currentAssistant)] [assistantConfig]
); );
const getMCPByServer = useCallback( const getMCPByServer = useCallback(
@@ -220,6 +255,9 @@ function SearchChat({
query?: string; query?: string;
} }
): Promise<DataSource[]> => { ): Promise<DataSource[]> => {
if (!(assistantConfig.mcpEnabled && assistantConfig.mcpVisible)) {
return [];
}
let response: any; let response: any;
if (isTauri) { if (isTauri) {
response = await platformAdapter.invokeBackend("mcp_server_search", { response = await platformAdapter.invokeBackend("mcp_server_search", {
@@ -240,13 +278,13 @@ function SearchChat({
}; };
}); });
} }
let ids = currentAssistant?._source?.mcp_servers?.ids; let ids = assistantConfig.mcpIds;
if (Array.isArray(ids) && ids.length > 0 && !ids.includes("*")) { if (Array.isArray(ids) && ids.length > 0 && !ids.includes("*")) {
response = response?.filter((item: any) => ids.includes(item.id)); response = response?.filter((item: any) => ids.includes(item.id));
} }
return response || []; return response || [];
}, },
[JSON.stringify(currentAssistant)] [assistantConfig]
); );
const setupWindowFocusListener = useCallback(async (callback: () => void) => { const setupWindowFocusListener = useCallback(async (callback: () => void) => {
@@ -289,18 +327,12 @@ function SearchChat({
return platformAdapter.getFileIcon(path, size); return platformAdapter.getFileIcon(path, size);
}, []); }, []);
const checkUpdate = useCallback(async () => {
return platformAdapter.checkUpdate();
}, []);
const relaunchApp = useCallback(async () => {
return platformAdapter.relaunchApp();
}, []);
const defaultStartupWindow = useStartupStore((state) => { const defaultStartupWindow = useStartupStore((state) => {
return state.defaultStartupWindow; return state.defaultStartupWindow;
}); });
const opacity = useAppearanceStore((state) => state.opacity);
useEffect(() => { useEffect(() => {
if (platformAdapter.isTauri()) { if (platformAdapter.isTauri()) {
changeMode(defaultStartupWindow === "chatMode"); changeMode(defaultStartupWindow === "chatMode");
@@ -317,7 +349,7 @@ function SearchChat({
<div <div
data-tauri-drag-region={isTauri} data-tauri-drag-region={isTauri}
className={clsx( className={clsx(
"m-auto overflow-hidden relative bg-no-repeat bg-cover bg-center bg-white dark:bg-black", "m-auto overflow-hidden relative bg-no-repeat bg-cover bg-center bg-white dark:bg-black flex flex-col",
[ [
isTransitioned isTransitioned
? "bg-chat_bg_light dark:bg-chat_bg_dark" ? "bg-chat_bg_light dark:bg-chat_bg_dark"
@@ -329,16 +361,35 @@ function SearchChat({
"rounded-xl": !isMobile && !isWin, "rounded-xl": !isMobile && !isWin,
"border border-[#E6E6E6] dark:border-[#272626]": isTauri && isLinux, "border border-[#E6E6E6] dark:border-[#272626]": isTauri && isLinux,
"border-t border-t-[#999] dark:border-t-[#333]": isTauri && isWin10, "border-t border-t-[#999] dark:border-t-[#333]": isTauri && isWin10,
"opacity-30": blurred,
} }
)} )}
style={{ opacity: blurred ? (opacity ?? 30) / 100 : 1 }}
> >
{isTransitioned && (
<div
data-tauri-drag-region={isTauri}
className="flex-1 w-full overflow-hidden"
>
<Suspense fallback={<LoadingFallback />}>
<ChatAI
ref={chatAIRef}
key="ChatAI"
changeInput={setInput}
isSearchActive={isSearchActive}
isDeepThinkActive={isDeepThinkActive}
isMCPActive={isMCPActive}
getFileUrl={getFileUrl}
showChatHistory={showChatHistory}
assistantIDs={assistantIDs}
/>
</Suspense>
</div>
)}
<div <div
data-tauri-drag-region={isTauri} data-tauri-drag-region={isTauri}
className={`p-2 absolute w-full flex justify-center transition-all duration-500 ${ className={`p-2 w-full flex justify-center transition-all duration-500 min-h-[82px] ${
isTransitioned isTransitioned ? "border-t" : "border-b"
? "top-[calc(100%-82px)] h-[82px] border-t"
: "top-0 h-[82px] border-b"
} border-[#E6E6E6] dark:border-[#272626]`} } border-[#E6E6E6] dark:border-[#272626]`}
> >
<InputBox <InputBox
@@ -375,50 +426,25 @@ function SearchChat({
/> />
</div> </div>
<div {!isTransitioned && (
data-tauri-drag-region={isTauri} <div
className={`absolute w-full transition-opacity duration-500 ${ data-tauri-drag-region={isTauri}
isTransitioned ? "opacity-0 pointer-events-none" : "opacity-100" className="flex-1 w-full overflow-auto"
} bottom-0 h-[calc(100%-82px)] `} >
> <Suspense fallback={<LoadingFallback />}>
<Suspense fallback={<LoadingFallback />}> <Search
<Search key="Search"
key="Search" isTauri={isTauri}
isTauri={isTauri} input={input}
input={input} isChatMode={isChatMode}
isChatMode={isChatMode} changeInput={setInput}
changeInput={setInput} hideCoco={hideCoco}
hideCoco={hideCoco} openSetting={openSetting}
openSetting={openSetting} setWindowAlwaysOnTop={setWindowAlwaysOnTop}
setWindowAlwaysOnTop={setWindowAlwaysOnTop} />
/> </Suspense>
</Suspense> </div>
</div> )}
<div
data-tauri-drag-region={isTauri}
className={`absolute w-full transition-all duration-500 select-auto ${
isTransitioned
? "top-0 opacity-100 pointer-events-auto"
: "-top-[506px] opacity-0 pointer-events-none"
} h-[calc(100%-90px)]`}
>
<Suspense fallback={<LoadingFallback />}>
<ChatAI
ref={chatAIRef}
key="ChatAI"
changeInput={setInput}
isSearchActive={isSearchActive}
isDeepThinkActive={isDeepThinkActive}
isMCPActive={isMCPActive}
getFileUrl={getFileUrl}
showChatHistory={showChatHistory}
assistantIDs={assistantIDs}
/>
</Suspense>
</div>
<UpdateApp checkUpdate={checkUpdate} relaunchApp={relaunchApp} />
</div> </div>
); );
} }

View File

@@ -0,0 +1,47 @@
import SettingsInput from "@/components/Settings/SettingsInput";
import SettingsItem from "@/components/Settings/SettingsItem";
import { useAppearanceStore } from "@/stores/appearanceStore";
import platformAdapter from "@/utils/platformAdapter";
import { AppWindowMac } from "lucide-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
const Appearance = () => {
const { t } = useTranslation();
const opacity = useAppearanceStore((state) => state.opacity);
const setOpacity = useAppearanceStore((state) => state.setOpacity);
useEffect(() => {
const unlisten = useAppearanceStore.subscribe((state) => {
platformAdapter.emitEvent("change-appearance-store", state);
});
return unlisten;
}, []);
return (
<>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t("settings.advanced.appearance.title")}
</h2>
<SettingsItem
icon={AppWindowMac}
title={t("settings.advanced.appearance.opacity.title")}
description={t("settings.advanced.appearance.opacity.description")}
>
<SettingsInput
type="number"
min={10}
max={100}
value={opacity}
onChange={(value) => {
return setOpacity(!value ? void 0 : Number(value));
}}
/>
</SettingsItem>
</>
);
};
export default Appearance;

View File

@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Command, RotateCcw } from "lucide-react"; import { Command, RotateCcw } from "lucide-react";
import { ChangeEvent, useEffect } from "react"; import { useEffect } from "react";
import { formatKey } from "@/utils/keyboardUtils"; import { formatKey } from "@/utils/keyboardUtils";
import SettingsItem from "@/components/Settings/SettingsItem"; import SettingsItem from "@/components/Settings/SettingsItem";
@@ -26,6 +26,7 @@ import {
import { ModifierKey } from "@/types/index"; import { ModifierKey } from "@/types/index";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import SettingsInput from "@/components/Settings/SettingsInput";
export const modifierKeys: ModifierKey[] = isMac export const modifierKeys: ModifierKey[] = isMac
? ["meta", "ctrl"] ? ["meta", "ctrl"]
@@ -234,12 +235,7 @@ const Shortcuts = () => {
}, },
]; ];
const handleChange = ( const handleChange = (value: string, setValue: (value: string) => void) => {
event: ChangeEvent<HTMLInputElement>,
setValue: (value: string) => void
) => {
const value = event.target.value.toUpperCase();
if (value.length > 1) return; if (value.length > 1) return;
const systemKeys = ["C", "V", "X", "Z", "Q", "H"]; const systemKeys = ["C", "V", "X", "Z", "Q", "H"];
@@ -284,7 +280,11 @@ const Shortcuts = () => {
}} }}
> >
{modifierKeys.map((item) => { {modifierKeys.map((item) => {
return <option value={item}>{formatKey(item)}</option>; return (
<option key={item} value={item}>
{formatKey(item)}
</option>
);
})} })}
</select> </select>
</SettingsItem> </SettingsItem>
@@ -302,12 +302,11 @@ const Shortcuts = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{formatKey(modifierKey)}</span> <span>{formatKey(modifierKey)}</span>
<span>+</span> <span>+</span>
<input <SettingsInput
className="w-20 h-8 px-2 rounded-md border bg-transparent border-black/5 dark:border-white/10 hover:border-[#0072FF] focus:border-[#0072FF] transition"
value={value} value={value}
maxLength={1} max={1}
onChange={(event) => { onChange={(value) => {
handleChange(event, setValue); handleChange(String(value).toUpperCase(), setValue);
}} }}
/> />

View File

@@ -0,0 +1,39 @@
import SettingsItem from "@/components/Settings/SettingsItem";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import { useAppearanceStore } from "@/stores/appearanceStore";
import { FlaskConical } from "lucide-react";
import { useTranslation } from "react-i18next";
const UpdateSettings = () => {
const { t } = useTranslation();
const snapshotUpdate = useAppearanceStore((state) => state.snapshotUpdate);
const setSnapshotUpdate = useAppearanceStore((state) => {
return state.setSnapshotUpdate;
});
return (
<>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t("settings.advanced.updateVersion.title")}
</h2>
<SettingsItem
icon={FlaskConical}
title={t("settings.advanced.updateVersion.snapshotUpdate.title")}
description={t(
"settings.advanced.updateVersion.snapshotUpdate.description"
)}
>
<SettingsToggle
label={t("settings.advanced.updateVersion.snapshotUpdate.title")}
checked={snapshotUpdate}
onChange={() => {
setSnapshotUpdate(!snapshotUpdate);
}}
/>
</SettingsItem>
</>
);
};
export default UpdateSettings;

View File

@@ -1,11 +1,23 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
AppWindowMac,
MessageSquareMore,
Search,
ShieldCheck,
Unplug,
} from "lucide-react";
import { useMount } from "ahooks";
import Shortcuts from "./components/Shortcuts"; import Shortcuts from "./components/Shortcuts";
import SettingsItem from "../SettingsItem"; import SettingsItem from "../SettingsItem";
import { AppWindowMac, MessageSquareMore, Search, Unplug } from "lucide-react";
import { useStartupStore } from "@/stores/startupStore"; import { useStartupStore } from "@/stores/startupStore";
import { useEffect } from "react";
import { emit } from "@tauri-apps/api/event";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import Appearance from "./components/Appearance";
import SettingsInput from "../SettingsInput";
import platformAdapter from "@/utils/platformAdapter";
import UpdateSettings from "./components/UpdateSettings";
import SettingsToggle from "../SettingsToggle";
const Advanced = () => { const Advanced = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -39,14 +51,28 @@ const Advanced = () => {
const setQueryTimeout = useConnectStore((state) => { const setQueryTimeout = useConnectStore((state) => {
return state.setQuerySourceTimeout; return state.setQuerySourceTimeout;
}); });
const allowSelfSignature = useConnectStore((state) => {
return state.allowSelfSignature;
});
const setAllowSelfSignature = useConnectStore((state) => {
return state.setAllowSelfSignature;
});
useMount(async () => {
const allowSelfSignature = await platformAdapter.invokeBackend<boolean>(
"get_allow_self_signature"
);
setAllowSelfSignature(allowSelfSignature);
});
useEffect(() => { useEffect(() => {
const unsubscribeStartup = useStartupStore.subscribe((state) => { const unsubscribeStartup = useStartupStore.subscribe((state) => {
emit("change-startup-store", state); platformAdapter.emitEvent("change-startup-store", state);
}); });
const unsubscribeConnect = useConnectStore.subscribe((state) => { const unsubscribeConnect = useConnectStore.subscribe((state) => {
emit("change-connect-store", state); platformAdapter.emitEvent("change-connect-store", state);
}); });
return () => { return () => {
@@ -165,13 +191,12 @@ const Advanced = () => {
"settings.advanced.connect.connectionTimeout.description" "settings.advanced.connect.connectionTimeout.description"
)} )}
> >
<input <SettingsInput
type="number" type="number"
min={10} min={10}
value={connectionTimeout} value={connectionTimeout}
className="w-20 h-8 px-2 rounded-md border bg-transparent border-black/5 dark:border-white/10" onChange={(value) => {
onChange={(event) => { setConnectionTimeout(!value ? void 0 : Number(value));
setConnectionTimeout(Number(event.target.value) || 120);
}} }}
/> />
</SettingsItem> </SettingsItem>
@@ -181,17 +206,40 @@ const Advanced = () => {
title={t("settings.advanced.connect.queryTimeout.title")} title={t("settings.advanced.connect.queryTimeout.title")}
description={t("settings.advanced.connect.queryTimeout.description")} description={t("settings.advanced.connect.queryTimeout.description")}
> >
<input <SettingsInput
type="number" type="number"
min={1} min={1}
value={queryTimeout} value={queryTimeout}
className="w-20 h-8 px-2 rounded-md border bg-transparent border-black/5 dark:border-white/10" onChange={(value) => {
onChange={(event) => { setQueryTimeout(!value ? void 0 : Number(value));
setQueryTimeout(Number(event.target.value) || 500); }}
/>
</SettingsItem>
<SettingsItem
icon={ShieldCheck}
title={t("settings.advanced.connect.allowSelfSignature.title")}
description={t(
"settings.advanced.connect.allowSelfSignature.description"
)}
>
<SettingsToggle
label={t("settings.advanced.connect.allowSelfSignature.title")}
checked={allowSelfSignature}
onChange={(value) => {
setAllowSelfSignature(value);
platformAdapter.invokeBackend("set_allow_self_signature", {
value,
});
}} }}
/> />
</SettingsItem> </SettingsItem>
</div> </div>
<Appearance />
<UpdateSettings />
</div> </div>
); );
}; };

View File

@@ -1,75 +0,0 @@
import { cloneElement, FC, useContext, useState } from "react";
import { ChevronRight } from "lucide-react";
import clsx from "clsx";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import { ExtensionsContext, Plugin } from "../..";
interface AccordionProps extends Plugin {}
const Accordion: FC<AccordionProps> = (props) => {
const {
id,
icon,
title,
type = "Extension",
alias = "-",
hotKey = "-",
enabled = true,
content,
} = props;
const { activeId, setActiveId } = useContext(ExtensionsContext);
const [expand, setExpand] = useState(false);
return (
<div>
<div
className={clsx("flex items-center h-8 -mx-2 px-2 text-sm rounded-md", {
"bg-[#f0f6fe] dark:bg-gray-700": id === activeId,
})}
onClick={() => {
setActiveId(id);
}}
>
<div className="w-[220px] flex items-center gap-1">
<div className="size-4">
{content && (
<ChevronRight
onClick={(event) => {
event.stopPropagation();
setExpand((prev) => !prev);
}}
className={clsx("size-full transition cursor-pointer", {
"rotate-90": expand,
})}
/>
)}
</div>
{cloneElement(icon, { className: "size-4" })}
<span>{title}</span>
</div>
<div className="flex-1 flex items-center text-[#999]">
<div className="flex-1">{type}</div>
<div className="flex-1">{alias}</div>
<div className="flex-1">{hotKey}</div>
<div className="flex-1 flex items-center justify-end">
<SettingsToggle
label=""
checked={enabled}
className="scale-75"
onChange={() => {}}
/>
</div>
</div>
</div>
{expand && <div className="text-sm">{content}</div>}
</div>
);
};
export default Accordion;

View File

@@ -1,68 +0,0 @@
import SettingsToggle from "@/components/Settings/SettingsToggle";
import { useApplicationsStore } from "@/stores/applicationsStore";
import platformAdapter from "@/utils/platformAdapter";
import { useContext } from "react";
import { ExtensionsContext } from "../../..";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
const Applications = () => {
const { t } = useTranslation();
const { activeId, setActiveId } = useContext(ExtensionsContext);
const allApps = useApplicationsStore((state) => state.allApps);
const disabledApps = useApplicationsStore((state) => state.disabledApps);
const setDisabledApps = useApplicationsStore((state) => {
return state.setDisabledApps;
});
return allApps.map((app) => {
const { name, icon } = app;
return (
<div
key={name}
className={clsx("flex items-center h-8 -mx-2 pl-10 pr-2 rounded-md", {
"bg-[#f0f6fe] dark:bg-gray-700": name === activeId,
})}
onClick={() => {
setActiveId(name);
}}
>
<div className="flex items-center gap-1 w-[180px] pr-2 overflow-hidden">
<img src={platformAdapter.convertFileSrc(icon)} className="size-5" />
<span className="text-sm truncate">{name}</span>
</div>
<div className="flex-1 flex items-center text-[#999] ">
<div className="flex-1">
{t("settings.extensions.application.title")}
</div>
<div className="flex-1">
{t("settings.extensions.application.hits.addAlias")}
</div>
<div className="flex-1">
{t("settings.extensions.application.hits.recordHotkey")}
</div>
<div className="flex-1 flex items-center justify-end">
<SettingsToggle
label=""
checked={!disabledApps.includes(name)}
className="scale-75"
onChange={() => {
if (disabledApps.includes(name)) {
setDisabledApps(disabledApps.filter((app) => app !== name));
} else {
setDisabledApps([...disabledApps, name]);
}
}}
/>
</div>
</div>
</div>
);
});
};
export default Applications;

View File

@@ -0,0 +1,241 @@
import {
cloneElement,
FC,
Fragment,
MouseEvent,
useContext,
useState,
} from "react";
import { ExtensionsContext, Plugin } from "../..";
import { useMount } from "ahooks";
import { ChevronRight, LoaderCircle } from "lucide-react";
import clsx from "clsx";
import { isArray, isFunction } from "lodash-es";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import platformAdapter from "@/utils/platformAdapter";
import Shortcut from "../Shortcut";
import SettingsInput from "@/components/Settings/SettingsInput";
import { useTranslation } from "react-i18next";
const Content = () => {
const { plugins } = useContext(ExtensionsContext);
return plugins.map((item) => {
return <Item key={item.id} {...item} level={1} />;
});
};
const Item: FC<Plugin & { level: number }> = (props) => {
const {
id,
icon,
name,
children,
type = "Extension",
manualLoad,
level = 1,
} = props;
const { activeId, setActiveId, setPlugins } = useContext(ExtensionsContext);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState(false);
const { t } = useTranslation();
const hasChildren = isArray(children);
const handleLoadChildren = async () => {
setLoading(true);
await props.loadChildren?.();
setLoading(false);
};
useMount(async () => {
if (!manualLoad) {
handleLoadChildren();
}
});
const handleExpand = async (event: MouseEvent) => {
event?.stopPropagation();
if (expanded) {
setExpanded(false);
} else {
if (manualLoad) {
await handleLoadChildren();
}
setExpanded(true);
}
};
const renderAlias = () => {
const { alias, onAliasChange } = props;
const handleChange = (value: string) => {
if (isFunction(onAliasChange)) {
return onAliasChange(value);
}
};
if (isFunction(onAliasChange)) {
return (
<div
className="-translate-x-2"
onClick={(event) => {
event.stopPropagation();
}}
>
<SettingsInput
defaultValue={alias}
placeholder={t("settings.extensions.hits.addAlias")}
className="!w-[90%] !h-6 !border-transparent rounded-[4px]"
onChange={(value) => {
handleChange(String(value));
}}
/>
</div>
);
}
return <>--</>;
};
const renderHotkey = () => {
const { hotkey, onHotkeyChange } = props;
const handleChange = (value: string) => {
if (isFunction(onHotkeyChange)) {
return onHotkeyChange(value);
}
};
if (isFunction(onHotkeyChange)) {
return (
<div
className="-translate-x-2"
onClick={(event) => {
event.stopPropagation();
}}
>
<Shortcut
value={hotkey}
placeholder={t("settings.extensions.hits.recordHotkey")}
onChange={handleChange}
/>
</div>
);
}
return <>--</>;
};
const renderSwitch = () => {
const { enabled = true, onEnabledChange } = props;
const handleChange = (value: boolean) => {
if (isFunction(onEnabledChange)) {
return onEnabledChange(value);
}
const command = `${value ? "enable" : "disable"}_local_query_source`;
platformAdapter.invokeBackend(command, {
querySourceId: id,
});
setPlugins((prevPlugins) => {
return prevPlugins.map((item) => {
if (item.id === id) {
return { ...item, enabled: value };
}
return item;
});
});
};
return (
<div
onClick={(event) => {
event.stopPropagation();
}}
>
<SettingsToggle
label={id}
checked={Boolean(enabled)}
className="scale-75"
onChange={handleChange}
/>
</div>
);
};
return (
<Fragment key={id}>
<div
className={clsx("-mx-2 px-2 text-sm rounded-md", {
"bg-[#f0f6fe] dark:bg-gray-700": id === activeId,
})}
>
<div
className="flex items-center justify-between gap-2 h-8"
onClick={() => {
setActiveId(id);
}}
>
<div
className="flex-1 flex items-center gap-1 overflow-hidden"
style={{ paddingLeft: (level - 1) * 20 }}
>
<div className="min-w-4 h-4">
{hasChildren && (
<>
{loading ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<ChevronRight
onClick={handleExpand}
className={clsx("size-4 transition cursor-pointer", {
"rotate-90": expanded,
})}
/>
)}
</>
)}
</div>
{cloneElement(icon, {
className: clsx("size-4", icon.props.className),
})}
<div className="truncate">{name}</div>
</div>
<div className="w-3/5 flex items-center text-[#999]">
<div className="flex-1">{type}</div>
<div className="flex-1">{renderAlias()}</div>
<div className="flex-1">{renderHotkey()}</div>
<div className="flex-1 flex items-center justify-end">
{renderSwitch()}
</div>
</div>
</div>
</div>
{hasChildren && (
<div
className={clsx({
hidden: !expanded,
})}
>
{children.map((item) => {
return <Item key={item.id} {...item} level={level + 1} />;
})}
</div>
)}
</Fragment>
);
};
export default Content;

View File

@@ -1,50 +1,77 @@
import { FC } from "react"; import { useContext, useMemo, useState } from "react";
import { Application } from "@/stores/applicationsStore";
import { filesize } from "filesize"; import { filesize } from "filesize";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "ahooks";
import platformAdapter from "@/utils/platformAdapter";
import { ExtensionsContext } from "../../..";
interface AppProps { interface Metadata {
current: Application; name: string;
where: string;
size: number;
icon: string;
created: number;
modified: number;
lastOpened: number;
} }
const App: FC<AppProps> = (props) => { const App = () => {
const { name, where, size, created, modified, lastOpened } = props.current;
const { t } = useTranslation(); const { t } = useTranslation();
const { activeId } = useContext(ExtensionsContext);
const metadata = [ const [appMetadata, setAppMetadata] = useState<Metadata>();
{
label: t("settings.extensions.application.details.name"), useAsyncEffect(async () => {
value: name, const appMetadata = await platformAdapter.invokeBackend<Metadata>(
}, "get_app_metadata",
{ {
label: t("settings.extensions.application.details.where"), appPath: activeId,
value: where, }
}, );
{
label: t("settings.extensions.application.details.type"), setAppMetadata(appMetadata);
value: t("settings.extensions.application.details.typeValue"), }, [activeId]);
},
{ const metadata = useMemo(() => {
label: t("settings.extensions.application.details.size"), if (!appMetadata) return [];
value: filesize(size, { standard: "jedec", spacer: "" }),
}, const { name, where, size, created, modified, lastOpened } = appMetadata;
{
label: t("settings.extensions.application.details.created"), return [
value: dayjs(created).format("YYYY/MM/DD HH:mm:ss"), {
}, label: t("settings.extensions.application.details.name"),
{ value: name,
label: t("settings.extensions.application.details.modified"), },
value: dayjs(modified).format("YYYY/MM/DD HH:mm:ss"), {
}, label: t("settings.extensions.application.details.where"),
{ value: where,
label: t("settings.extensions.application.details.lastOpened"), },
value: dayjs(lastOpened).format("YYYY/MM/DD HH:mm:ss"), {
}, label: t("settings.extensions.application.details.type"),
]; value: t("settings.extensions.application.details.typeValue"),
},
{
label: t("settings.extensions.application.details.size"),
value: filesize(size, { standard: "jedec", spacer: "" }),
},
{
label: t("settings.extensions.application.details.created"),
value: dayjs(created).format("YYYY/MM/DD HH:mm:ss"),
},
{
label: t("settings.extensions.application.details.modified"),
value: dayjs(modified).format("YYYY/MM/DD HH:mm:ss"),
},
{
label: t("settings.extensions.application.details.lastOpened"),
value: dayjs(lastOpened).format("YYYY/MM/DD HH:mm:ss"),
},
];
}, [appMetadata]);
return ( return (
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2 p-0">
{metadata.map((item) => { {metadata.map((item) => {
const { label, value } = item; const { label, value } = item;

View File

@@ -1,16 +1,26 @@
import { useApplicationsStore } from "@/stores/applicationsStore"; import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import { Button } from "@headlessui/react"; import { Button } from "@headlessui/react";
import { castArray, union } from "lodash-es"; import { useMount } from "ahooks";
import { castArray } from "lodash-es";
import { Folder, SquareArrowOutUpRight, X } from "lucide-react"; import { Folder, SquareArrowOutUpRight, X } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const Applications = () => { const Applications = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const searchPaths = useApplicationsStore((state) => state.searchPaths); const addError = useAppStore((state) => state.addError);
const setSearchPaths = useApplicationsStore((state) => state.setSearchPaths); const [paths, setPaths] = useState<string[]>([]);
const selectDirectory = async () => { useMount(async () => {
const paths = await platformAdapter.invokeBackend<string[]>(
"get_app_search_path"
);
setPaths(paths);
});
const handleAdd = async () => {
const selected = await platformAdapter.openFileDialog({ const selected = await platformAdapter.openFileDialog({
directory: true, directory: true,
multiple: true, multiple: true,
@@ -18,7 +28,49 @@ const Applications = () => {
if (!selected) return; if (!selected) return;
setSearchPaths(union(searchPaths, castArray(selected))); const selectedPaths = castArray(selected).filter((selectedPath) => {
if (paths.includes(selectedPath)) {
addError(
t("settings.extensions.application.hits.pathDuplication", {
replace: [selectedPath],
})
);
return false;
}
const isChildPath = paths.some((item) => {
return selectedPath.startsWith(item);
});
if (isChildPath) {
addError(
t("settings.extensions.application.hits.pathIncluded", {
replace: [selectedPath],
})
);
return false;
}
return true;
});
setPaths((prev) => prev.concat(selectedPaths));
for await (const path of selectedPaths) {
await platformAdapter.invokeBackend("add_app_search_path", {
searchPath: path,
});
}
};
const handleRemove = (path: string) => {
setPaths((prev) => prev.filter((item) => item !== path));
platformAdapter.invokeBackend("remove_app_search_path", {
searchPath: path,
});
}; };
return ( return (
@@ -35,13 +87,13 @@ const Applications = () => {
<Button <Button
className="w-full h-8 my-4 text-[#0087FF] border border-[#EEF0F3] hover:border-[#0087FF] dark:border-gray-700 rounded-md transition" className="w-full h-8 my-4 text-[#0087FF] border border-[#EEF0F3] hover:border-[#0087FF] dark:border-gray-700 rounded-md transition"
onClick={selectDirectory} onClick={handleAdd}
> >
{t("settings.extensions.application.button.addDirectories")} {t("settings.extensions.application.button.addDirectories")}
</Button> </Button>
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2 p-0">
{searchPaths.map((item) => { {paths.map((item) => {
return ( return (
<li key={item} className="flex items-center justify-between gap-2"> <li key={item} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 flex-1 overflow-hidden"> <div className="flex items-center gap-1 flex-1 overflow-hidden">
@@ -60,9 +112,7 @@ const Applications = () => {
<X <X
className="size-4 cursor-pointer" className="size-4 cursor-pointer"
onClick={() => { onClick={() => handleRemove(item)}
setSearchPaths(searchPaths.filter((path) => path !== item));
}}
/> />
</div> </div>
</li> </li>

View File

@@ -0,0 +1,42 @@
import { useContext, useMemo } from "react";
import { ExtensionsContext, Plugin } from "../..";
const Details = () => {
const { plugins, activeId } = useContext(ExtensionsContext);
const findPlugin = (plugins: Plugin[], id: string) => {
for (const plugin of plugins) {
const { children = [] } = plugin;
if (plugin.id === id) {
return plugin;
}
if (children.length > 0) {
const matched = findPlugin(children, id) as Plugin;
if (!matched) continue;
return matched;
}
}
};
const currentPlugin = useMemo(() => {
if (!activeId) return;
return findPlugin(plugins, activeId);
}, [activeId, plugins]);
return (
<div className="flex-1 h-full overflow-auto">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{currentPlugin?.name}
</h2>
{currentPlugin?.detail}
</div>
);
};
export default Details;

View File

@@ -0,0 +1,143 @@
import { find, isEmpty, map, remove, some, split } from "lodash-es";
import { useRef, type FC, type KeyboardEvent, type MouseEvent } from "react";
import { type Key, keys, modifierKeys, standardKeys } from "./keyboard";
import { CircleX } from "lucide-react";
import { useFocusWithin, useHover, useReactive } from "ahooks";
import clsx from "clsx";
interface ShortcutProps {
value?: string;
placeholder?: string;
isSystem?: boolean;
onChange?: (value: string) => void;
}
interface State {
value: Key[];
}
const Shortcut: FC<ShortcutProps> = (props) => {
const { value = "", placeholder, isSystem = true, onChange } = props;
const separator = isSystem ? "+" : ".";
const keyFiled = isSystem ? "tauriKey" : "hookKey";
const parseValue = () => {
if (!value) return [];
return split(value, separator).map((key) => {
return find(keys, { [keyFiled]: key })!;
});
};
const state = useReactive<State>({
value: parseValue(),
});
const containerRef = useRef<HTMLDivElement>(null);
const isHovering = useHover(containerRef);
const isFocusing = useFocusWithin(containerRef, {
onFocus: () => {
state.value = [];
},
onBlur: () => {
if (!isValidShortcut()) {
state.value = parseValue();
}
handleChange();
},
});
const isValidShortcut = () => {
if (state.value?.[0]?.eventKey?.startsWith("F")) {
return true;
}
const hasModifierKey = some(state.value, ({ eventKey }) => {
return some(modifierKeys, { eventKey });
});
const hasStandardKey = some(state.value, ({ eventKey }) => {
return some(standardKeys, { eventKey });
});
return hasModifierKey && hasStandardKey;
};
const getEventKey = (event: KeyboardEvent) => {
let { key, code } = event;
key = key.replace("Meta", "Command");
const isModifierKey = some(modifierKeys, { eventKey: key });
return isModifierKey ? key : code;
};
const handleChange = () => {
const nextValue = map(state.value, keyFiled).join(separator);
onChange?.(nextValue);
};
const handleKeyDown = (event: KeyboardEvent) => {
const eventKey = getEventKey(event);
const matched = find(keys, { eventKey });
const isInvalid = !matched;
const isDuplicate = some(state.value, { eventKey });
if (isInvalid || isDuplicate) return;
state.value.push(matched);
if (isValidShortcut()) {
containerRef.current?.blur();
}
};
const handleKeyUp = (event: KeyboardEvent) => {
remove(state.value, { eventKey: getEventKey(event) });
};
const handleClear = (event: MouseEvent) => {
event.preventDefault();
state.value = [];
handleChange();
};
return (
<div
ref={containerRef}
tabIndex={0}
className="relative flex items-center h-6 px-2 rounded-[4px] border border-transparent hover:border-[#0072FF] focus:border-[#0072FF] transition"
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
>
{isEmpty(state.value) ? (
<div className="whitespace-nowrap">{placeholder}</div>
) : (
<div className="font-bold text-primary">
{map(state.value, "symbol").join(" ")}
</div>
)}
<CircleX
size={16}
className={clsx(
"absolute right-2 hover:text-[#0072FF] cursor-pointer transition",
{
hidden: isFocusing || !isHovering || isEmpty(state.value),
}
)}
onMouseDown={handleClear}
/>
</div>
);
};
export default Shortcut;

View File

@@ -0,0 +1,314 @@
import { isMac } from "@/utils/platform";
import { defaults } from "lodash-es";
export interface Key {
eventKey: string;
hookKey?: string;
tauriKey?: string;
symbol?: string;
}
export const modifierKeys: Key[] = [
{
eventKey: "Shift",
symbol: isMac ? "⇧" : "Shift",
},
{
eventKey: "Control",
hookKey: "ctrl",
symbol: isMac ? "⌃" : "Ctrl",
},
{
eventKey: "Alt",
symbol: isMac ? "⌥" : "Alt",
},
{
eventKey: "Command",
hookKey: "meta",
symbol: isMac ? "⌘" : "Super",
},
].map((item) => {
const { eventKey } = item;
defaults<Key, Partial<Key>>(item, {
hookKey: eventKey.toLowerCase(),
tauriKey: eventKey,
});
return item;
});
export const standardKeys: Key[] = [
// 第一排
{
eventKey: "Escape",
hookKey: "esc",
symbol: isMac ? "⎋" : "Esc",
},
{
eventKey: "F1",
},
{
eventKey: "F2",
},
{
eventKey: "F3",
},
{
eventKey: "F4",
},
{
eventKey: "F5",
},
{
eventKey: "F6",
},
{
eventKey: "F7",
},
{
eventKey: "F8",
},
{
eventKey: "F9",
},
{
eventKey: "F10",
},
{
eventKey: "F11",
},
{
eventKey: "F12",
}, // 第二排
{
eventKey: "Backquote",
hookKey: "graveaccent",
symbol: "`",
},
{
eventKey: "Digit1",
},
{
eventKey: "Digit2",
},
{
eventKey: "Digit3",
},
{
eventKey: "Digit4",
},
{
eventKey: "Digit5",
},
{
eventKey: "Digit6",
},
{
eventKey: "Digit7",
},
{
eventKey: "Digit8",
},
{
eventKey: "Digit9",
},
{
eventKey: "Digit0",
},
{
eventKey: "Minus",
hookKey: "dash",
tauriKey: "-",
symbol: "-",
},
{
eventKey: "Equal",
hookKey: "equalsign",
tauriKey: "=",
symbol: "=",
},
{
eventKey: "Backspace",
symbol: isMac ? "⌫" : void 0,
},
// 第三排
{
eventKey: "Tab",
symbol: isMac ? "⇥" : void 0,
},
{
eventKey: "KeyQ",
},
{
eventKey: "KeyW",
},
{
eventKey: "KeyE",
},
{
eventKey: "KeyR",
},
{
eventKey: "KeyT",
},
{
eventKey: "KeyY",
},
{
eventKey: "KeyU",
},
{
eventKey: "KeyI",
},
{
eventKey: "KeyO",
},
{
eventKey: "KeyP",
},
{
eventKey: "BracketLeft",
hookKey: "openbracket",
symbol: "[",
},
{
eventKey: "BracketRight",
hookKey: "closebracket",
symbol: "]",
},
{
eventKey: "Backslash",
symbol: "\\",
},
// 第四排
{
eventKey: "KeyA",
},
{
eventKey: "KeyS",
},
{
eventKey: "KeyD",
},
{
eventKey: "KeyF",
},
{
eventKey: "KeyG",
},
{
eventKey: "KeyH",
},
{
eventKey: "KeyJ",
},
{
eventKey: "KeyK",
},
{
eventKey: "KeyL",
},
{
eventKey: "Semicolon",
symbol: ";",
},
{
eventKey: "Quote",
hookKey: "singlequote",
symbol: "'",
},
{
eventKey: "Enter",
symbol: isMac ? "↩︎" : void 0,
},
// 第五排
{
eventKey: "KeyZ",
},
{
eventKey: "KeyX",
},
{
eventKey: "KeyC",
},
{
eventKey: "KeyV",
},
{
eventKey: "KeyB",
},
{
eventKey: "KeyN",
},
{
eventKey: "KeyM",
},
{
eventKey: "Comma",
symbol: ",",
},
{
eventKey: "Period",
symbol: ".",
},
{
eventKey: "Slash",
hookKey: "forwardslash",
symbol: "/",
},
// 第六排
{
eventKey: "Space",
symbol: isMac ? "␣" : void 0,
},
// 方向键
{
eventKey: "ArrowUp",
hookKey: "uparrow",
symbol: "↑",
},
{
eventKey: "ArrowDown",
hookKey: "downarrow",
symbol: "↓",
},
{
eventKey: "ArrowLeft",
hookKey: "leftarrow",
symbol: "←",
},
{
eventKey: "ArrowRight",
hookKey: "rightarrow",
symbol: "→",
},
].map((item) => {
const { eventKey } = item;
defaults<Key, Partial<Key>>(item, {
hookKey: eventKey.toLowerCase(),
symbol: eventKey,
tauriKey: eventKey,
});
if (eventKey.startsWith("Digit") || eventKey.startsWith("Key")) {
item.tauriKey = item.symbol = eventKey.slice(-1);
item.hookKey = item.tauriKey.toLowerCase();
}
return item;
});
export const keys = modifierKeys.concat(standardKeys);
export const getKeySymbol = (key: string) => {
const fields = ["tauriKey", "hookKey"] as const;
const matched = keys.find((entry) => {
return fields.some((field) => entry[field] === key);
});
return matched?.symbol ?? key;
};

View File

@@ -1,81 +1,201 @@
import { import {
createContext, createContext,
Dispatch,
ReactElement, ReactElement,
ReactNode, ReactNode,
SetStateAction,
useEffect,
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { Folder } from "lucide-react"; import { Calculator, Folder } from "lucide-react";
import { noop } from "lodash-es"; import { noop } from "lodash-es";
import { useMount } from "ahooks";
import Accordion from "./components/Accordion";
import ApplicationsContent from "./components/Content/Applications";
import ApplicationsDetail from "./components/Details/Applications";
import { useApplicationsStore } from "@/stores/applicationsStore";
import Application from "./components/Details/Application";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import ApplicationsDetail from "./components/Details/Applications";
import Application from "./components/Details/Application";
import platformAdapter from "@/utils/platformAdapter";
import Content from "./components/Content";
import Details from "./components/Details";
export interface IApplication {
path: string;
name: string;
iconPath: string;
alias: string;
hotkey: string;
isDisabled: boolean;
}
export interface Plugin { export interface Plugin {
id: string; id: string;
icon: ReactElement; icon: ReactElement;
title: ReactNode; name: ReactNode;
type?: "Group" | "Extension"; type?: "Group" | "Extension" | "Application";
alias?: string; alias?: string;
hotKey?: string; hotkey?: string;
enabled?: boolean; enabled?: boolean;
content?: ReactNode;
detail?: ReactNode; detail?: ReactNode;
children?: Plugin[];
manualLoad?: boolean;
loadChildren?: () => Promise<void>;
onAliasChange?: (alias: string) => void;
onHotkeyChange?: (hotkey: string) => void;
onEnabledChange?: (enabled: boolean) => void;
} }
interface ExtensionsContextType { interface ExtensionsContextType {
plugins: Plugin[];
setPlugins: Dispatch<SetStateAction<Plugin[]>>;
activeId?: string; activeId?: string;
setActiveId: (id: string) => void; setActiveId: (id: string) => void;
} }
export const ExtensionsContext = createContext<ExtensionsContextType>({ export const ExtensionsContext = createContext<ExtensionsContextType>({
plugins: [],
setPlugins: noop,
setActiveId: noop, setActiveId: noop,
}); });
const Extensions = () => { const Extensions = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [apps, setApps] = useState<IApplication[]>([]);
const [disabled, setDisabled] = useState<string[]>([]);
const [activeId, setActiveId] = useState<string>();
const allApps = useApplicationsStore((state) => { useMount(async () => {
return state.allApps; const disabled = await platformAdapter.invokeBackend<string[]>(
"get_disabled_local_query_sources"
);
setDisabled(disabled);
}); });
const presetPlugins: Plugin[] = [ const loadApps = async () => {
{ const apps = await platformAdapter.invokeBackend<IApplication[]>(
id: "1", "get_app_list"
icon: <Folder />, );
title: t("settings.extensions.application.title"),
type: "Group",
content: <ApplicationsContent />,
detail: <ApplicationsDetail />,
},
// {
// id: "2",
// icon: <File />,
// title: "File Search",
// },
];
const plugins: Plugin[] = [...presetPlugins]; const sortedApps = apps.sort((a, b) => {
return a.name.localeCompare(b.name, undefined, {
const [activeId, setActiveId] = useState(plugins[0].id); sensitivity: "base",
});
const currentPlugin = useMemo(() => {
return plugins.find((plugin) => plugin.id === activeId);
}, [activeId, plugins]);
const currentApp = useMemo(() => {
return allApps.find((app) => {
return app.name === activeId;
}); });
}, [activeId, allApps]);
setApps(sortedApps);
};
const presetPlugins = useMemo<Plugin[]>(() => {
const plugins: Plugin[] = [
{
id: "Applications",
icon: <Folder />,
name: t("settings.extensions.application.title"),
type: "Group",
detail: <ApplicationsDetail />,
children: [],
manualLoad: true,
loadChildren: loadApps,
},
{
id: "Calculator",
icon: <Calculator />,
name: t("settings.extensions.calculator.title"),
},
];
if (apps.length > 0) {
for (const app of apps) {
const { path, iconPath, isDisabled } = app;
plugins[0].children?.push({
...app,
id: path,
type: "Application",
icon: (
<img
src={platformAdapter.convertFileSrc(iconPath)}
className="size-5"
/>
),
enabled: !isDisabled,
detail: <Application />,
onAliasChange(alias) {
platformAdapter.invokeBackend("set_app_alias", {
appPath: path,
alias,
});
const nextApps = apps.map((item) => {
if (item.path !== path) return item;
return { ...item, alias };
});
setApps(nextApps);
},
onHotkeyChange(hotkey) {
const command = `${hotkey ? "register" : "unregister"}_app_hotkey`;
platformAdapter.invokeBackend(command, {
appPath: path,
hotkey,
});
const nextApps = apps.map((item) => {
if (item.path !== path) return item;
return { ...item, hotkey };
});
setApps(nextApps);
},
onEnabledChange(enabled) {
const command = `${enabled ? "enable" : "disable"}_app_search`;
platformAdapter.invokeBackend(command, {
appPath: path,
});
const nextApps = apps.map((item) => {
if (item.path !== path) return item;
return { ...item, isDisabled: !enabled };
});
setApps(nextApps);
},
});
}
}
return plugins;
}, [apps]);
const [plugins, setPlugins] = useState<Plugin[]>(presetPlugins);
useEffect(() => {
setPlugins(presetPlugins);
}, [presetPlugins]);
useEffect(() => {
setPlugins((prevPlugins) => {
return prevPlugins.map((item) => {
if (disabled.includes(item.id)) {
return { ...item, enabled: false };
}
return item;
});
});
}, [disabled]);
return ( return (
<ExtensionsContext.Provider <ExtensionsContext.Provider
value={{ value={{
plugins,
setPlugins,
activeId, activeId,
setActiveId, setActiveId,
}} }}
@@ -88,11 +208,9 @@ const Extensions = () => {
<div> <div>
<div className="flex"> <div className="flex">
<div className="w-[220px]"> <div className="flex-1">{t("settings.extensions.list.name")}</div>
{t("settings.extensions.list.name")}
</div>
<div className="flex flex-1"> <div className="w-3/5 flex">
<div className="flex-1"> <div className="flex-1">
{t("settings.extensions.list.type")} {t("settings.extensions.list.type")}
</div> </div>
@@ -108,23 +226,11 @@ const Extensions = () => {
</div> </div>
</div> </div>
{plugins.map((item) => { <Content />
return <Accordion {...item} key={item.id} />;
})}
</div> </div>
</div> </div>
<div className="flex-1 h-full overflow-auto"> <Details />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{currentPlugin?.title}
{currentApp?.name}
</h2>
{currentPlugin?.detail}
{currentApp && <Application current={currentApp} />}
</div>
</div> </div>
</ExtensionsContext.Provider> </ExtensionsContext.Provider>
); );

View File

@@ -0,0 +1,47 @@
import { Input, InputProps } from "@headlessui/react";
import clsx from "clsx";
import { isNumber } from "lodash-es";
import { FC, FocusEvent } from "react";
interface SettingsInputProps extends Omit<InputProps, "onChange"> {
onChange: (value?: string | number) => void;
}
const SettingsInput: FC<SettingsInputProps> = (props) => {
const { className, onBlur, onChange, ...rest } = props;
const { type, min, max } = rest;
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
onBlur?.(event);
if (type !== "number") return;
if (event.target instanceof HTMLInputElement) {
const value = Number(event.target.value);
if (isNumber(min) && value < min) {
onChange?.(min);
}
if (isNumber(max) && value > max) {
onChange?.(max);
}
}
};
return (
<Input
{...rest}
className={clsx(
"w-20 h-8 px-2 rounded-md border bg-transparent border-black/5 dark:border-white/10 hover:!border-[#0072FF] focus:!border-[#0072FF] transition",
className
)}
onBlur={handleBlur}
onChange={(event) => {
onChange?.(event.target.value);
}}
/>
);
};
export default SettingsInput;

View File

@@ -16,7 +16,7 @@ export default function SettingsItem({
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Icon className="h-5 w-5 text-gray-400 dark:text-gray-500" /> <Icon className="h-5 min-w-5 text-gray-400 dark:text-gray-500" />
<div> <div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100"> <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{title} {title}

View File

@@ -45,7 +45,7 @@ const UpdateApp = ({ checkUpdate, relaunchApp }: UpdateAppProps) => {
const checkUpdateStatus = useCallback(async () => { const checkUpdateStatus = useCallback(async () => {
const update = await checkUpdate(); const update = await checkUpdate();
if (update?.available) { if (update) {
setUpdateInfo(update); setUpdateInfo(update);
if (skipVersion === update.version) return; if (skipVersion === update.version) return;

View File

@@ -39,182 +39,152 @@ export function useChatActions(
const chatClose = useCallback( const chatClose = useCallback(
async (activeChat?: Chat) => { async (activeChat?: Chat) => {
if (!activeChat?._id) return; if (!activeChat?._id) return;
try {
let response: any; let response: any;
if (isTauri) { if (isTauri) {
if (!currentServiceId) return; if (!currentServiceId) return;
response = await platformAdapter.commands("close_session_chat", { response = await platformAdapter.commands("close_session_chat", {
serverId: currentServiceId, serverId: currentServiceId,
sessionId: activeChat?._id, sessionId: activeChat?._id,
}); });
response = response ? JSON.parse(response) : null; response = response ? JSON.parse(response) : null;
} else { } else {
const [error, res] = await Post( const [_error, res] = await Post(
`/chat/${activeChat?._id}/_close`, `/chat/${activeChat?._id}/_close`,
{} {}
); );
if (error) { response = res;
console.error("_close", error);
return;
}
response = res;
}
console.log("_close", response);
} catch (error) {
console.error("chatClose:", error);
} }
console.log("_close", response);
}, },
[currentServiceId] [currentServiceId, isTauri]
); );
const cancelChat = useCallback( const cancelChat = useCallback(
async (activeChat?: Chat) => { async (activeChat?: Chat) => {
setCurChatEnd(true); setCurChatEnd(true);
if (!activeChat?._id) return; if (!activeChat?._id) return;
try { let response: any;
let response: any; if (isTauri) {
if (isTauri) { if (!currentServiceId) return;
if (!currentServiceId) return; response = await platformAdapter.commands("cancel_session_chat", {
response = await platformAdapter.commands("cancel_session_chat", { serverId: currentServiceId,
serverId: currentServiceId, sessionId: activeChat?._id,
sessionId: activeChat?._id, });
}); response = response ? JSON.parse(response) : null;
response = response ? JSON.parse(response) : null; } else {
} else { const [_error, res] = await Post(
const [error, res] = await Post( `/chat/${activeChat?._id}/_cancel`,
`/chat/${activeChat?._id}/_cancel`, {}
{} );
); response = res;
if (error) {
console.error("_cancel", error);
return;
}
response = res;
}
console.log("_cancel", response);
} catch (error) {
console.error("cancelChat:", error);
} }
console.log("_cancel", response);
}, },
[currentServiceId, setCurChatEnd] [currentServiceId, isTauri]
); );
const chatHistory = useCallback( const chatHistory = useCallback(
async (chat: Chat, callback?: (chat: Chat) => void) => { async (chat: Chat, callback?: (chat: Chat) => void) => {
if (!chat?._id) return; if (!chat?._id) return;
try {
let response: any; let response: any;
if (isTauri) { if (isTauri) {
if (!currentServiceId) return; if (!currentServiceId) return;
response = await platformAdapter.commands("session_chat_history", { response = await platformAdapter.commands("session_chat_history", {
serverId: currentServiceId, serverId: currentServiceId,
sessionId: chat?._id, sessionId: chat?._id,
from: 0, from: 0,
size: 20, size: 100,
}); });
response = response ? JSON.parse(response) : null; response = response ? JSON.parse(response) : null;
} else { } else {
const [error, res] = await Get(`/chat/${chat?._id}/_history`, { const [_error, res] = await Get(`/chat/${chat?._id}/_history`, {
from: 0, from: 0,
size: 20, size: 100,
}); });
if (error) { response = res;
console.error("_cancel", error);
return;
}
response = res;
}
const hits = response?.hits?.hits || [];
const updatedChat: Chat = {
...chat,
messages: hits,
};
console.log("id_history", response, updatedChat);
setActiveChat(updatedChat);
callback && callback(updatedChat);
} catch (error) {
console.error("chatHistory:", error);
} }
const hits = response?.hits?.hits || [];
const updatedChat: Chat = {
...chat,
messages: hits,
};
console.log("id_history", updatedChat);
setActiveChat(updatedChat);
callback && callback(updatedChat);
setVisibleStartPage(false); setVisibleStartPage(false);
}, },
[currentServiceId, setActiveChat] [currentServiceId, isTauri]
); );
const createNewChat = useCallback( const createNewChat = useCallback(
async (value: string = "", activeChat?: Chat, id?: string) => { async (value: string = "", activeChat?: Chat, id?: string) => {
try { setTimedoutShow(false);
setTimedoutShow(false); await chatClose(activeChat);
await chatClose(activeChat); clearAllChunkData();
clearAllChunkData(); setQuestion(value);
setQuestion(value);
if (!(websocketSessionId || id)) {
addError("websocketSessionId not found");
console.error("websocketSessionId", websocketSessionId, id);
return;
}
console.log("sourceDataIds", sourceDataIds, MCPIds, websocketSessionId, id);
let response: any;
if (isTauri) {
if (!currentServiceId) return;
response = await platformAdapter.commands("new_chat", {
serverId: currentServiceId,
websocketId: websocketSessionId || id,
message: value,
queryParams: {
search: isSearchActive,
deep_thinking: isDeepThinkActive,
mcp: isMCPActive,
datasource: sourceDataIds?.join(",") || "",
mcp_servers: MCPIds?.join(",") || "",
assistant_id: currentAssistant?._id || '',
},
});
} else {
console.log("websocketSessionId", websocketSessionId, id);
const [error, res] = await Post(
"/chat/_new",
{
message: value,
},
{
search: isSearchActive,
deep_thinking: isDeepThinkActive,
mcp: isMCPActive,
datasource: sourceDataIds?.join(",") || "",
mcp_servers: MCPIds?.join(",") || "",
assistant_id: currentAssistant?._id || '',
},
{
"WEBSOCKET-SESSION-ID": websocketSessionId || id,
}
);
if (error) {
console.error("_new", error);
return;
}
response = res;
}
console.log("_new", response);
const newChat: Chat = response;
curIdRef.current = response?.payload?.id;
newChat._source = { const sessionId = websocketSessionId || id;
message: value, if (!sessionId) {
}; addError("websocketSessionId not found");
const updatedChat: Chat = { console.error("websocketSessionId", websocketSessionId, id);
...newChat, return;
messages: [newChat],
};
changeInput && changeInput("");
setActiveChat(updatedChat);
setCurChatEnd(false);
} catch (error) {
console.error("createNewChat:", error);
} }
//console.log("sourceDataIds", sourceDataIds, MCPIds, websocketSessionId, id);
const queryParams = {
search: isSearchActive,
deep_thinking: isDeepThinkActive,
mcp: isMCPActive,
datasource: sourceDataIds?.join(",") || "",
mcp_servers: MCPIds?.join(",") || "",
assistant_id: currentAssistant?._id || '',
};
let response: any;
if (isTauri) {
if (!currentServiceId) return;
response = await platformAdapter.commands("new_chat", {
serverId: currentServiceId,
websocketId: sessionId,
message: value,
queryParams,
});
} else {
const [_error, res] = await Post(
"/chat/_new",
{
message: value,
},
queryParams,
{
"WEBSOCKET-SESSION-ID": sessionId,
}
);
response = res;
}
console.log("_new", response);
const newChat: Chat = response;
curIdRef.current = response?.payload?.id;
newChat._source = {
message: value,
};
const updatedChat: Chat = {
...newChat,
messages: [newChat],
};
changeInput && changeInput("");
setActiveChat(updatedChat);
setCurChatEnd(false);
setVisibleStartPage(false); setVisibleStartPage(false);
}, },
[ [
isTauri,
currentServiceId, currentServiceId,
sourceDataIds, sourceDataIds,
MCPIds, MCPIds,
@@ -224,6 +194,7 @@ export function useChatActions(
curIdRef, curIdRef,
websocketSessionId, websocketSessionId,
currentAssistant, currentAssistant,
chatClose,
] ]
); );
@@ -232,73 +203,62 @@ export function useChatActions(
if (!newChat?._id || !content) return; if (!newChat?._id || !content) return;
clearAllChunkData(); clearAllChunkData();
try {
if (!(websocketSessionId || id)) {
addError("websocketSessionId not found");
console.error("websocketSessionId", websocketSessionId, id);
return;
}
let response: any;
if (isTauri) {
if (!currentServiceId) return;
response = await platformAdapter.commands("send_message", {
serverId: currentServiceId,
websocketId: websocketSessionId || id,
sessionId: newChat?._id,
queryParams: {
search: isSearchActive,
deep_thinking: isDeepThinkActive,
mcp: isMCPActive,
datasource: sourceDataIds?.join(",") || "",
mcp_servers: MCPIds?.join(",") || "",
assistant_id: currentAssistant?._id || '',
},
message: content,
});
response = response ? JSON.parse(response) : null;
} else {
console.log("websocketSessionId", websocketSessionId, id);
const [error, res] = await Post(
`/chat/${newChat?._id}/_send`,
{
message: content,
},
{
search: isSearchActive,
deep_thinking: isDeepThinkActive,
mcp: isMCPActive,
datasource: sourceDataIds?.join(",") || "",
mcp_servers: MCPIds?.join(",") || "",
assistant_id: currentAssistant?._id || '',
},
{
"WEBSOCKET-SESSION-ID": websocketSessionId || id,
}
);
if (error) { const sessionId = websocketSessionId || id;
console.error("_cancel", error); if (!sessionId) {
return; addError("websocketSessionId not found");
} console.error("websocketSessionId", websocketSessionId, id);
response = res; return;
}
console.log("_send", response);
curIdRef.current = response[0]?._id;
const updatedChat: Chat = {
...newChat,
messages: [...(newChat?.messages || []), ...(response || [])],
};
changeInput && changeInput("");
setActiveChat(updatedChat);
setCurChatEnd(false);
} catch (error) {
console.error("sendMessage:", error);
} }
const queryParams = {
search: isSearchActive,
deep_thinking: isDeepThinkActive,
mcp: isMCPActive,
datasource: sourceDataIds?.join(",") || "",
mcp_servers: MCPIds?.join(",") || "",
assistant_id: currentAssistant?._id || '',
}
let response: any;
if (isTauri) {
if (!currentServiceId) return;
response = await platformAdapter.commands("send_message", {
serverId: currentServiceId,
websocketId: sessionId,
sessionId: newChat?._id,
queryParams,
message: content,
});
response = response ? JSON.parse(response) : null;
} else {
const [_error, res] = await Post(
`/chat/${newChat?._id}/_send`,
{
message: content,
},
queryParams,
{
"WEBSOCKET-SESSION-ID": sessionId,
}
);
response = res;
}
console.log("_send", response);
curIdRef.current = response[0]?._id;
const updatedChat: Chat = {
...newChat,
messages: [...(newChat?.messages || []), ...(response || [])],
};
changeInput && changeInput("");
setActiveChat(updatedChat);
setCurChatEnd(false);
setVisibleStartPage(false); setVisibleStartPage(false);
}, },
[ [
isTauri,
currentServiceId, currentServiceId,
sourceDataIds, sourceDataIds,
MCPIds, MCPIds,
@@ -306,8 +266,6 @@ export function useChatActions(
isDeepThinkActive, isDeepThinkActive,
isMCPActive, isMCPActive,
curIdRef, curIdRef,
setActiveChat,
setCurChatEnd,
changeInput, changeInput,
websocketSessionId, websocketSessionId,
currentAssistant, currentAssistant,
@@ -326,9 +284,6 @@ export function useChatActions(
[ [
chatHistory, chatHistory,
sendMessage, sendMessage,
setQuestion,
setTimedoutShow,
clearAllChunkData,
] ]
); );
@@ -336,66 +291,52 @@ export function useChatActions(
async (chat: Chat) => { async (chat: Chat) => {
if (!chat?._id) return; if (!chat?._id) return;
setVisibleStartPage(false); setVisibleStartPage(false);
try {
let response: any;
if (isTauri) {
if (!currentServiceId) return;
response = await platformAdapter.commands("open_session_chat", {
serverId: currentServiceId,
sessionId: chat?._id,
});
response = response ? JSON.parse(response) : null;
} else {
const [error, res] = await Post(`/chat/${chat?._id}/_open`, {});
if (error) {
console.error("_open", error);
return null;
}
response = res;
}
console.log("_open", response); let response: any;
return response; if (isTauri) {
} catch (error) { if (!currentServiceId) return;
console.error("open_session_chat:", error); response = await platformAdapter.commands("open_session_chat", {
return null; serverId: currentServiceId,
sessionId: chat?._id,
});
response = response ? JSON.parse(response) : null;
} else {
const [_error, res] = await Post(`/chat/${chat?._id}/_open`, {});
response = res;
} }
console.log("_open", response);
return response;
}, },
[currentServiceId] [currentServiceId, isTauri]
); );
const getChatHistory = useCallback(async () => { const getChatHistory = useCallback(async () => {
let response: any; let response: any;
if (isTauri) { if (isTauri) {
try {
if (!currentServiceId) return []; if (!currentServiceId) return [];
response = await platformAdapter.commands("chat_history", { response = await platformAdapter.commands("chat_history", {
serverId: currentServiceId, serverId: currentServiceId,
from: 0, from: 0,
size: 20, size: 100,
query: keyword, query: keyword,
}); });
} catch (error) {
console.error("chat_history", error);
}
response = response ? JSON.parse(response) : null; response = response ? JSON.parse(response) : null;
} else { } else {
const [error, res] = await Get(`/chat/_history`, { const [_error, res] = await Get(`/chat/_history`, {
from: 0, from: 0,
size: 20, size: 100,
}); });
if (error) {
console.error("_history", error);
return [];
}
response = res; response = res;
} }
console.log("_history", response); console.log("_history", response);
const hits = response?.hits?.hits || []; const hits = response?.hits?.hits || [];
setChats(hits); setChats(hits);
return hits; }, [currentServiceId, keyword, isTauri]);
}, [currentServiceId, keyword]);
useEffect(() => { useEffect(() => {
showChatHistory && connected && getChatHistory(); showChatHistory && connected && getChatHistory();
@@ -420,13 +361,13 @@ export function useChatActions(
url: "/ui/chat", url: "/ui/chat",
}); });
} }
}, []); }, [isTauri]);
const handleSearch = (keyword: string) => { const handleSearch = (keyword: string) => {
setKeyword(keyword); setKeyword(keyword);
}; };
const handleRename = async (chatId: string, title: string) => { const handleRename = useCallback(async (chatId: string, title: string) => {
if (!currentServiceId) return; if (!currentServiceId) return;
await platformAdapter.commands("update_session_chat", { await platformAdapter.commands("update_session_chat", {
@@ -434,13 +375,13 @@ export function useChatActions(
sessionId: chatId, sessionId: chatId,
title, title,
}); });
}; }, [currentServiceId]);
const handleDelete = async (id: string) => { const handleDelete = useCallback(async (chatId: string) => {
if (!currentServiceId) return; if (!currentServiceId) return;
await platformAdapter.commands("delete_session_chat", currentServiceId, id); await platformAdapter.commands("delete_session_chat", currentServiceId, chatId);
}; }, [currentServiceId]);
return { return {
chatClose, chatClose,

View File

@@ -2,6 +2,7 @@ import platformAdapter from "@/utils/platformAdapter";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { useKeyPress } from "ahooks"; import { useKeyPress } from "ahooks";
import { HISTORY_PANEL_ID } from "@/constants"; import { HISTORY_PANEL_ID } from "@/constants";
import { closeHistoryPanel } from "@/utils";
const useEscape = () => { const useEscape = () => {
const visibleContextMenu = useSearchStore((state) => { const visibleContextMenu = useSearchStore((state) => {
@@ -29,11 +30,7 @@ const useEscape = () => {
const historyPanel = document.getElementById(HISTORY_PANEL_ID); const historyPanel = document.getElementById(HISTORY_PANEL_ID);
if (historyPanel) { if (historyPanel) {
const button = document.querySelector( return closeHistoryPanel();
`[aria-controls="${HISTORY_PANEL_ID}"]`
);
return (button as HTMLElement).click();
} }
platformAdapter.hideWindow(); platformAdapter.hideWindow();

View File

@@ -77,7 +77,7 @@ export default function useMessageChunkData() {
}, []), }, []),
}; };
const clearAllChunkData = useCallback(() => { const clearAllChunkData = () => {
setQuery_intent(undefined); setQuery_intent(undefined);
setTools(undefined); setTools(undefined);
setFetch_source(undefined); setFetch_source(undefined);
@@ -85,7 +85,7 @@ export default function useMessageChunkData() {
setDeep_read(undefined); setDeep_read(undefined);
setThink(undefined); setThink(undefined);
setResponse(undefined); setResponse(undefined);
}, []); };
return { return {
data: { query_intent, tools, fetch_source, pick_source, deep_read, think, response }, data: { query_intent, tools, fetch_source, pick_source, deep_read, think, response },

View File

@@ -95,6 +95,5 @@ export function useMessageHandler(
return { return {
dealMsg, dealMsg,
messageTimeoutRef,
}; };
} }

View File

@@ -1,7 +1,9 @@
import { useAppearanceStore } from "@/stores/appearanceStore";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { useShortcutsStore } from "@/stores/shortcutsStore"; import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useStartupStore } from "@/stores/startupStore"; import { useStartupStore } from "@/stores/startupStore";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import { isNumber } from "lodash-es";
import { useEffect } from "react"; import { useEffect } from "react";
export const useSyncStore = () => { export const useSyncStore = () => {
@@ -77,6 +79,13 @@ export const useSyncStore = () => {
const setQueryTimeout = useConnectStore((state) => { const setQueryTimeout = useConnectStore((state) => {
return state.setQuerySourceTimeout; return state.setQuerySourceTimeout;
}); });
const setOpacity = useAppearanceStore((state) => state.setOpacity);
const setSnapshotUpdate = useAppearanceStore((state) => {
return state.setSnapshotUpdate;
});
const setAllowSelfSignature = useConnectStore((state) => {
return state.setAllowSelfSignature;
});
useEffect(() => { useEffect(() => {
if (!resetFixedWindow) { if (!resetFixedWindow) {
@@ -137,9 +146,24 @@ export const useSyncStore = () => {
}), }),
platformAdapter.listenEvent("change-connect-store", ({ payload }) => { platformAdapter.listenEvent("change-connect-store", ({ payload }) => {
const { connectionTimeout, querySourceTimeout } = payload; const { connectionTimeout, querySourceTimeout, allowSelfSignature } =
setConnectionTimeout(connectionTimeout); payload;
setQueryTimeout(querySourceTimeout); if (isNumber(connectionTimeout)) {
setConnectionTimeout(connectionTimeout);
}
if (isNumber(querySourceTimeout)) {
setQueryTimeout(querySourceTimeout);
}
setAllowSelfSignature(allowSelfSignature);
}),
platformAdapter.listenEvent("change-appearance-store", ({ payload }) => {
const { opacity, snapshotUpdate } = payload;
if (isNumber(opacity)) {
setOpacity(opacity);
}
setSnapshotUpdate(snapshotUpdate);
}), }),
]); ]);

View File

@@ -38,7 +38,7 @@ export default function useWebSocket({
// web // web
const { readyState, connect, disconnect } = useWebSocketAHook( const { readyState, connect, disconnect } = useWebSocketAHook(
// "wss://coco.infini.cloud/ws", //"wss://coco.infini.cloud/ws",
//"ws://localhost:9000/ws", //"ws://localhost:9000/ws",
isTauri ? "" : endpoint_websocket, isTauri ? "" : endpoint_websocket,
{ {
@@ -118,7 +118,7 @@ export default function useWebSocket({
}, },
[currentService] [currentService]
); );
const disconnectWS = async () => { const disconnectWS = useCallback(async () => {
if (!connected) return; if (!connected) return;
if (isTauri) { if (isTauri) {
try { try {
@@ -131,7 +131,8 @@ export default function useWebSocket({
} else { } else {
disconnect(); disconnect();
} }
}; }, [connected]);
const updateDealMsg = useCallback( const updateDealMsg = useCallback(
(newDealMsg: (msg: string) => void) => { (newDealMsg: (msg: string) => void) => {
dealMsgRef.current = newDealMsg; dealMsgRef.current = newDealMsg;
@@ -145,16 +146,10 @@ export default function useWebSocket({
let unlisten_message = null; let unlisten_message = null;
if (!isTauri) return; if (!isTauri) return;
unlisten_error = platformAdapter.listenEvent(`ws-error-${clientId}`, (event) => { unlisten_error = platformAdapter.listenEvent(`ws-error-${clientId}`, (event) => {
// {
// "error": {
// "reason": "invalid login"
// },
// "status": 401
// }
console.error(`ws-error-${clientId}`, event, connected); console.error(`ws-error-${clientId}`, event, connected);
if(connected) { if (connected) {
addError("WebSocket connection failed."); addError("WebSocket connection failed.");
} }
setConnected(false); // error setConnected(false); // error

View File

@@ -161,6 +161,24 @@
"queryTimeout": { "queryTimeout": {
"title": "Query Timeout", "title": "Query Timeout",
"description": "Terminates the query if no search results are returned within this time. Default: 500ms." "description": "Terminates the query if no search results are returned within this time. Default: 500ms."
},
"allowSelfSignature": {
"title": "Allow Self-Signed Certificates",
"description": "Allow connections to servers using self-signed certificates. Enable only if you trust the source."
}
},
"appearance": {
"title": "Appearance Settings",
"opacity": {
"title": "Pinned Window Dimness Setting",
"description": "Adjusts the opacity level of the Coco AI window when its pinned and not in focus. Set a value between 10% and 100%, where 100% means fully opaque (no dimming), and lower values increase transparency, allowing underlying content to show through."
}
},
"updateVersion": {
"title": "Version & Updates",
"snapshotUpdate": {
"title": "Snapshot Updates",
"description": "Get early access to new features. May be unstable."
} }
} }
}, },
@@ -182,11 +200,15 @@
"hotkey": "Hotkey", "hotkey": "Hotkey",
"enabled": "Enabled" "enabled": "Enabled"
}, },
"hits": {
"addAlias": "Add Alias",
"recordHotkey": "Record Hotkey"
},
"application": { "application": {
"title": "Applications", "title": "Applications",
"hits": { "hits": {
"addAlias": "Add Alias", "pathDuplication": "Path \"{{0}}\" is already in search scope.",
"recordHotkey": "Record Hotkey" "pathIncluded": "Path \"{{0}}\" is already covered by another search directory."
}, },
"button": { "button": {
"addDirectories": "Add Directories" "addDirectories": "Add Directories"
@@ -203,6 +225,9 @@
"modified": "Modified", "modified": "Modified",
"lastOpened": "Last Opened" "lastOpened": "Last Opened"
} }
},
"calculator": {
"title": "Calculator"
} }
} }
}, },
@@ -262,6 +287,10 @@
"searchPopover": { "searchPopover": {
"title": "Search Scope", "title": "Search Scope",
"allScope": "All Scope" "allScope": "All Scope"
},
"uploadFileHints": {
"tooltip": "Support screenshots, upload files, up to 50, single file up to 100 MB.",
"maxSize": "The file size cannot exceed 100 MB."
} }
}, },
"main": { "main": {

View File

@@ -161,6 +161,24 @@
"queryTimeout": { "queryTimeout": {
"title": "查询超时", "title": "查询超时",
"description": "在此时间内未返回搜索结果则终止查询。默认值500 毫秒。" "description": "在此时间内未返回搜索结果则终止查询。默认值500 毫秒。"
},
"allowSelfSignature": {
"title": "允许自签名证书",
"description": "允许连接使用自签名证书的服务器。仅在信任来源的情况下启用。"
}
},
"appearance": {
"title": "外观设置",
"opacity": {
"title": "置顶时失焦透明度",
"description": "设置 Coco AI 窗口在置顶且失去焦点时的不透明度10%100%100% 表示完全不透明)。"
}
},
"updateVersion": {
"title": "版本与更新",
"snapshotUpdate": {
"title": "快照版更新",
"description": "抢先体验新功能,可能不稳定。"
} }
} }
}, },
@@ -182,11 +200,15 @@
"hotkey": "热键", "hotkey": "热键",
"enabled": "启用状态" "enabled": "启用状态"
}, },
"hits": {
"addAlias": "添加别名",
"recordHotkey": "录制热键"
},
"application": { "application": {
"title": "应用程序", "title": "应用程序",
"hits": { "hits": {
"addAlias": "添加别名", "pathDuplication": "路径 \"{{0}}\" 已存在于搜索范围中。",
"recordHotkey": "录制热键" "pathIncluded": "路径 \"{{0}}\" 已被其他搜索目录包含。"
}, },
"button": { "button": {
"addDirectories": "添加目录" "addDirectories": "添加目录"
@@ -203,6 +225,9 @@
"modified": "修改时间", "modified": "修改时间",
"lastOpened": "上次打开时间" "lastOpened": "上次打开时间"
} }
},
"calculator": {
"title": "计算器"
} }
} }
}, },
@@ -262,6 +287,10 @@
"searchPopover": { "searchPopover": {
"title": "搜索范围", "title": "搜索范围",
"allScope": "所有范围" "allScope": "所有范围"
},
"uploadFileHints": {
"tooltip": "支持截图、上传文件,最多 50个单个文件最大 100 MB。",
"maxSize": "文件大小不能超过 100 MB。"
} }
}, },
"main": { "main": {

View File

@@ -1,4 +1,3 @@
// import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
@@ -8,7 +7,5 @@ import "./i18n";
import "./main.css"; import "./main.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
//<React.StrictMode>
<RouterProvider router={router} /> <RouterProvider router={router} />
// </React.StrictMode>
); );

View File

@@ -67,7 +67,7 @@ export default function Chat({}: ChatProps) {
let response: any = await chat_history({ let response: any = await chat_history({
serverId: currentService?.id, serverId: currentService?.id,
from: 0, from: 0,
size: 20, size: 100,
query: keyword, query: keyword,
}); });
response = response ? JSON.parse(response) : null; response = response ? JSON.parse(response) : null;
@@ -109,7 +109,7 @@ export default function Chat({}: ChatProps) {
serverId: currentService?.id, serverId: currentService?.id,
sessionId: chat?._id, sessionId: chat?._id,
from: 0, from: 0,
size: 20, size: 100,
}); });
response = response ? JSON.parse(response) : null; response = response ? JSON.parse(response) : null;
console.log("id_history", response); console.log("id_history", response);

View File

@@ -4,8 +4,12 @@ import SearchChat from "@/components/SearchChat";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { useSyncStore } from "@/hooks/useSyncStore"; import { useSyncStore } from "@/hooks/useSyncStore";
import UpdateApp from "@/components/UpdateApp";
import { useAppearanceStore } from "@/stores/appearanceStore";
function MainApp() { function MainApp() {
const addError = useAppStore((state) => state.addError);
const setIsTauri = useAppStore((state) => state.setIsTauri); const setIsTauri = useAppStore((state) => state.setIsTauri);
useEffect(() => { useEffect(() => {
setIsTauri(true); setIsTauri(true);
@@ -17,12 +21,33 @@ function MainApp() {
useSyncStore(); useSyncStore();
const snapshotUpdate = useAppearanceStore((state) => state.snapshotUpdate);
const checkUpdate = useCallback(async () => {
return platformAdapter.checkUpdate();
}, []);
const relaunchApp = useCallback(async () => {
return platformAdapter.relaunchApp();
}, []);
useEffect(() => {
if (!snapshotUpdate) return;
checkUpdate().catch((error) => {
addError("Update failed:" + error, "error");
});
}, [snapshotUpdate]);
return ( return (
<SearchChat <>
isTauri={true} <SearchChat
hideCoco={hideCoco} isTauri={true}
hasModules={["search", "chat"]} hideCoco={hideCoco}
/> hasModules={["search", "chat"]}
/>
<UpdateApp checkUpdate={checkUpdate} relaunchApp={relaunchApp} />
</>
); );
} }

View File

@@ -12,9 +12,6 @@ import Footer from "@/components/Common/UI/SettingsFooter";
import { useTray } from "@/hooks/useTray"; import { useTray } from "@/hooks/useTray";
import Advanced from "@/components/Settings/Advanced"; import Advanced from "@/components/Settings/Advanced";
import Extensions from "@/components/Settings/Extensions"; import Extensions from "@/components/Settings/Extensions";
import { useAsyncEffect, useMount } from "ahooks";
import { useApplicationsStore } from "@/stores/applicationsStore";
import platformAdapter from "@/utils/platformAdapter";
const tabIndexMap: { [key: string]: number } = { const tabIndexMap: { [key: string]: number } = {
general: 0, general: 0,
@@ -26,9 +23,6 @@ const tabIndexMap: { [key: string]: number } = {
function SettingsPage() { function SettingsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const searchPaths = useApplicationsStore((state) => state.searchPaths);
const setSearchPaths = useApplicationsStore((state) => state.setSearchPaths);
const setAllApps = useApplicationsStore((state) => state.setAllApps);
useTray(); useTray();
@@ -57,34 +51,9 @@ function SettingsPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
document.body.style.overflow = defaultIndex !== 1 ? "auto" : "hidden"; document.body.style.overflow = defaultIndex === 1 ? "hidden" : "auto";
}, [defaultIndex]); }, [defaultIndex]);
useMount(async () => {
if (searchPaths.length > 0) return;
const paths = await platformAdapter.invokeBackend<string[]>(
"get_default_search_paths"
);
setSearchPaths(paths);
});
useAsyncEffect(async () => {
if (searchPaths.length === 0) {
return setAllApps([]);
}
const apps = await platformAdapter.invokeBackend<any[]>(
"list_app_with_metadata_in",
{
searchPath: searchPaths,
}
);
setAllApps(apps);
}, [searchPaths]);
return ( return (
<div> <div>
<div className="min-h-screen pb-8 bg-white dark:bg-gray-900 text-gray-900 dark:text-white"> <div className="min-h-screen pb-8 bg-white dark:bg-gray-900 text-gray-900 dark:text-white">

View File

@@ -33,7 +33,7 @@ function WebApp({
height = 590, height = 590,
headers = { headers = {
"X-API-TOKEN": "X-API-TOKEN":
"cvqt6r02sdb2v3bkgip0x3ixv01f3r2lhnxoz1efbn160wm9og58wtv8t6wrv1ebvnvypuc23dx9pb33aemh", "d0erda62a89cir2p1rdgbdkjynbtwxa93e86op8fwyujsht11ckbcugw2zlp1lrvb87cnalv90p22jqbam21",
"APP-INTEGRATION-ID": "cvkm9hmhpcemufsg3vug", "APP-INTEGRATION-ID": "cvkm9hmhpcemufsg3vug",
}, },
// token = "cva1j5ehpcenic3ir7k0h8fb8qtv35iwtywze248oscrej8yoivhb5b1hyovp24xejjk27jy9ddt69ewfi3n", // https://coco.infini.cloud // token = "cva1j5ehpcenic3ir7k0h8fb8qtv35iwtywze248oscrej8yoivhb5b1hyovp24xejjk27jy9ddt69ewfi3n", // https://coco.infini.cloud

View File

@@ -1,3 +1,5 @@
import { useStartupStore } from "@/stores/startupStore";
export type AppState = { export type AppState = {
isChatMode: boolean; isChatMode: boolean;
input: string; input: string;
@@ -10,21 +12,18 @@ export type AppState = {
}; };
export type AppAction = export type AppAction =
| { type: 'SET_CHAT_MODE'; payload: boolean } | { type: "SET_CHAT_MODE"; payload: boolean }
| { type: 'SET_INPUT'; payload: string } | { type: "SET_INPUT"; payload: string }
| { type: 'TOGGLE_SEARCH_ACTIVE' } | { type: "TOGGLE_SEARCH_ACTIVE" }
| { type: 'TOGGLE_DEEP_THINK_ACTIVE' } | { type: "TOGGLE_DEEP_THINK_ACTIVE" }
| { type: 'TOGGLE_MCP_ACTIVE' } | { type: "TOGGLE_MCP_ACTIVE" }
| { type: 'SET_TYPING'; payload: boolean } | { type: "SET_TYPING"; payload: boolean }
| { type: 'SET_LOADING'; payload: boolean }; | { type: "SET_LOADING"; payload: boolean };
const getCachedChatMode = (): boolean => { const getCachedChatMode = (): boolean => {
try { const { defaultStartupWindow } = useStartupStore.getState();
const cached = localStorage.getItem('coco-chat-mode');
return cached === 'true'; return defaultStartupWindow === "chatMode";
} catch {
return false;
}
}; };
export const initialAppState: AppState = { export const initialAppState: AppState = {
@@ -35,26 +34,30 @@ export const initialAppState: AppState = {
isDeepThinkActive: false, isDeepThinkActive: false,
isMCPActive: false, isMCPActive: false,
isTyping: false, isTyping: false,
isLoading: false isLoading: false,
}; };
export function appReducer(state: AppState, action: AppAction): AppState { export function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) { switch (action.type) {
case 'SET_CHAT_MODE': case "SET_CHAT_MODE":
return { ...state, isChatMode: action.payload, isTransitioned: action.payload }; return {
case 'SET_INPUT': ...state,
isChatMode: action.payload,
isTransitioned: action.payload,
};
case "SET_INPUT":
return { ...state, input: action.payload }; return { ...state, input: action.payload };
case 'TOGGLE_SEARCH_ACTIVE': case "TOGGLE_SEARCH_ACTIVE":
return { ...state, isSearchActive: !state.isSearchActive }; return { ...state, isSearchActive: !state.isSearchActive };
case 'TOGGLE_DEEP_THINK_ACTIVE': case "TOGGLE_DEEP_THINK_ACTIVE":
return { ...state, isDeepThinkActive: !state.isDeepThinkActive }; return { ...state, isDeepThinkActive: !state.isDeepThinkActive };
case 'TOGGLE_MCP_ACTIVE': case "TOGGLE_MCP_ACTIVE":
return { ...state, isMCPActive: !state.isMCPActive }; return { ...state, isMCPActive: !state.isMCPActive };
case 'SET_TYPING': case "SET_TYPING":
return { ...state, isTyping: action.payload }; return { ...state, isTyping: action.payload };
case 'SET_LOADING': case "SET_LOADING":
return { ...state, isLoading: action.payload }; return { ...state, isLoading: action.payload };
default: default:
return state; return state;
} }
} }

View File

@@ -2,6 +2,8 @@ import { useEffect } from "react";
import { Outlet, useLocation } from "react-router-dom"; import { Outlet, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAsyncEffect, useEventListener, useMount } from "ahooks"; import { useAsyncEffect, useEventListener, useMount } from "ahooks";
import { isString } from "lodash-es";
import { error } from "@tauri-apps/plugin-log";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import useEscape from "@/hooks/useEscape"; import useEscape from "@/hooks/useEscape";
@@ -108,6 +110,12 @@ export default function Layout() {
useModifierKeyPress(); useModifierKeyPress();
useEventListener("unhandledrejection", ({ reason }) => {
const message = isString(reason) ? reason : JSON.stringify(reason);
error(message);
});
return ( return (
<> <>
<Outlet /> <Outlet />

View File

@@ -56,7 +56,7 @@ export type IAppStore = {
setLanguage: (language: string) => void; setLanguage: (language: string) => void;
isPinned: boolean; isPinned: boolean;
setIsPinned: (isPinned: boolean) => void; setIsPinned: (isPinned: boolean) => void;
initializeListeners: () => void; initializeListeners: () => Promise<() => void>;
showCocoShortcuts: string[]; showCocoShortcuts: string[];
setShowCocoShortcuts: (showCocoShortcuts: string[]) => void; setShowCocoShortcuts: (showCocoShortcuts: string[]) => void;
@@ -130,10 +130,14 @@ export const useAppStore = create<IAppStore>()(
isPinned: false, isPinned: false,
setIsPinned: (isPinned: boolean) => set({ isPinned }), setIsPinned: (isPinned: boolean) => set({ isPinned }),
initializeListeners: () => { initializeListeners: () => {
platformAdapter.listenEvent(ENDPOINT_CHANGE_EVENT, (event: any) => { return platformAdapter.listenEvent(
const { endpoint, endpoint_http, endpoint_websocket } = event.payload; ENDPOINT_CHANGE_EVENT,
set({ endpoint, endpoint_http, endpoint_websocket }); (event: any) => {
}); const { endpoint, endpoint_http, endpoint_websocket } =
event.payload;
set({ endpoint, endpoint_http, endpoint_websocket });
}
);
}, },
showCocoShortcuts: [], showCocoShortcuts: [],
setShowCocoShortcuts: (showCocoShortcuts: string[]) => { setShowCocoShortcuts: (showCocoShortcuts: string[]) => {

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