23 Commits

Author SHA1 Message Date
Steve Lau
7d0d11860c try to enable microphone access for signed macOS builds 2025-08-01 11:54:06 +08:00
ayangweb
d48d4af7d2 refactor: optimize upload shortcut display (#850) 2025-08-01 10:24:30 +08:00
ayangweb
876d14f9d9 refactor: optimize enter key display (#849) 2025-08-01 10:20:47 +08:00
ayangweb
a8e090c9be refactor: optimized sending messages (#848) 2025-08-01 09:36:06 +08:00
SteveLauC
c30df6cee0 feat: sub extension can set 'platforms' now (#847)
Before this commit, sub extensions were not allowed to set their
"platforms" field, this restriction is lifted in this commit.

By allowing this, a group extension can have sub extensions for
different platforms, here is an example (irrelavent fields are omitted
for the sake of simplicity):

```json
{
  "name": "Suspend my machine",
  "type": "Group",
  "platforms": ["macos", "windows"],
  "commands": [
    {
      "name": "Suspend macOS":
      "platforms": ["macos"],
      "action": {...}
    },
    {
      "name": "Suspend Windows":
      "platforms": ["windows"],
      "action": {...}
    }
  ]
}
```

While loading or installing extensions, incompatible sub extensions will
be filtered out by Coco, e.g., you won't see that "Suspend Windows"
command if you are on macOS.

An extra check is added in this commit to ensure a sub extensions won't
support the platforms that are incompatible with its main extension.

Even though main extensions and sub extensions can both have "platforms"
specified, the semantics of this field, when not set, differs between them.
For main extensions, it means this extension is compatible with all the
platforms supported by Coco (null == all).  For sub extensions, having it
not set implicitly says that this field has the same value as the main
extension's "platforms" field.

The primary reason behind this design is that if we choose the semantics used
by the main extension, treating null as all, all the extensions we currently
have will become invalid, because they are all macOS-only, the main extensions's
"platforms" field is "macos" and sub extensions' "platforms" is not set (null),
they will be equivalent to:

```json
{
  "name": "this is macOS-only",
  "type": "Group",
  "platforms": ["macos"],
  "commands": [
    {
      "name": "How the fxxk can this support all the platforms!"
      "platforms": ["macos", "windows", "linux"],
      "type": "Command",
      "action": {...}
    }
  ]
}
```
This hits exactly the check we mentioned earlier and will be rejected by
Coco.  If we have users installed them, the installed extensions will be
treated invalid and rejected by future Coco release, boom, we break backward
compatibility.

Also, the current design actually makes sense.  Nobody wants to repeatedly
tell Coco that all the sub extensions support macOS if this can be said only
once:

```json
{
  "name": "this is macOS-only",
  "platforms": ["macos"],
  "commands": [
    {
      "name": "This supports macOS"
      "platforms": ["macos"],
    },
    {
      "name": "This supports macOS too"
      "platforms": ["macos"],
    },
    {
      "name": "Guess what! this also supports macOS"
      "platforms": ["macos"],
    },
    {
      "name": "Come on dude, do I really to say this platform=macos so many times"
      "platforms": ["macos"],
    }
  ]
}
```
2025-07-31 21:49:59 +08:00
BiggerRain
b833769c25 refactor: calling service related interfaces (#831)
* chore: server

* chore: add

* refactor: calling service related interfaces

* chore: server list

* chore: add

* chore: add

* update

* chore: remove logs

* focs: update notes

* docs: remove server doc

---------

Co-authored-by: ayang <473033518@qq.com>
2025-07-31 15:59:35 +08:00
ayangweb
855fb2a168 feat: support sending files in chat messages (#764)
* feat: support sending files in chat messages

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* docs: update changelog
2025-07-31 15:36:03 +08:00
SteveLauC
d2735ec13b refactor: check Extension/plugin.json from all sources (#846)
Coco App has 4 sources of Extension/plugin.json that should be checked:

1. From the "<data directory>/third_party_extensions" directory
2. Imported via "Import Local Extension"
3. Downloaded from the "store/extension/<extension ID>/_download" API
4. From coco-extensions repository

   Granted, Coco APP won't check these files directly, but we will
   re-use the code and run them in that repository's CI.

Previously, only the Extensions from the first source were checked/validated.
This commit extracts the validation logic to a function and applies it to all
4 sources.

Also, the return value of the Tauri command "list_extensions()" has changed.
We no longer return a boolean indicating if any invalid extensions
are found during loading, which only makes sense when installing
extensions requires users to manually edit data files. Since we now
support extension store and local extension imports, it could be omitted.
2025-07-31 14:27:23 +08:00
SteveLauC
c40fc5818a chore: ignore tauri::AppHandle's generic argument R (#845)
This commit removes the generic argument R from all the AppHandle imports, which
is feasible as it has a default type. This change is made not only for simplicity,
but also **consistency**. Trait SearchSource uses this type:

```rust
pub trait SearchSource {
    async fn search(
        &self,
        tauri_app_handle: AppHandle,
        query: SearchQuery,
    ) -> Result<QueryResponse, SearchError>;
}
```

In order to make trait SearchSource object-safe, the AppHandle used in it cannot
contain generic arguments. So some parts of Coco already omit this generic
argument. This commit cleans up the remaining instances and unifies the usage
project-wide.
2025-07-29 21:55:03 +08:00
SteveLauC
a553ebd593 feat: support Quicklink on Rust side (#760)
This commit implements the support for Quicklink on Rust side. We still
need the frontend part to make this complete.
2025-07-29 16:30:12 +08:00
BiggerRain
232166eb89 chore: delete unused code files and dependencies (#841)
Mainly delete unused webSocket content, and delete other unused code files and dependencies
2025-07-29 13:02:28 +08:00
Medcl
99144950d9 Revert "chore: add macos config for tauri (#837)" (#840)
This reverts commit ee45d21bbe.
2025-07-29 11:04:53 +08:00
SteveLauC
32d4f45144 feat: support installing local extensions (#749)
This commit adds support for installing extensions from a local folder path:

```text
extension-directory/
├── assets/
│   ├── icon.png
│   └── other-assets...
└── plugin.json
```

Useful for testing and development of extensions before publishing.

Co-authored-by: ayang <473033518@qq.com>
2025-07-29 10:26:47 +08:00
BiggerRain
6bc78b41ef chore: web component loading font icon (#838)
* chore: web component loading font icon

* docs: update notes
2025-07-28 19:03:40 +08:00
SteveLauC
cd54beee04 refactor: split query_coco_fusion() (#836)
This commit splits query_coco_fusion() into 2 functions:

1. query_coco_fusion_single_query_source()
2. query_coco_fusion_multi_query_sources()

query_coco_fusion_single_query_source(), as the name suggests, will only search
1 query source. Due to this simplicity, it does not need the complex re-ranking
procedure used by query_coco_fusion_multi_query_sources(), which is the primary
reason why this commit was made.

Another reason behind the change is that the re-ranking logic makes the
search results of querying single query source incorrect, it removes documents
from the results. I didn't investigate the issue because dropping the complex
logic in single query source search would be the best solution here.
2025-07-28 17:29:17 +08:00
ayangweb
ee45d21bbe chore: add macos config for tauri (#837) 2025-07-28 16:35:11 +08:00
ayangweb
4709f8c660 feat: enhance ui for skipped version (#834) 2025-07-28 11:43:10 +08:00
SteveLauC
4696aa1759 test: test extract_build_number() (#835)
This commit adds a test for extract_build_number(), which I forgot to do
in commit 067fb7144f6[1].

[1]: 067fb7144f
2025-07-28 11:42:50 +08:00
ayangweb
924fc09516 fix: fix issue with update check failure (#833)
* fix: fix issue with update check failure

* docs: update changelog
2025-07-28 10:06:07 +08:00
SteveLauC
5a700662dd chore: release notes for 0.7.1 (#832) 2025-07-28 10:00:12 +08:00
BiggerRain
8f992bfa92 chore: bump version number to 0.7.1 (#830) 2025-07-27 17:26:08 +08:00
BiggerRain
e7dd27c744 chore: add toggle_move_to_active_space_attribute (#829)
* chore: add toggle_move_to_active_space_attribute

* chore: pin

* chore: add

* update

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-07-27 16:50:11 +08:00
ayangweb
7914836c3e fix: correct enter key behavior (#828) 2025-07-27 11:52:40 +08:00
147 changed files with 4266 additions and 3398 deletions

2
.env
View File

@@ -1,5 +1,3 @@
COCO_SERVER_URL=http://localhost:9000 #https://coco.infini.cloud #http://localhost:9000
COCO_WEBSOCKET_URL=ws://localhost:9000/ws #wss://coco.infini.cloud/ws #ws://localhost:9000/ws
#TAURI_DEV_HOST=0.0.0.0

View File

@@ -5,22 +5,46 @@ title: "Release Notes"
# Release Notes
Information about release notes of Coco Server is provided here.
Information about release notes of Coco App is provided here.
## Latest (In development)
### ❌ Breaking changes
### 🚀 Features
- feat: enhance ui for skipped version #834
- feat: support installing local extensions #749
- feat: support sending files in chat messages #764
- feat: sub extension can set 'platforms' now #847
### 🐛 Bug fix
- fix: fix issue with update check failure #833
### ✈️ Improvements
- refactor: calling service related interfaces #831
- refactor: split query_coco_fusion() #836
- chore: web component loading font icon #838
- chore: delete unused code files and dependencies #841
- chore: ignore tauri::AppHandle's generic argument R #845
- refactor: check Extension/plugin.json from all sources #846
## 0.7.1 (2025-07-27)
### ❌ Breaking changes
### 🚀 Features
### 🐛 Bug fix
- fix: correct enter key behavior #828
### ✈️ Improvements
- chore: web component add notification component #825
- refactor: collection behavior defaults to `MoveToActiveSpace`, and only use `CanJoinAllSpaces` when window is pinned #829
## 0.7.0 (2025-07-25)

View File

@@ -1,7 +1,7 @@
{
"name": "coco",
"private": true,
"version": "0.7.0",
"version": "0.7.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -31,7 +31,6 @@
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/plugin-updater": "github:infinilabs/tauri-plugin-updater#v2",
"@tauri-apps/plugin-websocket": "~2.3.0",
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
"@wavesurfer/react": "^1.0.11",
"ahooks": "^3.8.4",

10
pnpm-lock.yaml generated
View File

@@ -47,9 +47,6 @@ importers:
'@tauri-apps/plugin-updater':
specifier: github:infinilabs/tauri-plugin-updater#v2
version: https://codeload.github.com/infinilabs/tauri-plugin-updater/tar.gz/358e689c65e9943b53eff50bcb9dfd5b1cfc4072
'@tauri-apps/plugin-websocket':
specifier: ~2.3.0
version: 2.3.0
'@tauri-apps/plugin-window':
specifier: 2.0.0-alpha.1
version: 2.0.0-alpha.1
@@ -1261,9 +1258,6 @@ packages:
resolution: {tarball: https://codeload.github.com/infinilabs/tauri-plugin-updater/tar.gz/358e689c65e9943b53eff50bcb9dfd5b1cfc4072}
version: 2.7.1
'@tauri-apps/plugin-websocket@2.3.0':
resolution: {integrity: sha512-eAwRGe3tnqDeQYE0wq4g1PUKbam9tYvlC4uP/au12Y/z7MP4lrS4ylv+aoZ5Ly+hTlBdi7hDkhHomwF/UeBesA==}
'@tauri-apps/plugin-window@2.0.0-alpha.1':
resolution: {integrity: sha512-dFOAgal/3Txz3SQ+LNQq0AK1EPC+acdaFlwPVB/6KXUZYmaFleIlzgxDVoJCQ+/xOhxvYrdQaFLefh0I/Kldbg==}
@@ -4643,10 +4637,6 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.5.0
'@tauri-apps/plugin-websocket@2.3.0':
dependencies:
'@tauri-apps/api': 2.5.0
'@tauri-apps/plugin-window@2.0.0-alpha.1':
dependencies:
'@tauri-apps/api': 2.0.0-alpha.6

187
src-tauri/Cargo.lock generated
View File

@@ -840,7 +840,7 @@ dependencies = [
[[package]]
name = "coco"
version = "0.7.0"
version = "0.7.1"
dependencies = [
"anyhow",
"applications",
@@ -852,6 +852,7 @@ dependencies = [
"cfg-if",
"chinese-number",
"chrono",
"cocoa 0.24.1",
"derive_more 2.0.1",
"dirs 5.0.1",
"enigo",
@@ -861,6 +862,7 @@ dependencies = [
"hostname",
"http 1.3.1",
"hyper 0.14.32",
"indexmap 2.10.0",
"lazy_static",
"log",
"meval",
@@ -877,6 +879,7 @@ dependencies = [
"serde_json",
"serde_plain",
"strsim 0.10.0",
"strum",
"sysinfo",
"tauri",
"tauri-build",
@@ -899,13 +902,12 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-websocket",
"tauri-plugin-windows-version",
"thiserror 1.0.69",
"tokio",
"tokio-native-tls",
"tokio-stream",
"tokio-tungstenite 0.20.1",
"tokio-tungstenite",
"tokio-util",
"tungstenite 0.24.0",
"url",
@@ -914,6 +916,22 @@ dependencies = [
"zip 4.0.0",
]
[[package]]
name = "cocoa"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
dependencies = [
"bitflags 1.3.2",
"block",
"cocoa-foundation 0.1.2",
"core-foundation 0.9.4",
"core-graphics 0.22.3",
"foreign-types 0.3.2",
"libc",
"objc",
]
[[package]]
name = "cocoa"
version = "0.26.0"
@@ -922,14 +940,28 @@ checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2"
dependencies = [
"bitflags 2.9.0",
"block",
"cocoa-foundation",
"cocoa-foundation 0.2.0",
"core-foundation 0.10.0",
"core-graphics",
"core-graphics 0.24.0",
"foreign-types 0.5.0",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
dependencies = [
"bitflags 1.3.2",
"block",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.2.0"
@@ -939,7 +971,7 @@ dependencies = [
"bitflags 2.9.0",
"block",
"core-foundation 0.10.0",
"core-graphics-types",
"core-graphics-types 0.2.0",
"libc",
"objc",
]
@@ -1056,6 +1088,19 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.22.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"foreign-types 0.3.2",
"libc",
]
[[package]]
name = "core-graphics"
version = "0.24.0"
@@ -1064,11 +1109,22 @@ checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.9.0",
"core-foundation 0.10.0",
"core-graphics-types",
"core-graphics-types 0.2.0",
"foreign-types 0.5.0",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.2.0"
@@ -1472,8 +1528,8 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67fd9ae1736d6ebb2e472740fbee86fb2178b8d56feb98a6751411d4c95b7e72"
dependencies = [
"cocoa",
"core-graphics",
"cocoa 0.26.0",
"core-graphics 0.24.0",
"dunce",
"gdk",
"gdkx11",
@@ -1562,7 +1618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6"
dependencies = [
"core-foundation 0.10.0",
"core-graphics",
"core-graphics 0.24.0",
"foreign-types-shared 0.3.1",
"libc",
"log",
@@ -2432,7 +2488,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.3.1",
"indexmap 2.9.0",
"indexmap 2.10.0",
"slab",
"tokio",
"tokio-util",
@@ -2714,7 +2770,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.59.0",
"windows-core 0.61.2",
]
[[package]]
@@ -2901,9 +2957,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.9.0"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
dependencies = [
"equivalent",
"hashbrown 0.15.3",
@@ -4534,7 +4590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d"
dependencies = [
"base64 0.22.1",
"indexmap 2.9.0",
"indexmap 2.10.0",
"quick-xml 0.32.0",
"serde",
"time",
@@ -5498,7 +5554,7 @@ version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"indexmap 2.9.0",
"indexmap 2.10.0",
"itoa 1.0.15",
"memchr",
"ryu",
@@ -5556,7 +5612,7 @@ dependencies = [
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.9.0",
"indexmap 2.10.0",
"serde",
"serde_derive",
"serde_json",
@@ -5721,7 +5777,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
"core-graphics 0.24.0",
"foreign-types 0.5.0",
"js-sys",
"log",
@@ -5810,6 +5866,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -5889,7 +5966,7 @@ dependencies = [
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows 0.59.0",
"windows 0.61.3",
]
[[package]]
@@ -5947,7 +6024,7 @@ checksum = "1e59c1f38e657351a2e822eadf40d6a2ad4627b9c25557bc1180ec1b3295ef82"
dependencies = [
"bitflags 2.9.0",
"core-foundation 0.10.0",
"core-graphics",
"core-graphics 0.24.0",
"crossbeam-channel",
"dispatch",
"dlopen2",
@@ -6145,9 +6222,9 @@ source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#d4b9df797959f8fa
dependencies = [
"bitflags 2.9.0",
"block",
"cocoa",
"cocoa 0.26.0",
"core-foundation 0.10.0",
"core-graphics",
"core-graphics 0.24.0",
"objc",
"objc-foundation",
"objc_id",
@@ -6501,25 +6578,6 @@ dependencies = [
"zip 2.6.1",
]
[[package]]
name = "tauri-plugin-websocket"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af3ac71aec5fb0ae5441e830cd075b1cbed49ac3d39cb975a4894ea8fa2e62b9"
dependencies = [
"futures-util",
"http 1.3.1",
"log",
"rand 0.8.5",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.12",
"tokio",
"tokio-tungstenite 0.26.2",
]
[[package]]
name = "tauri-plugin-windows-version"
version = "2.0.0"
@@ -6627,7 +6685,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8d321dbc6f998d825ab3f0d62673e810c861aac2d0de2cc2c395328f1d113b4"
dependencies = [
"embed-resource",
"indexmap 2.9.0",
"indexmap 2.10.0",
"toml",
]
@@ -6854,22 +6912,6 @@ dependencies = [
"tungstenite 0.20.1",
]
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite 0.26.2",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-util"
version = "0.7.15"
@@ -6910,7 +6952,7 @@ version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap 2.9.0",
"indexmap 2.10.0",
"toml_datetime",
"winnow 0.5.40",
]
@@ -6921,7 +6963,7 @@ version = "0.20.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81"
dependencies = [
"indexmap 2.9.0",
"indexmap 2.10.0",
"toml_datetime",
"winnow 0.5.40",
]
@@ -6932,7 +6974,7 @@ version = "0.22.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
dependencies = [
"indexmap 2.9.0",
"indexmap 2.10.0",
"serde",
"serde_spanned",
"toml_datetime",
@@ -7076,25 +7118,6 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [
"bytes",
"data-encoding",
"http 1.3.1",
"httparse",
"log",
"rand 0.9.1",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.12",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -8565,7 +8588,7 @@ dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"indexmap 2.9.0",
"indexmap 2.10.0",
"memchr",
]
@@ -8584,7 +8607,7 @@ dependencies = [
"flate2",
"getrandom 0.3.2",
"hmac",
"indexmap 2.9.0",
"indexmap 2.10.0",
"liblzma",
"memchr",
"pbkdf2",

View File

@@ -1,6 +1,6 @@
[package]
name = "coco"
version = "0.7.0"
version = "0.7.1"
description = "Search, connect, collaborate all in one place."
authors = ["INFINI Labs"]
edition = "2024"
@@ -51,7 +51,6 @@ serde = { version = "1", features = ["derive"] }
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] }
tauri-plugin-http = "2"
tauri-plugin-websocket = "2"
tauri-plugin-deep-link = "2.0.0"
tauri-plugin-store = "2.2.0"
tauri-plugin-os = "2"
@@ -106,9 +105,12 @@ camino = "1.1.10"
tokio-stream = { version = "0.1.17", features = ["io-util"] }
cfg-if = "1.0.1"
sysinfo = "0.35.2"
indexmap = { version = "2.10.0", features = ["serde"] }
strum = { version = "0.27.2", features = ["derive"] }
[target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
cocoa = "0.24"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }

View File

@@ -12,6 +12,8 @@
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.network.client</key>
@@ -24,6 +26,5 @@
<string>6GVZT94974.rs.coco.app</string>
<key>com.apple.developer.team-identifier</key>
<string>6GVZT94974</string>
</dict>
</plist>
</plist>

View File

@@ -2,11 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>Request camera access for WebRTC</string>
<key>NSMicrophoneUsageDescription</key>
<string>Request microphone access for WebRTC</string>
<key>CFBundleIdentifier</key>
<string>rs.coco.app</string>
<key>CFBundleExecutable</key>

View File

@@ -37,9 +37,6 @@
"http:allow-fetch-cancel",
"http:allow-fetch-read-body",
"http:allow-fetch-send",
"websocket:default",
"websocket:allow-connect",
"websocket:allow-send",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",

View File

@@ -1,5 +1,5 @@
use crate::common::assistant::ChatRequestMessage;
use crate::common::http::{GetResponse, convert_query_params_to_strings};
use crate::common::http::convert_query_params_to_strings;
use crate::common::register::SearchSourceRegistry;
use crate::server::http_client::HttpClient;
use crate::{common, server::servers::COCO_SERVERS};
@@ -9,12 +9,12 @@ use futures_util::TryStreamExt;
use http::Method;
use serde_json::Value;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter, Manager, Runtime};
use tauri::{AppHandle, Emitter, Manager};
use tokio::io::AsyncBufReadExt;
#[tauri::command]
pub async fn chat_history<R: Runtime>(
_app_handle: AppHandle<R>,
pub async fn chat_history(
_app_handle: AppHandle,
server_id: String,
from: u32,
size: u32,
@@ -43,8 +43,8 @@ pub async fn chat_history<R: Runtime>(
}
#[tauri::command]
pub async fn session_chat_history<R: Runtime>(
_app_handle: AppHandle<R>,
pub async fn session_chat_history(
_app_handle: AppHandle,
server_id: String,
session_id: String,
from: u32,
@@ -66,8 +66,8 @@ pub async fn session_chat_history<R: Runtime>(
}
#[tauri::command]
pub async fn open_session_chat<R: Runtime>(
_app_handle: AppHandle<R>,
pub async fn open_session_chat(
_app_handle: AppHandle,
server_id: String,
session_id: String,
) -> Result<String, String> {
@@ -81,8 +81,8 @@ pub async fn open_session_chat<R: Runtime>(
}
#[tauri::command]
pub async fn close_session_chat<R: Runtime>(
_app_handle: AppHandle<R>,
pub async fn close_session_chat(
_app_handle: AppHandle,
server_id: String,
session_id: String,
) -> Result<String, String> {
@@ -95,8 +95,8 @@ pub async fn close_session_chat<R: Runtime>(
common::http::get_response_body_text(response).await
}
#[tauri::command]
pub async fn cancel_session_chat<R: Runtime>(
_app_handle: AppHandle<R>,
pub async fn cancel_session_chat(
_app_handle: AppHandle,
server_id: String,
session_id: String,
query_params: Option<HashMap<String, Value>>,
@@ -112,72 +112,37 @@ pub async fn cancel_session_chat<R: Runtime>(
}
#[tauri::command]
pub async fn new_chat<R: Runtime>(
_app_handle: AppHandle<R>,
pub async fn chat_create(
app_handle: AppHandle,
server_id: String,
websocket_id: String,
message: String,
query_params: Option<HashMap<String, Value>>,
) -> Result<GetResponse, String> {
let body = if !message.is_empty() {
let message = ChatRequestMessage {
message: Some(message),
};
Some(
serde_json::to_string(&message)
.map_err(|e| format!("Failed to serialize message: {}", e))?
.into(),
)
} else {
None
};
let mut headers = HashMap::new();
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
let response = HttpClient::advanced_post(
&server_id,
"/chat/_new",
Some(headers),
convert_query_params_to_strings(query_params),
body,
)
.await
.map_err(|e| format!("Error sending message: {}", e))?;
let body_text = common::http::get_response_body_text(response).await?;
log::debug!("New chat response: {}", &body_text);
let chat_response: GetResponse = serde_json::from_str(&body_text)
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
if chat_response.result != "created" {
return Err(format!("Unexpected result: {}", chat_response.result));
}
Ok(chat_response)
}
#[tauri::command]
pub async fn chat_create<R: Runtime>(
app_handle: AppHandle<R>,
server_id: String,
message: String,
message: Option<String>,
attachments: Option<Vec<String>>,
query_params: Option<HashMap<String, Value>>,
client_id: String,
) -> Result<(), String> {
let body = if !message.is_empty() {
let message = ChatRequestMessage {
message: Some(message),
println!("chat_create message: {:?}", message);
println!("chat_create attachments: {:?}", attachments);
let message_empty = message.as_ref().map_or(true, |m| m.is_empty());
let attachments_empty = attachments.as_ref().map_or(true, |a| a.is_empty());
if message_empty && attachments_empty {
return Err("Message and attachments are empty".to_string());
}
let body = {
let request_message: ChatRequestMessage = ChatRequestMessage {
message,
attachments,
};
println!("chat_create body: {:?}", request_message);
Some(
serde_json::to_string(&message)
serde_json::to_string(&request_message)
.map_err(|e| format!("Failed to serialize message: {}", e))?
.into(),
)
} else {
None
};
let response = HttpClient::advanced_post(
@@ -213,8 +178,6 @@ pub async fn chat_create<R: Runtime>(
if let Err(err) = app_handle.emit(&client_id, line) {
log::error!("Emit failed: {:?}", err);
print!("Error sending message: {:?}", err);
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
}
}
@@ -223,56 +186,38 @@ pub async fn chat_create<R: Runtime>(
}
#[tauri::command]
pub async fn send_message<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
websocket_id: String,
session_id: String,
message: String,
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
) -> Result<String, String> {
let path = format!("/chat/{}/_send", session_id);
let msg = ChatRequestMessage {
message: Some(message),
};
let mut headers = HashMap::new();
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap());
let response = HttpClient::advanced_post(
&server_id,
path.as_str(),
Some(headers),
convert_query_params_to_strings(query_params),
Some(body),
)
.await
.map_err(|e| format!("Error cancel session: {}", e))?;
common::http::get_response_body_text(response).await
}
#[tauri::command]
pub async fn chat_chat<R: Runtime>(
app_handle: AppHandle<R>,
pub async fn chat_chat(
app_handle: AppHandle,
server_id: String,
session_id: String,
message: String,
message: Option<String>,
attachments: Option<Vec<String>>,
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
client_id: String,
) -> Result<(), String> {
let body = if !message.is_empty() {
let message = ChatRequestMessage {
message: Some(message),
println!("chat_chat message: {:?}", message);
println!("chat_chat attachments: {:?}", attachments);
let message_empty = message.as_ref().map_or(true, |m| m.is_empty());
let attachments_empty = attachments.as_ref().map_or(true, |a| a.is_empty());
if message_empty && attachments_empty {
return Err("Message and attachments are empty".to_string());
}
let body = {
let request_message = ChatRequestMessage {
message,
attachments,
};
println!("chat_chat body: {:?}", request_message);
Some(
serde_json::to_string(&message)
serde_json::to_string(&request_message)
.map_err(|e| format!("Failed to serialize message: {}", e))?
.into(),
)
} else {
None
};
let path = format!("/chat/{}/_chat", session_id);
@@ -314,6 +259,9 @@ pub async fn chat_chat<R: Runtime>(
if let Err(err) = app_handle.emit(&client_id, line) {
log::error!("Emit failed: {:?}", err);
print!("Error sending message: {:?}", err);
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
}
}
@@ -365,8 +313,8 @@ pub async fn update_session_chat(
}
#[tauri::command]
pub async fn assistant_search<R: Runtime>(
_app_handle: AppHandle<R>,
pub async fn assistant_search(
_app_handle: AppHandle,
server_id: String,
query_params: Option<Vec<String>>,
) -> Result<Value, String> {
@@ -381,8 +329,8 @@ pub async fn assistant_search<R: Runtime>(
}
#[tauri::command]
pub async fn assistant_get<R: Runtime>(
_app_handle: AppHandle<R>,
pub async fn assistant_get(
_app_handle: AppHandle,
server_id: String,
assistant_id: String,
) -> Result<Value, String> {
@@ -405,8 +353,8 @@ pub async fn assistant_get<R: Runtime>(
///
/// Returns as soon as the assistant is found on any Coco server.
#[tauri::command]
pub async fn assistant_get_multi<R: Runtime>(
app_handle: AppHandle<R>,
pub async fn assistant_get_multi(
app_handle: AppHandle,
assistant_id: String,
) -> Result<Value, String> {
let search_sources = app_handle.state::<SearchSourceRegistry>();
@@ -499,8 +447,8 @@ pub fn remove_icon_fields(json: &str) -> String {
}
#[tauri::command]
pub async fn ask_ai<R: Runtime>(
app_handle: AppHandle<R>,
pub async fn ask_ai(
app_handle: AppHandle,
message: String,
server_id: String,
assistant_id: String,

View File

@@ -1,6 +1,6 @@
use std::{fs::create_dir, io::Read};
use tauri::{Manager, Runtime};
use tauri::Manager;
use tauri_plugin_autostart::ManagerExt;
/// If the state reported from the OS and the state stored by us differ, our state is
@@ -42,7 +42,7 @@ pub fn ensure_autostart_state_consistent(app: &mut tauri::App) -> Result<(), Str
Ok(())
}
fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, String> {
fn current_autostart(app: &tauri::AppHandle) -> Result<bool, String> {
use std::fs::File;
let path = app.path().app_config_dir().unwrap();
@@ -65,10 +65,7 @@ fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, Stri
}
#[tauri::command]
pub async fn change_autostart<R: Runtime>(
app: tauri::AppHandle<R>,
open: bool,
) -> Result<(), String> {
pub async fn change_autostart(app: tauri::AppHandle, open: bool) -> Result<(), String> {
use std::fs::File;
use std::io::Write;

View File

@@ -3,7 +3,10 @@ use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequestMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attachments: Option<Vec<String>>,
}
#[allow(dead_code)]

View File

@@ -1,7 +1,6 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::AppHandle;
use tauri::Runtime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RichLabel {
@@ -42,6 +41,15 @@ pub(crate) enum OnOpened {
Command {
action: crate::extension::CommandAction,
},
// NOTE that this variant has the same definition as `struct Quicklink`, but we
// cannot use it directly, its `link` field should be deserialized/serialized
// from/to a string, but we need a JSON object here.
//
// See also the comments in `struct Quicklink`.
Quicklink {
link: crate::extension::QuicklinkLink,
open_with: Option<String>,
},
}
impl OnOpened {
@@ -59,28 +67,37 @@ impl OnOpened {
ret
}
// Currently, our URL is static and does not support dynamic parameters.
// The URL of a quicklink is nearly useless without such dynamic user
// inputs, so until we have dynamic URL support, we just use "N/A".
Self::Quicklink { .. } => String::from("N/A"),
}
}
}
#[tauri::command]
pub(crate) async fn open<R: Runtime>(
tauri_app_handle: AppHandle<R>,
pub(crate) async fn open(
tauri_app_handle: AppHandle,
on_opened: OnOpened,
extra_args: Option<HashMap<String, String>>,
) -> Result<(), String> {
log::debug!("open({})", on_opened.url());
use crate::util::open as homemade_tauri_shell_open;
use std::process::Command;
match on_opened {
OnOpened::Application { app_path } => {
log::debug!("open application [{}]", app_path);
homemade_tauri_shell_open(tauri_app_handle.clone(), app_path).await?
}
OnOpened::Document { url } => {
log::debug!("open document [{}]", url);
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
}
OnOpened::Command { action } => {
log::debug!("open (execute) command [{:?}]", action);
let mut cmd = Command::new(action.exec);
if let Some(args) = action.args {
cmd.args(args);
@@ -107,6 +124,39 @@ pub(crate) async fn open<R: Runtime>(
));
}
}
OnOpened::Quicklink {
link,
open_with: opt_open_with,
} => {
let url = link.concatenate_url(&extra_args);
log::debug!("open quicklink [{}] with [{:?}]", url, opt_open_with);
cfg_if::cfg_if! {
// The `open_with` functionality is only supported on macOS, provided
// by the `open -a` command.
if #[cfg(target_os = "macos")] {
let mut cmd = Command::new("open");
if let Some(ref open_with) = opt_open_with {
cmd.arg("-a");
cmd.arg(open_with.as_str());
}
cmd.arg(&url);
let output = cmd.output().map_err(|e| format!("failed to spawn [open] due to error [{}]", e))?;
if !output.status.success() {
return Err(format!(
"failed to open with app {:?}: {}",
opt_open_with,
String::from_utf8_lossy(&output.stderr)
));
}
} else {
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
}
}
}
}
Ok(())

View File

@@ -27,7 +27,7 @@ use pizza_engine::{Engine, EngineBuilder, doc};
use serde_json::Value as Json;
use std::path::Path;
use std::path::PathBuf;
use tauri::{AppHandle, Manager, Runtime, async_runtime};
use tauri::{AppHandle, Manager, async_runtime};
use tauri_plugin_fs_pro::{IconOptions, icon, metadata, name};
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::Shortcut;
@@ -131,10 +131,7 @@ async fn get_app_name(app: &App) -> String {
/// Helper function to return an absolute path to `app`'s icon.
///
/// On macOS/Windows, we cache icons in our data directory using the `icon()` function.
async fn get_app_icon_path<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app: &App,
) -> Result<String, String> {
async fn get_app_icon_path(tauri_app_handle: &AppHandle, app: &App) -> Result<String, String> {
let res_path = if cfg!(target_os = "linux") {
let icon_path = app
.icon_path
@@ -213,8 +210,8 @@ impl SearchSourceState for ApplicationSearchSourceState {
}
/// Index applications if they have not been indexed (by checking if `app_index_dir` exists).
async fn index_applications_if_not_indexed<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
async fn index_applications_if_not_indexed(
tauri_app_handle: &AppHandle,
app_index_dir: &Path,
) -> anyhow::Result<ApplicationSearchSourceState> {
let index_exists = app_index_dir.exists();
@@ -315,13 +312,13 @@ async fn index_applications_if_not_indexed<R: Runtime>(
}
/// Upon application start, index all the applications found in the `get_default_search_paths()`.
struct IndexAllApplicationsTask<R: Runtime> {
tauri_app_handle: AppHandle<R>,
struct IndexAllApplicationsTask {
tauri_app_handle: AppHandle,
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
}
#[async_trait::async_trait(?Send)]
impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
impl Task for IndexAllApplicationsTask {
fn search_source_id(&self) -> &'static str {
APPLICATION_SEARCH_SOURCE_ID
}
@@ -343,13 +340,13 @@ impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
}
}
struct ReindexAllApplicationsTask<R: Runtime> {
tauri_app_handle: AppHandle<R>,
struct ReindexAllApplicationsTask {
tauri_app_handle: AppHandle,
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
}
#[async_trait::async_trait(?Send)]
impl<R: Runtime> Task for ReindexAllApplicationsTask<R> {
impl Task for ReindexAllApplicationsTask {
fn search_source_id(&self) -> &'static str {
APPLICATION_SEARCH_SOURCE_ID
}
@@ -377,14 +374,14 @@ impl<R: Runtime> Task for ReindexAllApplicationsTask<R> {
}
}
struct SearchApplicationsTask<R: Runtime> {
tauri_app_handle: AppHandle<R>,
struct SearchApplicationsTask {
tauri_app_handle: AppHandle,
query_string: String,
callback: Option<OneshotSender<Result<SearchResult, PizzaEngineError>>>,
}
#[async_trait::async_trait(?Send)]
impl<R: Runtime> Task for SearchApplicationsTask<R> {
impl Task for SearchApplicationsTask {
fn search_source_id(&self) -> &'static str {
APPLICATION_SEARCH_SOURCE_ID
}
@@ -514,9 +511,7 @@ impl Task for IndexNewApplicationsTask {
pub struct ApplicationSearchSource;
impl ApplicationSearchSource {
pub async fn prepare_index_and_store<R: Runtime>(
app_handle: AppHandle<R>,
) -> Result<(), String> {
pub async fn prepare_index_and_store(app_handle: AppHandle) -> Result<(), String> {
app_handle
.store(TAURI_STORE_APP_HOTKEY)
.map_err(|e| e.to_string())?;
@@ -683,7 +678,7 @@ fn pizza_engine_hits_to_coco_hits(
coco_hits
}
pub fn set_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str, alias: &str) {
pub fn set_app_alias(tauri_app_handle: &AppHandle, app_path: &str, alias: &str) {
let store = tauri_app_handle
.store(TAURI_STORE_APP_ALIAS)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
@@ -696,7 +691,7 @@ pub fn set_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str
// deleted while updating it.
}
fn get_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str) -> Option<String> {
fn get_app_alias(tauri_app_handle: &AppHandle, app_path: &str) -> Option<String> {
let store = tauri_app_handle
.store(TAURI_STORE_APP_ALIAS)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
@@ -714,9 +709,9 @@ fn get_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str) ->
/// The handler that will be invoked when an application hotkey is pressed.
///
/// The `app_path` argument is for logging-only.
fn app_hotkey_handler<R: Runtime>(
fn app_hotkey_handler(
app_path: String,
) -> impl Fn(&AppHandle<R>, &Shortcut, ShortcutEvent) + Send + Sync + 'static {
) -> impl Fn(&AppHandle, &Shortcut, ShortcutEvent) + Send + Sync + 'static {
move |tauri_app_handle, _hot_key, event| {
if event.state() == ShortcutState::Pressed {
let app_path_clone = app_path.clone();
@@ -732,7 +727,7 @@ fn app_hotkey_handler<R: Runtime>(
}
/// For all the applications, if it is enabled & has hotkey set, then set it up.
pub(crate) fn set_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
pub(crate) fn set_apps_hotkey(tauri_app_handle: &AppHandle) -> Result<(), String> {
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
@@ -756,7 +751,7 @@ pub(crate) fn set_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Re
}
/// For all the applications, if it is enabled & has hotkey set, then unset it.
pub(crate) fn unset_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
pub(crate) fn unset_apps_hotkey(tauri_app_handle: &AppHandle) -> Result<(), String> {
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
@@ -783,8 +778,8 @@ pub(crate) fn unset_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) ->
}
/// Set the hotkey but won't persist this settings change.
pub(crate) fn set_app_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) fn set_app_hotkey(
tauri_app_handle: &AppHandle,
app_path: &str,
hotkey: &str,
) -> Result<(), String> {
@@ -794,8 +789,8 @@ pub(crate) fn set_app_hotkey<R: Runtime>(
.map_err(|e| e.to_string())
}
pub fn register_app_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub fn register_app_hotkey(
tauri_app_handle: &AppHandle,
app_path: &str,
hotkey: &str,
) -> Result<(), String> {
@@ -812,10 +807,7 @@ pub fn register_app_hotkey<R: Runtime>(
Ok(())
}
pub fn unregister_app_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
) -> Result<(), String> {
pub fn unregister_app_hotkey(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
@@ -855,7 +847,7 @@ pub fn unregister_app_hotkey<R: Runtime>(
Ok(())
}
fn get_disabled_app_list<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Vec<String> {
fn get_disabled_app_list(tauri_app_handle: &AppHandle) -> Vec<String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| {
@@ -892,10 +884,7 @@ pub fn is_app_search_enabled(app_path: &str) -> bool {
disabled_app_list.iter().all(|path| path != app_path)
}
pub fn disable_app_search<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
) -> Result<(), String> {
pub fn disable_app_search(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| {
@@ -939,10 +928,7 @@ pub fn disable_app_search<R: Runtime>(
Ok(())
}
pub fn enable_app_search<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
) -> Result<(), String> {
pub fn enable_app_search(tauri_app_handle: &AppHandle, app_path: &str) -> Result<(), String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| {
@@ -984,8 +970,8 @@ pub fn enable_app_search<R: Runtime>(
}
#[tauri::command]
pub async fn add_app_search_path<R: Runtime>(
tauri_app_handle: AppHandle<R>,
pub async fn add_app_search_path(
tauri_app_handle: AppHandle,
search_path: String,
) -> Result<(), String> {
let mut search_paths = get_app_search_path(tauri_app_handle.clone()).await;
@@ -1010,8 +996,8 @@ pub async fn add_app_search_path<R: Runtime>(
}
#[tauri::command]
pub async fn remove_app_search_path<R: Runtime>(
tauri_app_handle: AppHandle<R>,
pub async fn remove_app_search_path(
tauri_app_handle: AppHandle,
search_path: String,
) -> Result<(), String> {
let mut search_paths = get_app_search_path(tauri_app_handle.clone()).await;
@@ -1036,7 +1022,7 @@ pub async fn remove_app_search_path<R: Runtime>(
}
#[tauri::command]
pub async fn get_app_search_path<R: Runtime>(tauri_app_handle: AppHandle<R>) -> Vec<String> {
pub async fn get_app_search_path(tauri_app_handle: AppHandle) -> Vec<String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| {
@@ -1065,9 +1051,7 @@ pub async fn get_app_search_path<R: Runtime>(tauri_app_handle: AppHandle<R>) ->
}
#[tauri::command]
pub async fn get_app_list<R: Runtime>(
tauri_app_handle: AppHandle<R>,
) -> Result<Vec<Extension>, String> {
pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>, String> {
let search_paths = get_app_search_path(tauri_app_handle.clone()).await;
let apps = list_app_in(search_paths)?;
@@ -1202,9 +1186,7 @@ pub async fn get_app_metadata(app_name: String, app_path: String) -> Result<AppM
}
#[tauri::command]
pub async fn reindex_applications<R: Runtime>(
tauri_app_handle: AppHandle<R>,
) -> Result<(), String> {
pub async fn reindex_applications(tauri_app_handle: AppHandle) -> Result<(), String> {
let (tx, rx) = tokio::sync::oneshot::channel();
let reindex_applications_task = ReindexAllApplicationsTask {
tauri_app_handle: tauri_app_handle.clone(),

View File

@@ -5,16 +5,14 @@ use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
use crate::common::traits::SearchSource;
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
use async_trait::async_trait;
use tauri::{AppHandle, Runtime};
use tauri::AppHandle;
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
pub struct ApplicationSearchSource;
impl ApplicationSearchSource {
pub async fn prepare_index_and_store<R: Runtime>(
_app_handle: AppHandle<R>,
) -> Result<(), String> {
pub async fn prepare_index_and_store(_app_handle: AppHandle) -> Result<(), String> {
Ok(())
}
}
@@ -45,37 +43,28 @@ impl SearchSource for ApplicationSearchSource {
}
}
pub fn set_app_alias<R: Runtime>(_tauri_app_handle: &AppHandle<R>, _app_path: &str, _alias: &str) {
pub fn set_app_alias(_tauri_app_handle: &AppHandle, _app_path: &str, _alias: &str) {
unreachable!("app list should be empty, there is no way this can be invoked")
}
pub fn register_app_hotkey<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
pub fn register_app_hotkey(
_tauri_app_handle: &AppHandle,
_app_path: &str,
_hotkey: &str,
) -> Result<(), String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}
pub fn unregister_app_hotkey<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
_app_path: &str,
) -> Result<(), String> {
pub fn unregister_app_hotkey(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}
pub fn disable_app_search<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
_app_path: &str,
) -> Result<(), String> {
pub fn disable_app_search(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
// no-op
Ok(())
}
pub fn enable_app_search<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
_app_path: &str,
) -> Result<(), String> {
pub fn enable_app_search(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
// no-op
Ok(())
}
@@ -85,8 +74,8 @@ pub fn is_app_search_enabled(_app_path: &str) -> bool {
}
#[tauri::command]
pub async fn add_app_search_path<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
pub async fn add_app_search_path(
_tauri_app_handle: AppHandle,
_search_path: String,
) -> Result<(), String> {
// no-op
@@ -94,8 +83,8 @@ pub async fn add_app_search_path<R: Runtime>(
}
#[tauri::command]
pub async fn remove_app_search_path<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
pub async fn remove_app_search_path(
_tauri_app_handle: AppHandle,
_search_path: String,
) -> Result<(), String> {
// no-op
@@ -103,43 +92,37 @@ pub async fn remove_app_search_path<R: Runtime>(
}
#[tauri::command]
pub async fn get_app_search_path<R: Runtime>(_tauri_app_handle: AppHandle<R>) -> Vec<String> {
pub async fn get_app_search_path(_tauri_app_handle: AppHandle) -> Vec<String> {
// Return an empty list
Vec::new()
}
#[tauri::command]
pub async fn get_app_list<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
) -> Result<Vec<Extension>, String> {
pub async fn get_app_list(_tauri_app_handle: AppHandle) -> Result<Vec<Extension>, String> {
// Return an empty list
Ok(Vec::new())
}
#[tauri::command]
pub async fn get_app_metadata<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
pub async fn get_app_metadata(
_tauri_app_handle: AppHandle,
_app_path: String,
) -> Result<AppMetadata, String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}
pub(crate) fn set_apps_hotkey<R: Runtime>(_tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
pub(crate) fn set_apps_hotkey(_tauri_app_handle: &AppHandle) -> Result<(), String> {
// no-op
Ok(())
}
pub(crate) fn unset_apps_hotkey<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
) -> Result<(), String> {
pub(crate) fn unset_apps_hotkey(_tauri_app_handle: &AppHandle) -> Result<(), String> {
// no-op
Ok(())
}
#[tauri::command]
pub async fn reindex_applications<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
) -> Result<(), String> {
pub async fn reindex_applications(_tauri_app_handle: AppHandle) -> Result<(), String> {
// no-op
Ok(())
}

View File

@@ -5,7 +5,6 @@ use serde::Serialize;
use serde_json::Value;
use std::sync::LazyLock;
use tauri::AppHandle;
use tauri::Runtime;
use tauri_plugin_store::StoreExt;
// Tauri store keys for file system configuration
@@ -54,7 +53,7 @@ impl Default for FileSearchConfig {
}
impl FileSearchConfig {
pub(crate) fn get<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Self {
pub(crate) fn get(tauri_app_handle: &AppHandle) -> Self {
let store = tauri_app_handle
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
.unwrap_or_else(|e| {
@@ -185,15 +184,13 @@ impl FileSearchConfig {
// Tauri commands for managing file system configuration
#[tauri::command]
pub async fn get_file_system_config<R: Runtime>(
tauri_app_handle: AppHandle<R>,
) -> FileSearchConfig {
pub async fn get_file_system_config(tauri_app_handle: AppHandle) -> FileSearchConfig {
FileSearchConfig::get(&tauri_app_handle)
}
#[tauri::command]
pub async fn set_file_system_config<R: Runtime>(
tauri_app_handle: AppHandle<R>,
pub async fn set_file_system_config(
tauri_app_handle: AppHandle,
config: FileSearchConfig,
) -> Result<(), String> {
let store = tauri_app_handle

View File

@@ -16,11 +16,9 @@ use crate::extension::{
};
use anyhow::Context;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Runtime};
use tauri::{AppHandle, Manager};
pub(crate) fn get_built_in_extension_directory<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
) -> PathBuf {
pub(crate) fn get_built_in_extension_directory(tauri_app_handle: &AppHandle) -> PathBuf {
let mut resource_dir = tauri_app_handle.path().app_data_dir().expect(
"User home directory not found, which should be impossible on desktop environments",
);
@@ -136,8 +134,8 @@ async fn load_built_in_extension(
/// We only read alias/hotkey/enabled from the JSON file, we have ensured that if
/// alias/hotkey is not supported, then it will be `None`. Besides that, no further
/// validation is needed because nothing could go wrong.
pub(crate) async fn list_built_in_extensions<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) async fn list_built_in_extensions(
tauri_app_handle: &AppHandle,
) -> Result<Vec<Extension>, String> {
let dir = get_built_in_extension_directory(tauri_app_handle);
@@ -191,8 +189,8 @@ pub(crate) async fn list_built_in_extensions<R: Runtime>(
Ok(built_in_extensions)
}
pub(super) async fn init_built_in_extension<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(super) async fn init_built_in_extension(
tauri_app_handle: &AppHandle,
extension: &Extension,
search_source_registry: &SearchSourceRegistry,
) -> Result<(), String> {
@@ -233,8 +231,8 @@ pub(crate) fn is_extension_built_in(bundle_id: &ExtensionBundleIdBorrowed<'_>) -
bundle_id.developer.is_none()
}
pub(crate) async fn enable_built_in_extension<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) async fn enable_built_in_extension(
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> {
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
@@ -321,8 +319,8 @@ pub(crate) async fn enable_built_in_extension<R: Runtime>(
Ok(())
}
pub(crate) async fn disable_built_in_extension<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) async fn disable_built_in_extension(
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> {
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
@@ -408,8 +406,8 @@ pub(crate) async fn disable_built_in_extension<R: Runtime>(
Ok(())
}
pub(crate) fn set_built_in_extension_alias<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) fn set_built_in_extension_alias(
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
alias: &str,
) {
@@ -420,8 +418,8 @@ pub(crate) fn set_built_in_extension_alias<R: Runtime>(
}
}
pub(crate) fn register_built_in_extension_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) fn register_built_in_extension_hotkey(
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
hotkey: &str,
) -> Result<(), String> {
@@ -433,8 +431,8 @@ pub(crate) fn register_built_in_extension_hotkey<R: Runtime>(
Ok(())
}
pub(crate) fn unregister_built_in_extension_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) fn unregister_built_in_extension_hotkey(
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> {
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
@@ -481,8 +479,8 @@ fn load_extension_from_json_file(
Ok(extension)
}
pub(crate) async fn is_built_in_extension_enabled<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
pub(crate) async fn is_built_in_extension_enabled(
tauri_app_handle: &AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<bool, String> {
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();

View File

@@ -7,12 +7,14 @@ use crate::util::platform::Platform;
use anyhow::Context;
use borrowme::{Borrow, ToOwned};
use derive_more::Display;
use indexmap::IndexMap;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as Json;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use tauri::{AppHandle, Manager, Runtime};
use tauri::{AppHandle, Manager};
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
@@ -23,7 +25,7 @@ fn default_true() -> bool {
true
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct Extension {
/// Extension ID.
///
@@ -193,8 +195,19 @@ impl Extension {
ExtensionType::Application => Some(OnOpened::Application {
app_path: self.id.clone(),
}),
ExtensionType::Quicklink => {
let quicklink = self.quicklink.clone().unwrap_or_else(|| {
panic!(
"Quicklink extension [{}]'s [quicklink] field is not set, something wrong with your extension validity check", self.id
)
});
Some(OnOpened::Quicklink{
link: quicklink.link,
open_with: quicklink.open_with,
})
}
ExtensionType::Script => todo!("not supported yet"),
ExtensionType::Quicklink => todo!("not supported yet"),
ExtensionType::Setting => todo!("not supported yet"),
ExtensionType::Calculator => None,
ExtensionType::AiExtension => None,
@@ -262,15 +275,181 @@ impl Extension {
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub(crate) struct CommandAction {
pub(crate) exec: String,
pub(crate) args: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct Quicklink {
link: String,
// NOTE that `struct QuicklinkLink` (not `struct Quicklink`) has its own
// derived `Deserialize/Serialize` impl, which deserializes/serializes
// it from/to a JSON object.
//
// We cannot use it here because we need to deserialize/serialize it from/to
// a string,
//
// "https://www.google.com/search?q={query}"
#[serde(deserialize_with = "deserialize_quicklink_link_from_string")]
#[serde(serialize_with = "serialize_quicklink_link_to_string")]
link: QuicklinkLink,
/// Specify the application to use to open this quicklink.
///
/// Only supported on macOS.
pub(crate) open_with: Option<String>,
}
/// Return name and optional default value of all the dynamic placeholder arguments.
///
/// NOTE that it is not a Rust associated function because we need to expose it
/// to the frontend code:
///
/// ```javascript
/// invoke('quicklink_link_arguments', { <A JSON that can be deserialized to `struct QuicklinkLink`> } )
/// ```
#[tauri::command]
pub(crate) fn quicklink_link_arguments(
quicklink_link: QuicklinkLink,
) -> IndexMap<String, Option<String>> {
let mut arguments_with_opt_default = IndexMap::new();
for component in quicklink_link.components.iter() {
if let QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} = component
{
arguments_with_opt_default.insert(argument_name.to_string(), default.as_ref().cloned());
}
}
arguments_with_opt_default
}
/// A quicklink consists of a sequence of components.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(crate) struct QuicklinkLink {
components: Vec<QuicklinkLinkComponent>,
}
impl QuicklinkLink {
/// Quicklinks that accept arguments cannot produce a complete URL
/// without user-supplied arguments.
///
/// This function attempts to concatenate the URL using the provided arguments,
/// if any.
pub(crate) fn concatenate_url(
&self,
user_supplied_args: &Option<HashMap<String, String>>,
) -> String {
let mut out = String::new();
for component in self.components.iter() {
match component {
QuicklinkLinkComponent::StaticStr(str) => {
out.push_str(str.as_str());
}
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
let opt_argument_value = {
let user_supplied_arg = user_supplied_args
.as_ref()
.and_then(|map| map.get(argument_name.as_str()));
if user_supplied_arg.is_some() {
user_supplied_arg
} else {
default.as_ref()
}
};
let argument_value_str = match opt_argument_value {
Some(str) => str.as_str(),
// None => an empty string
None => "",
};
out.push_str(argument_value_str);
}
}
}
out
}
}
/// Custom deserialization function for QuicklinkLink from string
fn deserialize_quicklink_link_from_string<'de, D>(
deserializer: D,
) -> Result<QuicklinkLink, D::Error>
where
D: serde::Deserializer<'de>,
{
let link_str = String::deserialize(deserializer)?;
let components = parse_quicklink_components(&link_str).map_err(serde::de::Error::custom)?;
Ok(QuicklinkLink { components })
}
/// Custom serialization function for QuicklinkLink to a string
fn serialize_quicklink_link_to_string<S>(
link: &QuicklinkLink,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut result = String::new();
for component in &link.components {
match component {
QuicklinkLinkComponent::StaticStr(s) => {
result.push_str(s);
}
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
result.push('{');
// If it's a simple case (no default), just use the argument name
if default.is_none() {
result.push_str(argument_name);
} else {
// Use the full format with argument_name and default
result.push_str(&format!(
r#"argument_name: "{}", default: "{}""#,
argument_name,
default.as_ref().unwrap()
));
}
result.push('}');
}
}
}
serializer.serialize_str(&result)
}
/// A link component is either a static string, or a dynamic placeholder, e.g.,
///
/// "https://www.google.com/search?q={query}"
///
/// The above link can be split into the following components:
///
/// [StaticStr("https://www.google.com/search?q="), DynamicPlaceholder { argument_name: "query", default: None }]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(crate) enum QuicklinkLinkComponent {
StaticStr(String),
/// For the valid formats of dynamic placeholder, see the doc comments of `fn parse_dynamic_placeholder()`
DynamicPlaceholder {
argument_name: String,
/// Will use this default value if this dynamic parameter is not supplied
/// by the user.
default: Option<String>,
},
}
#[derive(Debug, PartialEq, Deserialize, Serialize, Clone, Display, Copy)]
@@ -413,12 +592,12 @@ fn filter_out_extensions(
/// * boolean: indicates if we found any invalid extensions
/// * Vec<Extension>: loaded extensions
#[tauri::command]
pub(crate) async fn list_extensions<R: Runtime>(
tauri_app_handle: AppHandle<R>,
pub(crate) async fn list_extensions(
tauri_app_handle: AppHandle,
query: Option<String>,
extension_type: Option<ExtensionType>,
list_enabled: bool,
) -> Result<(bool, Vec<Extension>), String> {
) -> Result<Vec<Extension>, String> {
log::trace!("loading extensions");
let third_party_dir = third_party::get_third_party_extension_directory(&tauri_app_handle);
@@ -427,12 +606,11 @@ pub(crate) async fn list_extensions<R: Runtime>(
.await
.map_err(|e| e.to_string())?;
}
let (third_party_found_invalid_extension, mut third_party_extensions) =
let mut third_party_extensions =
third_party::list_third_party_extensions(&third_party_dir).await?;
let built_in_extensions = built_in::list_built_in_extensions(&tauri_app_handle).await?;
let found_invalid_extension = third_party_found_invalid_extension;
let mut extensions = {
third_party_extensions.extend(built_in_extensions);
@@ -480,7 +658,7 @@ pub(crate) async fn list_extensions<R: Runtime>(
});
}
Ok((found_invalid_extension, extensions))
Ok(extensions)
}
pub(crate) async fn init_extensions(
@@ -498,7 +676,7 @@ pub(crate) async fn init_extensions(
// extension store
search_source_registry_tauri_state
.register_source(third_party::store::ExtensionStore)
.register_source(third_party::install::store::ExtensionStore)
.await;
// Init the built-in enabled extensions
@@ -773,3 +951,601 @@ fn alter_extension_json_file(
Ok(())
}
/// Helper function to impl Deserialize for `QuicklinkLink`.
///
/// Parse a quicklink string into components, handling dynamic placeholders
fn parse_quicklink_components(input: &str) -> Result<Vec<QuicklinkLinkComponent>, String> {
let mut components = Vec::new();
let mut current_pos = 0;
let chars: Vec<char> = input.chars().collect();
while current_pos < chars.len() {
// Find the next opening brace
if let Some(open_pos) = chars[current_pos..].iter().position(|&c| c == '{') {
let absolute_open_pos = current_pos + open_pos;
// Add static string before the opening brace (if any)
if absolute_open_pos > current_pos {
let static_str: String = chars[current_pos..absolute_open_pos].iter().collect();
components.push(QuicklinkLinkComponent::StaticStr(static_str));
}
// Find the matching closing brace, handling nested braces
let mut brace_count = 1;
let mut close_pos = None;
for (i, &c) in chars[absolute_open_pos + 1..].iter().enumerate() {
match c {
'{' => brace_count += 1,
'}' => {
brace_count -= 1;
if brace_count == 0 {
close_pos = Some(i);
break;
}
}
_ => {}
}
}
if let Some(close_pos) = close_pos {
let absolute_close_pos = absolute_open_pos + 1 + close_pos;
// Extract the placeholder content
let placeholder_content: String = chars[absolute_open_pos + 1..absolute_close_pos]
.iter()
.collect();
let placeholder = parse_dynamic_placeholder(&placeholder_content)?;
components.push(placeholder);
current_pos = absolute_close_pos + 1;
} else {
return Err(format!(
"Unmatched opening brace at position {}",
absolute_open_pos
));
}
} else {
// No more opening braces, add the remaining string as static
if current_pos < chars.len() {
let static_str: String = chars[current_pos..].iter().collect();
components.push(QuicklinkLinkComponent::StaticStr(static_str));
}
break;
}
}
Ok(components)
}
/// Helper function to impl Deserialize for `QuicklinkLink`.
///
/// Parse the content inside braces into a DynamicPlaceholder.
///
/// It supports the following formats:
///
/// 1. {query}: should be parsed to DynamicPlaceholder {argument_name: "query", default: None }
/// 2. {argument_name: "query" }: should be parsed to DynamicPlaceholder {argument_name: "query", default: None }
/// 3. {argument_name: "query", default: "rust" }: should be parsed to DynamicPlaceholder {argument_name: "query", default: Some("rust") }
fn parse_dynamic_placeholder(content: &str) -> Result<QuicklinkLinkComponent, String> {
let trimmed = content.trim();
// Case 1: {query} - simple argument name
if !trimmed.contains(':') && !trimmed.contains(',') {
return Ok(QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: trimmed.to_string(),
default: None,
});
}
// Case 2 & 3: {argument_name: "query"} or {argument_name: "query", default: "rust"}
// Parse as a simplified JSON-like structure
let mut argument_name = None;
let mut default_value = None;
// Split by commas and process each part
let parts: Vec<&str> = trimmed.split(',').collect();
for part in parts {
let part = part.trim();
if let Some(colon_pos) = part.find(':') {
let key = part[..colon_pos].trim();
let value = part[colon_pos + 1..].trim();
// Remove quotes from value if present
let value = if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
&value[1..value.len() - 1]
} else {
value
};
match key {
"argument_name" => argument_name = Some(value.to_string()),
"default" => default_value = Some(value.to_string()),
_ => return Err(format!("Unknown key '{}' in placeholder", key)),
}
}
}
let argument_name = argument_name.ok_or("Missing argument_name in placeholder")?;
Ok(QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default: default_value,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json;
#[test]
fn test_deserialize_quicklink_link_case1() {
// Case 1: {query} - simple argument name
let test_string = "https://www.google.com/search?q={query}";
let components = parse_quicklink_components(test_string).unwrap();
let link = QuicklinkLink { components };
assert_eq!(link.components.len(), 2);
match &link.components[0] {
QuicklinkLinkComponent::StaticStr(s) => {
assert_eq!(s, "https://www.google.com/search?q=")
}
_ => panic!("Expected StaticStr component"),
}
match &link.components[1] {
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
assert_eq!(argument_name, "query");
assert_eq!(default, &None);
}
_ => panic!("Expected DynamicPlaceholder component"),
}
}
#[test]
fn test_deserialize_quicklink_link_case2() {
// Case 2: {argument_name: "query"} - explicit argument name
let test_string = r#"https://www.google.com/search?q={argument_name: "query"}"#;
let components = parse_quicklink_components(test_string).unwrap();
let link = QuicklinkLink { components };
assert_eq!(link.components.len(), 2);
match &link.components[0] {
QuicklinkLinkComponent::StaticStr(s) => {
assert_eq!(s, "https://www.google.com/search?q=")
}
_ => panic!("Expected StaticStr component"),
}
match &link.components[1] {
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
assert_eq!(argument_name, "query");
assert_eq!(default, &None);
}
_ => panic!("Expected DynamicPlaceholder component"),
}
}
#[test]
fn test_deserialize_quicklink_link_case3() {
// Case 3: {argument_name: "query", default: "rust"} - with default value
let test_string =
r#"https://www.google.com/search?q={argument_name: "query", default: "rust"}"#;
let components = parse_quicklink_components(test_string).unwrap();
let link = QuicklinkLink { components };
assert_eq!(link.components.len(), 2);
match &link.components[0] {
QuicklinkLinkComponent::StaticStr(s) => {
assert_eq!(s, "https://www.google.com/search?q=")
}
_ => panic!("Expected StaticStr component"),
}
match &link.components[1] {
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
assert_eq!(argument_name, "query");
assert_eq!(default, &Some("rust".to_string()));
}
_ => panic!("Expected DynamicPlaceholder component"),
}
}
#[test]
fn test_deserialize_quicklink_link_multiple_placeholders() {
// Test multiple placeholders in one string
let test_string = r#"https://example.com/{category}/search?q={query}&lang={argument_name: "language", default: "en"}"#;
let components = parse_quicklink_components(test_string).unwrap();
let link = QuicklinkLink { components };
assert_eq!(link.components.len(), 6);
// Check the components
match &link.components[0] {
QuicklinkLinkComponent::StaticStr(s) => assert_eq!(s, "https://example.com/"),
_ => panic!("Expected StaticStr component"),
}
match &link.components[1] {
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
assert_eq!(argument_name, "category");
assert_eq!(default, &None);
}
_ => panic!("Expected DynamicPlaceholder component"),
}
match &link.components[2] {
QuicklinkLinkComponent::StaticStr(s) => assert_eq!(s, "/search?q="),
_ => panic!("Expected StaticStr component"),
}
match &link.components[3] {
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
assert_eq!(argument_name, "query");
assert_eq!(default, &None);
}
_ => panic!("Expected DynamicPlaceholder component"),
}
match &link.components[4] {
QuicklinkLinkComponent::StaticStr(s) => assert_eq!(s, "&lang="),
_ => panic!("Expected StaticStr component"),
}
match &link.components[5] {
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name,
default,
} => {
assert_eq!(argument_name, "language");
assert_eq!(default, &Some("en".to_string()));
}
_ => panic!("Expected DynamicPlaceholder component"),
}
}
#[test]
fn test_deserialize_quicklink_link_no_placeholders() {
// Test string with no placeholders
let test_string = "https://www.google.com/search?q=fixed";
let components = parse_quicklink_components(test_string).unwrap();
let link = QuicklinkLink { components };
assert_eq!(link.components.len(), 1);
match &link.components[0] {
QuicklinkLinkComponent::StaticStr(s) => {
assert_eq!(s, "https://www.google.com/search?q=fixed")
}
_ => panic!("Expected StaticStr component"),
}
}
#[test]
fn test_deserialize_quicklink_link_error_unmatched_brace() {
// Test error case with unmatched brace
let test_string = "https://www.google.com/search?q={query";
let result = parse_quicklink_components(test_string);
assert!(result.is_err());
}
/// Unknown argument a and b
#[test]
fn test_deserialize_quicklink_link_unknown_arguments() {
let test_string = r#"https://www.google.com/search?q={a: "a", b: "b"}"#;
let result = parse_quicklink_components(test_string);
assert!(result.is_err());
}
#[test]
fn test_serialize_quicklink_link_empty_components() {
// Case 1: Empty components should result in empty string
let link = QuicklinkLink { components: vec![] };
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(serialized, r#""""#); // Empty string
}
#[test]
fn test_serialize_quicklink_link_static_str_only() {
// Case 2: Only StaticStr components
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::StaticStr("rust".to_string()),
],
};
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(serialized, r#""https://www.google.com/search?q=rust""#);
}
#[test]
fn test_serialize_quicklink_link_dynamic_placeholder_only() {
// Case 3: Only DynamicPlaceholder components
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: None,
},
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "language".to_string(),
default: Some("en".to_string()),
},
],
};
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(
serialized,
r#""{query}{argument_name: \"language\", default: \"en\"}""#
);
}
#[test]
fn test_serialize_quicklink_link_mixed_components() {
// Case 4: Mix of StaticStr and DynamicPlaceholder components
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: None,
},
QuicklinkLinkComponent::StaticStr("&lang=".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "language".to_string(),
default: Some("en".to_string()),
},
],
};
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(
serialized,
r#""https://www.google.com/search?q={query}&lang={argument_name: \"language\", default: \"en\"}""#
);
}
#[test]
fn test_serialize_quicklink_link_dynamic_placeholder_no_default() {
// Additional test: DynamicPlaceholder without default value
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://example.com/".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "category".to_string(),
default: None,
},
QuicklinkLinkComponent::StaticStr("/items".to_string()),
],
};
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(serialized, r#""https://example.com/{category}/items""#);
}
#[test]
fn test_serialize_quicklink_link_dynamic_placeholder_with_default() {
// Additional test: DynamicPlaceholder with default value
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://api.example.com/".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "version".to_string(),
default: Some("v1".to_string()),
},
QuicklinkLinkComponent::StaticStr("/data".to_string()),
],
};
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_quicklink_link_to_string(&link, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(
serialized,
r#""https://api.example.com/{argument_name: \"version\", default: \"v1\"}/data""#
);
}
#[test]
fn test_quicklink_link_arguments_empty_components() {
let link = QuicklinkLink { components: vec![] };
let map = quicklink_link_arguments(link);
assert!(map.is_empty());
}
#[test]
fn test_quicklink_link_arguments_static_str_only() {
let link = QuicklinkLink {
components: vec![QuicklinkLinkComponent::StaticStr(
"https://api.example.com/".to_string(),
)],
};
let map = quicklink_link_arguments(link);
assert!(map.is_empty());
}
#[test]
fn test_quicklink_link_arguments_dynamic_placeholder_only() {
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: None,
},
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "language".to_string(),
default: Some("en".to_string()),
},
],
};
let map = quicklink_link_arguments(link);
let expected_map = {
let mut map = IndexMap::new();
map.insert("query".into(), None);
map.insert("language".into(), Some("en".into()));
map
};
assert_eq!(map, expected_map);
}
#[test]
fn test_quicklink_link_concatenate_url_static_components_only() {
// Case 1: the link (QuicklinkLink) only contains static str components
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::StaticStr("rust".to_string()),
],
};
let result = link.concatenate_url(&None);
assert_eq!(result, "https://www.google.com/search?q=rust");
}
/// The link has 1 dynamic component with no default value, but `user_supplied_args` is None
#[test]
fn test_quicklink_link_concatenate_url_dynamic_no_default_no_args() {
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: None,
},
],
};
let result = link.concatenate_url(&None);
assert_eq!(result, "https://www.google.com/search?q=");
}
/// The link has 1 dynamic component with no default value, `user_supplied_args` is Some(hashmap),
/// but this dynamic argument is not provided in the hashmap
#[test]
fn test_quicklink_link_concatenate_url_dynamic_no_default_missing_from_args() {
use std::collections::HashMap;
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: None,
},
],
};
let mut user_args = HashMap::new();
user_args.insert("other_param".to_string(), "value".to_string());
let result = link.concatenate_url(&Some(user_args));
assert_eq!(result, "https://www.google.com/search?q=");
}
/// The link has 1 dynamic component with a default value, `user_supplied_args` is None
#[test]
fn test_quicklink_link_concatenate_url_dynamic_with_default_no_args() {
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: Some("rust".to_string()),
},
],
};
let result = link.concatenate_url(&None);
assert_eq!(result, "https://www.google.com/search?q=rust");
}
/// The link has 1 dynamic component with a default value, `user_supplied_args` is Some(hashmap),
/// this dynamic argument is not provided in the hashmap
#[test]
fn test_quicklink_link_concatenate_url_dynamic_with_default_missing_from_args() {
use std::collections::HashMap;
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: Some("rust".to_string()),
},
],
};
let mut user_args = HashMap::new();
user_args.insert("other_param".to_string(), "value".to_string());
let result = link.concatenate_url(&Some(user_args));
assert_eq!(result, "https://www.google.com/search?q=rust");
}
/// The link has 1 dynamic component with a default value, `user_supplied_args` is Some(hashmap),
/// hashmap contains the dynamic parameter.
///
/// (the user-supplied argument should be used, the default value should be ignored)
#[test]
fn test_quicklink_link_concatenate_url_dynamic_with_default_provided_in_args() {
use std::collections::HashMap;
let link = QuicklinkLink {
components: vec![
QuicklinkLinkComponent::StaticStr("https://www.google.com/search?q=".to_string()),
QuicklinkLinkComponent::DynamicPlaceholder {
argument_name: "query".to_string(),
default: Some("rust".to_string()),
},
],
};
let mut user_args = HashMap::new();
user_args.insert("query".to_string(), "python".to_string());
let result = link.concatenate_url(&Some(user_args));
assert_eq!(result, "https://www.google.com/search?q=python");
}
/// The link is empty
#[test]
fn test_quicklink_link_concatenate_url_empty_link() {
let link = QuicklinkLink { components: vec![] };
let result = link.concatenate_url(&None);
assert_eq!(result, "");
}
}

View 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
*/
}

View 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(&current_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(())
}

View 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(&current_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(&current_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(&current_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);
}
}
}

View File

@@ -1,6 +1,7 @@
//! Extension store related stuff.
use super::LOCAL_QUERY_SOURCE_TYPE;
use super::super::LOCAL_QUERY_SOURCE_TYPE;
use super::is_extension_installed;
use crate::common::document::DataSourceReference;
use crate::common::document::Document;
use crate::common::error::SearchError;
@@ -12,8 +13,11 @@ use crate::extension::Extension;
use crate::extension::PLUGIN_JSON_FILE_NAME;
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
use crate::extension::canonicalize_relative_icon_path;
use crate::extension::third_party::check::general_check;
use crate::extension::third_party::get_third_party_extension_directory;
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
use crate::server::http_client::HttpClient;
use crate::util::platform::Platform;
use async_trait::async_trait;
use reqwest::StatusCode;
use serde_json::Map as JsonObject;
@@ -152,14 +156,12 @@ pub(crate) async fn search_extension(
.get("developer")
.and_then(|dev| dev.get("id"))
.and_then(|id| id.as_str())
.expect("developer.id should exist")
.to_string();
.expect("developer.id should exist");
let extension_id = source_obj
.get("id")
.and_then(|id| id.as_str())
.expect("extension id should exist")
.to_string();
.expect("extension id should exist");
let installed = is_extension_installed(developer_id, extension_id).await;
source_obj.insert("installed".to_string(), Json::Bool(installed));
@@ -170,14 +172,6 @@ pub(crate) async fn search_extension(
Ok(extensions)
}
async fn is_extension_installed(developer: String, extension_id: String) -> bool {
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.extension_exists(&developer, &extension_id)
.await
}
#[tauri::command]
pub(crate) async fn install_extension_from_store(
tauri_app_handle: AppHandle,
@@ -259,6 +253,12 @@ pub(crate) async fn install_extension_from_store(
drop(plugin_json);
general_check(&extension)?;
// Extension is compatible with current platform, but it could contain sub
// extensions that are not, filter them out.
filter_out_incompatible_sub_extensions(&mut extension, Platform::current());
// Write extension files to the extension directory
let developer = extension.developer.clone().unwrap_or_default();
let extension_id = extension.id.clone();

View File

@@ -1,7 +1,7 @@
pub(crate) mod store;
pub(crate) mod check;
pub(crate) mod install;
use super::Extension;
use super::ExtensionType;
use super::LOCAL_QUERY_SOURCE_TYPE;
use super::PLUGIN_JSON_FILE_NAME;
use super::alter_extension_json_file;
@@ -18,15 +18,14 @@ use crate::extension::ExtensionBundleIdBorrowed;
use crate::util::platform::Platform;
use async_trait::async_trait;
use borrowme::ToOwned;
use check::general_check;
use function_name::named;
use std::ffi::OsStr;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::OnceLock;
use tauri::AppHandle;
use tauri::Manager;
use tauri::Runtime;
use tauri::async_runtime;
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::ShortcutState;
@@ -34,9 +33,7 @@ use tokio::fs::read_dir;
use tokio::sync::RwLock;
use tokio::sync::RwLockWriteGuard;
pub(crate) fn get_third_party_extension_directory<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
) -> PathBuf {
pub(crate) fn get_third_party_extension_directory(tauri_app_handle: &AppHandle) -> PathBuf {
let mut app_data_dir = tauri_app_handle.path().app_data_dir().expect(
"User home directory not found, which should be impossible on desktop environments",
);
@@ -47,9 +44,7 @@ pub(crate) fn get_third_party_extension_directory<R: Runtime>(
pub(crate) async fn list_third_party_extensions(
directory: &Path,
) -> Result<(bool, Vec<Extension>), String> {
let mut found_invalid_extensions = false;
) -> Result<Vec<Extension>, String> {
let mut extensions_dir_iter = read_dir(&directory).await.map_err(|e| e.to_string())?;
let current_platform = Platform::current();
@@ -65,7 +60,6 @@ pub(crate) async fn list_third_party_extensions(
};
let developer_dir_file_type = developer_dir.file_type().await.map_err(|e| e.to_string())?;
if !developer_dir_file_type.is_dir() {
found_invalid_extensions = true;
log::warn!(
"file [{}] under the third party extension directory should be a directory, but it is not",
developer_dir.file_name().display()
@@ -87,14 +81,17 @@ pub(crate) async fn list_third_party_extensions(
let Some(extension_dir) = opt_extension_dir else {
break 'extension;
};
let extension_dir_file_name = extension_dir
.file_name()
.into_string()
.expect("extension directory name should be UTF-8 encoded");
let extension_dir_file_type =
extension_dir.file_type().await.map_err(|e| e.to_string())?;
if !extension_dir_file_type.is_dir() {
found_invalid_extensions = true;
log::warn!(
"invalid extension [{}]: a valid extension should be a directory, but it is not",
extension_dir.file_name().display()
extension_dir_file_name
);
// Skip invalid extension
@@ -109,7 +106,6 @@ pub(crate) async fn list_third_party_extensions(
};
if !plugin_json_file_path.is_file() {
found_invalid_extensions = true;
log::warn!(
"invalid extension: [{}]: extension file [{}] should be a JSON file, but it is not",
extension_dir.file_name().display(),
@@ -126,10 +122,9 @@ pub(crate) async fn list_third_party_extensions(
let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) {
Ok(extension) => extension,
Err(e) => {
found_invalid_extensions = true;
log::warn!(
"invalid extension: [{}]: extension file [{}] is invalid, error: '{}'",
extension_dir.file_name().display(),
"invalid extension: [{}]: cannot parse file [{}] as a [struct Extension], error: '{}'",
extension_dir_file_name,
plugin_json_file_path.display(),
e
);
@@ -137,20 +132,56 @@ pub(crate) async fn list_third_party_extensions(
}
};
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
/* Check starts here */
if extension.id != extension_dir_file_name {
log::warn!(
"extension under [{}:{}] has an ID that is not same as the [{}]",
developer_dir.file_name().display(),
extension_dir_file_name,
extension.id,
);
continue;
}
// Extension should be unique
if extensions.iter().any(|ext: &Extension| {
ext.id == extension.id && ext.developer == extension.developer
}) {
log::warn!(
"an extension with the same bundle ID [ID {}, developer {:?}] already exists, skip this one",
extension.id,
extension.developer
);
continue;
}
if let Err(error_msg) = general_check(&extension) {
log::warn!("{}", error_msg);
if !validate_extension(
&extension,
&extension_dir.file_name(),
&extensions,
current_platform,
) {
found_invalid_extensions = true;
// Skip invalid extension
continue;
}
if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&current_platform) {
log::warn!(
"installed third-party extension [developer {}, ID {}] is not compatible with current platform, either user messes our directory or something wrong with our extension check",
extension
.developer
.as_ref()
.expect("third party extension should have [developer] set"),
extension.id
);
continue;
}
}
/* Check ends here */
// Turn it into an absolute path if it is a valid relative path because frontend code needs this.
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
extensions.push(extension);
}
}
@@ -163,203 +194,7 @@ pub(crate) async fn list_third_party_extensions(
.collect::<Vec<_>>()
);
Ok((found_invalid_extensions, extensions))
}
/// Helper function to validate `extension`, return `true` if it is valid.
fn validate_extension(
extension: &Extension,
extension_dir_name: &OsStr,
listed_extensions: &[Extension],
current_platform: Platform,
) -> bool {
if OsStr::new(&extension.id) != extension_dir_name {
log::warn!(
"invalid extension []: id [{}] and extension directory name [{}] do not match",
extension.id,
extension_dir_name.display()
);
return false;
}
// Extension ID should be unique
if listed_extensions.iter().any(|ext| ext.id == extension.id) {
log::warn!(
"invalid extension []: extension with id [{}] already exists",
extension.id,
);
return false;
}
if !validate_extension_or_sub_item(extension) {
return false;
}
// Extension is incompatible
if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&current_platform) {
log::warn!(
"extension [{}] is not compatible with the current platform [{}], it is available to {:?}",
extension.id,
current_platform,
platforms
.iter()
.map(|os| os.to_string())
.collect::<Vec<_>>()
);
return false;
}
}
if let Some(ref commands) = extension.commands {
if !validate_sub_items(&extension.id, commands) {
return false;
}
}
if let Some(ref scripts) = extension.scripts {
if !validate_sub_items(&extension.id, scripts) {
return false;
}
}
if let Some(ref quicklinks) = extension.quicklinks {
if !validate_sub_items(&extension.id, quicklinks) {
return false;
}
}
true
}
/// Checks that can be performed against an extension or a sub item.
fn validate_extension_or_sub_item(extension: &Extension) -> bool {
// If field `action` is Some, then it should be a Command
if extension.action.is_some() && extension.r#type != ExtensionType::Command {
log::warn!(
"invalid extension [{}], [action] is set for a non-Command extension",
extension.id
);
return false;
}
if extension.r#type == ExtensionType::Command && extension.action.is_none() {
log::warn!(
"invalid extension [{}], [action] should be set for a Command extension",
extension.id
);
return false;
}
// If field `quicklink` is Some, then it should be a Quicklink
if extension.quicklink.is_some() && extension.r#type != ExtensionType::Quicklink {
log::warn!(
"invalid extension [{}], [quicklink] is set for a non-Quicklink extension",
extension.id
);
return false;
}
if extension.r#type == ExtensionType::Quicklink && extension.quicklink.is_none() {
log::warn!(
"invalid extension [{}], [quicklink] should be set for a Quicklink extension",
extension.id
);
return false;
}
// Group and Extension cannot have alias
if extension.alias.is_some() {
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
{
log::warn!(
"invalid extension [{}], extension of type [{:?}] cannot have alias",
extension.id,
extension.r#type
);
return false;
}
}
// Group and Extension cannot have hotkey
if extension.hotkey.is_some() {
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
{
log::warn!(
"invalid extension [{}], extension of type [{:?}] cannot have hotkey",
extension.id,
extension.r#type
);
return false;
}
}
if extension.commands.is_some() || extension.scripts.is_some() || extension.quicklinks.is_some()
{
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
{
log::warn!(
"invalid extension [{}], only extension of type [Group] and [Extension] can have sub-items",
extension.id,
);
return false;
}
}
true
}
/// Helper function to check sub-items.
fn validate_sub_items(extension_id: &str, sub_items: &[Extension]) -> bool {
for (sub_item_index, sub_item) in sub_items.iter().enumerate() {
// If field `action` is Some, then it should be a Command
if sub_item.action.is_some() && sub_item.r#type != ExtensionType::Command {
log::warn!(
"invalid extension sub-item [{}-{}]: [action] is set for a non-Command extension",
extension_id,
sub_item.id
);
return false;
}
if sub_item.r#type == ExtensionType::Group || sub_item.r#type == ExtensionType::Extension {
log::warn!(
"invalid extension sub-item [{}-{}]: sub-item should not be of type [Group] or [Extension]",
extension_id,
sub_item.id
);
return false;
}
let sub_item_with_same_id_count = sub_items
.iter()
.enumerate()
.filter(|(_idx, ext)| ext.id == sub_item.id)
.filter(|(idx, _ext)| *idx != sub_item_index)
.count();
if sub_item_with_same_id_count != 0 {
log::warn!(
"invalid extension [{}]: found more than one sub-items with the same ID [{}]",
extension_id,
sub_item.id
);
return false;
}
if !validate_extension_or_sub_item(sub_item) {
return false;
}
if sub_item.platforms.is_some() {
log::warn!(
"invalid extension [{}]: key [platforms] should not be set in sub-items",
extension_id,
);
return false;
}
}
true
Ok(extensions)
}
/// All the third-party extensions will be registered as one search source.
@@ -419,7 +254,7 @@ impl ThirdPartyExtensionsSearchSource {
if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move {
let result = open(app_handle_clone, on_opened_clone).await;
let result = open(app_handle_clone, on_opened_clone, None).await;
if let Err(msg) = result {
log::warn!(
"failed to open extension [{}], error [{}]",
@@ -680,7 +515,7 @@ impl ThirdPartyExtensionsSearchSource {
if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move {
let result = open(app_handle_clone, on_opened_clone).await;
let result = open(app_handle_clone, on_opened_clone, None).await;
if let Err(msg) = result {
log::warn!(
"failed to open extension [{:?}], error [{}]",

View File

@@ -19,7 +19,7 @@ use std::sync::Mutex;
use std::sync::OnceLock;
use tauri::async_runtime::block_on;
use tauri::plugin::TauriPlugin;
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, WindowEvent};
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
use tauri_plugin_autostart::MacosLauncher;
/// Tauri store name
@@ -130,9 +130,7 @@ pub fn run() {
server::connector::get_connectors_by_server,
search::query_coco_fusion,
assistant::chat_history,
assistant::new_chat,
assistant::chat_create,
assistant::send_message,
assistant::chat_chat,
assistant::session_chat_history,
assistant::open_session_chat,
@@ -145,11 +143,9 @@ pub fn run() {
assistant::assistant_get_multi,
// server::get_coco_server_datasources,
// server::get_coco_server_connectors,
server::websocket::connect_to_server,
server::websocket::disconnect,
get_app_search_source,
server::attachment::upload_attachment,
server::attachment::get_attachment,
server::attachment::get_attachment_by_ids,
server::attachment::delete_attachment,
server::transcription::transcription,
server::system_settings::get_system_settings,
@@ -159,6 +155,7 @@ pub fn run() {
extension::built_in::application::add_app_search_path,
extension::built_in::application::remove_app_search_path,
extension::built_in::application::reindex_applications,
extension::quicklink_link_arguments,
extension::list_extensions,
extension::enable_extension,
extension::disable_extension,
@@ -166,8 +163,9 @@ pub fn run() {
extension::register_extension_hotkey,
extension::unregister_extension_hotkey,
extension::is_extension_enabled,
extension::third_party::store::search_extension,
extension::third_party::store::install_extension_from_store,
extension::third_party::install::store::search_extension,
extension::third_party::install::store::install_extension_from_store,
extension::third_party::install::local_extension::install_local_extension,
extension::third_party::uninstall_extension,
settings::set_allow_self_signature,
settings::get_allow_self_signature,
@@ -180,6 +178,8 @@ pub fn run() {
server::synthesize::synthesize,
util::file::get_file_icon,
util::app_lang::update_app_lang,
#[cfg(target_os = "macos")]
setup::toggle_move_to_active_space_attribute,
])
.setup(|app| {
let app_handle = app.handle().clone();
@@ -198,7 +198,6 @@ pub fn run() {
let registry = SearchSourceRegistry::default();
app.manage(registry); // Store registry in Tauri's app state
app.manage(server::websocket::WebSocketManager::default());
// This has to be called before initializing extensions as doing that
// requires access to the shortcut store, which will be set by this
@@ -210,7 +209,7 @@ pub fn run() {
// We want all the extensions here, so no filter condition specified.
match extension::list_extensions(app_handle.clone(), None, None, false).await {
Ok((_found_invalid_extensions, extensions)) => {
Ok(extensions) => {
// Initializing extension relies on SearchSourceRegistry, so this should
// be executed after `app.manage(registry)`
if let Err(e) =
@@ -291,7 +290,7 @@ pub fn run() {
});
}
pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
pub async fn init(app_handle: &AppHandle) {
// Await the async functions to load the servers and tokens
if let Err(err) = load_or_insert_default_server(app_handle).await {
log::error!("Failed to load servers: {}", err);
@@ -315,7 +314,7 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
}
#[tauri::command]
async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
async fn show_coco(app_handle: AppHandle) {
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
move_window_to_active_monitor(&window);
@@ -328,7 +327,7 @@ async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
}
#[tauri::command]
async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
async fn hide_coco(app: AppHandle) {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
if let Err(err) = window.hide() {
log::error!("Failed to hide the window: {}", err);
@@ -340,7 +339,7 @@ async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
}
}
fn move_window_to_active_monitor<R: Runtime>(window: &WebviewWindow<R>) {
fn move_window_to_active_monitor(window: &WebviewWindow) {
//dbg!("Moving window to active monitor");
// Try to get the available monitors, handle failure gracefully
let available_monitors = match window.available_monitors() {

View File

@@ -1,7 +1,7 @@
use crate::common::error::SearchError;
use crate::common::register::SearchSourceRegistry;
use crate::common::search::{
FailedRequest, MultiSourceQueryResponse, QueryHits, QueryResponse, QuerySource, SearchQuery,
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
};
use crate::common::traits::SearchSource;
use crate::server::servers::logout_coco_server;
@@ -13,74 +13,24 @@ use reqwest::StatusCode;
use std::cmp::Reverse;
use std::collections::HashMap;
use std::collections::HashSet;
use std::future::Future;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use tokio::time::error::Elapsed;
use tokio::time::{Duration, timeout};
/// Helper function to return the Future used for querying querysources.
///
/// It is a workaround for the limitations:
///
/// 1. 2 async blocks have different types in Rust's type system even though
/// they are literally same
/// 2. `futures::stream::FuturesUnordered` needs the `Futures` pushed to it to
/// have only 1 type
///
/// Putting the async block in a function to unify the types.
fn same_type_futures(
query_source: QuerySource,
query_source_trait_object: Arc<dyn SearchSource>,
timeout_duration: Duration,
search_query: SearchQuery,
tauri_app_handle: AppHandle,
) -> impl Future<
Output = (
QuerySource,
Result<Result<QueryResponse, SearchError>, Elapsed>,
),
> + 'static {
async move {
(
// Store `query_source` as part of future for debugging purposes.
query_source,
timeout(timeout_duration, async {
query_source_trait_object
.search(tauri_app_handle.clone(), search_query)
.await
})
.await,
)
}
}
#[named]
#[tauri::command]
pub async fn query_coco_fusion(
app_handle: AppHandle,
tauri_app_handle: AppHandle,
from: u64,
size: u64,
query_strings: HashMap<String, String>,
query_timeout: u64,
) -> Result<MultiSourceQueryResponse, SearchError> {
let query_keyword = query_strings
.get("query")
.unwrap_or(&"".to_string())
.clone();
let opt_query_source_id = query_strings.get("querysource");
let search_sources = app_handle.state::<SearchSourceRegistry>();
let sources_future = search_sources.get_sources();
let mut futures = FuturesUnordered::new();
let mut sources_list = sources_future.await;
let sources_list_len = sources_list.len();
// Time limit for each query
let search_sources = tauri_app_handle.state::<SearchSourceRegistry>();
let query_source_list = search_sources.get_sources().await;
let timeout_duration = Duration::from_millis(query_timeout);
let search_query = SearchQuery::new(from, size, query_strings.clone());
log::debug!(
"{}() invoked with parameters: from: [{}], size: [{}], query_strings: [{:?}], timeout: [{:?}]",
@@ -91,68 +41,170 @@ pub async fn query_coco_fusion(
timeout_duration
);
let search_query = SearchQuery::new(from, size, query_strings.clone());
// Dispatch to different `query_coco_fusion_xxx()` functions.
if let Some(query_source_id) = opt_query_source_id {
// If this query source ID is specified, we only query this query source.
log::debug!(
"parameter [querysource={}] specified, will only query this querysource",
query_source_id
);
let opt_query_source_trait_object_index = sources_list
.iter()
.position(|query_source| &query_source.get_type().id == query_source_id);
let Some(query_source_trait_object_index) = opt_query_source_trait_object_index else {
// It is possible (an edge case) that the frontend invokes `query_coco_fusion()` with a
// datasource that does not exist in the source list:
//
// 1. Search applications
// 2. Navigate to the application sub page
// 3. Disable the application extension in settings
// 4. hide the search window
// 5. Re-open the search window and search for something
//
// The application search source is not in the source list because the extension
// has been disabled, but the last search is indeed invoked with parameter
// `datasource=application`.
return Ok(MultiSourceQueryResponse {
failed: Vec::new(),
hits: Vec::new(),
total_hits: 0,
});
};
let query_source_trait_object = sources_list.remove(query_source_trait_object_index);
let query_source = query_source_trait_object.get_type();
futures.push(same_type_futures(
query_source,
query_source_trait_object,
query_coco_fusion_single_query_source(
tauri_app_handle,
query_source_list,
query_source_id.clone(),
timeout_duration,
search_query,
app_handle.clone(),
));
)
.await
} else {
log::debug!(
"will query querysources {:?}",
sources_list
.iter()
.map(|search_source| search_source.get_type().id.clone())
.collect::<Vec<String>>()
);
query_coco_fusion_multi_query_sources(
tauri_app_handle,
query_source_list,
timeout_duration,
search_query,
)
.await
}
}
for query_source_trait_object in sources_list {
let query_source = query_source_trait_object.get_type().clone();
futures.push(same_type_futures(
query_source,
query_source_trait_object,
timeout_duration,
search_query.clone(),
app_handle.clone(),
));
/// Query only 1 query source.
///
/// The logic here is much simpler than `query_coco_fusion_multi_query_sources()`
/// as we don't need to re-rank due to fact that this does not involve multiple
/// query sources.
async fn query_coco_fusion_single_query_source(
tauri_app_handle: AppHandle,
mut query_source_list: Vec<Arc<dyn SearchSource>>,
id_of_query_source_to_query: String,
timeout_duration: Duration,
search_query: SearchQuery,
) -> Result<MultiSourceQueryResponse, SearchError> {
// If this query source ID is specified, we only query this query source.
log::debug!(
"parameter [querysource={}] specified, will only query this query source",
id_of_query_source_to_query
);
let opt_query_source_trait_object_index = query_source_list
.iter()
.position(|query_source| query_source.get_type().id == id_of_query_source_to_query);
let Some(query_source_trait_object_index) = opt_query_source_trait_object_index else {
// It is possible (an edge case) that the frontend invokes `query_coco_fusion()`
// with a querysource that does not exist in the source list:
//
// 1. Search applications
// 2. Navigate to the application sub page
// 3. Disable the application extension in settings, which removes this
// query source from the list
// 4. hide the search window
// 5. Re-open the search window, you will still be in the sub page, type to search
// something
//
// The application query source is not in the source list because the extension
// was disabled and thus removed from the query sources, but the last
// search is indeed invoked with parameter `querysource=application`.
return Ok(MultiSourceQueryResponse {
failed: Vec::new(),
hits: Vec::new(),
total_hits: 0,
});
};
let query_source_trait_object = query_source_list.remove(query_source_trait_object_index);
let query_source = query_source_trait_object.get_type();
let search_fut = query_source_trait_object.search(tauri_app_handle.clone(), search_query);
let timeout_result = timeout(timeout_duration, search_fut).await;
let mut failed_requests: Vec<FailedRequest> = Vec::new();
let mut hits = Vec::new();
let mut total_hits = 0;
match timeout_result {
// Ignore the `_timeout` variable as it won't provide any useful debugging information.
Err(_timeout) => {
log::warn!(
"searching query source [{}] timed out, skip this request",
query_source.id
);
}
Ok(query_result) => match query_result {
Ok(response) => {
total_hits = response.total_hits;
for (document, score) in response.hits {
log::debug!(
"document from query source [{}]: ID [{}], title [{:?}], score [{}]",
response.source.id,
document.id,
document.title,
score
);
let query_hit = QueryHits {
source: Some(response.source.clone()),
score,
document,
};
hits.push(query_hit);
}
}
Err(search_error) => {
query_coco_fusion_handle_failed_request(
tauri_app_handle.clone(),
&mut failed_requests,
query_source,
search_error,
)
.await;
}
},
}
Ok(MultiSourceQueryResponse {
failed: failed_requests,
hits,
total_hits,
})
}
async fn query_coco_fusion_multi_query_sources(
tauri_app_handle: AppHandle,
query_source_trait_object_list: Vec<Arc<dyn SearchSource>>,
timeout_duration: Duration,
search_query: SearchQuery,
) -> Result<MultiSourceQueryResponse, SearchError> {
log::debug!(
"will query query sources {:?}",
query_source_trait_object_list
.iter()
.map(|search_source| search_source.get_type().id.clone())
.collect::<Vec<String>>()
);
let query_keyword = search_query
.query_strings
.get("query")
.unwrap_or(&"".to_string())
.clone();
let size = search_query.size;
let mut futures = FuturesUnordered::new();
let query_source_list_len = query_source_trait_object_list.len();
for query_source_trait_object in query_source_trait_object_list {
let query_source = query_source_trait_object.get_type().clone();
let tauri_app_handle_clone = tauri_app_handle.clone();
let search_query_clone = search_query.clone();
futures.push(async move {
(
// Store `query_source` as part of future for debugging purposes.
query_source,
timeout(timeout_duration, async {
query_source_trait_object
.search(tauri_app_handle_clone, search_query_clone)
.await
})
.await,
)
});
}
let mut total_hits = 0;
@@ -161,7 +213,7 @@ pub async fn query_coco_fusion(
let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::new();
let mut hits_per_source: HashMap<String, Vec<(QueryHits, f64)>> = HashMap::new();
if sources_list_len > 1 {
if query_source_list_len > 1 {
need_rerank = true; // If we have more than one source, we need to rerank the hits
}
@@ -173,25 +225,25 @@ pub async fn query_coco_fusion(
"searching query source [{}] timed out, skip this request",
query_source.id
);
// failed_requests.push(FailedRequest {
// source: query_source,
// status: 0,
// error: Some("querying timed out".into()),
// reason: None,
// });
}
Ok(query_result) => match query_result {
Ok(response) => {
total_hits += response.total_hits;
let source_id = response.source.id.clone();
for (doc, score) in response.hits {
log::debug!("doc: {}, {:?}, {}", doc.id, doc.title, score);
for (document, score) in response.hits {
log::debug!(
"document from query source [{}]: ID [{}], title [{:?}], score [{}]",
response.source.id,
document.id,
document.title,
score
);
let query_hit = QueryHits {
source: Some(response.source.clone()),
score,
document: doc,
document,
};
all_hits.push((source_id.clone(), query_hit.clone(), score));
@@ -203,46 +255,13 @@ pub async fn query_coco_fusion(
}
}
Err(search_error) => {
log::error!(
"searching query source [{}] failed, error [{}]",
query_source.id,
search_error
);
let mut status_code_num: u16 = 0;
if let SearchError::HttpError {
status_code: opt_status_code,
msg: _,
} = search_error
{
if let Some(status_code) = opt_status_code {
status_code_num = status_code.as_u16();
if status_code != StatusCode::OK {
if status_code == StatusCode::UNAUTHORIZED {
// This Coco server is unavailable. In addition to marking it as
// unavailable, we need to log out because the status code is 401.
logout_coco_server(app_handle.clone(), query_source.id.clone()).await.unwrap_or_else(|e| {
panic!(
"the search request to Coco server [id {}, name {}] failed with status code {}, the login token is invalid, we are trying to log out, but failed with error [{}]",
query_source.id, query_source.name, StatusCode::UNAUTHORIZED, e
);
})
} else {
// This Coco server is unavailable
mark_server_as_offline(app_handle.clone(), &query_source.id)
.await;
}
}
}
}
failed_requests.push(FailedRequest {
source: query_source,
status: status_code_num,
error: Some(search_error.to_string()),
reason: None,
});
query_coco_fusion_handle_failed_request(
tauri_app_handle.clone(),
&mut failed_requests,
query_source,
search_error,
)
.await;
}
},
}
@@ -402,3 +421,54 @@ fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(u
})
.collect()
}
/// Helper function to handle a failed request.
///
/// Extracted as a function because `query_coco_fusion_single_query_source()` and
/// `query_coco_fusion_multi_query_sources()` share the same error handling logic.
async fn query_coco_fusion_handle_failed_request(
tauri_app_handle: AppHandle,
failed_requests: &mut Vec<FailedRequest>,
query_source: QuerySource,
search_error: SearchError,
) {
log::error!(
"searching query source [{}] failed, error [{}]",
query_source.id,
search_error
);
let mut status_code_num: u16 = 0;
if let SearchError::HttpError {
status_code: opt_status_code,
msg: _,
} = search_error
{
if let Some(status_code) = opt_status_code {
status_code_num = status_code.as_u16();
if status_code != StatusCode::OK {
if status_code == StatusCode::UNAUTHORIZED {
// This Coco server is unavailable. In addition to marking it as
// unavailable, we need to log out because the status code is 401.
logout_coco_server(tauri_app_handle.clone(), query_source.id.to_string()).await.unwrap_or_else(|e| {
panic!(
"the search request to Coco server [id {}, name {}] failed with status code {}, the login token is invalid, we are trying to log out, but failed with error [{}]",
query_source.id, query_source.name, StatusCode::UNAUTHORIZED, e
);
})
} else {
// This Coco server is unavailable
mark_server_as_offline(tauri_app_handle.clone(), &query_source.id).await;
}
}
}
}
failed_requests.push(FailedRequest {
source: query_source,
status: status_code_num,
error: Some(search_error.to_string()),
reason: None,
});
}

View File

@@ -72,11 +72,19 @@ pub async fn upload_attachment(
}
#[command]
pub async fn get_attachment(server_id: String, session_id: String) -> Result<Value, String> {
let mut query_params = Vec::new();
query_params.push(format!("session={}", session_id));
pub async fn get_attachment_by_ids(
server_id: String,
attachments: Vec<String>,
) -> Result<Value, String> {
println!("get_attachment_by_ids server_id: {}", server_id);
println!("get_attachment_by_ids attachments: {:?}", attachments);
let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params))
let request_body = serde_json::json!({
"attachments": attachments
});
let body = reqwest::Body::from(serde_json::to_string(&request_body).unwrap());
let response = HttpClient::post(&server_id, "/attachment/_search", None, Some(body))
.await
.map_err(|e| format!("Request error: {}", e))?;

View File

@@ -4,7 +4,7 @@ use crate::server::servers::{
get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server,
try_register_server_to_search_source,
};
use tauri::{AppHandle, Runtime};
use tauri::AppHandle;
#[allow(dead_code)]
fn request_access_token_url(request_id: &str) -> String {
@@ -13,8 +13,8 @@ fn request_access_token_url(request_id: &str) -> String {
}
#[tauri::command]
pub async fn handle_sso_callback<R: Runtime>(
app_handle: AppHandle<R>,
pub async fn handle_sso_callback(
app_handle: AppHandle,
server_id: String,
request_id: String,
code: String,

View File

@@ -6,7 +6,7 @@ use http::StatusCode;
use lazy_static::lazy_static;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tauri::{AppHandle, Runtime};
use tauri::AppHandle;
lazy_static! {
static ref CONNECTOR_CACHE: Arc<RwLock<HashMap<String, HashMap<String, Connector>>>> =
@@ -29,7 +29,7 @@ pub fn get_connector_by_id(server_id: &str, connector_id: &str) -> Option<Connec
Some(connector.clone())
}
pub async fn refresh_all_connectors<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
pub async fn refresh_all_connectors(app_handle: &AppHandle) -> Result<(), String> {
let servers = get_all_servers().await;
// Collect all the tasks for fetching and refreshing connectors
@@ -122,8 +122,8 @@ pub async fn fetch_connectors_by_server(id: &str) -> Result<Vec<Connector>, Stri
}
#[tauri::command]
pub async fn get_connectors_by_server<R: Runtime>(
_app_handle: AppHandle<R>,
pub async fn get_connectors_by_server(
_app_handle: AppHandle,
id: String,
) -> Result<Vec<Connector>, String> {
let connectors = fetch_connectors_by_server(&id).await?;

View File

@@ -7,7 +7,7 @@ use http::StatusCode;
use lazy_static::lazy_static;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tauri::{AppHandle, Runtime};
use tauri::AppHandle;
lazy_static! {
static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String, HashMap<String, DataSource>>>> =
@@ -31,7 +31,7 @@ pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, Dat
Some(server_cache.clone())
}
pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) -> Result<(), String> {
pub async fn refresh_all_datasources(_app_handle: &AppHandle) -> Result<(), String> {
// dbg!("Attempting to refresh all datasources");
let servers = get_all_servers().await;

View File

@@ -11,4 +11,3 @@ pub mod servers;
pub mod synthesize;
pub mod system_settings;
pub mod transcription;
pub mod websocket;

View File

@@ -1,11 +1,11 @@
use crate::common::http::get_response_body_text;
use crate::common::profile::UserProfile;
use crate::server::http_client::HttpClient;
use tauri::{AppHandle, Runtime};
use tauri::AppHandle;
#[tauri::command]
pub async fn get_user_profiles<R: Runtime>(
_app_handle: AppHandle<R>,
pub async fn get_user_profiles(
_app_handle: AppHandle,
server_id: String,
) -> Result<UserProfile, String> {
// Use the generic GET method from HttpClient

View File

@@ -13,7 +13,6 @@ use serde_json::Value as JsonValue;
use serde_json::from_value;
use std::collections::HashMap;
use std::sync::LazyLock;
use tauri::Runtime;
use tauri::{AppHandle, Manager};
use tauri_plugin_store::StoreExt;
use tokio::sync::RwLock;
@@ -70,7 +69,7 @@ async fn remove_server_by_id(id: &str) -> Option<Server> {
cache.remove(id)
}
pub async fn persist_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
pub async fn persist_servers(app_handle: &AppHandle) -> Result<(), String> {
let cache = SERVER_LIST_CACHE.read().await;
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
@@ -99,7 +98,7 @@ pub async fn remove_server_token(id: &str) -> bool {
cache.remove(id).is_some()
}
pub async fn persist_servers_token<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
pub async fn persist_servers_token(app_handle: &AppHandle) -> Result<(), String> {
let cache = SERVER_TOKEN_LIST_CACHE.read().await;
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
@@ -158,9 +157,7 @@ fn get_default_server() -> Server {
}
}
pub async fn load_servers_token<R: Runtime>(
app_handle: &AppHandle<R>,
) -> Result<Vec<ServerAccessToken>, String> {
pub async fn load_servers_token(app_handle: &AppHandle) -> Result<Vec<ServerAccessToken>, String> {
log::debug!("Attempting to load servers token");
let store = app_handle
@@ -219,7 +216,7 @@ pub async fn load_servers_token<R: Runtime>(
}
}
pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<Server>, String> {
pub async fn load_servers(app_handle: &AppHandle) -> Result<Vec<Server>, String> {
let store = app_handle
.store(COCO_TAURI_STORE)
.expect("create or load a store should not fail");
@@ -276,9 +273,7 @@ pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<S
}
/// Function to load servers or insert a default one if none exist
pub async fn load_or_insert_default_server<R: Runtime>(
app_handle: &AppHandle<R>,
) -> Result<Vec<Server>, String> {
pub async fn load_or_insert_default_server(app_handle: &AppHandle) -> Result<Vec<Server>, String> {
log::debug!("Attempting to load or insert default server");
let exists_servers = load_servers(&app_handle).await;
@@ -296,9 +291,7 @@ pub async fn load_or_insert_default_server<R: Runtime>(
}
#[tauri::command]
pub async fn list_coco_servers<R: Runtime>(
app_handle: AppHandle<R>,
) -> Result<Vec<Server>, String> {
pub async fn list_coco_servers(app_handle: AppHandle) -> Result<Vec<Server>, String> {
//hard fresh all server's info, in order to get the actual health
refresh_all_coco_server_info(app_handle.clone()).await;
@@ -312,7 +305,7 @@ pub async fn get_all_servers() -> Vec<Server> {
cache.values().cloned().collect()
}
pub async fn refresh_all_coco_server_info<R: Runtime>(app_handle: AppHandle<R>) {
pub async fn refresh_all_coco_server_info(app_handle: AppHandle) {
let servers = get_all_servers().await;
for server in servers {
let _ = refresh_coco_server_info(app_handle.clone(), server.id.clone()).await;
@@ -320,10 +313,7 @@ pub async fn refresh_all_coco_server_info<R: Runtime>(app_handle: AppHandle<R>)
}
#[tauri::command]
pub async fn refresh_coco_server_info<R: Runtime>(
app_handle: AppHandle<R>,
id: String,
) -> Result<Server, String> {
pub async fn refresh_coco_server_info(app_handle: AppHandle, id: String) -> Result<Server, String> {
// Retrieve the server from the cache
let cached_server = {
let cache = SERVER_LIST_CACHE.read().await;
@@ -393,10 +383,7 @@ pub async fn refresh_coco_server_info<R: Runtime>(
}
#[tauri::command]
pub async fn add_coco_server<R: Runtime>(
app_handle: AppHandle<R>,
endpoint: String,
) -> Result<Server, String> {
pub async fn add_coco_server(app_handle: AppHandle, endpoint: String) -> Result<Server, String> {
load_or_insert_default_server(&app_handle)
.await
.map_err(|e| format!("Failed to load default servers: {}", e))?;
@@ -472,10 +459,7 @@ pub async fn add_coco_server<R: Runtime>(
#[tauri::command]
#[function_name::named]
pub async fn remove_coco_server<R: Runtime>(
app_handle: AppHandle<R>,
id: String,
) -> Result<(), ()> {
pub async fn remove_coco_server(app_handle: AppHandle, id: String) -> Result<(), ()> {
let registry = app_handle.state::<SearchSourceRegistry>();
registry.remove_source(id.as_str()).await;
@@ -507,7 +491,7 @@ pub async fn remove_coco_server<R: Runtime>(
#[tauri::command]
#[function_name::named]
pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> {
pub async fn enable_server(app_handle: AppHandle, id: String) -> Result<(), ()> {
let opt_server = get_server_by_id(id.as_str()).await;
let Some(mut server) = opt_server else {
@@ -532,7 +516,7 @@ pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
#[tauri::command]
#[function_name::named]
pub async fn disable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> {
pub async fn disable_server(app_handle: AppHandle, id: String) -> Result<(), ()> {
let opt_server = get_server_by_id(id.as_str()).await;
let Some(mut server) = opt_server else {
@@ -560,10 +544,7 @@ pub async fn disable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
/// enabled.
///
/// For public Coco server, an extra token is required.
pub async fn try_register_server_to_search_source(
app_handle: AppHandle<impl Runtime>,
server: &Server,
) {
pub async fn try_register_server_to_search_source(app_handle: AppHandle, server: &Server) {
if server.enabled {
log::trace!(
"Server [name: {}, id: {}] is public: {} and available: {}",
@@ -590,7 +571,7 @@ pub async fn try_register_server_to_search_source(
#[function_name::named]
#[allow(unused)]
async fn mark_server_as_online<R: Runtime>(app_handle: AppHandle<R>, id: &str) {
async fn mark_server_as_online(app_handle: AppHandle, id: &str) {
let server = get_server_by_id(id).await;
if let Some(mut server) = server {
server.available = true;
@@ -608,7 +589,7 @@ async fn mark_server_as_online<R: Runtime>(app_handle: AppHandle<R>, id: &str) {
}
#[function_name::named]
pub(crate) async fn mark_server_as_offline<R: Runtime>(app_handle: AppHandle<R>, id: &str) {
pub(crate) async fn mark_server_as_offline(app_handle: AppHandle, id: &str) {
let server = get_server_by_id(id).await;
if let Some(mut server) = server {
server.available = false;
@@ -628,10 +609,7 @@ pub(crate) async fn mark_server_as_offline<R: Runtime>(app_handle: AppHandle<R>,
#[tauri::command]
#[function_name::named]
pub async fn logout_coco_server<R: Runtime>(
app_handle: AppHandle<R>,
id: String,
) -> Result<(), String> {
pub async fn logout_coco_server(app_handle: AppHandle, id: String) -> Result<(), String> {
log::debug!("Attempting to log out server by id: {}", &id);
// Check if the server exists

View File

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

View File

@@ -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(())
}

View File

@@ -1,12 +1,12 @@
use crate::COCO_TAURI_STORE;
use serde_json::Value as Json;
use tauri::{AppHandle, Runtime};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
#[tauri::command]
pub async fn set_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>, value: bool) {
pub async fn set_allow_self_signature(tauri_app_handle: AppHandle, value: bool) {
use crate::server::http_client;
let store = tauri_app_handle
@@ -40,7 +40,7 @@ pub async fn set_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>
}
/// Synchronous version of `async get_allow_self_signature()`.
pub fn _get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) -> bool {
pub fn _get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
let store = tauri_app_handle
.store(COCO_TAURI_STORE)
.unwrap_or_else(|e| {
@@ -67,6 +67,6 @@ pub fn _get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) ->
}
#[tauri::command]
pub async fn get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) -> bool {
pub async fn get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
_get_allow_self_signature(tauri_app_handle)
}

View File

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

View File

@@ -1,5 +1,5 @@
use crate::{COCO_TAURI_STORE, hide_coco, show_coco};
use tauri::{App, AppHandle, Manager, Runtime, async_runtime};
use tauri::{App, AppHandle, Manager, async_runtime};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use tauri_plugin_store::{JsonValue, StoreExt};
@@ -50,14 +50,14 @@ pub fn enable_shortcut(app: &App) {
/// Get the stored shortcut as a string, same as [`_get_shortcut()`], except that
/// this is a `tauri::command` interface.
#[tauri::command]
pub async fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
pub async fn get_current_shortcut(app: AppHandle) -> Result<String, String> {
let shortcut = _get_shortcut(&app);
Ok(shortcut)
}
/// Get the current shortcut and unregister it on the tauri side.
#[tauri::command]
pub async fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
pub async fn unregister_shortcut(app: AppHandle) {
let shortcut_str = _get_shortcut(&app);
let shortcut = shortcut_str
.parse::<Shortcut>()
@@ -70,9 +70,9 @@ pub async fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
/// Change the global shortcut to `key`.
#[tauri::command]
pub async fn change_shortcut<R: Runtime>(
app: AppHandle<R>,
_window: tauri::Window<R>,
pub async fn change_shortcut(
app: AppHandle,
_window: tauri::Window,
key: String,
) -> Result<(), String> {
println!("key {}:", key);
@@ -94,7 +94,7 @@ pub async fn change_shortcut<R: Runtime>(
}
/// Helper function to register a shortcut, used for shortcut updates.
fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
fn _register_shortcut(app: &AppHandle, shortcut: Shortcut) {
app.global_shortcut()
.on_shortcut(shortcut, move |app, scut, event| {
if scut == &shortcut {
@@ -151,7 +151,7 @@ fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
}
/// Helper function to get the stored global shortcut, as a string.
pub fn _get_shortcut<R: Runtime>(app: &AppHandle<R>) -> String {
pub fn _get_shortcut(app: &AppHandle) -> String {
let store = app
.get_store(COCO_TAURI_STORE)
.expect("store should be loaded or created");

View File

@@ -4,7 +4,7 @@ pub(crate) mod platform;
pub(crate) mod updater;
use std::{path::Path, process::Command};
use tauri::{AppHandle, Runtime};
use tauri::AppHandle;
use tauri_plugin_shell::ShellExt;
/// We use this env variable to determine the DE on Linux.
@@ -88,7 +88,7 @@ fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
//
// tauri_plugin_shell::open() is deprecated, but we still use it.
#[allow(deprecated)]
pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<(), String> {
pub async fn open(app_handle: AppHandle, path: String) -> Result<(), String> {
if cfg!(target_os = "linux") {
let borrowed_path = Path::new(&path);
if let Some(file_extension) = borrowed_path.extension() {

View File

@@ -1,8 +1,22 @@
use derive_more::Display;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use strum::EnumCount;
use strum::VariantArray;
#[derive(Debug, Deserialize, Serialize, Copy, Clone, Hash, PartialEq, Eq, Display)]
#[derive(
Debug,
Deserialize,
Serialize,
Copy,
Clone,
Hash,
PartialEq,
Eq,
Display,
EnumCount,
VariantArray,
)]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub(crate) enum Platform {
#[display("macOS")]
@@ -18,7 +32,7 @@ impl Platform {
pub(crate) fn current() -> Platform {
let os_str = std::env::consts::OS;
serde_plain::from_str(os_str).unwrap_or_else(|_e| {
panic!("std::env::consts::OS is [{}], which is not a valid value for [enum Platform], valid values: ['macos', 'linux', 'windows']", os_str)
panic!("std::env::consts::OS is [{}], which is not a valid value for [enum Platform], valid values: {:?}", os_str, Self::VARIANTS.iter().map(|platform|platform.to_string()).collect::<Vec<String>>());
})
}
@@ -31,4 +45,17 @@ impl Platform {
Self::Linux => Cow::Owned(sysinfo::System::distribution_id()),
}
}
/// Returns the number of platforms supported by Coco.
//
// a.k.a., the number of this enum's variants.
pub(crate) fn num_of_supported_platforms() -> usize {
Platform::COUNT
}
/// Returns a set that contains all the platforms.
#[cfg(test)] // currently, only used in tests
pub(crate) fn all() -> std::collections::HashSet<Self> {
Platform::VARIANTS.into_iter().copied().collect()
}
}

View File

@@ -5,7 +5,7 @@ use tauri_plugin_updater::RemoteRelease;
///
/// If the version string is in the `x.y.z` format and does not include a build
/// number, we assume a build number of 0.
fn extract_version_number(version: &Version) -> u32 {
fn extract_build_number(version: &Version) -> u32 {
let pre = &version.pre;
if pre.is_empty() {
@@ -52,8 +52,8 @@ fn extract_version_number(version: &Version) -> u32 {
pub(crate) fn custom_version_comparator(local: Version, remote_release: RemoteRelease) -> bool {
let remote = remote_release.version;
let local_build_number = extract_version_number(&local);
let remote_build_number = extract_version_number(&remote);
let local_build_number = extract_build_number(&local);
let remote_build_number = extract_build_number(&remote);
let should_update = remote_build_number > local_build_number;
log::debug!(
@@ -65,3 +65,23 @@ pub(crate) fn custom_version_comparator(local: Version, remote_release: RemoteRe
should_update
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_build_number() {
// 0.6.0 => 0
let version = Version::parse("0.6.0").unwrap();
assert_eq!(extract_build_number(&version), 0);
// 0.6.0-2371 => 2371
let version = Version::parse("0.6.0-2371").unwrap();
assert_eq!(extract_build_number(&version), 2371);
// 0.6.0-SNAPSHOT-2371 => 2371
let version = Version::parse("0.6.0-SNAPSHOT-2371").unwrap();
assert_eq!(extract_build_number(&version), 2371);
}
}

View File

@@ -126,7 +126,6 @@
"https://release.infinilabs.com/coco/app/.latest.json?target={{target}}&arch={{arch}}&current_version={{current_version}}"
]
},
"websocket": {},
"shell": {},
"globalShortcut": {},
"deep-link": {

View File

@@ -86,6 +86,12 @@ export const Get = <T>(
} else {
res = result?.data as FcResponse<T>;
}
// web component log
infoLog({
username: "@/api/axiosRequest.ts",
logName: url,
})(res);
resolve([null, res as FcResponse<T>]);
})
.catch((err) => {
@@ -103,7 +109,7 @@ export const Post = <T>(
return new Promise((resolve) => {
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
let baseURL = appStore.state?.endpoint_http
let baseURL = appStore.state?.endpoint_http;
if (!baseURL || baseURL === "undefined") {
baseURL = "";
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,4 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import {
Server,
@@ -8,7 +7,7 @@ import {
GetResponse,
UploadAttachmentPayload,
UploadAttachmentResponse,
GetAttachmentPayload,
GetAttachmentByIdsPayload,
GetAttachmentResponse,
DeleteAttachmentPayload,
TranscriptionPayload,
@@ -18,17 +17,42 @@ import {
import { useAppStore } from "@/stores/appStore";
import { useAuthStore } from "@/stores/authStore";
import { useConnectStore } from "@/stores/connectStore";
import { SETTINGS_WINDOW_LABEL } from "@/constants";
import platformAdapter from "@/utils/platformAdapter";
export function handleLogout(serverId?: string) {
const setIsCurrentLogin = useAuthStore.getState().setIsCurrentLogin;
const { currentService, setCurrentService, serverList, setServerList } =
export async function getCurrentWindowService() {
const currentService = useConnectStore.getState().currentService;
const cloudSelectService = useConnectStore.getState().cloudSelectService;
const windowLabel = await platformAdapter.getCurrentWindowLabel();
return windowLabel === SETTINGS_WINDOW_LABEL
? cloudSelectService
: currentService;
}
export async function setCurrentWindowService(service: any) {
const windowLabel = await platformAdapter.getCurrentWindowLabel();
const { setCurrentService, setCloudSelectService } =
useConnectStore.getState();
const id = serverId || currentService?.id;
return windowLabel === SETTINGS_WINDOW_LABEL
? setCloudSelectService(service)
: setCurrentService(service);
}
export async function handleLogout(serverId?: string) {
const setIsCurrentLogin = useAuthStore.getState().setIsCurrentLogin;
const { serverList, setServerList } = useConnectStore.getState();
const service = await getCurrentWindowService();
const id = serverId || service?.id;
if (!id) return;
// Update the status first
setIsCurrentLogin(false);
emit("login_or_logout", false);
if (currentService?.id === id) {
setCurrentService({ ...currentService, profile: null });
if (service?.id === id) {
await setCurrentWindowService({ ...service, profile: null });
}
const updatedServerList = serverList.map((server) =>
server.id === id ? { ...server, profile: null } : server
@@ -55,13 +79,14 @@ async function invokeWithErrorHandler<T>(
args?: Record<string, any>
): Promise<T> {
const isCurrentLogin = useAuthStore.getState().isCurrentLogin;
const currentService = useConnectStore.getState().currentService;
const service = await getCurrentWindowService();
// Not logged in
console.log(command, isCurrentLogin, currentService?.profile);
// console.log("isCurrentLogin", command, isCurrentLogin);
if (
!WHITELIST_SERVERS.includes(command) &&
(!isCurrentLogin || !currentService?.profile)
(!isCurrentLogin || !service?.profile)
) {
console.error("This command requires authentication");
throw new Error("This command requires authentication");
@@ -89,6 +114,18 @@ async function invokeWithErrorHandler<T>(
}
}
// Server Data log
let parsedResult = result;
let logData = result;
if (typeof result === "string") {
parsedResult = JSON.parse(result);
logData = parsedResult;
}
infoLog({
username: "@/commands/servers.ts",
logName: command,
})(logData);
return result;
} catch (error: any) {
const errorMessage = error || "Command execution failed";
@@ -172,14 +209,6 @@ export function mcp_server_search({
return invokeWithErrorHandler(`mcp_server_search`, { id, queryParams });
}
export function connect_to_server(id: string, clientId: string): Promise<void> {
return invokeWithErrorHandler(`connect_to_server`, { id, clientId });
}
export function disconnect(clientId: string): Promise<void> {
return invokeWithErrorHandler(`disconnect`, { clientId });
}
export function chat_history({
serverId,
from = 0,
@@ -260,76 +289,40 @@ export function cancel_session_chat({
});
}
export function new_chat({
serverId,
websocketId,
message,
queryParams,
}: {
serverId: string;
websocketId: string;
message: string;
queryParams?: Record<string, any>;
}): Promise<GetResponse> {
return invokeWithErrorHandler(`new_chat`, {
serverId,
websocketId,
message,
queryParams,
});
}
export function chat_create({
serverId,
message,
attachments,
queryParams,
clientId,
}: {
serverId: string;
message: string;
attachments: string[];
queryParams?: Record<string, any>;
clientId: string;
}): Promise<GetResponse> {
return invokeWithErrorHandler(`chat_create`, {
serverId,
message,
attachments,
queryParams,
clientId,
});
}
export function send_message({
serverId,
websocketId,
sessionId,
message,
queryParams,
}: {
serverId: string;
websocketId: string;
sessionId: string;
message: string;
queryParams?: Record<string, any>;
}): Promise<string> {
return invokeWithErrorHandler(`send_message`, {
serverId,
websocketId,
sessionId,
message,
queryParams,
});
}
export function chat_chat({
serverId,
sessionId,
message,
attachments,
queryParams,
clientId,
}: {
serverId: string;
sessionId: string;
message: string;
attachments: string[];
queryParams?: Record<string, any>;
clientId: string;
}): Promise<string> {
@@ -337,6 +330,7 @@ export function chat_chat({
serverId,
sessionId,
message,
attachments,
queryParams,
clientId,
});
@@ -391,10 +385,13 @@ export const upload_attachment = async (payload: UploadAttachmentPayload) => {
}
};
export const get_attachment = (payload: GetAttachmentPayload) => {
return invokeWithErrorHandler<GetAttachmentResponse>("get_attachment", {
...payload,
});
export const get_attachment_by_ids = (payload: GetAttachmentByIdsPayload) => {
return invokeWithErrorHandler<GetAttachmentResponse>(
"get_attachment_by_ids",
{
...payload,
}
);
};
export const delete_attachment = (payload: DeleteAttachmentPayload) => {
@@ -420,4 +417,4 @@ export const query_coco_fusion = (payload: {
export const get_app_search_source = () => {
return invokeWithErrorHandler<void>("get_app_search_source");
};
};

View File

@@ -34,4 +34,8 @@ export function show_check(): Promise<void> {
export function hide_check(): Promise<void> {
return invoke('hide_check');
}
export function toggle_move_to_active_space_attribute(): Promise<void> {
return invoke('toggle_move_to_active_space_attribute');
}

View File

@@ -57,8 +57,6 @@ export const AssistantFetcher = ({
let assistantList = response?.hits?.hits ?? [];
console.log("assistantList", assistantList);
if (
!currentAssistant?._id ||
currentService?.id !== lastServerId.current

View 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;

View File

@@ -43,8 +43,13 @@ interface ChatAIProps {
instanceId?: string;
}
export interface SendMessageParams {
message?: string;
attachments?: string[];
}
export interface ChatAIRef {
init: (value: string) => void;
init: (params: SendMessageParams) => void;
cancelChat: () => void;
clearChat: () => void;
}
@@ -188,7 +193,7 @@ const ChatAI = memo(
isDeepThinkActive,
isMCPActive,
changeInput,
showChatHistory,
showChatHistory
);
const { dealMsg } = useMessageHandler(
@@ -225,7 +230,7 @@ const ChatAI = memo(
}, [activeChat, chatClose]);
const init = useCallback(
async (value: string) => {
async (params: SendMessageParams) => {
try {
//console.log("init", curChatEnd, activeChat?._id);
if (!isCurrentLogin) {
@@ -237,9 +242,9 @@ const ChatAI = memo(
return;
}
if (!activeChat?._id) {
await createNewChat(value);
await createNewChat(params);
} else {
await handleSendMessage(value, activeChat);
await handleSendMessage(activeChat, params);
}
} catch (error) {
console.error("Failed to initialize chat:", error);
@@ -285,7 +290,10 @@ const ChatAI = memo(
if (updatedChats.length > 0) {
setActiveChat(updatedChats[0]);
} else {
init("");
init({
message: "",
attachments: [],
});
}
}
@@ -396,8 +404,8 @@ const ChatAI = memo(
loadingStep={loadingStep}
timedoutShow={timedoutShow}
Question={Question}
handleSendMessage={(value) =>
handleSendMessage(value, activeChat)
handleSendMessage={(message) =>
handleSendMessage(activeChat, { message })
}
getFileUrl={getFileUrl}
formatUrl={formatUrl}
@@ -410,7 +418,11 @@ const ChatAI = memo(
)}
{!activeChat?._id && !visibleStartPage && (
<PrevSuggestion sendMessage={init} />
<PrevSuggestion
sendMessage={(message) => {
init({ message });
}}
/>
)}
</div>
</>

View File

@@ -3,13 +3,14 @@ import { useTranslation } from "react-i18next";
import { ChatMessage } from "@/components/ChatMessage";
import { Greetings } from "./Greetings";
// import FileList from "@/components/Assistant/FileList";
import AttachmentList from "@/components/Assistant/AttachmentList";
import { useChatScroll } from "@/hooks/useChatScroll";
import { useChatStore } from "@/stores/chatStore";
import type { Chat, IChunkData } from "@/types/chat";
import { useConnectStore } from "@/stores/connectStore";
// import SessionFile from "./SessionFile";
import ScrollToBottom from "@/components/Common/ScrollToBottom";
import { useChatStore } from "@/stores/chatStore";
interface ChatContentProps {
activeChat?: Chat;
@@ -44,14 +45,12 @@ export const ChatContent = ({
handleSendMessage,
formatUrl,
}: ChatContentProps) => {
// const sessionId = useConnectStore((state) => state.currentSessionId);
const setCurrentSessionId = useConnectStore((state) => {
return state.setCurrentSessionId;
});
const { currentSessionId, setCurrentSessionId } = useConnectStore();
const { t } = useTranslation();
// const uploadFiles = useChatStore((state) => state.uploadFiles);
const { uploadAttachments } = useChatStore();
const messagesEndRef = useRef<HTMLDivElement>(null);
const { scrollToBottom } = useChatScroll(messagesEndRef);
@@ -168,13 +167,13 @@ export const ChatContent = ({
<div ref={messagesEndRef} />
</div>
{/* {uploadFiles.length > 0 && (
<div key={sessionId} className="max-h-[120px] overflow-auto p-2">
<FileList />
{uploadAttachments.length > 0 && (
<div key={currentSessionId} className="max-h-[120px] overflow-auto p-2">
<AttachmentList />
</div>
)} */}
)}
{/* {sessionId && <SessionFile sessionId={sessionId} />} */}
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
</div>

View File

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

View File

@@ -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;

View File

@@ -17,6 +17,8 @@ import { Server as IServer } from "@/types/server";
import StatusIndicator from "@/components/Cloud/StatusIndicator";
import { useAuthStore } from "@/stores/authStore";
import { useSearchStore } from "@/stores/searchStore";
import { useServers } from "@/hooks/useServers";
import { getCurrentWindowService, setCurrentWindowService } from "@/commands";
interface ServerListProps {
clearChat: () => void;
@@ -25,17 +27,20 @@ interface ServerListProps {
export function ServerList({ clearChat }: ServerListProps) {
const { t } = useTranslation();
const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
const setIsCurrentLogin = useAuthStore((state) => state.setIsCurrentLogin);
const serviceList = useShortcutsStore((state) => state.serviceList);
const serviceListShortcut = useShortcutsStore(
(state) => state.serviceListShortcut
);
const setEndpoint = useAppStore((state) => state.setEndpoint);
const setCurrentService = useConnectStore((state) => state.setCurrentService);
const isTauri = useAppStore((state) => state.isTauri);
const currentService = useConnectStore((state) => state.currentService);
const cloudSelectService = useConnectStore((state) => {
return state.cloudSelectService;
});
const { setMessages } = useChatStore();
const [serverList, setServerList] = useState<IServer[]>([]);
const [list, setList] = useState<IServer[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [highlightId, setHighlightId] = useState<string>("");
@@ -49,44 +54,49 @@ export function ServerList({ clearChat }: ServerListProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const serverListButtonRef = useRef<HTMLButtonElement>(null);
const fetchServers = useCallback(
async (resetSelection: boolean) => {
platformAdapter.commands("list_coco_servers").then((res: any) => {
console.log("list_coco_servers", res);
if (!Array.isArray(res)) {
// If res is not an array, it might be an error message or something else.
// Log it and don't proceed.
// console.log("list_coco_servers did not return an array:", res);
setServerList([]); // Clear the list or handle as appropriate
return;
}
const enabledServers = (res as IServer[])?.filter(
(server) => server.enabled && server.available
);
const { refreshServerList } = useServers();
const serverList = useConnectStore((state) => state.serverList);
setServerList(enabledServers);
const switchServer = async (server: IServer) => {
if (!server) return;
try {
// Switch UI first, then switch server connection
await setCurrentWindowService(server);
setEndpoint(server.endpoint);
setMessages(""); // Clear previous messages
clearChat();
//
if (!server.public && !server.profile) {
setIsCurrentLogin(false);
return;
}
//
setIsCurrentLogin(true);
} catch (error) {
console.error("switchServer:", error);
}
};
if (resetSelection && enabledServers.length > 0) {
const currentServiceExists = enabledServers.find(
(server) => server.id === currentService?.id
);
const fetchServers = useCallback(async () => {
const service = await getCurrentWindowService();
if (currentServiceExists) {
switchServer(currentServiceExists);
} else {
switchServer(enabledServers[enabledServers.length - 1]);
}
}
const enabledServers = serverList.filter(
(server) => server.enabled && server.available
);
setList(enabledServers);
if (enabledServers.length > 0) {
const serviceExists = enabledServers.find((server) => {
return server.id === service?.id;
});
},
[currentService?.id]
);
useEffect(() => {
if (!isTauri) return;
fetchServers(true);
}, [currentService?.enabled]);
if (serviceExists) {
switchServer(serviceExists);
} else {
switchServer(enabledServers[enabledServers.length - 1]);
}
}
}, [currentService?.id, cloudSelectService?.id, serverList]);
useEffect(() => {
if (!askAiServerId || serverList.length === 0) return;
@@ -104,25 +114,12 @@ export function ServerList({ clearChat }: ServerListProps) {
useEffect(() => {
if (!isTauri) return;
fetchServers(true);
const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => {
//console.log("Login or Logout:", currentService, event.payload);
if (event.payload !== isCurrentLogin) {
setIsCurrentLogin(!!event.payload);
}
fetchServers(true);
});
return () => {
// Cleanup logic if needed
unlisten.then((fn) => fn());
};
}, []);
fetchServers();
}, [serverList]);
const handleRefresh = async () => {
setIsRefreshing(true);
await fetchServers(false);
await refreshServerList();
setTimeout(() => setIsRefreshing(false), 1000);
};
@@ -130,29 +127,10 @@ export function ServerList({ clearChat }: ServerListProps) {
platformAdapter.emitEvent("open_settings", "connect");
};
const switchServer = async (server: IServer) => {
if (!server) return;
try {
// Switch UI first, then switch server connection
setCurrentService(server);
setEndpoint(server.endpoint);
setMessages(""); // Clear previous messages
clearChat();
//
if (!server.public && !server.profile) {
setIsCurrentLogin(false);
return;
}
//
setIsCurrentLogin(true);
} catch (error) {
console.error("switchServer:", error);
}
};
useKeyPress(
["uparrow", "downarrow", "enter"],
(event, key) => {
async (event, key) => {
const service = await getCurrentWindowService();
const isClose = isNil(serverListButtonRef.current?.dataset["open"]);
const length = serverList.length;
@@ -162,9 +140,7 @@ export function ServerList({ clearChat }: ServerListProps) {
event.preventDefault();
const currentIndex = serverList.findIndex((server) => {
return (
server.id === (highlightId === "" ? currentService?.id : highlightId)
);
return server.id === (highlightId === "" ? service?.id : highlightId);
});
let nextIndex = currentIndex;
@@ -197,7 +173,7 @@ export function ServerList({ clearChat }: ServerListProps) {
<Popover ref={popoverRef} className="relative">
<PopoverButton ref={serverListButtonRef} className="flex items-center">
<VisibleKey
shortcut={serviceList}
shortcut={serviceListShortcut}
onKeyPress={() => {
serverListButtonRef.current?.click();
}}
@@ -240,8 +216,8 @@ export function ServerList({ clearChat }: ServerListProps) {
</div>
</div>
<div className="space-y-1">
{serverList.length > 0 ? (
serverList.map((server) => (
{list.length > 0 ? (
list.map((server) => (
<div
key={server.id}
onClick={() => switchServer(server)}

View File

@@ -1,5 +1,4 @@
import clsx from "clsx";
import { filesize } from "filesize";
import { Files, Trash2, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -10,6 +9,7 @@ import { AttachmentHit } from "@/types/commands";
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
import FileIcon from "../Common/Icons/FileIcon";
import { filesize } from "@/utils";
interface SessionFileProps {
sessionId: string;
@@ -39,10 +39,13 @@ const SessionFile = (props: SessionFileProps) => {
if (isTauri) {
console.log("sessionId", sessionId);
const response: any = await platformAdapter.commands("get_attachment", {
serverId,
sessionId,
});
const response: any = await platformAdapter.commands(
"get_attachment_by_ids",
{
serverId,
sessionId,
}
);
setUploadedFiles(response?.hits?.hits ?? []);
} else {
@@ -145,9 +148,7 @@ const SessionFile = (props: SessionFileProps) => {
</div>
<div className="text-xs text-[#999]">
{icon && <span className="pr-2">{icon}</span>}
<span>
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
<span>{filesize(size)}</span>
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react";
import dayjs from "dayjs";
import durationPlugin from "dayjs/plugin/duration";
import { nanoid } from "nanoid";
import { useThemeStore } from "@/stores/themeStore";
import loadingLight from "@/assets/images/ReadAloud/loading-light.png";
@@ -18,7 +19,6 @@ import closeDark from "@/assets/images/ReadAloud/close-dark.png";
import { useConnectStore } from "@/stores/connectStore";
import platformAdapter from "@/utils/platformAdapter";
import { useStreamAudio } from "@/hooks/useStreamAudio";
import { nanoid } from "nanoid";
import { useChatStore } from "@/stores/chatStore";
dayjs.extend(durationPlugin);

View File

@@ -82,8 +82,6 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
}
);
console.log("response", response);
const text = response?.results
.flatMap((item: any) => item?.transcription?.transcripts)
.map((item: any) => item?.text?.replace(/<\|[\/\w]+\|>/g, ""))
@@ -161,7 +159,7 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
<>
<div
className={clsx(
"size-6 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition cursor-pointer",
"min-w-6 h-6 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition cursor-pointer",
{
hidden: state.audioDevices.length === 0,
}

View File

@@ -1,17 +1,28 @@
import { useState } from "react";
import { FC, useState } from "react";
import clsx from "clsx";
import { CopyButton } from "@/components/Common/CopyButton";
import { useAsyncEffect } from "ahooks";
import platformAdapter from "@/utils/platformAdapter";
import { useConnectStore } from "@/stores/connectStore";
import { AttachmentItem } from "../Assistant/AttachmentList";
import { useAppStore } from "@/stores/appStore";
interface UserMessageProps {
messageContent: string;
message: string;
attachments: string[];
}
export const UserMessage = ({ messageContent }: UserMessageProps) => {
export const UserMessage: FC<UserMessageProps> = (props) => {
const { message, attachments } = props;
const [showCopyButton, setShowCopyButton] = useState(false);
const { currentService } = useConnectStore();
const [attachmentData, setAttachmentData] = useState<any[]>([]);
const { addError } = useAppStore();
const handleDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
if (typeof window !== "undefined" && typeof document !== "undefined") {
const selection = window.getSelection();
const range = document.createRange();
@@ -21,31 +32,81 @@ export const UserMessage = ({ messageContent }: UserMessageProps) => {
selection.removeAllRanges();
selection.addRange(range);
} catch (error) {
console.error('Selection failed:', error);
console.error("Selection failed:", error);
}
}
}
};
useAsyncEffect(async () => {
try {
if (attachments.length === 0) return;
const result: any = await platformAdapter.commands(
"get_attachment_by_ids",
{
serverId: currentService.id,
attachments,
}
);
setAttachmentData(result?.hits?.hits);
} catch (error) {
addError(String(error));
}
}, [attachments]);
return (
<div
className="max-w-full flex gap-1 items-center justify-end"
onMouseEnter={() => setShowCopyButton(true)}
onMouseLeave={() => setShowCopyButton(false)}
>
<div
className={clsx("size-6 transition", {
"opacity-0": !showCopyButton,
})}
>
<CopyButton textToCopy={messageContent} />
</div>
<div
className="max-w-[85%] overflow-auto text-left px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer user-select-text whitespace-pre-wrap"
onDoubleClick={handleDoubleClick}
>
{messageContent}
</div>
</div>
<>
{message && (
<div
className="flex gap-1 items-center justify-end"
onMouseEnter={() => setShowCopyButton(true)}
onMouseLeave={() => setShowCopyButton(false)}
>
<div
className={clsx("size-6 transition", {
"opacity-0": !showCopyButton,
})}
>
<CopyButton textToCopy={message} />
</div>
<div
className="max-w-[85%] overflow-auto text-left px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer user-select-text whitespace-pre-wrap"
onDoubleClick={handleDoubleClick}
>
{message}
</div>
</div>
)}
{attachmentData && (
<div
className={clsx("flex justify-end flex-wrap gap-y-2 w-full", {
"mt-3": message,
})}
>
{attachmentData.map((item) => {
const { id, name, size, icon } = item._source;
return (
<AttachmentItem
{...item._source}
key={id}
uploading={false}
uploaded
id={id}
extname={icon}
attachmentId={id}
name={name}
path={name}
size={size}
deletable={false}
/>
);
})}
</div>
)}
</>
);
};

View File

@@ -89,6 +89,7 @@ export const ChatMessage = memo(function ChatMessage({
]);
const messageContent = message?._source?.message || "";
const attachments = message?._source?.attachments ?? [];
const details = message?._source?.details || [];
const question = message?._source?.question || "";
@@ -103,7 +104,7 @@ export const ChatMessage = memo(function ChatMessage({
const renderContent = () => {
if (!isAssistant) {
return <UserMessage messageContent={messageContent} />;
return <UserMessage message={messageContent} attachments={attachments} />;
}
return (

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { emit } from "@tauri-apps/api/event";
import { DataSourcesList } from "./DataSourcesList";
import { Sidebar } from "./Sidebar";
@@ -9,6 +8,8 @@ import { useConnectStore } from "@/stores/connectStore";
import ServiceInfo from "./ServiceInfo";
import ServiceAuth from "./ServiceAuth";
import platformAdapter from "@/utils/platformAdapter";
import type { Server } from "@/types/server";
import { useServers } from "@/hooks/useServers";
export default function Cloud() {
const SidebarRef = useRef<{ refreshData: () => void }>(null);
@@ -17,100 +18,63 @@ export default function Cloud() {
const [isConnect, setIsConnect] = useState(true);
const { currentService, setCurrentService, serverList, setServerList } =
useConnectStore();
const {
cloudSelectService,
setCloudSelectService,
serverList,
setServerList,
} = useConnectStore();
const [refreshLoading, setRefreshLoading] = useState(false);
const { addServer, refreshServerList } = useServers();
// fetch the servers
useEffect(() => {
fetchServers(true);
}, []);
fetchServers();
}, [serverList]);
useEffect(() => {
// console.log("currentService", currentService);
setRefreshLoading(false);
setIsConnect(true);
}, [JSON.stringify(currentService)]);
}, [cloudSelectService?.id]);
const fetchServers = async (resetSelection: boolean) => {
platformAdapter
.commands("list_coco_servers")
.then((res: any) => {
if (errors.length > 0) {
res = (res || []).map((item: any) => {
if (item.id === currentService?.id) {
item.health = {
services: null,
status: null,
};
}
return item;
});
const fetchServers = useCallback(async () => {
let res = serverList;
if (errors.length > 0) {
res = res.map((item: Server) => {
if (item.id === cloudSelectService?.id) {
item.health = {
services: item.health?.services || {},
status: item.health?.status || "red",
};
}
console.log("list_coco_servers", res);
setServerList(res);
if (resetSelection && res.length > 0) {
const matched = res.find((server: any) => {
return server.id === currentService?.id;
});
if (matched) {
setCurrentService(matched);
} else {
setCurrentService(res[res.length - 1]);
}
}
})
};
const addServer = (endpointLink: string) => {
if (!endpointLink) {
throw new Error("Endpoint is required");
}
if (
!endpointLink.startsWith("http://") &&
!endpointLink.startsWith("https://")
) {
throw new Error("Invalid Endpoint");
}
setRefreshLoading(true);
return platformAdapter
.commands("add_coco_server", endpointLink)
.then((res: any) => {
// console.log("add_coco_server", res);
fetchServers(false).then((r) => {
console.log("fetchServers", r);
setCurrentService(res);
});
})
.finally(() => {
setRefreshLoading(false);
return item;
});
};
}
setServerList(res);
if (res.length > 0) {
const matched = res.find((server: any) => {
return server.id === cloudSelectService?.id;
});
if (matched) {
setCloudSelectService(matched);
} else {
setCloudSelectService(res[res.length - 1]);
}
}
}, [serverList, errors, cloudSelectService]);
const refreshClick = useCallback(
(id: string) => {
async (id: string) => {
setRefreshLoading(true);
platformAdapter
.commands("refresh_coco_server_info", id)
.then((res: any) => {
console.log("refresh_coco_server_info", id, res);
fetchServers(false).then((r) => {
console.log("fetchServers", r);
});
// update currentService
setCurrentService(res);
emit("login_or_logout", true);
})
.finally(() => {
setRefreshLoading(false);
});
await platformAdapter.commands("refresh_coco_server_info", id);
await refreshServerList();
setRefreshLoading(false);
},
[fetchServers]
[refreshServerList]
);
return (
@@ -127,7 +91,6 @@ export default function Cloud() {
<ServiceInfo
refreshLoading={refreshLoading}
refreshClick={refreshClick}
fetchServers={fetchServers}
/>
<ServiceAuth
@@ -135,8 +98,8 @@ export default function Cloud() {
refreshClick={refreshClick}
/>
{currentService?.profile && currentService?.available ? (
<DataSourcesList server={currentService?.id} />
{cloudSelectService?.profile && cloudSelectService?.available ? (
<DataSourcesList server={cloudSelectService?.id} />
) : null}
</div>
) : (

View File

@@ -21,7 +21,7 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
};
const onAddServerClick = async (endpoint: string) => {
console.log("onAddServer", endpoint);
//console.log("onAddServer", endpoint);
await onAddServer(endpoint);
setIsConnect(true);
};

View File

@@ -20,7 +20,6 @@ export function DataSourcesList({ server }: { server: string }) {
platformAdapter
.commands("get_connectors_by_server", server)
.then((res: any) => {
// console.log("get_connectors_by_server", res);
setConnectorData(res, server);
})
.finally(() => {});
@@ -29,7 +28,6 @@ export function DataSourcesList({ server }: { server: string }) {
platformAdapter
.commands("datasource_search", { id: server })
.then((res: any) => {
// console.log("datasource_search", res);
setDatasourceData(res, server);
})
.finally(() => {

View File

@@ -14,7 +14,7 @@ import { useConnectStore } from "@/stores/connectStore";
import { useAppStore } from "@/stores/appStore";
import { copyToClipboard } from "@/utils";
import platformAdapter from "@/utils/platformAdapter";
import { handleLogout } from "@/commands/servers";
import { useServers } from "@/hooks/useServers";
interface ServiceAuthProps {
setRefreshLoading: (loading: boolean) => void;
@@ -30,7 +30,9 @@ const ServiceAuth = memo(
const addError = useAppStore((state) => state.addError);
const currentService = useConnectStore((state) => state.currentService);
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
const { logoutServer } = useServers();
const [loading, setLoading] = useState(false);
@@ -41,7 +43,7 @@ const ServiceAuth = memo(
setSSORequestID(requestID);
// Generate the login URL with the current appUid
const url = `${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${requestID}`;
const url = `${cloudSelectService?.auth_provider?.sso?.url}/?provider=${cloudSelectService?.id}&product=coco&request_id=${requestID}`;
console.log("Open SSO link, requestID:", ssoRequestID, url);
@@ -50,20 +52,17 @@ const ServiceAuth = memo(
// Start loading state
setLoading(true);
}, [ssoRequestID, loading, currentService]);
}, [ssoRequestID, loading, cloudSelectService]);
const onLogout = useCallback((id: string) => {
setRefreshLoading(true);
platformAdapter
.commands("logout_coco_server", id)
.then((res: any) => {
console.log("logout_coco_server", id, JSON.stringify(res));
handleLogout(id);
})
.finally(() => {
const onLogout = useCallback(
(id: string) => {
setRefreshLoading(true);
logoutServer(id).finally(() => {
setRefreshLoading(false);
});
}, []);
},
[logoutServer]
);
const handleOAuthCallback = useCallback(
async (code: string | null, serverId: string | null) => {
@@ -109,7 +108,7 @@ const ServiceAuth = memo(
return;
}
const serverId = currentService?.id;
const serverId = cloudSelectService?.id;
handleOAuthCallback(code, serverId);
} catch (err) {
console.error("Failed to parse URL:", err);
@@ -162,9 +161,9 @@ const ServiceAuth = memo(
useEffect(() => {
setLoading(false);
}, [currentService]);
}, [cloudSelectService]);
if (!currentService?.auth_provider?.sso?.url) {
if (!cloudSelectService?.auth_provider?.sso?.url) {
return null;
}
@@ -173,10 +172,10 @@ const ServiceAuth = memo(
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
{t("cloud.accountInfo")}
</h2>
{currentService?.profile ? (
{cloudSelectService?.profile ? (
<UserProfile
server={currentService?.id}
userInfo={currentService?.profile}
server={cloudSelectService?.id}
userInfo={cloudSelectService?.profile}
onLogout={onLogout}
/>
) : (
@@ -190,7 +189,7 @@ const ServiceAuth = memo(
onCancel={() => setLoading(false)}
onCopy={() => {
copyToClipboard(
`${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${ssoRequestID}`
`${cloudSelectService?.auth_provider?.sso?.url}/?provider=${cloudSelectService?.id}&product=coco&request_id=${ssoRequestID}`
);
}}
/>
@@ -201,7 +200,7 @@ const ServiceAuth = memo(
<button
className="text-xs text-[#0096FB] dark:text-blue-400 block"
onClick={() =>
OpenURLWithBrowser(currentService?.provider?.eula)
OpenURLWithBrowser(cloudSelectService?.provider?.eula)
}
>
{t("cloud.eula")}
@@ -215,7 +214,7 @@ const ServiceAuth = memo(
<button
className="text-xs text-[#0096FB] dark:text-blue-400 block"
onClick={() =>
OpenURLWithBrowser(currentService?.provider?.privacy_policy)
OpenURLWithBrowser(cloudSelectService?.provider?.privacy_policy)
}
>
{t("cloud.privacyPolicy")}

View File

@@ -6,13 +6,13 @@ import { useConnectStore } from "@/stores/connectStore";
interface ServiceBannerProps {}
const ServiceBanner = memo(({}: ServiceBannerProps) => {
const currentService = useConnectStore((state) => state.currentService);
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
return (
<div className="w-full rounded-[4px] bg-[rgba(229,229,229,1)] dark:bg-gray-800 mb-6">
<img
width="100%"
src={currentService?.provider?.banner || bannerImg}
src={cloudSelectService?.provider?.banner || bannerImg}
alt="banner"
onError={(e) => {
const target = e.target as HTMLImageElement;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback } from "react";
import { memo } from "react";
import { Globe, RefreshCcw, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
@@ -7,90 +7,64 @@ import Tooltip from "@/components/Common/Tooltip";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import { OpenURLWithBrowser } from "@/utils";
import { useConnectStore } from "@/stores/connectStore";
import platformAdapter from "@/utils/platformAdapter";
import { useServers } from "@/hooks/useServers";
interface ServiceHeaderProps {
refreshLoading?: boolean;
refreshClick: (id: string) => void;
fetchServers: (force: boolean) => Promise<void>;
}
const ServiceHeader = memo(
({ refreshLoading, refreshClick, fetchServers }: ServiceHeaderProps) => {
({ refreshLoading, refreshClick }: ServiceHeaderProps) => {
const { t } = useTranslation();
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore(
(state) => state.setCurrentService
);
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
const enable_coco_server = useCallback(
async (enabled: boolean) => {
if (enabled) {
await platformAdapter.commands("enable_server", currentService?.id);
} else {
await platformAdapter.commands("disable_server", currentService?.id);
}
setCurrentService({ ...currentService, enabled });
await fetchServers(false);
},
[currentService?.id]
);
const removeServer = (id: string) => {
platformAdapter.commands("remove_coco_server", id).then((res: any) => {
console.log("remove_coco_server", id, JSON.stringify(res));
fetchServers(true).then((r) => {
console.log("fetchServers", r);
});
});
};
const { enableServer, removeServer } = useServers();
return (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<Tooltip content={currentService?.endpoint}>
<Tooltip content={cloudSelectService?.endpoint}>
<div className="flex items-center text-gray-900 dark:text-white font-medium cursor-pointer">
{currentService?.name}
{cloudSelectService?.name}
</div>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<SettingsToggle
checked={currentService?.enabled}
checked={cloudSelectService?.enabled}
className={clsx({
"bg-red-600 focus:ring-red-500": !currentService?.enabled,
"bg-red-600 focus:ring-red-500": !cloudSelectService?.enabled,
})}
label={
currentService?.enabled
cloudSelectService?.enabled
? t("cloud.enable_server")
: t("cloud.disable_server")
}
onChange={enable_coco_server}
onChange={enableServer}
/>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() =>
OpenURLWithBrowser(currentService?.provider?.website)
OpenURLWithBrowser(cloudSelectService?.provider?.website)
}
>
<Globe className="w-3.5 h-3.5" />
</button>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => refreshClick(currentService?.id)}
onClick={() => refreshClick(cloudSelectService?.id)}
>
<RefreshCcw
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""}`}
/>
</button>
{!currentService?.builtin && (
{!cloudSelectService?.builtin && (
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => removeServer(currentService?.id)}
onClick={() => removeServer(cloudSelectService?.id)}
>
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
</button>

View File

@@ -7,11 +7,10 @@ import ServiceMetadata from "./ServiceMetadata";
interface ServiceInfoProps {
refreshLoading?: boolean;
refreshClick: (id: string) => void;
fetchServers: (force: boolean) => Promise<void>;
}
const ServiceInfo = memo(
({ refreshLoading, refreshClick, fetchServers }: ServiceInfoProps) => {
({ refreshLoading, refreshClick }: ServiceInfoProps) => {
return (
<>
<ServiceBanner />
@@ -19,7 +18,6 @@ const ServiceInfo = memo(
<ServiceHeader
refreshLoading={refreshLoading}
refreshClick={refreshClick}
fetchServers={fetchServers}
/>
<ServiceMetadata />

View File

@@ -6,25 +6,25 @@ import { useConnectStore } from "@/stores/connectStore";
interface ServiceMetadataProps {}
const ServiceMetadata = memo(({}: ServiceMetadataProps) => {
const currentService = useConnectStore((state) => state.currentService);
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
return (
<div className="mb-8">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2 flex">
<span className="flex items-center gap-1">
<PackageOpen className="w-4 h-4" /> {currentService?.provider?.name}
<PackageOpen className="w-4 h-4" /> {cloudSelectService?.provider?.name}
</span>
<span className="mx-4">|</span>
<span className="flex items-center gap-1">
<GitFork className="w-4 h-4" /> {currentService?.version?.number}
<GitFork className="w-4 h-4" /> {cloudSelectService?.version?.number}
</span>
<span className="mx-4">|</span>
<span className="flex items-center gap-1">
<CalendarSync className="w-4 h-4" /> {currentService?.updated}
<CalendarSync className="w-4 h-4" /> {cloudSelectService?.updated}
</span>
</div>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
{currentService?.provider?.description}
{cloudSelectService?.provider?.description}
</p>
</div>
);

View File

@@ -20,13 +20,15 @@ interface ServerGroups {
export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
({ setIsConnect, serverList }, _ref) => {
const { t } = useTranslation();
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore(
(state) => state.setCurrentService
);
const cloudSelectService = useConnectStore((state) => {
return state.cloudSelectService;
});
const setCloudSelectService = useConnectStore((state) => {
return state.setCloudSelectService;
});
const selectService = (item: Server) => {
setCurrentService(item);
setCloudSelectService(item);
setIsConnect(true);
};
@@ -41,7 +43,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
// Extracted server item rendering
const renderServerItem = useCallback(
(item: Server) => {
const isSelected = currentService?.id === item.id;
const isSelected = cloudSelectService?.id === item.id;
return (
<div
key={item.id}
@@ -72,7 +74,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
</div>
);
},
[currentService]
[cloudSelectService]
);
const { builtinServers, customServers } = useMemo(() => {

View File

@@ -12,7 +12,6 @@ interface UserProfileProps {
export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
const handleLogout = () => {
onLogout(server);
console.log("Logout", server);
};
const [imageLoadError, setImageLoadError] = useState(false);

View File

@@ -18,7 +18,9 @@ const FileIcon: FC<FileIconProps> = (props) => {
.then(setIconName);
});
return <FontIcon name={iconName} className={twMerge("size-8", className)} />;
return (
<FontIcon name={iconName} className={twMerge("min-w-8 h-8", className)} />
);
};
export default FileIcon;

View File

@@ -1,5 +1,3 @@
import { useAppStore } from "@/stores/appStore";
import logoImg from "@/assets/icon.svg";
import { FC } from "react";
interface FontIconProps {
@@ -9,17 +7,11 @@ interface FontIconProps {
}
const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => {
const isTauri = useAppStore((state) => state.isTauri);
if (isTauri) {
return (
<svg className={`icon ${className || ""}`} style={style} {...rest}>
<use xlinkHref={`#${name}`} />
</svg>
);
} else {
return <img src={logoImg} className={className} alt={"coco"} />;
}
return (
<svg className={`icon ${className || ""}`} style={style} {...rest}>
<use xlinkHref={`#${name}`} />
</svg>
);
};
export default FontIcon;

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { ArrowDown01, CornerDownLeft } from "lucide-react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
@@ -19,6 +19,7 @@ import source_default_dark_img from "@/assets/images/source_default_dark.png";
import { useThemeStore } from "@/stores/themeStore";
import platformAdapter from "@/utils/platformAdapter";
import FontIcon from "../Icons/FontIcon";
import { useTogglePin } from "@/hooks/useTogglePin";
interface FooterProps {
setIsPinnedWeb?: (value: boolean) => void;
@@ -37,32 +38,24 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
const isDark = useThemeStore((state) => state.isDark);
const { isTauri, isPinned, setIsPinned } = useAppStore();
const { isTauri } = useAppStore();
const { setVisible, updateInfo } = useUpdateStore();
const { isPinned, togglePin } = useTogglePin({
onPinChange: setIsPinnedWeb,
});
const { setVisible, updateInfo, skipVersions } = useUpdateStore();
const { fixedWindow, modifierKey } = useShortcutsStore();
const setWindowAlwaysOnTop = useCallback(async (isPinned: boolean) => {
setIsPinnedWeb?.(isPinned);
return platformAdapter.setAlwaysOnTop(isPinned);
}, []);
const togglePin = async () => {
try {
const newPinned = !isPinned;
await setWindowAlwaysOnTop(newPinned);
setIsPinned(newPinned);
} catch (err) {
console.error("Failed to toggle window pin state:", err);
setIsPinned(isPinned);
}
};
const openSetting = useCallback(() => {
return platformAdapter.emitEvent("open_settings", "");
}, []);
const hasUpdate = useMemo(() => {
return updateInfo && !skipVersions.includes(updateInfo.version);
}, [updateInfo, skipVersions]);
const renderLeft = () => {
if (sourceData?.source?.name) {
return (
@@ -108,7 +101,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
/>
<div className="relative text-xs text-gray-500 dark:text-gray-400">
{updateInfo?.available ? (
{hasUpdate ? (
<div className="cursor-pointer" onClick={() => setVisible(true)}>
<span>{t("search.footer.updateAvailable")}</span>
<span className="absolute top-0 -right-2 size-1.5 bg-[#FF3434] rounded-full"></span>
@@ -138,7 +131,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
onClick={togglePin}
className={clsx({
"text-blue-500": isPinned,
"pl-2": updateInfo?.available,
"pl-2": hasUpdate,
})}
>
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>

View File

@@ -78,6 +78,10 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
return "→";
}
if (shortcut === "enter") {
return "↩︎";
}
return shortcut;
};

View File

@@ -135,12 +135,13 @@ export function useAssistantManager({
}
if (key === "Enter" && !shiftKey) {
if (!isEmpty(value)) {
e.stopPropagation();
}
e.preventDefault();
if (isTauri && !isChatMode && goAskAi) {
if (!isEmpty(value)) {
e.stopPropagation();
}
return handleAskAi();
}

View File

@@ -2,18 +2,21 @@ import React from "react";
import { Send } from "lucide-react";
import StopIcon from "@/icons/Stop";
import clsx from "clsx";
import { SendMessageParams } from "../Assistant/Chat";
import { getUploadedAttachmentsId, isAttachmentsUploaded } from "@/utils";
import VisibleKey from "../Common/VisibleKey";
interface ChatIconsProps {
lineCount: number;
isChatMode: boolean;
curChatEnd: boolean;
inputValue: string;
onSend: (value: string) => void;
onSend: (params: SendMessageParams) => void;
disabledChange: () => void;
}
const ChatIcons: React.FC<ChatIconsProps> = ({
lineCount,
isChatMode,
curChatEnd,
inputValue,
@@ -21,50 +24,48 @@ const ChatIcons: React.FC<ChatIconsProps> = ({
disabledChange,
}) => {
const renderSendButton = () => {
if (!isChatMode) return null;
if (!isChatMode) return;
if (curChatEnd) {
return (
<button
className={`ml-1 p-1 ${
inputValue ? "bg-[#0072FF]" : "bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]"
} rounded-full transition-colors h-6`}
className={clsx(
"flex items-center justify-center rounded-full transition-colors min-w-6 h-6 bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]",
{
"!bg-[#0072FF]": inputValue || isAttachmentsUploaded(),
}
)}
type="submit"
onClick={() => onSend(inputValue.trim())}
onClick={() => {
onSend({
message: inputValue.trim(),
attachments: getUploadedAttachmentsId(),
});
}}
>
<Send className="w-4 h-4 text-white" />
<VisibleKey shortcut="enter">
<Send className="size-[14px] text-white" />
</VisibleKey>
</button>
);
}
if (!curChatEnd) {
return (
<button
className={`ml-1 px-1 bg-[#0072FF] rounded-full transition-colors`}
type="submit"
onClick={() => disabledChange()}
>
<StopIcon
size={16}
className="w-4 h-4 text-white"
aria-label="Stop message"
/>
</button>
);
}
return null;
return (
<button
className={`ml-1 px-1 bg-[#0072FF] rounded-full transition-colors`}
type="submit"
onClick={() => disabledChange()}
>
<StopIcon
size={16}
className="w-4 h-4 text-white"
aria-label="Stop message"
/>
</button>
);
};
return (
<>
{lineCount === 1 ? (
renderSendButton()
) : (
<div className="w-full flex justify-end mt-1">{renderSendButton()}</div>
)}
</>
);
return renderSendButton();
};
export default ChatIcons;

View File

@@ -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>
);
}

View File

@@ -124,7 +124,6 @@ export const DocumentList: React.FC<DocumentListProps> = ({
}
}
console.log("_docs", from, queryStrings, response);
const list = response?.hits ?? [];
const allTotal = response?.total_hits ?? 0;
// set first select hover

View File

@@ -18,11 +18,13 @@ import { useAssistantManager } from "./AssistantManager";
import InputControls from "./InputControls";
import { useExtensionsStore } from "@/stores/extensionsStore";
import AudioRecording from "../AudioRecording";
import { isDefaultServer } from "@/utils";
import { getUploadedAttachmentsId, isDefaultServer } from "@/utils";
import { useTauriFocus } from "@/hooks/useTauriFocus";
import { SendMessageParams } from "../Assistant/Chat";
import { isEmpty } from "lodash-es";
interface ChatInputProps {
onSend: (message: string) => void;
onSend: (params: SendMessageParams) => void;
disabled: boolean;
disabledChange: () => void;
changeMode?: (isChatMode: boolean) => void;
@@ -84,18 +86,13 @@ export default function ChatInput({
}: ChatInputProps) {
const { t } = useTranslation();
const currentAssistant = useConnectStore((state) => state.currentAssistant);
const setBlurred = useAppStore((state) => state.setBlurred);
const isTauri = useAppStore((state) => state.isTauri);
const { currentAssistant } = useConnectStore();
const { sourceData, goAskAi } = useSearchStore();
const { modifierKey, returnToInput, setModifierKeyPressed } =
useShortcutsStore();
const language = useAppStore((state) => {
return state.language;
});
const { isTauri, language, setBlurred } = useAppStore();
useEffect(() => {
return () => {
@@ -108,6 +105,7 @@ export default function ChatInput({
const { curChatEnd } = useChatStore();
const { setSearchValue, visibleExtensionStore, selectedExtension } =
useSearchStore();
const { uploadAttachments } = useChatStore();
useTauriFocus({
onFocus() {
@@ -122,12 +120,17 @@ export default function ChatInput({
const handleSubmit = useCallback(() => {
const trimmedValue = inputValue.trim();
console.log("handleSubmit", trimmedValue, disabled);
if (trimmedValue && !disabled) {
if ((trimmedValue || !isEmpty(uploadAttachments)) && !disabled) {
changeInput("");
onSend(trimmedValue);
onSend({
message: trimmedValue,
attachments: getUploadedAttachmentsId(),
});
}
}, [inputValue, disabled, onSend]);
}, [inputValue, disabled, onSend, uploadAttachments]);
useKeyboardHandlers();
@@ -138,7 +141,7 @@ export default function ChatInput({
changeInput(value);
setSearchValue(value);
if (!isChatMode) {
onSend(value);
onSend({ message: value });
}
},
[changeInput, isChatMode, onSend]
@@ -289,16 +292,6 @@ export default function ChatInput({
}}
/>
)}
{isChatMode && curChatEnd && (
<div
className={`absolute ${
lineCount === 1 ? "-top-[5px]" : "top-[calc(100%-30px)]"
} right-[12px]`}
>
<VisibleKey shortcut="↩︎" />
</div>
)}
</div>
);

View File

@@ -16,8 +16,7 @@ import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { parseSearchQuery, SearchQuery } from "@/utils";
// import InputUpload from "./InputUpload";
// import AiSummaryIcon from "@/components/Common/Icons/AiSummaryIcon";
import InputUpload from "./InputUpload";
interface InputControlsProps {
isChatMode: boolean;
@@ -56,16 +55,16 @@ const InputControls = ({
isChatPage,
hasModules,
changeMode,
}: // checkScreenPermission,
// requestScreenPermission,
// getScreenMonitors,
// getScreenWindows,
// captureWindowScreenshot,
// captureMonitorScreenshot,
// openFileDialog,
// getFileMetadata,
// getFileIcon,
InputControlsProps) => {
checkScreenPermission,
requestScreenPermission,
getScreenMonitors,
getScreenWindows,
captureWindowScreenshot,
captureMonitorScreenshot,
openFileDialog,
getFileMetadata,
getFileIcon,
}: InputControlsProps) => {
const { t } = useTranslation();
const isTauri = useAppStore((state) => state.isTauri);
@@ -171,22 +170,24 @@ InputControlsProps) => {
>
{isChatMode ? (
<div className="flex gap-2 text-[12px] leading-3 text-[#333] dark:text-[#d8d8d8]">
{/* <InputUpload
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
getScreenWindows={getScreenWindows}
captureMonitorScreenshot={captureMonitorScreenshot}
captureWindowScreenshot={captureWindowScreenshot}
openFileDialog={openFileDialog}
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
/> */}
{source?.upload?.enabled && (
<InputUpload
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
getScreenWindows={getScreenWindows}
captureMonitorScreenshot={captureMonitorScreenshot}
captureWindowScreenshot={captureWindowScreenshot}
openFileDialog={openFileDialog}
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
/>
)}
{source?.type === "deep_think" && source?.config?.visible && (
<button
className={clsx(
"flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
"flex items-center justify-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
{
"!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive,
}
@@ -231,7 +232,8 @@ InputControlsProps) => {
getMCPByServer={getMCPByServer}
/>
{!(source?.datasource?.enabled && source?.datasource?.visible) &&
{!source?.upload?.enabled &&
!(source?.datasource?.enabled && source?.datasource?.visible) &&
(source?.type !== "deep_think" || !source?.config?.visible) &&
!(source?.mcp_servers?.enabled && source?.mcp_servers?.visible) && (
<div className="px-[9px]">

View File

@@ -1,4 +1,4 @@
import { FC, Fragment, MouseEvent } from "react";
import { FC, Fragment, MouseEvent, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, Plus } from "lucide-react";
import {
@@ -12,13 +12,15 @@ import {
} from "@headlessui/react";
import { castArray, find, isNil } from "lodash-es";
import { nanoid } from "nanoid";
import { useCreation, useKeyPress, useMount, useReactive } from "ahooks";
import { useCreation, useMount, useReactive } from "ahooks";
import { useChatStore } from "@/stores/chatStore";
import { useAppStore } from "@/stores/appStore";
import Tooltip from "@/components/Common/Tooltip";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import clsx from "clsx";
import { useConnectStore } from "@/stores/connectStore";
import { filesize } from "@/utils";
import VisibleKey from "../Common/VisibleKey";
interface State {
screenRecordingPermission?: boolean;
@@ -61,9 +63,26 @@ const InputUpload: FC<InputUploadProps> = (props) => {
getFileMetadata,
} = props;
const { t, i18n } = useTranslation();
const { uploadFiles, setUploadFiles } = useChatStore();
const { uploadAttachments, setUploadAttachments } = useChatStore();
const { withVisibility, addError } = useAppStore();
const { modifierKey, addFile, modifierKeyPressed } = useShortcutsStore();
const { addFile } = useShortcutsStore();
const { currentAssistant } = useConnectStore();
const uploadMaxSizeRef = useRef(1024 * 1024);
const uploadMaxCountRef = useRef(6);
const setVisibleStartPage = useConnectStore((state) => {
return state.setVisibleStartPage;
});
useEffect(() => {
if (!currentAssistant?._source?.upload) return;
const { max_file_size_in_bytes, max_file_count } =
currentAssistant._source.upload;
uploadMaxSizeRef.current = max_file_size_in_bytes;
uploadMaxCountRef.current = max_file_count;
}, [currentAssistant]);
const state = useReactive<State>({
screenshotableMonitors: [],
@@ -83,19 +102,25 @@ const InputUpload: FC<InputUploadProps> = (props) => {
if (isNil(selectedFiles)) return;
setVisibleStartPage(false);
handleUploadFiles(selectedFiles);
};
const handleUploadFiles = async (paths: string | string[]) => {
const files: typeof uploadFiles = [];
const files: typeof uploadAttachments = [];
for await (const path of castArray(paths)) {
if (find(uploadFiles, { path })) continue;
if (find(uploadAttachments, { path })) continue;
const stat = await getFileMetadata(path);
if (stat.size / 1024 / 1024 > 100) {
addError(t("search.input.uploadFileHints.maxSize"));
if (stat.size > uploadMaxSizeRef.current) {
addError(
t("search.input.uploadFileHints.maxSize", {
replace: [filesize(uploadMaxSizeRef.current)],
})
);
continue;
}
@@ -107,7 +132,7 @@ const InputUpload: FC<InputUploadProps> = (props) => {
});
}
setUploadFiles([...uploadFiles, ...files]);
setUploadAttachments([...uploadAttachments, ...files]);
};
const menuItems = useCreation<MenuItem[]>(() => {
@@ -172,28 +197,20 @@ const InputUpload: FC<InputUploadProps> = (props) => {
i18n.language,
]);
useKeyPress(`${modifierKey}.${addFile}`, handleSelectFile);
return (
<Menu>
<MenuButton className="flex p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<Tooltip content={t("search.input.uploadFileHints.tooltip")}>
<Plus
className={clsx("size-3 scale-[1.3]", {
hidden: modifierKeyPressed,
})}
/>
<div
className={clsx(
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]",
{
hidden: !modifierKeyPressed,
}
)}
>
{addFile}
</div>
<MenuButton className="flex items-center justify-center h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<Tooltip
content={t("search.input.uploadFileHints.tooltip", {
replace: [
uploadMaxCountRef.current,
filesize(uploadMaxSizeRef.current),
],
})}
>
<VisibleKey shortcut={addFile} onKeyPress={handleSelectFile}>
<Plus className="size-3 scale-[1.3]" />
</VisibleKey>
</Tooltip>
</MenuButton>

View File

@@ -166,7 +166,7 @@ export default function MCPPopover({
return (
<div
className={clsx(
"flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
{
"!bg-[rgba(0,114,255,0.3)]": isMCPActive,
}

View File

@@ -172,7 +172,7 @@ export default function SearchPopover({
return (
<div
className={clsx(
"flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
{
"!bg-[rgba(0,114,255,0.3)]": isSearchActive,
}

View File

@@ -13,7 +13,10 @@ import { useMount } from "ahooks";
import Search from "@/components/Search/Search";
import InputBox from "@/components/Search/InputBox";
import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat";
import ChatAI, {
ChatAIRef,
SendMessageParams,
} from "@/components/Assistant/Chat";
import { isLinux, isWin } from "@/utils/platform";
import { appReducer, initialAppState } from "@/reducers/appReducer";
import { useWindowEvents } from "@/hooks/useWindowEvents";
@@ -25,6 +28,7 @@ import { useThemeStore } from "@/stores/themeStore";
import { useConnectStore } from "@/stores/connectStore";
import { useAppearanceStore } from "@/stores/appearanceStore";
import type { StartPage } from "@/types/chat";
import { hasUploadingAttachment } from "@/utils";
interface SearchChatProps {
isTauri?: boolean;
@@ -125,7 +129,9 @@ function SearchChat({
useEffect(() => {
const init = async () => {
await initializeListeners_auth();
await platformAdapter.commands("get_app_search_source");
if (isTauri) {
await platformAdapter.commands("get_app_search_source");
}
};
init();
@@ -146,10 +152,12 @@ function SearchChat({
}, []);
const handleSendMessage = useCallback(
async (value: string) => {
dispatch({ type: "SET_INPUT", payload: value });
async (params: SendMessageParams) => {
if (hasUploadingAttachment()) return;
dispatch({ type: "SET_INPUT", payload: params?.message ?? "" });
if (isChatMode) {
chatAIRef.current?.init(value);
chatAIRef.current?.init(params);
}
},
[isChatMode]

View File

@@ -1,6 +1,8 @@
import { useTranslation } from "react-i18next";
import { Command, RotateCcw } from "lucide-react";
import { useEffect } from "react";
import { Button } from "@headlessui/react";
import clsx from "clsx";
import { formatKey } from "@/utils/keyboardUtils";
import SettingsItem from "@/components/Settings/SettingsItem";
@@ -9,7 +11,7 @@ import {
INITIAL_MODE_SWITCH,
INITIAL_RETURN_TO_INPUT,
// INITIAL_VOICE_INPUT,
// INITIAL_ADD_FILE,
INITIAL_ADD_FILE,
INITIAL_DEEP_THINKING,
INITIAL_INTERNET_SEARCH,
INITIAL_INTERNET_SEARCH_SCOPE,
@@ -28,8 +30,6 @@ import { ModifierKey } from "@/types/index";
import platformAdapter from "@/utils/platformAdapter";
import { useAppStore } from "@/stores/appStore";
import SettingsInput from "@/components/Settings/SettingsInput";
import { Button } from "@headlessui/react";
import clsx from "clsx";
export const modifierKeys: ModifierKey[] = isMac
? ["meta", "ctrl"]
@@ -46,8 +46,8 @@ const Shortcuts = () => {
setReturnToInput,
// voiceInput,
// setVoiceInput,
// addFile,
// setAddFile,
addFile,
setAddFile,
deepThinking,
setDeepThinking,
internetSearch,
@@ -66,8 +66,8 @@ const Shortcuts = () => {
setNewSession,
fixedWindow,
setFixedWindow,
serviceList,
setServiceList,
serviceListShortcut,
setServiceListShortcut,
external,
setExternal,
aiOverview,
@@ -106,15 +106,13 @@ const Shortcuts = () => {
// value: voiceInput,
// setValue: setVoiceInput,
// },
// {
// title: "settings.advanced.shortcuts.addFile.title",
// description: "settings.advanced.shortcuts.addFile.description",
// value: addFile,
// setValue: setAddFile,
// reset: () => {
// handleChange(INITIAL_ADD_FILE, setAddFile);
// },
// },
{
title: "settings.advanced.shortcuts.addFile.title",
description: "settings.advanced.shortcuts.addFile.description",
initialValue: INITIAL_ADD_FILE,
value: addFile,
setValue: setAddFile,
},
{
title: "settings.advanced.shortcuts.deepThinking.title",
description: "settings.advanced.shortcuts.deepThinking.description",
@@ -183,8 +181,8 @@ const Shortcuts = () => {
title: "settings.advanced.shortcuts.serviceList.title",
description: "settings.advanced.shortcuts.serviceList.description",
initialValue: INITIAL_SERVICE_LIST,
value: serviceList,
setValue: setServiceList,
value: serviceListShortcut,
setValue: setServiceListShortcut,
},
{
title: "settings.advanced.shortcuts.external.title",

View File

@@ -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>
);
}

View File

@@ -1,10 +1,11 @@
import { useContext, useMemo, useState } from "react";
import { filesize } from "filesize";
import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "ahooks";
import platformAdapter from "@/utils/platformAdapter";
import { ExtensionsContext } from "../../../index";
import { filesize } from "@/utils";
interface Metadata {
name: string;
@@ -57,7 +58,7 @@ const App = () => {
},
{
label: t("settings.extensions.application.details.size"),
value: filesize(size, { standard: "jedec", spacer: "" }),
value: filesize(size),
},
{
label: t("settings.extensions.application.details.created"),

View File

@@ -1,137 +1,164 @@
import { FC, useMemo, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { isArray } from "lodash-es";
import { useAsyncEffect, useMount } from "ahooks";
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
import SettingsSelectPro from "@/components/Settings/SettingsSelectPro";
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
import { useAsyncEffect, useMount } from "ahooks";
import { FC, useMemo, useState } from "react";
import { ExtensionId } from "../../..";
import { useTranslation } from "react-i18next";
import { isArray } from "lodash-es";
import { ExtensionId } from "@/components/Settings/Extensions/index";
import { useConnectStore } from "@/stores/connectStore";
import type { Server } from "@/types/server";
interface Assistant {
id: string;
name?: string;
icon?: string;
description?: string;
}
interface SharedAiProps {
id: ExtensionId;
server?: any;
setServer: (server: any) => void;
assistant?: any;
setAssistant: (assistant: any) => void;
server?: Server;
setServer: (server: Server | undefined) => void;
assistant?: Assistant;
setAssistant: (assistant: Assistant | undefined) => void;
}
const SharedAi: FC<SharedAiProps> = (props) => {
const { id, server, setServer, assistant, setAssistant } = props;
const [serverList, setServerList] = useState<any[]>([server]);
const [assistantList, setAssistantList] = useState<any[]>([assistant]);
const serverList = useConnectStore((state) => state.serverList);
const [list, setList] = useState<Server[]>([]);
const [assistantList, setAssistantList] = useState<Assistant[]>([]);
const addError = useAppStore((state) => state.addError);
const { fetchAssistant } = AssistantFetcher({});
const { t } = useTranslation();
const [assistantSearchValue, setAssistantSearchValue] = useState("");
const [isLoadingAssistants, setIsLoadingAssistants] = useState(false);
const getEnabledServers = useCallback((servers: Server[]): Server[] => {
if (!isArray(servers)) return [];
return servers.filter(
(s) => s.enabled && s.available && (s.public || s.profile)
);
}, []);
useMount(async () => {
try {
const data = await platformAdapter.invokeBackend<any[]>(
"list_coco_servers"
);
const enabledServers = getEnabledServers(serverList);
setList(enabledServers);
if (isArray(data)) {
const enabledServers = data.filter(
(s) => s.enabled && s.available && (s.public || s.profile)
);
setServerList(enabledServers);
if (server) {
const matchServer = enabledServers.find((item) => {
return item.id === server.id;
});
if (matchServer) {
return setServer(matchServer);
}
}
setServer(enabledServers[0]);
if (enabledServers.length === 0) {
setServer(undefined);
return;
}
if (server) {
const matchServer = enabledServers.find((item) => item.id === server.id);
if (matchServer) {
setServer(matchServer);
return;
}
}
setServer(enabledServers[0]);
} catch (error) {
addError(String(error));
console.error('Failed to load servers:', error);
addError(`Failed to load servers: ${String(error)}`);
}
});
useAsyncEffect(async () => {
try {
if (!server) return;
if (!server) {
setAssistantList([]);
setAssistant(undefined);
return;
}
setIsLoadingAssistants(true);
try {
const data = await fetchAssistant({
current: 1,
pageSize: 1000,
pageSize: 100,
serverId: server.id,
query: assistantSearchValue,
});
const list = data.list.map((item: any) => item._source);
const assistants: Assistant[] = data.list.map((item: any) => item._source);
setAssistantList(assistants);
setAssistantList(list);
if (assistants.length === 0) {
setAssistant(undefined);
return;
}
if (assistant) {
const matched = list.find((item: any) => {
return item.id === assistant.id;
});
const matched = assistants.find((item) => item.id === assistant.id);
if (matched) {
return setAssistant(matched);
setAssistant(matched);
return;
}
}
setAssistant(list[0]);
setAssistant(assistants[0]);
} catch (error) {
addError(String(error));
console.error('Failed to fetch assistants:', error);
addError(`Failed to fetch assistants: ${String(error)}`);
setAssistantList([]);
setAssistant(undefined);
} finally {
setIsLoadingAssistants(false);
}
}, [server, assistantSearchValue]);
}, [server?.id, assistantSearchValue]);
const selectList = useMemo(() => {
return [
{
label: t(
"settings.extensions.shardAi.details.linkedAssistant.label.cocoServer"
),
value: server?.id,
icon: server?.provider?.icon,
data: serverList,
searchable: false,
onChange: (value: string) => {
const matched = serverList.find((item) => item.id === value);
setServer(matched);
},
const serverSelectConfig = {
label: t(
"settings.extensions.shardAi.details.linkedAssistant.label.cocoServer"
),
value: server?.id,
icon: server?.provider?.icon,
data: list,
searchable: false,
onChange: (value: string) => {
const matched = list.find((item) => item.id === value);
setServer(matched);
},
{
label: t(
"settings.extensions.shardAi.details.linkedAssistant.label.aiAssistant"
),
value: assistant?.id,
icon: assistant?.icon,
data: assistantList,
searchable: true,
onChange: (value: string) => {
const matched = assistantList.find((item) => item.id === value);
onSearch: undefined,
};
setAssistant(matched);
},
onSearch: (value: string) => {
setAssistantSearchValue(value);
},
const assistantSelectConfig = {
label: t(
"settings.extensions.shardAi.details.linkedAssistant.label.aiAssistant"
),
value: assistant?.id,
icon: assistant?.icon,
data: assistantList,
searchable: true,
onChange: (value: string) => {
const matched = assistantList.find((item) => item.id === value);
setAssistant(matched);
},
];
}, [serverList, assistantList, server, assistant]);
onSearch: (value: string) => {
setAssistantSearchValue(value);
},
};
const renderDescription = () => {
if (id === "QuickAIAccess") {
return t("settings.extensions.quickAiAccess.description");
}
return [serverSelectConfig, assistantSelectConfig];
}, [list, assistantList, server?.id, assistant?.id, t]);
if (id === "AIOverview") {
return t("settings.extensions.aiOverview.description");
const renderDescription = useCallback(() => {
switch (id) {
case "QuickAIAccess":
return t("settings.extensions.quickAiAccess.description");
case "AIOverview":
return t("settings.extensions.aiOverview.description");
default:
return null;
}
};
}, [id, t]);
return (
<>
@@ -154,6 +181,7 @@ const SharedAi: FC<SharedAiProps> = (props) => {
searchable={searchable}
onChange={onChange}
onSearch={onSearch}
placeholder={isLoadingAssistants && searchable ? "Loading..." : undefined}
/>
</div>
);

View File

@@ -5,13 +5,14 @@ import type { LiteralUnion } from "type-fest";
import { cloneDeep, sortBy } from "lodash-es";
import clsx from "clsx";
import { Plus } from "lucide-react";
import { Button } from "@headlessui/react";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import platformAdapter from "@/utils/platformAdapter";
import Content from "./components/Content";
import Details from "./components/Details";
import { useExtensionsStore } from "@/stores/extensionsStore";
import SettingsInput from "../SettingsInput";
import { useAppStore } from "@/stores/appStore";
export type ExtensionId = LiteralUnion<
| "Applications"
@@ -90,6 +91,7 @@ export const Extensions = () => {
const { t } = useTranslation();
const state = useReactive<State>(cloneDeep(INITIAL_STATE));
const { configId, setConfigId } = useExtensionsStore();
const { addError } = useAppStore();
useEffect(() => {
getExtensions();
@@ -106,7 +108,7 @@ export const Extensions = () => {
});
const getExtensions = async () => {
const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>(
const extensions = await platformAdapter.invokeBackend<Extension[]>(
"list_extensions",
{
query: state.searchValue,
@@ -115,8 +117,6 @@ export const Extensions = () => {
}
);
const extensions = result[1];
state.extensions = sortBy(extensions, ["name"]);
if (configId) {
@@ -160,14 +160,65 @@ export const Extensions = () => {
{t("settings.extensions.title")}
</h2>
<Button
className="flex items-center justify-center size-6 border rounded-md dark:border-gray-700 hover:!border-[#0096FB] transition"
onClick={() => {
platformAdapter.emitEvent("open-extension-store");
}}
>
<Plus className="size-4 text-[#0096FB]" />
</Button>
<Menu>
<MenuButton className="flex items-center justify-center size-6 border rounded-md dark:border-gray-700 hover:!border-[#0096FB] transition">
<Plus className="size-4 text-[#0096FB]" />
</MenuButton>
<MenuItems
anchor={{ gap: 4 }}
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
>
<MenuItem>
<div
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={() => {
platformAdapter.emitEvent("open-extension-store");
}}
>
{t("settings.extensions.menuItem.extensionStore")}
</div>
</MenuItem>
<MenuItem>
<div
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={async () => {
try {
const path = await platformAdapter.openFileDialog({
directory: true,
});
if (!path) return;
await platformAdapter.invokeBackend(
"install_local_extension",
{ path }
);
await getExtensions();
addError(
t("settings.extensions.hints.importSuccess"),
"info"
);
} catch (error) {
const errorMessage = String(error);
if (errorMessage === "already imported") {
addError(t("settings.extensions.hints.extensionAlreadyImported"));
} else if (errorMessage === "incompatible") {
addError(t("settings.extensions.hints.incompatibleExtension"));
} else {
addError(t("settings.extensions.hints.importFailed"));
}
}
}}
>
{t("settings.extensions.menuItem.localExtensionImport")}
</div>
</MenuItem>
</MenuItems>
</Menu>
</div>
<div className="flex justify-between gap-6 my-4">

View File

@@ -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>
);
}

View File

@@ -32,8 +32,8 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
const {
visible,
setVisible,
skipVersion,
setSkipVersion,
skipVersions,
setSkipVersions,
isOptional,
updateInfo,
setUpdateInfo,
@@ -50,11 +50,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
}, []);
useEffect(() => {
if (!snapshotUpdate) return;
checkUpdate().catch((error) => {
addError("Update failed:" + error, "error");
});
checkUpdateStatus();
}, [snapshotUpdate]);
useEffect(() => {
@@ -79,13 +75,13 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
const update = await checkUpdate();
if (update) {
const { skipVersions } = useUpdateStore.getState();
setVisible(!skipVersions.includes(update.version));
setUpdateInfo(update);
if (skipVersion === update.version) return;
setVisible(true);
}
}, [skipVersion]);
}, [skipVersions]);
const cursorClassName = useMemo(() => {
return state.loading ? "cursor-not-allowed" : "cursor-pointer";
@@ -133,7 +129,9 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
const handleSkip = () => {
if (state.loading) return;
setSkipVersion(updateInfo?.version);
const { skipVersions, updateInfo } = useUpdateStore.getState();
setSkipVersions([...skipVersions, updateInfo.version]);
isCheckPage ? hide_check() : setVisible(false);
};
@@ -182,7 +180,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
<img src={isDark ? darkIcon : lightIcon} className="h-6" />
<div className="text-[#333] text-sm leading-5 py-2 dark:text-[#D8D8D8] text-center">
{updateInfo?.available ? (
{updateInfo ? (
isOptional ? (
t("update.optional_description")
) : (
@@ -196,7 +194,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
)}
</div>
{updateInfo?.available ? (
{updateInfo ? (
<div
className="text-xs text-[#0072FF] cursor-pointer"
onClick={() =>
@@ -223,21 +221,21 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
cursorClassName,
state.loading && "opacity-50"
)}
onClick={updateInfo?.available ? handleDownload : handleSkip}
onClick={updateInfo ? handleDownload : handleSkip}
>
{state.loading ? (
<div className="flex justify-center items-center gap-2">
<LoaderCircle className="animate-spin size-5" />
{percent}%
</div>
) : updateInfo?.available ? (
) : updateInfo ? (
t("update.button.install")
) : (
t("update.button.ok")
)}
</Button>
{updateInfo?.available && isOptional && (
{!isCheckPage && updateInfo && isOptional && (
<div
className={clsx("text-xs text-[#999]", cursorClassName)}
onClick={handleSkip}

View File

@@ -5,3 +5,7 @@ export const HISTORY_PANEL_ID = "headlessui-popover-panel:history-panel";
export const CONTEXT_MENU_PANEL_ID = "headlessui-popover-panel:context-menu";
export const DEFAULT_COCO_SERVER_ID = "default_coco_server";
export const MAIN_WINDOW_LABEL = "main";
export const SETTINGS_WINDOW_LABEL = "settings";

View File

@@ -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;

View File

@@ -9,6 +9,9 @@ import { useSearchStore } from "@/stores/searchStore";
import { useAuthStore } from "@/stores/authStore";
import { unrequitable } from "@/utils";
import { streamPost } from "@/api/streamFetch";
import { SendMessageParams } from "@/components/Assistant/Chat";
import { isEmpty } from "lodash-es";
import { useChatStore } from "@/stores/chatStore";
export function useChatActions(
setActiveChat: (chat: Chat | undefined) => void,
@@ -40,6 +43,9 @@ export function useChatActions(
} = useConnectStore();
const sourceDataIds = useSearchStore((state) => state.sourceDataIds);
const MCPIds = useSearchStore((state) => state.MCPIds);
const setUploadAttachments = useChatStore((state) => {
return state.setUploadAttachments;
});
const [keyword, setKeyword] = useState("");
@@ -76,7 +82,6 @@ export function useChatActions(
const [_error, res] = await Post(`/chat/${activeChat?._id}/_close`, {});
response = res;
}
console.log("_close", response);
},
[currentService?.id, isTauri]
);
@@ -124,7 +129,6 @@ export function useChatActions(
);
response = res;
}
console.log("_cancel", response);
},
[currentService?.id, isTauri]
);
@@ -170,7 +174,6 @@ export function useChatActions(
...chat,
messages: hits,
};
console.log("id_history", updatedChat);
updatedChatRef.current = updatedChat;
setActiveChat(updatedChat);
callback && callback(updatedChat);
@@ -254,20 +257,6 @@ export function useChatActions(
`chat-stream-${clientId}-${timestamp}`,
(event) => {
const msg = event.payload as string;
try {
// console.log("msg:", JSON.parse(msg));
// console.log("user:", msg.includes(`"user"`));
// console.log("_source:", msg.includes("_source"));
// console.log("result:", msg.includes("result"));
// console.log("");
// console.log("");
// console.log("");
// console.log("");
// console.log("");
} catch (error) {
console.error("Failed to parse JSON in listener:", error);
}
handleChatCreateStreamMessage(msg);
}
);
@@ -289,10 +278,12 @@ export function useChatActions(
);
const prepareChatSession = useCallback(
async (value: string, timestamp: number) => {
async (timestamp: number, value: string) => {
// 1. Cleaning and preparation
await clearAllChunkData();
setUploadAttachments([]);
// 2. Update the status again
await new Promise<void>((resolve) => {
changeInput && changeInput("");
@@ -310,12 +301,17 @@ export function useChatActions(
);
const createNewChat = useCallback(
async (value: string = "") => {
if (!value) return;
async (params?: SendMessageParams) => {
const { message, attachments } = params || {};
console.log("message", message);
console.log("attachments", attachments);
if (!message && isEmpty(attachments)) return;
const timestamp = Date.now();
await prepareChatSession(value, timestamp);
await prepareChatSession(timestamp, message ?? "");
const queryParams = {
search: isSearchActive,
@@ -328,19 +324,22 @@ export function useChatActions(
if (isTauri) {
if (!currentService?.id) return;
console.log("chat_create", clientId, timestamp);
await platformAdapter.commands("chat_create", {
serverId: currentService?.id,
message: value,
message,
attachments,
queryParams,
clientId: `chat-stream-${clientId}-${timestamp}`,
});
console.log("_create end", value);
console.log("_create end", message);
resetChatState();
} else {
await streamPost({
url: "/chat/_create",
body: { message: value },
body: { message },
queryParams,
onMessage: (line) => {
console.log("⏳", line);
@@ -365,12 +364,16 @@ export function useChatActions(
);
const sendMessage = useCallback(
async (content: string, newChat: Chat) => {
if (!newChat?._id || !content) return;
async (newChat: Chat, params?: SendMessageParams) => {
if (!newChat?._id || !params) return;
const { message, attachments } = params;
if (!message && isEmpty(attachments)) return;
const timestamp = Date.now();
await prepareChatSession(content, timestamp);
await prepareChatSession(timestamp, message ?? "");
const queryParams = {
search: isSearchActive,
@@ -388,15 +391,16 @@ export function useChatActions(
serverId: currentService?.id,
sessionId: newChat?._id,
queryParams,
message: content,
message,
attachments,
clientId: `chat-stream-${clientId}-${timestamp}`,
});
console.log("chat_chat end", content, clientId);
console.log("chat_chat end", message, clientId);
resetChatState();
} else {
await streamPost({
url: `/chat/${newChat?._id}/_chat`,
body: { message: content },
body: { message },
queryParams,
onMessage: (line) => {
console.log("line", line);
@@ -421,10 +425,14 @@ export function useChatActions(
);
const handleSendMessage = useCallback(
async (content: string, activeChat?: Chat) => {
if (!activeChat?._id || !content) return;
async (activeChat?: Chat, params?: SendMessageParams) => {
if (!activeChat?._id) return;
await chatHistory(activeChat, (chat) => sendMessage(content, chat));
const { message, attachments } = params ?? {};
if (!message && isEmpty(attachments)) return;
await chatHistory(activeChat, (chat) => sendMessage(chat, params));
},
[chatHistory, sendMessage]
);
@@ -455,7 +463,6 @@ export function useChatActions(
response = res;
}
console.log("_open", response);
return response;
},
[currentService?.id, isTauri]
@@ -484,7 +491,6 @@ export function useChatActions(
response = res;
}
console.log("_history", response);
const hits = response?.hits?.hits || [];
setChats(hits);
}, [

View File

@@ -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]);
}

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -26,15 +26,9 @@ const useScript = (src: string, onError?: () => void) => {
export default useScript;
export const useIconfontScript = (type: "web" | "app", serverUrl?: string) => {
if (type === "web") {
useScript(`${serverUrl}/assets/fonts/icons/iconfont.js`);
useScript(`${serverUrl}/assets/fonts/icons-app/iconfont.js`);
} else {
// Coco Server Icons
useScript("https://at.alicdn.com/t/c/font_4878526_cykw3et0ezd.js");
// Coco App Icons
useScript("https://at.alicdn.com/t/c/font_4934333_zclkkzo4fgo.js");
}
export const useIconfontScript = () => {
// Coco Server Icons
useScript("https://at.alicdn.com/t/c/font_4878526_cykw3et0ezd.js");
// Coco App Icons
useScript("https://at.alicdn.com/t/c/font_4934333_zclkkzo4fgo.js");
};

View File

@@ -174,6 +174,7 @@ export function useSearch() {
const [error, res]: any = await Get(
`/query/_search?query=${searchInput}`
);
if (error) {
console.error("_search", error);
response = { failed: [], hits: [], total_hits: 0 };

105
src/hooks/useServers.ts Normal file
View 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,
};
};

View File

@@ -1,3 +1,6 @@
import { isNumber } from "lodash-es";
import { useEffect } from "react";
import { useAppearanceStore } from "@/stores/appearanceStore";
import { useAppStore } from "@/stores/appStore";
import { useConnectStore } from "@/stores/connectStore";
@@ -5,8 +8,6 @@ import { useExtensionsStore } from "@/stores/extensionsStore";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useStartupStore } from "@/stores/startupStore";
import platformAdapter from "@/utils/platformAdapter";
import { isNumber } from "lodash-es";
import { useEffect } from "react";
export const useSyncStore = () => {
const setModifierKey = useShortcutsStore((state) => {
@@ -60,8 +61,8 @@ export const useSyncStore = () => {
const setFixedWindow = useShortcutsStore((state) => {
return state.setFixedWindow;
});
const setServiceList = useShortcutsStore((state) => {
return state.setServiceList;
const setServiceListShortcut = useShortcutsStore((state) => {
return state.setServiceListShortcut;
});
const setExternal = useShortcutsStore((state) => {
return state.setExternal;
@@ -143,7 +144,7 @@ export const useSyncStore = () => {
aiAssistant,
newSession,
fixedWindow,
serviceList,
serviceListShortcut,
external,
aiOverview,
} = payload;
@@ -162,7 +163,7 @@ export const useSyncStore = () => {
setAiAssistant(aiAssistant);
setNewSession(newSession);
setFixedWindow(fixedWindow);
setServiceList(serviceList);
setServiceListShortcut(serviceListShortcut);
setExternal(external);
setAiOverview(aiOverview);
}),

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

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

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