mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-19 12:59:24 +01:00
Compare commits
16 Commits
add-macos-
...
microphone
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d0d11860c | ||
|
|
d48d4af7d2 | ||
|
|
876d14f9d9 | ||
|
|
a8e090c9be | ||
|
|
c30df6cee0 | ||
|
|
b833769c25 | ||
|
|
855fb2a168 | ||
|
|
d2735ec13b | ||
|
|
c40fc5818a | ||
|
|
a553ebd593 | ||
|
|
232166eb89 | ||
|
|
99144950d9 | ||
|
|
32d4f45144 | ||
|
|
6bc78b41ef | ||
|
|
cd54beee04 | ||
|
|
ee45d21bbe |
2
.env
2
.env
@@ -1,5 +1,3 @@
|
|||||||
COCO_SERVER_URL=http://localhost:9000 #https://coco.infini.cloud #http://localhost:9000
|
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
|
#TAURI_DEV_HOST=0.0.0.0
|
||||||
@@ -14,6 +14,9 @@ Information about release notes of Coco App is provided here.
|
|||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
|
||||||
- feat: enhance ui for skipped version #834
|
- 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
|
### 🐛 Bug fix
|
||||||
|
|
||||||
@@ -21,6 +24,13 @@ Information about release notes of Coco App is provided here.
|
|||||||
|
|
||||||
### ✈️ Improvements
|
### ✈️ 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)
|
## 0.7.1 (2025-07-27)
|
||||||
|
|
||||||
### ❌ Breaking changes
|
### ❌ Breaking changes
|
||||||
|
|||||||
@@ -31,7 +31,6 @@
|
|||||||
"@tauri-apps/plugin-process": "^2.2.1",
|
"@tauri-apps/plugin-process": "^2.2.1",
|
||||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||||
"@tauri-apps/plugin-updater": "github:infinilabs/tauri-plugin-updater#v2",
|
"@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",
|
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
||||||
"@wavesurfer/react": "^1.0.11",
|
"@wavesurfer/react": "^1.0.11",
|
||||||
"ahooks": "^3.8.4",
|
"ahooks": "^3.8.4",
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -47,9 +47,6 @@ importers:
|
|||||||
'@tauri-apps/plugin-updater':
|
'@tauri-apps/plugin-updater':
|
||||||
specifier: github:infinilabs/tauri-plugin-updater#v2
|
specifier: github:infinilabs/tauri-plugin-updater#v2
|
||||||
version: https://codeload.github.com/infinilabs/tauri-plugin-updater/tar.gz/358e689c65e9943b53eff50bcb9dfd5b1cfc4072
|
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':
|
'@tauri-apps/plugin-window':
|
||||||
specifier: 2.0.0-alpha.1
|
specifier: 2.0.0-alpha.1
|
||||||
version: 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}
|
resolution: {tarball: https://codeload.github.com/infinilabs/tauri-plugin-updater/tar.gz/358e689c65e9943b53eff50bcb9dfd5b1cfc4072}
|
||||||
version: 2.7.1
|
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':
|
'@tauri-apps/plugin-window@2.0.0-alpha.1':
|
||||||
resolution: {integrity: sha512-dFOAgal/3Txz3SQ+LNQq0AK1EPC+acdaFlwPVB/6KXUZYmaFleIlzgxDVoJCQ+/xOhxvYrdQaFLefh0I/Kldbg==}
|
resolution: {integrity: sha512-dFOAgal/3Txz3SQ+LNQq0AK1EPC+acdaFlwPVB/6KXUZYmaFleIlzgxDVoJCQ+/xOhxvYrdQaFLefh0I/Kldbg==}
|
||||||
|
|
||||||
@@ -4643,10 +4637,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.5.0
|
'@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':
|
'@tauri-apps/plugin-window@2.0.0-alpha.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.0.0-alpha.6
|
'@tauri-apps/api': 2.0.0-alpha.6
|
||||||
|
|||||||
104
src-tauri/Cargo.lock
generated
104
src-tauri/Cargo.lock
generated
@@ -862,6 +862,7 @@ dependencies = [
|
|||||||
"hostname",
|
"hostname",
|
||||||
"http 1.3.1",
|
"http 1.3.1",
|
||||||
"hyper 0.14.32",
|
"hyper 0.14.32",
|
||||||
|
"indexmap 2.10.0",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"log",
|
"log",
|
||||||
"meval",
|
"meval",
|
||||||
@@ -878,6 +879,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_plain",
|
"serde_plain",
|
||||||
"strsim 0.10.0",
|
"strsim 0.10.0",
|
||||||
|
"strum",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
@@ -900,13 +902,12 @@ dependencies = [
|
|||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tauri-plugin-websocket",
|
|
||||||
"tauri-plugin-windows-version",
|
"tauri-plugin-windows-version",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tokio-tungstenite 0.20.1",
|
"tokio-tungstenite",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tungstenite 0.24.0",
|
"tungstenite 0.24.0",
|
||||||
"url",
|
"url",
|
||||||
@@ -2487,7 +2488,7 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"http 1.3.1",
|
"http 1.3.1",
|
||||||
"indexmap 2.9.0",
|
"indexmap 2.10.0",
|
||||||
"slab",
|
"slab",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
@@ -2956,9 +2957,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.9.0"
|
version = "2.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
|
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown 0.15.3",
|
"hashbrown 0.15.3",
|
||||||
@@ -4589,7 +4590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d"
|
checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.9.0",
|
"indexmap 2.10.0",
|
||||||
"quick-xml 0.32.0",
|
"quick-xml 0.32.0",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
@@ -5553,7 +5554,7 @@ version = "1.0.140"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
|
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.9.0",
|
"indexmap 2.10.0",
|
||||||
"itoa 1.0.15",
|
"itoa 1.0.15",
|
||||||
"memchr",
|
"memchr",
|
||||||
"ryu",
|
"ryu",
|
||||||
@@ -5611,7 +5612,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"hex",
|
"hex",
|
||||||
"indexmap 1.9.3",
|
"indexmap 1.9.3",
|
||||||
"indexmap 2.9.0",
|
"indexmap 2.10.0",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -5865,6 +5866,27 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
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]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -6556,25 +6578,6 @@ dependencies = [
|
|||||||
"zip 2.6.1",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-windows-version"
|
name = "tauri-plugin-windows-version"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -6682,7 +6685,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e8d321dbc6f998d825ab3f0d62673e810c861aac2d0de2cc2c395328f1d113b4"
|
checksum = "e8d321dbc6f998d825ab3f0d62673e810c861aac2d0de2cc2c395328f1d113b4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"embed-resource",
|
"embed-resource",
|
||||||
"indexmap 2.9.0",
|
"indexmap 2.10.0",
|
||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6909,22 +6912,6 @@ dependencies = [
|
|||||||
"tungstenite 0.20.1",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.15"
|
version = "0.7.15"
|
||||||
@@ -6965,7 +6952,7 @@ version = "0.19.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.9.0",
|
"indexmap 2.10.0",
|
||||||
"toml_datetime",
|
"toml_datetime",
|
||||||
"winnow 0.5.40",
|
"winnow 0.5.40",
|
||||||
]
|
]
|
||||||
@@ -6976,7 +6963,7 @@ version = "0.20.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81"
|
checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.9.0",
|
"indexmap 2.10.0",
|
||||||
"toml_datetime",
|
"toml_datetime",
|
||||||
"winnow 0.5.40",
|
"winnow 0.5.40",
|
||||||
]
|
]
|
||||||
@@ -6987,7 +6974,7 @@ version = "0.22.26"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
|
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.9.0",
|
"indexmap 2.10.0",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned",
|
"serde_spanned",
|
||||||
"toml_datetime",
|
"toml_datetime",
|
||||||
@@ -7131,25 +7118,6 @@ dependencies = [
|
|||||||
"utf-8",
|
"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]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -8620,7 +8588,7 @@ dependencies = [
|
|||||||
"arbitrary",
|
"arbitrary",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
"indexmap 2.9.0",
|
"indexmap 2.10.0",
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8639,7 +8607,7 @@ dependencies = [
|
|||||||
"flate2",
|
"flate2",
|
||||||
"getrandom 0.3.2",
|
"getrandom 0.3.2",
|
||||||
"hmac",
|
"hmac",
|
||||||
"indexmap 2.9.0",
|
"indexmap 2.10.0",
|
||||||
"liblzma",
|
"liblzma",
|
||||||
"memchr",
|
"memchr",
|
||||||
"pbkdf2",
|
"pbkdf2",
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
|
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
|
||||||
serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] }
|
serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] }
|
||||||
tauri-plugin-http = "2"
|
tauri-plugin-http = "2"
|
||||||
tauri-plugin-websocket = "2"
|
|
||||||
tauri-plugin-deep-link = "2.0.0"
|
tauri-plugin-deep-link = "2.0.0"
|
||||||
tauri-plugin-store = "2.2.0"
|
tauri-plugin-store = "2.2.0"
|
||||||
tauri-plugin-os = "2"
|
tauri-plugin-os = "2"
|
||||||
@@ -106,6 +105,8 @@ camino = "1.1.10"
|
|||||||
tokio-stream = { version = "0.1.17", features = ["io-util"] }
|
tokio-stream = { version = "0.1.17", features = ["io-util"] }
|
||||||
cfg-if = "1.0.1"
|
cfg-if = "1.0.1"
|
||||||
sysinfo = "0.35.2"
|
sysinfo = "0.35.2"
|
||||||
|
indexmap = { version = "2.10.0", features = ["serde"] }
|
||||||
|
strum = { version = "0.27.2", features = ["derive"] }
|
||||||
|
|
||||||
[target."cfg(target_os = \"macos\")".dependencies]
|
[target."cfg(target_os = \"macos\")".dependencies]
|
||||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.automation.apple-events</key>
|
<key>com.apple.security.automation.apple-events</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.microphone</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.device.audio-input</key>
|
<key>com.apple.security.device.audio-input</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
@@ -24,6 +26,5 @@
|
|||||||
<string>6GVZT94974.rs.coco.app</string>
|
<string>6GVZT94974.rs.coco.app</string>
|
||||||
<key>com.apple.developer.team-identifier</key>
|
<key>com.apple.developer.team-identifier</key>
|
||||||
<string>6GVZT94974</string>
|
<string>6GVZT94974</string>
|
||||||
|
|
||||||
</dict>
|
</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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
|
||||||
<string>Request camera access for WebRTC</string>
|
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
|
||||||
<string>Request microphone access for WebRTC</string>
|
|
||||||
|
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>rs.coco.app</string>
|
<string>rs.coco.app</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
|
|||||||
@@ -37,9 +37,6 @@
|
|||||||
"http:allow-fetch-cancel",
|
"http:allow-fetch-cancel",
|
||||||
"http:allow-fetch-read-body",
|
"http:allow-fetch-read-body",
|
||||||
"http:allow-fetch-send",
|
"http:allow-fetch-send",
|
||||||
"websocket:default",
|
|
||||||
"websocket:allow-connect",
|
|
||||||
"websocket:allow-send",
|
|
||||||
"autostart:allow-enable",
|
"autostart:allow-enable",
|
||||||
"autostart:allow-disable",
|
"autostart:allow-disable",
|
||||||
"autostart:allow-is-enabled",
|
"autostart:allow-is-enabled",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::common::assistant::ChatRequestMessage;
|
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::common::register::SearchSourceRegistry;
|
||||||
use crate::server::http_client::HttpClient;
|
use crate::server::http_client::HttpClient;
|
||||||
use crate::{common, server::servers::COCO_SERVERS};
|
use crate::{common, server::servers::COCO_SERVERS};
|
||||||
@@ -9,12 +9,12 @@ use futures_util::TryStreamExt;
|
|||||||
use http::Method;
|
use http::Method;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
use tokio::io::AsyncBufReadExt;
|
use tokio::io::AsyncBufReadExt;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn chat_history<R: Runtime>(
|
pub async fn chat_history(
|
||||||
_app_handle: AppHandle<R>,
|
_app_handle: AppHandle,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
from: u32,
|
from: u32,
|
||||||
size: u32,
|
size: u32,
|
||||||
@@ -43,8 +43,8 @@ pub async fn chat_history<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn session_chat_history<R: Runtime>(
|
pub async fn session_chat_history(
|
||||||
_app_handle: AppHandle<R>,
|
_app_handle: AppHandle,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
from: u32,
|
from: u32,
|
||||||
@@ -66,8 +66,8 @@ pub async fn session_chat_history<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_session_chat<R: Runtime>(
|
pub async fn open_session_chat(
|
||||||
_app_handle: AppHandle<R>,
|
_app_handle: AppHandle,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
@@ -81,8 +81,8 @@ pub async fn open_session_chat<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn close_session_chat<R: Runtime>(
|
pub async fn close_session_chat(
|
||||||
_app_handle: AppHandle<R>,
|
_app_handle: AppHandle,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
@@ -95,8 +95,8 @@ pub async fn close_session_chat<R: Runtime>(
|
|||||||
common::http::get_response_body_text(response).await
|
common::http::get_response_body_text(response).await
|
||||||
}
|
}
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn cancel_session_chat<R: Runtime>(
|
pub async fn cancel_session_chat(
|
||||||
_app_handle: AppHandle<R>,
|
_app_handle: AppHandle,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
query_params: Option<HashMap<String, Value>>,
|
query_params: Option<HashMap<String, Value>>,
|
||||||
@@ -112,72 +112,37 @@ pub async fn cancel_session_chat<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn new_chat<R: Runtime>(
|
pub async fn chat_create(
|
||||||
_app_handle: AppHandle<R>,
|
app_handle: AppHandle,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
websocket_id: String,
|
message: Option<String>,
|
||||||
message: String,
|
attachments: Option<Vec<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,
|
|
||||||
query_params: Option<HashMap<String, Value>>,
|
query_params: Option<HashMap<String, Value>>,
|
||||||
client_id: String,
|
client_id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let body = if !message.is_empty() {
|
println!("chat_create message: {:?}", message);
|
||||||
let message = ChatRequestMessage {
|
println!("chat_create attachments: {:?}", attachments);
|
||||||
message: Some(message),
|
|
||||||
|
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(
|
Some(
|
||||||
serde_json::to_string(&message)
|
serde_json::to_string(&request_message)
|
||||||
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
||||||
.into(),
|
.into(),
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = HttpClient::advanced_post(
|
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) {
|
if let Err(err) = app_handle.emit(&client_id, line) {
|
||||||
log::error!("Emit failed: {:?}", err);
|
log::error!("Emit failed: {:?}", err);
|
||||||
|
|
||||||
print!("Error sending message: {:?}", err);
|
|
||||||
|
|
||||||
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", 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]
|
#[tauri::command]
|
||||||
pub async fn send_message<R: Runtime>(
|
pub async fn chat_chat(
|
||||||
_app_handle: AppHandle<R>,
|
app_handle: AppHandle,
|
||||||
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>,
|
|
||||||
server_id: String,
|
server_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
message: String,
|
message: Option<String>,
|
||||||
|
attachments: Option<Vec<String>>,
|
||||||
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
||||||
client_id: String,
|
client_id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let body = if !message.is_empty() {
|
println!("chat_chat message: {:?}", message);
|
||||||
let message = ChatRequestMessage {
|
println!("chat_chat attachments: {:?}", attachments);
|
||||||
message: Some(message),
|
|
||||||
|
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(
|
Some(
|
||||||
serde_json::to_string(&message)
|
serde_json::to_string(&request_message)
|
||||||
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
||||||
.into(),
|
.into(),
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let path = format!("/chat/{}/_chat", session_id);
|
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) {
|
if let Err(err) = app_handle.emit(&client_id, line) {
|
||||||
log::error!("Emit failed: {:?}", err);
|
log::error!("Emit failed: {:?}", err);
|
||||||
|
|
||||||
|
print!("Error sending message: {:?}", err);
|
||||||
|
|
||||||
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
|
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,8 +313,8 @@ pub async fn update_session_chat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn assistant_search<R: Runtime>(
|
pub async fn assistant_search(
|
||||||
_app_handle: AppHandle<R>,
|
_app_handle: AppHandle,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
query_params: Option<Vec<String>>,
|
query_params: Option<Vec<String>>,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
@@ -381,8 +329,8 @@ pub async fn assistant_search<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn assistant_get<R: Runtime>(
|
pub async fn assistant_get(
|
||||||
_app_handle: AppHandle<R>,
|
_app_handle: AppHandle,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
assistant_id: String,
|
assistant_id: String,
|
||||||
) -> Result<Value, 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.
|
/// Returns as soon as the assistant is found on any Coco server.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn assistant_get_multi<R: Runtime>(
|
pub async fn assistant_get_multi(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle,
|
||||||
assistant_id: String,
|
assistant_id: String,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
let search_sources = app_handle.state::<SearchSourceRegistry>();
|
let search_sources = app_handle.state::<SearchSourceRegistry>();
|
||||||
@@ -499,8 +447,8 @@ pub fn remove_icon_fields(json: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn ask_ai<R: Runtime>(
|
pub async fn ask_ai(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle,
|
||||||
message: String,
|
message: String,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
assistant_id: String,
|
assistant_id: String,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::{fs::create_dir, io::Read};
|
use std::{fs::create_dir, io::Read};
|
||||||
|
|
||||||
use tauri::{Manager, Runtime};
|
use tauri::Manager;
|
||||||
use tauri_plugin_autostart::ManagerExt;
|
use tauri_plugin_autostart::ManagerExt;
|
||||||
|
|
||||||
/// If the state reported from the OS and the state stored by us differ, our state is
|
/// 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(())
|
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;
|
use std::fs::File;
|
||||||
|
|
||||||
let path = app.path().app_config_dir().unwrap();
|
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]
|
#[tauri::command]
|
||||||
pub async fn change_autostart<R: Runtime>(
|
pub async fn change_autostart(app: tauri::AppHandle, open: bool) -> Result<(), String> {
|
||||||
app: tauri::AppHandle<R>,
|
|
||||||
open: bool,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ use serde_json::Value;
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ChatRequestMessage {
|
pub struct ChatRequestMessage {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub attachments: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri::Runtime;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RichLabel {
|
pub struct RichLabel {
|
||||||
@@ -42,6 +41,15 @@ pub(crate) enum OnOpened {
|
|||||||
Command {
|
Command {
|
||||||
action: crate::extension::CommandAction,
|
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 {
|
impl OnOpened {
|
||||||
@@ -59,28 +67,37 @@ impl OnOpened {
|
|||||||
|
|
||||||
ret
|
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]
|
#[tauri::command]
|
||||||
pub(crate) async fn open<R: Runtime>(
|
pub(crate) async fn open(
|
||||||
tauri_app_handle: AppHandle<R>,
|
tauri_app_handle: AppHandle,
|
||||||
on_opened: OnOpened,
|
on_opened: OnOpened,
|
||||||
|
extra_args: Option<HashMap<String, String>>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::debug!("open({})", on_opened.url());
|
|
||||||
|
|
||||||
use crate::util::open as homemade_tauri_shell_open;
|
use crate::util::open as homemade_tauri_shell_open;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
match on_opened {
|
match on_opened {
|
||||||
OnOpened::Application { app_path } => {
|
OnOpened::Application { app_path } => {
|
||||||
|
log::debug!("open application [{}]", app_path);
|
||||||
|
|
||||||
homemade_tauri_shell_open(tauri_app_handle.clone(), app_path).await?
|
homemade_tauri_shell_open(tauri_app_handle.clone(), app_path).await?
|
||||||
}
|
}
|
||||||
OnOpened::Document { url } => {
|
OnOpened::Document { url } => {
|
||||||
|
log::debug!("open document [{}]", url);
|
||||||
|
|
||||||
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
||||||
}
|
}
|
||||||
OnOpened::Command { action } => {
|
OnOpened::Command { action } => {
|
||||||
|
log::debug!("open (execute) command [{:?}]", action);
|
||||||
|
|
||||||
let mut cmd = Command::new(action.exec);
|
let mut cmd = Command::new(action.exec);
|
||||||
if let Some(args) = action.args {
|
if let Some(args) = action.args {
|
||||||
cmd.args(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(())
|
Ok(())
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ use pizza_engine::{Engine, EngineBuilder, doc};
|
|||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tauri::{AppHandle, Manager, Runtime, async_runtime};
|
use tauri::{AppHandle, Manager, async_runtime};
|
||||||
use tauri_plugin_fs_pro::{IconOptions, icon, metadata, name};
|
use tauri_plugin_fs_pro::{IconOptions, icon, metadata, name};
|
||||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||||
use tauri_plugin_global_shortcut::Shortcut;
|
use tauri_plugin_global_shortcut::Shortcut;
|
||||||
@@ -131,10 +131,7 @@ async fn get_app_name(app: &App) -> String {
|
|||||||
/// Helper function to return an absolute path to `app`'s icon.
|
/// 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.
|
/// On macOS/Windows, we cache icons in our data directory using the `icon()` function.
|
||||||
async fn get_app_icon_path<R: Runtime>(
|
async fn get_app_icon_path(tauri_app_handle: &AppHandle, app: &App) -> Result<String, String> {
|
||||||
tauri_app_handle: &AppHandle<R>,
|
|
||||||
app: &App,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let res_path = if cfg!(target_os = "linux") {
|
let res_path = if cfg!(target_os = "linux") {
|
||||||
let icon_path = app
|
let icon_path = app
|
||||||
.icon_path
|
.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).
|
/// Index applications if they have not been indexed (by checking if `app_index_dir` exists).
|
||||||
async fn index_applications_if_not_indexed<R: Runtime>(
|
async fn index_applications_if_not_indexed(
|
||||||
tauri_app_handle: &AppHandle<R>,
|
tauri_app_handle: &AppHandle,
|
||||||
app_index_dir: &Path,
|
app_index_dir: &Path,
|
||||||
) -> anyhow::Result<ApplicationSearchSourceState> {
|
) -> anyhow::Result<ApplicationSearchSourceState> {
|
||||||
let index_exists = app_index_dir.exists();
|
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()`.
|
/// Upon application start, index all the applications found in the `get_default_search_paths()`.
|
||||||
struct IndexAllApplicationsTask<R: Runtime> {
|
struct IndexAllApplicationsTask {
|
||||||
tauri_app_handle: AppHandle<R>,
|
tauri_app_handle: AppHandle,
|
||||||
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
|
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait(?Send)]
|
#[async_trait::async_trait(?Send)]
|
||||||
impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
|
impl Task for IndexAllApplicationsTask {
|
||||||
fn search_source_id(&self) -> &'static str {
|
fn search_source_id(&self) -> &'static str {
|
||||||
APPLICATION_SEARCH_SOURCE_ID
|
APPLICATION_SEARCH_SOURCE_ID
|
||||||
}
|
}
|
||||||
@@ -343,13 +340,13 @@ impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ReindexAllApplicationsTask<R: Runtime> {
|
struct ReindexAllApplicationsTask {
|
||||||
tauri_app_handle: AppHandle<R>,
|
tauri_app_handle: AppHandle,
|
||||||
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
|
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait(?Send)]
|
#[async_trait::async_trait(?Send)]
|
||||||
impl<R: Runtime> Task for ReindexAllApplicationsTask<R> {
|
impl Task for ReindexAllApplicationsTask {
|
||||||
fn search_source_id(&self) -> &'static str {
|
fn search_source_id(&self) -> &'static str {
|
||||||
APPLICATION_SEARCH_SOURCE_ID
|
APPLICATION_SEARCH_SOURCE_ID
|
||||||
}
|
}
|
||||||
@@ -377,14 +374,14 @@ impl<R: Runtime> Task for ReindexAllApplicationsTask<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SearchApplicationsTask<R: Runtime> {
|
struct SearchApplicationsTask {
|
||||||
tauri_app_handle: AppHandle<R>,
|
tauri_app_handle: AppHandle,
|
||||||
query_string: String,
|
query_string: String,
|
||||||
callback: Option<OneshotSender<Result<SearchResult, PizzaEngineError>>>,
|
callback: Option<OneshotSender<Result<SearchResult, PizzaEngineError>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait(?Send)]
|
#[async_trait::async_trait(?Send)]
|
||||||
impl<R: Runtime> Task for SearchApplicationsTask<R> {
|
impl Task for SearchApplicationsTask {
|
||||||
fn search_source_id(&self) -> &'static str {
|
fn search_source_id(&self) -> &'static str {
|
||||||
APPLICATION_SEARCH_SOURCE_ID
|
APPLICATION_SEARCH_SOURCE_ID
|
||||||
}
|
}
|
||||||
@@ -514,9 +511,7 @@ impl Task for IndexNewApplicationsTask {
|
|||||||
pub struct ApplicationSearchSource;
|
pub struct ApplicationSearchSource;
|
||||||
|
|
||||||
impl ApplicationSearchSource {
|
impl ApplicationSearchSource {
|
||||||
pub async fn prepare_index_and_store<R: Runtime>(
|
pub async fn prepare_index_and_store(app_handle: AppHandle) -> Result<(), String> {
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
app_handle
|
app_handle
|
||||||
.store(TAURI_STORE_APP_HOTKEY)
|
.store(TAURI_STORE_APP_HOTKEY)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -683,7 +678,7 @@ fn pizza_engine_hits_to_coco_hits(
|
|||||||
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
|
let store = tauri_app_handle
|
||||||
.store(TAURI_STORE_APP_ALIAS)
|
.store(TAURI_STORE_APP_ALIAS)
|
||||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", 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.
|
// 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
|
let store = tauri_app_handle
|
||||||
.store(TAURI_STORE_APP_ALIAS)
|
.store(TAURI_STORE_APP_ALIAS)
|
||||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", 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 handler that will be invoked when an application hotkey is pressed.
|
||||||
///
|
///
|
||||||
/// The `app_path` argument is for logging-only.
|
/// The `app_path` argument is for logging-only.
|
||||||
fn app_hotkey_handler<R: Runtime>(
|
fn app_hotkey_handler(
|
||||||
app_path: String,
|
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| {
|
move |tauri_app_handle, _hot_key, event| {
|
||||||
if event.state() == ShortcutState::Pressed {
|
if event.state() == ShortcutState::Pressed {
|
||||||
let app_path_clone = app_path.clone();
|
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.
|
/// 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
|
let app_hotkey_store = tauri_app_handle
|
||||||
.store(TAURI_STORE_APP_HOTKEY)
|
.store(TAURI_STORE_APP_HOTKEY)
|
||||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", 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.
|
/// 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
|
let app_hotkey_store = tauri_app_handle
|
||||||
.store(TAURI_STORE_APP_HOTKEY)
|
.store(TAURI_STORE_APP_HOTKEY)
|
||||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", 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.
|
/// Set the hotkey but won't persist this settings change.
|
||||||
pub(crate) fn set_app_hotkey<R: Runtime>(
|
pub(crate) fn set_app_hotkey(
|
||||||
tauri_app_handle: &AppHandle<R>,
|
tauri_app_handle: &AppHandle,
|
||||||
app_path: &str,
|
app_path: &str,
|
||||||
hotkey: &str,
|
hotkey: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -794,8 +789,8 @@ pub(crate) fn set_app_hotkey<R: Runtime>(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_app_hotkey<R: Runtime>(
|
pub fn register_app_hotkey(
|
||||||
tauri_app_handle: &AppHandle<R>,
|
tauri_app_handle: &AppHandle,
|
||||||
app_path: &str,
|
app_path: &str,
|
||||||
hotkey: &str,
|
hotkey: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -812,10 +807,7 @@ pub fn register_app_hotkey<R: Runtime>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unregister_app_hotkey<R: Runtime>(
|
pub fn unregister_app_hotkey(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
|
||||||
tauri_app_handle: &AppHandle<R>,
|
|
||||||
app_path: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let app_hotkey_store = tauri_app_handle
|
let app_hotkey_store = tauri_app_handle
|
||||||
.store(TAURI_STORE_APP_HOTKEY)
|
.store(TAURI_STORE_APP_HOTKEY)
|
||||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", 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(())
|
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
|
let store = tauri_app_handle
|
||||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||||
.unwrap_or_else(|_| {
|
.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)
|
disabled_app_list.iter().all(|path| path != app_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn disable_app_search<R: Runtime>(
|
pub fn disable_app_search(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
|
||||||
tauri_app_handle: &AppHandle<R>,
|
|
||||||
app_path: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let store = tauri_app_handle
|
let store = tauri_app_handle
|
||||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
@@ -939,10 +928,7 @@ pub fn disable_app_search<R: Runtime>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn enable_app_search<R: Runtime>(
|
pub fn enable_app_search(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
|
||||||
tauri_app_handle: &AppHandle<R>,
|
|
||||||
app_path: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let store = tauri_app_handle
|
let store = tauri_app_handle
|
||||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
@@ -984,8 +970,8 @@ pub fn enable_app_search<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn add_app_search_path<R: Runtime>(
|
pub async fn add_app_search_path(
|
||||||
tauri_app_handle: AppHandle<R>,
|
tauri_app_handle: AppHandle,
|
||||||
search_path: String,
|
search_path: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut search_paths = get_app_search_path(tauri_app_handle.clone()).await;
|
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]
|
#[tauri::command]
|
||||||
pub async fn remove_app_search_path<R: Runtime>(
|
pub async fn remove_app_search_path(
|
||||||
tauri_app_handle: AppHandle<R>,
|
tauri_app_handle: AppHandle,
|
||||||
search_path: String,
|
search_path: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut search_paths = get_app_search_path(tauri_app_handle.clone()).await;
|
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]
|
#[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
|
let store = tauri_app_handle
|
||||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
@@ -1065,9 +1051,7 @@ pub async fn get_app_search_path<R: Runtime>(tauri_app_handle: AppHandle<R>) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_app_list<R: Runtime>(
|
pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>, String> {
|
||||||
tauri_app_handle: AppHandle<R>,
|
|
||||||
) -> Result<Vec<Extension>, String> {
|
|
||||||
let search_paths = get_app_search_path(tauri_app_handle.clone()).await;
|
let search_paths = get_app_search_path(tauri_app_handle.clone()).await;
|
||||||
let apps = list_app_in(search_paths)?;
|
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]
|
#[tauri::command]
|
||||||
pub async fn reindex_applications<R: Runtime>(
|
pub async fn reindex_applications(tauri_app_handle: AppHandle) -> Result<(), String> {
|
||||||
tauri_app_handle: AppHandle<R>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
let reindex_applications_task = ReindexAllApplicationsTask {
|
let reindex_applications_task = ReindexAllApplicationsTask {
|
||||||
tauri_app_handle: tauri_app_handle.clone(),
|
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::common::traits::SearchSource;
|
||||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::AppHandle;
|
||||||
|
|
||||||
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
|
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
|
||||||
|
|
||||||
pub struct ApplicationSearchSource;
|
pub struct ApplicationSearchSource;
|
||||||
|
|
||||||
impl ApplicationSearchSource {
|
impl ApplicationSearchSource {
|
||||||
pub async fn prepare_index_and_store<R: Runtime>(
|
pub async fn prepare_index_and_store(_app_handle: AppHandle) -> Result<(), String> {
|
||||||
_app_handle: AppHandle<R>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
Ok(())
|
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")
|
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_app_hotkey<R: Runtime>(
|
pub fn register_app_hotkey(
|
||||||
_tauri_app_handle: &AppHandle<R>,
|
_tauri_app_handle: &AppHandle,
|
||||||
_app_path: &str,
|
_app_path: &str,
|
||||||
_hotkey: &str,
|
_hotkey: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unregister_app_hotkey<R: Runtime>(
|
pub fn unregister_app_hotkey(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
|
||||||
_tauri_app_handle: &AppHandle<R>,
|
|
||||||
_app_path: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn disable_app_search<R: Runtime>(
|
pub fn disable_app_search(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
|
||||||
_tauri_app_handle: &AppHandle<R>,
|
|
||||||
_app_path: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
// no-op
|
// no-op
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn enable_app_search<R: Runtime>(
|
pub fn enable_app_search(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
|
||||||
_tauri_app_handle: &AppHandle<R>,
|
|
||||||
_app_path: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
// no-op
|
// no-op
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -85,8 +74,8 @@ pub fn is_app_search_enabled(_app_path: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn add_app_search_path<R: Runtime>(
|
pub async fn add_app_search_path(
|
||||||
_tauri_app_handle: AppHandle<R>,
|
_tauri_app_handle: AppHandle,
|
||||||
_search_path: String,
|
_search_path: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// no-op
|
// no-op
|
||||||
@@ -94,8 +83,8 @@ pub async fn add_app_search_path<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn remove_app_search_path<R: Runtime>(
|
pub async fn remove_app_search_path(
|
||||||
_tauri_app_handle: AppHandle<R>,
|
_tauri_app_handle: AppHandle,
|
||||||
_search_path: String,
|
_search_path: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// no-op
|
// no-op
|
||||||
@@ -103,43 +92,37 @@ pub async fn remove_app_search_path<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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
|
// Return an empty list
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_app_list<R: Runtime>(
|
pub async fn get_app_list(_tauri_app_handle: AppHandle) -> Result<Vec<Extension>, String> {
|
||||||
_tauri_app_handle: AppHandle<R>,
|
|
||||||
) -> Result<Vec<Extension>, String> {
|
|
||||||
// Return an empty list
|
// Return an empty list
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_app_metadata<R: Runtime>(
|
pub async fn get_app_metadata(
|
||||||
_tauri_app_handle: AppHandle<R>,
|
_tauri_app_handle: AppHandle,
|
||||||
_app_path: String,
|
_app_path: String,
|
||||||
) -> Result<AppMetadata, String> {
|
) -> Result<AppMetadata, String> {
|
||||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
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
|
// no-op
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn unset_apps_hotkey<R: Runtime>(
|
pub(crate) fn unset_apps_hotkey(_tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||||
_tauri_app_handle: &AppHandle<R>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
// no-op
|
// no-op
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn reindex_applications<R: Runtime>(
|
pub async fn reindex_applications(_tauri_app_handle: AppHandle) -> Result<(), String> {
|
||||||
_tauri_app_handle: AppHandle<R>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
// no-op
|
// no-op
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use serde::Serialize;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri::Runtime;
|
|
||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
// Tauri store keys for file system configuration
|
// Tauri store keys for file system configuration
|
||||||
@@ -54,7 +53,7 @@ impl Default for FileSearchConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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
|
let store = tauri_app_handle
|
||||||
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
|
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
@@ -185,15 +184,13 @@ impl FileSearchConfig {
|
|||||||
|
|
||||||
// Tauri commands for managing file system configuration
|
// Tauri commands for managing file system configuration
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_file_system_config<R: Runtime>(
|
pub async fn get_file_system_config(tauri_app_handle: AppHandle) -> FileSearchConfig {
|
||||||
tauri_app_handle: AppHandle<R>,
|
|
||||||
) -> FileSearchConfig {
|
|
||||||
FileSearchConfig::get(&tauri_app_handle)
|
FileSearchConfig::get(&tauri_app_handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_file_system_config<R: Runtime>(
|
pub async fn set_file_system_config(
|
||||||
tauri_app_handle: AppHandle<R>,
|
tauri_app_handle: AppHandle,
|
||||||
config: FileSearchConfig,
|
config: FileSearchConfig,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let store = tauri_app_handle
|
let store = tauri_app_handle
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ use crate::extension::{
|
|||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::{AppHandle, Manager, Runtime};
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
pub(crate) fn get_built_in_extension_directory<R: Runtime>(
|
pub(crate) fn get_built_in_extension_directory(tauri_app_handle: &AppHandle) -> PathBuf {
|
||||||
tauri_app_handle: &AppHandle<R>,
|
|
||||||
) -> PathBuf {
|
|
||||||
let mut resource_dir = tauri_app_handle.path().app_data_dir().expect(
|
let mut resource_dir = tauri_app_handle.path().app_data_dir().expect(
|
||||||
"User home directory not found, which should be impossible on desktop environments",
|
"User home directory not found, which should be impossible on desktop environments",
|
||||||
);
|
);
|
||||||
@@ -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
|
/// We only read alias/hotkey/enabled from the JSON file, we have ensured that if
|
||||||
/// alias/hotkey is not supported, then it will be `None`. Besides that, no further
|
/// alias/hotkey is not supported, then it will be `None`. Besides that, no further
|
||||||
/// validation is needed because nothing could go wrong.
|
/// validation is needed because nothing could go wrong.
|
||||||
pub(crate) async fn list_built_in_extensions<R: Runtime>(
|
pub(crate) async fn list_built_in_extensions(
|
||||||
tauri_app_handle: &AppHandle<R>,
|
tauri_app_handle: &AppHandle,
|
||||||
) -> Result<Vec<Extension>, String> {
|
) -> Result<Vec<Extension>, String> {
|
||||||
let dir = get_built_in_extension_directory(tauri_app_handle);
|
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)
|
Ok(built_in_extensions)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn init_built_in_extension<R: Runtime>(
|
pub(super) async fn init_built_in_extension(
|
||||||
tauri_app_handle: &AppHandle<R>,
|
tauri_app_handle: &AppHandle,
|
||||||
extension: &Extension,
|
extension: &Extension,
|
||||||
search_source_registry: &SearchSourceRegistry,
|
search_source_registry: &SearchSourceRegistry,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -233,8 +231,8 @@ pub(crate) fn is_extension_built_in(bundle_id: &ExtensionBundleIdBorrowed<'_>) -
|
|||||||
bundle_id.developer.is_none()
|
bundle_id.developer.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn enable_built_in_extension<R: Runtime>(
|
pub(crate) async fn enable_built_in_extension(
|
||||||
tauri_app_handle: &AppHandle<R>,
|
tauri_app_handle: &AppHandle,
|
||||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn disable_built_in_extension<R: Runtime>(
|
pub(crate) async fn disable_built_in_extension(
|
||||||
tauri_app_handle: &AppHandle<R>,
|
tauri_app_handle: &AppHandle,
|
||||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn set_built_in_extension_alias<R: Runtime>(
|
pub(crate) fn set_built_in_extension_alias(
|
||||||
tauri_app_handle: &AppHandle<R>,
|
tauri_app_handle: &AppHandle,
|
||||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||||
alias: &str,
|
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>(
|
pub(crate) fn register_built_in_extension_hotkey(
|
||||||
tauri_app_handle: &AppHandle<R>,
|
tauri_app_handle: &AppHandle,
|
||||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||||
hotkey: &str,
|
hotkey: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -433,8 +431,8 @@ pub(crate) fn register_built_in_extension_hotkey<R: Runtime>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn unregister_built_in_extension_hotkey<R: Runtime>(
|
pub(crate) fn unregister_built_in_extension_hotkey(
|
||||||
tauri_app_handle: &AppHandle<R>,
|
tauri_app_handle: &AppHandle,
|
||||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||||
@@ -481,8 +479,8 @@ fn load_extension_from_json_file(
|
|||||||
Ok(extension)
|
Ok(extension)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn is_built_in_extension_enabled<R: Runtime>(
|
pub(crate) async fn is_built_in_extension_enabled(
|
||||||
tauri_app_handle: &AppHandle<R>,
|
tauri_app_handle: &AppHandle,
|
||||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ use crate::util::platform::Platform;
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use borrowme::{Borrow, ToOwned};
|
use borrowme::{Borrow, ToOwned};
|
||||||
use derive_more::Display;
|
use derive_more::Display;
|
||||||
|
use indexmap::IndexMap;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tauri::{AppHandle, Manager, Runtime};
|
use tauri::{AppHandle, Manager};
|
||||||
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||||
|
|
||||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||||
@@ -23,7 +25,7 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||||
pub struct Extension {
|
pub struct Extension {
|
||||||
/// Extension ID.
|
/// Extension ID.
|
||||||
///
|
///
|
||||||
@@ -193,8 +195,19 @@ impl Extension {
|
|||||||
ExtensionType::Application => Some(OnOpened::Application {
|
ExtensionType::Application => Some(OnOpened::Application {
|
||||||
app_path: self.id.clone(),
|
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::Script => todo!("not supported yet"),
|
||||||
ExtensionType::Quicklink => todo!("not supported yet"),
|
|
||||||
ExtensionType::Setting => todo!("not supported yet"),
|
ExtensionType::Setting => todo!("not supported yet"),
|
||||||
ExtensionType::Calculator => None,
|
ExtensionType::Calculator => None,
|
||||||
ExtensionType::AiExtension => 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) struct CommandAction {
|
||||||
pub(crate) exec: String,
|
pub(crate) exec: String,
|
||||||
pub(crate) args: Option<Vec<String>>,
|
pub(crate) args: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||||
pub struct Quicklink {
|
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)]
|
#[derive(Debug, PartialEq, Deserialize, Serialize, Clone, Display, Copy)]
|
||||||
@@ -413,12 +592,12 @@ fn filter_out_extensions(
|
|||||||
/// * boolean: indicates if we found any invalid extensions
|
/// * boolean: indicates if we found any invalid extensions
|
||||||
/// * Vec<Extension>: loaded extensions
|
/// * Vec<Extension>: loaded extensions
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn list_extensions<R: Runtime>(
|
pub(crate) async fn list_extensions(
|
||||||
tauri_app_handle: AppHandle<R>,
|
tauri_app_handle: AppHandle,
|
||||||
query: Option<String>,
|
query: Option<String>,
|
||||||
extension_type: Option<ExtensionType>,
|
extension_type: Option<ExtensionType>,
|
||||||
list_enabled: bool,
|
list_enabled: bool,
|
||||||
) -> Result<(bool, Vec<Extension>), String> {
|
) -> Result<Vec<Extension>, String> {
|
||||||
log::trace!("loading extensions");
|
log::trace!("loading extensions");
|
||||||
|
|
||||||
let third_party_dir = third_party::get_third_party_extension_directory(&tauri_app_handle);
|
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
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.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?;
|
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 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 = {
|
let mut extensions = {
|
||||||
third_party_extensions.extend(built_in_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(
|
pub(crate) async fn init_extensions(
|
||||||
@@ -498,7 +676,7 @@ pub(crate) async fn init_extensions(
|
|||||||
|
|
||||||
// extension store
|
// extension store
|
||||||
search_source_registry_tauri_state
|
search_source_registry_tauri_state
|
||||||
.register_source(third_party::store::ExtensionStore)
|
.register_source(third_party::install::store::ExtensionStore)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Init the built-in enabled extensions
|
// Init the built-in enabled extensions
|
||||||
@@ -773,3 +951,601 @@ fn alter_extension_json_file(
|
|||||||
|
|
||||||
Ok(())
|
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.
|
//! 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::DataSourceReference;
|
||||||
use crate::common::document::Document;
|
use crate::common::document::Document;
|
||||||
use crate::common::error::SearchError;
|
use crate::common::error::SearchError;
|
||||||
@@ -12,8 +13,11 @@ use crate::extension::Extension;
|
|||||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||||
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||||
use crate::extension::canonicalize_relative_icon_path;
|
use crate::extension::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::get_third_party_extension_directory;
|
||||||
|
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
|
||||||
use crate::server::http_client::HttpClient;
|
use crate::server::http_client::HttpClient;
|
||||||
|
use crate::util::platform::Platform;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use serde_json::Map as JsonObject;
|
use serde_json::Map as JsonObject;
|
||||||
@@ -152,14 +156,12 @@ pub(crate) async fn search_extension(
|
|||||||
.get("developer")
|
.get("developer")
|
||||||
.and_then(|dev| dev.get("id"))
|
.and_then(|dev| dev.get("id"))
|
||||||
.and_then(|id| id.as_str())
|
.and_then(|id| id.as_str())
|
||||||
.expect("developer.id should exist")
|
.expect("developer.id should exist");
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let extension_id = source_obj
|
let extension_id = source_obj
|
||||||
.get("id")
|
.get("id")
|
||||||
.and_then(|id| id.as_str())
|
.and_then(|id| id.as_str())
|
||||||
.expect("extension id should exist")
|
.expect("extension id should exist");
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let installed = is_extension_installed(developer_id, extension_id).await;
|
let installed = is_extension_installed(developer_id, extension_id).await;
|
||||||
source_obj.insert("installed".to_string(), Json::Bool(installed));
|
source_obj.insert("installed".to_string(), Json::Bool(installed));
|
||||||
@@ -170,14 +172,6 @@ pub(crate) async fn search_extension(
|
|||||||
Ok(extensions)
|
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]
|
#[tauri::command]
|
||||||
pub(crate) async fn install_extension_from_store(
|
pub(crate) async fn install_extension_from_store(
|
||||||
tauri_app_handle: AppHandle,
|
tauri_app_handle: AppHandle,
|
||||||
@@ -259,6 +253,12 @@ pub(crate) async fn install_extension_from_store(
|
|||||||
|
|
||||||
drop(plugin_json);
|
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
|
// Write extension files to the extension directory
|
||||||
let developer = extension.developer.clone().unwrap_or_default();
|
let developer = extension.developer.clone().unwrap_or_default();
|
||||||
let extension_id = extension.id.clone();
|
let extension_id = extension.id.clone();
|
||||||
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::Extension;
|
||||||
use super::ExtensionType;
|
|
||||||
use super::LOCAL_QUERY_SOURCE_TYPE;
|
use super::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
use super::PLUGIN_JSON_FILE_NAME;
|
use super::PLUGIN_JSON_FILE_NAME;
|
||||||
use super::alter_extension_json_file;
|
use super::alter_extension_json_file;
|
||||||
@@ -18,15 +18,14 @@ use crate::extension::ExtensionBundleIdBorrowed;
|
|||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use borrowme::ToOwned;
|
use borrowme::ToOwned;
|
||||||
|
use check::general_check;
|
||||||
use function_name::named;
|
use function_name::named;
|
||||||
use std::ffi::OsStr;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use tauri::Runtime;
|
|
||||||
use tauri::async_runtime;
|
use tauri::async_runtime;
|
||||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||||
use tauri_plugin_global_shortcut::ShortcutState;
|
use tauri_plugin_global_shortcut::ShortcutState;
|
||||||
@@ -34,9 +33,7 @@ use tokio::fs::read_dir;
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tokio::sync::RwLockWriteGuard;
|
use tokio::sync::RwLockWriteGuard;
|
||||||
|
|
||||||
pub(crate) fn get_third_party_extension_directory<R: Runtime>(
|
pub(crate) fn get_third_party_extension_directory(tauri_app_handle: &AppHandle) -> PathBuf {
|
||||||
tauri_app_handle: &AppHandle<R>,
|
|
||||||
) -> PathBuf {
|
|
||||||
let mut app_data_dir = tauri_app_handle.path().app_data_dir().expect(
|
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",
|
"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(
|
pub(crate) async fn list_third_party_extensions(
|
||||||
directory: &Path,
|
directory: &Path,
|
||||||
) -> Result<(bool, Vec<Extension>), String> {
|
) -> Result<Vec<Extension>, String> {
|
||||||
let mut found_invalid_extensions = false;
|
|
||||||
|
|
||||||
let mut extensions_dir_iter = read_dir(&directory).await.map_err(|e| e.to_string())?;
|
let mut extensions_dir_iter = read_dir(&directory).await.map_err(|e| e.to_string())?;
|
||||||
let current_platform = Platform::current();
|
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())?;
|
let developer_dir_file_type = developer_dir.file_type().await.map_err(|e| e.to_string())?;
|
||||||
if !developer_dir_file_type.is_dir() {
|
if !developer_dir_file_type.is_dir() {
|
||||||
found_invalid_extensions = true;
|
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"file [{}] under the third party extension directory should be a directory, but it is not",
|
"file [{}] under the third party extension directory should be a directory, but it is not",
|
||||||
developer_dir.file_name().display()
|
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 {
|
let Some(extension_dir) = opt_extension_dir else {
|
||||||
break 'extension;
|
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 =
|
let extension_dir_file_type =
|
||||||
extension_dir.file_type().await.map_err(|e| e.to_string())?;
|
extension_dir.file_type().await.map_err(|e| e.to_string())?;
|
||||||
if !extension_dir_file_type.is_dir() {
|
if !extension_dir_file_type.is_dir() {
|
||||||
found_invalid_extensions = true;
|
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"invalid extension [{}]: a valid extension should be a directory, but it is not",
|
"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
|
// Skip invalid extension
|
||||||
@@ -109,7 +106,6 @@ pub(crate) async fn list_third_party_extensions(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if !plugin_json_file_path.is_file() {
|
if !plugin_json_file_path.is_file() {
|
||||||
found_invalid_extensions = true;
|
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"invalid extension: [{}]: extension file [{}] should be a JSON file, but it is not",
|
"invalid extension: [{}]: extension file [{}] should be a JSON file, but it is not",
|
||||||
extension_dir.file_name().display(),
|
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) {
|
let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) {
|
||||||
Ok(extension) => extension,
|
Ok(extension) => extension,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
found_invalid_extensions = true;
|
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"invalid extension: [{}]: extension file [{}] is invalid, error: '{}'",
|
"invalid extension: [{}]: cannot parse file [{}] as a [struct Extension], error: '{}'",
|
||||||
extension_dir.file_name().display(),
|
extension_dir_file_name,
|
||||||
plugin_json_file_path.display(),
|
plugin_json_file_path.display(),
|
||||||
e
|
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.
|
/* Check starts here */
|
||||||
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
|
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
|
// Skip invalid extension
|
||||||
continue;
|
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);
|
extensions.push(extension);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,203 +194,7 @@ pub(crate) async fn list_third_party_extensions(
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok((found_invalid_extensions, extensions))
|
Ok(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All the third-party extensions will be registered as one search source.
|
/// All the third-party extensions will be registered as one search source.
|
||||||
@@ -419,7 +254,7 @@ impl ThirdPartyExtensionsSearchSource {
|
|||||||
|
|
||||||
if event.state() == ShortcutState::Pressed {
|
if event.state() == ShortcutState::Pressed {
|
||||||
async_runtime::spawn(async move {
|
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 {
|
if let Err(msg) = result {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"failed to open extension [{}], error [{}]",
|
"failed to open extension [{}], error [{}]",
|
||||||
@@ -680,7 +515,7 @@ impl ThirdPartyExtensionsSearchSource {
|
|||||||
|
|
||||||
if event.state() == ShortcutState::Pressed {
|
if event.state() == ShortcutState::Pressed {
|
||||||
async_runtime::spawn(async move {
|
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 {
|
if let Err(msg) = result {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"failed to open extension [{:?}], error [{}]",
|
"failed to open extension [{:?}], error [{}]",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use std::sync::Mutex;
|
|||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use tauri::async_runtime::block_on;
|
use tauri::async_runtime::block_on;
|
||||||
use tauri::plugin::TauriPlugin;
|
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;
|
use tauri_plugin_autostart::MacosLauncher;
|
||||||
|
|
||||||
/// Tauri store name
|
/// Tauri store name
|
||||||
@@ -130,9 +130,7 @@ pub fn run() {
|
|||||||
server::connector::get_connectors_by_server,
|
server::connector::get_connectors_by_server,
|
||||||
search::query_coco_fusion,
|
search::query_coco_fusion,
|
||||||
assistant::chat_history,
|
assistant::chat_history,
|
||||||
assistant::new_chat,
|
|
||||||
assistant::chat_create,
|
assistant::chat_create,
|
||||||
assistant::send_message,
|
|
||||||
assistant::chat_chat,
|
assistant::chat_chat,
|
||||||
assistant::session_chat_history,
|
assistant::session_chat_history,
|
||||||
assistant::open_session_chat,
|
assistant::open_session_chat,
|
||||||
@@ -145,11 +143,9 @@ pub fn run() {
|
|||||||
assistant::assistant_get_multi,
|
assistant::assistant_get_multi,
|
||||||
// server::get_coco_server_datasources,
|
// server::get_coco_server_datasources,
|
||||||
// server::get_coco_server_connectors,
|
// server::get_coco_server_connectors,
|
||||||
server::websocket::connect_to_server,
|
|
||||||
server::websocket::disconnect,
|
|
||||||
get_app_search_source,
|
get_app_search_source,
|
||||||
server::attachment::upload_attachment,
|
server::attachment::upload_attachment,
|
||||||
server::attachment::get_attachment,
|
server::attachment::get_attachment_by_ids,
|
||||||
server::attachment::delete_attachment,
|
server::attachment::delete_attachment,
|
||||||
server::transcription::transcription,
|
server::transcription::transcription,
|
||||||
server::system_settings::get_system_settings,
|
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::add_app_search_path,
|
||||||
extension::built_in::application::remove_app_search_path,
|
extension::built_in::application::remove_app_search_path,
|
||||||
extension::built_in::application::reindex_applications,
|
extension::built_in::application::reindex_applications,
|
||||||
|
extension::quicklink_link_arguments,
|
||||||
extension::list_extensions,
|
extension::list_extensions,
|
||||||
extension::enable_extension,
|
extension::enable_extension,
|
||||||
extension::disable_extension,
|
extension::disable_extension,
|
||||||
@@ -166,8 +163,9 @@ pub fn run() {
|
|||||||
extension::register_extension_hotkey,
|
extension::register_extension_hotkey,
|
||||||
extension::unregister_extension_hotkey,
|
extension::unregister_extension_hotkey,
|
||||||
extension::is_extension_enabled,
|
extension::is_extension_enabled,
|
||||||
extension::third_party::store::search_extension,
|
extension::third_party::install::store::search_extension,
|
||||||
extension::third_party::store::install_extension_from_store,
|
extension::third_party::install::store::install_extension_from_store,
|
||||||
|
extension::third_party::install::local_extension::install_local_extension,
|
||||||
extension::third_party::uninstall_extension,
|
extension::third_party::uninstall_extension,
|
||||||
settings::set_allow_self_signature,
|
settings::set_allow_self_signature,
|
||||||
settings::get_allow_self_signature,
|
settings::get_allow_self_signature,
|
||||||
@@ -200,7 +198,6 @@ pub fn run() {
|
|||||||
let registry = SearchSourceRegistry::default();
|
let registry = SearchSourceRegistry::default();
|
||||||
|
|
||||||
app.manage(registry); // Store registry in Tauri's app state
|
app.manage(registry); // Store registry in Tauri's app state
|
||||||
app.manage(server::websocket::WebSocketManager::default());
|
|
||||||
|
|
||||||
// This has to be called before initializing extensions as doing that
|
// This has to be called before initializing extensions as doing that
|
||||||
// requires access to the shortcut store, which will be set by this
|
// requires access to the shortcut store, which will be set by this
|
||||||
@@ -212,7 +209,7 @@ pub fn run() {
|
|||||||
|
|
||||||
// We want all the extensions here, so no filter condition specified.
|
// We want all the extensions here, so no filter condition specified.
|
||||||
match extension::list_extensions(app_handle.clone(), None, None, false).await {
|
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
|
// Initializing extension relies on SearchSourceRegistry, so this should
|
||||||
// be executed after `app.manage(registry)`
|
// be executed after `app.manage(registry)`
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
@@ -293,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
|
// Await the async functions to load the servers and tokens
|
||||||
if let Err(err) = load_or_insert_default_server(app_handle).await {
|
if let Err(err) = load_or_insert_default_server(app_handle).await {
|
||||||
log::error!("Failed to load servers: {}", err);
|
log::error!("Failed to load servers: {}", err);
|
||||||
@@ -317,7 +314,7 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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) {
|
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||||
move_window_to_active_monitor(&window);
|
move_window_to_active_monitor(&window);
|
||||||
|
|
||||||
@@ -330,7 +327,7 @@ async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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 Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||||
if let Err(err) = window.hide() {
|
if let Err(err) = window.hide() {
|
||||||
log::error!("Failed to hide the window: {}", err);
|
log::error!("Failed to hide the window: {}", err);
|
||||||
@@ -342,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");
|
//dbg!("Moving window to active monitor");
|
||||||
// Try to get the available monitors, handle failure gracefully
|
// Try to get the available monitors, handle failure gracefully
|
||||||
let available_monitors = match window.available_monitors() {
|
let available_monitors = match window.available_monitors() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::common::error::SearchError;
|
use crate::common::error::SearchError;
|
||||||
use crate::common::register::SearchSourceRegistry;
|
use crate::common::register::SearchSourceRegistry;
|
||||||
use crate::common::search::{
|
use crate::common::search::{
|
||||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QueryResponse, QuerySource, SearchQuery,
|
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||||
};
|
};
|
||||||
use crate::common::traits::SearchSource;
|
use crate::common::traits::SearchSource;
|
||||||
use crate::server::servers::logout_coco_server;
|
use crate::server::servers::logout_coco_server;
|
||||||
@@ -13,74 +13,24 @@ use reqwest::StatusCode;
|
|||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::future::Future;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use tokio::time::error::Elapsed;
|
|
||||||
use tokio::time::{Duration, timeout};
|
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]
|
#[named]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn query_coco_fusion(
|
pub async fn query_coco_fusion(
|
||||||
app_handle: AppHandle,
|
tauri_app_handle: AppHandle,
|
||||||
from: u64,
|
from: u64,
|
||||||
size: u64,
|
size: u64,
|
||||||
query_strings: HashMap<String, String>,
|
query_strings: HashMap<String, String>,
|
||||||
query_timeout: u64,
|
query_timeout: u64,
|
||||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
) -> 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 opt_query_source_id = query_strings.get("querysource");
|
||||||
|
let search_sources = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||||
let search_sources = app_handle.state::<SearchSourceRegistry>();
|
let query_source_list = search_sources.get_sources().await;
|
||||||
|
|
||||||
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 timeout_duration = Duration::from_millis(query_timeout);
|
let timeout_duration = Duration::from_millis(query_timeout);
|
||||||
|
let search_query = SearchQuery::new(from, size, query_strings.clone());
|
||||||
|
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"{}() invoked with parameters: from: [{}], size: [{}], query_strings: [{:?}], timeout: [{:?}]",
|
"{}() invoked with parameters: from: [{}], size: [{}], query_strings: [{:?}], timeout: [{:?}]",
|
||||||
@@ -91,68 +41,170 @@ pub async fn query_coco_fusion(
|
|||||||
timeout_duration
|
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 let Some(query_source_id) = opt_query_source_id {
|
||||||
// If this query source ID is specified, we only query this query source.
|
query_coco_fusion_single_query_source(
|
||||||
log::debug!(
|
tauri_app_handle,
|
||||||
"parameter [querysource={}] specified, will only query this querysource",
|
query_source_list,
|
||||||
query_source_id
|
query_source_id.clone(),
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
|
||||||
timeout_duration,
|
timeout_duration,
|
||||||
search_query,
|
search_query,
|
||||||
app_handle.clone(),
|
)
|
||||||
));
|
.await
|
||||||
} else {
|
} else {
|
||||||
log::debug!(
|
query_coco_fusion_multi_query_sources(
|
||||||
"will query querysources {:?}",
|
tauri_app_handle,
|
||||||
sources_list
|
query_source_list,
|
||||||
.iter()
|
timeout_duration,
|
||||||
.map(|search_source| search_source.get_type().id.clone())
|
search_query,
|
||||||
.collect::<Vec<String>>()
|
)
|
||||||
);
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for query_source_trait_object in sources_list {
|
/// Query only 1 query source.
|
||||||
let query_source = query_source_trait_object.get_type().clone();
|
///
|
||||||
futures.push(same_type_futures(
|
/// The logic here is much simpler than `query_coco_fusion_multi_query_sources()`
|
||||||
query_source,
|
/// as we don't need to re-rank due to fact that this does not involve multiple
|
||||||
query_source_trait_object,
|
/// query sources.
|
||||||
timeout_duration,
|
async fn query_coco_fusion_single_query_source(
|
||||||
search_query.clone(),
|
tauri_app_handle: AppHandle,
|
||||||
app_handle.clone(),
|
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;
|
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 all_hits: Vec<(String, QueryHits, f64)> = Vec::new();
|
||||||
let mut hits_per_source: HashMap<String, Vec<(QueryHits, f64)>> = HashMap::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
|
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",
|
"searching query source [{}] timed out, skip this request",
|
||||||
query_source.id
|
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(query_result) => match query_result {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
total_hits += response.total_hits;
|
total_hits += response.total_hits;
|
||||||
let source_id = response.source.id.clone();
|
let source_id = response.source.id.clone();
|
||||||
|
|
||||||
for (doc, score) in response.hits {
|
for (document, score) in response.hits {
|
||||||
log::debug!("doc: {}, {:?}, {}", doc.id, doc.title, score);
|
log::debug!(
|
||||||
|
"document from query source [{}]: ID [{}], title [{:?}], score [{}]",
|
||||||
|
response.source.id,
|
||||||
|
document.id,
|
||||||
|
document.title,
|
||||||
|
score
|
||||||
|
);
|
||||||
|
|
||||||
let query_hit = QueryHits {
|
let query_hit = QueryHits {
|
||||||
source: Some(response.source.clone()),
|
source: Some(response.source.clone()),
|
||||||
score,
|
score,
|
||||||
document: doc,
|
document,
|
||||||
};
|
};
|
||||||
|
|
||||||
all_hits.push((source_id.clone(), query_hit.clone(), score));
|
all_hits.push((source_id.clone(), query_hit.clone(), score));
|
||||||
@@ -203,46 +255,13 @@ pub async fn query_coco_fusion(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(search_error) => {
|
Err(search_error) => {
|
||||||
log::error!(
|
query_coco_fusion_handle_failed_request(
|
||||||
"searching query source [{}] failed, error [{}]",
|
tauri_app_handle.clone(),
|
||||||
query_source.id,
|
&mut failed_requests,
|
||||||
search_error
|
query_source,
|
||||||
);
|
search_error,
|
||||||
|
)
|
||||||
let mut status_code_num: u16 = 0;
|
.await;
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -402,3 +421,54 @@ fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(u
|
|||||||
})
|
})
|
||||||
.collect()
|
.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]
|
#[command]
|
||||||
pub async fn get_attachment(server_id: String, session_id: String) -> Result<Value, String> {
|
pub async fn get_attachment_by_ids(
|
||||||
let mut query_params = Vec::new();
|
server_id: String,
|
||||||
query_params.push(format!("session={}", session_id));
|
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
|
.await
|
||||||
.map_err(|e| format!("Request error: {}", e))?;
|
.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,
|
get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server,
|
||||||
try_register_server_to_search_source,
|
try_register_server_to_search_source,
|
||||||
};
|
};
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::AppHandle;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn request_access_token_url(request_id: &str) -> String {
|
fn request_access_token_url(request_id: &str) -> String {
|
||||||
@@ -13,8 +13,8 @@ fn request_access_token_url(request_id: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn handle_sso_callback<R: Runtime>(
|
pub async fn handle_sso_callback(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
request_id: String,
|
request_id: String,
|
||||||
code: String,
|
code: String,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use http::StatusCode;
|
|||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::AppHandle;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref CONNECTOR_CACHE: Arc<RwLock<HashMap<String, HashMap<String, Connector>>>> =
|
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())
|
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;
|
let servers = get_all_servers().await;
|
||||||
|
|
||||||
// Collect all the tasks for fetching and refreshing connectors
|
// 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]
|
#[tauri::command]
|
||||||
pub async fn get_connectors_by_server<R: Runtime>(
|
pub async fn get_connectors_by_server(
|
||||||
_app_handle: AppHandle<R>,
|
_app_handle: AppHandle,
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<Vec<Connector>, String> {
|
) -> Result<Vec<Connector>, String> {
|
||||||
let connectors = fetch_connectors_by_server(&id).await?;
|
let connectors = fetch_connectors_by_server(&id).await?;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use http::StatusCode;
|
|||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::AppHandle;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String, HashMap<String, DataSource>>>> =
|
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())
|
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");
|
// dbg!("Attempting to refresh all datasources");
|
||||||
|
|
||||||
let servers = get_all_servers().await;
|
let servers = get_all_servers().await;
|
||||||
|
|||||||
@@ -11,4 +11,3 @@ pub mod servers;
|
|||||||
pub mod synthesize;
|
pub mod synthesize;
|
||||||
pub mod system_settings;
|
pub mod system_settings;
|
||||||
pub mod transcription;
|
pub mod transcription;
|
||||||
pub mod websocket;
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use crate::common::http::get_response_body_text;
|
use crate::common::http::get_response_body_text;
|
||||||
use crate::common::profile::UserProfile;
|
use crate::common::profile::UserProfile;
|
||||||
use crate::server::http_client::HttpClient;
|
use crate::server::http_client::HttpClient;
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::AppHandle;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_user_profiles<R: Runtime>(
|
pub async fn get_user_profiles(
|
||||||
_app_handle: AppHandle<R>,
|
_app_handle: AppHandle,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
) -> Result<UserProfile, String> {
|
) -> Result<UserProfile, String> {
|
||||||
// Use the generic GET method from HttpClient
|
// Use the generic GET method from HttpClient
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ use serde_json::Value as JsonValue;
|
|||||||
use serde_json::from_value;
|
use serde_json::from_value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use tauri::Runtime;
|
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
@@ -70,7 +69,7 @@ async fn remove_server_by_id(id: &str) -> Option<Server> {
|
|||||||
cache.remove(id)
|
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;
|
let cache = SERVER_LIST_CACHE.read().await;
|
||||||
|
|
||||||
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
|
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
|
||||||
@@ -99,7 +98,7 @@ pub async fn remove_server_token(id: &str) -> bool {
|
|||||||
cache.remove(id).is_some()
|
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;
|
let cache = SERVER_TOKEN_LIST_CACHE.read().await;
|
||||||
|
|
||||||
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
|
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
|
||||||
@@ -158,9 +157,7 @@ fn get_default_server() -> Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_servers_token<R: Runtime>(
|
pub async fn load_servers_token(app_handle: &AppHandle) -> Result<Vec<ServerAccessToken>, String> {
|
||||||
app_handle: &AppHandle<R>,
|
|
||||||
) -> Result<Vec<ServerAccessToken>, String> {
|
|
||||||
log::debug!("Attempting to load servers token");
|
log::debug!("Attempting to load servers token");
|
||||||
|
|
||||||
let store = app_handle
|
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
|
let store = app_handle
|
||||||
.store(COCO_TAURI_STORE)
|
.store(COCO_TAURI_STORE)
|
||||||
.expect("create or load a store should not fail");
|
.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
|
/// Function to load servers or insert a default one if none exist
|
||||||
pub async fn load_or_insert_default_server<R: Runtime>(
|
pub async fn load_or_insert_default_server(app_handle: &AppHandle) -> Result<Vec<Server>, String> {
|
||||||
app_handle: &AppHandle<R>,
|
|
||||||
) -> Result<Vec<Server>, String> {
|
|
||||||
log::debug!("Attempting to load or insert default server");
|
log::debug!("Attempting to load or insert default server");
|
||||||
|
|
||||||
let exists_servers = load_servers(&app_handle).await;
|
let exists_servers = load_servers(&app_handle).await;
|
||||||
@@ -296,9 +291,7 @@ pub async fn load_or_insert_default_server<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_coco_servers<R: Runtime>(
|
pub async fn list_coco_servers(app_handle: AppHandle) -> Result<Vec<Server>, String> {
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
) -> Result<Vec<Server>, String> {
|
|
||||||
//hard fresh all server's info, in order to get the actual health
|
//hard fresh all server's info, in order to get the actual health
|
||||||
refresh_all_coco_server_info(app_handle.clone()).await;
|
refresh_all_coco_server_info(app_handle.clone()).await;
|
||||||
|
|
||||||
@@ -312,7 +305,7 @@ pub async fn get_all_servers() -> Vec<Server> {
|
|||||||
cache.values().cloned().collect()
|
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;
|
let servers = get_all_servers().await;
|
||||||
for server in servers {
|
for server in servers {
|
||||||
let _ = refresh_coco_server_info(app_handle.clone(), server.id.clone()).await;
|
let _ = refresh_coco_server_info(app_handle.clone(), server.id.clone()).await;
|
||||||
@@ -320,10 +313,7 @@ pub async fn refresh_all_coco_server_info<R: Runtime>(app_handle: AppHandle<R>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn refresh_coco_server_info<R: Runtime>(
|
pub async fn refresh_coco_server_info(app_handle: AppHandle, id: String) -> Result<Server, String> {
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
id: String,
|
|
||||||
) -> Result<Server, String> {
|
|
||||||
// Retrieve the server from the cache
|
// Retrieve the server from the cache
|
||||||
let cached_server = {
|
let cached_server = {
|
||||||
let cache = SERVER_LIST_CACHE.read().await;
|
let cache = SERVER_LIST_CACHE.read().await;
|
||||||
@@ -393,10 +383,7 @@ pub async fn refresh_coco_server_info<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn add_coco_server<R: Runtime>(
|
pub async fn add_coco_server(app_handle: AppHandle, endpoint: String) -> Result<Server, String> {
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
endpoint: String,
|
|
||||||
) -> Result<Server, String> {
|
|
||||||
load_or_insert_default_server(&app_handle)
|
load_or_insert_default_server(&app_handle)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to load default servers: {}", e))?;
|
.map_err(|e| format!("Failed to load default servers: {}", e))?;
|
||||||
@@ -472,10 +459,7 @@ pub async fn add_coco_server<R: Runtime>(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[function_name::named]
|
#[function_name::named]
|
||||||
pub async fn remove_coco_server<R: Runtime>(
|
pub async fn remove_coco_server(app_handle: AppHandle, id: String) -> Result<(), ()> {
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
id: String,
|
|
||||||
) -> Result<(), ()> {
|
|
||||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||||
registry.remove_source(id.as_str()).await;
|
registry.remove_source(id.as_str()).await;
|
||||||
|
|
||||||
@@ -507,7 +491,7 @@ pub async fn remove_coco_server<R: Runtime>(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[function_name::named]
|
#[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 opt_server = get_server_by_id(id.as_str()).await;
|
||||||
|
|
||||||
let Some(mut server) = opt_server else {
|
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]
|
#[tauri::command]
|
||||||
#[function_name::named]
|
#[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 opt_server = get_server_by_id(id.as_str()).await;
|
||||||
|
|
||||||
let Some(mut server) = opt_server else {
|
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.
|
/// enabled.
|
||||||
///
|
///
|
||||||
/// For public Coco server, an extra token is required.
|
/// For public Coco server, an extra token is required.
|
||||||
pub async fn try_register_server_to_search_source(
|
pub async fn try_register_server_to_search_source(app_handle: AppHandle, server: &Server) {
|
||||||
app_handle: AppHandle<impl Runtime>,
|
|
||||||
server: &Server,
|
|
||||||
) {
|
|
||||||
if server.enabled {
|
if server.enabled {
|
||||||
log::trace!(
|
log::trace!(
|
||||||
"Server [name: {}, id: {}] is public: {} and available: {}",
|
"Server [name: {}, id: {}] is public: {} and available: {}",
|
||||||
@@ -590,7 +571,7 @@ pub async fn try_register_server_to_search_source(
|
|||||||
|
|
||||||
#[function_name::named]
|
#[function_name::named]
|
||||||
#[allow(unused)]
|
#[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;
|
let server = get_server_by_id(id).await;
|
||||||
if let Some(mut server) = server {
|
if let Some(mut server) = server {
|
||||||
server.available = true;
|
server.available = true;
|
||||||
@@ -608,7 +589,7 @@ async fn mark_server_as_online<R: Runtime>(app_handle: AppHandle<R>, id: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[function_name::named]
|
#[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;
|
let server = get_server_by_id(id).await;
|
||||||
if let Some(mut server) = server {
|
if let Some(mut server) = server {
|
||||||
server.available = false;
|
server.available = false;
|
||||||
@@ -628,10 +609,7 @@ pub(crate) async fn mark_server_as_offline<R: Runtime>(app_handle: AppHandle<R>,
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[function_name::named]
|
#[function_name::named]
|
||||||
pub async fn logout_coco_server<R: Runtime>(
|
pub async fn logout_coco_server(app_handle: AppHandle, id: String) -> Result<(), String> {
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
id: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
log::debug!("Attempting to log out server by id: {}", &id);
|
log::debug!("Attempting to log out server by id: {}", &id);
|
||||||
|
|
||||||
// Check if the server exists
|
// Check if the server exists
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ use crate::server::http_client::HttpClient;
|
|||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use http::Method;
|
use http::Method;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tauri::{AppHandle, Emitter, Runtime, command};
|
use tauri::{AppHandle, Emitter, command};
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn synthesize<R: Runtime>(
|
pub async fn synthesize(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle,
|
||||||
client_id: String,
|
client_id: String,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
voice: 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 crate::COCO_TAURI_STORE;
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::AppHandle;
|
||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
|
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
|
||||||
|
|
||||||
#[tauri::command]
|
#[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;
|
use crate::server::http_client;
|
||||||
|
|
||||||
let store = tauri_app_handle
|
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()`.
|
/// 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
|
let store = tauri_app_handle
|
||||||
.store(COCO_TAURI_STORE)
|
.store(COCO_TAURI_STORE)
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
@@ -67,6 +67,6 @@ pub fn _get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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)
|
_get_allow_self_signature(tauri_app_handle)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::{COCO_TAURI_STORE, hide_coco, show_coco};
|
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_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||||
use tauri_plugin_store::{JsonValue, StoreExt};
|
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
|
/// Get the stored shortcut as a string, same as [`_get_shortcut()`], except that
|
||||||
/// this is a `tauri::command` interface.
|
/// this is a `tauri::command` interface.
|
||||||
#[tauri::command]
|
#[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);
|
let shortcut = _get_shortcut(&app);
|
||||||
Ok(shortcut)
|
Ok(shortcut)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current shortcut and unregister it on the tauri side.
|
/// Get the current shortcut and unregister it on the tauri side.
|
||||||
#[tauri::command]
|
#[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_str = _get_shortcut(&app);
|
||||||
let shortcut = shortcut_str
|
let shortcut = shortcut_str
|
||||||
.parse::<Shortcut>()
|
.parse::<Shortcut>()
|
||||||
@@ -70,9 +70,9 @@ pub async fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
|
|||||||
|
|
||||||
/// Change the global shortcut to `key`.
|
/// Change the global shortcut to `key`.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn change_shortcut<R: Runtime>(
|
pub async fn change_shortcut(
|
||||||
app: AppHandle<R>,
|
app: AppHandle,
|
||||||
_window: tauri::Window<R>,
|
_window: tauri::Window,
|
||||||
key: String,
|
key: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
println!("key {}:", key);
|
println!("key {}:", key);
|
||||||
@@ -94,7 +94,7 @@ pub async fn change_shortcut<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Helper function to register a shortcut, used for shortcut updates.
|
/// 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()
|
app.global_shortcut()
|
||||||
.on_shortcut(shortcut, move |app, scut, event| {
|
.on_shortcut(shortcut, move |app, scut, event| {
|
||||||
if scut == &shortcut {
|
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.
|
/// 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
|
let store = app
|
||||||
.get_store(COCO_TAURI_STORE)
|
.get_store(COCO_TAURI_STORE)
|
||||||
.expect("store should be loaded or created");
|
.expect("store should be loaded or created");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ pub(crate) mod platform;
|
|||||||
pub(crate) mod updater;
|
pub(crate) mod updater;
|
||||||
|
|
||||||
use std::{path::Path, process::Command};
|
use std::{path::Path, process::Command};
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::AppHandle;
|
||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
|
|
||||||
/// We use this env variable to determine the DE on Linux.
|
/// 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.
|
// tauri_plugin_shell::open() is deprecated, but we still use it.
|
||||||
#[allow(deprecated)]
|
#[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") {
|
if cfg!(target_os = "linux") {
|
||||||
let borrowed_path = Path::new(&path);
|
let borrowed_path = Path::new(&path);
|
||||||
if let Some(file_extension) = borrowed_path.extension() {
|
if let Some(file_extension) = borrowed_path.extension() {
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
use derive_more::Display;
|
use derive_more::Display;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::borrow::Cow;
|
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"))]
|
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
|
||||||
pub(crate) enum Platform {
|
pub(crate) enum Platform {
|
||||||
#[display("macOS")]
|
#[display("macOS")]
|
||||||
@@ -18,7 +32,7 @@ impl Platform {
|
|||||||
pub(crate) fn current() -> Platform {
|
pub(crate) fn current() -> Platform {
|
||||||
let os_str = std::env::consts::OS;
|
let os_str = std::env::consts::OS;
|
||||||
serde_plain::from_str(os_str).unwrap_or_else(|_e| {
|
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()),
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,6 @@
|
|||||||
"https://release.infinilabs.com/coco/app/.latest.json?target={{target}}&arch={{arch}}¤t_version={{current_version}}"
|
"https://release.infinilabs.com/coco/app/.latest.json?target={{target}}&arch={{arch}}¤t_version={{current_version}}"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"websocket": {},
|
|
||||||
"shell": {},
|
"shell": {},
|
||||||
"globalShortcut": {},
|
"globalShortcut": {},
|
||||||
"deep-link": {
|
"deep-link": {
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ export const Get = <T>(
|
|||||||
} else {
|
} else {
|
||||||
res = result?.data as FcResponse<T>;
|
res = result?.data as FcResponse<T>;
|
||||||
}
|
}
|
||||||
|
// web component log
|
||||||
|
infoLog({
|
||||||
|
username: "@/api/axiosRequest.ts",
|
||||||
|
logName: url,
|
||||||
|
})(res);
|
||||||
|
|
||||||
resolve([null, res as FcResponse<T>]);
|
resolve([null, res as FcResponse<T>]);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -103,7 +109,7 @@ export const Post = <T>(
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
|
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
|
||||||
|
|
||||||
let baseURL = appStore.state?.endpoint_http
|
let baseURL = appStore.state?.endpoint_http;
|
||||||
if (!baseURL || baseURL === "undefined") {
|
if (!baseURL || baseURL === "undefined") {
|
||||||
baseURL = "";
|
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 { invoke } from "@tauri-apps/api/core";
|
||||||
import { emit } from "@tauri-apps/api/event";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Server,
|
Server,
|
||||||
@@ -8,7 +7,7 @@ import {
|
|||||||
GetResponse,
|
GetResponse,
|
||||||
UploadAttachmentPayload,
|
UploadAttachmentPayload,
|
||||||
UploadAttachmentResponse,
|
UploadAttachmentResponse,
|
||||||
GetAttachmentPayload,
|
GetAttachmentByIdsPayload,
|
||||||
GetAttachmentResponse,
|
GetAttachmentResponse,
|
||||||
DeleteAttachmentPayload,
|
DeleteAttachmentPayload,
|
||||||
TranscriptionPayload,
|
TranscriptionPayload,
|
||||||
@@ -18,17 +17,42 @@ import {
|
|||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
|
import { SETTINGS_WINDOW_LABEL } from "@/constants";
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
|
||||||
export function handleLogout(serverId?: string) {
|
export async function getCurrentWindowService() {
|
||||||
const setIsCurrentLogin = useAuthStore.getState().setIsCurrentLogin;
|
const currentService = useConnectStore.getState().currentService;
|
||||||
const { currentService, setCurrentService, serverList, setServerList } =
|
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();
|
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;
|
if (!id) return;
|
||||||
|
|
||||||
|
// Update the status first
|
||||||
setIsCurrentLogin(false);
|
setIsCurrentLogin(false);
|
||||||
emit("login_or_logout", false);
|
if (service?.id === id) {
|
||||||
if (currentService?.id === id) {
|
await setCurrentWindowService({ ...service, profile: null });
|
||||||
setCurrentService({ ...currentService, profile: null });
|
|
||||||
}
|
}
|
||||||
const updatedServerList = serverList.map((server) =>
|
const updatedServerList = serverList.map((server) =>
|
||||||
server.id === id ? { ...server, profile: null } : server
|
server.id === id ? { ...server, profile: null } : server
|
||||||
@@ -55,13 +79,14 @@ async function invokeWithErrorHandler<T>(
|
|||||||
args?: Record<string, any>
|
args?: Record<string, any>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const isCurrentLogin = useAuthStore.getState().isCurrentLogin;
|
const isCurrentLogin = useAuthStore.getState().isCurrentLogin;
|
||||||
const currentService = useConnectStore.getState().currentService;
|
|
||||||
|
const service = await getCurrentWindowService();
|
||||||
|
|
||||||
// Not logged in
|
// Not logged in
|
||||||
console.log(command, isCurrentLogin, currentService?.profile);
|
// console.log("isCurrentLogin", command, isCurrentLogin);
|
||||||
if (
|
if (
|
||||||
!WHITELIST_SERVERS.includes(command) &&
|
!WHITELIST_SERVERS.includes(command) &&
|
||||||
(!isCurrentLogin || !currentService?.profile)
|
(!isCurrentLogin || !service?.profile)
|
||||||
) {
|
) {
|
||||||
console.error("This command requires authentication");
|
console.error("This command requires authentication");
|
||||||
throw new Error("This command requires authentication");
|
throw new Error("This command requires authentication");
|
||||||
@@ -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;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMessage = error || "Command execution failed";
|
const errorMessage = error || "Command execution failed";
|
||||||
@@ -172,14 +209,6 @@ export function mcp_server_search({
|
|||||||
return invokeWithErrorHandler(`mcp_server_search`, { id, queryParams });
|
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({
|
export function chat_history({
|
||||||
serverId,
|
serverId,
|
||||||
from = 0,
|
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({
|
export function chat_create({
|
||||||
serverId,
|
serverId,
|
||||||
message,
|
message,
|
||||||
|
attachments,
|
||||||
queryParams,
|
queryParams,
|
||||||
clientId,
|
clientId,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
attachments: string[];
|
||||||
queryParams?: Record<string, any>;
|
queryParams?: Record<string, any>;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
}): Promise<GetResponse> {
|
}): Promise<GetResponse> {
|
||||||
return invokeWithErrorHandler(`chat_create`, {
|
return invokeWithErrorHandler(`chat_create`, {
|
||||||
serverId,
|
serverId,
|
||||||
message,
|
message,
|
||||||
|
attachments,
|
||||||
queryParams,
|
queryParams,
|
||||||
clientId,
|
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({
|
export function chat_chat({
|
||||||
serverId,
|
serverId,
|
||||||
sessionId,
|
sessionId,
|
||||||
message,
|
message,
|
||||||
|
attachments,
|
||||||
queryParams,
|
queryParams,
|
||||||
clientId,
|
clientId,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
attachments: string[];
|
||||||
queryParams?: Record<string, any>;
|
queryParams?: Record<string, any>;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
@@ -337,6 +330,7 @@ export function chat_chat({
|
|||||||
serverId,
|
serverId,
|
||||||
sessionId,
|
sessionId,
|
||||||
message,
|
message,
|
||||||
|
attachments,
|
||||||
queryParams,
|
queryParams,
|
||||||
clientId,
|
clientId,
|
||||||
});
|
});
|
||||||
@@ -391,10 +385,13 @@ export const upload_attachment = async (payload: UploadAttachmentPayload) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const get_attachment = (payload: GetAttachmentPayload) => {
|
export const get_attachment_by_ids = (payload: GetAttachmentByIdsPayload) => {
|
||||||
return invokeWithErrorHandler<GetAttachmentResponse>("get_attachment", {
|
return invokeWithErrorHandler<GetAttachmentResponse>(
|
||||||
...payload,
|
"get_attachment_by_ids",
|
||||||
});
|
{
|
||||||
|
...payload,
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const delete_attachment = (payload: DeleteAttachmentPayload) => {
|
export const delete_attachment = (payload: DeleteAttachmentPayload) => {
|
||||||
@@ -420,4 +417,4 @@ export const query_coco_fusion = (payload: {
|
|||||||
|
|
||||||
export const get_app_search_source = () => {
|
export const get_app_search_source = () => {
|
||||||
return invokeWithErrorHandler<void>("get_app_search_source");
|
return invokeWithErrorHandler<void>("get_app_search_source");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,8 +57,6 @@ export const AssistantFetcher = ({
|
|||||||
|
|
||||||
let assistantList = response?.hits?.hits ?? [];
|
let assistantList = response?.hits?.hits ?? [];
|
||||||
|
|
||||||
console.log("assistantList", assistantList);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!currentAssistant?._id ||
|
!currentAssistant?._id ||
|
||||||
currentService?.id !== lastServerId.current
|
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;
|
instanceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SendMessageParams {
|
||||||
|
message?: string;
|
||||||
|
attachments?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatAIRef {
|
export interface ChatAIRef {
|
||||||
init: (value: string) => void;
|
init: (params: SendMessageParams) => void;
|
||||||
cancelChat: () => void;
|
cancelChat: () => void;
|
||||||
clearChat: () => void;
|
clearChat: () => void;
|
||||||
}
|
}
|
||||||
@@ -188,7 +193,7 @@ const ChatAI = memo(
|
|||||||
isDeepThinkActive,
|
isDeepThinkActive,
|
||||||
isMCPActive,
|
isMCPActive,
|
||||||
changeInput,
|
changeInput,
|
||||||
showChatHistory,
|
showChatHistory
|
||||||
);
|
);
|
||||||
|
|
||||||
const { dealMsg } = useMessageHandler(
|
const { dealMsg } = useMessageHandler(
|
||||||
@@ -225,7 +230,7 @@ const ChatAI = memo(
|
|||||||
}, [activeChat, chatClose]);
|
}, [activeChat, chatClose]);
|
||||||
|
|
||||||
const init = useCallback(
|
const init = useCallback(
|
||||||
async (value: string) => {
|
async (params: SendMessageParams) => {
|
||||||
try {
|
try {
|
||||||
//console.log("init", curChatEnd, activeChat?._id);
|
//console.log("init", curChatEnd, activeChat?._id);
|
||||||
if (!isCurrentLogin) {
|
if (!isCurrentLogin) {
|
||||||
@@ -237,9 +242,9 @@ const ChatAI = memo(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!activeChat?._id) {
|
if (!activeChat?._id) {
|
||||||
await createNewChat(value);
|
await createNewChat(params);
|
||||||
} else {
|
} else {
|
||||||
await handleSendMessage(value, activeChat);
|
await handleSendMessage(activeChat, params);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to initialize chat:", error);
|
console.error("Failed to initialize chat:", error);
|
||||||
@@ -285,7 +290,10 @@ const ChatAI = memo(
|
|||||||
if (updatedChats.length > 0) {
|
if (updatedChats.length > 0) {
|
||||||
setActiveChat(updatedChats[0]);
|
setActiveChat(updatedChats[0]);
|
||||||
} else {
|
} else {
|
||||||
init("");
|
init({
|
||||||
|
message: "",
|
||||||
|
attachments: [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,8 +404,8 @@ const ChatAI = memo(
|
|||||||
loadingStep={loadingStep}
|
loadingStep={loadingStep}
|
||||||
timedoutShow={timedoutShow}
|
timedoutShow={timedoutShow}
|
||||||
Question={Question}
|
Question={Question}
|
||||||
handleSendMessage={(value) =>
|
handleSendMessage={(message) =>
|
||||||
handleSendMessage(value, activeChat)
|
handleSendMessage(activeChat, { message })
|
||||||
}
|
}
|
||||||
getFileUrl={getFileUrl}
|
getFileUrl={getFileUrl}
|
||||||
formatUrl={formatUrl}
|
formatUrl={formatUrl}
|
||||||
@@ -410,7 +418,11 @@ const ChatAI = memo(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!activeChat?._id && !visibleStartPage && (
|
{!activeChat?._id && !visibleStartPage && (
|
||||||
<PrevSuggestion sendMessage={init} />
|
<PrevSuggestion
|
||||||
|
sendMessage={(message) => {
|
||||||
|
init({ message });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { ChatMessage } from "@/components/ChatMessage";
|
import { ChatMessage } from "@/components/ChatMessage";
|
||||||
import { Greetings } from "./Greetings";
|
import { Greetings } from "./Greetings";
|
||||||
// import FileList from "@/components/Assistant/FileList";
|
import AttachmentList from "@/components/Assistant/AttachmentList";
|
||||||
import { useChatScroll } from "@/hooks/useChatScroll";
|
import { useChatScroll } from "@/hooks/useChatScroll";
|
||||||
import { useChatStore } from "@/stores/chatStore";
|
|
||||||
import type { Chat, IChunkData } from "@/types/chat";
|
import type { Chat, IChunkData } from "@/types/chat";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
// import SessionFile from "./SessionFile";
|
// import SessionFile from "./SessionFile";
|
||||||
import ScrollToBottom from "@/components/Common/ScrollToBottom";
|
import ScrollToBottom from "@/components/Common/ScrollToBottom";
|
||||||
|
import { useChatStore } from "@/stores/chatStore";
|
||||||
|
|
||||||
interface ChatContentProps {
|
interface ChatContentProps {
|
||||||
activeChat?: Chat;
|
activeChat?: Chat;
|
||||||
@@ -44,14 +45,12 @@ export const ChatContent = ({
|
|||||||
handleSendMessage,
|
handleSendMessage,
|
||||||
formatUrl,
|
formatUrl,
|
||||||
}: ChatContentProps) => {
|
}: ChatContentProps) => {
|
||||||
// const sessionId = useConnectStore((state) => state.currentSessionId);
|
const { currentSessionId, setCurrentSessionId } = useConnectStore();
|
||||||
const setCurrentSessionId = useConnectStore((state) => {
|
|
||||||
return state.setCurrentSessionId;
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// const uploadFiles = useChatStore((state) => state.uploadFiles);
|
const { uploadAttachments } = useChatStore();
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { scrollToBottom } = useChatScroll(messagesEndRef);
|
const { scrollToBottom } = useChatScroll(messagesEndRef);
|
||||||
@@ -168,13 +167,13 @@ export const ChatContent = ({
|
|||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* {uploadFiles.length > 0 && (
|
{uploadAttachments.length > 0 && (
|
||||||
<div key={sessionId} className="max-h-[120px] overflow-auto p-2">
|
<div key={currentSessionId} className="max-h-[120px] overflow-auto p-2">
|
||||||
<FileList />
|
<AttachmentList />
|
||||||
</div>
|
</div>
|
||||||
)} */}
|
)}
|
||||||
|
|
||||||
{/* {sessionId && <SessionFile sessionId={sessionId} />} */}
|
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
|
||||||
|
|
||||||
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
|
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 StatusIndicator from "@/components/Cloud/StatusIndicator";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { useSearchStore } from "@/stores/searchStore";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
|
import { useServers } from "@/hooks/useServers";
|
||||||
|
import { getCurrentWindowService, setCurrentWindowService } from "@/commands";
|
||||||
|
|
||||||
interface ServerListProps {
|
interface ServerListProps {
|
||||||
clearChat: () => void;
|
clearChat: () => void;
|
||||||
@@ -25,17 +27,20 @@ interface ServerListProps {
|
|||||||
export function ServerList({ clearChat }: ServerListProps) {
|
export function ServerList({ clearChat }: ServerListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
|
|
||||||
const setIsCurrentLogin = useAuthStore((state) => state.setIsCurrentLogin);
|
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 setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
|
||||||
const isTauri = useAppStore((state) => state.isTauri);
|
const isTauri = useAppStore((state) => state.isTauri);
|
||||||
const currentService = useConnectStore((state) => state.currentService);
|
const currentService = useConnectStore((state) => state.currentService);
|
||||||
|
const cloudSelectService = useConnectStore((state) => {
|
||||||
|
return state.cloudSelectService;
|
||||||
|
});
|
||||||
|
|
||||||
const { setMessages } = useChatStore();
|
const { setMessages } = useChatStore();
|
||||||
|
|
||||||
const [serverList, setServerList] = useState<IServer[]>([]);
|
const [list, setList] = useState<IServer[]>([]);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [highlightId, setHighlightId] = useState<string>("");
|
const [highlightId, setHighlightId] = useState<string>("");
|
||||||
|
|
||||||
@@ -49,44 +54,49 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const serverListButtonRef = useRef<HTMLButtonElement>(null);
|
const serverListButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const fetchServers = useCallback(
|
const { refreshServerList } = useServers();
|
||||||
async (resetSelection: boolean) => {
|
const serverList = useConnectStore((state) => state.serverList);
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
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 fetchServers = useCallback(async () => {
|
||||||
const currentServiceExists = enabledServers.find(
|
const service = await getCurrentWindowService();
|
||||||
(server) => server.id === currentService?.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentServiceExists) {
|
const enabledServers = serverList.filter(
|
||||||
switchServer(currentServiceExists);
|
(server) => server.enabled && server.available
|
||||||
} else {
|
);
|
||||||
switchServer(enabledServers[enabledServers.length - 1]);
|
setList(enabledServers);
|
||||||
}
|
|
||||||
}
|
if (enabledServers.length > 0) {
|
||||||
|
const serviceExists = enabledServers.find((server) => {
|
||||||
|
return server.id === service?.id;
|
||||||
});
|
});
|
||||||
},
|
|
||||||
[currentService?.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (serviceExists) {
|
||||||
if (!isTauri) return;
|
switchServer(serviceExists);
|
||||||
|
} else {
|
||||||
fetchServers(true);
|
switchServer(enabledServers[enabledServers.length - 1]);
|
||||||
}, [currentService?.enabled]);
|
}
|
||||||
|
}
|
||||||
|
}, [currentService?.id, cloudSelectService?.id, serverList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!askAiServerId || serverList.length === 0) return;
|
if (!askAiServerId || serverList.length === 0) return;
|
||||||
@@ -104,25 +114,12 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri) return;
|
if (!isTauri) return;
|
||||||
|
|
||||||
fetchServers(true);
|
fetchServers();
|
||||||
|
}, [serverList]);
|
||||||
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());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
await fetchServers(false);
|
await refreshServerList();
|
||||||
setTimeout(() => setIsRefreshing(false), 1000);
|
setTimeout(() => setIsRefreshing(false), 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,29 +127,10 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
platformAdapter.emitEvent("open_settings", "connect");
|
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(
|
useKeyPress(
|
||||||
["uparrow", "downarrow", "enter"],
|
["uparrow", "downarrow", "enter"],
|
||||||
(event, key) => {
|
async (event, key) => {
|
||||||
|
const service = await getCurrentWindowService();
|
||||||
const isClose = isNil(serverListButtonRef.current?.dataset["open"]);
|
const isClose = isNil(serverListButtonRef.current?.dataset["open"]);
|
||||||
const length = serverList.length;
|
const length = serverList.length;
|
||||||
|
|
||||||
@@ -162,9 +140,7 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const currentIndex = serverList.findIndex((server) => {
|
const currentIndex = serverList.findIndex((server) => {
|
||||||
return (
|
return server.id === (highlightId === "" ? service?.id : highlightId);
|
||||||
server.id === (highlightId === "" ? currentService?.id : highlightId)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let nextIndex = currentIndex;
|
let nextIndex = currentIndex;
|
||||||
@@ -197,7 +173,7 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
<Popover ref={popoverRef} className="relative">
|
<Popover ref={popoverRef} className="relative">
|
||||||
<PopoverButton ref={serverListButtonRef} className="flex items-center">
|
<PopoverButton ref={serverListButtonRef} className="flex items-center">
|
||||||
<VisibleKey
|
<VisibleKey
|
||||||
shortcut={serviceList}
|
shortcut={serviceListShortcut}
|
||||||
onKeyPress={() => {
|
onKeyPress={() => {
|
||||||
serverListButtonRef.current?.click();
|
serverListButtonRef.current?.click();
|
||||||
}}
|
}}
|
||||||
@@ -240,8 +216,8 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{serverList.length > 0 ? (
|
{list.length > 0 ? (
|
||||||
serverList.map((server) => (
|
list.map((server) => (
|
||||||
<div
|
<div
|
||||||
key={server.id}
|
key={server.id}
|
||||||
onClick={() => switchServer(server)}
|
onClick={() => switchServer(server)}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { filesize } from "filesize";
|
|
||||||
import { Files, Trash2, X } from "lucide-react";
|
import { Files, Trash2, X } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -10,6 +9,7 @@ import { AttachmentHit } from "@/types/commands";
|
|||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import FileIcon from "../Common/Icons/FileIcon";
|
import FileIcon from "../Common/Icons/FileIcon";
|
||||||
|
import { filesize } from "@/utils";
|
||||||
|
|
||||||
interface SessionFileProps {
|
interface SessionFileProps {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -39,10 +39,13 @@ const SessionFile = (props: SessionFileProps) => {
|
|||||||
if (isTauri) {
|
if (isTauri) {
|
||||||
console.log("sessionId", sessionId);
|
console.log("sessionId", sessionId);
|
||||||
|
|
||||||
const response: any = await platformAdapter.commands("get_attachment", {
|
const response: any = await platformAdapter.commands(
|
||||||
serverId,
|
"get_attachment_by_ids",
|
||||||
sessionId,
|
{
|
||||||
});
|
serverId,
|
||||||
|
sessionId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
setUploadedFiles(response?.hits?.hits ?? []);
|
setUploadedFiles(response?.hits?.hits ?? []);
|
||||||
} else {
|
} else {
|
||||||
@@ -145,9 +148,7 @@ const SessionFile = (props: SessionFileProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[#999]">
|
<div className="text-xs text-[#999]">
|
||||||
{icon && <span className="pr-2">{icon}</span>}
|
{icon && <span className="pr-2">{icon}</span>}
|
||||||
<span>
|
<span>{filesize(size)}</span>
|
||||||
{filesize(size, { standard: "jedec", spacer: "" })}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import durationPlugin from "dayjs/plugin/duration";
|
import durationPlugin from "dayjs/plugin/duration";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
import { useThemeStore } from "@/stores/themeStore";
|
import { useThemeStore } from "@/stores/themeStore";
|
||||||
import loadingLight from "@/assets/images/ReadAloud/loading-light.png";
|
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 { useConnectStore } from "@/stores/connectStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { useStreamAudio } from "@/hooks/useStreamAudio";
|
import { useStreamAudio } from "@/hooks/useStreamAudio";
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
import { useChatStore } from "@/stores/chatStore";
|
import { useChatStore } from "@/stores/chatStore";
|
||||||
|
|
||||||
dayjs.extend(durationPlugin);
|
dayjs.extend(durationPlugin);
|
||||||
|
|||||||
@@ -82,8 +82,6 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("response", response);
|
|
||||||
|
|
||||||
const text = response?.results
|
const text = response?.results
|
||||||
.flatMap((item: any) => item?.transcription?.transcripts)
|
.flatMap((item: any) => item?.transcription?.transcripts)
|
||||||
.map((item: any) => item?.text?.replace(/<\|[\/\w]+\|>/g, ""))
|
.map((item: any) => item?.text?.replace(/<\|[\/\w]+\|>/g, ""))
|
||||||
@@ -161,7 +159,7 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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,
|
hidden: state.audioDevices.length === 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
import { useState } from "react";
|
import { FC, useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import { CopyButton } from "@/components/Common/CopyButton";
|
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 {
|
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 [showCopyButton, setShowCopyButton] = useState(false);
|
||||||
|
const { currentService } = useConnectStore();
|
||||||
|
const [attachmentData, setAttachmentData] = useState<any[]>([]);
|
||||||
|
const { addError } = useAppStore();
|
||||||
|
|
||||||
const handleDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
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 selection = window.getSelection();
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
|
|
||||||
@@ -21,31 +32,81 @@ export const UserMessage = ({ messageContent }: UserMessageProps) => {
|
|||||||
selection.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
selection.addRange(range);
|
selection.addRange(range);
|
||||||
} catch (error) {
|
} 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 (
|
return (
|
||||||
<div
|
<>
|
||||||
className="max-w-full flex gap-1 items-center justify-end"
|
{message && (
|
||||||
onMouseEnter={() => setShowCopyButton(true)}
|
<div
|
||||||
onMouseLeave={() => setShowCopyButton(false)}
|
className="flex gap-1 items-center justify-end"
|
||||||
>
|
onMouseEnter={() => setShowCopyButton(true)}
|
||||||
<div
|
onMouseLeave={() => setShowCopyButton(false)}
|
||||||
className={clsx("size-6 transition", {
|
>
|
||||||
"opacity-0": !showCopyButton,
|
<div
|
||||||
})}
|
className={clsx("size-6 transition", {
|
||||||
>
|
"opacity-0": !showCopyButton,
|
||||||
<CopyButton textToCopy={messageContent} />
|
})}
|
||||||
</div>
|
>
|
||||||
<div
|
<CopyButton textToCopy={message} />
|
||||||
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"
|
</div>
|
||||||
onDoubleClick={handleDoubleClick}
|
<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"
|
||||||
{messageContent}
|
onDoubleClick={handleDoubleClick}
|
||||||
</div>
|
>
|
||||||
</div>
|
{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 messageContent = message?._source?.message || "";
|
||||||
|
const attachments = message?._source?.attachments ?? [];
|
||||||
const details = message?._source?.details || [];
|
const details = message?._source?.details || [];
|
||||||
const question = message?._source?.question || "";
|
const question = message?._source?.question || "";
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (!isAssistant) {
|
if (!isAssistant) {
|
||||||
return <UserMessage messageContent={messageContent} />;
|
return <UserMessage message={messageContent} attachments={attachments} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import { emit } from "@tauri-apps/api/event";
|
|
||||||
|
|
||||||
import { DataSourcesList } from "./DataSourcesList";
|
import { DataSourcesList } from "./DataSourcesList";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
@@ -9,6 +8,8 @@ import { useConnectStore } from "@/stores/connectStore";
|
|||||||
import ServiceInfo from "./ServiceInfo";
|
import ServiceInfo from "./ServiceInfo";
|
||||||
import ServiceAuth from "./ServiceAuth";
|
import ServiceAuth from "./ServiceAuth";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
import type { Server } from "@/types/server";
|
||||||
|
import { useServers } from "@/hooks/useServers";
|
||||||
|
|
||||||
export default function Cloud() {
|
export default function Cloud() {
|
||||||
const SidebarRef = useRef<{ refreshData: () => void }>(null);
|
const SidebarRef = useRef<{ refreshData: () => void }>(null);
|
||||||
@@ -17,100 +18,63 @@ export default function Cloud() {
|
|||||||
|
|
||||||
const [isConnect, setIsConnect] = useState(true);
|
const [isConnect, setIsConnect] = useState(true);
|
||||||
|
|
||||||
const { currentService, setCurrentService, serverList, setServerList } =
|
const {
|
||||||
useConnectStore();
|
cloudSelectService,
|
||||||
|
setCloudSelectService,
|
||||||
|
serverList,
|
||||||
|
setServerList,
|
||||||
|
} = useConnectStore();
|
||||||
|
|
||||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||||
|
|
||||||
|
const { addServer, refreshServerList } = useServers();
|
||||||
|
|
||||||
// fetch the servers
|
// fetch the servers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchServers(true);
|
fetchServers();
|
||||||
}, []);
|
}, [serverList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// console.log("currentService", currentService);
|
|
||||||
setRefreshLoading(false);
|
setRefreshLoading(false);
|
||||||
setIsConnect(true);
|
setIsConnect(true);
|
||||||
}, [JSON.stringify(currentService)]);
|
}, [cloudSelectService?.id]);
|
||||||
|
|
||||||
const fetchServers = async (resetSelection: boolean) => {
|
const fetchServers = useCallback(async () => {
|
||||||
platformAdapter
|
let res = serverList;
|
||||||
.commands("list_coco_servers")
|
if (errors.length > 0) {
|
||||||
.then((res: any) => {
|
res = res.map((item: Server) => {
|
||||||
if (errors.length > 0) {
|
if (item.id === cloudSelectService?.id) {
|
||||||
res = (res || []).map((item: any) => {
|
item.health = {
|
||||||
if (item.id === currentService?.id) {
|
services: item.health?.services || {},
|
||||||
item.health = {
|
status: item.health?.status || "red",
|
||||||
services: null,
|
};
|
||||||
status: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
console.log("list_coco_servers", res);
|
return item;
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
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(
|
const refreshClick = useCallback(
|
||||||
(id: string) => {
|
async (id: string) => {
|
||||||
setRefreshLoading(true);
|
setRefreshLoading(true);
|
||||||
platformAdapter
|
await platformAdapter.commands("refresh_coco_server_info", id);
|
||||||
.commands("refresh_coco_server_info", id)
|
await refreshServerList();
|
||||||
.then((res: any) => {
|
setRefreshLoading(false);
|
||||||
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);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[fetchServers]
|
[refreshServerList]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -127,7 +91,6 @@ export default function Cloud() {
|
|||||||
<ServiceInfo
|
<ServiceInfo
|
||||||
refreshLoading={refreshLoading}
|
refreshLoading={refreshLoading}
|
||||||
refreshClick={refreshClick}
|
refreshClick={refreshClick}
|
||||||
fetchServers={fetchServers}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ServiceAuth
|
<ServiceAuth
|
||||||
@@ -135,8 +98,8 @@ export default function Cloud() {
|
|||||||
refreshClick={refreshClick}
|
refreshClick={refreshClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{currentService?.profile && currentService?.available ? (
|
{cloudSelectService?.profile && cloudSelectService?.available ? (
|
||||||
<DataSourcesList server={currentService?.id} />
|
<DataSourcesList server={cloudSelectService?.id} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onAddServerClick = async (endpoint: string) => {
|
const onAddServerClick = async (endpoint: string) => {
|
||||||
console.log("onAddServer", endpoint);
|
//console.log("onAddServer", endpoint);
|
||||||
await onAddServer(endpoint);
|
await onAddServer(endpoint);
|
||||||
setIsConnect(true);
|
setIsConnect(true);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export function DataSourcesList({ server }: { server: string }) {
|
|||||||
platformAdapter
|
platformAdapter
|
||||||
.commands("get_connectors_by_server", server)
|
.commands("get_connectors_by_server", server)
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
// console.log("get_connectors_by_server", res);
|
|
||||||
setConnectorData(res, server);
|
setConnectorData(res, server);
|
||||||
})
|
})
|
||||||
.finally(() => {});
|
.finally(() => {});
|
||||||
@@ -29,7 +28,6 @@ export function DataSourcesList({ server }: { server: string }) {
|
|||||||
platformAdapter
|
platformAdapter
|
||||||
.commands("datasource_search", { id: server })
|
.commands("datasource_search", { id: server })
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
// console.log("datasource_search", res);
|
|
||||||
setDatasourceData(res, server);
|
setDatasourceData(res, server);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useConnectStore } from "@/stores/connectStore";
|
|||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import { copyToClipboard } from "@/utils";
|
import { copyToClipboard } from "@/utils";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { handleLogout } from "@/commands/servers";
|
import { useServers } from "@/hooks/useServers";
|
||||||
|
|
||||||
interface ServiceAuthProps {
|
interface ServiceAuthProps {
|
||||||
setRefreshLoading: (loading: boolean) => void;
|
setRefreshLoading: (loading: boolean) => void;
|
||||||
@@ -30,7 +30,9 @@ const ServiceAuth = memo(
|
|||||||
|
|
||||||
const addError = useAppStore((state) => state.addError);
|
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);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ const ServiceAuth = memo(
|
|||||||
setSSORequestID(requestID);
|
setSSORequestID(requestID);
|
||||||
|
|
||||||
// Generate the login URL with the current appUid
|
// 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);
|
console.log("Open SSO link, requestID:", ssoRequestID, url);
|
||||||
|
|
||||||
@@ -50,20 +52,17 @@ const ServiceAuth = memo(
|
|||||||
|
|
||||||
// Start loading state
|
// Start loading state
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}, [ssoRequestID, loading, currentService]);
|
}, [ssoRequestID, loading, cloudSelectService]);
|
||||||
|
|
||||||
const onLogout = useCallback((id: string) => {
|
const onLogout = useCallback(
|
||||||
setRefreshLoading(true);
|
(id: string) => {
|
||||||
platformAdapter
|
setRefreshLoading(true);
|
||||||
.commands("logout_coco_server", id)
|
logoutServer(id).finally(() => {
|
||||||
.then((res: any) => {
|
|
||||||
console.log("logout_coco_server", id, JSON.stringify(res));
|
|
||||||
handleLogout(id);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setRefreshLoading(false);
|
setRefreshLoading(false);
|
||||||
});
|
});
|
||||||
}, []);
|
},
|
||||||
|
[logoutServer]
|
||||||
|
);
|
||||||
|
|
||||||
const handleOAuthCallback = useCallback(
|
const handleOAuthCallback = useCallback(
|
||||||
async (code: string | null, serverId: string | null) => {
|
async (code: string | null, serverId: string | null) => {
|
||||||
@@ -109,7 +108,7 @@ const ServiceAuth = memo(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverId = currentService?.id;
|
const serverId = cloudSelectService?.id;
|
||||||
handleOAuthCallback(code, serverId);
|
handleOAuthCallback(code, serverId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to parse URL:", err);
|
console.error("Failed to parse URL:", err);
|
||||||
@@ -162,9 +161,9 @@ const ServiceAuth = memo(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [currentService]);
|
}, [cloudSelectService]);
|
||||||
|
|
||||||
if (!currentService?.auth_provider?.sso?.url) {
|
if (!cloudSelectService?.auth_provider?.sso?.url) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,10 +172,10 @@ const ServiceAuth = memo(
|
|||||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||||
{t("cloud.accountInfo")}
|
{t("cloud.accountInfo")}
|
||||||
</h2>
|
</h2>
|
||||||
{currentService?.profile ? (
|
{cloudSelectService?.profile ? (
|
||||||
<UserProfile
|
<UserProfile
|
||||||
server={currentService?.id}
|
server={cloudSelectService?.id}
|
||||||
userInfo={currentService?.profile}
|
userInfo={cloudSelectService?.profile}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -190,7 +189,7 @@ const ServiceAuth = memo(
|
|||||||
onCancel={() => setLoading(false)}
|
onCancel={() => setLoading(false)}
|
||||||
onCopy={() => {
|
onCopy={() => {
|
||||||
copyToClipboard(
|
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
|
<button
|
||||||
className="text-xs text-[#0096FB] dark:text-blue-400 block"
|
className="text-xs text-[#0096FB] dark:text-blue-400 block"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
OpenURLWithBrowser(currentService?.provider?.eula)
|
OpenURLWithBrowser(cloudSelectService?.provider?.eula)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t("cloud.eula")}
|
{t("cloud.eula")}
|
||||||
@@ -215,7 +214,7 @@ const ServiceAuth = memo(
|
|||||||
<button
|
<button
|
||||||
className="text-xs text-[#0096FB] dark:text-blue-400 block"
|
className="text-xs text-[#0096FB] dark:text-blue-400 block"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
OpenURLWithBrowser(currentService?.provider?.privacy_policy)
|
OpenURLWithBrowser(cloudSelectService?.provider?.privacy_policy)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t("cloud.privacyPolicy")}
|
{t("cloud.privacyPolicy")}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import { useConnectStore } from "@/stores/connectStore";
|
|||||||
interface ServiceBannerProps {}
|
interface ServiceBannerProps {}
|
||||||
|
|
||||||
const ServiceBanner = memo(({}: ServiceBannerProps) => {
|
const ServiceBanner = memo(({}: ServiceBannerProps) => {
|
||||||
const currentService = useConnectStore((state) => state.currentService);
|
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full rounded-[4px] bg-[rgba(229,229,229,1)] dark:bg-gray-800 mb-6">
|
<div className="w-full rounded-[4px] bg-[rgba(229,229,229,1)] dark:bg-gray-800 mb-6">
|
||||||
<img
|
<img
|
||||||
width="100%"
|
width="100%"
|
||||||
src={currentService?.provider?.banner || bannerImg}
|
src={cloudSelectService?.provider?.banner || bannerImg}
|
||||||
alt="banner"
|
alt="banner"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
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 { Globe, RefreshCcw, Trash2 } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -7,90 +7,64 @@ import Tooltip from "@/components/Common/Tooltip";
|
|||||||
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
||||||
import { OpenURLWithBrowser } from "@/utils";
|
import { OpenURLWithBrowser } from "@/utils";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import { useServers } from "@/hooks/useServers";
|
||||||
|
|
||||||
interface ServiceHeaderProps {
|
interface ServiceHeaderProps {
|
||||||
refreshLoading?: boolean;
|
refreshLoading?: boolean;
|
||||||
refreshClick: (id: string) => void;
|
refreshClick: (id: string) => void;
|
||||||
fetchServers: (force: boolean) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServiceHeader = memo(
|
const ServiceHeader = memo(
|
||||||
({ refreshLoading, refreshClick, fetchServers }: ServiceHeaderProps) => {
|
({ refreshLoading, refreshClick }: ServiceHeaderProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const currentService = useConnectStore((state) => state.currentService);
|
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
|
||||||
const setCurrentService = useConnectStore(
|
|
||||||
(state) => state.setCurrentService
|
|
||||||
);
|
|
||||||
|
|
||||||
const enable_coco_server = useCallback(
|
const { enableServer, removeServer } = useServers();
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center space-x-3">
|
<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">
|
<div className="flex items-center text-gray-900 dark:text-white font-medium cursor-pointer">
|
||||||
{currentService?.name}
|
{cloudSelectService?.name}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
checked={currentService?.enabled}
|
checked={cloudSelectService?.enabled}
|
||||||
className={clsx({
|
className={clsx({
|
||||||
"bg-red-600 focus:ring-red-500": !currentService?.enabled,
|
"bg-red-600 focus:ring-red-500": !cloudSelectService?.enabled,
|
||||||
})}
|
})}
|
||||||
label={
|
label={
|
||||||
currentService?.enabled
|
cloudSelectService?.enabled
|
||||||
? t("cloud.enable_server")
|
? t("cloud.enable_server")
|
||||||
: t("cloud.disable_server")
|
: t("cloud.disable_server")
|
||||||
}
|
}
|
||||||
onChange={enable_coco_server}
|
onChange={enableServer}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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"
|
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={() =>
|
onClick={() =>
|
||||||
OpenURLWithBrowser(currentService?.provider?.website)
|
OpenURLWithBrowser(cloudSelectService?.provider?.website)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Globe className="w-3.5 h-3.5" />
|
<Globe className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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
|
<RefreshCcw
|
||||||
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""}`}
|
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{!currentService?.builtin && (
|
{!cloudSelectService?.builtin && (
|
||||||
<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"
|
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]" />
|
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ import ServiceMetadata from "./ServiceMetadata";
|
|||||||
interface ServiceInfoProps {
|
interface ServiceInfoProps {
|
||||||
refreshLoading?: boolean;
|
refreshLoading?: boolean;
|
||||||
refreshClick: (id: string) => void;
|
refreshClick: (id: string) => void;
|
||||||
fetchServers: (force: boolean) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServiceInfo = memo(
|
const ServiceInfo = memo(
|
||||||
({ refreshLoading, refreshClick, fetchServers }: ServiceInfoProps) => {
|
({ refreshLoading, refreshClick }: ServiceInfoProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ServiceBanner />
|
<ServiceBanner />
|
||||||
@@ -19,7 +18,6 @@ const ServiceInfo = memo(
|
|||||||
<ServiceHeader
|
<ServiceHeader
|
||||||
refreshLoading={refreshLoading}
|
refreshLoading={refreshLoading}
|
||||||
refreshClick={refreshClick}
|
refreshClick={refreshClick}
|
||||||
fetchServers={fetchServers}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ServiceMetadata />
|
<ServiceMetadata />
|
||||||
|
|||||||
@@ -6,25 +6,25 @@ import { useConnectStore } from "@/stores/connectStore";
|
|||||||
interface ServiceMetadataProps {}
|
interface ServiceMetadataProps {}
|
||||||
|
|
||||||
const ServiceMetadata = memo(({}: ServiceMetadataProps) => {
|
const ServiceMetadata = memo(({}: ServiceMetadataProps) => {
|
||||||
const currentService = useConnectStore((state) => state.currentService);
|
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2 flex">
|
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2 flex">
|
||||||
<span className="flex items-center gap-1">
|
<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>
|
||||||
<span className="mx-4">|</span>
|
<span className="mx-4">|</span>
|
||||||
<span className="flex items-center gap-1">
|
<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>
|
||||||
<span className="mx-4">|</span>
|
<span className="mx-4">|</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<CalendarSync className="w-4 h-4" /> {currentService?.updated}
|
<CalendarSync className="w-4 h-4" /> {cloudSelectService?.updated}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||||
{currentService?.provider?.description}
|
{cloudSelectService?.provider?.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,13 +20,15 @@ interface ServerGroups {
|
|||||||
export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
||||||
({ setIsConnect, serverList }, _ref) => {
|
({ setIsConnect, serverList }, _ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentService = useConnectStore((state) => state.currentService);
|
const cloudSelectService = useConnectStore((state) => {
|
||||||
const setCurrentService = useConnectStore(
|
return state.cloudSelectService;
|
||||||
(state) => state.setCurrentService
|
});
|
||||||
);
|
const setCloudSelectService = useConnectStore((state) => {
|
||||||
|
return state.setCloudSelectService;
|
||||||
|
});
|
||||||
|
|
||||||
const selectService = (item: Server) => {
|
const selectService = (item: Server) => {
|
||||||
setCurrentService(item);
|
setCloudSelectService(item);
|
||||||
setIsConnect(true);
|
setIsConnect(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
|||||||
// Extracted server item rendering
|
// Extracted server item rendering
|
||||||
const renderServerItem = useCallback(
|
const renderServerItem = useCallback(
|
||||||
(item: Server) => {
|
(item: Server) => {
|
||||||
const isSelected = currentService?.id === item.id;
|
const isSelected = cloudSelectService?.id === item.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -72,7 +74,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[currentService]
|
[cloudSelectService]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { builtinServers, customServers } = useMemo(() => {
|
const { builtinServers, customServers } = useMemo(() => {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ interface UserProfileProps {
|
|||||||
export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
|
export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
onLogout(server);
|
onLogout(server);
|
||||||
console.log("Logout", server);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [imageLoadError, setImageLoadError] = useState(false);
|
const [imageLoadError, setImageLoadError] = useState(false);
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const FileIcon: FC<FileIconProps> = (props) => {
|
|||||||
.then(setIconName);
|
.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;
|
export default FileIcon;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useAppStore } from "@/stores/appStore";
|
|
||||||
import logoImg from "@/assets/icon.svg";
|
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
|
|
||||||
interface FontIconProps {
|
interface FontIconProps {
|
||||||
@@ -9,17 +7,11 @@ interface FontIconProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => {
|
const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => {
|
||||||
const isTauri = useAppStore((state) => state.isTauri);
|
return (
|
||||||
|
<svg className={`icon ${className || ""}`} style={style} {...rest}>
|
||||||
if (isTauri) {
|
<use xlinkHref={`#${name}`} />
|
||||||
return (
|
</svg>
|
||||||
<svg className={`icon ${className || ""}`} style={style} {...rest}>
|
);
|
||||||
<use xlinkHref={`#${name}`} />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return <img src={logoImg} className={className} alt={"coco"} />;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FontIcon;
|
export default FontIcon;
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
|
|||||||
return "→";
|
return "→";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shortcut === "enter") {
|
||||||
|
return "↩︎";
|
||||||
|
}
|
||||||
|
|
||||||
return shortcut;
|
return shortcut;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ import React from "react";
|
|||||||
import { Send } from "lucide-react";
|
import { Send } from "lucide-react";
|
||||||
|
|
||||||
import StopIcon from "@/icons/Stop";
|
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 {
|
interface ChatIconsProps {
|
||||||
lineCount: number;
|
lineCount: number;
|
||||||
isChatMode: boolean;
|
isChatMode: boolean;
|
||||||
curChatEnd: boolean;
|
curChatEnd: boolean;
|
||||||
inputValue: string;
|
inputValue: string;
|
||||||
onSend: (value: string) => void;
|
onSend: (params: SendMessageParams) => void;
|
||||||
disabledChange: () => void;
|
disabledChange: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatIcons: React.FC<ChatIconsProps> = ({
|
const ChatIcons: React.FC<ChatIconsProps> = ({
|
||||||
lineCount,
|
|
||||||
isChatMode,
|
isChatMode,
|
||||||
curChatEnd,
|
curChatEnd,
|
||||||
inputValue,
|
inputValue,
|
||||||
@@ -21,50 +24,48 @@ const ChatIcons: React.FC<ChatIconsProps> = ({
|
|||||||
disabledChange,
|
disabledChange,
|
||||||
}) => {
|
}) => {
|
||||||
const renderSendButton = () => {
|
const renderSendButton = () => {
|
||||||
if (!isChatMode) return null;
|
if (!isChatMode) return;
|
||||||
|
|
||||||
if (curChatEnd) {
|
if (curChatEnd) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`ml-1 p-1 ${
|
className={clsx(
|
||||||
inputValue ? "bg-[#0072FF]" : "bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]"
|
"flex items-center justify-center rounded-full transition-colors min-w-6 h-6 bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]",
|
||||||
} rounded-full transition-colors h-6`}
|
{
|
||||||
|
"!bg-[#0072FF]": inputValue || isAttachmentsUploaded(),
|
||||||
|
}
|
||||||
|
)}
|
||||||
type="submit"
|
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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!curChatEnd) {
|
return (
|
||||||
return (
|
<button
|
||||||
<button
|
className={`ml-1 px-1 bg-[#0072FF] rounded-full transition-colors`}
|
||||||
className={`ml-1 px-1 bg-[#0072FF] rounded-full transition-colors`}
|
type="submit"
|
||||||
type="submit"
|
onClick={() => disabledChange()}
|
||||||
onClick={() => disabledChange()}
|
>
|
||||||
>
|
<StopIcon
|
||||||
<StopIcon
|
size={16}
|
||||||
size={16}
|
className="w-4 h-4 text-white"
|
||||||
className="w-4 h-4 text-white"
|
aria-label="Stop message"
|
||||||
aria-label="Stop message"
|
/>
|
||||||
/>
|
</button>
|
||||||
</button>
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return renderSendButton();
|
||||||
<>
|
|
||||||
{lineCount === 1 ? (
|
|
||||||
renderSendButton()
|
|
||||||
) : (
|
|
||||||
<div className="w-full flex justify-end mt-1">{renderSendButton()}</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ChatIcons;
|
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 list = response?.hits ?? [];
|
||||||
const allTotal = response?.total_hits ?? 0;
|
const allTotal = response?.total_hits ?? 0;
|
||||||
// set first select hover
|
// set first select hover
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ import { useAssistantManager } from "./AssistantManager";
|
|||||||
import InputControls from "./InputControls";
|
import InputControls from "./InputControls";
|
||||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||||
import AudioRecording from "../AudioRecording";
|
import AudioRecording from "../AudioRecording";
|
||||||
import { isDefaultServer } from "@/utils";
|
import { getUploadedAttachmentsId, isDefaultServer } from "@/utils";
|
||||||
import { useTauriFocus } from "@/hooks/useTauriFocus";
|
import { useTauriFocus } from "@/hooks/useTauriFocus";
|
||||||
|
import { SendMessageParams } from "../Assistant/Chat";
|
||||||
|
import { isEmpty } from "lodash-es";
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (message: string) => void;
|
onSend: (params: SendMessageParams) => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
disabledChange: () => void;
|
disabledChange: () => void;
|
||||||
changeMode?: (isChatMode: boolean) => void;
|
changeMode?: (isChatMode: boolean) => void;
|
||||||
@@ -84,18 +86,13 @@ export default function ChatInput({
|
|||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
const { currentAssistant } = useConnectStore();
|
||||||
|
|
||||||
const setBlurred = useAppStore((state) => state.setBlurred);
|
|
||||||
const isTauri = useAppStore((state) => state.isTauri);
|
|
||||||
|
|
||||||
const { sourceData, goAskAi } = useSearchStore();
|
const { sourceData, goAskAi } = useSearchStore();
|
||||||
|
|
||||||
const { modifierKey, returnToInput, setModifierKeyPressed } =
|
const { modifierKey, returnToInput, setModifierKeyPressed } =
|
||||||
useShortcutsStore();
|
useShortcutsStore();
|
||||||
const language = useAppStore((state) => {
|
const { isTauri, language, setBlurred } = useAppStore();
|
||||||
return state.language;
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -108,6 +105,7 @@ export default function ChatInput({
|
|||||||
const { curChatEnd } = useChatStore();
|
const { curChatEnd } = useChatStore();
|
||||||
const { setSearchValue, visibleExtensionStore, selectedExtension } =
|
const { setSearchValue, visibleExtensionStore, selectedExtension } =
|
||||||
useSearchStore();
|
useSearchStore();
|
||||||
|
const { uploadAttachments } = useChatStore();
|
||||||
|
|
||||||
useTauriFocus({
|
useTauriFocus({
|
||||||
onFocus() {
|
onFocus() {
|
||||||
@@ -122,12 +120,17 @@ export default function ChatInput({
|
|||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
const trimmedValue = inputValue.trim();
|
const trimmedValue = inputValue.trim();
|
||||||
|
|
||||||
console.log("handleSubmit", trimmedValue, disabled);
|
console.log("handleSubmit", trimmedValue, disabled);
|
||||||
if (trimmedValue && !disabled) {
|
|
||||||
|
if ((trimmedValue || !isEmpty(uploadAttachments)) && !disabled) {
|
||||||
changeInput("");
|
changeInput("");
|
||||||
onSend(trimmedValue);
|
onSend({
|
||||||
|
message: trimmedValue,
|
||||||
|
attachments: getUploadedAttachmentsId(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [inputValue, disabled, onSend]);
|
}, [inputValue, disabled, onSend, uploadAttachments]);
|
||||||
|
|
||||||
useKeyboardHandlers();
|
useKeyboardHandlers();
|
||||||
|
|
||||||
@@ -138,7 +141,7 @@ export default function ChatInput({
|
|||||||
changeInput(value);
|
changeInput(value);
|
||||||
setSearchValue(value);
|
setSearchValue(value);
|
||||||
if (!isChatMode) {
|
if (!isChatMode) {
|
||||||
onSend(value);
|
onSend({ message: value });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[changeInput, isChatMode, onSend]
|
[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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import { useAppStore } from "@/stores/appStore";
|
|||||||
import { useSearchStore } from "@/stores/searchStore";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||||
import { parseSearchQuery, SearchQuery } from "@/utils";
|
import { parseSearchQuery, SearchQuery } from "@/utils";
|
||||||
// import InputUpload from "./InputUpload";
|
import InputUpload from "./InputUpload";
|
||||||
// import AiSummaryIcon from "@/components/Common/Icons/AiSummaryIcon";
|
|
||||||
|
|
||||||
interface InputControlsProps {
|
interface InputControlsProps {
|
||||||
isChatMode: boolean;
|
isChatMode: boolean;
|
||||||
@@ -56,16 +55,16 @@ const InputControls = ({
|
|||||||
isChatPage,
|
isChatPage,
|
||||||
hasModules,
|
hasModules,
|
||||||
changeMode,
|
changeMode,
|
||||||
}: // checkScreenPermission,
|
checkScreenPermission,
|
||||||
// requestScreenPermission,
|
requestScreenPermission,
|
||||||
// getScreenMonitors,
|
getScreenMonitors,
|
||||||
// getScreenWindows,
|
getScreenWindows,
|
||||||
// captureWindowScreenshot,
|
captureWindowScreenshot,
|
||||||
// captureMonitorScreenshot,
|
captureMonitorScreenshot,
|
||||||
// openFileDialog,
|
openFileDialog,
|
||||||
// getFileMetadata,
|
getFileMetadata,
|
||||||
// getFileIcon,
|
getFileIcon,
|
||||||
InputControlsProps) => {
|
}: InputControlsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const isTauri = useAppStore((state) => state.isTauri);
|
const isTauri = useAppStore((state) => state.isTauri);
|
||||||
@@ -171,22 +170,24 @@ InputControlsProps) => {
|
|||||||
>
|
>
|
||||||
{isChatMode ? (
|
{isChatMode ? (
|
||||||
<div className="flex gap-2 text-[12px] leading-3 text-[#333] dark:text-[#d8d8d8]">
|
<div className="flex gap-2 text-[12px] leading-3 text-[#333] dark:text-[#d8d8d8]">
|
||||||
{/* <InputUpload
|
{source?.upload?.enabled && (
|
||||||
checkScreenPermission={checkScreenPermission}
|
<InputUpload
|
||||||
requestScreenPermission={requestScreenPermission}
|
checkScreenPermission={checkScreenPermission}
|
||||||
getScreenMonitors={getScreenMonitors}
|
requestScreenPermission={requestScreenPermission}
|
||||||
getScreenWindows={getScreenWindows}
|
getScreenMonitors={getScreenMonitors}
|
||||||
captureMonitorScreenshot={captureMonitorScreenshot}
|
getScreenWindows={getScreenWindows}
|
||||||
captureWindowScreenshot={captureWindowScreenshot}
|
captureMonitorScreenshot={captureMonitorScreenshot}
|
||||||
openFileDialog={openFileDialog}
|
captureWindowScreenshot={captureWindowScreenshot}
|
||||||
getFileMetadata={getFileMetadata}
|
openFileDialog={openFileDialog}
|
||||||
getFileIcon={getFileIcon}
|
getFileMetadata={getFileMetadata}
|
||||||
/> */}
|
getFileIcon={getFileIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{source?.type === "deep_think" && source?.config?.visible && (
|
{source?.type === "deep_think" && source?.config?.visible && (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
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,
|
"!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive,
|
||||||
}
|
}
|
||||||
@@ -231,7 +232,8 @@ InputControlsProps) => {
|
|||||||
getMCPByServer={getMCPByServer}
|
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?.type !== "deep_think" || !source?.config?.visible) &&
|
||||||
!(source?.mcp_servers?.enabled && source?.mcp_servers?.visible) && (
|
!(source?.mcp_servers?.enabled && source?.mcp_servers?.visible) && (
|
||||||
<div className="px-[9px]">
|
<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 { useTranslation } from "react-i18next";
|
||||||
import { ChevronRight, Plus } from "lucide-react";
|
import { ChevronRight, Plus } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -12,13 +12,15 @@ import {
|
|||||||
} from "@headlessui/react";
|
} from "@headlessui/react";
|
||||||
import { castArray, find, isNil } from "lodash-es";
|
import { castArray, find, isNil } from "lodash-es";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { useCreation, useKeyPress, useMount, useReactive } from "ahooks";
|
import { useCreation, useMount, useReactive } from "ahooks";
|
||||||
|
|
||||||
import { useChatStore } from "@/stores/chatStore";
|
import { useChatStore } from "@/stores/chatStore";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import Tooltip from "@/components/Common/Tooltip";
|
import Tooltip from "@/components/Common/Tooltip";
|
||||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
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 {
|
interface State {
|
||||||
screenRecordingPermission?: boolean;
|
screenRecordingPermission?: boolean;
|
||||||
@@ -61,9 +63,26 @@ const InputUpload: FC<InputUploadProps> = (props) => {
|
|||||||
getFileMetadata,
|
getFileMetadata,
|
||||||
} = props;
|
} = props;
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { uploadFiles, setUploadFiles } = useChatStore();
|
const { uploadAttachments, setUploadAttachments } = useChatStore();
|
||||||
const { withVisibility, addError } = useAppStore();
|
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>({
|
const state = useReactive<State>({
|
||||||
screenshotableMonitors: [],
|
screenshotableMonitors: [],
|
||||||
@@ -83,19 +102,25 @@ const InputUpload: FC<InputUploadProps> = (props) => {
|
|||||||
|
|
||||||
if (isNil(selectedFiles)) return;
|
if (isNil(selectedFiles)) return;
|
||||||
|
|
||||||
|
setVisibleStartPage(false);
|
||||||
|
|
||||||
handleUploadFiles(selectedFiles);
|
handleUploadFiles(selectedFiles);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUploadFiles = async (paths: string | string[]) => {
|
const handleUploadFiles = async (paths: string | string[]) => {
|
||||||
const files: typeof uploadFiles = [];
|
const files: typeof uploadAttachments = [];
|
||||||
|
|
||||||
for await (const path of castArray(paths)) {
|
for await (const path of castArray(paths)) {
|
||||||
if (find(uploadFiles, { path })) continue;
|
if (find(uploadAttachments, { path })) continue;
|
||||||
|
|
||||||
const stat = await getFileMetadata(path);
|
const stat = await getFileMetadata(path);
|
||||||
|
|
||||||
if (stat.size / 1024 / 1024 > 100) {
|
if (stat.size > uploadMaxSizeRef.current) {
|
||||||
addError(t("search.input.uploadFileHints.maxSize"));
|
addError(
|
||||||
|
t("search.input.uploadFileHints.maxSize", {
|
||||||
|
replace: [filesize(uploadMaxSizeRef.current)],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -107,7 +132,7 @@ const InputUpload: FC<InputUploadProps> = (props) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setUploadFiles([...uploadFiles, ...files]);
|
setUploadAttachments([...uploadAttachments, ...files]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuItems = useCreation<MenuItem[]>(() => {
|
const menuItems = useCreation<MenuItem[]>(() => {
|
||||||
@@ -172,28 +197,20 @@ const InputUpload: FC<InputUploadProps> = (props) => {
|
|||||||
i18n.language,
|
i18n.language,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useKeyPress(`${modifierKey}.${addFile}`, handleSelectFile);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuButton className="flex p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
|
<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")}>
|
<Tooltip
|
||||||
<Plus
|
content={t("search.input.uploadFileHints.tooltip", {
|
||||||
className={clsx("size-3 scale-[1.3]", {
|
replace: [
|
||||||
hidden: modifierKeyPressed,
|
uploadMaxCountRef.current,
|
||||||
})}
|
filesize(uploadMaxSizeRef.current),
|
||||||
/>
|
],
|
||||||
|
})}
|
||||||
<div
|
>
|
||||||
className={clsx(
|
<VisibleKey shortcut={addFile} onKeyPress={handleSelectFile}>
|
||||||
"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]",
|
<Plus className="size-3 scale-[1.3]" />
|
||||||
{
|
</VisibleKey>
|
||||||
hidden: !modifierKeyPressed,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{addFile}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function MCPPopover({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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,
|
"!bg-[rgba(0,114,255,0.3)]": isMCPActive,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export default function SearchPopover({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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,
|
"!bg-[rgba(0,114,255,0.3)]": isSearchActive,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import { useMount } from "ahooks";
|
|||||||
|
|
||||||
import Search from "@/components/Search/Search";
|
import Search from "@/components/Search/Search";
|
||||||
import InputBox from "@/components/Search/InputBox";
|
import InputBox from "@/components/Search/InputBox";
|
||||||
import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat";
|
import ChatAI, {
|
||||||
|
ChatAIRef,
|
||||||
|
SendMessageParams,
|
||||||
|
} from "@/components/Assistant/Chat";
|
||||||
import { isLinux, isWin } from "@/utils/platform";
|
import { isLinux, isWin } from "@/utils/platform";
|
||||||
import { appReducer, initialAppState } from "@/reducers/appReducer";
|
import { appReducer, initialAppState } from "@/reducers/appReducer";
|
||||||
import { useWindowEvents } from "@/hooks/useWindowEvents";
|
import { useWindowEvents } from "@/hooks/useWindowEvents";
|
||||||
@@ -25,6 +28,7 @@ import { useThemeStore } from "@/stores/themeStore";
|
|||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
import { useAppearanceStore } from "@/stores/appearanceStore";
|
import { useAppearanceStore } from "@/stores/appearanceStore";
|
||||||
import type { StartPage } from "@/types/chat";
|
import type { StartPage } from "@/types/chat";
|
||||||
|
import { hasUploadingAttachment } from "@/utils";
|
||||||
|
|
||||||
interface SearchChatProps {
|
interface SearchChatProps {
|
||||||
isTauri?: boolean;
|
isTauri?: boolean;
|
||||||
@@ -125,7 +129,9 @@ function SearchChat({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
await initializeListeners_auth();
|
await initializeListeners_auth();
|
||||||
await platformAdapter.commands("get_app_search_source");
|
if (isTauri) {
|
||||||
|
await platformAdapter.commands("get_app_search_source");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
init();
|
init();
|
||||||
@@ -146,10 +152,12 @@ function SearchChat({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSendMessage = useCallback(
|
const handleSendMessage = useCallback(
|
||||||
async (value: string) => {
|
async (params: SendMessageParams) => {
|
||||||
dispatch({ type: "SET_INPUT", payload: value });
|
if (hasUploadingAttachment()) return;
|
||||||
|
|
||||||
|
dispatch({ type: "SET_INPUT", payload: params?.message ?? "" });
|
||||||
if (isChatMode) {
|
if (isChatMode) {
|
||||||
chatAIRef.current?.init(value);
|
chatAIRef.current?.init(params);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isChatMode]
|
[isChatMode]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Command, RotateCcw } from "lucide-react";
|
import { Command, RotateCcw } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { Button } from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
import { formatKey } from "@/utils/keyboardUtils";
|
import { formatKey } from "@/utils/keyboardUtils";
|
||||||
import SettingsItem from "@/components/Settings/SettingsItem";
|
import SettingsItem from "@/components/Settings/SettingsItem";
|
||||||
@@ -9,7 +11,7 @@ import {
|
|||||||
INITIAL_MODE_SWITCH,
|
INITIAL_MODE_SWITCH,
|
||||||
INITIAL_RETURN_TO_INPUT,
|
INITIAL_RETURN_TO_INPUT,
|
||||||
// INITIAL_VOICE_INPUT,
|
// INITIAL_VOICE_INPUT,
|
||||||
// INITIAL_ADD_FILE,
|
INITIAL_ADD_FILE,
|
||||||
INITIAL_DEEP_THINKING,
|
INITIAL_DEEP_THINKING,
|
||||||
INITIAL_INTERNET_SEARCH,
|
INITIAL_INTERNET_SEARCH,
|
||||||
INITIAL_INTERNET_SEARCH_SCOPE,
|
INITIAL_INTERNET_SEARCH_SCOPE,
|
||||||
@@ -28,8 +30,6 @@ import { ModifierKey } from "@/types/index";
|
|||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import SettingsInput from "@/components/Settings/SettingsInput";
|
import SettingsInput from "@/components/Settings/SettingsInput";
|
||||||
import { Button } from "@headlessui/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
export const modifierKeys: ModifierKey[] = isMac
|
export const modifierKeys: ModifierKey[] = isMac
|
||||||
? ["meta", "ctrl"]
|
? ["meta", "ctrl"]
|
||||||
@@ -46,8 +46,8 @@ const Shortcuts = () => {
|
|||||||
setReturnToInput,
|
setReturnToInput,
|
||||||
// voiceInput,
|
// voiceInput,
|
||||||
// setVoiceInput,
|
// setVoiceInput,
|
||||||
// addFile,
|
addFile,
|
||||||
// setAddFile,
|
setAddFile,
|
||||||
deepThinking,
|
deepThinking,
|
||||||
setDeepThinking,
|
setDeepThinking,
|
||||||
internetSearch,
|
internetSearch,
|
||||||
@@ -66,8 +66,8 @@ const Shortcuts = () => {
|
|||||||
setNewSession,
|
setNewSession,
|
||||||
fixedWindow,
|
fixedWindow,
|
||||||
setFixedWindow,
|
setFixedWindow,
|
||||||
serviceList,
|
serviceListShortcut,
|
||||||
setServiceList,
|
setServiceListShortcut,
|
||||||
external,
|
external,
|
||||||
setExternal,
|
setExternal,
|
||||||
aiOverview,
|
aiOverview,
|
||||||
@@ -106,15 +106,13 @@ const Shortcuts = () => {
|
|||||||
// value: voiceInput,
|
// value: voiceInput,
|
||||||
// setValue: setVoiceInput,
|
// setValue: setVoiceInput,
|
||||||
// },
|
// },
|
||||||
// {
|
{
|
||||||
// title: "settings.advanced.shortcuts.addFile.title",
|
title: "settings.advanced.shortcuts.addFile.title",
|
||||||
// description: "settings.advanced.shortcuts.addFile.description",
|
description: "settings.advanced.shortcuts.addFile.description",
|
||||||
// value: addFile,
|
initialValue: INITIAL_ADD_FILE,
|
||||||
// setValue: setAddFile,
|
value: addFile,
|
||||||
// reset: () => {
|
setValue: setAddFile,
|
||||||
// handleChange(INITIAL_ADD_FILE, setAddFile);
|
},
|
||||||
// },
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
title: "settings.advanced.shortcuts.deepThinking.title",
|
title: "settings.advanced.shortcuts.deepThinking.title",
|
||||||
description: "settings.advanced.shortcuts.deepThinking.description",
|
description: "settings.advanced.shortcuts.deepThinking.description",
|
||||||
@@ -183,8 +181,8 @@ const Shortcuts = () => {
|
|||||||
title: "settings.advanced.shortcuts.serviceList.title",
|
title: "settings.advanced.shortcuts.serviceList.title",
|
||||||
description: "settings.advanced.shortcuts.serviceList.description",
|
description: "settings.advanced.shortcuts.serviceList.description",
|
||||||
initialValue: INITIAL_SERVICE_LIST,
|
initialValue: INITIAL_SERVICE_LIST,
|
||||||
value: serviceList,
|
value: serviceListShortcut,
|
||||||
setValue: setServiceList,
|
setValue: setServiceListShortcut,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "settings.advanced.shortcuts.external.title",
|
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 { useContext, useMemo, useState } from "react";
|
||||||
import { filesize } from "filesize";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAsyncEffect } from "ahooks";
|
import { useAsyncEffect } from "ahooks";
|
||||||
|
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { ExtensionsContext } from "../../../index";
|
import { ExtensionsContext } from "../../../index";
|
||||||
|
import { filesize } from "@/utils";
|
||||||
|
|
||||||
interface Metadata {
|
interface Metadata {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -57,7 +58,7 @@ const App = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("settings.extensions.application.details.size"),
|
label: t("settings.extensions.application.details.size"),
|
||||||
value: filesize(size, { standard: "jedec", spacer: "" }),
|
value: filesize(size),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("settings.extensions.application.details.created"),
|
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 { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
|
||||||
import SettingsSelectPro from "@/components/Settings/SettingsSelectPro";
|
import SettingsSelectPro from "@/components/Settings/SettingsSelectPro";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import { ExtensionId } from "@/components/Settings/Extensions/index";
|
||||||
import { useAsyncEffect, useMount } from "ahooks";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
import { FC, useMemo, useState } from "react";
|
import type { Server } from "@/types/server";
|
||||||
import { ExtensionId } from "../../..";
|
|
||||||
import { useTranslation } from "react-i18next";
|
interface Assistant {
|
||||||
import { isArray } from "lodash-es";
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
icon?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface SharedAiProps {
|
interface SharedAiProps {
|
||||||
id: ExtensionId;
|
id: ExtensionId;
|
||||||
server?: any;
|
server?: Server;
|
||||||
setServer: (server: any) => void;
|
setServer: (server: Server | undefined) => void;
|
||||||
assistant?: any;
|
assistant?: Assistant;
|
||||||
setAssistant: (assistant: any) => void;
|
setAssistant: (assistant: Assistant | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SharedAi: FC<SharedAiProps> = (props) => {
|
const SharedAi: FC<SharedAiProps> = (props) => {
|
||||||
const { id, server, setServer, assistant, setAssistant } = props;
|
const { id, server, setServer, assistant, setAssistant } = props;
|
||||||
|
|
||||||
const [serverList, setServerList] = useState<any[]>([server]);
|
const serverList = useConnectStore((state) => state.serverList);
|
||||||
const [assistantList, setAssistantList] = useState<any[]>([assistant]);
|
|
||||||
|
const [list, setList] = useState<Server[]>([]);
|
||||||
|
const [assistantList, setAssistantList] = useState<Assistant[]>([]);
|
||||||
const addError = useAppStore((state) => state.addError);
|
const addError = useAppStore((state) => state.addError);
|
||||||
const { fetchAssistant } = AssistantFetcher({});
|
const { fetchAssistant } = AssistantFetcher({});
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [assistantSearchValue, setAssistantSearchValue] = useState("");
|
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 () => {
|
useMount(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await platformAdapter.invokeBackend<any[]>(
|
const enabledServers = getEnabledServers(serverList);
|
||||||
"list_coco_servers"
|
setList(enabledServers);
|
||||||
);
|
|
||||||
|
|
||||||
if (isArray(data)) {
|
if (enabledServers.length === 0) {
|
||||||
const enabledServers = data.filter(
|
setServer(undefined);
|
||||||
(s) => s.enabled && s.available && (s.public || s.profile)
|
return;
|
||||||
);
|
|
||||||
|
|
||||||
setServerList(enabledServers);
|
|
||||||
|
|
||||||
if (server) {
|
|
||||||
const matchServer = enabledServers.find((item) => {
|
|
||||||
return item.id === server.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matchServer) {
|
|
||||||
return setServer(matchServer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setServer(enabledServers[0]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (server) {
|
||||||
|
const matchServer = enabledServers.find((item) => item.id === server.id);
|
||||||
|
if (matchServer) {
|
||||||
|
setServer(matchServer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setServer(enabledServers[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addError(String(error));
|
console.error('Failed to load servers:', error);
|
||||||
|
addError(`Failed to load servers: ${String(error)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useAsyncEffect(async () => {
|
useAsyncEffect(async () => {
|
||||||
try {
|
if (!server) {
|
||||||
if (!server) return;
|
setAssistantList([]);
|
||||||
|
setAssistant(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingAssistants(true);
|
||||||
|
try {
|
||||||
const data = await fetchAssistant({
|
const data = await fetchAssistant({
|
||||||
current: 1,
|
current: 1,
|
||||||
pageSize: 1000,
|
pageSize: 100,
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
query: assistantSearchValue,
|
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) {
|
if (assistant) {
|
||||||
const matched = list.find((item: any) => {
|
const matched = assistants.find((item) => item.id === assistant.id);
|
||||||
return item.id === assistant.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matched) {
|
if (matched) {
|
||||||
return setAssistant(matched);
|
setAssistant(matched);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setAssistant(list[0]);
|
setAssistant(assistants[0]);
|
||||||
} catch (error) {
|
} 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(() => {
|
const selectList = useMemo(() => {
|
||||||
return [
|
const serverSelectConfig = {
|
||||||
{
|
label: t(
|
||||||
label: t(
|
"settings.extensions.shardAi.details.linkedAssistant.label.cocoServer"
|
||||||
"settings.extensions.shardAi.details.linkedAssistant.label.cocoServer"
|
),
|
||||||
),
|
value: server?.id,
|
||||||
value: server?.id,
|
icon: server?.provider?.icon,
|
||||||
icon: server?.provider?.icon,
|
data: list,
|
||||||
data: serverList,
|
searchable: false,
|
||||||
searchable: false,
|
onChange: (value: string) => {
|
||||||
onChange: (value: string) => {
|
const matched = list.find((item) => item.id === value);
|
||||||
const matched = serverList.find((item) => item.id === value);
|
setServer(matched);
|
||||||
|
|
||||||
setServer(matched);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
onSearch: undefined,
|
||||||
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);
|
const assistantSelectConfig = {
|
||||||
},
|
label: t(
|
||||||
onSearch: (value: string) => {
|
"settings.extensions.shardAi.details.linkedAssistant.label.aiAssistant"
|
||||||
setAssistantSearchValue(value);
|
),
|
||||||
},
|
value: assistant?.id,
|
||||||
|
icon: assistant?.icon,
|
||||||
|
data: assistantList,
|
||||||
|
searchable: true,
|
||||||
|
onChange: (value: string) => {
|
||||||
|
const matched = assistantList.find((item) => item.id === value);
|
||||||
|
setAssistant(matched);
|
||||||
},
|
},
|
||||||
];
|
onSearch: (value: string) => {
|
||||||
}, [serverList, assistantList, server, assistant]);
|
setAssistantSearchValue(value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const renderDescription = () => {
|
return [serverSelectConfig, assistantSelectConfig];
|
||||||
if (id === "QuickAIAccess") {
|
}, [list, assistantList, server?.id, assistant?.id, t]);
|
||||||
return t("settings.extensions.quickAiAccess.description");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id === "AIOverview") {
|
const renderDescription = useCallback(() => {
|
||||||
return t("settings.extensions.aiOverview.description");
|
switch (id) {
|
||||||
|
case "QuickAIAccess":
|
||||||
|
return t("settings.extensions.quickAiAccess.description");
|
||||||
|
case "AIOverview":
|
||||||
|
return t("settings.extensions.aiOverview.description");
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
};
|
}, [id, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -154,6 +181,7 @@ const SharedAi: FC<SharedAiProps> = (props) => {
|
|||||||
searchable={searchable}
|
searchable={searchable}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onSearch={onSearch}
|
onSearch={onSearch}
|
||||||
|
placeholder={isLoadingAssistants && searchable ? "Loading..." : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import type { LiteralUnion } from "type-fest";
|
|||||||
import { cloneDeep, sortBy } from "lodash-es";
|
import { cloneDeep, sortBy } from "lodash-es";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { Button } from "@headlessui/react";
|
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||||
|
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import Content from "./components/Content";
|
import Content from "./components/Content";
|
||||||
import Details from "./components/Details";
|
import Details from "./components/Details";
|
||||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||||
import SettingsInput from "../SettingsInput";
|
import SettingsInput from "../SettingsInput";
|
||||||
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
|
||||||
export type ExtensionId = LiteralUnion<
|
export type ExtensionId = LiteralUnion<
|
||||||
| "Applications"
|
| "Applications"
|
||||||
@@ -90,6 +91,7 @@ export const Extensions = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const state = useReactive<State>(cloneDeep(INITIAL_STATE));
|
const state = useReactive<State>(cloneDeep(INITIAL_STATE));
|
||||||
const { configId, setConfigId } = useExtensionsStore();
|
const { configId, setConfigId } = useExtensionsStore();
|
||||||
|
const { addError } = useAppStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getExtensions();
|
getExtensions();
|
||||||
@@ -106,7 +108,7 @@ export const Extensions = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getExtensions = async () => {
|
const getExtensions = async () => {
|
||||||
const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>(
|
const extensions = await platformAdapter.invokeBackend<Extension[]>(
|
||||||
"list_extensions",
|
"list_extensions",
|
||||||
{
|
{
|
||||||
query: state.searchValue,
|
query: state.searchValue,
|
||||||
@@ -115,8 +117,6 @@ export const Extensions = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const extensions = result[1];
|
|
||||||
|
|
||||||
state.extensions = sortBy(extensions, ["name"]);
|
state.extensions = sortBy(extensions, ["name"]);
|
||||||
|
|
||||||
if (configId) {
|
if (configId) {
|
||||||
@@ -160,14 +160,65 @@ export const Extensions = () => {
|
|||||||
{t("settings.extensions.title")}
|
{t("settings.extensions.title")}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<Button
|
<Menu>
|
||||||
className="flex items-center justify-center size-6 border rounded-md dark:border-gray-700 hover:!border-[#0096FB] transition"
|
<MenuButton className="flex items-center justify-center size-6 border rounded-md dark:border-gray-700 hover:!border-[#0096FB] transition">
|
||||||
onClick={() => {
|
<Plus className="size-4 text-[#0096FB]" />
|
||||||
platformAdapter.emitEvent("open-extension-store");
|
</MenuButton>
|
||||||
}}
|
|
||||||
>
|
<MenuItems
|
||||||
<Plus className="size-4 text-[#0096FB]" />
|
anchor={{ gap: 4 }}
|
||||||
</Button>
|
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>
|
||||||
|
|
||||||
<div className="flex justify-between gap-6 my-4">
|
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 CONTEXT_MENU_PANEL_ID = "headlessui-popover-panel:context-menu";
|
||||||
|
|
||||||
export const DEFAULT_COCO_SERVER_ID = "default_coco_server";
|
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 { useAuthStore } from "@/stores/authStore";
|
||||||
import { unrequitable } from "@/utils";
|
import { unrequitable } from "@/utils";
|
||||||
import { streamPost } from "@/api/streamFetch";
|
import { streamPost } from "@/api/streamFetch";
|
||||||
|
import { SendMessageParams } from "@/components/Assistant/Chat";
|
||||||
|
import { isEmpty } from "lodash-es";
|
||||||
|
import { useChatStore } from "@/stores/chatStore";
|
||||||
|
|
||||||
export function useChatActions(
|
export function useChatActions(
|
||||||
setActiveChat: (chat: Chat | undefined) => void,
|
setActiveChat: (chat: Chat | undefined) => void,
|
||||||
@@ -40,6 +43,9 @@ export function useChatActions(
|
|||||||
} = useConnectStore();
|
} = useConnectStore();
|
||||||
const sourceDataIds = useSearchStore((state) => state.sourceDataIds);
|
const sourceDataIds = useSearchStore((state) => state.sourceDataIds);
|
||||||
const MCPIds = useSearchStore((state) => state.MCPIds);
|
const MCPIds = useSearchStore((state) => state.MCPIds);
|
||||||
|
const setUploadAttachments = useChatStore((state) => {
|
||||||
|
return state.setUploadAttachments;
|
||||||
|
});
|
||||||
|
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
|
|
||||||
@@ -76,7 +82,6 @@ export function useChatActions(
|
|||||||
const [_error, res] = await Post(`/chat/${activeChat?._id}/_close`, {});
|
const [_error, res] = await Post(`/chat/${activeChat?._id}/_close`, {});
|
||||||
response = res;
|
response = res;
|
||||||
}
|
}
|
||||||
console.log("_close", response);
|
|
||||||
},
|
},
|
||||||
[currentService?.id, isTauri]
|
[currentService?.id, isTauri]
|
||||||
);
|
);
|
||||||
@@ -124,7 +129,6 @@ export function useChatActions(
|
|||||||
);
|
);
|
||||||
response = res;
|
response = res;
|
||||||
}
|
}
|
||||||
console.log("_cancel", response);
|
|
||||||
},
|
},
|
||||||
[currentService?.id, isTauri]
|
[currentService?.id, isTauri]
|
||||||
);
|
);
|
||||||
@@ -170,7 +174,6 @@ export function useChatActions(
|
|||||||
...chat,
|
...chat,
|
||||||
messages: hits,
|
messages: hits,
|
||||||
};
|
};
|
||||||
console.log("id_history", updatedChat);
|
|
||||||
updatedChatRef.current = updatedChat;
|
updatedChatRef.current = updatedChat;
|
||||||
setActiveChat(updatedChat);
|
setActiveChat(updatedChat);
|
||||||
callback && callback(updatedChat);
|
callback && callback(updatedChat);
|
||||||
@@ -254,20 +257,6 @@ export function useChatActions(
|
|||||||
`chat-stream-${clientId}-${timestamp}`,
|
`chat-stream-${clientId}-${timestamp}`,
|
||||||
(event) => {
|
(event) => {
|
||||||
const msg = event.payload as string;
|
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);
|
handleChatCreateStreamMessage(msg);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -289,10 +278,12 @@ export function useChatActions(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const prepareChatSession = useCallback(
|
const prepareChatSession = useCallback(
|
||||||
async (value: string, timestamp: number) => {
|
async (timestamp: number, value: string) => {
|
||||||
// 1. Cleaning and preparation
|
// 1. Cleaning and preparation
|
||||||
await clearAllChunkData();
|
await clearAllChunkData();
|
||||||
|
|
||||||
|
setUploadAttachments([]);
|
||||||
|
|
||||||
// 2. Update the status again
|
// 2. Update the status again
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
changeInput && changeInput("");
|
changeInput && changeInput("");
|
||||||
@@ -310,12 +301,17 @@ export function useChatActions(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const createNewChat = useCallback(
|
const createNewChat = useCallback(
|
||||||
async (value: string = "") => {
|
async (params?: SendMessageParams) => {
|
||||||
if (!value) return;
|
const { message, attachments } = params || {};
|
||||||
|
|
||||||
|
console.log("message", message);
|
||||||
|
console.log("attachments", attachments);
|
||||||
|
|
||||||
|
if (!message && isEmpty(attachments)) return;
|
||||||
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
||||||
await prepareChatSession(value, timestamp);
|
await prepareChatSession(timestamp, message ?? "");
|
||||||
|
|
||||||
const queryParams = {
|
const queryParams = {
|
||||||
search: isSearchActive,
|
search: isSearchActive,
|
||||||
@@ -328,19 +324,22 @@ export function useChatActions(
|
|||||||
|
|
||||||
if (isTauri) {
|
if (isTauri) {
|
||||||
if (!currentService?.id) return;
|
if (!currentService?.id) return;
|
||||||
|
|
||||||
console.log("chat_create", clientId, timestamp);
|
console.log("chat_create", clientId, timestamp);
|
||||||
|
|
||||||
await platformAdapter.commands("chat_create", {
|
await platformAdapter.commands("chat_create", {
|
||||||
serverId: currentService?.id,
|
serverId: currentService?.id,
|
||||||
message: value,
|
message,
|
||||||
|
attachments,
|
||||||
queryParams,
|
queryParams,
|
||||||
clientId: `chat-stream-${clientId}-${timestamp}`,
|
clientId: `chat-stream-${clientId}-${timestamp}`,
|
||||||
});
|
});
|
||||||
console.log("_create end", value);
|
console.log("_create end", message);
|
||||||
resetChatState();
|
resetChatState();
|
||||||
} else {
|
} else {
|
||||||
await streamPost({
|
await streamPost({
|
||||||
url: "/chat/_create",
|
url: "/chat/_create",
|
||||||
body: { message: value },
|
body: { message },
|
||||||
queryParams,
|
queryParams,
|
||||||
onMessage: (line) => {
|
onMessage: (line) => {
|
||||||
console.log("⏳", line);
|
console.log("⏳", line);
|
||||||
@@ -365,12 +364,16 @@ export function useChatActions(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
async (content: string, newChat: Chat) => {
|
async (newChat: Chat, params?: SendMessageParams) => {
|
||||||
if (!newChat?._id || !content) return;
|
if (!newChat?._id || !params) return;
|
||||||
|
|
||||||
|
const { message, attachments } = params;
|
||||||
|
|
||||||
|
if (!message && isEmpty(attachments)) return;
|
||||||
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
||||||
await prepareChatSession(content, timestamp);
|
await prepareChatSession(timestamp, message ?? "");
|
||||||
|
|
||||||
const queryParams = {
|
const queryParams = {
|
||||||
search: isSearchActive,
|
search: isSearchActive,
|
||||||
@@ -388,15 +391,16 @@ export function useChatActions(
|
|||||||
serverId: currentService?.id,
|
serverId: currentService?.id,
|
||||||
sessionId: newChat?._id,
|
sessionId: newChat?._id,
|
||||||
queryParams,
|
queryParams,
|
||||||
message: content,
|
message,
|
||||||
|
attachments,
|
||||||
clientId: `chat-stream-${clientId}-${timestamp}`,
|
clientId: `chat-stream-${clientId}-${timestamp}`,
|
||||||
});
|
});
|
||||||
console.log("chat_chat end", content, clientId);
|
console.log("chat_chat end", message, clientId);
|
||||||
resetChatState();
|
resetChatState();
|
||||||
} else {
|
} else {
|
||||||
await streamPost({
|
await streamPost({
|
||||||
url: `/chat/${newChat?._id}/_chat`,
|
url: `/chat/${newChat?._id}/_chat`,
|
||||||
body: { message: content },
|
body: { message },
|
||||||
queryParams,
|
queryParams,
|
||||||
onMessage: (line) => {
|
onMessage: (line) => {
|
||||||
console.log("line", line);
|
console.log("line", line);
|
||||||
@@ -421,10 +425,14 @@ export function useChatActions(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSendMessage = useCallback(
|
const handleSendMessage = useCallback(
|
||||||
async (content: string, activeChat?: Chat) => {
|
async (activeChat?: Chat, params?: SendMessageParams) => {
|
||||||
if (!activeChat?._id || !content) return;
|
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]
|
[chatHistory, sendMessage]
|
||||||
);
|
);
|
||||||
@@ -455,7 +463,6 @@ export function useChatActions(
|
|||||||
response = res;
|
response = res;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("_open", response);
|
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
[currentService?.id, isTauri]
|
[currentService?.id, isTauri]
|
||||||
@@ -484,7 +491,6 @@ export function useChatActions(
|
|||||||
response = res;
|
response = res;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("_history", response);
|
|
||||||
const hits = response?.hits?.hits || [];
|
const hits = response?.hits?.hits || [];
|
||||||
setChats(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 default useScript;
|
||||||
|
|
||||||
export const useIconfontScript = (type: "web" | "app", serverUrl?: string) => {
|
export const useIconfontScript = () => {
|
||||||
if (type === "web") {
|
// Coco Server Icons
|
||||||
useScript(`${serverUrl}/assets/fonts/icons/iconfont.js`);
|
useScript("https://at.alicdn.com/t/c/font_4878526_cykw3et0ezd.js");
|
||||||
useScript(`${serverUrl}/assets/fonts/icons-app/iconfont.js`);
|
// Coco App Icons
|
||||||
} else {
|
useScript("https://at.alicdn.com/t/c/font_4934333_zclkkzo4fgo.js");
|
||||||
// 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(
|
const [error, res]: any = await Get(
|
||||||
`/query/_search?query=${searchInput}`
|
`/query/_search?query=${searchInput}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("_search", error);
|
console.error("_search", error);
|
||||||
response = { failed: [], hits: [], total_hits: 0 };
|
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 { useAppearanceStore } from "@/stores/appearanceStore";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
@@ -5,8 +8,6 @@ import { useExtensionsStore } from "@/stores/extensionsStore";
|
|||||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
import { useStartupStore } from "@/stores/startupStore";
|
import { useStartupStore } from "@/stores/startupStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { isNumber } from "lodash-es";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export const useSyncStore = () => {
|
export const useSyncStore = () => {
|
||||||
const setModifierKey = useShortcutsStore((state) => {
|
const setModifierKey = useShortcutsStore((state) => {
|
||||||
@@ -60,8 +61,8 @@ export const useSyncStore = () => {
|
|||||||
const setFixedWindow = useShortcutsStore((state) => {
|
const setFixedWindow = useShortcutsStore((state) => {
|
||||||
return state.setFixedWindow;
|
return state.setFixedWindow;
|
||||||
});
|
});
|
||||||
const setServiceList = useShortcutsStore((state) => {
|
const setServiceListShortcut = useShortcutsStore((state) => {
|
||||||
return state.setServiceList;
|
return state.setServiceListShortcut;
|
||||||
});
|
});
|
||||||
const setExternal = useShortcutsStore((state) => {
|
const setExternal = useShortcutsStore((state) => {
|
||||||
return state.setExternal;
|
return state.setExternal;
|
||||||
@@ -143,7 +144,7 @@ export const useSyncStore = () => {
|
|||||||
aiAssistant,
|
aiAssistant,
|
||||||
newSession,
|
newSession,
|
||||||
fixedWindow,
|
fixedWindow,
|
||||||
serviceList,
|
serviceListShortcut,
|
||||||
external,
|
external,
|
||||||
aiOverview,
|
aiOverview,
|
||||||
} = payload;
|
} = payload;
|
||||||
@@ -162,7 +163,7 @@ export const useSyncStore = () => {
|
|||||||
setAiAssistant(aiAssistant);
|
setAiAssistant(aiAssistant);
|
||||||
setNewSession(newSession);
|
setNewSession(newSession);
|
||||||
setFixedWindow(fixedWindow);
|
setFixedWindow(fixedWindow);
|
||||||
setServiceList(serviceList);
|
setServiceListShortcut(serviceListShortcut);
|
||||||
setExternal(external);
|
setExternal(external);
|
||||||
setAiOverview(aiOverview);
|
setAiOverview(aiOverview);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { useCallback } from "react";
|
|||||||
|
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { toggle_move_to_active_space_attribute } from "@/commands/system";
|
|
||||||
import { isMac } from "@/utils/platform";
|
|
||||||
|
|
||||||
interface UseTogglePinOptions {
|
interface UseTogglePinOptions {
|
||||||
onPinChange?: (isPinned: boolean) => void;
|
onPinChange?: (isPinned: boolean) => void;
|
||||||
@@ -21,8 +19,8 @@ export const useTogglePin = (options?: UseTogglePinOptions) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await platformAdapter.setAlwaysOnTop(newPinned);
|
await platformAdapter.setAlwaysOnTop(newPinned);
|
||||||
|
await platformAdapter.toggleMoveToActiveSpaceAttribute();
|
||||||
setIsPinned(newPinned);
|
setIsPinned(newPinned);
|
||||||
isMac && toggle_move_to_active_space_attribute();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to toggle window pin state:", err);
|
console.error("Failed to toggle window pin state:", err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
import { useEffect, useCallback, useRef } from "react";
|
|
||||||
import { useWebSocket as useWebSocketAHook } from "ahooks";
|
|
||||||
|
|
||||||
import { useAppStore } from "@/stores/appStore";
|
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
|
||||||
import { Server } from "@/types/server";
|
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
|
||||||
|
|
||||||
enum ReadyState {
|
|
||||||
Connecting = 0,
|
|
||||||
Open = 1,
|
|
||||||
Closing = 2,
|
|
||||||
Closed = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WebSocketProps {
|
|
||||||
clientId: string;
|
|
||||||
connected: boolean;
|
|
||||||
setConnected: (connected: boolean) => void;
|
|
||||||
dealMsgRef: React.MutableRefObject<((msg: string) => void) | null>;
|
|
||||||
onWebsocketSessionId?: (sessionId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function useWebSocket({
|
|
||||||
clientId,
|
|
||||||
connected,
|
|
||||||
setConnected,
|
|
||||||
dealMsgRef,
|
|
||||||
onWebsocketSessionId,
|
|
||||||
}: WebSocketProps) {
|
|
||||||
const isTauri = useAppStore((state) => state.isTauri);
|
|
||||||
const endpoint_websocket = useAppStore((state) => state.endpoint_websocket);
|
|
||||||
const addError = useAppStore((state) => state.addError);
|
|
||||||
|
|
||||||
const currentService = useConnectStore((state) => state.currentService);
|
|
||||||
|
|
||||||
const websocketIdRef = useRef<string>("");
|
|
||||||
const messageQueue = useRef<string[]>([]);
|
|
||||||
const processingRef = useRef(false);
|
|
||||||
|
|
||||||
// web
|
|
||||||
const { readyState, connect, disconnect } = useWebSocketAHook(
|
|
||||||
//"wss://coco.infini.cloud/ws",
|
|
||||||
//"ws://localhost:9000/ws",
|
|
||||||
isTauri ? "" : endpoint_websocket,
|
|
||||||
{
|
|
||||||
manual: true,
|
|
||||||
reconnectLimit: 3,
|
|
||||||
reconnectInterval: 3000,
|
|
||||||
onMessage: (event) => {
|
|
||||||
const msg = event.data as string;
|
|
||||||
messageQueue.current.push(msg);
|
|
||||||
processQueue();
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isTauri) {
|
|
||||||
connect(); // web
|
|
||||||
}
|
|
||||||
}, [isTauri, connect]);
|
|
||||||
|
|
||||||
const processMessage = useCallback(
|
|
||||||
(msg: string) => {
|
|
||||||
try {
|
|
||||||
if (msg.includes("websocket-session-id")) {
|
|
||||||
const sessionId = msg.split(":")[1].trim();
|
|
||||||
websocketIdRef.current = sessionId;
|
|
||||||
setConnected(true); // web connected
|
|
||||||
console.log("setConnected:", sessionId);
|
|
||||||
onWebsocketSessionId?.(sessionId);
|
|
||||||
} else {
|
|
||||||
dealMsgRef.current?.(msg);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error processing message:", error, msg);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onWebsocketSessionId]
|
|
||||||
);
|
|
||||||
const processQueue = useCallback(() => {
|
|
||||||
if (processingRef.current || messageQueue.current.length === 0) return;
|
|
||||||
|
|
||||||
processingRef.current = true;
|
|
||||||
while (messageQueue.current.length > 0) {
|
|
||||||
const msg = messageQueue.current.shift();
|
|
||||||
if (msg) {
|
|
||||||
// console.log("Processing message:", msg.substring(0, 100));
|
|
||||||
processMessage(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
processingRef.current = false;
|
|
||||||
}, [processMessage]);
|
|
||||||
useEffect(() => {
|
|
||||||
// web
|
|
||||||
if (readyState !== ReadyState.Open) {
|
|
||||||
setConnected(false); // state
|
|
||||||
}
|
|
||||||
}, [readyState]);
|
|
||||||
|
|
||||||
// Tauri
|
|
||||||
// 1. WebSocket connects when loading or switching services
|
|
||||||
// src/components/Assistant/ChatHeader.tsx
|
|
||||||
// 2. If not connected or disconnected, input box has a connect button, clicking it will connect to WebSocket
|
|
||||||
// src/components/Search/InputBox.tsx
|
|
||||||
const reconnect = useCallback(
|
|
||||||
async (server?: Server) => {
|
|
||||||
setConnected(false); // Disconnect before attempting to reconnect
|
|
||||||
if (isTauri) {
|
|
||||||
const targetServer = server || currentService;
|
|
||||||
if (!targetServer?.id) return;
|
|
||||||
try {
|
|
||||||
// console.log("reconnect", targetServer.id);
|
|
||||||
await platformAdapter.commands("connect_to_server", targetServer.id, clientId);
|
|
||||||
} catch (error) {
|
|
||||||
setConnected(false); // error
|
|
||||||
console.error("Failed to connect:", error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentService, clientId]
|
|
||||||
);
|
|
||||||
const disconnectWS = useCallback(async () => {
|
|
||||||
if (!connected) return;
|
|
||||||
if (isTauri) {
|
|
||||||
try {
|
|
||||||
console.log("disconnect");
|
|
||||||
await platformAdapter.commands("disconnect", clientId);
|
|
||||||
setConnected(false); // disconnected
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to disconnect:", error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
disconnect();
|
|
||||||
}
|
|
||||||
}, [connected]);
|
|
||||||
|
|
||||||
const updateDealMsg = useCallback(
|
|
||||||
(newDealMsg: (msg: string) => void) => {
|
|
||||||
dealMsgRef.current = newDealMsg;
|
|
||||||
},
|
|
||||||
[dealMsgRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
const unlistenErrorRef = useRef<Promise<() => void> | null>(null);
|
|
||||||
const unlistenCancelRef = useRef<Promise<() => void> | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isTauri || !currentService?.id) return;
|
|
||||||
|
|
||||||
const unlisten_message = platformAdapter.listenEvent(`ws-message-${clientId}`, (event) => {
|
|
||||||
const msg = event.payload as string;
|
|
||||||
// console.log(`ws-message-${clientId}`, msg);
|
|
||||||
if (msg.includes("websocket-session-id")) {
|
|
||||||
const sessionId = msg.split(":")[1].trim();
|
|
||||||
websocketIdRef.current = sessionId;
|
|
||||||
console.log("setConnected sessionId:", sessionId);
|
|
||||||
setConnected(true); // Tauri connected
|
|
||||||
if (onWebsocketSessionId) {
|
|
||||||
onWebsocketSessionId(sessionId);
|
|
||||||
}
|
|
||||||
// Listen for errors
|
|
||||||
unlistenErrorRef.current = platformAdapter.listenEvent(`ws-error-${clientId}`, (event) => {
|
|
||||||
if (connected) {
|
|
||||||
const id = event.payload as string;
|
|
||||||
console.error(`ws-error-${clientId}`, id === currentService?.id, connected);
|
|
||||||
if (id === currentService?.id) {
|
|
||||||
addError("WebSocket connection failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setConnected(false); // error
|
|
||||||
});
|
|
||||||
// Listen for cancel
|
|
||||||
unlistenCancelRef.current = platformAdapter.listenEvent(`ws-cancel-${clientId}`, () => {
|
|
||||||
setConnected(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dealMsgRef.current?.(msg);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlisten_message.then((fn) => fn());
|
|
||||||
if (unlistenErrorRef.current) {
|
|
||||||
unlistenErrorRef.current.then((fn) => fn());
|
|
||||||
}
|
|
||||||
if (unlistenCancelRef.current) {
|
|
||||||
unlistenCancelRef.current.then((fn) => fn());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [currentService?.id, dealMsgRef, connected, clientId]);
|
|
||||||
|
|
||||||
return { reconnect, disconnectWS, updateDealMsg };
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import SVGWrap from "./SVGWrap";
|
|
||||||
|
|
||||||
export default function ArrowLeft(props: I.SVG) {
|
|
||||||
return (
|
|
||||||
<SVGWrap {...props} viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="m7.85 13l2.85 2.85q.3.3.288.7t-.288.7q-.3.3-.712.313t-.713-.288L4.7 12.7q-.3-.3-.3-.7t.3-.7l4.575-4.575q.3-.3.713-.287t.712.312q.275.3.288.7t-.288.7L7.85 11H19q.425 0 .713.288T20 12t-.288.713T19 13z"
|
|
||||||
/>
|
|
||||||
</SVGWrap>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import SVGWrap from "./SVGWrap";
|
|
||||||
|
|
||||||
export default function Ask(props: I.SVG) {
|
|
||||||
return (
|
|
||||||
<SVGWrap {...props} viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M13.038 19.927A9.93 9.93 0 0 1 7.7 19L3 20l1.3-3.9C1.976 12.663 2.874 8.228 6.4 5.726c3.526-2.501 8.59-2.296 11.845.48c1.993 1.7 2.93 4.043 2.746 6.346M19 16l-2 3h4l-2 3"
|
|
||||||
/>
|
|
||||||
</SVGWrap>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { I } from '../index.d';
|
||||||
import SVGWrap from "./SVGWrap";
|
import SVGWrap from "./SVGWrap";
|
||||||
|
|
||||||
export default function History(props: I.SVG) {
|
export default function History(props: I.SVG) {
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import SVGWrap from "./SVGWrap";
|
|
||||||
|
|
||||||
export default function Link(props: I.SVG) {
|
|
||||||
return (
|
|
||||||
<SVGWrap {...props} viewBox="0 0 256 256">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M117.18 188.74a12 12 0 0 1 0 17l-5.12 5.12A58.26 58.26 0 0 1 70.6 228a58.62 58.62 0 0 1-41.46-100.08l34.75-34.75a58.64 58.64 0 0 1 98.56 28.11a12 12 0 1 1-23.37 5.44a34.65 34.65 0 0 0-58.22-16.58l-34.75 34.75A34.62 34.62 0 0 0 70.57 204a34.41 34.41 0 0 0 24.49-10.14l5.11-5.12a12 12 0 0 1 17.01 0M226.83 45.17a58.65 58.65 0 0 0-82.93 0l-5.11 5.11a12 12 0 0 0 17 17l5.12-5.12a34.63 34.63 0 1 1 49 49l-34.81 34.7A34.39 34.39 0 0 1 150.61 156a34.63 34.63 0 0 1-33.69-26.72a12 12 0 0 0-23.38 5.44A58.64 58.64 0 0 0 150.56 180h.05a58.28 58.28 0 0 0 41.47-17.17l34.75-34.75a58.62 58.62 0 0 0 0-82.91"
|
|
||||||
/>
|
|
||||||
</SVGWrap>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { I } from '../index.d';
|
||||||
import SVGWrap from "./SVGWrap";
|
import SVGWrap from "./SVGWrap";
|
||||||
|
|
||||||
export default function Pin(props: I.SVG) {
|
export default function Pin(props: I.SVG) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { I } from '../index.d';
|
||||||
import SVGWrap from "./SVGWrap";
|
import SVGWrap from "./SVGWrap";
|
||||||
|
|
||||||
export default function PinOff(props: I.SVG) {
|
export default function PinOff(props: I.SVG) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user