Compare commits
164 Commits
upload-fil
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c9a2ff441 | ||
|
|
2251b0af95 | ||
|
|
560a12ab93 | ||
|
|
2ff66c0b91 | ||
|
|
ef4a184233 | ||
|
|
8422bc03e7 | ||
|
|
370113129c | ||
|
|
cb758ef452 | ||
|
|
12b9b4bb81 | ||
|
|
562db19f16 | ||
|
|
dc5cd9aecb | ||
|
|
0b018cd24f | ||
|
|
2ed22d3d7c | ||
|
|
4ce9561eb7 | ||
|
|
3aeb39b3af | ||
|
|
27e99d4629 | ||
|
|
df70276a54 | ||
|
|
6553a8f5d3 | ||
|
|
4ebbc9ec6e | ||
|
|
4208633556 | ||
|
|
fc43fbe798 | ||
|
|
b5bb9105d4 | ||
|
|
b6ebd6e5f8 | ||
|
|
22216491b6 | ||
|
|
44ca66259c | ||
|
|
be3cae36e2 | ||
|
|
35ea30626f | ||
|
|
4bcae5cffb | ||
|
|
76458db8ab | ||
|
|
5b41e190d3 | ||
|
|
43ac9a054c | ||
|
|
ac485a32cc | ||
|
|
e10908a095 | ||
|
|
78b8908ac8 | ||
|
|
3c54cb84a8 | ||
|
|
8ed808c591 | ||
|
|
7a2dde7448 | ||
|
|
65451fc63e | ||
|
|
5d108a46d3 | ||
|
|
f9567c2d46 | ||
|
|
da917e6012 | ||
|
|
335a906674 | ||
|
|
a50a636d59 | ||
|
|
2dd3f776e6 | ||
|
|
40f6aa0ccd | ||
|
|
4da9e024e0 | ||
|
|
c20bba51f5 | ||
|
|
0a62a2095b | ||
|
|
5677995185 | ||
|
|
ec4e5e7d1d | ||
|
|
1df5265b1a | ||
|
|
fb8a4684dc | ||
|
|
0b609e570d | ||
|
|
f91f6bdc17 | ||
|
|
57590f3b57 | ||
|
|
c18f9ea154 | ||
|
|
441875d9b4 | ||
|
|
eddf9075bb | ||
|
|
9eac8f8a8e | ||
|
|
515260c43f | ||
|
|
118de0e80b | ||
|
|
19ce896fdc | ||
|
|
4a41ea5d8b | ||
|
|
880e1206ce | ||
|
|
1e6d9f9550 | ||
|
|
ff0faf425f | ||
|
|
1fbf5d6552 | ||
|
|
db41e817c3 | ||
|
|
1296755bc5 | ||
|
|
d410f20864 | ||
|
|
61d0a3b79a | ||
|
|
b24319b649 | ||
|
|
3c0fb24548 | ||
|
|
2fcbed0381 | ||
|
|
7444347e0c | ||
|
|
725ce042de | ||
|
|
3b67de5387 | ||
|
|
9b53a026ff | ||
|
|
9ea7dbf3aa | ||
|
|
55622911ac | ||
|
|
92f78ad08c | ||
|
|
f690dbaab2 | ||
|
|
210efe763d | ||
|
|
f23498afa0 | ||
|
|
a80a5d928f | ||
|
|
b733bb5516 | ||
|
|
5046754534 | ||
|
|
f557f7e780 | ||
|
|
18feb2d690 | ||
|
|
af59f2fe9f | ||
|
|
5e1bb54d5e | ||
|
|
33fa516aad | ||
|
|
d2c1cf513d | ||
|
|
f81bec8403 | ||
|
|
cce956ac15 | ||
|
|
0d1174c8dd | ||
|
|
e0258dc2fa | ||
|
|
310a70838b | ||
|
|
94d7f809d2 | ||
|
|
e1d1bc2684 | ||
|
|
a9e3bb3eee | ||
|
|
d184851e3b | ||
|
|
c9b785ccf3 | ||
|
|
4c5ae8c718 | ||
|
|
8a7f7bc708 | ||
|
|
3d44d10048 | ||
|
|
97d880ea27 | ||
|
|
6c53056edd | ||
|
|
a6fd2ebd16 | ||
|
|
b509176572 | ||
|
|
17f2bcf7a8 | ||
|
|
c471a83821 | ||
|
|
51b0a2a545 | ||
|
|
baded2af1e | ||
|
|
2b21426355 | ||
|
|
8edc938426 | ||
|
|
fa919bee11 | ||
|
|
50f1e611c3 | ||
|
|
4c3cf28012 | ||
|
|
89fcc67222 | ||
|
|
33c9ce67df | ||
|
|
c6dadfd83e | ||
|
|
e707a8b5c7 | ||
|
|
5c5364974a | ||
|
|
9d3e3e8dde | ||
|
|
e065ba749f | ||
|
|
2dd8e3160c | ||
|
|
6aeecfe3ac | ||
|
|
334e29d69b | ||
|
|
382f89ace0 | ||
|
|
32c7cc5060 | ||
|
|
c13151d69e | ||
|
|
07c4ab03b5 | ||
|
|
cf3f2affa5 | ||
|
|
401832ad43 | ||
|
|
6a6f48d2fc | ||
|
|
8a6c90d124 | ||
|
|
34acecbcb0 | ||
|
|
4474212b7d | ||
|
|
1187b641d4 | ||
|
|
ef8cd569e4 | ||
|
|
5ef06bfc95 | ||
|
|
2b59addb08 | ||
|
|
ee750620f2 | ||
|
|
acc3b1a0d2 | ||
|
|
4372747014 | ||
|
|
ee531209aa | ||
|
|
ee0bbce3e2 | ||
|
|
7eccf99f92 | ||
|
|
5044a98bb7 | ||
|
|
72165812bf | ||
|
|
f9c1be8517 | ||
|
|
71ce23ef21 | ||
|
|
3e6041cbd8 | ||
|
|
0b9e158b55 | ||
|
|
688ced3fc3 | ||
|
|
16e0382a8b | ||
|
|
91c9cd5725 | ||
|
|
7f3e602bb3 | ||
|
|
5e9d41ea5c | ||
|
|
8bdb93d813 | ||
|
|
690e6a3225 | ||
|
|
111d9bddca | ||
|
|
7645b3e736 |
18
.github/workflows/enforce-no-dep-pizza-engine.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: Enforce no dependency pizza-engine
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name:
|
||||
working-directory: ./src-tauri
|
||||
run: |
|
||||
# if cargo remove pizza-engine succeeds, then it is in our dependency list, fail the CI pipeline.
|
||||
if cargo remove pizza-engine; then exit 1; fi
|
||||
49
.github/workflows/release.yml
vendored
@@ -91,8 +91,55 @@ jobs:
|
||||
- name: Install app dependencies and build web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build the app
|
||||
- name: Set up SSH agent for private repository clone
|
||||
if: matrix.target != 'i686-pc-windows-msvc'
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Add Git server to known hosts
|
||||
if: matrix.platform != 'windows-latest'
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan github.com >> ~/.ssh/known_hosts
|
||||
chmod 600 ~/.ssh/known_hosts
|
||||
|
||||
- name: Pizza engine features setup
|
||||
run: |
|
||||
if [[ ${{ matrix.target }} == "i686-pc-windows-msvc" ]]; then
|
||||
rustup target add i686-pc-windows-msvc --toolchain stable
|
||||
else
|
||||
make add-dep-pizza-engine-linux
|
||||
rustup target add ${{ matrix.target}} --toolchain nightly-2025-02-28
|
||||
fi
|
||||
|
||||
|
||||
- name: Build the app with ${{ matrix.platform }}
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
if: matrix.target != 'i686-pc-windows-msvc'
|
||||
env:
|
||||
CI: false
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ""
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ""
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: Coco ${{ needs.create-release.outputs.APP_VERSION }}
|
||||
releaseBody: ""
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: --target ${{ matrix.target }} --features use_pizza_engine
|
||||
|
||||
- name: Build the app with ${{ matrix.platform }} (windows i686 only)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
if: matrix.target == 'i686-pc-windows-msvc'
|
||||
env:
|
||||
CI: false
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
|
||||
1
.vscode/settings.json
vendored
@@ -13,6 +13,7 @@
|
||||
"elif",
|
||||
"errmsg",
|
||||
"fullscreen",
|
||||
"fulltext",
|
||||
"headlessui",
|
||||
"Icdbb",
|
||||
"icns",
|
||||
|
||||
6
Makefile
@@ -78,4 +78,8 @@ clean-rebuild:
|
||||
$(MAKE) dev-build
|
||||
|
||||
add-dep-pizza-engine:
|
||||
cd src-tauri && cargo add --git ssh://git@github.com/infinilabs/pizza.git pizza-engine --features query_string_parser,persistence
|
||||
cd src-tauri && cargo add --git ssh://git@github.com/infinilabs/pizza.git pizza-engine --features query_string_parser,persistence
|
||||
|
||||
dev-build-with-pizza: add-dep-pizza-engine
|
||||
@echo "Starting desktop development with Pizza Engine pulled in..."
|
||||
RUST_BACKTRACE=1 pnpm tauri dev --features use_pizza_engine
|
||||
@@ -1,21 +1,35 @@
|
||||
---
|
||||
weight: 10
|
||||
title: "Mac OS"
|
||||
title: "macOS"
|
||||
asciinema: true
|
||||
---
|
||||
|
||||
# Mac OS
|
||||
# macOS
|
||||
|
||||
## Download Coco AI
|
||||
|
||||
Goto [https://coco.rs/](https://coco.rs/)
|
||||
Go to [coco.rs](https://coco.rs/) and download the package of your architecture:
|
||||
|
||||
{{% load-img "/img/download-mac-app.png" "" %}}
|
||||
{{% load-img "/img/macos/mac-download-app.png" "" %}}
|
||||
|
||||
It should be placed in your "Downloads" folder:
|
||||
|
||||
{{% load-img "/img/macos/mac-zip-file.png" "" %}}
|
||||
|
||||
## Unzip DMG file
|
||||
|
||||
{{% load-img "/img/unzip-dmg-file.png" "" %}}
|
||||
Unzip the file:
|
||||
|
||||
{{% load-img "/img/macos/mac-unzip-zip-file.png" "" %}}
|
||||
|
||||
You will get a `dmg` file:
|
||||
|
||||
{{% load-img "/img/macos/mac-dmg.png" "" %}}
|
||||
|
||||
## Drag to Application Folder
|
||||
|
||||
{{% load-img "/img/drag-to-application-folder.png" "" %}}
|
||||
Double click the `dmg` file, a window will pop up. Then drag the "Coco-AI" app to
|
||||
your "Applications" folder:
|
||||
|
||||
{{% load-img "/img/macos/drag-to-app-folder.png" "" %}}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ asciinema: true
|
||||
[if_x11]: https://unix.stackexchange.com/q/202891/498440
|
||||
|
||||
|
||||
## Goto [https://coco.rs/](https://coco.rs/)
|
||||
## Go to the download page
|
||||
|
||||
Download page: [link](https://coco.rs/#install)
|
||||
|
||||
## Download the package
|
||||
|
||||
|
||||
@@ -13,6 +13,58 @@ Information about release notes of Coco Server is provided here.
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
## 0.5.2 (2025-06-13)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- feat: ai overview support is enabled with shortcut #597
|
||||
- feat: add key monitoring during reset #615
|
||||
- feat: calculator extension add description #623
|
||||
- feat: support right-click actions after text selection #624
|
||||
- feat: add ai overview minimum number of search results configuration #625
|
||||
- feat: add internationalized translations of AI-related extensions #632
|
||||
- feat: context menu support for secondary pages #680
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
- fix: fixed issue with incorrect login status #600
|
||||
- fix: new chat assistant id not found #603
|
||||
- fix: resolve regex error on older macOS versions #605
|
||||
- fix: fix chat log update and sorting issues #612
|
||||
- fix: resolved an issue where number keys were not working on the web #616
|
||||
- fix: do not panic when the datasource specified does not exist #618
|
||||
- fix: fixed modifier keys not working with continue chat #619
|
||||
- fix: invalid DSL error if input contains multiple lines #620
|
||||
- fix: fix ai overview hidden height before message #622
|
||||
- fix: tab key hides window in chat mode #641
|
||||
- fix: arrow keys still navigated search when menu opened with Cmd+K #642
|
||||
- fix: input lost when reopening dialog after search #644
|
||||
- fix: web page unmount event #645
|
||||
- fix: fix the problem of local path not opening #650
|
||||
- fix: number keys not following settings #661
|
||||
- fix: fix problem with up and down key indexing #676
|
||||
- fix: arrow inserting escape sequences #683
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
- chore: initialize current assistant from history #606
|
||||
- chore: add onContextMenu event #629
|
||||
- chore: more logs for the setup process #634
|
||||
- chore: copy supports http protocol #639
|
||||
- chore: add special character filtering #668
|
||||
|
||||
## 0.5.1 (2025-05-31)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- feat: check or enter to close the list of assistants #469
|
||||
- feat: add dimness settings for pinned window #470
|
||||
- feat: supports Shift + Enter input box line feeds #472
|
||||
@@ -24,17 +76,35 @@ Information about release notes of Coco Server is provided here.
|
||||
- feat: the search input box supports multi-line input #501
|
||||
- feat: websocket support self-signed TLS #504
|
||||
- feat: add option to allow self-signed certificates #509
|
||||
- feat: add AI summary component #518
|
||||
- feat: dynamic log level via env var COCO_LOG #535
|
||||
- feat: add quick AI access to search mode #556
|
||||
- feat: rerank search results #561
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
- fix: solve the problem of modifying the assistant in the chat #476
|
||||
- fix: several issues around search #502
|
||||
- fix: fixed the newly created session has no title when it is deleted #511
|
||||
- fix: loading chat history for potential empty attachments
|
||||
- fix: datasource & MCP list synchronization update #521
|
||||
- fix: app icon & category icon #529
|
||||
- fix: show only enabled datasource & MCP list
|
||||
- fix: server image loading failure #534
|
||||
- fix: panic when fetching app metadata on Windows #538
|
||||
- fix: service switching error #539
|
||||
- fix: switch server assistant and session unchanged #540
|
||||
- fix: history list height #550
|
||||
- fix: secondary page cannot be searched #551
|
||||
- fix: the scroll button is not displayed by default #552
|
||||
- fix: suggestion list position #553
|
||||
- fix: independent chat window has no data #554
|
||||
- fix: resolved navigation error on continue chat action #558
|
||||
- fix: make extension search source respect parameter datasource #576
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
- chore: adjust list error message #475
|
||||
- fix: solve the problem of modifying the assistant in the chat #476
|
||||
- chore: refine wording on search failure
|
||||
- chore:search and MCP show hidden logic #494
|
||||
- chore: greetings show hidden logic #496
|
||||
@@ -45,6 +115,26 @@ Information about release notes of Coco Server is provided here.
|
||||
- refactor: optimized the modification operation of the numeric input box #508
|
||||
- style: modify the style of the search input box #513
|
||||
- style: chat input icons show #515
|
||||
- refactor: refactoring icon component #514
|
||||
- refactor: optimizing list styles in markdown content #520
|
||||
- feat: add a component for text reading aloud #522
|
||||
- style: history component styles #528
|
||||
- style: search error styles #533
|
||||
- chore: skip register server that not logged in #536
|
||||
- refactor: service info related components #537
|
||||
- chore: chat content can be copied #539
|
||||
- refactor: refactoring search error #541
|
||||
- chore: add assistant count #542
|
||||
- chore: add global login judgment #544
|
||||
- chore: mark server offline on user logout #546
|
||||
- chore: logout update server profile #549
|
||||
- chore: assistant keyboard events and mouse events #559
|
||||
- chore: web component start page config #560
|
||||
- chore: assistant chat placeholder & refactor input box components #566
|
||||
- refactor: input box related components #568
|
||||
- chore: mark unavailable server to offline on refresh info #569
|
||||
- chore: only show available servers in chat #570
|
||||
- refactor: search result related components #571
|
||||
|
||||
## 0.4.0 (2025-04-27)
|
||||
|
||||
@@ -74,6 +164,8 @@ Information about release notes of Coco Server is provided here.
|
||||
- feat: data sources support displaying customized icons #432
|
||||
- feat: add shortcut key conflict hint and reset function #442
|
||||
- feat: updated to include error message #465
|
||||
- feat: support third party extensions #572
|
||||
- feat: support ai overview #572
|
||||
|
||||
### Bug fix
|
||||
|
||||
|
||||
BIN
docs/static/img/download-mac-app.png
vendored
|
Before Width: | Height: | Size: 155 KiB |
BIN
docs/static/img/drag-to-application-folder.png
vendored
|
Before Width: | Height: | Size: 69 KiB |
BIN
docs/static/img/macos/drag-to-app-folder.png
vendored
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
docs/static/img/macos/mac-dmg.png
vendored
Normal file
|
After Width: | Height: | Size: 586 KiB |
BIN
docs/static/img/macos/mac-download-app.png
vendored
Normal file
|
After Width: | Height: | Size: 299 KiB |
BIN
docs/static/img/macos/mac-unzip-zip-file.png
vendored
Normal file
|
After Width: | Height: | Size: 650 KiB |
BIN
docs/static/img/macos/mac-zip-file.png
vendored
Normal file
|
After Width: | Height: | Size: 441 KiB |
BIN
docs/static/img/unzip-dmg-file.png
vendored
|
Before Width: | Height: | Size: 121 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "coco",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -27,6 +27,7 @@
|
||||
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
|
||||
"@tauri-apps/plugin-http": "~2.0.2",
|
||||
"@tauri-apps/plugin-log": "~2.4.0",
|
||||
"@tauri-apps/plugin-opener": "^2.2.7",
|
||||
"@tauri-apps/plugin-os": "^2.2.1",
|
||||
"@tauri-apps/plugin-process": "^2.2.1",
|
||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||
@@ -44,6 +45,7 @@
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.461.0",
|
||||
"mdast-util-gfm-autolink-literal": "2.0.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"react": "^18.3.1",
|
||||
@@ -62,6 +64,7 @@
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0",
|
||||
"tauri-plugin-screenshots-api": "^2.2.0",
|
||||
"tauri-plugin-windows-version-api": "^2.0.0",
|
||||
"type-fest": "^4.41.0",
|
||||
"use-debounce": "^10.0.4",
|
||||
"uuid": "^11.1.0",
|
||||
"wavesurfer.js": "^7.9.5",
|
||||
@@ -89,5 +92,6 @@
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^5.4.19"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
||||
}
|
||||
|
||||
78
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
||||
'@tauri-apps/plugin-log':
|
||||
specifier: ~2.4.0
|
||||
version: 2.4.0
|
||||
'@tauri-apps/plugin-opener':
|
||||
specifier: ^2.2.7
|
||||
version: 2.2.7
|
||||
'@tauri-apps/plugin-os':
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
@@ -86,6 +89,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^0.461.0
|
||||
version: 0.461.0(react@18.3.1)
|
||||
mdast-util-gfm-autolink-literal:
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
mermaid:
|
||||
specifier: ^11.6.0
|
||||
version: 11.6.0
|
||||
@@ -140,6 +146,9 @@ importers:
|
||||
tauri-plugin-windows-version-api:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
type-fest:
|
||||
specifier: ^4.41.0
|
||||
version: 4.41.0
|
||||
use-debounce:
|
||||
specifier: ^10.0.4
|
||||
version: 10.0.4(react@18.3.1)
|
||||
@@ -182,7 +191,7 @@ importers:
|
||||
version: 1.8.8
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.1(vite@5.4.19(@types/node@22.15.17)(sass@1.87.0))
|
||||
version: 4.4.1(vite@5.4.19(@types/node@22.15.17)(sass@1.87.0)(terser@5.40.0))
|
||||
autoprefixer:
|
||||
specifier: ^10.4.21
|
||||
version: 10.4.21(postcss@8.5.3)
|
||||
@@ -215,7 +224,7 @@ importers:
|
||||
version: 5.8.3
|
||||
vite:
|
||||
specifier: ^5.4.19
|
||||
version: 5.4.19(@types/node@22.15.17)(sass@1.87.0)
|
||||
version: 5.4.19(@types/node@22.15.17)(sass@1.87.0)(terser@5.40.0)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -813,6 +822,9 @@ packages:
|
||||
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/source-map@0.3.6':
|
||||
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0':
|
||||
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
|
||||
|
||||
@@ -1256,6 +1268,9 @@ packages:
|
||||
'@tauri-apps/plugin-log@2.4.0':
|
||||
resolution: {integrity: sha512-j7yrDtLNmayCBOO2esl3aZv9jSXy2an8MDLry3Ys9ZXerwUg35n1Y2uD8HoCR+8Ng/EUgx215+qOUfJasjYrHw==}
|
||||
|
||||
'@tauri-apps/plugin-opener@2.2.7':
|
||||
resolution: {integrity: sha512-uduEyvOdjpPOEeDRrhwlCspG/f9EQalHumWBtLBnp3fRp++fKGLqDOyUhSIn7PzX45b/rKep//ZQSAQoIxobLA==}
|
||||
|
||||
'@tauri-apps/plugin-os@2.2.1':
|
||||
resolution: {integrity: sha512-cNYpNri2CCc6BaNeB6G/mOtLvg8dFyFQyCUdf2y0K8PIAKGEWdEcu8DECkydU2B+oj4OJihDPD2de5K6cbVl9A==}
|
||||
|
||||
@@ -1583,6 +1598,9 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
bundle-name@4.1.0:
|
||||
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1695,6 +1713,9 @@ packages:
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
commander@4.1.1:
|
||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -2640,8 +2661,8 @@ packages:
|
||||
mdast-util-from-markdown@2.0.2:
|
||||
resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
|
||||
|
||||
mdast-util-gfm-autolink-literal@2.0.1:
|
||||
resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
|
||||
mdast-util-gfm-autolink-literal@2.0.0:
|
||||
resolution: {integrity: sha512-FyzMsduZZHSc3i0Px3PQcBT4WJY/X/RCtEJKuybiC6sjPqLv7h1yqAkmILZtuxMSsUyaLUWNp71+vQH2zqp5cg==}
|
||||
|
||||
mdast-util-gfm-footnote@2.1.0:
|
||||
resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
|
||||
@@ -3346,6 +3367,9 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
|
||||
|
||||
source-map@0.6.1:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3440,6 +3464,11 @@ packages:
|
||||
tauri-plugin-windows-version-api@2.0.0:
|
||||
resolution: {integrity: sha512-tty5n4ASYbXpnsD5ws2iTcTTpDCrSbzRTVp5Bo3UTpYGqlN1gBn2Zk8s3oO4w7VIM5WtJhDM9Jr/UgoTk7tFJQ==}
|
||||
|
||||
terser@5.40.0:
|
||||
resolution: {integrity: sha512-cfeKl/jjwSR5ar7d0FGmave9hFGJT8obyo0z+CrQOylLDbk7X81nPU6vq9VORa5jU30SkDnT2FXjLbR8HLP+xA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
thenify-all@1.6.0:
|
||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -4260,6 +4289,12 @@ snapshots:
|
||||
|
||||
'@jridgewell/set-array@1.2.1': {}
|
||||
|
||||
'@jridgewell/source-map@0.3.6':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.8
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
@@ -4637,6 +4672,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
|
||||
'@tauri-apps/plugin-opener@2.2.7':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
|
||||
'@tauri-apps/plugin-os@2.2.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
@@ -4878,14 +4917,14 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@vitejs/plugin-react@4.4.1(vite@5.4.19(@types/node@22.15.17)(sass@1.87.0))':
|
||||
'@vitejs/plugin-react@4.4.1(vite@5.4.19(@types/node@22.15.17)(sass@1.87.0)(terser@5.40.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.27.1
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.1)
|
||||
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.1)
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.17.0
|
||||
vite: 5.4.19(@types/node@22.15.17)(sass@1.87.0)
|
||||
vite: 5.4.19(@types/node@22.15.17)(sass@1.87.0)(terser@5.40.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -5014,6 +5053,9 @@ snapshots:
|
||||
node-releases: 2.0.19
|
||||
update-browserslist-db: 1.1.3(browserslist@4.24.5)
|
||||
|
||||
buffer-from@1.1.2:
|
||||
optional: true
|
||||
|
||||
bundle-name@4.1.0:
|
||||
dependencies:
|
||||
run-applescript: 7.0.0
|
||||
@@ -5110,6 +5152,9 @@ snapshots:
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
commander@2.20.3:
|
||||
optional: true
|
||||
|
||||
commander@4.1.1: {}
|
||||
|
||||
commander@7.2.0: {}
|
||||
@@ -6114,7 +6159,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm-autolink-literal@2.0.1:
|
||||
mdast-util-gfm-autolink-literal@2.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
ccount: 2.0.1
|
||||
@@ -6162,7 +6207,7 @@ snapshots:
|
||||
mdast-util-gfm@3.1.0:
|
||||
dependencies:
|
||||
mdast-util-from-markdown: 2.0.2
|
||||
mdast-util-gfm-autolink-literal: 2.0.1
|
||||
mdast-util-gfm-autolink-literal: 2.0.0
|
||||
mdast-util-gfm-footnote: 2.1.0
|
||||
mdast-util-gfm-strikethrough: 2.0.0
|
||||
mdast-util-gfm-table: 2.0.0
|
||||
@@ -7121,6 +7166,12 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
source-map: 0.6.1
|
||||
optional: true
|
||||
|
||||
source-map@0.6.1:
|
||||
optional: true
|
||||
|
||||
@@ -7240,6 +7291,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
|
||||
terser@5.40.0:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.6
|
||||
acorn: 8.14.1
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
optional: true
|
||||
|
||||
thenify-all@1.6.0:
|
||||
dependencies:
|
||||
thenify: 3.3.1
|
||||
@@ -7426,7 +7485,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.2
|
||||
|
||||
vite@5.4.19(@types/node@22.15.17)(sass@1.87.0):
|
||||
vite@5.4.19(@types/node@22.15.17)(sass@1.87.0)(terser@5.40.0):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.3
|
||||
@@ -7435,6 +7494,7 @@ snapshots:
|
||||
'@types/node': 22.15.17
|
||||
fsevents: 2.3.3
|
||||
sass: 1.87.0
|
||||
terser: 5.40.0
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
|
||||
1
scripts/devWeb.ts
Normal file
@@ -0,0 +1 @@
|
||||
(() => {})();
|
||||
84
src-tauri/Cargo.lock
generated
@@ -821,15 +821,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "coco"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"applications",
|
||||
"async-trait",
|
||||
"base64 0.13.1",
|
||||
"chinese-number",
|
||||
"chrono",
|
||||
"derive_more 2.0.1",
|
||||
"dirs 5.0.1",
|
||||
"enigo",
|
||||
"function_name",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"hostname",
|
||||
@@ -844,9 +847,12 @@ dependencies = [
|
||||
"ordered-float",
|
||||
"pizza-common",
|
||||
"plist",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_plain",
|
||||
"strsim 0.10.0",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-nspanel",
|
||||
@@ -860,6 +866,7 @@ dependencies = [
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-macos-permissions",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-os",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-screenshots",
|
||||
@@ -1290,6 +1297,27 @@ dependencies = [
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1825,6 +1853,21 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "function_name"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1ab577a896d09940b5fe12ec5ae71f9d8211fff62c919c03a3750a9901e98a7"
|
||||
dependencies = [
|
||||
"function_name-proc-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "function_name-proc-macro"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673464e1e314dd67a0fd9544abc99e8eb28d0c7e3b69b033bcff9b2d00b87333"
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
@@ -5327,7 +5370,7 @@ checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"derive_more 0.99.20",
|
||||
"fxhash",
|
||||
"log",
|
||||
"matches",
|
||||
@@ -5402,6 +5445,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_plain"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -6228,6 +6280,28 @@ dependencies = [
|
||||
"thiserror 2.0.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66644b71a31ec1a8a52c4a16575edd28cf763c87cf4a7da24c884122b5c77097"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
"objc2-app-kit 0.3.1",
|
||||
"objc2-foundation 0.3.1",
|
||||
"open",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.12",
|
||||
"url",
|
||||
"windows 0.61.1",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-os"
|
||||
version = "2.2.1"
|
||||
@@ -7039,6 +7113,12 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coco"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
description = "Search, connect, collaborate – all in one place."
|
||||
authors = ["INFINI Labs"]
|
||||
edition = "2021"
|
||||
@@ -44,7 +44,7 @@ use_pizza_engine = []
|
||||
[dependencies]
|
||||
pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "main" }
|
||||
|
||||
tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png", "unstable"] }
|
||||
tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png"] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# Need `arbitrary_precision` feature to support storing u128
|
||||
@@ -81,7 +81,7 @@ plist = "1.7"
|
||||
base64 = "0.13"
|
||||
walkdir = "2"
|
||||
log = "0.4"
|
||||
|
||||
strsim = "0.10"
|
||||
futures-util = "0.3.31"
|
||||
url = "2.5.2"
|
||||
http = "1.1.0"
|
||||
@@ -93,6 +93,12 @@ chinese-number = "0.7"
|
||||
num2words = "1"
|
||||
tauri-plugin-log = "2"
|
||||
chrono = "0.4.41"
|
||||
serde_plain = "1.0.2"
|
||||
derive_more = { version = "2.0.1", features = ["display"] }
|
||||
anyhow = "1.0.98"
|
||||
function_name = "0.3.0"
|
||||
regex = "1.11.1"
|
||||
tauri-plugin-opener = "2"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||
|
||||
8
src-tauri/assets/extension/AIOverview/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "AIOverview",
|
||||
"title": "AI Overview",
|
||||
"description": "...",
|
||||
"icon": "font_a-AIOverview",
|
||||
"type": "ai_extension",
|
||||
"enabled": true
|
||||
}
|
||||
9
src-tauri/assets/extension/Applications/plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "Applications",
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"title": "Applications",
|
||||
"description": "...",
|
||||
"icon": "font_Application",
|
||||
"type": "group",
|
||||
"enabled": true
|
||||
}
|
||||
9
src-tauri/assets/extension/Calculator/plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "Calculator",
|
||||
"title": "Calculator",
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"description": "...",
|
||||
"icon": "font_Calculator",
|
||||
"type": "calculator",
|
||||
"enabled": true
|
||||
}
|
||||
8
src-tauri/assets/extension/QuickAIAccess/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "QuickAIAccess",
|
||||
"title": "Quick AI Access",
|
||||
"description": "...",
|
||||
"icon": "font_a-QuickAIAccess",
|
||||
"type": "ai_extension",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -71,6 +71,7 @@
|
||||
"process:default",
|
||||
"updater:default",
|
||||
"windows-version:default",
|
||||
"log:default"
|
||||
"log:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly-2024-10-29"
|
||||
channel = "nightly-2025-02-28"
|
||||
@@ -1,10 +1,16 @@
|
||||
use crate::common;
|
||||
use crate::common::assistant::ChatRequestMessage;
|
||||
use crate::common::http::GetResponse;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::{common, server::servers::COCO_SERVERS};
|
||||
use futures::stream::FuturesUnordered;
|
||||
use futures::StreamExt;
|
||||
use futures_util::TryStreamExt;
|
||||
use http::Method;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn chat_history<R: Runtime>(
|
||||
@@ -141,8 +147,10 @@ pub async fn new_chat<R: Runtime>(
|
||||
|
||||
let body_text = common::http::get_response_body_text(response).await?;
|
||||
|
||||
let chat_response: GetResponse =
|
||||
serde_json::from_str(&body_text).map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
||||
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));
|
||||
@@ -179,6 +187,7 @@ pub async fn send_message<R: Runtime>(
|
||||
.await
|
||||
.map_err(|e| format!("Error cancel session: {}", e))?;
|
||||
|
||||
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
@@ -248,11 +257,180 @@ pub async fn assistant_search<R: Runtime>(
|
||||
None,
|
||||
Some(reqwest::Body::from(body.to_string())),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error searching assistants: {}", e))?;
|
||||
.await
|
||||
.map_err(|e| format!("Error searching assistants: {}", e))?;
|
||||
|
||||
response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assistant_get<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
assistant_id: String,
|
||||
) -> Result<Value, String> {
|
||||
let response = HttpClient::get(
|
||||
&server_id,
|
||||
&format!("/assistant/{}", assistant_id),
|
||||
None, // headers
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error getting assistant: {}", e))?;
|
||||
|
||||
response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
/// Gets the information of the assistant specified by `assistant_id` by querying **all**
|
||||
/// Coco servers.
|
||||
///
|
||||
/// 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>,
|
||||
assistant_id: String,
|
||||
) -> Result<Value, String> {
|
||||
let search_sources = app_handle.state::<SearchSourceRegistry>();
|
||||
let sources_future = search_sources.get_sources();
|
||||
let sources_list = sources_future.await;
|
||||
|
||||
let mut futures = FuturesUnordered::new();
|
||||
|
||||
for query_source in &sources_list {
|
||||
let query_source_type = query_source.get_type();
|
||||
if query_source_type.r#type != COCO_SERVERS {
|
||||
// Assistants only exists on Coco servers.
|
||||
continue;
|
||||
}
|
||||
|
||||
let coco_server_id = query_source_type.id.clone();
|
||||
|
||||
let path = format!("/assistant/{}", assistant_id);
|
||||
|
||||
let fut = async move {
|
||||
let res_response = HttpClient::get(
|
||||
&coco_server_id,
|
||||
&path,
|
||||
None, // headers
|
||||
)
|
||||
.await;
|
||||
match res_response {
|
||||
Ok(response) => response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
};
|
||||
|
||||
futures.push(fut);
|
||||
}
|
||||
|
||||
while let Some(res_response_json) = futures.next().await {
|
||||
let response_json = match res_response_json {
|
||||
Ok(json) => json,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
// Example response JSON
|
||||
//
|
||||
// When assistant is not found:
|
||||
// ```json
|
||||
// {
|
||||
// "_id": "ID",
|
||||
// "result": "not_found"
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// When assistant is found:
|
||||
// ```json
|
||||
// {
|
||||
// "_id": "ID",
|
||||
// "_source": {...}
|
||||
// "found": true
|
||||
// }
|
||||
// ```
|
||||
if let Some(found) = response_json.get("found") {
|
||||
if found == true {
|
||||
return Ok(response_json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"could not find Assistant [{}] on all the Coco servers",
|
||||
assistant_id
|
||||
))
|
||||
}
|
||||
|
||||
use regex::Regex;
|
||||
/// Remove all `"icon": "..."` fields from a JSON string
|
||||
pub fn remove_icon_fields(json: &str) -> String {
|
||||
// Regex to match `"icon": "..."` fields, including base64 or escaped strings
|
||||
let re = Regex::new(r#""icon"\s*:\s*"[^"]*"(,?)"#).unwrap();
|
||||
// Replace with empty string, or just remove trailing comma if needed
|
||||
re.replace_all(json, |caps: ®ex::Captures| {
|
||||
if &caps[1] == "," {
|
||||
"".to_string() // keep comma removal logic safe
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
}).to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ask_ai<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
message: String,
|
||||
server_id: String,
|
||||
assistant_id: String,
|
||||
client_id: String,
|
||||
) -> Result<(), String> {
|
||||
let cleaned = remove_icon_fields(message.as_str());
|
||||
|
||||
let body = serde_json::json!({ "message": cleaned });
|
||||
|
||||
let path = format!("/assistant/{}/_ask", assistant_id);
|
||||
|
||||
println!("Sending request to {}", &path);
|
||||
|
||||
let response = HttpClient::send_request(
|
||||
server_id.as_str(),
|
||||
Method::POST,
|
||||
path.as_str(),
|
||||
None,
|
||||
None,
|
||||
Some(reqwest::Body::from(body.to_string())),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if response.status() == 429 {
|
||||
log::warn!("Rate limit exceeded for assistant: {}", &assistant_id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Request Failed: {}", response.status()));
|
||||
}
|
||||
|
||||
let stream = response.bytes_stream();
|
||||
let reader = tokio_util::io::StreamReader::new(
|
||||
stream.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)),
|
||||
);
|
||||
let mut lines = tokio::io::BufReader::new(reader).lines();
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
dbg!("Received line: {}", &line);
|
||||
|
||||
let _ = app_handle.emit(&client_id, line).map_err(|err| {
|
||||
println!("Failed to emit: {:?}", err);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,38 +3,43 @@ use std::{fs::create_dir, io::Read};
|
||||
use tauri::{Manager, Runtime};
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
|
||||
// Start or stop according to configuration
|
||||
pub fn enable_autostart(app: &mut tauri::App) {
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
|
||||
app.handle()
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
MacosLauncher::AppleScript,
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
/// If the state reported from the OS and the state stored by us differ, our state is
|
||||
/// prioritized and seen as the correct one. Update the OS state to make them consistent.
|
||||
pub fn ensure_autostart_state_consistent(app: &mut tauri::App) -> Result<(), String> {
|
||||
let autostart_manager = app.autolaunch();
|
||||
|
||||
// close autostart
|
||||
// autostart_manager.disable().unwrap();
|
||||
// return;
|
||||
let os_state = autostart_manager.is_enabled().map_err(|e| e.to_string())?;
|
||||
let coco_stored_state = current_autostart(app.app_handle()).map_err(|e| e.to_string())?;
|
||||
|
||||
match (
|
||||
autostart_manager.is_enabled(),
|
||||
current_autostart(app.app_handle()),
|
||||
) {
|
||||
(Ok(false), Ok(true)) => match autostart_manager.enable() {
|
||||
Ok(_) => println!("Autostart enabled successfully."),
|
||||
Err(err) => eprintln!("Failed to enable autostart: {}", err),
|
||||
},
|
||||
(Ok(true), Ok(false)) => match autostart_manager.disable() {
|
||||
Ok(_) => println!("Autostart disable successfully."),
|
||||
Err(err) => eprintln!("Failed to disable autostart: {}", err),
|
||||
},
|
||||
_ => (),
|
||||
if os_state != coco_stored_state {
|
||||
log::warn!(
|
||||
"autostart inconsistent states, OS state [{}], Coco state [{}], config file could be deleted or corrupted",
|
||||
os_state,
|
||||
coco_stored_state
|
||||
);
|
||||
log::info!("trying to correct the inconsistent states");
|
||||
|
||||
let result = if coco_stored_state {
|
||||
autostart_manager.enable()
|
||||
} else {
|
||||
autostart_manager.disable()
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
log::info!("inconsistent autostart states fixed");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"failed to fix inconsistent autostart state due to error [{}]",
|
||||
e
|
||||
);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, String> {
|
||||
|
||||
@@ -9,13 +9,13 @@ pub struct ChatRequestMessage {
|
||||
#[allow(dead_code)]
|
||||
pub struct NewChatResponse {
|
||||
pub _id: String,
|
||||
pub _source: Source,
|
||||
pub _source: Session,
|
||||
pub result: String,
|
||||
pub payload: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Source {
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub created: String,
|
||||
pub updated: String,
|
||||
@@ -23,4 +23,11 @@ pub struct Source {
|
||||
pub title: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub manually_renamed_title: bool,
|
||||
pub visible: Option<bool>,
|
||||
pub context: Option<SessionContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SessionContext {
|
||||
pub attachments: Option<Vec<String>>,
|
||||
}
|
||||
@@ -29,6 +29,71 @@ pub struct EditorInfo {
|
||||
pub timestamp: Option<String>,
|
||||
}
|
||||
|
||||
/// Defines the action that would be performed when a document gets opened.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) enum OnOpened {
|
||||
/// Launch the application
|
||||
Application { app_path: String },
|
||||
/// Open the URL.
|
||||
Document { url: String },
|
||||
/// Spawn a child process to run the `CommandAction`.
|
||||
Command {
|
||||
action: crate::extension::CommandAction,
|
||||
},
|
||||
}
|
||||
|
||||
impl OnOpened {
|
||||
pub(crate) fn url(&self) -> String {
|
||||
match self {
|
||||
Self::Application { app_path } => app_path.clone(),
|
||||
Self::Document { url } => url.clone(),
|
||||
Self::Command { action } => {
|
||||
const WHITESPACE: &str = " ";
|
||||
let mut ret = action.exec.clone();
|
||||
ret.push_str(WHITESPACE);
|
||||
ret.push_str(action.args.join(WHITESPACE).as_str());
|
||||
|
||||
ret
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn open(on_opened: OnOpened) -> Result<(), String> {
|
||||
log::debug!("open({})", on_opened.url());
|
||||
|
||||
use crate::util::open as homemade_tauri_shell_open;
|
||||
use crate::GLOBAL_TAURI_APP_HANDLE;
|
||||
use std::process::Command;
|
||||
|
||||
let global_tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
|
||||
match on_opened {
|
||||
OnOpened::Application { app_path } => {
|
||||
homemade_tauri_shell_open(global_tauri_app_handle.clone(), app_path).await?
|
||||
}
|
||||
OnOpened::Document { url } => {
|
||||
homemade_tauri_shell_open(global_tauri_app_handle.clone(), url).await?
|
||||
}
|
||||
OnOpened::Command { action } => {
|
||||
let mut cmd = Command::new(action.exec);
|
||||
cmd.args(action.args);
|
||||
let output = cmd.output().map_err(|e| e.to_string())?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"Command failed, stderr [{}]",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Document {
|
||||
pub id: String,
|
||||
@@ -48,6 +113,8 @@ pub struct Document {
|
||||
pub thumbnail: Option<String>,
|
||||
pub cover: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
/// What will happen if we open this document.
|
||||
pub on_opened: Option<OnOpened>,
|
||||
pub url: Option<String>,
|
||||
pub size: Option<i64>,
|
||||
pub metadata: Option<HashMap<String, serde_json::Value>>,
|
||||
|
||||
@@ -2,32 +2,52 @@ use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ErrorDetail {
|
||||
pub reason: String,
|
||||
pub status: u16,
|
||||
#[allow(dead_code)]
|
||||
pub struct ErrorCause {
|
||||
#[serde(default)]
|
||||
pub r#type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ErrorDetail {
|
||||
#[serde(default)]
|
||||
pub root_cause: Option<Vec<ErrorCause>>,
|
||||
#[serde(default)]
|
||||
pub r#type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub caused_by: Option<ErrorCause>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: ErrorDetail,
|
||||
#[serde(default)]
|
||||
pub error: Option<ErrorDetail>,
|
||||
#[serde(default)]
|
||||
pub status: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Serialize)]
|
||||
pub enum SearchError {
|
||||
#[error("HTTP request failed: {0}")]
|
||||
#[error("HttpError: {0}")]
|
||||
HttpError(String),
|
||||
|
||||
#[error("Invalid response format: {0}")]
|
||||
#[error("ParseError: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Timeout occurred")]
|
||||
Timeout,
|
||||
|
||||
#[error("Unknown error: {0}")]
|
||||
#[error("UnknownError: {0}")]
|
||||
#[allow(dead_code)]
|
||||
Unknown(String),
|
||||
|
||||
#[error("InternalError error: {0}")]
|
||||
#[error("InternalError: {0}")]
|
||||
#[allow(dead_code)]
|
||||
InternalError(String),
|
||||
}
|
||||
@@ -42,4 +62,4 @@ impl From<reqwest::Error> for SearchError {
|
||||
SearchError::HttpError(err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,17 +36,21 @@ pub async fn get_response_body_text(response: Response) -> Result<String, String
|
||||
return Err(fallback_error);
|
||||
}
|
||||
|
||||
|
||||
match serde_json::from_str::<common::error::ErrorResponse>(&body) {
|
||||
Ok(parsed_error) => {
|
||||
dbg!(&parsed_error);
|
||||
Err(format!(
|
||||
"Server error ({}): {}",
|
||||
parsed_error.error.status, parsed_error.error.reason
|
||||
"Server error ({}): {:?}",
|
||||
status, parsed_error.error
|
||||
))
|
||||
}
|
||||
Err(_) => Err(fallback_error),
|
||||
Err(_) => {
|
||||
log::warn!("Failed to parse error response: {}", &body);
|
||||
Err(fallback_error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ pub struct Shards {
|
||||
pub struct Hits<T> {
|
||||
pub total: Total,
|
||||
pub max_score: Option<f32>,
|
||||
pub hits: Vec<SearchHit<T>>,
|
||||
pub hits: Option<Vec<SearchHit<T>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -36,9 +36,9 @@ pub struct Total {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SearchHit<T> {
|
||||
pub _index: String,
|
||||
pub _type: String,
|
||||
pub _id: String,
|
||||
pub _index: Option<String>,
|
||||
pub _type: Option<String>,
|
||||
pub _id: Option<String>,
|
||||
pub _score: Option<f64>,
|
||||
pub _source: T, // This will hold the type we pass in (e.g., DataSource)
|
||||
}
|
||||
@@ -58,13 +58,18 @@ where
|
||||
Ok(search_response)
|
||||
}
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
pub async fn parse_search_hits<T>(response: Response) -> Result<Vec<SearchHit<T>>, Box<dyn Error>>
|
||||
where
|
||||
T: for<'de> Deserialize<'de> + std::fmt::Debug,
|
||||
T: DeserializeOwned + std::fmt::Debug,
|
||||
{
|
||||
let response = parse_search_response(response).await?;
|
||||
|
||||
Ok(response.hits.hits)
|
||||
match response.hits.hits {
|
||||
Some(hits) => Ok(hits),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn parse_search_results<T>(response: Response) -> Result<Vec<T>, Box<dyn Error>>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::common::health::Health;
|
||||
use crate::common::profile::UserProfile;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -60,6 +62,7 @@ pub struct Server {
|
||||
pub auth_provider: AuthProvider,
|
||||
#[serde(default = "default_priority_type")]
|
||||
pub priority: u32,
|
||||
pub stats: Option<HashMap<String, Value>>,
|
||||
}
|
||||
|
||||
impl PartialEq for Server {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::common::error::SearchError;
|
||||
// use std::{future::Future, pin::Pin};
|
||||
use crate::common::search::SearchQuery;
|
||||
use crate::common::search::{QueryResponse, QuerySource};
|
||||
use async_trait::async_trait;
|
||||
@@ -10,4 +9,3 @@ pub trait SearchSource: Send + Sync {
|
||||
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>;
|
||||
}
|
||||
|
||||
|
||||
1
src-tauri/src/extension/built_in/ai_overview.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub(super) const EXTENSION_ID: &str = "AIOverview";
|
||||
@@ -12,7 +12,6 @@ pub use with_feature::*;
|
||||
#[cfg(not(feature = "use_pizza_engine"))]
|
||||
pub use without_feature::*;
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppEntry {
|
||||
@@ -24,15 +23,13 @@ pub struct AppEntry {
|
||||
is_disabled: bool,
|
||||
}
|
||||
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppMetadata {
|
||||
name: String,
|
||||
r#where: String,
|
||||
size: u64,
|
||||
icon: String,
|
||||
created: u128,
|
||||
modified: u128,
|
||||
last_opened: u128,
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
use super::super::SearchSourceState;
|
||||
use super::super::Task;
|
||||
use super::super::RUNTIME_TX;
|
||||
use super::AppEntry;
|
||||
use super::super::pizza_engine_runtime::SearchSourceState;
|
||||
use super::super::pizza_engine_runtime::Task;
|
||||
use super::super::pizza_engine_runtime::RUNTIME_TX;
|
||||
use super::super::Extension;
|
||||
use super::AppMetadata;
|
||||
use crate::common::document::{DataSourceReference, Document};
|
||||
use crate::common::document::{DataSourceReference, Document, OnOpened};
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::util::open;
|
||||
use crate::GLOBAL_TAURI_APP_HANDLE;
|
||||
use applications::{App, AppTrait};
|
||||
use async_trait::async_trait;
|
||||
use log::{debug, info, warn};
|
||||
use log::{error, warn};
|
||||
use pizza_engine::document::FieldType;
|
||||
use pizza_engine::document::{
|
||||
Document as PizzaEngineDocument, DraftDoc as PizzaEngineDraftDoc, FieldValue,
|
||||
@@ -24,12 +25,13 @@ use pizza_engine::store::{DiskStore, DiskStoreSnapshot};
|
||||
use pizza_engine::writer::Writer;
|
||||
use pizza_engine::{doc, Engine, EngineBuilder};
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{async_runtime, AppHandle, Manager, Runtime};
|
||||
use tauri_plugin_fs_pro::{icon, metadata, name, IconOptions};
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use tauri_plugin_global_shortcut::Shortcut;
|
||||
use tauri_plugin_global_shortcut::ShortcutEvent;
|
||||
use tauri_plugin_global_shortcut::ShortcutState;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::sync::oneshot::Sender as OneshotSender;
|
||||
|
||||
@@ -112,10 +114,10 @@ fn get_app_path(app: &App) -> String {
|
||||
|
||||
/// Helper function to return `app`'s path.
|
||||
///
|
||||
/// * Windows/macOS: extract `app_path`'s file name and remove the file extension
|
||||
/// * Linux: return the name specified in `.desktop` file
|
||||
/// * macOS: extract `app_path`'s file name and remove the file extension
|
||||
/// * Windows/Linux: return the name specified in `.desktop` file
|
||||
async fn get_app_name(app: &App) -> String {
|
||||
if cfg!(target_os = "linux") {
|
||||
if cfg!(any(target_os = "linux", target_os = "windows")) {
|
||||
app.name.clone()
|
||||
} else {
|
||||
let app_path = get_app_path(app);
|
||||
@@ -191,6 +193,9 @@ macro_rules! task_exec_try {
|
||||
};
|
||||
}
|
||||
|
||||
// Fields `engine` and `writer` become unused without app list synchronizer, allow
|
||||
// this rather than removing these fields as we will bring the synchronizer back.
|
||||
#[allow(dead_code)]
|
||||
struct ApplicationSearchSourceState {
|
||||
engine: Engine<DiskStore>,
|
||||
writer: Writer<DiskStore>,
|
||||
@@ -263,20 +268,29 @@ impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
|
||||
get_app_icon_path(&self.tauri_app_handle, app).await,
|
||||
callback
|
||||
);
|
||||
let app_alias = get_app_alias(&self.tauri_app_handle, &app_path).unwrap_or(String::new());
|
||||
let app_alias =
|
||||
get_app_alias(&self.tauri_app_handle, &app_path).unwrap_or(String::new());
|
||||
|
||||
if app_name.is_empty() || app_name.eq(&self.tauri_app_handle.package_info().name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let document = doc!( app_path, {
|
||||
FIELD_APP_NAME => app_name,
|
||||
// You cannot write `app_name.clone()` within the `doc!()` macro, we should fix this.
|
||||
let app_name_clone = app_name.clone();
|
||||
let app_path_clone = app_path.clone();
|
||||
let document = doc!( app_path_clone, {
|
||||
FIELD_APP_NAME => app_name_clone,
|
||||
FIELD_ICON_PATH => app_icon_path,
|
||||
FIELD_APP_ALIAS => app_alias,
|
||||
}
|
||||
);
|
||||
|
||||
task_exec_try!(writer.create_document(document).await, callback);
|
||||
// We don't error out because one failure won't break the whole thing
|
||||
if let Err(e) = writer.create_document(document).await {
|
||||
warn!(
|
||||
"failed to index application [app name: '{}', app path: '{}'] due to error [{}]", app_name, app_path, e
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
task_exec_try!(writer.commit(), callback);
|
||||
@@ -312,11 +326,22 @@ impl<R: Runtime> Task for SearchApplicationsTask<R> {
|
||||
|
||||
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>) {
|
||||
let callback = self.callback.take().unwrap();
|
||||
let disabled_app_list = get_disabled_app_list(self.tauri_app_handle.clone());
|
||||
let disabled_app_list = get_disabled_app_list(&self.tauri_app_handle);
|
||||
|
||||
// TODO: search via alias, implement this when Pizza engine supports update
|
||||
//
|
||||
// NOTE: we use the Debug impl rather than Display for `self.query_string` as String's Debug
|
||||
// impl won't interrupt escape characters. So for input like:
|
||||
//
|
||||
// ```text
|
||||
// Google
|
||||
// Chrome
|
||||
// ```
|
||||
//
|
||||
// It will be passed to Pizza like "Google\nChrome". Using Display impl would result
|
||||
// in an invalid query DSL and serde will complain.
|
||||
let dsl = format!(
|
||||
"{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME}\": \"{}\" }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": \"{}\" }} }} ] }} }} }}", self.query_string, self.query_string);
|
||||
"{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": {:?} }} }} ] }} }} }}", self.query_string, self.query_string);
|
||||
|
||||
let state = state
|
||||
.as_mut()
|
||||
@@ -359,6 +384,10 @@ impl<R: Runtime> Task for SearchApplicationsTask<R> {
|
||||
/// 2. New search paths have been added by the user
|
||||
///
|
||||
/// We use this task to index them.
|
||||
//
|
||||
// This become unused without app list synchronizer, allow this rather than
|
||||
// removing the task as we will bring the synchronizer back.
|
||||
#[allow(dead_code)]
|
||||
struct IndexNewApplicationsTask {
|
||||
applications: Vec<PizzaEngineDraftDoc>,
|
||||
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
|
||||
@@ -412,7 +441,13 @@ impl ApplicationSearchSource {
|
||||
.send(Box::new(index_applications_task))
|
||||
.unwrap();
|
||||
|
||||
rx.await.unwrap()?;
|
||||
let indexing_applications_result = rx.await.unwrap();
|
||||
if let Err(ref e) = indexing_applications_result {
|
||||
error!(
|
||||
"indexing local applications failed, app search won't work, error [{}]",
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
app_handle
|
||||
.store(TAURI_STORE_APP_HOTKEY)
|
||||
@@ -439,112 +474,6 @@ impl ApplicationSearchSource {
|
||||
|
||||
register_app_hotkey_upon_start(app_handle.clone())?;
|
||||
|
||||
let app_handle_clone = app_handle.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("local app search - app list synchronizer".into())
|
||||
.spawn(move || {
|
||||
let tokio_rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to start a tokio runtime");
|
||||
|
||||
tokio_rt.block_on(async move {
|
||||
info!("app list synchronizer started");
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60 * 2)).await;
|
||||
debug!("app list synchronizer working");
|
||||
|
||||
let stored_app_list = get_app_list(app_handle_clone.clone())
|
||||
.await
|
||||
.expect("failed to fetch the stored app list");
|
||||
let store = app_handle_clone
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
.unwrap_or_else(|_e| {
|
||||
panic!(
|
||||
"store [{}] not found/loaded",
|
||||
TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH
|
||||
)
|
||||
});
|
||||
let search_path_json =
|
||||
store.get(TAURI_STORE_KEY_SEARCH_PATH).unwrap_or_else(|| {
|
||||
panic!("key [{}] not found", TAURI_STORE_KEY_SEARCH_PATH)
|
||||
});
|
||||
let search_paths: Vec<String> = match search_path_json {
|
||||
Json::Array(array) => array
|
||||
.into_iter()
|
||||
.map(|json| match json {
|
||||
Json::String(str) => str,
|
||||
_ => unreachable!("search path should be a string"),
|
||||
})
|
||||
.collect(),
|
||||
_ => unreachable!("search paths should be stored in an array"),
|
||||
};
|
||||
let mut current_app_list = list_app_in(search_paths).unwrap_or_else(|e| {
|
||||
panic!("failed to fetch app list due to error [{}]", e)
|
||||
});
|
||||
// filter out Coco-AI
|
||||
current_app_list.retain(|app| app.name != app_handle.package_info().name);
|
||||
|
||||
let current_app_list_path_hash_index = {
|
||||
let mut index = HashMap::new();
|
||||
for (idx, app) in current_app_list.iter().enumerate() {
|
||||
index.insert(get_app_path(app), idx);
|
||||
}
|
||||
|
||||
index
|
||||
};
|
||||
let current_app_path_list: HashSet<String> =
|
||||
current_app_list.iter().map(get_app_path).collect();
|
||||
let stored_app_path_list: HashSet<String> = stored_app_list
|
||||
.iter()
|
||||
.map(|app_entry| app_entry.path.clone())
|
||||
.collect();
|
||||
|
||||
let new_apps = current_app_path_list.difference(&stored_app_path_list);
|
||||
debug!("found new apps [{:?}]", new_apps);
|
||||
|
||||
// Synchronize the stored app list
|
||||
let mut new_apps_pizza_engine_documents = Vec::new();
|
||||
|
||||
for new_app_path in new_apps {
|
||||
let idx = *current_app_list_path_hash_index.get(new_app_path).unwrap();
|
||||
let new_app = current_app_list.get(idx).unwrap();
|
||||
let new_app_name = get_app_name(new_app).await;
|
||||
let new_app_icon_path =
|
||||
get_app_icon_path(&app_handle_clone, new_app).await.unwrap();
|
||||
let new_app_alias = get_app_alias(&app_handle_clone, &new_app_path).unwrap_or(String::new());
|
||||
|
||||
let new_app_pizza_engine_document = doc!(new_app_path.clone(), {
|
||||
FIELD_APP_NAME => new_app_name,
|
||||
FIELD_ICON_PATH => new_app_icon_path,
|
||||
FIELD_APP_ALIAS => new_app_alias,
|
||||
}
|
||||
);
|
||||
|
||||
new_apps_pizza_engine_documents.push(new_app_pizza_engine_document);
|
||||
}
|
||||
|
||||
let (callback, wait_for_complete) = tokio::sync::oneshot::channel();
|
||||
let index_new_apps_task = Box::new(IndexNewApplicationsTask {
|
||||
applications: new_apps_pizza_engine_documents,
|
||||
callback: Some(callback),
|
||||
});
|
||||
RUNTIME_TX
|
||||
.get()
|
||||
.unwrap()
|
||||
.send(index_new_apps_task)
|
||||
.expect("rx dropped, pizza runtime could possibly be dead");
|
||||
wait_for_complete
|
||||
.await
|
||||
.expect("tx dropped, pizza runtime could possibly be dead")
|
||||
.unwrap_or_else(|e| {
|
||||
panic!("failed to index new apps due to error [{}]", e)
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -633,19 +562,24 @@ fn pizza_engine_hits_to_coco_hits(
|
||||
FieldValue::Text(string) => string,
|
||||
_ => unreachable!("field icon is of type Text"),
|
||||
};
|
||||
let on_opened = OnOpened::Application {
|
||||
app_path: app_path.clone(),
|
||||
};
|
||||
let url = on_opened.url();
|
||||
|
||||
let coco_document = Document {
|
||||
source: Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into()),
|
||||
id: Some(QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into()),
|
||||
icon: None,
|
||||
icon: Some(String::from("font_Application")),
|
||||
}),
|
||||
id: app_path.clone(),
|
||||
category: Some("Application".to_string()),
|
||||
title: Some(app_name.clone()),
|
||||
url: Some(app_path),
|
||||
icon: Some(app_icon_path),
|
||||
on_opened: Some(on_opened),
|
||||
url: Some(url),
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
@@ -656,35 +590,54 @@ fn pizza_engine_hits_to_coco_hits(
|
||||
coco_hits
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_app_alias<R: Runtime>(tauri_app_handle: AppHandle<R>, app_path: String, alias: String) {
|
||||
let store = tauri_app_handle.store(TAURI_STORE_APP_ALIAS).unwrap_or_else(|_| {
|
||||
panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS)
|
||||
});
|
||||
pub fn set_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, 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));
|
||||
|
||||
store.set(app_path, alias);
|
||||
|
||||
// TODO: When pizza supports update, update index if this app's document exists there.
|
||||
//
|
||||
// NOTE: possible (depends on how we impl concurrency control in Pizza) TOCTOU: document gets
|
||||
//
|
||||
// NOTE: possible (depends on how we impl concurrency control in Pizza) TOCTOU: document gets
|
||||
// deleted while updating it.
|
||||
}
|
||||
|
||||
fn get_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, 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)
|
||||
});
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_ALIAS)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
|
||||
|
||||
let json = store.get(app_path)?;
|
||||
|
||||
let string = match json {
|
||||
Json::String(s) => s,
|
||||
_ => unreachable!("app alias should be stored in a string"),
|
||||
Json::String(s) => s,
|
||||
_ => unreachable!("app alias should be stored in a string"),
|
||||
};
|
||||
|
||||
Some(string)
|
||||
}
|
||||
|
||||
/// 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>(
|
||||
app_path: String,
|
||||
) -> impl Fn(&AppHandle<R>, &Shortcut, ShortcutEvent) + Send + Sync + 'static {
|
||||
move |tauri_app_handle, _hot_key, event| {
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
let app_path_clone = app_path.clone();
|
||||
let tauri_app_handle_clone = tauri_app_handle.clone();
|
||||
// This closure will be executed on the main thread, so we spawn to reduce the potential UI lag.
|
||||
async_runtime::spawn(async move {
|
||||
if let Err(e) = open(tauri_app_handle_clone, app_path_clone).await {
|
||||
warn!("failed to open app due to [{}]", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn register_app_hotkey_upon_start<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
@@ -700,73 +653,49 @@ fn register_app_hotkey_upon_start<R: Runtime>(
|
||||
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.on_shortcut(
|
||||
hotkey.as_str(),
|
||||
move |tauri_app_handle, _hot_key, _event| {
|
||||
let app_path_clone = app_path.clone();
|
||||
let tauri_app_handle_clone = tauri_app_handle.clone();
|
||||
// This closure will be executed on the main thread, so we spawn to reduce the potential UI lag.
|
||||
async_runtime::spawn(async move {
|
||||
if let Err(e) = open(tauri_app_handle_clone, app_path_clone).await {
|
||||
warn!("failed to open app due to [{}]", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
.on_shortcut(hotkey.as_str(), app_hotkey_handler(app_path))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register_app_hotkey<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
app_path: String,
|
||||
hotkey: String,
|
||||
pub fn register_app_hotkey<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app_path: &str,
|
||||
hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
// Ignore the error as it may not be registered
|
||||
unregister_app_hotkey(tauri_app_handle, app_path)?;
|
||||
|
||||
let app_hotkey_store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_HOTKEY)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
|
||||
|
||||
app_hotkey_store.set(app_path.clone(), hotkey.as_str());
|
||||
app_hotkey_store.set(app_path, hotkey);
|
||||
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.on_shortcut(
|
||||
hotkey.as_str(),
|
||||
move |tauri_app_handle, _hot_key, _event| {
|
||||
let app_path_clone = app_path.clone();
|
||||
let tauri_app_handle_clone = tauri_app_handle.clone();
|
||||
// This closure will be executed on the main thread, so we spawn to reduce the potential UI lag.
|
||||
async_runtime::spawn(async move {
|
||||
if let Err(e) = open(tauri_app_handle_clone, app_path_clone).await {
|
||||
warn!("failed to open app due to [{}]", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
.on_shortcut(hotkey, app_hotkey_handler(app_path.into()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn unregister_app_hotkey<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
app_path: String,
|
||||
pub fn unregister_app_hotkey<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
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));
|
||||
|
||||
let Some(hotkey) = app_hotkey_store.get(app_path.as_str()) else {
|
||||
let error_msg = format!(
|
||||
let Some(hotkey) = app_hotkey_store.get(app_path) else {
|
||||
warn!(
|
||||
"unregister an Application hotkey that does not exist app: [{}]",
|
||||
app_path,
|
||||
);
|
||||
warn!("{}", error_msg);
|
||||
return Err(error_msg);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let hotkey = match hotkey {
|
||||
@@ -774,11 +703,18 @@ pub async fn unregister_app_hotkey<R: Runtime>(
|
||||
_ => unreachable!("hotkey should be stored in a string"),
|
||||
};
|
||||
|
||||
let deleted = app_hotkey_store.delete(app_path.as_str());
|
||||
let deleted = app_hotkey_store.delete(app_path);
|
||||
if !deleted {
|
||||
return Err("failed to delete application hotkey from store".into());
|
||||
}
|
||||
|
||||
if !tauri_app_handle
|
||||
.global_shortcut()
|
||||
.is_registered(hotkey.as_str())
|
||||
{
|
||||
panic!("inconsistent state, tauri store a hotkey is stored in the tauri store but it is not registered");
|
||||
}
|
||||
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.unregister(hotkey.as_str())
|
||||
@@ -787,7 +723,7 @@ pub async 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<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Vec<String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -814,10 +750,19 @@ fn get_disabled_app_list<R: Runtime>(tauri_app_handle: AppHandle<R>) -> Vec<Stri
|
||||
disabled_app_list
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disable_app_search<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
app_path: String,
|
||||
pub fn is_app_search_enabled(app_path: &str) -> bool {
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
|
||||
let disabled_app_list = get_disabled_app_list(tauri_app_handle);
|
||||
|
||||
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> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
@@ -830,24 +775,26 @@ pub async fn disable_app_search<R: Runtime>(
|
||||
|
||||
let mut disabled_app_list = get_disabled_app_list(tauri_app_handle);
|
||||
|
||||
if disabled_app_list.contains(&app_path) {
|
||||
if disabled_app_list
|
||||
.iter()
|
||||
.any(|disabled_app| disabled_app == app_path)
|
||||
{
|
||||
return Err(format!(
|
||||
"trying to disable an app that is disabled [{}]",
|
||||
app_path
|
||||
));
|
||||
}
|
||||
|
||||
disabled_app_list.push(app_path);
|
||||
disabled_app_list.push(app_path.into());
|
||||
|
||||
store.set(TAURI_STORE_KEY_DISABLED_APP_LIST, disabled_app_list);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enable_app_search<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
app_path: String,
|
||||
pub fn enable_app_search<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
@@ -961,7 +908,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<AppEntry>, String> {
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
let search_paths = get_app_search_path(tauri_app_handle.clone()).await;
|
||||
let apps = list_app_in(search_paths)?;
|
||||
|
||||
@@ -992,14 +939,12 @@ pub async fn get_app_list<R: Runtime>(
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_HOTKEY)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
|
||||
let opt_string = store.get(&path).map(|json| match json {
|
||||
store.get(&path).map(|json| match json {
|
||||
Json::String(s) => s,
|
||||
_ => unreachable!("app hotkey should be stored in a string"),
|
||||
});
|
||||
|
||||
opt_string.unwrap_or(String::new())
|
||||
})
|
||||
};
|
||||
let is_disabled = {
|
||||
let enabled = {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
|
||||
@@ -1014,25 +959,36 @@ pub async fn get_app_list<R: Runtime>(
|
||||
});
|
||||
|
||||
let disabled_app_list = match disabled_app_list_json {
|
||||
Json::Array(v) => v.into_iter().map(|json| {
|
||||
match json {
|
||||
Json::Array(v) => v
|
||||
.into_iter()
|
||||
.map(|json| match json {
|
||||
Json::String(str) => str,
|
||||
_ => unreachable!("app path should be stored in a string"),
|
||||
}
|
||||
}).collect::<Vec<String>>(),
|
||||
})
|
||||
.collect::<Vec<String>>(),
|
||||
_ => unreachable!("disabled app list should be stored in an array"),
|
||||
};
|
||||
|
||||
disabled_app_list.contains(&path)
|
||||
!disabled_app_list.contains(&path)
|
||||
};
|
||||
|
||||
let app_entry = AppEntry {
|
||||
path,
|
||||
name,
|
||||
icon_path,
|
||||
alias,
|
||||
let app_entry = Extension {
|
||||
id: path,
|
||||
title: name,
|
||||
platforms: None,
|
||||
// Leave it empty as it won't be used
|
||||
description: String::new(),
|
||||
icon: icon_path,
|
||||
r#type: ExtensionType::Application,
|
||||
action: None,
|
||||
quick_link: None,
|
||||
commands: None,
|
||||
scripts: None,
|
||||
quick_links: None,
|
||||
alias: Some(alias),
|
||||
hotkey,
|
||||
is_disabled,
|
||||
enabled,
|
||||
settings: None,
|
||||
};
|
||||
|
||||
app_entries.push(app_entry);
|
||||
@@ -1042,15 +998,7 @@ pub async fn get_app_list<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_metadata<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
app_path: String,
|
||||
) -> Result<AppMetadata, String> {
|
||||
let app =
|
||||
App::from_path(std::path::Path::new(&app_path)).expect("frontend sends an invalid app");
|
||||
|
||||
let app_path = get_app_path(&app);
|
||||
let app_name = get_app_name(&app).await;
|
||||
pub async fn get_app_metadata(app_name: String, app_path: String) -> Result<AppMetadata, String> {
|
||||
let app_path_where = {
|
||||
let app_path_borrowed_path = std::path::Path::new(app_path.as_str());
|
||||
let app_path_where = app_path_borrowed_path
|
||||
@@ -1062,23 +1010,28 @@ pub async fn get_app_metadata<R: Runtime>(
|
||||
.expect("it is guaranteed to be UTF-8 encoded")
|
||||
.to_string()
|
||||
};
|
||||
let icon = get_app_icon_path(&tauri_app_handle, &app).await?;
|
||||
|
||||
let raw_app_metadata = metadata(app_path.into(), None).await?;
|
||||
let raw_app_metadata = metadata(app_path.clone().into(), None).await?;
|
||||
|
||||
let last_opened = if cfg!(any(target_os = "macos", target_os = "windows")) {
|
||||
let app_exe_path = app.app_path_exe.as_ref().expect("exe path should be Some").clone();
|
||||
let last_opened = if cfg!(target_os = "macos") {
|
||||
let app = App::from_path(std::path::Path::new(&app_path))
|
||||
.unwrap_or_else(|e| panic!("App::from_path({}) failed due to error '{}'", app_path, e));
|
||||
|
||||
let app_exe_path = app
|
||||
.app_path_exe
|
||||
.as_ref()
|
||||
.expect("exe path should be Some")
|
||||
.clone();
|
||||
let raw_app_exe_metadata = metadata(app_exe_path, None).await?;
|
||||
raw_app_exe_metadata.accessed_at
|
||||
} else {
|
||||
} else {
|
||||
raw_app_metadata.accessed_at
|
||||
};
|
||||
};
|
||||
|
||||
Ok(AppMetadata {
|
||||
name: app_name,
|
||||
r#where: app_path_where,
|
||||
size: raw_app_metadata.size,
|
||||
icon,
|
||||
created: raw_app_metadata.created_at,
|
||||
modified: raw_app_metadata.modified_at,
|
||||
last_opened,
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::super::Extension;
|
||||
use super::AppMetadata;
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use async_trait::async_trait;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use super::AppEntry;
|
||||
use super::AppMetadata;
|
||||
|
||||
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
|
||||
|
||||
@@ -39,46 +39,45 @@ impl SearchSource for ApplicationSearchSource {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_app_alias(_app_path: String, _alias: String) -> Result<(), String> {
|
||||
pub fn set_app_alias<R: Runtime>(_tauri_app_handle: &AppHandle<R>, _app_path: &str, _alias: &str) {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_app_path: String,
|
||||
_hotkey: String,
|
||||
pub fn register_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
_hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn unregister_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_app_path: String,
|
||||
pub fn unregister_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_app_path: String,
|
||||
pub fn disable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_app_path: String,
|
||||
pub fn enable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_app_search_enabled(_app_path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_app_search_path<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
@@ -103,11 +102,10 @@ pub async fn get_app_search_path<R: Runtime>(_tauri_app_handle: AppHandle<R>) ->
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_list<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<AppEntry>, String> {
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
// Return an empty list
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use super::super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::common::{
|
||||
document::{DataSourceReference, Document},
|
||||
error::SearchError,
|
||||
@@ -23,7 +23,7 @@ impl CalculatorSource {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_query(query: String) -> Value {
|
||||
fn parse_query(query: &str) -> Value {
|
||||
let mut query_json = serde_json::Map::new();
|
||||
|
||||
let operators = ["+", "-", "*", "/", "%"];
|
||||
@@ -48,7 +48,7 @@ fn parse_query(query: String) -> Value {
|
||||
query_json.insert("type".to_string(), Value::String("expression".to_string()));
|
||||
}
|
||||
|
||||
query_json.insert("value".to_string(), Value::String(query));
|
||||
query_json.insert("value".to_string(), Value::String(query.to_string()));
|
||||
|
||||
Value::Object(query_json)
|
||||
}
|
||||
@@ -108,11 +108,17 @@ impl SearchSource for CalculatorSource {
|
||||
}
|
||||
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
|
||||
let query_string = query
|
||||
.query_strings
|
||||
.get("query")
|
||||
.unwrap_or(&"".to_string())
|
||||
.to_string();
|
||||
let Some(query_string) = query.query_strings.get("query") else {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
};
|
||||
|
||||
// Trim the leading and tailing whitespace so that our later if condition
|
||||
// will only be evaluated against non-whitespace characters.
|
||||
let query_string = query_string.trim();
|
||||
|
||||
if query_string.is_empty() || query_string.len() == 1 {
|
||||
return Ok(QueryResponse {
|
||||
@@ -122,42 +128,56 @@ impl SearchSource for CalculatorSource {
|
||||
});
|
||||
}
|
||||
|
||||
match meval::eval_str(&query_string) {
|
||||
Ok(num) => {
|
||||
let mut payload: HashMap<String, Value> = HashMap::new();
|
||||
let query_string_clone = query_string.to_string();
|
||||
let query_source = self.get_type();
|
||||
let base_score = self.base_score;
|
||||
let closure = move || -> QueryResponse {
|
||||
let res_num = meval::eval_str(&query_string_clone);
|
||||
|
||||
let payload_query = parse_query(query_string);
|
||||
let payload_result = parse_result(num);
|
||||
match res_num {
|
||||
Ok(num) => {
|
||||
let mut payload: HashMap<String, Value> = HashMap::new();
|
||||
|
||||
payload.insert("query".to_string(), payload_query);
|
||||
payload.insert("result".to_string(), payload_result);
|
||||
let payload_query = parse_query(&query_string_clone);
|
||||
let payload_result = parse_result(num);
|
||||
|
||||
let doc = Document {
|
||||
id: DATA_SOURCE_ID.to_string(),
|
||||
category: Some(DATA_SOURCE_ID.to_string()),
|
||||
payload: Some(payload),
|
||||
source: Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(DATA_SOURCE_ID.into()),
|
||||
id: Some(DATA_SOURCE_ID.into()),
|
||||
icon: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
payload.insert("query".to_string(), payload_query);
|
||||
payload.insert("result".to_string(), payload_result);
|
||||
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: vec![(doc, self.base_score)],
|
||||
total_hits: 1,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
let doc = Document {
|
||||
id: DATA_SOURCE_ID.to_string(),
|
||||
category: Some(DATA_SOURCE_ID.to_string()),
|
||||
payload: Some(payload),
|
||||
source: Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(DATA_SOURCE_ID.into()),
|
||||
id: Some(DATA_SOURCE_ID.into()),
|
||||
icon: Some(String::from("font_Calculator")),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
QueryResponse {
|
||||
source: query_source,
|
||||
hits: vec![(doc, base_score)],
|
||||
total_hits: 1,
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
QueryResponse {
|
||||
source: query_source,
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let spawn_result = tokio::task::spawn_blocking(closure).await;
|
||||
|
||||
match spawn_result {
|
||||
Ok(response) => Ok(response),
|
||||
Err(e) => std::panic::resume_unwind(e.into_panic()),
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src-tauri/src/extension/built_in/file_system.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
310
src-tauri/src/extension/built_in/mod.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
//! Built-in extensions and related stuff.
|
||||
|
||||
pub mod ai_overview;
|
||||
pub mod application;
|
||||
pub mod calculator;
|
||||
pub mod file_system;
|
||||
pub mod pizza_engine_runtime;
|
||||
pub mod quick_ai_access;
|
||||
|
||||
use super::Extension;
|
||||
use crate::extension::{alter_extension_json_file, load_extension_from_json_file};
|
||||
use crate::{SearchSourceRegistry, GLOBAL_TAURI_APP_HANDLE};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
use tauri::path::BaseDirectory;
|
||||
use tauri::Manager;
|
||||
|
||||
pub(crate) static BUILT_IN_EXTENSION_DIRECTORY: LazyLock<PathBuf> = LazyLock::new(|| {
|
||||
let mut resource_dir = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set")
|
||||
.path()
|
||||
.resolve("assets", BaseDirectory::Resource)
|
||||
.expect(
|
||||
"User home directory not found, which should be impossible on desktop environments",
|
||||
);
|
||||
resource_dir.push("extension");
|
||||
|
||||
resource_dir
|
||||
});
|
||||
|
||||
pub(super) async fn init_built_in_extension(
|
||||
extension: &Extension,
|
||||
search_source_registry: &SearchSourceRegistry,
|
||||
) {
|
||||
log::trace!("initializing built-in extensions");
|
||||
|
||||
if extension.id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
search_source_registry
|
||||
.register_source(application::ApplicationSearchSource)
|
||||
.await;
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
|
||||
if extension.id == calculator::DATA_SOURCE_ID {
|
||||
let calculator_search = calculator::CalculatorSource::new(2000f64);
|
||||
search_source_registry
|
||||
.register_source(calculator_search)
|
||||
.await;
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_extension_built_in(extension_id: &str) -> bool {
|
||||
if extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
return true;
|
||||
}
|
||||
|
||||
if extension_id.starts_with(&format!(
|
||||
"{}.",
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if extension_id == calculator::DATA_SOURCE_ID {
|
||||
return true;
|
||||
}
|
||||
|
||||
if extension_id == quick_ai_access::EXTENSION_ID {
|
||||
return true;
|
||||
}
|
||||
|
||||
if extension_id == ai_overview::EXTENSION_ID {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) async fn enable_built_in_extension(extension_id: &str) -> Result<(), String> {
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
let update_extension = |extension: &mut Extension| -> Result<(), String> {
|
||||
extension.enabled = true;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
search_source_registry_tauri_state
|
||||
.register_source(application::ApplicationSearchSource)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if this is an application
|
||||
let application_prefix = format!(
|
||||
"{}.",
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
);
|
||||
if extension_id.starts_with(&application_prefix) {
|
||||
let app_path = &extension_id[application_prefix.len()..];
|
||||
application::enable_app_search(tauri_app_handle, app_path)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if extension_id == calculator::DATA_SOURCE_ID {
|
||||
let calculator_search = calculator::CalculatorSource::new(2000f64);
|
||||
search_source_registry_tauri_state
|
||||
.register_source(calculator_search)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if extension_id == quick_ai_access::EXTENSION_ID {
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if extension_id == ai_overview::EXTENSION_ID {
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn disable_built_in_extension(extension_id: &str) -> Result<(), String> {
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
let update_extension = |extension: &mut Extension| -> Result<(), String> {
|
||||
extension.enabled = false;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
search_source_registry_tauri_state
|
||||
.remove_source(extension_id)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if this is an application
|
||||
let application_prefix = format!(
|
||||
"{}.",
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
);
|
||||
if extension_id.starts_with(&application_prefix) {
|
||||
let app_path = &extension_id[application_prefix.len()..];
|
||||
application::disable_app_search(tauri_app_handle, app_path)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if extension_id == calculator::DATA_SOURCE_ID {
|
||||
search_source_registry_tauri_state
|
||||
.remove_source(extension_id)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if extension_id == quick_ai_access::EXTENSION_ID {
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if extension_id == ai_overview::EXTENSION_ID {
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_built_in_extension_alias(extension_id: &str, alias: &str) {
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
|
||||
let application_prefix = format!(
|
||||
"{}.",
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
);
|
||||
if extension_id.starts_with(&application_prefix) {
|
||||
let app_path = &extension_id[application_prefix.len()..];
|
||||
application::set_app_alias(tauri_app_handle, app_path, alias);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn register_built_in_extension_hotkey(
|
||||
extension_id: &str,
|
||||
hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
let application_prefix = format!(
|
||||
"{}.",
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
);
|
||||
if extension_id.starts_with(&application_prefix) {
|
||||
let app_path = &extension_id[application_prefix.len()..];
|
||||
application::register_app_hotkey(&tauri_app_handle, app_path, hotkey)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn unregister_built_in_extension_hotkey(extension_id: &str) -> Result<(), String> {
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
let application_prefix = format!(
|
||||
"{}.",
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
);
|
||||
if extension_id.starts_with(&application_prefix) {
|
||||
let app_path = &extension_id[application_prefix.len()..];
|
||||
application::unregister_app_hotkey(&tauri_app_handle, app_path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn is_built_in_extension_enabled(extension_id: &str) -> Result<bool, String> {
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
if extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
return Ok(search_source_registry_tauri_state
|
||||
.get_source(extension_id)
|
||||
.await
|
||||
.is_some());
|
||||
}
|
||||
|
||||
// Check if this is an application
|
||||
let application_prefix = format!(
|
||||
"{}.",
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
);
|
||||
if extension_id.starts_with(&application_prefix) {
|
||||
let app_path = &extension_id[application_prefix.len()..];
|
||||
return Ok(application::is_app_search_enabled(app_path));
|
||||
}
|
||||
|
||||
if extension_id == calculator::DATA_SOURCE_ID {
|
||||
return Ok(search_source_registry_tauri_state
|
||||
.get_source(extension_id)
|
||||
.await
|
||||
.is_some());
|
||||
}
|
||||
|
||||
if extension_id == quick_ai_access::EXTENSION_ID {
|
||||
let extension =
|
||||
load_extension_from_json_file(&BUILT_IN_EXTENSION_DIRECTORY.as_path(), extension_id)?;
|
||||
return Ok(extension.enabled);
|
||||
}
|
||||
|
||||
if extension_id == ai_overview::EXTENSION_ID {
|
||||
let extension =
|
||||
load_extension_from_json_file(&BUILT_IN_EXTENSION_DIRECTORY.as_path(), extension_id)?;
|
||||
return Ok(extension.enabled);
|
||||
}
|
||||
|
||||
unreachable!("extension [{}] is not a built-in extension", extension_id)
|
||||
}
|
||||
76
src-tauri/src/extension/built_in/pizza_engine_runtime.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! We use Pizza Engine to index applications and local files. The engine will be
|
||||
//! run in the thread/runtime defined in this file.
|
||||
//!
|
||||
//! # Why such a thread/runtime is needed
|
||||
//!
|
||||
//! Generally, Tokio async runtime requires all the async tasks running on it to be
|
||||
//! `Send` and `Sync`, but the async tasks created by Pizza Engine are not,
|
||||
//! which forces us to create a dedicated thread/runtime to execute them.
|
||||
|
||||
use std::any::Any;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub(crate) trait SearchSourceState {
|
||||
#[cfg_attr(not(feature = "use_pizza_engine"), allow(unused))]
|
||||
fn as_mut_any(&mut self) -> &mut dyn Any;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
pub(crate) trait Task: Send + Sync {
|
||||
fn search_source_id(&self) -> &'static str;
|
||||
|
||||
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>);
|
||||
}
|
||||
|
||||
pub(crate) static RUNTIME_TX: OnceLock<tokio::sync::mpsc::UnboundedSender<Box<dyn Task>>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// This function blocks until the runtime thread is ready for accepting tasks.
|
||||
pub(crate) async fn start_pizza_engine_runtime() {
|
||||
const THREAD_NAME: &str = "Pizza engine runtime thread";
|
||||
|
||||
log::trace!("starting Pizza engine runtime");
|
||||
let (engine_start_signal_tx, engine_start_signal_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name(THREAD_NAME.into())
|
||||
.spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
let main = async {
|
||||
let mut states: HashMap<String, Option<Box<dyn SearchSourceState>>> =
|
||||
HashMap::new();
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
RUNTIME_TX.set(tx).unwrap();
|
||||
|
||||
engine_start_signal_tx
|
||||
.send(())
|
||||
.expect("engine_start_signal_rx dropped");
|
||||
|
||||
while let Some(mut task) = rx.recv().await {
|
||||
let opt_search_source_state = match states.entry(task.search_source_id().into())
|
||||
{
|
||||
Entry::Occupied(o) => o.into_mut(),
|
||||
Entry::Vacant(v) => v.insert(None),
|
||||
};
|
||||
task.exec(opt_search_source_state).await;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(main);
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"failed to start thread [{}] due to error [{}]",
|
||||
THREAD_NAME, e
|
||||
);
|
||||
});
|
||||
|
||||
engine_start_signal_rx
|
||||
.await
|
||||
.expect("engine_start_signal_tx dropped, the runtime thread could be dead");
|
||||
log::trace!("Pizza engine runtime started");
|
||||
}
|
||||
1
src-tauri/src/extension/built_in/quick_ai_access.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub(super) const EXTENSION_ID: &str = "QuickAIAccess";
|
||||
825
src-tauri/src/extension/mod.rs
Normal file
@@ -0,0 +1,825 @@
|
||||
pub(crate) mod built_in;
|
||||
mod third_party;
|
||||
|
||||
use crate::common::document::OnOpened;
|
||||
use crate::{common::register::SearchSourceRegistry, GLOBAL_TAURI_APP_HANDLE};
|
||||
use anyhow::Context;
|
||||
use derive_more::Display;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use tauri::Manager;
|
||||
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
|
||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
|
||||
const ASSETS_DIRECTORY_FILE_NAME: &str = "assets";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Copy, Clone, Hash, PartialEq, Eq, Display)]
|
||||
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
|
||||
enum Platform {
|
||||
#[display("macOS")]
|
||||
Macos,
|
||||
#[display("Linux")]
|
||||
Linux,
|
||||
#[display("windows")]
|
||||
Windows,
|
||||
}
|
||||
|
||||
/// Helper function to determine the current platform.
|
||||
fn current_platform() -> 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)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Extension {
|
||||
/// Unique extension identifier.
|
||||
id: String,
|
||||
/// Extension name.
|
||||
title: String,
|
||||
/// Platforms supported by this extension.
|
||||
///
|
||||
/// If `None`, then this extension can be used on all the platforms.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
platforms: Option<HashSet<Platform>>,
|
||||
/// Extension description.
|
||||
description: String,
|
||||
//// Specify the icon for this extension, multi options are available:
|
||||
///
|
||||
/// 1. It can be a path to the icon file, the path can be
|
||||
///
|
||||
/// * relative (relative to the "assets" directory)
|
||||
/// * absolute
|
||||
/// 2. It can be a font class code, e.g., 'font_coco', if you want to use
|
||||
/// Coco's built-in icons.
|
||||
///
|
||||
/// In cases where your icon file is named similarly to a font class code, Coco
|
||||
/// will treat it as an icon file if it exists, i.e., if file `<extension>/assets/font_coco`
|
||||
/// exists, then Coco will use this file rather than the built-in 'font_coco' icon.
|
||||
icon: String,
|
||||
r#type: ExtensionType,
|
||||
/// If this is a Command extension, then action defines the operation to execute
|
||||
/// when the it is triggered.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
action: Option<CommandAction>,
|
||||
/// The link to open if this is a QuickLink extension.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
quick_link: Option<QuickLink>,
|
||||
|
||||
// If this extension is of type Group or Extension, then it behaves like a
|
||||
// directory, i.e., it could contain sub items.
|
||||
commands: Option<Vec<Extension>>,
|
||||
scripts: Option<Vec<Extension>>,
|
||||
quick_links: Option<Vec<Extension>>,
|
||||
|
||||
/// The alias of the extension.
|
||||
///
|
||||
/// Extension of type Group and Extension cannot have alias.
|
||||
///
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
alias: Option<String>,
|
||||
/// The hotkey of the extension.
|
||||
///
|
||||
/// Extension of type Group and Extension cannot have hotkey.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
hotkey: Option<String>,
|
||||
|
||||
/// Is this extension enabled.
|
||||
enabled: bool,
|
||||
|
||||
/// Extension settings
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
settings: Option<Json>,
|
||||
}
|
||||
|
||||
impl Extension {
|
||||
/// Whether this extension could be searched.
|
||||
pub(crate) fn searchable(&self) -> bool {
|
||||
self.on_opened().is_some()
|
||||
}
|
||||
/// Return what will happen when we open this extension.
|
||||
///
|
||||
/// `None` if it cannot be opened.
|
||||
pub(crate) fn on_opened(&self) -> Option<OnOpened> {
|
||||
match self.r#type {
|
||||
ExtensionType::Group => None,
|
||||
ExtensionType::Extension => None,
|
||||
ExtensionType::Command => Some(OnOpened::Command {
|
||||
action: self.action.clone().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Command extension [{}]'s [action] field is not set, something wrong with your extension validity check", self.id
|
||||
)
|
||||
}),
|
||||
}),
|
||||
ExtensionType::Application => Some(OnOpened::Application {
|
||||
app_path: self.id.clone(),
|
||||
}),
|
||||
ExtensionType::Script => todo!("not supported yet"),
|
||||
ExtensionType::Quicklink => todo!("not supported yet"),
|
||||
ExtensionType::Setting => todo!("not supported yet"),
|
||||
ExtensionType::Calculator => None,
|
||||
ExtensionType::AiExtension => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform `how` against the extension specified by `extension_id`.
|
||||
///
|
||||
/// Please note that `extension_id` could point to a sub extension.
|
||||
pub(crate) fn modify(
|
||||
&mut self,
|
||||
extension_id: &str,
|
||||
how: impl FnOnce(&mut Self) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let (parent_extension_id, opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
assert_eq!(
|
||||
parent_extension_id, self.id,
|
||||
"modify() should be invoked against a parent extension"
|
||||
);
|
||||
|
||||
let Some(sub_extension_id) = opt_sub_extension_id else {
|
||||
how(self)?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Search in commands
|
||||
if let Some(ref mut commands) = self.commands {
|
||||
if let Some(command) = commands.iter_mut().find(|cmd| cmd.id == sub_extension_id) {
|
||||
how(command)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Search in scripts
|
||||
if let Some(ref mut scripts) = self.scripts {
|
||||
if let Some(script) = scripts.iter_mut().find(|scr| scr.id == sub_extension_id) {
|
||||
how(script)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Search in quick_links
|
||||
if let Some(ref mut quick_links) = self.quick_links {
|
||||
if let Some(link) = quick_links
|
||||
.iter_mut()
|
||||
.find(|lnk| lnk.id == sub_extension_id)
|
||||
{
|
||||
how(link)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"extension [{}] not found in {:?}",
|
||||
extension_id, self
|
||||
))
|
||||
}
|
||||
|
||||
/// Get the extension specified by `extension_id`.
|
||||
///
|
||||
/// Please note that `extension_id` could point to a sub extension.
|
||||
pub(crate) fn get_extension_mut(&mut self, extension_id: &str) -> Option<&mut Self> {
|
||||
let (parent_extension_id, opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
if parent_extension_id != self.id {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some(sub_extension_id) = opt_sub_extension_id else {
|
||||
return Some(self);
|
||||
};
|
||||
|
||||
self.get_sub_extension_mut(sub_extension_id)
|
||||
}
|
||||
|
||||
pub(crate) fn get_sub_extension_mut(&mut self, sub_extension_id: &str) -> Option<&mut Self> {
|
||||
if !self.r#type.contains_sub_items() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(ref mut commands) = self.commands {
|
||||
if let Some(sub_ext) = commands.iter_mut().find(|cmd| cmd.id == sub_extension_id) {
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
if let Some(ref mut scripts) = self.scripts {
|
||||
if let Some(sub_ext) = scripts
|
||||
.iter_mut()
|
||||
.find(|script| script.id == sub_extension_id)
|
||||
{
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
if let Some(ref mut quick_links) = self.quick_links {
|
||||
if let Some(sub_ext) = quick_links
|
||||
.iter_mut()
|
||||
.find(|link| link.id == sub_extension_id)
|
||||
{
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub(crate) struct CommandAction {
|
||||
pub(crate) exec: String,
|
||||
pub(crate) args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct QuickLink {
|
||||
link: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Deserialize, Serialize, Clone, Display)]
|
||||
#[serde(rename_all(serialize = "snake_case", deserialize = "snake_case"))]
|
||||
pub enum ExtensionType {
|
||||
#[display("Group")]
|
||||
Group,
|
||||
#[display("Extension")]
|
||||
Extension,
|
||||
#[display("Command")]
|
||||
Command,
|
||||
#[display("Application")]
|
||||
Application,
|
||||
#[display("Script")]
|
||||
Script,
|
||||
#[display("Quicklink")]
|
||||
Quicklink,
|
||||
#[display("Setting")]
|
||||
Setting,
|
||||
#[display("Calculator")]
|
||||
Calculator,
|
||||
#[display("AI Extension")]
|
||||
AiExtension,
|
||||
}
|
||||
|
||||
impl ExtensionType {
|
||||
pub(crate) fn contains_sub_items(&self) -> bool {
|
||||
self == &Self::Group || self == &Self::Extension
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_relative_icon_path(
|
||||
extension_dir: &Path,
|
||||
extension: &mut Extension,
|
||||
) -> Result<(), String> {
|
||||
fn _canonicalize_relative_icon_path(
|
||||
extension_dir: &Path,
|
||||
extension: &mut Extension,
|
||||
) -> Result<(), String> {
|
||||
let icon_str = &extension.icon;
|
||||
let icon_path = Path::new(icon_str);
|
||||
|
||||
if icon_path.is_relative() {
|
||||
let absolute_icon_path = {
|
||||
let mut assets_directory = extension_dir.join(ASSETS_DIRECTORY_FILE_NAME);
|
||||
assets_directory.push(icon_path);
|
||||
|
||||
assets_directory
|
||||
};
|
||||
|
||||
if absolute_icon_path.try_exists().map_err(|e| e.to_string())? {
|
||||
extension.icon = absolute_icon_path
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("path should be UTF-8 encoded");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
_canonicalize_relative_icon_path(extension_dir, extension)?;
|
||||
|
||||
if let Some(commands) = &mut extension.commands {
|
||||
for command in commands {
|
||||
_canonicalize_relative_icon_path(extension_dir, command)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(scripts) = &mut extension.scripts {
|
||||
for script in scripts {
|
||||
_canonicalize_relative_icon_path(extension_dir, script)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(quick_links) = &mut extension.quick_links {
|
||||
for quick_link in quick_links {
|
||||
_canonicalize_relative_icon_path(extension_dir, quick_link)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_extensions_under_directory(directory: &Path) -> Result<(bool, Vec<Extension>), String> {
|
||||
let mut found_invalid_extensions = false;
|
||||
|
||||
let extension_directory = std::fs::read_dir(&directory).map_err(|e| e.to_string())?;
|
||||
let current_platform = current_platform();
|
||||
|
||||
let mut extensions = Vec::new();
|
||||
for res_extension_dir in extension_directory {
|
||||
let extension_dir = res_extension_dir.map_err(|e| e.to_string())?;
|
||||
let file_type = extension_dir.file_type().map_err(|e| e.to_string())?;
|
||||
if !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()
|
||||
);
|
||||
|
||||
// Skip invalid extension
|
||||
continue;
|
||||
}
|
||||
|
||||
let plugin_json_file_path = {
|
||||
let mut path = extension_dir.path();
|
||||
path.push(PLUGIN_JSON_FILE_NAME);
|
||||
|
||||
path
|
||||
};
|
||||
|
||||
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(),
|
||||
plugin_json_file_path.display()
|
||||
);
|
||||
|
||||
// Skip invalid extension
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut extension = match serde_json::from_reader::<_, Extension>(
|
||||
std::fs::File::open(&plugin_json_file_path).map_err(|e| e.to_string())?,
|
||||
) {
|
||||
Ok(extension) => extension,
|
||||
Err(e) => {
|
||||
found_invalid_extensions = true;
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: extension file [{}] is invalid, error: '{}'",
|
||||
extension_dir.file_name().display(),
|
||||
plugin_json_file_path.display(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 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)?;
|
||||
|
||||
if !validate_extension(
|
||||
&extension,
|
||||
&extension_dir.file_name(),
|
||||
&extensions,
|
||||
current_platform,
|
||||
) {
|
||||
found_invalid_extensions = true;
|
||||
// Skip invalid extension
|
||||
continue;
|
||||
}
|
||||
|
||||
extensions.push(extension);
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"loaded extensions: {:?}",
|
||||
extensions
|
||||
.iter()
|
||||
.map(|ext| ext.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
Ok((found_invalid_extensions, extensions))
|
||||
}
|
||||
|
||||
/// Return value:
|
||||
///
|
||||
/// * boolean: indicates if we found any invalid extensions
|
||||
/// * Vec<Extension>: loaded extensions
|
||||
#[tauri::command]
|
||||
pub(crate) async fn list_extensions() -> Result<(bool, Vec<Extension>), String> {
|
||||
log::trace!("loading extensions");
|
||||
|
||||
let third_party_dir = third_party::THIRD_PARTY_EXTENSION_DIRECTORY.as_path();
|
||||
if !third_party_dir.try_exists().map_err(|e| e.to_string())? {
|
||||
tokio::fs::create_dir_all(third_party_dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let (third_party_found_invalid_extension, mut third_party_extensions) =
|
||||
list_extensions_under_directory(third_party_dir)?;
|
||||
|
||||
let built_in_dir = built_in::BUILT_IN_EXTENSION_DIRECTORY.as_path();
|
||||
let (built_in_found_invalid_extension, built_in_extensions) =
|
||||
list_extensions_under_directory(built_in_dir)?;
|
||||
|
||||
let found_invalid_extension =
|
||||
third_party_found_invalid_extension || built_in_found_invalid_extension;
|
||||
let extensions = {
|
||||
third_party_extensions.extend(built_in_extensions);
|
||||
|
||||
third_party_extensions
|
||||
};
|
||||
|
||||
Ok((found_invalid_extension, extensions))
|
||||
}
|
||||
|
||||
/// Helper function to validate `extension`, return `true` if it is valid.
|
||||
fn validate_extension(
|
||||
extension: &Extension,
|
||||
extension_dir_name: &OsStr,
|
||||
listed_extensions: &[Extension],
|
||||
current_platform: Platform,
|
||||
) -> bool {
|
||||
if OsStr::new(&extension.id) != extension_dir_name {
|
||||
log::warn!(
|
||||
"invalid extension []: id [{}] and extension directory name [{}] do not match",
|
||||
extension.id,
|
||||
extension_dir_name.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extension ID should be unique
|
||||
if listed_extensions.iter().any(|ext| ext.id == extension.id) {
|
||||
log::warn!(
|
||||
"invalid extension []: extension with id [{}] already exists",
|
||||
extension.id,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if !validate_extension_or_sub_item(extension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extension is incompatible
|
||||
if let Some(ref platforms) = extension.platforms {
|
||||
if !platforms.contains(¤t_platform) {
|
||||
log::warn!("extension [{}] is not compatible with the current platform [{}], it is available to {:?}", extension.id, current_platform, platforms.iter().map(|os|os.to_string()).collect::<Vec<_>>());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref commands) = extension.commands {
|
||||
if !validate_sub_items(&extension.id, commands) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref scripts) = extension.scripts {
|
||||
if !validate_sub_items(&extension.id, scripts) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref quick_links) = extension.quick_links {
|
||||
if !validate_sub_items(&extension.id, quick_links) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Checks that can be performed against an extension or a sub item.
|
||||
fn validate_extension_or_sub_item(extension: &Extension) -> bool {
|
||||
// Only
|
||||
//
|
||||
// 1. letters
|
||||
// 2. hyphens
|
||||
// 3. numbers
|
||||
//
|
||||
// are allowed in the ID.
|
||||
if !extension
|
||||
.id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphabetic() || c == '-')
|
||||
{
|
||||
log::warn!(
|
||||
"invalid extension [{}], [id] should contain only letters, numbers, or hyphens",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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 `quick_link` is Some, then it should be a QuickLink
|
||||
if extension.quick_link.is_some() && extension.r#type != ExtensionType::Quicklink {
|
||||
log::warn!(
|
||||
"invalid extension [{}], [quick_link] is set for a non-QuickLink extension",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::Quicklink && extension.quick_link.is_none() {
|
||||
log::warn!(
|
||||
"invalid extension [{}], [quick_link] 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.quick_links.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
|
||||
}
|
||||
|
||||
pub(crate) async fn init_extensions(mut extensions: Vec<Extension>) -> Result<(), String> {
|
||||
log::trace!("initializing extensions");
|
||||
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
built_in::application::ApplicationSearchSource::init(tauri_app_handle.clone()).await?;
|
||||
|
||||
// Init the built-in enabled extensions
|
||||
for built_in_extension in extensions
|
||||
.extract_if(.., |ext| built_in::is_extension_built_in(&ext.id))
|
||||
.filter(|ext| ext.enabled)
|
||||
{
|
||||
built_in::init_built_in_extension(&built_in_extension, &search_source_registry_tauri_state)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Now the third-party extensions
|
||||
let third_party_search_source = third_party::ThirdPartyExtensionsSearchSource::new(extensions);
|
||||
third_party_search_source
|
||||
.restore_extensions_hotkey()
|
||||
.await?;
|
||||
let third_party_search_source_clone = third_party_search_source.clone();
|
||||
// Set the global search source so that we can access it in `#[tauri::command]`s
|
||||
// ignore the result because this function will be invoked twice, which
|
||||
// means this global variable will be set twice.
|
||||
let _ = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.set(third_party_search_source_clone);
|
||||
search_source_registry_tauri_state
|
||||
.register_source(third_party_search_source)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn enable_extension(extension_id: String) -> Result<(), String> {
|
||||
println!("enable_extension: {}", extension_id);
|
||||
|
||||
if built_in::is_extension_built_in(&extension_id) {
|
||||
built_in::enable_built_in_extension(&extension_id).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").enable_extension(&extension_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn disable_extension(extension_id: String) -> Result<(), String> {
|
||||
println!("disable_extension: {}", extension_id);
|
||||
|
||||
if built_in::is_extension_built_in(&extension_id) {
|
||||
built_in::disable_built_in_extension(&extension_id).await?;
|
||||
return Ok(());
|
||||
}
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").disable_extension(&extension_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn set_extension_alias(extension_id: String, alias: String) -> Result<(), String> {
|
||||
if built_in::is_extension_built_in(&extension_id) {
|
||||
built_in::set_built_in_extension_alias(&extension_id, &alias);
|
||||
return Ok(());
|
||||
}
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").set_extension_alias(&extension_id, &alias).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn register_extension_hotkey(
|
||||
extension_id: String,
|
||||
hotkey: String,
|
||||
) -> Result<(), String> {
|
||||
println!("register_extension_hotkey: {}, {}", extension_id, hotkey);
|
||||
|
||||
if built_in::is_extension_built_in(&extension_id) {
|
||||
built_in::register_built_in_extension_hotkey(&extension_id, &hotkey)?;
|
||||
return Ok(());
|
||||
}
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").register_extension_hotkey(&extension_id, &hotkey).await
|
||||
}
|
||||
|
||||
/// NOTE: this function won't error out if the extension specified by `extension_id`
|
||||
/// has no hotkey set because we need it to behave like this.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn unregister_extension_hotkey(extension_id: String) -> Result<(), String> {
|
||||
if built_in::is_extension_built_in(&extension_id) {
|
||||
built_in::unregister_built_in_extension_hotkey(&extension_id)?;
|
||||
return Ok(());
|
||||
}
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").unregister_extension_hotkey(&extension_id).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn is_extension_enabled(extension_id: String) -> Result<bool, String> {
|
||||
if built_in::is_extension_built_in(&extension_id) {
|
||||
return built_in::is_built_in_extension_enabled(&extension_id).await;
|
||||
}
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").is_extension_enabled(&extension_id).await
|
||||
}
|
||||
|
||||
fn split_extension_id(extension_id: &str) -> (&str, Option<&str>) {
|
||||
match extension_id.find('.') {
|
||||
Some(idx) => (&extension_id[..idx], Some(&extension_id[idx + 1..])),
|
||||
None => (extension_id, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_extension_from_json_file(
|
||||
extension_directory: &Path,
|
||||
extension_id: &str,
|
||||
) -> Result<Extension, String> {
|
||||
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
let json_file_path = {
|
||||
let mut extension_directory_path = extension_directory.join(parent_extension_id);
|
||||
extension_directory_path.push(PLUGIN_JSON_FILE_NAME);
|
||||
|
||||
extension_directory_path
|
||||
};
|
||||
|
||||
let mut extension = serde_json::from_reader::<_, Extension>(
|
||||
std::fs::File::open(&json_file_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"the [{}] file for extension [{}] is missing or broken",
|
||||
PLUGIN_JSON_FILE_NAME, parent_extension_id
|
||||
)
|
||||
})
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
canonicalize_relative_icon_path(extension_directory, &mut extension)?;
|
||||
|
||||
Ok(extension)
|
||||
}
|
||||
|
||||
fn alter_extension_json_file(
|
||||
extension_directory: &Path,
|
||||
extension_id: &str,
|
||||
how: impl Fn(&mut Extension) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
log::debug!(
|
||||
"altering extension JSON file for extension [{}]",
|
||||
extension_id
|
||||
);
|
||||
|
||||
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
let json_file_path = {
|
||||
let mut extension_directory_path = extension_directory.join(parent_extension_id);
|
||||
extension_directory_path.push(PLUGIN_JSON_FILE_NAME);
|
||||
|
||||
extension_directory_path
|
||||
};
|
||||
|
||||
let mut extension = serde_json::from_reader::<_, Extension>(
|
||||
std::fs::File::open(&json_file_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"the [{}] file for extension [{}] is missing or broken",
|
||||
PLUGIN_JSON_FILE_NAME, parent_extension_id
|
||||
)
|
||||
})
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
extension.modify(extension_id, how)?;
|
||||
|
||||
std::fs::write(
|
||||
&json_file_path,
|
||||
serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
770
src-tauri/src/extension/third_party.rs
Normal file
@@ -0,0 +1,770 @@
|
||||
use super::alter_extension_json_file;
|
||||
use super::Extension;
|
||||
use super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::common::document::open;
|
||||
use crate::common::document::DataSourceReference;
|
||||
use crate::common::document::Document;
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::search::QueryResponse;
|
||||
use crate::common::search::QuerySource;
|
||||
use crate::common::search::SearchQuery;
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::split_extension_id;
|
||||
use crate::GLOBAL_TAURI_APP_HANDLE;
|
||||
use async_trait::async_trait;
|
||||
use function_name::named;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::OnceLock;
|
||||
use tauri::async_runtime;
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use tauri_plugin_global_shortcut::ShortcutState;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub(crate) static THIRD_PARTY_EXTENSION_DIRECTORY: LazyLock<PathBuf> = LazyLock::new(|| {
|
||||
let mut app_data_dir = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set")
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect(
|
||||
"User home directory not found, which should be impossible on desktop environments",
|
||||
);
|
||||
app_data_dir.push("extension");
|
||||
|
||||
app_data_dir
|
||||
});
|
||||
|
||||
/// All the third-party extensions will be registered as one search source.
|
||||
///
|
||||
/// Since some `#[tauri::command]`s need to access it, we store it in a global
|
||||
/// static variable as well.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ThirdPartyExtensionsSearchSource {
|
||||
inner: Arc<ThirdPartyExtensionsSearchSourceInner>,
|
||||
}
|
||||
|
||||
impl ThirdPartyExtensionsSearchSource {
|
||||
pub(super) fn new(extensions: Vec<Extension>) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(ThirdPartyExtensionsSearchSourceInner {
|
||||
extensions: RwLock::new(extensions),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[named]
|
||||
pub(super) async fn enable_extension(&self, extension_id: &str) -> Result<(), String> {
|
||||
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
|
||||
let mut extensions_write_lock = self.inner.extensions.write().await;
|
||||
let opt_index = extensions_write_lock
|
||||
.iter()
|
||||
.position(|ext| ext.id == parent_extension_id);
|
||||
|
||||
let Some(index) = opt_index else {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that does not exist [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
};
|
||||
|
||||
let extension = extensions_write_lock
|
||||
.get_mut(index)
|
||||
.expect("just checked this extension exists");
|
||||
|
||||
let update_extension = |ext: &mut Extension| -> Result<(), String> {
|
||||
if ext.enabled {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that is already enabled [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
}
|
||||
ext.enabled = true;
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
extension.modify(extension_id, update_extension)?;
|
||||
alter_extension_json_file(
|
||||
&THIRD_PARTY_EXTENSION_DIRECTORY,
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[named]
|
||||
pub(super) async fn disable_extension(&self, extension_id: &str) -> Result<(), String> {
|
||||
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
|
||||
let mut extensions_write_lock = self.inner.extensions.write().await;
|
||||
let opt_index = extensions_write_lock
|
||||
.iter()
|
||||
.position(|ext| ext.id == parent_extension_id);
|
||||
|
||||
let Some(index) = opt_index else {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that does not exist [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
};
|
||||
|
||||
let extension = extensions_write_lock
|
||||
.get_mut(index)
|
||||
.expect("just checked this extension exists");
|
||||
|
||||
let update_extension = |ext: &mut Extension| -> Result<(), String> {
|
||||
if !ext.enabled {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that is already enabled [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
}
|
||||
ext.enabled = false;
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
extension.modify(extension_id, update_extension)?;
|
||||
alter_extension_json_file(
|
||||
&THIRD_PARTY_EXTENSION_DIRECTORY,
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[named]
|
||||
pub(super) async fn set_extension_alias(
|
||||
&self,
|
||||
extension_id: &str,
|
||||
alias: &str,
|
||||
) -> Result<(), String> {
|
||||
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
|
||||
let mut extensions_write_lock = self.inner.extensions.write().await;
|
||||
let opt_index = extensions_write_lock
|
||||
.iter()
|
||||
.position(|ext| ext.id == parent_extension_id);
|
||||
|
||||
let Some(index) = opt_index else {
|
||||
log::warn!(
|
||||
"{} invoked with an extension that does not exist [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let extension = extensions_write_lock
|
||||
.get_mut(index)
|
||||
.expect("just checked this extension exists");
|
||||
|
||||
let update_extension = |ext: &mut Extension| -> Result<(), String> {
|
||||
ext.alias = Some(alias.to_string());
|
||||
Ok(())
|
||||
};
|
||||
|
||||
extension.modify(extension_id, update_extension)?;
|
||||
alter_extension_json_file(
|
||||
&THIRD_PARTY_EXTENSION_DIRECTORY,
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn restore_extensions_hotkey(&self) -> Result<(), String> {
|
||||
fn set_up_hotkey<R: tauri::Runtime>(
|
||||
tauri_app_handle: &tauri::AppHandle<R>,
|
||||
extension: &Extension,
|
||||
) -> Result<(), String> {
|
||||
if let Some(ref hotkey) = extension.hotkey {
|
||||
let on_opened = extension.on_opened().unwrap_or_else(|| panic!( "extension has hotkey, but on_open() returns None, extension ID [{}], extension type [{:?}]", extension.id, extension.r#type));
|
||||
|
||||
let extension_id_clone = extension.id.clone();
|
||||
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.on_shortcut(hotkey.as_str(), move |_tauri_app_handle, _hotkey, event| {
|
||||
let on_opened_clone = on_opened.clone();
|
||||
let extension_id_clone = extension_id_clone.clone();
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
async_runtime::spawn(async move {
|
||||
let result = open(on_opened_clone).await;
|
||||
if let Err(msg) = result {
|
||||
log::warn!(
|
||||
"failed to open extension [{}], error [{}]",
|
||||
extension_id_clone,
|
||||
msg
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let extensions_read_lock = self.inner.extensions.read().await;
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
|
||||
for extension in extensions_read_lock.iter() {
|
||||
if extension.r#type.contains_sub_items() {
|
||||
if let Some(commands) = &extension.commands {
|
||||
for command in commands.iter().filter(|cmd| cmd.enabled) {
|
||||
set_up_hotkey(tauri_app_handle, command)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(scripts) = &extension.scripts {
|
||||
for script in scripts.iter().filter(|script| script.enabled) {
|
||||
set_up_hotkey(tauri_app_handle, script)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(quick_links) = &extension.quick_links {
|
||||
for quick_link in quick_links.iter().filter(|link| link.enabled) {
|
||||
set_up_hotkey(tauri_app_handle, quick_link)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
set_up_hotkey(tauri_app_handle, extension)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[named]
|
||||
pub(super) async fn register_extension_hotkey(
|
||||
&self,
|
||||
extension_id: &str,
|
||||
hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
self.unregister_extension_hotkey(extension_id).await?;
|
||||
|
||||
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
let mut extensions_write_lock = self.inner.extensions.write().await;
|
||||
let opt_index = extensions_write_lock
|
||||
.iter()
|
||||
.position(|ext| ext.id == parent_extension_id);
|
||||
|
||||
let Some(index) = opt_index else {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that does not exist [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
};
|
||||
|
||||
let mut extension = extensions_write_lock
|
||||
.get_mut(index)
|
||||
.expect("just checked this extension exists");
|
||||
|
||||
let update_extension = |ext: &mut Extension| -> Result<(), String> {
|
||||
ext.hotkey = Some(hotkey.into());
|
||||
Ok(())
|
||||
};
|
||||
|
||||
// Update extension (memory and file)
|
||||
extension.modify(extension_id, update_extension)?;
|
||||
alter_extension_json_file(
|
||||
&THIRD_PARTY_EXTENSION_DIRECTORY,
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
// To make borrow checker happy
|
||||
let extension_dbg_string = format!("{:?}", extension);
|
||||
extension = match extension.get_extension_mut(extension_id) {
|
||||
Some(ext) => ext,
|
||||
None => {
|
||||
panic!(
|
||||
"extension [{}] should be found in {}",
|
||||
extension_id, extension_dbg_string
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Set hotkey
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
let on_opened = extension.on_opened().unwrap_or_else(|| panic!(
|
||||
"setting hotkey for an extension that cannot be opened, extension ID [{}], extension type [{:?}]", extension_id, extension.r#type,
|
||||
));
|
||||
|
||||
let extension_id_clone = extension_id.to_string();
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.on_shortcut(hotkey, move |_tauri_app_handle, _hotkey, event| {
|
||||
let on_opened_clone = on_opened.clone();
|
||||
let extension_id_clone = extension_id_clone.clone();
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
async_runtime::spawn(async move {
|
||||
let result = open(on_opened_clone).await;
|
||||
if let Err(msg) = result {
|
||||
log::warn!(
|
||||
"failed to open extension [{}], error [{}]",
|
||||
extension_id_clone,
|
||||
msg
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// NOTE: this function won't error out if the extension specified by `extension_id`
|
||||
/// has no hotkey set because we need it to behave like this.
|
||||
#[named]
|
||||
pub(super) async fn unregister_extension_hotkey(
|
||||
&self,
|
||||
extension_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
|
||||
let mut extensions_write_lock = self.inner.extensions.write().await;
|
||||
let opt_index = extensions_write_lock
|
||||
.iter()
|
||||
.position(|ext| ext.id == parent_extension_id);
|
||||
|
||||
let Some(index) = opt_index else {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that does not exist [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
};
|
||||
|
||||
let parent_extension = extensions_write_lock
|
||||
.get_mut(index)
|
||||
.expect("just checked this extension exists");
|
||||
let Some(extension) = parent_extension.get_extension_mut(extension_id) else {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that does not exist [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
};
|
||||
|
||||
let Some(hotkey) = extension.hotkey.clone() else {
|
||||
log::warn!(
|
||||
"extension [{}] has no hotkey set, but we are trying to unregister it",
|
||||
extension_id
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let update_extension = |extension: &mut Extension| -> Result<(), String> {
|
||||
extension.hotkey = None;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
parent_extension.modify(extension_id, update_extension)?;
|
||||
alter_extension_json_file(
|
||||
&THIRD_PARTY_EXTENSION_DIRECTORY,
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
// Set hotkey
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.unregister(hotkey.as_str())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[named]
|
||||
pub(super) async fn is_extension_enabled(&self, extension_id: &str) -> Result<bool, String> {
|
||||
let (parent_extension_id, opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
|
||||
let extensions_read_lock = self.inner.extensions.read().await;
|
||||
let opt_index = extensions_read_lock
|
||||
.iter()
|
||||
.position(|ext| ext.id == parent_extension_id);
|
||||
|
||||
let Some(index) = opt_index else {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that does not exist [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
};
|
||||
|
||||
let extension = extensions_read_lock
|
||||
.get(index)
|
||||
.expect("just checked this extension exists");
|
||||
|
||||
if let Some(sub_extension_id) = opt_sub_extension_id {
|
||||
// For a sub-extension, it is enabled iff:
|
||||
//
|
||||
// 1. Its parent extension is enabled, and
|
||||
// 2. It is enabled
|
||||
if !extension.enabled {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if let Some(ref commands) = extension.commands {
|
||||
if let Some(sub_ext) = commands.iter().find(|cmd| cmd.id == sub_extension_id) {
|
||||
return Ok(sub_ext.enabled);
|
||||
}
|
||||
}
|
||||
if let Some(ref scripts) = extension.scripts {
|
||||
if let Some(sub_ext) = scripts.iter().find(|script| script.id == sub_extension_id) {
|
||||
return Ok(sub_ext.enabled);
|
||||
}
|
||||
}
|
||||
if let Some(ref commands) = extension.commands {
|
||||
if let Some(sub_ext) = commands
|
||||
.iter()
|
||||
.find(|quick_link| quick_link.id == sub_extension_id)
|
||||
{
|
||||
return Ok(sub_ext.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"{} invoked with a sub-extension that does not exist [{}/{}]",
|
||||
function_name!(),
|
||||
parent_extension_id,
|
||||
sub_extension_id
|
||||
))
|
||||
} else {
|
||||
Ok(extension.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
|
||||
OnceLock::new();
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ThirdPartyExtensionsSearchSourceInner {
|
||||
extensions: RwLock<Vec<Extension>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
fn get_type(&self) -> QuerySource {
|
||||
QuerySource {
|
||||
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
|
||||
name: hostname::get()
|
||||
.unwrap_or("My Computer".into())
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
id: "extensions".into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
|
||||
let Some(query_string) = query.query_strings.get("query") else {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
};
|
||||
|
||||
let opt_data_source = query
|
||||
.query_strings
|
||||
.get("datasource")
|
||||
.map(|owned_str| owned_str.to_string());
|
||||
|
||||
let query_lower = query_string.to_lowercase();
|
||||
let inner_clone = Arc::clone(&self.inner);
|
||||
|
||||
let closure = move || {
|
||||
let mut hits = Vec::new();
|
||||
let extensions_read_lock = futures::executor::block_on(async { inner_clone.extensions.read().await });
|
||||
|
||||
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
|
||||
if extension.r#type.contains_sub_items() {
|
||||
if let Some(ref commands) = extension.commands {
|
||||
for command in commands.iter().filter(|cmd| cmd.enabled) {
|
||||
if let Some(hit) =
|
||||
extension_to_hit(command, &query_lower, opt_data_source.as_deref())
|
||||
{
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref scripts) = extension.scripts {
|
||||
for script in scripts.iter().filter(|script| script.enabled) {
|
||||
if let Some(hit) =
|
||||
extension_to_hit(script, &query_lower, opt_data_source.as_deref())
|
||||
{
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref quick_links) = extension.quick_links {
|
||||
for quick_link in quick_links.iter().filter(|link| link.enabled) {
|
||||
if let Some(hit) =
|
||||
extension_to_hit(quick_link, &query_lower, opt_data_source.as_deref())
|
||||
{
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let Some(hit) = extension_to_hit(extension, &query_lower, opt_data_source.as_deref()) {
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hits
|
||||
};
|
||||
|
||||
let join_result = tokio::task::spawn_blocking(closure).await;
|
||||
|
||||
let hits = match join_result {
|
||||
Ok(hits) => hits,
|
||||
Err(e) => std::panic::resume_unwind(e.into_panic()),
|
||||
};
|
||||
let total_hits = hits.len();
|
||||
|
||||
Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits,
|
||||
total_hits,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn extension_to_hit(
|
||||
extension: &Extension,
|
||||
query_lower: &str,
|
||||
opt_data_source: Option<&str>,
|
||||
) -> Option<(Document, f64)> {
|
||||
if !extension.searchable() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let extension_type_string = extension.r#type.to_string();
|
||||
|
||||
if let Some(data_source) = opt_data_source {
|
||||
let document_data_source_id = extension_type_string.as_str();
|
||||
|
||||
if document_data_source_id != data_source {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let mut total_score = 0.0;
|
||||
|
||||
// Score based on title match
|
||||
// Title is considered more important, so it gets a higher weight.
|
||||
if let Some(title_score) =
|
||||
calculate_text_similarity(&query_lower, &extension.title.to_lowercase())
|
||||
{
|
||||
total_score += title_score * 1.0; // Weight for title
|
||||
}
|
||||
|
||||
// Score based on alias match if available
|
||||
// Alias is considered less important than title, so it gets a lower weight.
|
||||
if let Some(alias) = &extension.alias {
|
||||
if let Some(alias_score) = calculate_text_similarity(&query_lower, &alias.to_lowercase()) {
|
||||
total_score += alias_score * 0.7; // Weight for alias
|
||||
}
|
||||
}
|
||||
|
||||
// Only include if there's some relevance (score is meaningfully positive)
|
||||
if total_score > 0.01 {
|
||||
let on_opened = extension.on_opened().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"extension (id [{}], type [{:?}]) is searchable, and should have a valid on_opened",
|
||||
extension.id, extension.r#type
|
||||
)
|
||||
});
|
||||
let url = on_opened.url();
|
||||
|
||||
let document = Document {
|
||||
id: extension.id.clone(),
|
||||
title: Some(extension.title.clone()),
|
||||
icon: Some(extension.icon.clone()),
|
||||
on_opened: Some(on_opened),
|
||||
url: Some(url),
|
||||
category: Some(extension_type_string.clone()),
|
||||
source: Some(DataSourceReference {
|
||||
id: Some(extension_type_string.clone()),
|
||||
name: Some(extension_type_string.clone()),
|
||||
icon: None,
|
||||
r#type: Some(extension_type_string),
|
||||
}),
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Some((document, total_score))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates a similarity score between a query and a text, aiming for a [0, 1] range.
|
||||
// Assumes query and text are already lowercased.
|
||||
fn calculate_text_similarity(query: &str, text: &str) -> Option<f64> {
|
||||
if query.is_empty() || text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if text == query {
|
||||
return Some(1.0); // Perfect match
|
||||
}
|
||||
|
||||
let query_len = query.len() as f64;
|
||||
let text_len = text.len() as f64;
|
||||
let ratio = query_len / text_len;
|
||||
let mut score: f64 = 0.0;
|
||||
|
||||
// Case 1: Text starts with the query (prefix match)
|
||||
// Score: base 0.5, bonus up to 0.4 for how much of `text` is covered by `query`. Max 0.9.
|
||||
if text.starts_with(query) {
|
||||
score = score.max(0.5 + 0.4 * ratio);
|
||||
}
|
||||
|
||||
// Case 2: Text contains the query (substring match, not necessarily prefix)
|
||||
// Score: base 0.3, bonus up to 0.3. Max 0.6.
|
||||
// `score.max` ensures that if it's both a prefix and contains, the higher score (prefix) is taken.
|
||||
if text.contains(query) {
|
||||
score = score.max(0.3 + 0.3 * ratio);
|
||||
}
|
||||
|
||||
// Case 3: Fallback for "all query characters exist in text" (order-independent)
|
||||
if score < 0.2 {
|
||||
if query.chars().all(|c_q| text.contains(c_q)) {
|
||||
score = score.max(0.15); // Fixed low score for this weaker match type
|
||||
}
|
||||
}
|
||||
|
||||
if score > 0.0 {
|
||||
// Cap non-perfect matches slightly below 1.0 to make perfect (1.0) distinct.
|
||||
Some(score.min(0.95))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Helper function for approximate floating point comparison
|
||||
fn approx_eq(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-10
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_strings() {
|
||||
assert_eq!(calculate_text_similarity("", "text"), None);
|
||||
assert_eq!(calculate_text_similarity("query", ""), None);
|
||||
assert_eq!(calculate_text_similarity("", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_perfect_match() {
|
||||
assert_eq!(calculate_text_similarity("text", "text"), Some(1.0));
|
||||
assert_eq!(calculate_text_similarity("a", "a"), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_match() {
|
||||
// For "te" and "text":
|
||||
// score = 0.5 + 0.4 * (2/4) = 0.5 + 0.2 = 0.7
|
||||
let score = calculate_text_similarity("te", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.7));
|
||||
|
||||
// For "tex" and "text":
|
||||
// score = 0.5 + 0.4 * (3/4) = 0.5 + 0.3 = 0.8
|
||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substring_match() {
|
||||
// For "ex" and "text":
|
||||
// score = 0.3 + 0.3 * (2/4) = 0.3 + 0.15 = 0.45
|
||||
let score = calculate_text_similarity("ex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.45));
|
||||
|
||||
// Prefix should score higher than substring
|
||||
assert!(
|
||||
calculate_text_similarity("te", "text").unwrap()
|
||||
> calculate_text_similarity("ex", "text").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_character_presence() {
|
||||
// Characters present but not in sequence
|
||||
// "tac" in "contact" - not a substring, but all chars exist
|
||||
let score = calculate_text_similarity("tac", "contact").unwrap();
|
||||
assert!(approx_eq(0.3 + 0.3 * (3.0 / 7.0), score));
|
||||
|
||||
assert!(calculate_text_similarity("ac", "contact").is_some());
|
||||
|
||||
// Should not apply if some characters are missing
|
||||
assert_eq!(calculate_text_similarity("xyz", "contact"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combined_scenarios() {
|
||||
// Test that character presence fallback doesn't override higher scores
|
||||
// "tex" is a prefix of "text" with score 0.8
|
||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.8));
|
||||
|
||||
// Test a case where the characters exist but it's already a substring
|
||||
// "act" is a substring of "contact" with score > 0.2, so fallback won't apply
|
||||
let expected_score = 0.3 + 0.3 * (3.0 / 7.0);
|
||||
let actual_score = calculate_text_similarity("act", "contact").unwrap();
|
||||
assert!(approx_eq(actual_score, expected_score));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_similarity() {
|
||||
assert_eq!(calculate_text_similarity("xyz", "test"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_capping() {
|
||||
// Use a long query that's a prefix of a slightly longer text
|
||||
let long_text = "abcdefghijklmnopqrstuvwxyz";
|
||||
let long_prefix = "abcdefghijklmnopqrstuvwxy"; // All but last letter
|
||||
|
||||
// Expected score would be 0.5 + 0.4 * (25/26) = 0.5 + 0.385 = 0.885
|
||||
let expected_score = 0.5 + 0.4 * (25.0 / 26.0);
|
||||
let actual_score = calculate_text_similarity(long_prefix, long_text).unwrap();
|
||||
assert!(approx_eq(actual_score, expected_score));
|
||||
|
||||
// Verify that non-perfect matches are capped at 0.95
|
||||
assert!(calculate_text_similarity("almost", "almost perfect").unwrap() <= 0.95);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
mod assistant;
|
||||
mod autostart;
|
||||
mod common;
|
||||
mod local;
|
||||
mod extension;
|
||||
mod search;
|
||||
mod server;
|
||||
mod settings;
|
||||
@@ -13,16 +13,14 @@ use crate::common::register::SearchSourceRegistry;
|
||||
// use crate::common::traits::SearchSource;
|
||||
use crate::common::{MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
|
||||
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
||||
use autostart::{change_autostart, enable_autostart};
|
||||
use autostart::{change_autostart, ensure_autostart_state_consistent};
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use tauri::async_runtime::block_on;
|
||||
use tauri::plugin::TauriPlugin;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::ActivationPolicy;
|
||||
use tauri::{
|
||||
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, Window, WindowEvent,
|
||||
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, WindowEvent,
|
||||
};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
|
||||
@@ -64,11 +62,13 @@ pub fn run() {
|
||||
let ctx = tauri::generate_context!();
|
||||
|
||||
let mut app_builder = tauri::Builder::default();
|
||||
// Set up logger first
|
||||
app_builder = app_builder.plugin(set_up_tauri_logger());
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
app_builder = app_builder.plugin(tauri_plugin_single_instance::init(|_app, argv, _cwd| {
|
||||
println!("a new app instance was opened with {argv:?} and the deep link event was already triggered");
|
||||
log::debug!("a new app instance was opened with {argv:?} and the deep link event was already triggered");
|
||||
// when defining deep link schemes at runtime, you must also check `argv` here
|
||||
}));
|
||||
}
|
||||
@@ -77,7 +77,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
MacosLauncher::AppleScript,
|
||||
MacosLauncher::LaunchAgent,
|
||||
None,
|
||||
))
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
@@ -89,7 +89,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_windows_version::init())
|
||||
.plugin(set_up_tauri_logger());
|
||||
.plugin(tauri_plugin_opener::init());
|
||||
|
||||
// Conditional compilation for macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -131,6 +131,8 @@ pub fn run() {
|
||||
assistant::delete_session_chat,
|
||||
assistant::update_session_chat,
|
||||
assistant::assistant_search,
|
||||
assistant::assistant_get,
|
||||
assistant::assistant_get_multi,
|
||||
// server::get_coco_server_datasources,
|
||||
// server::get_coco_server_connectors,
|
||||
server::websocket::connect_to_server,
|
||||
@@ -140,30 +142,38 @@ pub fn run() {
|
||||
server::attachment::get_attachment,
|
||||
server::attachment::delete_attachment,
|
||||
server::transcription::transcription,
|
||||
util::open,
|
||||
server::system_settings::get_system_settings,
|
||||
simulate_mouse_click,
|
||||
local::get_disabled_local_query_sources,
|
||||
local::enable_local_query_source,
|
||||
local::disable_local_query_source,
|
||||
local::application::get_app_list,
|
||||
local::application::get_app_search_path,
|
||||
local::application::get_app_metadata,
|
||||
local::application::set_app_alias,
|
||||
local::application::register_app_hotkey,
|
||||
local::application::unregister_app_hotkey,
|
||||
local::application::disable_app_search,
|
||||
local::application::enable_app_search,
|
||||
local::application::add_app_search_path,
|
||||
local::application::remove_app_search_path,
|
||||
extension::built_in::application::get_app_list,
|
||||
extension::built_in::application::get_app_search_path,
|
||||
extension::built_in::application::get_app_metadata,
|
||||
extension::built_in::application::add_app_search_path,
|
||||
extension::built_in::application::remove_app_search_path,
|
||||
extension::list_extensions,
|
||||
extension::enable_extension,
|
||||
extension::disable_extension,
|
||||
extension::set_extension_alias,
|
||||
extension::register_extension_hotkey,
|
||||
extension::unregister_extension_hotkey,
|
||||
extension::is_extension_enabled,
|
||||
settings::set_allow_self_signature,
|
||||
settings::get_allow_self_signature,
|
||||
assistant::ask_ai,
|
||||
crate::common::document::open,
|
||||
])
|
||||
.setup(|app| {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
log::trace!("hiding Dock icon on macOS");
|
||||
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
||||
log::trace!("Dock icon should be hidden now");
|
||||
}
|
||||
|
||||
let app_handle = app.handle().clone();
|
||||
GLOBAL_TAURI_APP_HANDLE
|
||||
.set(app_handle.clone())
|
||||
.expect("variable already initialized");
|
||||
log::trace!("global Tauri app handle set");
|
||||
|
||||
let registry = SearchSourceRegistry::default();
|
||||
|
||||
@@ -176,15 +186,12 @@ pub fn run() {
|
||||
|
||||
shortcut::enable_shortcut(app);
|
||||
|
||||
enable_autostart(app);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
app.set_activation_policy(ActivationPolicy::Accessory);
|
||||
ensure_autostart_state_consistent(app)?;
|
||||
|
||||
// app.listen("theme-changed", move |event| {
|
||||
// if let Ok(payload) = serde_json::from_str::<ThemeChangedPayload>(event.payload()) {
|
||||
// // switch_tray_icon(app.app_handle(), payload.is_dark_mode);
|
||||
// println!("Theme changed: is_dark_mode = {}", payload.is_dark_mode);
|
||||
// log::debug!("Theme changed: is_dark_mode = {}", payload.is_dark_mode);
|
||||
// }
|
||||
// });
|
||||
|
||||
@@ -210,7 +217,7 @@ pub fn run() {
|
||||
})
|
||||
.on_window_event(|window, event| match event {
|
||||
WindowEvent::CloseRequested { api, .. } => {
|
||||
dbg!("Close requested event received");
|
||||
//dbg!("Close requested event received");
|
||||
window.hide().unwrap();
|
||||
api.prevent_close();
|
||||
}
|
||||
@@ -225,10 +232,10 @@ pub fn run() {
|
||||
has_visible_windows,
|
||||
..
|
||||
} => {
|
||||
dbg!(
|
||||
"Reopen event received: has_visible_windows = {}",
|
||||
has_visible_windows
|
||||
);
|
||||
// dbg!(
|
||||
// "Reopen event received: has_visible_windows = {}",
|
||||
// has_visible_windows
|
||||
// );
|
||||
if has_visible_windows {
|
||||
return;
|
||||
}
|
||||
@@ -242,11 +249,11 @@ pub fn run() {
|
||||
pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
// Await the async functions to load the servers and tokens
|
||||
if let Err(err) = load_or_insert_default_server(app_handle).await {
|
||||
eprintln!("Failed to load servers: {}", err);
|
||||
log::error!("Failed to load servers: {}", err);
|
||||
}
|
||||
|
||||
if let Err(err) = load_servers_token(app_handle).await {
|
||||
eprintln!("Failed to load server tokens: {}", err);
|
||||
log::error!("Failed to load server tokens: {}", err);
|
||||
}
|
||||
|
||||
let coco_servers = server::servers::get_all_servers();
|
||||
@@ -259,12 +266,12 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
.await;
|
||||
}
|
||||
|
||||
local::start_pizza_engine_runtime();
|
||||
extension::built_in::pizza_engine_runtime::start_pizza_engine_runtime().await;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
|
||||
if let Some(window) = app_handle.get_window(MAIN_WINDOW_LABEL) {
|
||||
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
move_window_to_active_monitor(&window);
|
||||
|
||||
let _ = window.show();
|
||||
@@ -277,24 +284,24 @@ async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
|
||||
|
||||
#[tauri::command]
|
||||
async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
|
||||
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
if let Err(err) = window.hide() {
|
||||
eprintln!("Failed to hide the window: {}", err);
|
||||
log::error!("Failed to hide the window: {}", err);
|
||||
} else {
|
||||
println!("Window successfully hidden.");
|
||||
log::debug!("Window successfully hidden.");
|
||||
}
|
||||
} else {
|
||||
eprintln!("Main window not found.");
|
||||
log::error!("Main window not found.");
|
||||
}
|
||||
}
|
||||
|
||||
fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
|
||||
dbg!("Moving window to active monitor");
|
||||
fn move_window_to_active_monitor<R: Runtime>(window: &WebviewWindow<R>) {
|
||||
//dbg!("Moving window to active monitor");
|
||||
// Try to get the available monitors, handle failure gracefully
|
||||
let available_monitors = match window.available_monitors() {
|
||||
Ok(monitors) => monitors,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to get monitors: {}", e);
|
||||
log::error!("Failed to get monitors: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -303,7 +310,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
|
||||
let cursor_position = match window.cursor_position() {
|
||||
Ok(pos) => Some(pos),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to get cursor position: {}", e);
|
||||
log::error!("Failed to get cursor position: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -332,7 +339,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
|
||||
let monitor = match target_monitor.or_else(|| window.primary_monitor().ok().flatten()) {
|
||||
Some(monitor) => monitor,
|
||||
None => {
|
||||
eprintln!("No monitor found!");
|
||||
log::error!("No monitor found!");
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -342,7 +349,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
|
||||
|
||||
if let Some(ref prev_name) = *previous_monitor_name {
|
||||
if name.to_string() == *prev_name {
|
||||
println!("Currently on the same monitor");
|
||||
log::debug!("Currently on the same monitor");
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -356,7 +363,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
|
||||
let window_size = match window.inner_size() {
|
||||
Ok(size) => size,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to get window size: {}", e);
|
||||
log::error!("Failed to get window size: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -370,52 +377,24 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
|
||||
|
||||
// Move the window to the new position
|
||||
if let Err(e) = window.set_position(PhysicalPosition::new(window_x, window_y)) {
|
||||
eprintln!("Failed to move window: {}", e);
|
||||
log::error!("Failed to move window: {}", e);
|
||||
}
|
||||
|
||||
if let Some(name) = monitor.name() {
|
||||
println!("Window moved to monitor: {}", name);
|
||||
log::debug!("Window moved to monitor: {}", name);
|
||||
|
||||
let mut previous_monitor = PREVIOUS_MONITOR_NAME.lock().unwrap();
|
||||
*previous_monitor = Some(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn open_settings(app: &tauri::AppHandle) {
|
||||
use tauri::webview::WebviewBuilder;
|
||||
println!("settings menu item was clicked");
|
||||
let window = app.get_webview_window("settings");
|
||||
if let Some(window) = window {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
} else {
|
||||
let window = tauri::window::WindowBuilder::new(app, "settings")
|
||||
.title("Settings Window")
|
||||
.fullscreen(false)
|
||||
.resizable(false)
|
||||
.minimizable(false)
|
||||
.maximizable(false)
|
||||
.inner_size(800.0, 600.0)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let webview_builder =
|
||||
WebviewBuilder::new("settings", tauri::WebviewUrl::App("/ui/settings".into()));
|
||||
let _webview = window
|
||||
.add_child(
|
||||
webview_builder,
|
||||
tauri::LogicalPosition::new(0, 0),
|
||||
window.inner_size().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> {
|
||||
local::init_local_search_source(&app_handle).await?;
|
||||
let (_found_invalid_extensions, extensions) = extension::list_extensions()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
extension::init_extensions(extensions).await?;
|
||||
|
||||
let _ = server::connector::refresh_all_connectors(&app_handle).await;
|
||||
let _ = server::datasource::refresh_all_datasources(&app_handle).await;
|
||||
|
||||
@@ -424,7 +403,14 @@ async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(
|
||||
|
||||
#[tauri::command]
|
||||
async fn show_settings(app_handle: AppHandle) {
|
||||
open_settings(&app_handle);
|
||||
log::debug!("settings menu item was clicked");
|
||||
let window = app_handle
|
||||
.get_webview_window(SETTINGS_WINDOW_LABEL)
|
||||
.expect("we have a settings window");
|
||||
|
||||
window.show().unwrap();
|
||||
window.unminimize().unwrap();
|
||||
window.set_focus().unwrap();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -487,6 +473,12 @@ async fn simulate_mouse_click<R: Runtime>(window: WebviewWindow<R>, is_chat_mode
|
||||
/// ```
|
||||
fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
|
||||
use log::Level;
|
||||
use log::LevelFilter;
|
||||
use tauri_plugin_log::Builder;
|
||||
|
||||
/// Coco-AI app's default log level.
|
||||
const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info;
|
||||
const LOG_LEVEL_ENV_VAR: &str = "COCO_LOG";
|
||||
|
||||
fn format_log_level(level: Level) -> &'static str {
|
||||
match level {
|
||||
@@ -508,16 +500,88 @@ fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
|
||||
str
|
||||
}
|
||||
|
||||
tauri_plugin_log::Builder::new()
|
||||
.format(|out, message, record| {
|
||||
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
|
||||
let level = format_log_level(record.level());
|
||||
let target_and_line = format_target_and_line(record);
|
||||
out.finish(format_args!(
|
||||
"[{}] [{}] [{}] {}",
|
||||
now, level, target_and_line, message
|
||||
));
|
||||
})
|
||||
.level(log::LevelFilter::Debug)
|
||||
.build()
|
||||
/// Allow us to configure dynamic log levels via environment variable `COCO_LOG`.
|
||||
///
|
||||
/// Generally, it mirros the behavior of `env_logger`. Syntax: `COCO_LOG=[target][=][level][,...]`
|
||||
///
|
||||
/// * If this environment variable is not set, use the default log level.
|
||||
/// * If it is set, respect it:
|
||||
///
|
||||
/// * `COCO_LOG=coco_lib` turns on all logging for the `coco_lib` module, which is
|
||||
/// equivalent to `COCO_LOG=coco_lib=trace`
|
||||
/// * `COCO_LOG=trace` turns on all logging for the application, regardless of its name
|
||||
/// * `COCO_LOG=TRACE` turns on all logging for the application, regardless of its name (same as previous)
|
||||
/// * `COCO_LOG=reqwest=debug` turns on debug logging for `reqwest`
|
||||
/// * `COCO_LOG=trace,tauri=off` turns on all the logging except for the logs come from `tauri`
|
||||
/// * `COCO_LOG=off` turns off all logging for the application
|
||||
/// * `COCO_LOG=` Since the value is empty, turns off all logging for the application as well
|
||||
fn dynamic_log_level(mut builder: Builder) -> Builder {
|
||||
let Some(log_levels) = std::env::var_os(LOG_LEVEL_ENV_VAR) else {
|
||||
return builder.level(DEFAULT_LOG_LEVEL);
|
||||
};
|
||||
|
||||
builder = builder.level(LevelFilter::Off);
|
||||
|
||||
let log_levels = log_levels.into_string().unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"The value '{}' set in environment varaible '{}' is not UTF-8 encoded",
|
||||
// Cannot use `.display()` here becuase that requires MSRV 1.87.0
|
||||
e.to_string_lossy(),
|
||||
LOG_LEVEL_ENV_VAR
|
||||
)
|
||||
});
|
||||
|
||||
// COCO_LOG=[target][=][level][,...]
|
||||
let target_log_levels = log_levels.split(',');
|
||||
for target_log_level in target_log_levels {
|
||||
#[allow(clippy::collapsible_else_if)]
|
||||
if let Some(char_index) = target_log_level.chars().position(|c| c == '=') {
|
||||
let (target, equal_sign_and_level) = target_log_level.split_at(char_index);
|
||||
// Remove the equal sign, we know it takes 1 byte
|
||||
let level = &equal_sign_and_level[1..];
|
||||
|
||||
if let Ok(level) = level.parse::<LevelFilter>() {
|
||||
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
|
||||
builder = builder.level_for(target.to_string(), level);
|
||||
} else {
|
||||
panic!(
|
||||
"log level '{}' set in '{}={}' is invalid",
|
||||
level, target, level
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if let Ok(level) = target_log_level.parse::<LevelFilter>() {
|
||||
// This is a level
|
||||
builder = builder.level(level);
|
||||
} else {
|
||||
// This is a target, enable all the logging
|
||||
//
|
||||
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
|
||||
builder = builder.level_for(target_log_level.to_string(), LevelFilter::Trace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
builder
|
||||
}
|
||||
|
||||
// When running the built binary, set `COCO_LOG` to `coco_lib=trace` to capture all logs
|
||||
// that come from Coco in the log file, which helps with debugging.
|
||||
if !tauri::is_dev() {
|
||||
std::env::set_var("COCO_LOG", "coco_lib=trace");
|
||||
}
|
||||
|
||||
let mut builder = tauri_plugin_log::Builder::new();
|
||||
builder = builder.format(|out, message, record| {
|
||||
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
|
||||
let level = format_log_level(record.level());
|
||||
let target_and_line = format_target_and_line(record);
|
||||
out.finish(format_args!(
|
||||
"[{}] [{}] [{}] {}",
|
||||
now, level, target_and_line, message
|
||||
));
|
||||
});
|
||||
builder = dynamic_log_level(builder);
|
||||
|
||||
builder.build()
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
pub mod application;
|
||||
pub mod calculator;
|
||||
pub mod file_system;
|
||||
|
||||
use std::any::Any;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use serde_json::Value as Json;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||
pub const TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE: &str = "local_query_source_enabled_state";
|
||||
|
||||
trait SearchSourceState {
|
||||
#[cfg_attr(not(feature = "use_pizza_engine"), allow(unused))]
|
||||
fn as_mut_any(&mut self) -> &mut dyn Any;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
trait Task: Send + Sync {
|
||||
fn search_source_id(&self) -> &'static str;
|
||||
|
||||
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>);
|
||||
}
|
||||
|
||||
static RUNTIME_TX: OnceLock<tokio::sync::mpsc::UnboundedSender<Box<dyn Task>>> = OnceLock::new();
|
||||
|
||||
pub(crate) fn start_pizza_engine_runtime() {
|
||||
std::thread::spawn(|| {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
let main = async {
|
||||
let mut states: HashMap<String, Option<Box<dyn SearchSourceState>>> = HashMap::new();
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
RUNTIME_TX.set(tx).unwrap();
|
||||
|
||||
while let Some(mut task) = rx.recv().await {
|
||||
let opt_search_source_state = match states.entry(task.search_source_id().into()) {
|
||||
Entry::Occupied(o) => o.into_mut(),
|
||||
Entry::Vacant(v) => v.insert(None),
|
||||
};
|
||||
task.exec(opt_search_source_state).await;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(main);
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) async fn init_local_search_source<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
let enabled_status_store = app_handle
|
||||
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if enabled_status_store.is_empty() {
|
||||
enabled_status_store.set(
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME,
|
||||
Json::Bool(true),
|
||||
);
|
||||
enabled_status_store.set(calculator::DATA_SOURCE_ID, Json::Bool(true));
|
||||
}
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
application::ApplicationSearchSource::init(app_handle.clone()).await?;
|
||||
|
||||
for (id, enabled) in enabled_status_store.entries() {
|
||||
let enabled = match enabled {
|
||||
Json::Bool(b) => b,
|
||||
_ => unreachable!("enabled state should be stored as a boolean"),
|
||||
};
|
||||
|
||||
if enabled {
|
||||
if id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
registry
|
||||
.register_source(application::ApplicationSearchSource)
|
||||
.await;
|
||||
}
|
||||
|
||||
if id == calculator::DATA_SOURCE_ID {
|
||||
let calculator_search = calculator::CalculatorSource::new(2000f64);
|
||||
registry.register_source(calculator_search).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_disabled_local_query_sources<R: Runtime>(app_handle: AppHandle<R>) -> Vec<String> {
|
||||
let enabled_status_store = app_handle
|
||||
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
|
||||
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
|
||||
)
|
||||
});
|
||||
let mut disabled_local_query_sources = Vec::new();
|
||||
|
||||
for (id, enabled) in enabled_status_store.entries() {
|
||||
let enabled = match enabled {
|
||||
Json::Bool(b) => b,
|
||||
_ => unreachable!("enabled state should be stored as a boolean"),
|
||||
};
|
||||
|
||||
if !enabled {
|
||||
disabled_local_query_sources.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
disabled_local_query_sources
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enable_local_query_source<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
query_source_id: String,
|
||||
) {
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
if query_source_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
let application_search = application::ApplicationSearchSource;
|
||||
registry.register_source(application_search).await;
|
||||
}
|
||||
if query_source_id == calculator::DATA_SOURCE_ID {
|
||||
let calculator_search = calculator::CalculatorSource::new(2000f64);
|
||||
registry.register_source(calculator_search).await;
|
||||
}
|
||||
|
||||
let enabled_status_store = app_handle
|
||||
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
|
||||
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
|
||||
)
|
||||
});
|
||||
enabled_status_store.set(query_source_id, Json::Bool(true));
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disable_local_query_source<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
query_source_id: String,
|
||||
) {
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
registry.remove_source(&query_source_id).await;
|
||||
|
||||
let enabled_status_store = app_handle
|
||||
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
|
||||
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
|
||||
)
|
||||
});
|
||||
enabled_status_store.set(query_source_id, Json::Bool(false));
|
||||
}
|
||||
@@ -1,15 +1,55 @@
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::common::search::{
|
||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QueryResponse, QuerySource, SearchQuery,
|
||||
};
|
||||
use crate::common::traits::SearchSource;
|
||||
use function_name::named;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use futures::StreamExt;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tokio::time::error::Elapsed;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
/// 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,
|
||||
) -> 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(search_query).await
|
||||
})
|
||||
.await,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[named]
|
||||
#[tauri::command]
|
||||
pub async fn query_coco_fusion<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
@@ -18,113 +58,153 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
query_strings: HashMap<String, String>,
|
||||
query_timeout: u64,
|
||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
||||
let query_source_to_search = query_strings.get("querysource");
|
||||
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 = HashMap::new();
|
||||
|
||||
let sources_list = sources_future.await;
|
||||
let mut sources_list = sources_future.await;
|
||||
let sources_list_len = sources_list.len();
|
||||
|
||||
// Time limit for each query
|
||||
let timeout_duration = Duration::from_millis(query_timeout);
|
||||
|
||||
// Push all queries into futures
|
||||
for query_source in sources_list {
|
||||
let query_source_type = query_source.get_type().clone();
|
||||
log::debug!(
|
||||
"{}(): {:?}, timeout: {:?}",
|
||||
function_name!(),
|
||||
query_strings,
|
||||
timeout_duration
|
||||
);
|
||||
|
||||
if let Some(query_source_to_search) = query_source_to_search {
|
||||
// We should not search this data source
|
||||
if &query_source_type.id != query_source_to_search {
|
||||
continue;
|
||||
}
|
||||
let search_query = SearchQuery::new(from, size, query_strings.clone());
|
||||
|
||||
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,
|
||||
timeout_duration,
|
||||
search_query,
|
||||
));
|
||||
} else {
|
||||
for query_source_trait_object in sources_list {
|
||||
let query_source = query_source_trait_object.get_type().clone();
|
||||
log::debug!("will query querysource [{}]", query_source.id);
|
||||
futures.push(same_type_futures(
|
||||
query_source,
|
||||
query_source_trait_object,
|
||||
timeout_duration,
|
||||
search_query.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
sources.insert(query_source_type.id.clone(), query_source_type);
|
||||
|
||||
let query = SearchQuery::new(from, size, query_strings.clone());
|
||||
let query_source_clone = query_source.clone(); // Clone Arc to avoid ownership issues
|
||||
|
||||
futures.push(tokio::spawn(async move {
|
||||
// Timeout each query execution
|
||||
timeout(timeout_duration, async {
|
||||
query_source_clone.search(query).await
|
||||
})
|
||||
.await
|
||||
}));
|
||||
}
|
||||
|
||||
let mut total_hits = 0;
|
||||
let mut need_rerank = true; //TODO set default to false when boost supported in Pizza
|
||||
let mut failed_requests = Vec::new();
|
||||
let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::new();
|
||||
let mut hits_per_source: HashMap<String, Vec<(QueryHits, f64)>> = HashMap::new();
|
||||
|
||||
while let Some(result) = futures.next().await {
|
||||
match result {
|
||||
Ok(Ok(Ok(response))) => {
|
||||
total_hits += response.total_hits;
|
||||
let source_id = response.source.id.clone();
|
||||
if sources_list_len > 1 {
|
||||
need_rerank = true; // If we have more than one source, we need to rerank the hits
|
||||
}
|
||||
|
||||
for (doc, score) in response.hits {
|
||||
let query_hit = QueryHits {
|
||||
source: Some(response.source.clone()),
|
||||
score,
|
||||
document: doc,
|
||||
};
|
||||
while let Some((query_source, timeout_result)) = futures.next().await {
|
||||
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
|
||||
);
|
||||
// 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();
|
||||
|
||||
all_hits.push((source_id.clone(), query_hit.clone(), score));
|
||||
for (doc, score) in response.hits {
|
||||
log::debug!("doc: {}, {:?}, {}", doc.id, doc.title, score);
|
||||
|
||||
hits_per_source
|
||||
.entry(source_id.clone())
|
||||
.or_insert_with(Vec::new)
|
||||
.push((query_hit, score));
|
||||
let query_hit = QueryHits {
|
||||
source: Some(response.source.clone()),
|
||||
score,
|
||||
document: doc,
|
||||
};
|
||||
|
||||
all_hits.push((source_id.clone(), query_hit.clone(), score));
|
||||
|
||||
hits_per_source
|
||||
.entry(source_id.clone())
|
||||
.or_insert_with(Vec::new)
|
||||
.push((query_hit, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Ok(Err(err))) => {
|
||||
failed_requests.push(FailedRequest {
|
||||
source: QuerySource {
|
||||
r#type: "N/A".into(),
|
||||
name: "N/A".into(),
|
||||
id: "N/A".into(),
|
||||
},
|
||||
status: 0,
|
||||
error: Some(err.to_string()),
|
||||
reason: None,
|
||||
});
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
failed_requests.push(FailedRequest {
|
||||
source: QuerySource {
|
||||
r#type: "N/A".into(),
|
||||
name: "N/A".into(),
|
||||
id: "N/A".into(),
|
||||
},
|
||||
status: 0,
|
||||
error: Some(err.to_string()),
|
||||
reason: None,
|
||||
});
|
||||
}
|
||||
// Timeout reached, skip this request
|
||||
_ => {
|
||||
failed_requests.push(FailedRequest {
|
||||
source: QuerySource {
|
||||
r#type: "N/A".into(),
|
||||
name: "N/A".into(),
|
||||
id: "N/A".into(),
|
||||
},
|
||||
status: 0,
|
||||
error: Some(format!("{:?}", &result)),
|
||||
reason: None,
|
||||
});
|
||||
}
|
||||
Err(search_error) => {
|
||||
log::error!(
|
||||
"searching query source [{}] failed, error [{}]",
|
||||
query_source.id,
|
||||
search_error
|
||||
);
|
||||
failed_requests.push(FailedRequest {
|
||||
source: query_source,
|
||||
status: 0,
|
||||
error: Some(search_error.to_string()),
|
||||
reason: None,
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Sort hits within each source by score (descending)
|
||||
for hits in hits_per_source.values_mut() {
|
||||
hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Greater));
|
||||
}
|
||||
|
||||
let total_sources = hits_per_source.len();
|
||||
@@ -140,16 +220,71 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
// Distribute hits fairly across sources
|
||||
for (_source_id, hits) in &mut hits_per_source {
|
||||
let take_count = hits.len().min(max_hits_per_source);
|
||||
for (doc, _) in hits.drain(0..take_count) {
|
||||
for (doc, score) in hits.drain(0..take_count) {
|
||||
if !seen_docs.contains(&doc.document.id) {
|
||||
seen_docs.insert(doc.document.id.clone());
|
||||
log::debug!(
|
||||
"collect doc: {}, {:?}, {}",
|
||||
doc.document.id,
|
||||
doc.document.title,
|
||||
score
|
||||
);
|
||||
final_hits.push(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we still need more hits, take the highest-scoring remaining ones
|
||||
if final_hits.len() < size as usize {
|
||||
log::debug!("final hits: {:?}", final_hits.len());
|
||||
|
||||
let mut unique_sources = HashSet::new();
|
||||
for hit in &final_hits {
|
||||
if let Some(source) = &hit.source {
|
||||
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
|
||||
unique_sources.insert(&source.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"Multiple sources found: {:?}, no rerank needed",
|
||||
unique_sources
|
||||
);
|
||||
|
||||
if unique_sources.len() < 1 {
|
||||
need_rerank = false; // If we have hits from multiple sources, we don't need to rerank
|
||||
}
|
||||
|
||||
if need_rerank && final_hits.len() > 1 {
|
||||
// Precollect (index, title)
|
||||
let titles_to_score: Vec<(usize, &str)> = final_hits
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, hit)| {
|
||||
let source = hit.source.as_ref()?;
|
||||
let title = hit.document.title.as_deref()?;
|
||||
|
||||
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
|
||||
Some((idx, title))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Score them
|
||||
let scored_hits = boosted_levenshtein_rerank(query_keyword.as_str(), titles_to_score);
|
||||
|
||||
// Sort descending by score
|
||||
let mut scored_hits = scored_hits;
|
||||
scored_hits.sort_by_key(|&(_, score)| Reverse((score * 1000.0) as u64));
|
||||
|
||||
// Apply new scores to final_hits
|
||||
for (idx, score) in scored_hits.into_iter().take(size as usize) {
|
||||
final_hits[idx].score = score;
|
||||
}
|
||||
} else if final_hits.len() < size as usize {
|
||||
// If we still need more hits, take the highest-scoring remaining ones
|
||||
|
||||
let remaining_needed = size as usize - final_hits.len();
|
||||
|
||||
// Sort all hits by score descending, removing duplicates by document ID
|
||||
@@ -179,9 +314,45 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
if final_hits.len() < 5 {
|
||||
//TODO: Add a recommendation system to suggest more sources
|
||||
log::info!(
|
||||
"Less than 5 hits found, consider using recommendation to find more suggestions."
|
||||
);
|
||||
//local: recent history, local extensions
|
||||
//remote: ai agents, quick links, other tasks, managed by server
|
||||
}
|
||||
|
||||
Ok(MultiSourceQueryResponse {
|
||||
failed: failed_requests,
|
||||
hits: final_hits,
|
||||
total_hits,
|
||||
})
|
||||
}
|
||||
|
||||
fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(usize, f64)> {
|
||||
use strsim::levenshtein;
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
titles
|
||||
.into_iter()
|
||||
.map(|(idx, title)| {
|
||||
let mut score = 0.0;
|
||||
|
||||
if title.contains(query) {
|
||||
score += 0.4;
|
||||
} else if title.to_lowercase().contains(&query_lower) {
|
||||
score += 0.2;
|
||||
}
|
||||
|
||||
let dist = levenshtein(&query_lower, &title.to_lowercase());
|
||||
let max_len = query_lower.len().max(title.len());
|
||||
if max_len > 0 {
|
||||
score += (1.0 - (dist as f64 / max_len as f64)) as f32;
|
||||
}
|
||||
|
||||
(idx, score.min(1.0) as f64)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::server::connector::get_connector_by_id;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::server::servers::get_all_servers;
|
||||
use lazy_static::lazy_static;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
@@ -12,7 +13,7 @@ use tauri::{AppHandle, Runtime};
|
||||
pub struct GetDatasourcesByServerOptions {
|
||||
pub from: Option<u32>,
|
||||
pub size: Option<u32>,
|
||||
pub query: Option<String>,
|
||||
pub query: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
@@ -32,7 +33,7 @@ pub fn save_datasource_to_cache(server_id: &str, datasources: Vec<DataSource>) {
|
||||
#[allow(dead_code)]
|
||||
pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, DataSource>> {
|
||||
let cache = DATASOURCE_CACHE.read().unwrap(); // Acquire read lock
|
||||
// dbg!("cache: {:?}", &cache);
|
||||
// dbg!("cache: {:?}", &cache);
|
||||
let server_cache = cache.get(server_id)?; // Get the server's cache
|
||||
Some(server_cache.clone())
|
||||
}
|
||||
@@ -100,31 +101,14 @@ pub async fn datasource_search(
|
||||
) -> Result<Vec<DataSource>, String> {
|
||||
let from = options.as_ref().and_then(|opt| opt.from).unwrap_or(0);
|
||||
let size = options.as_ref().and_then(|opt| opt.size).unwrap_or(10000);
|
||||
let query = options
|
||||
.and_then(|opt| opt.query)
|
||||
.unwrap_or(String::default());
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"from": from,
|
||||
"size": size,
|
||||
});
|
||||
|
||||
if !query.is_empty() {
|
||||
body["query"] = serde_json::json!({
|
||||
"bool": {
|
||||
"must": [{
|
||||
"query_string": {
|
||||
"fields": ["combined_fulltext"],
|
||||
"query": query,
|
||||
"fuzziness": "AUTO",
|
||||
"fuzzy_prefix_length": 2,
|
||||
"fuzzy_max_expansions": 10,
|
||||
"fuzzy_transpositions": true,
|
||||
"allow_leading_wildcard": false
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
if let Some(q) = options.and_then(|get_data_source_options| get_data_source_options.query ) {
|
||||
body["query"] = q;
|
||||
}
|
||||
|
||||
// Perform the async HTTP request outside the cache lock
|
||||
@@ -134,12 +118,12 @@ pub async fn datasource_search(
|
||||
None,
|
||||
Some(reqwest::Body::from(body.to_string())),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error fetching datasource: {}", e))?;
|
||||
.await
|
||||
.map_err(|e| format!("Error fetching datasource: {}", e))?;
|
||||
|
||||
// Parse the search results from the response
|
||||
let datasources: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
|
||||
dbg!("Error parsing search results: {}", &e);
|
||||
//dbg!("Error parsing search results: {}", &e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
@@ -152,35 +136,17 @@ pub async fn datasource_search(
|
||||
#[tauri::command]
|
||||
pub async fn mcp_server_search(
|
||||
id: &str,
|
||||
options: Option<GetDatasourcesByServerOptions>,
|
||||
from: u32,
|
||||
size: u32,
|
||||
query: Option<HashMap<String, Value>>,
|
||||
) -> Result<Vec<DataSource>, String> {
|
||||
let from = options.as_ref().and_then(|opt| opt.from).unwrap_or(0);
|
||||
let size = options.as_ref().and_then(|opt| opt.size).unwrap_or(10000);
|
||||
let query = options
|
||||
.and_then(|opt| opt.query)
|
||||
.unwrap_or(String::default());
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"from": from,
|
||||
"size": size,
|
||||
"from": from,
|
||||
"size": size,
|
||||
});
|
||||
|
||||
if !query.is_empty() {
|
||||
body["query"] = serde_json::json!({
|
||||
"bool": {
|
||||
"must": [{
|
||||
"query_string": {
|
||||
"fields": ["combined_fulltext"],
|
||||
"query": query,
|
||||
"fuzziness": "AUTO",
|
||||
"fuzzy_prefix_length": 2,
|
||||
"fuzzy_max_expansions": 10,
|
||||
"fuzzy_transpositions": true,
|
||||
"allow_leading_wildcard": false
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
if let Some(q) = query {
|
||||
body["query"] = serde_json::to_value(q).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Perform the async HTTP request outside the cache lock
|
||||
@@ -190,12 +156,12 @@ pub async fn mcp_server_search(
|
||||
None,
|
||||
Some(reqwest::Body::from(body.to_string())),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error fetching datasource: {}", e))?;
|
||||
.await
|
||||
.map_err(|e| format!("Error fetching datasource: {}", e))?;
|
||||
|
||||
// Parse the search results from the response
|
||||
let mcp_server: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
|
||||
dbg!("Error parsing search results: {}", &e);
|
||||
//dbg!("Error parsing search results: {}", &e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ impl HttpClient {
|
||||
Self::get_request_builder(method, url, headers, query_params, body).await;
|
||||
|
||||
let response = request_builder.send().await.map_err(|e| {
|
||||
dbg!("Failed to send request: {}", &e);
|
||||
//dbg!("Failed to send request: {}", &e);
|
||||
format!("Failed to send request: {}", e)
|
||||
})?;
|
||||
|
||||
@@ -165,12 +165,12 @@ impl HttpClient {
|
||||
headers.insert("X-API-TOKEN".to_string(), t);
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"Sending request to server: {}, url: {}, headers: {:?}",
|
||||
&server_id,
|
||||
&url,
|
||||
&headers
|
||||
);
|
||||
// log::debug!(
|
||||
// "Sending request to server: {}, url: {}, headers: {:?}",
|
||||
// &server_id,
|
||||
// &url,
|
||||
// &headers
|
||||
// );
|
||||
|
||||
Self::send_raw_request(method, &url, query_params, Some(headers), body).await
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::common::document::Document;
|
||||
use crate::common::document::{Document, OnOpened};
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::http::get_response_body_text;
|
||||
use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery, SearchResponse};
|
||||
@@ -93,6 +93,8 @@ impl SearchSource for CocoSearchSource {
|
||||
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
|
||||
let url = "/query/_search";
|
||||
let mut total_hits = 0;
|
||||
let mut hits: Vec<(Document, f64)> = Vec::new();
|
||||
|
||||
let mut query_args: HashMap<String, JsonValue> = HashMap::new();
|
||||
query_args.insert("from".into(), JsonValue::Number(query.from.into()));
|
||||
@@ -101,31 +103,44 @@ impl SearchSource for CocoSearchSource {
|
||||
query_args.insert(key, JsonValue::String(value));
|
||||
}
|
||||
|
||||
let response = HttpClient::get(
|
||||
&self.server.id,
|
||||
&url,
|
||||
Some(query_args),
|
||||
)
|
||||
let response = HttpClient::get(&self.server.id, &url, Some(query_args))
|
||||
.await
|
||||
.map_err(|e| SearchError::HttpError(format!("Error to send search request: {}", e)))?;
|
||||
.map_err(|e| SearchError::HttpError(format!("{}", e)))?;
|
||||
|
||||
// Use the helper function to parse the response body
|
||||
let response_body = get_response_body_text(response)
|
||||
.await
|
||||
.map_err(|e| SearchError::ParseError(format!("Failed to read response body: {}", e)))?;
|
||||
.map_err(|e| SearchError::ParseError(e))?;
|
||||
|
||||
// Parse the search response from the body text
|
||||
let parsed: SearchResponse<Document> = serde_json::from_str(&response_body)
|
||||
.map_err(|e| SearchError::ParseError(format!("Failed to parse search response: {}", e)))?;
|
||||
// Check if the response body is empty
|
||||
if !response_body.is_empty() {
|
||||
// log::info!("Search response body: {}", &response_body);
|
||||
|
||||
// Process the parsed response
|
||||
let total_hits = parsed.hits.total.value as usize;
|
||||
let hits: Vec<(Document, f64)> = parsed
|
||||
.hits
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|hit| (hit._source, hit._score.unwrap_or(0.0))) // Default _score to 0.0 if None
|
||||
.collect();
|
||||
// Parse the search response from the body text
|
||||
let parsed: SearchResponse<Document> = serde_json::from_str(&response_body)
|
||||
.map_err(|e| SearchError::ParseError(format!("{}", e)))?;
|
||||
|
||||
|
||||
// Process the parsed response
|
||||
total_hits = parsed.hits.total.value as usize;
|
||||
|
||||
if let Some(items) = parsed.hits.hits {
|
||||
for hit in items {
|
||||
let mut document = hit._source;
|
||||
// Default _score to 0.0 if None
|
||||
let score = hit._score.unwrap_or(0.0);
|
||||
|
||||
let on_opened = document
|
||||
.url
|
||||
.as_ref()
|
||||
.map(|url| OnOpened::Document { url: url.clone() });
|
||||
// Set the `on_opened` field as it won't be returned from Coco server
|
||||
document.on_opened = on_opened;
|
||||
|
||||
hits.push((document, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the final result
|
||||
Ok(QueryResponse {
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn save_server(server: &Server) -> bool {
|
||||
}
|
||||
|
||||
fn remove_server_by_id(id: String) -> bool {
|
||||
dbg!("remove server by id:", &id);
|
||||
log::debug!("remove server by id: {}", &id);
|
||||
let mut cache = SERVER_CACHE.write().unwrap();
|
||||
let deleted = cache.remove(id.as_str());
|
||||
deleted.is_some()
|
||||
@@ -87,7 +87,7 @@ pub async fn persist_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<()
|
||||
}
|
||||
|
||||
pub fn remove_server_token(id: &str) -> bool {
|
||||
dbg!("remove server token by id:", &id);
|
||||
log::debug!("remove server token by id: {}", &id);
|
||||
let mut cache = SERVER_TOKEN.write().unwrap();
|
||||
cache.remove(id).is_some()
|
||||
}
|
||||
@@ -104,7 +104,7 @@ pub fn persist_servers_token<R: Runtime>(app_handle: &AppHandle<R>) -> Result<()
|
||||
.map(|server| serde_json::to_value(server).expect("Failed to serialize access_tokens")) // Automatically serialize all fields
|
||||
.collect();
|
||||
|
||||
dbg!(format!("persist servers token: {:?}", &json_servers));
|
||||
log::debug!("persist servers token: {:?}", &json_servers);
|
||||
|
||||
// Save the serialized servers to Tauri's store
|
||||
app_handle
|
||||
@@ -143,17 +143,18 @@ fn get_default_server() -> Server {
|
||||
profile: None,
|
||||
auth_provider: AuthProvider {
|
||||
sso: Sso {
|
||||
url: "https://coco.infini.cloud/sso/login/".to_string(),
|
||||
url: "https://coco.infini.cloud/sso/login/cloud?provider=coco-cloud&product=coco".to_string(),
|
||||
},
|
||||
},
|
||||
priority: 0,
|
||||
stats: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_servers_token<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
) -> Result<Vec<ServerAccessToken>, String> {
|
||||
dbg!("Attempting to load servers token");
|
||||
log::debug!("Attempting to load servers token");
|
||||
|
||||
let store = app_handle
|
||||
.store(COCO_TAURI_STORE)
|
||||
@@ -187,10 +188,7 @@ pub async fn load_servers_token<R: Runtime>(
|
||||
save_access_token(server.id.clone(), server.clone());
|
||||
}
|
||||
|
||||
dbg!(format!(
|
||||
"loaded {:?} servers's token",
|
||||
&deserialized_tokens.len()
|
||||
));
|
||||
log::debug!("loaded {:?} servers's token", &deserialized_tokens.len());
|
||||
|
||||
Ok(deserialized_tokens)
|
||||
} else {
|
||||
@@ -231,7 +229,7 @@ pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<S
|
||||
save_server(&server);
|
||||
}
|
||||
|
||||
// dbg!(format!("load servers: {:?}", &deserialized_servers));
|
||||
log::debug!("load servers: {:?}", &deserialized_servers);
|
||||
|
||||
Ok(deserialized_servers)
|
||||
} else {
|
||||
@@ -243,18 +241,18 @@ pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<S
|
||||
pub async fn load_or_insert_default_server<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
) -> Result<Vec<Server>, String> {
|
||||
dbg!("Attempting to load or insert default server");
|
||||
log::debug!("Attempting to load or insert default server");
|
||||
|
||||
let exists_servers = load_servers(&app_handle).await;
|
||||
if exists_servers.is_ok() && !exists_servers.as_ref()?.is_empty() {
|
||||
dbg!(format!("loaded {} servers", &exists_servers.clone()?.len()));
|
||||
log::debug!("loaded {} servers", &exists_servers.clone()?.len());
|
||||
return exists_servers;
|
||||
}
|
||||
|
||||
let default = get_default_server();
|
||||
save_server(&default);
|
||||
|
||||
dbg!("loaded default servers");
|
||||
log::debug!("loaded default servers");
|
||||
|
||||
Ok(vec![default])
|
||||
}
|
||||
@@ -317,10 +315,19 @@ pub async fn refresh_coco_server_info<R: Runtime>(
|
||||
// Send request to fetch updated server info
|
||||
let response = HttpClient::get(&id, "/provider/_info", None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to contact the server: {}", e))?;
|
||||
.map_err(|e| {
|
||||
format!("Failed to contact the server: {}", e)
|
||||
});
|
||||
|
||||
if response.is_err() {
|
||||
let _ = mark_server_as_offline(app_handle, &id).await;
|
||||
return Err(response.err().unwrap());
|
||||
}
|
||||
|
||||
let response = response?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
mark_server_as_offline(&id).await;
|
||||
let _ = mark_server_as_offline(app_handle, &id).await;
|
||||
return Err(format!("Request failed with status: {}", response.status()));
|
||||
}
|
||||
|
||||
@@ -364,10 +371,10 @@ pub async fn add_coco_server<R: Runtime>(
|
||||
let endpoint = endpoint.trim_end_matches('/');
|
||||
|
||||
if check_endpoint_exists(endpoint) {
|
||||
dbg!(format!(
|
||||
log::debug!(
|
||||
"This Coco server has already been registered: {:?}",
|
||||
&endpoint
|
||||
));
|
||||
);
|
||||
return Err("This Coco server has already been registered.".into());
|
||||
}
|
||||
|
||||
@@ -376,7 +383,7 @@ pub async fn add_coco_server<R: Runtime>(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
|
||||
|
||||
dbg!(format!("Get provider info response: {:?}", &response));
|
||||
log::debug!("Get provider info response: {:?}", &response);
|
||||
|
||||
let body = get_response_body_text(response).await?;
|
||||
|
||||
@@ -400,7 +407,7 @@ pub async fn add_coco_server<R: Runtime>(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to persist Coco servers: {}", e))?;
|
||||
|
||||
dbg!(format!("Successfully registered server: {:?}", &endpoint));
|
||||
log::debug!("Successfully registered server: {:?}", &endpoint);
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
@@ -446,26 +453,46 @@ pub async fn try_register_server_to_search_source(
|
||||
server: &Server,
|
||||
) {
|
||||
if server.enabled {
|
||||
log::trace!(
|
||||
"Server {} is public: {} and available: {}",
|
||||
&server.name,
|
||||
&server.public,
|
||||
&server.available
|
||||
);
|
||||
|
||||
if !server.public {
|
||||
let token = get_server_token(&server.id).await;
|
||||
|
||||
if !token.is_ok() || token.is_ok() && token.unwrap().is_none() {
|
||||
log::debug!("Server {} is not public and no token was found", &server.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
let source = CocoSearchSource::new(server.clone());
|
||||
registry.register_source(source).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mark_server_as_offline(id: &str) {
|
||||
#[tauri::command]
|
||||
pub async fn mark_server_as_offline<R: Runtime>(
|
||||
app_handle: AppHandle<R>, id: &str) -> Result<(), ()> {
|
||||
// println!("server_is_offline: {}", id);
|
||||
let server = get_server_by_id(id);
|
||||
if let Some(mut server) = server {
|
||||
server.available = false;
|
||||
server.health = None;
|
||||
save_server(&server);
|
||||
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
registry.remove_source(id).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> {
|
||||
println!("disable_server: {}", id);
|
||||
|
||||
let server = get_server_by_id(id.as_str());
|
||||
if let Some(mut server) = server {
|
||||
server.enabled = false;
|
||||
@@ -486,47 +513,48 @@ pub async fn logout_coco_server<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
dbg!("Attempting to log out server by id:", &id);
|
||||
log::debug!("Attempting to log out server by id: {}", &id);
|
||||
|
||||
// Check if server token exists
|
||||
if let Some(_token) = get_server_token(id.as_str()).await? {
|
||||
dbg!("Found server token for id:", &id);
|
||||
log::debug!("Found server token for id: {}", &id);
|
||||
|
||||
// Remove the server token from cache
|
||||
remove_server_token(id.as_str());
|
||||
|
||||
// Persist the updated tokens
|
||||
if let Err(e) = persist_servers_token(&app_handle) {
|
||||
dbg!("Failed to save tokens for id: {}. Error: {:?}", &id, &e);
|
||||
log::debug!("Failed to save tokens for id: {}. Error: {:?}", &id, &e);
|
||||
return Err(format!("Failed to save tokens: {}", &e));
|
||||
}
|
||||
} else {
|
||||
// Log the case where server token is not found
|
||||
dbg!("No server token found for id: {}", &id);
|
||||
log::debug!("No server token found for id: {}", &id);
|
||||
}
|
||||
|
||||
// Check if the server exists
|
||||
if let Some(mut server) = get_server_by_id(id.as_str()) {
|
||||
dbg!("Found server for id:", &id);
|
||||
log::debug!("Found server for id: {}", &id);
|
||||
|
||||
// Clear server profile
|
||||
server.profile = None;
|
||||
let _ = mark_server_as_offline(app_handle.clone(), id.as_str()).await;
|
||||
|
||||
// Save the updated server data
|
||||
save_server(&server);
|
||||
|
||||
// Persist the updated server data
|
||||
if let Err(e) = persist_servers(&app_handle).await {
|
||||
dbg!("Failed to save server for id: {}. Error: {:?}", &id, &e);
|
||||
log::debug!("Failed to save server for id: {}. Error: {:?}", &id, &e);
|
||||
return Err(format!("Failed to save server: {}", &e));
|
||||
}
|
||||
} else {
|
||||
// Log the case where server is not found
|
||||
dbg!("No server found for id: {}", &id);
|
||||
log::debug!("No server found for id: {}", &id);
|
||||
return Err(format!("No server found for id: {}", id));
|
||||
}
|
||||
|
||||
dbg!("Successfully logged out server with id:", &id);
|
||||
log::debug!("Successfully logged out server with id: {}", &id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -577,6 +605,7 @@ fn test_trim_endpoint_last_forward_slash() {
|
||||
},
|
||||
},
|
||||
priority: 0,
|
||||
stats: None,
|
||||
};
|
||||
|
||||
trim_endpoint_last_forward_slash(&mut server);
|
||||
|
||||
@@ -95,8 +95,8 @@ pub async fn connect_to_server<R: Runtime>(
|
||||
true, // disable_nagle
|
||||
Some(connector), // Connector
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket TLS error: {:?}", e))?;
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket TLS error: {:?}", e))?;
|
||||
|
||||
let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
|
||||
|
||||
@@ -125,6 +125,7 @@ pub async fn connect_to_server<R: Runtime>(
|
||||
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;
|
||||
}
|
||||
@@ -132,7 +133,8 @@ pub async fn connect_to_server<R: Runtime>(
|
||||
}
|
||||
}
|
||||
_ = cancel_rx.recv() => {
|
||||
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
|
||||
log::debug!("WebSocket connection cancelled");
|
||||
let _ = app_handle_clone.emit(&format!("ws-cancel-{}", client_id_clone), id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
||||
use tauri::{ActivationPolicy, App, Emitter, EventTarget, WebviewWindow};
|
||||
use tauri::{App, Emitter, EventTarget, WebviewWindow};
|
||||
use tauri_nspanel::{cocoa::appkit::NSWindowCollectionBehavior, panel_delegate, WebviewWindowExt};
|
||||
|
||||
use crate::common::MAIN_WINDOW_LABEL;
|
||||
@@ -12,9 +12,7 @@ const WINDOW_BLUR_EVENT: &str = "tauri://blur";
|
||||
const WINDOW_MOVED_EVENT: &str = "tauri://move";
|
||||
const WINDOW_RESIZED_EVENT: &str = "tauri://resize";
|
||||
|
||||
pub fn platform(app: &mut App, main_window: WebviewWindow, _settings_window: WebviewWindow) {
|
||||
app.set_activation_policy(ActivationPolicy::Accessory);
|
||||
|
||||
pub fn platform(_app: &mut App, main_window: WebviewWindow, _settings_window: WebviewWindow) {
|
||||
// Convert ns_window to ns_panel
|
||||
let panel = main_window.to_panel().unwrap();
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ pub use linux::*;
|
||||
|
||||
pub fn default(app: &mut App, main_window: WebviewWindow, settings_window: WebviewWindow) {
|
||||
// Development mode automatically opens the console: https://tauri.app/develop/debug
|
||||
#[cfg(all(dev, debug_assertions))]
|
||||
#[cfg(debug_assertions)]
|
||||
main_window.open_devtools();
|
||||
|
||||
platform(app, main_window.clone(), settings_window.clone());
|
||||
|
||||
@@ -17,6 +17,7 @@ const DEFAULT_SHORTCUT: &str = "ctrl+shift+space";
|
||||
|
||||
/// Set up the shortcut upon app start.
|
||||
pub fn enable_shortcut(app: &App) {
|
||||
log::trace!("setting up Coco hotkey");
|
||||
let store = app
|
||||
.store(COCO_TAURI_STORE)
|
||||
.expect("creating a store should not fail");
|
||||
@@ -43,6 +44,7 @@ pub fn enable_shortcut(app: &App) {
|
||||
.expect("default shortcut should never be invalid");
|
||||
_register_shortcut_upon_start(app, default_shortcut);
|
||||
}
|
||||
log::trace!("Coco hotkey has been set");
|
||||
}
|
||||
|
||||
/// Get the stored shortcut as a string, same as [`_get_shortcut()`], except that
|
||||
@@ -97,7 +99,7 @@ fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
|
||||
.on_shortcut(shortcut, move |app, scut, event| {
|
||||
if scut == &shortcut {
|
||||
dbg!("shortcut pressed");
|
||||
let main_window = app.get_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
let main_window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
if let ShortcutState::Pressed = event.state() {
|
||||
let app_handle = app.clone();
|
||||
if main_window.is_visible().unwrap() {
|
||||
@@ -126,7 +128,7 @@ fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(move |app, scut, event| {
|
||||
if scut == &shortcut {
|
||||
let window = app.get_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
let window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
if let ShortcutState::Pressed = event.state() {
|
||||
let app_handle = app.clone();
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
|
||||
//
|
||||
// tauri_plugin_shell::open() is deprecated, but we still use it.
|
||||
#[allow(deprecated)]
|
||||
#[tauri::command]
|
||||
pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<(), String> {
|
||||
if cfg!(target_os = "linux") {
|
||||
let borrowed_path = Path::new(&path);
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
"url": "/ui/settings",
|
||||
"width": 1000,
|
||||
"height": 700,
|
||||
"minHeight": 700,
|
||||
"minWidth": 1000,
|
||||
"center": true,
|
||||
"transparent": true,
|
||||
"maximizable": false,
|
||||
@@ -92,7 +94,7 @@
|
||||
"icons/StoreLogo.png"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "12.0",
|
||||
"minimumSystemVersion": "10.12",
|
||||
"hardenedRuntime": true,
|
||||
"dmg": {
|
||||
"appPosition": {
|
||||
@@ -105,7 +107,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"resources": ["assets", "icons"]
|
||||
"resources": ["assets/**/*", "icons"]
|
||||
},
|
||||
"plugins": {
|
||||
"features": {
|
||||
|
||||
BIN
src/assets/images/ReadAloud/back-dark.png
Executable file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/images/ReadAloud/back-light.png
Executable file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/images/ReadAloud/close-dark.png
Executable file
|
After Width: | Height: | Size: 346 B |
BIN
src/assets/images/ReadAloud/close-light.png
Executable file
|
After Width: | Height: | Size: 347 B |
BIN
src/assets/images/ReadAloud/forward-dark.png
Executable file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/images/ReadAloud/forward-light.png
Executable file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/images/ReadAloud/loading-dark.png
Executable file
|
After Width: | Height: | Size: 485 B |
BIN
src/assets/images/ReadAloud/loading-light.png
Executable file
|
After Width: | Height: | Size: 491 B |
BIN
src/assets/images/ReadAloud/pause-dark.png
Executable file
|
After Width: | Height: | Size: 504 B |
BIN
src/assets/images/ReadAloud/pause-light.png
Executable file
|
After Width: | Height: | Size: 500 B |
BIN
src/assets/images/ReadAloud/play-dark.png
Executable file
|
After Width: | Height: | Size: 203 B |
BIN
src/assets/images/ReadAloud/play-light.png
Executable file
|
After Width: | Height: | Size: 196 B |
@@ -16,11 +16,32 @@ import {
|
||||
MultiSourceQueryResponse,
|
||||
} from "@/types/commands";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
// Endpoints that don't require authentication
|
||||
const WHITELIST_SERVERS = [
|
||||
"list_coco_servers",
|
||||
"add_coco_server",
|
||||
"enable_server",
|
||||
"disable_server",
|
||||
"remove_coco_server",
|
||||
"logout_coco_server",
|
||||
"refresh_coco_server_info",
|
||||
"handle_sso_callback",
|
||||
"query_coco_fusion",
|
||||
"open_session_chat", // TODO: quick ai access is a configured service, even if the current service is not logged in, it should not affect the configured service.
|
||||
];
|
||||
|
||||
async function invokeWithErrorHandler<T>(
|
||||
command: string,
|
||||
args?: Record<string, any>
|
||||
): Promise<T> {
|
||||
const isCurrentLogin = useAuthStore.getState().isCurrentLogin;
|
||||
if (!WHITELIST_SERVERS.includes(command) && !isCurrentLogin) {
|
||||
console.error("This command requires authentication");
|
||||
throw new Error("This command requires authentication");
|
||||
}
|
||||
//
|
||||
const addError = useAppStore.getState().addError;
|
||||
try {
|
||||
const result = await invoke<T>(command, args);
|
||||
@@ -30,7 +51,7 @@ async function invokeWithErrorHandler<T>(
|
||||
const failedResult = result as any;
|
||||
if (failedResult.failed?.length > 0 && failedResult?.hits?.length == 0) {
|
||||
failedResult.failed.forEach((error: any) => {
|
||||
addError(error.error, 'error');
|
||||
addError(error.error, "error");
|
||||
// console.error(error.error);
|
||||
});
|
||||
}
|
||||
@@ -261,6 +282,19 @@ export const assistant_search = (payload: {
|
||||
return invokeWithErrorHandler<boolean>("assistant_search", payload);
|
||||
};
|
||||
|
||||
export const assistant_get = (payload: {
|
||||
serverId: string;
|
||||
assistantId: string;
|
||||
}): Promise<boolean> => {
|
||||
return invokeWithErrorHandler<boolean>("assistant_get", payload);
|
||||
};
|
||||
|
||||
export const assistant_get_multi = (payload: {
|
||||
assistantId: string;
|
||||
}): Promise<boolean> => {
|
||||
return invokeWithErrorHandler<boolean>("assistant_get_multi", payload);
|
||||
};
|
||||
|
||||
export const upload_attachment = async (payload: UploadAttachmentPayload) => {
|
||||
const response = await invokeWithErrorHandler<UploadAttachmentResponse>(
|
||||
"upload_attachment",
|
||||
|
||||
125
src/components/Assistant/AssistantFetcher.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
import { Post } from "@/api/axiosRequest";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
interface AssistantFetcherProps {
|
||||
debounceKeyword?: string;
|
||||
assistantIDs?: string[];
|
||||
}
|
||||
|
||||
export const AssistantFetcher = ({
|
||||
debounceKeyword = "",
|
||||
assistantIDs = [],
|
||||
}: AssistantFetcherProps) => {
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
|
||||
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||||
const setCurrentAssistant = useConnectStore((state) => {
|
||||
return state.setCurrentAssistant;
|
||||
});
|
||||
|
||||
const lastServerId = useRef<string | null>(null);
|
||||
|
||||
const fetchAssistant = async (params: {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
serverId?: string;
|
||||
}) => {
|
||||
try {
|
||||
if (isTauri && !currentService?.enabled) {
|
||||
return {
|
||||
total: 0,
|
||||
list: [],
|
||||
};
|
||||
}
|
||||
|
||||
const { pageSize, current, serverId = currentService?.id } = params;
|
||||
|
||||
const from = (current - 1) * pageSize;
|
||||
const size = pageSize;
|
||||
|
||||
let response: any;
|
||||
|
||||
const body: Record<string, any> = {
|
||||
serverId,
|
||||
from,
|
||||
size,
|
||||
};
|
||||
|
||||
body.query = {
|
||||
bool: {
|
||||
must: [{ term: { enabled: true } }],
|
||||
},
|
||||
};
|
||||
|
||||
if (debounceKeyword) {
|
||||
body.query.bool.must.push({
|
||||
query_string: {
|
||||
fields: ["combined_fulltext"],
|
||||
query: debounceKeyword,
|
||||
fuzziness: "AUTO",
|
||||
fuzzy_prefix_length: 2,
|
||||
fuzzy_max_expansions: 10,
|
||||
fuzzy_transpositions: true,
|
||||
allow_leading_wildcard: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (assistantIDs.length > 0) {
|
||||
body.query.bool.must.push({
|
||||
terms: {
|
||||
id: assistantIDs.map((id) => id),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isTauri) {
|
||||
if (!currentService?.id) {
|
||||
throw new Error("currentService is undefined");
|
||||
}
|
||||
|
||||
response = await platformAdapter.commands("assistant_search", body);
|
||||
} else {
|
||||
body.serverId = undefined;
|
||||
const [error, res] = await Post(`/assistant/_search`, body);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
response = res;
|
||||
}
|
||||
|
||||
let assistantList = response?.hits?.hits ?? [];
|
||||
|
||||
console.log("assistantList", assistantList);
|
||||
|
||||
if (
|
||||
!currentAssistant?._id ||
|
||||
currentService?.id !== lastServerId.current
|
||||
) {
|
||||
setCurrentAssistant(assistantList[0]);
|
||||
}
|
||||
lastServerId.current = currentService?.id;
|
||||
|
||||
return {
|
||||
total: response.hits.total.value,
|
||||
list: assistantList,
|
||||
};
|
||||
} catch (error) {
|
||||
setCurrentAssistant(null);
|
||||
console.error("assistant_search", error);
|
||||
return {
|
||||
total: 0,
|
||||
list: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return { fetchAssistant };
|
||||
};
|
||||
70
src/components/Assistant/AssistantItem.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { memo } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import VisibleKey from "@/components/Common/VisibleKey";
|
||||
import FontIcon from "@/components/Common/Icons/FontIcon";
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
|
||||
interface AssistantItemProps {
|
||||
_id: string;
|
||||
_source?: {
|
||||
icon?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
};
|
||||
name?: string;
|
||||
isActive: boolean;
|
||||
isHighlight: boolean;
|
||||
isKeyboardActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const AssistantItem = memo(
|
||||
({
|
||||
_id,
|
||||
_source,
|
||||
name,
|
||||
isActive,
|
||||
isHighlight,
|
||||
isKeyboardActive = false,
|
||||
onClick,
|
||||
}: AssistantItemProps) => (
|
||||
<button
|
||||
key={_id}
|
||||
className={clsx(
|
||||
"w-full flex items-center h-[50px] gap-2 rounded-lg p-2 mb-1 transition",
|
||||
{
|
||||
"hover:bg-[#E6E6E6] dark:hover:bg-[#1F2937]": !isKeyboardActive,
|
||||
"bg-[#E6E6E6] dark:bg-[#1F2937]": isHighlight || isActive,
|
||||
}
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center justify-center size-6 bg-white border border-[#E6E6E6] rounded-full overflow-hidden">
|
||||
{_source?.icon?.startsWith("font_") ? (
|
||||
<FontIcon name={_source?.icon} className="size-4" />
|
||||
) : (
|
||||
<img src={logoImg} className="size-4" alt={name} />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{_source?.name || "-"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{_source?.description || ""}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="flex items-center">
|
||||
<VisibleKey shortcut="↓↑" shortcutClassName="w-6 -translate-x-4">
|
||||
<Check className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</VisibleKey>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
|
||||
export default AssistantItem;
|
||||
@@ -1,48 +1,32 @@
|
||||
import { useState, useRef, useCallback, useMemo } from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
RefreshCw,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { ChevronDownIcon, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isNil } from "lodash-es";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import { useDebounce, useKeyPress, usePagination } from "ahooks";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import VisibleKey from "@/components/Common/VisibleKey";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import FontIcon from "@/components/Common/Icons/FontIcon";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { Post } from "@/api/axiosRequest";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import {
|
||||
useAsyncEffect,
|
||||
useDebounce,
|
||||
useKeyPress,
|
||||
usePagination,
|
||||
useReactive,
|
||||
} from "ahooks";
|
||||
import clsx from "clsx";
|
||||
import NoDataImage from "../Common/NoDataImage";
|
||||
import PopoverInput from "../Common/PopoverInput";
|
||||
import { isNil } from "lodash-es";
|
||||
import NoDataImage from "@/components/Common/NoDataImage";
|
||||
import PopoverInput from "@/components/Common/PopoverInput";
|
||||
import { AssistantFetcher } from "./AssistantFetcher";
|
||||
import AssistantItem from "./AssistantItem";
|
||||
import Pagination from "@/components/Common/Pagination";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { specialCharacterFiltering } from "@/utils"
|
||||
|
||||
interface AssistantListProps {
|
||||
assistantIDs?: string[];
|
||||
}
|
||||
|
||||
interface State {
|
||||
allAssistants: any[];
|
||||
}
|
||||
|
||||
export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { connected } = useChatStore();
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const setAssistantList = useConnectStore((state) => state.setAssistantList);
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||||
const setCurrentAssistant = useConnectStore((state) => {
|
||||
@@ -56,135 +40,45 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const debounceKeyword = useDebounce(keyword, { wait: 500 });
|
||||
const state = useReactive<State>({
|
||||
allAssistants: [],
|
||||
const askAiAssistantId = useSearchStore((state) => state.askAiAssistantId);
|
||||
const setAskAiAssistantId = useSearchStore((state) => {
|
||||
return state.setAskAiAssistantId;
|
||||
});
|
||||
const assistantList = useConnectStore((state) => state.assistantList);
|
||||
const connected = useChatStore((state) => {
|
||||
return state.connected;
|
||||
});
|
||||
|
||||
const currentServiceId = useMemo(() => {
|
||||
return currentService?.id;
|
||||
}, [connected, currentService?.id]);
|
||||
const { fetchAssistant } = AssistantFetcher({
|
||||
debounceKeyword,
|
||||
assistantIDs,
|
||||
});
|
||||
|
||||
const fetchAssistant = async (params: {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
}) => {
|
||||
try {
|
||||
const { pageSize, current } = params;
|
||||
|
||||
const from = (current - 1) * pageSize;
|
||||
const size = pageSize;
|
||||
|
||||
let response: any;
|
||||
|
||||
const body: Record<string, any> = {
|
||||
serverId: currentServiceId,
|
||||
from,
|
||||
size,
|
||||
};
|
||||
|
||||
if (debounceKeyword || assistantIDs.length > 0) {
|
||||
body.query = {
|
||||
bool: {
|
||||
must: [],
|
||||
},
|
||||
};
|
||||
if (debounceKeyword) {
|
||||
body.query.bool.must.push({
|
||||
query_string: {
|
||||
fields: ["combined_fulltext"],
|
||||
query: debounceKeyword,
|
||||
fuzziness: "AUTO",
|
||||
fuzzy_prefix_length: 2,
|
||||
fuzzy_max_expansions: 10,
|
||||
fuzzy_transpositions: true,
|
||||
allow_leading_wildcard: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (assistantIDs.length > 0) {
|
||||
body.query.bool.must.push({
|
||||
terms: {
|
||||
id: assistantIDs.map((id) => id),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isTauri) {
|
||||
if (!currentServiceId) {
|
||||
throw new Error("currentServiceId is undefined");
|
||||
}
|
||||
|
||||
response = await platformAdapter.commands("assistant_search", body);
|
||||
} else {
|
||||
const [error, res] = await Post(`/assistant/_search`, body);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
response = res;
|
||||
}
|
||||
|
||||
console.log("assistant_search", response);
|
||||
|
||||
let assistantList = response?.hits?.hits ?? [];
|
||||
|
||||
console.log("assistantList", assistantList);
|
||||
|
||||
for (const item of assistantList) {
|
||||
const index = state.allAssistants.findIndex((allItem: any) => {
|
||||
return item._id === allItem._id;
|
||||
});
|
||||
|
||||
if (index === -1) {
|
||||
state.allAssistants.push(item);
|
||||
} else {
|
||||
state.allAssistants[index] = item;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("state.allAssistants", state.allAssistants);
|
||||
|
||||
const matched = state.allAssistants.find((item: any) => {
|
||||
return item._id === currentAssistant?._id;
|
||||
});
|
||||
|
||||
console.log("matched", matched);
|
||||
|
||||
if (matched) {
|
||||
setCurrentAssistant(matched);
|
||||
} else {
|
||||
setCurrentAssistant(assistantList[0]);
|
||||
}
|
||||
|
||||
return {
|
||||
total: response.hits.total.value,
|
||||
list: assistantList,
|
||||
};
|
||||
} catch (error) {
|
||||
setCurrentAssistant(null);
|
||||
|
||||
console.error("assistant_search", error);
|
||||
|
||||
return {
|
||||
const getAssistants = (params: { current: number; pageSize: number }) => {
|
||||
if (!connected) {
|
||||
return Promise.resolve({
|
||||
total: 0,
|
||||
list: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return fetchAssistant(params);
|
||||
};
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const data = await fetchAssistant({ current: 1, pageSize: 1000 });
|
||||
|
||||
setAssistantList(data.list);
|
||||
}, [currentServiceId]);
|
||||
|
||||
const { pagination, runAsync } = usePagination(fetchAssistant, {
|
||||
const { pagination, runAsync } = usePagination(getAssistants, {
|
||||
defaultPageSize: 5,
|
||||
refreshDeps: [currentServiceId, debounceKeyword],
|
||||
refreshDeps: [
|
||||
currentService?.id,
|
||||
debounceKeyword,
|
||||
currentService?.enabled,
|
||||
connected,
|
||||
],
|
||||
onSuccess(data) {
|
||||
setAssistants(data.list);
|
||||
|
||||
if (data.list.length === 0) {
|
||||
setCurrentAssistant(void 0);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -196,6 +90,22 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
setTimeout(() => setIsRefreshing(false), 1000);
|
||||
};
|
||||
|
||||
const [highlightIndex, setHighlightIndex] = useState<number>(-1);
|
||||
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!askAiAssistantId || assistantList.length === 0) return;
|
||||
|
||||
const matched = assistantList.find((item) => {
|
||||
return item._id === askAiAssistantId;
|
||||
});
|
||||
|
||||
if (!matched) return;
|
||||
|
||||
setCurrentAssistant(matched);
|
||||
setAskAiAssistantId(void 0);
|
||||
}, [assistantList, askAiAssistantId]);
|
||||
|
||||
useKeyPress(
|
||||
["uparrow", "downarrow", "enter"],
|
||||
(event, key) => {
|
||||
@@ -206,9 +116,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (key === "enter") {
|
||||
return popoverButtonRef.current?.click();
|
||||
}
|
||||
setIsKeyboardActive(true);
|
||||
|
||||
const index = assistants.findIndex(
|
||||
(item) => item._id === currentAssistant?._id
|
||||
@@ -217,15 +125,20 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
|
||||
if (length <= 1) return;
|
||||
|
||||
let nextIndex = index;
|
||||
let nextIndex = highlightIndex === -1 ? index : highlightIndex;
|
||||
|
||||
if (key === "uparrow") {
|
||||
nextIndex = index > 0 ? index - 1 : length - 1;
|
||||
} else {
|
||||
nextIndex = index < length - 1 ? index + 1 : 0;
|
||||
nextIndex = nextIndex > 0 ? nextIndex - 1 : length - 1;
|
||||
} else if (key === "downarrow") {
|
||||
nextIndex = nextIndex < length - 1 ? nextIndex + 1 : 0;
|
||||
}
|
||||
|
||||
setCurrentAssistant(assistants[nextIndex]);
|
||||
if (key === "enter") {
|
||||
setCurrentAssistant(assistants[nextIndex]);
|
||||
return popoverButtonRef.current?.click();
|
||||
}
|
||||
|
||||
setHighlightIndex(nextIndex);
|
||||
},
|
||||
{
|
||||
target: popoverRef,
|
||||
@@ -246,6 +159,11 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
pagination.changeCurrent(pagination.current + 1);
|
||||
}, [pagination]);
|
||||
|
||||
const handleMouseMove = useCallback(() => {
|
||||
setHighlightIndex(-1);
|
||||
setIsKeyboardActive(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Popover ref={popoverRef}>
|
||||
@@ -280,7 +198,10 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
</VisibleKey>
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel className="absolute z-50 top-full mt-1 left-0 w-60 rounded-xl bg-white dark:bg-[#202126] p-3 text-sm/6 text-[#333] dark:text-[#D8D8D8] shadow-lg border dark:border-white/10 focus:outline-none max-h-[calc(100vh-80px)] overflow-y-auto">
|
||||
<PopoverPanel
|
||||
className="absolute z-50 top-full mt-1 left-0 w-60 rounded-xl bg-white dark:bg-[#202126] p-3 text-sm/6 text-[#333] dark:text-[#D8D8D8] shadow-lg border dark:border-white/10 focus:outline-none max-h-[calc(100vh-80px)] overflow-y-auto"
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
<div className="flex items-center justify-between text-sm font-bold">
|
||||
<div>
|
||||
{t("assistant.popover.title")}({pagination.total})
|
||||
@@ -319,81 +240,37 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
placeholder={t("assistant.popover.search")}
|
||||
className="w-full h-8 px-2 bg-transparent border rounded-md dark:border-white/10"
|
||||
onChange={(event) => {
|
||||
console.log("onChange", event.target.value);
|
||||
setKeyword(event.target.value.trim());
|
||||
const value = specialCharacterFiltering(event.target.value.trim())
|
||||
setKeyword(value);
|
||||
}}
|
||||
/>
|
||||
</VisibleKey>
|
||||
|
||||
{assistants.length > 0 ? (
|
||||
<>
|
||||
{assistants.map((assistant) => {
|
||||
const { _id, _source, name } = assistant;
|
||||
|
||||
const isActive = currentAssistant?._id === _id;
|
||||
|
||||
{assistants.map((assistant, index) => {
|
||||
return (
|
||||
<button
|
||||
key={_id}
|
||||
className={clsx(
|
||||
"w-full flex items-center h-[50px] gap-2 rounded-lg p-2 mb-1 hover:bg-[#E6E6E6] dark:hover:bg-[#1F2937] transition",
|
||||
{
|
||||
"bg-[#E6E6E6] dark:bg-[#1F2937]": isActive,
|
||||
}
|
||||
)}
|
||||
<AssistantItem
|
||||
key={assistant._id}
|
||||
{...assistant}
|
||||
isActive={currentAssistant?._id === assistant._id}
|
||||
isHighlight={highlightIndex === index}
|
||||
isKeyboardActive={isKeyboardActive}
|
||||
onClick={() => {
|
||||
setCurrentAssistant(assistant);
|
||||
popoverButtonRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center size-6 bg-white border border-[#E6E6E6] rounded-full overflow-hidden">
|
||||
{_source?.icon?.startsWith("font_") ? (
|
||||
<FontIcon name={_source?.icon} className="size-4" />
|
||||
) : (
|
||||
<img src={logoImg} className="size-4" alt={name} />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{_source?.name || "-"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{_source?.description || ""}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="flex items-center">
|
||||
<VisibleKey
|
||||
shortcut="↓↑"
|
||||
shortcutClassName="w-6 -translate-x-4"
|
||||
>
|
||||
<Check className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</VisibleKey>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex items-center justify-between h-8 -mx-3 -mb-3 px-3 text-[#999] border-t dark:border-t-white/10">
|
||||
<VisibleKey shortcut="leftarrow" onKeyPress={handlePrev}>
|
||||
<ChevronLeft
|
||||
className="size-4 cursor-pointer"
|
||||
onClick={handlePrev}
|
||||
/>
|
||||
</VisibleKey>
|
||||
|
||||
<div className="text-xs">
|
||||
{pagination.current}/{pagination.totalPage}
|
||||
</div>
|
||||
|
||||
<VisibleKey shortcut="rightarrow" onKeyPress={handleNext}>
|
||||
<ChevronRight
|
||||
className="size-4 cursor-pointer"
|
||||
onClick={handleNext}
|
||||
/>
|
||||
</VisibleKey>
|
||||
</div>
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
totalPage={pagination.totalPage}
|
||||
onPrev={handlePrev}
|
||||
onNext={handleNext}
|
||||
className="-mx-3 -mb-3"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-2">
|
||||
|
||||
@@ -19,9 +19,13 @@ import { ChatSidebar } from "./ChatSidebar";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { ChatContent } from "./ChatContent";
|
||||
import ConnectPrompt from "./ConnectPrompt";
|
||||
import type { Chat } from "./types";
|
||||
import type { Chat, StartPage } from "@/types/chat";
|
||||
import PrevSuggestion from "@/components/ChatMessage/PrevSuggestion";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
// import ReadAloud from "./ReadAloud";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import Splash from "./Splash";
|
||||
|
||||
interface ChatAIProps {
|
||||
isSearchActive?: boolean;
|
||||
@@ -36,6 +40,7 @@ interface ChatAIProps {
|
||||
getFileUrl: (path: string) => string;
|
||||
showChatHistory?: boolean;
|
||||
assistantIDs?: string[];
|
||||
startPage?: StartPage;
|
||||
}
|
||||
|
||||
export interface ChatAIRef {
|
||||
@@ -61,6 +66,7 @@ const ChatAI = memo(
|
||||
getFileUrl,
|
||||
showChatHistory,
|
||||
assistantIDs,
|
||||
startPage,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -74,7 +80,13 @@ const ChatAI = memo(
|
||||
const { curChatEnd, setCurChatEnd, connected, setConnected } =
|
||||
useChatStore();
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
|
||||
const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
|
||||
const setIsCurrentLogin = useAuthStore((state) => {
|
||||
return state.setIsCurrentLogin;
|
||||
});
|
||||
|
||||
const visibleStartPage = useConnectStore((state) => {
|
||||
return state.visibleStartPage;
|
||||
});
|
||||
@@ -83,17 +95,51 @@ const ChatAI = memo(
|
||||
|
||||
const [activeChat, setActiveChat] = useState<Chat>();
|
||||
const [timedoutShow, setTimedoutShow] = useState(false);
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
|
||||
const curIdRef = useRef("");
|
||||
|
||||
const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen);
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const askAiSessionId = useSearchStore((state) => state.askAiSessionId);
|
||||
const setAskAiSessionId = useSearchStore(
|
||||
(state) => state.setAskAiSessionId
|
||||
);
|
||||
const askAiServerId = useSearchStore((state) => {
|
||||
return state.askAiServerId;
|
||||
});
|
||||
const currentService = useConnectStore((state) => {
|
||||
return state.currentService;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
activeChatProp && setActiveChat(activeChatProp);
|
||||
}, [activeChatProp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
|
||||
if (!currentService?.enabled) {
|
||||
setActiveChat(void 0);
|
||||
setIsCurrentLogin(false);
|
||||
}
|
||||
|
||||
if (showChatHistory && connected) {
|
||||
getChatHistory();
|
||||
}
|
||||
}, [currentService?.enabled, showChatHistory, connected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (askAiServerId || !askAiSessionId || chats.length === 0) return;
|
||||
|
||||
const matched = chats.find((item) => item._id === askAiSessionId);
|
||||
|
||||
if (matched) {
|
||||
onSelectChat(matched);
|
||||
|
||||
setAskAiSessionId(void 0);
|
||||
}
|
||||
}, [chats, askAiSessionId, askAiServerId]);
|
||||
|
||||
const [Question, setQuestion] = useState<string>("");
|
||||
|
||||
const [websocketSessionId, setWebsocketSessionId] = useState("");
|
||||
@@ -133,7 +179,6 @@ const ChatAI = memo(
|
||||
clientId,
|
||||
connected,
|
||||
setConnected,
|
||||
currentService,
|
||||
dealMsgRef,
|
||||
onWebsocketSessionId,
|
||||
});
|
||||
@@ -151,7 +196,6 @@ const ChatAI = memo(
|
||||
handleRename,
|
||||
handleDelete,
|
||||
} = useChatActions(
|
||||
currentService?.id,
|
||||
setActiveChat,
|
||||
setCurChatEnd,
|
||||
setTimedoutShow,
|
||||
@@ -195,8 +239,8 @@ const ChatAI = memo(
|
||||
const init = useCallback(
|
||||
async (value: string) => {
|
||||
try {
|
||||
//console.log("init", isLogin, curChatEnd, activeChat?._id);
|
||||
if (!isLogin) {
|
||||
//console.log("init", curChatEnd, activeChat?._id);
|
||||
if (!isCurrentLogin) {
|
||||
addError("Please login to continue chatting");
|
||||
return;
|
||||
}
|
||||
@@ -214,7 +258,7 @@ const ChatAI = memo(
|
||||
}
|
||||
},
|
||||
[
|
||||
isLogin,
|
||||
isCurrentLogin,
|
||||
curChatEnd,
|
||||
activeChat?._id,
|
||||
createNewChat,
|
||||
@@ -295,6 +339,7 @@ const ChatAI = memo(
|
||||
(chatId: string, title: string) => {
|
||||
setChats((prev) => {
|
||||
const chatIndex = prev.findIndex((chat) => chat._id === chatId);
|
||||
|
||||
if (chatIndex === -1) return prev;
|
||||
|
||||
const modifiedChat = {
|
||||
@@ -303,8 +348,8 @@ const ChatAI = memo(
|
||||
};
|
||||
|
||||
const result = [...prev];
|
||||
result.splice(chatIndex, 1);
|
||||
return [modifiedChat, ...result];
|
||||
result.splice(chatIndex, 1, modifiedChat);
|
||||
return result;
|
||||
});
|
||||
|
||||
if (activeChat?._id === chatId) {
|
||||
@@ -320,16 +365,12 @@ const ChatAI = memo(
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={`flex flex-col rounded-md relative h-full overflow-hidden`}
|
||||
>
|
||||
<>
|
||||
{showChatHistory && !setIsSidebarOpen && (
|
||||
<ChatSidebar
|
||||
isSidebarOpen={isSidebarOpenChat}
|
||||
chats={chats}
|
||||
activeChat={activeChat}
|
||||
// onNewChat={clearChat}
|
||||
onSelectChat={onSelectChat}
|
||||
onDeleteChat={deleteChat}
|
||||
fetchChatHistory={getChatHistory}
|
||||
@@ -337,48 +378,55 @@ const ChatAI = memo(
|
||||
onRename={renameChat}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChatHeader
|
||||
onCreateNewChat={clearChat}
|
||||
onOpenChatAI={openChatAI}
|
||||
setIsSidebarOpen={toggleSidebar}
|
||||
isSidebarOpen={isSidebarOpenChat}
|
||||
activeChat={activeChat}
|
||||
reconnect={reconnect}
|
||||
isChatPage={isChatPage}
|
||||
isLogin={isLogin}
|
||||
setIsLogin={setIsLogin}
|
||||
showChatHistory={showChatHistory}
|
||||
assistantIDs={assistantIDs}
|
||||
/>
|
||||
|
||||
{isLogin ? (
|
||||
<ChatContent
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={`flex flex-col rounded-md h-full overflow-hidden relative`}
|
||||
>
|
||||
<ChatHeader
|
||||
clearChat={clearChat}
|
||||
onOpenChatAI={openChatAI}
|
||||
setIsSidebarOpen={toggleSidebar}
|
||||
isSidebarOpen={isSidebarOpenChat}
|
||||
activeChat={activeChat}
|
||||
curChatEnd={curChatEnd}
|
||||
query_intent={query_intent}
|
||||
tools={tools}
|
||||
fetch_source={fetch_source}
|
||||
pick_source={pick_source}
|
||||
deep_read={deep_read}
|
||||
think={think}
|
||||
response={response}
|
||||
loadingStep={loadingStep}
|
||||
timedoutShow={timedoutShow}
|
||||
Question={Question}
|
||||
handleSendMessage={(value) =>
|
||||
handleSendMessage(value, activeChat)
|
||||
}
|
||||
getFileUrl={getFileUrl}
|
||||
reconnect={reconnect}
|
||||
isChatPage={isChatPage}
|
||||
showChatHistory={showChatHistory}
|
||||
assistantIDs={assistantIDs}
|
||||
/>
|
||||
) : (
|
||||
<ConnectPrompt />
|
||||
)}
|
||||
|
||||
{!activeChat?._id && !visibleStartPage && (
|
||||
<PrevSuggestion sendMessage={init} />
|
||||
)}
|
||||
</div>
|
||||
{isCurrentLogin ? (
|
||||
<>
|
||||
<ChatContent
|
||||
activeChat={activeChat}
|
||||
curChatEnd={curChatEnd}
|
||||
query_intent={query_intent}
|
||||
tools={tools}
|
||||
fetch_source={fetch_source}
|
||||
pick_source={pick_source}
|
||||
deep_read={deep_read}
|
||||
think={think}
|
||||
response={response}
|
||||
loadingStep={loadingStep}
|
||||
timedoutShow={timedoutShow}
|
||||
Question={Question}
|
||||
handleSendMessage={(value) =>
|
||||
handleSendMessage(value, activeChat)
|
||||
}
|
||||
getFileUrl={getFileUrl}
|
||||
/>
|
||||
<Splash assistantIDs={assistantIDs} startPage={startPage} />
|
||||
</>
|
||||
) : (
|
||||
<ConnectPrompt />
|
||||
)}
|
||||
|
||||
{!activeChat?._id && !visibleStartPage && (
|
||||
<PrevSuggestion sendMessage={init} />
|
||||
)}
|
||||
|
||||
{/* <ReadAloud /> */}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,13 +6,10 @@ import { Greetings } from "./Greetings";
|
||||
import FileList from "@/components/Assistant/FileList";
|
||||
import { useChatScroll } from "@/hooks/useChatScroll";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import type { Chat, IChunkData } from "./types";
|
||||
// import SessionFile from "./SessionFile";
|
||||
import type { Chat, IChunkData } from "@/types/chat";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import SessionFile from "./SessionFile";
|
||||
import Splash from "./Splash";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import ScrollToBottom from "@/components/Common/ScrollToBottom";
|
||||
|
||||
interface ChatContentProps {
|
||||
activeChat?: Chat;
|
||||
@@ -52,10 +49,6 @@ export const ChatContent = ({
|
||||
return state.setCurrentSessionId;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSessionId(activeChat?._id);
|
||||
}, [activeChat?._id]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const uploadFiles = useChatStore((state) => state.uploadFiles);
|
||||
@@ -64,11 +57,17 @@ export const ChatContent = ({
|
||||
const { scrollToBottom } = useChatScroll(messagesEndRef);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAtBottom(true);
|
||||
setCurrentSessionId(activeChat?._id);
|
||||
}, [activeChat?._id]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [
|
||||
activeChat?.messages,
|
||||
activeChat?.id,
|
||||
query_intent?.message_chunk,
|
||||
fetch_source?.message_chunk,
|
||||
pick_source?.message_chunk,
|
||||
@@ -96,13 +95,14 @@ export const ChatContent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden flex flex-col justify-between relative">
|
||||
<div className="flex-1 overflow-hidden flex flex-col justify-between relative user-select-text">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{(!activeChat || activeChat?.messages?.length === 0) && <Greetings />}
|
||||
{(!activeChat || activeChat?.messages?.length === 0) &&
|
||||
!visibleStartPage && <Greetings />}
|
||||
|
||||
{activeChat?.messages?.map((message, index) => (
|
||||
<ChatMessage
|
||||
@@ -173,24 +173,7 @@ export const ChatContent = ({
|
||||
|
||||
{sessionId && <SessionFile sessionId={sessionId} />}
|
||||
|
||||
<Splash />
|
||||
|
||||
<button
|
||||
className={clsx(
|
||||
"absolute right-4 bottom-4 flex items-center justify-center size-8 border bg-white rounded-full shadow dark:border-[#272828] dark:bg-black dark:shadow-white/15",
|
||||
{
|
||||
hidden: isAtBottom,
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current?.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ArrowDown className="size-5" />
|
||||
</button>
|
||||
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,38 +5,36 @@ import HistoryIcon from "@/icons/History";
|
||||
import PinOffIcon from "@/icons/PinOff";
|
||||
import PinIcon from "@/icons/Pin";
|
||||
import WindowsFullIcon from "@/icons/WindowsFull";
|
||||
import { useAppStore, IServer } from "@/stores/appStore";
|
||||
import type { Chat } from "./types";
|
||||
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 { Server } from "@/types/server"
|
||||
|
||||
|
||||
interface ChatHeaderProps {
|
||||
onCreateNewChat: () => void;
|
||||
clearChat: () => void;
|
||||
onOpenChatAI: () => void;
|
||||
setIsSidebarOpen: () => void;
|
||||
isSidebarOpen: boolean;
|
||||
activeChat: Chat | undefined;
|
||||
reconnect: (server?: IServer) => void;
|
||||
isLogin: boolean;
|
||||
setIsLogin: (isLogin: boolean) => void;
|
||||
reconnect: (server?: Server) => void;
|
||||
isChatPage?: boolean;
|
||||
showChatHistory?: boolean;
|
||||
assistantIDs?: string[];
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
onCreateNewChat,
|
||||
clearChat,
|
||||
onOpenChatAI,
|
||||
isSidebarOpen,
|
||||
setIsSidebarOpen,
|
||||
activeChat,
|
||||
reconnect,
|
||||
isLogin,
|
||||
setIsLogin,
|
||||
isChatPage = false,
|
||||
showChatHistory = true,
|
||||
assistantIDs,
|
||||
@@ -97,10 +95,10 @@ export function ChatHeader({
|
||||
|
||||
{showChatHistory ? (
|
||||
<button
|
||||
onClick={onCreateNewChat}
|
||||
onClick={clearChat}
|
||||
className="p-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<VisibleKey shortcut={newSession} onKeyPress={onCreateNewChat}>
|
||||
<VisibleKey shortcutClassName="top-2.5" shortcut={newSession} onKeyPress={clearChat}>
|
||||
<MessageSquarePlus className="h-4 w-4 relative top-0.5" />
|
||||
</VisibleKey>
|
||||
</button>
|
||||
@@ -112,6 +110,7 @@ export function ChatHeader({
|
||||
activeChat?._source?.message ||
|
||||
activeChat?._id}
|
||||
</h2>
|
||||
|
||||
{isTauri ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -126,10 +125,8 @@ export function ChatHeader({
|
||||
</button>
|
||||
|
||||
<ServerList
|
||||
isLogin={isLogin}
|
||||
setIsLogin={setIsLogin}
|
||||
reconnect={reconnect}
|
||||
onCreateNewChat={onCreateNewChat}
|
||||
clearChat={clearChat}
|
||||
/>
|
||||
|
||||
{isChatPage ? null : (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
// import { Sidebar } from "@/components/Assistant/Sidebar";
|
||||
import type { Chat } from "./types";
|
||||
import type { Chat } from "@/types/chat";
|
||||
import HistoryList from "../Common/HistoryList";
|
||||
import { HISTORY_PANEL_ID } from "@/constants";
|
||||
|
||||
@@ -9,7 +8,6 @@ interface ChatSidebarProps {
|
||||
isSidebarOpen: boolean;
|
||||
chats: Chat[];
|
||||
activeChat?: Chat;
|
||||
// onNewChat: () => void;
|
||||
onSelectChat: (chat: any) => void;
|
||||
onDeleteChat: (chatId: string) => void;
|
||||
fetchChatHistory: () => void;
|
||||
@@ -21,7 +19,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
isSidebarOpen,
|
||||
chats,
|
||||
activeChat,
|
||||
// onNewChat,
|
||||
onSelectChat,
|
||||
onDeleteChat,
|
||||
fetchChatHistory,
|
||||
@@ -32,7 +29,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
<div
|
||||
data-sidebar
|
||||
className={`
|
||||
h-screen fixed top-0 left-0 z-100 w-64
|
||||
h-screen absolute top-0 left-0 z-100 w-64
|
||||
transform transition-all duration-300 ease-in-out
|
||||
${isSidebarOpen ? "translate-x-0" : "-translate-x-full"}
|
||||
bg-gray-100 dark:bg-gray-800
|
||||
@@ -42,8 +39,8 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
>
|
||||
{isSidebarOpen && (
|
||||
<HistoryList
|
||||
id={HISTORY_PANEL_ID}
|
||||
list={chats}
|
||||
historyPanelId={HISTORY_PANEL_ID}
|
||||
chats={chats}
|
||||
active={activeChat}
|
||||
onSearch={onSearch}
|
||||
onRefresh={fetchChatHistory}
|
||||
@@ -52,14 +49,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
onRemove={onDeleteChat}
|
||||
/>
|
||||
)}
|
||||
{/* <Sidebar
|
||||
chats={chats}
|
||||
activeChat={activeChat}
|
||||
onNewChat={onNewChat}
|
||||
onSelectChat={onSelectChat}
|
||||
onDeleteChat={onDeleteChat}
|
||||
fetchChatHistory={fetchChatHistory}
|
||||
/> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
135
src/components/Assistant/ReadAloud.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useReactive } from "ahooks";
|
||||
import dayjs from "dayjs";
|
||||
import durationPlugin from "dayjs/plugin/duration";
|
||||
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import loadingLight from "@/assets/images/ReadAloud/loading-light.png";
|
||||
import loadingDark from "@/assets/images/ReadAloud/loading-dark.png";
|
||||
import playLight from "@/assets/images/ReadAloud/play-light.png";
|
||||
import playDark from "@/assets/images/ReadAloud/play-dark.png";
|
||||
import pauseLight from "@/assets/images/ReadAloud/pause-light.png";
|
||||
import pauseDark from "@/assets/images/ReadAloud/pause-dark.png";
|
||||
import backLight from "@/assets/images/ReadAloud/back-light.png";
|
||||
import backDark from "@/assets/images/ReadAloud/back-dark.png";
|
||||
import forwardLight from "@/assets/images/ReadAloud/forward-light.png";
|
||||
import forwardDark from "@/assets/images/ReadAloud/forward-dark.png";
|
||||
import closeLight from "@/assets/images/ReadAloud/close-light.png";
|
||||
import closeDark from "@/assets/images/ReadAloud/close-dark.png";
|
||||
|
||||
dayjs.extend(durationPlugin);
|
||||
|
||||
interface State {
|
||||
loading: boolean;
|
||||
playing: boolean;
|
||||
totalDuration: number;
|
||||
currentDuration: number;
|
||||
}
|
||||
|
||||
const ReadAloud = () => {
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
const state = useReactive<State>({
|
||||
loading: false,
|
||||
playing: true,
|
||||
totalDuration: 300,
|
||||
currentDuration: 0,
|
||||
});
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const formatTime = useMemo(() => {
|
||||
return dayjs.duration(state.currentDuration * 1000).format("mm:ss");
|
||||
}, [state.currentDuration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.playing && state.currentDuration >= state.totalDuration) {
|
||||
state.currentDuration = 0;
|
||||
}
|
||||
|
||||
changeCurrentDuration();
|
||||
}, [state.playing]);
|
||||
|
||||
const changeCurrentDuration = (duration = state.currentDuration) => {
|
||||
clearTimeout(timerRef.current);
|
||||
|
||||
let nextDuration = duration;
|
||||
|
||||
if (duration < 0) {
|
||||
nextDuration = 0;
|
||||
}
|
||||
|
||||
if (duration >= state.totalDuration) {
|
||||
state.currentDuration = state.totalDuration;
|
||||
|
||||
state.playing = false;
|
||||
}
|
||||
|
||||
if (!state.playing) return;
|
||||
|
||||
state.currentDuration = nextDuration;
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
changeCurrentDuration(duration + 1);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-[60px] left-1/2 z-1000 w-[200px] h-12 px-4 flex items-center justify-between -translate-x-1/2 border rounded-lg text-[#333] dark:text-[#D8D8D8] bg-white dark:bg-black dark:border-[#272828] shadow-[0_4px_8px_rgba(0,0,0,0.2)] dark:shadow-[0_4px_8px_rgba(255,255,255,0.15)]">
|
||||
<div className="flex items-center gap-2">
|
||||
{state.loading ? (
|
||||
<img
|
||||
src={isDark ? loadingDark : loadingLight}
|
||||
className="size-4 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => {
|
||||
state.playing = !state.playing;
|
||||
}}
|
||||
>
|
||||
{state.playing ? (
|
||||
<img
|
||||
src={isDark ? playDark : playLight}
|
||||
className="size-4 cursor-pointer"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={isDark ? pauseDark : pauseLight}
|
||||
className="size-4 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-sm">{formatTime}</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{!state.loading && (
|
||||
<>
|
||||
<img
|
||||
src={isDark ? backDark : backLight}
|
||||
className="size-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
changeCurrentDuration(state.currentDuration - 15);
|
||||
}}
|
||||
/>
|
||||
|
||||
<img
|
||||
src={isDark ? forwardDark : forwardLight}
|
||||
className="size-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
changeCurrentDuration(state.currentDuration + 15);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={isDark ? closeDark : closeLight}
|
||||
className="size-4 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadAloud;
|
||||
@@ -3,32 +3,31 @@ import { Settings, RefreshCw, Check, Server } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import { useKeyPress } from "ahooks";
|
||||
import { isNil } from "lodash-es";
|
||||
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import ServerIcon from "@/icons/Server";
|
||||
import VisibleKey from "../Common/VisibleKey";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useAppStore, IServer } from "@/stores/appStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { isNil } from "lodash-es";
|
||||
import { Server as IServer } from "@/types/server";
|
||||
import StatusIndicator from "@/components/Cloud/StatusIndicator";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
|
||||
interface ServerListProps {
|
||||
isLogin: boolean;
|
||||
setIsLogin: (isLogin: boolean) => void;
|
||||
reconnect: (server?: IServer) => void;
|
||||
onCreateNewChat: () => void;
|
||||
clearChat: () => void;
|
||||
}
|
||||
|
||||
export function ServerList({
|
||||
isLogin,
|
||||
setIsLogin,
|
||||
reconnect,
|
||||
onCreateNewChat,
|
||||
}: ServerListProps) {
|
||||
export function ServerList({ reconnect, clearChat }: ServerListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
|
||||
const setIsCurrentLogin = useAuthStore((state) => state.setIsCurrentLogin);
|
||||
const serviceList = useShortcutsStore((state) => state.serviceList);
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
||||
@@ -39,7 +38,12 @@ export function ServerList({
|
||||
|
||||
const [serverList, setServerList] = useState<IServer[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const askAiServerId = useSearchStore((state) => {
|
||||
return state.askAiServerId;
|
||||
});
|
||||
const setAskAiServerId = useSearchStore((state) => {
|
||||
return state.setAskAiServerId;
|
||||
});
|
||||
const serverListButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const fetchServers = useCallback(
|
||||
@@ -48,7 +52,7 @@ export function ServerList({
|
||||
.commands("list_coco_servers")
|
||||
.then((res: any) => {
|
||||
const enabledServers = (res as IServer[]).filter(
|
||||
(server) => server.enabled !== false
|
||||
(server) => server.enabled && server.available
|
||||
);
|
||||
//console.log("list_coco_servers", enabledServers);
|
||||
setServerList(enabledServers);
|
||||
@@ -72,6 +76,23 @@ export function ServerList({
|
||||
[currentService?.id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers(true);
|
||||
}, [currentService?.enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!askAiServerId || serverList.length === 0) return;
|
||||
|
||||
const matched = serverList.find((server) => {
|
||||
return server.id === askAiServerId;
|
||||
});
|
||||
|
||||
if (!matched) return;
|
||||
|
||||
switchServer(matched);
|
||||
setAskAiServerId(void 0);
|
||||
}, [serverList, askAiServerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
|
||||
@@ -79,8 +100,8 @@ export function ServerList({
|
||||
|
||||
const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => {
|
||||
//console.log("Login or Logout:", currentService, event.payload);
|
||||
if (event.payload !== isLogin) {
|
||||
setIsLogin(!!event.payload);
|
||||
if (event.payload !== isCurrentLogin) {
|
||||
setIsCurrentLogin(!!event.payload);
|
||||
}
|
||||
fetchServers(true);
|
||||
});
|
||||
@@ -108,13 +129,14 @@ export function ServerList({
|
||||
setCurrentService(server);
|
||||
setEndpoint(server.endpoint);
|
||||
setMessages(""); // Clear previous messages
|
||||
onCreateNewChat();
|
||||
clearChat();
|
||||
//
|
||||
if (!server.public && !server.profile) {
|
||||
setIsLogin(false);
|
||||
setIsCurrentLogin(false);
|
||||
return;
|
||||
}
|
||||
setIsLogin(true);
|
||||
//
|
||||
setIsCurrentLogin(true);
|
||||
// The Rust backend will automatically disconnect,
|
||||
// so we don't need to handle disconnection on the frontend
|
||||
// src-tauri/src/server/websocket.rs
|
||||
@@ -162,7 +184,7 @@ export function ServerList({
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between mb-3 whitespace-nowrap">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Servers
|
||||
{t("assistant.chat.servers")}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -205,23 +227,27 @@ export function ServerList({
|
||||
src={server?.provider?.icon || logoImg}
|
||||
alt={server.name}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = logoImg;
|
||||
}}
|
||||
/>
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
|
||||
{server.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
|
||||
AI Assistant: {server.assistantCount || 1}
|
||||
{t("assistant.chat.aiAssistant")}:{" "}
|
||||
{server.stats?.assistant_count || 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
server.health?.status
|
||||
? `bg-[${server.health?.status}]`
|
||||
: "bg-gray-400 dark:bg-gray-600"
|
||||
}`}
|
||||
<StatusIndicator
|
||||
enabled={server.enabled}
|
||||
public={server.public}
|
||||
hasProfile={!!server?.profile}
|
||||
status={server.health?.status}
|
||||
/>
|
||||
<div className="size-4 flex justify-end">
|
||||
{currentService?.id === server.id && (
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MessageSquare, Plus, RefreshCw } from "lucide-react";
|
||||
|
||||
import type { Chat } from "./types";
|
||||
|
||||
interface SidebarProps {
|
||||
chats: Chat[];
|
||||
activeChat: Chat | undefined;
|
||||
onNewChat: () => void;
|
||||
onSelectChat: (chat: Chat) => void;
|
||||
onDeleteChat: (chatId: string) => void;
|
||||
className?: string;
|
||||
fetchChatHistory: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
chats,
|
||||
activeChat,
|
||||
onNewChat,
|
||||
onSelectChat,
|
||||
className = "",
|
||||
fetchChatHistory,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-col ${className}`}>
|
||||
<div className="flex justify-between gap-1 p-4">
|
||||
<button
|
||||
onClick={onNewChat}
|
||||
className={`flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all border border-[#E6E6E6] dark:border-[#272626] text-gray-700 hover:bg-gray-50/80 active:bg-gray-100/80 dark:text-white dark:hover:bg-gray-600/50 dark:active:bg-gray-500/50`}
|
||||
>
|
||||
<Plus className={`h-4 w-4 text-[#0072FF] dark:text-[#0072FF]`} />
|
||||
{t("assistant.sidebar.newChat")}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
fetchChatHistory();
|
||||
setTimeout(() => setIsRefreshing(false), 1000);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
|
||||
isRefreshing ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-3 space-y-2 custom-scrollbar">
|
||||
{chats.map((chat) => (
|
||||
<div
|
||||
key={chat._id}
|
||||
className={`group relative rounded-xl transition-all ${
|
||||
activeChat?._id === chat._id
|
||||
? "bg-gray-100/80 dark:bg-gray-700/50"
|
||||
: "hover:bg-gray-50/80 dark:hover:bg-gray-600/30"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-sm text-left"
|
||||
onClick={() => onSelectChat(chat)}
|
||||
>
|
||||
<MessageSquare
|
||||
className={`h-4 w-4 flex-shrink-0 ${
|
||||
activeChat?._id === chat._id
|
||||
? "text-[#0072FF] dark:text-[#0072FF]"
|
||||
: "text-gray-400 dark:text-gray-500"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`truncate ${
|
||||
activeChat?._id === chat._id
|
||||
? "text-gray-900 dark:text-white font-medium"
|
||||
: "text-gray-600 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{chat?._source?.title || chat?._id}
|
||||
</span>
|
||||
</button>
|
||||
{activeChat?._id === chat._id && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-[#0072FF]" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,14 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { CircleX, MoveRight } from "lucide-react";
|
||||
import { useMount } from "ahooks";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import FontIcon from "../Common/Icons/FontIcon";
|
||||
import FontIcon from "@/components/Common/Icons/FontIcon";
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import { Get } from "@/api/axiosRequest";
|
||||
|
||||
interface StartPage {
|
||||
enabled?: boolean;
|
||||
logo?: {
|
||||
light?: string;
|
||||
dark?: string;
|
||||
};
|
||||
introduction?: string;
|
||||
display_assistants?: string[];
|
||||
}
|
||||
import { AssistantFetcher } from "./AssistantFetcher";
|
||||
import type { StartPage } from "@/types/chat";
|
||||
|
||||
export interface Response {
|
||||
app_settings?: {
|
||||
@@ -28,57 +18,67 @@ export interface Response {
|
||||
};
|
||||
}
|
||||
|
||||
const Splash = () => {
|
||||
interface SplashProps {
|
||||
assistantIDs?: string[];
|
||||
startPage?: StartPage;
|
||||
}
|
||||
|
||||
const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const [settings, setSettings] = useState<StartPage>();
|
||||
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
||||
const setVisibleStartPage = useConnectStore((state) => {
|
||||
return state.setVisibleStartPage;
|
||||
});
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
const assistantList = useConnectStore((state) => state.assistantList);
|
||||
const setAssistantList = useConnectStore((state) => state.setAssistantList);
|
||||
const setCurrentAssistant = useConnectStore((state) => {
|
||||
return state.setCurrentAssistant;
|
||||
});
|
||||
|
||||
useMount(async () => {
|
||||
try {
|
||||
const serverId = currentService.id;
|
||||
const [settings, setSettings] = useState<StartPage>();
|
||||
|
||||
let response: Response = {};
|
||||
|
||||
if (isTauri) {
|
||||
response = await platformAdapter.invokeBackend<Response>(
|
||||
"get_system_settings",
|
||||
{
|
||||
serverId,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const [err, result] = await Get("/settings");
|
||||
|
||||
if (err) {
|
||||
throw new Error(err);
|
||||
}
|
||||
|
||||
response = result as Response;
|
||||
}
|
||||
|
||||
const settings = response?.app_settings?.chat?.start_page;
|
||||
|
||||
setVisibleStartPage(Boolean(settings?.enabled));
|
||||
|
||||
setSettings(settings);
|
||||
} catch (error) {
|
||||
addError(String(error), "error");
|
||||
}
|
||||
const { fetchAssistant } = AssistantFetcher({
|
||||
assistantIDs,
|
||||
});
|
||||
|
||||
const settingsAssistantList = useMemo(() => {
|
||||
console.log("assistantList", assistantList);
|
||||
const fetchData = async () => {
|
||||
const data = await fetchAssistant({ current: 1, pageSize: 1000 });
|
||||
setAssistantList(data.list || []);
|
||||
};
|
||||
|
||||
const getSettings = async () => {
|
||||
const serverId = currentService.id;
|
||||
|
||||
let response: any;
|
||||
if (isTauri) {
|
||||
response = await platformAdapter.invokeBackend<Response>(
|
||||
"get_system_settings",
|
||||
{
|
||||
serverId,
|
||||
}
|
||||
);
|
||||
response = response?.app_settings?.chat?.start_page;
|
||||
} else {
|
||||
response = startPage;
|
||||
}
|
||||
setVisibleStartPage(Boolean(response?.enabled));
|
||||
setSettings(response);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getSettings();
|
||||
fetchData();
|
||||
}, [currentService?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentService?.enabled) return;
|
||||
|
||||
isTauri && setVisibleStartPage(false);
|
||||
}, [currentService?.enabled]);
|
||||
|
||||
const settingsAssistantList = useMemo(() => {
|
||||
return assistantList.filter((item) => {
|
||||
return settings?.display_assistants?.includes(item?._source?.id);
|
||||
});
|
||||
@@ -96,7 +96,7 @@ const Splash = () => {
|
||||
|
||||
return (
|
||||
visibleStartPage && (
|
||||
<div className="absolute inset-0 flex flex-col items-center px-6 pt-6 text-[#333] dark:text-white select-none overflow-y-auto custom-scrollbar">
|
||||
<div className="absolute top-12 inset-0 flex flex-col items-center px-6 pt-6 text-[#333] dark:text-white select-none overflow-y-auto custom-scrollbar">
|
||||
<CircleX
|
||||
className="absolute top-3 right-3 size-4 text-[#999] cursor-pointer"
|
||||
onClick={() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Loader, Hammer, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { IChunkData } from "@/components/Assistant/types";
|
||||
import type { IChunkData } from "@/types/chat";
|
||||
import Markdown from "./Markdown";
|
||||
|
||||
interface CallToolsProps {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ChevronDown, ChevronUp, Loader } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { IChunkData } from "@/components/Assistant/types";
|
||||
import type { IChunkData } from "@/types/chat";
|
||||
import ReadingIcon from "@/icons/Reading";
|
||||
|
||||
interface DeepReadeProps {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { OpenURLWithBrowser } from "@/utils/index";
|
||||
import type { IChunkData } from "@/components/Assistant/types";
|
||||
import type { IChunkData } from "@/types/chat";
|
||||
import RetrieveIcon from "@/icons/Retrieve";
|
||||
|
||||
interface FetchSourceProps {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
@@ -6,12 +8,16 @@ import {
|
||||
Volume2,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { copyToClipboard } from "@/utils";
|
||||
|
||||
interface MessageActionsProps {
|
||||
id: string;
|
||||
content: string;
|
||||
question?: string;
|
||||
actionClassName?: string;
|
||||
actionIconSize?: number;
|
||||
copyButtonId?: string;
|
||||
onResend?: () => void;
|
||||
}
|
||||
|
||||
@@ -21,6 +27,9 @@ export const MessageActions = ({
|
||||
id,
|
||||
content,
|
||||
question,
|
||||
actionClassName,
|
||||
actionIconSize,
|
||||
copyButtonId,
|
||||
onResend,
|
||||
}: MessageActionsProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -33,7 +42,7 @@ export const MessageActions = ({
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
await copyToClipboard(content);
|
||||
setCopied(true);
|
||||
const timerID = setTimeout(() => {
|
||||
setCopied(false);
|
||||
@@ -86,16 +95,29 @@ export const MessageActions = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<div className={clsx("flex items-center gap-1 mt-2", actionClassName)}>
|
||||
{!isRefreshOnly && (
|
||||
<button
|
||||
id={copyButtonId}
|
||||
onClick={handleCopy}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-[#38C200] dark:text-[#38C200]" />
|
||||
<Check
|
||||
className="w-4 h-4 text-[#38C200] dark:text-[#38C200]"
|
||||
style={{
|
||||
width: actionIconSize,
|
||||
height: actionIconSize,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-[#666666] dark:text-[#A3A3A3]" />
|
||||
<Copy
|
||||
className="w-4 h-4 text-[#666666] dark:text-[#A3A3A3]"
|
||||
style={{
|
||||
width: actionIconSize,
|
||||
height: actionIconSize,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
@@ -112,6 +134,10 @@ export const MessageActions = ({
|
||||
? "text-[#1990FF] dark:text-[#1990FF]"
|
||||
: "text-[#666666] dark:text-[#A3A3A3]"
|
||||
}`}
|
||||
style={{
|
||||
width: actionIconSize,
|
||||
height: actionIconSize,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
@@ -128,6 +154,10 @@ export const MessageActions = ({
|
||||
? "text-[#1990FF] dark:text-[#1990FF]"
|
||||
: "text-[#666666] dark:text-[#A3A3A3]"
|
||||
}`}
|
||||
style={{
|
||||
width: actionIconSize,
|
||||
height: actionIconSize,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
@@ -142,6 +172,10 @@ export const MessageActions = ({
|
||||
? "text-[#1990FF] dark:text-[#1990FF]"
|
||||
: "text-[#666666] dark:text-[#A3A3A3]"
|
||||
}`}
|
||||
style={{
|
||||
width: actionIconSize,
|
||||
height: actionIconSize,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
@@ -158,6 +192,10 @@ export const MessageActions = ({
|
||||
? "text-[#1990FF] dark:text-[#1990FF]"
|
||||
: "text-[#666666] dark:text-[#A3A3A3]"
|
||||
}`}
|
||||
style={{
|
||||
width: actionIconSize,
|
||||
height: actionIconSize,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ChevronDown, ChevronUp, Loader } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { IChunkData } from "@/components/Assistant/types";
|
||||
import type { IChunkData } from "@/types/chat";
|
||||
import SelectionIcon from "@/icons/Selection";
|
||||
|
||||
interface PickSourceProps {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ChevronDown, ChevronUp, Loader } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { IChunkData } from "@/components/Assistant/types";
|
||||
import type { IChunkData } from "@/types/chat";
|
||||
import UnderstandIcon from "@/icons/Understand";
|
||||
|
||||
interface QueryIntentProps {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {Loader, Brain, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { IChunkData } from "@/components/Assistant/types";
|
||||
import type { IChunkData } from "@/types/chat";
|
||||
|
||||
interface ThinkProps {
|
||||
Detail?: any;
|
||||
|
||||
@@ -12,7 +12,7 @@ export const UserMessage = ({ messageContent }: UserMessageProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-1 items-center"
|
||||
className="flex gap-1 items-center justify-end"
|
||||
onMouseEnter={() => setShowCopyButton(true)}
|
||||
onMouseLeave={() => setShowCopyButton(false)}
|
||||
>
|
||||
@@ -24,7 +24,7 @@ export const UserMessage = ({ messageContent }: UserMessageProps) => {
|
||||
<CopyButton textToCopy={messageContent} />
|
||||
</div>
|
||||
<div
|
||||
className="px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer select-none"
|
||||
className="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={(e) => {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import type { Message, IChunkData } from "@/components/Assistant/types";
|
||||
import type { Message, IChunkData } from "@/types/chat";
|
||||
import { QueryIntent } from "./QueryIntent";
|
||||
import { CallTools } from "./CallTools";
|
||||
import { FetchSource } from "./FetchSource";
|
||||
@@ -29,6 +29,11 @@ interface ChatMessageProps {
|
||||
response?: IChunkData;
|
||||
onResend?: (value: string) => void;
|
||||
loadingStep?: Record<string, boolean>;
|
||||
hide_assistant?: boolean;
|
||||
rootClassName?: string;
|
||||
actionClassName?: string;
|
||||
actionIconSize?: number;
|
||||
copyButtonId?: string;
|
||||
}
|
||||
|
||||
export const ChatMessage = memo(function ChatMessage({
|
||||
@@ -43,6 +48,11 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
response,
|
||||
onResend,
|
||||
loadingStep,
|
||||
hide_assistant = false,
|
||||
rootClassName,
|
||||
actionClassName,
|
||||
actionIconSize,
|
||||
copyButtonId,
|
||||
}: ChatMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -52,17 +62,29 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
|
||||
const isAssistant = message?._source?.type === "assistant";
|
||||
const assistant_id = message?._source?.assistant_id;
|
||||
const assistant_item = message?._source?.assistant_item;
|
||||
|
||||
useEffect(() => {
|
||||
let target = currentAssistant;
|
||||
if (isAssistant && assistant_id && Array.isArray(assistantList)) {
|
||||
const found = assistantList.find((item) => item._id === assistant_id);
|
||||
if (found) {
|
||||
target = found;
|
||||
}
|
||||
if (assistant_item) {
|
||||
setAssistant(assistant_item);
|
||||
return;
|
||||
}
|
||||
setAssistant(target);
|
||||
}, [isAssistant, assistant_id, assistantList, currentAssistant]);
|
||||
|
||||
if (isAssistant && assistant_id && Array.isArray(assistantList)) {
|
||||
setAssistant(
|
||||
assistantList.find((item) => item._id === assistant_id) ?? {}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setAssistant(currentAssistant);
|
||||
}, [
|
||||
isAssistant,
|
||||
assistant_item,
|
||||
assistant_id,
|
||||
assistantList,
|
||||
currentAssistant,
|
||||
]);
|
||||
|
||||
const messageContent = message?._source?.message || "";
|
||||
const details = message?._source?.details || [];
|
||||
@@ -72,7 +94,6 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
isTyping === false && (messageContent || response?.message_chunk);
|
||||
|
||||
const [suggestion, setSuggestion] = useState<string[]>([]);
|
||||
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
||||
|
||||
const getSuggestion = (suggestion: string[]) => {
|
||||
setSuggestion(suggestion);
|
||||
@@ -131,6 +152,9 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
id={message._id}
|
||||
content={messageContent || response?.message_chunk || ""}
|
||||
question={question}
|
||||
actionClassName={actionClassName}
|
||||
actionIconSize={actionIconSize}
|
||||
copyButtonId={copyButtonId}
|
||||
onResend={() => {
|
||||
onResend && onResend(question);
|
||||
}}
|
||||
@@ -151,9 +175,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
className={clsx(
|
||||
"py-8 flex",
|
||||
[isAssistant ? "justify-start" : "justify-end"],
|
||||
{
|
||||
hidden: visibleStartPage,
|
||||
}
|
||||
rootClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -166,22 +188,27 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
isAssistant ? "text-left" : "text-right"
|
||||
}`}
|
||||
>
|
||||
<div className="w-full flex items-center gap-1 font-semibold text-sm text-[#333] dark:text-[#d8d8d8]">
|
||||
{isAssistant ? (
|
||||
<div className="w-6 h-6 flex justify-center items-center rounded-full bg-white border border-[#E6E6E6]">
|
||||
{assistant?._source?.icon?.startsWith("font_") ? (
|
||||
<FontIcon name={assistant._source.icon} className="w-4 h-4" />
|
||||
) : (
|
||||
<img
|
||||
src={logoImg}
|
||||
className="w-4 h-4"
|
||||
alt={t("assistant.message.logo")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{isAssistant ? assistant?._source?.name || "Coco AI" : ""}
|
||||
</div>
|
||||
{!hide_assistant && (
|
||||
<div className="w-full flex items-center gap-1 font-semibold text-sm text-[#333] dark:text-[#d8d8d8]">
|
||||
{isAssistant ? (
|
||||
<div className="w-6 h-6 flex justify-center items-center rounded-full bg-white border border-[#E6E6E6]">
|
||||
{assistant?._source?.icon?.startsWith("font_") ? (
|
||||
<FontIcon
|
||||
name={assistant._source.icon}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={logoImg}
|
||||
className="w-4 h-4"
|
||||
alt={t("assistant.message.logo")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{isAssistant ? assistant?._source?.name || "Coco AI" : ""}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full prose dark:prose-invert prose-sm max-w-none">
|
||||
<div className="w-full pl-7 text-[#333] dark:text-[#d8d8d8] leading-relaxed">
|
||||
{renderContent()}
|
||||
|
||||
@@ -112,7 +112,6 @@
|
||||
.markdown-body {
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
margin: 16px 0 0;
|
||||
color: var(--color-fg-default);
|
||||
background-color: var(--color-canvas-default);
|
||||
font-size: 14px;
|
||||
@@ -125,6 +124,11 @@
|
||||
border: 1px solid var(--color-border-muted) !important;
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
all: revert !important;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-body .octicon {
|
||||
@@ -415,25 +419,6 @@
|
||||
border-left: 0.25em solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.markdown-body ol ol,
|
||||
.markdown-body ul ol {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
|
||||
.markdown-body ul ul ol,
|
||||
.markdown-body ul ol ol,
|
||||
.markdown-body ol ul ol,
|
||||
.markdown-body ol ol ol {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
|
||||
.markdown-body dd {
|
||||
margin-left: 0;
|
||||
}
|
||||
@@ -510,8 +495,6 @@
|
||||
|
||||
.markdown-body p,
|
||||
.markdown-body blockquote,
|
||||
.markdown-body ul,
|
||||
.markdown-body ol,
|
||||
.markdown-body dl,
|
||||
.markdown-body table,
|
||||
.markdown-body pre,
|
||||
@@ -627,14 +610,6 @@
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.markdown-body ul ul,
|
||||
.markdown-body ul ol,
|
||||
.markdown-body ol ol,
|
||||
.markdown-body ol ul {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-body li > p {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -1,64 +1,32 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CalendarSync,
|
||||
Copy,
|
||||
GitFork,
|
||||
Globe,
|
||||
PackageOpen,
|
||||
RefreshCcw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import {
|
||||
getCurrent as getCurrentDeepLinkUrls,
|
||||
onOpenUrl,
|
||||
} from "@tauri-apps/plugin-deep-link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
|
||||
import { UserProfile } from "./UserProfile";
|
||||
import { DataSourcesList } from "./DataSourcesList";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Connect } from "./Connect";
|
||||
import { OpenURLWithBrowser } from "@/utils";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
|
||||
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
||||
import Tooltip from "@/components/Common/Tooltip";
|
||||
import {
|
||||
list_coco_servers,
|
||||
add_coco_server,
|
||||
enable_server,
|
||||
disable_server,
|
||||
logout_coco_server,
|
||||
remove_coco_server,
|
||||
refresh_coco_server_info,
|
||||
handle_sso_callback,
|
||||
} from "@/commands";
|
||||
import ServiceInfo from "./ServiceInfo";
|
||||
import ServiceAuth from "./ServiceAuth";
|
||||
|
||||
export default function Cloud() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const SidebarRef = useRef<{ refreshData: () => void }>(null);
|
||||
|
||||
const errors = useAppStore((state) => state.errors);
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
|
||||
const [isConnect, setIsConnect] = useState(true);
|
||||
|
||||
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
|
||||
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
||||
|
||||
const serverList = useConnectStore((state) => state.serverList);
|
||||
const setServerList = useConnectStore((state) => state.setServerList);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||
|
||||
// fetch the servers
|
||||
@@ -68,7 +36,6 @@ export default function Cloud() {
|
||||
|
||||
useEffect(() => {
|
||||
console.log("currentService", currentService);
|
||||
setLoading(false);
|
||||
setRefreshLoading(false);
|
||||
setIsConnect(true);
|
||||
}, [JSON.stringify(currentService)]);
|
||||
@@ -131,331 +98,47 @@ export default function Cloud() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (code: string | null, serverId: string | null) => {
|
||||
if (!code || !serverId) {
|
||||
addError("No authorization code received");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Handling OAuth callback:", { code, serverId });
|
||||
await handle_sso_callback({
|
||||
serverId: serverId, // Make sure 'server_id' is the correct argument
|
||||
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
|
||||
code: code,
|
||||
const refreshClick = useCallback(
|
||||
(id: string) => {
|
||||
setRefreshLoading(true);
|
||||
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);
|
||||
});
|
||||
|
||||
if (serverId != null) {
|
||||
refreshClick(serverId);
|
||||
}
|
||||
|
||||
getCurrentWindow().setFocus();
|
||||
} catch (e) {
|
||||
console.error("Sign in failed:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[ssoRequestID]
|
||||
);
|
||||
|
||||
const handleUrl = (url: string) => {
|
||||
try {
|
||||
const urlObject = new URL(url.trim());
|
||||
console.log("handle urlObject:", urlObject);
|
||||
|
||||
// pass request_id and check with local, if the request_id are same, then continue
|
||||
const reqId = urlObject.searchParams.get("request_id");
|
||||
const code = urlObject.searchParams.get("code");
|
||||
|
||||
if (reqId != ssoRequestID) {
|
||||
console.log("Request ID not matched, skip");
|
||||
addError("Request ID not matched, skip");
|
||||
return;
|
||||
}
|
||||
|
||||
const serverId = currentService?.id;
|
||||
handleOAuthCallback(code, serverId);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse URL:", err);
|
||||
addError("Invalid URL format: " + err);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch the initial deep link intent
|
||||
useEffect(() => {
|
||||
// Function to handle pasted URL
|
||||
const handlePaste = (event: any) => {
|
||||
const pastedText = event.clipboardData.getData("text").trim();
|
||||
console.log("handle paste text:", pastedText);
|
||||
if (isValidCallbackUrl(pastedText)) {
|
||||
// Handle the URL as if it's a deep link
|
||||
console.log("handle callback on paste:", pastedText);
|
||||
handleUrl(pastedText);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to check if the pasted URL is valid for our deep link scheme
|
||||
const isValidCallbackUrl = (url: string) => {
|
||||
return url && url.startsWith("coco://oauth_callback");
|
||||
};
|
||||
|
||||
// Adding event listener for paste events
|
||||
document.addEventListener("paste", handlePaste);
|
||||
|
||||
getCurrentDeepLinkUrls()
|
||||
.then((urls) => {
|
||||
console.log("URLs:", urls);
|
||||
if (urls && urls.length > 0) {
|
||||
if (isValidCallbackUrl(urls[0].trim())) {
|
||||
handleUrl(urls[0]);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get initial URLs:", err);
|
||||
addError("Failed to get initial URLs: " + err);
|
||||
});
|
||||
|
||||
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
|
||||
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
document.removeEventListener("paste", handlePaste);
|
||||
};
|
||||
}, [ssoRequestID]);
|
||||
|
||||
const LoginClick = useCallback(() => {
|
||||
if (loading) return; // Prevent multiple clicks if already loading
|
||||
|
||||
let requestID = uuidv4();
|
||||
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}`;
|
||||
|
||||
console.log("Open SSO link, requestID:", ssoRequestID, url);
|
||||
|
||||
// Open the URL in a browser
|
||||
OpenURLWithBrowser(url);
|
||||
|
||||
// Start loading state
|
||||
setLoading(true);
|
||||
}, [ssoRequestID, loading, currentService]);
|
||||
|
||||
const refreshClick = (id: string) => {
|
||||
setRefreshLoading(true);
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
function onAddServer() {
|
||||
setIsConnect(false);
|
||||
}
|
||||
|
||||
function onLogout(id: string) {
|
||||
console.log("onLogout", id);
|
||||
setRefreshLoading(true);
|
||||
logout_coco_server(id)
|
||||
.then((res: any) => {
|
||||
console.log("logout_coco_server", id, JSON.stringify(res));
|
||||
refreshClick(id);
|
||||
emit("login_or_logout", false);
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
const removeServer = (id: string) => {
|
||||
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 enable_coco_server = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
await enable_server(currentService?.id);
|
||||
} else {
|
||||
await disable_server(currentService?.id);
|
||||
}
|
||||
|
||||
setCurrentService({ ...currentService, enabled });
|
||||
|
||||
await fetchServers(false);
|
||||
},
|
||||
[currentService?.id]
|
||||
[fetchServers]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex bg-gray-50 dark:bg-gray-900">
|
||||
<Sidebar
|
||||
ref={SidebarRef}
|
||||
onAddServer={onAddServer}
|
||||
setIsConnect={setIsConnect}
|
||||
serverList={serverList}
|
||||
/>
|
||||
|
||||
<main className="flex-1 p-4 py-8">
|
||||
{isConnect ? (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<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}
|
||||
alt="banner"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Tooltip content={currentService?.endpoint}>
|
||||
<div className="flex items-center text-gray-900 dark:text-white font-medium cursor-pointer">
|
||||
{currentService?.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsToggle
|
||||
checked={currentService?.enabled}
|
||||
className={clsx({
|
||||
"bg-red-600 focus:ring-red-500": !currentService?.enabled,
|
||||
})}
|
||||
label={
|
||||
currentService?.enabled
|
||||
? t("cloud.enable_server")
|
||||
: t("cloud.disable_server")
|
||||
}
|
||||
onChange={enable_coco_server}
|
||||
/>
|
||||
<ServiceInfo
|
||||
refreshLoading={refreshLoading}
|
||||
refreshClick={refreshClick}
|
||||
fetchServers={fetchServers}
|
||||
/>
|
||||
|
||||
<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)
|
||||
}
|
||||
>
|
||||
<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)}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`w-3.5 h-3.5 ${
|
||||
refreshLoading ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{!currentService?.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)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
</span>
|
||||
<span className="mx-4">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="w-4 h-4" />{" "}
|
||||
{currentService?.version?.number}
|
||||
</span>
|
||||
<span className="mx-4">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarSync className="w-4 h-4" /> {currentService?.updated}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{currentService?.provider?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{currentService?.auth_provider?.sso?.url ? (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
{t("cloud.accountInfo")}
|
||||
</h2>
|
||||
{currentService?.profile ? (
|
||||
<UserProfile
|
||||
server={currentService?.id}
|
||||
userInfo={currentService?.profile}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{/* Login Button (conditionally rendered when not loading) */}
|
||||
{!loading && (
|
||||
<button
|
||||
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
|
||||
onClick={LoginClick}
|
||||
>
|
||||
{t("cloud.login")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cancel Button and Copy URL button while loading */}
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors mb-3"
|
||||
onClick={() => setLoading(false)} // Reset loading state
|
||||
>
|
||||
{t("cloud.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${ssoRequestID}`
|
||||
);
|
||||
}}
|
||||
className="text-xl text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
<Copy className="inline mr-2" />{" "}
|
||||
</button>
|
||||
<div className="text-justify italic text-xs">
|
||||
{t("cloud.manualCopyLink")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Privacy Policy Link */}
|
||||
<button
|
||||
className="text-xs text-[#0096FB] dark:text-blue-400 block"
|
||||
onClick={() =>
|
||||
OpenURLWithBrowser(
|
||||
currentService?.provider?.privacy_policy
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("cloud.privacyPolicy")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<ServiceAuth
|
||||
setRefreshLoading={setRefreshLoading}
|
||||
refreshClick={refreshClick}
|
||||
/>
|
||||
|
||||
{currentService?.profile ? (
|
||||
<DataSourcesList server={currentService?.id} />
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { specialCharacterFiltering } from "@/utils/index"
|
||||
|
||||
interface ConnectServiceProps {
|
||||
setIsConnect: (isConnect: boolean) => void;
|
||||
@@ -14,8 +14,6 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
||||
const [endpointLink, setEndpointLink] = useState("");
|
||||
const [refreshLoading] = useState(false);
|
||||
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
@@ -26,19 +24,15 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
||||
|
||||
const onAddServerClick = async (endpoint: string) => {
|
||||
console.log("onAddServer", endpoint);
|
||||
try {
|
||||
await onAddServer(endpoint);
|
||||
setIsConnect(true); // Only set as connected if the server is added successfully
|
||||
} catch (err: any) {
|
||||
// Handle the error if something goes wrong
|
||||
const errorMessage =
|
||||
typeof err === "string"
|
||||
? err
|
||||
: err?.message || "An unknown error occurred.";
|
||||
addError(errorMessage);
|
||||
}
|
||||
await onAddServer(endpoint);
|
||||
setIsConnect(true);
|
||||
};
|
||||
|
||||
const onChangeEndpoint = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = specialCharacterFiltering(e.target.value)
|
||||
setEndpointLink(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
@@ -73,7 +67,7 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
||||
id="endpoint"
|
||||
value={endpointLink}
|
||||
placeholder={t("cloud.connect.serverPlaceholder")}
|
||||
onChange={(e) => setEndpointLink(e.target.value)}
|
||||
onChange={onChangeEndpoint}
|
||||
className="text-[#101010] dark:text-white flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -4,10 +4,7 @@ import { RefreshCcw } from "lucide-react";
|
||||
|
||||
import { DataSourceItem } from "./DataSourceItem";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import {
|
||||
get_connectors_by_server,
|
||||
datasource_search,
|
||||
} from "@/commands";
|
||||
import { get_connectors_by_server, datasource_search } from "@/commands";
|
||||
|
||||
export function DataSourcesList({ server }: { server: string }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -17,8 +14,9 @@ export function DataSourcesList({ server }: { server: string }) {
|
||||
const setDatasourceData = useConnectStore((state) => state.setDatasourceData);
|
||||
const setConnectorData = useConnectStore((state) => state.setConnectorData);
|
||||
|
||||
function initServerAppData({ server }: { server: string }) {
|
||||
//fetch datasource data
|
||||
function initServerAppData() {
|
||||
setRefreshLoading(true);
|
||||
// fetch connectors data
|
||||
get_connectors_by_server(server)
|
||||
.then((res: any) => {
|
||||
// console.log("get_connectors_by_server", res);
|
||||
@@ -26,31 +24,20 @@ export function DataSourcesList({ server }: { server: string }) {
|
||||
})
|
||||
.finally(() => {});
|
||||
|
||||
//fetch datasource data
|
||||
// fetch datasource data
|
||||
datasource_search(server)
|
||||
.then((res: any) => {
|
||||
// console.log("datasource_search", res);
|
||||
setDatasourceData(res, server);
|
||||
})
|
||||
.finally(() => {});
|
||||
}
|
||||
|
||||
async function getDatasourceData() {
|
||||
setRefreshLoading(true);
|
||||
try {
|
||||
initServerAppData({ server });
|
||||
} finally {
|
||||
setRefreshLoading(false);
|
||||
}
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getDatasourceData();
|
||||
}, []);
|
||||
|
||||
// const handleToggle = (id: string, enabled: boolean) => {
|
||||
// console.log("handleToggle", id, enabled);
|
||||
// };
|
||||
initServerAppData();
|
||||
}, [server]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -58,10 +45,12 @@ export function DataSourcesList({ server }: { server: string }) {
|
||||
{t("cloud.dataSource.title")}
|
||||
<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={() => getDatasourceData()}
|
||||
onClick={() => initServerAppData()}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""}`}
|
||||
className={`w-3.5 h-3.5 transition-transform duration-1000 ${
|
||||
refreshLoading ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
278
src/components/Cloud/ServiceAuth.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { FC, memo, useCallback, useEffect, useState } from "react";
|
||||
import { Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import {
|
||||
getCurrent as getCurrentDeepLinkUrls,
|
||||
onOpenUrl,
|
||||
} from "@tauri-apps/plugin-deep-link";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
import { UserProfile } from "./UserProfile";
|
||||
import { OpenURLWithBrowser } from "@/utils";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { logout_coco_server, handle_sso_callback } from "@/commands";
|
||||
import { copyToClipboard } from "@/utils";
|
||||
|
||||
interface ServiceAuthProps {
|
||||
setRefreshLoading: (loading: boolean) => void;
|
||||
refreshClick: (id: string) => void;
|
||||
}
|
||||
|
||||
const ServiceAuth = memo(
|
||||
({ setRefreshLoading, refreshClick }: ServiceAuthProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
|
||||
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
|
||||
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const setCurrentService = useConnectStore(
|
||||
(state) => state.setCurrentService
|
||||
);
|
||||
const serverList = useConnectStore((state) => state.serverList);
|
||||
const setServerList = useConnectStore((state) => state.setServerList);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const LoginClick = useCallback(() => {
|
||||
if (loading) return; // Prevent multiple clicks if already loading
|
||||
|
||||
let requestID = uuidv4();
|
||||
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}`;
|
||||
|
||||
console.log("Open SSO link, requestID:", ssoRequestID, url);
|
||||
|
||||
// Open the URL in a browser
|
||||
OpenURLWithBrowser(url);
|
||||
|
||||
// Start loading state
|
||||
setLoading(true);
|
||||
}, [ssoRequestID, loading, currentService]);
|
||||
|
||||
const onLogout = useCallback(
|
||||
(id: string) => {
|
||||
setRefreshLoading(true);
|
||||
logout_coco_server(id)
|
||||
.then((res: any) => {
|
||||
console.log("logout_coco_server", id, JSON.stringify(res));
|
||||
emit("login_or_logout", false);
|
||||
// update server profile
|
||||
setCurrentService({ ...currentService, profile: null });
|
||||
const updatedServerList = serverList.map((server) =>
|
||||
server.id === id ? { ...server, profile: null } : server
|
||||
);
|
||||
console.log("updatedServerList", updatedServerList);
|
||||
setServerList(updatedServerList);
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
},
|
||||
[currentService, serverList]
|
||||
);
|
||||
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (code: string | null, serverId: string | null) => {
|
||||
if (!code || !serverId) {
|
||||
addError("No authorization code received");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Handling OAuth callback:", { code, serverId });
|
||||
await handle_sso_callback({
|
||||
serverId: serverId, // Make sure 'server_id' is the correct argument
|
||||
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
|
||||
code: code,
|
||||
});
|
||||
|
||||
if (serverId != null) {
|
||||
refreshClick(serverId);
|
||||
}
|
||||
|
||||
getCurrentWindow().setFocus();
|
||||
} catch (e) {
|
||||
console.error("Sign in failed:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[ssoRequestID]
|
||||
);
|
||||
|
||||
const handleUrl = (url: string) => {
|
||||
try {
|
||||
const urlObject = new URL(url.trim());
|
||||
console.log("handle urlObject:", urlObject);
|
||||
|
||||
// pass request_id and check with local, if the request_id are same, then continue
|
||||
const reqId = urlObject.searchParams.get("request_id");
|
||||
const code = urlObject.searchParams.get("code");
|
||||
|
||||
if (reqId != ssoRequestID) {
|
||||
console.log("Request ID not matched, skip");
|
||||
addError("Request ID not matched, skip");
|
||||
return;
|
||||
}
|
||||
|
||||
const serverId = currentService?.id;
|
||||
handleOAuthCallback(code, serverId);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse URL:", err);
|
||||
addError("Invalid URL format: " + err);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch the initial deep link intent
|
||||
useEffect(() => {
|
||||
// Function to handle pasted URL
|
||||
const handlePaste = (event: any) => {
|
||||
const pastedText = event.clipboardData.getData("text").trim();
|
||||
console.log("handle paste text:", pastedText);
|
||||
if (isValidCallbackUrl(pastedText)) {
|
||||
// Handle the URL as if it's a deep link
|
||||
console.log("handle callback on paste:", pastedText);
|
||||
handleUrl(pastedText);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to check if the pasted URL is valid for our deep link scheme
|
||||
const isValidCallbackUrl = (url: string) => {
|
||||
return url && url.startsWith("coco://oauth_callback");
|
||||
};
|
||||
|
||||
// Adding event listener for paste events
|
||||
document.addEventListener("paste", handlePaste);
|
||||
|
||||
getCurrentDeepLinkUrls()
|
||||
.then((urls) => {
|
||||
console.log("URLs:", urls);
|
||||
if (urls && urls.length > 0) {
|
||||
if (isValidCallbackUrl(urls[0].trim())) {
|
||||
handleUrl(urls[0]);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get initial URLs:", err);
|
||||
addError("Failed to get initial URLs: " + err);
|
||||
});
|
||||
|
||||
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
|
||||
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
document.removeEventListener("paste", handlePaste);
|
||||
};
|
||||
}, [ssoRequestID]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [currentService]);
|
||||
|
||||
if (!currentService?.auth_provider?.sso?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
{t("cloud.accountInfo")}
|
||||
</h2>
|
||||
{currentService?.profile ? (
|
||||
<UserProfile
|
||||
server={currentService?.id}
|
||||
userInfo={currentService?.profile}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{/* Login Button (conditionally rendered when not loading) */}
|
||||
{!loading && <LoginButton LoginClick={LoginClick} />}
|
||||
|
||||
{/* Cancel Button and Copy URL button while loading */}
|
||||
{loading && (
|
||||
<LoadingState
|
||||
onCancel={() => setLoading(false)}
|
||||
onCopy={() => {
|
||||
copyToClipboard(
|
||||
`${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${ssoRequestID}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Privacy Policy Link */}
|
||||
<button
|
||||
className="text-xs text-[#0096FB] dark:text-blue-400 block"
|
||||
onClick={() =>
|
||||
OpenURLWithBrowser(currentService?.provider?.privacy_policy)
|
||||
}
|
||||
>
|
||||
{t("cloud.privacyPolicy")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default ServiceAuth;
|
||||
|
||||
interface LoginButtonProps {
|
||||
LoginClick: () => void;
|
||||
}
|
||||
|
||||
const LoginButton: FC<LoginButtonProps> = memo((props) => {
|
||||
const { LoginClick } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<button
|
||||
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
|
||||
onClick={LoginClick}
|
||||
aria-label={t("cloud.login")}
|
||||
>
|
||||
{t("cloud.login")}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
interface LoadingStateProps {
|
||||
onCancel: () => void;
|
||||
onCopy: () => void;
|
||||
}
|
||||
|
||||
const LoadingState: FC<LoadingStateProps> = memo((props) => {
|
||||
const { onCancel, onCopy } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors mb-3"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("cloud.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="text-xl text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
<Copy className="inline mr-2" />{" "}
|
||||
</button>
|
||||
<div className="text-justify italic text-xs">
|
||||
{t("cloud.manualCopyLink")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
26
src/components/Cloud/ServiceBanner.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
interface ServiceBannerProps {}
|
||||
|
||||
const ServiceBanner = memo(({}: ServiceBannerProps) => {
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
|
||||
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}
|
||||
alt="banner"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = bannerImg;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ServiceBanner;
|
||||