mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-17 20:17:43 +01:00
Compare commits
23 Commits
v0.7.0
...
microphone
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d0d11860c | ||
|
|
d48d4af7d2 | ||
|
|
876d14f9d9 | ||
|
|
a8e090c9be | ||
|
|
c30df6cee0 | ||
|
|
b833769c25 | ||
|
|
855fb2a168 | ||
|
|
d2735ec13b | ||
|
|
c40fc5818a | ||
|
|
a553ebd593 | ||
|
|
232166eb89 | ||
|
|
99144950d9 | ||
|
|
32d4f45144 | ||
|
|
6bc78b41ef | ||
|
|
cd54beee04 | ||
|
|
ee45d21bbe | ||
|
|
4709f8c660 | ||
|
|
4696aa1759 | ||
|
|
924fc09516 | ||
|
|
5a700662dd | ||
|
|
8f992bfa92 | ||
|
|
e7dd27c744 | ||
|
|
7914836c3e |
2
.env
2
.env
@@ -1,5 +1,3 @@
|
||||
COCO_SERVER_URL=http://localhost:9000 #https://coco.infini.cloud #http://localhost:9000
|
||||
|
||||
COCO_WEBSOCKET_URL=ws://localhost:9000/ws #wss://coco.infini.cloud/ws #ws://localhost:9000/ws
|
||||
|
||||
#TAURI_DEV_HOST=0.0.0.0
|
||||
@@ -5,22 +5,46 @@ title: "Release Notes"
|
||||
|
||||
# Release Notes
|
||||
|
||||
Information about release notes of Coco Server is provided here.
|
||||
Information about release notes of Coco App is provided here.
|
||||
|
||||
## Latest (In development)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- feat: enhance ui for skipped version #834
|
||||
- feat: support installing local extensions #749
|
||||
- feat: support sending files in chat messages #764
|
||||
- feat: sub extension can set 'platforms' now #847
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
- fix: fix issue with update check failure #833
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
- refactor: calling service related interfaces #831
|
||||
- refactor: split query_coco_fusion() #836
|
||||
- chore: web component loading font icon #838
|
||||
- chore: delete unused code files and dependencies #841
|
||||
- chore: ignore tauri::AppHandle's generic argument R #845
|
||||
- refactor: check Extension/plugin.json from all sources #846
|
||||
|
||||
## 0.7.1 (2025-07-27)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
- fix: correct enter key behavior #828
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
- chore: web component add notification component #825
|
||||
- refactor: collection behavior defaults to `MoveToActiveSpace`, and only use `CanJoinAllSpaces` when window is pinned #829
|
||||
|
||||
## 0.7.0 (2025-07-25)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "coco",
|
||||
"private": true,
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -31,7 +31,6 @@
|
||||
"@tauri-apps/plugin-process": "^2.2.1",
|
||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "github:infinilabs/tauri-plugin-updater#v2",
|
||||
"@tauri-apps/plugin-websocket": "~2.3.0",
|
||||
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
||||
"@wavesurfer/react": "^1.0.11",
|
||||
"ahooks": "^3.8.4",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -47,9 +47,6 @@ importers:
|
||||
'@tauri-apps/plugin-updater':
|
||||
specifier: github:infinilabs/tauri-plugin-updater#v2
|
||||
version: https://codeload.github.com/infinilabs/tauri-plugin-updater/tar.gz/358e689c65e9943b53eff50bcb9dfd5b1cfc4072
|
||||
'@tauri-apps/plugin-websocket':
|
||||
specifier: ~2.3.0
|
||||
version: 2.3.0
|
||||
'@tauri-apps/plugin-window':
|
||||
specifier: 2.0.0-alpha.1
|
||||
version: 2.0.0-alpha.1
|
||||
@@ -1261,9 +1258,6 @@ packages:
|
||||
resolution: {tarball: https://codeload.github.com/infinilabs/tauri-plugin-updater/tar.gz/358e689c65e9943b53eff50bcb9dfd5b1cfc4072}
|
||||
version: 2.7.1
|
||||
|
||||
'@tauri-apps/plugin-websocket@2.3.0':
|
||||
resolution: {integrity: sha512-eAwRGe3tnqDeQYE0wq4g1PUKbam9tYvlC4uP/au12Y/z7MP4lrS4ylv+aoZ5Ly+hTlBdi7hDkhHomwF/UeBesA==}
|
||||
|
||||
'@tauri-apps/plugin-window@2.0.0-alpha.1':
|
||||
resolution: {integrity: sha512-dFOAgal/3Txz3SQ+LNQq0AK1EPC+acdaFlwPVB/6KXUZYmaFleIlzgxDVoJCQ+/xOhxvYrdQaFLefh0I/Kldbg==}
|
||||
|
||||
@@ -4643,10 +4637,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
|
||||
'@tauri-apps/plugin-websocket@2.3.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
|
||||
'@tauri-apps/plugin-window@2.0.0-alpha.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.0.0-alpha.6
|
||||
|
||||
187
src-tauri/Cargo.lock
generated
187
src-tauri/Cargo.lock
generated
@@ -840,7 +840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "coco"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"applications",
|
||||
@@ -852,6 +852,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"chinese-number",
|
||||
"chrono",
|
||||
"cocoa 0.24.1",
|
||||
"derive_more 2.0.1",
|
||||
"dirs 5.0.1",
|
||||
"enigo",
|
||||
@@ -861,6 +862,7 @@ dependencies = [
|
||||
"hostname",
|
||||
"http 1.3.1",
|
||||
"hyper 0.14.32",
|
||||
"indexmap 2.10.0",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"meval",
|
||||
@@ -877,6 +879,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_plain",
|
||||
"strsim 0.10.0",
|
||||
"strum",
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
@@ -899,13 +902,12 @@ dependencies = [
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-websocket",
|
||||
"tauri-plugin-windows-version",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite 0.20.1",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"tungstenite 0.24.0",
|
||||
"url",
|
||||
@@ -914,6 +916,22 @@ dependencies = [
|
||||
"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]]
|
||||
name = "cocoa"
|
||||
version = "0.26.0"
|
||||
@@ -922,14 +940,28 @@ checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2"
|
||||
dependencies = [
|
||||
"bitflags 2.9.0",
|
||||
"block",
|
||||
"cocoa-foundation",
|
||||
"cocoa-foundation 0.2.0",
|
||||
"core-foundation 0.10.0",
|
||||
"core-graphics",
|
||||
"core-graphics 0.24.0",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
"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]]
|
||||
name = "cocoa-foundation"
|
||||
version = "0.2.0"
|
||||
@@ -939,7 +971,7 @@ dependencies = [
|
||||
"bitflags 2.9.0",
|
||||
"block",
|
||||
"core-foundation 0.10.0",
|
||||
"core-graphics-types",
|
||||
"core-graphics-types 0.2.0",
|
||||
"libc",
|
||||
"objc",
|
||||
]
|
||||
@@ -1056,6 +1088,19 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "core-graphics"
|
||||
version = "0.24.0"
|
||||
@@ -1064,11 +1109,22 @@ checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||
dependencies = [
|
||||
"bitflags 2.9.0",
|
||||
"core-foundation 0.10.0",
|
||||
"core-graphics-types",
|
||||
"core-graphics-types 0.2.0",
|
||||
"foreign-types 0.5.0",
|
||||
"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]]
|
||||
name = "core-graphics-types"
|
||||
version = "0.2.0"
|
||||
@@ -1472,8 +1528,8 @@ version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67fd9ae1736d6ebb2e472740fbee86fb2178b8d56feb98a6751411d4c95b7e72"
|
||||
dependencies = [
|
||||
"cocoa",
|
||||
"core-graphics",
|
||||
"cocoa 0.26.0",
|
||||
"core-graphics 0.24.0",
|
||||
"dunce",
|
||||
"gdk",
|
||||
"gdkx11",
|
||||
@@ -1562,7 +1618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.0",
|
||||
"core-graphics",
|
||||
"core-graphics 0.24.0",
|
||||
"foreign-types-shared 0.3.1",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -2432,7 +2488,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.3.1",
|
||||
"indexmap 2.9.0",
|
||||
"indexmap 2.10.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -2714,7 +2770,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.59.0",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2901,9 +2957,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.9.0"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
|
||||
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.15.3",
|
||||
@@ -4534,7 +4590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.9.0",
|
||||
"indexmap 2.10.0",
|
||||
"quick-xml 0.32.0",
|
||||
"serde",
|
||||
"time",
|
||||
@@ -5498,7 +5554,7 @@ version = "1.0.140"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
|
||||
dependencies = [
|
||||
"indexmap 2.9.0",
|
||||
"indexmap 2.10.0",
|
||||
"itoa 1.0.15",
|
||||
"memchr",
|
||||
"ryu",
|
||||
@@ -5556,7 +5612,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.9.0",
|
||||
"indexmap 2.10.0",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
@@ -5721,7 +5777,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
"core-graphics",
|
||||
"core-graphics 0.24.0",
|
||||
"foreign-types 0.5.0",
|
||||
"js-sys",
|
||||
"log",
|
||||
@@ -5810,6 +5866,27 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -5889,7 +5966,7 @@ dependencies = [
|
||||
"ntapi",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-kit",
|
||||
"windows 0.59.0",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5947,7 +6024,7 @@ checksum = "1e59c1f38e657351a2e822eadf40d6a2ad4627b9c25557bc1180ec1b3295ef82"
|
||||
dependencies = [
|
||||
"bitflags 2.9.0",
|
||||
"core-foundation 0.10.0",
|
||||
"core-graphics",
|
||||
"core-graphics 0.24.0",
|
||||
"crossbeam-channel",
|
||||
"dispatch",
|
||||
"dlopen2",
|
||||
@@ -6145,9 +6222,9 @@ source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#d4b9df797959f8fa
|
||||
dependencies = [
|
||||
"bitflags 2.9.0",
|
||||
"block",
|
||||
"cocoa",
|
||||
"cocoa 0.26.0",
|
||||
"core-foundation 0.10.0",
|
||||
"core-graphics",
|
||||
"core-graphics 0.24.0",
|
||||
"objc",
|
||||
"objc-foundation",
|
||||
"objc_id",
|
||||
@@ -6501,25 +6578,6 @@ dependencies = [
|
||||
"zip 2.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-websocket"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af3ac71aec5fb0ae5441e830cd075b1cbed49ac3d39cb975a4894ea8fa2e62b9"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"http 1.3.1",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.12",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.26.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-windows-version"
|
||||
version = "2.0.0"
|
||||
@@ -6627,7 +6685,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8d321dbc6f998d825ab3f0d62673e810c861aac2d0de2cc2c395328f1d113b4"
|
||||
dependencies = [
|
||||
"embed-resource",
|
||||
"indexmap 2.9.0",
|
||||
"indexmap 2.10.0",
|
||||
"toml",
|
||||
]
|
||||
|
||||
@@ -6854,22 +6912,6 @@ dependencies = [
|
||||
"tungstenite 0.20.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite 0.26.2",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.15"
|
||||
@@ -6910,7 +6952,7 @@ version = "0.19.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||
dependencies = [
|
||||
"indexmap 2.9.0",
|
||||
"indexmap 2.10.0",
|
||||
"toml_datetime",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
@@ -6921,7 +6963,7 @@ version = "0.20.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81"
|
||||
dependencies = [
|
||||
"indexmap 2.9.0",
|
||||
"indexmap 2.10.0",
|
||||
"toml_datetime",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
@@ -6932,7 +6974,7 @@ version = "0.22.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
|
||||
dependencies = [
|
||||
"indexmap 2.9.0",
|
||||
"indexmap 2.10.0",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
@@ -7076,25 +7118,6 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 1.3.1",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.1",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.12",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -8565,7 +8588,7 @@ dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"indexmap 2.9.0",
|
||||
"indexmap 2.10.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
@@ -8584,7 +8607,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"getrandom 0.3.2",
|
||||
"hmac",
|
||||
"indexmap 2.9.0",
|
||||
"indexmap 2.10.0",
|
||||
"liblzma",
|
||||
"memchr",
|
||||
"pbkdf2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coco"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
description = "Search, connect, collaborate – all in one place."
|
||||
authors = ["INFINI Labs"]
|
||||
edition = "2024"
|
||||
@@ -51,7 +51,6 @@ serde = { version = "1", features = ["derive"] }
|
||||
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
|
||||
serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] }
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-websocket = "2"
|
||||
tauri-plugin-deep-link = "2.0.0"
|
||||
tauri-plugin-store = "2.2.0"
|
||||
tauri-plugin-os = "2"
|
||||
@@ -106,9 +105,12 @@ camino = "1.1.10"
|
||||
tokio-stream = { version = "0.1.17", features = ["io-util"] }
|
||||
cfg-if = "1.0.1"
|
||||
sysinfo = "0.35.2"
|
||||
indexmap = { version = "2.10.0", features = ["serde"] }
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
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]
|
||||
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
@@ -24,6 +26,5 @@
|
||||
<string>6GVZT94974.rs.coco.app</string>
|
||||
<key>com.apple.developer.team-identifier</key>
|
||||
<string>6GVZT94974</string>
|
||||
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
@@ -2,11 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Request camera access for WebRTC</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Request microphone access for WebRTC</string>
|
||||
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>rs.coco.app</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
|
||||
@@ -37,9 +37,6 @@
|
||||
"http:allow-fetch-cancel",
|
||||
"http:allow-fetch-read-body",
|
||||
"http:allow-fetch-send",
|
||||
"websocket:default",
|
||||
"websocket:allow-connect",
|
||||
"websocket:allow-send",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::common::assistant::ChatRequestMessage;
|
||||
use crate::common::http::{GetResponse, convert_query_params_to_strings};
|
||||
use crate::common::http::convert_query_params_to_strings;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::{common, server::servers::COCO_SERVERS};
|
||||
@@ -9,12 +9,12 @@ use futures_util::TryStreamExt;
|
||||
use http::Method;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn chat_history<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn chat_history(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
from: u32,
|
||||
size: u32,
|
||||
@@ -43,8 +43,8 @@ pub async fn chat_history<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn session_chat_history<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn session_chat_history(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
from: u32,
|
||||
@@ -66,8 +66,8 @@ pub async fn session_chat_history<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_session_chat<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn open_session_chat(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<String, String> {
|
||||
@@ -81,8 +81,8 @@ pub async fn open_session_chat<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn close_session_chat<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn close_session_chat(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<String, String> {
|
||||
@@ -95,8 +95,8 @@ pub async fn close_session_chat<R: Runtime>(
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn cancel_session_chat<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn cancel_session_chat(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
query_params: Option<HashMap<String, Value>>,
|
||||
@@ -112,72 +112,37 @@ pub async fn cancel_session_chat<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn new_chat<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn chat_create(
|
||||
app_handle: AppHandle,
|
||||
server_id: String,
|
||||
websocket_id: String,
|
||||
message: String,
|
||||
query_params: Option<HashMap<String, Value>>,
|
||||
) -> Result<GetResponse, String> {
|
||||
let body = if !message.is_empty() {
|
||||
let message = ChatRequestMessage {
|
||||
message: Some(message),
|
||||
};
|
||||
Some(
|
||||
serde_json::to_string(&message)
|
||||
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
|
||||
|
||||
let response = HttpClient::advanced_post(
|
||||
&server_id,
|
||||
"/chat/_new",
|
||||
Some(headers),
|
||||
convert_query_params_to_strings(query_params),
|
||||
body,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error sending message: {}", e))?;
|
||||
|
||||
let body_text = common::http::get_response_body_text(response).await?;
|
||||
|
||||
log::debug!("New chat response: {}", &body_text);
|
||||
|
||||
let chat_response: GetResponse = serde_json::from_str(&body_text)
|
||||
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
||||
|
||||
if chat_response.result != "created" {
|
||||
return Err(format!("Unexpected result: {}", chat_response.result));
|
||||
}
|
||||
|
||||
Ok(chat_response)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn chat_create<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
message: String,
|
||||
message: Option<String>,
|
||||
attachments: Option<Vec<String>>,
|
||||
query_params: Option<HashMap<String, Value>>,
|
||||
client_id: String,
|
||||
) -> Result<(), String> {
|
||||
let body = if !message.is_empty() {
|
||||
let message = ChatRequestMessage {
|
||||
message: Some(message),
|
||||
println!("chat_create message: {:?}", message);
|
||||
println!("chat_create attachments: {:?}", attachments);
|
||||
|
||||
let message_empty = message.as_ref().map_or(true, |m| m.is_empty());
|
||||
let attachments_empty = attachments.as_ref().map_or(true, |a| a.is_empty());
|
||||
|
||||
if message_empty && attachments_empty {
|
||||
return Err("Message and attachments are empty".to_string());
|
||||
}
|
||||
|
||||
let body = {
|
||||
let request_message: ChatRequestMessage = ChatRequestMessage {
|
||||
message,
|
||||
attachments,
|
||||
};
|
||||
|
||||
println!("chat_create body: {:?}", request_message);
|
||||
|
||||
Some(
|
||||
serde_json::to_string(&message)
|
||||
serde_json::to_string(&request_message)
|
||||
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = HttpClient::advanced_post(
|
||||
@@ -213,8 +178,6 @@ pub async fn chat_create<R: Runtime>(
|
||||
if let Err(err) = app_handle.emit(&client_id, line) {
|
||||
log::error!("Emit failed: {:?}", err);
|
||||
|
||||
print!("Error sending message: {:?}", err);
|
||||
|
||||
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
|
||||
}
|
||||
}
|
||||
@@ -223,56 +186,38 @@ pub async fn chat_create<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_message<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
websocket_id: String,
|
||||
session_id: String,
|
||||
message: String,
|
||||
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
||||
) -> Result<String, String> {
|
||||
let path = format!("/chat/{}/_send", session_id);
|
||||
let msg = ChatRequestMessage {
|
||||
message: Some(message),
|
||||
};
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
|
||||
|
||||
let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap());
|
||||
let response = HttpClient::advanced_post(
|
||||
&server_id,
|
||||
path.as_str(),
|
||||
Some(headers),
|
||||
convert_query_params_to_strings(query_params),
|
||||
Some(body),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error cancel session: {}", e))?;
|
||||
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn chat_chat<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
pub async fn chat_chat(
|
||||
app_handle: AppHandle,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
message: String,
|
||||
message: Option<String>,
|
||||
attachments: Option<Vec<String>>,
|
||||
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
||||
client_id: String,
|
||||
) -> Result<(), String> {
|
||||
let body = if !message.is_empty() {
|
||||
let message = ChatRequestMessage {
|
||||
message: Some(message),
|
||||
println!("chat_chat message: {:?}", message);
|
||||
println!("chat_chat attachments: {:?}", attachments);
|
||||
|
||||
let message_empty = message.as_ref().map_or(true, |m| m.is_empty());
|
||||
let attachments_empty = attachments.as_ref().map_or(true, |a| a.is_empty());
|
||||
|
||||
if message_empty && attachments_empty {
|
||||
return Err("Message and attachments are empty".to_string());
|
||||
}
|
||||
|
||||
let body = {
|
||||
let request_message = ChatRequestMessage {
|
||||
message,
|
||||
attachments,
|
||||
};
|
||||
|
||||
println!("chat_chat body: {:?}", request_message);
|
||||
|
||||
Some(
|
||||
serde_json::to_string(&message)
|
||||
serde_json::to_string(&request_message)
|
||||
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let path = format!("/chat/{}/_chat", session_id);
|
||||
@@ -314,6 +259,9 @@ pub async fn chat_chat<R: Runtime>(
|
||||
|
||||
if let Err(err) = app_handle.emit(&client_id, line) {
|
||||
log::error!("Emit failed: {:?}", err);
|
||||
|
||||
print!("Error sending message: {:?}", err);
|
||||
|
||||
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
|
||||
}
|
||||
}
|
||||
@@ -365,8 +313,8 @@ pub async fn update_session_chat(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assistant_search<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn assistant_search(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
query_params: Option<Vec<String>>,
|
||||
) -> Result<Value, String> {
|
||||
@@ -381,8 +329,8 @@ pub async fn assistant_search<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assistant_get<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn assistant_get(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
assistant_id: String,
|
||||
) -> Result<Value, String> {
|
||||
@@ -405,8 +353,8 @@ pub async fn assistant_get<R: Runtime>(
|
||||
///
|
||||
/// Returns as soon as the assistant is found on any Coco server.
|
||||
#[tauri::command]
|
||||
pub async fn assistant_get_multi<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
pub async fn assistant_get_multi(
|
||||
app_handle: AppHandle,
|
||||
assistant_id: String,
|
||||
) -> Result<Value, String> {
|
||||
let search_sources = app_handle.state::<SearchSourceRegistry>();
|
||||
@@ -499,8 +447,8 @@ pub fn remove_icon_fields(json: &str) -> String {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ask_ai<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
pub async fn ask_ai(
|
||||
app_handle: AppHandle,
|
||||
message: String,
|
||||
server_id: String,
|
||||
assistant_id: String,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{fs::create_dir, io::Read};
|
||||
|
||||
use tauri::{Manager, Runtime};
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
|
||||
/// If the state reported from the OS and the state stored by us differ, our state is
|
||||
@@ -42,7 +42,7 @@ pub fn ensure_autostart_state_consistent(app: &mut tauri::App) -> Result<(), Str
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, String> {
|
||||
fn current_autostart(app: &tauri::AppHandle) -> Result<bool, String> {
|
||||
use std::fs::File;
|
||||
|
||||
let path = app.path().app_config_dir().unwrap();
|
||||
@@ -65,10 +65,7 @@ fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn change_autostart<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
open: bool,
|
||||
) -> Result<(), String> {
|
||||
pub async fn change_autostart(app: tauri::AppHandle, open: bool) -> Result<(), String> {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatRequestMessage {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub attachments: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Runtime;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RichLabel {
|
||||
@@ -42,6 +41,15 @@ pub(crate) enum OnOpened {
|
||||
Command {
|
||||
action: crate::extension::CommandAction,
|
||||
},
|
||||
// NOTE that this variant has the same definition as `struct Quicklink`, but we
|
||||
// cannot use it directly, its `link` field should be deserialized/serialized
|
||||
// from/to a string, but we need a JSON object here.
|
||||
//
|
||||
// See also the comments in `struct Quicklink`.
|
||||
Quicklink {
|
||||
link: crate::extension::QuicklinkLink,
|
||||
open_with: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl OnOpened {
|
||||
@@ -59,28 +67,37 @@ impl OnOpened {
|
||||
|
||||
ret
|
||||
}
|
||||
// Currently, our URL is static and does not support dynamic parameters.
|
||||
// The URL of a quicklink is nearly useless without such dynamic user
|
||||
// inputs, so until we have dynamic URL support, we just use "N/A".
|
||||
Self::Quicklink { .. } => String::from("N/A"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn open<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
pub(crate) async fn open(
|
||||
tauri_app_handle: AppHandle,
|
||||
on_opened: OnOpened,
|
||||
extra_args: Option<HashMap<String, String>>,
|
||||
) -> Result<(), String> {
|
||||
log::debug!("open({})", on_opened.url());
|
||||
|
||||
use crate::util::open as homemade_tauri_shell_open;
|
||||
use std::process::Command;
|
||||
|
||||
match on_opened {
|
||||
OnOpened::Application { app_path } => {
|
||||
log::debug!("open application [{}]", app_path);
|
||||
|
||||
homemade_tauri_shell_open(tauri_app_handle.clone(), app_path).await?
|
||||
}
|
||||
OnOpened::Document { url } => {
|
||||
log::debug!("open document [{}]", url);
|
||||
|
||||
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
||||
}
|
||||
OnOpened::Command { action } => {
|
||||
log::debug!("open (execute) command [{:?}]", action);
|
||||
|
||||
let mut cmd = Command::new(action.exec);
|
||||
if let Some(args) = action.args {
|
||||
cmd.args(args);
|
||||
@@ -107,6 +124,39 @@ pub(crate) async fn open<R: Runtime>(
|
||||
));
|
||||
}
|
||||
}
|
||||
OnOpened::Quicklink {
|
||||
link,
|
||||
open_with: opt_open_with,
|
||||
} => {
|
||||
let url = link.concatenate_url(&extra_args);
|
||||
|
||||
log::debug!("open quicklink [{}] with [{:?}]", url, opt_open_with);
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
// The `open_with` functionality is only supported on macOS, provided
|
||||
// by the `open -a` command.
|
||||
if #[cfg(target_os = "macos")] {
|
||||
let mut cmd = Command::new("open");
|
||||
if let Some(ref open_with) = opt_open_with {
|
||||
cmd.arg("-a");
|
||||
cmd.arg(open_with.as_str());
|
||||
}
|
||||
cmd.arg(&url);
|
||||
|
||||
let output = cmd.output().map_err(|e| format!("failed to spawn [open] due to error [{}]", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"failed to open with app {:?}: {}",
|
||||
opt_open_with,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
} else {
|
||||
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -27,7 +27,7 @@ use pizza_engine::{Engine, EngineBuilder, doc};
|
||||
use serde_json::Value as Json;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager, Runtime, async_runtime};
|
||||
use tauri::{AppHandle, Manager, async_runtime};
|
||||
use tauri_plugin_fs_pro::{IconOptions, icon, metadata, name};
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use tauri_plugin_global_shortcut::Shortcut;
|
||||
@@ -131,10 +131,7 @@ async fn get_app_name(app: &App) -> String {
|
||||
/// Helper function to return an absolute path to `app`'s icon.
|
||||
///
|
||||
/// On macOS/Windows, we cache icons in our data directory using the `icon()` function.
|
||||
async fn get_app_icon_path<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app: &App,
|
||||
) -> Result<String, String> {
|
||||
async fn get_app_icon_path(tauri_app_handle: &AppHandle, app: &App) -> Result<String, String> {
|
||||
let res_path = if cfg!(target_os = "linux") {
|
||||
let icon_path = app
|
||||
.icon_path
|
||||
@@ -213,8 +210,8 @@ impl SearchSourceState for ApplicationSearchSourceState {
|
||||
}
|
||||
|
||||
/// Index applications if they have not been indexed (by checking if `app_index_dir` exists).
|
||||
async fn index_applications_if_not_indexed<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
async fn index_applications_if_not_indexed(
|
||||
tauri_app_handle: &AppHandle,
|
||||
app_index_dir: &Path,
|
||||
) -> anyhow::Result<ApplicationSearchSourceState> {
|
||||
let index_exists = app_index_dir.exists();
|
||||
@@ -315,13 +312,13 @@ async fn index_applications_if_not_indexed<R: Runtime>(
|
||||
}
|
||||
|
||||
/// Upon application start, index all the applications found in the `get_default_search_paths()`.
|
||||
struct IndexAllApplicationsTask<R: Runtime> {
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
struct IndexAllApplicationsTask {
|
||||
tauri_app_handle: AppHandle,
|
||||
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
|
||||
impl Task for IndexAllApplicationsTask {
|
||||
fn search_source_id(&self) -> &'static str {
|
||||
APPLICATION_SEARCH_SOURCE_ID
|
||||
}
|
||||
@@ -343,13 +340,13 @@ impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
|
||||
}
|
||||
}
|
||||
|
||||
struct ReindexAllApplicationsTask<R: Runtime> {
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
struct ReindexAllApplicationsTask {
|
||||
tauri_app_handle: AppHandle,
|
||||
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl<R: Runtime> Task for ReindexAllApplicationsTask<R> {
|
||||
impl Task for ReindexAllApplicationsTask {
|
||||
fn search_source_id(&self) -> &'static str {
|
||||
APPLICATION_SEARCH_SOURCE_ID
|
||||
}
|
||||
@@ -377,14 +374,14 @@ impl<R: Runtime> Task for ReindexAllApplicationsTask<R> {
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchApplicationsTask<R: Runtime> {
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
struct SearchApplicationsTask {
|
||||
tauri_app_handle: AppHandle,
|
||||
query_string: String,
|
||||
callback: Option<OneshotSender<Result<SearchResult, PizzaEngineError>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl<R: Runtime> Task for SearchApplicationsTask<R> {
|
||||
impl Task for SearchApplicationsTask {
|
||||
fn search_source_id(&self) -> &'static str {
|
||||
APPLICATION_SEARCH_SOURCE_ID
|
||||
}
|
||||
@@ -514,9 +511,7 @@ impl Task for IndexNewApplicationsTask {
|
||||
pub struct ApplicationSearchSource;
|
||||
|
||||
impl ApplicationSearchSource {
|
||||
pub async fn prepare_index_and_store<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
pub async fn prepare_index_and_store(app_handle: AppHandle) -> Result<(), String> {
|
||||
app_handle
|
||||
.store(TAURI_STORE_APP_HOTKEY)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -683,7 +678,7 @@ fn pizza_engine_hits_to_coco_hits(
|
||||
coco_hits
|
||||
}
|
||||
|
||||
pub fn set_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str, alias: &str) {
|
||||
pub fn set_app_alias(tauri_app_handle: &AppHandle, app_path: &str, alias: &str) {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_ALIAS)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
|
||||
@@ -696,7 +691,7 @@ pub fn set_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str
|
||||
// deleted while updating it.
|
||||
}
|
||||
|
||||
fn get_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str) -> Option<String> {
|
||||
fn get_app_alias(tauri_app_handle: &AppHandle, app_path: &str) -> Option<String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_ALIAS)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
|
||||
@@ -714,9 +709,9 @@ fn get_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str) ->
|
||||
/// The handler that will be invoked when an application hotkey is pressed.
|
||||
///
|
||||
/// The `app_path` argument is for logging-only.
|
||||
fn app_hotkey_handler<R: Runtime>(
|
||||
fn app_hotkey_handler(
|
||||
app_path: String,
|
||||
) -> impl Fn(&AppHandle<R>, &Shortcut, ShortcutEvent) + Send + Sync + 'static {
|
||||
) -> impl Fn(&AppHandle, &Shortcut, ShortcutEvent) + Send + Sync + 'static {
|
||||
move |tauri_app_handle, _hot_key, event| {
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
let app_path_clone = app_path.clone();
|
||||
@@ -732,7 +727,7 @@ fn app_hotkey_handler<R: Runtime>(
|
||||
}
|
||||
|
||||
/// For all the applications, if it is enabled & has hotkey set, then set it up.
|
||||
pub(crate) fn set_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub(crate) fn set_apps_hotkey(tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
let app_hotkey_store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_HOTKEY)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
|
||||
@@ -756,7 +751,7 @@ pub(crate) fn set_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Re
|
||||
}
|
||||
|
||||
/// For all the applications, if it is enabled & has hotkey set, then unset it.
|
||||
pub(crate) fn unset_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub(crate) fn unset_apps_hotkey(tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
let app_hotkey_store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_HOTKEY)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
|
||||
@@ -783,8 +778,8 @@ pub(crate) fn unset_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) ->
|
||||
}
|
||||
|
||||
/// Set the hotkey but won't persist this settings change.
|
||||
pub(crate) fn set_app_hotkey<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) fn set_app_hotkey(
|
||||
tauri_app_handle: &AppHandle,
|
||||
app_path: &str,
|
||||
hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
@@ -794,8 +789,8 @@ pub(crate) fn set_app_hotkey<R: Runtime>(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn register_app_hotkey<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub fn register_app_hotkey(
|
||||
tauri_app_handle: &AppHandle,
|
||||
app_path: &str,
|
||||
hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
@@ -812,10 +807,7 @@ pub fn register_app_hotkey<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unregister_app_hotkey<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn unregister_app_hotkey(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
|
||||
let app_hotkey_store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_HOTKEY)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
|
||||
@@ -855,7 +847,7 @@ pub fn unregister_app_hotkey<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_disabled_app_list<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Vec<String> {
|
||||
fn get_disabled_app_list(tauri_app_handle: &AppHandle) -> Vec<String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -892,10 +884,7 @@ pub fn is_app_search_enabled(app_path: &str) -> bool {
|
||||
disabled_app_list.iter().all(|path| path != app_path)
|
||||
}
|
||||
|
||||
pub fn disable_app_search<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn disable_app_search(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -939,10 +928,7 @@ pub fn disable_app_search<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn enable_app_search<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn enable_app_search(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -984,8 +970,8 @@ pub fn enable_app_search<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_app_search_path<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
pub async fn add_app_search_path(
|
||||
tauri_app_handle: AppHandle,
|
||||
search_path: String,
|
||||
) -> Result<(), String> {
|
||||
let mut search_paths = get_app_search_path(tauri_app_handle.clone()).await;
|
||||
@@ -1010,8 +996,8 @@ pub async fn add_app_search_path<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_app_search_path<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
pub async fn remove_app_search_path(
|
||||
tauri_app_handle: AppHandle,
|
||||
search_path: String,
|
||||
) -> Result<(), String> {
|
||||
let mut search_paths = get_app_search_path(tauri_app_handle.clone()).await;
|
||||
@@ -1036,7 +1022,7 @@ pub async fn remove_app_search_path<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_search_path<R: Runtime>(tauri_app_handle: AppHandle<R>) -> Vec<String> {
|
||||
pub async fn get_app_search_path(tauri_app_handle: AppHandle) -> Vec<String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -1065,9 +1051,7 @@ pub async fn get_app_search_path<R: Runtime>(tauri_app_handle: AppHandle<R>) ->
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_list<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>, String> {
|
||||
let search_paths = get_app_search_path(tauri_app_handle.clone()).await;
|
||||
let apps = list_app_in(search_paths)?;
|
||||
|
||||
@@ -1202,9 +1186,7 @@ pub async fn get_app_metadata(app_name: String, app_path: String) -> Result<AppM
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reindex_applications<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
pub async fn reindex_applications(tauri_app_handle: AppHandle) -> Result<(), String> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let reindex_applications_task = ReindexAllApplicationsTask {
|
||||
tauri_app_handle: tauri_app_handle.clone(),
|
||||
|
||||
@@ -5,16 +5,14 @@ use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use async_trait::async_trait;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
|
||||
|
||||
pub struct ApplicationSearchSource;
|
||||
|
||||
impl ApplicationSearchSource {
|
||||
pub async fn prepare_index_and_store<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
pub async fn prepare_index_and_store(_app_handle: AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -45,37 +43,28 @@ impl SearchSource for ApplicationSearchSource {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_app_alias<R: Runtime>(_tauri_app_handle: &AppHandle<R>, _app_path: &str, _alias: &str) {
|
||||
pub fn set_app_alias(_tauri_app_handle: &AppHandle, _app_path: &str, _alias: &str) {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
pub fn register_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
pub fn register_app_hotkey(
|
||||
_tauri_app_handle: &AppHandle,
|
||||
_app_path: &str,
|
||||
_hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
pub fn unregister_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn unregister_app_hotkey(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
pub fn disable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn disable_app_search(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn enable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn enable_app_search(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
@@ -85,8 +74,8 @@ pub fn is_app_search_enabled(_app_path: &str) -> bool {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_app_search_path<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
pub async fn add_app_search_path(
|
||||
_tauri_app_handle: AppHandle,
|
||||
_search_path: String,
|
||||
) -> Result<(), String> {
|
||||
// no-op
|
||||
@@ -94,8 +83,8 @@ pub async fn add_app_search_path<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_app_search_path<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
pub async fn remove_app_search_path(
|
||||
_tauri_app_handle: AppHandle,
|
||||
_search_path: String,
|
||||
) -> Result<(), String> {
|
||||
// no-op
|
||||
@@ -103,43 +92,37 @@ pub async fn remove_app_search_path<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_search_path<R: Runtime>(_tauri_app_handle: AppHandle<R>) -> Vec<String> {
|
||||
pub async fn get_app_search_path(_tauri_app_handle: AppHandle) -> Vec<String> {
|
||||
// Return an empty list
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_list<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
pub async fn get_app_list(_tauri_app_handle: AppHandle) -> Result<Vec<Extension>, String> {
|
||||
// Return an empty list
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_metadata<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
pub async fn get_app_metadata(
|
||||
_tauri_app_handle: AppHandle,
|
||||
_app_path: String,
|
||||
) -> Result<AppMetadata, String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
pub(crate) fn set_apps_hotkey<R: Runtime>(_tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub(crate) fn set_apps_hotkey(_tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn unset_apps_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
pub(crate) fn unset_apps_hotkey(_tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reindex_applications<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
pub async fn reindex_applications(_tauri_app_handle: AppHandle) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::LazyLock;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Runtime;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
// Tauri store keys for file system configuration
|
||||
@@ -54,7 +53,7 @@ impl Default for FileSearchConfig {
|
||||
}
|
||||
|
||||
impl FileSearchConfig {
|
||||
pub(crate) fn get<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Self {
|
||||
pub(crate) fn get(tauri_app_handle: &AppHandle) -> Self {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
|
||||
.unwrap_or_else(|e| {
|
||||
@@ -185,15 +184,13 @@ impl FileSearchConfig {
|
||||
|
||||
// Tauri commands for managing file system configuration
|
||||
#[tauri::command]
|
||||
pub async fn get_file_system_config<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
) -> FileSearchConfig {
|
||||
pub async fn get_file_system_config(tauri_app_handle: AppHandle) -> FileSearchConfig {
|
||||
FileSearchConfig::get(&tauri_app_handle)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_file_system_config<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
pub async fn set_file_system_config(
|
||||
tauri_app_handle: AppHandle,
|
||||
config: FileSearchConfig,
|
||||
) -> Result<(), String> {
|
||||
let store = tauri_app_handle
|
||||
|
||||
@@ -16,11 +16,9 @@ use crate::extension::{
|
||||
};
|
||||
use anyhow::Context;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
pub(crate) fn get_built_in_extension_directory<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
) -> PathBuf {
|
||||
pub(crate) fn get_built_in_extension_directory(tauri_app_handle: &AppHandle) -> PathBuf {
|
||||
let mut resource_dir = tauri_app_handle.path().app_data_dir().expect(
|
||||
"User home directory not found, which should be impossible on desktop environments",
|
||||
);
|
||||
@@ -136,8 +134,8 @@ async fn load_built_in_extension(
|
||||
/// 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
|
||||
/// validation is needed because nothing could go wrong.
|
||||
pub(crate) async fn list_built_in_extensions<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) async fn list_built_in_extensions(
|
||||
tauri_app_handle: &AppHandle,
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
let dir = get_built_in_extension_directory(tauri_app_handle);
|
||||
|
||||
@@ -191,8 +189,8 @@ pub(crate) async fn list_built_in_extensions<R: Runtime>(
|
||||
Ok(built_in_extensions)
|
||||
}
|
||||
|
||||
pub(super) async fn init_built_in_extension<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(super) async fn init_built_in_extension(
|
||||
tauri_app_handle: &AppHandle,
|
||||
extension: &Extension,
|
||||
search_source_registry: &SearchSourceRegistry,
|
||||
) -> Result<(), String> {
|
||||
@@ -233,8 +231,8 @@ pub(crate) fn is_extension_built_in(bundle_id: &ExtensionBundleIdBorrowed<'_>) -
|
||||
bundle_id.developer.is_none()
|
||||
}
|
||||
|
||||
pub(crate) async fn enable_built_in_extension<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) async fn enable_built_in_extension(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
) -> Result<(), String> {
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
@@ -321,8 +319,8 @@ pub(crate) async fn enable_built_in_extension<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn disable_built_in_extension<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) async fn disable_built_in_extension(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
) -> Result<(), String> {
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
@@ -408,8 +406,8 @@ pub(crate) async fn disable_built_in_extension<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_built_in_extension_alias<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) fn set_built_in_extension_alias(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
alias: &str,
|
||||
) {
|
||||
@@ -420,8 +418,8 @@ pub(crate) fn set_built_in_extension_alias<R: Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn register_built_in_extension_hotkey<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) fn register_built_in_extension_hotkey(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
@@ -433,8 +431,8 @@ pub(crate) fn register_built_in_extension_hotkey<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn unregister_built_in_extension_hotkey<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) fn unregister_built_in_extension_hotkey(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
) -> Result<(), String> {
|
||||
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
@@ -481,8 +479,8 @@ fn load_extension_from_json_file(
|
||||
Ok(extension)
|
||||
}
|
||||
|
||||
pub(crate) async fn is_built_in_extension_enabled<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
pub(crate) async fn is_built_in_extension_enabled(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
) -> Result<bool, String> {
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
@@ -7,12 +7,14 @@ use crate::util::platform::Platform;
|
||||
use anyhow::Context;
|
||||
use borrowme::{Borrow, ToOwned};
|
||||
use derive_more::Display;
|
||||
use indexmap::IndexMap;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
|
||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||
@@ -23,7 +25,7 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
pub struct Extension {
|
||||
/// Extension ID.
|
||||
///
|
||||
@@ -193,8 +195,19 @@ impl Extension {
|
||||
ExtensionType::Application => Some(OnOpened::Application {
|
||||
app_path: self.id.clone(),
|
||||
}),
|
||||
ExtensionType::Quicklink => {
|
||||
let quicklink = self.quicklink.clone().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Quicklink extension [{}]'s [quicklink] field is not set, something wrong with your extension validity check", self.id
|
||||
)
|
||||
});
|
||||
|
||||
Some(OnOpened::Quicklink{
|
||||
link: quicklink.link,
|
||||
open_with: quicklink.open_with,
|
||||
})
|
||||
}
|
||||
ExtensionType::Script => todo!("not supported yet"),
|
||||
ExtensionType::Quicklink => todo!("not supported yet"),
|
||||
ExtensionType::Setting => todo!("not supported yet"),
|
||||
ExtensionType::Calculator => None,
|
||||
ExtensionType::AiExtension => None,
|
||||
@@ -262,15 +275,181 @@ impl Extension {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
pub(crate) struct CommandAction {
|
||||
pub(crate) exec: String,
|
||||
pub(crate) args: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
pub struct Quicklink {
|
||||
link: String,
|
||||
// NOTE that `struct QuicklinkLink` (not `struct Quicklink`) has its own
|
||||
// derived `Deserialize/Serialize` impl, which deserializes/serializes
|
||||
// it from/to a JSON object.
|
||||
//
|
||||
// We cannot use it here because we need to deserialize/serialize it from/to
|
||||
// a string,
|
||||
//
|
||||
// "https://www.google.com/search?q={query}"
|
||||
#[serde(deserialize_with = "deserialize_quicklink_link_from_string")]
|
||||
#[serde(serialize_with = "serialize_quicklink_link_to_string")]
|
||||
link: QuicklinkLink,
|
||||
/// Specify the application to use to open this quicklink.
|
||||
///
|
||||
/// Only supported on macOS.
|
||||
pub(crate) open_with: Option<String>,
|
||||
}
|
||||
|
||||
/// Return name and optional default value of all the dynamic placeholder arguments.
|
||||
///
|
||||
/// NOTE that it is not a Rust associated function because we need to expose it
|
||||
/// to the frontend code:
|
||||
///
|
||||
/// ```javascript
|
||||
/// invoke('quicklink_link_arguments', { <A JSON that can be deserialized to `struct QuicklinkLink`> } )
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub(crate) fn quicklink_link_arguments(
|
||||
quicklink_link: QuicklinkLink,
|
||||
) -> IndexMap<String, Option<String>> {
|
||||
let mut arguments_with_opt_default = IndexMap::new();
|
||||
|
||||
for component in quicklink_link.components.iter() {
|
||||
if let QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name,
|
||||
default,
|
||||
} = component
|
||||
{
|
||||
arguments_with_opt_default.insert(argument_name.to_string(), default.as_ref().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
arguments_with_opt_default
|
||||
}
|
||||
|
||||
/// A quicklink consists of a sequence of components.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) struct QuicklinkLink {
|
||||
components: Vec<QuicklinkLinkComponent>,
|
||||
}
|
||||
|
||||
impl QuicklinkLink {
|
||||
/// Quicklinks that accept arguments cannot produce a complete URL
|
||||
/// without user-supplied arguments.
|
||||
///
|
||||
/// This function attempts to concatenate the URL using the provided arguments,
|
||||
/// if any.
|
||||
pub(crate) fn concatenate_url(
|
||||
&self,
|
||||
user_supplied_args: &Option<HashMap<String, String>>,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
for component in self.components.iter() {
|
||||
match component {
|
||||
QuicklinkLinkComponent::StaticStr(str) => {
|
||||
out.push_str(str.as_str());
|
||||
}
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name,
|
||||
default,
|
||||
} => {
|
||||
let opt_argument_value = {
|
||||
let user_supplied_arg = user_supplied_args
|
||||
.as_ref()
|
||||
.and_then(|map| map.get(argument_name.as_str()));
|
||||
|
||||
if user_supplied_arg.is_some() {
|
||||
user_supplied_arg
|
||||
} else {
|
||||
default.as_ref()
|
||||
}
|
||||
};
|
||||
|
||||
let argument_value_str = match opt_argument_value {
|
||||
Some(str) => str.as_str(),
|
||||
// None => an empty string
|
||||
None => "",
|
||||
};
|
||||
|
||||
out.push_str(argument_value_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom deserialization function for QuicklinkLink from string
|
||||
fn deserialize_quicklink_link_from_string<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<QuicklinkLink, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let link_str = String::deserialize(deserializer)?;
|
||||
let components = parse_quicklink_components(&link_str).map_err(serde::de::Error::custom)?;
|
||||
|
||||
Ok(QuicklinkLink { components })
|
||||
}
|
||||
|
||||
/// Custom serialization function for QuicklinkLink to a string
|
||||
fn serialize_quicklink_link_to_string<S>(
|
||||
link: &QuicklinkLink,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let mut result = String::new();
|
||||
|
||||
for component in &link.components {
|
||||
match component {
|
||||
QuicklinkLinkComponent::StaticStr(s) => {
|
||||
result.push_str(s);
|
||||
}
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name,
|
||||
default,
|
||||
} => {
|
||||
result.push('{');
|
||||
|
||||
// If it's a simple case (no default), just use the argument name
|
||||
if default.is_none() {
|
||||
result.push_str(argument_name);
|
||||
} else {
|
||||
// Use the full format with argument_name and default
|
||||
result.push_str(&format!(
|
||||
r#"argument_name: "{}", default: "{}""#,
|
||||
argument_name,
|
||||
default.as_ref().unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
result.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serializer.serialize_str(&result)
|
||||
}
|
||||
|
||||
/// A link component is either a static string, or a dynamic placeholder, e.g.,
|
||||
///
|
||||
/// "https://www.google.com/search?q={query}"
|
||||
///
|
||||
/// The above link can be split into the following components:
|
||||
///
|
||||
/// [StaticStr("https://www.google.com/search?q="), DynamicPlaceholder { argument_name: "query", default: None }]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) enum QuicklinkLinkComponent {
|
||||
StaticStr(String),
|
||||
/// For the valid formats of dynamic placeholder, see the doc comments of `fn parse_dynamic_placeholder()`
|
||||
DynamicPlaceholder {
|
||||
argument_name: String,
|
||||
/// Will use this default value if this dynamic parameter is not supplied
|
||||
/// by the user.
|
||||
default: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Deserialize, Serialize, Clone, Display, Copy)]
|
||||
@@ -413,12 +592,12 @@ fn filter_out_extensions(
|
||||
/// * boolean: indicates if we found any invalid extensions
|
||||
/// * Vec<Extension>: loaded extensions
|
||||
#[tauri::command]
|
||||
pub(crate) async fn list_extensions<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
pub(crate) async fn list_extensions(
|
||||
tauri_app_handle: AppHandle,
|
||||
query: Option<String>,
|
||||
extension_type: Option<ExtensionType>,
|
||||
list_enabled: bool,
|
||||
) -> Result<(bool, Vec<Extension>), String> {
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
log::trace!("loading extensions");
|
||||
|
||||
let third_party_dir = third_party::get_third_party_extension_directory(&tauri_app_handle);
|
||||
@@ -427,12 +606,11 @@ pub(crate) async fn list_extensions<R: Runtime>(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let (third_party_found_invalid_extension, mut third_party_extensions) =
|
||||
let mut third_party_extensions =
|
||||
third_party::list_third_party_extensions(&third_party_dir).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 mut extensions = {
|
||||
third_party_extensions.extend(built_in_extensions);
|
||||
|
||||
@@ -480,7 +658,7 @@ pub(crate) async fn list_extensions<R: Runtime>(
|
||||
});
|
||||
}
|
||||
|
||||
Ok((found_invalid_extension, extensions))
|
||||
Ok(extensions)
|
||||
}
|
||||
|
||||
pub(crate) async fn init_extensions(
|
||||
@@ -498,7 +676,7 @@ pub(crate) async fn init_extensions(
|
||||
|
||||
// extension store
|
||||
search_source_registry_tauri_state
|
||||
.register_source(third_party::store::ExtensionStore)
|
||||
.register_source(third_party::install::store::ExtensionStore)
|
||||
.await;
|
||||
|
||||
// Init the built-in enabled extensions
|
||||
@@ -773,3 +951,601 @@ fn alter_extension_json_file(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper function to impl Deserialize for `QuicklinkLink`.
|
||||
///
|
||||
/// Parse a quicklink string into components, handling dynamic placeholders
|
||||
fn parse_quicklink_components(input: &str) -> Result<Vec<QuicklinkLinkComponent>, String> {
|
||||
let mut components = Vec::new();
|
||||
let mut current_pos = 0;
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
|
||||
while current_pos < chars.len() {
|
||||
// Find the next opening brace
|
||||
if let Some(open_pos) = chars[current_pos..].iter().position(|&c| c == '{') {
|
||||
let absolute_open_pos = current_pos + open_pos;
|
||||
|
||||
// Add static string before the opening brace (if any)
|
||||
if absolute_open_pos > current_pos {
|
||||
let static_str: String = chars[current_pos..absolute_open_pos].iter().collect();
|
||||
components.push(QuicklinkLinkComponent::StaticStr(static_str));
|
||||
}
|
||||
|
||||
// Find the matching closing brace, handling nested braces
|
||||
let mut brace_count = 1;
|
||||
let mut close_pos = None;
|
||||
|
||||
for (i, &c) in chars[absolute_open_pos + 1..].iter().enumerate() {
|
||||
match c {
|
||||
'{' => brace_count += 1,
|
||||
'}' => {
|
||||
brace_count -= 1;
|
||||
if brace_count == 0 {
|
||||
close_pos = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(close_pos) = close_pos {
|
||||
let absolute_close_pos = absolute_open_pos + 1 + close_pos;
|
||||
|
||||
// Extract the placeholder content
|
||||
let placeholder_content: String = chars[absolute_open_pos + 1..absolute_close_pos]
|
||||
.iter()
|
||||
.collect();
|
||||
let placeholder = parse_dynamic_placeholder(&placeholder_content)?;
|
||||
components.push(placeholder);
|
||||
|
||||
current_pos = absolute_close_pos + 1;
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Unmatched opening brace at position {}",
|
||||
absolute_open_pos
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// No more opening braces, add the remaining string as static
|
||||
if current_pos < chars.len() {
|
||||
let static_str: String = chars[current_pos..].iter().collect();
|
||||
components.push(QuicklinkLinkComponent::StaticStr(static_str));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(components)
|
||||
}
|
||||
|
||||
/// Helper function to impl Deserialize for `QuicklinkLink`.
|
||||
///
|
||||
/// Parse the content inside braces into a DynamicPlaceholder.
|
||||
///
|
||||
/// It supports the following formats:
|
||||
///
|
||||
/// 1. {query}: should be parsed to DynamicPlaceholder {argument_name: "query", default: None }
|
||||
/// 2. {argument_name: "query" }: should be parsed to DynamicPlaceholder {argument_name: "query", default: None }
|
||||
/// 3. {argument_name: "query", default: "rust" }: should be parsed to DynamicPlaceholder {argument_name: "query", default: Some("rust") }
|
||||
fn parse_dynamic_placeholder(content: &str) -> Result<QuicklinkLinkComponent, String> {
|
||||
let trimmed = content.trim();
|
||||
|
||||
// Case 1: {query} - simple argument name
|
||||
if !trimmed.contains(':') && !trimmed.contains(',') {
|
||||
return Ok(QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name: trimmed.to_string(),
|
||||
default: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Case 2 & 3: {argument_name: "query"} or {argument_name: "query", default: "rust"}
|
||||
// Parse as a simplified JSON-like structure
|
||||
let mut argument_name = None;
|
||||
let mut default_value = None;
|
||||
|
||||
// Split by commas and process each part
|
||||
let parts: Vec<&str> = trimmed.split(',').collect();
|
||||
|
||||
for part in parts {
|
||||
let part = part.trim();
|
||||
if let Some(colon_pos) = part.find(':') {
|
||||
let key = part[..colon_pos].trim();
|
||||
let value = part[colon_pos + 1..].trim();
|
||||
|
||||
// Remove quotes from value if present
|
||||
let value = if (value.starts_with('"') && value.ends_with('"'))
|
||||
|| (value.starts_with('\'') && value.ends_with('\''))
|
||||
{
|
||||
&value[1..value.len() - 1]
|
||||
} else {
|
||||
value
|
||||
};
|
||||
|
||||
match key {
|
||||
"argument_name" => argument_name = Some(value.to_string()),
|
||||
"default" => default_value = Some(value.to_string()),
|
||||
_ => return Err(format!("Unknown key '{}' in placeholder", key)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let argument_name = argument_name.ok_or("Missing argument_name in placeholder")?;
|
||||
|
||||
Ok(QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name,
|
||||
default: default_value,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json;
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_quicklink_link_case1() {
|
||||
// Case 1: {query} - simple argument name
|
||||
let test_string = "https://www.google.com/search?q={query}";
|
||||
let components = parse_quicklink_components(test_string).unwrap();
|
||||
let link = QuicklinkLink { components };
|
||||
|
||||
assert_eq!(link.components.len(), 2);
|
||||
|
||||
match &link.components[0] {
|
||||
QuicklinkLinkComponent::StaticStr(s) => {
|
||||
assert_eq!(s, "https://www.google.com/search?q=")
|
||||
}
|
||||
_ => panic!("Expected StaticStr component"),
|
||||
}
|
||||
|
||||
match &link.components[1] {
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name,
|
||||
default,
|
||||
} => {
|
||||
assert_eq!(argument_name, "query");
|
||||
assert_eq!(default, &None);
|
||||
}
|
||||
_ => panic!("Expected DynamicPlaceholder component"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_quicklink_link_case2() {
|
||||
// Case 2: {argument_name: "query"} - explicit argument name
|
||||
let test_string = r#"https://www.google.com/search?q={argument_name: "query"}"#;
|
||||
let components = parse_quicklink_components(test_string).unwrap();
|
||||
let link = QuicklinkLink { components };
|
||||
|
||||
assert_eq!(link.components.len(), 2);
|
||||
|
||||
match &link.components[0] {
|
||||
QuicklinkLinkComponent::StaticStr(s) => {
|
||||
assert_eq!(s, "https://www.google.com/search?q=")
|
||||
}
|
||||
_ => panic!("Expected StaticStr component"),
|
||||
}
|
||||
|
||||
match &link.components[1] {
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name,
|
||||
default,
|
||||
} => {
|
||||
assert_eq!(argument_name, "query");
|
||||
assert_eq!(default, &None);
|
||||
}
|
||||
_ => panic!("Expected DynamicPlaceholder component"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_quicklink_link_case3() {
|
||||
// Case 3: {argument_name: "query", default: "rust"} - with default value
|
||||
let test_string =
|
||||
r#"https://www.google.com/search?q={argument_name: "query", default: "rust"}"#;
|
||||
let components = parse_quicklink_components(test_string).unwrap();
|
||||
let link = QuicklinkLink { components };
|
||||
|
||||
assert_eq!(link.components.len(), 2);
|
||||
|
||||
match &link.components[0] {
|
||||
QuicklinkLinkComponent::StaticStr(s) => {
|
||||
assert_eq!(s, "https://www.google.com/search?q=")
|
||||
}
|
||||
_ => panic!("Expected StaticStr component"),
|
||||
}
|
||||
|
||||
match &link.components[1] {
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name,
|
||||
default,
|
||||
} => {
|
||||
assert_eq!(argument_name, "query");
|
||||
assert_eq!(default, &Some("rust".to_string()));
|
||||
}
|
||||
_ => panic!("Expected DynamicPlaceholder component"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_quicklink_link_multiple_placeholders() {
|
||||
// Test multiple placeholders in one string
|
||||
let test_string = r#"https://example.com/{category}/search?q={query}&lang={argument_name: "language", default: "en"}"#;
|
||||
let components = parse_quicklink_components(test_string).unwrap();
|
||||
let link = QuicklinkLink { components };
|
||||
|
||||
assert_eq!(link.components.len(), 6);
|
||||
|
||||
// Check the components
|
||||
match &link.components[0] {
|
||||
QuicklinkLinkComponent::StaticStr(s) => assert_eq!(s, "https://example.com/"),
|
||||
_ => panic!("Expected StaticStr component"),
|
||||
}
|
||||
|
||||
match &link.components[1] {
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name,
|
||||
default,
|
||||
} => {
|
||||
assert_eq!(argument_name, "category");
|
||||
assert_eq!(default, &None);
|
||||
}
|
||||
_ => panic!("Expected DynamicPlaceholder component"),
|
||||
}
|
||||
|
||||
match &link.components[2] {
|
||||
QuicklinkLinkComponent::StaticStr(s) => assert_eq!(s, "/search?q="),
|
||||
_ => panic!("Expected StaticStr component"),
|
||||
}
|
||||
|
||||
match &link.components[3] {
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name,
|
||||
default,
|
||||
} => {
|
||||
assert_eq!(argument_name, "query");
|
||||
assert_eq!(default, &None);
|
||||
}
|
||||
_ => panic!("Expected DynamicPlaceholder component"),
|
||||
}
|
||||
|
||||
match &link.components[4] {
|
||||
QuicklinkLinkComponent::StaticStr(s) => assert_eq!(s, "&lang="),
|
||||
_ => panic!("Expected StaticStr component"),
|
||||
}
|
||||
|
||||
match &link.components[5] {
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name,
|
||||
default,
|
||||
} => {
|
||||
assert_eq!(argument_name, "language");
|
||||
assert_eq!(default, &Some("en".to_string()));
|
||||
}
|
||||
_ => panic!("Expected DynamicPlaceholder component"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_quicklink_link_no_placeholders() {
|
||||
// Test string with no placeholders
|
||||
let test_string = "https://www.google.com/search?q=fixed";
|
||||
let components = parse_quicklink_components(test_string).unwrap();
|
||||
let link = QuicklinkLink { components };
|
||||
|
||||
assert_eq!(link.components.len(), 1);
|
||||
|
||||
match &link.components[0] {
|
||||
QuicklinkLinkComponent::StaticStr(s) => {
|
||||
assert_eq!(s, "https://www.google.com/search?q=fixed")
|
||||
}
|
||||
_ => panic!("Expected StaticStr component"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_quicklink_link_error_unmatched_brace() {
|
||||
// Test error case with unmatched brace
|
||||
let test_string = "https://www.google.com/search?q={query";
|
||||
let result = parse_quicklink_components(test_string);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
/// Unknown argument a and b
|
||||
#[test]
|
||||
fn test_deserialize_quicklink_link_unknown_arguments() {
|
||||
let test_string = r#"https://www.google.com/search?q={a: "a", b: "b"}"#;
|
||||
let result = parse_quicklink_components(test_string);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_quicklink_link_empty_components() {
|
||||
// Case 1: Empty components should result in empty string
|
||||
let link = QuicklinkLink { components: vec![] };
|
||||
|
||||
let mut serializer = serde_json::Serializer::new(Vec::new());
|
||||
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
|
||||
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
|
||||
assert_eq!(serialized, r#""""#); // Empty string
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_quicklink_link_static_str_only() {
|
||||
// Case 2: Only StaticStr components
|
||||
let link = QuicklinkLink {
|
||||
components: vec![
|
||||
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
|
||||
QuicklinkLinkComponent::StaticStr("rust".to_string()),
|
||||
],
|
||||
};
|
||||
|
||||
let mut serializer = serde_json::Serializer::new(Vec::new());
|
||||
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
|
||||
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
|
||||
assert_eq!(serialized, r#""https://www.google.com/search?q=rust""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_quicklink_link_dynamic_placeholder_only() {
|
||||
// Case 3: Only DynamicPlaceholder components
|
||||
let link = QuicklinkLink {
|
||||
components: vec![
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name: "query".to_string(),
|
||||
default: None,
|
||||
},
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name: "language".to_string(),
|
||||
default: Some("en".to_string()),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let mut serializer = serde_json::Serializer::new(Vec::new());
|
||||
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
|
||||
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
|
||||
assert_eq!(
|
||||
serialized,
|
||||
r#""{query}{argument_name: \"language\", default: \"en\"}""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_quicklink_link_mixed_components() {
|
||||
// Case 4: Mix of StaticStr and DynamicPlaceholder components
|
||||
let link = QuicklinkLink {
|
||||
components: vec![
|
||||
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name: "query".to_string(),
|
||||
default: None,
|
||||
},
|
||||
QuicklinkLinkComponent::StaticStr("&lang=".to_string()),
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name: "language".to_string(),
|
||||
default: Some("en".to_string()),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let mut serializer = serde_json::Serializer::new(Vec::new());
|
||||
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
|
||||
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
|
||||
assert_eq!(
|
||||
serialized,
|
||||
r#""https://www.google.com/search?q={query}&lang={argument_name: \"language\", default: \"en\"}""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_quicklink_link_dynamic_placeholder_no_default() {
|
||||
// Additional test: DynamicPlaceholder without default value
|
||||
let link = QuicklinkLink {
|
||||
components: vec![
|
||||
QuicklinkLinkComponent::StaticStr("https://example.com/".to_string()),
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name: "category".to_string(),
|
||||
default: None,
|
||||
},
|
||||
QuicklinkLinkComponent::StaticStr("/items".to_string()),
|
||||
],
|
||||
};
|
||||
|
||||
let mut serializer = serde_json::Serializer::new(Vec::new());
|
||||
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
|
||||
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
|
||||
assert_eq!(serialized, r#""https://example.com/{category}/items""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_quicklink_link_dynamic_placeholder_with_default() {
|
||||
// Additional test: DynamicPlaceholder with default value
|
||||
let link = QuicklinkLink {
|
||||
components: vec![
|
||||
QuicklinkLinkComponent::StaticStr("https://api.example.com/".to_string()),
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name: "version".to_string(),
|
||||
default: Some("v1".to_string()),
|
||||
},
|
||||
QuicklinkLinkComponent::StaticStr("/data".to_string()),
|
||||
],
|
||||
};
|
||||
|
||||
let mut serializer = serde_json::Serializer::new(Vec::new());
|
||||
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
|
||||
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
|
||||
assert_eq!(
|
||||
serialized,
|
||||
r#""https://api.example.com/{argument_name: \"version\", default: \"v1\"}/data""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quicklink_link_arguments_empty_components() {
|
||||
let link = QuicklinkLink { components: vec![] };
|
||||
|
||||
let map = quicklink_link_arguments(link);
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quicklink_link_arguments_static_str_only() {
|
||||
let link = QuicklinkLink {
|
||||
components: vec![QuicklinkLinkComponent::StaticStr(
|
||||
"https://api.example.com/".to_string(),
|
||||
)],
|
||||
};
|
||||
|
||||
let map = quicklink_link_arguments(link);
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quicklink_link_arguments_dynamic_placeholder_only() {
|
||||
let link = QuicklinkLink {
|
||||
components: vec![
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name: "query".to_string(),
|
||||
default: None,
|
||||
},
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name: "language".to_string(),
|
||||
default: Some("en".to_string()),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let map = quicklink_link_arguments(link);
|
||||
|
||||
let expected_map = {
|
||||
let mut map = IndexMap::new();
|
||||
map.insert("query".into(), None);
|
||||
map.insert("language".into(), Some("en".into()));
|
||||
|
||||
map
|
||||
};
|
||||
assert_eq!(map, expected_map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quicklink_link_concatenate_url_static_components_only() {
|
||||
// Case 1: the link (QuicklinkLink) only contains static str components
|
||||
let link = QuicklinkLink {
|
||||
components: vec![
|
||||
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
|
||||
QuicklinkLinkComponent::StaticStr("rust".to_string()),
|
||||
],
|
||||
};
|
||||
let result = link.concatenate_url(&None);
|
||||
assert_eq!(result, "https://www.google.com/search?q=rust");
|
||||
}
|
||||
|
||||
/// The link has 1 dynamic component with no default value, but `user_supplied_args` is None
|
||||
#[test]
|
||||
fn test_quicklink_link_concatenate_url_dynamic_no_default_no_args() {
|
||||
let link = QuicklinkLink {
|
||||
components: vec![
|
||||
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name: "query".to_string(),
|
||||
default: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
let result = link.concatenate_url(&None);
|
||||
assert_eq!(result, "https://www.google.com/search?q=");
|
||||
}
|
||||
|
||||
/// The link has 1 dynamic component with no default value, `user_supplied_args` is Some(hashmap),
|
||||
/// but this dynamic argument is not provided in the hashmap
|
||||
#[test]
|
||||
fn test_quicklink_link_concatenate_url_dynamic_no_default_missing_from_args() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let link = QuicklinkLink {
|
||||
components: vec![
|
||||
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name: "query".to_string(),
|
||||
default: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
let mut user_args = HashMap::new();
|
||||
user_args.insert("other_param".to_string(), "value".to_string());
|
||||
let result = link.concatenate_url(&Some(user_args));
|
||||
assert_eq!(result, "https://www.google.com/search?q=");
|
||||
}
|
||||
|
||||
/// The link has 1 dynamic component with a default value, `user_supplied_args` is None
|
||||
#[test]
|
||||
fn test_quicklink_link_concatenate_url_dynamic_with_default_no_args() {
|
||||
let link = QuicklinkLink {
|
||||
components: vec![
|
||||
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name: "query".to_string(),
|
||||
default: Some("rust".to_string()),
|
||||
},
|
||||
],
|
||||
};
|
||||
let result = link.concatenate_url(&None);
|
||||
assert_eq!(result, "https://www.google.com/search?q=rust");
|
||||
}
|
||||
|
||||
/// The link has 1 dynamic component with a default value, `user_supplied_args` is Some(hashmap),
|
||||
/// this dynamic argument is not provided in the hashmap
|
||||
#[test]
|
||||
fn test_quicklink_link_concatenate_url_dynamic_with_default_missing_from_args() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let link = QuicklinkLink {
|
||||
components: vec![
|
||||
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name: "query".to_string(),
|
||||
default: Some("rust".to_string()),
|
||||
},
|
||||
],
|
||||
};
|
||||
let mut user_args = HashMap::new();
|
||||
user_args.insert("other_param".to_string(), "value".to_string());
|
||||
let result = link.concatenate_url(&Some(user_args));
|
||||
assert_eq!(result, "https://www.google.com/search?q=rust");
|
||||
}
|
||||
|
||||
/// The link has 1 dynamic component with a default value, `user_supplied_args` is Some(hashmap),
|
||||
/// hashmap contains the dynamic parameter.
|
||||
///
|
||||
/// (the user-supplied argument should be used, the default value should be ignored)
|
||||
#[test]
|
||||
fn test_quicklink_link_concatenate_url_dynamic_with_default_provided_in_args() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let link = QuicklinkLink {
|
||||
components: vec![
|
||||
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
|
||||
QuicklinkLinkComponent::DynamicPlaceholder {
|
||||
argument_name: "query".to_string(),
|
||||
default: Some("rust".to_string()),
|
||||
},
|
||||
],
|
||||
};
|
||||
let mut user_args = HashMap::new();
|
||||
user_args.insert("query".to_string(), "python".to_string());
|
||||
let result = link.concatenate_url(&Some(user_args));
|
||||
assert_eq!(result, "https://www.google.com/search?q=python");
|
||||
}
|
||||
|
||||
/// The link is empty
|
||||
#[test]
|
||||
fn test_quicklink_link_concatenate_url_empty_link() {
|
||||
let link = QuicklinkLink { components: vec![] };
|
||||
let result = link.concatenate_url(&None);
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
}
|
||||
|
||||
694
src-tauri/src/extension/third_party/check.rs
vendored
Normal file
694
src-tauri/src/extension/third_party/check.rs
vendored
Normal file
@@ -0,0 +1,694 @@
|
||||
//! Coco has 4 sources of `plugin.json` to check and validate:
|
||||
//!
|
||||
//! 1. From coco-extensions repository
|
||||
//!
|
||||
//! Granted, Coco APP won't check these files directly, but the code here
|
||||
//! will run in that repository's CI to prevent errors in the first place.
|
||||
//!
|
||||
//! 2. From the "<data directory>/third_party_extensions" directory
|
||||
//! 3. Imported via "Import Local Extension"
|
||||
//! 4. Downloaded from the "store/extension/<extension ID>/_download" API
|
||||
//!
|
||||
//! This file contains the checks that are general enough to be applied to all
|
||||
//! these 4 sources
|
||||
|
||||
use crate::extension::Extension;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::util::platform::Platform;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub(crate) fn general_check(extension: &Extension) -> Result<(), String> {
|
||||
// Check main extension
|
||||
check_main_extension_only(extension)?;
|
||||
check_main_extension_or_sub_extension(extension, &format!("extension [{}]", extension.id))?;
|
||||
|
||||
// `None` if `extension` is compatible with all the platforms. Otherwise `Some(limited_platforms)`
|
||||
let limited_supported_platforms = match extension.platforms.as_ref() {
|
||||
Some(platforms) => {
|
||||
if platforms.len() == Platform::num_of_supported_platforms() {
|
||||
None
|
||||
} else {
|
||||
Some(platforms)
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Check sub extensions
|
||||
let commands = match extension.commands {
|
||||
Some(ref v) => v.as_slice(),
|
||||
None => &[],
|
||||
};
|
||||
let scripts = match extension.scripts {
|
||||
Some(ref v) => v.as_slice(),
|
||||
None => &[],
|
||||
};
|
||||
let quicklinks = match extension.quicklinks {
|
||||
Some(ref v) => v.as_slice(),
|
||||
None => &[],
|
||||
};
|
||||
let sub_extensions = [commands, scripts, quicklinks].concat();
|
||||
let mut sub_extension_ids = HashSet::new();
|
||||
|
||||
for sub_extension in sub_extensions.iter() {
|
||||
check_sub_extension_only(&extension.id, sub_extension, limited_supported_platforms)?;
|
||||
check_main_extension_or_sub_extension(
|
||||
extension,
|
||||
&format!("sub-extension [{}-{}]", extension.id, sub_extension.id),
|
||||
)?;
|
||||
|
||||
if !sub_extension_ids.insert(sub_extension.id.as_str()) {
|
||||
// extension ID already exists
|
||||
return Err(format!(
|
||||
"sub-extension with ID [{}] already exists",
|
||||
sub_extension.id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This checks the main extension only, it won't check sub-extensions.
|
||||
fn check_main_extension_only(extension: &Extension) -> Result<(), String> {
|
||||
// Group and Extension cannot have alias
|
||||
if extension.alias.is_some() {
|
||||
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid extension [{}], extension of type [{:?}] cannot have alias",
|
||||
extension.id, extension.r#type
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Group and Extension cannot have hotkey
|
||||
if extension.hotkey.is_some() {
|
||||
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid extension [{}], extension of type [{:?}] cannot have hotkey",
|
||||
extension.id, extension.r#type
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if extension.commands.is_some() || extension.scripts.is_some() || extension.quicklinks.is_some()
|
||||
{
|
||||
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid extension [{}], only extension of type [Group] and [Extension] can have sub-extensions",
|
||||
extension.id,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_sub_extension_only(
|
||||
extension_id: &str,
|
||||
sub_extension: &Extension,
|
||||
limited_platforms: Option<&HashSet<Platform>>,
|
||||
) -> Result<(), String> {
|
||||
if sub_extension.r#type == ExtensionType::Group
|
||||
|| sub_extension.r#type == ExtensionType::Extension
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid sub-extension [{}-{}]: sub-extensions should not be of type [Group] or [Extension]",
|
||||
extension_id, sub_extension.id
|
||||
));
|
||||
}
|
||||
|
||||
if sub_extension.commands.is_some()
|
||||
|| sub_extension.scripts.is_some()
|
||||
|| sub_extension.quicklinks.is_some()
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid sub-extension [{}-{}]: fields [commands/scripts/quicklinks] should not be set in sub-extensions",
|
||||
extension_id, sub_extension.id
|
||||
));
|
||||
}
|
||||
|
||||
if sub_extension.developer.is_some() {
|
||||
return Err(format!(
|
||||
"invalid sub-extension [{}-{}]: field [developer] should not be set in sub-extensions",
|
||||
extension_id, sub_extension.id
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(platforms_supported_by_main_extension) = limited_platforms {
|
||||
match sub_extension.platforms {
|
||||
Some(ref platforms_supported_by_sub_extension) => {
|
||||
let diff = platforms_supported_by_sub_extension
|
||||
.difference(&platforms_supported_by_main_extension)
|
||||
.into_iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
if !diff.is_empty() {
|
||||
return Err(format!(
|
||||
"invalid sub-extension [{}-{}]: it supports platforms {:?} that are not supported by the main extension",
|
||||
extension_id, sub_extension.id, diff
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// if `sub_extension.platform` is None, it means it has the same value
|
||||
// as main extension's `platforms` field, so we don't need to check it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_main_extension_or_sub_extension(
|
||||
extension: &Extension,
|
||||
identifier: &str,
|
||||
) -> Result<(), String> {
|
||||
// If field `action` is Some, then it should be a Command
|
||||
if extension.action.is_some() && extension.r#type != ExtensionType::Command {
|
||||
return Err(format!(
|
||||
"invalid {}, field [action] is set for a non-Command extension",
|
||||
identifier
|
||||
));
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::Command && extension.action.is_none() {
|
||||
return Err(format!(
|
||||
"invalid {}, field [action] should be set for a Command extension",
|
||||
identifier
|
||||
));
|
||||
}
|
||||
|
||||
// If field `quicklink` is Some, then it should be a Quicklink
|
||||
if extension.quicklink.is_some() && extension.r#type != ExtensionType::Quicklink {
|
||||
return Err(format!(
|
||||
"invalid {}, field [quicklink] is set for a non-Quicklink extension",
|
||||
identifier
|
||||
));
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::Quicklink && extension.quicklink.is_none() {
|
||||
return Err(format!(
|
||||
"invalid {}, field [quicklink] should be set for a Quicklink extension",
|
||||
identifier
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extension::{CommandAction, Quicklink, QuicklinkLink, QuicklinkLinkComponent};
|
||||
|
||||
/// Helper function to create a basic valid extension
|
||||
fn create_basic_extension(id: &str, extension_type: ExtensionType) -> Extension {
|
||||
Extension {
|
||||
id: id.to_string(),
|
||||
name: "Test Extension".to_string(),
|
||||
developer: None,
|
||||
platforms: None,
|
||||
description: "Test description".to_string(),
|
||||
icon: "test-icon.png".to_string(),
|
||||
r#type: extension_type,
|
||||
action: None,
|
||||
quicklink: None,
|
||||
commands: None,
|
||||
scripts: None,
|
||||
quicklinks: None,
|
||||
alias: None,
|
||||
hotkey: None,
|
||||
enabled: true,
|
||||
settings: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to create a command action
|
||||
fn create_command_action() -> CommandAction {
|
||||
CommandAction {
|
||||
exec: "echo".to_string(),
|
||||
args: Some(vec!["test".to_string()]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to create a quicklink
|
||||
fn create_quicklink() -> Quicklink {
|
||||
Quicklink {
|
||||
link: QuicklinkLink {
|
||||
components: vec![QuicklinkLinkComponent::StaticStr(
|
||||
"https://example.com".to_string(),
|
||||
)],
|
||||
},
|
||||
open_with: None,
|
||||
}
|
||||
}
|
||||
|
||||
/* test_check_main_extension_only */
|
||||
#[test]
|
||||
fn test_group_cannot_have_alias() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
extension.alias = Some("group-alias".to_string());
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("cannot have alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extension_cannot_have_alias() {
|
||||
let mut extension = create_basic_extension("test-ext", ExtensionType::Extension);
|
||||
extension.alias = Some("ext-alias".to_string());
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("cannot have alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_cannot_have_hotkey() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
extension.hotkey = Some("cmd+g".to_string());
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("cannot have hotkey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extension_cannot_have_hotkey() {
|
||||
let mut extension = create_basic_extension("test-ext", ExtensionType::Extension);
|
||||
extension.hotkey = Some("cmd+e".to_string());
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("cannot have hotkey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_container_types_cannot_have_sub_extensions() {
|
||||
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
|
||||
extension.action = Some(create_command_action());
|
||||
extension.commands = Some(vec![create_basic_extension(
|
||||
"sub-cmd",
|
||||
ExtensionType::Command,
|
||||
)]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("only extension of type [Group] and [Extension] can have sub-extensions")
|
||||
);
|
||||
}
|
||||
/* test_check_main_extension_only */
|
||||
|
||||
/* test check_main_extension_or_sub_extension */
|
||||
#[test]
|
||||
fn test_command_must_have_action() {
|
||||
let extension = create_basic_extension("test-cmd", ExtensionType::Command);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("field [action] should be set for a Command extension")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_command_cannot_have_action() {
|
||||
let mut extension = create_basic_extension("test-script", ExtensionType::Script);
|
||||
extension.action = Some(create_command_action());
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("field [action] is set for a non-Command extension")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quicklink_must_have_quicklink_field() {
|
||||
let extension = create_basic_extension("test-quicklink", ExtensionType::Quicklink);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("field [quicklink] should be set for a Quicklink extension")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_quicklink_cannot_have_quicklink_field() {
|
||||
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
|
||||
extension.action = Some(create_command_action());
|
||||
extension.quicklink = Some(create_quicklink());
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("field [quicklink] is set for a non-Quicklink extension")
|
||||
);
|
||||
}
|
||||
/* test check_main_extension_or_sub_extension */
|
||||
|
||||
/* Test check_sub_extension_only */
|
||||
#[test]
|
||||
fn test_sub_extension_cannot_be_group() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
let sub_group = create_basic_extension("sub-group", ExtensionType::Group);
|
||||
extension.commands = Some(vec![sub_group]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("sub-extensions should not be of type [Group] or [Extension]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sub_extension_cannot_be_extension() {
|
||||
let mut extension = create_basic_extension("test-ext", ExtensionType::Extension);
|
||||
let sub_ext = create_basic_extension("sub-ext", ExtensionType::Extension);
|
||||
extension.scripts = Some(vec![sub_ext]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("sub-extensions should not be of type [Group] or [Extension]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sub_extension_cannot_have_developer() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.developer = Some("test-dev".to_string());
|
||||
|
||||
extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("field [developer] should not be set in sub-extensions")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sub_extension_cannot_have_sub_extensions() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.commands = Some(vec![create_basic_extension(
|
||||
"nested-cmd",
|
||||
ExtensionType::Command,
|
||||
)]);
|
||||
|
||||
extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().contains(
|
||||
"fields [commands/scripts/quicklinks] should not be set in sub-extensions"
|
||||
)
|
||||
);
|
||||
}
|
||||
/* Test check_sub_extension_only */
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_sub_extension_ids() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
|
||||
let mut cmd1 = create_basic_extension("duplicate-id", ExtensionType::Command);
|
||||
cmd1.action = Some(create_command_action());
|
||||
|
||||
let mut cmd2 = create_basic_extension("duplicate-id", ExtensionType::Command);
|
||||
cmd2.action = Some(create_command_action());
|
||||
|
||||
extension.commands = Some(vec![cmd1, cmd2]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("sub-extension with ID [duplicate-id] already exists")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_ids_across_different_sub_extension_types() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
|
||||
let mut cmd = create_basic_extension("same-id", ExtensionType::Command);
|
||||
cmd.action = Some(create_command_action());
|
||||
|
||||
let script = create_basic_extension("same-id", ExtensionType::Script);
|
||||
|
||||
extension.commands = Some(vec![cmd]);
|
||||
extension.scripts = Some(vec![script]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("sub-extension with ID [same-id] already exists")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_group_extension() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
extension.commands = Some(vec![create_basic_extension("cmd1", ExtensionType::Command)]);
|
||||
|
||||
assert!(general_check(&extension).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_extension_type() {
|
||||
let mut extension = create_basic_extension("test-ext", ExtensionType::Extension);
|
||||
extension.scripts = Some(vec![create_basic_extension(
|
||||
"script1",
|
||||
ExtensionType::Script,
|
||||
)]);
|
||||
|
||||
assert!(general_check(&extension).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_command_extension() {
|
||||
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
|
||||
extension.action = Some(create_command_action());
|
||||
|
||||
assert!(general_check(&extension).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_quicklink_extension() {
|
||||
let mut extension = create_basic_extension("test-quicklink", ExtensionType::Quicklink);
|
||||
extension.quicklink = Some(create_quicklink());
|
||||
|
||||
assert!(general_check(&extension).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_complex_extension() {
|
||||
let mut extension = create_basic_extension("spotify-controls", ExtensionType::Extension);
|
||||
|
||||
// Add valid commands
|
||||
let mut play_pause = create_basic_extension("play-pause", ExtensionType::Command);
|
||||
play_pause.action = Some(create_command_action());
|
||||
|
||||
let mut next_track = create_basic_extension("next-track", ExtensionType::Command);
|
||||
next_track.action = Some(create_command_action());
|
||||
|
||||
let mut prev_track = create_basic_extension("prev-track", ExtensionType::Command);
|
||||
prev_track.action = Some(create_command_action());
|
||||
|
||||
extension.commands = Some(vec![play_pause, next_track, prev_track]);
|
||||
|
||||
assert!(general_check(&extension).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_single_layer_command() {
|
||||
let mut extension = create_basic_extension("empty-trash", ExtensionType::Command);
|
||||
extension.action = Some(create_command_action());
|
||||
|
||||
assert!(general_check(&extension).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_alias_and_hotkey_allowed() {
|
||||
let mut extension = create_basic_extension("test-cmd", ExtensionType::Command);
|
||||
extension.action = Some(create_command_action());
|
||||
extension.alias = Some("cmd-alias".to_string());
|
||||
extension.hotkey = Some("cmd+t".to_string());
|
||||
|
||||
assert!(general_check(&extension).is_ok());
|
||||
}
|
||||
|
||||
/*
|
||||
* Tests for check that sub extension cannot support extensions that are not
|
||||
* supported by the main extension
|
||||
*
|
||||
* Start here
|
||||
*/
|
||||
#[test]
|
||||
fn test_platform_validation_both_none() {
|
||||
// Case 1: main extension's platforms = None, sub extension's platforms = None
|
||||
// Should return Ok(())
|
||||
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
|
||||
main_extension.platforms = None;
|
||||
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.platforms = None;
|
||||
|
||||
main_extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&main_extension);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_validation_main_all_sub_none() {
|
||||
// Case 2: main extension's platforms = Some(all platforms), sub extension's platforms = None
|
||||
// Should return Ok(())
|
||||
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
|
||||
main_extension.platforms = Some(Platform::all());
|
||||
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.platforms = None;
|
||||
|
||||
main_extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&main_extension);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_validation_main_none_sub_some() {
|
||||
// Case 3: main extension's platforms = None, sub extension's platforms = Some([Platform::Macos])
|
||||
// Should return Ok(()) because None means supports all platforms
|
||||
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
|
||||
main_extension.platforms = None;
|
||||
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.platforms = Some(HashSet::from([Platform::Macos]));
|
||||
|
||||
main_extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&main_extension);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_validation_main_all_sub_subset() {
|
||||
// Case 4: main extension's platforms = Some(all platforms), sub extension's platforms = Some([Platform::Macos])
|
||||
// Should return Ok(()) because sub extension supports a subset of main extension's platforms
|
||||
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
|
||||
main_extension.platforms = Some(Platform::all());
|
||||
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.platforms = Some(HashSet::from([Platform::Macos]));
|
||||
|
||||
main_extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&main_extension);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_validation_main_limited_sub_unsupported() {
|
||||
// Case 5: main extension's platforms = Some([Platform::Macos]), sub extension's platforms = Some([Platform::Linux])
|
||||
// Should return Err because sub extension supports a platform not supported by main extension
|
||||
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
|
||||
main_extension.platforms = Some(HashSet::from([Platform::Macos]));
|
||||
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.platforms = Some(HashSet::from([Platform::Linux]));
|
||||
|
||||
main_extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&main_extension);
|
||||
assert!(result.is_err());
|
||||
let error_msg = result.unwrap_err();
|
||||
assert!(error_msg.contains("it supports platforms"));
|
||||
assert!(error_msg.contains("that are not supported by the main extension"));
|
||||
assert!(error_msg.contains("Linux")); // Should mention the unsupported platform
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_validation_main_partial_sub_unsupported() {
|
||||
// Case 6: main extension's platforms = Some([Platform::Macos, Platform::Windows]), sub extension's platforms = Some([Platform::Linux])
|
||||
// Should return Err because sub extension supports a platform not supported by main extension
|
||||
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
|
||||
main_extension.platforms = Some(HashSet::from([Platform::Macos, Platform::Windows]));
|
||||
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.platforms = Some(HashSet::from([Platform::Linux]));
|
||||
|
||||
main_extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&main_extension);
|
||||
assert!(result.is_err());
|
||||
let error_msg = result.unwrap_err();
|
||||
assert!(error_msg.contains("it supports platforms"));
|
||||
assert!(error_msg.contains("that are not supported by the main extension"));
|
||||
assert!(error_msg.contains("Linux")); // Should mention the unsupported platform
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_validation_main_limited_sub_none() {
|
||||
// Case 7: main extension's platforms = Some([Platform::Macos]), sub extension's platforms = None
|
||||
// Should return Ok(()) because when sub extension's platforms is None, it inherits main extension's platforms
|
||||
let mut main_extension = create_basic_extension("main-ext", ExtensionType::Group);
|
||||
main_extension.platforms = Some(HashSet::from([Platform::Macos]));
|
||||
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.action = Some(create_command_action());
|
||||
sub_cmd.platforms = None;
|
||||
|
||||
main_extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&main_extension);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
/*
|
||||
* Tests for check that sub extension cannot support extensions that are not
|
||||
* supported by the main extension
|
||||
*
|
||||
* End here
|
||||
*/
|
||||
}
|
||||
249
src-tauri/src/extension/third_party/install/local_extension.rs
vendored
Normal file
249
src-tauri/src/extension/third_party/install/local_extension.rs
vendored
Normal file
@@ -0,0 +1,249 @@
|
||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||
use crate::extension::third_party::check::general_check;
|
||||
use crate::extension::third_party::install::{
|
||||
filter_out_incompatible_sub_extensions, is_extension_installed,
|
||||
};
|
||||
use crate::extension::third_party::{
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
|
||||
};
|
||||
use crate::extension::{Extension, canonicalize_relative_icon_path};
|
||||
use crate::util::platform::Platform;
|
||||
use serde_json::Value as Json;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tauri::AppHandle;
|
||||
use tokio::fs;
|
||||
|
||||
/// All the extensions installed from local file will belong to a special developer
|
||||
/// "__local__".
|
||||
const DEVELOPER_ID_LOCAL: &str = "__local__";
|
||||
|
||||
/// Install the extension specified by `path`.
|
||||
///
|
||||
/// `path` should point to a directory with the following structure:
|
||||
///
|
||||
/// ```text
|
||||
/// extension-directory/
|
||||
/// ├── assets/
|
||||
/// │ ├── icon.png
|
||||
/// │ └── other-assets...
|
||||
/// └── plugin.json
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_local_extension(
|
||||
tauri_app_handle: AppHandle,
|
||||
path: PathBuf,
|
||||
) -> Result<(), String> {
|
||||
let extension_dir_name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| "Invalid extension: no directory name".to_string())?
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid extension: non-UTF8 extension id".to_string())?;
|
||||
|
||||
// we use extension directory name as the extension ID.
|
||||
let extension_id = extension_dir_name;
|
||||
if is_extension_installed(DEVELOPER_ID_LOCAL, extension_id).await {
|
||||
// The frontend code uses this string to distinguish between 2 error cases:
|
||||
//
|
||||
// 1. This extension is already imported
|
||||
// 2. This extension is incompatible with the current platform
|
||||
// 3. The selected directory does not contain a valid extension
|
||||
//
|
||||
// do NOT edit this without updating the frontend code.
|
||||
//
|
||||
// ```ts
|
||||
// if (errorMessage === "already imported") {
|
||||
// addError(t("settings.extensions.hints.extensionAlreadyImported"));
|
||||
// } else if (errorMessage === "incompatible") {
|
||||
// addError(t("settings.extensions.hints.incompatibleExtension"));
|
||||
// } else {
|
||||
// addError(t("settings.extensions.hints.importFailed"));
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// This is definitely error-prone, but we have to do this until we have
|
||||
// structured error type
|
||||
return Err("already imported".into());
|
||||
}
|
||||
|
||||
let plugin_json_path = path.join(PLUGIN_JSON_FILE_NAME);
|
||||
|
||||
let plugin_json_content = fs::read_to_string(&plugin_json_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Parse as JSON first as it is not valid for `struct Extension`, we need to
|
||||
// correct it (set fields `id` and `developer`) before converting it to `struct Extension`:
|
||||
let mut extension_json: Json =
|
||||
serde_json::from_str(&plugin_json_content).map_err(|e| e.to_string())?;
|
||||
|
||||
// Set the main extension ID to the directory name
|
||||
let extension_obj = extension_json
|
||||
.as_object_mut()
|
||||
.expect("extension_json should be an object");
|
||||
extension_obj.insert("id".to_string(), Json::String(extension_id.to_string()));
|
||||
extension_obj.insert(
|
||||
"developer".to_string(),
|
||||
Json::String(DEVELOPER_ID_LOCAL.to_string()),
|
||||
);
|
||||
|
||||
// Counter for sub-extension IDs
|
||||
let mut counter = 1u32;
|
||||
|
||||
// Set IDs for commands
|
||||
if let Some(commands) = extension_obj.get_mut("commands") {
|
||||
if let Some(commands_array) = commands.as_array_mut() {
|
||||
for command in commands_array {
|
||||
if let Some(command_obj) = command.as_object_mut() {
|
||||
command_obj.insert("id".to_string(), Json::String(counter.to_string()));
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set IDs for quicklinks
|
||||
if let Some(quicklinks) = extension_obj.get_mut("quicklinks") {
|
||||
if let Some(quicklinks_array) = quicklinks.as_array_mut() {
|
||||
for quicklink in quicklinks_array {
|
||||
if let Some(quicklink_obj) = quicklink.as_object_mut() {
|
||||
quicklink_obj.insert("id".to_string(), Json::String(counter.to_string()));
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set IDs for scripts
|
||||
if let Some(scripts) = extension_obj.get_mut("scripts") {
|
||||
if let Some(scripts_array) = scripts.as_array_mut() {
|
||||
for script in scripts_array {
|
||||
if let Some(script_obj) = script.as_object_mut() {
|
||||
script_obj.insert("id".to_string(), Json::String(counter.to_string()));
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now we can convert JSON to `struct Extension`
|
||||
let mut extension: Extension =
|
||||
serde_json::from_value(extension_json).map_err(|e| e.to_string())?;
|
||||
|
||||
let current_platform = Platform::current();
|
||||
/* Check begins here */
|
||||
general_check(&extension)?;
|
||||
|
||||
if let Some(ref platforms) = extension.platforms {
|
||||
if !platforms.contains(¤t_platform) {
|
||||
// The frontend code uses this string to distinguish between 3 error cases:
|
||||
//
|
||||
// 1. This extension is already imported
|
||||
// 2. This extension is incompatible with the current platform
|
||||
// 3. The selected directory does not contain a valid extension
|
||||
//
|
||||
// do NOT edit this without updating the frontend code.
|
||||
//
|
||||
// ```ts
|
||||
// if (errorMessage === "already imported") {
|
||||
// addError(t("settings.extensions.hints.extensionAlreadyImported"));
|
||||
// } else if (errorMessage === "incompatible") {
|
||||
// addError(t("settings.extensions.hints.incompatibleExtension"));
|
||||
// } else {
|
||||
// addError(t("settings.extensions.hints.importFailed"));
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// This is definitely error-prone, but we have to do this until we have
|
||||
// structured error type
|
||||
return Err("incompatible".into());
|
||||
}
|
||||
}
|
||||
/* Check ends here */
|
||||
|
||||
// Extension is compatible with current platform, but it could contain sub
|
||||
// extensions that are not, filter them out.
|
||||
filter_out_incompatible_sub_extensions(&mut extension, current_platform);
|
||||
|
||||
// Create destination directory
|
||||
let dest_dir = get_third_party_extension_directory(&tauri_app_handle)
|
||||
.join(DEVELOPER_ID_LOCAL)
|
||||
.join(extension_dir_name);
|
||||
|
||||
fs::create_dir_all(&dest_dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Copy all files except plugin.json
|
||||
let mut entries = fs::read_dir(&path).await.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? {
|
||||
let file_name = entry.file_name();
|
||||
let file_name_str = file_name
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid filename: non-UTF8".to_string())?;
|
||||
|
||||
// plugin.json will be handled separately.
|
||||
if file_name_str == PLUGIN_JSON_FILE_NAME {
|
||||
continue;
|
||||
}
|
||||
|
||||
let src_path = entry.path();
|
||||
let dest_path = dest_dir.join(&file_name);
|
||||
|
||||
if src_path.is_dir() {
|
||||
// Recursively copy directory
|
||||
copy_dir_recursively(&src_path, &dest_path).await?;
|
||||
} else {
|
||||
// Copy file
|
||||
fs::copy(&src_path, &dest_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
// Write the corrected plugin.json file
|
||||
let corrected_plugin_json =
|
||||
serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?;
|
||||
|
||||
let dest_plugin_json_path = dest_dir.join(PLUGIN_JSON_FILE_NAME);
|
||||
fs::write(&dest_plugin_json_path, corrected_plugin_json)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Canonicalize relative icon paths
|
||||
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
|
||||
|
||||
// Add extension to the search source
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.unwrap()
|
||||
.add_extension(extension)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper function to recursively copy directories.
|
||||
#[async_recursion::async_recursion]
|
||||
async fn copy_dir_recursively(src: &Path, dest: &Path) -> Result<(), String> {
|
||||
tokio::fs::create_dir_all(dest)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut read_dir = tokio::fs::read_dir(src).await.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(entry) = read_dir.next_entry().await.map_err(|e| e.to_string())? {
|
||||
let src_path = entry.path();
|
||||
let dest_path = dest.join(entry.file_name());
|
||||
|
||||
if src_path.is_dir() {
|
||||
copy_dir_recursively(&src_path, &dest_path).await?;
|
||||
} else {
|
||||
tokio::fs::copy(&src_path, &dest_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
224
src-tauri/src/extension/third_party/install/mod.rs
vendored
Normal file
224
src-tauri/src/extension/third_party/install/mod.rs
vendored
Normal file
@@ -0,0 +1,224 @@
|
||||
//! This module contains the code of extension installation.
|
||||
//!
|
||||
//!
|
||||
//! # How
|
||||
//!
|
||||
//! Technically, installing an extension involves the following steps:
|
||||
//!
|
||||
//! 1. Correct the `plugin.json` JSON if it does not conform to our `struct Extension`
|
||||
//! definition.
|
||||
//!
|
||||
//! 2. Write the extension files to the corresponding location
|
||||
//!
|
||||
//! * developer directory
|
||||
//! * extension directory
|
||||
//! * assets directory
|
||||
//! * various assets files, e.g., "icon.png"
|
||||
//! * plugin.json file
|
||||
//!
|
||||
//! 3. Canonicalize the `Extension.icon` fields if they are relative paths
|
||||
//! (relative to the `assets` directory)
|
||||
//!
|
||||
//! 4. Deserialize the `plugin.json` file to a `struct Extension`, and call
|
||||
//! `THIRD_PARTY_EXTENSIONS_DIRECTORY.add_extension(extension)` to add it to
|
||||
//! the in-memory extension list.
|
||||
|
||||
pub(crate) mod local_extension;
|
||||
pub(crate) mod store;
|
||||
|
||||
use crate::extension::Extension;
|
||||
use crate::util::platform::Platform;
|
||||
|
||||
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
|
||||
pub(crate) async fn is_extension_installed(developer: &str, extension_id: &str) -> bool {
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.unwrap()
|
||||
.extension_exists(developer, extension_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Filters out sub-extensions that are not compatible with the current platform.
|
||||
///
|
||||
/// We make `current_platform` an argument so that this function is testable.
|
||||
pub(crate) fn filter_out_incompatible_sub_extensions(
|
||||
extension: &mut Extension,
|
||||
current_platform: Platform,
|
||||
) {
|
||||
// Only process extensions of type Group or Extension that can have sub-extensions
|
||||
if !extension.r#type.contains_sub_items() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter commands
|
||||
if let Some(ref mut commands) = extension.commands {
|
||||
commands.retain(|sub_ext| {
|
||||
// If platforms is None, the sub-extension is compatible with all platforms
|
||||
if let Some(ref platforms) = sub_ext.platforms {
|
||||
platforms.contains(¤t_platform)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filter scripts
|
||||
if let Some(ref mut scripts) = extension.scripts {
|
||||
scripts.retain(|sub_ext| {
|
||||
// If platforms is None, the sub-extension is compatible with all platforms
|
||||
if let Some(ref platforms) = sub_ext.platforms {
|
||||
platforms.contains(¤t_platform)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filter quicklinks
|
||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||
quicklinks.retain(|sub_ext| {
|
||||
// If platforms is None, the sub-extension is compatible with all platforms
|
||||
if let Some(ref platforms) = sub_ext.platforms {
|
||||
platforms.contains(¤t_platform)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extension::ExtensionType;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Helper function to create a basic extension for testing
|
||||
/// `filter_out_incompatible_sub_extensions`
|
||||
fn create_test_extension(
|
||||
extension_type: ExtensionType,
|
||||
platforms: Option<HashSet<Platform>>,
|
||||
) -> Extension {
|
||||
Extension {
|
||||
id: "ID".into(),
|
||||
name: "name".into(),
|
||||
developer: None,
|
||||
platforms,
|
||||
description: "Test extension".to_string(),
|
||||
icon: "test-icon".to_string(),
|
||||
r#type: extension_type,
|
||||
action: None,
|
||||
quicklink: None,
|
||||
commands: None,
|
||||
scripts: None,
|
||||
quicklinks: None,
|
||||
alias: None,
|
||||
hotkey: None,
|
||||
enabled: true,
|
||||
settings: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_out_incompatible_sub_extensions_filter_non_group_extension_unchanged() {
|
||||
// Command
|
||||
let mut extension = create_test_extension(ExtensionType::Command, None);
|
||||
let clone = extension.clone();
|
||||
filter_out_incompatible_sub_extensions(&mut extension, Platform::Linux);
|
||||
assert_eq!(extension, clone);
|
||||
|
||||
// Quicklink
|
||||
let mut extension = create_test_extension(ExtensionType::Quicklink, None);
|
||||
let clone = extension.clone();
|
||||
filter_out_incompatible_sub_extensions(&mut extension, Platform::Linux);
|
||||
assert_eq!(extension, clone);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_out_incompatible_sub_extensions() {
|
||||
let mut main_extension = create_test_extension(ExtensionType::Group, None);
|
||||
// init sub extensions, which are macOS-only
|
||||
let commands = vec![create_test_extension(
|
||||
ExtensionType::Command,
|
||||
Some(HashSet::from([Platform::Macos])),
|
||||
)];
|
||||
let quicklinks = vec![create_test_extension(
|
||||
ExtensionType::Quicklink,
|
||||
Some(HashSet::from([Platform::Macos])),
|
||||
)];
|
||||
let scripts = vec![create_test_extension(
|
||||
ExtensionType::Script,
|
||||
Some(HashSet::from([Platform::Macos])),
|
||||
)];
|
||||
// Set sub extensions
|
||||
main_extension.commands = Some(commands);
|
||||
main_extension.quicklinks = Some(quicklinks);
|
||||
main_extension.scripts = Some(scripts);
|
||||
|
||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||
|
||||
// assertions
|
||||
assert!(main_extension.commands.unwrap().is_empty());
|
||||
assert!(main_extension.quicklinks.unwrap().is_empty());
|
||||
assert!(main_extension.scripts.unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// Sub extensions are compatible with all the platforms, nothing to filter out.
|
||||
#[test]
|
||||
fn test_filter_out_incompatible_sub_extensions_all_compatible() {
|
||||
{
|
||||
let mut main_extension = create_test_extension(ExtensionType::Group, None);
|
||||
// init sub extensions, which are compatible with all the platforms
|
||||
let commands = vec![create_test_extension(
|
||||
ExtensionType::Command,
|
||||
Some(Platform::all()),
|
||||
)];
|
||||
let quicklinks = vec![create_test_extension(
|
||||
ExtensionType::Quicklink,
|
||||
Some(Platform::all()),
|
||||
)];
|
||||
let scripts = vec![create_test_extension(
|
||||
ExtensionType::Script,
|
||||
Some(Platform::all()),
|
||||
)];
|
||||
// Set sub extensions
|
||||
main_extension.commands = Some(commands);
|
||||
main_extension.quicklinks = Some(quicklinks);
|
||||
main_extension.scripts = Some(scripts);
|
||||
|
||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||
|
||||
// assertions
|
||||
assert_eq!(main_extension.commands.unwrap().len(), 1);
|
||||
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
|
||||
assert_eq!(main_extension.scripts.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
// `platforms: None` means all platforms as well
|
||||
{
|
||||
let mut main_extension = create_test_extension(ExtensionType::Group, None);
|
||||
// init sub extensions, which are compatible with all the platforms
|
||||
let commands = vec![create_test_extension(ExtensionType::Command, None)];
|
||||
let quicklinks = vec![create_test_extension(ExtensionType::Quicklink, None)];
|
||||
let scripts = vec![create_test_extension(ExtensionType::Script, None)];
|
||||
// Set sub extensions
|
||||
main_extension.commands = Some(commands);
|
||||
main_extension.quicklinks = Some(quicklinks);
|
||||
main_extension.scripts = Some(scripts);
|
||||
|
||||
// Current platform is Linux, all the sub extensions should be filtered out.
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Linux);
|
||||
|
||||
// assertions
|
||||
assert_eq!(main_extension.commands.unwrap().len(), 1);
|
||||
assert_eq!(main_extension.quicklinks.unwrap().len(), 1);
|
||||
assert_eq!(main_extension.scripts.unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Extension store related stuff.
|
||||
|
||||
use super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use super::super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use super::is_extension_installed;
|
||||
use crate::common::document::DataSourceReference;
|
||||
use crate::common::document::Document;
|
||||
use crate::common::error::SearchError;
|
||||
@@ -12,8 +13,11 @@ use crate::extension::Extension;
|
||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
use crate::extension::canonicalize_relative_icon_path;
|
||||
use crate::extension::third_party::check::general_check;
|
||||
use crate::extension::third_party::get_third_party_extension_directory;
|
||||
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::util::platform::Platform;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::Map as JsonObject;
|
||||
@@ -152,14 +156,12 @@ pub(crate) async fn search_extension(
|
||||
.get("developer")
|
||||
.and_then(|dev| dev.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.expect("developer.id should exist")
|
||||
.to_string();
|
||||
.expect("developer.id should exist");
|
||||
|
||||
let extension_id = source_obj
|
||||
.get("id")
|
||||
.and_then(|id| id.as_str())
|
||||
.expect("extension id should exist")
|
||||
.to_string();
|
||||
.expect("extension id should exist");
|
||||
|
||||
let installed = is_extension_installed(developer_id, extension_id).await;
|
||||
source_obj.insert("installed".to_string(), Json::Bool(installed));
|
||||
@@ -170,14 +172,6 @@ pub(crate) async fn search_extension(
|
||||
Ok(extensions)
|
||||
}
|
||||
|
||||
async fn is_extension_installed(developer: String, extension_id: String) -> bool {
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.unwrap()
|
||||
.extension_exists(&developer, &extension_id)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_extension_from_store(
|
||||
tauri_app_handle: AppHandle,
|
||||
@@ -259,6 +253,12 @@ pub(crate) async fn install_extension_from_store(
|
||||
|
||||
drop(plugin_json);
|
||||
|
||||
general_check(&extension)?;
|
||||
|
||||
// Extension is compatible with current platform, but it could contain sub
|
||||
// extensions that are not, filter them out.
|
||||
filter_out_incompatible_sub_extensions(&mut extension, Platform::current());
|
||||
|
||||
// Write extension files to the extension directory
|
||||
let developer = extension.developer.clone().unwrap_or_default();
|
||||
let extension_id = extension.id.clone();
|
||||
285
src-tauri/src/extension/third_party/mod.rs
vendored
285
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -1,7 +1,7 @@
|
||||
pub(crate) mod store;
|
||||
pub(crate) mod check;
|
||||
pub(crate) mod install;
|
||||
|
||||
use super::Extension;
|
||||
use super::ExtensionType;
|
||||
use super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use super::PLUGIN_JSON_FILE_NAME;
|
||||
use super::alter_extension_json_file;
|
||||
@@ -18,15 +18,14 @@ use crate::extension::ExtensionBundleIdBorrowed;
|
||||
use crate::util::platform::Platform;
|
||||
use async_trait::async_trait;
|
||||
use borrowme::ToOwned;
|
||||
use check::general_check;
|
||||
use function_name::named;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
use tauri::Runtime;
|
||||
use tauri::async_runtime;
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use tauri_plugin_global_shortcut::ShortcutState;
|
||||
@@ -34,9 +33,7 @@ use tokio::fs::read_dir;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::RwLockWriteGuard;
|
||||
|
||||
pub(crate) fn get_third_party_extension_directory<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
) -> PathBuf {
|
||||
pub(crate) fn get_third_party_extension_directory(tauri_app_handle: &AppHandle) -> PathBuf {
|
||||
let mut app_data_dir = tauri_app_handle.path().app_data_dir().expect(
|
||||
"User home directory not found, which should be impossible on desktop environments",
|
||||
);
|
||||
@@ -47,9 +44,7 @@ pub(crate) fn get_third_party_extension_directory<R: Runtime>(
|
||||
|
||||
pub(crate) async fn list_third_party_extensions(
|
||||
directory: &Path,
|
||||
) -> Result<(bool, Vec<Extension>), String> {
|
||||
let mut found_invalid_extensions = false;
|
||||
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
let mut extensions_dir_iter = read_dir(&directory).await.map_err(|e| e.to_string())?;
|
||||
let current_platform = Platform::current();
|
||||
|
||||
@@ -65,7 +60,6 @@ pub(crate) async fn list_third_party_extensions(
|
||||
};
|
||||
let developer_dir_file_type = developer_dir.file_type().await.map_err(|e| e.to_string())?;
|
||||
if !developer_dir_file_type.is_dir() {
|
||||
found_invalid_extensions = true;
|
||||
log::warn!(
|
||||
"file [{}] under the third party extension directory should be a directory, but it is not",
|
||||
developer_dir.file_name().display()
|
||||
@@ -87,14 +81,17 @@ pub(crate) async fn list_third_party_extensions(
|
||||
let Some(extension_dir) = opt_extension_dir else {
|
||||
break 'extension;
|
||||
};
|
||||
let extension_dir_file_name = extension_dir
|
||||
.file_name()
|
||||
.into_string()
|
||||
.expect("extension directory name should be UTF-8 encoded");
|
||||
|
||||
let extension_dir_file_type =
|
||||
extension_dir.file_type().await.map_err(|e| e.to_string())?;
|
||||
if !extension_dir_file_type.is_dir() {
|
||||
found_invalid_extensions = true;
|
||||
log::warn!(
|
||||
"invalid extension [{}]: a valid extension should be a directory, but it is not",
|
||||
extension_dir.file_name().display()
|
||||
extension_dir_file_name
|
||||
);
|
||||
|
||||
// Skip invalid extension
|
||||
@@ -109,7 +106,6 @@ pub(crate) async fn list_third_party_extensions(
|
||||
};
|
||||
|
||||
if !plugin_json_file_path.is_file() {
|
||||
found_invalid_extensions = true;
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: extension file [{}] should be a JSON file, but it is not",
|
||||
extension_dir.file_name().display(),
|
||||
@@ -126,10 +122,9 @@ pub(crate) async fn list_third_party_extensions(
|
||||
let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) {
|
||||
Ok(extension) => extension,
|
||||
Err(e) => {
|
||||
found_invalid_extensions = true;
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: extension file [{}] is invalid, error: '{}'",
|
||||
extension_dir.file_name().display(),
|
||||
"invalid extension: [{}]: cannot parse file [{}] as a [struct Extension], error: '{}'",
|
||||
extension_dir_file_name,
|
||||
plugin_json_file_path.display(),
|
||||
e
|
||||
);
|
||||
@@ -137,20 +132,56 @@ pub(crate) async fn list_third_party_extensions(
|
||||
}
|
||||
};
|
||||
|
||||
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
|
||||
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
|
||||
/* Check starts here */
|
||||
if extension.id != extension_dir_file_name {
|
||||
log::warn!(
|
||||
"extension under [{}:{}] has an ID that is not same as the [{}]",
|
||||
developer_dir.file_name().display(),
|
||||
extension_dir_file_name,
|
||||
extension.id,
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extension should be unique
|
||||
if extensions.iter().any(|ext: &Extension| {
|
||||
ext.id == extension.id && ext.developer == extension.developer
|
||||
}) {
|
||||
log::warn!(
|
||||
"an extension with the same bundle ID [ID {}, developer {:?}] already exists, skip this one",
|
||||
extension.id,
|
||||
extension.developer
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(error_msg) = general_check(&extension) {
|
||||
log::warn!("{}", error_msg);
|
||||
|
||||
if !validate_extension(
|
||||
&extension,
|
||||
&extension_dir.file_name(),
|
||||
&extensions,
|
||||
current_platform,
|
||||
) {
|
||||
found_invalid_extensions = true;
|
||||
// Skip invalid extension
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(ref platforms) = extension.platforms {
|
||||
if !platforms.contains(¤t_platform) {
|
||||
log::warn!(
|
||||
"installed third-party extension [developer {}, ID {}] is not compatible with current platform, either user messes our directory or something wrong with our extension check",
|
||||
extension
|
||||
.developer
|
||||
.as_ref()
|
||||
.expect("third party extension should have [developer] set"),
|
||||
extension.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
/* Check ends here */
|
||||
|
||||
// Turn it into an absolute path if it is a valid relative path because frontend code needs this.
|
||||
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
|
||||
|
||||
extensions.push(extension);
|
||||
}
|
||||
}
|
||||
@@ -163,203 +194,7 @@ pub(crate) async fn list_third_party_extensions(
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
Ok((found_invalid_extensions, extensions))
|
||||
}
|
||||
|
||||
/// Helper function to validate `extension`, return `true` if it is valid.
|
||||
fn validate_extension(
|
||||
extension: &Extension,
|
||||
extension_dir_name: &OsStr,
|
||||
listed_extensions: &[Extension],
|
||||
current_platform: Platform,
|
||||
) -> bool {
|
||||
if OsStr::new(&extension.id) != extension_dir_name {
|
||||
log::warn!(
|
||||
"invalid extension []: id [{}] and extension directory name [{}] do not match",
|
||||
extension.id,
|
||||
extension_dir_name.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extension ID should be unique
|
||||
if listed_extensions.iter().any(|ext| ext.id == extension.id) {
|
||||
log::warn!(
|
||||
"invalid extension []: extension with id [{}] already exists",
|
||||
extension.id,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if !validate_extension_or_sub_item(extension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extension is incompatible
|
||||
if let Some(ref platforms) = extension.platforms {
|
||||
if !platforms.contains(¤t_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<_>>()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref commands) = extension.commands {
|
||||
if !validate_sub_items(&extension.id, commands) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref scripts) = extension.scripts {
|
||||
if !validate_sub_items(&extension.id, scripts) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref quicklinks) = extension.quicklinks {
|
||||
if !validate_sub_items(&extension.id, quicklinks) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Checks that can be performed against an extension or a sub item.
|
||||
fn validate_extension_or_sub_item(extension: &Extension) -> bool {
|
||||
// If field `action` is Some, then it should be a Command
|
||||
if extension.action.is_some() && extension.r#type != ExtensionType::Command {
|
||||
log::warn!(
|
||||
"invalid extension [{}], [action] is set for a non-Command extension",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::Command && extension.action.is_none() {
|
||||
log::warn!(
|
||||
"invalid extension [{}], [action] should be set for a Command extension",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If field `quicklink` is Some, then it should be a Quicklink
|
||||
if extension.quicklink.is_some() && extension.r#type != ExtensionType::Quicklink {
|
||||
log::warn!(
|
||||
"invalid extension [{}], [quicklink] is set for a non-Quicklink extension",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::Quicklink && extension.quicklink.is_none() {
|
||||
log::warn!(
|
||||
"invalid extension [{}], [quicklink] should be set for a Quicklink extension",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Group and Extension cannot have alias
|
||||
if extension.alias.is_some() {
|
||||
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
|
||||
{
|
||||
log::warn!(
|
||||
"invalid extension [{}], extension of type [{:?}] cannot have alias",
|
||||
extension.id,
|
||||
extension.r#type
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Group and Extension cannot have hotkey
|
||||
if extension.hotkey.is_some() {
|
||||
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
|
||||
{
|
||||
log::warn!(
|
||||
"invalid extension [{}], extension of type [{:?}] cannot have hotkey",
|
||||
extension.id,
|
||||
extension.r#type
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if extension.commands.is_some() || extension.scripts.is_some() || extension.quicklinks.is_some()
|
||||
{
|
||||
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
|
||||
{
|
||||
log::warn!(
|
||||
"invalid extension [{}], only extension of type [Group] and [Extension] can have sub-items",
|
||||
extension.id,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Helper function to check sub-items.
|
||||
fn validate_sub_items(extension_id: &str, sub_items: &[Extension]) -> bool {
|
||||
for (sub_item_index, sub_item) in sub_items.iter().enumerate() {
|
||||
// If field `action` is Some, then it should be a Command
|
||||
if sub_item.action.is_some() && sub_item.r#type != ExtensionType::Command {
|
||||
log::warn!(
|
||||
"invalid extension sub-item [{}-{}]: [action] is set for a non-Command extension",
|
||||
extension_id,
|
||||
sub_item.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if sub_item.r#type == ExtensionType::Group || sub_item.r#type == ExtensionType::Extension {
|
||||
log::warn!(
|
||||
"invalid extension sub-item [{}-{}]: sub-item should not be of type [Group] or [Extension]",
|
||||
extension_id,
|
||||
sub_item.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let sub_item_with_same_id_count = sub_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_idx, ext)| ext.id == sub_item.id)
|
||||
.filter(|(idx, _ext)| *idx != sub_item_index)
|
||||
.count();
|
||||
if sub_item_with_same_id_count != 0 {
|
||||
log::warn!(
|
||||
"invalid extension [{}]: found more than one sub-items with the same ID [{}]",
|
||||
extension_id,
|
||||
sub_item.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if !validate_extension_or_sub_item(sub_item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if sub_item.platforms.is_some() {
|
||||
log::warn!(
|
||||
"invalid extension [{}]: key [platforms] should not be set in sub-items",
|
||||
extension_id,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
Ok(extensions)
|
||||
}
|
||||
|
||||
/// All the third-party extensions will be registered as one search source.
|
||||
@@ -419,7 +254,7 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
async_runtime::spawn(async move {
|
||||
let result = open(app_handle_clone, on_opened_clone).await;
|
||||
let result = open(app_handle_clone, on_opened_clone, None).await;
|
||||
if let Err(msg) = result {
|
||||
log::warn!(
|
||||
"failed to open extension [{}], error [{}]",
|
||||
@@ -680,7 +515,7 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
async_runtime::spawn(async move {
|
||||
let result = open(app_handle_clone, on_opened_clone).await;
|
||||
let result = open(app_handle_clone, on_opened_clone, None).await;
|
||||
if let Err(msg) = result {
|
||||
log::warn!(
|
||||
"failed to open extension [{:?}], error [{}]",
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use tauri::async_runtime::block_on;
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, WindowEvent};
|
||||
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
|
||||
/// Tauri store name
|
||||
@@ -130,9 +130,7 @@ pub fn run() {
|
||||
server::connector::get_connectors_by_server,
|
||||
search::query_coco_fusion,
|
||||
assistant::chat_history,
|
||||
assistant::new_chat,
|
||||
assistant::chat_create,
|
||||
assistant::send_message,
|
||||
assistant::chat_chat,
|
||||
assistant::session_chat_history,
|
||||
assistant::open_session_chat,
|
||||
@@ -145,11 +143,9 @@ pub fn run() {
|
||||
assistant::assistant_get_multi,
|
||||
// server::get_coco_server_datasources,
|
||||
// server::get_coco_server_connectors,
|
||||
server::websocket::connect_to_server,
|
||||
server::websocket::disconnect,
|
||||
get_app_search_source,
|
||||
server::attachment::upload_attachment,
|
||||
server::attachment::get_attachment,
|
||||
server::attachment::get_attachment_by_ids,
|
||||
server::attachment::delete_attachment,
|
||||
server::transcription::transcription,
|
||||
server::system_settings::get_system_settings,
|
||||
@@ -159,6 +155,7 @@ pub fn run() {
|
||||
extension::built_in::application::add_app_search_path,
|
||||
extension::built_in::application::remove_app_search_path,
|
||||
extension::built_in::application::reindex_applications,
|
||||
extension::quicklink_link_arguments,
|
||||
extension::list_extensions,
|
||||
extension::enable_extension,
|
||||
extension::disable_extension,
|
||||
@@ -166,8 +163,9 @@ pub fn run() {
|
||||
extension::register_extension_hotkey,
|
||||
extension::unregister_extension_hotkey,
|
||||
extension::is_extension_enabled,
|
||||
extension::third_party::store::search_extension,
|
||||
extension::third_party::store::install_extension_from_store,
|
||||
extension::third_party::install::store::search_extension,
|
||||
extension::third_party::install::store::install_extension_from_store,
|
||||
extension::third_party::install::local_extension::install_local_extension,
|
||||
extension::third_party::uninstall_extension,
|
||||
settings::set_allow_self_signature,
|
||||
settings::get_allow_self_signature,
|
||||
@@ -180,6 +178,8 @@ pub fn run() {
|
||||
server::synthesize::synthesize,
|
||||
util::file::get_file_icon,
|
||||
util::app_lang::update_app_lang,
|
||||
#[cfg(target_os = "macos")]
|
||||
setup::toggle_move_to_active_space_attribute,
|
||||
])
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
@@ -198,7 +198,6 @@ pub fn run() {
|
||||
let registry = SearchSourceRegistry::default();
|
||||
|
||||
app.manage(registry); // Store registry in Tauri's app state
|
||||
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
|
||||
@@ -210,7 +209,7 @@ pub fn run() {
|
||||
|
||||
// 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)) => {
|
||||
Ok(extensions) => {
|
||||
// Initializing extension relies on SearchSourceRegistry, so this should
|
||||
// be executed after `app.manage(registry)`
|
||||
if let Err(e) =
|
||||
@@ -291,7 +290,7 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
pub async fn init(app_handle: &AppHandle) {
|
||||
// Await the async functions to load the servers and tokens
|
||||
if let Err(err) = load_or_insert_default_server(app_handle).await {
|
||||
log::error!("Failed to load servers: {}", err);
|
||||
@@ -315,7 +314,7 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
|
||||
async fn show_coco(app_handle: AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
move_window_to_active_monitor(&window);
|
||||
|
||||
@@ -328,7 +327,7 @@ async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
|
||||
async fn hide_coco(app: AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
if let Err(err) = window.hide() {
|
||||
log::error!("Failed to hide the window: {}", err);
|
||||
@@ -340,7 +339,7 @@ async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn move_window_to_active_monitor<R: Runtime>(window: &WebviewWindow<R>) {
|
||||
fn move_window_to_active_monitor(window: &WebviewWindow) {
|
||||
//dbg!("Moving window to active monitor");
|
||||
// Try to get the available monitors, handle failure gracefully
|
||||
let available_monitors = match window.available_monitors() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::common::search::{
|
||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QueryResponse, QuerySource, SearchQuery,
|
||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||
};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::server::servers::logout_coco_server;
|
||||
@@ -13,74 +13,24 @@ use reqwest::StatusCode;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tokio::time::error::Elapsed;
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
/// Helper function to return the Future used for querying querysources.
|
||||
///
|
||||
/// It is a workaround for the limitations:
|
||||
///
|
||||
/// 1. 2 async blocks have different types in Rust's type system even though
|
||||
/// they are literally same
|
||||
/// 2. `futures::stream::FuturesUnordered` needs the `Futures` pushed to it to
|
||||
/// have only 1 type
|
||||
///
|
||||
/// Putting the async block in a function to unify the types.
|
||||
fn same_type_futures(
|
||||
query_source: QuerySource,
|
||||
query_source_trait_object: Arc<dyn SearchSource>,
|
||||
timeout_duration: Duration,
|
||||
search_query: SearchQuery,
|
||||
tauri_app_handle: AppHandle,
|
||||
) -> impl Future<
|
||||
Output = (
|
||||
QuerySource,
|
||||
Result<Result<QueryResponse, SearchError>, Elapsed>,
|
||||
),
|
||||
> + 'static {
|
||||
async move {
|
||||
(
|
||||
// Store `query_source` as part of future for debugging purposes.
|
||||
query_source,
|
||||
timeout(timeout_duration, async {
|
||||
query_source_trait_object
|
||||
.search(tauri_app_handle.clone(), search_query)
|
||||
.await
|
||||
})
|
||||
.await,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[named]
|
||||
#[tauri::command]
|
||||
pub async fn query_coco_fusion(
|
||||
app_handle: AppHandle,
|
||||
tauri_app_handle: AppHandle,
|
||||
from: u64,
|
||||
size: u64,
|
||||
query_strings: HashMap<String, String>,
|
||||
query_timeout: u64,
|
||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
||||
let query_keyword = query_strings
|
||||
.get("query")
|
||||
.unwrap_or(&"".to_string())
|
||||
.clone();
|
||||
|
||||
let opt_query_source_id = query_strings.get("querysource");
|
||||
|
||||
let search_sources = app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
let sources_future = search_sources.get_sources();
|
||||
let mut futures = FuturesUnordered::new();
|
||||
|
||||
let mut sources_list = sources_future.await;
|
||||
let sources_list_len = sources_list.len();
|
||||
|
||||
// Time limit for each query
|
||||
let search_sources = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
let query_source_list = search_sources.get_sources().await;
|
||||
let timeout_duration = Duration::from_millis(query_timeout);
|
||||
let search_query = SearchQuery::new(from, size, query_strings.clone());
|
||||
|
||||
log::debug!(
|
||||
"{}() invoked with parameters: from: [{}], size: [{}], query_strings: [{:?}], timeout: [{:?}]",
|
||||
@@ -91,68 +41,170 @@ pub async fn query_coco_fusion(
|
||||
timeout_duration
|
||||
);
|
||||
|
||||
let search_query = SearchQuery::new(from, size, query_strings.clone());
|
||||
|
||||
// Dispatch to different `query_coco_fusion_xxx()` functions.
|
||||
if let Some(query_source_id) = opt_query_source_id {
|
||||
// If this query source ID is specified, we only query this query source.
|
||||
log::debug!(
|
||||
"parameter [querysource={}] specified, will only query this querysource",
|
||||
query_source_id
|
||||
);
|
||||
|
||||
let opt_query_source_trait_object_index = sources_list
|
||||
.iter()
|
||||
.position(|query_source| &query_source.get_type().id == query_source_id);
|
||||
|
||||
let Some(query_source_trait_object_index) = opt_query_source_trait_object_index else {
|
||||
// It is possible (an edge case) that the frontend invokes `query_coco_fusion()` with a
|
||||
// datasource that does not exist in the source list:
|
||||
//
|
||||
// 1. Search applications
|
||||
// 2. Navigate to the application sub page
|
||||
// 3. Disable the application extension in settings
|
||||
// 4. hide the search window
|
||||
// 5. Re-open the search window and search for something
|
||||
//
|
||||
// The application search source is not in the source list because the extension
|
||||
// has been disabled, but the last search is indeed invoked with parameter
|
||||
// `datasource=application`.
|
||||
return Ok(MultiSourceQueryResponse {
|
||||
failed: Vec::new(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
};
|
||||
|
||||
let query_source_trait_object = sources_list.remove(query_source_trait_object_index);
|
||||
let query_source = query_source_trait_object.get_type();
|
||||
|
||||
futures.push(same_type_futures(
|
||||
query_source,
|
||||
query_source_trait_object,
|
||||
query_coco_fusion_single_query_source(
|
||||
tauri_app_handle,
|
||||
query_source_list,
|
||||
query_source_id.clone(),
|
||||
timeout_duration,
|
||||
search_query,
|
||||
app_handle.clone(),
|
||||
));
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
log::debug!(
|
||||
"will query querysources {:?}",
|
||||
sources_list
|
||||
.iter()
|
||||
.map(|search_source| search_source.get_type().id.clone())
|
||||
.collect::<Vec<String>>()
|
||||
);
|
||||
query_coco_fusion_multi_query_sources(
|
||||
tauri_app_handle,
|
||||
query_source_list,
|
||||
timeout_duration,
|
||||
search_query,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
for query_source_trait_object in sources_list {
|
||||
let query_source = query_source_trait_object.get_type().clone();
|
||||
futures.push(same_type_futures(
|
||||
query_source,
|
||||
query_source_trait_object,
|
||||
timeout_duration,
|
||||
search_query.clone(),
|
||||
app_handle.clone(),
|
||||
));
|
||||
/// Query only 1 query source.
|
||||
///
|
||||
/// The logic here is much simpler than `query_coco_fusion_multi_query_sources()`
|
||||
/// as we don't need to re-rank due to fact that this does not involve multiple
|
||||
/// query sources.
|
||||
async fn query_coco_fusion_single_query_source(
|
||||
tauri_app_handle: AppHandle,
|
||||
mut query_source_list: Vec<Arc<dyn SearchSource>>,
|
||||
id_of_query_source_to_query: String,
|
||||
timeout_duration: Duration,
|
||||
search_query: SearchQuery,
|
||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
||||
// If this query source ID is specified, we only query this query source.
|
||||
log::debug!(
|
||||
"parameter [querysource={}] specified, will only query this query source",
|
||||
id_of_query_source_to_query
|
||||
);
|
||||
|
||||
let opt_query_source_trait_object_index = query_source_list
|
||||
.iter()
|
||||
.position(|query_source| query_source.get_type().id == id_of_query_source_to_query);
|
||||
|
||||
let Some(query_source_trait_object_index) = opt_query_source_trait_object_index else {
|
||||
// It is possible (an edge case) that the frontend invokes `query_coco_fusion()`
|
||||
// with a querysource that does not exist in the source list:
|
||||
//
|
||||
// 1. Search applications
|
||||
// 2. Navigate to the application sub page
|
||||
// 3. Disable the application extension in settings, which removes this
|
||||
// query source from the list
|
||||
// 4. hide the search window
|
||||
// 5. Re-open the search window, you will still be in the sub page, type to search
|
||||
// something
|
||||
//
|
||||
// The application query source is not in the source list because the extension
|
||||
// was disabled and thus removed from the query sources, but the last
|
||||
// search is indeed invoked with parameter `querysource=application`.
|
||||
return Ok(MultiSourceQueryResponse {
|
||||
failed: Vec::new(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
};
|
||||
|
||||
let query_source_trait_object = query_source_list.remove(query_source_trait_object_index);
|
||||
let query_source = query_source_trait_object.get_type();
|
||||
let search_fut = query_source_trait_object.search(tauri_app_handle.clone(), search_query);
|
||||
let timeout_result = timeout(timeout_duration, search_fut).await;
|
||||
|
||||
let mut failed_requests: Vec<FailedRequest> = Vec::new();
|
||||
let mut hits = Vec::new();
|
||||
let mut total_hits = 0;
|
||||
|
||||
match timeout_result {
|
||||
// Ignore the `_timeout` variable as it won't provide any useful debugging information.
|
||||
Err(_timeout) => {
|
||||
log::warn!(
|
||||
"searching query source [{}] timed out, skip this request",
|
||||
query_source.id
|
||||
);
|
||||
}
|
||||
Ok(query_result) => match query_result {
|
||||
Ok(response) => {
|
||||
total_hits = response.total_hits;
|
||||
|
||||
for (document, score) in response.hits {
|
||||
log::debug!(
|
||||
"document from query source [{}]: ID [{}], title [{:?}], score [{}]",
|
||||
response.source.id,
|
||||
document.id,
|
||||
document.title,
|
||||
score
|
||||
);
|
||||
|
||||
let query_hit = QueryHits {
|
||||
source: Some(response.source.clone()),
|
||||
score,
|
||||
document,
|
||||
};
|
||||
|
||||
hits.push(query_hit);
|
||||
}
|
||||
}
|
||||
Err(search_error) => {
|
||||
query_coco_fusion_handle_failed_request(
|
||||
tauri_app_handle.clone(),
|
||||
&mut failed_requests,
|
||||
query_source,
|
||||
search_error,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(MultiSourceQueryResponse {
|
||||
failed: failed_requests,
|
||||
hits,
|
||||
total_hits,
|
||||
})
|
||||
}
|
||||
|
||||
async fn query_coco_fusion_multi_query_sources(
|
||||
tauri_app_handle: AppHandle,
|
||||
query_source_trait_object_list: Vec<Arc<dyn SearchSource>>,
|
||||
timeout_duration: Duration,
|
||||
search_query: SearchQuery,
|
||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
||||
log::debug!(
|
||||
"will query query sources {:?}",
|
||||
query_source_trait_object_list
|
||||
.iter()
|
||||
.map(|search_source| search_source.get_type().id.clone())
|
||||
.collect::<Vec<String>>()
|
||||
);
|
||||
|
||||
let query_keyword = search_query
|
||||
.query_strings
|
||||
.get("query")
|
||||
.unwrap_or(&"".to_string())
|
||||
.clone();
|
||||
let size = search_query.size;
|
||||
|
||||
let mut futures = FuturesUnordered::new();
|
||||
|
||||
let query_source_list_len = query_source_trait_object_list.len();
|
||||
for query_source_trait_object in query_source_trait_object_list {
|
||||
let query_source = query_source_trait_object.get_type().clone();
|
||||
let tauri_app_handle_clone = tauri_app_handle.clone();
|
||||
let search_query_clone = search_query.clone();
|
||||
|
||||
futures.push(async move {
|
||||
(
|
||||
// Store `query_source` as part of future for debugging purposes.
|
||||
query_source,
|
||||
timeout(timeout_duration, async {
|
||||
query_source_trait_object
|
||||
.search(tauri_app_handle_clone, search_query_clone)
|
||||
.await
|
||||
})
|
||||
.await,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
let mut total_hits = 0;
|
||||
@@ -161,7 +213,7 @@ pub async fn query_coco_fusion(
|
||||
let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::new();
|
||||
let mut hits_per_source: HashMap<String, Vec<(QueryHits, f64)>> = HashMap::new();
|
||||
|
||||
if sources_list_len > 1 {
|
||||
if query_source_list_len > 1 {
|
||||
need_rerank = true; // If we have more than one source, we need to rerank the hits
|
||||
}
|
||||
|
||||
@@ -173,25 +225,25 @@ pub async fn query_coco_fusion(
|
||||
"searching query source [{}] timed out, skip this request",
|
||||
query_source.id
|
||||
);
|
||||
// failed_requests.push(FailedRequest {
|
||||
// source: query_source,
|
||||
// status: 0,
|
||||
// error: Some("querying timed out".into()),
|
||||
// reason: None,
|
||||
// });
|
||||
}
|
||||
Ok(query_result) => match query_result {
|
||||
Ok(response) => {
|
||||
total_hits += response.total_hits;
|
||||
let source_id = response.source.id.clone();
|
||||
|
||||
for (doc, score) in response.hits {
|
||||
log::debug!("doc: {}, {:?}, {}", doc.id, doc.title, score);
|
||||
for (document, score) in response.hits {
|
||||
log::debug!(
|
||||
"document from query source [{}]: ID [{}], title [{:?}], score [{}]",
|
||||
response.source.id,
|
||||
document.id,
|
||||
document.title,
|
||||
score
|
||||
);
|
||||
|
||||
let query_hit = QueryHits {
|
||||
source: Some(response.source.clone()),
|
||||
score,
|
||||
document: doc,
|
||||
document,
|
||||
};
|
||||
|
||||
all_hits.push((source_id.clone(), query_hit.clone(), score));
|
||||
@@ -203,46 +255,13 @@ pub async fn query_coco_fusion(
|
||||
}
|
||||
}
|
||||
Err(search_error) => {
|
||||
log::error!(
|
||||
"searching query source [{}] failed, error [{}]",
|
||||
query_source.id,
|
||||
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 {
|
||||
source: query_source,
|
||||
status: status_code_num,
|
||||
error: Some(search_error.to_string()),
|
||||
reason: None,
|
||||
});
|
||||
query_coco_fusion_handle_failed_request(
|
||||
tauri_app_handle.clone(),
|
||||
&mut failed_requests,
|
||||
query_source,
|
||||
search_error,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -402,3 +421,54 @@ fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(u
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Helper function to handle a failed request.
|
||||
///
|
||||
/// Extracted as a function because `query_coco_fusion_single_query_source()` and
|
||||
/// `query_coco_fusion_multi_query_sources()` share the same error handling logic.
|
||||
async fn query_coco_fusion_handle_failed_request(
|
||||
tauri_app_handle: AppHandle,
|
||||
failed_requests: &mut Vec<FailedRequest>,
|
||||
query_source: QuerySource,
|
||||
search_error: SearchError,
|
||||
) {
|
||||
log::error!(
|
||||
"searching query source [{}] failed, error [{}]",
|
||||
query_source.id,
|
||||
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(tauri_app_handle.clone(), query_source.id.to_string()).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(tauri_app_handle.clone(), &query_source.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
failed_requests.push(FailedRequest {
|
||||
source: query_source,
|
||||
status: status_code_num,
|
||||
error: Some(search_error.to_string()),
|
||||
reason: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,11 +72,19 @@ pub async fn upload_attachment(
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn get_attachment(server_id: String, session_id: String) -> Result<Value, String> {
|
||||
let mut query_params = Vec::new();
|
||||
query_params.push(format!("session={}", session_id));
|
||||
pub async fn get_attachment_by_ids(
|
||||
server_id: String,
|
||||
attachments: Vec<String>,
|
||||
) -> Result<Value, String> {
|
||||
println!("get_attachment_by_ids server_id: {}", server_id);
|
||||
println!("get_attachment_by_ids attachments: {:?}", attachments);
|
||||
|
||||
let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params))
|
||||
let request_body = serde_json::json!({
|
||||
"attachments": attachments
|
||||
});
|
||||
let body = reqwest::Body::from(serde_json::to_string(&request_body).unwrap());
|
||||
|
||||
let response = HttpClient::post(&server_id, "/attachment/_search", None, Some(body))
|
||||
.await
|
||||
.map_err(|e| format!("Request error: {}", e))?;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::server::servers::{
|
||||
get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server,
|
||||
try_register_server_to_search_source,
|
||||
};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn request_access_token_url(request_id: &str) -> String {
|
||||
@@ -13,8 +13,8 @@ fn request_access_token_url(request_id: &str) -> String {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn handle_sso_callback<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
pub async fn handle_sso_callback(
|
||||
app_handle: AppHandle,
|
||||
server_id: String,
|
||||
request_id: String,
|
||||
code: String,
|
||||
|
||||
@@ -6,7 +6,7 @@ use http::StatusCode;
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
|
||||
lazy_static! {
|
||||
static ref CONNECTOR_CACHE: Arc<RwLock<HashMap<String, HashMap<String, Connector>>>> =
|
||||
@@ -29,7 +29,7 @@ pub fn get_connector_by_id(server_id: &str, connector_id: &str) -> Option<Connec
|
||||
Some(connector.clone())
|
||||
}
|
||||
|
||||
pub async fn refresh_all_connectors<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub async fn refresh_all_connectors(app_handle: &AppHandle) -> Result<(), String> {
|
||||
let servers = get_all_servers().await;
|
||||
|
||||
// Collect all the tasks for fetching and refreshing connectors
|
||||
@@ -122,8 +122,8 @@ pub async fn fetch_connectors_by_server(id: &str) -> Result<Vec<Connector>, Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_connectors_by_server<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn get_connectors_by_server(
|
||||
_app_handle: AppHandle,
|
||||
id: String,
|
||||
) -> Result<Vec<Connector>, String> {
|
||||
let connectors = fetch_connectors_by_server(&id).await?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use http::StatusCode;
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
|
||||
lazy_static! {
|
||||
static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String, HashMap<String, DataSource>>>> =
|
||||
@@ -31,7 +31,7 @@ pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, Dat
|
||||
Some(server_cache.clone())
|
||||
}
|
||||
|
||||
pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub async fn refresh_all_datasources(_app_handle: &AppHandle) -> Result<(), String> {
|
||||
// dbg!("Attempting to refresh all datasources");
|
||||
|
||||
let servers = get_all_servers().await;
|
||||
|
||||
@@ -11,4 +11,3 @@ pub mod servers;
|
||||
pub mod synthesize;
|
||||
pub mod system_settings;
|
||||
pub mod transcription;
|
||||
pub mod websocket;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::common::http::get_response_body_text;
|
||||
use crate::common::profile::UserProfile;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_user_profiles<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn get_user_profiles(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
) -> Result<UserProfile, String> {
|
||||
// Use the generic GET method from HttpClient
|
||||
|
||||
@@ -13,7 +13,6 @@ use serde_json::Value as JsonValue;
|
||||
use serde_json::from_value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use tauri::Runtime;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -70,7 +69,7 @@ async fn remove_server_by_id(id: &str) -> Option<Server> {
|
||||
cache.remove(id)
|
||||
}
|
||||
|
||||
pub async fn persist_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub async fn persist_servers(app_handle: &AppHandle) -> Result<(), String> {
|
||||
let cache = SERVER_LIST_CACHE.read().await;
|
||||
|
||||
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
|
||||
@@ -99,7 +98,7 @@ pub async fn remove_server_token(id: &str) -> bool {
|
||||
cache.remove(id).is_some()
|
||||
}
|
||||
|
||||
pub async fn persist_servers_token<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub async fn persist_servers_token(app_handle: &AppHandle) -> Result<(), String> {
|
||||
let cache = SERVER_TOKEN_LIST_CACHE.read().await;
|
||||
|
||||
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
|
||||
@@ -158,9 +157,7 @@ fn get_default_server() -> Server {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_servers_token<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
) -> Result<Vec<ServerAccessToken>, String> {
|
||||
pub async fn load_servers_token(app_handle: &AppHandle) -> Result<Vec<ServerAccessToken>, String> {
|
||||
log::debug!("Attempting to load servers token");
|
||||
|
||||
let store = app_handle
|
||||
@@ -219,7 +216,7 @@ pub async fn load_servers_token<R: Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<Server>, String> {
|
||||
pub async fn load_servers(app_handle: &AppHandle) -> Result<Vec<Server>, String> {
|
||||
let store = app_handle
|
||||
.store(COCO_TAURI_STORE)
|
||||
.expect("create or load a store should not fail");
|
||||
@@ -276,9 +273,7 @@ pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<S
|
||||
}
|
||||
|
||||
/// Function to load servers or insert a default one if none exist
|
||||
pub async fn load_or_insert_default_server<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
) -> Result<Vec<Server>, String> {
|
||||
pub async fn load_or_insert_default_server(app_handle: &AppHandle) -> Result<Vec<Server>, String> {
|
||||
log::debug!("Attempting to load or insert default server");
|
||||
|
||||
let exists_servers = load_servers(&app_handle).await;
|
||||
@@ -296,9 +291,7 @@ pub async fn load_or_insert_default_server<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_coco_servers<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<Server>, String> {
|
||||
pub async fn list_coco_servers(app_handle: AppHandle) -> Result<Vec<Server>, String> {
|
||||
//hard fresh all server's info, in order to get the actual health
|
||||
refresh_all_coco_server_info(app_handle.clone()).await;
|
||||
|
||||
@@ -312,7 +305,7 @@ pub async fn get_all_servers() -> Vec<Server> {
|
||||
cache.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn refresh_all_coco_server_info<R: Runtime>(app_handle: AppHandle<R>) {
|
||||
pub async fn refresh_all_coco_server_info(app_handle: AppHandle) {
|
||||
let servers = get_all_servers().await;
|
||||
for server in servers {
|
||||
let _ = refresh_coco_server_info(app_handle.clone(), server.id.clone()).await;
|
||||
@@ -320,10 +313,7 @@ pub async fn refresh_all_coco_server_info<R: Runtime>(app_handle: AppHandle<R>)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn refresh_coco_server_info<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
id: String,
|
||||
) -> Result<Server, String> {
|
||||
pub async fn refresh_coco_server_info(app_handle: AppHandle, id: String) -> Result<Server, String> {
|
||||
// Retrieve the server from the cache
|
||||
let cached_server = {
|
||||
let cache = SERVER_LIST_CACHE.read().await;
|
||||
@@ -393,10 +383,7 @@ pub async fn refresh_coco_server_info<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_coco_server<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
endpoint: String,
|
||||
) -> Result<Server, String> {
|
||||
pub async fn add_coco_server(app_handle: AppHandle, endpoint: String) -> Result<Server, String> {
|
||||
load_or_insert_default_server(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load default servers: {}", e))?;
|
||||
@@ -472,10 +459,7 @@ pub async fn add_coco_server<R: Runtime>(
|
||||
|
||||
#[tauri::command]
|
||||
#[function_name::named]
|
||||
pub async fn remove_coco_server<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
id: String,
|
||||
) -> Result<(), ()> {
|
||||
pub async fn remove_coco_server(app_handle: AppHandle, id: String) -> Result<(), ()> {
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
registry.remove_source(id.as_str()).await;
|
||||
|
||||
@@ -507,7 +491,7 @@ pub async fn remove_coco_server<R: Runtime>(
|
||||
|
||||
#[tauri::command]
|
||||
#[function_name::named]
|
||||
pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> {
|
||||
pub async fn enable_server(app_handle: AppHandle, id: String) -> Result<(), ()> {
|
||||
let opt_server = get_server_by_id(id.as_str()).await;
|
||||
|
||||
let Some(mut server) = opt_server else {
|
||||
@@ -532,7 +516,7 @@ pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
|
||||
|
||||
#[tauri::command]
|
||||
#[function_name::named]
|
||||
pub async fn disable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> {
|
||||
pub async fn disable_server(app_handle: AppHandle, id: String) -> Result<(), ()> {
|
||||
let opt_server = get_server_by_id(id.as_str()).await;
|
||||
|
||||
let Some(mut server) = opt_server else {
|
||||
@@ -560,10 +544,7 @@ pub async fn disable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
|
||||
/// enabled.
|
||||
///
|
||||
/// For public Coco server, an extra token is required.
|
||||
pub async fn try_register_server_to_search_source(
|
||||
app_handle: AppHandle<impl Runtime>,
|
||||
server: &Server,
|
||||
) {
|
||||
pub async fn try_register_server_to_search_source(app_handle: AppHandle, server: &Server) {
|
||||
if server.enabled {
|
||||
log::trace!(
|
||||
"Server [name: {}, id: {}] is public: {} and available: {}",
|
||||
@@ -590,7 +571,7 @@ pub async fn try_register_server_to_search_source(
|
||||
|
||||
#[function_name::named]
|
||||
#[allow(unused)]
|
||||
async fn mark_server_as_online<R: Runtime>(app_handle: AppHandle<R>, id: &str) {
|
||||
async fn mark_server_as_online(app_handle: AppHandle, id: &str) {
|
||||
let server = get_server_by_id(id).await;
|
||||
if let Some(mut server) = server {
|
||||
server.available = true;
|
||||
@@ -608,7 +589,7 @@ async fn mark_server_as_online<R: Runtime>(app_handle: AppHandle<R>, id: &str) {
|
||||
}
|
||||
|
||||
#[function_name::named]
|
||||
pub(crate) async fn mark_server_as_offline<R: Runtime>(app_handle: AppHandle<R>, id: &str) {
|
||||
pub(crate) async fn mark_server_as_offline(app_handle: AppHandle, id: &str) {
|
||||
let server = get_server_by_id(id).await;
|
||||
if let Some(mut server) = server {
|
||||
server.available = false;
|
||||
@@ -628,10 +609,7 @@ pub(crate) async fn mark_server_as_offline<R: Runtime>(app_handle: AppHandle<R>,
|
||||
|
||||
#[tauri::command]
|
||||
#[function_name::named]
|
||||
pub async fn logout_coco_server<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
pub async fn logout_coco_server(app_handle: AppHandle, id: String) -> Result<(), String> {
|
||||
log::debug!("Attempting to log out server by id: {}", &id);
|
||||
|
||||
// Check if the server exists
|
||||
|
||||
@@ -2,11 +2,11 @@ use crate::server::http_client::HttpClient;
|
||||
use futures_util::StreamExt;
|
||||
use http::Method;
|
||||
use serde_json::json;
|
||||
use tauri::{AppHandle, Emitter, Runtime, command};
|
||||
use tauri::{AppHandle, Emitter, command};
|
||||
|
||||
#[command]
|
||||
pub async fn synthesize<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
pub async fn synthesize(
|
||||
app_handle: AppHandle,
|
||||
client_id: String,
|
||||
server_id: String,
|
||||
voice: String,
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
use crate::server::servers::{get_server_by_id, get_server_token};
|
||||
use futures::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, Runtime};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_tungstenite::MaybeTlsStream;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::{Connector, connect_async_tls_with_config};
|
||||
#[derive(Default)]
|
||||
pub struct WebSocketManager {
|
||||
connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>,
|
||||
}
|
||||
|
||||
struct WebSocketInstance {
|
||||
ws_connection: Mutex<WebSocketStream<MaybeTlsStream<TcpStream>>>, // No need to lock the entire map
|
||||
cancel_tx: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
|
||||
let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
let ws_protocol = if url.scheme() == "https" {
|
||||
"wss://"
|
||||
} else {
|
||||
"ws://"
|
||||
};
|
||||
let host = url.host_str().ok_or("No host found in URL")?;
|
||||
let port = url
|
||||
.port_or_known_default()
|
||||
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
|
||||
|
||||
let ws_endpoint = if port == 80 || port == 443 {
|
||||
format!("{}{}{}", ws_protocol, host, "/ws")
|
||||
} else {
|
||||
format!("{}{}:{}/ws", ws_protocol, host, port)
|
||||
};
|
||||
Ok(ws_endpoint)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect_to_server<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
id: String,
|
||||
client_id: String,
|
||||
state: tauri::State<'_, WebSocketManager>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let connections_clone = state.connections.clone();
|
||||
|
||||
// Disconnect old connection first
|
||||
disconnect(client_id.clone(), state.clone()).await.ok();
|
||||
|
||||
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 token = get_server_token(&id).await.map(|t| t.access_token.clone());
|
||||
|
||||
let mut request =
|
||||
tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint)
|
||||
.map_err(|e| format!("Failed to create WebSocket request: {}", e))?;
|
||||
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Connection", "Upgrade".parse().unwrap());
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Upgrade", "websocket".parse().unwrap());
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Sec-WebSocket-Version", "13".parse().unwrap());
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Sec-WebSocket-Key", generate_key().parse().unwrap());
|
||||
|
||||
if let Some(token) = token {
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("X-API-TOKEN", token.parse().unwrap());
|
||||
}
|
||||
|
||||
let allow_self_signature =
|
||||
crate::settings::get_allow_self_signature(tauri_app_handle.clone()).await;
|
||||
let tls_connector = tokio_native_tls::native_tls::TlsConnector::builder()
|
||||
.danger_accept_invalid_certs(allow_self_signature)
|
||||
.build()
|
||||
.map_err(|e| format!("TLS build error: {:?}", e))?;
|
||||
|
||||
let connector = Connector::NativeTls(tls_connector.into());
|
||||
|
||||
let (ws_stream, _) = connect_async_tls_with_config(
|
||||
request,
|
||||
None, // WebSocketConfig
|
||||
true, // disable_nagle
|
||||
Some(connector), // Connector
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket TLS error: {:?}", e))?;
|
||||
|
||||
let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
|
||||
|
||||
let instance = Arc::new(WebSocketInstance {
|
||||
ws_connection: Mutex::new(ws_stream),
|
||||
cancel_tx,
|
||||
});
|
||||
|
||||
// Insert connection into the map (lock is held briefly)
|
||||
{
|
||||
let mut connections = connections_clone.lock().await;
|
||||
connections.insert(client_id.clone(), instance.clone());
|
||||
}
|
||||
|
||||
// Spawn WebSocket handler in a separate task
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let client_id_clone = client_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let ws = &mut *instance.ws_connection.lock().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = ws.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let _ = app_handle_clone.emit(&format!("ws-message-{}", client_id_clone), text);
|
||||
},
|
||||
Some(Err(_)) | None => {
|
||||
log::debug!("WebSocket connection closed or error");
|
||||
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ = cancel_rx.recv() => {
|
||||
log::debug!("WebSocket connection cancelled");
|
||||
let _ = app_handle_clone.emit(&format!("ws-cancel-{}", client_id_clone), id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove connection after it closes
|
||||
let mut connections = connections_clone.lock().await;
|
||||
connections.remove(&client_id_clone);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(
|
||||
client_id: String,
|
||||
state: tauri::State<'_, WebSocketManager>,
|
||||
) -> Result<(), String> {
|
||||
let instance = {
|
||||
let mut connections = state.connections.lock().await;
|
||||
connections.remove(&client_id)
|
||||
};
|
||||
|
||||
if let Some(instance) = instance {
|
||||
let _ = instance.cancel_tx.send(()).await;
|
||||
|
||||
// Close WebSocket (lock only the connection, not the whole map)
|
||||
let mut ws = instance.ws_connection.lock().await;
|
||||
let _ = ws.close(None).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::COCO_TAURI_STORE;
|
||||
use serde_json::Value as Json;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>, value: bool) {
|
||||
pub async fn set_allow_self_signature(tauri_app_handle: AppHandle, value: bool) {
|
||||
use crate::server::http_client;
|
||||
|
||||
let store = tauri_app_handle
|
||||
@@ -40,7 +40,7 @@ pub async fn set_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>
|
||||
}
|
||||
|
||||
/// Synchronous version of `async get_allow_self_signature()`.
|
||||
pub fn _get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) -> bool {
|
||||
pub fn _get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
|
||||
let store = tauri_app_handle
|
||||
.store(COCO_TAURI_STORE)
|
||||
.unwrap_or_else(|e| {
|
||||
@@ -67,6 +67,6 @@ pub fn _get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) ->
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) -> bool {
|
||||
pub async fn get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
|
||||
_get_allow_self_signature(tauri_app_handle)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
||||
use tauri::{App, Emitter, EventTarget, WebviewWindow};
|
||||
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
||||
|
||||
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;
|
||||
@@ -29,7 +32,7 @@ pub fn platform(
|
||||
|
||||
// Share the window across all desktop spaces and full screen
|
||||
panel.set_collection_behaviour(
|
||||
NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces
|
||||
NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
|
||||
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
|
||||
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary,
|
||||
);
|
||||
@@ -78,3 +81,50 @@ pub fn platform(
|
||||
// Set the delegate object for the window to handle window events
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{COCO_TAURI_STORE, hide_coco, show_coco};
|
||||
use tauri::{App, AppHandle, Manager, Runtime, async_runtime};
|
||||
use tauri::{App, AppHandle, Manager, async_runtime};
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
use tauri_plugin_store::{JsonValue, StoreExt};
|
||||
|
||||
@@ -50,14 +50,14 @@ pub fn enable_shortcut(app: &App) {
|
||||
/// Get the stored shortcut as a string, same as [`_get_shortcut()`], except that
|
||||
/// this is a `tauri::command` interface.
|
||||
#[tauri::command]
|
||||
pub async fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
|
||||
pub async fn get_current_shortcut(app: AppHandle) -> Result<String, String> {
|
||||
let shortcut = _get_shortcut(&app);
|
||||
Ok(shortcut)
|
||||
}
|
||||
|
||||
/// Get the current shortcut and unregister it on the tauri side.
|
||||
#[tauri::command]
|
||||
pub async fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
|
||||
pub async fn unregister_shortcut(app: AppHandle) {
|
||||
let shortcut_str = _get_shortcut(&app);
|
||||
let shortcut = shortcut_str
|
||||
.parse::<Shortcut>()
|
||||
@@ -70,9 +70,9 @@ pub async fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
|
||||
|
||||
/// Change the global shortcut to `key`.
|
||||
#[tauri::command]
|
||||
pub async fn change_shortcut<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
_window: tauri::Window<R>,
|
||||
pub async fn change_shortcut(
|
||||
app: AppHandle,
|
||||
_window: tauri::Window,
|
||||
key: String,
|
||||
) -> Result<(), String> {
|
||||
println!("key {}:", key);
|
||||
@@ -94,7 +94,7 @@ pub async fn change_shortcut<R: Runtime>(
|
||||
}
|
||||
|
||||
/// Helper function to register a shortcut, used for shortcut updates.
|
||||
fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
|
||||
fn _register_shortcut(app: &AppHandle, shortcut: Shortcut) {
|
||||
app.global_shortcut()
|
||||
.on_shortcut(shortcut, move |app, scut, event| {
|
||||
if scut == &shortcut {
|
||||
@@ -151,7 +151,7 @@ fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
|
||||
}
|
||||
|
||||
/// Helper function to get the stored global shortcut, as a string.
|
||||
pub fn _get_shortcut<R: Runtime>(app: &AppHandle<R>) -> String {
|
||||
pub fn _get_shortcut(app: &AppHandle) -> String {
|
||||
let store = app
|
||||
.get_store(COCO_TAURI_STORE)
|
||||
.expect("store should be loaded or created");
|
||||
|
||||
@@ -4,7 +4,7 @@ pub(crate) mod platform;
|
||||
pub(crate) mod updater;
|
||||
|
||||
use std::{path::Path, process::Command};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
/// We use this env variable to determine the DE on Linux.
|
||||
@@ -88,7 +88,7 @@ fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
|
||||
//
|
||||
// tauri_plugin_shell::open() is deprecated, but we still use it.
|
||||
#[allow(deprecated)]
|
||||
pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<(), String> {
|
||||
pub async fn open(app_handle: AppHandle, path: String) -> Result<(), String> {
|
||||
if cfg!(target_os = "linux") {
|
||||
let borrowed_path = Path::new(&path);
|
||||
if let Some(file_extension) = borrowed_path.extension() {
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
use derive_more::Display;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
use strum::EnumCount;
|
||||
use strum::VariantArray;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Copy, Clone, Hash, PartialEq, Eq, Display)]
|
||||
#[derive(
|
||||
Debug,
|
||||
Deserialize,
|
||||
Serialize,
|
||||
Copy,
|
||||
Clone,
|
||||
Hash,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Display,
|
||||
EnumCount,
|
||||
VariantArray,
|
||||
)]
|
||||
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
|
||||
pub(crate) enum Platform {
|
||||
#[display("macOS")]
|
||||
@@ -18,7 +32,7 @@ impl Platform {
|
||||
pub(crate) fn current() -> Platform {
|
||||
let os_str = std::env::consts::OS;
|
||||
serde_plain::from_str(os_str).unwrap_or_else(|_e| {
|
||||
panic!("std::env::consts::OS is [{}], which is not a valid value for [enum Platform], valid values: ['macos', 'linux', 'windows']", os_str)
|
||||
panic!("std::env::consts::OS is [{}], which is not a valid value for [enum Platform], valid values: {:?}", os_str, Self::VARIANTS.iter().map(|platform|platform.to_string()).collect::<Vec<String>>());
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,4 +45,17 @@ impl Platform {
|
||||
Self::Linux => Cow::Owned(sysinfo::System::distribution_id()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of platforms supported by Coco.
|
||||
//
|
||||
// a.k.a., the number of this enum's variants.
|
||||
pub(crate) fn num_of_supported_platforms() -> usize {
|
||||
Platform::COUNT
|
||||
}
|
||||
|
||||
/// Returns a set that contains all the platforms.
|
||||
#[cfg(test)] // currently, only used in tests
|
||||
pub(crate) fn all() -> std::collections::HashSet<Self> {
|
||||
Platform::VARIANTS.into_iter().copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use tauri_plugin_updater::RemoteRelease;
|
||||
///
|
||||
/// 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 {
|
||||
fn extract_build_number(version: &Version) -> u32 {
|
||||
let pre = &version.pre;
|
||||
|
||||
if pre.is_empty() {
|
||||
@@ -52,8 +52,8 @@ fn extract_version_number(version: &Version) -> u32 {
|
||||
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 local_build_number = extract_build_number(&local);
|
||||
let remote_build_number = extract_build_number(&remote);
|
||||
|
||||
let should_update = remote_build_number > local_build_number;
|
||||
log::debug!(
|
||||
@@ -65,3 +65,23 @@ pub(crate) fn custom_version_comparator(local: Version, remote_release: RemoteRe
|
||||
|
||||
should_update
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_build_number() {
|
||||
// 0.6.0 => 0
|
||||
let version = Version::parse("0.6.0").unwrap();
|
||||
assert_eq!(extract_build_number(&version), 0);
|
||||
|
||||
// 0.6.0-2371 => 2371
|
||||
let version = Version::parse("0.6.0-2371").unwrap();
|
||||
assert_eq!(extract_build_number(&version), 2371);
|
||||
|
||||
// 0.6.0-SNAPSHOT-2371 => 2371
|
||||
let version = Version::parse("0.6.0-SNAPSHOT-2371").unwrap();
|
||||
assert_eq!(extract_build_number(&version), 2371);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,6 @@
|
||||
"https://release.infinilabs.com/coco/app/.latest.json?target={{target}}&arch={{arch}}¤t_version={{current_version}}"
|
||||
]
|
||||
},
|
||||
"websocket": {},
|
||||
"shell": {},
|
||||
"globalShortcut": {},
|
||||
"deep-link": {
|
||||
|
||||
@@ -86,6 +86,12 @@ export const Get = <T>(
|
||||
} else {
|
||||
res = result?.data as FcResponse<T>;
|
||||
}
|
||||
// web component log
|
||||
infoLog({
|
||||
username: "@/api/axiosRequest.ts",
|
||||
logName: url,
|
||||
})(res);
|
||||
|
||||
resolve([null, res as FcResponse<T>]);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -103,7 +109,7 @@ export const Post = <T>(
|
||||
return new Promise((resolve) => {
|
||||
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
|
||||
|
||||
let baseURL = appStore.state?.endpoint_http
|
||||
let baseURL = appStore.state?.endpoint_http;
|
||||
if (!baseURL || baseURL === "undefined") {
|
||||
baseURL = "";
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,4 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
|
||||
import {
|
||||
Server,
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
GetResponse,
|
||||
UploadAttachmentPayload,
|
||||
UploadAttachmentResponse,
|
||||
GetAttachmentPayload,
|
||||
GetAttachmentByIdsPayload,
|
||||
GetAttachmentResponse,
|
||||
DeleteAttachmentPayload,
|
||||
TranscriptionPayload,
|
||||
@@ -18,17 +17,42 @@ import {
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { SETTINGS_WINDOW_LABEL } from "@/constants";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
export function handleLogout(serverId?: string) {
|
||||
const setIsCurrentLogin = useAuthStore.getState().setIsCurrentLogin;
|
||||
const { currentService, setCurrentService, serverList, setServerList } =
|
||||
export async function getCurrentWindowService() {
|
||||
const currentService = useConnectStore.getState().currentService;
|
||||
const cloudSelectService = useConnectStore.getState().cloudSelectService;
|
||||
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
||||
|
||||
return windowLabel === SETTINGS_WINDOW_LABEL
|
||||
? cloudSelectService
|
||||
: currentService;
|
||||
}
|
||||
|
||||
export async function setCurrentWindowService(service: any) {
|
||||
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
||||
const { setCurrentService, setCloudSelectService } =
|
||||
useConnectStore.getState();
|
||||
const id = serverId || currentService?.id;
|
||||
|
||||
return windowLabel === SETTINGS_WINDOW_LABEL
|
||||
? setCloudSelectService(service)
|
||||
: setCurrentService(service);
|
||||
}
|
||||
|
||||
export async function handleLogout(serverId?: string) {
|
||||
const setIsCurrentLogin = useAuthStore.getState().setIsCurrentLogin;
|
||||
const { serverList, setServerList } = useConnectStore.getState();
|
||||
|
||||
const service = await getCurrentWindowService();
|
||||
|
||||
const id = serverId || service?.id;
|
||||
if (!id) return;
|
||||
|
||||
// Update the status first
|
||||
setIsCurrentLogin(false);
|
||||
emit("login_or_logout", false);
|
||||
if (currentService?.id === id) {
|
||||
setCurrentService({ ...currentService, profile: null });
|
||||
if (service?.id === id) {
|
||||
await setCurrentWindowService({ ...service, profile: null });
|
||||
}
|
||||
const updatedServerList = serverList.map((server) =>
|
||||
server.id === id ? { ...server, profile: null } : server
|
||||
@@ -55,13 +79,14 @@ async function invokeWithErrorHandler<T>(
|
||||
args?: Record<string, any>
|
||||
): Promise<T> {
|
||||
const isCurrentLogin = useAuthStore.getState().isCurrentLogin;
|
||||
const currentService = useConnectStore.getState().currentService;
|
||||
|
||||
const service = await getCurrentWindowService();
|
||||
|
||||
// Not logged in
|
||||
console.log(command, isCurrentLogin, currentService?.profile);
|
||||
// console.log("isCurrentLogin", command, isCurrentLogin);
|
||||
if (
|
||||
!WHITELIST_SERVERS.includes(command) &&
|
||||
(!isCurrentLogin || !currentService?.profile)
|
||||
(!isCurrentLogin || !service?.profile)
|
||||
) {
|
||||
console.error("This command requires authentication");
|
||||
throw new Error("This command requires authentication");
|
||||
@@ -89,6 +114,18 @@ async function invokeWithErrorHandler<T>(
|
||||
}
|
||||
}
|
||||
|
||||
// Server Data log
|
||||
let parsedResult = result;
|
||||
let logData = result;
|
||||
if (typeof result === "string") {
|
||||
parsedResult = JSON.parse(result);
|
||||
logData = parsedResult;
|
||||
}
|
||||
infoLog({
|
||||
username: "@/commands/servers.ts",
|
||||
logName: command,
|
||||
})(logData);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const errorMessage = error || "Command execution failed";
|
||||
@@ -172,14 +209,6 @@ export function mcp_server_search({
|
||||
return invokeWithErrorHandler(`mcp_server_search`, { id, queryParams });
|
||||
}
|
||||
|
||||
export function connect_to_server(id: string, clientId: string): Promise<void> {
|
||||
return invokeWithErrorHandler(`connect_to_server`, { id, clientId });
|
||||
}
|
||||
|
||||
export function disconnect(clientId: string): Promise<void> {
|
||||
return invokeWithErrorHandler(`disconnect`, { clientId });
|
||||
}
|
||||
|
||||
export function chat_history({
|
||||
serverId,
|
||||
from = 0,
|
||||
@@ -260,76 +289,40 @@ export function cancel_session_chat({
|
||||
});
|
||||
}
|
||||
|
||||
export function new_chat({
|
||||
serverId,
|
||||
websocketId,
|
||||
message,
|
||||
queryParams,
|
||||
}: {
|
||||
serverId: string;
|
||||
websocketId: string;
|
||||
message: string;
|
||||
queryParams?: Record<string, any>;
|
||||
}): Promise<GetResponse> {
|
||||
return invokeWithErrorHandler(`new_chat`, {
|
||||
serverId,
|
||||
websocketId,
|
||||
message,
|
||||
queryParams,
|
||||
});
|
||||
}
|
||||
|
||||
export function chat_create({
|
||||
serverId,
|
||||
message,
|
||||
attachments,
|
||||
queryParams,
|
||||
clientId,
|
||||
}: {
|
||||
serverId: string;
|
||||
message: string;
|
||||
attachments: string[];
|
||||
queryParams?: Record<string, any>;
|
||||
clientId: string;
|
||||
}): Promise<GetResponse> {
|
||||
return invokeWithErrorHandler(`chat_create`, {
|
||||
serverId,
|
||||
message,
|
||||
attachments,
|
||||
queryParams,
|
||||
clientId,
|
||||
});
|
||||
}
|
||||
|
||||
export function send_message({
|
||||
serverId,
|
||||
websocketId,
|
||||
sessionId,
|
||||
message,
|
||||
queryParams,
|
||||
}: {
|
||||
serverId: string;
|
||||
websocketId: string;
|
||||
sessionId: string;
|
||||
message: string;
|
||||
queryParams?: Record<string, any>;
|
||||
}): Promise<string> {
|
||||
return invokeWithErrorHandler(`send_message`, {
|
||||
serverId,
|
||||
websocketId,
|
||||
sessionId,
|
||||
message,
|
||||
queryParams,
|
||||
});
|
||||
}
|
||||
|
||||
export function chat_chat({
|
||||
serverId,
|
||||
sessionId,
|
||||
message,
|
||||
attachments,
|
||||
queryParams,
|
||||
clientId,
|
||||
}: {
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
message: string;
|
||||
attachments: string[];
|
||||
queryParams?: Record<string, any>;
|
||||
clientId: string;
|
||||
}): Promise<string> {
|
||||
@@ -337,6 +330,7 @@ export function chat_chat({
|
||||
serverId,
|
||||
sessionId,
|
||||
message,
|
||||
attachments,
|
||||
queryParams,
|
||||
clientId,
|
||||
});
|
||||
@@ -391,10 +385,13 @@ export const upload_attachment = async (payload: UploadAttachmentPayload) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const get_attachment = (payload: GetAttachmentPayload) => {
|
||||
return invokeWithErrorHandler<GetAttachmentResponse>("get_attachment", {
|
||||
...payload,
|
||||
});
|
||||
export const get_attachment_by_ids = (payload: GetAttachmentByIdsPayload) => {
|
||||
return invokeWithErrorHandler<GetAttachmentResponse>(
|
||||
"get_attachment_by_ids",
|
||||
{
|
||||
...payload,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const delete_attachment = (payload: DeleteAttachmentPayload) => {
|
||||
@@ -420,4 +417,4 @@ export const query_coco_fusion = (payload: {
|
||||
|
||||
export const get_app_search_source = () => {
|
||||
return invokeWithErrorHandler<void>("get_app_search_source");
|
||||
};
|
||||
};
|
||||
|
||||
@@ -34,4 +34,8 @@ export function show_check(): Promise<void> {
|
||||
|
||||
export function hide_check(): Promise<void> {
|
||||
return invoke('hide_check');
|
||||
}
|
||||
|
||||
export function toggle_move_to_active_space_attribute(): Promise<void> {
|
||||
return invoke('toggle_move_to_active_space_attribute');
|
||||
}
|
||||
@@ -57,8 +57,6 @@ export const AssistantFetcher = ({
|
||||
|
||||
let assistantList = response?.hits?.hits ?? [];
|
||||
|
||||
console.log("assistantList", assistantList);
|
||||
|
||||
if (
|
||||
!currentAssistant?._id ||
|
||||
currentService?.id !== lastServerId.current
|
||||
|
||||
182
src/components/Assistant/AttachmentList.tsx
Normal file
182
src/components/Assistant/AttachmentList.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { FC, useEffect, useMemo } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useChatStore, UploadAttachments } from "@/stores/chatStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import Tooltip2 from "../Common/Tooltip2";
|
||||
import FileIcon from "../Common/Icons/FileIcon";
|
||||
import { filesize } from "@/utils";
|
||||
|
||||
const AttachmentList = () => {
|
||||
const { uploadAttachments, setUploadAttachments } = useChatStore();
|
||||
const { currentService } = useConnectStore();
|
||||
|
||||
const serverId = useMemo(() => {
|
||||
return currentService.id;
|
||||
}, [currentService]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setUploadAttachments([]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const uploadAttachment = async (data: UploadAttachments) => {
|
||||
const { uploading, uploaded, uploadFailed, path } = data;
|
||||
|
||||
if (uploading || uploaded || uploadFailed) return;
|
||||
|
||||
const { uploadAttachments } = useChatStore.getState();
|
||||
|
||||
const matched = uploadAttachments.find((item) => item.id === data.id);
|
||||
|
||||
if (matched) {
|
||||
matched.uploading = true;
|
||||
|
||||
setUploadAttachments(uploadAttachments);
|
||||
}
|
||||
|
||||
try {
|
||||
const attachmentIds: any = await platformAdapter.commands(
|
||||
"upload_attachment",
|
||||
{
|
||||
serverId,
|
||||
filePaths: [path],
|
||||
}
|
||||
);
|
||||
|
||||
if (!attachmentIds) {
|
||||
throw new Error("Failed to get attachment id");
|
||||
} else {
|
||||
Object.assign(data, {
|
||||
uploaded: true,
|
||||
attachmentId: attachmentIds[0],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
Object.assign(data, {
|
||||
uploadFailed: true,
|
||||
failedMessage: String(error),
|
||||
});
|
||||
} finally {
|
||||
Object.assign(data, {
|
||||
uploading: false,
|
||||
});
|
||||
|
||||
setUploadAttachments(uploadAttachments);
|
||||
}
|
||||
};
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (uploadAttachments.length === 0) return;
|
||||
|
||||
for (const item of uploadAttachments) {
|
||||
uploadAttachment(item);
|
||||
}
|
||||
}, [uploadAttachments]);
|
||||
|
||||
const deleteFile = async (id: string) => {
|
||||
const { uploadAttachments } = useChatStore.getState();
|
||||
|
||||
const matched = uploadAttachments.find((item) => item.id === id);
|
||||
|
||||
if (!matched) return;
|
||||
|
||||
const { uploadFailed, attachmentId } = matched;
|
||||
|
||||
setUploadAttachments(uploadAttachments.filter((file) => file.id !== id));
|
||||
|
||||
if (uploadFailed) return;
|
||||
|
||||
platformAdapter.commands("delete_attachment", {
|
||||
serverId,
|
||||
id: attachmentId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
|
||||
{uploadAttachments.map((file) => {
|
||||
return (
|
||||
<AttachmentItem
|
||||
key={file.id}
|
||||
{...file}
|
||||
deletable
|
||||
onDelete={deleteFile}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AttachmentItemProps extends UploadAttachments {
|
||||
deletable?: boolean;
|
||||
onDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
export const AttachmentItem: FC<AttachmentItemProps> = (props) => {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
path,
|
||||
extname,
|
||||
size,
|
||||
uploaded,
|
||||
attachmentId,
|
||||
uploadFailed,
|
||||
failedMessage,
|
||||
deletable,
|
||||
onDelete,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div key={id} className="w-1/3 px-1">
|
||||
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
|
||||
{(uploadFailed || attachmentId) && deletable && (
|
||||
<div
|
||||
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
|
||||
onClick={() => {
|
||||
onDelete?.(id);
|
||||
}}
|
||||
>
|
||||
<X className="size-[10px] text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FileIcon path={path} />
|
||||
|
||||
<div className="flex flex-col justify-between overflow-hidden">
|
||||
<div className="truncate text-sm text-[#333333] dark:text-[#D8D8D8]">
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
{uploadFailed && failedMessage ? (
|
||||
<Tooltip2 content={failedMessage}>
|
||||
<span className="text-red-500">Upload Failed</span>
|
||||
</Tooltip2>
|
||||
) : (
|
||||
<div className="text-[#999]">
|
||||
{uploaded ? (
|
||||
<div className="flex gap-2">
|
||||
{extname && <span>{extname}</span>}
|
||||
<span>{filesize(size)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>{t("assistant.fileList.uploading")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentList;
|
||||
@@ -43,8 +43,13 @@ interface ChatAIProps {
|
||||
instanceId?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageParams {
|
||||
message?: string;
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
export interface ChatAIRef {
|
||||
init: (value: string) => void;
|
||||
init: (params: SendMessageParams) => void;
|
||||
cancelChat: () => void;
|
||||
clearChat: () => void;
|
||||
}
|
||||
@@ -188,7 +193,7 @@ const ChatAI = memo(
|
||||
isDeepThinkActive,
|
||||
isMCPActive,
|
||||
changeInput,
|
||||
showChatHistory,
|
||||
showChatHistory
|
||||
);
|
||||
|
||||
const { dealMsg } = useMessageHandler(
|
||||
@@ -225,7 +230,7 @@ const ChatAI = memo(
|
||||
}, [activeChat, chatClose]);
|
||||
|
||||
const init = useCallback(
|
||||
async (value: string) => {
|
||||
async (params: SendMessageParams) => {
|
||||
try {
|
||||
//console.log("init", curChatEnd, activeChat?._id);
|
||||
if (!isCurrentLogin) {
|
||||
@@ -237,9 +242,9 @@ const ChatAI = memo(
|
||||
return;
|
||||
}
|
||||
if (!activeChat?._id) {
|
||||
await createNewChat(value);
|
||||
await createNewChat(params);
|
||||
} else {
|
||||
await handleSendMessage(value, activeChat);
|
||||
await handleSendMessage(activeChat, params);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize chat:", error);
|
||||
@@ -285,7 +290,10 @@ const ChatAI = memo(
|
||||
if (updatedChats.length > 0) {
|
||||
setActiveChat(updatedChats[0]);
|
||||
} else {
|
||||
init("");
|
||||
init({
|
||||
message: "",
|
||||
attachments: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,8 +404,8 @@ const ChatAI = memo(
|
||||
loadingStep={loadingStep}
|
||||
timedoutShow={timedoutShow}
|
||||
Question={Question}
|
||||
handleSendMessage={(value) =>
|
||||
handleSendMessage(value, activeChat)
|
||||
handleSendMessage={(message) =>
|
||||
handleSendMessage(activeChat, { message })
|
||||
}
|
||||
getFileUrl={getFileUrl}
|
||||
formatUrl={formatUrl}
|
||||
@@ -410,7 +418,11 @@ const ChatAI = memo(
|
||||
)}
|
||||
|
||||
{!activeChat?._id && !visibleStartPage && (
|
||||
<PrevSuggestion sendMessage={init} />
|
||||
<PrevSuggestion
|
||||
sendMessage={(message) => {
|
||||
init({ message });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -3,13 +3,14 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatMessage } from "@/components/ChatMessage";
|
||||
import { Greetings } from "./Greetings";
|
||||
// import FileList from "@/components/Assistant/FileList";
|
||||
import AttachmentList from "@/components/Assistant/AttachmentList";
|
||||
import { useChatScroll } from "@/hooks/useChatScroll";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
|
||||
import type { Chat, IChunkData } from "@/types/chat";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
// import SessionFile from "./SessionFile";
|
||||
import ScrollToBottom from "@/components/Common/ScrollToBottom";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
|
||||
interface ChatContentProps {
|
||||
activeChat?: Chat;
|
||||
@@ -44,14 +45,12 @@ export const ChatContent = ({
|
||||
handleSendMessage,
|
||||
formatUrl,
|
||||
}: ChatContentProps) => {
|
||||
// const sessionId = useConnectStore((state) => state.currentSessionId);
|
||||
const setCurrentSessionId = useConnectStore((state) => {
|
||||
return state.setCurrentSessionId;
|
||||
});
|
||||
const { currentSessionId, setCurrentSessionId } = useConnectStore();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// const uploadFiles = useChatStore((state) => state.uploadFiles);
|
||||
const { uploadAttachments } = useChatStore();
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { scrollToBottom } = useChatScroll(messagesEndRef);
|
||||
@@ -168,13 +167,13 @@ export const ChatContent = ({
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* {uploadFiles.length > 0 && (
|
||||
<div key={sessionId} className="max-h-[120px] overflow-auto p-2">
|
||||
<FileList />
|
||||
{uploadAttachments.length > 0 && (
|
||||
<div key={currentSessionId} className="max-h-[120px] overflow-auto p-2">
|
||||
<AttachmentList />
|
||||
</div>
|
||||
)} */}
|
||||
)}
|
||||
|
||||
{/* {sessionId && <SessionFile sessionId={sessionId} />} */}
|
||||
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
|
||||
|
||||
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
|
||||
</div>
|
||||
|
||||
@@ -7,12 +7,12 @@ import PinIcon from "@/icons/Pin";
|
||||
import WindowsFullIcon from "@/icons/WindowsFull";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import type { Chat } from "@/types/chat";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import VisibleKey from "../Common/VisibleKey";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { HISTORY_PANEL_ID } from "@/constants";
|
||||
import { AssistantList } from "./AssistantList";
|
||||
import { ServerList } from "./ServerList";
|
||||
import { useTogglePin } from "@/hooks/useTogglePin";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
clearChat: () => void;
|
||||
@@ -35,22 +35,12 @@ export function ChatHeader({
|
||||
showChatHistory = true,
|
||||
assistantIDs,
|
||||
}: ChatHeaderProps) {
|
||||
const { isPinned, setIsPinned, isTauri } = useAppStore();
|
||||
const { isTauri } = useAppStore();
|
||||
const { isPinned, togglePin } = useTogglePin();
|
||||
|
||||
const { historicalRecords, newSession, fixedWindow, external } =
|
||||
useShortcutsStore();
|
||||
|
||||
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 (
|
||||
<header
|
||||
className="flex items-center justify-between py-2 px-3 select-none"
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { filesize } from "filesize";
|
||||
import { X } from "lucide-react";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useChatStore, UploadFile } from "@/stores/chatStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import Tooltip2 from "../Common/Tooltip2";
|
||||
import FileIcon from "../Common/Icons/FileIcon";
|
||||
|
||||
const FileList = () => {
|
||||
const { t } = useTranslation();
|
||||
const { uploadFiles, setUploadFiles } = useChatStore();
|
||||
const { currentService } = useConnectStore();
|
||||
|
||||
const serverId = useMemo(() => {
|
||||
return currentService.id;
|
||||
}, [currentService]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setUploadFiles([]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (uploadFiles.length === 0) return;
|
||||
|
||||
for await (const item of uploadFiles) {
|
||||
const { uploaded, path } = item;
|
||||
|
||||
if (uploaded) continue;
|
||||
|
||||
try {
|
||||
const attachmentIds: any = await platformAdapter.commands(
|
||||
"upload_attachment",
|
||||
{
|
||||
serverId,
|
||||
filePaths: [path],
|
||||
}
|
||||
);
|
||||
|
||||
if (!attachmentIds) {
|
||||
throw new Error("Failed to get attachment id");
|
||||
} else {
|
||||
Object.assign(item, {
|
||||
uploaded: true,
|
||||
attachmentId: attachmentIds[0],
|
||||
});
|
||||
}
|
||||
|
||||
setUploadFiles(uploadFiles);
|
||||
} catch (error) {
|
||||
Object.assign(item, {
|
||||
uploadFailed: true,
|
||||
failedMessage: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [uploadFiles]);
|
||||
|
||||
const deleteFile = async (file: UploadFile) => {
|
||||
const { id, uploadFailed, attachmentId } = file;
|
||||
|
||||
setUploadFiles(uploadFiles.filter((file) => file.id !== id));
|
||||
|
||||
if (uploadFailed) return;
|
||||
|
||||
platformAdapter.commands("delete_attachment", {
|
||||
serverId,
|
||||
id: attachmentId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
|
||||
{uploadFiles.map((file) => {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
path,
|
||||
extname,
|
||||
size,
|
||||
uploaded,
|
||||
attachmentId,
|
||||
uploadFailed,
|
||||
failedMessage,
|
||||
} = file;
|
||||
|
||||
return (
|
||||
<div key={id} className="w-1/3 px-1">
|
||||
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
|
||||
{(uploadFailed || attachmentId) && (
|
||||
<div
|
||||
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
|
||||
onClick={() => {
|
||||
deleteFile(file);
|
||||
}}
|
||||
>
|
||||
<X className="size-[10px] text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FileIcon path={path} />
|
||||
|
||||
<div className="flex flex-col justify-between overflow-hidden">
|
||||
<div className="truncate text-[#333333] dark:text-[#D8D8D8]">
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
{uploadFailed && failedMessage ? (
|
||||
<Tooltip2 content={failedMessage}>
|
||||
<span className="text-red-500">Upload Failed</span>
|
||||
</Tooltip2>
|
||||
) : (
|
||||
<div className="text-[#999]">
|
||||
{uploaded ? (
|
||||
<div className="flex gap-2">
|
||||
{extname && <span>{extname}</span>}
|
||||
<span>
|
||||
{filesize(size, { standard: "jedec", spacer: "" })}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>{t("assistant.fileList.uploading")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileList;
|
||||
@@ -17,6 +17,8 @@ import { Server as IServer } from "@/types/server";
|
||||
import StatusIndicator from "@/components/Cloud/StatusIndicator";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
import { getCurrentWindowService, setCurrentWindowService } from "@/commands";
|
||||
|
||||
interface ServerListProps {
|
||||
clearChat: () => void;
|
||||
@@ -25,17 +27,20 @@ interface ServerListProps {
|
||||
export function ServerList({ clearChat }: ServerListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
|
||||
const setIsCurrentLogin = useAuthStore((state) => state.setIsCurrentLogin);
|
||||
const serviceList = useShortcutsStore((state) => state.serviceList);
|
||||
const serviceListShortcut = useShortcutsStore(
|
||||
(state) => state.serviceListShortcut
|
||||
);
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const cloudSelectService = useConnectStore((state) => {
|
||||
return state.cloudSelectService;
|
||||
});
|
||||
|
||||
const { setMessages } = useChatStore();
|
||||
|
||||
const [serverList, setServerList] = useState<IServer[]>([]);
|
||||
const [list, setList] = useState<IServer[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [highlightId, setHighlightId] = useState<string>("");
|
||||
|
||||
@@ -49,44 +54,49 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const serverListButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const fetchServers = useCallback(
|
||||
async (resetSelection: boolean) => {
|
||||
platformAdapter.commands("list_coco_servers").then((res: any) => {
|
||||
console.log("list_coco_servers", res);
|
||||
if (!Array.isArray(res)) {
|
||||
// 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
|
||||
);
|
||||
const { refreshServerList } = useServers();
|
||||
const serverList = useConnectStore((state) => state.serverList);
|
||||
|
||||
setServerList(enabledServers);
|
||||
const switchServer = async (server: IServer) => {
|
||||
if (!server) return;
|
||||
try {
|
||||
// Switch UI first, then switch server connection
|
||||
await setCurrentWindowService(server);
|
||||
setEndpoint(server.endpoint);
|
||||
setMessages(""); // Clear previous messages
|
||||
clearChat();
|
||||
//
|
||||
if (!server.public && !server.profile) {
|
||||
setIsCurrentLogin(false);
|
||||
return;
|
||||
}
|
||||
//
|
||||
setIsCurrentLogin(true);
|
||||
} catch (error) {
|
||||
console.error("switchServer:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (resetSelection && enabledServers.length > 0) {
|
||||
const currentServiceExists = enabledServers.find(
|
||||
(server) => server.id === currentService?.id
|
||||
);
|
||||
const fetchServers = useCallback(async () => {
|
||||
const service = await getCurrentWindowService();
|
||||
|
||||
if (currentServiceExists) {
|
||||
switchServer(currentServiceExists);
|
||||
} else {
|
||||
switchServer(enabledServers[enabledServers.length - 1]);
|
||||
}
|
||||
}
|
||||
const enabledServers = serverList.filter(
|
||||
(server) => server.enabled && server.available
|
||||
);
|
||||
setList(enabledServers);
|
||||
|
||||
if (enabledServers.length > 0) {
|
||||
const serviceExists = enabledServers.find((server) => {
|
||||
return server.id === service?.id;
|
||||
});
|
||||
},
|
||||
[currentService?.id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
|
||||
fetchServers(true);
|
||||
}, [currentService?.enabled]);
|
||||
if (serviceExists) {
|
||||
switchServer(serviceExists);
|
||||
} else {
|
||||
switchServer(enabledServers[enabledServers.length - 1]);
|
||||
}
|
||||
}
|
||||
}, [currentService?.id, cloudSelectService?.id, serverList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!askAiServerId || serverList.length === 0) return;
|
||||
@@ -104,25 +114,12 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
|
||||
fetchServers(true);
|
||||
|
||||
const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => {
|
||||
//console.log("Login or Logout:", currentService, event.payload);
|
||||
if (event.payload !== isCurrentLogin) {
|
||||
setIsCurrentLogin(!!event.payload);
|
||||
}
|
||||
fetchServers(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cleanup logic if needed
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
fetchServers();
|
||||
}, [serverList]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await fetchServers(false);
|
||||
await refreshServerList();
|
||||
setTimeout(() => setIsRefreshing(false), 1000);
|
||||
};
|
||||
|
||||
@@ -130,29 +127,10 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
platformAdapter.emitEvent("open_settings", "connect");
|
||||
};
|
||||
|
||||
const switchServer = async (server: IServer) => {
|
||||
if (!server) return;
|
||||
try {
|
||||
// Switch UI first, then switch server connection
|
||||
setCurrentService(server);
|
||||
setEndpoint(server.endpoint);
|
||||
setMessages(""); // Clear previous messages
|
||||
clearChat();
|
||||
//
|
||||
if (!server.public && !server.profile) {
|
||||
setIsCurrentLogin(false);
|
||||
return;
|
||||
}
|
||||
//
|
||||
setIsCurrentLogin(true);
|
||||
} catch (error) {
|
||||
console.error("switchServer:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useKeyPress(
|
||||
["uparrow", "downarrow", "enter"],
|
||||
(event, key) => {
|
||||
async (event, key) => {
|
||||
const service = await getCurrentWindowService();
|
||||
const isClose = isNil(serverListButtonRef.current?.dataset["open"]);
|
||||
const length = serverList.length;
|
||||
|
||||
@@ -162,9 +140,7 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
event.preventDefault();
|
||||
|
||||
const currentIndex = serverList.findIndex((server) => {
|
||||
return (
|
||||
server.id === (highlightId === "" ? currentService?.id : highlightId)
|
||||
);
|
||||
return server.id === (highlightId === "" ? service?.id : highlightId);
|
||||
});
|
||||
|
||||
let nextIndex = currentIndex;
|
||||
@@ -197,7 +173,7 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
<Popover ref={popoverRef} className="relative">
|
||||
<PopoverButton ref={serverListButtonRef} className="flex items-center">
|
||||
<VisibleKey
|
||||
shortcut={serviceList}
|
||||
shortcut={serviceListShortcut}
|
||||
onKeyPress={() => {
|
||||
serverListButtonRef.current?.click();
|
||||
}}
|
||||
@@ -240,8 +216,8 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{serverList.length > 0 ? (
|
||||
serverList.map((server) => (
|
||||
{list.length > 0 ? (
|
||||
list.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
onClick={() => switchServer(server)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import { filesize } from "filesize";
|
||||
import { Files, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -10,6 +9,7 @@ import { AttachmentHit } from "@/types/commands";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import FileIcon from "../Common/Icons/FileIcon";
|
||||
import { filesize } from "@/utils";
|
||||
|
||||
interface SessionFileProps {
|
||||
sessionId: string;
|
||||
@@ -39,10 +39,13 @@ const SessionFile = (props: SessionFileProps) => {
|
||||
if (isTauri) {
|
||||
console.log("sessionId", sessionId);
|
||||
|
||||
const response: any = await platformAdapter.commands("get_attachment", {
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
const response: any = await platformAdapter.commands(
|
||||
"get_attachment_by_ids",
|
||||
{
|
||||
serverId,
|
||||
sessionId,
|
||||
}
|
||||
);
|
||||
|
||||
setUploadedFiles(response?.hits?.hits ?? []);
|
||||
} else {
|
||||
@@ -145,9 +148,7 @@ const SessionFile = (props: SessionFileProps) => {
|
||||
</div>
|
||||
<div className="text-xs text-[#999]">
|
||||
{icon && <span className="pr-2">{icon}</span>}
|
||||
<span>
|
||||
{filesize(size, { standard: "jedec", spacer: "" })}
|
||||
</span>
|
||||
<span>{filesize(size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import durationPlugin from "dayjs/plugin/duration";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import loadingLight from "@/assets/images/ReadAloud/loading-light.png";
|
||||
@@ -18,7 +19,6 @@ import closeDark from "@/assets/images/ReadAloud/close-dark.png";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useStreamAudio } from "@/hooks/useStreamAudio";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
|
||||
dayjs.extend(durationPlugin);
|
||||
|
||||
@@ -82,8 +82,6 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
|
||||
}
|
||||
);
|
||||
|
||||
console.log("response", response);
|
||||
|
||||
const text = response?.results
|
||||
.flatMap((item: any) => item?.transcription?.transcripts)
|
||||
.map((item: any) => item?.text?.replace(/<\|[\/\w]+\|>/g, ""))
|
||||
@@ -161,7 +159,7 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
"size-6 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition cursor-pointer",
|
||||
"min-w-6 h-6 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition cursor-pointer",
|
||||
{
|
||||
hidden: state.audioDevices.length === 0,
|
||||
}
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { useState } from "react";
|
||||
import { FC, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { CopyButton } from "@/components/Common/CopyButton";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { AttachmentItem } from "../Assistant/AttachmentList";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
interface UserMessageProps {
|
||||
messageContent: string;
|
||||
message: string;
|
||||
attachments: string[];
|
||||
}
|
||||
|
||||
export const UserMessage = ({ messageContent }: UserMessageProps) => {
|
||||
export const UserMessage: FC<UserMessageProps> = (props) => {
|
||||
const { message, attachments } = props;
|
||||
|
||||
const [showCopyButton, setShowCopyButton] = useState(false);
|
||||
const { currentService } = useConnectStore();
|
||||
const [attachmentData, setAttachmentData] = useState<any[]>([]);
|
||||
const { addError } = useAppStore();
|
||||
|
||||
const handleDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
|
||||
@@ -21,31 +32,81 @@ export const UserMessage = ({ messageContent }: UserMessageProps) => {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} catch (error) {
|
||||
console.error('Selection failed:', error);
|
||||
console.error("Selection failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
try {
|
||||
if (attachments.length === 0) return;
|
||||
|
||||
const result: any = await platformAdapter.commands(
|
||||
"get_attachment_by_ids",
|
||||
{
|
||||
serverId: currentService.id,
|
||||
attachments,
|
||||
}
|
||||
);
|
||||
|
||||
setAttachmentData(result?.hits?.hits);
|
||||
} catch (error) {
|
||||
addError(String(error));
|
||||
}
|
||||
}, [attachments]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-full flex gap-1 items-center justify-end"
|
||||
onMouseEnter={() => setShowCopyButton(true)}
|
||||
onMouseLeave={() => setShowCopyButton(false)}
|
||||
>
|
||||
<div
|
||||
className={clsx("size-6 transition", {
|
||||
"opacity-0": !showCopyButton,
|
||||
})}
|
||||
>
|
||||
<CopyButton textToCopy={messageContent} />
|
||||
</div>
|
||||
<div
|
||||
className="max-w-[85%] overflow-auto text-left px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer user-select-text whitespace-pre-wrap"
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{messageContent}
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{message && (
|
||||
<div
|
||||
className="flex gap-1 items-center justify-end"
|
||||
onMouseEnter={() => setShowCopyButton(true)}
|
||||
onMouseLeave={() => setShowCopyButton(false)}
|
||||
>
|
||||
<div
|
||||
className={clsx("size-6 transition", {
|
||||
"opacity-0": !showCopyButton,
|
||||
})}
|
||||
>
|
||||
<CopyButton textToCopy={message} />
|
||||
</div>
|
||||
<div
|
||||
className="max-w-[85%] overflow-auto text-left px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer user-select-text whitespace-pre-wrap"
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachmentData && (
|
||||
<div
|
||||
className={clsx("flex justify-end flex-wrap gap-y-2 w-full", {
|
||||
"mt-3": message,
|
||||
})}
|
||||
>
|
||||
{attachmentData.map((item) => {
|
||||
const { id, name, size, icon } = item._source;
|
||||
|
||||
return (
|
||||
<AttachmentItem
|
||||
{...item._source}
|
||||
key={id}
|
||||
uploading={false}
|
||||
uploaded
|
||||
id={id}
|
||||
extname={icon}
|
||||
attachmentId={id}
|
||||
name={name}
|
||||
path={name}
|
||||
size={size}
|
||||
deletable={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -89,6 +89,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
]);
|
||||
|
||||
const messageContent = message?._source?.message || "";
|
||||
const attachments = message?._source?.attachments ?? [];
|
||||
const details = message?._source?.details || [];
|
||||
const question = message?._source?.question || "";
|
||||
|
||||
@@ -103,7 +104,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
|
||||
const renderContent = () => {
|
||||
if (!isAssistant) {
|
||||
return <UserMessage messageContent={messageContent} />;
|
||||
return <UserMessage message={messageContent} attachments={attachments} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
|
||||
import { DataSourcesList } from "./DataSourcesList";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
@@ -9,6 +8,8 @@ import { useConnectStore } from "@/stores/connectStore";
|
||||
import ServiceInfo from "./ServiceInfo";
|
||||
import ServiceAuth from "./ServiceAuth";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import type { Server } from "@/types/server";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
|
||||
export default function Cloud() {
|
||||
const SidebarRef = useRef<{ refreshData: () => void }>(null);
|
||||
@@ -17,100 +18,63 @@ export default function Cloud() {
|
||||
|
||||
const [isConnect, setIsConnect] = useState(true);
|
||||
|
||||
const { currentService, setCurrentService, serverList, setServerList } =
|
||||
useConnectStore();
|
||||
const {
|
||||
cloudSelectService,
|
||||
setCloudSelectService,
|
||||
serverList,
|
||||
setServerList,
|
||||
} = useConnectStore();
|
||||
|
||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||
|
||||
const { addServer, refreshServerList } = useServers();
|
||||
|
||||
// fetch the servers
|
||||
useEffect(() => {
|
||||
fetchServers(true);
|
||||
}, []);
|
||||
fetchServers();
|
||||
}, [serverList]);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log("currentService", currentService);
|
||||
setRefreshLoading(false);
|
||||
setIsConnect(true);
|
||||
}, [JSON.stringify(currentService)]);
|
||||
}, [cloudSelectService?.id]);
|
||||
|
||||
const fetchServers = async (resetSelection: boolean) => {
|
||||
platformAdapter
|
||||
.commands("list_coco_servers")
|
||||
.then((res: any) => {
|
||||
if (errors.length > 0) {
|
||||
res = (res || []).map((item: any) => {
|
||||
if (item.id === currentService?.id) {
|
||||
item.health = {
|
||||
services: null,
|
||||
status: null,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
const fetchServers = useCallback(async () => {
|
||||
let res = serverList;
|
||||
if (errors.length > 0) {
|
||||
res = res.map((item: Server) => {
|
||||
if (item.id === cloudSelectService?.id) {
|
||||
item.health = {
|
||||
services: item.health?.services || {},
|
||||
status: item.health?.status || "red",
|
||||
};
|
||||
}
|
||||
console.log("list_coco_servers", res);
|
||||
setServerList(res);
|
||||
|
||||
if (resetSelection && res.length > 0) {
|
||||
const matched = res.find((server: any) => {
|
||||
return server.id === currentService?.id;
|
||||
});
|
||||
|
||||
if (matched) {
|
||||
setCurrentService(matched);
|
||||
} else {
|
||||
setCurrentService(res[res.length - 1]);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const addServer = (endpointLink: string) => {
|
||||
if (!endpointLink) {
|
||||
throw new Error("Endpoint is required");
|
||||
}
|
||||
if (
|
||||
!endpointLink.startsWith("http://") &&
|
||||
!endpointLink.startsWith("https://")
|
||||
) {
|
||||
throw new Error("Invalid Endpoint");
|
||||
}
|
||||
|
||||
setRefreshLoading(true);
|
||||
|
||||
return platformAdapter
|
||||
.commands("add_coco_server", endpointLink)
|
||||
.then((res: any) => {
|
||||
// console.log("add_coco_server", res);
|
||||
fetchServers(false).then((r) => {
|
||||
console.log("fetchServers", r);
|
||||
setCurrentService(res);
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
return item;
|
||||
});
|
||||
};
|
||||
}
|
||||
setServerList(res);
|
||||
|
||||
if (res.length > 0) {
|
||||
const matched = res.find((server: any) => {
|
||||
return server.id === cloudSelectService?.id;
|
||||
});
|
||||
|
||||
if (matched) {
|
||||
setCloudSelectService(matched);
|
||||
} else {
|
||||
setCloudSelectService(res[res.length - 1]);
|
||||
}
|
||||
}
|
||||
}, [serverList, errors, cloudSelectService]);
|
||||
|
||||
const refreshClick = useCallback(
|
||||
(id: string) => {
|
||||
async (id: string) => {
|
||||
setRefreshLoading(true);
|
||||
platformAdapter
|
||||
.commands("refresh_coco_server_info", id)
|
||||
.then((res: any) => {
|
||||
console.log("refresh_coco_server_info", id, res);
|
||||
fetchServers(false).then((r) => {
|
||||
console.log("fetchServers", r);
|
||||
});
|
||||
// update currentService
|
||||
setCurrentService(res);
|
||||
emit("login_or_logout", true);
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
await platformAdapter.commands("refresh_coco_server_info", id);
|
||||
await refreshServerList();
|
||||
setRefreshLoading(false);
|
||||
},
|
||||
[fetchServers]
|
||||
[refreshServerList]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -127,7 +91,6 @@ export default function Cloud() {
|
||||
<ServiceInfo
|
||||
refreshLoading={refreshLoading}
|
||||
refreshClick={refreshClick}
|
||||
fetchServers={fetchServers}
|
||||
/>
|
||||
|
||||
<ServiceAuth
|
||||
@@ -135,8 +98,8 @@ export default function Cloud() {
|
||||
refreshClick={refreshClick}
|
||||
/>
|
||||
|
||||
{currentService?.profile && currentService?.available ? (
|
||||
<DataSourcesList server={currentService?.id} />
|
||||
{cloudSelectService?.profile && cloudSelectService?.available ? (
|
||||
<DataSourcesList server={cloudSelectService?.id} />
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -21,7 +21,7 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
||||
};
|
||||
|
||||
const onAddServerClick = async (endpoint: string) => {
|
||||
console.log("onAddServer", endpoint);
|
||||
//console.log("onAddServer", endpoint);
|
||||
await onAddServer(endpoint);
|
||||
setIsConnect(true);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ export function DataSourcesList({ server }: { server: string }) {
|
||||
platformAdapter
|
||||
.commands("get_connectors_by_server", server)
|
||||
.then((res: any) => {
|
||||
// console.log("get_connectors_by_server", res);
|
||||
setConnectorData(res, server);
|
||||
})
|
||||
.finally(() => {});
|
||||
@@ -29,7 +28,6 @@ export function DataSourcesList({ server }: { server: string }) {
|
||||
platformAdapter
|
||||
.commands("datasource_search", { id: server })
|
||||
.then((res: any) => {
|
||||
// console.log("datasource_search", res);
|
||||
setDatasourceData(res, server);
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { copyToClipboard } from "@/utils";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { handleLogout } from "@/commands/servers";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
|
||||
interface ServiceAuthProps {
|
||||
setRefreshLoading: (loading: boolean) => void;
|
||||
@@ -30,7 +30,9 @@ const ServiceAuth = memo(
|
||||
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
|
||||
|
||||
const { logoutServer } = useServers();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -41,7 +43,7 @@ const ServiceAuth = memo(
|
||||
setSSORequestID(requestID);
|
||||
|
||||
// Generate the login URL with the current appUid
|
||||
const url = `${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${requestID}`;
|
||||
const url = `${cloudSelectService?.auth_provider?.sso?.url}/?provider=${cloudSelectService?.id}&product=coco&request_id=${requestID}`;
|
||||
|
||||
console.log("Open SSO link, requestID:", ssoRequestID, url);
|
||||
|
||||
@@ -50,20 +52,17 @@ const ServiceAuth = memo(
|
||||
|
||||
// Start loading state
|
||||
setLoading(true);
|
||||
}, [ssoRequestID, loading, currentService]);
|
||||
}, [ssoRequestID, loading, cloudSelectService]);
|
||||
|
||||
const onLogout = useCallback((id: string) => {
|
||||
setRefreshLoading(true);
|
||||
platformAdapter
|
||||
.commands("logout_coco_server", id)
|
||||
.then((res: any) => {
|
||||
console.log("logout_coco_server", id, JSON.stringify(res));
|
||||
handleLogout(id);
|
||||
})
|
||||
.finally(() => {
|
||||
const onLogout = useCallback(
|
||||
(id: string) => {
|
||||
setRefreshLoading(true);
|
||||
logoutServer(id).finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[logoutServer]
|
||||
);
|
||||
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (code: string | null, serverId: string | null) => {
|
||||
@@ -109,7 +108,7 @@ const ServiceAuth = memo(
|
||||
return;
|
||||
}
|
||||
|
||||
const serverId = currentService?.id;
|
||||
const serverId = cloudSelectService?.id;
|
||||
handleOAuthCallback(code, serverId);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse URL:", err);
|
||||
@@ -162,9 +161,9 @@ const ServiceAuth = memo(
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [currentService]);
|
||||
}, [cloudSelectService]);
|
||||
|
||||
if (!currentService?.auth_provider?.sso?.url) {
|
||||
if (!cloudSelectService?.auth_provider?.sso?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -173,10 +172,10 @@ const ServiceAuth = memo(
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
{t("cloud.accountInfo")}
|
||||
</h2>
|
||||
{currentService?.profile ? (
|
||||
{cloudSelectService?.profile ? (
|
||||
<UserProfile
|
||||
server={currentService?.id}
|
||||
userInfo={currentService?.profile}
|
||||
server={cloudSelectService?.id}
|
||||
userInfo={cloudSelectService?.profile}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
) : (
|
||||
@@ -190,7 +189,7 @@ const ServiceAuth = memo(
|
||||
onCancel={() => setLoading(false)}
|
||||
onCopy={() => {
|
||||
copyToClipboard(
|
||||
`${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${ssoRequestID}`
|
||||
`${cloudSelectService?.auth_provider?.sso?.url}/?provider=${cloudSelectService?.id}&product=coco&request_id=${ssoRequestID}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -201,7 +200,7 @@ const ServiceAuth = memo(
|
||||
<button
|
||||
className="text-xs text-[#0096FB] dark:text-blue-400 block"
|
||||
onClick={() =>
|
||||
OpenURLWithBrowser(currentService?.provider?.eula)
|
||||
OpenURLWithBrowser(cloudSelectService?.provider?.eula)
|
||||
}
|
||||
>
|
||||
{t("cloud.eula")}
|
||||
@@ -215,7 +214,7 @@ const ServiceAuth = memo(
|
||||
<button
|
||||
className="text-xs text-[#0096FB] dark:text-blue-400 block"
|
||||
onClick={() =>
|
||||
OpenURLWithBrowser(currentService?.provider?.privacy_policy)
|
||||
OpenURLWithBrowser(cloudSelectService?.provider?.privacy_policy)
|
||||
}
|
||||
>
|
||||
{t("cloud.privacyPolicy")}
|
||||
|
||||
@@ -6,13 +6,13 @@ import { useConnectStore } from "@/stores/connectStore";
|
||||
interface ServiceBannerProps {}
|
||||
|
||||
const ServiceBanner = memo(({}: ServiceBannerProps) => {
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-[4px] bg-[rgba(229,229,229,1)] dark:bg-gray-800 mb-6">
|
||||
<img
|
||||
width="100%"
|
||||
src={currentService?.provider?.banner || bannerImg}
|
||||
src={cloudSelectService?.provider?.banner || bannerImg}
|
||||
alt="banner"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { memo } from "react";
|
||||
import { Globe, RefreshCcw, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
@@ -7,90 +7,64 @@ import Tooltip from "@/components/Common/Tooltip";
|
||||
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
||||
import { OpenURLWithBrowser } from "@/utils";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
|
||||
interface ServiceHeaderProps {
|
||||
refreshLoading?: boolean;
|
||||
refreshClick: (id: string) => void;
|
||||
fetchServers: (force: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
const ServiceHeader = memo(
|
||||
({ refreshLoading, refreshClick, fetchServers }: ServiceHeaderProps) => {
|
||||
({ refreshLoading, refreshClick }: ServiceHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const setCurrentService = useConnectStore(
|
||||
(state) => state.setCurrentService
|
||||
);
|
||||
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
|
||||
|
||||
const enable_coco_server = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
await platformAdapter.commands("enable_server", currentService?.id);
|
||||
} else {
|
||||
await platformAdapter.commands("disable_server", currentService?.id);
|
||||
}
|
||||
|
||||
setCurrentService({ ...currentService, enabled });
|
||||
|
||||
await fetchServers(false);
|
||||
},
|
||||
[currentService?.id]
|
||||
);
|
||||
|
||||
const removeServer = (id: string) => {
|
||||
platformAdapter.commands("remove_coco_server", id).then((res: any) => {
|
||||
console.log("remove_coco_server", id, JSON.stringify(res));
|
||||
fetchServers(true).then((r) => {
|
||||
console.log("fetchServers", r);
|
||||
});
|
||||
});
|
||||
};
|
||||
const { enableServer, removeServer } = useServers();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Tooltip content={currentService?.endpoint}>
|
||||
<Tooltip content={cloudSelectService?.endpoint}>
|
||||
<div className="flex items-center text-gray-900 dark:text-white font-medium cursor-pointer">
|
||||
{currentService?.name}
|
||||
{cloudSelectService?.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsToggle
|
||||
checked={currentService?.enabled}
|
||||
checked={cloudSelectService?.enabled}
|
||||
className={clsx({
|
||||
"bg-red-600 focus:ring-red-500": !currentService?.enabled,
|
||||
"bg-red-600 focus:ring-red-500": !cloudSelectService?.enabled,
|
||||
})}
|
||||
label={
|
||||
currentService?.enabled
|
||||
cloudSelectService?.enabled
|
||||
? t("cloud.enable_server")
|
||||
: t("cloud.disable_server")
|
||||
}
|
||||
onChange={enable_coco_server}
|
||||
onChange={enableServer}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||
onClick={() =>
|
||||
OpenURLWithBrowser(currentService?.provider?.website)
|
||||
OpenURLWithBrowser(cloudSelectService?.provider?.website)
|
||||
}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||
onClick={() => refreshClick(currentService?.id)}
|
||||
onClick={() => refreshClick(cloudSelectService?.id)}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{!currentService?.builtin && (
|
||||
{!cloudSelectService?.builtin && (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||
onClick={() => removeServer(currentService?.id)}
|
||||
onClick={() => removeServer(cloudSelectService?.id)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
|
||||
</button>
|
||||
|
||||
@@ -7,11 +7,10 @@ import ServiceMetadata from "./ServiceMetadata";
|
||||
interface ServiceInfoProps {
|
||||
refreshLoading?: boolean;
|
||||
refreshClick: (id: string) => void;
|
||||
fetchServers: (force: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
const ServiceInfo = memo(
|
||||
({ refreshLoading, refreshClick, fetchServers }: ServiceInfoProps) => {
|
||||
({ refreshLoading, refreshClick }: ServiceInfoProps) => {
|
||||
return (
|
||||
<>
|
||||
<ServiceBanner />
|
||||
@@ -19,7 +18,6 @@ const ServiceInfo = memo(
|
||||
<ServiceHeader
|
||||
refreshLoading={refreshLoading}
|
||||
refreshClick={refreshClick}
|
||||
fetchServers={fetchServers}
|
||||
/>
|
||||
|
||||
<ServiceMetadata />
|
||||
|
||||
@@ -6,25 +6,25 @@ import { useConnectStore } from "@/stores/connectStore";
|
||||
interface ServiceMetadataProps {}
|
||||
|
||||
const ServiceMetadata = memo(({}: ServiceMetadataProps) => {
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2 flex">
|
||||
<span className="flex items-center gap-1">
|
||||
<PackageOpen className="w-4 h-4" /> {currentService?.provider?.name}
|
||||
<PackageOpen className="w-4 h-4" /> {cloudSelectService?.provider?.name}
|
||||
</span>
|
||||
<span className="mx-4">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="w-4 h-4" /> {currentService?.version?.number}
|
||||
<GitFork className="w-4 h-4" /> {cloudSelectService?.version?.number}
|
||||
</span>
|
||||
<span className="mx-4">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarSync className="w-4 h-4" /> {currentService?.updated}
|
||||
<CalendarSync className="w-4 h-4" /> {cloudSelectService?.updated}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{currentService?.provider?.description}
|
||||
{cloudSelectService?.provider?.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,13 +20,15 @@ interface ServerGroups {
|
||||
export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
||||
({ setIsConnect, serverList }, _ref) => {
|
||||
const { t } = useTranslation();
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const setCurrentService = useConnectStore(
|
||||
(state) => state.setCurrentService
|
||||
);
|
||||
const cloudSelectService = useConnectStore((state) => {
|
||||
return state.cloudSelectService;
|
||||
});
|
||||
const setCloudSelectService = useConnectStore((state) => {
|
||||
return state.setCloudSelectService;
|
||||
});
|
||||
|
||||
const selectService = (item: Server) => {
|
||||
setCurrentService(item);
|
||||
setCloudSelectService(item);
|
||||
setIsConnect(true);
|
||||
};
|
||||
|
||||
@@ -41,7 +43,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
||||
// Extracted server item rendering
|
||||
const renderServerItem = useCallback(
|
||||
(item: Server) => {
|
||||
const isSelected = currentService?.id === item.id;
|
||||
const isSelected = cloudSelectService?.id === item.id;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
@@ -72,7 +74,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[currentService]
|
||||
[cloudSelectService]
|
||||
);
|
||||
|
||||
const { builtinServers, customServers } = useMemo(() => {
|
||||
|
||||
@@ -12,7 +12,6 @@ interface UserProfileProps {
|
||||
export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
|
||||
const handleLogout = () => {
|
||||
onLogout(server);
|
||||
console.log("Logout", server);
|
||||
};
|
||||
|
||||
const [imageLoadError, setImageLoadError] = useState(false);
|
||||
|
||||
@@ -18,7 +18,9 @@ const FileIcon: FC<FileIconProps> = (props) => {
|
||||
.then(setIconName);
|
||||
});
|
||||
|
||||
return <FontIcon name={iconName} className={twMerge("size-8", className)} />;
|
||||
return (
|
||||
<FontIcon name={iconName} className={twMerge("min-w-8 h-8", className)} />
|
||||
);
|
||||
};
|
||||
|
||||
export default FileIcon;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import { FC } from "react";
|
||||
|
||||
interface FontIconProps {
|
||||
@@ -9,17 +7,11 @@ interface FontIconProps {
|
||||
}
|
||||
|
||||
const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => {
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
|
||||
if (isTauri) {
|
||||
return (
|
||||
<svg className={`icon ${className || ""}`} style={style} {...rest}>
|
||||
<use xlinkHref={`#${name}`} />
|
||||
</svg>
|
||||
);
|
||||
} else {
|
||||
return <img src={logoImg} className={className} alt={"coco"} />;
|
||||
}
|
||||
return (
|
||||
<svg className={`icon ${className || ""}`} style={style} {...rest}>
|
||||
<use xlinkHref={`#${name}`} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default FontIcon;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ArrowDown01, CornerDownLeft } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
@@ -19,6 +19,7 @@ import source_default_dark_img from "@/assets/images/source_default_dark.png";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import FontIcon from "../Icons/FontIcon";
|
||||
import { useTogglePin } from "@/hooks/useTogglePin";
|
||||
|
||||
interface FooterProps {
|
||||
setIsPinnedWeb?: (value: boolean) => void;
|
||||
@@ -37,32 +38,24 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
||||
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
|
||||
const { isTauri, isPinned, setIsPinned } = useAppStore();
|
||||
const { isTauri } = useAppStore();
|
||||
|
||||
const { setVisible, updateInfo } = useUpdateStore();
|
||||
const { isPinned, togglePin } = useTogglePin({
|
||||
onPinChange: setIsPinnedWeb,
|
||||
});
|
||||
|
||||
const { setVisible, updateInfo, skipVersions } = useUpdateStore();
|
||||
|
||||
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(() => {
|
||||
return platformAdapter.emitEvent("open_settings", "");
|
||||
}, []);
|
||||
|
||||
const hasUpdate = useMemo(() => {
|
||||
return updateInfo && !skipVersions.includes(updateInfo.version);
|
||||
}, [updateInfo, skipVersions]);
|
||||
|
||||
const renderLeft = () => {
|
||||
if (sourceData?.source?.name) {
|
||||
return (
|
||||
@@ -108,7 +101,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
||||
/>
|
||||
|
||||
<div className="relative text-xs text-gray-500 dark:text-gray-400">
|
||||
{updateInfo?.available ? (
|
||||
{hasUpdate ? (
|
||||
<div className="cursor-pointer" onClick={() => setVisible(true)}>
|
||||
<span>{t("search.footer.updateAvailable")}</span>
|
||||
<span className="absolute top-0 -right-2 size-1.5 bg-[#FF3434] rounded-full"></span>
|
||||
@@ -138,7 +131,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
||||
onClick={togglePin}
|
||||
className={clsx({
|
||||
"text-blue-500": isPinned,
|
||||
"pl-2": updateInfo?.available,
|
||||
"pl-2": hasUpdate,
|
||||
})}
|
||||
>
|
||||
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
||||
|
||||
@@ -78,6 +78,10 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
|
||||
return "→";
|
||||
}
|
||||
|
||||
if (shortcut === "enter") {
|
||||
return "↩︎";
|
||||
}
|
||||
|
||||
return shortcut;
|
||||
};
|
||||
|
||||
|
||||
@@ -135,12 +135,13 @@ export function useAssistantManager({
|
||||
}
|
||||
|
||||
if (key === "Enter" && !shiftKey) {
|
||||
if (!isEmpty(value)) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
if (isTauri && !isChatMode && goAskAi) {
|
||||
if (!isEmpty(value)) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
return handleAskAi();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,21 @@ import React from "react";
|
||||
import { Send } from "lucide-react";
|
||||
|
||||
import StopIcon from "@/icons/Stop";
|
||||
import clsx from "clsx";
|
||||
import { SendMessageParams } from "../Assistant/Chat";
|
||||
import { getUploadedAttachmentsId, isAttachmentsUploaded } from "@/utils";
|
||||
import VisibleKey from "../Common/VisibleKey";
|
||||
|
||||
interface ChatIconsProps {
|
||||
lineCount: number;
|
||||
isChatMode: boolean;
|
||||
curChatEnd: boolean;
|
||||
inputValue: string;
|
||||
onSend: (value: string) => void;
|
||||
onSend: (params: SendMessageParams) => void;
|
||||
disabledChange: () => void;
|
||||
}
|
||||
|
||||
const ChatIcons: React.FC<ChatIconsProps> = ({
|
||||
lineCount,
|
||||
isChatMode,
|
||||
curChatEnd,
|
||||
inputValue,
|
||||
@@ -21,50 +24,48 @@ const ChatIcons: React.FC<ChatIconsProps> = ({
|
||||
disabledChange,
|
||||
}) => {
|
||||
const renderSendButton = () => {
|
||||
if (!isChatMode) return null;
|
||||
if (!isChatMode) return;
|
||||
|
||||
if (curChatEnd) {
|
||||
return (
|
||||
<button
|
||||
className={`ml-1 p-1 ${
|
||||
inputValue ? "bg-[#0072FF]" : "bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]"
|
||||
} rounded-full transition-colors h-6`}
|
||||
className={clsx(
|
||||
"flex items-center justify-center rounded-full transition-colors min-w-6 h-6 bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]",
|
||||
{
|
||||
"!bg-[#0072FF]": inputValue || isAttachmentsUploaded(),
|
||||
}
|
||||
)}
|
||||
type="submit"
|
||||
onClick={() => onSend(inputValue.trim())}
|
||||
onClick={() => {
|
||||
onSend({
|
||||
message: inputValue.trim(),
|
||||
attachments: getUploadedAttachmentsId(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Send className="w-4 h-4 text-white" />
|
||||
<VisibleKey shortcut="enter">
|
||||
<Send className="size-[14px] text-white" />
|
||||
</VisibleKey>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!curChatEnd) {
|
||||
return (
|
||||
<button
|
||||
className={`ml-1 px-1 bg-[#0072FF] rounded-full transition-colors`}
|
||||
type="submit"
|
||||
onClick={() => disabledChange()}
|
||||
>
|
||||
<StopIcon
|
||||
size={16}
|
||||
className="w-4 h-4 text-white"
|
||||
aria-label="Stop message"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<button
|
||||
className={`ml-1 px-1 bg-[#0072FF] rounded-full transition-colors`}
|
||||
type="submit"
|
||||
onClick={() => disabledChange()}
|
||||
>
|
||||
<StopIcon
|
||||
size={16}
|
||||
className="w-4 h-4 text-white"
|
||||
aria-label="Stop message"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{lineCount === 1 ? (
|
||||
renderSendButton()
|
||||
) : (
|
||||
<div className="w-full flex justify-end mt-1">{renderSendButton()}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return renderSendButton();
|
||||
};
|
||||
|
||||
export default ChatIcons;
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import VisibleKey from "@/components/Common/VisibleKey";
|
||||
|
||||
interface ConnectionErrorProps {
|
||||
reconnect: () => void;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export default function ConnectionError({
|
||||
reconnect,
|
||||
connected,
|
||||
}: ConnectionErrorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [reconnectCountdown, setReconnectCountdown] = useState<number>(0);
|
||||
useEffect(() => {
|
||||
if (!reconnectCountdown || connected) {
|
||||
setReconnectCountdown(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnectCountdown > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setReconnectCountdown(reconnectCountdown - 1);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [reconnectCountdown, connected]);
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 right-0 bottom-0 left-0 px-2 py-4 bg-[rgba(238,238,238,0.98)] dark:bg-[rgba(32,33,38,0.9)] backdrop-blur-[2px] rounded-md font-normal text-xs text-gray-400 flex items-center gap-4 z-10">
|
||||
{t("search.input.connectionError")}
|
||||
<div
|
||||
className="px-1 h-[24px] text-[#0061FF] font-normal text-xs flex items-center justify-center cursor-pointer underline"
|
||||
onClick={() => {
|
||||
reconnect();
|
||||
setReconnectCountdown(10);
|
||||
}}
|
||||
>
|
||||
{reconnectCountdown > 0 ? (
|
||||
`${t("search.input.connecting")}(${reconnectCountdown}s)`
|
||||
) : (
|
||||
<VisibleKey
|
||||
shortcut="R"
|
||||
onKeyPress={() => {
|
||||
reconnect();
|
||||
setReconnectCountdown(10);
|
||||
}}
|
||||
>
|
||||
{t("search.input.reconnect")}
|
||||
</VisibleKey>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -124,7 +124,6 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
console.log("_docs", from, queryStrings, response);
|
||||
const list = response?.hits ?? [];
|
||||
const allTotal = response?.total_hits ?? 0;
|
||||
// set first select hover
|
||||
|
||||
@@ -18,11 +18,13 @@ import { useAssistantManager } from "./AssistantManager";
|
||||
import InputControls from "./InputControls";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import AudioRecording from "../AudioRecording";
|
||||
import { isDefaultServer } from "@/utils";
|
||||
import { getUploadedAttachmentsId, isDefaultServer } from "@/utils";
|
||||
import { useTauriFocus } from "@/hooks/useTauriFocus";
|
||||
import { SendMessageParams } from "../Assistant/Chat";
|
||||
import { isEmpty } from "lodash-es";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
onSend: (params: SendMessageParams) => void;
|
||||
disabled: boolean;
|
||||
disabledChange: () => void;
|
||||
changeMode?: (isChatMode: boolean) => void;
|
||||
@@ -84,18 +86,13 @@ export default function ChatInput({
|
||||
}: ChatInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||||
|
||||
const setBlurred = useAppStore((state) => state.setBlurred);
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const { currentAssistant } = useConnectStore();
|
||||
|
||||
const { sourceData, goAskAi } = useSearchStore();
|
||||
|
||||
const { modifierKey, returnToInput, setModifierKeyPressed } =
|
||||
useShortcutsStore();
|
||||
const language = useAppStore((state) => {
|
||||
return state.language;
|
||||
});
|
||||
const { isTauri, language, setBlurred } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -108,6 +105,7 @@ export default function ChatInput({
|
||||
const { curChatEnd } = useChatStore();
|
||||
const { setSearchValue, visibleExtensionStore, selectedExtension } =
|
||||
useSearchStore();
|
||||
const { uploadAttachments } = useChatStore();
|
||||
|
||||
useTauriFocus({
|
||||
onFocus() {
|
||||
@@ -122,12 +120,17 @@ export default function ChatInput({
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmedValue = inputValue.trim();
|
||||
|
||||
console.log("handleSubmit", trimmedValue, disabled);
|
||||
if (trimmedValue && !disabled) {
|
||||
|
||||
if ((trimmedValue || !isEmpty(uploadAttachments)) && !disabled) {
|
||||
changeInput("");
|
||||
onSend(trimmedValue);
|
||||
onSend({
|
||||
message: trimmedValue,
|
||||
attachments: getUploadedAttachmentsId(),
|
||||
});
|
||||
}
|
||||
}, [inputValue, disabled, onSend]);
|
||||
}, [inputValue, disabled, onSend, uploadAttachments]);
|
||||
|
||||
useKeyboardHandlers();
|
||||
|
||||
@@ -138,7 +141,7 @@ export default function ChatInput({
|
||||
changeInput(value);
|
||||
setSearchValue(value);
|
||||
if (!isChatMode) {
|
||||
onSend(value);
|
||||
onSend({ message: value });
|
||||
}
|
||||
},
|
||||
[changeInput, isChatMode, onSend]
|
||||
@@ -289,16 +292,6 @@ export default function ChatInput({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isChatMode && curChatEnd && (
|
||||
<div
|
||||
className={`absolute ${
|
||||
lineCount === 1 ? "-top-[5px]" : "top-[calc(100%-30px)]"
|
||||
} right-[12px]`}
|
||||
>
|
||||
<VisibleKey shortcut="↩︎" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@ import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import { parseSearchQuery, SearchQuery } from "@/utils";
|
||||
// import InputUpload from "./InputUpload";
|
||||
// import AiSummaryIcon from "@/components/Common/Icons/AiSummaryIcon";
|
||||
import InputUpload from "./InputUpload";
|
||||
|
||||
interface InputControlsProps {
|
||||
isChatMode: boolean;
|
||||
@@ -56,16 +55,16 @@ const InputControls = ({
|
||||
isChatPage,
|
||||
hasModules,
|
||||
changeMode,
|
||||
}: // checkScreenPermission,
|
||||
// requestScreenPermission,
|
||||
// getScreenMonitors,
|
||||
// getScreenWindows,
|
||||
// captureWindowScreenshot,
|
||||
// captureMonitorScreenshot,
|
||||
// openFileDialog,
|
||||
// getFileMetadata,
|
||||
// getFileIcon,
|
||||
InputControlsProps) => {
|
||||
checkScreenPermission,
|
||||
requestScreenPermission,
|
||||
getScreenMonitors,
|
||||
getScreenWindows,
|
||||
captureWindowScreenshot,
|
||||
captureMonitorScreenshot,
|
||||
openFileDialog,
|
||||
getFileMetadata,
|
||||
getFileIcon,
|
||||
}: InputControlsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
@@ -171,22 +170,24 @@ InputControlsProps) => {
|
||||
>
|
||||
{isChatMode ? (
|
||||
<div className="flex gap-2 text-[12px] leading-3 text-[#333] dark:text-[#d8d8d8]">
|
||||
{/* <InputUpload
|
||||
checkScreenPermission={checkScreenPermission}
|
||||
requestScreenPermission={requestScreenPermission}
|
||||
getScreenMonitors={getScreenMonitors}
|
||||
getScreenWindows={getScreenWindows}
|
||||
captureMonitorScreenshot={captureMonitorScreenshot}
|
||||
captureWindowScreenshot={captureWindowScreenshot}
|
||||
openFileDialog={openFileDialog}
|
||||
getFileMetadata={getFileMetadata}
|
||||
getFileIcon={getFileIcon}
|
||||
/> */}
|
||||
{source?.upload?.enabled && (
|
||||
<InputUpload
|
||||
checkScreenPermission={checkScreenPermission}
|
||||
requestScreenPermission={requestScreenPermission}
|
||||
getScreenMonitors={getScreenMonitors}
|
||||
getScreenWindows={getScreenWindows}
|
||||
captureMonitorScreenshot={captureMonitorScreenshot}
|
||||
captureWindowScreenshot={captureWindowScreenshot}
|
||||
openFileDialog={openFileDialog}
|
||||
getFileMetadata={getFileMetadata}
|
||||
getFileIcon={getFileIcon}
|
||||
/>
|
||||
)}
|
||||
|
||||
{source?.type === "deep_think" && source?.config?.visible && (
|
||||
<button
|
||||
className={clsx(
|
||||
"flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
|
||||
"flex items-center justify-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
|
||||
{
|
||||
"!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive,
|
||||
}
|
||||
@@ -231,7 +232,8 @@ InputControlsProps) => {
|
||||
getMCPByServer={getMCPByServer}
|
||||
/>
|
||||
|
||||
{!(source?.datasource?.enabled && source?.datasource?.visible) &&
|
||||
{!source?.upload?.enabled &&
|
||||
!(source?.datasource?.enabled && source?.datasource?.visible) &&
|
||||
(source?.type !== "deep_think" || !source?.config?.visible) &&
|
||||
!(source?.mcp_servers?.enabled && source?.mcp_servers?.visible) && (
|
||||
<div className="px-[9px]">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, Fragment, MouseEvent } from "react";
|
||||
import { FC, Fragment, MouseEvent, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight, Plus } from "lucide-react";
|
||||
import {
|
||||
@@ -12,13 +12,15 @@ import {
|
||||
} from "@headlessui/react";
|
||||
import { castArray, find, isNil } from "lodash-es";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useCreation, useKeyPress, useMount, useReactive } from "ahooks";
|
||||
import { useCreation, useMount, useReactive } from "ahooks";
|
||||
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import Tooltip from "@/components/Common/Tooltip";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import clsx from "clsx";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { filesize } from "@/utils";
|
||||
import VisibleKey from "../Common/VisibleKey";
|
||||
|
||||
interface State {
|
||||
screenRecordingPermission?: boolean;
|
||||
@@ -61,9 +63,26 @@ const InputUpload: FC<InputUploadProps> = (props) => {
|
||||
getFileMetadata,
|
||||
} = props;
|
||||
const { t, i18n } = useTranslation();
|
||||
const { uploadFiles, setUploadFiles } = useChatStore();
|
||||
const { uploadAttachments, setUploadAttachments } = useChatStore();
|
||||
const { withVisibility, addError } = useAppStore();
|
||||
const { modifierKey, addFile, modifierKeyPressed } = useShortcutsStore();
|
||||
const { addFile } = useShortcutsStore();
|
||||
const { currentAssistant } = useConnectStore();
|
||||
const uploadMaxSizeRef = useRef(1024 * 1024);
|
||||
const uploadMaxCountRef = useRef(6);
|
||||
const setVisibleStartPage = useConnectStore((state) => {
|
||||
return state.setVisibleStartPage;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentAssistant?._source?.upload) return;
|
||||
|
||||
const { max_file_size_in_bytes, max_file_count } =
|
||||
currentAssistant._source.upload;
|
||||
|
||||
uploadMaxSizeRef.current = max_file_size_in_bytes;
|
||||
|
||||
uploadMaxCountRef.current = max_file_count;
|
||||
}, [currentAssistant]);
|
||||
|
||||
const state = useReactive<State>({
|
||||
screenshotableMonitors: [],
|
||||
@@ -83,19 +102,25 @@ const InputUpload: FC<InputUploadProps> = (props) => {
|
||||
|
||||
if (isNil(selectedFiles)) return;
|
||||
|
||||
setVisibleStartPage(false);
|
||||
|
||||
handleUploadFiles(selectedFiles);
|
||||
};
|
||||
|
||||
const handleUploadFiles = async (paths: string | string[]) => {
|
||||
const files: typeof uploadFiles = [];
|
||||
const files: typeof uploadAttachments = [];
|
||||
|
||||
for await (const path of castArray(paths)) {
|
||||
if (find(uploadFiles, { path })) continue;
|
||||
if (find(uploadAttachments, { path })) continue;
|
||||
|
||||
const stat = await getFileMetadata(path);
|
||||
|
||||
if (stat.size / 1024 / 1024 > 100) {
|
||||
addError(t("search.input.uploadFileHints.maxSize"));
|
||||
if (stat.size > uploadMaxSizeRef.current) {
|
||||
addError(
|
||||
t("search.input.uploadFileHints.maxSize", {
|
||||
replace: [filesize(uploadMaxSizeRef.current)],
|
||||
})
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -107,7 +132,7 @@ const InputUpload: FC<InputUploadProps> = (props) => {
|
||||
});
|
||||
}
|
||||
|
||||
setUploadFiles([...uploadFiles, ...files]);
|
||||
setUploadAttachments([...uploadAttachments, ...files]);
|
||||
};
|
||||
|
||||
const menuItems = useCreation<MenuItem[]>(() => {
|
||||
@@ -172,28 +197,20 @@ const InputUpload: FC<InputUploadProps> = (props) => {
|
||||
i18n.language,
|
||||
]);
|
||||
|
||||
useKeyPress(`${modifierKey}.${addFile}`, handleSelectFile);
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<MenuButton className="flex p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
|
||||
<Tooltip content={t("search.input.uploadFileHints.tooltip")}>
|
||||
<Plus
|
||||
className={clsx("size-3 scale-[1.3]", {
|
||||
hidden: modifierKeyPressed,
|
||||
})}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]",
|
||||
{
|
||||
hidden: !modifierKeyPressed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{addFile}
|
||||
</div>
|
||||
<MenuButton className="flex items-center justify-center h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
|
||||
<Tooltip
|
||||
content={t("search.input.uploadFileHints.tooltip", {
|
||||
replace: [
|
||||
uploadMaxCountRef.current,
|
||||
filesize(uploadMaxSizeRef.current),
|
||||
],
|
||||
})}
|
||||
>
|
||||
<VisibleKey shortcut={addFile} onKeyPress={handleSelectFile}>
|
||||
<Plus className="size-3 scale-[1.3]" />
|
||||
</VisibleKey>
|
||||
</Tooltip>
|
||||
</MenuButton>
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function MCPPopover({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
|
||||
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
|
||||
{
|
||||
"!bg-[rgba(0,114,255,0.3)]": isMCPActive,
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export default function SearchPopover({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
|
||||
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
|
||||
{
|
||||
"!bg-[rgba(0,114,255,0.3)]": isSearchActive,
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ import { useMount } from "ahooks";
|
||||
|
||||
import Search from "@/components/Search/Search";
|
||||
import InputBox from "@/components/Search/InputBox";
|
||||
import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat";
|
||||
import ChatAI, {
|
||||
ChatAIRef,
|
||||
SendMessageParams,
|
||||
} from "@/components/Assistant/Chat";
|
||||
import { isLinux, isWin } from "@/utils/platform";
|
||||
import { appReducer, initialAppState } from "@/reducers/appReducer";
|
||||
import { useWindowEvents } from "@/hooks/useWindowEvents";
|
||||
@@ -25,6 +28,7 @@ import { useThemeStore } from "@/stores/themeStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useAppearanceStore } from "@/stores/appearanceStore";
|
||||
import type { StartPage } from "@/types/chat";
|
||||
import { hasUploadingAttachment } from "@/utils";
|
||||
|
||||
interface SearchChatProps {
|
||||
isTauri?: boolean;
|
||||
@@ -125,7 +129,9 @@ function SearchChat({
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
await initializeListeners_auth();
|
||||
await platformAdapter.commands("get_app_search_source");
|
||||
if (isTauri) {
|
||||
await platformAdapter.commands("get_app_search_source");
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
@@ -146,10 +152,12 @@ function SearchChat({
|
||||
}, []);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (value: string) => {
|
||||
dispatch({ type: "SET_INPUT", payload: value });
|
||||
async (params: SendMessageParams) => {
|
||||
if (hasUploadingAttachment()) return;
|
||||
|
||||
dispatch({ type: "SET_INPUT", payload: params?.message ?? "" });
|
||||
if (isChatMode) {
|
||||
chatAIRef.current?.init(value);
|
||||
chatAIRef.current?.init(params);
|
||||
}
|
||||
},
|
||||
[isChatMode]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Command, RotateCcw } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { formatKey } from "@/utils/keyboardUtils";
|
||||
import SettingsItem from "@/components/Settings/SettingsItem";
|
||||
@@ -9,7 +11,7 @@ import {
|
||||
INITIAL_MODE_SWITCH,
|
||||
INITIAL_RETURN_TO_INPUT,
|
||||
// INITIAL_VOICE_INPUT,
|
||||
// INITIAL_ADD_FILE,
|
||||
INITIAL_ADD_FILE,
|
||||
INITIAL_DEEP_THINKING,
|
||||
INITIAL_INTERNET_SEARCH,
|
||||
INITIAL_INTERNET_SEARCH_SCOPE,
|
||||
@@ -28,8 +30,6 @@ import { ModifierKey } from "@/types/index";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import SettingsInput from "@/components/Settings/SettingsInput";
|
||||
import { Button } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const modifierKeys: ModifierKey[] = isMac
|
||||
? ["meta", "ctrl"]
|
||||
@@ -46,8 +46,8 @@ const Shortcuts = () => {
|
||||
setReturnToInput,
|
||||
// voiceInput,
|
||||
// setVoiceInput,
|
||||
// addFile,
|
||||
// setAddFile,
|
||||
addFile,
|
||||
setAddFile,
|
||||
deepThinking,
|
||||
setDeepThinking,
|
||||
internetSearch,
|
||||
@@ -66,8 +66,8 @@ const Shortcuts = () => {
|
||||
setNewSession,
|
||||
fixedWindow,
|
||||
setFixedWindow,
|
||||
serviceList,
|
||||
setServiceList,
|
||||
serviceListShortcut,
|
||||
setServiceListShortcut,
|
||||
external,
|
||||
setExternal,
|
||||
aiOverview,
|
||||
@@ -106,15 +106,13 @@ const Shortcuts = () => {
|
||||
// value: voiceInput,
|
||||
// setValue: setVoiceInput,
|
||||
// },
|
||||
// {
|
||||
// title: "settings.advanced.shortcuts.addFile.title",
|
||||
// description: "settings.advanced.shortcuts.addFile.description",
|
||||
// value: addFile,
|
||||
// setValue: setAddFile,
|
||||
// reset: () => {
|
||||
// handleChange(INITIAL_ADD_FILE, setAddFile);
|
||||
// },
|
||||
// },
|
||||
{
|
||||
title: "settings.advanced.shortcuts.addFile.title",
|
||||
description: "settings.advanced.shortcuts.addFile.description",
|
||||
initialValue: INITIAL_ADD_FILE,
|
||||
value: addFile,
|
||||
setValue: setAddFile,
|
||||
},
|
||||
{
|
||||
title: "settings.advanced.shortcuts.deepThinking.title",
|
||||
description: "settings.advanced.shortcuts.deepThinking.description",
|
||||
@@ -183,8 +181,8 @@ const Shortcuts = () => {
|
||||
title: "settings.advanced.shortcuts.serviceList.title",
|
||||
description: "settings.advanced.shortcuts.serviceList.description",
|
||||
initialValue: INITIAL_SERVICE_LIST,
|
||||
value: serviceList,
|
||||
setValue: setServiceList,
|
||||
value: serviceListShortcut,
|
||||
setValue: setServiceListShortcut,
|
||||
},
|
||||
{
|
||||
title: "settings.advanced.shortcuts.external.title",
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import SettingsItem from "./SettingsItem";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { AppEndpoint } from "@/types/index";
|
||||
|
||||
const ENDPOINTS = [
|
||||
{ value: "https://coco.infini.cloud", label: "https://coco.infini.cloud" },
|
||||
{ value: "http://localhost:9000", label: "http://localhost:9000" },
|
||||
{ value: "http://infini.tpddns.cn:27200", label: "http://infini.tpddns.cn:27200" },
|
||||
];
|
||||
|
||||
export default function AdvancedSettings() {
|
||||
const { t } = useTranslation();
|
||||
const endpoint = useAppStore(state => state.endpoint);
|
||||
const setEndpoint = useAppStore(state => state.setEndpoint);
|
||||
|
||||
useEffect(() => {}, [endpoint]);
|
||||
|
||||
const onChangeEndpoint = async (newEndpoint: AppEndpoint) => {
|
||||
await setEndpoint(newEndpoint);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('settings.advanced.title')}
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
<SettingsItem
|
||||
icon={Globe}
|
||||
title={t('settings.advanced.endpoint.title')}
|
||||
description={t('settings.advanced.endpoint.description')}
|
||||
>
|
||||
<div className={`p-4 rounded-lg`}>
|
||||
<select
|
||||
value={endpoint}
|
||||
onChange={(e) => onChangeEndpoint(e.target.value as AppEndpoint)}
|
||||
className={`w-full px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white border-gray-300 text-gray-900 dark:bg-gray-800 dark:border-gray-600 dark:text-white`}
|
||||
>
|
||||
{ENDPOINTS.map(({ value, label }) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useContext, useMemo, useState } from "react";
|
||||
import { filesize } from "filesize";
|
||||
import dayjs from "dayjs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { ExtensionsContext } from "../../../index";
|
||||
import { filesize } from "@/utils";
|
||||
|
||||
interface Metadata {
|
||||
name: string;
|
||||
@@ -57,7 +58,7 @@ const App = () => {
|
||||
},
|
||||
{
|
||||
label: t("settings.extensions.application.details.size"),
|
||||
value: filesize(size, { standard: "jedec", spacer: "" }),
|
||||
value: filesize(size),
|
||||
},
|
||||
{
|
||||
label: t("settings.extensions.application.details.created"),
|
||||
|
||||
@@ -1,137 +1,164 @@
|
||||
import { FC, useMemo, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isArray } from "lodash-es";
|
||||
import { useAsyncEffect, useMount } from "ahooks";
|
||||
|
||||
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
|
||||
import SettingsSelectPro from "@/components/Settings/SettingsSelectPro";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useAsyncEffect, useMount } from "ahooks";
|
||||
import { FC, useMemo, useState } from "react";
|
||||
import { ExtensionId } from "../../..";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isArray } from "lodash-es";
|
||||
import { ExtensionId } from "@/components/Settings/Extensions/index";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import type { Server } from "@/types/server";
|
||||
|
||||
interface Assistant {
|
||||
id: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SharedAiProps {
|
||||
id: ExtensionId;
|
||||
server?: any;
|
||||
setServer: (server: any) => void;
|
||||
assistant?: any;
|
||||
setAssistant: (assistant: any) => void;
|
||||
server?: Server;
|
||||
setServer: (server: Server | undefined) => void;
|
||||
assistant?: Assistant;
|
||||
setAssistant: (assistant: Assistant | undefined) => void;
|
||||
}
|
||||
|
||||
const SharedAi: FC<SharedAiProps> = (props) => {
|
||||
const { id, server, setServer, assistant, setAssistant } = props;
|
||||
|
||||
const [serverList, setServerList] = useState<any[]>([server]);
|
||||
const [assistantList, setAssistantList] = useState<any[]>([assistant]);
|
||||
const serverList = useConnectStore((state) => state.serverList);
|
||||
|
||||
const [list, setList] = useState<Server[]>([]);
|
||||
const [assistantList, setAssistantList] = useState<Assistant[]>([]);
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
const { fetchAssistant } = AssistantFetcher({});
|
||||
const { t } = useTranslation();
|
||||
const [assistantSearchValue, setAssistantSearchValue] = useState("");
|
||||
const [isLoadingAssistants, setIsLoadingAssistants] = useState(false);
|
||||
|
||||
const getEnabledServers = useCallback((servers: Server[]): Server[] => {
|
||||
if (!isArray(servers)) return [];
|
||||
return servers.filter(
|
||||
(s) => s.enabled && s.available && (s.public || s.profile)
|
||||
);
|
||||
}, []);
|
||||
|
||||
useMount(async () => {
|
||||
try {
|
||||
const data = await platformAdapter.invokeBackend<any[]>(
|
||||
"list_coco_servers"
|
||||
);
|
||||
const enabledServers = getEnabledServers(serverList);
|
||||
setList(enabledServers);
|
||||
|
||||
if (isArray(data)) {
|
||||
const enabledServers = data.filter(
|
||||
(s) => s.enabled && s.available && (s.public || s.profile)
|
||||
);
|
||||
|
||||
setServerList(enabledServers);
|
||||
|
||||
if (server) {
|
||||
const matchServer = enabledServers.find((item) => {
|
||||
return item.id === server.id;
|
||||
});
|
||||
|
||||
if (matchServer) {
|
||||
return setServer(matchServer);
|
||||
}
|
||||
}
|
||||
|
||||
setServer(enabledServers[0]);
|
||||
if (enabledServers.length === 0) {
|
||||
setServer(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (server) {
|
||||
const matchServer = enabledServers.find((item) => item.id === server.id);
|
||||
if (matchServer) {
|
||||
setServer(matchServer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setServer(enabledServers[0]);
|
||||
} catch (error) {
|
||||
addError(String(error));
|
||||
console.error('Failed to load servers:', error);
|
||||
addError(`Failed to load servers: ${String(error)}`);
|
||||
}
|
||||
});
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
try {
|
||||
if (!server) return;
|
||||
if (!server) {
|
||||
setAssistantList([]);
|
||||
setAssistant(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingAssistants(true);
|
||||
try {
|
||||
const data = await fetchAssistant({
|
||||
current: 1,
|
||||
pageSize: 1000,
|
||||
pageSize: 100,
|
||||
serverId: server.id,
|
||||
query: assistantSearchValue,
|
||||
});
|
||||
|
||||
const list = data.list.map((item: any) => item._source);
|
||||
const assistants: Assistant[] = data.list.map((item: any) => item._source);
|
||||
setAssistantList(assistants);
|
||||
|
||||
setAssistantList(list);
|
||||
if (assistants.length === 0) {
|
||||
setAssistant(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (assistant) {
|
||||
const matched = list.find((item: any) => {
|
||||
return item.id === assistant.id;
|
||||
});
|
||||
|
||||
const matched = assistants.find((item) => item.id === assistant.id);
|
||||
if (matched) {
|
||||
return setAssistant(matched);
|
||||
setAssistant(matched);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setAssistant(list[0]);
|
||||
setAssistant(assistants[0]);
|
||||
} catch (error) {
|
||||
addError(String(error));
|
||||
console.error('Failed to fetch assistants:', error);
|
||||
addError(`Failed to fetch assistants: ${String(error)}`);
|
||||
setAssistantList([]);
|
||||
setAssistant(undefined);
|
||||
} finally {
|
||||
setIsLoadingAssistants(false);
|
||||
}
|
||||
}, [server, assistantSearchValue]);
|
||||
}, [server?.id, assistantSearchValue]);
|
||||
|
||||
const selectList = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t(
|
||||
"settings.extensions.shardAi.details.linkedAssistant.label.cocoServer"
|
||||
),
|
||||
value: server?.id,
|
||||
icon: server?.provider?.icon,
|
||||
data: serverList,
|
||||
searchable: false,
|
||||
onChange: (value: string) => {
|
||||
const matched = serverList.find((item) => item.id === value);
|
||||
|
||||
setServer(matched);
|
||||
},
|
||||
const serverSelectConfig = {
|
||||
label: t(
|
||||
"settings.extensions.shardAi.details.linkedAssistant.label.cocoServer"
|
||||
),
|
||||
value: server?.id,
|
||||
icon: server?.provider?.icon,
|
||||
data: list,
|
||||
searchable: false,
|
||||
onChange: (value: string) => {
|
||||
const matched = list.find((item) => item.id === value);
|
||||
setServer(matched);
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
"settings.extensions.shardAi.details.linkedAssistant.label.aiAssistant"
|
||||
),
|
||||
value: assistant?.id,
|
||||
icon: assistant?.icon,
|
||||
data: assistantList,
|
||||
searchable: true,
|
||||
onChange: (value: string) => {
|
||||
const matched = assistantList.find((item) => item.id === value);
|
||||
onSearch: undefined,
|
||||
};
|
||||
|
||||
setAssistant(matched);
|
||||
},
|
||||
onSearch: (value: string) => {
|
||||
setAssistantSearchValue(value);
|
||||
},
|
||||
const assistantSelectConfig = {
|
||||
label: t(
|
||||
"settings.extensions.shardAi.details.linkedAssistant.label.aiAssistant"
|
||||
),
|
||||
value: assistant?.id,
|
||||
icon: assistant?.icon,
|
||||
data: assistantList,
|
||||
searchable: true,
|
||||
onChange: (value: string) => {
|
||||
const matched = assistantList.find((item) => item.id === value);
|
||||
setAssistant(matched);
|
||||
},
|
||||
];
|
||||
}, [serverList, assistantList, server, assistant]);
|
||||
onSearch: (value: string) => {
|
||||
setAssistantSearchValue(value);
|
||||
},
|
||||
};
|
||||
|
||||
const renderDescription = () => {
|
||||
if (id === "QuickAIAccess") {
|
||||
return t("settings.extensions.quickAiAccess.description");
|
||||
}
|
||||
return [serverSelectConfig, assistantSelectConfig];
|
||||
}, [list, assistantList, server?.id, assistant?.id, t]);
|
||||
|
||||
if (id === "AIOverview") {
|
||||
return t("settings.extensions.aiOverview.description");
|
||||
const renderDescription = useCallback(() => {
|
||||
switch (id) {
|
||||
case "QuickAIAccess":
|
||||
return t("settings.extensions.quickAiAccess.description");
|
||||
case "AIOverview":
|
||||
return t("settings.extensions.aiOverview.description");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}, [id, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -154,6 +181,7 @@ const SharedAi: FC<SharedAiProps> = (props) => {
|
||||
searchable={searchable}
|
||||
onChange={onChange}
|
||||
onSearch={onSearch}
|
||||
placeholder={isLoadingAssistants && searchable ? "Loading..." : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,13 +5,14 @@ import type { LiteralUnion } from "type-fest";
|
||||
import { cloneDeep, sortBy } from "lodash-es";
|
||||
import clsx from "clsx";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import Content from "./components/Content";
|
||||
import Details from "./components/Details";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import SettingsInput from "../SettingsInput";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
export type ExtensionId = LiteralUnion<
|
||||
| "Applications"
|
||||
@@ -90,6 +91,7 @@ export const Extensions = () => {
|
||||
const { t } = useTranslation();
|
||||
const state = useReactive<State>(cloneDeep(INITIAL_STATE));
|
||||
const { configId, setConfigId } = useExtensionsStore();
|
||||
const { addError } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
getExtensions();
|
||||
@@ -106,7 +108,7 @@ export const Extensions = () => {
|
||||
});
|
||||
|
||||
const getExtensions = async () => {
|
||||
const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>(
|
||||
const extensions = await platformAdapter.invokeBackend<Extension[]>(
|
||||
"list_extensions",
|
||||
{
|
||||
query: state.searchValue,
|
||||
@@ -115,8 +117,6 @@ export const Extensions = () => {
|
||||
}
|
||||
);
|
||||
|
||||
const extensions = result[1];
|
||||
|
||||
state.extensions = sortBy(extensions, ["name"]);
|
||||
|
||||
if (configId) {
|
||||
@@ -160,14 +160,65 @@ export const Extensions = () => {
|
||||
{t("settings.extensions.title")}
|
||||
</h2>
|
||||
|
||||
<Button
|
||||
className="flex items-center justify-center size-6 border rounded-md dark:border-gray-700 hover:!border-[#0096FB] transition"
|
||||
onClick={() => {
|
||||
platformAdapter.emitEvent("open-extension-store");
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 text-[#0096FB]" />
|
||||
</Button>
|
||||
<Menu>
|
||||
<MenuButton className="flex items-center justify-center size-6 border rounded-md dark:border-gray-700 hover:!border-[#0096FB] transition">
|
||||
<Plus className="size-4 text-[#0096FB]" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuItems
|
||||
anchor={{ gap: 4 }}
|
||||
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<MenuItem>
|
||||
<div
|
||||
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
|
||||
onClick={() => {
|
||||
platformAdapter.emitEvent("open-extension-store");
|
||||
}}
|
||||
>
|
||||
{t("settings.extensions.menuItem.extensionStore")}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div
|
||||
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const path = await platformAdapter.openFileDialog({
|
||||
directory: true,
|
||||
});
|
||||
|
||||
if (!path) return;
|
||||
|
||||
await platformAdapter.invokeBackend(
|
||||
"install_local_extension",
|
||||
{ path }
|
||||
);
|
||||
|
||||
await getExtensions();
|
||||
|
||||
addError(
|
||||
t("settings.extensions.hints.importSuccess"),
|
||||
"info"
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = String(error);
|
||||
|
||||
if (errorMessage === "already imported") {
|
||||
addError(t("settings.extensions.hints.extensionAlreadyImported"));
|
||||
} else if (errorMessage === "incompatible") {
|
||||
addError(t("settings.extensions.hints.incompatibleExtension"));
|
||||
} else {
|
||||
addError(t("settings.extensions.hints.importFailed"));
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("settings.extensions.menuItem.localExtensionImport")}
|
||||
</div>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-6 my-4">
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Select } from '@headlessui/react'
|
||||
|
||||
interface SettingsSelectProps {
|
||||
options: string[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function SettingsSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: SettingsSelectProps) {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
className="rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option.toLowerCase()}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -32,8 +32,8 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
||||
const {
|
||||
visible,
|
||||
setVisible,
|
||||
skipVersion,
|
||||
setSkipVersion,
|
||||
skipVersions,
|
||||
setSkipVersions,
|
||||
isOptional,
|
||||
updateInfo,
|
||||
setUpdateInfo,
|
||||
@@ -50,11 +50,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshotUpdate) return;
|
||||
|
||||
checkUpdate().catch((error) => {
|
||||
addError("Update failed:" + error, "error");
|
||||
});
|
||||
checkUpdateStatus();
|
||||
}, [snapshotUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,13 +75,13 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
||||
const update = await checkUpdate();
|
||||
|
||||
if (update) {
|
||||
const { skipVersions } = useUpdateStore.getState();
|
||||
|
||||
setVisible(!skipVersions.includes(update.version));
|
||||
|
||||
setUpdateInfo(update);
|
||||
|
||||
if (skipVersion === update.version) return;
|
||||
|
||||
setVisible(true);
|
||||
}
|
||||
}, [skipVersion]);
|
||||
}, [skipVersions]);
|
||||
|
||||
const cursorClassName = useMemo(() => {
|
||||
return state.loading ? "cursor-not-allowed" : "cursor-pointer";
|
||||
@@ -133,7 +129,9 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
||||
const handleSkip = () => {
|
||||
if (state.loading) return;
|
||||
|
||||
setSkipVersion(updateInfo?.version);
|
||||
const { skipVersions, updateInfo } = useUpdateStore.getState();
|
||||
|
||||
setSkipVersions([...skipVersions, updateInfo.version]);
|
||||
|
||||
isCheckPage ? hide_check() : setVisible(false);
|
||||
};
|
||||
@@ -182,7 +180,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
||||
<img src={isDark ? darkIcon : lightIcon} className="h-6" />
|
||||
|
||||
<div className="text-[#333] text-sm leading-5 py-2 dark:text-[#D8D8D8] text-center">
|
||||
{updateInfo?.available ? (
|
||||
{updateInfo ? (
|
||||
isOptional ? (
|
||||
t("update.optional_description")
|
||||
) : (
|
||||
@@ -196,7 +194,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{updateInfo?.available ? (
|
||||
{updateInfo ? (
|
||||
<div
|
||||
className="text-xs text-[#0072FF] cursor-pointer"
|
||||
onClick={() =>
|
||||
@@ -223,21 +221,21 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
||||
cursorClassName,
|
||||
state.loading && "opacity-50"
|
||||
)}
|
||||
onClick={updateInfo?.available ? handleDownload : handleSkip}
|
||||
onClick={updateInfo ? handleDownload : handleSkip}
|
||||
>
|
||||
{state.loading ? (
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<LoaderCircle className="animate-spin size-5" />
|
||||
{percent}%
|
||||
</div>
|
||||
) : updateInfo?.available ? (
|
||||
) : updateInfo ? (
|
||||
t("update.button.install")
|
||||
) : (
|
||||
t("update.button.ok")
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{updateInfo?.available && isOptional && (
|
||||
{!isCheckPage && updateInfo && isOptional && (
|
||||
<div
|
||||
className={clsx("text-xs text-[#999]", cursorClassName)}
|
||||
onClick={handleSkip}
|
||||
|
||||
@@ -5,3 +5,7 @@ export const HISTORY_PANEL_ID = "headlessui-popover-panel:history-panel";
|
||||
export const CONTEXT_MENU_PANEL_ID = "headlessui-popover-panel:context-menu";
|
||||
|
||||
export const DEFAULT_COCO_SERVER_ID = "default_coco_server";
|
||||
|
||||
export const MAIN_WINDOW_LABEL = "main";
|
||||
|
||||
export const SETTINGS_WINDOW_LABEL = "settings";
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { LogicalSize } from "@tauri-apps/api/dpi";
|
||||
|
||||
// Custom hook to auto-resize window based on content height
|
||||
const useAutoResizeWindow = () => {
|
||||
// Function to resize the window to the content's size
|
||||
const resizeWindowToContent = async () => {
|
||||
const contentHeight = document.getElementById("main_window")?.scrollHeight || 0;
|
||||
|
||||
try {
|
||||
// Resize the window to fit content size
|
||||
await getCurrentWebviewWindow()?.setSize(
|
||||
new LogicalSize(680, contentHeight)
|
||||
);
|
||||
|
||||
console.log("Window resized to content size");
|
||||
} catch (error) {
|
||||
console.error("Error resizing window:", error);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Initially resize the window
|
||||
resizeWindowToContent();
|
||||
|
||||
// Set up a ResizeObserver to listen for changes in content size
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
resizeWindowToContent();
|
||||
});
|
||||
|
||||
// Observe the document body for content size changes
|
||||
resizeObserver.observe(document.body);
|
||||
|
||||
// Clean up the observer when the component is unmounted
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []); // Only run once when the component is mounted
|
||||
|
||||
// Optionally, you can return values if you need to handle window size elsewhere
|
||||
};
|
||||
|
||||
export default useAutoResizeWindow;
|
||||
@@ -9,6 +9,9 @@ import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { unrequitable } from "@/utils";
|
||||
import { streamPost } from "@/api/streamFetch";
|
||||
import { SendMessageParams } from "@/components/Assistant/Chat";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
|
||||
export function useChatActions(
|
||||
setActiveChat: (chat: Chat | undefined) => void,
|
||||
@@ -40,6 +43,9 @@ export function useChatActions(
|
||||
} = useConnectStore();
|
||||
const sourceDataIds = useSearchStore((state) => state.sourceDataIds);
|
||||
const MCPIds = useSearchStore((state) => state.MCPIds);
|
||||
const setUploadAttachments = useChatStore((state) => {
|
||||
return state.setUploadAttachments;
|
||||
});
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
@@ -76,7 +82,6 @@ export function useChatActions(
|
||||
const [_error, res] = await Post(`/chat/${activeChat?._id}/_close`, {});
|
||||
response = res;
|
||||
}
|
||||
console.log("_close", response);
|
||||
},
|
||||
[currentService?.id, isTauri]
|
||||
);
|
||||
@@ -124,7 +129,6 @@ export function useChatActions(
|
||||
);
|
||||
response = res;
|
||||
}
|
||||
console.log("_cancel", response);
|
||||
},
|
||||
[currentService?.id, isTauri]
|
||||
);
|
||||
@@ -170,7 +174,6 @@ export function useChatActions(
|
||||
...chat,
|
||||
messages: hits,
|
||||
};
|
||||
console.log("id_history", updatedChat);
|
||||
updatedChatRef.current = updatedChat;
|
||||
setActiveChat(updatedChat);
|
||||
callback && callback(updatedChat);
|
||||
@@ -254,20 +257,6 @@ export function useChatActions(
|
||||
`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);
|
||||
}
|
||||
);
|
||||
@@ -289,10 +278,12 @@ export function useChatActions(
|
||||
);
|
||||
|
||||
const prepareChatSession = useCallback(
|
||||
async (value: string, timestamp: number) => {
|
||||
async (timestamp: number, value: string) => {
|
||||
// 1. Cleaning and preparation
|
||||
await clearAllChunkData();
|
||||
|
||||
setUploadAttachments([]);
|
||||
|
||||
// 2. Update the status again
|
||||
await new Promise<void>((resolve) => {
|
||||
changeInput && changeInput("");
|
||||
@@ -310,12 +301,17 @@ export function useChatActions(
|
||||
);
|
||||
|
||||
const createNewChat = useCallback(
|
||||
async (value: string = "") => {
|
||||
if (!value) return;
|
||||
async (params?: SendMessageParams) => {
|
||||
const { message, attachments } = params || {};
|
||||
|
||||
console.log("message", message);
|
||||
console.log("attachments", attachments);
|
||||
|
||||
if (!message && isEmpty(attachments)) return;
|
||||
|
||||
const timestamp = Date.now();
|
||||
|
||||
await prepareChatSession(value, timestamp);
|
||||
await prepareChatSession(timestamp, message ?? "");
|
||||
|
||||
const queryParams = {
|
||||
search: isSearchActive,
|
||||
@@ -328,19 +324,22 @@ export function useChatActions(
|
||||
|
||||
if (isTauri) {
|
||||
if (!currentService?.id) return;
|
||||
|
||||
console.log("chat_create", clientId, timestamp);
|
||||
|
||||
await platformAdapter.commands("chat_create", {
|
||||
serverId: currentService?.id,
|
||||
message: value,
|
||||
message,
|
||||
attachments,
|
||||
queryParams,
|
||||
clientId: `chat-stream-${clientId}-${timestamp}`,
|
||||
});
|
||||
console.log("_create end", value);
|
||||
console.log("_create end", message);
|
||||
resetChatState();
|
||||
} else {
|
||||
await streamPost({
|
||||
url: "/chat/_create",
|
||||
body: { message: value },
|
||||
body: { message },
|
||||
queryParams,
|
||||
onMessage: (line) => {
|
||||
console.log("⏳", line);
|
||||
@@ -365,12 +364,16 @@ export function useChatActions(
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, newChat: Chat) => {
|
||||
if (!newChat?._id || !content) return;
|
||||
async (newChat: Chat, params?: SendMessageParams) => {
|
||||
if (!newChat?._id || !params) return;
|
||||
|
||||
const { message, attachments } = params;
|
||||
|
||||
if (!message && isEmpty(attachments)) return;
|
||||
|
||||
const timestamp = Date.now();
|
||||
|
||||
await prepareChatSession(content, timestamp);
|
||||
await prepareChatSession(timestamp, message ?? "");
|
||||
|
||||
const queryParams = {
|
||||
search: isSearchActive,
|
||||
@@ -388,15 +391,16 @@ export function useChatActions(
|
||||
serverId: currentService?.id,
|
||||
sessionId: newChat?._id,
|
||||
queryParams,
|
||||
message: content,
|
||||
message,
|
||||
attachments,
|
||||
clientId: `chat-stream-${clientId}-${timestamp}`,
|
||||
});
|
||||
console.log("chat_chat end", content, clientId);
|
||||
console.log("chat_chat end", message, clientId);
|
||||
resetChatState();
|
||||
} else {
|
||||
await streamPost({
|
||||
url: `/chat/${newChat?._id}/_chat`,
|
||||
body: { message: content },
|
||||
body: { message },
|
||||
queryParams,
|
||||
onMessage: (line) => {
|
||||
console.log("line", line);
|
||||
@@ -421,10 +425,14 @@ export function useChatActions(
|
||||
);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (content: string, activeChat?: Chat) => {
|
||||
if (!activeChat?._id || !content) return;
|
||||
async (activeChat?: Chat, params?: SendMessageParams) => {
|
||||
if (!activeChat?._id) return;
|
||||
|
||||
await chatHistory(activeChat, (chat) => sendMessage(content, chat));
|
||||
const { message, attachments } = params ?? {};
|
||||
|
||||
if (!message && isEmpty(attachments)) return;
|
||||
|
||||
await chatHistory(activeChat, (chat) => sendMessage(chat, params));
|
||||
},
|
||||
[chatHistory, sendMessage]
|
||||
);
|
||||
@@ -455,7 +463,6 @@ export function useChatActions(
|
||||
response = res;
|
||||
}
|
||||
|
||||
console.log("_open", response);
|
||||
return response;
|
||||
},
|
||||
[currentService?.id, isTauri]
|
||||
@@ -484,7 +491,6 @@ export function useChatActions(
|
||||
response = res;
|
||||
}
|
||||
|
||||
console.log("_history", response);
|
||||
const hits = response?.hits?.hits || [];
|
||||
setChats(hits);
|
||||
}, [
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useEffect, RefObject } from 'react';
|
||||
|
||||
export function useClickAway(
|
||||
ref: RefObject<HTMLElement>,
|
||||
handler: (event: MouseEvent | TouchEvent) => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
const listener = (event: MouseEvent | TouchEvent) => {
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
handler(event);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', listener);
|
||||
document.addEventListener('touchstart', listener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', listener);
|
||||
document.removeEventListener('touchstart', listener);
|
||||
};
|
||||
}, [ref, handler]);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
interface UseFeatureControlProps {
|
||||
initialFeatures: string[];
|
||||
featureToToggle: string;
|
||||
condition: (assistant: any) => boolean;
|
||||
}
|
||||
|
||||
export const useFeatureControl = ({
|
||||
initialFeatures,
|
||||
featureToToggle,
|
||||
condition,
|
||||
}: UseFeatureControlProps) => {
|
||||
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||||
const [features, setFeatures] = useState<string[]>(initialFeatures);
|
||||
|
||||
useEffect(() => {
|
||||
if (condition(currentAssistant)) {
|
||||
setFeatures((prev) => prev.filter((feature) => feature !== featureToToggle));
|
||||
} else {
|
||||
setFeatures((prev) => {
|
||||
if (!prev.includes(featureToToggle)) {
|
||||
return [...prev, featureToToggle];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [JSON.stringify(currentAssistant), featureToToggle]);
|
||||
|
||||
return features;
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const useInfiniteScroll = (callback: () => void) => {
|
||||
const loaderRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
{ threshold: 1.0 }
|
||||
);
|
||||
|
||||
if (loaderRef.current) {
|
||||
observer.observe(loaderRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (loaderRef.current) {
|
||||
observer.unobserve(loaderRef.current);
|
||||
}
|
||||
};
|
||||
}, [callback]);
|
||||
|
||||
return loaderRef;
|
||||
};
|
||||
|
||||
export default useInfiniteScroll;
|
||||
@@ -26,15 +26,9 @@ const useScript = (src: string, onError?: () => void) => {
|
||||
|
||||
export default useScript;
|
||||
|
||||
export const useIconfontScript = (type: "web" | "app", serverUrl?: string) => {
|
||||
if (type === "web") {
|
||||
useScript(`${serverUrl}/assets/fonts/icons/iconfont.js`);
|
||||
useScript(`${serverUrl}/assets/fonts/icons-app/iconfont.js`);
|
||||
} else {
|
||||
// Coco Server Icons
|
||||
useScript("https://at.alicdn.com/t/c/font_4878526_cykw3et0ezd.js");
|
||||
|
||||
// Coco App Icons
|
||||
useScript("https://at.alicdn.com/t/c/font_4934333_zclkkzo4fgo.js");
|
||||
}
|
||||
export const useIconfontScript = () => {
|
||||
// Coco Server Icons
|
||||
useScript("https://at.alicdn.com/t/c/font_4878526_cykw3et0ezd.js");
|
||||
// Coco App Icons
|
||||
useScript("https://at.alicdn.com/t/c/font_4934333_zclkkzo4fgo.js");
|
||||
};
|
||||
|
||||
@@ -174,6 +174,7 @@ export function useSearch() {
|
||||
const [error, res]: any = await Get(
|
||||
`/query/_search?query=${searchInput}`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error("_search", error);
|
||||
response = { failed: [], hits: [], total_hits: 0 };
|
||||
|
||||
105
src/hooks/useServers.ts
Normal file
105
src/hooks/useServers.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import type { Server } from "@/types/server";
|
||||
import {
|
||||
getCurrentWindowService,
|
||||
setCurrentWindowService,
|
||||
handleLogout,
|
||||
} from "@/commands/servers";
|
||||
|
||||
export const useServers = () => {
|
||||
const setServerList = useConnectStore((state) => state.setServerList);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const cloudSelectService = useConnectStore((state) => {
|
||||
return state.cloudSelectService;
|
||||
});
|
||||
|
||||
const getAllServerList = async () => {
|
||||
try {
|
||||
const res = await platformAdapter.commands("list_coco_servers");
|
||||
if (!Array.isArray(res)) {
|
||||
// If res is not an array, it might be an error message or something else.
|
||||
// Log it and don't proceed.
|
||||
console.warn("Invalid server list response:", res);
|
||||
setServerList([]); // Clear the list or handle as appropriate
|
||||
return;
|
||||
}
|
||||
setServerList(res);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch server list:", error);
|
||||
setServerList([]);
|
||||
}
|
||||
};
|
||||
|
||||
const addServer = useCallback(
|
||||
async (endpointLink: string): Promise<Server> => {
|
||||
if (!endpointLink) {
|
||||
throw new Error("Endpoint is required");
|
||||
}
|
||||
if (
|
||||
!endpointLink.startsWith("http://") &&
|
||||
!endpointLink.startsWith("https://")
|
||||
) {
|
||||
throw new Error("Invalid Endpoint");
|
||||
}
|
||||
|
||||
const res: Server = await platformAdapter.commands(
|
||||
"add_coco_server",
|
||||
endpointLink
|
||||
);
|
||||
await getAllServerList();
|
||||
await setCurrentWindowService(res);
|
||||
return res;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const enableServer = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
const service = await getCurrentWindowService();
|
||||
|
||||
if (!service?.id) {
|
||||
throw new Error("No current service selected");
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
await platformAdapter.commands("enable_server", service.id);
|
||||
} else {
|
||||
await platformAdapter.commands("disable_server", service.id);
|
||||
}
|
||||
|
||||
await setCurrentWindowService({ ...service, enabled });
|
||||
await getAllServerList();
|
||||
},
|
||||
[currentService, cloudSelectService]
|
||||
);
|
||||
|
||||
const removeServer = useCallback(
|
||||
async (id: string) => {
|
||||
await platformAdapter.commands("remove_coco_server", id);
|
||||
await getAllServerList();
|
||||
},
|
||||
[currentService?.id, cloudSelectService?.id]
|
||||
);
|
||||
|
||||
const logoutServer = useCallback(async (id: string) => {
|
||||
await platformAdapter.commands("logout_coco_server", id);
|
||||
handleLogout(id);
|
||||
await getAllServerList();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getAllServerList();
|
||||
}, [currentService?.enabled, cloudSelectService?.enabled]);
|
||||
|
||||
return {
|
||||
getAllServerList,
|
||||
refreshServerList: getAllServerList,
|
||||
addServer,
|
||||
enableServer,
|
||||
removeServer,
|
||||
logoutServer,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,6 @@
|
||||
import { isNumber } from "lodash-es";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useAppearanceStore } from "@/stores/appearanceStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
@@ -5,8 +8,6 @@ import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { useStartupStore } from "@/stores/startupStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { isNumber } from "lodash-es";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const useSyncStore = () => {
|
||||
const setModifierKey = useShortcutsStore((state) => {
|
||||
@@ -60,8 +61,8 @@ export const useSyncStore = () => {
|
||||
const setFixedWindow = useShortcutsStore((state) => {
|
||||
return state.setFixedWindow;
|
||||
});
|
||||
const setServiceList = useShortcutsStore((state) => {
|
||||
return state.setServiceList;
|
||||
const setServiceListShortcut = useShortcutsStore((state) => {
|
||||
return state.setServiceListShortcut;
|
||||
});
|
||||
const setExternal = useShortcutsStore((state) => {
|
||||
return state.setExternal;
|
||||
@@ -143,7 +144,7 @@ export const useSyncStore = () => {
|
||||
aiAssistant,
|
||||
newSession,
|
||||
fixedWindow,
|
||||
serviceList,
|
||||
serviceListShortcut,
|
||||
external,
|
||||
aiOverview,
|
||||
} = payload;
|
||||
@@ -162,7 +163,7 @@ export const useSyncStore = () => {
|
||||
setAiAssistant(aiAssistant);
|
||||
setNewSession(newSession);
|
||||
setFixedWindow(fixedWindow);
|
||||
setServiceList(serviceList);
|
||||
setServiceListShortcut(serviceListShortcut);
|
||||
setExternal(external);
|
||||
setAiOverview(aiOverview);
|
||||
}),
|
||||
|
||||
33
src/hooks/useTogglePin.ts
Normal file
33
src/hooks/useTogglePin.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
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);
|
||||
await platformAdapter.toggleMoveToActiveSpaceAttribute();
|
||||
setIsPinned(newPinned);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle window pin state:", err);
|
||||
}
|
||||
}, [isPinned, setIsPinned, options?.onPinChange]);
|
||||
|
||||
return {
|
||||
isPinned,
|
||||
togglePin,
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user