Compare commits
325 Commits
v0.5.0
...
add-search
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ceb385505 | ||
|
|
371f8d0daa | ||
|
|
28cf5ca326 | ||
|
|
ed10be5c6f | ||
|
|
0bcb974837 | ||
|
|
4c4c08a598 | ||
|
|
724db0f66d | ||
|
|
a8a14cae18 | ||
|
|
eea6a7a5ae | ||
|
|
fdc8967b76 | ||
|
|
5df1f9668d | ||
|
|
23607e6b4c | ||
|
|
494be3db62 | ||
|
|
530502ecff | ||
|
|
8d6204a9d8 | ||
|
|
ca350dfeed | ||
|
|
3a9c9ec9eb | ||
|
|
f7c0600480 | ||
|
|
ed8a1cb477 | ||
|
|
abf20f81ff | ||
|
|
65a48efdde | ||
|
|
0613238876 | ||
|
|
501f6df473 | ||
|
|
67c8c4bdfa | ||
|
|
b5f5d3bd28 | ||
|
|
378d5aef69 | ||
|
|
10ee3cd9d3 | ||
|
|
97d2450fa7 | ||
|
|
18828ab043 | ||
|
|
3b80eb77b4 | ||
|
|
6e2514adbd | ||
|
|
82074ab46a | ||
|
|
8158d49050 | ||
|
|
3052878869 | ||
|
|
73ca224ad8 | ||
|
|
ff7721d17f | ||
|
|
fc642ac0e3 | ||
|
|
d5d616ade9 | ||
|
|
96977be623 | ||
|
|
d8a1b9b9c6 | ||
|
|
f83b1ba2a7 | ||
|
|
8ac0065234 | ||
|
|
31806b6057 | ||
|
|
533bfaf45b | ||
|
|
459705af70 | ||
|
|
84e556ddad | ||
|
|
b50a20c7d4 | ||
|
|
d4ccd780b2 | ||
|
|
aef934e9a2 | ||
|
|
1fb927c26b | ||
|
|
8974624b3c | ||
|
|
d99b35bf4c | ||
|
|
594d0ffe3f | ||
|
|
7b08a87766 | ||
|
|
ab5ca24270 | ||
|
|
c593b07187 | ||
|
|
c088dde749 | ||
|
|
1fdf7c499d | ||
|
|
01dfc616d4 | ||
|
|
8d7d655581 | ||
|
|
5292538dd7 | ||
|
|
bab98d4576 | ||
|
|
6067fa7029 | ||
|
|
61860b400f | ||
|
|
50518b6c21 | ||
|
|
b5d3ce9910 | ||
|
|
abac92d8d5 | ||
|
|
9ea7c9b1ff | ||
|
|
fcbc77fb5a | ||
|
|
60b34a118b | ||
|
|
3e0839f3da | ||
|
|
bd61faf660 | ||
|
|
0e48f4f71c | ||
|
|
24fe7144f8 | ||
|
|
e92eee1ecf | ||
|
|
1996298f0c | ||
|
|
c879c63b17 | ||
|
|
892fe78d03 | ||
|
|
e5860f63c7 | ||
|
|
fa9656bfd7 | ||
|
|
03954748b6 | ||
|
|
4a627cb32e | ||
|
|
3029303e95 | ||
|
|
fc7cd165a8 | ||
|
|
f267df3f71 | ||
|
|
b07707e973 | ||
|
|
6b0111b89f | ||
|
|
e029ddf2ba | ||
|
|
731cfc5bd7 | ||
|
|
cbd8dc52cd | ||
|
|
d1ad1af71a | ||
|
|
121f9c6118 | ||
|
|
770f60f30c | ||
|
|
5c92b5acab | ||
|
|
8e49455acf | ||
|
|
859def21bf | ||
|
|
6145306ee8 | ||
|
|
d0f7b7b833 | ||
|
|
f221606ae2 | ||
|
|
cd00ada3ac | ||
|
|
be6611133a | ||
|
|
9e682ceafc | ||
|
|
5510bedf7f | ||
|
|
ea34b7a404 | ||
|
|
ce94543baa | ||
|
|
89a8304b9e | ||
|
|
9652a54f08 | ||
|
|
ca71f07f3a | ||
|
|
00eb6bed2b | ||
|
|
95dc7a88d2 | ||
|
|
6aec9cbae2 | ||
|
|
4e58bc4b2c | ||
|
|
a9a4b5319c | ||
|
|
6523fef12b | ||
|
|
b8affcd4a1 | ||
|
|
595ae676b7 | ||
|
|
5c76c92c95 | ||
|
|
f03ad8a6c8 | ||
|
|
386ebb60c0 | ||
|
|
17c7227a44 | ||
|
|
23faaf6fc3 | ||
|
|
3131d3cea4 | ||
|
|
3014dc8839 | ||
|
|
829d3868c4 | ||
|
|
6584504142 | ||
|
|
01c51d83d6 | ||
|
|
29442826c5 | ||
|
|
e249c02123 | ||
|
|
7ac4508e8d | ||
|
|
450baccc92 | ||
|
|
bd0c9a740b | ||
|
|
fca11a9001 | ||
|
|
1aa30ee5bc | ||
|
|
cdaa151028 | ||
|
|
fd8d5819b8 | ||
|
|
4a5a4da399 | ||
|
|
efaaf73cd7 | ||
|
|
86540ad1a9 | ||
|
|
950482608d | ||
|
|
412c8d8612 | ||
|
|
de3c78a5aa | ||
|
|
eafa704ca5 | ||
|
|
86357079f8 | ||
|
|
ed118151cc | ||
|
|
50b26e2d9e | ||
|
|
a4aacc16d9 | ||
|
|
9aa7d23632 | ||
|
|
99b316da19 | ||
|
|
828c84762b | ||
|
|
5dae5d1cc1 | ||
|
|
23372655ca | ||
|
|
f5b33af7f1 | ||
|
|
993da9a8ad | ||
|
|
93f1024230 | ||
|
|
7b5e528060 | ||
|
|
1d5ba3ab07 | ||
|
|
f93c527561 | ||
|
|
6065353ac9 | ||
|
|
783cb73b29 | ||
|
|
ee75f0d119 | ||
|
|
aaac874f2c | ||
|
|
cd9e454991 | ||
|
|
d0fc79238b | ||
|
|
3ed84c2318 | ||
|
|
bd039398ba | ||
|
|
568db6aba0 | ||
|
|
2eb10933e7 | ||
|
|
5c6cf18139 | ||
|
|
01c31d884a | ||
|
|
d48d4af7d2 | ||
|
|
876d14f9d9 | ||
|
|
a8e090c9be | ||
|
|
c30df6cee0 | ||
|
|
b833769c25 | ||
|
|
855fb2a168 | ||
|
|
d2735ec13b | ||
|
|
c40fc5818a | ||
|
|
a553ebd593 | ||
|
|
232166eb89 | ||
|
|
99144950d9 | ||
|
|
32d4f45144 | ||
|
|
6bc78b41ef | ||
|
|
cd54beee04 | ||
|
|
ee45d21bbe | ||
|
|
4709f8c660 | ||
|
|
4696aa1759 | ||
|
|
924fc09516 | ||
|
|
5a700662dd | ||
|
|
8f992bfa92 | ||
|
|
e7dd27c744 | ||
|
|
7914836c3e | ||
|
|
b37bf1f7c7 | ||
|
|
419d9d55c5 | ||
|
|
d3ed54c771 | ||
|
|
8f26dbcbe6 | ||
|
|
663873ae14 | ||
|
|
286b1be212 | ||
|
|
37221782b0 | ||
|
|
644e291105 | ||
|
|
aae6984aa7 | ||
|
|
dbd296d399 | ||
|
|
e2ad25967d | ||
|
|
21b61d80d8 | ||
|
|
9f4c693ac4 | ||
|
|
45c27cac56 | ||
|
|
e46035afd4 | ||
|
|
1004bb73f4 | ||
|
|
d664fa7271 | ||
|
|
067fb7144f | ||
|
|
579f91f3aa | ||
|
|
abe2aecedf | ||
|
|
e8f9a4e627 | ||
|
|
22b1558e8b | ||
|
|
ca3b514a65 | ||
|
|
c694c4eda9 | ||
|
|
ac835c76aa | ||
|
|
25bbab7432 | ||
|
|
cca00e944e | ||
|
|
e78fe4ac89 | ||
|
|
60fd79f1fa | ||
|
|
5c0a865822 | ||
|
|
5b50e4b51b | ||
|
|
b97386a827 | ||
|
|
29aa26af94 | ||
|
|
3650d9914c | ||
|
|
f26031047c | ||
|
|
c8719926be | ||
|
|
f1dfc5c730 | ||
|
|
74ed642a42 | ||
|
|
5a17173620 | ||
|
|
29d14ff931 | ||
|
|
ad01504766 | ||
|
|
57ab08fb6d | ||
|
|
db5c09f80c | ||
|
|
b1e2c6961d | ||
|
|
3f4abe51e5 | ||
|
|
060c09e11c | ||
|
|
657df482bf | ||
|
|
f4f7732927 | ||
|
|
5e536e1444 | ||
|
|
2b48cdf84a | ||
|
|
bc37616506 | ||
|
|
07bcd80776 | ||
|
|
7b8b396368 | ||
|
|
823a95d601 | ||
|
|
af0b98a41b | ||
|
|
7d0e7cd7dc | ||
|
|
e56d6b1b60 | ||
|
|
941cf96a07 | ||
|
|
14fbf2ac5d | ||
|
|
494e2f0d8a | ||
|
|
e3a3849fa4 | ||
|
|
0b5e31a476 | ||
|
|
c8a723ed9d | ||
|
|
aaf4bf2737 | ||
|
|
24b0123a61 | ||
|
|
e8bd970cdb | ||
|
|
dd3be3a819 | ||
|
|
5b034c28ac | ||
|
|
b17949fe29 | ||
|
|
5d37420109 | ||
|
|
1d3ceb0c70 | ||
|
|
4d11afe18e | ||
|
|
0c0291c8c0 | ||
|
|
cca672b2cb | ||
|
|
5b27488402 | ||
|
|
c1c4e0db7b | ||
|
|
074a7c8b0a | ||
|
|
bc524e19db | ||
|
|
05f70d26d9 | ||
|
|
ab26dc7c6a | ||
|
|
6ff6b46139 | ||
|
|
119fd87a25 | ||
|
|
de226a8fa4 | ||
|
|
6865957725 | ||
|
|
87818d69ed | ||
|
|
38b67d01b8 | ||
|
|
a4f4a24730 | ||
|
|
87bd3d020f | ||
|
|
825ac5d565 | ||
|
|
f21a35e15d | ||
|
|
6e90b28204 | ||
|
|
e92e5e5158 | ||
|
|
2ac81566c6 | ||
|
|
b004670dec | ||
|
|
a426e33e6b | ||
|
|
bb7dd6bf7c | ||
|
|
37c5f2de24 | ||
|
|
ab6c25fe96 | ||
|
|
1fb464df09 | ||
|
|
65aa75043f | ||
|
|
79dcc7b4ec | ||
|
|
3d29cfe235 | ||
|
|
aea3a7ba98 | ||
|
|
190dfc6ecd | ||
|
|
316a7940d6 | ||
|
|
acfc1bb32d | ||
|
|
c4d178dc2d | ||
|
|
6333c697d5 | ||
|
|
810541494f | ||
|
|
e45dc2acbe | ||
|
|
2d1ccb9744 | ||
|
|
406f3b31e9 | ||
|
|
f51dd81014 | ||
|
|
3b38cbfb6c | ||
|
|
a4483ba277 | ||
|
|
bf46979b80 | ||
|
|
070f171ad4 | ||
|
|
3180704a0d | ||
|
|
b3f68697ce | ||
|
|
69d2b4b834 | ||
|
|
6837286061 | ||
|
|
a431ead22a | ||
|
|
7ec41dfe80 | ||
|
|
06053e9fd9 | ||
|
|
70b048fba3 | ||
|
|
45083f829b | ||
|
|
e4f6fb8e98 | ||
|
|
ee182b22da | ||
|
|
a37e22c227 | ||
|
|
d75ab1018d | ||
|
|
40ad066e69 | ||
|
|
a2a5a9f8fe | ||
|
|
5fd9339e56 | ||
|
|
a8a9208b1f |
2
.env
@@ -1,5 +1,3 @@
|
||||
COCO_SERVER_URL=http://localhost:9000 #https://coco.infini.cloud #http://localhost:9000
|
||||
|
||||
COCO_WEBSOCKET_URL=ws://localhost:9000/ws #wss://coco.infini.cloud/ws #ws://localhost:9000/ws
|
||||
|
||||
#TAURI_DEV_HOST=0.0.0.0
|
||||
70
.github/workflows/frontend-ci.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Frontend Code Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Only run it when Frontend code changes
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'tsup.config.ts'
|
||||
- 'package.json'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# No need to pass the version arg as it is specified by "packageManager" in package.json
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Switch platformAdapter to Web adapter
|
||||
shell: bash
|
||||
run: >
|
||||
node -e "const fs=require('fs');const f='src/utils/platformAdapter.ts';
|
||||
let s=fs.readFileSync(f,'utf8');
|
||||
s=s.replace(/import\\s*\\{\\s*createTauriAdapter\\s*\\}\\s*from\\s*\\\"\\.\\/tauriAdapter\\\";/,'import { createWebAdapter } from \\\"./webAdapter\\\";');
|
||||
s=s.replace(/let\\s+platformAdapter\\s*=\\s*createTauriAdapter\\(\\);/,'let platformAdapter = createWebAdapter();');
|
||||
fs.writeFileSync(f,s);"
|
||||
|
||||
- name: Build web (Tauri dependency check)
|
||||
run: pnpm build:web
|
||||
|
||||
- name: Verify no Tauri refs in web output
|
||||
shell: bash
|
||||
run: |
|
||||
if grep -R -n -E '@tauri-apps|tauri-plugin' out/search-chat; then
|
||||
echo 'Tauri references found in web build output';
|
||||
exit 1;
|
||||
else
|
||||
echo 'No Tauri references found';
|
||||
fi
|
||||
|
||||
- name: Restore platformAdapter to Tauri adapter
|
||||
shell: bash
|
||||
run: >
|
||||
node -e "const fs=require('fs');const f='src/utils/platformAdapter.ts';
|
||||
let s=fs.readFileSync(f,'utf8');
|
||||
s=s.replace(/import\\s*\\{\\s*createWebAdapter\\s*\\}\\s*from\\s*\\\"\\.\\/webAdapter\\\";/,'import { createTauriAdapter } from \\\"./tauriAdapter\\\";');
|
||||
s=s.replace(/let\\s+platformAdapter\\s*=\\s*createWebAdapter\\(\\);/,'let platformAdapter = createTauriAdapter();');
|
||||
fs.writeFileSync(f,s);"
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
134
.github/workflows/release.yml
vendored
@@ -9,10 +9,16 @@ on:
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
APP_VERSION: ${{ steps.get-version.outputs.APP_VERSION }}
|
||||
RELEASE_BODY: ${{ steps.get-changelog.outputs.RELEASE_BODY }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set output
|
||||
id: vars
|
||||
run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
|
||||
@@ -22,11 +28,28 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Get build version
|
||||
shell: bash
|
||||
id: get-version
|
||||
run: |
|
||||
PACKAGE_VERSION=$(jq -r '.version' package.json)
|
||||
CARGO_VERSION=$(grep -m 1 '^version =' src-tauri/Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
if [ "$PACKAGE_VERSION" != "$CARGO_VERSION" ]; then
|
||||
echo "::error::Version mismatch!"
|
||||
else
|
||||
echo "Version match: $PACKAGE_VERSION"
|
||||
fi
|
||||
echo "APP_VERSION=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate changelog
|
||||
id: create_release
|
||||
run: npx changelogithub --draft --name ${{ steps.vars.outputs.tag }}
|
||||
id: get-changelog
|
||||
run: |
|
||||
CHANGELOG_BODY=$(npx changelogithub --draft --name ${{ steps.vars.outputs.tag }})
|
||||
echo "RELEASE_BODY<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CHANGELOG_BODY" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-app:
|
||||
needs: create-release
|
||||
@@ -52,11 +75,23 @@ jobs:
|
||||
target: "x86_64-unknown-linux-gnu"
|
||||
- platform: "ubuntu-22.04-arm"
|
||||
target: "aarch64-unknown-linux-gnu"
|
||||
env:
|
||||
APP_VERSION: ${{ needs.create-release.outputs.APP_VERSION }}
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout dependency repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'infinilabs/pizza'
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
submodules: recursive
|
||||
ref: main
|
||||
path: pizza
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -65,17 +100,41 @@ jobs:
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Install rust target
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: startsWith(matrix.platform, 'ubuntu-22.04')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libtracker-sparql-3.0-dev
|
||||
|
||||
- name: Install Rust stable
|
||||
run: rustup toolchain install stable
|
||||
|
||||
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
|
||||
# And bindgen relies on 'libclang'
|
||||
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
|
||||
#
|
||||
# We don't need to install it because it is already included in GitHub
|
||||
# Action runner image:
|
||||
# https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md#language-and-runtime
|
||||
|
||||
|
||||
- name: Add Rust build target
|
||||
working-directory: src-tauri
|
||||
shell: bash
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} || true
|
||||
|
||||
- name: Add pizza engine as a dependency
|
||||
working-directory: src-tauri
|
||||
shell: bash
|
||||
run: |
|
||||
BUILD_ARGS="--target ${{ matrix.target }}"
|
||||
if [[ "${{matrix.target }}" != "i686-pc-windows-msvc" ]]; then
|
||||
echo "Adding pizza engine as a dependency for ${{matrix.platform }}-${{matrix.target }}"
|
||||
( cargo add --path ../pizza/lib/engine --features query_string_parser,persistence )
|
||||
BUILD_ARGS+=" --features use_pizza_engine"
|
||||
else
|
||||
echo "Skipping pizza engine dependency for ${{matrix.platform }}-${{matrix.target }}"
|
||||
fi
|
||||
echo "BUILD_ARGS=${BUILD_ARGS}" >> $GITHUB_ENV
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
@@ -90,33 +149,9 @@ jobs:
|
||||
|
||||
- name: Install app dependencies and build web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- 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 }}
|
||||
|
||||
- name: Build the coco at ${{ matrix.platform}} for ${{ matrix.target }} @ ${{ env.APP_VERSION }}
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
if: matrix.target != 'i686-pc-windows-msvc'
|
||||
env:
|
||||
CI: false
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
@@ -131,31 +166,8 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: Coco ${{ needs.create-release.outputs.APP_VERSION }}
|
||||
releaseBody: ""
|
||||
releaseName: Coco ${{ env.APP_VERSION }}
|
||||
releaseBody: "${{ needs.create-release.outputs.RELEASE_BODY }}"
|
||||
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 }}
|
||||
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 }}
|
||||
args: ${{ env.BUILD_ARGS }}
|
||||
|
||||
69
.github/workflows/rust_code_check.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
name: Rust Code Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Only run it when Rust code changes
|
||||
paths:
|
||||
- 'src-tauri/**'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout dependency (pizza-engine) repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'infinilabs/pizza'
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
submodules: recursive
|
||||
ref: main
|
||||
path: pizza
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: startsWith(matrix.platform, 'ubuntu-latest')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libtracker-sparql-3.0-dev
|
||||
|
||||
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
|
||||
# And bindgen relies on 'libclang'
|
||||
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
|
||||
#
|
||||
# We don't need to install it because it is already included in GitHub
|
||||
# Action runner image:
|
||||
# https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md#language-and-runtime
|
||||
|
||||
- name: Add pizza engine as a dependency
|
||||
working-directory: src-tauri
|
||||
shell: bash
|
||||
run: cargo add --path ../pizza/lib/engine --features query_string_parser,persistence
|
||||
|
||||
- name: Format check
|
||||
working-directory: src-tauri
|
||||
shell: bash
|
||||
run: |
|
||||
rustup component add rustfmt
|
||||
cargo fmt --all --check
|
||||
|
||||
- name: Check compilation (Without Pizza engine enabled)
|
||||
working-directory: ./src-tauri
|
||||
run: cargo check
|
||||
|
||||
- name: Check compilation (With Pizza engine enabled)
|
||||
working-directory: ./src-tauri
|
||||
run: cargo check --features use_pizza_engine
|
||||
|
||||
- name: Run tests (Without Pizza engine)
|
||||
working-directory: ./src-tauri
|
||||
run: cargo test
|
||||
|
||||
- name: Run tests (With Pizza engine)
|
||||
working-directory: ./src-tauri
|
||||
run: cargo test --features use_pizza_engine
|
||||
4
.gitignore
vendored
@@ -13,6 +13,8 @@ dist-ssr
|
||||
*.local
|
||||
out
|
||||
src/components/web
|
||||
SearchChatDemo/
|
||||
web.md
|
||||
|
||||
# Editor directories and files
|
||||
# .vscode/*
|
||||
@@ -26,3 +28,5 @@ src/components/web
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
|
||||
.trae
|
||||
17
.vscode/settings.json
vendored
@@ -8,10 +8,13 @@
|
||||
"clsx",
|
||||
"codegen",
|
||||
"dataurl",
|
||||
"deeplink",
|
||||
"deepthink",
|
||||
"dtolnay",
|
||||
"dyld",
|
||||
"elif",
|
||||
"errmsg",
|
||||
"frontmost",
|
||||
"fullscreen",
|
||||
"fulltext",
|
||||
"headlessui",
|
||||
@@ -30,22 +33,31 @@
|
||||
"localstorage",
|
||||
"lucide",
|
||||
"maximizable",
|
||||
"mdast",
|
||||
"meval",
|
||||
"Minimizable",
|
||||
"msvc",
|
||||
"njsproj",
|
||||
"nord",
|
||||
"nowrap",
|
||||
"nspanel",
|
||||
"nsstring",
|
||||
"ntvs",
|
||||
"objc",
|
||||
"overscan",
|
||||
"partialize",
|
||||
"patchelf",
|
||||
"Quicklink",
|
||||
"Quicklinks",
|
||||
"Raycast",
|
||||
"rehype",
|
||||
"reqwest",
|
||||
"rerank",
|
||||
"rgba",
|
||||
"rustup",
|
||||
"screenshotable",
|
||||
"serde",
|
||||
"Shadcn",
|
||||
"swatinem",
|
||||
"tailwindcss",
|
||||
"tauri",
|
||||
@@ -53,9 +65,11 @@
|
||||
"timedout",
|
||||
"titlebar",
|
||||
"tpddns",
|
||||
"trae",
|
||||
"traptitech",
|
||||
"unlisten",
|
||||
"unlistener",
|
||||
"unlisteners",
|
||||
"unminimize",
|
||||
"uuidv",
|
||||
"VITE",
|
||||
@@ -76,5 +90,6 @@
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.detectIndentation": false
|
||||
"editor.detectIndentation": false,
|
||||
"i18n-ally.displayLanguage": "zh"
|
||||
}
|
||||
@@ -64,9 +64,9 @@ At Coco AI, we aim to streamline workplace collaboration by centralizing access
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 18.12
|
||||
- Rust (latest stable)
|
||||
- pnpm (package manager)
|
||||
- [Node.js >= 18.12](https://nodejs.org/en/download/)
|
||||
- [Rust (latest stable)](https://www.rust-lang.org/tools/install)
|
||||
- [pnpm (package manager)](https://pnpm.io/installation)
|
||||
|
||||
### Development Setup
|
||||
|
||||
@@ -91,6 +91,8 @@ pnpm tauri build
|
||||
|
||||
- [Coco App Documentation](https://docs.infinilabs.com/coco-app/main/)
|
||||
- [Coco Server Documentation](https://docs.infinilabs.com/coco-server/main/)
|
||||
- [DeepWiki Coco App](https://deepwiki.com/infinilabs/coco-app)
|
||||
- [DeepWiki Coco Server](https://deepwiki.com/infinilabs/coco-server)
|
||||
- [Tauri Documentation](https://tauri.app/)
|
||||
|
||||
## Contributors
|
||||
|
||||
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/main.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ Coco AI is a fully open-source, cross-platform unified search and productivity t
|
||||
|
||||
{{% load-img "/img/coco-preview.gif" "" %}}
|
||||
|
||||
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).
|
||||
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-server/](https://docs.infinilabs.com/coco-server/).
|
||||
|
||||
## Community
|
||||
|
||||
|
||||
59
docs/content.en/docs/core-features/AI Overview.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
weight: 1
|
||||
title: AI Overview
|
||||
---
|
||||
|
||||
# AI Overview
|
||||
|
||||
The **AI Overview** feature can automatically refine and summarize current search results in search mode, helping users quickly grasp the key points of the search results without having to browse each individual result. This feature is particularly useful in scenarios where information needs to be extracted quickly.
|
||||
|
||||
{{% load-img "/img/core-features/ai_overview_01.png" "" %}}
|
||||
|
||||
|
||||
|
||||
|
||||
## Feature Overview
|
||||
|
||||
- **Automatic Refinement and Summary**: When a user performs a search, AI Overview automatically generates a concise summary based on the current search results, providing key information from the results.
|
||||
|
||||
- **Improve Work Efficiency**: By avoiding the need to manually browse through numerous results, AI Overview helps users quickly focus on the most relevant information, saving time.
|
||||
|
||||
|
||||
|
||||
|
||||
## Enabling AI Overview
|
||||
|
||||
{{% load-img "/img/core-features/ai_overview_02.png" "" %}}
|
||||
|
||||
|
||||
|
||||
To use the **AI Overview** feature, you need to configure it in the settings:
|
||||
|
||||
1. Open the **Settings** page and select the **Extensions** option.
|
||||
|
||||
2. In the **AI Overview Extension** configuration, choose an **AI assistant** that you want to use for summarization.
|
||||
|
||||
3. Configure the **trigger strategy**:
|
||||
- **Minimum number of search results**: Set the minimum number of search results required to trigger AI Overview.
|
||||
- **Minimum input length**: Set the minimum length of the input query; the summary function will only start when the input content is long enough.
|
||||
- **Delay after typing stops**: Set the time delay after input stops to start the summary function, avoiding unnecessary summaries triggered by frequent input.
|
||||
|
||||
4. After saving the settings, in search mode, press `Meta + O` to enable the AI Overview feature, and AI Overview will automatically generate summaries for the search results according to your configuration.
|
||||
|
||||
|
||||
|
||||
|
||||
> 💡 **Tip**: **The style and depth of the summary depend on the AI assistant you choose.**
|
||||
>
|
||||
> Think of it as an "information assistant"; the role you assign to it determines its reporting style:
|
||||
>
|
||||
> - **"Summary Abstract" assistant**: Provides quick, general summaries.
|
||||
>
|
||||
> - **"Technical Expert" assistant**: May generate summaries that focus more on technical specifications and code snippets.
|
||||
>
|
||||
> - **"Market Analyst" assistant**: Will pay more attention to market data, competitive dynamics, etc.
|
||||
>
|
||||
>
|
||||
> 💡 **Tip**: **For faster response speed**
|
||||
>
|
||||
> If you pursue **ultimate response speed**, it is recommended to configure an assistant using a **fast token-generation, non-inference type model** for the AI Overview feature. Such models can quickly generate summaries for you, making information acquisition smooth.
|
||||
41
docs/content.en/docs/core-features/Application Search.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
weight: 4
|
||||
title: Application Search
|
||||
---
|
||||
|
||||
# Application Search
|
||||
|
||||
The **Applications** feature allows you to directly search for and launch locally installed applications in Coco AI. You can quickly find and open any application through the unified search entry without switching windows or manually searching.
|
||||
|
||||
{{% load-img "/img/core-features/application_search_01.png" "" %}}
|
||||
|
||||
|
||||
## Feature Overview
|
||||
|
||||
- **Quick Launch**: Enter the application name in the search box to instantly match results and quickly open the program.
|
||||
|
||||
- **Custom Search Scope**: Control which directories' applications are indexed and displayed through settings.
|
||||
|
||||
|
||||
|
||||
|
||||
## Feature Settings
|
||||
|
||||
{{% load-img "/img/core-features/application_search_02.png" "" %}}
|
||||
|
||||
To use the **AI Overview** feature, you need to configure it in the settings:
|
||||
|
||||
1. **Search Scope**
|
||||
Specify the paths where Coco AI will search for executable applications.
|
||||
|
||||
- For example:
|
||||
- macOS: `/Applications`, `~/Applications`
|
||||
- Windows: `C:\Program Files`, `C:\Users\<User>\AppData\Local`
|
||||
- You can add or remove paths according to actual needs to avoid displaying irrelevant programs.
|
||||
|
||||
2. **Rebuild Index**
|
||||
|
||||
Rescan and update the local application index.
|
||||
|
||||
- Usually, there is no need to perform this manually.
|
||||
- If you find that an installed application does not appear in the search results, you can click **Rebuild Index** to manually retry and update the results.
|
||||
32
docs/content.en/docs/core-features/Calculator.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
weight: 5
|
||||
title: Calculator
|
||||
---
|
||||
|
||||
# Calculator
|
||||
|
||||
Coco AI provides a concise calculator function that allows users to perform quickquick basic mathematical calculations directly in the input box without opening a separate calculator application. Simply enter an arithmetic expression, and the system will instantly provide the result. It also supports copying the arithmetic expression and the calculation result for easy use at any time.
|
||||
|
||||
{{% load-img "/img/core-features/calculator_01.png" "" %}}
|
||||
|
||||
|
||||
|
||||
## Feature Overview
|
||||
|
||||
- **Quick Calculation**: Enter basic mathematical expressions in Coco AI's input box, and the system will automatically calculate and display the result.
|
||||
- **Support for Basic Mathematical Operations**: Currently supports basic arithmetic operations such as addition, subtraction, multiplication, and division.
|
||||
- **Copy Expression and Result**: Supports copying the complete arithmetic expression and calculation result for easy pasting into other applications.
|
||||
|
||||
|
||||
|
||||
## Usage Method
|
||||
|
||||
1. **Enter an Expression**:
|
||||
- Directly input a basic mathematical expression in Coco AI's input box, for example: `256 * 42`
|
||||
- The system will automatically calculate and display the result.
|
||||
2. **Copy Expression and Result**:
|
||||
- When the result is displayed, press `Enter` to copy the calculation result.
|
||||
- Use the shortcut key `Meta + K` to open more operations, and select **Copy Answer**, **Copy Question and Answer**, or **Copy Answer (in Word)**
|
||||
|
||||
{{% load-img "/img/core-features/calculator_02.png" "" %}}
|
||||
|
||||
55
docs/content.en/docs/core-features/File Search.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
weight: 3
|
||||
title: File Search
|
||||
---
|
||||
|
||||
# File Search
|
||||
|
||||
The File Search feature allows you to directly use the system's local search capability in Coco AI to quickly find files on your computer. You can flexibly set the search scope, excluded directories, file types, and search methods to get more accurate results.
|
||||
|
||||
{{% load-img "/img/core-features/filesearch_01.png" "" %}}
|
||||
|
||||
|
||||
|
||||
## Feature Overview
|
||||
|
||||
- **System-level search integration**: Coco AI leverages the file indexing capabilities provided by the operating system (such as macOS Spotlight, Windows Search, etc.) to achieve efficient local file search.
|
||||
- **Flexible search control**: Supports custom search scopes and excluded paths, and can filter file types according to needs.
|
||||
- **Content-level search**: On supported systems, you can choose to search file contents at the same time, not just file names.
|
||||
|
||||
|
||||
|
||||
## Feature Settings
|
||||
|
||||
{{% load-img "/img/core-features/filesearch_02.png" "" %}}
|
||||
|
||||
Coco AI is already equipped with local file search capabilities. You don't need any additional operations; you can start typing keywords in the search box to experience it immediately. If you want to exclude certain folders or add new search locations, you can manage your preferences at any time through **"Settings → Extensions → File Search"**.
|
||||
|
||||
1. **Search By**
|
||||
Select the matching method for the search:
|
||||
|
||||
- **Name**: Only match file names (faster).
|
||||
- **Name + Contents**: Match both file names and file contents (depending on operating system support).
|
||||
|
||||
2. **Search Scope**
|
||||
Select the folders or disk locations to be included in the search.
|
||||
|
||||
- For example: `/Users/username/Documents` or `D:\Projects`
|
||||
|
||||
3. **Exclude Scope**
|
||||
Specify paths that are not included in the search, used to reduce irrelevant results or improve search speed.
|
||||
|
||||
- For example: `node_modules`, `tmp`, `Library` and other system cache directories.
|
||||
|
||||
4. **Search File Types**
|
||||
Limit the file extensions or types to be searched.
|
||||
|
||||
- For example: `.pdf`, `.docx`, `.md`, `.txt`
|
||||
|
||||
|
||||
|
||||
> 💡 **Tips**: **System Support Differences**
|
||||
>
|
||||
> - **macOS**: Implements mixed search of file names and contents through **Spotlight**, supporting fast response and fuzzy matching.
|
||||
> - **Windows**: Relies on the system's **Windows Search Indexer**, supporting file name search; content search requires enabling content indexing for corresponding file types in system index settings.
|
||||
> - **Linux**: Generally only supports file name search, depending on the distribution and configuration.
|
||||
32
docs/content.en/docs/core-features/Quick AI Access.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
weight: 2
|
||||
title: Quick AI Access
|
||||
---
|
||||
|
||||
# Quick AI Access
|
||||
|
||||
The **Quick AI Access** feature allows you to directly start a conversation with AI through the search box without switching to chat mode. This feature provides users with a smoother and more efficient interaction experience, especially suitable for scenarios where quick feedback or handling simple questions is needed.
|
||||
|
||||
{{% load-img "/img/core-features/quick_ai_access_01.png" "" %}}
|
||||
|
||||
|
||||
|
||||
## Feature Overview
|
||||
|
||||
{{% load-img "/img/core-features/quick_ai_access_02.png" "" %}}
|
||||
|
||||
- **Quickly Start a Conversation**: After entering content in the search box, press `Meta + Enter` to directly start a conversation with the AI assistant without switching to chat mode.
|
||||
- **Instant Response**: Coco AI will display the conversation reply in the same window, providing answers or suggestions quickly.
|
||||
- **Switch from Conversation Mode**: After completing a quick conversation, press `Meta + Enter` to switch to the full chat mode and continue multi-turn conversations.
|
||||
|
||||
|
||||
|
||||
## Enabling Quick AI Access
|
||||
|
||||
{{% load-img "/img/core-features/quick_ai_access_03.png" "" %}}
|
||||
|
||||
To use the **Quick AI Access** feature, you need to configure it in the settings:
|
||||
|
||||
1. Open the **Settings** page and select the **Extensions** option.
|
||||
2. In the **Quick AI Access Extension** configuration, associate an AI assistant that you want to quickly access via `Meta + Enter`.
|
||||
3. After saving the settings, you can directly start a conversation with the selected assistant through `Meta + Enter` in the search box.
|
||||
45
docs/content.en/docs/core-features/Window Management.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
weight: 6
|
||||
title: Window Management
|
||||
---
|
||||
|
||||
# Window Management
|
||||
|
||||
Easily adjust, reorganize, and move the windows you're focusing on.
|
||||
No need for manual dragging—quickly perform window layout operations through commands.
|
||||
|
||||
{{% load-img "/img/core-features/window_management_01.png" "" %}}
|
||||
|
||||
|
||||
|
||||
|
||||
## Feature Overview
|
||||
|
||||
- **Move Windows**: Move the current window to the left half, right half, top half, or bottom half of the screen.
|
||||
- **Resize Windows**: Quickly adjust to full screen, centered, 1/3, or 2/3 size layouts.
|
||||
- **Multi-monitor Support**: Quickly move windows between multiple monitors.
|
||||
- **Focus Windows**: Quickly focus on a specified window or application via shortcut keys.
|
||||
|
||||
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
{{% load-img "/img/core-features/window_management_02.png" "" %}}
|
||||
|
||||
Enter commands included in Window Management in the **Coco AI search box** to browse and execute window management commands, such as:
|
||||
|
||||
- **Almost Maximize Bottom** — Maximize the window to the lower area of the screen
|
||||
- **Bottom Half** — Move the current window to the lower half of the screen
|
||||
- **Bottom Left Quarter** — Position the window to the bottom-left quarter
|
||||
- **Bottom Right Sixth** — Place the window in the bottom-right sixth area
|
||||
|
||||
The window's position and size will be adjusted immediately after selecting a command.
|
||||
|
||||
|
||||
|
||||
|
||||
> 💡 **Tips**
|
||||
>
|
||||
> - System-level window operations are supported; some special types of windows (such as full-screen or independent floating windows) may not be controllable.
|
||||
> - It is recommended to combine custom shortcuts for commands to quickly achieve common window layouts.
|
||||
5
docs/content.en/docs/core-features/_index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
weight: 2
|
||||
title: Core Features
|
||||
bookCollapseSection: true
|
||||
---
|
||||
103
docs/content.en/docs/getting-started/AI Chat.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
weight: 4
|
||||
title: AI Chat
|
||||
---
|
||||
|
||||
# AI Chat
|
||||
|
||||
Coco AI is not just a search tool, but your AI intelligent center.
|
||||
In chat mode, you can communicate with AI in natural language, ask questions, analyze files, and summarize knowledge.
|
||||
|
||||
{{% load-img "/img/core-features/basics_02.png" "" %}}
|
||||
|
||||
|
||||
|
||||
|
||||
## Chat Entry
|
||||
|
||||
- Use the global shortcut (default: `Shift + Meta + Space`) to open the Coco AI interface.
|
||||
|
||||
- The interface is in chat mode (use the switch button or the shortcut `Meta + T` to switch modes).
|
||||
|
||||
- Enter natural language questions in the input box. Press `Enter` to start the conversation.
|
||||
|
||||
|
||||
|
||||
|
||||
## Chat Interface and Functions
|
||||
|
||||
Coco AI's chat interface is designed to be concise and intuitive, allowing you to quickly switch AI assistants, access different Coco Servers, browse historical conversations, or use advanced capabilities such as deep thinking, web search, and tool calls.
|
||||
|
||||
{{% load-img "/img/core-features/ai_chat_01.png" "" %}}
|
||||
|
||||
#### Interface Overview
|
||||
|
||||
In chat mode, the Coco AI interface mainly consists of the following areas:
|
||||
|
||||
- **Top Bar**
|
||||
- **Assistant Selection**: The drop-down menu in the upper left corner allows you to quickly switch between different AI assistants.
|
||||
- **Historical Conversations**: Click the icon in the upper left corner to view recent conversations, and click any one to restore the conversation context.
|
||||
- **Server Switching**: The cloud icon in the upper right corner shows the currently connected Coco Server, and you can switch or refresh the server with one click.
|
||||
- **Independent Window Mode**: The icon in the upper right corner can pop up the current conversation into an independent window, facilitating multi-task collaboration or comparison viewing.
|
||||
- **Middle Area**
|
||||
- Displays conversation content and AI responses.
|
||||
- **Bottom Input Area**
|
||||
- Enter messages and press `Enter` to send, supporting voice input.
|
||||
- The left function bar includes controls such as web search, tool call (MCP), and deep thinking switch.
|
||||
|
||||
|
||||
|
||||
|
||||
#### Multiple Servers and Assistants
|
||||
|
||||
##### Switching Coco Server
|
||||
|
||||
Coco AI supports connecting to multiple Coco Servers, and each server can contain a different number of AI assistants.
|
||||
|
||||
Click the **server icon** in the upper right corner to view the current connection status:
|
||||
|
||||
- Displays the server name and online status.
|
||||
- Lists the number of available AI assistants on the server.
|
||||
- Supports one-click switching, refreshing, or entering the settings page.
|
||||
|
||||
{{% load-img "/img/core-features/ai_chat_03.png" "" %}}
|
||||
|
||||
|
||||
|
||||
##### Switching AI Assistants
|
||||
|
||||
The drop-down menu in the upper left corner lists all assistants in the current server.
|
||||
|
||||
Each assistant may have different capabilities and modes according to the configuration.
|
||||
|
||||
{{% load-img "/img/core-features/ai_chat_02.png" "" %}}
|
||||
|
||||
|
||||
|
||||
> 💡 **Tip**: When switching assistants in the same conversation, Coco AI will automatically retain the context of the current conversation. This means you can let different assistants take turns answering or supplementing analysis in the same round of conversation without re-entering background content.
|
||||
|
||||
|
||||
|
||||
#### Bottom Function Bar
|
||||
|
||||
The function buttons at the bottom left of the input box can quickly call the following capabilities:
|
||||
|
||||
| Function | Icon | Description |
|
||||
| -------------------- | ---- | ------------------------------------------------------------ |
|
||||
| **Deep Think Switch** | 🧠 | Turn on or off the deep think capability (only available for assistants in deep think mode). |
|
||||
| **Search Switch** | 🌐 | Call the data sources connected in the Coco Server for real-time search. (Some data sources can be selected as needed) |
|
||||
| **MCP Switch** | 🔨 | Call external tools or commands, such as database query, translation, task execution, etc. |
|
||||
|
||||
> **💡 Tip**: Search and MCP tool calls rely on the currently connected Coco Server, and their availability depends on server configuration.
|
||||
|
||||
{{% load-img "/img/core-features/ai_chat_04.png" "" %}}
|
||||
|
||||
|
||||
|
||||
#### Interactive Operations
|
||||
|
||||
- Press `Enter` to send a message
|
||||
- Press `Shift + Enter` to wrap lines
|
||||
- Press `Meta + U` to switch AI assistants
|
||||
- Press `Meta + S` to switch Coco Server
|
||||
- Press `Meta + E` to pop up the current conversation into an independent window
|
||||
63
docs/content.en/docs/getting-started/Extension.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
weight: 5
|
||||
title: Extension
|
||||
---
|
||||
|
||||
# Extension
|
||||
|
||||
Extensions of Coco AI are plug-in modules that add specific functions to the core system. By installing extensions, you can greatly enhance the capabilities of Coco AI and create a personalized intelligent working environment.
|
||||
|
||||
{{% load-img "/img/core-features/extension_01.png" "" %}}
|
||||
|
||||
|
||||
|
||||
## How to Install Extensions
|
||||
|
||||
#### Install via Extension Store
|
||||
|
||||
In the Extension Store, you can browse or search for the required extensions. After finding the desired extension, press `↵` to view details, and click the install button on the details page. Coco AI will automatically complete the download and installation process.
|
||||
|
||||
{{% load-img "/img/core-features/extension_02.png" "" %}}
|
||||
|
||||
|
||||
|
||||
## How to Use Extensions
|
||||
|
||||
After installing an extension, you can call it through the unified search box.
|
||||
|
||||
#### Command-type Extensions (Commands)
|
||||
|
||||
In search mode, enter the command name or keywords, select the corresponding command from the search results, and press Enter to execute it.
|
||||
|
||||
|
||||
|
||||
#### View-type Extensions (Views)
|
||||
|
||||
View-type extensions provide a complete user interface, embedding visual applications in Coco AI, which can display complex information and offer rich interactive experiences.
|
||||
|
||||
In search mode, enter the extension name or keywords, select the corresponding extension from the search results, and press `↵` to enter the corresponding extension's interaction interface.
|
||||
|
||||
{{% load-img "/img/core-features/extension_04.png" "" %}}
|
||||
|
||||
|
||||
|
||||
## Extension Management
|
||||
|
||||
#### View Installed Extensions
|
||||
|
||||
Open Settings (shortcut key: `Meta+,`). On the Extensions page, you can:
|
||||
|
||||
- Filter by type
|
||||
- Check the extension status (enabled/disabled)
|
||||
- View and modify extension configurations
|
||||
- Uninstall extensions
|
||||
- Set extension command shortcuts or aliases
|
||||
|
||||
{{% load-img "/img/core-features/extension_03.png" "" %}}
|
||||
|
||||
|
||||
|
||||
|
||||
#### Uninstall Extensions
|
||||
|
||||
On the Extensions page in Settings, select the extension you want to uninstall. On the right side of the extension title in the details section, click the `…` button and select Uninstall.
|
||||
87
docs/content.en/docs/getting-started/Keyboard Shortcuts.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
weight: 6
|
||||
title: Keyboard Shortcuts
|
||||
---
|
||||
|
||||
# Keyboard Shortcuts
|
||||
|
||||
Coco AI provides an intuitive set of keyboard shortcuts to help you navigate efficiently, execute commands, switch modes, and manage conversations. Mastering these shortcuts can greatly enhance your user experience.
|
||||
|
||||
|
||||
|
||||
|
||||
## You don't need to memorize the shortcuts
|
||||
|
||||
Simply go to **Settings** (shortcut: `Meta + ,`) → **General → Tooltip**, and turn on the shortcut hint switch. After enabling, when you hold down the modifier key, the corresponding shortcut hints will be displayed in real-time in each functional area of the interface.
|
||||
|
||||
{{% load-img "/img/core-features/shortcuts_01.png" "" %}}
|
||||
|
||||
{{% load-img "/img/core-features/shortcuts_03.png" "" %}}
|
||||
|
||||
|
||||
## Global Shortcuts
|
||||
|
||||
These shortcuts work across any interface, helping you quickly access Coco AI's core functions:
|
||||
|
||||
- `Shift + Meta + Space` Open the Coco AI window
|
||||
|
||||
- `Meta + T` Switch between search/conversation modes
|
||||
|
||||
- `Meta + I` Return to the input box
|
||||
|
||||
- `Meta + P` Pin the window, keeping the Coco AI window displayed at the front of the desktop
|
||||
|
||||
- `Meta + ,` Open the Coco AI settings page
|
||||
|
||||
- `Esc` Close the Coco AI window
|
||||
|
||||
|
||||
|
||||
|
||||
## Search Mode Shortcuts
|
||||
|
||||
In Coco AI's search mode, keyboard shortcuts can help you browse and filter search results more efficiently:
|
||||
|
||||
- `Enter` Open the selected result
|
||||
|
||||
- `Meta + Number` Select the result corresponding to the number and open it
|
||||
|
||||
- `Meta + K` View actionable items for the selected result
|
||||
|
||||
- `Tab` Use the data source of the current result as a filter condition
|
||||
|
||||
- `Arrow Up / Down` Select search results up and down
|
||||
|
||||
- `Meta + Arrow Up / Down` Quickly jump to the first result of the upper/lower category
|
||||
|
||||
|
||||
|
||||
|
||||
## Chat Mode Shortcuts
|
||||
|
||||
In Coco AI's chat mode, keyboard shortcuts can help you quickly switch assistants, control conversations, and input information:
|
||||
|
||||
- `Enter` Send a message
|
||||
|
||||
- `Shift + Enter` Enter a new line
|
||||
|
||||
- `Meta + N` Create a new conversation
|
||||
|
||||
- `Meta + Y` View conversation history
|
||||
|
||||
- `Meta + U` Switch assistants
|
||||
|
||||
- `Meta + S` Switch Coco Server
|
||||
|
||||
- `Meta + E` Pop out the current conversation into an independent window
|
||||
|
||||
|
||||
|
||||
|
||||
## Custom Shortcuts
|
||||
|
||||
Coco AI allows you to customize shortcuts in the settings to adjust according to your personal needs.
|
||||
|
||||
Simply go to **Settings** (shortcut: `Meta + ,`) → **Advanced → Keyboard Shortcuts** to modify the default shortcuts.
|
||||
|
||||
{{% load-img "/img/core-features/shortcuts_02.png" "" %}}
|
||||
69
docs/content.en/docs/getting-started/Search.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
weight: 3
|
||||
title: Search
|
||||
---
|
||||
|
||||
# Search
|
||||
|
||||
Coco AI's search function is designed to provide a unified, intelligent, and efficient cross-platform information retrieval experience. In search mode, you can quickly find local files, applications, commands, extensions, data sources in Coco Server (including Google Drive, Notion, Yuque, Hugo sites, RSS, Github, Postgres, etc.), and AI assistants through the search box.
|
||||
|
||||
{{% load-img "/img/core-features/search_02.png" "" %}}
|
||||
|
||||
|
||||
|
||||
## Search Entrance
|
||||
|
||||
- Open the Coco AI interface using the global shortcut (default: `Shift + Meta + Space`).
|
||||
- The interface is in search mode (switch modes using the toggle button or the shortcut `Meta + T`).
|
||||
- Enter keywords, file names, or natural language questions in the input box.
|
||||
|
||||
{{% load-img "/img/core-features/search_01.png" "" %}}
|
||||
|
||||
|
||||
|
||||
## Search Results
|
||||
|
||||
Coco AI will automatically return a set of concise, structured search results after you enter keywords.
|
||||
|
||||
|
||||
|
||||
#### Default Display Rules
|
||||
|
||||
- By default, the **first 10 results** are displayed.
|
||||
- When results come from multiple data sources (such as Hugo sites, Google Drive, local files, etc.), they will be displayed **grouped by data source**.
|
||||
- If there are fewer than 10 results, they will be displayed **without grouping** in a single list.
|
||||
- Each result shows:
|
||||
- **Title** (file name, note name, or conversation title)
|
||||
- **Directory information** (belonging path or location)
|
||||
- Key matching fragments or summaries
|
||||
|
||||
> 💡 **Tip**: Grouping allows you to quickly understand the range of matching content in different data sources, saving time in filtering.
|
||||
|
||||
|
||||
|
||||
#### Quick Filtering and Navigation
|
||||
|
||||
{{% load-img "/img/core-features/search_03.png" "" %}}
|
||||
|
||||
When browsing results, you can use the **Tab key** to quickly filter the currently selected result:
|
||||
|
||||
- After pressing `Tab`, Coco AI will automatically use the data source of the current result as the filtering condition.
|
||||
- The interface will switch to the separate search view of that data source.
|
||||
|
||||
In the single data source search view, Coco AI will display more abundant content, including:
|
||||
|
||||
- **More search results** (no longer limited to 10)
|
||||
- **More file attributes** (size, type, modification time, etc.)
|
||||
- **Content thumbnails** or **preview summaries** to facilitate quick judgment of relevance
|
||||
|
||||
When the selected result is an AI assistant or extension, the Tab key can initiate a quick conversation with the AI assistant or open the extension.
|
||||
|
||||
|
||||
|
||||
#### Interactive Operations
|
||||
|
||||
- Use the `↓↑` arrow keys or mouse to select result items.
|
||||
- Press `Enter` or `Meta + number` to open the result item.
|
||||
- Press `Tab` to filter to the results of that data source or further interact with the selected result.
|
||||
- Press the `Backspace` key to delete input content and return to the previous level.
|
||||
- Press `Esc` to exit the Coco AI interface.
|
||||
48
docs/content.en/docs/getting-started/Settings.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
weight: 7
|
||||
title: Settings
|
||||
---
|
||||
|
||||
# Settings
|
||||
|
||||
In Coco AI, you can adjust various settings of the application according to your personal needs (shortcut: `Meta + ,`) . The settings page is divided into several main sections, allowing you to easily manage startup items, shortcuts, extensions, connections, and advanced features.
|
||||
|
||||
|
||||
|
||||
## General
|
||||
|
||||
In the General Settings section, you can adjust Coco AI's startup items, startup shortcuts, interface appearance, and language.
|
||||
|
||||
{{% load-img "/img/core-features/settings_01.png" "" %}}
|
||||
|
||||
|
||||
|
||||
## Extension
|
||||
|
||||
In the Extension Settings, you can view, manage, and configure installed extensions.
|
||||
|
||||
{{% load-img "/img/core-features/settings_02.png" "" %}}
|
||||
|
||||
|
||||
|
||||
## Connect
|
||||
|
||||
In the Connect Settings, you can view and manage connections to Coco Server. This section involves logging in, enabling/disabling, and deleting connected servers.
|
||||
|
||||
{{% load-img "/img/core-features/settings_03.png" "" %}}
|
||||
|
||||
|
||||
|
||||
## Advanced
|
||||
|
||||
In the Advanced Settings, you can configure more detailed options such as startup, connections, shortcuts, and version updates.
|
||||
|
||||
{{% load-img "/img/core-features/settings_04.png" "" %}}
|
||||
|
||||
|
||||
|
||||
## About
|
||||
|
||||
In the About Us section, you can view current version information, access help documentation, and submit feedback.
|
||||
|
||||
{{% load-img "/img/core-features/settings_05.png" "" %}}
|
||||
33
docs/content.en/docs/getting-started/The Basics.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
weight: 2
|
||||
title: The Basics
|
||||
---
|
||||
|
||||
# The Basics
|
||||
|
||||
Coco AI is always ready to help you quickly go from "wanting to ask" to "finding answers". This page will briefly introduce the core concepts and quick start process of Coco AI.
|
||||
|
||||
|
||||
|
||||
## Core Operations
|
||||
|
||||
- Use the global shortcut (default: `Shift + Meta + Space`) to open the Coco AI interface.
|
||||
- In the interface, use the toggle button or shortcut key (default: `Meta + T`) to switch between search and AI chat modes.
|
||||
- In search mode, enter keywords in the input box to search for local files, cloud data sources, applications, commands, etc., then press `Enter` to open them.
|
||||
- In chat mode, select different AI assistants and talk directly to them.
|
||||
- Enhance functions with extensions: such as multimedia control, screenshot, window management, etc.
|
||||
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
The following operations can help you quickly get familiar with Coco AI:
|
||||
|
||||
1. Press the shortcut key `Shift + Meta + Space` to open Coco AI.
|
||||
2. In search mode, enter a keyword (e.g., "project report") in the input box to see Coco AI search for related files.
|
||||
3. In search mode, enter a mathematical operation (e.g., `256*42`) in the input box to view the quick calculation result.
|
||||
4. In search mode, select a search result and press the hotkey `tab` to activate the data source or category filter.
|
||||
5. In search mode, search for and open the Extensions Store, then install an extension.
|
||||
6. In chat mode, enter a question (e.g., "What is Coco AI") in the input box to see the answer from the AI assistant.
|
||||
7. In chat mode, click the icon in the upper right corner of the window (shortcut key: `Meta + E`) to activate the independent window chat.
|
||||
8. In the settings (shortcut key: `Meta + ,`), go to the connection settings to connect to Coco Cloud or your self-deployed Coco Server, so as to access cloud data sources and AI assistants, allowing Coco AI to achieve one-stop search.
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
weight: 10
|
||||
weight: 1
|
||||
title: "Getting Started"
|
||||
bookCollapseSection: false
|
||||
---
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
weight: 10
|
||||
weight: 1
|
||||
title: "Installation"
|
||||
bookCollapseSection: true
|
||||
---
|
||||
|
||||
@@ -13,6 +13,12 @@ asciinema: true
|
||||
[x11_protocol]: https://en.wikipedia.org/wiki/X_Window_System
|
||||
[if_x11]: https://unix.stackexchange.com/q/202891/498440
|
||||
|
||||
## Install dependencies
|
||||
|
||||
```sh
|
||||
$ sudo apt-get update
|
||||
$ sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libtracker-sparql-3.0-dev
|
||||
```
|
||||
|
||||
## Go to the download page
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ title: "Release Notes"
|
||||
|
||||
# Release Notes
|
||||
|
||||
Information about release notes of Coco Server is provided here.
|
||||
Information about release notes of Coco App is provided here.
|
||||
|
||||
## Latest (In development)
|
||||
|
||||
@@ -13,53 +13,246 @@ Information about release notes of Coco Server is provided here.
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- feat: add open button to launch installed extension #1013
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
- fix: fix the abnormal input height issue #1006
|
||||
- fix: implement custom serialization for Extension.minimum_coco_version #1010
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
## 0.5.2 (2025-06-13)
|
||||
- refactor: replace legacy components with shadcn/ui components #1002
|
||||
- chore: show error msg (not err code) when installing exts via deeplink fails #1007
|
||||
- refactor: treat Applications and File Search as normal extensions #1012
|
||||
|
||||
## 0.9.1 (2025-12-05)
|
||||
|
||||
### ❌ 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
|
||||
- feat: add selection toolbar window for mac #980
|
||||
- feat: add a heartbeat worker to check Coco server availability #988
|
||||
- feat: selection settings add & delete #992
|
||||
|
||||
### 🐛 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
|
||||
- fix: search_extension should not panic when ext is not found #983
|
||||
- fix: persist configuration settings properly #987
|
||||
|
||||
### ✈️ 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
|
||||
- chore: write panic message to stdout in panic hook #989
|
||||
- refactor: error handling in install_extension interfaces #995
|
||||
- chore: adjust the position of the compact mode window #997
|
||||
|
||||
## 0.5.1 (2025-05-31)
|
||||
## 0.9.0 (2025-11-19)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- feat: support switching groups via keyboard shortcuts #911
|
||||
- feat: support opening logs from about page #915
|
||||
- feat: support moving cursor with home and end keys #918
|
||||
- feat: support pageup/pagedown to navigate search results #920
|
||||
- feat: standardize multi-level menu label structure #925
|
||||
- feat(View Extension): page field now accepts HTTP(s) links #925
|
||||
- feat: return sub-exts when extension type exts themselves are matched #928
|
||||
- feat: open quick ai with modifier key + enter #939
|
||||
- feat: allow navigate back when cursor is at the beginning #940
|
||||
- feat(extension compatibility): minimum_coco_version #946
|
||||
- feat: add compact mode for window #947
|
||||
- feat: advanced settings search debounce & local query source weight #950
|
||||
- feat: add window opacity configuration option #963
|
||||
- feat: add auto collapse delay for compact mode #981
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
- fix: automatic update of service list #913
|
||||
- fix: duplicate chat content #916
|
||||
- fix: resolve pinned window shortcut not working #917
|
||||
- fix: WM ext does not work when operating focused win from another display #919
|
||||
- fix(Window Management): Next/Previous Desktop do not work #926
|
||||
- fix: fix page rapidly flickering issue #935
|
||||
- fix(view extension): broken search bar UI when opening extensions via hotkey #938
|
||||
- fix: allow deletion after selecting all text #943
|
||||
- fix: prevent shaking when switching between chat and search pages #955
|
||||
- fix: prevent duplicate login success messages #977
|
||||
- fix: fix quick ai not continuing conversation #979
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
- refactor: improve sorting logic of search results #910
|
||||
- style: add dark drop shadow to images #912
|
||||
- chore: add cross-domain configuration for web component #921
|
||||
- refactor: retry if AXUIElementSetAttributeValue() does not work #924
|
||||
- refactor(calculator): skip evaluation if expr is in form "num => num" #929
|
||||
- chore: use a custom log directory #930
|
||||
- chore: bump tauri_nspanel to v2.1 #933
|
||||
- refactor: show_coco/hide_coco now use NSPanel's function on macOS #933
|
||||
- refactor: procedure that convert_pages() into a func #934
|
||||
- refactor(post-search): collect at least 2 documents from each query source #948
|
||||
- refactor: custom_version_comparator() now compares semantic versions #941
|
||||
- chore: center the main window vertically #959
|
||||
- refactor(view extension): load HTML/resources via local HTTP server #973
|
||||
|
||||
## 0.8.0 (2025-09-28)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
- chore: update request accesstoken api #866
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- feat: enhance ui for skipped version #834
|
||||
- feat: support installing local extensions #749
|
||||
- feat: support sending files in chat messages #764
|
||||
- feat: sub extension can set 'platforms' now #847
|
||||
- feat: add extension uninstall option in settings #855
|
||||
- feat: impl extension settings 'hide_before_open' #862
|
||||
- feat: index both en/zh_CN app names and show app name in chosen language #875
|
||||
- feat: support context menu in debug mode #882
|
||||
- feat: file search for Linux/GNOME #884
|
||||
- feat: file search for Linux/KDE #886
|
||||
- feat: extension Window Management for macOS #892
|
||||
- feat: new extension type View #894
|
||||
- feat: support opening file in its containing folder #900
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
- fix: fix issue with update check failure #833
|
||||
- fix: web component login state #857
|
||||
- fix: shortcut key not opening extension store #877
|
||||
- fix: set up hotkey on main thread or Windows will complain #879
|
||||
- fix: resolve deeplink login issue #881
|
||||
- fix: use kill_on_drop() to avoid zombie proc in error case #887
|
||||
- fix: settings window rendering/loading issue 889
|
||||
- fix: ensure search paths are indexed #896
|
||||
- fix: bump applications-rs to fix empty app name issue #898
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
- refactor: calling service related interfaces #831
|
||||
- refactor: split query_coco_fusion() #836
|
||||
- chore: web component loading font icon #838
|
||||
- chore: delete unused code files and dependencies #841
|
||||
- chore: ignore tauri::AppHandle's generic argument R #845
|
||||
- refactor: check Extension/plugin.json from all sources #846
|
||||
- refactor: pinning window won't set CanJoinAllSpaces on macOS #854
|
||||
- build: web component build error #858
|
||||
- refactor: coordinate third-party extension operations using lock #867
|
||||
- refactor: index iOS apps and macOS apps that store icon in Assets.car #872
|
||||
- refactor: accept both '-' and '\_' as locale str separator #876
|
||||
- refactor: relax the file search conditions on macOS #883
|
||||
- refactor: ensure Coco won't take focus #891
|
||||
- chore: skip login check for web widget #895
|
||||
- chore: convertFileSrc() "link[href]" and "img[src]" #901
|
||||
|
||||
## 0.7.1 (2025-07-27)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
- fix: correct enter key behavior #828
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
- chore: web component add notification component #825
|
||||
- refactor: collection behavior defaults to `MoveToActiveSpace`, and only use `CanJoinAllSpaces` when window is pinned #829
|
||||
|
||||
## 0.7.0 (2025-07-25)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- feat: file search using spotlight #705
|
||||
- feat: voice input support in both search and chat modes #732
|
||||
- feat: text to speech now powered by LLM #750
|
||||
- feat: file search for Windows #762
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
- fix(file search): apply filters before from/size parameters #741
|
||||
- fix(file search): searching by name&content does not search file name #743
|
||||
- fix: prevent window from hiding when moved on Windows #748
|
||||
- fix: unregister ext hotkey when it gets deleted #770
|
||||
- fix: indexing apps does not respect search scope config #773
|
||||
- fix: restore missing category titles on subpages #772
|
||||
- fix: correct incorrect assistant display when quick ai access #779
|
||||
- fix: resolved minor issues with voice playback #780
|
||||
- fix: fixed incorrect taskbar icon display on linux #783
|
||||
- fix: fix data inconsistency issue on secondary pages #784
|
||||
- fix: incorrect status when installing extension #789
|
||||
- fix: increase read_timeout for HTTP streaming stability #798
|
||||
- fix: enter key problem #794
|
||||
- fix: fix selection issue after renaming #800
|
||||
- fix: fix shortcut issue in windows context menu #804
|
||||
- fix: panic caused by "state() called before manage()" #806
|
||||
- fix: fix multiline input issue #808
|
||||
- fix: fix ctrl+k not working #815
|
||||
- fix: fix update window config sync #818
|
||||
- fix: fix enter key on subpages #819
|
||||
- fix: panic on Ubuntu (GNOME) when opening apps #821
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
- refactor: prioritize stat(2) when checking if a file is dir #737
|
||||
- refactor: change File Search ext type to extension #738
|
||||
- refactor: create chat & send chat api #739
|
||||
- chore: icon support for more file types #740
|
||||
- chore: replace meval-rs with our fork to clear dep warning #745
|
||||
- refactor: adjusted assistant, datasource, mcp_server interface parameters #746
|
||||
- refactor: adjust extension code hierarchy #747
|
||||
- chore: bump dep applications-rs #751
|
||||
- chore: rename QuickLink/quick_link to Quicklink/quicklink #752
|
||||
- chore: assistant params & styles #753
|
||||
- chore: make optional fields optional #758
|
||||
- chore: search-chat components add formatUrl & think data & icons url #765
|
||||
- chore: Coco app http request headers #744
|
||||
- refactor: do status code check before deserializing response #767
|
||||
- style: splash adapts to the width of mobile phones #768
|
||||
- chore: search-chat add language and formatUrl parameters #775
|
||||
- chore: not request the interface if not logged in #795
|
||||
- refactor: clean up unsupported characters from query string in Win Search #802
|
||||
- chore: display backtrace in panic log #805
|
||||
|
||||
## 0.6.0 (2025-06-29)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- feat: support `Tab` and `Enter` for delete dialog buttons #700
|
||||
- feat: add check for updates #701
|
||||
- feat: impl extension store #699
|
||||
- feat: support back navigation via delete key #717
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
- fix: quick ai state synchronous #693
|
||||
- fix: toggle extension should register/unregister hotkey #691
|
||||
- fix: take coco server back on refresh #696
|
||||
- fix: some input fields couldn’t accept spaces #709
|
||||
- fix: context menu search not working #713
|
||||
- fix: open extension store display #724
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
- refactor: use author/ext_id as extension unique identifier #643
|
||||
- refactor: refactoring search api #679
|
||||
- chore: continue to chat page display #690
|
||||
- chore: improve server list selection with enter key #692
|
||||
- chore: add message for latest version check #703
|
||||
- chore: log command execution results #718
|
||||
- chore: adjust styles and add button reindex #719
|
||||
|
||||
## 0.5.0 (2025-06-13)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
@@ -80,6 +273,13 @@ Information about release notes of Coco Server is provided here.
|
||||
- feat: dynamic log level via env var COCO_LOG #535
|
||||
- feat: add quick AI access to search mode #556
|
||||
- feat: rerank search results #561
|
||||
- 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
|
||||
|
||||
@@ -101,6 +301,23 @@ Information about release notes of Coco Server is provided here.
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -135,6 +352,12 @@ Information about release notes of Coco Server is provided here.
|
||||
- chore: mark unavailable server to offline on refresh info #569
|
||||
- chore: only show available servers in chat #570
|
||||
- refactor: search result related components #571
|
||||
- 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
|
||||
- refactor: use author/ext_id as extension unique identifier #643
|
||||
- chore: add special character filtering #668
|
||||
|
||||
## 0.4.0 (2025-04-27)
|
||||
|
||||
|
||||
BIN
docs/static/img/core-features/ai_chat_01.png
vendored
Normal file
|
After Width: | Height: | Size: 802 KiB |
BIN
docs/static/img/core-features/ai_chat_02.png
vendored
Normal file
|
After Width: | Height: | Size: 846 KiB |
BIN
docs/static/img/core-features/ai_chat_03.png
vendored
Normal file
|
After Width: | Height: | Size: 785 KiB |
BIN
docs/static/img/core-features/ai_chat_04.png
vendored
Normal file
|
After Width: | Height: | Size: 806 KiB |
BIN
docs/static/img/core-features/ai_overview_01.png
vendored
Normal file
|
After Width: | Height: | Size: 836 KiB |
BIN
docs/static/img/core-features/ai_overview_02.png
vendored
Normal file
|
After Width: | Height: | Size: 657 KiB |
BIN
docs/static/img/core-features/application_search_01.png
vendored
Normal file
|
After Width: | Height: | Size: 851 KiB |
BIN
docs/static/img/core-features/application_search_02.png
vendored
Normal file
|
After Width: | Height: | Size: 674 KiB |
BIN
docs/static/img/core-features/basics_02.png
vendored
Normal file
|
After Width: | Height: | Size: 777 KiB |
BIN
docs/static/img/core-features/calculator_01.png
vendored
Normal file
|
After Width: | Height: | Size: 845 KiB |
BIN
docs/static/img/core-features/calculator_02.png
vendored
Normal file
|
After Width: | Height: | Size: 854 KiB |
BIN
docs/static/img/core-features/extension_01.png
vendored
Normal file
|
After Width: | Height: | Size: 849 KiB |
BIN
docs/static/img/core-features/extension_02.png
vendored
Normal file
|
After Width: | Height: | Size: 840 KiB |
BIN
docs/static/img/core-features/extension_03.png
vendored
Normal file
|
After Width: | Height: | Size: 650 KiB |
BIN
docs/static/img/core-features/extension_04.png
vendored
Normal file
|
After Width: | Height: | Size: 694 KiB |
BIN
docs/static/img/core-features/filesearch_01.png
vendored
Normal file
|
After Width: | Height: | Size: 878 KiB |
BIN
docs/static/img/core-features/filesearch_02.png
vendored
Normal file
|
After Width: | Height: | Size: 671 KiB |
BIN
docs/static/img/core-features/quick_ai_access_01.png
vendored
Normal file
|
After Width: | Height: | Size: 896 KiB |
BIN
docs/static/img/core-features/quick_ai_access_02.png
vendored
Normal file
|
After Width: | Height: | Size: 826 KiB |
BIN
docs/static/img/core-features/quick_ai_access_03.png
vendored
Normal file
|
After Width: | Height: | Size: 654 KiB |
BIN
docs/static/img/core-features/search_01.png
vendored
Normal file
|
After Width: | Height: | Size: 849 KiB |
BIN
docs/static/img/core-features/search_02.png
vendored
Normal file
|
After Width: | Height: | Size: 871 KiB |
BIN
docs/static/img/core-features/search_03.png
vendored
Normal file
|
After Width: | Height: | Size: 876 KiB |
BIN
docs/static/img/core-features/settings_01.png
vendored
Normal file
|
After Width: | Height: | Size: 611 KiB |
BIN
docs/static/img/core-features/settings_02.png
vendored
Normal file
|
After Width: | Height: | Size: 637 KiB |
BIN
docs/static/img/core-features/settings_03.png
vendored
Normal file
|
After Width: | Height: | Size: 721 KiB |
BIN
docs/static/img/core-features/settings_04.png
vendored
Normal file
|
After Width: | Height: | Size: 631 KiB |
BIN
docs/static/img/core-features/settings_05.png
vendored
Normal file
|
After Width: | Height: | Size: 586 KiB |
BIN
docs/static/img/core-features/shortcuts_01.png
vendored
Normal file
|
After Width: | Height: | Size: 620 KiB |
BIN
docs/static/img/core-features/shortcuts_02.png
vendored
Normal file
|
After Width: | Height: | Size: 613 KiB |
BIN
docs/static/img/core-features/shortcuts_03.png
vendored
Normal file
|
After Width: | Height: | Size: 832 KiB |
BIN
docs/static/img/core-features/window_management_01.png
vendored
Normal file
|
After Width: | Height: | Size: 874 KiB |
BIN
docs/static/img/core-features/window_management_02.png
vendored
Normal file
|
After Width: | Height: | Size: 646 KiB |
42
package.json
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "coco",
|
||||
"private": true,
|
||||
"version": "0.5.0",
|
||||
"version": "0.9.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:web": "cross-env BUILD_TARGET=web tsc && cross-env BUILD_TARGET=web tsup --format esm",
|
||||
"publish:web": "cd out/search-chat && npm publish",
|
||||
"publish:web:beta": "cd dist/search-chat && npm publish --tag beta",
|
||||
"publish:web:alpha": "cd dist/search-chat && npm publish --tag alpha",
|
||||
"publish:web:rc": "cd dist/search-chat && npm publish --tag rc",
|
||||
"publish:web:beta": "cd out/search-chat && npm publish --tag beta",
|
||||
"publish:web:alpha": "cd out/search-chat && npm publish --tag alpha",
|
||||
"publish:web:rc": "cd out/search-chat && npm publish --tag rc",
|
||||
"publish:web:otp": "cd out/search-chat && npm publish --access public --otp $NPM_OTP",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"release": "release-it",
|
||||
@@ -18,37 +19,52 @@
|
||||
"release-beta": "release-it --preRelease=beta --preReleaseBase=1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@headlessui/react": "^2.2.2",
|
||||
"@infinilabs/custom-icons": "0.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.1.5",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.2.1",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-autostart": "~2.2.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.2.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||
"@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-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-os": "^2.2.1",
|
||||
"@tauri-apps/plugin-process": "^2.2.1",
|
||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "github:infinilabs/tauri-plugin-updater#v2",
|
||||
"@tauri-apps/plugin-websocket": "~2.3.0",
|
||||
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
||||
"@tauri-store/zustand": "^1.1.0",
|
||||
"@wavesurfer/react": "^1.0.11",
|
||||
"ahooks": "^3.8.4",
|
||||
"axios": "^1.9.0",
|
||||
"antd": "^6.1.1",
|
||||
"axios": "^1.12.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.5.0",
|
||||
"filesize": "^10.1.6",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.461.0",
|
||||
"lucide-react": "^0.561.0",
|
||||
"mdast-util-gfm-autolink-literal": "2.0.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hotkeys-hook": "^4.6.2",
|
||||
"react-i18next": "^15.5.1",
|
||||
@@ -60,6 +76,8 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tauri-plugin-fs-pro-api": "^2.4.0",
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0",
|
||||
"tauri-plugin-screenshots-api": "^2.2.0",
|
||||
@@ -71,6 +89,8 @@
|
||||
"zustand": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@types/dom-speech-recognition": "^0.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
@@ -87,7 +107,7 @@
|
||||
"postcss": "^8.5.3",
|
||||
"release-it": "^18.1.2",
|
||||
"sass": "^1.87.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3",
|
||||
|
||||
3561
pnpm-lock.yaml
generated
@@ -1,6 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
// Tailwind v4 PostCSS plugin has moved to @tailwindcss/postcss
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
39
scripts/buildWebAfter.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const extractCssVars = () => {
|
||||
const filePath = join(__dirname, "../out/search-chat/index.css");
|
||||
|
||||
const cssContent = readFileSync(filePath, "utf-8");
|
||||
|
||||
const vars: Record<string, string> = {};
|
||||
|
||||
const propertyBlockRegex = /@property\s+(--[\w-]+)\s*\{([\s\S]*?)\}/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = propertyBlockRegex.exec(cssContent))) {
|
||||
const [, varName, body] = match;
|
||||
|
||||
const initialValueMatch = /initial-value\s*:\s*([^;]+);/.exec(body);
|
||||
|
||||
if (initialValueMatch) {
|
||||
vars[varName] = initialValueMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
const cssVarsBlock =
|
||||
`.coco-container {\n` +
|
||||
Object.entries(vars)
|
||||
.map(([k, v]) => ` ${k}: ${v};`)
|
||||
.join("\n") +
|
||||
`\n}\n`;
|
||||
|
||||
writeFileSync(filePath, `${cssContent}\n${cssVarsBlock}`, "utf-8");
|
||||
};
|
||||
|
||||
extractCssVars();
|
||||
4378
src-tauri/Cargo.lock
generated
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "coco"
|
||||
version = "0.5.0"
|
||||
version = "0.9.1"
|
||||
description = "Search, connect, collaborate – all in one place."
|
||||
authors = ["INFINI Labs"]
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
[lib]
|
||||
@@ -15,6 +15,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = ["default"] }
|
||||
cfg-if = "1.0.1"
|
||||
|
||||
[features]
|
||||
default = ["desktop"]
|
||||
@@ -49,9 +50,8 @@ tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# Need `arbitrary_precision` feature to support storing u128
|
||||
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
|
||||
serde_json = { version = "1", features = ["arbitrary_precision"] }
|
||||
serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] }
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-websocket = "2"
|
||||
tauri-plugin-deep-link = "2.0.0"
|
||||
tauri-plugin-store = "2.2.0"
|
||||
tauri-plugin-os = "2"
|
||||
@@ -62,7 +62,7 @@ tauri-plugin-drag = "2"
|
||||
tauri-plugin-macos-permissions = "2"
|
||||
tauri-plugin-fs-pro = "2"
|
||||
tauri-plugin-screenshots = "2"
|
||||
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "7bb507e6b12f73c96f3a52f0578d0246a689f381" }
|
||||
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "b5fac4034a40d42e72f727f1aa1cc1f19fe86653" }
|
||||
tokio-native-tls = "0.3" # For wss connections
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
|
||||
@@ -83,12 +83,11 @@ walkdir = "2"
|
||||
log = "0.4"
|
||||
strsim = "0.10"
|
||||
futures-util = "0.3.31"
|
||||
url = "2.5.2"
|
||||
http = "1.1.0"
|
||||
tungstenite = "0.24.0"
|
||||
tokio-util = "0.7.14"
|
||||
tauri-plugin-windows-version = "2"
|
||||
meval = "0.2"
|
||||
meval = { git = "https://github.com/infinilabs/meval-rs" }
|
||||
chinese-number = "0.7"
|
||||
num2words = "1"
|
||||
tauri-plugin-log = "2"
|
||||
@@ -98,13 +97,56 @@ derive_more = { version = "2.0.1", features = ["display"] }
|
||||
anyhow = "1.0.98"
|
||||
function_name = "0.3.0"
|
||||
regex = "1.11.1"
|
||||
borrowme = "0.0.15"
|
||||
tauri-plugin-opener = "2"
|
||||
async-recursion = "1.1.1"
|
||||
zip = "4.0.0"
|
||||
url = "2.5.2"
|
||||
camino = "1.1.10"
|
||||
tokio-stream = { version = "0.1.17", features = ["io-util"] }
|
||||
sysinfo = "0.35.2"
|
||||
indexmap = { version = "2.10.0", features = ["serde"] }
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
sys-locale = "0.3.2"
|
||||
tauri-plugin-prevent-default = "1"
|
||||
oneshot = "0.1.11"
|
||||
bitflags = "2.9.3"
|
||||
cfg-if = "1.0.1"
|
||||
dunce = "1.0.5"
|
||||
urlencoding = "2.1.3"
|
||||
scraper = "0.17"
|
||||
toml = "0.8"
|
||||
path-clean = "1.0.1"
|
||||
actix-files = "0.6.8"
|
||||
actix-web = "4.11.0"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-zustand = "1"
|
||||
snafu = "0.8.9"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.23.0"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" }
|
||||
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
|
||||
objc2 = "0.6.2"
|
||||
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }
|
||||
objc2-application-services = { version = "0.3.1", features = ["HIServices"] }
|
||||
objc2-core-graphics = { version = "=0.3.1", features = ["CGEvent"] }
|
||||
# macOS-only: used by selection_monitor.rs to check AX trust/prompt
|
||||
macos-accessibility-client = "0.0.1"
|
||||
|
||||
[target."cfg(target_os = \"linux\")".dependencies]
|
||||
gio = "0.21.2"
|
||||
glib = "0.21.2"
|
||||
tracker-rs = "0.7"
|
||||
which = "8.0.0"
|
||||
configparser = "3.1.0"
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
||||
serde = { version = "1.0.219", features = ["derive"], optional = true }
|
||||
|
||||
|
||||
[profile.dev]
|
||||
incremental = true # Compile your binary in smaller steps.
|
||||
@@ -120,6 +162,13 @@ strip = true # Ensures debug symbols are removed.
|
||||
tauri-plugin-autostart = "^2.2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-updater = { git = "https://github.com/infinilabs/plugins-workspace", branch = "v2" }
|
||||
# This should be compatible with the semver used by `tauri-plugin-updater`
|
||||
semver = { version = "1", features = ["serde"] }
|
||||
|
||||
[target."cfg(target_os = \"windows\")".dependencies]
|
||||
enigo="0.3"
|
||||
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Search", "Win32_UI_Shell_PropertiesSystem", "Win32_Data"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32", "Win32_System", "Win32_System_Com"] }
|
||||
|
||||
[target."cfg(target_os = \"windows\")".build-dependencies]
|
||||
bindgen = "0.72.1"
|
||||
|
||||
@@ -38,5 +38,9 @@
|
||||
<string>Coco AI requires camera access for scanning documents and capturing images.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Coco AI uses speech recognition to convert your voice into text for a hands-free experience.</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Coco AI requires access to Apple Events to enable certain features, such as opening files and applications.</string>
|
||||
<key>NSAccessibility</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"id": "AIOverview",
|
||||
"title": "AI Overview",
|
||||
"description": "...",
|
||||
"icon": "font_a-AIOverview",
|
||||
"type": "ai_extension",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": "Applications",
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"title": "Applications",
|
||||
"description": "...",
|
||||
"icon": "font_Application",
|
||||
"type": "group",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": "Calculator",
|
||||
"title": "Calculator",
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"description": "...",
|
||||
"icon": "font_Calculator",
|
||||
"type": "calculator",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"id": "QuickAIAccess",
|
||||
"title": "Quick AI Access",
|
||||
"description": "...",
|
||||
"icon": "font_a-QuickAIAccess",
|
||||
"type": "ai_extension",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -1,3 +1,42 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
tauri_build::build();
|
||||
|
||||
// If env var `GITHUB_ACTIONS` exists, we are running in CI, set up the `ci`
|
||||
// attribute
|
||||
if std::env::var("GITHUB_ACTIONS").is_ok() {
|
||||
println!("cargo:rustc-cfg=ci");
|
||||
}
|
||||
|
||||
// Notify `rustc` of this `cfg` attribute to suppress unknown attribute warnings.
|
||||
//
|
||||
// unexpected condition name: `ci`
|
||||
println!("cargo::rustc-check-cfg=cfg(ci)");
|
||||
|
||||
// Bindgen searchapi.h on Windows as the windows create does not provide
|
||||
// bindings for it
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "windows")] {
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
let wrapper_header = r#"#include <windows.h>
|
||||
#include <searchapi.h>"#;
|
||||
|
||||
let searchapi_bindings = bindgen::Builder::default()
|
||||
.header_contents("wrapper.h", wrapper_header)
|
||||
.generate()
|
||||
.expect("failed to generate bindings for <searchapi.h>");
|
||||
|
||||
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||
searchapi_bindings
|
||||
.write_to_file(out_path.join("searchapi_bindings.rs"))
|
||||
.expect("couldn't write bindings to <OUT_DIR/searchapi_bindings.rs>")
|
||||
|
||||
// Looks like there is no need to link the library that contains the
|
||||
// implementation of functions declared in 'searchapi.h' manually as
|
||||
// the FFI bindings work (without doing that).
|
||||
//
|
||||
// This is wield, I do not expect the linker will link it automatically.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main", "chat", "settings"],
|
||||
"windows": ["main", "chat", "settings", "check", "selection"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:allow-emit",
|
||||
@@ -30,6 +30,7 @@
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:deny-internal-toggle-maximize",
|
||||
"core:window:allow-set-shadow",
|
||||
"core:window:allow-set-position",
|
||||
"core:app:allow-set-app-theme",
|
||||
"shell:default",
|
||||
"http:default",
|
||||
@@ -37,9 +38,6 @@
|
||||
"http:allow-fetch-cancel",
|
||||
"http:allow-fetch-read-body",
|
||||
"http:allow-fetch-send",
|
||||
"websocket:default",
|
||||
"websocket:allow-connect",
|
||||
"websocket:allow-send",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled",
|
||||
@@ -72,6 +70,7 @@
|
||||
"updater:default",
|
||||
"windows-version:default",
|
||||
"log:default",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"core:window:allow-unminimize"
|
||||
]
|
||||
}
|
||||
|
||||
5
src-tauri/capabilities/zustand.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"identifier": "zustand",
|
||||
"windows": ["*"],
|
||||
"permissions": ["zustand:default", "core:event:default"]
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly-2025-02-28"
|
||||
channel = "nightly-2025-06-26"
|
||||
@@ -1,205 +1,280 @@
|
||||
use crate::common::assistant::ChatRequestMessage;
|
||||
use crate::common::http::GetResponse;
|
||||
use crate::common::http::convert_query_params_to_strings;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::server::http_client::{DecodeResponseSnafu, HttpClient, HttpRequestError};
|
||||
use crate::{common, server::servers::COCO_SERVERS};
|
||||
use futures::stream::FuturesUnordered;
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use futures_util::TryStreamExt;
|
||||
use http::Method;
|
||||
use serde_json::Value;
|
||||
use snafu::ResultExt;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn chat_history<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn chat_history(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
from: u32,
|
||||
size: u32,
|
||||
query: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let mut query_params: HashMap<String, Value> = HashMap::new();
|
||||
if from > 0 {
|
||||
query_params.insert("from".to_string(), from.into());
|
||||
}
|
||||
if size > 0 {
|
||||
query_params.insert("size".to_string(), size.into());
|
||||
}
|
||||
) -> Result<String, HttpRequestError> {
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
// Add from/size as number values
|
||||
query_params.push(format!("from={}", from));
|
||||
query_params.push(format!("size={}", size));
|
||||
|
||||
if let Some(query) = query {
|
||||
if !query.is_empty() {
|
||||
query_params.insert("query".to_string(), query.into());
|
||||
query_params.push(format!("query={}", query.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
dbg!("Error get history: {}", &e);
|
||||
format!("Error get history: {}", e)
|
||||
})?;
|
||||
let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params)).await?;
|
||||
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn session_chat_history<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn session_chat_history(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
from: u32,
|
||||
size: u32,
|
||||
) -> Result<String, String> {
|
||||
let mut query_params: HashMap<String, Value> = HashMap::new();
|
||||
if from > 0 {
|
||||
query_params.insert("from".to_string(), from.into());
|
||||
}
|
||||
if size > 0 {
|
||||
query_params.insert("size".to_string(), size.into());
|
||||
}
|
||||
) -> Result<String, HttpRequestError> {
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
// Add from/size as number values
|
||||
query_params.push(format!("from={}", from));
|
||||
query_params.push(format!("size={}", size));
|
||||
|
||||
let path = format!("/chat/{}/_history", session_id);
|
||||
|
||||
let response = HttpClient::get(&server_id, path.as_str(), Some(query_params))
|
||||
.await
|
||||
.map_err(|e| format!("Error get session message: {}", e))?;
|
||||
let response = HttpClient::get(&server_id, path.as_str(), Some(query_params)).await?;
|
||||
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_session_chat<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn open_session_chat(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<String, String> {
|
||||
let query_params = HashMap::new();
|
||||
) -> Result<String, HttpRequestError> {
|
||||
let path = format!("/chat/{}/_open", session_id);
|
||||
|
||||
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
||||
.await
|
||||
.map_err(|e| format!("Error open session: {}", e))?;
|
||||
let response = HttpClient::post(&server_id, path.as_str(), None, None).await?;
|
||||
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn close_session_chat<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn close_session_chat(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<String, String> {
|
||||
let query_params = HashMap::new();
|
||||
) -> Result<String, HttpRequestError> {
|
||||
let path = format!("/chat/{}/_close", session_id);
|
||||
|
||||
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
||||
.await
|
||||
.map_err(|e| format!("Error close session: {}", e))?;
|
||||
let response = HttpClient::post(&server_id, path.as_str(), None, None).await?;
|
||||
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn cancel_session_chat<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn cancel_session_chat(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<String, String> {
|
||||
let query_params = HashMap::new();
|
||||
query_params: Option<HashMap<String, Value>>,
|
||||
) -> Result<String, HttpRequestError> {
|
||||
let path = format!("/chat/{}/_cancel", session_id);
|
||||
let query_params = convert_query_params_to_strings(query_params);
|
||||
|
||||
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
||||
.await
|
||||
.map_err(|e| format!("Error cancel session: {}", e))?;
|
||||
let response = HttpClient::post(&server_id, path.as_str(), query_params, None).await?;
|
||||
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn new_chat<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn chat_create(
|
||||
app_handle: AppHandle,
|
||||
server_id: String,
|
||||
websocket_id: String,
|
||||
message: String,
|
||||
message: Option<String>,
|
||||
attachments: Option<Vec<String>>,
|
||||
query_params: Option<HashMap<String, Value>>,
|
||||
) -> Result<GetResponse, String> {
|
||||
let body = if !message.is_empty() {
|
||||
let message = ChatRequestMessage {
|
||||
message: Some(message),
|
||||
client_id: String,
|
||||
) -> Result<(), String> {
|
||||
println!("chat_create message: {:?}", message);
|
||||
println!("chat_create attachments: {:?}", attachments);
|
||||
|
||||
let message_empty = message.as_ref().map_or(true, |m| m.is_empty());
|
||||
let attachments_empty = attachments.as_ref().map_or(true, |a| a.is_empty());
|
||||
|
||||
if message_empty && attachments_empty {
|
||||
return Err("Message and attachments are empty".to_string());
|
||||
}
|
||||
|
||||
let body = {
|
||||
let request_message: ChatRequestMessage = ChatRequestMessage {
|
||||
message,
|
||||
attachments,
|
||||
};
|
||||
|
||||
println!("chat_create body: {:?}", request_message);
|
||||
|
||||
Some(
|
||||
serde_json::to_string(&message)
|
||||
serde_json::to_string(&request_message)
|
||||
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
|
||||
let response = HttpClient::advanced_post(
|
||||
&server_id,
|
||||
"/chat/_create",
|
||||
None,
|
||||
convert_query_params_to_strings(query_params),
|
||||
body,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error sending message: {}", e))?;
|
||||
|
||||
let response =
|
||||
HttpClient::advanced_post(&server_id, "/chat/_new", Some(headers), query_params, body)
|
||||
.await
|
||||
.map_err(|e| format!("Error sending message: {}", e))?;
|
||||
|
||||
let body_text = common::http::get_response_body_text(response).await?;
|
||||
|
||||
log::debug!("New chat response: {}", &body_text);
|
||||
|
||||
let chat_response: GetResponse = serde_json::from_str(&body_text)
|
||||
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
||||
|
||||
if chat_response.result != "created" {
|
||||
return Err(format!("Unexpected result: {}", chat_response.result));
|
||||
if response.status() == 429 {
|
||||
log::warn!("Rate limit exceeded for chat create");
|
||||
return Err("Rate limited".to_string());
|
||||
}
|
||||
|
||||
Ok(chat_response)
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Request failed with status: {}", 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();
|
||||
|
||||
log::info!("client_id_create: {}", &client_id);
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
log::info!("Received chat stream line: {}", &line);
|
||||
|
||||
if let Err(err) = app_handle.emit(&client_id, line) {
|
||||
log::error!("Emit failed: {:?}", err);
|
||||
|
||||
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_message<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn chat_chat(
|
||||
app_handle: AppHandle,
|
||||
server_id: String,
|
||||
websocket_id: String,
|
||||
session_id: String,
|
||||
message: String,
|
||||
message: Option<String>,
|
||||
attachments: Option<Vec<String>>,
|
||||
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
||||
) -> Result<String, String> {
|
||||
let path = format!("/chat/{}/_send", session_id);
|
||||
let msg = ChatRequestMessage {
|
||||
message: Some(message),
|
||||
client_id: String,
|
||||
) -> Result<(), String> {
|
||||
println!("chat_chat message: {:?}", message);
|
||||
println!("chat_chat attachments: {:?}", attachments);
|
||||
|
||||
let message_empty = message.as_ref().map_or(true, |m| m.is_empty());
|
||||
let attachments_empty = attachments.as_ref().map_or(true, |a| a.is_empty());
|
||||
|
||||
if message_empty && attachments_empty {
|
||||
return Err("Message and attachments are empty".to_string());
|
||||
}
|
||||
|
||||
let body = {
|
||||
let request_message = ChatRequestMessage {
|
||||
message,
|
||||
attachments,
|
||||
};
|
||||
|
||||
println!("chat_chat body: {:?}", request_message);
|
||||
|
||||
Some(
|
||||
serde_json::to_string(&request_message)
|
||||
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
||||
.into(),
|
||||
)
|
||||
};
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
|
||||
let path = format!("/chat/{}/_chat", session_id);
|
||||
|
||||
let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap());
|
||||
let response = HttpClient::advanced_post(
|
||||
&server_id,
|
||||
path.as_str(),
|
||||
Some(headers),
|
||||
query_params,
|
||||
Some(body),
|
||||
None,
|
||||
convert_query_params_to_strings(query_params),
|
||||
body,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error cancel session: {}", e))?;
|
||||
.await
|
||||
.map_err(|e| format!("Error sending message: {}", e))?;
|
||||
|
||||
if response.status() == 429 {
|
||||
log::warn!("Rate limit exceeded for chat create");
|
||||
return Err("Rate limited".to_string());
|
||||
}
|
||||
|
||||
common::http::get_response_body_text(response).await
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Request failed with status: {}", 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();
|
||||
let mut first_log = true;
|
||||
|
||||
log::info!("client_id: {}", &client_id);
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
log::info!("Received chat stream line: {}", &line);
|
||||
if first_log {
|
||||
log::info!("first stream line: {}", &line);
|
||||
first_log = false;
|
||||
}
|
||||
|
||||
if let Err(err) = app_handle.emit(&client_id, line) {
|
||||
log::error!("Emit failed: {:?}", err);
|
||||
|
||||
print!("Error sending message: {:?}", err);
|
||||
|
||||
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_session_chat(server_id: String, session_id: String) -> Result<bool, String> {
|
||||
pub async fn delete_session_chat(
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<bool, HttpRequestError> {
|
||||
let response =
|
||||
HttpClient::delete(&server_id, &format!("/chat/{}", session_id), None, None).await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
Ok(true)
|
||||
} else {
|
||||
Err(format!("Delete failed with status: {}", response.status()))
|
||||
Err(HttpRequestError::RequestFailed {
|
||||
status: status.as_u16(),
|
||||
error_response_body_str: None,
|
||||
coco_server_api_error_response_body: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +284,7 @@ pub async fn update_session_chat(
|
||||
session_id: String,
|
||||
title: Option<String>,
|
||||
context: Option<HashMap<String, Value>>,
|
||||
) -> Result<bool, String> {
|
||||
) -> Result<bool, HttpRequestError> {
|
||||
let mut body = HashMap::new();
|
||||
if let Some(title) = title {
|
||||
body.insert("title".to_string(), Value::String(title));
|
||||
@@ -228,62 +303,36 @@ pub async fn update_session_chat(
|
||||
None,
|
||||
Some(reqwest::Body::from(serde_json::to_string(&body).unwrap())),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error updating session: {}", e))?;
|
||||
.await?;
|
||||
|
||||
Ok(response.status().is_success())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assistant_search<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn assistant_search(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
from: u32,
|
||||
size: u32,
|
||||
query: Option<HashMap<String, Value>>,
|
||||
) -> Result<Value, String> {
|
||||
let mut body = serde_json::json!({
|
||||
"from": from,
|
||||
"size": size,
|
||||
});
|
||||
query_params: Option<Vec<String>>,
|
||||
) -> Result<Value, HttpRequestError> {
|
||||
let response = HttpClient::post(&server_id, "/assistant/_search", query_params, None).await?;
|
||||
|
||||
if let Some(q) = query {
|
||||
body["query"] = serde_json::to_value(q).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let response = HttpClient::post(
|
||||
&server_id,
|
||||
"/assistant/_search",
|
||||
None,
|
||||
Some(reqwest::Body::from(body.to_string())),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error searching assistants: {}", e))?;
|
||||
|
||||
response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
response.json::<Value>().await.context(DecodeResponseSnafu)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assistant_get<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
pub async fn assistant_get(
|
||||
_app_handle: AppHandle,
|
||||
server_id: String,
|
||||
assistant_id: String,
|
||||
) -> Result<Value, String> {
|
||||
) -> Result<Value, HttpRequestError> {
|
||||
let response = HttpClient::get(
|
||||
&server_id,
|
||||
&format!("/assistant/{}", assistant_id),
|
||||
None, // headers
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error getting assistant: {}", e))?;
|
||||
.await?;
|
||||
|
||||
response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
response.json::<Value>().await.context(DecodeResponseSnafu)
|
||||
}
|
||||
|
||||
/// Gets the information of the assistant specified by `assistant_id` by querying **all**
|
||||
@@ -291,10 +340,10 @@ pub async fn assistant_get<R: Runtime>(
|
||||
///
|
||||
/// Returns as soon as the assistant is found on any Coco server.
|
||||
#[tauri::command]
|
||||
pub async fn assistant_get_multi<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
pub async fn assistant_get_multi(
|
||||
app_handle: AppHandle,
|
||||
assistant_id: String,
|
||||
) -> Result<Value, String> {
|
||||
) -> Result<Option<Value>, HttpRequestError> {
|
||||
let search_sources = app_handle.state::<SearchSourceRegistry>();
|
||||
let sources_future = search_sources.get_sources();
|
||||
let sources_list = sources_future.await;
|
||||
@@ -313,19 +362,17 @@ pub async fn assistant_get_multi<R: Runtime>(
|
||||
let path = format!("/assistant/{}", assistant_id);
|
||||
|
||||
let fut = async move {
|
||||
let res_response = HttpClient::get(
|
||||
let 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),
|
||||
}
|
||||
.await?;
|
||||
|
||||
response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.context(DecodeResponseSnafu)
|
||||
};
|
||||
|
||||
futures.push(fut);
|
||||
@@ -357,15 +404,12 @@ pub async fn assistant_get_multi<R: Runtime>(
|
||||
// ```
|
||||
if let Some(found) = response_json.get("found") {
|
||||
if found == true {
|
||||
return Ok(response_json);
|
||||
return Ok(Some(response_json));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"could not find Assistant [{}] on all the Coco servers",
|
||||
assistant_id
|
||||
))
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
use regex::Regex;
|
||||
@@ -380,17 +424,18 @@ pub fn remove_icon_fields(json: &str) -> String {
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
}).to_string()
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ask_ai<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
pub async fn ask_ai(
|
||||
app_handle: AppHandle,
|
||||
message: String,
|
||||
server_id: String,
|
||||
assistant_id: String,
|
||||
client_id: String,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<(), HttpRequestError> {
|
||||
let cleaned = remove_icon_fields(message.as_str());
|
||||
|
||||
let body = serde_json::json!({ "message": cleaned });
|
||||
@@ -407,15 +452,21 @@ pub async fn ask_ai<R: Runtime>(
|
||||
None,
|
||||
Some(reqwest::Body::from(body.to_string())),
|
||||
)
|
||||
.await?;
|
||||
.await?;
|
||||
|
||||
if response.status() == 429 {
|
||||
let status = response.status().as_u16();
|
||||
|
||||
if 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()));
|
||||
return Err(HttpRequestError::RequestFailed {
|
||||
status,
|
||||
error_response_body_str: None,
|
||||
coco_server_api_error_response_body: None,
|
||||
});
|
||||
}
|
||||
|
||||
let stream = response.bytes_stream();
|
||||
@@ -428,7 +479,7 @@ pub async fn ask_ai<R: Runtime>(
|
||||
dbg!("Received line: {}", &line);
|
||||
|
||||
let _ = app_handle.emit(&client_id, line).map_err(|err| {
|
||||
println!("Failed to emit: {:?}", err);
|
||||
log::error!("Failed to emit: {:?}", err);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use std::{fs::create_dir, io::Read};
|
||||
|
||||
use tauri::{Manager, Runtime};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
|
||||
/// 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();
|
||||
pub fn ensure_autostart_state_consistent(tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
let autostart_manager = tauri_app_handle.autolaunch();
|
||||
|
||||
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())?;
|
||||
let coco_stored_state = current_autostart(tauri_app_handle).map_err(|e| e.to_string())?;
|
||||
|
||||
if os_state != coco_stored_state {
|
||||
log::warn!(
|
||||
@@ -42,7 +42,7 @@ pub fn ensure_autostart_state_consistent(app: &mut tauri::App) -> Result<(), Str
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, String> {
|
||||
fn current_autostart(app: &tauri::AppHandle) -> Result<bool, String> {
|
||||
use std::fs::File;
|
||||
|
||||
let path = app.path().app_config_dir().unwrap();
|
||||
@@ -65,10 +65,7 @@ fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn change_autostart<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
open: bool,
|
||||
) -> Result<(), String> {
|
||||
pub async fn change_autostart(app: tauri::AppHandle, open: bool) -> Result<(), String> {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatRequestMessage {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub attachments: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -30,4 +33,4 @@ pub struct Session {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SessionContext {
|
||||
pub attachments: Option<Vec<String>>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
// use crate::common::health::Status;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RequestAccessTokenResponse {
|
||||
pub access_token: String,
|
||||
pub expire_in: u32,
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Connector {
|
||||
pub id: String,
|
||||
pub created: Option<String>,
|
||||
@@ -13,7 +13,7 @@ pub struct Connector {
|
||||
pub url: Option<String>,
|
||||
pub assets: Option<ConnectorAssets>,
|
||||
}
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectorAssets {
|
||||
pub icons: Option<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,4 @@ pub struct DataSource {
|
||||
pub struct ConnectorConfig {
|
||||
pub id: Option<String>,
|
||||
pub config: Option<serde_json::Value>, // Using serde_json::Value to handle any type of config
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::extension::built_in::window_management::actions::Action;
|
||||
use crate::extension::view_extension::serve_files_in;
|
||||
use crate::extension::{ExtensionPermission, ExtensionSettings, ViewExtensionUISettings};
|
||||
use log::debug;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RichLabel {
|
||||
@@ -29,17 +36,68 @@ pub struct EditorInfo {
|
||||
pub timestamp: Option<String>,
|
||||
}
|
||||
|
||||
/// Defines the action that would be performed when a document gets opened.
|
||||
/// Defines the action that would be performed when a [document](Document) gets opened.
|
||||
///
|
||||
/// "Document" is a uniform type that the backend uses to send the search results
|
||||
/// back to the frontend. Since Coco can search many sources, "Document" can
|
||||
/// represent different things, application, web page, local file, extensions, and
|
||||
/// so on. Each has its own specific open action.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) enum OnOpened {
|
||||
/// Launch the application
|
||||
Application { app_path: String },
|
||||
/// Open the URL.
|
||||
Document { url: String },
|
||||
/// Perform this WM action.
|
||||
#[cfg(target_os = "macos")]
|
||||
WindowManagementAction { action: Action },
|
||||
/// The document is an extension.
|
||||
Extension(ExtensionOnOpened),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct ExtensionOnOpened {
|
||||
/// Different types of extensions have different open behaviors.
|
||||
pub(crate) ty: ExtensionOnOpenedType,
|
||||
/// Extensions settings. Some could affect open action.
|
||||
///
|
||||
/// Optional because not all extensions have their settings.
|
||||
pub(crate) settings: Option<ExtensionSettings>,
|
||||
/// Permission needed by this extension.
|
||||
///
|
||||
/// We do permission check when opening this permission. Currently, we only
|
||||
/// do this to View extensions.
|
||||
pub(crate) permission: Option<ExtensionPermission>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) enum ExtensionOnOpenedType {
|
||||
/// Spawn a child process to run the `CommandAction`.
|
||||
Command {
|
||||
action: crate::extension::CommandAction,
|
||||
},
|
||||
/// Open the `link`.
|
||||
//
|
||||
// NOTE that this variant has the same definition as `struct Quicklink`, but we
|
||||
// cannot use it directly, its `link` field should be deserialized/serialized
|
||||
// from/to a string, but we need a JSON object here.
|
||||
//
|
||||
// See also the comments in `struct Quicklink`.
|
||||
Quicklink {
|
||||
link: crate::extension::QuicklinkLink,
|
||||
open_with: Option<String>,
|
||||
},
|
||||
View {
|
||||
/// Extension name
|
||||
name: String,
|
||||
// An absolute path to the extension icon or a font code.
|
||||
icon: String,
|
||||
/// Path to the HTML file that coco will load and render.
|
||||
///
|
||||
/// It should be an absolute path or Tauri cannot open it.
|
||||
page: String,
|
||||
ui: Option<ViewExtensionUISettings>,
|
||||
},
|
||||
}
|
||||
|
||||
impl OnOpened {
|
||||
@@ -47,46 +105,190 @@ impl OnOpened {
|
||||
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());
|
||||
#[cfg(target_os = "macos")]
|
||||
Self::WindowManagementAction { action: _ } => {
|
||||
// We don't have URL for this
|
||||
String::from("N/A")
|
||||
}
|
||||
Self::Extension(ext_on_opened) => {
|
||||
match &ext_on_opened.ty {
|
||||
ExtensionOnOpenedType::Command { action } => {
|
||||
const WHITESPACE: &str = " ";
|
||||
let mut ret = action.exec.clone();
|
||||
ret.push_str(WHITESPACE);
|
||||
if let Some(ref args) = action.args {
|
||||
ret.push_str(args.join(WHITESPACE).as_str());
|
||||
}
|
||||
|
||||
ret
|
||||
ret
|
||||
}
|
||||
// Currently, our URL is static and does not support dynamic parameters.
|
||||
// The URL of a quicklink is nearly useless without such dynamic user
|
||||
// inputs, so until we have dynamic URL support, we just use "N/A".
|
||||
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
|
||||
ExtensionOnOpenedType::View {
|
||||
name: _,
|
||||
icon: _,
|
||||
page: _,
|
||||
ui: _,
|
||||
} => {
|
||||
// We currently don't have URL for this kind of extension.
|
||||
String::from("N/A")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn open(on_opened: OnOpened) -> Result<(), String> {
|
||||
log::debug!("open({})", on_opened.url());
|
||||
|
||||
pub(crate) async fn open(
|
||||
tauri_app_handle: AppHandle,
|
||||
on_opened: OnOpened,
|
||||
extra_args: Option<HashMap<String, Json>>,
|
||||
) -> Result<(), String> {
|
||||
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?
|
||||
log::debug!("open application [{}]", app_path);
|
||||
|
||||
homemade_tauri_shell_open(tauri_app_handle.clone(), app_path).await?
|
||||
}
|
||||
OnOpened::Document { url } => {
|
||||
homemade_tauri_shell_open(global_tauri_app_handle.clone(), url).await?
|
||||
log::debug!("open document [{}]", url);
|
||||
|
||||
homemade_tauri_shell_open(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)
|
||||
));
|
||||
#[cfg(target_os = "macos")]
|
||||
OnOpened::WindowManagementAction { action } => {
|
||||
log::debug!("perform Window Management action [{:?}]", action);
|
||||
|
||||
crate::extension::built_in::window_management::perform_action_on_main_thread(
|
||||
&tauri_app_handle,
|
||||
action,
|
||||
)?;
|
||||
}
|
||||
OnOpened::Extension(ext_on_opened) => {
|
||||
// Apply the settings that would affect open behavior
|
||||
if let Some(settings) = ext_on_opened.settings {
|
||||
if let Some(should_hide) = settings.hide_before_open {
|
||||
if should_hide {
|
||||
crate::hide_coco(tauri_app_handle.clone()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let permission = ext_on_opened.permission;
|
||||
|
||||
match ext_on_opened.ty {
|
||||
ExtensionOnOpenedType::Command { action } => {
|
||||
log::debug!("open (execute) command [{:?}]", action);
|
||||
|
||||
let mut cmd = Command::new(action.exec);
|
||||
if let Some(args) = action.args {
|
||||
cmd.args(args);
|
||||
}
|
||||
let output = cmd.output().map_err(|e| e.to_string())?;
|
||||
// Sometimes, we wanna see the result in logs even though it doesn't fail.
|
||||
log::debug!(
|
||||
"executing open(Command) result, exit code: [{}], stdout: [{}], stderr: [{}]",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
if !output.status.success() {
|
||||
log::warn!(
|
||||
"executing open(Command) failed, exit code: [{}], stdout: [{}], stderr: [{}]",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
return Err(format!(
|
||||
"Command failed, stderr [{}]",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
}
|
||||
ExtensionOnOpenedType::Quicklink {
|
||||
link,
|
||||
open_with: opt_open_with,
|
||||
} => {
|
||||
let url = link.concatenate_url(&extra_args);
|
||||
|
||||
log::debug!("open quicklink [{}] with [{:?}]", url, opt_open_with);
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
// The `open_with` functionality is only supported on macOS, provided
|
||||
// by the `open -a` command.
|
||||
if #[cfg(target_os = "macos")] {
|
||||
let mut cmd = Command::new("open");
|
||||
if let Some(ref open_with) = opt_open_with {
|
||||
cmd.arg("-a");
|
||||
cmd.arg(open_with.as_str());
|
||||
}
|
||||
cmd.arg(&url);
|
||||
|
||||
let output = cmd.output().map_err(|e| format!("failed to spawn [open] due to error [{}]", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"failed to open with app {:?}: {}",
|
||||
opt_open_with,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
} else {
|
||||
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
||||
}
|
||||
}
|
||||
}
|
||||
ExtensionOnOpenedType::View {
|
||||
name,
|
||||
icon,
|
||||
page,
|
||||
ui,
|
||||
} => {
|
||||
let page_path = Utf8Path::new(&page);
|
||||
let directory = page_path.parent().unwrap_or_else(|| {
|
||||
panic!("View extension page path should have a parent, i.e., it should be under a directory, but [{}] does not", page);
|
||||
});
|
||||
let mut url = serve_files_in(directory.as_ref()).await;
|
||||
|
||||
/*
|
||||
* Emit an event to let the frontend code open this extension.
|
||||
*
|
||||
* Payload `view_extension_opened` contains the information needed
|
||||
* to do that.
|
||||
*
|
||||
* See "src/pages/main/index.tsx" for more info.
|
||||
*/
|
||||
use camino::Utf8Path;
|
||||
use serde_json::Value as Json;
|
||||
use serde_json::to_value;
|
||||
|
||||
let html_filename = page_path
|
||||
.file_name()
|
||||
.unwrap_or_else(|| {
|
||||
panic!("View extension page path should have a file name, but [{}] does not have one", page);
|
||||
}).to_string();
|
||||
url.push('/');
|
||||
url.push_str(&html_filename);
|
||||
|
||||
let html_file_url = url;
|
||||
debug!("View extension listening on: {}", html_file_url);
|
||||
let view_extension_opened: [Json; 5] = [
|
||||
Json::String(name),
|
||||
Json::String(icon),
|
||||
Json::String(html_file_url),
|
||||
to_value(permission).unwrap(),
|
||||
to_value(ui).unwrap(),
|
||||
];
|
||||
tauri_app_handle
|
||||
.emit("open_view_extension", view_extension_opened)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,162 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
use snafu::prelude::*;
|
||||
|
||||
use crate::server::http_client::HttpRequestError;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ApiErrorCause {
|
||||
/// Only the top-level error contains this.
|
||||
#[serde(default)]
|
||||
pub root_cause: Option<Vec<ApiErrorCause>>,
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ErrorCause {
|
||||
#[serde(default)]
|
||||
pub r#type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
|
||||
/// Recursion, [error A] cause by [error B] caused by [error C]
|
||||
#[serde(default)]
|
||||
pub caused_by: Option<Box<ApiErrorCause>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ErrorDetail {
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ApiError {
|
||||
#[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 {
|
||||
#[serde(default)]
|
||||
pub error: Option<ErrorDetail>,
|
||||
pub error: Option<ApiErrorCause>,
|
||||
#[serde(default)]
|
||||
pub status: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Serialize)]
|
||||
#[derive(Debug, Snafu, Serialize)]
|
||||
#[snafu(visibility(pub(crate)))]
|
||||
pub enum SearchError {
|
||||
#[error("HttpError: {0}")]
|
||||
HttpError(String),
|
||||
|
||||
#[error("ParseError: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Timeout occurred")]
|
||||
Timeout,
|
||||
|
||||
#[error("UnknownError: {0}")]
|
||||
#[allow(dead_code)]
|
||||
Unknown(String),
|
||||
|
||||
#[error("InternalError: {0}")]
|
||||
#[allow(dead_code)]
|
||||
InternalError(String),
|
||||
#[snafu(display("HTTP request error"))]
|
||||
HttpError { source: HttpRequestError },
|
||||
#[snafu(display("failed to decode query response"))]
|
||||
ResponseDecodeError {
|
||||
#[serde(serialize_with = "serialize_error")]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
/// The search operation timed out.
|
||||
#[snafu(display("search operation timed out"))]
|
||||
SearchTimeout,
|
||||
#[snafu(display("an internal error occurred: '{}'", error))]
|
||||
InternalError { error: String },
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for SearchError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
if err.is_timeout() {
|
||||
SearchError::Timeout
|
||||
} else if err.is_decode() {
|
||||
SearchError::ParseError(err.to_string())
|
||||
} else {
|
||||
SearchError::HttpError(err.to_string())
|
||||
pub(crate) fn serialize_error<S, E: std::error::Error>(
|
||||
error: &E,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&report_error(error, ReportErrorStyle::SingleLine))
|
||||
}
|
||||
|
||||
/// `ReportErrorStyle` controls the error reporting format.
|
||||
pub(crate) enum ReportErrorStyle {
|
||||
/// Report it in one line of message. This is suitable when you write dump
|
||||
/// errors to logs.
|
||||
///
|
||||
/// ```text
|
||||
/// 'failed to installed extension', caused by ['Json parsing error' 'I/O error: file not found']
|
||||
/// ```
|
||||
SingleLine,
|
||||
/// Allow it to span multiple lines.
|
||||
///
|
||||
/// ```text
|
||||
/// failed to installed extension
|
||||
/// Caused by:
|
||||
///
|
||||
/// 0: Json parsing error
|
||||
/// 1: I/O error: file not found
|
||||
/// ```
|
||||
MultipleLines,
|
||||
}
|
||||
|
||||
/// In Rust, a typical Display impl of an Error won't contain it source information[1],
|
||||
/// so we need a reporter to report the full error message.
|
||||
///
|
||||
/// [1]: https://stackoverflow.com/q/62869360/14092446
|
||||
pub(crate) fn report_error<E: std::error::Error>(e: &E, style: ReportErrorStyle) -> String {
|
||||
use std::fmt::Write;
|
||||
|
||||
match style {
|
||||
ReportErrorStyle::SingleLine => {
|
||||
let mut error_msg = format!("'{}'", e);
|
||||
if let Some(cause) = e.source() {
|
||||
error_msg.push_str(", caused by: [");
|
||||
|
||||
for (i, e) in std::iter::successors(Some(cause), |e| e.source()).enumerate() {
|
||||
if i != 0 {
|
||||
error_msg.push(' ');
|
||||
}
|
||||
write!(&mut error_msg, "'{}'", e).expect("failed to write in-memory string");
|
||||
}
|
||||
error_msg.push(']');
|
||||
}
|
||||
|
||||
error_msg
|
||||
}
|
||||
ReportErrorStyle::MultipleLines => snafu::Report::from_error(e).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io;
|
||||
|
||||
#[derive(Debug, Snafu)]
|
||||
enum Error {
|
||||
#[snafu(display("I/O Error"))]
|
||||
Io { source: io::Error },
|
||||
#[snafu(display("Foo"))]
|
||||
Foo,
|
||||
#[snafu(display("Nested"))]
|
||||
Nested { source: ReadError },
|
||||
}
|
||||
|
||||
#[derive(Debug, Snafu)]
|
||||
enum ReadError {
|
||||
#[snafu(display("failed to read config file"))]
|
||||
ReadConfig { source: io::Error },
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_error_single_line_one_caused_by() {
|
||||
let err = Error::Io {
|
||||
source: io::Error::new(io::ErrorKind::NotFound, "file Cargo.toml not found"),
|
||||
};
|
||||
|
||||
let error_msg = report_error(&err, ReportErrorStyle::SingleLine);
|
||||
assert_eq!(
|
||||
error_msg,
|
||||
"'I/O Error', caused by: ['file Cargo.toml not found']"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_error_single_line_multiple_caused_by() {
|
||||
let err = Error::Nested {
|
||||
source: ReadError::ReadConfig {
|
||||
source: io::Error::new(io::ErrorKind::NotFound, "not found"),
|
||||
},
|
||||
};
|
||||
|
||||
let error_msg = report_error(&err, ReportErrorStyle::SingleLine);
|
||||
assert_eq!(
|
||||
error_msg,
|
||||
"'Nested', caused by: ['failed to read config file' 'not found']"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_error_single_line_no_caused_by() {
|
||||
let err = Error::Foo;
|
||||
|
||||
let error_msg = report_error(&err, ReportErrorStyle::SingleLine);
|
||||
assert_eq!(error_msg, "'Foo'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use crate::common;
|
||||
use crate::{
|
||||
common,
|
||||
server::http_client::{DecodeResponseSnafu, HttpRequestError},
|
||||
};
|
||||
use reqwest::Response;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use snafu::ResultExt;
|
||||
use std::collections::HashMap;
|
||||
use tauri_plugin_store::JsonValue;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GetResponse {
|
||||
@@ -19,38 +25,54 @@ pub struct Source {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
pub async fn get_response_body_text(response: Response) -> Result<String, String> {
|
||||
pub async fn get_response_body_text(response: Response) -> Result<String, HttpRequestError> {
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {}, code: {}", e, status))?;
|
||||
.context(DecodeResponseSnafu)?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
log::debug!("Response status: {}, body: {}", status, &body);
|
||||
|
||||
if status < 200 || status >= 400 {
|
||||
// Try to parse the error body
|
||||
let fallback_error = "Failed to send message".to_string();
|
||||
|
||||
if body.trim().is_empty() {
|
||||
return Err(fallback_error);
|
||||
if body.is_empty() {
|
||||
return Err(HttpRequestError::RequestFailed {
|
||||
status,
|
||||
error_response_body_str: None,
|
||||
coco_server_api_error_response_body: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
match serde_json::from_str::<common::error::ErrorResponse>(&body) {
|
||||
Ok(parsed_error) => {
|
||||
dbg!(&parsed_error);
|
||||
Err(format!(
|
||||
"Server error ({}): {:?}",
|
||||
status, parsed_error.error
|
||||
))
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!("Failed to parse error response: {}", &body);
|
||||
Err(fallback_error)
|
||||
}
|
||||
}
|
||||
// Ignore this error, including a `serde_json::Error` in `HttpRequestError::RequestFailed`
|
||||
// would be too verbose. And it is still easy to debug without this error, since we have
|
||||
// the raw error response body.
|
||||
let api_error = serde_json::from_str::<common::error::ApiError>(&body).ok();
|
||||
Err(HttpRequestError::RequestFailed {
|
||||
status,
|
||||
error_response_body_str: Some(body),
|
||||
coco_server_api_error_response_body: api_error,
|
||||
})
|
||||
} else {
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn convert_query_params_to_strings(
|
||||
query_params: Option<HashMap<String, JsonValue>>,
|
||||
) -> Option<Vec<String>> {
|
||||
query_params.map(|map| {
|
||||
map.into_iter()
|
||||
.filter_map(|(k, v)| match v {
|
||||
JsonValue::String(s) => Some(format!("{}={}", k, s)),
|
||||
JsonValue::Number(n) => Some(format!("{}={}", k, n)),
|
||||
JsonValue::Bool(b) => Some(format!("{}={}", k, b)),
|
||||
_ => {
|
||||
eprintln!("Skipping unsupported query value for key '{}': {:?}", k, v);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
pub mod health;
|
||||
pub mod profile;
|
||||
pub mod server;
|
||||
pub mod auth;
|
||||
pub mod datasource;
|
||||
pub mod connector;
|
||||
pub mod search;
|
||||
pub mod document;
|
||||
pub mod traits;
|
||||
pub mod register;
|
||||
pub mod assistant;
|
||||
pub mod http;
|
||||
pub mod connector;
|
||||
pub mod datasource;
|
||||
pub mod document;
|
||||
pub mod error;
|
||||
pub mod health;
|
||||
pub mod http;
|
||||
pub mod profile;
|
||||
pub mod register;
|
||||
pub mod search;
|
||||
pub mod server;
|
||||
pub mod traits;
|
||||
|
||||
pub static MAIN_WINDOW_LABEL: &str = "main";
|
||||
pub static SETTINGS_WINDOW_LABEL: &str = "settings";
|
||||
pub static CHECK_WINDOW_LABEL: &str = "check";
|
||||
|
||||
@@ -13,4 +13,4 @@ pub struct UserProfile {
|
||||
pub email: String,
|
||||
pub avatar: Option<String>,
|
||||
pub preferences: Option<Preferences>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,11 @@ impl SearchSourceRegistry {
|
||||
sources.clear();
|
||||
}
|
||||
|
||||
pub async fn remove_source(&self, id: &str) {
|
||||
/// Remove the SearchSource specified by `id`, return a boolean indicating
|
||||
/// if it get removed or not.
|
||||
pub async fn remove_source(&self, id: &str) -> bool {
|
||||
let mut sources = self.sources.write().await;
|
||||
sources.remove(id);
|
||||
sources.remove(id).is_some()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -7,10 +7,11 @@ use std::error::Error;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SearchResponse<T> {
|
||||
pub took: u64,
|
||||
pub timed_out: bool,
|
||||
pub took: Option<u64>,
|
||||
pub timed_out: Option<bool>,
|
||||
pub _shards: Option<Shards>,
|
||||
pub hits: Hits<T>,
|
||||
pub aggregations: Option<Aggregations>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -83,20 +84,6 @@ where
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn parse_search_results_with_score<T>(
|
||||
response: Response,
|
||||
) -> Result<Vec<(T, Option<f64>)>, Box<dyn Error>>
|
||||
where
|
||||
T: for<'de> Deserialize<'de> + std::fmt::Debug,
|
||||
{
|
||||
Ok(parse_search_hits(response)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|hit| (hit._source, hit._score))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SearchQuery {
|
||||
pub from: u64,
|
||||
@@ -114,7 +101,7 @@ impl SearchQuery {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Hash, PartialEq, Eq)]
|
||||
pub struct QuerySource {
|
||||
pub r#type: String, //coco-server/local/ etc.
|
||||
pub id: String, //coco server's id
|
||||
@@ -136,11 +123,92 @@ pub struct FailedRequest {
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Aggregation {
|
||||
buckets: Vec<AggBucket>,
|
||||
}
|
||||
|
||||
/// A bucket's fields contain more than just "doc_count" and "key", but we only
|
||||
/// need them. Serde can deserialize this as we don't `deny_unknown_fields`.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct AggBucket {
|
||||
doc_count: usize,
|
||||
key: String,
|
||||
}
|
||||
|
||||
/// Coco server aggregation result.
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "type": {
|
||||
/// "buckets": [
|
||||
/// {
|
||||
/// "doc_count": 26,
|
||||
/// "key": "web_page"
|
||||
/// },
|
||||
/// {
|
||||
/// "doc_count": 1,
|
||||
/// "key": "pdf"
|
||||
/// }
|
||||
/// ]
|
||||
/// },
|
||||
/// "lang": {
|
||||
/// "buckets": [
|
||||
/// {
|
||||
/// "doc_count": 30,
|
||||
/// "key": "en"
|
||||
/// }
|
||||
/// ]
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub type Aggregations = HashMap<String, Aggregation>;
|
||||
|
||||
/// Merge the buckets in `from` to `to`.
|
||||
pub(crate) fn merge_aggregations(to: &mut Option<Aggregations>, from: Aggregations) {
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
if from.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match to {
|
||||
None => {
|
||||
*to = Some(from);
|
||||
}
|
||||
Some(to_map) => {
|
||||
for (agg_name, agg) in from {
|
||||
match to_map.entry(agg_name) {
|
||||
Entry::Occupied(mut occ) => {
|
||||
let to_agg = occ.get_mut();
|
||||
|
||||
for bucket in agg.buckets {
|
||||
if let Some(existing) = to_agg
|
||||
.buckets
|
||||
.iter_mut()
|
||||
.find(|existing| existing.key == bucket.key)
|
||||
{
|
||||
existing.doc_count += bucket.doc_count;
|
||||
} else {
|
||||
to_agg.buckets.push(bucket);
|
||||
}
|
||||
}
|
||||
}
|
||||
Entry::Vacant(vacant) => {
|
||||
vacant.insert(agg);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct QueryResponse {
|
||||
pub source: QuerySource,
|
||||
pub hits: Vec<(Document, f64)>,
|
||||
pub total_hits: usize,
|
||||
pub aggregations: Option<Aggregations>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -148,4 +216,76 @@ pub struct MultiSourceQueryResponse {
|
||||
pub failed: Vec<FailedRequest>,
|
||||
pub hits: Vec<QueryHits>,
|
||||
pub total_hits: usize,
|
||||
pub aggregations: Option<Aggregations>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Helper function to create an `AggBUcket`, used in tests.
|
||||
fn bucket(key: &str, doc_count: usize) -> AggBucket {
|
||||
AggBucket {
|
||||
key: key.to_string(),
|
||||
doc_count,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to create an `Aggregation`, used in tests.
|
||||
fn agg_with_buckets(buckets: Vec<AggBucket>) -> Aggregation {
|
||||
Aggregation { buckets }
|
||||
}
|
||||
|
||||
/// Helper function to get `doc_count` from the bucket specified by `key`.
|
||||
///
|
||||
/// Utility for assertion.
|
||||
fn get_doc_count(agg: &Aggregation, key: &str) -> usize {
|
||||
agg.buckets
|
||||
.iter()
|
||||
.find(|b| b.key == key)
|
||||
.map(|b| b.doc_count)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_into_none_initializes() {
|
||||
let mut to: Option<Aggregations> = None;
|
||||
let mut from = Aggregations::new();
|
||||
from.insert("terms".to_string(), agg_with_buckets(vec![bucket("a", 2)]));
|
||||
|
||||
merge_aggregations(&mut to, from);
|
||||
|
||||
let terms = to.unwrap().get("terms").cloned().unwrap();
|
||||
assert_eq!(get_doc_count(&terms, "a"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_sums_and_appends_buckets() {
|
||||
let mut to_inner = Aggregations::new();
|
||||
to_inner.insert(
|
||||
"terms".to_string(),
|
||||
agg_with_buckets(vec![bucket("a", 1), bucket("b", 2)]),
|
||||
);
|
||||
let mut to = Some(to_inner);
|
||||
|
||||
let mut from = Aggregations::new();
|
||||
from.insert(
|
||||
"terms".to_string(),
|
||||
agg_with_buckets(vec![bucket("a", 3), bucket("c", 5)]),
|
||||
);
|
||||
from.insert(
|
||||
"lang".to_string(),
|
||||
agg_with_buckets(vec![bucket("zh", 3), bucket("en", 5)]),
|
||||
);
|
||||
|
||||
merge_aggregations(&mut to, from);
|
||||
|
||||
let terms = to.as_ref().unwrap().get("terms").unwrap();
|
||||
assert_eq!(get_doc_count(terms, "a"), 4);
|
||||
assert_eq!(get_doc_count(terms, "b"), 2);
|
||||
assert_eq!(get_doc_count(terms, "c"), 5);
|
||||
let lang = to.as_ref().unwrap().get("lang").unwrap();
|
||||
assert_eq!(get_doc_count(lang, "zh"), 3);
|
||||
assert_eq!(get_doc_count(lang, "en"), 5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +50,17 @@ pub struct Server {
|
||||
pub updated: String,
|
||||
#[serde(default = "default_enabled_type")]
|
||||
pub enabled: bool,
|
||||
/// Public Coco servers can be used without signing in.
|
||||
#[serde(default = "default_bool_type")]
|
||||
pub public: bool,
|
||||
|
||||
/// A coco server is available if:
|
||||
///
|
||||
/// 1. It is still online, we check this via the `GET /base_url/provider/_info`
|
||||
/// interface.
|
||||
/// 2. A user is logged in to this Coco server, i.e., a token is stored in the
|
||||
/// `SERVER_TOKEN_LIST_CACHE`.
|
||||
/// For public Coco servers, requirement 2 is not needed.
|
||||
#[serde(default = "default_available_type")]
|
||||
pub available: bool,
|
||||
|
||||
@@ -84,7 +92,10 @@ pub struct ServerAccessToken {
|
||||
#[serde(default = "default_empty_string")] // Custom default function for empty string
|
||||
pub id: String,
|
||||
pub access_token: String,
|
||||
pub expired_at: u32, //unix timestamp in seconds
|
||||
/// Unix timestamp in seconds
|
||||
///
|
||||
/// Currently, this is UNUSED.
|
||||
pub expired_at: u32,
|
||||
}
|
||||
|
||||
impl ServerAccessToken {
|
||||
|
||||
@@ -2,10 +2,15 @@ use crate::common::error::SearchError;
|
||||
use crate::common::search::SearchQuery;
|
||||
use crate::common::search::{QueryResponse, QuerySource};
|
||||
use async_trait::async_trait;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[async_trait]
|
||||
pub trait SearchSource: Send + Sync {
|
||||
fn get_type(&self) -> QuerySource;
|
||||
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>;
|
||||
async fn search(
|
||||
&self,
|
||||
tauri_app_handle: AppHandle,
|
||||
query: SearchQuery,
|
||||
) -> Result<QueryResponse, SearchError>;
|
||||
}
|
||||
|
||||
5
src-tauri/src/extension/api/apis.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
# Complete Coco extension API list grouped by its category.
|
||||
|
||||
fs = [
|
||||
"read_dir"
|
||||
]
|
||||
22
src-tauri/src/extension/api/fs.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! File system APIs
|
||||
|
||||
use tokio::fs::read_dir as tokio_read_dir;
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_dir(path: String) -> Result<Vec<String>, String> {
|
||||
let mut iter = tokio_read_dir(path).await.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut file_names = Vec::new();
|
||||
|
||||
loop {
|
||||
let opt_entry = iter.next_entry().await.map_err(|e| e.to_string())?;
|
||||
let Some(entry) = opt_entry else {
|
||||
break;
|
||||
};
|
||||
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
file_names.push(file_name);
|
||||
}
|
||||
|
||||
Ok(file_names)
|
||||
}
|
||||
21
src-tauri/src/extension/api/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! The Rust implementation of the Coco extension APIs.
|
||||
//!
|
||||
//! Extension developers do not use these Rust APIs directly, they use our
|
||||
//! [Typescript library][ts_lib], which eventually calls these APIs.
|
||||
//!
|
||||
//! [ts_lib]: https://github.com/infinilabs/coco-api
|
||||
|
||||
pub(crate) mod fs;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Return all the available APIs grouped by their category.
|
||||
#[tauri::command]
|
||||
pub(crate) fn apis() -> HashMap<String, Vec<String>> {
|
||||
static APIS_TOML: &str = include_str!("./apis.toml");
|
||||
|
||||
let apis: HashMap<String, Vec<String>> =
|
||||
toml::from_str(APIS_TOML).expect("Failed to parse apis.toml file");
|
||||
|
||||
apis
|
||||
}
|
||||
@@ -1 +1,13 @@
|
||||
pub(super) const EXTENSION_ID: &str = "AIOverview";
|
||||
pub(super) const EXTENSION_ID: &str = "AIOverview";
|
||||
|
||||
/// JSON file for this extension.
|
||||
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
|
||||
{
|
||||
"id": "AIOverview",
|
||||
"name": "AI Overview",
|
||||
"description": "...",
|
||||
"icon": "font_a-AIOverview",
|
||||
"type": "ai_extension",
|
||||
"enabled": true
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -14,6 +14,8 @@ pub use without_feature::*;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(dead_code)]
|
||||
|
||||
pub struct AppEntry {
|
||||
path: String,
|
||||
name: String,
|
||||
@@ -33,3 +35,16 @@ pub struct AppMetadata {
|
||||
modified: u128,
|
||||
last_opened: u128,
|
||||
}
|
||||
|
||||
/// JSON file for this extension.
|
||||
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
|
||||
{
|
||||
"id": "Applications",
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"name": "Applications",
|
||||
"description": "Application search",
|
||||
"icon": "font_Application",
|
||||
"type": "group",
|
||||
"enabled": true
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -5,14 +5,14 @@ use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use async_trait::async_trait;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
|
||||
|
||||
pub struct ApplicationSearchSource;
|
||||
|
||||
impl ApplicationSearchSource {
|
||||
pub async fn init<R: Runtime>(_app_handle: AppHandle<R>) -> Result<(), String> {
|
||||
pub async fn prepare_index_and_store(_app_handle: AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -30,46 +30,43 @@ impl SearchSource for ApplicationSearchSource {
|
||||
}
|
||||
}
|
||||
|
||||
async fn search(&self, _query: SearchQuery) -> Result<QueryResponse, SearchError> {
|
||||
async fn search(
|
||||
&self,
|
||||
_tauri_app_handle: AppHandle,
|
||||
_query: SearchQuery,
|
||||
) -> Result<QueryResponse, SearchError> {
|
||||
Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_app_alias<R: Runtime>(_tauri_app_handle: &AppHandle<R>, _app_path: &str, _alias: &str) {
|
||||
pub fn set_app_alias(_tauri_app_handle: &AppHandle, _app_path: &str, _alias: &str) {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
pub fn register_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
pub fn register_app_hotkey(
|
||||
_tauri_app_handle: &AppHandle,
|
||||
_app_path: &str,
|
||||
_hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
pub fn unregister_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn unregister_app_hotkey(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
pub fn disable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn disable_app_search(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn enable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn enable_app_search(_tauri_app_handle: &AppHandle, _app_path: &str) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
@@ -79,8 +76,8 @@ pub fn is_app_search_enabled(_app_path: &str) -> bool {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_app_search_path<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
pub async fn add_app_search_path(
|
||||
_tauri_app_handle: AppHandle,
|
||||
_search_path: String,
|
||||
) -> Result<(), String> {
|
||||
// no-op
|
||||
@@ -88,8 +85,8 @@ pub async fn add_app_search_path<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_app_search_path<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
pub async fn remove_app_search_path(
|
||||
_tauri_app_handle: AppHandle,
|
||||
_search_path: String,
|
||||
) -> Result<(), String> {
|
||||
// no-op
|
||||
@@ -97,23 +94,37 @@ pub async fn remove_app_search_path<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_search_path<R: Runtime>(_tauri_app_handle: AppHandle<R>) -> Vec<String> {
|
||||
pub async fn get_app_search_path(_tauri_app_handle: AppHandle) -> Vec<String> {
|
||||
// Return an empty list
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_list<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
pub async fn get_app_list(_tauri_app_handle: AppHandle) -> Result<Vec<Extension>, String> {
|
||||
// Return an empty list
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_metadata<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
pub async fn get_app_metadata(
|
||||
_tauri_app_handle: AppHandle,
|
||||
_app_path: String,
|
||||
) -> Result<AppMetadata, String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
pub(crate) fn set_apps_hotkey(_tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn unset_apps_hotkey(_tauri_app_handle: &AppHandle) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reindex_applications(_tauri_app_handle: AppHandle) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,9 +10,23 @@ use chinese_number::{ChineseCase, ChineseCountMethod, ChineseVariant, NumberToCh
|
||||
use num2words::Num2Words;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub(crate) const DATA_SOURCE_ID: &str = "Calculator";
|
||||
|
||||
/// JSON file for this extension.
|
||||
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
|
||||
{
|
||||
"id": "Calculator",
|
||||
"name": "Calculator",
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"description": "...",
|
||||
"icon": "font_Calculator",
|
||||
"type": "calculator",
|
||||
"enabled": true
|
||||
}
|
||||
"#;
|
||||
|
||||
pub struct CalculatorSource {
|
||||
base_score: f64,
|
||||
}
|
||||
@@ -107,12 +121,18 @@ impl SearchSource for CalculatorSource {
|
||||
}
|
||||
}
|
||||
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
|
||||
async fn search(
|
||||
&self,
|
||||
_tauri_app_handle: AppHandle,
|
||||
query: SearchQuery,
|
||||
) -> Result<QueryResponse, SearchError> {
|
||||
let Some(query_string) = query.query_strings.get("query") else {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -120,11 +140,13 @@ impl SearchSource for CalculatorSource {
|
||||
// will only be evaluated against non-whitespace characters.
|
||||
let query_string = query_string.trim();
|
||||
|
||||
if query_string.is_empty() || query_string.len() == 1 {
|
||||
if query_string.is_empty() {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -132,6 +154,30 @@ impl SearchSource for CalculatorSource {
|
||||
let query_source = self.get_type();
|
||||
let base_score = self.base_score;
|
||||
let closure = move || -> QueryResponse {
|
||||
let Ok(tokens) = meval::tokenizer::tokenize(&query_string_clone) else {
|
||||
// Invalid expression, return nothing.
|
||||
return QueryResponse {
|
||||
source: query_source,
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
};
|
||||
};
|
||||
// If it is only a number, no need to evaluate it as the result is
|
||||
// this number.
|
||||
// Actually, there is no need to return the result back to the users
|
||||
// in such case because letting them know "x = x" is meaningless.
|
||||
if tokens.len() == 1 && matches!(tokens[0], meval::tokenizer::Token::Number(_)) {
|
||||
return QueryResponse {
|
||||
source: query_source,
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
};
|
||||
}
|
||||
|
||||
let res_num = meval::eval_str(&query_string_clone);
|
||||
|
||||
match res_num {
|
||||
@@ -161,15 +207,17 @@ impl SearchSource for CalculatorSource {
|
||||
source: query_source,
|
||||
hits: vec![(doc, base_score)],
|
||||
total_hits: 1,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
QueryResponse {
|
||||
source: query_source,
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
}
|
||||
}
|
||||
Err(_) => QueryResponse {
|
||||
source: query_source,
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
216
src-tauri/src/extension/built_in/file_search/config.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
//! File Search configuration entries definition and getter/setter functions.
|
||||
|
||||
use crate::extension::built_in::file_search::implementation::apply_config;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::LazyLock;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
// Tauri store keys for file system configuration
|
||||
const TAURI_STORE_FILE_SYSTEM_CONFIG: &str = "file_system_config";
|
||||
const TAURI_STORE_KEY_SEARCH_BY: &str = "search_by";
|
||||
const TAURI_STORE_KEY_SEARCH_PATHS: &str = "search_paths";
|
||||
const TAURI_STORE_KEY_EXCLUDE_PATHS: &str = "exclude_paths";
|
||||
const TAURI_STORE_KEY_FILE_TYPES: &str = "file_types";
|
||||
|
||||
static HOME_DIR: LazyLock<String> = LazyLock::new(|| {
|
||||
let os_string = dirs::home_dir()
|
||||
.expect("$HOME should be set")
|
||||
.into_os_string();
|
||||
os_string
|
||||
.into_string()
|
||||
.expect("User home directory should be encoded with UTF-8")
|
||||
});
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq)]
|
||||
pub enum SearchBy {
|
||||
Name,
|
||||
NameAndContents,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileSearchConfig {
|
||||
pub search_paths: Vec<String>,
|
||||
pub exclude_paths: Vec<String>,
|
||||
pub file_types: Vec<String>,
|
||||
pub search_by: SearchBy,
|
||||
}
|
||||
|
||||
impl Default for FileSearchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
search_paths: vec![
|
||||
format!("{}/Documents", HOME_DIR.as_str()),
|
||||
format!("{}/Desktop", HOME_DIR.as_str()),
|
||||
format!("{}/Downloads", HOME_DIR.as_str()),
|
||||
],
|
||||
exclude_paths: Vec::new(),
|
||||
file_types: Vec::new(),
|
||||
search_by: SearchBy::Name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileSearchConfig {
|
||||
pub(crate) fn get(tauri_app_handle: &AppHandle) -> Self {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"store [{}] not found/loaded, error [{}]",
|
||||
TAURI_STORE_FILE_SYSTEM_CONFIG, e
|
||||
)
|
||||
});
|
||||
|
||||
// Default value, will be used when specific config entries are not set
|
||||
let default_config = FileSearchConfig::default();
|
||||
|
||||
let search_paths = {
|
||||
if let Some(search_paths) = store.get(TAURI_STORE_KEY_SEARCH_PATHS) {
|
||||
match search_paths {
|
||||
Value::Array(arr) => {
|
||||
let mut vec = Vec::with_capacity(arr.len());
|
||||
for v in arr {
|
||||
match v {
|
||||
Value::String(s) => vec.push(s),
|
||||
other => panic!(
|
||||
"Expected all elements of 'search_paths' to be strings, but found: {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
vec
|
||||
}
|
||||
other => panic!(
|
||||
"Expected 'search_paths' to be an array of strings in the file system config store, but got: {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
} else {
|
||||
store.set(
|
||||
TAURI_STORE_KEY_SEARCH_PATHS,
|
||||
default_config.search_paths.as_slice(),
|
||||
);
|
||||
default_config.search_paths
|
||||
}
|
||||
};
|
||||
|
||||
let exclude_paths = {
|
||||
if let Some(exclude_paths) = store.get(TAURI_STORE_KEY_EXCLUDE_PATHS) {
|
||||
match exclude_paths {
|
||||
Value::Array(arr) => {
|
||||
let mut vec = Vec::with_capacity(arr.len());
|
||||
for v in arr {
|
||||
match v {
|
||||
Value::String(s) => vec.push(s),
|
||||
other => panic!(
|
||||
"Expected all elements of 'exclude_paths' to be strings, but found: {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
vec
|
||||
}
|
||||
other => panic!(
|
||||
"Expected 'exclude_paths' to be an array of strings in the file system config store, but got: {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
} else {
|
||||
store.set(
|
||||
TAURI_STORE_KEY_EXCLUDE_PATHS,
|
||||
default_config.exclude_paths.as_slice(),
|
||||
);
|
||||
default_config.exclude_paths
|
||||
}
|
||||
};
|
||||
|
||||
let file_types = {
|
||||
if let Some(file_types) = store.get(TAURI_STORE_KEY_FILE_TYPES) {
|
||||
match file_types {
|
||||
Value::Array(arr) => {
|
||||
let mut vec = Vec::with_capacity(arr.len());
|
||||
for v in arr {
|
||||
match v {
|
||||
Value::String(s) => vec.push(s),
|
||||
other => panic!(
|
||||
"Expected all elements of 'file_types' to be strings, but found: {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
vec
|
||||
}
|
||||
other => panic!(
|
||||
"Expected 'file_types' to be an array of strings in the file system config store, but got: {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
} else {
|
||||
store.set(
|
||||
TAURI_STORE_KEY_FILE_TYPES,
|
||||
default_config.file_types.as_slice(),
|
||||
);
|
||||
default_config.file_types
|
||||
}
|
||||
};
|
||||
|
||||
let search_by = {
|
||||
if let Some(search_by) = store.get(TAURI_STORE_KEY_SEARCH_BY) {
|
||||
serde_json::from_value(search_by.clone()).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Failed to deserialize 'search_by' from file system config store. Invalid JSON: {:?}, error: {}",
|
||||
search_by, e
|
||||
)
|
||||
})
|
||||
} else {
|
||||
store.set(
|
||||
TAURI_STORE_KEY_SEARCH_BY,
|
||||
serde_json::to_value(default_config.search_by).unwrap(),
|
||||
);
|
||||
default_config.search_by
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
search_by,
|
||||
search_paths,
|
||||
exclude_paths,
|
||||
file_types,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri commands for managing file system configuration
|
||||
#[tauri::command]
|
||||
pub async fn get_file_system_config(tauri_app_handle: AppHandle) -> FileSearchConfig {
|
||||
FileSearchConfig::get(&tauri_app_handle)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_file_system_config(
|
||||
tauri_app_handle: AppHandle,
|
||||
config: FileSearchConfig,
|
||||
) -> Result<(), String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_FILE_SYSTEM_CONFIG)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
store.set(TAURI_STORE_KEY_SEARCH_PATHS, config.search_paths.as_slice());
|
||||
store.set(
|
||||
TAURI_STORE_KEY_EXCLUDE_PATHS,
|
||||
config.exclude_paths.as_slice(),
|
||||
);
|
||||
store.set(TAURI_STORE_KEY_FILE_TYPES, config.file_types.as_slice());
|
||||
store.set(
|
||||
TAURI_STORE_KEY_SEARCH_BY,
|
||||
serde_json::to_value(config.search_by).unwrap(),
|
||||
);
|
||||
|
||||
// Apply the config when we know that this set operation won't fail
|
||||
apply_config(&config)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
//! File system powered by GNOME's Tracker engine.
|
||||
|
||||
use super::super::super::EXTENSION_ID;
|
||||
use super::super::super::config::FileSearchConfig;
|
||||
use super::super::should_be_filtered_out;
|
||||
use crate::common::document::DataSourceReference;
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::util::file::sync_get_file_icon;
|
||||
use crate::{
|
||||
common::document::{Document, OnOpened},
|
||||
extension::built_in::file_search::config::SearchBy,
|
||||
};
|
||||
use camino::Utf8Path;
|
||||
use gio::Cancellable;
|
||||
use gio::Settings;
|
||||
use gio::prelude::SettingsExtManual;
|
||||
use glib::GString;
|
||||
use glib::collections::strv::StrV;
|
||||
use tracker::{SparqlConnection, SparqlCursor, prelude::SparqlCursorExtManual};
|
||||
|
||||
/// The service that we will connect to.
|
||||
const SERVICE_NAME: &str = "org.freedesktop.Tracker3.Miner.Files";
|
||||
|
||||
/// Tracker won't return scores when we are not using full-text seach. In that
|
||||
/// case, we use this score.
|
||||
const SCORE: f64 = 1.0;
|
||||
|
||||
/// Helper function to return different SPARQL queries depending on the different configurations.
|
||||
fn query_sparql(query_string: &str, config: &FileSearchConfig) -> String {
|
||||
match config.search_by {
|
||||
SearchBy::Name => {
|
||||
// Cannot use the inverted index as that searches for all the attributes,
|
||||
// but we only want to search the filename.
|
||||
format!(
|
||||
"SELECT nie:url(?file_item) WHERE {{ ?file_item nfo:fileName ?fileName . FILTER(regex(?fileName, '{query_string}', 'i')) }}"
|
||||
)
|
||||
}
|
||||
SearchBy::NameAndContents => {
|
||||
// Full-text search against all attributes
|
||||
// OR
|
||||
// filename search
|
||||
format!(
|
||||
"SELECT nie:url(?file_item) fts:rank(?file_item) WHERE {{ {{ ?file_item fts:match '{query_string}' }} UNION {{ ?file_item nfo:fileName ?fileName . FILTER(regex(?fileName, '{query_string}', 'i')) }} }} ORDER BY DESC fts:rank(?file_item)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to replace unsupported characters with whitespace.
|
||||
///
|
||||
/// Tracker will error out if it encounters these characters.
|
||||
///
|
||||
/// The complete list of unsupported characters is unknown and we don't know how
|
||||
/// to escape them, so let's replace them.
|
||||
fn query_string_cleanup(old: &str) -> String {
|
||||
const UNSUPPORTED_CHAR: [char; 3] = ['\'', '\n', '\\'];
|
||||
|
||||
// Using len in bytes is ok
|
||||
let mut chars = Vec::with_capacity(old.len());
|
||||
for char in old.chars() {
|
||||
if UNSUPPORTED_CHAR.contains(&char) {
|
||||
chars.push(' ');
|
||||
} else {
|
||||
chars.push(char);
|
||||
}
|
||||
}
|
||||
|
||||
chars.into_iter().collect()
|
||||
}
|
||||
|
||||
struct Query {
|
||||
conn: SparqlConnection,
|
||||
cursor: SparqlCursor,
|
||||
}
|
||||
|
||||
impl Query {
|
||||
fn new(query_string: &str, config: &FileSearchConfig) -> Result<Self, String> {
|
||||
let query_string = query_string_cleanup(query_string);
|
||||
let sparql = query_sparql(&query_string, config);
|
||||
let conn =
|
||||
SparqlConnection::bus_new(SERVICE_NAME, None, None).map_err(|e| e.to_string())?;
|
||||
let cursor = conn
|
||||
.query(&sparql, Cancellable::NONE)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Self { conn, cursor })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Query {
|
||||
fn drop(&mut self) {
|
||||
self.cursor.close();
|
||||
self.conn.close();
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Query {
|
||||
/// It yields a tuple `(file path, score)`
|
||||
type Item = Result<(String, f64), String>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
loop {
|
||||
let has_next = match self
|
||||
.cursor
|
||||
.next(Cancellable::NONE)
|
||||
.map_err(|e| e.to_string())
|
||||
{
|
||||
Ok(has_next) => has_next,
|
||||
Err(err_str) => return Some(Err(err_str)),
|
||||
};
|
||||
|
||||
if !has_next {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The first column is the URL
|
||||
let file_url_column = self.cursor.string(0);
|
||||
// It could be None (or NULL ptr if you use C), I have no clue why.
|
||||
let opt_str = file_url_column.as_ref().map(|gstr| gstr.as_str());
|
||||
|
||||
match opt_str {
|
||||
Some(url) => {
|
||||
// The returned URL has a prefix that we need to trim
|
||||
const PREFIX: &str = "file://";
|
||||
const PREFIX_LEN: usize = PREFIX.len();
|
||||
|
||||
let file_path = url[PREFIX_LEN..].to_string();
|
||||
assert!(!file_path.is_empty());
|
||||
assert_ne!(file_path, "/", "file search should not hit the root path");
|
||||
|
||||
let score = {
|
||||
// The second column is the score, this column may not
|
||||
// exist. We use SCORE if the real value is absent.
|
||||
let score_column = self.cursor.string(1);
|
||||
let opt_score_str = score_column.as_ref().map(|g_str| g_str.as_str());
|
||||
let opt_score = opt_score_str.map(|str| {
|
||||
str.parse::<f64>()
|
||||
.expect("score should be valid for type f64")
|
||||
});
|
||||
|
||||
opt_score.unwrap_or(SCORE)
|
||||
};
|
||||
|
||||
return Some(Ok((file_path, score)));
|
||||
}
|
||||
None => {
|
||||
// another try
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn hits(
|
||||
query_string: &str,
|
||||
from: usize,
|
||||
size: usize,
|
||||
config: &FileSearchConfig,
|
||||
) -> Result<Vec<(Document, f64)>, String> {
|
||||
// Special cases that will make querying faster.
|
||||
if query_string.is_empty() || size == 0 || config.search_paths.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut result_hits = Vec::with_capacity(size);
|
||||
|
||||
let need_to_skip = {
|
||||
if matches!(config.search_by, SearchBy::Name) {
|
||||
// We don't use full-text search in this case, the returned documents
|
||||
// won't be scored, the query hits won't be sorted, so processing the
|
||||
// from parameter is meaningless.
|
||||
false
|
||||
} else {
|
||||
from > 0
|
||||
}
|
||||
};
|
||||
let mut num_skipped = 0;
|
||||
let should_skip = from;
|
||||
|
||||
let query = Query::new(query_string, config)?;
|
||||
for res_entry in query {
|
||||
let (file_path, score) = res_entry?;
|
||||
|
||||
// This should be called before processing the `from` parameter.
|
||||
if should_be_filtered_out(config, &file_path, true, true, true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process the `from` parameter.
|
||||
if need_to_skip && num_skipped < should_skip {
|
||||
// Skip this
|
||||
num_skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let icon = sync_get_file_icon(&file_path);
|
||||
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||
let r#where = file_path_of_type_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expect path [{}] to have a parent, but it does not",
|
||||
file_path
|
||||
);
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let file_name = file_path_of_type_path.file_name().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expect path [{}] to have a file name, but it does not",
|
||||
file_path
|
||||
);
|
||||
});
|
||||
let on_opened = OnOpened::Document {
|
||||
url: file_path.to_string(),
|
||||
};
|
||||
|
||||
let doc = Document {
|
||||
id: file_path.to_string(),
|
||||
title: Some(file_name.to_string()),
|
||||
source: Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(EXTENSION_ID.into()),
|
||||
id: Some(EXTENSION_ID.into()),
|
||||
icon: Some(String::from("font_Filesearch")),
|
||||
}),
|
||||
category: Some(r#where),
|
||||
on_opened: Some(on_opened),
|
||||
url: Some(file_path),
|
||||
icon: Some(icon.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
result_hits.push((doc, score));
|
||||
|
||||
// Collected enough documents, return
|
||||
if result_hits.len() >= size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result_hits)
|
||||
}
|
||||
|
||||
fn ensure_path_in_recursive_indexing_scope(list: &mut StrV, path: &str) {
|
||||
for item in list.iter() {
|
||||
let item_path = Utf8Path::new(item.as_str());
|
||||
let path = Utf8Path::new(path);
|
||||
|
||||
// It is already covered or listed
|
||||
if path.starts_with(item_path) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
list.push(
|
||||
GString::from_utf8_checked(path.as_bytes().to_vec())
|
||||
.expect("search_path_str contains an interior NUL"),
|
||||
);
|
||||
}
|
||||
|
||||
fn ensure_path_and_descendants_not_in_single_indexing_scope(list: &mut StrV, path: &str) {
|
||||
// Indexes to the items that should be removed
|
||||
let mut item_to_remove = Vec::new();
|
||||
for (idx, item) in list.iter().enumerate() {
|
||||
let item_path = Utf8Path::new(item.as_str());
|
||||
let path = Utf8Path::new(path);
|
||||
|
||||
if item_path.starts_with(path) {
|
||||
item_to_remove.push(idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse the indexes so that the remove operation won't invalidate them.
|
||||
for idx in item_to_remove.into_iter().rev() {
|
||||
list.remove(idx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_config(config: &FileSearchConfig) -> Result<(), String> {
|
||||
// Tracker provides the following configuration entries to allow users to
|
||||
// tweak the indexing scope:
|
||||
//
|
||||
// 1. ignored-directories: A list of names, directories with such names will be ignored.
|
||||
// ['po', 'CVS', 'core-dumps', 'lost+found']
|
||||
// 2. ignored-directories-with-content: Avoid any directory containing a file blocklisted here
|
||||
// ['.trackerignore', '.git', '.hg', '.nomedia']
|
||||
// 3. ignored-files: List of file patterns to avoid
|
||||
// ['*~', '*.o', '*.la', '*.lo', '*.loT', '*.in', '*.m4', '*.rej', ...]
|
||||
// 4. index-recursive-directories: List of directories to index recursively
|
||||
// ['&DESKTOP', '&DOCUMENTS', '&MUSIC', '&PICTURES', '&VIDEOS']
|
||||
// 5. index-single-directories: List of directories to index without inspecting subfolders,
|
||||
// ['$HOME', '&DOWNLOAD']
|
||||
//
|
||||
// The first 3 entries specify patterns, in order to use them, we have to walk
|
||||
// through the whole directory tree listed in search paths, which is impractical.
|
||||
// So we only use the last 2 entries.
|
||||
//
|
||||
//
|
||||
// Just want to mention that setting search path to "/home" could break Tracker:
|
||||
//
|
||||
// ```text
|
||||
// Unknown target graph for uri:'file:///home' and mime:'inode/directory'
|
||||
// ```
|
||||
//
|
||||
// See the related bug reports:
|
||||
//
|
||||
// https://gitlab.gnome.org/GNOME/localsearch/-/issues/313
|
||||
// https://bugs.launchpad.net/bugs/2077181
|
||||
//
|
||||
//
|
||||
// There is nothing we can do.
|
||||
|
||||
const TRACKER_SETTINGS_SCHEMA: &str = "org.freedesktop.Tracker3.Miner.Files";
|
||||
const KEY_INDEX_RECURSIVE_DIRECTORIES: &str = "index-recursive-directories";
|
||||
const KEY_INDEX_SINGLE_DIRECTORIES: &str = "index-single-directories";
|
||||
|
||||
let search_paths = &config.search_paths;
|
||||
|
||||
let settings = Settings::new(TRACKER_SETTINGS_SCHEMA);
|
||||
let mut recursive_list: StrV = settings.strv(KEY_INDEX_RECURSIVE_DIRECTORIES);
|
||||
let mut single_list: StrV = settings.strv(KEY_INDEX_SINGLE_DIRECTORIES);
|
||||
|
||||
for search_path in search_paths {
|
||||
// We want our search path to be included in the recursive directories or
|
||||
// any directory within the list covers it.
|
||||
ensure_path_in_recursive_indexing_scope(&mut recursive_list, search_path);
|
||||
// We want our search path and its any descendants are not listed in
|
||||
// the index directories list.
|
||||
ensure_path_and_descendants_not_in_single_indexing_scope(&mut single_list, search_path);
|
||||
}
|
||||
|
||||
settings
|
||||
.set_strv(KEY_INDEX_RECURSIVE_DIRECTORIES, recursive_list)
|
||||
.expect("key is not read-only");
|
||||
settings
|
||||
.set_strv(KEY_INDEX_SINGLE_DIRECTORIES, single_list)
|
||||
.expect("key is not be read-only");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_basic() {
|
||||
assert_eq!(query_string_cleanup("test"), "test");
|
||||
assert_eq!(query_string_cleanup("hello world"), "hello world");
|
||||
assert_eq!(query_string_cleanup("file.txt"), "file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_unsupported_chars() {
|
||||
assert_eq!(query_string_cleanup("test'file"), "test file");
|
||||
assert_eq!(query_string_cleanup("test\nfile"), "test file");
|
||||
assert_eq!(query_string_cleanup("test\\file"), "test file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_multiple_unsupported() {
|
||||
assert_eq!(query_string_cleanup("test'file\nname"), "test file name");
|
||||
assert_eq!(query_string_cleanup("test\'file"), "test file");
|
||||
assert_eq!(query_string_cleanup("\n'test"), " test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_edge_cases() {
|
||||
assert_eq!(query_string_cleanup(""), "");
|
||||
assert_eq!(query_string_cleanup("'"), " ");
|
||||
assert_eq!(query_string_cleanup("\n"), " ");
|
||||
assert_eq!(query_string_cleanup("\\"), " ");
|
||||
assert_eq!(query_string_cleanup(" '\n\\ "), " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_mixed_content() {
|
||||
assert_eq!(
|
||||
query_string_cleanup("document's content\nwith\\backslash"),
|
||||
"document s content with backslash"
|
||||
);
|
||||
assert_eq!(
|
||||
query_string_cleanup("path/to'file\nextension\\test"),
|
||||
"path/to file extension test"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! File search for KDE, powered by its Baloo engine.
|
||||
|
||||
use super::super::super::EXTENSION_ID;
|
||||
use super::super::super::config::FileSearchConfig;
|
||||
use super::super::super::config::SearchBy;
|
||||
use super::super::should_be_filtered_out;
|
||||
use crate::common::document::{DataSourceReference, Document};
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::extension::OnOpened;
|
||||
use crate::util::file::sync_get_file_icon;
|
||||
use camino::Utf8Path;
|
||||
use configparser::ini::Ini;
|
||||
use configparser::ini::WriteOptions;
|
||||
use futures::stream::Stream;
|
||||
use futures::stream::StreamExt;
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::process::Child;
|
||||
use tokio::process::Command;
|
||||
use tokio_stream::wrappers::LinesStream;
|
||||
|
||||
/// Baloo does not support scoring, use this score for all the documents.
|
||||
const SCORE: f64 = 1.0;
|
||||
|
||||
/// KDE6 updates the binary name to "baloosearch6", but I believe there still have
|
||||
/// distros using the original name. So we need to check both.
|
||||
fn cli_tool_lookup() -> Option<PathBuf> {
|
||||
use which::which;
|
||||
|
||||
let res_path = which("baloosearch").or_else(|_| which("baloosearch6"));
|
||||
res_path.ok()
|
||||
}
|
||||
|
||||
pub(crate) async fn hits(
|
||||
query_string: &str,
|
||||
_from: usize,
|
||||
size: usize,
|
||||
config: &FileSearchConfig,
|
||||
) -> Result<Vec<(Document, f64)>, String> {
|
||||
// Special cases that will make querying faster.
|
||||
if query_string.is_empty() || size == 0 || config.search_paths.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// If the tool is not found, return an empty result as well.
|
||||
let Some(tool_path) = cli_tool_lookup() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let (mut iter, _baloosearch_child_process) =
|
||||
execute_baloosearch_query(tool_path, query_string, size, config)?;
|
||||
|
||||
// Convert results to documents
|
||||
let mut hits: Vec<(Document, f64)> = Vec::new();
|
||||
while let Some(res_file_path) = iter.next().await {
|
||||
let file_path = res_file_path.map_err(|io_err| io_err.to_string())?;
|
||||
|
||||
let icon = sync_get_file_icon(&file_path);
|
||||
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||
let r#where = file_path_of_type_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expect path [{}] to have a parent, but it does not",
|
||||
file_path
|
||||
);
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let file_name = file_path_of_type_path.file_name().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expect path [{}] to have a file name, but it does not",
|
||||
file_path
|
||||
);
|
||||
});
|
||||
let on_opened = OnOpened::Document {
|
||||
url: file_path.clone(),
|
||||
};
|
||||
|
||||
let doc = Document {
|
||||
id: file_path.clone(),
|
||||
title: Some(file_name.to_string()),
|
||||
source: Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(EXTENSION_ID.into()),
|
||||
id: Some(EXTENSION_ID.into()),
|
||||
icon: Some(String::from("font_Filesearch")),
|
||||
}),
|
||||
category: Some(r#where),
|
||||
on_opened: Some(on_opened),
|
||||
url: Some(file_path),
|
||||
icon: Some(icon.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
hits.push((doc, SCORE));
|
||||
}
|
||||
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
/// Return an array containing the `baloosearch` command and its arguments.
|
||||
fn build_baloosearch_query(
|
||||
tool_path: PathBuf,
|
||||
query_string: &str,
|
||||
config: &FileSearchConfig,
|
||||
) -> Vec<String> {
|
||||
let tool_path = tool_path
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("binary path should be UTF-8 encoded");
|
||||
|
||||
let mut args = vec![tool_path];
|
||||
|
||||
match config.search_by {
|
||||
SearchBy::Name => {
|
||||
args.push(format!("filename:{query_string}"));
|
||||
}
|
||||
SearchBy::NameAndContents => {
|
||||
args.push(query_string.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
for search_path in config.search_paths.iter() {
|
||||
args.extend_from_slice(&["-d".into(), search_path.clone()]);
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
/// Spawn the `baloosearch` child process and return an async iterator over its output,
|
||||
/// allowing us to collect the results asynchronously.
|
||||
///
|
||||
/// # Return value:
|
||||
///
|
||||
/// * impl Stream: an async iterator that will yield the matched files
|
||||
/// * Child: The handle to the baloosearch process. The child process will be
|
||||
/// killed when this handle gets dropped so we need to keep it alive util we
|
||||
/// exhaust the stream.
|
||||
fn execute_baloosearch_query(
|
||||
tool_path: PathBuf,
|
||||
query_string: &str,
|
||||
size: usize,
|
||||
config: &FileSearchConfig,
|
||||
) -> Result<(impl Stream<Item = std::io::Result<String>>, Child), String> {
|
||||
let args = build_baloosearch_query(tool_path, query_string, config);
|
||||
|
||||
let (rx, tx) = std::io::pipe().unwrap();
|
||||
let rx_owned = OwnedFd::from(rx);
|
||||
let async_rx = tokio::net::unix::pipe::Receiver::from_owned_fd(rx_owned).unwrap();
|
||||
let buffered_rx = BufReader::new(async_rx);
|
||||
let lines = LinesStream::new(buffered_rx.lines());
|
||||
|
||||
let child = Command::new(&args[0])
|
||||
.args(&args[1..])
|
||||
.stdout(tx)
|
||||
.stderr(std::process::Stdio::null())
|
||||
// The child process will be killed when the Child instance gets dropped.
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn baloosearch: {e}"))?;
|
||||
let config_clone = config.clone();
|
||||
let iter = lines
|
||||
.filter(move |res_path| {
|
||||
std::future::ready({
|
||||
match res_path {
|
||||
Ok(path) => !should_be_filtered_out(&config_clone, path, false, true, true),
|
||||
Err(_) => {
|
||||
// Don't filter out Err() values
|
||||
true
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.take(size);
|
||||
|
||||
Ok((iter, child))
|
||||
}
|
||||
|
||||
pub(crate) fn apply_config(config: &FileSearchConfig) -> Result<(), String> {
|
||||
// Users can tweak Baloo via its configuration file, below are the fields that
|
||||
// we need to modify:
|
||||
//
|
||||
// * Indexing-Enabled: turn indexing on or off
|
||||
// * only basic indexing: If true, Baloo only indexes file names
|
||||
// * folders: directories to index
|
||||
// * exclude folders: directories to skip
|
||||
//
|
||||
// ```ini
|
||||
// [Basic Settings]
|
||||
// Indexing-Enabled=true
|
||||
//
|
||||
// [General]
|
||||
// only basic indexing=true
|
||||
// folders[$e]=$HOME/
|
||||
// exclude folders[$e]=$HOME/FolderA/,$HOME/FolderB/
|
||||
// ```
|
||||
|
||||
const SECTION_GENERAL: &str = "General";
|
||||
const KEY_INCLUDE_FOLDERS: &str = "folders[$e]";
|
||||
const KEY_EXCLUDE_FOLDERS: &str = "exclude folders[$e]";
|
||||
const FOLDERS_SEPARATOR: &str = ",";
|
||||
|
||||
let rc_file_path = {
|
||||
let mut home = dirs::home_dir()
|
||||
.expect("cannot find the home directory, Coco should never run in such a environment");
|
||||
home.push(".config/baloofilerc");
|
||||
home
|
||||
};
|
||||
|
||||
// Parse and load the rc file, it is in format INI
|
||||
//
|
||||
// Use `new_cs()`, the case-sensitive version of constructor as the config
|
||||
// file contains uppercase letters, so it is case-sensitive.
|
||||
let mut baloo_config = Ini::new_cs();
|
||||
if rc_file_path.try_exists().map_err(|e| e.to_string())? {
|
||||
let _ = baloo_config.load(rc_file_path.as_path())?;
|
||||
}
|
||||
|
||||
// Ensure indexing is enabled
|
||||
let _ = baloo_config.setstr("Basic Settings", "Indexing-Enabled", Some("true"));
|
||||
|
||||
// Let baloo index file content if we need that
|
||||
if config.search_by == SearchBy::NameAndContents {
|
||||
let _ = baloo_config.setstr(SECTION_GENERAL, "only basic indexing", Some("false"));
|
||||
}
|
||||
|
||||
let mut include_folders = {
|
||||
match baloo_config.get(SECTION_GENERAL, KEY_INCLUDE_FOLDERS) {
|
||||
Some(str) => str
|
||||
.split(FOLDERS_SEPARATOR)
|
||||
.map(|str| str.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
let mut exclude_folders = {
|
||||
match baloo_config.get(SECTION_GENERAL, KEY_EXCLUDE_FOLDERS) {
|
||||
Some(str) => str
|
||||
.split(FOLDERS_SEPARATOR)
|
||||
.map(|str| str.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
fn ensure_path_included_include_folders(
|
||||
include_folders: &mut Vec<String>,
|
||||
search_path: &Utf8Path,
|
||||
) {
|
||||
for include_folder in include_folders.iter() {
|
||||
let include_folder = Utf8Path::new(include_folder.as_str());
|
||||
if search_path.starts_with(include_folder) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
include_folders.push(search_path.as_str().to_string());
|
||||
}
|
||||
|
||||
fn ensure_path_and_descendants_not_excluded(
|
||||
exclude_folders: &mut Vec<String>,
|
||||
search_path: &Utf8Path,
|
||||
) {
|
||||
let mut items_to_remove = Vec::new();
|
||||
for (idx, exclude_folder) in exclude_folders.iter().enumerate() {
|
||||
let exclude_folder = Utf8Path::new(exclude_folder);
|
||||
|
||||
if exclude_folder.starts_with(search_path) {
|
||||
items_to_remove.push(idx);
|
||||
}
|
||||
}
|
||||
|
||||
for idx in items_to_remove.into_iter().rev() {
|
||||
exclude_folders.remove(idx);
|
||||
}
|
||||
}
|
||||
|
||||
for search_path in config.search_paths.iter() {
|
||||
let search_path = Utf8Path::new(search_path.as_str());
|
||||
|
||||
ensure_path_included_include_folders(&mut include_folders, search_path);
|
||||
ensure_path_and_descendants_not_excluded(&mut exclude_folders, search_path);
|
||||
}
|
||||
|
||||
let include_folders_str: String = include_folders.as_slice().join(FOLDERS_SEPARATOR);
|
||||
let exclude_folders_str: String = exclude_folders.as_slice().join(FOLDERS_SEPARATOR);
|
||||
|
||||
let _ = baloo_config.set(
|
||||
SECTION_GENERAL,
|
||||
KEY_INCLUDE_FOLDERS,
|
||||
Some(include_folders_str),
|
||||
);
|
||||
let _ = baloo_config.set(
|
||||
SECTION_GENERAL,
|
||||
KEY_EXCLUDE_FOLDERS,
|
||||
Some(exclude_folders_str),
|
||||
);
|
||||
|
||||
baloo_config
|
||||
.pretty_write(rc_file_path.as_path(), &WriteOptions::new())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||