45 Commits

Author SHA1 Message Date
BiggerRain
8f992bfa92 chore: bump version number to 0.7.1 (#830) 2025-07-27 17:26:08 +08:00
BiggerRain
e7dd27c744 chore: add toggle_move_to_active_space_attribute (#829)
* chore: add toggle_move_to_active_space_attribute

* chore: pin

* chore: add

* update

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-07-27 16:50:11 +08:00
ayangweb
7914836c3e fix: correct enter key behavior (#828) 2025-07-27 11:52:40 +08:00
BiggerRain
b37bf1f7c7 chore: bump version number to 0.7.0 (#827) 2025-07-25 19:54:33 +08:00
BiggerRain
419d9d55c5 chore: web componet remove server name (#826) 2025-07-25 18:16:07 +08:00
BiggerRain
d3ed54c771 chore: web component add notification component (#825)
* chroe: web component add notification component

* docs: update notes
2025-07-25 18:15:49 +08:00
ayangweb
8f26dbcbe6 refactor: optimize subpage shortcut context menu (#822)
* refactor: optimize subpage shortcut context menu

* update

* update
2025-07-25 16:43:41 +08:00
ayangweb
663873ae14 refactor: optimize carriage return copying (#823) 2025-07-25 16:43:05 +08:00
SteveLauC
286b1be212 fix: panic on Ubuntu (GNOME) when opening apps (#821)
On Ubuntu (the GNOME version), Coco would panic when users open an app due
to the reason that Coco thinks it is running in an unsupported desktop
environment (DE).

We rely on the environment variable XDG_CURRENT_DESKTOP to detect the DE,
Ubuntu sets this variable to "ubuntu:GNOME" instead of just "GNOME",
which was not handled by the previous implementation.

This commit supports this case. Also, when Coco runs in an unsupported DE,
opening apps should not panic the app. After this commit, we would return
an error.
2025-07-25 15:32:48 +08:00
ayangweb
37221782b0 refactor: optimize shortcut key triggering (#820) 2025-07-25 14:54:32 +08:00
ayangweb
644e291105 fix: fix update window config sync (#818)
* fix: fix update window config sync

* docs: update changelog
2025-07-25 14:47:20 +08:00
BiggerRain
aae6984aa7 fix: re-search data initialization (#817) 2025-07-25 14:43:27 +08:00
ayangweb
dbd296d399 fix: fix enter key on subpages (#819)
* fix: fix enter key on subpages

* docs: update changelog
2025-07-25 14:43:16 +08:00
ayangweb
e2ad25967d fix: fix ctrl+k not working (#815) 2025-07-25 14:30:03 +08:00
ayangweb
21b61d80d8 refactor: optimize method calls for checking for updates (#814) 2025-07-25 13:42:12 +08:00
ayangweb
9f4c693ac4 refactor: optimize line breaks in input boxes (#813) 2025-07-25 12:36:07 +08:00
BiggerRain
45c27cac56 chore: cancel interface param (#816) 2025-07-25 12:16:23 +08:00
BiggerRain
e46035afd4 fix:the client id is the same (#812)
* chore: add

* fix: client id
2025-07-25 11:25:22 +08:00
BiggerRain
1004bb73f4 chore: delay the chat monitoring event (#811) 2025-07-24 20:03:30 +08:00
BiggerRain
d664fa7271 chore: handle reply to message (#799)
* chore: add reply to message

* chore: handle rust data

* log

* chore: id

* feat: add

* chore: loading step

* chore: cur id

* feat: add

* accept query parameters

* chore: add message id for cancel

* chore: remove log

* chore: remove log

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-07-24 18:06:59 +08:00
SteveLauC
067fb7144f refactor: use custom version comparator to determine if we should update (#810) 2025-07-24 16:05:36 +08:00
ayangweb
579f91f3aa refactor: refactor version update check (#809) 2025-07-24 11:56:57 +08:00
ayangweb
abe2aecedf fix: fix multiline input issue (#808) 2025-07-24 10:58:57 +08:00
SteveLauC
e8f9a4e627 chore: log querysources to search only when querysource is not set (#807) 2025-07-24 09:39:29 +08:00
ayangweb
22b1558e8b refactor: optimized data fetching for secondary pages (#803) 2025-07-23 18:56:56 +08:00
SteveLauC
ca3b514a65 fix: panic caused by "state() called before manage()" (#806)
This commit fixes the following panic:

```
Time: [2025-07-23-17-03-23]
Location: [/Users/steve/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.5.1/src/lib.rs:742:7]
Message: [state() called before manage() for tauri_plugin_global_shortcut::GlobalShortcut<tauri_runtime_wry::Wry<tauri::EventLoopMessage>>]
```

The root cause is that, in a Tauri application, before you can access a piece of
managed state with the .state() method, you must first register it with Tauri
using .manage(). When a user reigsters hotkey for an extension,
initializing extensions will invoke the .state() method, at that point,
.manage() hasn't been called.

The fix is simple, we simply call .manage() earlies (invoked by our
`shortcut::enable_shortcut(app)` function).
2025-07-23 18:56:16 +08:00
SteveLauC
c694c4eda9 chore: display backtrace in panic log (#805)
Having backtrace in the panic log will help debugging a lot. Under
release builds, we strip our binary so the symbols information is
unavailable, but this information is still useful in debug builds.

Panic log in release builds:

```
Time: [YYYY-MM-DD-HH-MM-SS]
Location: [/Users/foo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.5.1/src/lib.rs:742:7]
Message: [state() called before manage() for tauri_plugin_global_shortcut::GlobalShortcut<tauri_runtime_wry::Wry<tauri::EventLoopMessage>>]
Backtrace:
   0: __mh_execute_header
   1: __mh_execute_header
   2: __mh_execute_header
   3: __mh_execute_header
   4: __mh_execute_header
   5: __mh_execute_header
   6: __mh_execute_header
   7: __mh_execute_header
   8: __mh_execute_header
   9: __mh_execute_header
  10: __mh_execute_header
  11: __mh_execute_header
  12: __mh_execute_header
  13: __mh_execute_header
  14: __mh_execute_header
  15: __mh_execute_header
  16: __mh_execute_header
  17: <unknown>
  18: <unknown>
```
2025-07-23 17:00:48 +08:00
ayangweb
ac835c76aa fix: fix shortcut issue in windows context menu (#804)
* fix: fix shortcut issue in windows context menu

* docs: update changelog
2025-07-23 16:20:46 +08:00
SteveLauC
25bbab7432 refactor: clean up unsupported characters from query string in Win Search (#802)
We found that Windows Search would error out if it encounters a single
quote character, the natural solution would be to escape it. But I couldn't
find out how. The approach mentioned by most posts:

```
~="<Unsupported Char>"
```

won't work in my test. So I decided to replace it with a whitespace.

Single quote is not the first unsupported character I know, the newline
character is not supported as well, so it will be handled in the same
way.
2025-07-23 16:13:15 +08:00
ayangweb
cca00e944e fix: fix selection issue after renaming (#800) 2025-07-23 13:59:33 +08:00
SteveLauC
e78fe4ac89 fix: broken windows search (#801)
This commit fixes the search issue introduced by [commit](5c0a865822). We have no idea why the tauri command `get_app_search_source` won't be invoked after that commit on Windows.

This commit resolves the issue by moving the extension init logic to the Rust side.

Also, update the querysource logs in `quey_coco_fusion()`, the old one won't say anything if the querysource list is empty, the new one will tell us that.
2025-07-23 12:33:18 +08:00
Medcl
60fd79f1fa fix: increase read_timeout for HTTP streaming stability (#798) 2025-07-22 18:44:27 +08:00
BiggerRain
5c0a865822 chore: not request the interface if not logged in (#795)
* chore: not request the interface if not logged in

* chore: res

* chore: res

* chore: common interface

* chore: no login

* chore: login

* chore: login

* chore: add

* dbg print servers

* chore: id

* docs: update notes

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-07-22 16:15:58 +08:00
SteveLauC
5b50e4b51b ci: add Rust code format check to CI (#797)
This commit adds the Rust code format check to our CI.
2025-07-22 15:11:13 +08:00
SteveLauC
b97386a827 refactor: avoid GLOBAL_TAURI_APP_HANLE if possible (#796)
This commit fixes the Windows panic issue. 

Coco panicked because it accessed `GLOBAL_TAURI_APP_HANDLE` when this global variable wasn't initialized. I removed all the uses of this variable except for the one use in `src-tauri/src/server/http_client.rs`, which I don't have a good way to refactor.

If you are wondering why this didn't happen in the past, the access was triggered by the frontend code, something there likely changed. Regardless, this global variable is still dangerous and error-prone, so we should avoid it.

Also, this commit fixes the issue that the panic hook does not work on Windows because the log filename contains ":", which is not allowed by the Windows file system.
2025-07-22 14:43:27 +08:00
SteveLauC
29aa26af94 chore: add a panic hook to catch panic msg (#793) 2025-07-22 10:34:27 +08:00
BiggerRain
3650d9914c fix: enter key problem (#794)
* fixed: enter key problem

* docs: update notes

* fix: enter key problem
2025-07-22 10:13:08 +08:00
SteveLauC
f26031047c fix: refreshing Coco server should register it to SearchSource (#792) 2025-07-22 08:51:57 +08:00
BiggerRain
c8719926be chore: add 401 unauthorized (#791) 2025-07-21 22:21:07 +08:00
BiggerRain
f1dfc5c730 fixed: chat message confusion (#782)
* fix: chat

* fix: chat

* chore: add session id

* fix: fixed incorrect taskbar icon display on linux (#783)

* fix: fixed incorrect taskbar icon display on linux

* docs: update changelog

* fix: fix data inconsistency issue on secondary pages (#784)

* chore: chat

* chore: chat

* chore: add logging message

* chore: chat

* chore: chat

* chore: add

* feat: add

* chore: chat end

* style: message width

---------

Co-authored-by: ayangweb <75017711+ayangweb@users.noreply.github.com>
Co-authored-by: medcl <m@medcl.net>
2025-07-21 21:17:20 +08:00
SteveLauC
74ed642a42 refactor: tighten up Coco servers state management (#790)
* refactor: tighten up Coco servers state management

* ignore unused warnings

* log out if the failed request has status 401
2025-07-21 20:39:16 +08:00
ayangweb
5a17173620 fix: incorrect status when installing extension (#789)
* fix: incorrect status when installing extension

* docs: update changelog
2025-07-21 18:17:30 +08:00
SteveLauC
29d14ff931 chore: remove unused type ServerTokenResponse (#788)
After this commit[1], type `ServerTokenResponse` became unused, remove
it as well.

[1]: 57ab08fb6d
2025-07-21 15:30:26 +08:00
ayangweb
ad01504766 refactor: decouple window switch services to ensure they operate independently (#786) 2025-07-20 17:26:15 +08:00
SteveLauC
57ab08fb6d chore: remove unused tauri cmd get_server_token (#787)
Found this tauri command while reading the code, then I realized that
token management logic should all be kept in the backend, there is no
need to expose it to the frontend. And indeed, searching for it in the
frontend code showed that it is not used at all.

```sh
$ cd src

$ rg get_server_token
commands/servers.ts
75:export function get_server_token(id: string): Promise<ServerTokenResponse> {
76:  return invokeWithErrorHandler(`get_server_token`, { id });
```

So remove it.
2025-07-20 17:25:32 +08:00
97 changed files with 2297 additions and 1270 deletions

View File

@@ -1,4 +1,4 @@
name: Rust Code Compile Check name: Rust Code Check
on: on:
pull_request: pull_request:
@@ -7,7 +7,7 @@ on:
- 'src-tauri/**' - 'src-tauri/**'
jobs: jobs:
compile-check: check:
strategy: strategy:
matrix: matrix:
platform: [ubuntu-latest, windows-latest, macos-latest] platform: [ubuntu-latest, windows-latest, macos-latest]
@@ -37,6 +37,13 @@ jobs:
shell: bash shell: bash
run: cargo add --path ../pizza/lib/engine --features query_string_parser,persistence run: cargo add --path ../pizza/lib/engine --features query_string_parser,persistence
- name: Format check
working-directory: src-tauri
shell: bash
run: |
rustup component add rustfmt
cargo fmt --all --check
- name: Check compilation (Without Pizza engine enabled) - name: Check compilation (Without Pizza engine enabled)
working-directory: ./src-tauri working-directory: ./src-tauri
run: cargo check run: cargo check

View File

@@ -62,6 +62,7 @@
"traptitech", "traptitech",
"unlisten", "unlisten",
"unlistener", "unlistener",
"unlisteners",
"unminimize", "unminimize",
"uuidv", "uuidv",
"VITE", "VITE",

View File

@@ -13,6 +13,20 @@ Information about release notes of Coco Server is provided here.
### 🚀 Features ### 🚀 Features
### 🐛 Bug fix
- fix: correct enter key behavior #828
### ✈️ Improvements
- chore: web component add notification component #825
## 0.7.0 (2025-07-25)
### ❌ Breaking changes
### 🚀 Features
- feat: file search using spotlight #705 - feat: file search using spotlight #705
- feat: voice input support in both search and chat modes #732 - feat: voice input support in both search and chat modes #732
- feat: text to speech now powered by LLM #750 - feat: text to speech now powered by LLM #750
@@ -30,6 +44,17 @@ Information about release notes of Coco Server is provided here.
- fix: resolved minor issues with voice playback #780 - fix: resolved minor issues with voice playback #780
- fix: fixed incorrect taskbar icon display on linux #783 - fix: fixed incorrect taskbar icon display on linux #783
- fix: fix data inconsistency issue on secondary pages #784 - fix: fix data inconsistency issue on secondary pages #784
- fix: incorrect status when installing extension #789
- fix: increase read_timeout for HTTP streaming stability #798
- fix: enter key problem #794
- fix: fix selection issue after renaming #800
- fix: fix shortcut issue in windows context menu #804
- fix: panic caused by "state() called before manage()" #806
- fix: fix multiline input issue #808
- fix: fix ctrl+k not working #815
- fix: fix update window config sync #818
- fix: fix enter key on subpages #819
- fix: panic on Ubuntu (GNOME) when opening apps #821
### ✈️ Improvements ### ✈️ Improvements
@@ -49,6 +74,9 @@ Information about release notes of Coco Server is provided here.
- refactor: do status code check before deserializing response #767 - refactor: do status code check before deserializing response #767
- style: splash adapts to the width of mobile phones #768 - style: splash adapts to the width of mobile phones #768
- chore: search-chat add language and formatUrl parameters #775 - chore: search-chat add language and formatUrl parameters #775
- chore: not request the interface if not logged in #795
- refactor: clean up unsupported characters from query string in Win Search #802
- chore: display backtrace in panic log #805
## 0.6.0 (2025-06-29) ## 0.6.0 (2025-06-29)

View File

@@ -1,7 +1,7 @@
{ {
"name": "coco", "name": "coco",
"private": true, "private": true,
"version": "0.6.0", "version": "0.7.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

84
src-tauri/Cargo.lock generated
View File

@@ -840,7 +840,7 @@ dependencies = [
[[package]] [[package]]
name = "coco" name = "coco"
version = "0.6.0" version = "0.7.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"applications", "applications",
@@ -852,6 +852,7 @@ dependencies = [
"cfg-if", "cfg-if",
"chinese-number", "chinese-number",
"chrono", "chrono",
"cocoa 0.24.1",
"derive_more 2.0.1", "derive_more 2.0.1",
"dirs 5.0.1", "dirs 5.0.1",
"enigo", "enigo",
@@ -872,6 +873,7 @@ dependencies = [
"plist", "plist",
"regex", "regex",
"reqwest", "reqwest",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"serde_plain", "serde_plain",
@@ -913,6 +915,22 @@ dependencies = [
"zip 4.0.0", "zip 4.0.0",
] ]
[[package]]
name = "cocoa"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
dependencies = [
"bitflags 1.3.2",
"block",
"cocoa-foundation 0.1.2",
"core-foundation 0.9.4",
"core-graphics 0.22.3",
"foreign-types 0.3.2",
"libc",
"objc",
]
[[package]] [[package]]
name = "cocoa" name = "cocoa"
version = "0.26.0" version = "0.26.0"
@@ -921,14 +939,28 @@ checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2"
dependencies = [ dependencies = [
"bitflags 2.9.0", "bitflags 2.9.0",
"block", "block",
"cocoa-foundation", "cocoa-foundation 0.2.0",
"core-foundation 0.10.0", "core-foundation 0.10.0",
"core-graphics", "core-graphics 0.24.0",
"foreign-types 0.5.0", "foreign-types 0.5.0",
"libc", "libc",
"objc", "objc",
] ]
[[package]]
name = "cocoa-foundation"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
dependencies = [
"bitflags 1.3.2",
"block",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"libc",
"objc",
]
[[package]] [[package]]
name = "cocoa-foundation" name = "cocoa-foundation"
version = "0.2.0" version = "0.2.0"
@@ -938,7 +970,7 @@ dependencies = [
"bitflags 2.9.0", "bitflags 2.9.0",
"block", "block",
"core-foundation 0.10.0", "core-foundation 0.10.0",
"core-graphics-types", "core-graphics-types 0.2.0",
"libc", "libc",
"objc", "objc",
] ]
@@ -1055,6 +1087,19 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.22.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"foreign-types 0.3.2",
"libc",
]
[[package]] [[package]]
name = "core-graphics" name = "core-graphics"
version = "0.24.0" version = "0.24.0"
@@ -1063,11 +1108,22 @@ checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [ dependencies = [
"bitflags 2.9.0", "bitflags 2.9.0",
"core-foundation 0.10.0", "core-foundation 0.10.0",
"core-graphics-types", "core-graphics-types 0.2.0",
"foreign-types 0.5.0", "foreign-types 0.5.0",
"libc", "libc",
] ]
[[package]]
name = "core-graphics-types"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"libc",
]
[[package]] [[package]]
name = "core-graphics-types" name = "core-graphics-types"
version = "0.2.0" version = "0.2.0"
@@ -1471,8 +1527,8 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67fd9ae1736d6ebb2e472740fbee86fb2178b8d56feb98a6751411d4c95b7e72" checksum = "67fd9ae1736d6ebb2e472740fbee86fb2178b8d56feb98a6751411d4c95b7e72"
dependencies = [ dependencies = [
"cocoa", "cocoa 0.26.0",
"core-graphics", "core-graphics 0.24.0",
"dunce", "dunce",
"gdk", "gdk",
"gdkx11", "gdkx11",
@@ -1561,7 +1617,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6" checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6"
dependencies = [ dependencies = [
"core-foundation 0.10.0", "core-foundation 0.10.0",
"core-graphics", "core-graphics 0.24.0",
"foreign-types-shared 0.3.1", "foreign-types-shared 0.3.1",
"libc", "libc",
"log", "log",
@@ -2713,7 +2769,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core 0.59.0", "windows-core 0.61.2",
] ]
[[package]] [[package]]
@@ -5720,7 +5776,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"cfg_aliases", "cfg_aliases",
"core-graphics", "core-graphics 0.24.0",
"foreign-types 0.5.0", "foreign-types 0.5.0",
"js-sys", "js-sys",
"log", "log",
@@ -5888,7 +5944,7 @@ dependencies = [
"ntapi", "ntapi",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-io-kit", "objc2-io-kit",
"windows 0.59.0", "windows 0.61.3",
] ]
[[package]] [[package]]
@@ -5946,7 +6002,7 @@ checksum = "1e59c1f38e657351a2e822eadf40d6a2ad4627b9c25557bc1180ec1b3295ef82"
dependencies = [ dependencies = [
"bitflags 2.9.0", "bitflags 2.9.0",
"core-foundation 0.10.0", "core-foundation 0.10.0",
"core-graphics", "core-graphics 0.24.0",
"crossbeam-channel", "crossbeam-channel",
"dispatch", "dispatch",
"dlopen2", "dlopen2",
@@ -6144,9 +6200,9 @@ source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#d4b9df797959f8fa
dependencies = [ dependencies = [
"bitflags 2.9.0", "bitflags 2.9.0",
"block", "block",
"cocoa", "cocoa 0.26.0",
"core-foundation 0.10.0", "core-foundation 0.10.0",
"core-graphics", "core-graphics 0.24.0",
"objc", "objc",
"objc-foundation", "objc-foundation",
"objc_id", "objc_id",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "coco" name = "coco"
version = "0.6.0" version = "0.7.1"
description = "Search, connect, collaborate all in one place." description = "Search, connect, collaborate all in one place."
authors = ["INFINI Labs"] authors = ["INFINI Labs"]
edition = "2024" edition = "2024"
@@ -109,6 +109,7 @@ sysinfo = "0.35.2"
[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" }
cocoa = "0.24"
[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"] }
@@ -127,6 +128,8 @@ strip = true # Ensures debug symbols are removed.
tauri-plugin-autostart = "^2.2" tauri-plugin-autostart = "^2.2"
tauri-plugin-global-shortcut = "2" tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = { git = "https://github.com/infinilabs/plugins-workspace", branch = "v2" } tauri-plugin-updater = { git = "https://github.com/infinilabs/plugins-workspace", branch = "v2" }
# This should be compatible with the semver used by `tauri-plugin-updater`
semver = { version = "1", features = ["serde"] }
[target."cfg(target_os = \"windows\")".dependencies] [target."cfg(target_os = \"windows\")".dependencies]
enigo="0.3" enigo="0.3"

View File

@@ -99,10 +99,12 @@ pub async fn cancel_session_chat<R: Runtime>(
_app_handle: AppHandle<R>, _app_handle: AppHandle<R>,
server_id: String, server_id: String,
session_id: String, session_id: String,
query_params: Option<HashMap<String, Value>>,
) -> Result<String, String> { ) -> Result<String, String> {
let path = format!("/chat/{}/_cancel", session_id); let path = format!("/chat/{}/_cancel", session_id);
let query_params = convert_query_params_to_strings(query_params);
let response = HttpClient::post(&server_id, path.as_str(), None, None) let response = HttpClient::post(&server_id, path.as_str(), query_params, None)
.await .await
.map_err(|e| format!("Error cancel session: {}", e))?; .map_err(|e| format!("Error cancel session: {}", e))?;
@@ -163,6 +165,7 @@ pub async fn chat_create<R: Runtime>(
server_id: String, server_id: String,
message: String, message: String,
query_params: Option<HashMap<String, Value>>, query_params: Option<HashMap<String, Value>>,
client_id: String,
) -> Result<(), String> { ) -> Result<(), String> {
let body = if !message.is_empty() { let body = if !message.is_empty() {
let message = ChatRequestMessage { let message = ChatRequestMessage {
@@ -202,10 +205,12 @@ pub async fn chat_create<R: Runtime>(
); );
let mut lines = tokio::io::BufReader::new(reader).lines(); let mut lines = tokio::io::BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await { log::info!("client_id_create: {}", &client_id);
log::debug!("Received chat stream line: {}", &line);
if let Err(err) = app_handle.emit("chat-create-stream", line) { while let Ok(Some(line)) = lines.next_line().await {
log::info!("Received chat stream line: {}", &line);
if let Err(err) = app_handle.emit(&client_id, line) {
log::error!("Emit failed: {:?}", err); log::error!("Emit failed: {:?}", err);
print!("Error sending message: {:?}", err); print!("Error sending message: {:?}", err);
@@ -255,6 +260,7 @@ pub async fn chat_chat<R: Runtime>(
session_id: String, session_id: String,
message: String, message: String,
query_params: Option<HashMap<String, Value>>, //search,deep_thinking query_params: Option<HashMap<String, Value>>, //search,deep_thinking
client_id: String,
) -> Result<(), String> { ) -> Result<(), String> {
let body = if !message.is_empty() { let body = if !message.is_empty() {
let message = ChatRequestMessage { let message = ChatRequestMessage {
@@ -295,11 +301,18 @@ pub async fn chat_chat<R: Runtime>(
stream.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)), stream.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)),
); );
let mut lines = tokio::io::BufReader::new(reader).lines(); let mut lines = tokio::io::BufReader::new(reader).lines();
let mut first_log = true;
log::info!("client_id: {}", &client_id);
while let Ok(Some(line)) = lines.next_line().await { while let Ok(Some(line)) = lines.next_line().await {
log::debug!("Received chat stream line: {}", &line); log::info!("Received chat stream line: {}", &line);
if first_log {
log::info!("first stream line: {}", &line);
first_log = false;
}
if let Err(err) = app_handle.emit("chat-create-stream", line) { if let Err(err) = app_handle.emit(&client_id, line) {
log::error!("Emit failed: {:?}", err); log::error!("Emit failed: {:?}", err);
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err)); let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
} }

View File

@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug,Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Connector { pub struct Connector {
pub id: String, pub id: String,
pub created: Option<String>, pub created: Option<String>,
@@ -13,7 +13,7 @@ pub struct Connector {
pub url: Option<String>, pub url: Option<String>,
pub assets: Option<ConnectorAssets>, pub assets: Option<ConnectorAssets>,
} }
#[derive(Debug,Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectorAssets { pub struct ConnectorAssets {
pub icons: Option<std::collections::HashMap<String, String>>, pub icons: Option<std::collections::HashMap<String, String>>,
} }

View File

@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use tauri::AppHandle;
use tauri::Runtime;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RichLabel { pub struct RichLabel {
@@ -62,23 +64,21 @@ impl OnOpened {
} }
#[tauri::command] #[tauri::command]
pub(crate) async fn open(on_opened: OnOpened) -> Result<(), String> { pub(crate) async fn open<R: Runtime>(
tauri_app_handle: AppHandle<R>,
on_opened: OnOpened,
) -> Result<(), String> {
log::debug!("open({})", on_opened.url()); log::debug!("open({})", on_opened.url());
use crate::util::open as homemade_tauri_shell_open; use crate::util::open as homemade_tauri_shell_open;
use crate::GLOBAL_TAURI_APP_HANDLE;
use std::process::Command; use std::process::Command;
let global_tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
match on_opened { match on_opened {
OnOpened::Application { app_path } => { OnOpened::Application { app_path } => {
homemade_tauri_shell_open(global_tauri_app_handle.clone(), app_path).await? homemade_tauri_shell_open(tauri_app_handle.clone(), app_path).await?
} }
OnOpened::Document { url } => { OnOpened::Document { url } => {
homemade_tauri_shell_open(global_tauri_app_handle.clone(), url).await? homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
} }
OnOpened::Command { action } => { OnOpened::Command { action } => {
let mut cmd = Command::new(action.exec); let mut cmd = Command::new(action.exec);

View File

@@ -1,8 +1,22 @@
use serde::{Deserialize, Serialize}; use reqwest::StatusCode;
use serde::{Deserialize, Serialize, Serializer};
use thiserror::Error; use thiserror::Error;
fn serialize_optional_status_code<S>(
status_code: &Option<StatusCode>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match status_code {
Some(code) => serializer.serialize_str(&format!("{:?}", code)),
None => serializer.serialize_none(),
}
}
#[allow(unused)]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct ErrorCause { pub struct ErrorCause {
#[serde(default)] #[serde(default)]
pub r#type: Option<String>, pub r#type: Option<String>,
@@ -11,7 +25,7 @@ pub struct ErrorCause {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)] #[allow(unused)]
pub struct ErrorDetail { pub struct ErrorDetail {
#[serde(default)] #[serde(default)]
pub root_cause: Option<Vec<ErrorCause>>, pub root_cause: Option<Vec<ErrorCause>>,
@@ -24,18 +38,22 @@ pub struct ErrorDetail {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct ErrorResponse { pub struct ErrorResponse {
#[serde(default)] #[serde(default)]
pub error: Option<ErrorDetail>, pub error: Option<ErrorDetail>,
#[serde(default)] #[serde(default)]
#[allow(unused)]
pub status: Option<u16>, pub status: Option<u16>,
} }
#[derive(Debug, Error, Serialize)] #[derive(Debug, Error, Serialize)]
pub enum SearchError { pub enum SearchError {
#[error("HttpError: {0}")] #[error("HttpError: status code [{status_code:?}], msg [{msg}]")]
HttpError(String), HttpError {
#[serde(serialize_with = "serialize_optional_status_code")]
status_code: Option<StatusCode>,
msg: String,
},
#[error("ParseError: {0}")] #[error("ParseError: {0}")]
ParseError(String), ParseError(String),
@@ -43,12 +61,7 @@ pub enum SearchError {
#[error("Timeout occurred")] #[error("Timeout occurred")]
Timeout, Timeout,
#[error("UnknownError: {0}")]
#[allow(dead_code)]
Unknown(String),
#[error("InternalError: {0}")] #[error("InternalError: {0}")]
#[allow(dead_code)]
InternalError(String), InternalError(String),
} }
@@ -59,7 +72,10 @@ impl From<reqwest::Error> for SearchError {
} else if err.is_decode() { } else if err.is_decode() {
SearchError::ParseError(err.to_string()) SearchError::ParseError(err.to_string())
} else { } else {
SearchError::HttpError(err.to_string()) SearchError::HttpError {
status_code: err.status(),
msg: err.to_string(),
}
} }
} }
} }

View File

@@ -38,7 +38,6 @@ pub async fn get_response_body_text(response: Response) -> Result<String, String
return Err(fallback_error); return Err(fallback_error);
} }
match serde_json::from_str::<common::error::ErrorResponse>(&body) { match serde_json::from_str::<common::error::ErrorResponse>(&body) {
Ok(parsed_error) => { Ok(parsed_error) => {
dbg!(&parsed_error); dbg!(&parsed_error);
@@ -57,7 +56,6 @@ pub async fn get_response_body_text(response: Response) -> Result<String, String
} }
} }
pub fn convert_query_params_to_strings( pub fn convert_query_params_to_strings(
query_params: Option<HashMap<String, JsonValue>>, query_params: Option<HashMap<String, JsonValue>>,
) -> Option<Vec<String>> { ) -> Option<Vec<String>> {
@@ -68,10 +66,7 @@ pub fn convert_query_params_to_strings(
JsonValue::Number(n) => Some(format!("{}={}", k, n)), JsonValue::Number(n) => Some(format!("{}={}", k, n)),
JsonValue::Bool(b) => Some(format!("{}={}", k, b)), JsonValue::Bool(b) => Some(format!("{}={}", k, b)),
_ => { _ => {
eprintln!( eprintln!("Skipping unsupported query value for key '{}': {:?}", k, v);
"Skipping unsupported query value for key '{}': {:?}",
k, v
);
None None
} }
}) })

View File

@@ -83,20 +83,6 @@ where
.collect()) .collect())
} }
#[allow(dead_code)]
pub async fn parse_search_results_with_score<T>(
response: Response,
) -> Result<Vec<(T, Option<f64>)>, Box<dyn Error>>
where
T: for<'de> Deserialize<'de> + std::fmt::Debug,
{
Ok(parse_search_hits(response)
.await?
.into_iter()
.map(|hit| (hit._source, hit._score))
.collect())
}
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct SearchQuery { pub struct SearchQuery {
pub from: u64, pub from: u64,

View File

@@ -50,9 +50,17 @@ pub struct Server {
pub updated: String, pub updated: String,
#[serde(default = "default_enabled_type")] #[serde(default = "default_enabled_type")]
pub enabled: bool, pub enabled: bool,
/// Public Coco servers can be used without signing in.
#[serde(default = "default_bool_type")] #[serde(default = "default_bool_type")]
pub public: bool, pub public: bool,
/// A coco server is available if:
///
/// 1. It is still online, we check this via the `GET /base_url/provider/_info`
/// interface.
/// 2. A user is logged in to this Coco server, i.e., a token is stored in the
/// `SERVER_TOKEN_LIST_CACHE`.
/// For public Coco servers, requirement 2 is not needed.
#[serde(default = "default_available_type")] #[serde(default = "default_available_type")]
pub available: bool, pub available: bool,
@@ -84,7 +92,10 @@ pub struct ServerAccessToken {
#[serde(default = "default_empty_string")] // Custom default function for empty string #[serde(default = "default_empty_string")] // Custom default function for empty string
pub id: String, pub id: String,
pub access_token: String, pub access_token: String,
pub expired_at: u32, //unix timestamp in seconds /// Unix timestamp in seconds
///
/// Currently, this is UNUSED.
pub expired_at: u32,
} }
impl ServerAccessToken { impl ServerAccessToken {

View File

@@ -2,10 +2,15 @@ use crate::common::error::SearchError;
use crate::common::search::SearchQuery; use crate::common::search::SearchQuery;
use crate::common::search::{QueryResponse, QuerySource}; use crate::common::search::{QueryResponse, QuerySource};
use async_trait::async_trait; use async_trait::async_trait;
use tauri::AppHandle;
#[async_trait] #[async_trait]
pub trait SearchSource: Send + Sync { pub trait SearchSource: Send + Sync {
fn get_type(&self) -> QuerySource; fn get_type(&self) -> QuerySource;
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>; async fn search(
&self,
tauri_app_handle: AppHandle,
query: SearchQuery,
) -> Result<QueryResponse, SearchError>;
} }

View File

@@ -1,8 +1,9 @@
use super::super::Extension;
use super::super::pizza_engine_runtime::RUNTIME_TX;
use super::super::pizza_engine_runtime::SearchSourceState; use super::super::pizza_engine_runtime::SearchSourceState;
use super::super::pizza_engine_runtime::Task; use super::super::pizza_engine_runtime::Task;
use super::super::pizza_engine_runtime::RUNTIME_TX;
use super::super::Extension;
use super::AppMetadata; use super::AppMetadata;
use crate::GLOBAL_TAURI_APP_HANDLE;
use crate::common::document::{DataSourceReference, Document, OnOpened}; use crate::common::document::{DataSourceReference, Document, OnOpened};
use crate::common::error::SearchError; use crate::common::error::SearchError;
use crate::common::search::{QueryResponse, QuerySource, SearchQuery}; use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
@@ -10,7 +11,6 @@ use crate::common::traits::SearchSource;
use crate::extension::ExtensionType; use crate::extension::ExtensionType;
use crate::extension::LOCAL_QUERY_SOURCE_TYPE; use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
use crate::util::open; use crate::util::open;
use crate::GLOBAL_TAURI_APP_HANDLE;
use applications::{App, AppTrait}; use applications::{App, AppTrait};
use async_trait::async_trait; use async_trait::async_trait;
use log::{error, warn}; use log::{error, warn};
@@ -23,12 +23,12 @@ use pizza_engine::error::PizzaEngineError;
use pizza_engine::search::{OriginalQuery, QueryContext, SearchResult, Searcher}; use pizza_engine::search::{OriginalQuery, QueryContext, SearchResult, Searcher};
use pizza_engine::store::{DiskStore, DiskStoreSnapshot}; use pizza_engine::store::{DiskStore, DiskStoreSnapshot};
use pizza_engine::writer::Writer; use pizza_engine::writer::Writer;
use pizza_engine::{doc, Engine, EngineBuilder}; use pizza_engine::{Engine, EngineBuilder, doc};
use serde_json::Value as Json; use serde_json::Value as Json;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use tauri::{async_runtime, AppHandle, Manager, Runtime}; use tauri::{AppHandle, Manager, Runtime, async_runtime};
use tauri_plugin_fs_pro::{icon, metadata, name, IconOptions}; use tauri_plugin_fs_pro::{IconOptions, icon, metadata, name};
use tauri_plugin_global_shortcut::GlobalShortcutExt; use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::Shortcut; use tauri_plugin_global_shortcut::Shortcut;
use tauri_plugin_global_shortcut::ShortcutEvent; use tauri_plugin_global_shortcut::ShortcutEvent;
@@ -246,8 +246,8 @@ async fn index_applications_if_not_indexed<R: Runtime>(
if !index_exists { if !index_exists {
let search_path = { let search_path = {
let disabled_app_list_and_search_path_store = tauri_app_handle let disabled_app_list_and_search_path_store =
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)?; tauri_app_handle.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)?;
let search_path_json = disabled_app_list_and_search_path_store let search_path_json = disabled_app_list_and_search_path_store
.get(TAURI_STORE_KEY_SEARCH_PATH) .get(TAURI_STORE_KEY_SEARCH_PATH)
.unwrap_or_else(|| { .unwrap_or_else(|| {
@@ -294,7 +294,8 @@ async fn index_applications_if_not_indexed<R: Runtime>(
// We don't error out because one failure won't break the whole thing // We don't error out because one failure won't break the whole thing
if let Err(e) = writer.create_document(document).await { if let Err(e) = writer.create_document(document).await {
warn!( warn!(
"failed to index application [app name: '{}', app path: '{}'] due to error [{}]", app_name, app_path, e "failed to index application [app name: '{}', app path: '{}'] due to error [{}]",
app_name, app_path, e
) )
} }
} }
@@ -401,7 +402,9 @@ impl<R: Runtime> Task for SearchApplicationsTask<R> {
let rx_dropped_error = callback.send(Ok(empty_hits)).is_err(); let rx_dropped_error = callback.send(Ok(empty_hits)).is_err();
if rx_dropped_error { if rx_dropped_error {
warn!("failed to send local app search result back because the corresponding channel receiver end has been unexpected dropped, which could happen due to a low query timeout") warn!(
"failed to send local app search result back because the corresponding channel receiver end has been unexpected dropped, which could happen due to a low query timeout"
)
} }
return; return;
@@ -422,7 +425,9 @@ impl<R: Runtime> Task for SearchApplicationsTask<R> {
// It will be passed to Pizza like "Google\nChrome". Using Display impl would result // It will be passed to Pizza like "Google\nChrome". Using Display impl would result
// in an invalid query DSL and serde will complain. // in an invalid query DSL and serde will complain.
let dsl = format!( let dsl = format!(
"{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": {:?} }} }} ] }} }} }}", self.query_string, self.query_string); "{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": {:?} }} }} ] }} }} }}",
self.query_string, self.query_string
);
let state = state let state = state
.as_mut_any() .as_mut_any()
@@ -453,7 +458,9 @@ impl<R: Runtime> Task for SearchApplicationsTask<R> {
let rx_dropped_error = callback.send(Ok(search_result)).is_err(); let rx_dropped_error = callback.send(Ok(search_result)).is_err();
if rx_dropped_error { if rx_dropped_error {
warn!("failed to send local app search result back because the corresponding channel receiver end has been unexpected dropped, which could happen due to a low query timeout") warn!(
"failed to send local app search result back because the corresponding channel receiver end has been unexpected dropped, which could happen due to a low query timeout"
)
} }
} }
} }
@@ -536,7 +543,6 @@ impl ApplicationSearchSource {
.set(TAURI_STORE_KEY_SEARCH_PATH, default_search_path); .set(TAURI_STORE_KEY_SEARCH_PATH, default_search_path);
} }
let (tx, rx) = tokio::sync::oneshot::channel(); let (tx, rx) = tokio::sync::oneshot::channel();
let index_applications_task = IndexAllApplicationsTask { let index_applications_task = IndexAllApplicationsTask {
tauri_app_handle: app_handle.clone(), tauri_app_handle: app_handle.clone(),
@@ -557,7 +563,6 @@ impl ApplicationSearchSource {
) )
} }
Ok(()) Ok(())
} }
} }
@@ -575,7 +580,11 @@ impl SearchSource for ApplicationSearchSource {
} }
} }
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> { async fn search(
&self,
_tauri_app_handle: AppHandle,
query: SearchQuery,
) -> Result<QueryResponse, SearchError> {
let query_string = query let query_string = query
.query_strings .query_strings
.get("query") .get("query")
@@ -833,7 +842,9 @@ pub fn unregister_app_hotkey<R: Runtime>(
.global_shortcut() .global_shortcut()
.is_registered(hotkey.as_str()) .is_registered(hotkey.as_str())
{ {
panic!("inconsistent state, tauri store a hotkey is stored in the tauri store but it is not registered"); panic!(
"inconsistent state, tauri store a hotkey is stored in the tauri store but it is not registered"
);
} }
tauri_app_handle tauri_app_handle

View File

@@ -32,7 +32,11 @@ impl SearchSource for ApplicationSearchSource {
} }
} }
async fn search(&self, _query: SearchQuery) -> Result<QueryResponse, SearchError> { async fn search(
&self,
_tauri_app_handle: AppHandle,
_query: SearchQuery,
) -> Result<QueryResponse, SearchError> {
Ok(QueryResponse { Ok(QueryResponse {
source: self.get_type(), source: self.get_type(),
hits: Vec::new(), hits: Vec::new(),

View File

@@ -10,6 +10,7 @@ use chinese_number::{ChineseCase, ChineseCountMethod, ChineseVariant, NumberToCh
use num2words::Num2Words; use num2words::Num2Words;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use tauri::AppHandle;
pub(crate) const DATA_SOURCE_ID: &str = "Calculator"; pub(crate) const DATA_SOURCE_ID: &str = "Calculator";
@@ -120,7 +121,11 @@ impl SearchSource for CalculatorSource {
} }
} }
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> { async fn search(
&self,
_tauri_app_handle: AppHandle,
query: SearchQuery,
) -> Result<QueryResponse, SearchError> {
let Some(query_string) = query.query_strings.get("query") else { let Some(query_string) = query.query_strings.get("query") else {
return Ok(QueryResponse { return Ok(QueryResponse {
source: self.get_type(), source: self.get_type(),

View File

@@ -4,6 +4,8 @@ use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use serde_json::Value; use serde_json::Value;
use std::sync::LazyLock; use std::sync::LazyLock;
use tauri::AppHandle;
use tauri::Runtime;
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
// Tauri store keys for file system configuration // Tauri store keys for file system configuration
@@ -52,11 +54,7 @@ impl Default for FileSearchConfig {
} }
impl FileSearchConfig { impl FileSearchConfig {
pub(crate) fn get() -> Self { pub(crate) fn get<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Self {
let tauri_app_handle = crate::GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let store = tauri_app_handle let store = tauri_app_handle
.store(TAURI_STORE_FILE_SYSTEM_CONFIG) .store(TAURI_STORE_FILE_SYSTEM_CONFIG)
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
@@ -187,16 +185,17 @@ impl FileSearchConfig {
// Tauri commands for managing file system configuration // Tauri commands for managing file system configuration
#[tauri::command] #[tauri::command]
pub async fn get_file_system_config() -> FileSearchConfig { pub async fn get_file_system_config<R: Runtime>(
FileSearchConfig::get() tauri_app_handle: AppHandle<R>,
) -> FileSearchConfig {
FileSearchConfig::get(&tauri_app_handle)
} }
#[tauri::command] #[tauri::command]
pub async fn set_file_system_config(config: FileSearchConfig) -> Result<(), String> { pub async fn set_file_system_config<R: Runtime>(
let tauri_app_handle = crate::GLOBAL_TAURI_APP_HANDLE tauri_app_handle: AppHandle<R>,
.get() config: FileSearchConfig,
.expect("global tauri app handle not set"); ) -> Result<(), String> {
let store = tauri_app_handle let store = tauri_app_handle
.store(TAURI_STORE_FILE_SYSTEM_CONFIG) .store(TAURI_STORE_FILE_SYSTEM_CONFIG)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;

View File

@@ -1,11 +1,9 @@
use super::super::EXTENSION_ID;
use super::super::config::FileSearchConfig; use super::super::config::FileSearchConfig;
use super::super::config::SearchBy; use super::super::config::SearchBy;
use super::super::EXTENSION_ID; use crate::common::document::{DataSourceReference, Document};
use crate::common::{
document::{DataSourceReference, Document},
};
use crate::extension::OnOpened;
use crate::extension::LOCAL_QUERY_SOURCE_TYPE; use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
use crate::extension::OnOpened;
use crate::util::file::get_file_icon; use crate::util::file::get_file_icon;
use futures::stream::Stream; use futures::stream::Stream;
use futures::stream::StreamExt; use futures::stream::StreamExt;
@@ -32,8 +30,7 @@ pub(crate) async fn hits(
// Convert results to documents // Convert results to documents
let mut hits: Vec<(Document, f64)> = Vec::new(); let mut hits: Vec<(Document, f64)> = Vec::new();
while let Some(res_file_path) = iter.next().await { while let Some(res_file_path) = iter.next().await {
let file_path = let file_path = res_file_path.map_err(|io_err| io_err.to_string())?;
res_file_path.map_err(|io_err| io_err.to_string())?;
let icon = get_file_icon(file_path.clone()).await; let icon = get_file_icon(file_path.clone()).await;
let file_path_of_type_path = camino::Utf8Path::new(&file_path); let file_path_of_type_path = camino::Utf8Path::new(&file_path);

View File

@@ -3,25 +3,25 @@
//! https://github.com/IRONAGE-Park/rag-sample/blob/3f0ad8c8012026cd3a7e453d08f041609426cb91/src/native/windows.rs //! https://github.com/IRONAGE-Park/rag-sample/blob/3f0ad8c8012026cd3a7e453d08f041609426cb91/src/native/windows.rs
//! is the starting point of this implementation. //! is the starting point of this implementation.
use super::super::EXTENSION_ID;
use super::super::config::FileSearchConfig; use super::super::config::FileSearchConfig;
use super::super::config::SearchBy; use super::super::config::SearchBy;
use super::super::EXTENSION_ID;
use crate::common::document::{DataSourceReference, Document}; use crate::common::document::{DataSourceReference, Document};
use crate::extension::OnOpened;
use crate::extension::LOCAL_QUERY_SOURCE_TYPE; use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
use crate::extension::OnOpened;
use crate::util::file::get_file_icon; use crate::util::file::get_file_icon;
use windows::{ use windows::{
core::{w, IUnknown, Interface, GUID, PWSTR},
Win32::System::{ Win32::System::{
Com::{CoCreateInstance, CLSCTX_INPROC_SERVER}, Com::{CLSCTX_INPROC_SERVER, CoCreateInstance},
Ole::{OleInitialize, OleUninitialize}, Ole::{OleInitialize, OleUninitialize},
Search::{ Search::{
IAccessor, ICommand, ICommandText, IDBCreateCommand, IDBCreateSession, IDBInitialize, DB_NULL_HCHAPTER, DBACCESSOR_ROWDATA, DBBINDING, DBMEMOWNER_CLIENTOWNED,
IDataInitialize, IRowset, DBACCESSOR_ROWDATA, DBBINDING, DBMEMOWNER_CLIENTOWNED, DBPARAMIO_NOTPARAM, DBPART_VALUE, DBTYPE_WSTR, HACCESSOR, IAccessor, ICommand,
DBPARAMIO_NOTPARAM, DBPART_VALUE, DBTYPE_WSTR, DB_NULL_HCHAPTER, HACCESSOR, ICommandText, IDBCreateCommand, IDBCreateSession, IDBInitialize, IDataInitialize,
MSDAINITIALIZE, IRowset, MSDAINITIALIZE,
}, },
}, },
core::{GUID, IUnknown, Interface, PWSTR, w},
}; };
/// Owned version of `PWSTR` that holds the heap memory. /// Owned version of `PWSTR` that holds the heap memory.
@@ -50,6 +50,28 @@ impl<S: AsRef<str> + ?Sized> From<&S> for PwStrOwned {
} }
} }
/// Helper function to replace unsupported characters with whitespace.
///
/// Windows search will error out if it encounters these characters.
///
/// The complete list of unsupported characters is unknown and we don't know how
/// to escape them, so let's replace them.
fn query_string_cleanup(old: &str) -> String {
const UNSUPPORTED_CHAR: [char; 2] = ['\'', '\n'];
// Using len in bytes is ok
let mut chars = Vec::with_capacity(old.len());
for char in old.chars() {
if UNSUPPORTED_CHAR.contains(&char) {
chars.push(' ');
} else {
chars.push(char);
}
}
chars.into_iter().collect()
}
/// Helper function to construct the Windows Search SQL. /// Helper function to construct the Windows Search SQL.
/// ///
/// Paging is not natively supported by windows Search SQL, it only supports `size` /// Paging is not natively supported by windows Search SQL, it only supports `size`
@@ -69,11 +91,8 @@ fn query_sql(query_string: &str, from: usize, size: usize, config: &FileSearchCo
"SELECT TOP {} System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE", "SELECT TOP {} System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE",
top_n top_n
); );
// Use debug print to escape the newline character, which cannot be handled by Windows Search.
let query_string_debug_print = format!("{:?}", query_string); let query_string = query_string_cleanup(query_string);
// Debug print will be double quoted, we need to trim them.
let query_string_debug_print_len = query_string_debug_print.len();
let query_string = &query_string_debug_print[1..(query_string_debug_print_len - 1)];
let search_by_predicate = match config.search_by { let search_by_predicate = match config.search_by {
SearchBy::Name => { SearchBy::Name => {
@@ -454,7 +473,7 @@ pub(crate) async fn hits(
// //
// I have no idea about the underlying root cause // I have no idea about the underlying root cause
#[cfg(all(test, not(ci)))] #[cfg(all(test, not(ci)))]
mod test { mod test_windows_search {
use super::*; use super::*;
/// Helper function for ensuring `sql` is valid SQL by actually executing it. /// Helper function for ensuring `sql` is valid SQL by actually executing it.
@@ -491,7 +510,10 @@ mod test {
}; };
let sql = query_sql("coco", 0, 10, &config); let sql = query_sql("coco", 0, 10, &config);
assert_eq!(sql, "SELECT TOP 10 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE ((System.FileName LIKE '%coco%') OR CONTAINS('coco'))"); assert_eq!(
sql,
"SELECT TOP 10 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE ((System.FileName LIKE '%coco%') OR CONTAINS('coco'))"
);
ensure_it_is_valid_sql(&sql); ensure_it_is_valid_sql(&sql);
} }
@@ -505,7 +527,10 @@ mod test {
}; };
let sql = query_sql("coco", 0, 10, &config); let sql = query_sql("coco", 0, 10, &config);
assert_eq!(sql, "SELECT TOP 10 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%coco%') AND (SCOPE = 'file:C:/Users/')"); assert_eq!(
sql,
"SELECT TOP 10 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%coco%') AND (SCOPE = 'file:C:/Users/')"
);
ensure_it_is_valid_sql(&sql); ensure_it_is_valid_sql(&sql);
} }
@@ -523,7 +548,10 @@ mod test {
}; };
let sql = query_sql("test", 0, 5, &config); let sql = query_sql("test", 0, 5, &config);
assert_eq!(sql, "SELECT TOP 5 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%test%') AND (SCOPE = 'file:C:/Users/' OR SCOPE = 'file:D:/Projects/' OR SCOPE = 'file:E:/Documents/')"); assert_eq!(
sql,
"SELECT TOP 5 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%test%') AND (SCOPE = 'file:C:/Users/' OR SCOPE = 'file:D:/Projects/' OR SCOPE = 'file:E:/Documents/')"
);
ensure_it_is_valid_sql(&sql); ensure_it_is_valid_sql(&sql);
} }
@@ -537,7 +565,10 @@ mod test {
}; };
let sql = query_sql("file", 0, 20, &config); let sql = query_sql("file", 0, 20, &config);
assert_eq!(sql, "SELECT TOP 20 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%file%') AND ((NOT SCOPE = 'file:C:/Windows/'))"); assert_eq!(
sql,
"SELECT TOP 20 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%file%') AND ((NOT SCOPE = 'file:C:/Windows/'))"
);
ensure_it_is_valid_sql(&sql); ensure_it_is_valid_sql(&sql);
} }
@@ -551,7 +582,10 @@ mod test {
}; };
let sql = query_sql("data", 5, 15, &config); let sql = query_sql("data", 5, 15, &config);
assert_eq!(sql, "SELECT TOP 20 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%data%') AND ((NOT SCOPE = 'file:C:/Windows/') AND (NOT SCOPE = 'file:C:/System/') AND (NOT SCOPE = 'file:C:/Temp/'))"); assert_eq!(
sql,
"SELECT TOP 20 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%data%') AND ((NOT SCOPE = 'file:C:/Windows/') AND (NOT SCOPE = 'file:C:/System/') AND (NOT SCOPE = 'file:C:/Temp/'))"
);
ensure_it_is_valid_sql(&sql); ensure_it_is_valid_sql(&sql);
} }
@@ -565,7 +599,10 @@ mod test {
}; };
let sql = query_sql("readme", 0, 10, &config); let sql = query_sql("readme", 0, 10, &config);
assert_eq!(sql, "SELECT TOP 10 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%readme%') AND (System.FileExtension = '.txt')"); assert_eq!(
sql,
"SELECT TOP 10 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%readme%') AND (System.FileExtension = '.txt')"
);
ensure_it_is_valid_sql(&sql); ensure_it_is_valid_sql(&sql);
} }
@@ -579,7 +616,10 @@ mod test {
}; };
let sql = query_sql("config", 0, 50, &config); let sql = query_sql("config", 0, 50, &config);
assert_eq!(sql, "SELECT TOP 50 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%config%') AND (System.FileExtension = '.rs' OR System.FileExtension = '.toml' OR System.FileExtension = '.md' OR System.FileExtension = '.json')"); assert_eq!(
sql,
"SELECT TOP 50 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%config%') AND (System.FileExtension = '.rs' OR System.FileExtension = '.toml' OR System.FileExtension = '.md' OR System.FileExtension = '.json')"
);
ensure_it_is_valid_sql(&sql); ensure_it_is_valid_sql(&sql);
} }
@@ -593,7 +633,10 @@ mod test {
}; };
let sql = query_sql("main", 10, 25, &config); let sql = query_sql("main", 10, 25, &config);
assert_eq!(sql, "SELECT TOP 35 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%main%') AND (SCOPE = 'file:C:/Projects/' OR SCOPE = 'file:D:/Code/') AND ((NOT SCOPE = 'file:C:/Projects/temp/')) AND (System.FileExtension = '.rs' OR System.FileExtension = '.ts')"); assert_eq!(
sql,
"SELECT TOP 35 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%main%') AND (SCOPE = 'file:C:/Projects/' OR SCOPE = 'file:D:/Code/') AND ((NOT SCOPE = 'file:C:/Projects/temp/')) AND (System.FileExtension = '.rs' OR System.FileExtension = '.ts')"
);
ensure_it_is_valid_sql(&sql); ensure_it_is_valid_sql(&sql);
} }
@@ -607,7 +650,10 @@ mod test {
}; };
let sql = query_sql("hello-world", 0, 10, &config); let sql = query_sql("hello-world", 0, 10, &config);
assert_eq!(sql, "SELECT TOP 10 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%hello-world%') AND (SCOPE = 'file:C:/Users/John Doe/') AND (System.FileExtension = '.c++')"); assert_eq!(
sql,
"SELECT TOP 10 System.ItemUrl, System.Search.Rank FROM SystemIndex WHERE (System.FileName LIKE '%hello-world%') AND (SCOPE = 'file:C:/Users/John Doe/') AND (System.FileExtension = '.c++')"
);
ensure_it_is_valid_sql(&sql); ensure_it_is_valid_sql(&sql);
} }
@@ -628,3 +674,78 @@ mod test {
ensure_it_is_valid_sql(&sql); ensure_it_is_valid_sql(&sql);
} }
} }
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_query_string_cleanup_no_unsupported_chars() {
let input = "hello world";
let result = query_string_cleanup(input);
assert_eq!(result, input);
}
#[test]
fn test_query_string_cleanup_single_quote() {
let input = "don't worry";
let result = query_string_cleanup(input);
assert_eq!(result, "don t worry");
}
#[test]
fn test_query_string_cleanup_newline() {
let input = "line1\nline2";
let result = query_string_cleanup(input);
assert_eq!(result, "line1 line2");
}
#[test]
fn test_query_string_cleanup_both_unsupported_chars() {
let input = "don't\nworry";
let result = query_string_cleanup(input);
assert_eq!(result, "don t worry");
}
#[test]
fn test_query_string_cleanup_multiple_single_quotes() {
let input = "it's a 'test' string";
let result = query_string_cleanup(input);
assert_eq!(result, "it s a test string");
}
#[test]
fn test_query_string_cleanup_multiple_newlines() {
let input = "line1\n\nline2\nline3";
let result = query_string_cleanup(input);
assert_eq!(result, "line1 line2 line3");
}
#[test]
fn test_query_string_cleanup_empty_string() {
let input = "";
let result = query_string_cleanup(input);
assert_eq!(result, input);
}
#[test]
fn test_query_string_cleanup_only_unsupported_chars() {
let input = "'\n'";
let result = query_string_cleanup(input);
assert_eq!(result, " ");
}
#[test]
fn test_query_string_cleanup_unicode_characters() {
let input = "héllo wörld's\nfile";
let result = query_string_cleanup(input);
assert_eq!(result, "héllo wörld s file");
}
#[test]
fn test_query_string_cleanup_special_chars_preserved() {
let input = "test@file#name$with%symbols";
let result = query_string_cleanup(input);
assert_eq!(result, input);
}
}

View File

@@ -10,6 +10,7 @@ use crate::common::{
use async_trait::async_trait; use async_trait::async_trait;
use config::FileSearchConfig; use config::FileSearchConfig;
use hostname; use hostname;
use tauri::AppHandle;
pub(crate) const EXTENSION_ID: &str = "File Search"; pub(crate) const EXTENSION_ID: &str = "File Search";
@@ -40,7 +41,11 @@ impl SearchSource for FileSearchExtensionSearchSource {
} }
} }
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> { async fn search(
&self,
tauri_app_handle: AppHandle,
query: SearchQuery,
) -> Result<QueryResponse, SearchError> {
let Some(query_string) = query.query_strings.get("query") else { let Some(query_string) = query.query_strings.get("query") else {
return Ok(QueryResponse { return Ok(QueryResponse {
source: self.get_type(), source: self.get_type(),
@@ -61,7 +66,7 @@ impl SearchSource for FileSearchExtensionSearchSource {
} }
// Get configuration from tauri store // Get configuration from tauri store
let config = FileSearchConfig::get(); let config = FileSearchConfig::get(&tauri_app_handle);
// If search paths are empty, then the hit should be empty. // If search paths are empty, then the hit should be empty.
// //
@@ -78,7 +83,9 @@ impl SearchSource for FileSearchExtensionSearchSource {
// Execute search in a blocking task // Execute search in a blocking task
let query_source = self.get_type(); let query_source = self.get_type();
let hits = implementation::hits(&query_string, from, size, &config).await.map_err(SearchError::InternalError)?; let hits = implementation::hits(&query_string, from, size, &config)
.await
.map_err(SearchError::InternalError)?;
let total_hits = hits.len(); let total_hits = hits.len();
Ok(QueryResponse { Ok(QueryResponse {

View File

@@ -9,29 +9,25 @@ pub mod pizza_engine_runtime;
pub mod quick_ai_access; pub mod quick_ai_access;
use super::Extension; use super::Extension;
use crate::SearchSourceRegistry;
use crate::extension::built_in::application::{set_apps_hotkey, unset_apps_hotkey}; use crate::extension::built_in::application::{set_apps_hotkey, unset_apps_hotkey};
use crate::extension::{ use crate::extension::{
alter_extension_json_file, ExtensionBundleIdBorrowed, PLUGIN_JSON_FILE_NAME, ExtensionBundleIdBorrowed, PLUGIN_JSON_FILE_NAME, alter_extension_json_file,
}; };
use crate::{SearchSourceRegistry, GLOBAL_TAURI_APP_HANDLE};
use anyhow::Context; use anyhow::Context;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use tauri::{AppHandle, Manager, Runtime}; use tauri::{AppHandle, Manager, Runtime};
pub(crate) static BUILT_IN_EXTENSION_DIRECTORY: LazyLock<PathBuf> = LazyLock::new(|| { pub(crate) fn get_built_in_extension_directory<R: Runtime>(
let mut resource_dir = GLOBAL_TAURI_APP_HANDLE tauri_app_handle: &AppHandle<R>,
.get() ) -> PathBuf {
.expect("global tauri app handle not set") let mut resource_dir = tauri_app_handle.path().app_data_dir().expect(
.path()
.app_data_dir()
.expect(
"User home directory not found, which should be impossible on desktop environments", "User home directory not found, which should be impossible on desktop environments",
); );
resource_dir.push("built_in_extensions"); resource_dir.push("built_in_extensions");
resource_dir resource_dir
}); }
/// Helper function to load the built-in extension specified by `extension_id`, used /// Helper function to load the built-in extension specified by `extension_id`, used
/// in `list_built_in_extensions()`. /// in `list_built_in_extensions()`.
@@ -86,7 +82,10 @@ async fn load_built_in_extension(
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let res_plugin_json = serde_json::from_str::<Extension>(&plugin_json_file_content); let res_plugin_json = serde_json::from_str::<Extension>(&plugin_json_file_content);
let Ok(plugin_json) = res_plugin_json else { let Ok(plugin_json) = res_plugin_json else {
log::warn!("user invalidated built-in extension [{}] file, overwriting it with the default template", extension_id); log::warn!(
"user invalidated built-in extension [{}] file, overwriting it with the default template",
extension_id
);
// If the JSON file cannot be parsed as `struct Extension`, overwrite it with the default template and return. // If the JSON file cannot be parsed as `struct Extension`, overwrite it with the default template and return.
tokio::fs::write(plugin_json_file_path, default_plugin_json_file) tokio::fs::write(plugin_json_file_path, default_plugin_json_file)
@@ -137,13 +136,15 @@ async fn load_built_in_extension(
/// We only read alias/hotkey/enabled from the JSON file, we have ensured that if /// We only read alias/hotkey/enabled from the JSON file, we have ensured that if
/// alias/hotkey is not supported, then it will be `None`. Besides that, no further /// alias/hotkey is not supported, then it will be `None`. Besides that, no further
/// validation is needed because nothing could go wrong. /// validation is needed because nothing could go wrong.
pub(crate) async fn list_built_in_extensions() -> Result<Vec<Extension>, String> { pub(crate) async fn list_built_in_extensions<R: Runtime>(
let dir = BUILT_IN_EXTENSION_DIRECTORY.as_path(); tauri_app_handle: &AppHandle<R>,
) -> Result<Vec<Extension>, String> {
let dir = get_built_in_extension_directory(tauri_app_handle);
let mut built_in_extensions = Vec::new(); let mut built_in_extensions = Vec::new();
built_in_extensions.push( built_in_extensions.push(
load_built_in_extension( load_built_in_extension(
dir, &dir,
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME, application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME,
application::PLUGIN_JSON_FILE, application::PLUGIN_JSON_FILE,
) )
@@ -151,7 +152,7 @@ pub(crate) async fn list_built_in_extensions() -> Result<Vec<Extension>, String>
); );
built_in_extensions.push( built_in_extensions.push(
load_built_in_extension( load_built_in_extension(
dir, &dir,
calculator::DATA_SOURCE_ID, calculator::DATA_SOURCE_ID,
calculator::PLUGIN_JSON_FILE, calculator::PLUGIN_JSON_FILE,
) )
@@ -159,7 +160,7 @@ pub(crate) async fn list_built_in_extensions() -> Result<Vec<Extension>, String>
); );
built_in_extensions.push( built_in_extensions.push(
load_built_in_extension( load_built_in_extension(
dir, &dir,
ai_overview::EXTENSION_ID, ai_overview::EXTENSION_ID,
ai_overview::PLUGIN_JSON_FILE, ai_overview::PLUGIN_JSON_FILE,
) )
@@ -167,7 +168,7 @@ pub(crate) async fn list_built_in_extensions() -> Result<Vec<Extension>, String>
); );
built_in_extensions.push( built_in_extensions.push(
load_built_in_extension( load_built_in_extension(
dir, &dir,
quick_ai_access::EXTENSION_ID, quick_ai_access::EXTENSION_ID,
quick_ai_access::PLUGIN_JSON_FILE, quick_ai_access::PLUGIN_JSON_FILE,
) )
@@ -178,7 +179,7 @@ pub(crate) async fn list_built_in_extensions() -> Result<Vec<Extension>, String>
if #[cfg(any(target_os = "macos", target_os = "windows"))] { if #[cfg(any(target_os = "macos", target_os = "windows"))] {
built_in_extensions.push( built_in_extensions.push(
load_built_in_extension( load_built_in_extension(
dir, &dir,
file_search::EXTENSION_ID, file_search::EXTENSION_ID,
file_search::PLUGIN_JSON_FILE, file_search::PLUGIN_JSON_FILE,
) )
@@ -232,12 +233,10 @@ pub(crate) fn is_extension_built_in(bundle_id: &ExtensionBundleIdBorrowed<'_>) -
bundle_id.developer.is_none() bundle_id.developer.is_none()
} }
pub(crate) async fn enable_built_in_extension( pub(crate) async fn enable_built_in_extension<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
bundle_id: &ExtensionBundleIdBorrowed<'_>, bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> { ) -> Result<(), String> {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>(); let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
let update_extension = |extension: &mut Extension| -> Result<(), String> { let update_extension = |extension: &mut Extension| -> Result<(), String> {
@@ -254,7 +253,7 @@ pub(crate) async fn enable_built_in_extension(
set_apps_hotkey(tauri_app_handle)?; set_apps_hotkey(tauri_app_handle)?;
alter_extension_json_file( alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(), &get_built_in_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
@@ -277,7 +276,7 @@ pub(crate) async fn enable_built_in_extension(
.register_source(calculator_search) .register_source(calculator_search)
.await; .await;
alter_extension_json_file( alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(), &get_built_in_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
@@ -286,7 +285,7 @@ pub(crate) async fn enable_built_in_extension(
if bundle_id.extension_id == quick_ai_access::EXTENSION_ID { if bundle_id.extension_id == quick_ai_access::EXTENSION_ID {
alter_extension_json_file( alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(), &get_built_in_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
@@ -295,7 +294,7 @@ pub(crate) async fn enable_built_in_extension(
if bundle_id.extension_id == ai_overview::EXTENSION_ID { if bundle_id.extension_id == ai_overview::EXTENSION_ID {
alter_extension_json_file( alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(), &get_built_in_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
@@ -310,7 +309,7 @@ pub(crate) async fn enable_built_in_extension(
.register_source(file_system_search) .register_source(file_system_search)
.await; .await;
alter_extension_json_file( alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(), &get_built_in_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
@@ -322,12 +321,10 @@ pub(crate) async fn enable_built_in_extension(
Ok(()) Ok(())
} }
pub(crate) async fn disable_built_in_extension( pub(crate) async fn disable_built_in_extension<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
bundle_id: &ExtensionBundleIdBorrowed<'_>, bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> { ) -> Result<(), String> {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>(); let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
let update_extension = |extension: &mut Extension| -> Result<(), String> { let update_extension = |extension: &mut Extension| -> Result<(), String> {
@@ -344,7 +341,7 @@ pub(crate) async fn disable_built_in_extension(
unset_apps_hotkey(tauri_app_handle)?; unset_apps_hotkey(tauri_app_handle)?;
alter_extension_json_file( alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(), &get_built_in_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
@@ -365,7 +362,7 @@ pub(crate) async fn disable_built_in_extension(
.remove_source(bundle_id.extension_id) .remove_source(bundle_id.extension_id)
.await; .await;
alter_extension_json_file( alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(), &get_built_in_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
@@ -374,7 +371,7 @@ pub(crate) async fn disable_built_in_extension(
if bundle_id.extension_id == quick_ai_access::EXTENSION_ID { if bundle_id.extension_id == quick_ai_access::EXTENSION_ID {
alter_extension_json_file( alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(), &get_built_in_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
@@ -384,7 +381,7 @@ pub(crate) async fn disable_built_in_extension(
if bundle_id.extension_id == ai_overview::EXTENSION_ID { if bundle_id.extension_id == ai_overview::EXTENSION_ID {
alter_extension_json_file( alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(), &get_built_in_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
@@ -399,7 +396,7 @@ pub(crate) async fn disable_built_in_extension(
.remove_source(bundle_id.extension_id) .remove_source(bundle_id.extension_id)
.await; .await;
alter_extension_json_file( alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(), &get_built_in_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
@@ -411,11 +408,11 @@ pub(crate) async fn disable_built_in_extension(
Ok(()) Ok(())
} }
pub(crate) fn set_built_in_extension_alias(bundle_id: &ExtensionBundleIdBorrowed<'_>, alias: &str) { pub(crate) fn set_built_in_extension_alias<R: Runtime>(
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE tauri_app_handle: &AppHandle<R>,
.get() bundle_id: &ExtensionBundleIdBorrowed<'_>,
.expect("global tauri app handle not set"); alias: &str,
) {
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME { if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
if let Some(app_path) = bundle_id.sub_extension_id { if let Some(app_path) = bundle_id.sub_extension_id {
application::set_app_alias(tauri_app_handle, app_path, alias); application::set_app_alias(tauri_app_handle, app_path, alias);
@@ -423,14 +420,11 @@ pub(crate) fn set_built_in_extension_alias(bundle_id: &ExtensionBundleIdBorrowed
} }
} }
pub(crate) fn register_built_in_extension_hotkey( pub(crate) fn register_built_in_extension_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
bundle_id: &ExtensionBundleIdBorrowed<'_>, bundle_id: &ExtensionBundleIdBorrowed<'_>,
hotkey: &str, hotkey: &str,
) -> Result<(), String> { ) -> Result<(), String> {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME { if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
if let Some(app_path) = bundle_id.sub_extension_id { if let Some(app_path) = bundle_id.sub_extension_id {
application::register_app_hotkey(&tauri_app_handle, app_path, hotkey)?; application::register_app_hotkey(&tauri_app_handle, app_path, hotkey)?;
@@ -439,13 +433,10 @@ pub(crate) fn register_built_in_extension_hotkey(
Ok(()) Ok(())
} }
pub(crate) fn unregister_built_in_extension_hotkey( pub(crate) fn unregister_built_in_extension_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
bundle_id: &ExtensionBundleIdBorrowed<'_>, bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> { ) -> Result<(), String> {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME { if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
if let Some(app_path) = bundle_id.sub_extension_id { if let Some(app_path) = bundle_id.sub_extension_id {
application::unregister_app_hotkey(&tauri_app_handle, app_path)?; application::unregister_app_hotkey(&tauri_app_handle, app_path)?;
@@ -490,12 +481,10 @@ fn load_extension_from_json_file(
Ok(extension) Ok(extension)
} }
pub(crate) async fn is_built_in_extension_enabled( pub(crate) async fn is_built_in_extension_enabled<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
bundle_id: &ExtensionBundleIdBorrowed<'_>, bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>(); let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
@@ -523,7 +512,7 @@ pub(crate) async fn is_built_in_extension_enabled(
if bundle_id.extension_id == quick_ai_access::EXTENSION_ID { if bundle_id.extension_id == quick_ai_access::EXTENSION_ID {
let extension = load_extension_from_json_file( let extension = load_extension_from_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(), &get_built_in_extension_directory(tauri_app_handle),
bundle_id.extension_id, bundle_id.extension_id,
)?; )?;
return Ok(extension.enabled); return Ok(extension.enabled);
@@ -531,7 +520,7 @@ pub(crate) async fn is_built_in_extension_enabled(
if bundle_id.extension_id == ai_overview::EXTENSION_ID { if bundle_id.extension_id == ai_overview::EXTENSION_ID {
let extension = load_extension_from_json_file( let extension = load_extension_from_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(), &get_built_in_extension_directory(tauri_app_handle),
bundle_id.extension_id, bundle_id.extension_id,
)?; )?;
return Ok(extension.enabled); return Ok(extension.enabled);

View File

@@ -8,8 +8,8 @@
//! which forces us to create a dedicated thread/runtime to execute them. //! which forces us to create a dedicated thread/runtime to execute them.
use std::any::Any; use std::any::Any;
use std::collections::hash_map::Entry;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::sync::OnceLock; use std::sync::OnceLock;
pub(crate) trait SearchSourceState { pub(crate) trait SearchSourceState {

View File

@@ -2,7 +2,8 @@ pub(crate) mod built_in;
pub(crate) mod third_party; pub(crate) mod third_party;
use crate::common::document::OnOpened; use crate::common::document::OnOpened;
use crate::{common::register::SearchSourceRegistry, GLOBAL_TAURI_APP_HANDLE}; use crate::common::register::SearchSourceRegistry;
use crate::util::platform::Platform;
use anyhow::Context; use anyhow::Context;
use borrowme::{Borrow, ToOwned}; use borrowme::{Borrow, ToOwned};
use derive_more::Display; use derive_more::Display;
@@ -11,9 +12,8 @@ use serde::Serialize;
use serde_json::Value as Json; use serde_json::Value as Json;
use std::collections::HashSet; use std::collections::HashSet;
use std::path::Path; use std::path::Path;
use tauri::Manager; use tauri::{AppHandle, Manager, Runtime};
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE; use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
use crate::util::platform::Platform;
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local"; pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json"; const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
@@ -413,23 +413,24 @@ fn filter_out_extensions(
/// * boolean: indicates if we found any invalid extensions /// * boolean: indicates if we found any invalid extensions
/// * Vec<Extension>: loaded extensions /// * Vec<Extension>: loaded extensions
#[tauri::command] #[tauri::command]
pub(crate) async fn list_extensions( pub(crate) async fn list_extensions<R: Runtime>(
tauri_app_handle: AppHandle<R>,
query: Option<String>, query: Option<String>,
extension_type: Option<ExtensionType>, extension_type: Option<ExtensionType>,
list_enabled: bool, list_enabled: bool,
) -> Result<(bool, Vec<Extension>), String> { ) -> Result<(bool, Vec<Extension>), String> {
log::trace!("loading extensions"); log::trace!("loading extensions");
let third_party_dir = third_party::THIRD_PARTY_EXTENSIONS_DIRECTORY.as_path(); let third_party_dir = third_party::get_third_party_extension_directory(&tauri_app_handle);
if !third_party_dir.try_exists().map_err(|e| e.to_string())? { if !third_party_dir.try_exists().map_err(|e| e.to_string())? {
tokio::fs::create_dir_all(third_party_dir) tokio::fs::create_dir_all(&third_party_dir)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
let (third_party_found_invalid_extension, mut third_party_extensions) = let (third_party_found_invalid_extension, mut third_party_extensions) =
third_party::list_third_party_extensions(third_party_dir).await?; third_party::list_third_party_extensions(&third_party_dir).await?;
let built_in_extensions = built_in::list_built_in_extensions().await?; let built_in_extensions = built_in::list_built_in_extensions(&tauri_app_handle).await?;
let found_invalid_extension = third_party_found_invalid_extension; let found_invalid_extension = third_party_found_invalid_extension;
let mut extensions = { let mut extensions = {
@@ -482,12 +483,12 @@ pub(crate) async fn list_extensions(
Ok((found_invalid_extension, extensions)) Ok((found_invalid_extension, extensions))
} }
pub(crate) async fn init_extensions(mut extensions: Vec<Extension>) -> Result<(), String> { pub(crate) async fn init_extensions(
tauri_app_handle: AppHandle,
mut extensions: Vec<Extension>,
) -> Result<(), String> {
log::trace!("initializing extensions"); log::trace!("initializing extensions");
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>(); let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
built_in::application::ApplicationSearchSource::prepare_index_and_store( built_in::application::ApplicationSearchSource::prepare_index_and_store(
@@ -508,7 +509,7 @@ pub(crate) async fn init_extensions(mut extensions: Vec<Extension>) -> Result<()
.filter(|ext| ext.enabled) .filter(|ext| ext.enabled)
{ {
built_in::init_built_in_extension( built_in::init_built_in_extension(
tauri_app_handle, &tauri_app_handle,
&built_in_extension, &built_in_extension,
&search_source_registry_tauri_state, &search_source_registry_tauri_state,
) )
@@ -517,7 +518,7 @@ pub(crate) async fn init_extensions(mut extensions: Vec<Extension>) -> Result<()
// Now the third-party extensions // Now the third-party extensions
let third_party_search_source = third_party::ThirdPartyExtensionsSearchSource::new(extensions); let third_party_search_source = third_party::ThirdPartyExtensionsSearchSource::new(extensions);
third_party_search_source.init().await?; third_party_search_source.init(&tauri_app_handle).await?;
let third_party_search_source_clone = third_party_search_source.clone(); let third_party_search_source_clone = third_party_search_source.clone();
// Set the global search source so that we can access it in `#[tauri::command]`s // Set the global search source so that we can access it in `#[tauri::command]`s
// ignore the result because this function will be invoked twice, which // ignore the result because this function will be invoked twice, which
@@ -531,79 +532,96 @@ pub(crate) async fn init_extensions(mut extensions: Vec<Extension>) -> Result<()
} }
#[tauri::command] #[tauri::command]
pub(crate) async fn enable_extension(bundle_id: ExtensionBundleId) -> Result<(), String> { pub(crate) async fn enable_extension(
tauri_app_handle: AppHandle,
bundle_id: ExtensionBundleId,
) -> Result<(), String> {
let bundle_id_borrowed = bundle_id.borrow(); let bundle_id_borrowed = bundle_id.borrow();
if built_in::is_extension_built_in(&bundle_id_borrowed) { if built_in::is_extension_built_in(&bundle_id_borrowed) {
built_in::enable_built_in_extension(&bundle_id_borrowed).await?; built_in::enable_built_in_extension(&tauri_app_handle, &bundle_id_borrowed).await?;
return Ok(()); return Ok(());
} }
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").enable_extension(&bundle_id_borrowed).await third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").enable_extension(&tauri_app_handle, &bundle_id_borrowed).await
} }
#[tauri::command] #[tauri::command]
pub(crate) async fn disable_extension(bundle_id: ExtensionBundleId) -> Result<(), String> { pub(crate) async fn disable_extension(
tauri_app_handle: AppHandle,
bundle_id: ExtensionBundleId,
) -> Result<(), String> {
let bundle_id_borrowed = bundle_id.borrow(); let bundle_id_borrowed = bundle_id.borrow();
if built_in::is_extension_built_in(&bundle_id_borrowed) { if built_in::is_extension_built_in(&bundle_id_borrowed) {
built_in::disable_built_in_extension(&bundle_id_borrowed).await?; built_in::disable_built_in_extension(&tauri_app_handle, &bundle_id_borrowed).await?;
return Ok(()); return Ok(());
} }
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").disable_extension(&bundle_id_borrowed).await third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").disable_extension(&tauri_app_handle, &bundle_id_borrowed).await
} }
#[tauri::command] #[tauri::command]
pub(crate) async fn set_extension_alias( pub(crate) async fn set_extension_alias(
tauri_app_handle: AppHandle,
bundle_id: ExtensionBundleId, bundle_id: ExtensionBundleId,
alias: String, alias: String,
) -> Result<(), String> { ) -> Result<(), String> {
let bundle_id_borrowed = bundle_id.borrow(); let bundle_id_borrowed = bundle_id.borrow();
if built_in::is_extension_built_in(&bundle_id_borrowed) { if built_in::is_extension_built_in(&bundle_id_borrowed) {
built_in::set_built_in_extension_alias(&bundle_id_borrowed, &alias); built_in::set_built_in_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias);
return Ok(()); return Ok(());
} }
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").set_extension_alias(&bundle_id_borrowed, &alias).await third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").set_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias).await
} }
#[tauri::command] #[tauri::command]
pub(crate) async fn register_extension_hotkey( pub(crate) async fn register_extension_hotkey(
tauri_app_handle: AppHandle,
bundle_id: ExtensionBundleId, bundle_id: ExtensionBundleId,
hotkey: String, hotkey: String,
) -> Result<(), String> { ) -> Result<(), String> {
let bundle_id_borrowed = bundle_id.borrow(); let bundle_id_borrowed = bundle_id.borrow();
if built_in::is_extension_built_in(&bundle_id_borrowed) { if built_in::is_extension_built_in(&bundle_id_borrowed) {
built_in::register_built_in_extension_hotkey(&bundle_id_borrowed, &hotkey)?; built_in::register_built_in_extension_hotkey(
&tauri_app_handle,
&bundle_id_borrowed,
&hotkey,
)?;
return Ok(()); return Ok(());
} }
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").register_extension_hotkey(&bundle_id_borrowed, &hotkey).await third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").register_extension_hotkey(&tauri_app_handle, &bundle_id_borrowed, &hotkey).await
} }
/// NOTE: this function won't error out if the extension specified by `extension_id` /// NOTE: this function won't error out if the extension specified by `extension_id`
/// has no hotkey set because we need it to behave like this. /// has no hotkey set because we need it to behave like this.
#[tauri::command] #[tauri::command]
pub(crate) async fn unregister_extension_hotkey( pub(crate) async fn unregister_extension_hotkey(
tauri_app_handle: AppHandle,
bundle_id: ExtensionBundleId, bundle_id: ExtensionBundleId,
) -> Result<(), String> { ) -> Result<(), String> {
let bundle_id_borrowed = bundle_id.borrow(); let bundle_id_borrowed = bundle_id.borrow();
if built_in::is_extension_built_in(&bundle_id_borrowed) { if built_in::is_extension_built_in(&bundle_id_borrowed) {
built_in::unregister_built_in_extension_hotkey(&bundle_id_borrowed)?; built_in::unregister_built_in_extension_hotkey(&tauri_app_handle, &bundle_id_borrowed)?;
return Ok(()); return Ok(());
} }
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").unregister_extension_hotkey(&bundle_id_borrowed).await?; third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").unregister_extension_hotkey(&tauri_app_handle, &bundle_id_borrowed).await?;
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
pub(crate) async fn is_extension_enabled(bundle_id: ExtensionBundleId) -> Result<bool, String> { pub(crate) async fn is_extension_enabled(
tauri_app_handle: AppHandle,
bundle_id: ExtensionBundleId,
) -> Result<bool, String> {
let bundle_id_borrowed = bundle_id.borrow(); let bundle_id_borrowed = bundle_id.borrow();
if built_in::is_extension_built_in(&bundle_id_borrowed) { if built_in::is_extension_built_in(&bundle_id_borrowed) {
return built_in::is_built_in_extension_enabled(&bundle_id_borrowed).await; return built_in::is_built_in_extension_enabled(&tauri_app_handle, &bundle_id_borrowed)
.await;
} }
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").is_extension_enabled(&bundle_id_borrowed).await third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").is_extension_enabled(&bundle_id_borrowed).await
} }
@@ -704,10 +722,7 @@ fn alter_extension_json_file(
// Search in quicklinks // Search in quicklinks
if let Some(ref mut quicklinks) = root_extension.quicklinks { if let Some(ref mut quicklinks) = root_extension.quicklinks {
if let Some(link) = quicklinks if let Some(link) = quicklinks.iter_mut().find(|lnk| lnk.id == sub_extension_id) {
.iter_mut()
.find(|lnk| lnk.id == sub_extension_id)
{
how(link)?; how(link)?;
return Ok(()); return Ok(());
} }

View File

@@ -1,14 +1,14 @@
pub(crate) mod store; pub(crate) mod store;
use super::alter_extension_json_file;
use super::canonicalize_relative_icon_path;
use super::Extension; use super::Extension;
use super::ExtensionType; use super::ExtensionType;
use super::LOCAL_QUERY_SOURCE_TYPE; use super::LOCAL_QUERY_SOURCE_TYPE;
use super::PLUGIN_JSON_FILE_NAME; use super::PLUGIN_JSON_FILE_NAME;
use crate::common::document::open; use super::alter_extension_json_file;
use super::canonicalize_relative_icon_path;
use crate::common::document::DataSourceReference; use crate::common::document::DataSourceReference;
use crate::common::document::Document; use crate::common::document::Document;
use crate::common::document::open;
use crate::common::error::SearchError; use crate::common::error::SearchError;
use crate::common::search::QueryResponse; use crate::common::search::QueryResponse;
use crate::common::search::QuerySource; use crate::common::search::QuerySource;
@@ -16,7 +16,6 @@ use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource; use crate::common::traits::SearchSource;
use crate::extension::ExtensionBundleIdBorrowed; use crate::extension::ExtensionBundleIdBorrowed;
use crate::util::platform::Platform; use crate::util::platform::Platform;
use crate::GLOBAL_TAURI_APP_HANDLE;
use async_trait::async_trait; use async_trait::async_trait;
use borrowme::ToOwned; use borrowme::ToOwned;
use function_name::named; use function_name::named;
@@ -24,29 +23,27 @@ use std::ffi::OsStr;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::sync::LazyLock;
use std::sync::OnceLock; use std::sync::OnceLock;
use tauri::async_runtime; use tauri::AppHandle;
use tauri::Manager; use tauri::Manager;
use tauri::Runtime;
use tauri::async_runtime;
use tauri_plugin_global_shortcut::GlobalShortcutExt; use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::ShortcutState; use tauri_plugin_global_shortcut::ShortcutState;
use tokio::fs::read_dir; use tokio::fs::read_dir;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tokio::sync::RwLockWriteGuard; use tokio::sync::RwLockWriteGuard;
pub(crate) static THIRD_PARTY_EXTENSIONS_DIRECTORY: LazyLock<PathBuf> = LazyLock::new(|| { pub(crate) fn get_third_party_extension_directory<R: Runtime>(
let mut app_data_dir = GLOBAL_TAURI_APP_HANDLE tauri_app_handle: &AppHandle<R>,
.get() ) -> PathBuf {
.expect("global tauri app handle not set") let mut app_data_dir = tauri_app_handle.path().app_data_dir().expect(
.path()
.app_data_dir()
.expect(
"User home directory not found, which should be impossible on desktop environments", "User home directory not found, which should be impossible on desktop environments",
); );
app_data_dir.push("third_party_extensions"); app_data_dir.push("third_party_extensions");
app_data_dir app_data_dir
}); }
pub(crate) async fn list_third_party_extensions( pub(crate) async fn list_third_party_extensions(
directory: &Path, directory: &Path,
@@ -201,7 +198,15 @@ fn validate_extension(
// Extension is incompatible // Extension is incompatible
if let Some(ref platforms) = extension.platforms { if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&current_platform) { if !platforms.contains(&current_platform) {
log::warn!("extension [{}] is not compatible with the current platform [{}], it is available to {:?}", extension.id, current_platform, platforms.iter().map(|os|os.to_string()).collect::<Vec<_>>()); log::warn!(
"extension [{}] is not compatible with the current platform [{}], it is available to {:?}",
extension.id,
current_platform,
platforms
.iter()
.map(|os| os.to_string())
.collect::<Vec<_>>()
);
return false; return false;
} }
} }
@@ -320,7 +325,8 @@ fn validate_sub_items(extension_id: &str, sub_items: &[Extension]) -> bool {
if sub_item.r#type == ExtensionType::Group || sub_item.r#type == ExtensionType::Extension { if sub_item.r#type == ExtensionType::Group || sub_item.r#type == ExtensionType::Extension {
log::warn!( log::warn!(
"invalid extension sub-item [{}-{}]: sub-item should not be of type [Group] or [Extension]", "invalid extension sub-item [{}-{}]: sub-item should not be of type [Group] or [Extension]",
extension_id, sub_item.id extension_id,
sub_item.id
); );
return false; return false;
} }
@@ -394,11 +400,11 @@ impl ThirdPartyExtensionsSearchSource {
/// Note that when you enable a parent extension, its **enabled** children extensions /// Note that when you enable a parent extension, its **enabled** children extensions
/// should also be enabled. /// should also be enabled.
#[async_recursion::async_recursion] #[async_recursion::async_recursion]
async fn _enable_extension(extension: &Extension) -> Result<(), String> { async fn _enable_extension(
tauri_app_handle: &AppHandle,
extension: &Extension,
) -> Result<(), String> {
if extension.supports_alias_hotkey() { if extension.supports_alias_hotkey() {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
if let Some(ref hotkey) = extension.hotkey { if let Some(ref hotkey) = extension.hotkey {
let on_opened = extension.on_opened().unwrap_or_else(|| panic!( "extension has hotkey, but on_open() returns None, extension ID [{}], extension type [{:?}]", extension.id, extension.r#type)); let on_opened = extension.on_opened().unwrap_or_else(|| panic!( "extension has hotkey, but on_open() returns None, extension ID [{}], extension type [{:?}]", extension.id, extension.r#type));
@@ -406,12 +412,14 @@ impl ThirdPartyExtensionsSearchSource {
tauri_app_handle tauri_app_handle
.global_shortcut() .global_shortcut()
.on_shortcut(hotkey.as_str(), move |_tauri_app_handle, _hotkey, event| { .on_shortcut(hotkey.as_str(), move |tauri_app_handle, _hotkey, event| {
let on_opened_clone = on_opened.clone(); let on_opened_clone = on_opened.clone();
let extension_id_clone = extension_id_clone.clone(); let extension_id_clone = extension_id_clone.clone();
let app_handle_clone = tauri_app_handle.clone();
if event.state() == ShortcutState::Pressed { if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move { async_runtime::spawn(async move {
let result = open(on_opened_clone).await; let result = open(app_handle_clone, on_opened_clone).await;
if let Err(msg) = result { if let Err(msg) = result {
log::warn!( log::warn!(
"failed to open extension [{}], error [{}]", "failed to open extension [{}], error [{}]",
@@ -430,19 +438,19 @@ impl ThirdPartyExtensionsSearchSource {
if extension.r#type.contains_sub_items() { if extension.r#type.contains_sub_items() {
if let Some(commands) = &extension.commands { if let Some(commands) = &extension.commands {
for command in commands.iter().filter(|ext| ext.enabled) { for command in commands.iter().filter(|ext| ext.enabled) {
Self::_enable_extension(command).await?; Self::_enable_extension(&tauri_app_handle, command).await?;
} }
} }
if let Some(scripts) = &extension.scripts { if let Some(scripts) = &extension.scripts {
for script in scripts.iter().filter(|ext| ext.enabled) { for script in scripts.iter().filter(|ext| ext.enabled) {
Self::_enable_extension(script).await?; Self::_enable_extension(&tauri_app_handle, script).await?;
} }
} }
if let Some(quicklinks) = &extension.quicklinks { if let Some(quicklinks) = &extension.quicklinks {
for quicklink in quicklinks.iter().filter(|ext| ext.enabled) { for quicklink in quicklinks.iter().filter(|ext| ext.enabled) {
Self::_enable_extension(quicklink).await?; Self::_enable_extension(&tauri_app_handle, quicklink).await?;
} }
} }
} }
@@ -457,12 +465,11 @@ impl ThirdPartyExtensionsSearchSource {
/// Note that when you disable a parent extension, its **enabled** children extensions /// Note that when you disable a parent extension, its **enabled** children extensions
/// should also be disabled. /// should also be disabled.
#[async_recursion::async_recursion] #[async_recursion::async_recursion]
async fn _disable_extension(extension: &Extension) -> Result<(), String> { async fn _disable_extension(
tauri_app_handle: &AppHandle,
extension: &Extension,
) -> Result<(), String> {
if let Some(ref hotkey) = extension.hotkey { if let Some(ref hotkey) = extension.hotkey {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
tauri_app_handle tauri_app_handle
.global_shortcut() .global_shortcut()
.unregister(hotkey.as_str()) .unregister(hotkey.as_str())
@@ -473,19 +480,19 @@ impl ThirdPartyExtensionsSearchSource {
if extension.r#type.contains_sub_items() { if extension.r#type.contains_sub_items() {
if let Some(commands) = &extension.commands { if let Some(commands) = &extension.commands {
for command in commands.iter().filter(|ext| ext.enabled) { for command in commands.iter().filter(|ext| ext.enabled) {
Self::_disable_extension(command).await?; Self::_disable_extension(tauri_app_handle, command).await?;
} }
} }
if let Some(scripts) = &extension.scripts { if let Some(scripts) = &extension.scripts {
for script in scripts.iter().filter(|ext| ext.enabled) { for script in scripts.iter().filter(|ext| ext.enabled) {
Self::_disable_extension(script).await?; Self::_disable_extension(tauri_app_handle, script).await?;
} }
} }
if let Some(quicklinks) = &extension.quicklinks { if let Some(quicklinks) = &extension.quicklinks {
for quicklink in quicklinks.iter().filter(|ext| ext.enabled) { for quicklink in quicklinks.iter().filter(|ext| ext.enabled) {
Self::_disable_extension(quicklink).await?; Self::_disable_extension(tauri_app_handle, quicklink).await?;
} }
} }
} }
@@ -504,6 +511,7 @@ impl ThirdPartyExtensionsSearchSource {
#[named] #[named]
pub(super) async fn enable_extension( pub(super) async fn enable_extension(
&self, &self,
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>, bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut extensions_write_lock = self.inner.extensions.write().await; let mut extensions_write_lock = self.inner.extensions.write().await;
@@ -531,11 +539,11 @@ impl ThirdPartyExtensionsSearchSource {
update_extension(extension)?; update_extension(extension)?;
alter_extension_json_file( alter_extension_json_file(
&THIRD_PARTY_EXTENSIONS_DIRECTORY, &get_third_party_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
Self::_enable_extension(extension).await?; Self::_enable_extension(tauri_app_handle, extension).await?;
Ok(()) Ok(())
} }
@@ -543,6 +551,7 @@ impl ThirdPartyExtensionsSearchSource {
#[named] #[named]
pub(super) async fn disable_extension( pub(super) async fn disable_extension(
&self, &self,
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>, bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut extensions_write_lock = self.inner.extensions.write().await; let mut extensions_write_lock = self.inner.extensions.write().await;
@@ -570,11 +579,11 @@ impl ThirdPartyExtensionsSearchSource {
update_extension(extension)?; update_extension(extension)?;
alter_extension_json_file( alter_extension_json_file(
&THIRD_PARTY_EXTENSIONS_DIRECTORY, &get_third_party_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
Self::_disable_extension(extension).await?; Self::_disable_extension(tauri_app_handle, extension).await?;
Ok(()) Ok(())
} }
@@ -582,6 +591,7 @@ impl ThirdPartyExtensionsSearchSource {
#[named] #[named]
pub(super) async fn set_extension_alias( pub(super) async fn set_extension_alias(
&self, &self,
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>, bundle_id: &ExtensionBundleIdBorrowed<'_>,
alias: &str, alias: &str,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -602,7 +612,7 @@ impl ThirdPartyExtensionsSearchSource {
update_extension(extension)?; update_extension(extension)?;
alter_extension_json_file( alter_extension_json_file(
&THIRD_PARTY_EXTENSIONS_DIRECTORY, &get_third_party_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
@@ -612,11 +622,11 @@ impl ThirdPartyExtensionsSearchSource {
/// Initialize the third-party extensions, which literally means /// Initialize the third-party extensions, which literally means
/// enabling/activating the enabled extensions. /// enabling/activating the enabled extensions.
pub(super) async fn init(&self) -> Result<(), String> { pub(super) async fn init(&self, tauri_app_handle: &AppHandle) -> Result<(), String> {
let extensions_read_lock = self.inner.extensions.read().await; let extensions_read_lock = self.inner.extensions.read().await;
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) { for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
Self::_enable_extension(extension).await?; Self::_enable_extension(tauri_app_handle, extension).await?;
} }
Ok(()) Ok(())
@@ -625,10 +635,12 @@ impl ThirdPartyExtensionsSearchSource {
#[named] #[named]
pub(super) async fn register_extension_hotkey( pub(super) async fn register_extension_hotkey(
&self, &self,
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>, bundle_id: &ExtensionBundleIdBorrowed<'_>,
hotkey: &str, hotkey: &str,
) -> Result<(), String> { ) -> Result<(), String> {
self.unregister_extension_hotkey(bundle_id).await?; self.unregister_extension_hotkey(tauri_app_handle, bundle_id)
.await?;
let mut extensions_write_lock = self.inner.extensions.write().await; let mut extensions_write_lock = self.inner.extensions.write().await;
let extension = let extension =
@@ -648,15 +660,12 @@ impl ThirdPartyExtensionsSearchSource {
// Update extension (memory and file) // Update extension (memory and file)
update_extension(extension)?; update_extension(extension)?;
alter_extension_json_file( alter_extension_json_file(
&THIRD_PARTY_EXTENSIONS_DIRECTORY, &get_third_party_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
// Set hotkey // Set hotkey
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let on_opened = extension.on_opened().unwrap_or_else(|| panic!( let on_opened = extension.on_opened().unwrap_or_else(|| panic!(
"setting hotkey for an extension that cannot be opened, extension ID [{:?}], extension type [{:?}]", bundle_id, extension.r#type, "setting hotkey for an extension that cannot be opened, extension ID [{:?}], extension type [{:?}]", bundle_id, extension.r#type,
)); ));
@@ -664,12 +673,14 @@ impl ThirdPartyExtensionsSearchSource {
let bundle_id_owned = bundle_id.to_owned(); let bundle_id_owned = bundle_id.to_owned();
tauri_app_handle tauri_app_handle
.global_shortcut() .global_shortcut()
.on_shortcut(hotkey, move |_tauri_app_handle, _hotkey, event| { .on_shortcut(hotkey, move |tauri_app_handle, _hotkey, event| {
let on_opened_clone = on_opened.clone(); let on_opened_clone = on_opened.clone();
let bundle_id_clone = bundle_id_owned.clone(); let bundle_id_clone = bundle_id_owned.clone();
let app_handle_clone = tauri_app_handle.clone();
if event.state() == ShortcutState::Pressed { if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move { async_runtime::spawn(async move {
let result = open(on_opened_clone).await; let result = open(app_handle_clone, on_opened_clone).await;
if let Err(msg) = result { if let Err(msg) = result {
log::warn!( log::warn!(
"failed to open extension [{:?}], error [{}]", "failed to open extension [{:?}], error [{}]",
@@ -690,6 +701,7 @@ impl ThirdPartyExtensionsSearchSource {
#[named] #[named]
pub(super) async fn unregister_extension_hotkey( pub(super) async fn unregister_extension_hotkey(
&self, &self,
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>, bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut extensions_write_lock = self.inner.extensions.write().await; let mut extensions_write_lock = self.inner.extensions.write().await;
@@ -717,15 +729,12 @@ impl ThirdPartyExtensionsSearchSource {
update_extension(extension)?; update_extension(extension)?;
alter_extension_json_file( alter_extension_json_file(
&THIRD_PARTY_EXTENSIONS_DIRECTORY, &get_third_party_extension_directory(tauri_app_handle),
bundle_id, bundle_id,
update_extension, update_extension,
)?; )?;
// Set hotkey // Set hotkey
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
tauri_app_handle tauri_app_handle
.global_shortcut() .global_shortcut()
.unregister(hotkey.as_str()) .unregister(hotkey.as_str())
@@ -846,7 +855,11 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
} }
} }
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> { async fn search(
&self,
_tauri_app_handle: AppHandle,
query: SearchQuery,
) -> Result<QueryResponse, SearchError> {
let Some(query_string) = query.query_strings.get("query") else { let Some(query_string) = query.query_strings.get("query") else {
return Ok(QueryResponse { return Ok(QueryResponse {
source: self.get_type(), source: self.get_type(),
@@ -1045,11 +1058,13 @@ fn calculate_text_similarity(query: &str, text: &str) -> Option<f64> {
#[tauri::command] #[tauri::command]
pub(crate) async fn uninstall_extension( pub(crate) async fn uninstall_extension(
tauri_app_handle: AppHandle,
developer: String, developer: String,
extension_id: String, extension_id: String,
) -> Result<(), String> { ) -> Result<(), String> {
let extension_dir = { let extension_dir = {
let mut path = THIRD_PARTY_EXTENSIONS_DIRECTORY.join(developer.as_str()); let mut path = get_third_party_extension_directory(&tauri_app_handle);
path.push(developer.as_str());
path.push(extension_id.as_str()); path.push(extension_id.as_str());
path path
@@ -1075,7 +1090,7 @@ pub(crate) async fn uninstall_extension(
// Unregistering hotkey is the only thing that we will do when we disable // Unregistering hotkey is the only thing that we will do when we disable
// an extension, so we directly use this function here even though "disabling" // an extension, so we directly use this function here even though "disabling"
// the extension that one is trying to uninstall does not make too much sense. // the extension that one is trying to uninstall does not make too much sense.
ThirdPartyExtensionsSearchSource::_disable_extension(&extension).await?; ThirdPartyExtensionsSearchSource::_disable_extension(&tauri_app_handle, &extension).await?;
Ok(()) Ok(())
} }

View File

@@ -8,17 +8,18 @@ use crate::common::search::QueryResponse;
use crate::common::search::QuerySource; use crate::common::search::QuerySource;
use crate::common::search::SearchQuery; use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource; use crate::common::traits::SearchSource;
use crate::extension::canonicalize_relative_icon_path;
use crate::extension::third_party::THIRD_PARTY_EXTENSIONS_DIRECTORY;
use crate::extension::Extension; use crate::extension::Extension;
use crate::extension::PLUGIN_JSON_FILE_NAME; use crate::extension::PLUGIN_JSON_FILE_NAME;
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE; use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
use crate::extension::canonicalize_relative_icon_path;
use crate::extension::third_party::get_third_party_extension_directory;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use async_trait::async_trait; use async_trait::async_trait;
use reqwest::StatusCode; use reqwest::StatusCode;
use serde_json::Map as JsonObject; use serde_json::Map as JsonObject;
use serde_json::Value as Json; use serde_json::Value as Json;
use std::io::Read; use std::io::Read;
use tauri::AppHandle;
const DATA_SOURCE_ID: &str = "Extension Store"; const DATA_SOURCE_ID: &str = "Extension Store";
@@ -37,7 +38,11 @@ impl SearchSource for ExtensionStore {
} }
} }
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> { async fn search(
&self,
_tauri_app_handle: AppHandle,
query: SearchQuery,
) -> Result<QueryResponse, SearchError> {
const SCORE: f64 = 2000.0; const SCORE: f64 = 2000.0;
let Some(query_string) = query.query_strings.get("query") else { let Some(query_string) = query.query_strings.get("query") else {
@@ -174,7 +179,10 @@ async fn is_extension_installed(developer: String, extension_id: String) -> bool
} }
#[tauri::command] #[tauri::command]
pub(crate) async fn install_extension_from_store(id: String) -> Result<(), String> { pub(crate) async fn install_extension_from_store(
tauri_app_handle: AppHandle,
id: String,
) -> Result<(), String> {
let path = format!("store/extension/{}/_download", id); let path = format!("store/extension/{}/_download", id);
let response = HttpClient::get("default_coco_server", &path, None) let response = HttpClient::get("default_coco_server", &path, None)
.await .await
@@ -199,7 +207,9 @@ pub(crate) async fn install_extension_from_store(id: String) -> Result<(), Strin
// 2. sub-extensions won't have their `id` fields set // 2. sub-extensions won't have their `id` fields set
// //
// we need to correct it // we need to correct it
let mut plugin_json = archive.by_name(PLUGIN_JSON_FILE_NAME).map_err(|e| e.to_string())?; let mut plugin_json = archive
.by_name(PLUGIN_JSON_FILE_NAME)
.map_err(|e| e.to_string())?;
let mut plugin_json_content = String::new(); let mut plugin_json_content = String::new();
std::io::Read::read_to_string(&mut plugin_json, &mut plugin_json_content) std::io::Read::read_to_string(&mut plugin_json, &mut plugin_json_content)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -249,12 +259,11 @@ pub(crate) async fn install_extension_from_store(id: String) -> Result<(), Strin
drop(plugin_json); drop(plugin_json);
// Write extension files to the extension directory // Write extension files to the extension directory
let developer = extension.developer.clone().unwrap_or_default(); let developer = extension.developer.clone().unwrap_or_default();
let extension_id = extension.id.clone(); let extension_id = extension.id.clone();
let extension_directory = { let extension_directory = {
let mut path = THIRD_PARTY_EXTENSIONS_DIRECTORY.to_path_buf(); let mut path = get_third_party_extension_directory(&tauri_app_handle);
path.push(developer); path.push(developer);
path.push(extension_id.as_str()); path.push(extension_id.as_str());
path path
@@ -288,14 +297,29 @@ pub(crate) async fn install_extension_from_store(id: String) -> Result<(), Strin
let dest_file_path = extension_directory.join(zip_file_name); let dest_file_path = extension_directory.join(zip_file_name);
// For cases like `assets/xxx.png` // For cases like `assets/xxx.png`
if let Some(parent_dir) = dest_file_path.parent() && !parent_dir.exists() { if let Some(parent_dir) = dest_file_path.parent()
tokio::fs::create_dir_all(parent_dir).await.map_err(|e| e.to_string())?; && !parent_dir.exists()
{
tokio::fs::create_dir_all(parent_dir)
.await
.map_err(|e| e.to_string())?;
} }
let mut dest_file = tokio::fs::File::create(&dest_file_path) .await .map_err(|e| e.to_string())?; let mut dest_file = tokio::fs::File::create(&dest_file_path)
let mut src_bytes = Vec::with_capacity(zip_file.size().try_into().expect("we won't have a extension file that is bigger than 4GiB")); .await
zip_file.read_to_end(&mut src_bytes).map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
tokio::io::copy(&mut src_bytes.as_slice(), &mut dest_file).await.map_err(|e| e.to_string())?; let mut src_bytes = Vec::with_capacity(
zip_file
.size()
.try_into()
.expect("we won't have a extension file that is bigger than 4GiB"),
);
zip_file
.read_to_end(&mut src_bytes)
.map_err(|e| e.to_string())?;
tokio::io::copy(&mut src_bytes.as_slice(), &mut dest_file)
.await
.map_err(|e| e.to_string())?;
} }
// Create plugin.json from the extension variable // Create plugin.json from the extension variable
let plugin_json_path = extension_directory.join(PLUGIN_JSON_FILE_NAME); let plugin_json_path = extension_directory.join(PLUGIN_JSON_FILE_NAME);
@@ -304,7 +328,6 @@ pub(crate) async fn install_extension_from_store(id: String) -> Result<(), Strin
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Turn it into an absolute path if it is a valid relative path because frontend code need this. // Turn it into an absolute path if it is a valid relative path because frontend code need this.
canonicalize_relative_icon_path(&extension_directory, &mut extension)?; canonicalize_relative_icon_path(&extension_directory, &mut extension)?;

View File

@@ -28,9 +28,14 @@ pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
lazy_static! { 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, /// To allow us to access tauri's `AppHandle` when its context is inaccessible,
/// store it globally. It will be set in `init()`. /// store it globally. It will be set in `init()`.
///
/// # WARNING
///
/// You may find this work, but the usage is discouraged and should be generally
/// avoided. If you do need it, always be careful that it may not be set() when
/// you access it.
pub(crate) static GLOBAL_TAURI_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new(); pub(crate) static GLOBAL_TAURI_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
#[tauri::command] #[tauri::command]
@@ -85,7 +90,11 @@ pub fn run() {
.plugin(tauri_plugin_macos_permissions::init()) .plugin(tauri_plugin_macos_permissions::init())
.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()
.default_version_comparator(crate::util::updater::custom_version_comparator)
.build(),
)
.plugin(tauri_plugin_windows_version::init()) .plugin(tauri_plugin_windows_version::init())
.plugin(tauri_plugin_opener::init()); .plugin(tauri_plugin_opener::init());
@@ -107,7 +116,6 @@ pub fn run() {
show_settings, show_settings,
show_check, show_check,
hide_check, hide_check,
server::servers::get_server_token,
server::servers::add_coco_server, server::servers::add_coco_server,
server::servers::remove_coco_server, server::servers::remove_coco_server,
server::servers::list_coco_servers, server::servers::list_coco_servers,
@@ -172,8 +180,16 @@ pub fn run() {
server::synthesize::synthesize, server::synthesize::synthesize,
util::file::get_file_icon, util::file::get_file_icon,
util::app_lang::update_app_lang, util::app_lang::update_app_lang,
#[cfg(target_os = "macos")]
setup::toggle_move_to_active_space_attribute,
]) ])
.setup(|app| { .setup(|app| {
let app_handle = app.handle().clone();
GLOBAL_TAURI_APP_HANDLE
.set(app_handle.clone())
.expect("global tauri AppHandle already initialized");
log::trace!("global Tauri AppHandle set");
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
log::trace!("hiding Dock icon on macOS"); log::trace!("hiding Dock icon on macOS");
@@ -181,22 +197,35 @@ pub fn run() {
log::trace!("Dock icon should be hidden now"); log::trace!("Dock icon should be hidden now");
} }
let app_handle = app.handle().clone();
GLOBAL_TAURI_APP_HANDLE
.set(app_handle.clone())
.expect("variable already initialized");
log::trace!("global Tauri app handle set");
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
app.manage(server::websocket::WebSocketManager::default()); app.manage(server::websocket::WebSocketManager::default());
// This has to be called before initializing extensions as doing that
// requires access to the shortcut store, which will be set by this
// function.
shortcut::enable_shortcut(app);
block_on(async { block_on(async {
init(app.handle()).await; init(app.handle()).await;
});
shortcut::enable_shortcut(app); // We want all the extensions here, so no filter condition specified.
match extension::list_extensions(app_handle.clone(), None, None, false).await {
Ok((_found_invalid_extensions, extensions)) => {
// Initializing extension relies on SearchSourceRegistry, so this should
// be executed after `app.manage(registry)`
if let Err(e) =
extension::init_extensions(app_handle.clone(), extensions).await
{
log::error!("initializing extensions failed with error [{}]", e);
}
}
Err(e) => {
log::error!("listing extensions failed with error [{}]", e);
}
}
});
ensure_autostart_state_consistent(app)?; ensure_autostart_state_consistent(app)?;
@@ -274,7 +303,7 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
log::error!("Failed to load server tokens: {}", err); log::error!("Failed to load server tokens: {}", err);
} }
let coco_servers = server::servers::get_all_servers(); let coco_servers = server::servers::get_all_servers().await;
// Get the registry from Tauri's state // Get the registry from Tauri's state
// let registry: State<SearchSourceRegistry> = app_handle.state::<SearchSourceRegistry>(); // let registry: State<SearchSourceRegistry> = app_handle.state::<SearchSourceRegistry>();
@@ -407,13 +436,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &WebviewWindow<R>) {
} }
#[tauri::command] #[tauri::command]
async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> { async fn get_app_search_source(app_handle: AppHandle) -> Result<(), String> {
// We want all the extensions here, so no filter condition specified.
let (_found_invalid_extensions, extensions) = extension::list_extensions(None, None, false)
.await
.map_err(|e| e.to_string())?;
extension::init_extensions(extensions).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;

View File

@@ -1,5 +1,112 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!! // Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
/// Helper function to return the log directory.
///
/// This should return the same value as `tauri_app_handle.path().app_log_dir().unwrap()`.
fn app_log_dir() -> PathBuf {
// This function `app_log_dir()` is for the panic hook, which should be set
// before Tauri performs any initialization. At that point, we do not have
// access to the identifier provided by Tauri, so we need to define our own
// one here.
//
// NOTE: If you update identifier in the following files, update this one
// as well!
//
// src-tauri/tauri.linux.conf.json
// src-tauri/Entitlements.plist
// src-tauri/tauri.conf.json
// src-tauri/Info.plist
const IDENTIFIER: &str = "rs.coco.app";
#[cfg(target_os = "macos")]
let path = dirs::home_dir()
.expect("cannot find the home directory, Coco should never run in such a environment")
.join("Library/Logs")
.join(IDENTIFIER);
#[cfg(not(target_os = "macos"))]
let path = dirs::data_local_dir()
.expect("app local dir is None, we should not encounter this")
.join(IDENTIFIER)
.join("logs");
path
}
/// Set up panic hook to log panic information to a file
fn setup_panic_hook() {
std::panic::set_hook(Box::new(|panic_info| {
let timestamp = chrono::Local::now();
// "%Y-%m-%d %H:%M:%S"
//
// I would like to use the above format, but Windows does not allow that
// and complains with OS error 123.
let datetime_str = timestamp.format("%Y-%m-%d-%H-%M-%S").to_string();
let log_dir = app_log_dir();
// Ensure the log directory exists
if let Err(e) = std::fs::create_dir_all(&log_dir) {
eprintln!("Panic hook error: failed to create log directory: {}", e);
return;
}
let panic_file = log_dir.join(format!("{}_rust_panic.log", datetime_str));
// Prepare panic information
let panic_message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic message".to_string()
};
let location = if let Some(location) = panic_info.location() {
format!(
"{}:{}:{}",
location.file(),
location.line(),
location.column()
)
} else {
"Unknown location".to_string()
};
// Use `force_capture()` instead of `capture()` as we want backtrace
// regardless of whether the corresponding env vars are set or not.
let backtrace = std::backtrace::Backtrace::force_capture();
let panic_log = format!(
"Time: [{}]\nLocation: [{}]\nMessage: [{}]\nBacktrace: \n{}",
datetime_str, location, panic_message, backtrace
);
// Write to panic file
match OpenOptions::new()
.create(true)
.append(true)
.open(&panic_file)
{
Ok(mut file) => {
if let Err(e) = writeln!(file, "{}", panic_log) {
eprintln!("Panic hook error: Failed to write panic to file: {}", e);
}
}
Err(e) => {
eprintln!("Panic hook error: Failed to open panic log file: {}", e);
}
}
}));
}
fn main() { fn main() {
// Panic hook setup should be the first thing to do, everything could panic!
setup_panic_hook();
coco_lib::run(); coco_lib::run();
} }

View File

@@ -4,17 +4,20 @@ use crate::common::search::{
FailedRequest, MultiSourceQueryResponse, QueryHits, QueryResponse, QuerySource, SearchQuery, FailedRequest, MultiSourceQueryResponse, QueryHits, QueryResponse, QuerySource, SearchQuery,
}; };
use crate::common::traits::SearchSource; use crate::common::traits::SearchSource;
use crate::server::servers::logout_coco_server;
use crate::server::servers::mark_server_as_offline;
use function_name::named; use function_name::named;
use futures::stream::FuturesUnordered;
use futures::StreamExt; use futures::StreamExt;
use futures::stream::FuturesUnordered;
use reqwest::StatusCode;
use std::cmp::Reverse; use std::cmp::Reverse;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet; use std::collections::HashSet;
use std::future::Future; use std::future::Future;
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Manager, Runtime}; use tauri::{AppHandle, Manager};
use tokio::time::error::Elapsed; use tokio::time::error::Elapsed;
use tokio::time::{timeout, Duration}; use tokio::time::{Duration, timeout};
/// Helper function to return the Future used for querying querysources. /// Helper function to return the Future used for querying querysources.
/// ///
@@ -31,6 +34,7 @@ fn same_type_futures(
query_source_trait_object: Arc<dyn SearchSource>, query_source_trait_object: Arc<dyn SearchSource>,
timeout_duration: Duration, timeout_duration: Duration,
search_query: SearchQuery, search_query: SearchQuery,
tauri_app_handle: AppHandle,
) -> impl Future< ) -> impl Future<
Output = ( Output = (
QuerySource, QuerySource,
@@ -42,7 +46,9 @@ fn same_type_futures(
// Store `query_source` as part of future for debugging purposes. // Store `query_source` as part of future for debugging purposes.
query_source, query_source,
timeout(timeout_duration, async { timeout(timeout_duration, async {
query_source_trait_object.search(search_query).await query_source_trait_object
.search(tauri_app_handle.clone(), search_query)
.await
}) })
.await, .await,
) )
@@ -51,8 +57,8 @@ fn same_type_futures(
#[named] #[named]
#[tauri::command] #[tauri::command]
pub async fn query_coco_fusion<R: Runtime>( pub async fn query_coco_fusion(
app_handle: AppHandle<R>, app_handle: AppHandle,
from: u64, from: u64,
size: u64, size: u64,
query_strings: HashMap<String, String>, query_strings: HashMap<String, String>,
@@ -77,8 +83,10 @@ pub async fn query_coco_fusion<R: Runtime>(
let timeout_duration = Duration::from_millis(query_timeout); let timeout_duration = Duration::from_millis(query_timeout);
log::debug!( log::debug!(
"{}(): {:?}, timeout: {:?}", "{}() invoked with parameters: from: [{}], size: [{}], query_strings: [{:?}], timeout: [{:?}]",
function_name!(), function_name!(),
from,
size,
query_strings, query_strings,
timeout_duration timeout_duration
); );
@@ -124,16 +132,25 @@ pub async fn query_coco_fusion<R: Runtime>(
query_source_trait_object, query_source_trait_object,
timeout_duration, timeout_duration,
search_query, search_query,
app_handle.clone(),
)); ));
} else { } else {
log::debug!(
"will query querysources {:?}",
sources_list
.iter()
.map(|search_source| search_source.get_type().id.clone())
.collect::<Vec<String>>()
);
for query_source_trait_object in sources_list { for query_source_trait_object in sources_list {
let query_source = query_source_trait_object.get_type().clone(); let query_source = query_source_trait_object.get_type().clone();
log::debug!("will query querysource [{}]", query_source.id);
futures.push(same_type_futures( futures.push(same_type_futures(
query_source, query_source,
query_source_trait_object, query_source_trait_object,
timeout_duration, timeout_duration,
search_query.clone(), search_query.clone(),
app_handle.clone(),
)); ));
} }
} }
@@ -191,9 +208,38 @@ pub async fn query_coco_fusion<R: Runtime>(
query_source.id, query_source.id,
search_error search_error
); );
let mut status_code_num: u16 = 0;
if let SearchError::HttpError {
status_code: opt_status_code,
msg: _,
} = search_error
{
if let Some(status_code) = opt_status_code {
status_code_num = status_code.as_u16();
if status_code != StatusCode::OK {
if status_code == StatusCode::UNAUTHORIZED {
// This Coco server is unavailable. In addition to marking it as
// unavailable, we need to log out because the status code is 401.
logout_coco_server(app_handle.clone(), query_source.id.clone()).await.unwrap_or_else(|e| {
panic!(
"the search request to Coco server [id {}, name {}] failed with status code {}, the login token is invalid, we are trying to log out, but failed with error [{}]",
query_source.id, query_source.name, StatusCode::UNAUTHORIZED, e
);
})
} else {
// This Coco server is unavailable
mark_server_as_offline(app_handle.clone(), &query_source.id)
.await;
}
}
}
}
failed_requests.push(FailedRequest { failed_requests.push(FailedRequest {
source: query_source, source: query_source,
status: 0, status: status_code_num,
error: Some(search_error.to_string()), error: Some(search_error.to_string()),
reason: None, reason: None,
}); });

View File

@@ -45,10 +45,12 @@ pub async fn upload_attachment(
form = form.part("files", part); form = form.part("files", part);
} }
let server = get_server_by_id(&server_id).ok_or("Server not found")?; let server = get_server_by_id(&server_id)
.await
.ok_or("Server not found")?;
let url = HttpClient::join_url(&server.endpoint, &format!("attachment/_upload")); let url = HttpClient::join_url(&server.endpoint, &format!("attachment/_upload"));
let token = get_server_token(&server_id).await?; let token = get_server_token(&server_id).await;
let mut headers = HashMap::new(); let mut headers = HashMap::new();
if let Some(token) = token { if let Some(token) = token {
headers.insert("X-API-TOKEN".to_string(), token.access_token); headers.insert("X-API-TOKEN".to_string(), token.access_token);

View File

@@ -20,15 +20,15 @@ pub async fn handle_sso_callback<R: Runtime>(
code: String, code: String,
) -> Result<(), String> { ) -> Result<(), String> {
// Retrieve the server details using the server ID // Retrieve the server details using the server ID
let server = get_server_by_id(&server_id); let server = get_server_by_id(&server_id).await;
let expire_in = 3600; // TODO, need to update to actual expire_in value let expire_in = 3600; // TODO, need to update to actual expire_in value
if let Some(mut server) = server { if let Some(mut server) = server {
// Save the access token for the server // Save the access token for the server
let access_token = ServerAccessToken::new(server_id.clone(), code.clone(), expire_in); let access_token = ServerAccessToken::new(server_id.clone(), code.clone(), expire_in);
// dbg!(&server_id, &request_id, &code, &token); // dbg!(&server_id, &request_id, &code, &token);
save_access_token(server_id.clone(), access_token); save_access_token(server_id.clone(), access_token).await;
persist_servers_token(&app_handle)?; persist_servers_token(&app_handle).await?;
// Register the server to the search source // Register the server to the search source
try_register_server_to_search_source(app_handle.clone(), &server).await; try_register_server_to_search_source(app_handle.clone(), &server).await;
@@ -41,7 +41,7 @@ pub async fn handle_sso_callback<R: Runtime>(
Ok(p) => { Ok(p) => {
server.profile = Some(p); server.profile = Some(p);
server.available = true; server.available = true;
save_server(&server); save_server(&server).await;
persist_servers(&app_handle).await?; persist_servers(&app_handle).await?;
Ok(()) Ok(())
} }

View File

@@ -30,7 +30,7 @@ pub fn get_connector_by_id(server_id: &str, connector_id: &str) -> Option<Connec
} }
pub async fn refresh_all_connectors<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> { pub async fn refresh_all_connectors<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
let servers = get_all_servers(); let servers = get_all_servers().await;
// 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();

View File

@@ -34,7 +34,7 @@ pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, Dat
pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) -> Result<(), String> { pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) -> Result<(), String> {
// dbg!("Attempting to refresh all datasources"); // dbg!("Attempting to refresh all datasources");
let servers = get_all_servers(); let servers = get_all_servers().await;
let mut server_map = HashMap::new(); let mut server_map = HashMap::new();

View File

@@ -11,8 +11,8 @@ use tokio::sync::Mutex;
pub(crate) fn new_reqwest_http_client(accept_invalid_certs: bool) -> Client { pub(crate) fn new_reqwest_http_client(accept_invalid_certs: bool) -> Client {
Client::builder() Client::builder()
.read_timeout(Duration::from_secs(3)) // Set a timeout of 3 second .read_timeout(Duration::from_secs(60)) // Set a timeout of 60 second
.connect_timeout(Duration::from_secs(3)) // Set a timeout of 3 second .connect_timeout(Duration::from_secs(30)) // Set a timeout of 30 second
.timeout(Duration::from_secs(5 * 60)) // Set a timeout of 5 minute .timeout(Duration::from_secs(5 * 60)) // Set a timeout of 5 minute
.danger_accept_invalid_certs(accept_invalid_certs) // allow self-signed certificates .danger_accept_invalid_certs(accept_invalid_certs) // allow self-signed certificates
.build() .build()
@@ -175,14 +175,14 @@ impl HttpClient {
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
// Fetch the server using the server_id // Fetch the server using the server_id
let server = get_server_by_id(server_id); let server = get_server_by_id(server_id).await;
if let Some(s) = server { if let Some(s) = server {
// Construct the URL // Construct the URL
let url = HttpClient::join_url(&s.endpoint, path); let url = HttpClient::join_url(&s.endpoint, path);
// Retrieve the token for the server (token is optional) // Retrieve the token for the server (token is optional)
let token = get_server_token(server_id) let token = get_server_token(server_id)
.await? .await
.map(|t| t.access_token.clone()); .map(|t| t.access_token.clone());
let mut headers = if let Some(custom_headers) = custom_headers { let mut headers = if let Some(custom_headers) = custom_headers {
@@ -205,7 +205,7 @@ impl HttpClient {
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 {
Err("Server not found".to_string()) Err(format!("Server [{}] not found", server_id))
} }
} }

View File

@@ -6,10 +6,10 @@ 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 async_trait::async_trait; use async_trait::async_trait;
// use futures::stream::StreamExt;
use ordered_float::OrderedFloat; use ordered_float::OrderedFloat;
use reqwest::StatusCode;
use std::collections::HashMap; use std::collections::HashMap;
// use std::hash::Hash; use tauri::AppHandle;
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) struct DocumentsSizedCollector { pub(crate) struct DocumentsSizedCollector {
@@ -44,7 +44,7 @@ impl DocumentsSizedCollector {
} }
} }
fn documents(self) -> impl ExactSizeIterator<Item=Document> { fn documents(self) -> impl ExactSizeIterator<Item = Document> {
self.docs.into_iter().map(|(_, doc, _)| doc) self.docs.into_iter().map(|(_, doc, _)| doc)
} }
@@ -90,7 +90,11 @@ impl SearchSource for CocoSearchSource {
} }
} }
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> { async fn search(
&self,
_tauri_app_handle: AppHandle,
query: SearchQuery,
) -> Result<QueryResponse, SearchError> {
let url = "/query/_search"; let url = "/query/_search";
let mut total_hits = 0; let mut total_hits = 0;
let mut hits: Vec<(Document, f64)> = Vec::new(); let mut hits: Vec<(Document, f64)> = Vec::new();
@@ -108,7 +112,18 @@ impl SearchSource for CocoSearchSource {
let response = HttpClient::get(&self.server.id, &url, Some(query_params)) let response = HttpClient::get(&self.server.id, &url, Some(query_params))
.await .await
.map_err(|e| SearchError::HttpError(format!("{}", e)))?; .map_err(|e| SearchError::HttpError {
status_code: None,
msg: format!("{}", e),
})?;
let status_code = response.status();
if ![StatusCode::OK, StatusCode::CREATED].contains(&status_code) {
return Err(SearchError::HttpError {
status_code: Some(status_code),
msg: format!("Request failed with status code [{}]", status_code),
});
}
// 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)
@@ -123,7 +138,6 @@ impl SearchSource for CocoSearchSource {
let parsed: SearchResponse<Document> = serde_json::from_str(&response_body) let parsed: SearchResponse<Document> = serde_json::from_str(&response_body)
.map_err(|e| SearchError::ParseError(format!("{}", e)))?; .map_err(|e| SearchError::ParseError(format!("{}", e)))?;
// Process the parsed response // Process the parsed response
total_hits = parsed.hits.total.value as usize; total_hits = parsed.hits.total.value as usize;

View File

@@ -1,3 +1,4 @@
use crate::COCO_TAURI_STORE;
use crate::common::http::get_response_body_text; use crate::common::http::get_response_body_text;
use crate::common::register::SearchSourceRegistry; use crate::common::register::SearchSourceRegistry;
use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version}; use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version};
@@ -5,68 +6,72 @@ use crate::server::connector::fetch_connectors_by_server;
use crate::server::datasource::datasource_search; use crate::server::datasource::datasource_search;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use crate::server::search::CocoSearchSource; use crate::server::search::CocoSearchSource;
use crate::COCO_TAURI_STORE; use function_name;
use lazy_static::lazy_static; use http::StatusCode;
use reqwest::Method; use reqwest::Method;
use serde_json::from_value;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use serde_json::from_value;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::LazyLock;
use std::sync::RwLock;
use tauri::Runtime; use tauri::Runtime;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
// Assuming you're using serde_json use tokio::sync::RwLock;
lazy_static! { /// Coco sever list
static ref SERVER_CACHE: Arc<RwLock<HashMap<String, Server>>> = static SERVER_LIST_CACHE: LazyLock<RwLock<HashMap<String, Server>>> =
Arc::new(RwLock::new(HashMap::new())); LazyLock::new(|| RwLock::new(HashMap::new()));
static ref SERVER_TOKEN: Arc<RwLock<HashMap<String, ServerAccessToken>>> =
Arc::new(RwLock::new(HashMap::new()));
}
#[allow(dead_code)] /// If a server has a token stored here that has not expired, it is considered logged in.
fn check_server_exists(id: &str) -> bool { ///
let cache = SERVER_CACHE.read().unwrap(); // Acquire read lock /// Since the `expire_at` field of `struct ServerAccessToken` is currently unused,
cache.contains_key(id) /// all servers stored here are treated as logged in.
} static SERVER_TOKEN_LIST_CACHE: LazyLock<RwLock<HashMap<String, ServerAccessToken>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
pub fn get_server_by_id(id: &str) -> Option<Server> { /// `SERVER_LIST_CACHE` will be stored in KV store COCO_TAURI_STORE, under this key.
let cache = SERVER_CACHE.read().unwrap(); // Acquire read lock pub const COCO_SERVERS: &str = "coco_servers";
/// `SERVER_TOKEN_LIST_CACHE` will be stored in KV store COCO_TAURI_STORE, under this key.
const COCO_SERVER_TOKENS: &str = "coco_server_tokens";
pub async fn get_server_by_id(id: &str) -> Option<Server> {
let cache = SERVER_LIST_CACHE.read().await;
cache.get(id).cloned() cache.get(id).cloned()
} }
#[tauri::command] pub async fn get_server_token(id: &str) -> Option<ServerAccessToken> {
pub async fn get_server_token(id: &str) -> Result<Option<ServerAccessToken>, String> { let cache = SERVER_TOKEN_LIST_CACHE.read().await;
let cache = SERVER_TOKEN.read().map_err(|err| err.to_string())?;
Ok(cache.get(id).cloned()) cache.get(id).cloned()
} }
pub fn save_access_token(server_id: String, token: ServerAccessToken) -> bool { pub async fn save_access_token(server_id: String, token: ServerAccessToken) -> bool {
let mut cache = SERVER_TOKEN.write().unwrap(); let mut cache = SERVER_TOKEN_LIST_CACHE.write().await;
cache.insert(server_id, token).is_none() cache.insert(server_id, token).is_none()
} }
fn check_endpoint_exists(endpoint: &str) -> bool { async fn check_endpoint_exists(endpoint: &str) -> bool {
let cache = SERVER_CACHE.read().unwrap(); let cache = SERVER_LIST_CACHE.read().await;
cache.values().any(|server| server.endpoint == endpoint) cache.values().any(|server| server.endpoint == endpoint)
} }
pub fn save_server(server: &Server) -> bool { /// Return true if `server` does not exists in the server list, i.e., it is a newly-added
let mut cache = SERVER_CACHE.write().unwrap(); /// server.
cache.insert(server.id.clone(), server.clone()).is_none() // If the server id did not exist, `insert` will return `None` pub async fn save_server(server: &Server) -> bool {
let mut cache = SERVER_LIST_CACHE.write().await;
cache.insert(server.id.clone(), server.clone()).is_none()
} }
fn remove_server_by_id(id: String) -> bool { /// Return the removed `Server` if it exists in the server list.
async fn remove_server_by_id(id: &str) -> Option<Server> {
log::debug!("remove server by id: {}", &id); log::debug!("remove server by id: {}", &id);
let mut cache = SERVER_CACHE.write().unwrap(); let mut cache = SERVER_LIST_CACHE.write().await;
let deleted = cache.remove(id.as_str()); cache.remove(id)
deleted.is_some()
} }
pub async fn persist_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> { pub async fn persist_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
let cache = SERVER_CACHE.read().unwrap(); // Acquire a read lock, not a write lock, since you're not modifying the cache let cache = SERVER_LIST_CACHE.read().await;
// Convert HashMap to Vec for serialization (iterating over values of HashMap) // Convert HashMap to Vec for serialization (iterating over values of HashMap)
let servers: Vec<Server> = cache.values().cloned().collect(); let servers: Vec<Server> = cache.values().cloned().collect();
@@ -86,14 +91,16 @@ pub async fn persist_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<()
Ok(()) Ok(())
} }
pub fn remove_server_token(id: &str) -> bool { /// Return true if the server token of the server specified by `id` exists in
/// the token list and gets deleted.
pub async fn remove_server_token(id: &str) -> bool {
log::debug!("remove server token by id: {}", &id); log::debug!("remove server token by id: {}", &id);
let mut cache = SERVER_TOKEN.write().unwrap(); let mut cache = SERVER_TOKEN_LIST_CACHE.write().await;
cache.remove(id).is_some() cache.remove(id).is_some()
} }
pub fn persist_servers_token<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> { pub async fn persist_servers_token<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
let cache = SERVER_TOKEN.read().unwrap(); // Acquire a read lock, not a write lock, since you're not modifying the cache let cache = SERVER_TOKEN_LIST_CACHE.read().await;
// Convert HashMap to Vec for serialization (iterating over values of HashMap) // Convert HashMap to Vec for serialization (iterating over values of HashMap)
let servers: Vec<ServerAccessToken> = cache.values().cloned().collect(); let servers: Vec<ServerAccessToken> = cache.values().cloned().collect();
@@ -173,26 +180,42 @@ pub async fn load_servers_token<R: Runtime>(
servers.ok_or_else(|| "Failed to read servers from store: No servers found".to_string())?; servers.ok_or_else(|| "Failed to read servers from store: No servers found".to_string())?;
// Convert each item in the JsonValue array to a Server // Convert each item in the JsonValue array to a Server
if let JsonValue::Array(servers_array) = servers { match servers {
// Deserialize each JsonValue into Server, filtering out any errors JsonValue::Array(servers_array) => {
let deserialized_tokens: Vec<ServerAccessToken> = servers_array let mut deserialized_tokens: Vec<ServerAccessToken> =
.into_iter() Vec::with_capacity(servers_array.len());
.filter_map(|server_json| from_value(server_json).ok()) // Only keep valid Server instances for server_json in servers_array {
.collect(); match from_value(server_json.clone()) {
Ok(token) => {
deserialized_tokens.push(token);
}
Err(e) => {
panic!(
"failed to deserialize JSON [{}] to [struct ServerAccessToken], error [{}], store [{}] key [{}] is possibly corrupted!",
server_json, e, COCO_TAURI_STORE, COCO_SERVER_TOKENS
);
}
}
}
if deserialized_tokens.is_empty() { if deserialized_tokens.is_empty() {
return Err("Failed to deserialize any servers from the store.".to_string()); return Err("Failed to deserialize any servers from the store.".to_string());
} }
for server in deserialized_tokens.iter() { for server in deserialized_tokens.iter() {
save_access_token(server.id.clone(), server.clone()); save_access_token(server.id.clone(), server.clone()).await;
} }
log::debug!("loaded {:?} servers's token", &deserialized_tokens.len()); log::debug!("loaded {:?} servers's token", &deserialized_tokens.len());
Ok(deserialized_tokens) Ok(deserialized_tokens)
} else { }
Err("Failed to read servers from store: Invalid format".to_string()) _ => {
unreachable!(
"coco server tokens should be stored in an array under store [{}] key [{}], but it is not",
COCO_TAURI_STORE, COCO_SERVER_TOKENS
);
}
} }
} }
@@ -214,26 +237,41 @@ pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<S
servers.ok_or_else(|| "Failed to read servers from store: No servers found".to_string())?; servers.ok_or_else(|| "Failed to read servers from store: No servers found".to_string())?;
// Convert each item in the JsonValue array to a Server // Convert each item in the JsonValue array to a Server
if let JsonValue::Array(servers_array) = servers { match servers {
// Deserialize each JsonValue into Server, filtering out any errors JsonValue::Array(servers_array) => {
let deserialized_servers: Vec<Server> = servers_array let mut deserialized_servers = Vec::with_capacity(servers_array.len());
.into_iter() for server_json in servers_array {
.filter_map(|server_json| from_value(server_json).ok()) // Only keep valid Server instances match from_value(server_json.clone()) {
.collect(); Ok(server) => {
deserialized_servers.push(server);
}
Err(e) => {
panic!(
"failed to deserialize JSON [{}] to [struct Server], error [{}], store [{}] key [{}] is possibly corrupted!",
server_json, e, COCO_TAURI_STORE, COCO_SERVERS
);
}
}
}
if deserialized_servers.is_empty() { if deserialized_servers.is_empty() {
return Err("Failed to deserialize any servers from the store.".to_string()); return Err("Failed to deserialize any servers from the store.".to_string());
} }
for server in deserialized_servers.iter() { for server in deserialized_servers.iter() {
save_server(&server); save_server(&server).await;
} }
log::debug!("load servers: {:?}", &deserialized_servers); log::debug!("load servers: {:?}", &deserialized_servers);
Ok(deserialized_servers) Ok(deserialized_servers)
} else { }
Err("Failed to read servers from store: Invalid format".to_string()) _ => {
unreachable!(
"coco servers should be stored in an array under store [{}] key [{}], but it is not",
COCO_TAURI_STORE, COCO_SERVERS
);
}
} }
} }
@@ -250,7 +288,7 @@ pub async fn load_or_insert_default_server<R: Runtime>(
} }
let default = get_default_server(); let default = get_default_server();
save_server(&default); save_server(&default).await;
log::debug!("loaded default servers"); log::debug!("loaded default servers");
@@ -259,33 +297,23 @@ pub async fn load_or_insert_default_server<R: Runtime>(
#[tauri::command] #[tauri::command]
pub async fn list_coco_servers<R: Runtime>( pub async fn list_coco_servers<R: Runtime>(
_app_handle: AppHandle<R>, app_handle: AppHandle<R>,
) -> Result<Vec<Server>, String> { ) -> Result<Vec<Server>, String> {
//hard fresh all server's info, in order to get the actual health //hard fresh all server's info, in order to get the actual health
refresh_all_coco_server_info(_app_handle.clone()).await; refresh_all_coco_server_info(app_handle.clone()).await;
let servers: Vec<Server> = get_all_servers().await;
let servers: Vec<Server> = get_all_servers();
Ok(servers) Ok(servers)
} }
#[allow(dead_code)] pub async fn get_all_servers() -> Vec<Server> {
pub fn get_servers_as_hashmap() -> HashMap<String, Server> { let cache = SERVER_LIST_CACHE.read().await;
let cache = SERVER_CACHE.read().unwrap();
cache.clone()
}
pub fn get_all_servers() -> Vec<Server> {
let cache = SERVER_CACHE.read().unwrap();
cache.values().cloned().collect() cache.values().cloned().collect()
} }
/// We store added Coco servers in the Tauri store using this key.
pub const COCO_SERVERS: &str = "coco_servers";
const COCO_SERVER_TOKENS: &str = "coco_server_tokens";
pub async fn refresh_all_coco_server_info<R: Runtime>(app_handle: AppHandle<R>) { pub async fn refresh_all_coco_server_info<R: Runtime>(app_handle: AppHandle<R>) {
let servers = get_all_servers(); let servers = get_all_servers().await;
for server in servers { for server in servers {
let _ = refresh_coco_server_info(app_handle.clone(), server.id.clone()).await; let _ = refresh_coco_server_info(app_handle.clone(), server.id.clone()).await;
} }
@@ -298,7 +326,7 @@ pub async fn refresh_coco_server_info<R: Runtime>(
) -> Result<Server, String> { ) -> Result<Server, String> {
// Retrieve the server from the cache // Retrieve the server from the cache
let cached_server = { let cached_server = {
let cache = SERVER_CACHE.read().unwrap(); let cache = SERVER_LIST_CACHE.read().await;
cache.get(&id).cloned() cache.get(&id).cloned()
}; };
@@ -313,19 +341,16 @@ pub async fn refresh_coco_server_info<R: Runtime>(
let profile = server.profile; let profile = server.profile;
// Send request to fetch updated server info // Send request to fetch updated server info
let response = HttpClient::get(&id, "/provider/_info", None) let response = match HttpClient::get(&id, "/provider/_info", None).await {
.await Ok(response) => response,
.map_err(|e| format!("Failed to contact the server: {}", e)); Err(e) => {
mark_server_as_offline(app_handle, &id).await;
if response.is_err() { return Err(e);
let _ = mark_server_as_offline(app_handle, &id).await;
return Err(response.err().unwrap());
} }
};
let response = response?;
if !response.status().is_success() { if !response.status().is_success() {
let _ = mark_server_as_offline(app_handle, &id).await; mark_server_as_offline(app_handle, &id).await;
return Err(format!("Request failed with status: {}", response.status())); return Err(format!("Request failed with status: {}", response.status()));
} }
@@ -336,19 +361,26 @@ pub async fn refresh_coco_server_info<R: Runtime>(
let mut updated_server: Server = serde_json::from_str(&body) let mut updated_server: Server = serde_json::from_str(&body)
.map_err(|e| format!("Failed to deserialize the response: {}", e))?; .map_err(|e| format!("Failed to deserialize the response: {}", e))?;
// Mark server as online
let _ = mark_server_as_online(app_handle.clone(), &id).await;
// Restore local state // Restore local state
updated_server.id = id.clone(); updated_server.id = id.clone();
updated_server.builtin = is_builtin; updated_server.builtin = is_builtin;
updated_server.enabled = is_enabled; updated_server.enabled = is_enabled;
updated_server.available = true; updated_server.available = {
if server.public {
// Public Coco servers are available as long as they are online.
true
} else {
// For non-public Coco servers, we still need to check if it is
// logged in, i.e., has a token stored in `SERVER_TOKEN_LIST_CACHE`.
get_server_token(&id).await.is_some()
}
};
updated_server.profile = profile; updated_server.profile = profile;
trim_endpoint_last_forward_slash(&mut updated_server); trim_endpoint_last_forward_slash(&mut updated_server);
// Save and persist // Save and persist
save_server(&updated_server); save_server(&updated_server).await;
try_register_server_to_search_source(app_handle.clone(), &updated_server).await;
persist_servers(&app_handle) persist_servers(&app_handle)
.await .await
.map_err(|e| format!("Failed to persist servers: {}", e))?; .map_err(|e| format!("Failed to persist servers: {}", e))?;
@@ -371,10 +403,10 @@ pub async fn add_coco_server<R: Runtime>(
let endpoint = endpoint.trim_end_matches('/'); let endpoint = endpoint.trim_end_matches('/');
if check_endpoint_exists(endpoint) { if check_endpoint_exists(endpoint).await {
log::debug!( log::debug!(
"This Coco server has already been registered: {:?}", "trying to register a Coco server [{}] that has already been registered",
&endpoint endpoint
); );
return Err("This Coco server has already been registered.".into()); return Err("This Coco server has already been registered.".into());
} }
@@ -386,6 +418,15 @@ pub async fn add_coco_server<R: Runtime>(
log::debug!("Get provider info response: {:?}", &response); log::debug!("Get provider info response: {:?}", &response);
if response.status() != StatusCode::OK {
log::debug!(
"trying to register a Coco server [{}] that is possibly down",
endpoint
);
return Err("This Coco server is possibly down".into());
}
let body = get_response_body_text(response).await?; let body = get_response_body_text(response).await?;
let mut server: Server = serde_json::from_str(&body) let mut server: Server = serde_json::from_str(&body)
@@ -393,15 +434,32 @@ pub async fn add_coco_server<R: Runtime>(
trim_endpoint_last_forward_slash(&mut server); trim_endpoint_last_forward_slash(&mut server);
// The JSON returned from `provider/_info` won't have this field, serde will set
// it to an empty string during deserialization, we need to set a valid value here.
if server.id.is_empty() { if server.id.is_empty() {
server.id = pizza_common::utils::uuid::Uuid::new().to_string(); server.id = pizza_common::utils::uuid::Uuid::new().to_string();
} }
// Use the default name, if it is not set.
if server.name.is_empty() { if server.name.is_empty() {
server.name = "Coco Server".to_string(); server.name = "Coco Server".to_string();
} }
save_server(&server); // Update the `available` field
if server.public {
// Serde already sets this to true, but just to make the code clear, do it again.
server.available = true;
} else {
let opt_token = get_server_token(&server.id).await;
assert!(
opt_token.is_none(),
"this Coco server is newly-added, we should have no token stored for it!"
);
// This is a non-public Coco server, and it is not logged in, so it is unavailable.
server.available = false;
}
save_server(&server).await;
try_register_server_to_search_source(app_handle.clone(), &server).await; try_register_server_to_search_source(app_handle.clone(), &server).await;
persist_servers(&app_handle) persist_servers(&app_handle)
@@ -413,6 +471,7 @@ pub async fn add_coco_server<R: Runtime>(
} }
#[tauri::command] #[tauri::command]
#[function_name::named]
pub async fn remove_coco_server<R: Runtime>( pub async fn remove_coco_server<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
id: String, id: String,
@@ -420,24 +479,47 @@ pub async fn remove_coco_server<R: Runtime>(
let registry = app_handle.state::<SearchSourceRegistry>(); let registry = app_handle.state::<SearchSourceRegistry>();
registry.remove_source(id.as_str()).await; registry.remove_source(id.as_str()).await;
remove_server_token(id.as_str()); let opt_server = remove_server_by_id(id.as_str()).await;
remove_server_by_id(id); let Some(server) = opt_server else {
panic!(
"[{}()] invoked with a server [{}] that does not exist! Mismatched states between frontend and backend!",
function_name!(),
id
);
};
persist_servers(&app_handle) persist_servers(&app_handle)
.await .await
.expect("failed to save servers"); .expect("failed to save servers");
persist_servers_token(&app_handle).expect("failed to save server tokens");
// Only non-public Coco servers require tokens
if !server.public {
// If is logged in, clear the token as well.
let deleted = remove_server_token(id.as_str()).await;
if deleted {
persist_servers_token(&app_handle)
.await
.expect("failed to save server tokens");
}
}
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
#[function_name::named]
pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> { pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> {
println!("enable_server: {}", id); let opt_server = get_server_by_id(id.as_str()).await;
let Some(mut server) = opt_server else {
panic!(
"[{}()] invoked with a server [{}] that does not exist! Mismatched states between frontend and backend!",
function_name!(),
id
);
};
let server = get_server_by_id(id.as_str());
if let Some(mut server) = server {
server.enabled = true; server.enabled = true;
save_server(&server); save_server(&server).await;
// Register the server to the search source // Register the server to the search source
try_register_server_to_search_source(app_handle.clone(), &server).await; try_register_server_to_search_source(app_handle.clone(), &server).await;
@@ -445,26 +527,56 @@ pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
persist_servers(&app_handle) persist_servers(&app_handle)
.await .await
.expect("failed to save servers"); .expect("failed to save servers");
}
Ok(()) Ok(())
} }
#[tauri::command]
#[function_name::named]
pub async fn disable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> {
let opt_server = get_server_by_id(id.as_str()).await;
let Some(mut server) = opt_server else {
panic!(
"[{}()] invoked with a server [{}] that does not exist! Mismatched states between frontend and backend!",
function_name!(),
id
);
};
server.enabled = false;
let registry = app_handle.state::<SearchSourceRegistry>();
registry.remove_source(id.as_str()).await;
save_server(&server).await;
persist_servers(&app_handle)
.await
.expect("failed to save servers");
Ok(())
}
/// For non-public Coco servers, we add it to the search source as long as it is
/// enabled.
///
/// For public Coco server, an extra token is required.
pub async fn try_register_server_to_search_source( pub async fn try_register_server_to_search_source(
app_handle: AppHandle<impl Runtime>, app_handle: AppHandle<impl Runtime>,
server: &Server, server: &Server,
) { ) {
if server.enabled { if server.enabled {
log::trace!( log::trace!(
"Server {} is public: {} and available: {}", "Server [name: {}, id: {}] is public: {} and available: {}",
&server.name, &server.name,
&server.id,
&server.public, &server.public,
&server.available &server.available
); );
if !server.public { if !server.public {
let token = get_server_token(&server.id).await; let opt_token = get_server_token(&server.id).await;
if !token.is_ok() || token.is_ok() && token.unwrap().is_none() { if opt_token.is_none() {
log::debug!("Server {} is not public and no token was found", &server.id); log::debug!("Server {} is not public and no token was found", &server.id);
return; return;
} }
@@ -476,113 +588,110 @@ pub async fn try_register_server_to_search_source(
} }
} }
#[tauri::command] #[function_name::named]
pub async fn mark_server_as_online<R: Runtime>( #[allow(unused)]
app_handle: AppHandle<R>, id: &str) -> Result<(), ()> { async fn mark_server_as_online<R: Runtime>(app_handle: AppHandle<R>, id: &str) {
// println!("server_is_offline: {}", id); let server = get_server_by_id(id).await;
let server = get_server_by_id(id);
if let Some(mut server) = server { if let Some(mut server) = server {
server.available = true; server.available = true;
server.health = None; server.health = None;
save_server(&server); save_server(&server).await;
try_register_server_to_search_source(app_handle.clone(), &server).await; try_register_server_to_search_source(app_handle.clone(), &server).await;
} else {
log::warn!(
"[{}()] invoked with a server [{}] that does not exist!",
function_name!(),
id
);
} }
Ok(())
} }
#[tauri::command] #[function_name::named]
pub async fn mark_server_as_offline<R: Runtime>( pub(crate) async fn mark_server_as_offline<R: Runtime>(app_handle: AppHandle<R>, id: &str) {
app_handle: AppHandle<R>, let server = get_server_by_id(id).await;
id: &str,
) -> Result<(), ()> {
// println!("server_is_offline: {}", id);
let server = get_server_by_id(id);
if let Some(mut server) = server { if let Some(mut server) = server {
server.available = false; server.available = false;
server.health = None; server.health = None;
save_server(&server); save_server(&server).await;
let registry = app_handle.state::<SearchSourceRegistry>(); let registry = app_handle.state::<SearchSourceRegistry>();
registry.remove_source(id).await; registry.remove_source(id).await;
} else {
log::warn!(
"[{}()] invoked with a server [{}] that does not exist!",
function_name!(),
id
);
} }
Ok(())
}
#[tauri::command]
pub async fn disable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> {
let server = get_server_by_id(id.as_str());
if let Some(mut server) = server {
server.enabled = false;
let registry = app_handle.state::<SearchSourceRegistry>();
registry.remove_source(id.as_str()).await;
save_server(&server);
persist_servers(&app_handle)
.await
.expect("failed to save servers");
}
Ok(())
} }
#[tauri::command] #[tauri::command]
#[function_name::named]
pub async fn logout_coco_server<R: Runtime>( pub async fn logout_coco_server<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
id: String, id: String,
) -> Result<(), String> { ) -> Result<(), String> {
log::debug!("Attempting to log out server by id: {}", &id); log::debug!("Attempting to log out server by id: {}", &id);
// Check if server token exists
if let Some(_token) = get_server_token(id.as_str()).await? {
log::debug!("Found server token for id: {}", &id);
// Remove the server token from cache
remove_server_token(id.as_str());
// Persist the updated tokens
if let Err(e) = persist_servers_token(&app_handle) {
log::debug!("Failed to save tokens for id: {}. Error: {:?}", &id, &e);
return Err(format!("Failed to save tokens: {}", &e));
}
} else {
// Log the case where server token is not found
log::debug!("No server token found for id: {}", &id);
}
// Check if the server exists // Check if the server exists
if let Some(mut server) = get_server_by_id(id.as_str()) { let Some(mut server) = get_server_by_id(id.as_str()).await else {
log::debug!("Found server for id: {}", &id); panic!(
"[{}()] invoked with a server [{}] that does not exist! Mismatched states between frontend and backend!",
function_name!(),
id
);
};
// Clear server profile // Clear server profile
server.profile = None; server.profile = None;
let _ = mark_server_as_offline(app_handle.clone(), id.as_str()).await; // Logging out from a non-public Coco server makes it unavailable
if !server.public {
server.available = false;
}
// Save the updated server data // Save the updated server data
save_server(&server); save_server(&server).await;
// Persist the updated server data // Persist the updated server data
if let Err(e) = persist_servers(&app_handle).await { if let Err(e) = persist_servers(&app_handle).await {
log::debug!("Failed to save server for id: {}. Error: {:?}", &id, &e); log::debug!("Failed to save server for id: {}. Error: {:?}", &id, &e);
return Err(format!("Failed to save server: {}", &e)); return Err(format!("Failed to save server: {}", &e));
} }
let has_token = get_server_token(id.as_str()).await.is_some();
if server.public {
if has_token {
panic!("Public Coco server won't have token")
}
} else { } else {
// Log the case where server is not found assert!(
log::debug!("No server found for id: {}", &id); has_token,
return Err(format!("No server found for id: {}", id)); "This is a non-public Coco server, and it is logged in, we should have a token"
);
// Remove the server token from cache
remove_server_token(id.as_str()).await;
// Persist the updated tokens
if let Err(e) = persist_servers_token(&app_handle).await {
log::debug!("Failed to save tokens for id: {}. Error: {:?}", &id, &e);
return Err(format!("Failed to save tokens: {}", &e));
}
}
// Remove it from the search source if it becomes unavailable
if !server.available {
let registry = app_handle.state::<SearchSourceRegistry>();
registry.remove_source(id.as_str()).await;
} }
log::debug!("Successfully logged out server with id: {}", &id); log::debug!("Successfully logged out server with id: {}", &id);
Ok(()) Ok(())
} }
/// Removes the trailing slash from the server's endpoint if present. /// Helper function to remove the trailing slash from the server's endpoint if present.
fn trim_endpoint_last_forward_slash(server: &mut Server) { fn trim_endpoint_last_forward_slash(server: &mut Server) {
if server.endpoint.ends_with('/') { let endpoint = &mut server.endpoint;
server.endpoint.pop(); // Remove the last character while endpoint.ends_with('/') {
while server.endpoint.ends_with('/') { endpoint.pop();
server.endpoint.pop();
}
} }
} }
@@ -591,8 +700,12 @@ fn provider_info_url(endpoint: &str) -> String {
format!("{endpoint}/provider/_info") format!("{endpoint}/provider/_info")
} }
#[test] #[cfg(test)]
fn test_trim_endpoint_last_forward_slash() { mod tests {
use super::*;
#[test]
fn test_trim_endpoint_last_forward_slash() {
let mut server = Server { let mut server = Server {
id: "test".to_string(), id: "test".to_string(),
builtin: false, builtin: false,
@@ -629,4 +742,5 @@ fn test_trim_endpoint_last_forward_slash() {
trim_endpoint_last_forward_slash(&mut server); trim_endpoint_last_forward_slash(&mut server);
assert_eq!(server.endpoint, "https://example.com"); assert_eq!(server.endpoint, "https://example.com");
}
} }

View File

@@ -2,7 +2,7 @@ use crate::server::http_client::HttpClient;
use futures_util::StreamExt; use futures_util::StreamExt;
use http::Method; use http::Method;
use serde_json::json; use serde_json::json;
use tauri::{command, AppHandle, Emitter, Runtime}; use tauri::{AppHandle, Emitter, Runtime, command};
#[command] #[command]
pub async fn synthesize<R: Runtime>( pub async fn synthesize<R: Runtime>(

View File

@@ -1,7 +1,7 @@
use crate::common::http::get_response_body_text; use crate::common::http::get_response_body_text;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{from_str, Value}; use serde_json::{Value, from_str};
use tauri::command; use tauri::command;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]

View File

@@ -4,12 +4,12 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter, Runtime}; use tauri::{AppHandle, Emitter, Runtime};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::MaybeTlsStream; use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::{connect_async_tls_with_config, Connector}; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::{Connector, connect_async_tls_with_config};
#[derive(Default)] #[derive(Default)]
pub struct WebSocketManager { pub struct WebSocketManager {
connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>, connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>,
@@ -53,9 +53,11 @@ pub async fn connect_to_server<R: Runtime>(
// Disconnect old connection first // Disconnect old connection first
disconnect(client_id.clone(), state.clone()).await.ok(); disconnect(client_id.clone(), state.clone()).await.ok();
let server = get_server_by_id(&id).ok_or(format!("Server with ID {} not found", id))?; let server = get_server_by_id(&id)
.await
.ok_or(format!("Server with ID {} not found", id))?;
let endpoint = convert_to_websocket(&server.endpoint)?; let endpoint = convert_to_websocket(&server.endpoint)?;
let token = get_server_token(&id).await?.map(|t| t.access_token.clone()); let token = get_server_token(&id).await.map(|t| t.access_token.clone());
let mut request = let mut request =
tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint) tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint)

View File

@@ -1,6 +1,9 @@
//credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs //! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
use tauri::{App, Emitter, EventTarget, WebviewWindow};
use tauri_nspanel::{cocoa::appkit::NSWindowCollectionBehavior, panel_delegate, WebviewWindowExt}; use cocoa::appkit::NSWindow;
use tauri::Manager;
use tauri::{App, AppHandle, Emitter, EventTarget, WebviewWindow};
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
use crate::common::MAIN_WINDOW_LABEL; use crate::common::MAIN_WINDOW_LABEL;
@@ -29,7 +32,7 @@ pub fn platform(
// Share the window across all desktop spaces and full screen // Share the window across all desktop spaces and full screen
panel.set_collection_behaviour( panel.set_collection_behaviour(
NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary | NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary, | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary,
); );
@@ -78,3 +81,50 @@ pub fn platform(
// Set the delegate object for the window to handle window events // Set the delegate object for the window to handle window events
panel.set_delegate(delegate); panel.set_delegate(delegate);
} }
/// Change NS window attribute between `NSWindowCollectionBehaviorCanJoinAllSpaces`
/// and `NSWindowCollectionBehaviorMoveToActiveSpace` accordingly.
///
/// NOTE: this tauri command is not async because we should run it in the main
/// thread, or `ns_window.setCollectionBehavior_(collection_behavior)` would lead
/// to UB.
#[tauri::command]
pub(crate) fn toggle_move_to_active_space_attribute(tauri_app_hanlde: AppHandle) {
use cocoa::appkit::NSWindowCollectionBehavior;
use cocoa::base::id;
let main_window = tauri_app_hanlde
.get_webview_window(MAIN_WINDOW_LABEL)
.unwrap();
let ns_window = main_window.ns_window().unwrap() as id;
let mut collection_behavior = unsafe { ns_window.collectionBehavior() };
let join_all_spaces = collection_behavior
.contains(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
let move_to_active_space = collection_behavior
.contains(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
match (join_all_spaces, move_to_active_space) {
(true, false) => {
collection_behavior
.remove(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
collection_behavior
.insert(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
}
(false, true) => {
collection_behavior
.remove(NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace);
collection_behavior
.insert(NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces);
}
_ => {
panic!(
"invalid NS window attribute, NSWindowCollectionBehaviorCanJoinAllSpaces is set [{}], NSWindowCollectionBehaviorMoveToActiveSpace is set [{}]",
join_all_spaces, move_to_active_space
);
}
}
unsafe {
ns_window.setCollectionBehavior_(collection_behavior);
}
}

View File

@@ -1,5 +1,5 @@
use crate::{hide_coco, show_coco, COCO_TAURI_STORE}; use crate::{COCO_TAURI_STORE, hide_coco, show_coco};
use tauri::{async_runtime, App, AppHandle, Manager, Runtime}; use tauri::{App, AppHandle, Manager, Runtime, async_runtime};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use tauri_plugin_store::{JsonValue, StoreExt}; use tauri_plugin_store::{JsonValue, StoreExt};

View File

@@ -1,4 +1,3 @@
#[derive(Debug, Clone, PartialEq, Copy)] #[derive(Debug, Clone, PartialEq, Copy)]
pub(crate) enum FileType { pub(crate) enum FileType {
Folder, Folder,
@@ -51,7 +50,6 @@ pub(crate) enum FileType {
Unknown, Unknown,
} }
async fn get_file_type(path: &str) -> FileType { async fn get_file_type(path: &str) -> FileType {
let path = camino::Utf8Path::new(path); let path = camino::Utf8Path::new(path);
@@ -116,7 +114,6 @@ async fn get_file_type(path: &str) -> FileType {
} }
} }
fn type_to_icon(ty: FileType) -> &'static str { fn type_to_icon(ty: FileType) -> &'static str {
match ty { match ty {
FileType::Folder => "font_file_folder", FileType::Folder => "font_file_folder",
@@ -170,7 +167,6 @@ fn type_to_icon(ty: FileType) -> &'static str {
} }
} }
#[tauri::command] #[tauri::command]
pub(crate) async fn get_file_icon(path: String) -> &'static str { pub(crate) async fn get_file_icon(path: String) -> &'static str {
let ty = get_file_type(path.as_str()).await; let ty = get_file_type(path.as_str()).await;

View File

@@ -1,14 +1,20 @@
pub(crate) mod app_lang;
pub(crate) mod file; pub(crate) mod file;
pub(crate) mod platform; pub(crate) mod platform;
pub(crate) mod app_lang; pub(crate) mod updater;
use std::{path::Path, process::Command}; use std::{path::Path, process::Command};
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime};
use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::ShellExt;
/// We use this env variable to determine the DE on Linux.
const XDG_CURRENT_DESKTOP: &str = "XDG_CURRENT_DESKTOP";
#[derive(Debug, PartialEq)]
enum LinuxDesktopEnvironment { enum LinuxDesktopEnvironment {
Gnome, Gnome,
Kde, Kde,
Unsupported { xdg_current_desktop: String },
} }
impl LinuxDesktopEnvironment { impl LinuxDesktopEnvironment {
@@ -34,6 +40,14 @@ impl LinuxDesktopEnvironment {
.arg(path) .arg(path)
.output() .output()
.map_err(|e| e.to_string())?, .map_err(|e| e.to_string())?,
Self::Unsupported {
xdg_current_desktop,
} => {
return Err(format!(
"Cannot open apps as this Linux desktop environment [{}] is not supported",
xdg_current_desktop
));
}
}; };
if !cmd_output.status.success() { if !cmd_output.status.success() {
@@ -48,20 +62,23 @@ impl LinuxDesktopEnvironment {
} }
} }
/// None means that it is likely that we do not have a desktop environment.
fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> { fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
let de_os_str = std::env::var_os("XDG_CURRENT_DESKTOP")?; let de_os_str = std::env::var_os(XDG_CURRENT_DESKTOP)?;
let de_str = de_os_str let de_str = de_os_str.into_string().unwrap_or_else(|_os_string| {
.into_string() panic!("${} should be UTF-8 encoded", XDG_CURRENT_DESKTOP);
.expect("$XDG_CURRENT_DESKTOP should be UTF-8 encoded"); });
let de = match de_str.as_str() { let de = match de_str.as_str() {
"GNOME" => LinuxDesktopEnvironment::Gnome, "GNOME" => LinuxDesktopEnvironment::Gnome,
// Ubuntu uses "ubuntu:GNOME" instead of just "GNOME", they really love
// their distro name.
"ubuntu:GNOME" => LinuxDesktopEnvironment::Gnome,
"KDE" => LinuxDesktopEnvironment::Kde, "KDE" => LinuxDesktopEnvironment::Kde,
unsupported_de => unimplemented!( _ => LinuxDesktopEnvironment::Unsupported {
"This desktop environment [{}] has not been supported yet", xdg_current_desktop: de_str,
unsupported_de },
),
}; };
Some(de) Some(de)
@@ -76,7 +93,7 @@ pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<
let borrowed_path = Path::new(&path); let borrowed_path = Path::new(&path);
if let Some(file_extension) = borrowed_path.extension() { if let Some(file_extension) = borrowed_path.extension() {
if file_extension == "desktop" { if file_extension == "desktop" {
let desktop_environment = get_linux_desktop_environment().expect("The Linux OS is running without a desktop, Coco could never run in such a environment"); let desktop_environment = get_linux_desktop_environment().expect("The Linux OS is running without a desktop, Coco could never run in such an environment");
return desktop_environment.launch_app_via_desktop_file(path); return desktop_environment.launch_app_via_desktop_file(path);
} }
} }
@@ -87,3 +104,55 @@ pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<
.open(path, None) .open(path, None)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[cfg(test)]
mod tests {
use super::*;
// This test modifies env var XDG_CURRENT_DESKTOP, which is kinda unsafe
// but considering this is just test, it is ok to do so.
#[test]
fn test_get_linux_desktop_environment() {
// SAFETY: Rust code won't modify/read XDG_CURRENT_DESKTOP concurrently, we
// have no guarantee from the underlying C code.
unsafe {
// Save the original value if it exists
let original_value = std::env::var_os(XDG_CURRENT_DESKTOP);
// Test when XDG_CURRENT_DESKTOP is not set
std::env::remove_var(XDG_CURRENT_DESKTOP);
assert!(get_linux_desktop_environment().is_none());
// Test GNOME
std::env::set_var(XDG_CURRENT_DESKTOP, "GNOME");
let result = get_linux_desktop_environment();
assert_eq!(result.unwrap(), LinuxDesktopEnvironment::Gnome);
// Test ubuntu:GNOME
std::env::set_var(XDG_CURRENT_DESKTOP, "ubuntu:GNOME");
let result = get_linux_desktop_environment();
assert_eq!(result.unwrap(), LinuxDesktopEnvironment::Gnome);
// Test KDE
std::env::set_var(XDG_CURRENT_DESKTOP, "KDE");
let result = get_linux_desktop_environment();
assert_eq!(result.unwrap(), LinuxDesktopEnvironment::Kde);
// Test unsupported desktop environment
std::env::set_var(XDG_CURRENT_DESKTOP, "XFCE");
let result = get_linux_desktop_environment();
assert_eq!(
result.unwrap(),
LinuxDesktopEnvironment::Unsupported {
xdg_current_desktop: "XFCE".into()
}
);
// Restore the original value
match original_value {
Some(value) => std::env::set_var(XDG_CURRENT_DESKTOP, value),
None => std::env::remove_var(XDG_CURRENT_DESKTOP),
}
}
}
}

View File

@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use derive_more::Display; use derive_more::Display;
use serde::{Deserialize, Serialize};
use std::borrow::Cow; use std::borrow::Cow;
#[derive(Debug, Deserialize, Serialize, Copy, Clone, Hash, PartialEq, Eq, Display)] #[derive(Debug, Deserialize, Serialize, Copy, Clone, Hash, PartialEq, Eq, Display)]
@@ -13,7 +13,6 @@ pub(crate) enum Platform {
Windows, Windows,
} }
impl Platform { impl Platform {
/// Helper function to determine the current platform. /// Helper function to determine the current platform.
pub(crate) fn current() -> Platform { pub(crate) fn current() -> Platform {
@@ -26,16 +25,10 @@ impl Platform {
/// Return the `X-OS-NAME` HTTP request header. /// Return the `X-OS-NAME` HTTP request header.
pub(crate) fn to_os_name_http_header_str(&self) -> Cow<'static, str> { pub(crate) fn to_os_name_http_header_str(&self) -> Cow<'static, str> {
match self { match self {
Self::Macos => { Self::Macos => Cow::Borrowed("macos"),
Cow::Borrowed("macos") Self::Windows => Cow::Borrowed("windows"),
}
Self::Windows => {
Cow::Borrowed("windows")
}
// For Linux, we need the actual distro `ID`, not just a "linux". // For Linux, we need the actual distro `ID`, not just a "linux".
Self::Linux => { Self::Linux => Cow::Owned(sysinfo::System::distribution_id()),
Cow::Owned(sysinfo::System::distribution_id())
}
} }
} }
} }

View File

@@ -0,0 +1,67 @@
use semver::Version;
use tauri_plugin_updater::RemoteRelease;
/// Helper function to extract the build number out of `version`.
///
/// If the version string is in the `x.y.z` format and does not include a build
/// number, we assume a build number of 0.
fn extract_version_number(version: &Version) -> u32 {
let pre = &version.pre;
if pre.is_empty() {
// A special value for the versions that do not have array
0
} else {
let pre_str = pre.as_str();
let build_number_str = {
match pre_str.strip_prefix("SNAPSHOT-") {
Some(str) => str,
None => pre_str,
}
};
let build_number : u32 = build_number_str.parse().unwrap_or_else(|e| {
panic!(
"invalid build number, cannot parse [{}] to a valid build number, error [{}], version [{}]",
build_number_str, e, version
)
});
build_number
}
}
/// # Local version format
///
/// Packages built in our CI use the following format:
///
/// * `x.y.z-SNAPSHOT-<build number>`
/// * `x.y.z-<build number>`
///
/// If you build Coco from src, the version will be in format `x.y.z`
///
/// # Remote version format
///
/// `x.y.z-<build number>`
///
/// # How we compare versions
///
/// We compare versions based solely on the build number.
/// If the version string is in the `x.y.z` format and does not include a build number,
/// we assume a build number of 0. As a result, such versions are considered older
/// than any version with an explicit build number.
pub(crate) fn custom_version_comparator(local: Version, remote_release: RemoteRelease) -> bool {
let remote = remote_release.version;
let local_build_number = extract_version_number(&local);
let remote_build_number = extract_version_number(&remote);
let should_update = remote_build_number > local_build_number;
log::debug!(
"custom version comparator invoked, local version [{}], remote version [{}], should update [{}]",
local,
remote,
should_update
);
should_update
}

View File

@@ -1,7 +1,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { import {
ServerTokenResponse,
Server, Server,
Connector, Connector,
DataSource, DataSource,
@@ -17,6 +17,24 @@ import {
} from "@/types/commands"; } from "@/types/commands";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { useAuthStore } from "@/stores/authStore"; import { useAuthStore } from "@/stores/authStore";
import { useConnectStore } from "@/stores/connectStore";
export function handleLogout(serverId?: string) {
const setIsCurrentLogin = useAuthStore.getState().setIsCurrentLogin;
const { currentService, setCurrentService, serverList, setServerList } =
useConnectStore.getState();
const id = serverId || currentService?.id;
if (!id) return;
setIsCurrentLogin(false);
emit("login_or_logout", false);
if (currentService?.id === id) {
setCurrentService({ ...currentService, profile: null });
}
const updatedServerList = serverList.map((server) =>
server.id === id ? { ...server, profile: null } : server
);
setServerList(updatedServerList);
}
// Endpoints that don't require authentication // Endpoints that don't require authentication
const WHITELIST_SERVERS = [ const WHITELIST_SERVERS = [
@@ -37,7 +55,14 @@ async function invokeWithErrorHandler<T>(
args?: Record<string, any> args?: Record<string, any>
): Promise<T> { ): Promise<T> {
const isCurrentLogin = useAuthStore.getState().isCurrentLogin; const isCurrentLogin = useAuthStore.getState().isCurrentLogin;
if (!WHITELIST_SERVERS.includes(command) && !isCurrentLogin) { const currentService = useConnectStore.getState().currentService;
// Not logged in
console.log(command, isCurrentLogin, currentService?.profile);
if (
!WHITELIST_SERVERS.includes(command) &&
(!isCurrentLogin || !currentService?.profile)
) {
console.error("This command requires authentication"); console.error("This command requires authentication");
throw new Error("This command requires authentication"); throw new Error("This command requires authentication");
} }
@@ -67,15 +92,16 @@ async function invokeWithErrorHandler<T>(
return result; return result;
} catch (error: any) { } catch (error: any) {
const errorMessage = error || "Command execution failed"; const errorMessage = error || "Command execution failed";
// 401 Unauthorized
if (errorMessage.includes("Unauthorized")) {
handleLogout();
} else {
addError(command + ":" + errorMessage, "error"); addError(command + ":" + errorMessage, "error");
}
throw error; throw error;
} }
} }
export function get_server_token(id: string): Promise<ServerTokenResponse> {
return invokeWithErrorHandler(`get_server_token`, { id });
}
export function list_coco_servers(): Promise<Server[]> { export function list_coco_servers(): Promise<Server[]> {
return invokeWithErrorHandler(`list_coco_servers`); return invokeWithErrorHandler(`list_coco_servers`);
} }
@@ -221,13 +247,16 @@ export function open_session_chat({
export function cancel_session_chat({ export function cancel_session_chat({
serverId, serverId,
sessionId, sessionId,
queryParams,
}: { }: {
serverId: string; serverId: string;
sessionId: string; sessionId: string;
queryParams?: Record<string, any>;
}): Promise<string> { }): Promise<string> {
return invokeWithErrorHandler(`cancel_session_chat`, { return invokeWithErrorHandler(`cancel_session_chat`, {
serverId, serverId,
sessionId, sessionId,
queryParams,
}); });
} }
@@ -254,15 +283,18 @@ export function chat_create({
serverId, serverId,
message, message,
queryParams, queryParams,
clientId,
}: { }: {
serverId: string; serverId: string;
message: string; message: string;
queryParams?: Record<string, any>; queryParams?: Record<string, any>;
clientId: string;
}): Promise<GetResponse> { }): Promise<GetResponse> {
return invokeWithErrorHandler(`chat_create`, { return invokeWithErrorHandler(`chat_create`, {
serverId, serverId,
message, message,
queryParams, queryParams,
clientId,
}); });
} }
@@ -293,17 +325,20 @@ export function chat_chat({
sessionId, sessionId,
message, message,
queryParams, queryParams,
clientId,
}: { }: {
serverId: string; serverId: string;
sessionId: string; sessionId: string;
message: string; message: string;
queryParams?: Record<string, any>; queryParams?: Record<string, any>;
clientId: string;
}): Promise<string> { }): Promise<string> {
return invokeWithErrorHandler(`chat_chat`, { return invokeWithErrorHandler(`chat_chat`, {
serverId, serverId,
sessionId, sessionId,
message, message,
queryParams, queryParams,
clientId,
}); });
} }
@@ -382,3 +417,7 @@ export const query_coco_fusion = (payload: {
...payload, ...payload,
}); });
}; };
export const get_app_search_source = () => {
return invokeWithErrorHandler<void>("get_app_search_source");
};

View File

@@ -35,3 +35,7 @@ export function show_check(): Promise<void> {
export function hide_check(): Promise<void> { export function hide_check(): Promise<void> {
return invoke('hide_check'); return invoke('hide_check');
} }
export function toggle_move_to_active_space_attribute(): Promise<void> {
return invoke('toggle_move_to_active_space_attribute');
}

View File

@@ -40,6 +40,7 @@ interface ChatAIProps {
assistantIDs?: string[]; assistantIDs?: string[];
startPage?: StartPage; startPage?: StartPage;
formatUrl?: (data: any) => string; formatUrl?: (data: any) => string;
instanceId?: string;
} }
export interface ChatAIRef { export interface ChatAIRef {
@@ -66,6 +67,7 @@ const ChatAI = memo(
assistantIDs, assistantIDs,
startPage, startPage,
formatUrl, formatUrl,
instanceId,
}, },
ref ref
) => { ) => {
@@ -75,7 +77,8 @@ const ChatAI = memo(
clearChat: clearChat, clearChat: clearChat,
})); }));
const { curChatEnd, setCurChatEnd } = useChatStore(); const curChatEnd = useChatStore((state) => state.curChatEnd);
const setCurChatEnd = useChatStore((state) => state.setCurChatEnd);
const isTauri = useAppStore((state) => state.isTauri); const isTauri = useAppStore((state) => state.isTauri);
@@ -92,6 +95,7 @@ const ChatAI = memo(
const [timedoutShow, setTimedoutShow] = useState(false); const [timedoutShow, setTimedoutShow] = useState(false);
const curIdRef = useRef(""); const curIdRef = useRef("");
const curSessionIdRef = useRef("");
const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen); const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen);
const [chats, setChats] = useState<Chat[]>([]); const [chats, setChats] = useState<Chat[]>([]);
@@ -175,17 +179,21 @@ const ChatAI = memo(
clearAllChunkData, clearAllChunkData,
setQuestion, setQuestion,
curIdRef, curIdRef,
curSessionIdRef,
setChats, setChats,
dealMsgRef, dealMsgRef,
setLoadingStep,
isChatPage,
isSearchActive, isSearchActive,
isDeepThinkActive, isDeepThinkActive,
isMCPActive, isMCPActive,
changeInput, changeInput,
showChatHistory showChatHistory,
); );
const { dealMsg } = useMessageHandler( const { dealMsg } = useMessageHandler(
curIdRef, curIdRef,
curSessionIdRef,
setCurChatEnd, setCurChatEnd,
setTimedoutShow, setTimedoutShow,
(chat) => cancelChat(chat || activeChat), (chat) => cancelChat(chat || activeChat),
@@ -229,7 +237,7 @@ const ChatAI = memo(
return; return;
} }
if (!activeChat?._id) { if (!activeChat?._id) {
await createNewChat(value, activeChat); await createNewChat(value);
} else { } else {
await handleSendMessage(value, activeChat); await handleSendMessage(value, activeChat);
} }
@@ -254,7 +262,8 @@ const ChatAI = memo(
const onSelectChat = useCallback( const onSelectChat = useCallback(
async (chat: Chat) => { async (chat: Chat) => {
setTimedoutShow(false); setTimedoutShow(false);
clearAllChunkData();
await clearAllChunkData();
await cancelChat(activeChat); await cancelChat(activeChat);
await chatClose(activeChat); await chatClose(activeChat);
const response = await openSessionChat(chat); const response = await openSessionChat(chat);
@@ -359,6 +368,7 @@ const ChatAI = memo(
)} )}
<div <div
data-tauri-drag-region data-tauri-drag-region
data-chat-instance={instanceId}
className={`flex flex-col rounded-md h-full overflow-hidden relative`} className={`flex flex-col rounded-md h-full overflow-hidden relative`}
> >
<ChatHeader <ChatHeader
@@ -376,7 +386,6 @@ const ChatAI = memo(
<> <>
<ChatContent <ChatContent
activeChat={activeChat} activeChat={activeChat}
curChatEnd={curChatEnd}
query_intent={query_intent} query_intent={query_intent}
tools={tools} tools={tools}
fetch_source={fetch_source} fetch_source={fetch_source}
@@ -392,6 +401,7 @@ const ChatAI = memo(
} }
getFileUrl={getFileUrl} getFileUrl={getFileUrl}
formatUrl={formatUrl} formatUrl={formatUrl}
curIdRef={curIdRef}
/> />
<Splash assistantIDs={assistantIDs} startPage={startPage} /> <Splash assistantIDs={assistantIDs} startPage={startPage} />
</> </>

View File

@@ -5,7 +5,7 @@ import { ChatMessage } from "@/components/ChatMessage";
import { Greetings } from "./Greetings"; import { Greetings } from "./Greetings";
// import FileList from "@/components/Assistant/FileList"; // import FileList from "@/components/Assistant/FileList";
import { useChatScroll } from "@/hooks/useChatScroll"; import { useChatScroll } from "@/hooks/useChatScroll";
// import { useChatStore } from "@/stores/chatStore"; import { useChatStore } from "@/stores/chatStore";
import type { Chat, IChunkData } from "@/types/chat"; import type { Chat, IChunkData } from "@/types/chat";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
// import SessionFile from "./SessionFile"; // import SessionFile from "./SessionFile";
@@ -13,7 +13,6 @@ import ScrollToBottom from "@/components/Common/ScrollToBottom";
interface ChatContentProps { interface ChatContentProps {
activeChat?: Chat; activeChat?: Chat;
curChatEnd: boolean;
query_intent?: IChunkData; query_intent?: IChunkData;
tools?: IChunkData; tools?: IChunkData;
fetch_source?: IChunkData; fetch_source?: IChunkData;
@@ -27,11 +26,11 @@ interface ChatContentProps {
handleSendMessage: (content: string, newChat?: Chat) => void; handleSendMessage: (content: string, newChat?: Chat) => void;
getFileUrl: (path: string) => string; getFileUrl: (path: string) => string;
formatUrl?: (data: any) => string; formatUrl?: (data: any) => string;
curIdRef: React.MutableRefObject<string>;
} }
export const ChatContent = ({ export const ChatContent = ({
activeChat, activeChat,
curChatEnd,
query_intent, query_intent,
tools, tools,
fetch_source, fetch_source,
@@ -60,6 +59,8 @@ export const ChatContent = ({
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const visibleStartPage = useConnectStore((state) => state.visibleStartPage); const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
const curChatEnd = useChatStore((state) => state.curChatEnd);
useEffect(() => { useEffect(() => {
setIsAtBottom(true); setIsAtBottom(true);
setCurrentSessionId(activeChat?._id); setCurrentSessionId(activeChat?._id);
@@ -68,7 +69,7 @@ export const ChatContent = ({
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
}, [ }, [
activeChat?.id, activeChat?._id,
query_intent?.message_chunk, query_intent?.message_chunk,
fetch_source?.message_chunk, fetch_source?.message_chunk,
pick_source?.message_chunk, pick_source?.message_chunk,
@@ -122,7 +123,7 @@ export const ChatContent = ({
deep_read || deep_read ||
think || think ||
response) && response) &&
activeChat?._id ? ( activeChat?._source?.id ? (
<ChatMessage <ChatMessage
key={"current"} key={"current"}
message={{ message={{

View File

@@ -7,12 +7,12 @@ import PinIcon from "@/icons/Pin";
import WindowsFullIcon from "@/icons/WindowsFull"; import WindowsFullIcon from "@/icons/WindowsFull";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import type { Chat } from "@/types/chat"; import type { Chat } from "@/types/chat";
import platformAdapter from "@/utils/platformAdapter";
import VisibleKey from "../Common/VisibleKey"; import VisibleKey from "../Common/VisibleKey";
import { useShortcutsStore } from "@/stores/shortcutsStore"; import { useShortcutsStore } from "@/stores/shortcutsStore";
import { HISTORY_PANEL_ID } from "@/constants"; import { HISTORY_PANEL_ID } from "@/constants";
import { AssistantList } from "./AssistantList"; import { AssistantList } from "./AssistantList";
import { ServerList } from "./ServerList"; import { ServerList } from "./ServerList";
import { useTogglePin } from "@/hooks/useTogglePin";
interface ChatHeaderProps { interface ChatHeaderProps {
clearChat: () => void; clearChat: () => void;
@@ -35,32 +35,11 @@ export function ChatHeader({
showChatHistory = true, showChatHistory = true,
assistantIDs, assistantIDs,
}: ChatHeaderProps) { }: ChatHeaderProps) {
const isPinned = useAppStore((state) => state.isPinned); const { isTauri } = useAppStore();
const setIsPinned = useAppStore((state) => state.setIsPinned); const { isPinned, togglePin } = useTogglePin();
const isTauri = useAppStore((state) => state.isTauri); const { historicalRecords, newSession, fixedWindow, external } =
const historicalRecords = useShortcutsStore((state) => { useShortcutsStore();
return state.historicalRecords;
});
const newSession = useShortcutsStore((state) => {
return state.newSession;
});
const fixedWindow = useShortcutsStore((state) => {
return state.fixedWindow;
});
const external = useShortcutsStore((state) => state.external);
const togglePin = async () => {
try {
const newPinned = !isPinned;
await platformAdapter.setAlwaysOnTop(newPinned);
setIsPinned(newPinned);
} catch (err) {
console.error("Failed to toggle window pin state:", err);
setIsPinned(isPinned);
}
};
return ( return (
<header <header

View File

@@ -51,13 +51,19 @@ export function ServerList({ clearChat }: ServerListProps) {
const fetchServers = useCallback( const fetchServers = useCallback(
async (resetSelection: boolean) => { async (resetSelection: boolean) => {
platformAdapter platformAdapter.commands("list_coco_servers").then((res: any) => {
.commands("list_coco_servers") console.log("list_coco_servers", res);
.then((res: any) => { if (!Array.isArray(res)) {
const enabledServers = (res as IServer[]).filter( // If res is not an array, it might be an error message or something else.
// Log it and don't proceed.
// console.log("list_coco_servers did not return an array:", res);
setServerList([]); // Clear the list or handle as appropriate
return;
}
const enabledServers = (res as IServer[])?.filter(
(server) => server.enabled && server.available (server) => server.enabled && server.available
); );
//console.log("list_coco_servers", enabledServers);
setServerList(enabledServers); setServerList(enabledServers);
if (resetSelection && enabledServers.length > 0) { if (resetSelection && enabledServers.length > 0) {
@@ -71,9 +77,6 @@ export function ServerList({ clearChat }: ServerListProps) {
switchServer(enabledServers[enabledServers.length - 1]); switchServer(enabledServers[enabledServers.length - 1]);
} }
} }
})
.catch((err: any) => {
console.error(err);
}); });
}, },
[currentService?.id] [currentService?.id]
@@ -147,7 +150,9 @@ export function ServerList({ clearChat }: ServerListProps) {
} }
}; };
useKeyPress(["uparrow", "downarrow", "enter"], (event, key) => { useKeyPress(
["uparrow", "downarrow", "enter"],
(event, key) => {
const isClose = isNil(serverListButtonRef.current?.dataset["open"]); const isClose = isNil(serverListButtonRef.current?.dataset["open"]);
const length = serverList.length; const length = serverList.length;
@@ -157,7 +162,9 @@ export function ServerList({ clearChat }: ServerListProps) {
event.preventDefault(); event.preventDefault();
const currentIndex = serverList.findIndex((server) => { const currentIndex = serverList.findIndex((server) => {
return server.id === (highlightId === '' ? currentService?.id : highlightId); return (
server.id === (highlightId === "" ? currentService?.id : highlightId)
);
}); });
let nextIndex = currentIndex; let nextIndex = currentIndex;
@@ -176,9 +183,11 @@ export function ServerList({ clearChat }: ServerListProps) {
serverListButtonRef.current?.click(); serverListButtonRef.current?.click();
} }
} }
}, { },
{
target: popoverRef, target: popoverRef,
}); }
);
const handleMouseMove = useCallback(() => { const handleMouseMove = useCallback(() => {
setHighlightId(""); setHighlightId("");
@@ -199,7 +208,8 @@ export function ServerList({ clearChat }: ServerListProps) {
<PopoverPanel <PopoverPanel
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
className="absolute right-0 z-10 mt-2 min-w-[240px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700"> className="absolute right-0 z-10 mt-2 min-w-[240px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700"
>
<div className="p-3"> <div className="p-3">
<div className="flex items-center justify-between mb-3 whitespace-nowrap"> <div className="flex items-center justify-between mb-3 whitespace-nowrap">
<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">
@@ -221,7 +231,8 @@ export function ServerList({ clearChat }: ServerListProps) {
> >
<VisibleKey shortcut="R" onKeyPress={handleRefresh}> <VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCw <RefreshCw
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${isRefreshing ? "animate-spin" : "" className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
isRefreshing ? "animate-spin" : ""
}`} }`}
/> />
</VisibleKey> </VisibleKey>
@@ -235,7 +246,9 @@ export function ServerList({ clearChat }: ServerListProps) {
key={server.id} key={server.id}
onClick={() => switchServer(server)} onClick={() => switchServer(server)}
className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap
${currentService?.id === server.id || highlightId === server.id ${
currentService?.id === server.id ||
highlightId === server.id
? "bg-gray-100 dark:bg-gray-800" ? "bg-gray-100 dark:bg-gray-800"
: "hover:bg-gray-50 dark:hover:bg-gray-800/50" : "hover:bg-gray-50 dark:hover:bg-gray-800/50"
}`} }`}

View File

@@ -43,6 +43,7 @@ export const QueryIntent = ({
useEffect(() => { useEffect(() => {
if (!ChunkData?.message_chunk) return; if (!ChunkData?.message_chunk) return;
if (!loading) { if (!loading) {
try {
const cleanContent = ChunkData.message_chunk.replace(/^"|"$/g, ""); const cleanContent = ChunkData.message_chunk.replace(/^"|"$/g, "");
const allMatches = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/g); const allMatches = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/g);
if (allMatches) { if (allMatches) {
@@ -55,6 +56,9 @@ export const QueryIntent = ({
} }
setData(data); setData(data);
} }
} catch (error) {
console.error("Failed to process message chunk in QueryIntent:", error);
}
} }
}, [ChunkData?.message_chunk, loading]); }, [ChunkData?.message_chunk, loading]);
@@ -79,14 +83,22 @@ export const QueryIntent = ({
<> <>
<Loader className="w-4 h-4 animate-spin text-[#1990FF]" /> <Loader className="w-4 h-4 animate-spin text-[#1990FF]" />
<span className="text-xs text-[#999999] italic"> <span className="text-xs text-[#999999] italic">
{t(`assistant.message.steps.${ChunkData?.chunk_type || Detail.type}`)} {t(
`assistant.message.steps.${
ChunkData?.chunk_type || Detail.type
}`
)}
</span> </span>
</> </>
) : ( ) : (
<> <>
<UnderstandIcon className="w-4 h-4 text-[#38C200]" /> <UnderstandIcon className="w-4 h-4 text-[#38C200]" />
<span className="text-xs text-[#999999]"> <span className="text-xs text-[#999999]">
{t(`assistant.message.steps.${ChunkData?.chunk_type || Detail.type}`)} {t(
`assistant.message.steps.${
ChunkData?.chunk_type || Detail.type
}`
)}
</span> </span>
</> </>
)} )}

View File

@@ -29,7 +29,7 @@ export const UserMessage = ({ messageContent }: UserMessageProps) => {
return ( return (
<div <div
className="flex gap-1 items-center justify-end" className="max-w-full flex gap-1 items-center justify-end"
onMouseEnter={() => setShowCopyButton(true)} onMouseEnter={() => setShowCopyButton(true)}
onMouseLeave={() => setShowCopyButton(false)} onMouseLeave={() => setShowCopyButton(false)}
> >

View File

@@ -176,13 +176,13 @@ export const ChatMessage = memo(function ChatMessage({
return ( return (
<div <div
className={clsx( className={clsx(
"py-8 flex", "w-full py-8 flex",
[isAssistant ? "justify-start" : "justify-end"], [isAssistant ? "justify-start" : "justify-end"],
rootClassName rootClassName
)} )}
> >
<div <div
className={`px-4 flex gap-4 ${ className={`w-full px-4 flex gap-4 ${
isAssistant ? "w-full" : "flex-row-reverse" isAssistant ? "w-full" : "flex-row-reverse"
}`} }`}
> >

View File

@@ -6,13 +6,9 @@ import { Sidebar } from "./Sidebar";
import { Connect } from "./Connect"; import { Connect } from "./Connect";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import {
list_coco_servers,
add_coco_server,
refresh_coco_server_info,
} from "@/commands";
import ServiceInfo from "./ServiceInfo"; import ServiceInfo from "./ServiceInfo";
import ServiceAuth from "./ServiceAuth"; import ServiceAuth from "./ServiceAuth";
import platformAdapter from "@/utils/platformAdapter";
export default function Cloud() { export default function Cloud() {
const SidebarRef = useRef<{ refreshData: () => void }>(null); const SidebarRef = useRef<{ refreshData: () => void }>(null);
@@ -21,11 +17,8 @@ export default function Cloud() {
const [isConnect, setIsConnect] = useState(true); const [isConnect, setIsConnect] = useState(true);
const currentService = useConnectStore((state) => state.currentService); const { currentService, setCurrentService, serverList, setServerList } =
const setCurrentService = useConnectStore((state) => state.setCurrentService); useConnectStore();
const serverList = useConnectStore((state) => state.serverList);
const setServerList = useConnectStore((state) => state.setServerList);
const [refreshLoading, setRefreshLoading] = useState(false); const [refreshLoading, setRefreshLoading] = useState(false);
@@ -41,7 +34,8 @@ export default function Cloud() {
}, [JSON.stringify(currentService)]); }, [JSON.stringify(currentService)]);
const fetchServers = async (resetSelection: boolean) => { const fetchServers = async (resetSelection: boolean) => {
list_coco_servers() platformAdapter
.commands("list_coco_servers")
.then((res: any) => { .then((res: any) => {
if (errors.length > 0) { if (errors.length > 0) {
res = (res || []).map((item: any) => { res = (res || []).map((item: any) => {
@@ -54,7 +48,7 @@ export default function Cloud() {
return item; return item;
}); });
} }
// console.log("list_coco_servers", res); console.log("list_coco_servers", res);
setServerList(res); setServerList(res);
if (resetSelection && res.length > 0) { if (resetSelection && res.length > 0) {
@@ -69,9 +63,6 @@ export default function Cloud() {
} }
} }
}) })
.catch((err: any) => {
console.error(err);
});
}; };
const addServer = (endpointLink: string) => { const addServer = (endpointLink: string) => {
@@ -87,7 +78,8 @@ export default function Cloud() {
setRefreshLoading(true); setRefreshLoading(true);
return add_coco_server(endpointLink) return platformAdapter
.commands("add_coco_server", endpointLink)
.then((res: any) => { .then((res: any) => {
// console.log("add_coco_server", res); // console.log("add_coco_server", res);
fetchServers(false).then((r) => { fetchServers(false).then((r) => {
@@ -103,7 +95,8 @@ export default function Cloud() {
const refreshClick = useCallback( const refreshClick = useCallback(
(id: string) => { (id: string) => {
setRefreshLoading(true); setRefreshLoading(true);
refresh_coco_server_info(id) platformAdapter
.commands("refresh_coco_server_info", id)
.then((res: any) => { .then((res: any) => {
console.log("refresh_coco_server_info", id, res); console.log("refresh_coco_server_info", id, res);
fetchServers(false).then((r) => { fetchServers(false).then((r) => {

View File

@@ -4,7 +4,7 @@ import { RefreshCcw } from "lucide-react";
import { DataSourceItem } from "./DataSourceItem"; import { DataSourceItem } from "./DataSourceItem";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { get_connectors_by_server, datasource_search } from "@/commands"; import platformAdapter from "@/utils/platformAdapter";
export function DataSourcesList({ server }: { server: string }) { export function DataSourcesList({ server }: { server: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -17,7 +17,8 @@ export function DataSourcesList({ server }: { server: string }) {
function initServerAppData() { function initServerAppData() {
setRefreshLoading(true); setRefreshLoading(true);
// fetch connectors data // fetch connectors data
get_connectors_by_server(server) platformAdapter
.commands("get_connectors_by_server", server)
.then((res: any) => { .then((res: any) => {
// console.log("get_connectors_by_server", res); // console.log("get_connectors_by_server", res);
setConnectorData(res, server); setConnectorData(res, server);
@@ -25,7 +26,8 @@ export function DataSourcesList({ server }: { server: string }) {
.finally(() => {}); .finally(() => {});
// fetch datasource data // fetch datasource data
datasource_search({ id: server }) platformAdapter
.commands("datasource_search", { id: server })
.then((res: any) => { .then((res: any) => {
// console.log("datasource_search", res); // console.log("datasource_search", res);
setDatasourceData(res, server); setDatasourceData(res, server);

View File

@@ -2,7 +2,6 @@ import { FC, memo, useCallback, useEffect, useState } from "react";
import { Copy } from "lucide-react"; import { Copy } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { emit } from "@tauri-apps/api/event";
import { import {
getCurrent as getCurrentDeepLinkUrls, getCurrent as getCurrentDeepLinkUrls,
onOpenUrl, onOpenUrl,
@@ -13,8 +12,9 @@ import { UserProfile } from "./UserProfile";
import { OpenURLWithBrowser } from "@/utils"; import { OpenURLWithBrowser } from "@/utils";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { logout_coco_server, handle_sso_callback } from "@/commands";
import { copyToClipboard } from "@/utils"; import { copyToClipboard } from "@/utils";
import platformAdapter from "@/utils/platformAdapter";
import { handleLogout } from "@/commands/servers";
interface ServiceAuthProps { interface ServiceAuthProps {
setRefreshLoading: (loading: boolean) => void; setRefreshLoading: (loading: boolean) => void;
@@ -31,11 +31,6 @@ const ServiceAuth = memo(
const addError = useAppStore((state) => state.addError); const addError = useAppStore((state) => state.addError);
const currentService = useConnectStore((state) => state.currentService); const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore(
(state) => state.setCurrentService
);
const serverList = useConnectStore((state) => state.serverList);
const setServerList = useConnectStore((state) => state.setServerList);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -57,27 +52,18 @@ const ServiceAuth = memo(
setLoading(true); setLoading(true);
}, [ssoRequestID, loading, currentService]); }, [ssoRequestID, loading, currentService]);
const onLogout = useCallback( const onLogout = useCallback((id: string) => {
(id: string) => {
setRefreshLoading(true); setRefreshLoading(true);
logout_coco_server(id) platformAdapter
.commands("logout_coco_server", id)
.then((res: any) => { .then((res: any) => {
console.log("logout_coco_server", id, JSON.stringify(res)); console.log("logout_coco_server", id, JSON.stringify(res));
emit("login_or_logout", false); handleLogout(id);
// update server profile
setCurrentService({ ...currentService, profile: null });
const updatedServerList = serverList.map((server) =>
server.id === id ? { ...server, profile: null } : server
);
console.log("updatedServerList", updatedServerList);
setServerList(updatedServerList);
}) })
.finally(() => { .finally(() => {
setRefreshLoading(false); setRefreshLoading(false);
}); });
}, }, []);
[currentService, serverList]
);
const handleOAuthCallback = useCallback( const handleOAuthCallback = useCallback(
async (code: string | null, serverId: string | null) => { async (code: string | null, serverId: string | null) => {
@@ -88,7 +74,7 @@ const ServiceAuth = memo(
try { try {
console.log("Handling OAuth callback:", { code, serverId }); console.log("Handling OAuth callback:", { code, serverId });
await handle_sso_callback({ await platformAdapter.commands("handle_sso_callback", {
serverId: serverId, // Make sure 'server_id' is the correct argument serverId: serverId, // Make sure 'server_id' is the correct argument
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
code: code, code: code,

View File

@@ -7,7 +7,7 @@ import Tooltip from "@/components/Common/Tooltip";
import SettingsToggle from "@/components/Settings/SettingsToggle"; import SettingsToggle from "@/components/Settings/SettingsToggle";
import { OpenURLWithBrowser } from "@/utils"; import { OpenURLWithBrowser } from "@/utils";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { enable_server, disable_server, remove_coco_server } from "@/commands"; import platformAdapter from "@/utils/platformAdapter";
interface ServiceHeaderProps { interface ServiceHeaderProps {
refreshLoading?: boolean; refreshLoading?: boolean;
@@ -27,9 +27,9 @@ const ServiceHeader = memo(
const enable_coco_server = useCallback( const enable_coco_server = useCallback(
async (enabled: boolean) => { async (enabled: boolean) => {
if (enabled) { if (enabled) {
await enable_server(currentService?.id); await platformAdapter.commands("enable_server", currentService?.id);
} else { } else {
await disable_server(currentService?.id); await platformAdapter.commands("disable_server", currentService?.id);
} }
setCurrentService({ ...currentService, enabled }); setCurrentService({ ...currentService, enabled });
@@ -40,7 +40,7 @@ const ServiceHeader = memo(
); );
const removeServer = (id: string) => { const removeServer = (id: string) => {
remove_coco_server(id).then((res: any) => { platformAdapter.commands("remove_coco_server", id).then((res: any) => {
console.log("remove_coco_server", id, JSON.stringify(res)); console.log("remove_coco_server", id, JSON.stringify(res));
fetchServers(true).then((r) => { fetchServers(true).then((r) => {
console.log("fetchServers", r); console.log("fetchServers", r);

View File

@@ -6,12 +6,14 @@ import { useAppStore } from "@/stores/appStore";
interface ErrorNotificationProps { interface ErrorNotificationProps {
duration?: number; duration?: number;
autoClose?: boolean; autoClose?: boolean;
isTauri?: boolean;
} }
const ErrorNotification = ({ const ErrorNotification = ({
duration = 3000, duration = 3000,
autoClose = true autoClose = true,
}: ErrorNotificationProps) => { isTauri = true,
}: ErrorNotificationProps) => {
const errors = useAppStore((state) => state.errors); const errors = useAppStore((state) => state.errors);
const removeError = useAppStore((state) => state.removeError); const removeError = useAppStore((state) => state.removeError);
@@ -33,7 +35,11 @@ const ErrorNotification = ({
if (errors.length === 0) return null; if (errors.length === 0) return null;
return ( return (
<div className="fixed bottom-10 right-4 z-50 max-w-[calc(100%-32px)] space-y-2"> <div
className={`${
isTauri ? "fixed" : "absolute"
} bottom-10 right-4 z-50 max-w-[calc(100%-32px)] space-y-2`}
>
{errors.map((error) => ( {errors.map((error) => (
<div <div
key={error.id} key={error.id}

View File

@@ -107,6 +107,8 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key !== "Enter") return; if (event.key !== "Enter") return;
event.stopPropagation();
const value = event.currentTarget.value; const value = event.currentTarget.value;
onRename(item._id || "", value); onRename(item._id || "", value);

View File

@@ -19,6 +19,7 @@ import source_default_dark_img from "@/assets/images/source_default_dark.png";
import { useThemeStore } from "@/stores/themeStore"; import { useThemeStore } from "@/stores/themeStore";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import FontIcon from "../Icons/FontIcon"; import FontIcon from "../Icons/FontIcon";
import { useTogglePin } from "@/hooks/useTogglePin";
interface FooterProps { interface FooterProps {
setIsPinnedWeb?: (value: boolean) => void; setIsPinnedWeb?: (value: boolean) => void;
@@ -37,28 +38,16 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
const isDark = useThemeStore((state) => state.isDark); const isDark = useThemeStore((state) => state.isDark);
const { isTauri, isPinned, setIsPinned } = useAppStore(); const { isTauri } = useAppStore();
const { isPinned, togglePin } = useTogglePin({
onPinChange: setIsPinnedWeb,
});
const { setVisible, updateInfo } = useUpdateStore(); const { setVisible, updateInfo } = useUpdateStore();
const { fixedWindow, modifierKey } = useShortcutsStore(); const { fixedWindow, modifierKey } = useShortcutsStore();
const setWindowAlwaysOnTop = useCallback(async (isPinned: boolean) => {
setIsPinnedWeb?.(isPinned);
return platformAdapter.setAlwaysOnTop(isPinned);
}, []);
const togglePin = async () => {
try {
const newPinned = !isPinned;
await setWindowAlwaysOnTop(newPinned);
setIsPinned(newPinned);
} catch (err) {
console.error("Failed to toggle window pin state:", err);
setIsPinned(isPinned);
}
};
const openSetting = useCallback(() => { const openSetting = useCallback(() => {
return platformAdapter.emitEvent("open_settings", ""); return platformAdapter.emitEvent("open_settings", "");
}, []); }, []);

View File

@@ -5,7 +5,7 @@ import {
useReactive, useReactive,
useUnmount, useUnmount,
} from "ahooks"; } from "ahooks";
import { useEffect, useRef, useState } from "react"; import { FC, useEffect, useRef, useState } from "react";
import { noop } from "lodash-es"; import { noop } from "lodash-es";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -17,6 +17,10 @@ import { useAppStore } from "@/stores/appStore";
import { useExtensionsStore } from "@/stores/extensionsStore"; import { useExtensionsStore } from "@/stores/extensionsStore";
import { useShortcutsStore } from "@/stores/shortcutsStore"; import { useShortcutsStore } from "@/stores/shortcutsStore";
interface AskAiProps {
isChatMode: boolean;
}
interface State { interface State {
serverId?: string; serverId?: string;
assistantId?: string; assistantId?: string;
@@ -24,7 +28,9 @@ interface State {
copyButtonId: string; copyButtonId: string;
} }
const AskAi = () => { const AskAi: FC<AskAiProps> = (props) => {
const { isChatMode } = props;
const { const {
askAiMessage, askAiMessage,
setGoAskAi, setGoAskAi,
@@ -162,7 +168,7 @@ const AskAi = () => {
useAsyncEffect(async () => { useAsyncEffect(async () => {
if (!askAiMessage || !state.serverId || !state.assistantId) return; if (!askAiMessage || !state.serverId || !state.assistantId) return;
clearAllChunkData(); await clearAllChunkData();
const { serverId, assistantId } = state; const { serverId, assistantId } = state;
@@ -186,7 +192,7 @@ const AskAi = () => {
useKeyPress( useKeyPress(
`${modifierKey}.enter`, `${modifierKey}.enter`,
async () => { async () => {
if (isTyping) return; if (isChatMode || isTyping) return;
const { serverId, assistantId } = state; const { serverId, assistantId } = state;
@@ -204,7 +210,7 @@ const AskAi = () => {
useKeyPress( useKeyPress(
"enter", "enter",
() => { () => {
if (isTyping || !state.copyButtonId) return; if (isChatMode || isTyping || !state.copyButtonId) return;
const copyButton = document.getElementById(state.copyButtonId); const copyButton = document.getElementById(state.copyButtonId);

View File

@@ -134,10 +134,18 @@ export function useAssistantManager({
return handleAskAi(); return handleAskAi();
} }
if (key === "Enter" && !shiftKey && !isChatMode && isTauri) { if (key === "Enter" && !shiftKey) {
e.preventDefault(); e.preventDefault();
goAskAi ? handleAskAi() : handleSubmit(); if (isTauri && !isChatMode && goAskAi) {
if (!isEmpty(value)) {
e.stopPropagation();
}
return handleAskAi();
}
handleSubmit();
} }
}, },
[ [

View File

@@ -1,17 +1,16 @@
import { useBoolean, useDebounceFn } from "ahooks"; import { useBoolean } from "ahooks";
import { import {
useRef,
useImperativeHandle, useImperativeHandle,
forwardRef, forwardRef,
KeyboardEvent, KeyboardEvent,
useEffect,
useCallback, useCallback,
ChangeEvent,
useRef,
useEffect,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const LINE_HEIGHT = 24; // 1.5rem const MAX_HEIGHT = 240;
const MAX_FIRST_LINE_WIDTH = 470; // Width in pixels for first line
const MAX_HEIGHT = 240; // 15rem
interface AutoResizeTextareaProps { interface AutoResizeTextareaProps {
isChatMode: boolean; isChatMode: boolean;
@@ -21,6 +20,7 @@ interface AutoResizeTextareaProps {
chatPlaceholder?: string; chatPlaceholder?: string;
lineCount?: number; lineCount?: number;
onLineCountChange?: (lineCount: number) => void; onLineCountChange?: (lineCount: number) => void;
firstLineMaxWidth: number;
} }
// Forward ref to allow parent to interact with this component // Forward ref to allow parent to interact with this component
@@ -35,87 +35,15 @@ const AutoResizeTextarea = forwardRef<
setInput, setInput,
handleKeyDown, handleKeyDown,
chatPlaceholder, chatPlaceholder,
lineCount = 1,
onLineCountChange, onLineCountChange,
firstLineMaxWidth,
}, },
ref ref
) => { ) => {
const { t } = useTranslation(); const { t } = useTranslation();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isComposition, { setTrue, setFalse }] = useBoolean(); const [isComposition, { setTrue, setFalse }] = useBoolean();
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Memoize resize logic const calcRef = useRef<HTMLDivElement>(null);
const { run: debouncedResize } = useDebounceFn(
() => {
const textarea = textareaRef.current;
if (!textarea) return;
if (typeof window === "undefined" || typeof document === "undefined")
return;
// Reset height to auto to get the correct scrollHeight
textarea.style.height = "auto";
// Create a hidden span to measure first line width
const span = document.createElement("span");
span.style.visibility = "hidden";
span.style.position = "absolute";
span.style.whiteSpace = "pre";
span.style.font = window.getComputedStyle(textarea).font;
// Get first line content
const content = textarea.value;
const firstLineEnd =
content.indexOf("\n") === -1 ? content.length : content.indexOf("\n");
span.textContent = content.slice(0, firstLineEnd);
document.body.appendChild(span);
// Calculate lines based on first line width
const firstLineWidth = span.offsetWidth;
document.body.removeChild(span);
// Start with 1 line
let lines = 1;
// Add a line if first line exceeds max width
if (firstLineWidth > MAX_FIRST_LINE_WIDTH) {
lines += 1;
}
// Add lines based on scrollHeight for remaining content
const scrollHeight = textarea.scrollHeight;
const remainingLines = Math.floor(
(scrollHeight - LINE_HEIGHT) / LINE_HEIGHT
);
lines += Math.max(0, remainingLines);
// Calculate final height
const newHeight = Math.min(lines * LINE_HEIGHT, MAX_HEIGHT);
// Only update if height actually changed
if (textarea.style.height !== `${newHeight}px`) {
textarea.style.height = `${newHeight}px`;
onLineCountChange?.(lines);
}
},
{ wait: 100 }
);
// Handle input changes and initial setup
useEffect(() => {
if (textareaRef.current) {
debouncedResize();
}
}, [input, debouncedResize]);
useEffect(() => {
if (textareaRef.current) {
requestAnimationFrame(() => {
// Set cursor position to end
const length = textareaRef.current?.value.length || 0;
textareaRef.current?.setSelectionRange(length, length);
});
}
}, [lineCount]);
// Expose methods to the parent via ref // Expose methods to the parent via ref
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -135,14 +63,46 @@ const AutoResizeTextarea = forwardRef<
handleKeyDown?.(event); handleKeyDown?.(event);
}; };
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea || !calcRef.current) return;
if (!calcRef.current) return;
textarea.style.height = "auto";
const computedStyle = getComputedStyle(textarea);
const lineHeight = parseInt(computedStyle.lineHeight);
let height = lineHeight;
let minHeight = lineHeight;
const hasNewline = /[\r\n]/.test(input);
const firstLineExceeds =
calcRef.current?.offsetWidth >= firstLineMaxWidth - 32;
if (hasNewline || firstLineExceeds) {
minHeight = lineHeight * 2;
height = Math.min(
Math.max(minHeight, textarea.scrollHeight),
MAX_HEIGHT
);
}
textarea.style.height = `${height}px`;
textarea.style.minHeight = `${minHeight}px`;
onLineCountChange?.(height / lineHeight);
}, [input, firstLineMaxWidth]);
const handleChange = useCallback( const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => { (event: ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value); setInput(event.currentTarget.value);
}, },
[setInput] [setInput]
); );
return ( return (
<>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
id={isChatMode ? "chat-textarea" : "search-textarea"} id={isChatMode ? "chat-textarea" : "search-textarea"}
@@ -150,7 +110,7 @@ const AutoResizeTextarea = forwardRef<
autoComplete="off" autoComplete="off"
autoCapitalize="none" autoCapitalize="none"
spellCheck="false" spellCheck="false"
className="text-base flex-1 outline-none w-full min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent custom-scrollbar" className="text-base flex-1 outline-none w-full min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent custom-scrollbar resize-none overflow-y-auto"
placeholder={chatPlaceholder || t("search.textarea.placeholder")} placeholder={chatPlaceholder || t("search.textarea.placeholder")}
aria-label={t("search.textarea.ariaLabel")} aria-label={t("search.textarea.ariaLabel")}
value={input} value={input}
@@ -161,14 +121,12 @@ const AutoResizeTextarea = forwardRef<
setTimeout(setFalse, 0); setTimeout(setFalse, 0);
}} }}
rows={1} rows={1}
style={{
resize: "none", // Prevent manual resize
overflow: "auto",
minHeight: "1.5rem",
maxHeight: "13.5rem", // Limit height to 9 rows (9 * 1.5 line-height)
lineHeight: "1.5rem", // Line height to match row height
}}
/> />
<div ref={calcRef} className="absolute whitespace-nowrap -z-10">
{input}
</div>
</>
); );
} }
); );

View File

@@ -142,7 +142,9 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
type === "AI Assistant" || type === "AI Assistant" ||
id === "Extension Store", id === "Extension Store",
clickEvent() { clickEvent() {
copyToClipboard(formatUrl && formatUrl(selectedSearchContent) || url); copyToClipboard(
(formatUrl && formatUrl(selectedSearchContent)) || url
);
}, },
}, },
{ {
@@ -169,7 +171,7 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
name: t("search.contextMenu.copyQuestionAndAnswer"), name: t("search.contextMenu.copyQuestionAndAnswer"),
icon: <Copy />, icon: <Copy />,
keys: isMac ? ["⌘", "L"] : ["Ctrl", "L"], keys: isMac ? ["⌘", "L"] : ["Ctrl", "L"],
shortcut: isMac ? "meta.l" : "ctrl+l", shortcut: isMac ? "meta.l" : "ctrl.l",
hide: category !== "Calculator", hide: category !== "Calculator",
clickEvent() { clickEvent() {
copyToClipboard(`${query.value} = ${result.value}`); copyToClipboard(`${query.value} = ${result.value}`);
@@ -198,7 +200,7 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
} }
}, [selectedSearchContent]); }, [selectedSearchContent]);
useOSKeyPress(["meta.k", "ctrl+k"], () => { useOSKeyPress(["meta.k", "ctrl.k"], () => {
if (isNil(selectedSearchContent) && isNil(selectedExtension)) return; if (isNil(selectedSearchContent) && isNil(selectedExtension)) return;
setVisibleContextMenu(!visibleContextMenu); setVisibleContextMenu(!visibleContextMenu);
@@ -224,19 +226,29 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
} }
}); });
useOSKeyPress(shortcuts, (_, key) => { useOSKeyPress(
shortcuts,
(event, key) => {
if (!visibleContextMenu) return; if (!visibleContextMenu) return;
event.stopPropagation();
let matched; let matched;
if (key === "enter") { if (key === "enter") {
matched = searchMenus.find((_, index) => index === state.activeMenuIndex); matched = searchMenus.find((_, index) => {
return index === state.activeMenuIndex;
});
} else { } else {
matched = searchMenus.find((item) => item.shortcut === key); matched = searchMenus.find((item) => item.shortcut === key);
} }
handleClick(matched?.clickEvent); handleClick(matched?.clickEvent);
}); },
{
target: document.body,
}
);
useEffect(() => { useEffect(() => {
setOpenPopover(visibleContextMenu); setOpenPopover(visibleContextMenu);

View File

@@ -13,6 +13,7 @@ import { useConnectStore } from "@/stores/connectStore";
import SearchEmpty from "../Common/SearchEmpty"; import SearchEmpty from "../Common/SearchEmpty";
import { Data } from "ahooks/lib/useInfiniteScroll/types"; import { Data } from "ahooks/lib/useInfiniteScroll/types";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { isNil } from "lodash-es";
interface DocumentListProps { interface DocumentListProps {
onSelectDocument: (id: string) => void; onSelectDocument: (id: string) => void;
@@ -48,13 +49,29 @@ 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 taskIdRef = useRef(nanoid()); const taskIdRef = useRef(nanoid());
const [data, setData] = useState<Data>(); const [data, setData] = useState<Data>({ list: [] });
const loadingFromRef = useRef<number>(-1);
const querySourceTimeoutRef = useRef(querySourceTimeout); const querySourceTimeoutRef = useRef(querySourceTimeout);
useEffect(() => { useEffect(() => {
querySourceTimeoutRef.current = querySourceTimeout; querySourceTimeoutRef.current = querySourceTimeout;
}, [querySourceTimeout]); }, [querySourceTimeout]);
const setSelectedSearchContent = useSearchStore((state) => {
return state.setSelectedSearchContent;
});
useEffect(() => {
if (isNil(selectedItem)) return;
const hit = data.list[selectedItem];
const item = { ...hit?.document, querySource: hit?.source };
setSelectedSearchContent(item);
}, [selectedItem, data]);
const getData = async (taskId: string, data?: Data) => { const getData = async (taskId: string, data?: Data) => {
const from = data?.list?.length || 0; const from = data?.list?.length || 0;
@@ -108,27 +125,56 @@ export const DocumentList: React.FC<DocumentListProps> = ({
} }
console.log("_docs", from, queryStrings, response); console.log("_docs", from, queryStrings, response);
const list = response?.hits || []; const list = response?.hits ?? [];
const total = response?.total_hits || 0; const allTotal = response?.total_hits ?? 0;
setTotal(total); // set first select hover
if (from === 0 && list.length > 0) {
setSelectedItem(0);
getDocDetail(list[0]?.document);
}
if (taskId === taskIdRef.current) { if (taskId === taskIdRef.current) {
setData({ list }); // Prevent the last data from being 0
setTotal((prevTotal) => {
if (list.length === 0) {
return data?.list?.length === 0 ? 0 : prevTotal;
}
return allTotal;
});
setData((prev) => ({
...prev,
list: prev.list.concat(list),
}));
} }
return { return {
list: list, list: list,
hasMore: list.length === PAGE_SIZE && from + list.length < total, hasMore: list.length === PAGE_SIZE && from + list.length < allTotal,
}; };
}; };
const { loading } = useInfiniteScroll( const { loading } = useInfiniteScroll(
(data) => { (data) => {
const taskId = nanoid(); // Prevent repeated requests for the same from value
const currentFrom = data?.list?.length || 0;
// If it starts from 0, it means it is a new search, reset the anti-duplicate flag
if (currentFrom === 0) {
loadingFromRef.current = -1;
}
if (loadingFromRef.current === currentFrom) {
return Promise.resolve({ list: [], hasMore: false });
}
loadingFromRef.current = currentFrom;
const taskId = nanoid();
taskIdRef.current = taskId; taskIdRef.current = taskId;
return getData(taskId, data); return getData(taskId, data).finally(() => {
loadingFromRef.current = -1; // reset
});
}, },
{ {
target: containerRef, target: containerRef,
@@ -160,6 +206,15 @@ export const DocumentList: React.FC<DocumentListProps> = ({
setIsKeyboardMode(false); setIsKeyboardMode(false);
}, [isChatMode, input]); }, [isChatMode, input]);
useEffect(() => {
setTotal(0);
setData((prev) => ({
...prev,
list: [],
}));
loadingFromRef.current = -1;
}, [input]);
const { visibleContextMenu } = useSearchStore(); const { visibleContextMenu } = useSearchStore();
const handleKeyDown = useCallback( const handleKeyDown = useCallback(

View File

@@ -80,7 +80,6 @@ const ExtensionStore = () => {
setSelectedExtension, setSelectedExtension,
installingExtensions, installingExtensions,
setInstallingExtensions, setInstallingExtensions,
uninstallingExtensions,
setUninstallingExtensions, setUninstallingExtensions,
visibleExtensionDetail, visibleExtensionDetail,
setVisibleExtensionDetail, setVisibleExtensionDetail,
@@ -149,11 +148,7 @@ const ExtensionStore = () => {
useKeyPress( useKeyPress(
`${modifierKey}.enter`, `${modifierKey}.enter`,
() => { () => {
if ( if (visibleContextMenu || visibleExtensionDetail) {
visibleContextMenu ||
visibleExtensionDetail ||
selectedExtension?.installed
) {
return; return;
} }
@@ -181,42 +176,49 @@ const ExtensionStore = () => {
setSelectedExtension(list[nextIndex]); setSelectedExtension(list[nextIndex]);
}); });
const toggleInstall = (installed = true) => { const toggleInstall = (extension: SearchExtensionItem) => {
if (!selectedExtension) return; if (!extension) return;
const { id } = selectedExtension; const { id, installed } = extension;
setList((prev) => { setList((prev) => {
return prev.map((item) => { return prev.map((item) => {
if (item.id === id) { if (item.id === id) {
return { ...item, installed }; return { ...item, installed: !installed };
} }
return item; return item;
}); });
}); });
const { selectedExtension } = useSearchStore.getState();
if (selectedExtension?.id === id) { if (selectedExtension?.id === id) {
setSelectedExtension({ setSelectedExtension({
...selectedExtension, ...selectedExtension,
installed, installed: !installed,
}); });
} }
}; };
const handleInstall = async () => { const handleInstall = async () => {
const { selectedExtension, installingExtensions } =
useSearchStore.getState();
if (!selectedExtension) return; if (!selectedExtension) return;
const { id, name, installed } = selectedExtension; const { id, name, installed } = selectedExtension;
try {
if (installed || installingExtensions.includes(id)) return; if (installed || installingExtensions.includes(id)) return;
try {
setInstallingExtensions(installingExtensions.concat(id)); setInstallingExtensions(installingExtensions.concat(id));
await platformAdapter.invokeBackend("install_extension_from_store", { id }); await platformAdapter.invokeBackend("install_extension_from_store", {
id,
});
toggleInstall(); toggleInstall(selectedExtension);
addError( addError(
`${name} ${t("extensionStore.hints.installationCompleted")}`, `${name} ${t("extensionStore.hints.installationCompleted")}`,
@@ -225,6 +227,8 @@ const ExtensionStore = () => {
} catch (error) { } catch (error) {
addError(String(error), "error"); addError(String(error), "error");
} finally { } finally {
const { installingExtensions } = useSearchStore.getState();
setInstallingExtensions( setInstallingExtensions(
installingExtensions.filter((item) => item !== id) installingExtensions.filter((item) => item !== id)
); );
@@ -232,13 +236,16 @@ const ExtensionStore = () => {
}; };
const handleUnInstall = async () => { const handleUnInstall = async () => {
const { selectedExtension, uninstallingExtensions } =
useSearchStore.getState();
if (!selectedExtension) return; if (!selectedExtension) return;
const { id, name, installed, developer } = selectedExtension; const { id, name, installed, developer } = selectedExtension;
try {
if (!installed || uninstallingExtensions.includes(id)) return; if (!installed || uninstallingExtensions.includes(id)) return;
try {
setUninstallingExtensions(uninstallingExtensions.concat(id)); setUninstallingExtensions(uninstallingExtensions.concat(id));
await platformAdapter.invokeBackend("uninstall_extension", { await platformAdapter.invokeBackend("uninstall_extension", {
@@ -246,7 +253,7 @@ const ExtensionStore = () => {
extensionId: id, extensionId: id,
}); });
toggleInstall(false); toggleInstall(selectedExtension);
addError( addError(
`${name} ${t("extensionStore.hints.uninstallationCompleted")}`, `${name} ${t("extensionStore.hints.uninstallationCompleted")}`,
@@ -255,6 +262,8 @@ const ExtensionStore = () => {
} catch (error) { } catch (error) {
addError(String(error), "error"); addError(String(error), "error");
} finally { } finally {
const { uninstallingExtensions } = useSearchStore.getState();
setUninstallingExtensions( setUninstallingExtensions(
uninstallingExtensions.filter((item) => item !== id) uninstallingExtensions.filter((item) => item !== id)
); );

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useKeyPress } from "ahooks"; import { useKeyPress, useSize } from "ahooks";
import clsx from "clsx";
import AutoResizeTextarea from "./AutoResizeTextarea"; import AutoResizeTextarea from "./AutoResizeTextarea";
import { useChatStore } from "@/stores/chatStore"; import { useChatStore } from "@/stores/chatStore";
@@ -128,11 +129,7 @@ export default function ChatInput({
} }
}, [inputValue, disabled, onSend]); }, [inputValue, disabled, onSend]);
useKeyboardHandlers({ useKeyboardHandlers();
isChatMode,
handleSubmit,
curChatEnd,
});
useKeyPress(`${modifierKey}.${returnToInput}`, handleToggleFocus); useKeyPress(`${modifierKey}.${returnToInput}`, handleToggleFocus);
@@ -203,21 +200,30 @@ export default function ChatInput({
const { currentService } = useConnectStore(); const { currentService } = useConnectStore();
const [visibleAudioInput, setVisibleAudioInput] = useState(false); const [visibleAudioInput, setVisibleAudioInput] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const containerSize = useSize(containerRef);
const searchIconRef = useRef<HTMLDivElement>(null);
const searchIconSize = useSize(searchIconRef);
const extraIconRef = useRef<HTMLDivElement>(null);
const extraIconSize = useSize(extraIconRef);
useEffect(() => { useEffect(() => {
setVisibleAudioInput(isDefaultServer()); setVisibleAudioInput(isDefaultServer());
}, [currentService]); }, [currentService]);
const renderSearchIcon = () => ( const renderSearchIcon = () => (
<div ref={searchIconRef} className="w-fit">
<SearchIcons <SearchIcons
lineCount={lineCount} lineCount={lineCount}
isChatMode={isChatMode} isChatMode={isChatMode}
assistant={askAIRef.current} assistant={askAIRef.current}
/> />
</div>
); );
const renderExtraIcon = () => ( const renderExtraIcon = () => (
<div className="flex items-center gap-2"> <div ref={extraIconRef} className="flex items-center gap-2 w-fit">
{isChatMode && (
<ChatIcons <ChatIcons
lineCount={lineCount} lineCount={lineCount}
isChatMode={isChatMode} isChatMode={isChatMode}
@@ -226,6 +232,7 @@ export default function ChatInput({
onSend={onSend} onSend={onSend}
disabledChange={disabledChange} disabledChange={disabledChange}
/> />
)}
{!isChatMode && {!isChatMode &&
(sourceData || visibleExtensionStore || selectedExtension) && ( (sourceData || visibleExtensionStore || selectedExtension) && (
@@ -295,7 +302,8 @@ export default function ChatInput({
</div> </div>
); );
const renderTextarea = () => ( const renderTextarea = () => {
return (
<VisibleKey <VisibleKey
shortcut={returnToInput} shortcut={returnToInput}
rootClassName="flex-1 flex items-center justify-center" rootClassName="flex-1 flex items-center justify-center"
@@ -316,35 +324,42 @@ export default function ChatInput({
} }
lineCount={lineCount} lineCount={lineCount}
onLineCountChange={setLineCount} onLineCountChange={setLineCount}
firstLineMaxWidth={
(containerSize?.width ?? 0) -
(searchIconSize?.width ?? 0) -
(extraIconSize?.width ?? 0)
}
/> />
</VisibleKey> </VisibleKey>
); );
};
return ( return (
<div className={`w-full relative`}> <div className={`w-full relative`}>
<div <div
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`}
> >
{lineCount === 1 ? ( <div
<div className="relative flex items-center gap-2 w-full"> ref={containerRef}
{renderSearchIcon()} className={clsx("relative w-full", {
"flex items-center gap-2": lineCount === 1,
})}
>
{lineCount === 1 && renderSearchIcon()}
{renderTextarea()} {renderTextarea()}
{renderExtraIcon()} {lineCount === 1 && renderExtraIcon()}
</div>
) : (
<div className="relative w-full">
{renderTextarea()}
{lineCount > 1 && (
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<div className="flex-1">{renderSearchIcon()}</div> <div className="flex-1">{renderSearchIcon()}</div>
<div className="self-end">{renderExtraIcon()}</div> <div className="self-end">{renderExtraIcon()}</div>
</div> </div>
</div>
)} )}
</div> </div>
</div>
<InputControls <InputControls
isChatMode={isChatMode} isChatMode={isChatMode}

View File

@@ -55,7 +55,7 @@ export default function MCPPopover({
query: debouncedKeyword, query: debouncedKeyword,
}); });
console.log("getMCPByServer", res); // console.log("getMCPByServer", res);
if (res?.length === 0) { if (res?.length === 0) {
setDataList([]); setDataList([]);

View File

@@ -78,7 +78,7 @@ const SearchResultsPanel = memo<{
}, [visibleExtensionStore, visibleExtensionDetail]); }, [visibleExtensionStore, visibleExtensionDetail]);
if (visibleExtensionStore) return <ExtensionStore />; if (visibleExtensionStore) return <ExtensionStore />;
if (goAskAi) return <AskAi />; if (goAskAi) return <AskAi isChatMode={isChatMode} />;
if (suggests.length === 0) return <NoResults />; if (suggests.length === 0) return <NoResults />;
return sourceData ? ( return sourceData ? (
@@ -127,7 +127,7 @@ function Search({
<Footer setIsPinnedWeb={setIsPinned} /> <Footer setIsPinnedWeb={setIsPinned} />
<ContextMenu formatUrl={formatUrl}/> <ContextMenu formatUrl={formatUrl} />
</div> </div>
); );
} }

View File

@@ -61,7 +61,7 @@ export default function SearchPopover({
} }
); );
console.log("getDataSourcesByServer", res); //console.log("getDataSourcesByServer", res);
if (res?.length === 0) { if (res?.length === 0) {
setDataSourceList([]); setDataSourceList([]);

View File

@@ -8,6 +8,7 @@ import VisibleKey from "@/components/Common/VisibleKey";
import source_default_img from "@/assets/images/source_default.png"; import source_default_img from "@/assets/images/source_default.png";
import source_default_dark_img from "@/assets/images/source_default_dark.png"; import source_default_dark_img from "@/assets/images/source_default_dark.png";
import type { QueryHits } from "@/types/search"; import type { QueryHits } from "@/types/search";
import { useAppStore } from "@/stores/appStore";
interface SearchSourceProps { interface SearchSourceProps {
sourceName: string; sourceName: string;
@@ -29,6 +30,8 @@ export const SearchSource: React.FC<SearchSourceProps> = ({
items[0]?.document.category === "Calculator" || items[0]?.document.category === "Calculator" ||
items[0]?.document.category === "AI Overview"; items[0]?.document.category === "AI Overview";
const isTauri = useAppStore((state) => state.isTauri);
return ( return (
<div className="p-2 text-xs text-[#999] dark:text-[#666] flex items-center gap-2.5 relative"> <div className="p-2 text-xs text-[#999] dark:text-[#666] flex items-center gap-2.5 relative">
<CommonIcon <CommonIcon
@@ -38,7 +41,7 @@ export const SearchSource: React.FC<SearchSourceProps> = ({
defaultIcon={isDark ? source_default_dark_img : source_default_img} defaultIcon={isDark ? source_default_dark_img : source_default_img}
className="w-4 h-4" className="w-4 h-4"
/> />
{sourceName} {items[0]?.source?.name && `- ${items[0].source.name}`} {sourceName} {isTauri && items[0]?.source?.name && `- ${items[0].source.name}`}
<div className="flex-1 border-b border-b-[#e6e6e6] dark:border-b-[#272626]"></div> <div className="flex-1 border-b border-b-[#e6e6e6] dark:border-b-[#272626]"></div>
{!hideArrow && ( {!hideArrow && (
<> <>

View File

@@ -125,7 +125,7 @@ function SearchChat({
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
await initializeListeners_auth(); await initializeListeners_auth();
await platformAdapter.invokeBackend("get_app_search_source"); await platformAdapter.commands("get_app_search_source");
}; };
init(); init();
@@ -271,6 +271,7 @@ function SearchChat({
<ChatAI <ChatAI
ref={chatAIRef} ref={chatAIRef}
key="ChatAI" key="ChatAI"
instanceId="search-chat"
changeInput={setInput} changeInput={setInput}
isSearchActive={isSearchActive} isSearchActive={isSearchActive}
isDeepThinkActive={isDeepThinkActive} isDeepThinkActive={isDeepThinkActive}

View File

@@ -28,7 +28,7 @@ interface UpdateAppProps {
const UpdateApp = ({ isCheckPage }: UpdateAppProps) => { const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const isDark = useThemeStore((state) => state.isDark); const { isDark } = useThemeStore();
const { const {
visible, visible,
setVisible, setVisible,
@@ -38,14 +38,14 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
updateInfo, updateInfo,
setUpdateInfo, setUpdateInfo,
} = useUpdateStore(); } = useUpdateStore();
const addError = useAppStore((state) => state.addError); const { addError } = useAppStore();
const snapshotUpdate = useAppearanceStore((state) => state.snapshotUpdate); const { snapshotUpdate } = useAppearanceStore();
const checkUpdate = useCallback(async () => { const checkUpdate = useCallback(() => {
return platformAdapter.checkUpdate(); return platformAdapter.checkUpdate();
}, []); }, []);
const relaunchApp = useCallback(async () => { const relaunchApp = useCallback(() => {
return platformAdapter.relaunchApp(); return platformAdapter.relaunchApp();
}, []); }, []);
@@ -57,6 +57,18 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
}); });
}, [snapshotUpdate]); }, [snapshotUpdate]);
useEffect(() => {
const unlisten = platformAdapter.listenEvent("check-update", () => {
if (!isCheckPage) return;
checkUpdateStatus();
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
const state = useReactive<State>({ download: 0 }); const state = useReactive<State>({ download: 0 });
useInterval(() => checkUpdateStatus(), 1000 * 60 * 60 * 24, { useInterval(() => checkUpdateStatus(), 1000 * 60 * 60 * 24, {
@@ -198,7 +210,9 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
) : ( ) : (
<div className={clsx("text-xs text-[#999]", cursorClassName)}> <div className={clsx("text-xs text-[#999]", cursorClassName)}>
{t("update.latest", { {t("update.latest", {
replace: [updateInfo?.version || process.env.VERSION || "N/A"], replace: [
updateInfo?.version || process.env.VERSION || "N/A",
],
})} })}
</div> </div>
)} )}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, useRef } from "react"; import { useCallback, useEffect, useState, useRef, useMemo } from "react";
import type { Chat } from "@/types/chat"; import type { Chat } from "@/types/chat";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
@@ -14,11 +14,14 @@ export function useChatActions(
setActiveChat: (chat: Chat | undefined) => void, setActiveChat: (chat: Chat | undefined) => void,
setCurChatEnd: (value: boolean) => void, setCurChatEnd: (value: boolean) => void,
setTimedoutShow: (value: boolean) => void, setTimedoutShow: (value: boolean) => void,
clearAllChunkData: () => void, clearAllChunkData: () => Promise<void>,
setQuestion: (value: string) => void, setQuestion: (value: string) => void,
curIdRef: React.MutableRefObject<string>, curIdRef: React.MutableRefObject<string>,
curSessionIdRef: React.MutableRefObject<string>,
setChats: (chats: Chat[]) => void, setChats: (chats: Chat[]) => void,
dealMsgRef: React.MutableRefObject<((msg: string) => void) | null>, dealMsgRef: React.MutableRefObject<((msg: string) => void) | null>,
setLoadingStep: (loading: Record<string, boolean>) => void,
isChatPage?: boolean,
isSearchActive?: boolean, isSearchActive?: boolean,
isDeepThinkActive?: boolean, isDeepThinkActive?: boolean,
isMCPActive?: boolean, isMCPActive?: boolean,
@@ -40,6 +43,23 @@ export function useChatActions(
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
// Add a ref at the beginning of the useChatActions function to store the listener.
const unlistenersRef = useRef<{
message?: () => void;
chatMessage?: () => void;
error?: () => void;
}>({});
const cleanupListeners = useCallback(() => {
if (unlistenersRef.current.chatMessage) {
unlistenersRef.current.chatMessage();
}
if (unlistenersRef.current.error) {
unlistenersRef.current.error();
}
unlistenersRef.current = {};
}, []);
const chatClose = useCallback( const chatClose = useCallback(
async (activeChat?: Chat) => { async (activeChat?: Chat) => {
if (!activeChat?._id) return; if (!activeChat?._id) return;
@@ -61,9 +81,30 @@ export function useChatActions(
[currentService?.id, isTauri] [currentService?.id, isTauri]
); );
const resetChatState = useCallback(() => {
setCurChatEnd(true);
// Stop listening for streaming data.
cleanupListeners();
setLoadingStep({
query_intent: false,
tools: false,
fetch_source: false,
pick_source: false,
deep_read: false,
think: false,
response: false,
});
}, [cleanupListeners]);
// 1. onSelectChat
// 2. dealMsg setTimedoutShow
// 3. disabledChange Manual shutdown
const cancelChat = useCallback( const cancelChat = useCallback(
async (activeChat?: Chat) => { async (activeChat?: Chat) => {
setCurChatEnd(true); resetChatState();
if (!activeChat?._id) return; if (!activeChat?._id) return;
let response: any; let response: any;
if (isTauri) { if (isTauri) {
@@ -71,12 +112,15 @@ export function useChatActions(
response = await platformAdapter.commands("cancel_session_chat", { response = await platformAdapter.commands("cancel_session_chat", {
serverId: currentService?.id, serverId: currentService?.id,
sessionId: activeChat?._id, sessionId: activeChat?._id,
queryParams: {
message_id: curIdRef.current,
},
}); });
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?message_id=${curIdRef.current}`,
{} undefined
); );
response = res; response = res;
} }
@@ -93,6 +137,7 @@ export function useChatActions(
async (chat: Chat, callback?: (chat: Chat) => void) => { async (chat: Chat, callback?: (chat: Chat) => void) => {
if (!chat?._id) return; if (!chat?._id) return;
curSessionIdRef.current = chat?._id;
let response: any; let response: any;
if (isTauri) { if (isTauri) {
if (!currentService?.id) return; if (!currentService?.id) return;
@@ -100,13 +145,13 @@ export function useChatActions(
serverId: currentService?.id, serverId: currentService?.id,
sessionId: chat?._id, sessionId: chat?._id,
from: 0, from: 0,
size: 100, size: 1000,
}); });
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: 100, size: 1000,
}); });
response = res; response = res;
} }
@@ -134,14 +179,144 @@ export function useChatActions(
[currentService?.id, isTauri, assistantList] [currentService?.id, isTauri, assistantList]
); );
const createNewChat = useCallback( // Modify the clientId generation logic to include the instance ID.
async (value: string = "", activeChat?: Chat) => { const clientId = useMemo(() => {
setTimedoutShow(false); const pageType = isChatPage ? "standalone-chat" : "search-chat";
await chatClose(activeChat); return `${pageType}`;
clearAllChunkData(); }, [isChatPage]);
setQuestion(value);
const handleChatCreateStreamMessage = useCallback(
(msg: string) => {
if (
msg.includes(`"user"`) &&
msg.includes("_source") &&
msg.includes("result")
) {
try {
const response = JSON.parse(msg);
console.log("first", response);
let updatedChat: Chat;
if (Array.isArray(response)) {
curIdRef.current = response[0]?._id;
curSessionIdRef.current = response[0]?._source?.session_id;
console.log(
"curIdRef-curSessionIdRef-Array",
curIdRef.current,
curSessionIdRef.current
);
updatedChat = {
...updatedChatRef.current,
messages: [
...(updatedChatRef.current?.messages || []),
...(response || []),
],
};
console.log("array", updatedChat, updatedChatRef.current?.messages);
} else {
const newChat: Chat = response;
curIdRef.current = response?.payload?.id;
curSessionIdRef.current = response?.payload?.session_id;
console.log(
"curIdRef-curSessionIdRef",
curIdRef.current,
curSessionIdRef.current
);
newChat._source = {
...response?.payload,
};
updatedChat = {
...newChat,
messages: [newChat],
};
}
setActiveChat(updatedChat);
return;
} catch (error) {
console.error("Failed to parse JSON:", error, "Raw message:", msg);
return;
}
}
dealMsgRef.current?.(msg);
},
[changeInput, setActiveChat, setCurChatEnd, setVisibleStartPage]
);
const setupListeners = useCallback(
async (timestamp: number) => {
cleanupListeners();
console.log("setupListeners", clientId, timestamp);
const unlisten_chat_message = await platformAdapter.listenEvent(
`chat-stream-${clientId}-${timestamp}`,
(event) => {
const msg = event.payload as string;
try {
// console.log("msg:", JSON.parse(msg));
// console.log("user:", msg.includes(`"user"`));
// console.log("_source:", msg.includes("_source"));
// console.log("result:", msg.includes("result"));
// console.log("");
// console.log("");
// console.log("");
// console.log("");
// console.log("");
} catch (error) {
console.error("Failed to parse JSON in listener:", error);
}
handleChatCreateStreamMessage(msg);
}
);
const unlisten_error = await platformAdapter.listenEvent(
`chat-create-error`,
(event) => {
console.error("chat-create-error", event.payload);
}
);
// Store the listener references.
unlistenersRef.current = {
chatMessage: unlisten_chat_message,
error: unlisten_error,
};
},
[currentService?.id, clientId, handleChatCreateStreamMessage]
);
const prepareChatSession = useCallback(
async (value: string, timestamp: number) => {
// 1. Cleaning and preparation
await clearAllChunkData();
// 2. Update the status again
await new Promise<void>((resolve) => {
changeInput && changeInput("");
setVisibleStartPage(false);
setTimedoutShow(false);
setQuestion(value);
setCurChatEnd(false);
setTimeout(resolve, 0);
});
// 4. Set up the listener first
await setupListeners(timestamp);
},
[setupListeners]
);
const createNewChat = useCallback(
async (value: string = "") => {
if (!value) return;
const timestamp = Date.now();
await prepareChatSession(value, timestamp);
//console.log("sourceDataIds", sourceDataIds, MCPIds, id);
const queryParams = { const queryParams = {
search: isSearchActive, search: isSearchActive,
deep_thinking: isDeepThinkActive, deep_thinking: isDeepThinkActive,
@@ -150,13 +325,18 @@ export function useChatActions(
mcp_servers: MCPIds?.join(",") || "", mcp_servers: MCPIds?.join(",") || "",
assistant_id: currentAssistant?._id || "", assistant_id: currentAssistant?._id || "",
}; };
if (isTauri) { if (isTauri) {
if (!currentService?.id) return; if (!currentService?.id) return;
console.log("chat_create", clientId, timestamp);
await platformAdapter.commands("chat_create", { await platformAdapter.commands("chat_create", {
serverId: currentService?.id, serverId: currentService?.id,
message: value, message: value,
queryParams, queryParams,
clientId: `chat-stream-${clientId}-${timestamp}`,
}); });
console.log("_create end", value);
resetChatState();
} else { } else {
await streamPost({ await streamPost({
url: "/chat/_create", url: "/chat/_create",
@@ -169,7 +349,6 @@ export function useChatActions(
}, },
}); });
} }
console.log("_create", currentService?.id, value, queryParams);
}, },
[ [
isTauri, isTauri,
@@ -179,9 +358,9 @@ export function useChatActions(
isSearchActive, isSearchActive,
isDeepThinkActive, isDeepThinkActive,
isMCPActive, isMCPActive,
curIdRef,
currentAssistant, currentAssistant,
chatClose, chatClose,
clientId,
] ]
); );
@@ -189,7 +368,9 @@ export function useChatActions(
async (content: string, newChat: Chat) => { async (content: string, newChat: Chat) => {
if (!newChat?._id || !content) return; if (!newChat?._id || !content) return;
clearAllChunkData(); const timestamp = Date.now();
await prepareChatSession(content, timestamp);
const queryParams = { const queryParams = {
search: isSearchActive, search: isSearchActive,
@@ -199,14 +380,19 @@ export function useChatActions(
mcp_servers: MCPIds?.join(",") || "", mcp_servers: MCPIds?.join(",") || "",
assistant_id: currentAssistant?._id || "", assistant_id: currentAssistant?._id || "",
}; };
if (isTauri) { if (isTauri) {
if (!currentService?.id) return; if (!currentService?.id) return;
console.log("chat_chat", clientId, timestamp);
await platformAdapter.commands("chat_chat", { await platformAdapter.commands("chat_chat", {
serverId: currentService?.id, serverId: currentService?.id,
sessionId: newChat?._id, sessionId: newChat?._id,
queryParams, queryParams,
message: content, message: content,
clientId: `chat-stream-${clientId}-${timestamp}`,
}); });
console.log("chat_chat end", content, clientId);
resetChatState();
} else { } else {
await streamPost({ await streamPost({
url: `/chat/${newChat?._id}/_chat`, url: `/chat/${newChat?._id}/_chat`,
@@ -219,14 +405,6 @@ export function useChatActions(
}, },
}); });
} }
console.log(
"chat_chat",
currentService?.id,
newChat?._id,
queryParams,
content
);
}, },
[ [
isTauri, isTauri,
@@ -236,101 +414,28 @@ export function useChatActions(
isSearchActive, isSearchActive,
isDeepThinkActive, isDeepThinkActive,
isMCPActive, isMCPActive,
curIdRef,
changeInput, changeInput,
currentAssistant, currentAssistant,
clientId,
] ]
); );
const handleSendMessage = useCallback( const handleSendMessage = useCallback(
async (content: string, activeChat?: Chat) => { async (content: string, activeChat?: Chat) => {
if (!activeChat?._id || !content) return; if (!activeChat?._id || !content) return;
setQuestion(content);
setTimedoutShow(false);
await chatHistory(activeChat, (chat) => sendMessage(content, chat)); await chatHistory(activeChat, (chat) => sendMessage(content, chat));
}, },
[chatHistory, sendMessage] [chatHistory, sendMessage]
); );
const handleChatCreateStreamMessage = useCallback(
(msg: string) => {
if (
msg.includes("_id") &&
msg.includes("_source") &&
msg.includes("result")
) {
const response = JSON.parse(msg);
console.log("first", response);
let updatedChat: Chat;
if (Array.isArray(response)) {
curIdRef.current = response[0]?._id;
updatedChat = {
...updatedChatRef.current,
messages: [
...(updatedChatRef.current?.messages || []),
...(response || []),
],
};
console.log("array", updatedChat, updatedChatRef.current?.messages);
} else {
const newChat: Chat = response;
curIdRef.current = response?.payload?.id;
newChat._source = {
...response?.payload,
};
updatedChat = {
...newChat,
messages: [newChat],
};
}
changeInput && changeInput("");
setActiveChat(updatedChat);
setCurChatEnd(false);
setVisibleStartPage(false);
return;
}
dealMsgRef.current?.(msg);
},
[
curIdRef,
updatedChatRef,
changeInput,
setActiveChat,
setCurChatEnd,
setVisibleStartPage,
dealMsgRef,
]
);
useEffect(() => { useEffect(() => {
if (!isTauri || !currentService?.id) return; if (!isTauri || !currentService?.id) return;
const unlisten_message = platformAdapter.listenEvent(
`chat-create-stream`,
(event) => {
const msg = event.payload as string;
//console.log("chat-create-stream", msg);
handleChatCreateStreamMessage(msg);
}
);
const unlisten_error = platformAdapter.listenEvent(
`chat-create-error`,
(event) => {
console.error("chat-create-error", event.payload);
}
);
return () => { return () => {
unlisten_message.then((fn) => fn()); cleanupListeners();
unlisten_error.then((fn) => fn());
}; };
}, [currentService?.id, dealMsgRef, updatedChatRef.current]); }, [currentService?.id]);
const openSessionChat = useCallback( const openSessionChat = useCallback(
async (chat: Chat) => { async (chat: Chat) => {

View File

@@ -3,17 +3,7 @@ import { useCallback, useEffect } from "react";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { useShortcutsStore } from "@/stores/shortcutsStore"; import { useShortcutsStore } from "@/stores/shortcutsStore";
interface KeyboardHandlersProps { export function useKeyboardHandlers() {
isChatMode: boolean;
handleSubmit: () => void;
curChatEnd?: boolean;
}
export function useKeyboardHandlers({
isChatMode,
handleSubmit,
curChatEnd,
}: KeyboardHandlersProps) {
const { const {
setSourceData, setSourceData,
visibleExtensionStore, visibleExtensionStore,
@@ -47,21 +37,8 @@ export function useKeyboardHandlers({
return setSourceData(void 0); return setSourceData(void 0);
} }
// Handle Enter without meta key requirement
if (e.code === "Enter" && !e.shiftKey && isChatMode) {
e.preventDefault();
curChatEnd && handleSubmit();
}
}, },
[ [setSourceData, modifierKey, visibleExtensionDetail]
isChatMode,
handleSubmit,
setSourceData,
curChatEnd,
modifierKey,
visibleExtensionDetail,
]
); );
useEffect(() => { useEffect(() => {

View File

@@ -78,6 +78,7 @@ export default function useMessageChunkData() {
}; };
const clearAllChunkData = () => { const clearAllChunkData = () => {
return new Promise<void>((resolve) => {
setQuery_intent(undefined); setQuery_intent(undefined);
setTools(undefined); setTools(undefined);
setFetch_source(undefined); setFetch_source(undefined);
@@ -85,10 +86,20 @@ export default function useMessageChunkData() {
setDeep_read(undefined); setDeep_read(undefined);
setThink(undefined); setThink(undefined);
setResponse(undefined); setResponse(undefined);
setTimeout(resolve, 0);
});
}; };
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,
},
handlers, handlers,
clearAllChunkData, clearAllChunkData,
}; };

View File

@@ -5,6 +5,7 @@ import { useConnectStore } from "@/stores/connectStore";
export function useMessageHandler( export function useMessageHandler(
curIdRef: React.MutableRefObject<string>, curIdRef: React.MutableRefObject<string>,
curSessionIdRef: React.MutableRefObject<string>,
setCurChatEnd: (value: boolean) => void, setCurChatEnd: (value: boolean) => void,
setTimedoutShow: (value: boolean) => void, setTimedoutShow: (value: boolean) => void,
onCancel: (chat?: Chat) => void, onCancel: (chat?: Chat) => void,
@@ -41,8 +42,20 @@ export function useMessageHandler(
try { try {
const chunkData = JSON.parse(msg); const chunkData = JSON.parse(msg);
// console.log("chunkData", chunkData);
// console.log(
// "reply_to_message",
// chunkData.reply_to_message,
// curIdRef.current
// );
// console.log(
// "session_id",
// chunkData.session_id,
// curSessionIdRef.current
// );
if (chunkData.reply_to_message !== curIdRef.current) return; if (chunkData.reply_to_message !== curIdRef.current) return;
if (chunkData.session_id !== curSessionIdRef.current) return;
setLoadingStep(() => ({ setLoadingStep(() => ({
query_intent: false, query_intent: false,
@@ -55,8 +68,6 @@ export function useMessageHandler(
[chunkData.chunk_type]: true, [chunkData.chunk_type]: true,
})); }));
if (chunkData.chunk_type === "query_intent") { if (chunkData.chunk_type === "query_intent") {
handlers.deal_query_intent(chunkData); handlers.deal_query_intent(chunkData);
} else if (chunkData.chunk_type === "tools") { } else if (chunkData.chunk_type === "tools") {
@@ -87,7 +98,7 @@ export function useMessageHandler(
} }
if (inThinkRef.current) { if (inThinkRef.current) {
handlers.deal_think({...chunkData, chunk_type: "think"}); handlers.deal_think({ ...chunkData, chunk_type: "think" });
} else { } else {
handlers.deal_response(chunkData); handlers.deal_response(chunkData);
} }
@@ -105,13 +116,7 @@ export function useMessageHandler(
console.error("parse error:", error); console.error("parse error:", error);
} }
}, },
[ [onCancel, setCurChatEnd, setTimedoutShow, connectionTimeout]
onCancel,
setCurChatEnd,
setTimedoutShow,
curIdRef.current,
connectionTimeout,
]
); );
return { return {

View File

@@ -113,7 +113,7 @@ export const useStreamChat = (options: Options) => {
useAsyncEffect(async () => { useAsyncEffect(async () => {
if (!message || !server || !assistant) return; if (!message || !server || !assistant) return;
clearAllChunkData(); await clearAllChunkData();
state.messageId = nanoid(); state.messageId = nanoid();

View File

@@ -113,7 +113,6 @@ export const useSyncStore = () => {
const setAiOverviewMinQuantity = useExtensionsStore((state) => { const setAiOverviewMinQuantity = useExtensionsStore((state) => {
return state.setAiOverviewMinQuantity; return state.setAiOverviewMinQuantity;
}); });
const setCurrentService = useConnectStore((state) => state.setCurrentService);
const setShowTooltip = useAppStore((state) => state.setShowTooltip); const setShowTooltip = useAppStore((state) => state.setShowTooltip);
const setEndpoint = useAppStore((state) => state.setEndpoint); const setEndpoint = useAppStore((state) => state.setEndpoint);
const setLanguage = useAppStore((state) => state.setLanguage); const setLanguage = useAppStore((state) => state.setLanguage);
@@ -180,12 +179,8 @@ export const useSyncStore = () => {
}), }),
platformAdapter.listenEvent("change-connect-store", ({ payload }) => { platformAdapter.listenEvent("change-connect-store", ({ payload }) => {
const { const { connectionTimeout, querySourceTimeout, allowSelfSignature } =
connectionTimeout, payload;
querySourceTimeout,
allowSelfSignature,
currentService,
} = payload;
if (isNumber(connectionTimeout)) { if (isNumber(connectionTimeout)) {
setConnectionTimeout(connectionTimeout); setConnectionTimeout(connectionTimeout);
} }
@@ -193,8 +188,6 @@ export const useSyncStore = () => {
setQueryTimeout(querySourceTimeout); setQueryTimeout(querySourceTimeout);
} }
setAllowSelfSignature(allowSelfSignature); setAllowSelfSignature(allowSelfSignature);
setCurrentService(currentService);
}), }),
platformAdapter.listenEvent("change-appearance-store", ({ payload }) => { platformAdapter.listenEvent("change-appearance-store", ({ payload }) => {

35
src/hooks/useTogglePin.ts Normal file
View File

@@ -0,0 +1,35 @@
import { useCallback } from "react";
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
import { toggle_move_to_active_space_attribute } from "@/commands/system";
import { isMac } from "@/utils/platform";
interface UseTogglePinOptions {
onPinChange?: (isPinned: boolean) => void;
}
export const useTogglePin = (options?: UseTogglePinOptions) => {
const { isPinned, setIsPinned } = useAppStore();
const togglePin = useCallback(async () => {
try {
const newPinned = !isPinned;
if (options?.onPinChange) {
options.onPinChange(newPinned);
}
await platformAdapter.setAlwaysOnTop(newPinned);
setIsPinned(newPinned);
isMac && toggle_move_to_active_space_attribute();
} catch (err) {
console.error("Failed to toggle window pin state:", err);
}
}, [isPinned, setIsPinned, options?.onPinChange]);
return {
isPinned,
togglePin,
};
};

View File

@@ -7,7 +7,6 @@ import { exit } from "@tauri-apps/plugin-process";
import { isMac } from "@/utils/platform"; import { isMac } from "@/utils/platform";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { useUpdateStore } from "@/stores/updateStore";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import { show_coco, show_settings, show_check } from "@/commands"; import { show_coco, show_settings, show_check } from "@/commands";
@@ -53,7 +52,7 @@ export const useTray = () => {
text: t("tray.showCoco"), text: t("tray.showCoco"),
accelerator: showCocoShortcuts.join("+"), accelerator: showCocoShortcuts.join("+"),
action: () => { action: () => {
show_coco() show_coco();
}, },
}), }),
PredefinedMenuItem.new({ item: "Separator" }), PredefinedMenuItem.new({ item: "Separator" }),
@@ -61,18 +60,15 @@ export const useTray = () => {
text: t("tray.settings"), text: t("tray.settings"),
// accelerator: "CommandOrControl+,", // accelerator: "CommandOrControl+,",
action: () => { action: () => {
show_settings() show_settings();
}, },
}), }),
MenuItem.new({ MenuItem.new({
text: t("tray.checkUpdate"), text: t("tray.checkUpdate"),
action: async () => { action: async () => {
const update = await platformAdapter.checkUpdate(); await show_check();
if (update) {
useUpdateStore.getState().setUpdateInfo(update); platformAdapter.emitEvent("check-update");
useUpdateStore.getState().setVisible(true);
}
show_check();
}, },
}), }),
PredefinedMenuItem.new({ item: "Separator" }), PredefinedMenuItem.new({ item: "Separator" }),

View File

@@ -489,7 +489,7 @@
}, },
"tray": { "tray": {
"showCoco": "Show Coco", "showCoco": "Show Coco",
"settings": "Settings...", "settings": "Settings",
"quitCoco": "Quit Coco", "quitCoco": "Quit Coco",
"checkUpdate": "Check for Updates" "checkUpdate": "Check for Updates"
}, },

View File

@@ -18,22 +18,15 @@ import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat";
import type { Chat as typeChat } from "@/types/chat"; import type { Chat as typeChat } from "@/types/chat";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import InputBox from "@/components/Search/InputBox"; import InputBox from "@/components/Search/InputBox";
import {
chat_history,
session_chat_history,
close_session_chat,
open_session_chat,
delete_session_chat,
update_session_chat,
} from "@/commands";
import HistoryList from "@/components/Common/HistoryList"; import HistoryList from "@/components/Common/HistoryList";
import { useSyncStore } from "@/hooks/useSyncStore"; import { useSyncStore } from "@/hooks/useSyncStore";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { unrequitable } from "@/utils"; import { unrequitable } from "@/utils";
import platformAdapter from "@/utils/platformAdapter";
interface ChatProps {} interface StandaloneChatProps {}
export default function Chat({}: ChatProps) { export default function StandaloneChat({}: StandaloneChatProps) {
const setIsTauri = useAppStore((state) => state.setIsTauri); const setIsTauri = useAppStore((state) => state.setIsTauri);
useEffect(() => { useEffect(() => {
setIsTauri(true); setIsTauri(true);
@@ -74,7 +67,7 @@ export default function Chat({}: ChatProps) {
return setChats([]); return setChats([]);
} }
let response: any = await chat_history({ let response: any = await platformAdapter.commands("chat_history", {
serverId: currentService?.id, serverId: currentService?.id,
from: 0, from: 0,
size: 100, size: 100,
@@ -118,12 +111,15 @@ export default function Chat({}: ChatProps) {
const chatHistory = async (chat: typeChat) => { const chatHistory = async (chat: typeChat) => {
try { try {
let response: any = await session_chat_history({ let response: any = await platformAdapter.commands(
"session_chat_history",
{
serverId: currentService?.id, serverId: currentService?.id,
sessionId: chat?._id || "", sessionId: chat?._id || "",
from: 0, from: 0,
size: 100, size: 500,
}); }
);
response = response ? JSON.parse(response) : null; response = response ? JSON.parse(response) : null;
console.log("id_history", response); console.log("id_history", response);
const hits = response?.hits?.hits || []; const hits = response?.hits?.hits || [];
@@ -149,7 +145,7 @@ export default function Chat({}: ChatProps) {
const chatClose = async () => { const chatClose = async () => {
if (!activeChat?._id) return; if (!activeChat?._id) return;
try { try {
let response: any = await close_session_chat({ let response: any = await platformAdapter.commands("close_session_chat", {
serverId: currentService?.id, serverId: currentService?.id,
sessionId: activeChat?._id, sessionId: activeChat?._id,
}); });
@@ -163,7 +159,7 @@ export default function Chat({}: ChatProps) {
const onSelectChat = async (chat: any) => { const onSelectChat = async (chat: any) => {
chatClose(); chatClose();
try { try {
let response: any = await open_session_chat({ let response: any = await platformAdapter.commands("open_session_chat", {
serverId: currentService?.id, serverId: currentService?.id,
sessionId: chat?._id, sessionId: chat?._id,
}); });
@@ -262,7 +258,7 @@ export default function Chat({}: ChatProps) {
}); });
} }
update_session_chat({ platformAdapter.commands("update_session_chat", {
serverId: currentService.id, serverId: currentService.id,
sessionId: chatId, sessionId: chatId,
title, title,
@@ -272,7 +268,7 @@ export default function Chat({}: ChatProps) {
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!currentService?.id) return; if (!currentService?.id) return;
await delete_session_chat(currentService.id, id); await platformAdapter.commands("delete_session_chat", currentService.id, id);
}; };
return ( return (
@@ -304,6 +300,7 @@ export default function Chat({}: ChatProps) {
<ChatAI <ChatAI
ref={chatAIRef} ref={chatAIRef}
key="ChatAI" key="ChatAI"
instanceId="standalone-chat"
activeChatProp={activeChat} activeChatProp={activeChat}
isSearchActive={isSearchActive} isSearchActive={isSearchActive}
isDeepThinkActive={isDeepThinkActive} isDeepThinkActive={isDeepThinkActive}

View File

@@ -2,19 +2,18 @@ import { useEffect } from "react";
import { useUpdateStore } from "@/stores/updateStore"; import { useUpdateStore } from "@/stores/updateStore";
import UpdateApp from "@/components/UpdateApp"; import UpdateApp from "@/components/UpdateApp";
import { useSyncStore } from "@/hooks/useSyncStore";
const CheckApp = () => { const CheckApp = () => {
const setVisible = useUpdateStore((state) => state.setVisible); const { setVisible } = useUpdateStore();
useSyncStore();
useEffect(() => { useEffect(() => {
setVisible(true) setVisible(true);
}, []) }, []);
return ( return <UpdateApp isCheckPage />;
<div>
<UpdateApp isCheckPage={true} />
</div>
);
}; };
export default CheckApp; export default CheckApp;

View File

@@ -7,7 +7,7 @@ import { listen } from "@tauri-apps/api/event";
import SettingsPanel from "@/components/Settings/SettingsPanel"; import SettingsPanel from "@/components/Settings/SettingsPanel";
import GeneralSettings from "@/components/Settings/GeneralSettings"; import GeneralSettings from "@/components/Settings/GeneralSettings";
import AboutView from "@/components/Settings/AboutView"; import AboutView from "@/components/Settings/AboutView";
import Cloud from "@/components/Cloud/Cloud.tsx"; import Cloud from "@/components/Cloud/Cloud";
import Footer from "@/components/Common/UI/SettingsFooter"; 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";
@@ -83,10 +83,6 @@ function SettingsPage() {
<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">
<div className="max-w-6xl mx-auto p-4"> <div className="max-w-6xl mx-auto p-4">
{/* <div className="flex items-center justify-center mb-2">
<h1 className="text-xl font-bold">Coco Settings</h1>
</div> */}
<TabGroup <TabGroup
selectedIndex={defaultIndex} selectedIndex={defaultIndex}
onChange={(index) => { onChange={(index) => {

View File

@@ -10,6 +10,7 @@ import useEscape from "@/hooks/useEscape";
import { useViewportHeight } from "@/hooks/useViewportHeight"; import { useViewportHeight } from "@/hooks/useViewportHeight";
import { useIconfontScript } from "@/hooks/useScript"; import { useIconfontScript } from "@/hooks/useScript";
import type { StartPage } from "@/types/chat"; import type { StartPage } from "@/types/chat";
import ErrorNotification from "@/components/Common/ErrorNotification";
import "@/i18n"; import "@/i18n";
import "@/web.css"; import "@/web.css";
@@ -87,7 +88,7 @@ function WebApp({
return ( return (
<div <div
id="searchChat-container" id="searchChat-container"
className={`coco-container ${theme}`} className={`coco-container relative ${theme}`}
data-theme={theme} data-theme={theme}
style={{ style={{
maxWidth: `${width}px`, maxWidth: `${width}px`,
@@ -127,6 +128,7 @@ function WebApp({
startPage={startPage} startPage={startPage}
formatUrl={formatUrl} formatUrl={formatUrl}
/> />
<ErrorNotification isTauri={false}/>
</div> </div>
); );
} }

View File

@@ -4,7 +4,7 @@ import Layout from "./layout";
import ErrorPage from "@/pages/error/index"; import ErrorPage from "@/pages/error/index";
import DesktopApp from "@/pages/main/index"; import DesktopApp from "@/pages/main/index";
import SettingsPage from "@/pages/settings/index"; import SettingsPage from "@/pages/settings/index";
import ChatAI from "@/pages/chat/index"; import StandaloneChat from "@/pages/chat/index";
import WebPage from "@/pages/web/index"; import WebPage from "@/pages/web/index";
import CheckPage from "@/pages/check/index"; import CheckPage from "@/pages/check/index";
@@ -25,7 +25,7 @@ export const router = createBrowserRouter(
children: [ children: [
{ path: "/ui", element: <DesktopApp /> }, { path: "/ui", element: <DesktopApp /> },
{ path: "/ui/settings", element: <SettingsPage /> }, { path: "/ui/settings", element: <SettingsPage /> },
{ path: "/ui/chat", element: <ChatAI /> }, { path: "/ui/chat", element: <StandaloneChat /> },
{ path: "/ui/check", element: <CheckPage /> }, { path: "/ui/check", element: <CheckPage /> },
{ path: "/web", element: <WebPage /> }, { path: "/web", element: <WebPage /> },
], ],

View File

@@ -14,9 +14,10 @@ export interface ISource {
message?: any; message?: any;
title?: string; title?: string;
question?: string; question?: string;
details?: any[]; details?: any[] | null;
assistant_id?: string; assistant_id?: string;
assistant_item?: any; assistant_item?: any;
[key: string]: any;
} }
export interface Chat { export interface Chat {
_id?: string; _id?: string;

View File

@@ -1,7 +1,3 @@
export interface ServerTokenResponse {
access_token?: string;
}
interface Provider { interface Provider {
name: string; name: string;
icon: string; icon: string;

View File

@@ -48,9 +48,10 @@ export interface EventPayloads {
"install-extension": void; "install-extension": void;
"uninstall-extension": void; "uninstall-extension": void;
"config-extension": string; "config-extension": string;
"chat-create-stream": string; [key: `chat-stream-${string}`]: string;
"chat-create-error": string; "chat-create-error": string;
[key: `synthesize-${string}`]: any; [key: `synthesize-${string}`]: any;
"check-update": any;
} }
// Window operation interface // Window operation interface
@@ -118,9 +119,15 @@ export interface SystemOperations {
commands: <T>(commandName: string, ...args: any[]) => Promise<T>; commands: <T>(commandName: string, ...args: any[]) => Promise<T>;
isWindows10: () => Promise<boolean>; isWindows10: () => Promise<boolean>;
revealItemInDir: (path: string) => Promise<unknown>; revealItemInDir: (path: string) => Promise<unknown>;
openSearchItem: (data: SearchDocument, formatUrl?: (item: SearchDocument) => string) => Promise<unknown>; openSearchItem: (
data: SearchDocument,
formatUrl?: (item: SearchDocument) => string
) => Promise<unknown>;
searchMCPServers: (serverId: string, queryParams: string[]) => Promise<any[]>; searchMCPServers: (serverId: string, queryParams: string[]) => Promise<any[]>;
searchDataSources: (serverId: string, queryParams: string[]) => Promise<any[]>; searchDataSources: (
serverId: string,
queryParams: string[]
) => Promise<any[]>;
fetchAssistant: (serverId: string, queryParams: string[]) => Promise<any>; fetchAssistant: (serverId: string, queryParams: string[]) => Promise<any>;
} }