117 Commits

Author SHA1 Message Date
Hardy
f81bec8403 chore: rollback publish (#589)
* chore: rollback publish

* chore: set toolchain

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-05-31 16:29:08 +08:00
medcl
cce956ac15 v0.5.2 2025-05-31 16:06:12 +08:00
Hardy
0d1174c8dd chore: fix ci publish error (#588)
* chore: fix ci publish error

* docs: update release notes

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-05-31 16:05:28 +08:00
ayangweb
e0258dc2fa fix: fixed issue with quick ai access making multiple requests at once (#586) 2025-05-31 15:56:35 +08:00
medcl
310a70838b v0.5.1 2025-05-31 15:55:33 +08:00
Hardy
94d7f809d2 chore: add ssh private key for pizza engine (#587)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-05-31 15:51:20 +08:00
medcl
e1d1bc2684 v0.5.0 2025-05-31 15:01:02 +08:00
Medcl
a9e3bb3eee chore: ignore throttle message (#585) 2025-05-31 11:07:01 +08:00
Medcl
d184851e3b chore: remove icon field before ask ai (#584) 2025-05-31 10:03:19 +08:00
BiggerRain
c9b785ccf3 fix: sent chat once more (#583) 2025-05-31 08:53:37 +08:00
Medcl
4c5ae8c718 chore: update error handling (#582)
* chore: update error handling

* chore: update min osx version
2025-05-31 08:50:27 +08:00
Hardy
8a7f7bc708 chore: add pizza feature for release (#581)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-05-30 22:28:44 +08:00
ayangweb
3d44d10048 refactor: remove unused disabledExtensions related code (#580) 2025-05-30 19:41:51 +08:00
BiggerRain
97d880ea27 fix: useScript error (#579) 2025-05-30 19:41:29 +08:00
Medcl
6c53056edd chore: update default coco server (#578) 2025-05-30 19:27:41 +08:00
ayangweb
a6fd2ebd16 fix: fix web carriage return not jumping (#577) 2025-05-30 18:41:58 +08:00
SteveLauC
b509176572 fix: make extension search source respect parameter datasource (#576) 2025-05-30 18:39:09 +08:00
ayangweb
17f2bcf7a8 fix: fix the problem that web cannot click on the jump (#575) 2025-05-30 18:22:18 +08:00
ayangweb
c471a83821 feat: support third party extensions (#572)
* refactor: support third party extensions

* fix tests

* fix: assistant_get error

* aaa

* bbb

* ccc

* ddd

* fix: aa

* fix: aa

* sss

* fix:asds

* eee

* refactor: loosen restriction of query string length

* fix: input auto

* feat: add ai overview trigger condition configuration

* refactor: continue chatting to select the corresponding mini-helper

* chore: settings width height

* aaa

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
Co-authored-by: rain <15911122312@163.com>
2025-05-30 17:18:52 +08:00
SteveLauC
51b0a2a545 refactor: remove thread app list synchronizer as it leaks memory on macOS (#573) 2025-05-29 17:55:24 +08:00
BiggerRain
baded2af1e refactor: search result related components (#571)
* refactor: search result related components

* refactor: search result related components

* docs: update notes

* refactor: search result related components

* fix: ArrowLeft error

* chore: remove log

* fix: ask ai
2025-05-29 16:01:52 +08:00
BiggerRain
2b21426355 refactor: input box related components (#568)
* refactor: input box components

* chore: change variable name

* docs: update notes

* fix: shortcut key failure issue
2025-05-28 12:29:28 +08:00
BiggerRain
8edc938426 chore: only show available servers in chat (#570)
* chore: add server available

* docs: update notes

* docs: update notes
2025-05-28 10:51:25 +08:00
Medcl
fa919bee11 chore: mark unavailable server to offline on refresh info (#569)
* chore: mark server offline on refresh info

* chore: update release notes
2025-05-28 10:43:53 +08:00
Medcl
50f1e611c3 refactor: refactoring rerank feature (#567)
* refactor: refactoring rerank feature

* chore: remove unused code

* chore: pull back unrelated changes
2025-05-27 18:27:53 +08:00
BiggerRain
4c3cf28012 chore: assistant chat placeholder & refactor input box components (#566)
* chore: input placeholder

* chore: add assitant

* impl assistant_get_multi()

* chore: add assitant

* refactor: input box components

* chore: ask ai search placeholder

* chore: ask ai search placeholder

* docs: update notes

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-05-27 16:29:43 +08:00
BiggerRain
89fcc67222 fix: assistant list (#563)
* fix: assistant list

* fix: assistant list

* fix: assistant list

* fix: assistant list
2025-05-27 09:24:58 +08:00
Medcl
33c9ce67df chore: remove pizza deps (#565) 2025-05-27 09:09:17 +08:00
SteveLauC
c6dadfd83e ci: deny dep pizza-engine (#564)
* ci: deny dep pizza-engine

* ci: set PWD to cargo workspace
2025-05-27 08:59:46 +08:00
Medcl
e707a8b5c7 chore: rerank support ignore case (#562)
* chore: rerank support ignore case

* chore: remove unused deps
2025-05-26 19:24:01 +08:00
BiggerRain
5c5364974a chore: web component start page config (#560)
* chore: web component start page config

* chore: web component start page config

* docs: update notes
2025-05-26 18:54:33 +08:00
Medcl
9d3e3e8dde feat: rerank search results (#561)
* feat: rerank search results

* chore: update release notes
2025-05-26 18:54:06 +08:00
BiggerRain
e065ba749f chore: assistant keyboard events and mouse events (#559)
* chore: assistant keyboard events and mouse events

* docs: update notes
2025-05-26 15:44:05 +08:00
ayangweb
2dd8e3160c fix: resolved navigation error on continue chat action (#558)
* fix: resolved navigation error on continue chat action

* docs: update changelog
2025-05-26 10:56:29 +08:00
ayangweb
6aeecfe3ac feat: add quick AI access to search mode (#556)
* feat: add quick AI access to search mode

* feat: add aI assistant quick access

* refactor: adjusting lodash-es import location to optimize code structure

* docs: update changelog

* fix: fix the logic of assigning serverId in AskAi component

* refactor: optimized layout

* refactor: optimized some issues
2025-05-23 18:14:41 +08:00
SteveLauC
334e29d69b chore: add make cmd dev-build-with-pizza (#555) 2025-05-23 16:43:38 +08:00
BiggerRain
382f89ace0 fix: independent chat app has no datasources (#554)
* fix: independent chat window has no data

* docs: update notes
2025-05-23 16:42:35 +08:00
BiggerRain
32c7cc5060 fix: suggestion list position (#553)
* fix: suggestion List position

* docs: update notes
2025-05-23 15:31:27 +08:00
BiggerRain
c13151d69e fix: the scroll button is not displayed by default (#552)
* fix: the scroll button is not displayed by default

* docs: update notes
2025-05-23 14:53:57 +08:00
BiggerRain
07c4ab03b5 fix: secondary page cannot be searched (#551)
* fix: secondary page cannot be searched

* docs: update notes
2025-05-22 19:45:28 +08:00
BiggerRain
cf3f2affa5 fix: history list height (#550)
* fix: history list height

* docs: update notes
2025-05-22 16:28:11 +08:00
BiggerRain
401832ad43 chore: logout update server profile (#549)
* chore: logout update server profile

* docs: update notes
2025-05-22 11:53:23 +08:00
Medcl
6a6f48d2fc chore: mark server offline on user logout (#546)
* chore: mark server offline on user logout

* update release notes
2025-05-22 11:37:20 +08:00
BiggerRain
8a6c90d124 chore: add global login judgment (#544)
* chore: add global login judgment

* docs: update notes
2025-05-22 10:59:46 +08:00
BiggerRain
34acecbcb0 chore: add assistant count (#542)
* fix: switch server assistant and session session unchanged

* docs: update notes

* fix: add server error

* chore: add assistant count

* docs: update notes
2025-05-21 15:29:04 +08:00
SteveLauC
4474212b7d chore: dead code cleanup (#543) 2025-05-21 14:40:38 +08:00
Medcl
1187b641d4 refactor: refactoring search error (#541)
* refactor: refactoring search error

* chore: update release notes
2025-05-21 14:27:17 +08:00
BiggerRain
ef8cd569e4 fix: switch server assistant and session session unchanged (#540)
* fix: switch server assistant and session session unchanged

* docs: update notes
2025-05-21 11:34:03 +08:00
BiggerRain
5ef06bfc95 fix: service switching error (#539)
* fix: service switching error

* build: build error

* chore: chat content can be copied

* docs: update notes

* fix: service switching error

* chore: change to send cancel event to ws_cancel

* chore: add ws-cancel

---------

Co-authored-by: medcl <m@medcl.net>
2025-05-21 09:04:57 +08:00
SteveLauC
2b59addb08 fix: panic when fetching app metadata on Windows (#538)
* fix: panic when fetching app metadata on Windows

* release note
2025-05-21 09:04:08 +08:00
BiggerRain
ee750620f2 refactor: service info related components (#537)
* refactor: service info related components

* docs: update notes

* refactor: chat header service status
2025-05-20 17:02:10 +08:00
Medcl
acc3b1a0d2 chore: skip register server that not logged in (#536)
* chore: update logging message

* chore: skip register server that not logged in

* chore: update logging message

* chore: update release notes
2025-05-20 15:10:27 +08:00
SteveLauC
4372747014 feat: dynamic log level via env var COCO_LOG (#535) 2025-05-20 12:54:07 +08:00
BiggerRain
ee531209aa fix: server image loading failure (#534)
* fix: server image loading failure

* docs: update notes
2025-05-20 09:31:54 +08:00
BiggerRain
ee0bbce3e2 style: search error styles (#533)
* style: search error styles

* docs: update notes
2025-05-19 19:54:34 +08:00
SteveLauC
7eccf99f92 fix: do not pass whitespace-only strings to Calculator expr evaluation lib (#532) 2025-05-19 19:24:32 +08:00
SteveLauC
5044a98bb7 fix: app hotkey hanlder invoked twice (key pressed and released) (#531) 2025-05-19 18:40:44 +08:00
SteveLauC
72165812bf refactor: ignore the error happens while indexing a specific app (#530)
* refactor: ignore the error happens while indexing a specific app

* refactor: ignore the error happens while indexing a specific app
2025-05-19 17:28:13 +08:00
BiggerRain
f9c1be8517 fix: app icon & category icon (#529) 2025-05-19 17:24:51 +08:00
BiggerRain
71ce23ef21 style: history component styles (#528)
* style: history component styles

* docs: update notes

* build: build & publish web componet version 1.2.1

* build: build & publish web componet version 1.2.2
2025-05-19 16:56:00 +08:00
Medcl
3e6041cbd8 chroe: update minimum macOS version to 10 (#527) 2025-05-18 15:06:06 +08:00
SteveLauC
0b9e158b55 fix: panic caused by an unwrap() (#526) 2025-05-17 18:44:17 +08:00
BiggerRain
688ced3fc3 build: build & publish web component (#524) 2025-05-17 16:53:17 +08:00
BiggerRain
16e0382a8b docs: update release notes (#525) 2025-05-17 16:52:26 +08:00
BiggerRain
91c9cd5725 fix: show only enabled datasource & MCP list (#523)
* fix: show only enabled datasource & MCP list

* docs: update notes

* fix: show only enabled datasource & MCP list
2025-05-17 12:01:18 +08:00
ayangweb
7f3e602bb3 feat: add a component for text reading aloud (#522)
* feat: add a component for text reading aloud

* docs: update changelog
2025-05-16 16:21:57 +08:00
BiggerRain
5e9d41ea5c fix: datasource & MCP list synchronization update (#521)
* fix: datasource & MCP list update

* docs: update notes

* docs:update notes
2025-05-16 15:09:51 +08:00
Medcl
8bdb93d813 refactor: refactoring icon component (#514)
* chore: try to fix icon for insecure-tls deployment

* chore: handling icon resource loading errors

* refactor: refactored icon component

* chore: update release notes

---------

Co-authored-by: rain <15911122312@163.com>
2025-05-16 12:03:43 +08:00
ayangweb
690e6a3225 refactor: optimizing list styles in markdown content (#520)
* refactor: optimizing list styles in markdown content

* docs: update changelog

* style: remove unless code
2025-05-16 10:21:41 +08:00
ayangweb
111d9bddca style: remove useless code (#519) 2025-05-16 09:17:41 +08:00
ayangweb
7645b3e736 feat: add AI summary component (#518)
* feat: add AI summary component

* docs: update changelog

* refactor: update
2025-05-15 18:27:17 +08:00
Medcl
ac21074db6 fix: loading chat history for potential empty attachments (#516)
* fix: loading chat history for potential empty attachments

* chore: update release notes
2025-05-15 15:38:46 +08:00
BiggerRain
496ae025d8 style: chat input icons show (#515)
* style: chat input icons show

* style: chat input icons show

* docs: update notes
2025-05-15 15:26:49 +08:00
SteveLauC
ac5a196746 refactor: store setting allowSelfSignature in backend (#512)
* refactor: store setting allowSelfSignature in backend

* refactor: store setting allowSelfSignature in backend

* refactor: only reinit client when config gets updated

* refactor: docking api

* unused import cleanup

---------

Co-authored-by: ayang <473033518@qq.com>
Co-authored-by: ayangweb <75017711+ayangweb@users.noreply.github.com>
2025-05-15 09:17:03 +08:00
BiggerRain
aa99588001 style: modify the style of the search input box (#513)
* style: modify the style of the search input box

* build: build error
2025-05-15 08:54:42 +08:00
BiggerRain
163df77e8a fix: fixed the newly created session has no title when it is deleted (#511)
* fix: fixed the issue that the newly created session has no title when it is deleted

* docs: update notes
2025-05-14 16:14:57 +08:00
ayangweb
21509f35e5 refactor: optimize the style problem of icons (#510) 2025-05-14 16:09:51 +08:00
ayangweb
7bf59aa259 feat: add option to allow self-signed certificates (#509)
* feat: add option to allow self-signed certificates

* docs: update changelog
2025-05-14 16:00:40 +08:00
ayangweb
4aa377e486 refactor: optimized the modification operation of the numeric input box (#508)
* refactor: optimized the modification operation of the numeric input box

* docs: update changelog
2025-05-14 15:03:17 +08:00
ayangweb
feb716039c refactor: changing the timing of app list loading (#507) 2025-05-14 11:49:53 +08:00
BiggerRain
448d2a6069 refactor: optimizing the code (#505)
* refactor: optimizing the code

* docs: update notes
2025-05-14 10:59:18 +08:00
Medcl
c31a4aa52a feat: websocket support self-signed TLS (#504)
* feat: websocket support self-signed TLS

* chore: update release notes

* chore: remove unused comments
2025-05-14 10:07:49 +08:00
SteveLauC
73ac29ef3b refactor: fetch app list in settings in real time (#498) 2025-05-13 18:16:40 +08:00
Medcl
3cd73f13ab chore: update readme (#503) 2025-05-13 18:13:39 +08:00
Medcl
95ccbaec3e fix: several issues around search (#502)
* fix: several issues around search

* chore: update release notes
2025-05-13 18:12:57 +08:00
BiggerRain
d52ce481f9 feat: the search input box supports multi-line input (#501)
* feat: the search input box supports multi-line input

* docs: update notes
2025-05-13 16:26:54 +08:00
BiggerRain
573e1cf038 chore: add clear monitoring & cache calculation to optimize performance (#500)
* chore: add clear monitoring & cache calculation to optimize performance

* docs: update notes
2025-05-13 14:07:06 +08:00
BiggerRain
5162604cfd chore: UpdateApp component loading location (#499)
* chore: UpdateApp component loading location

* docs: update notes
2025-05-13 11:40:29 +08:00
ayangweb
e38053682d refactor: optimize styling issues with chat content (#497)
* refactor: optimize styling issues with chat content

* style: changing the import order
2025-05-13 11:24:07 +08:00
BiggerRain
018ec9e4ed chore: greetings show hidden logic (#496)
* chore: greetings show hidden logic

* docs: update notes
2025-05-13 11:01:05 +08:00
BiggerRain
f9e5c6cc28 chore: search and MCP show hidden logic (#494)
* chore: enabled_by_default search & MCP

* chore: add enabled param judge

* chore: add enabled param judge

* chore: add enabled param judge

* docs: update notes

---------

Co-authored-by: ayangweb <75017711+ayangweb@users.noreply.github.com>
2025-05-13 10:44:49 +08:00
ayangweb
6bb64e92d9 feat: the chat content has added a button to return to the bottom (#495)
* feat: the chat content has added a button to return to the bottom

* docs: update changelog

* refactor: optimization effect
2025-05-13 10:36:02 +08:00
SteveLauC
7962c329c7 chore: add ~/Applications to search path on macOS (#493)
* chore: add ~/Applications to search path on macOS

* changelog entry
2025-05-12 18:47:25 +08:00
ayangweb
dd6bd2093d refactor: optimize the style of the sidebar (#492)
* refactor: optimize the style of the sidebar

* refactor: adjust z-index value to increase sidebar hierarchy
2025-05-12 18:08:50 +08:00
SteveLauC
25d998a41c fix: duplicate flatpak applications (#491) 2025-05-12 17:47:16 +08:00
BiggerRain
3cfb03dd49 feat: the chat input box supports multi-line input (#490)
* chore: chat input

* feat: the chat input box supports multi-line input

* docs: update notes

* chore: remove env record

* chore: remove debug
2025-05-12 16:03:49 +08:00
SteveLauC
386b9cc48b fix: panic caused memory allocation failure on Linux (#489) 2025-05-12 15:27:49 +08:00
ayangweb
006b679386 refactor: refactor the content style of the extended page (#488) 2025-05-12 14:55:36 +08:00
SteveLauC
d47fb3cbc6 refactor: set up tauri-plugin-log as the logger (#487)
* refactor: set up tauri-plugins-log as the logger

* refactor: captures the front-end promise and outputs it to the log

---------

Co-authored-by: ayang <473033518@qq.com>
2025-05-12 09:33:37 +08:00
SteveLauC
26f71cff08 chore: remove dependency pizza engine (#486)
* chore: remove dep pizza engine

* style: fmt
2025-05-11 14:44:16 +08:00
SteveLauC
ae8f95e19c chore: use ssh instead of https to pull pizza_engine (#485) 2025-05-09 18:58:47 +08:00
Medcl
4c49daf510 chore: refine wording for search failure (#484)
* chore: refine wording on search failure

* chore: update release notes
2025-05-09 18:14:18 +08:00
SteveLauC
8d2528e521 refactor: use pizza_engine for app search (#346)
* refactor: use pizza_engine for app search

* refactor: do not break the build when pizza_engine is unavailable
2025-05-09 17:54:58 +08:00
ayangweb
4895322397 feat: history list add put away button (#482)
* feat: history list add put away button

* docs: update changelog
2025-05-09 16:17:02 +08:00
BiggerRain
a8a4d435fc chore: debug datasource component (#483) 2025-05-09 16:16:12 +08:00
ayangweb
1c0335feb4 fix: fix the focusing problem of the input box in windows (#481) 2025-05-07 18:09:19 +08:00
ayangweb
8498578425 feat: support for snapshot version updates (#480)
* feat: support for snapshot version updates

* docs: update changelog
2025-05-07 16:43:44 +08:00
Hardy
326e161505 chore: add github action build arm64 platform (#479)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-05-07 10:50:39 +08:00
BiggerRain
e96e6b4a89 build: solve build error (#477)
* build: solve build error

* build: solve build error
2025-05-01 14:46:50 +08:00
BiggerRain
853ea38058 fix: solve the problem of modifying the assistant in the chat (#476)
* refactor: refactored chat code

* fix: Solve the problem of modifying the assistant in the chat

* docs: update notes

* docs: update notes
2025-04-30 16:24:14 +08:00
BiggerRain
4e127f8cdc chore: adjust list error message (#475)
* chore: adjust list error message

* docs: update notes
2025-04-30 09:01:31 +08:00
ayangweb
51ada19d42 refactor: optimize the mode display of the first launched window (#474) 2025-04-29 19:07:03 +08:00
ayangweb
86f3741302 docs: update changelog (#473) 2025-04-29 17:47:14 +08:00
ayangweb
bb50b150c0 feat: supports Shift + Enter input box line feeds (#472) 2025-04-29 17:44:17 +08:00
ayangweb
a092354fee feat: supports setting of out-of-focus transparency on top (#470)
* feat: supports setting of out-of-focus transparency on top

* docs: update changelog

* refactor: optimize translation content

* docs: update changelog
2025-04-29 17:08:01 +08:00
SteveLauC
2ffbb79358 docs: document how to install Coco app on Ubuntu (#471) 2025-04-29 17:05:16 +08:00
ayangweb
661b5d1b77 feat: check or enter to close the list of assistants (#469)
* feat: check or enter to close the list of assistants

* docs: update changelog
2025-04-29 15:16:46 +08:00
201 changed files with 13911 additions and 6258 deletions

View File

@@ -0,0 +1,18 @@
name: Enforce no dependency pizza-engine
on:
pull_request:
jobs:
main:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name:
working-directory: ./src-tauri
run: |
# if cargo remove pizza-engine succeeds, then it is in our dependency list, fail the CI pipeline.
if cargo remove pizza-engine; then exit 1; fi

View File

@@ -50,6 +50,8 @@ jobs:
- platform: "ubuntu-22.04"
target: "x86_64-unknown-linux-gnu"
- platform: "ubuntu-22.04-arm"
target: "aarch64-unknown-linux-gnu"
runs-on: ${{ matrix.platform }}
steps:
@@ -67,10 +69,10 @@ jobs:
run: rustup target add ${{ matrix.target }}
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
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
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Install Rust stable
run: rustup toolchain install stable
@@ -89,8 +91,51 @@ jobs:
- name: Install app dependencies and build web
run: pnpm install --frozen-lockfile
- name: Build the app
- name: Set up SSH agent for private repository clone
if: matrix.target != 'i686-pc-windows-msvc'
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Add Git server to known hosts
if: matrix.platform != 'windows-latest'
run: |
mkdir -p ~/.ssh
ssh-keyscan github.com >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Pizza engine features setup
if: matrix.target != 'i686-pc-windows-msvc'
run: |
make add-dep-pizza-engine
rustup target add ${{ matrix.target}} --toolchain nightly-2025-02-28
- name: Build the app with ${{ matrix.platform }}
uses: tauri-apps/tauri-action@v0
if: matrix.target != 'i686-pc-windows-msvc'
env:
CI: false
PLATFORM: ${{ matrix.platform }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ""
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ""
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: ${{ github.ref_name }}
releaseName: Coco ${{ needs.create-release.outputs.APP_VERSION }}
releaseBody: ""
releaseDraft: true
prerelease: false
args: --target ${{ matrix.target }} --features use_pizza_engine
- name: Build the app with ${{ matrix.platform }} (windows i686 only)
uses: tauri-apps/tauri-action@v0
if: matrix.target == 'i686-pc-windows-msvc'
env:
CI: false
PLATFORM: ${{ matrix.platform }}

1
.gitignore vendored
View File

@@ -25,3 +25,4 @@ src/components/web
*.njsproj
*.sln
*.sw?
.env

View File

@@ -13,6 +13,7 @@
"elif",
"errmsg",
"fullscreen",
"fulltext",
"headlessui",
"Icdbb",
"icns",

View File

@@ -76,3 +76,10 @@ clean-rebuild:
@echo "Cleaning up and rebuilding..."
rm -rf node_modules
$(MAKE) dev-build
add-dep-pizza-engine:
cd src-tauri && cargo add --git ssh://git@github.com/infinilabs/pizza.git pizza-engine --features query_string_parser,persistence
dev-build-with-pizza: add-dep-pizza-engine
@echo "Starting desktop development with Pizza Engine pulled in..."
RUST_BACKTRACE=1 pnpm tauri dev --features use_pizza_engine

View File

@@ -93,6 +93,12 @@ pnpm tauri build
- [Coco Server Documentation](https://docs.infinilabs.com/coco-server/main/)
- [Tauri Documentation](https://tauri.app/)
## Contributors
<a href="https://github.com/infinilabs/coco-app/graphs/contributors">
<img src="https://contrib.rocks/image?repo=infinilabs/coco-app" />
</a>
## 📄 License
Coco AI is an open-source project licensed under the [MIT License](LICENSE). You can freely use, modify, and

View File

@@ -0,0 +1,38 @@
---
weight: 10
title: "Ubuntu"
asciinema: true
---
# Ubuntu
> NOTE: Coco app only works fully under [X11][x11_protocol].
>
> Don't know if you running X11 or not? take a look at this [question][if_x11]!
[x11_protocol]: https://en.wikipedia.org/wiki/X_Window_System
[if_x11]: https://unix.stackexchange.com/q/202891/498440
## Goto [https://coco.rs/](https://coco.rs/)
## Download the package
Download the package of your architecture, it should be put in your `Downloads` directory
and look like this:
```sh
$ cd ~/Downloads
$ ls
Coco-AI-x.y.z-bbbb-deb-linux-amd64.zip
# or Coco-AI-x.y.z-bbbb-deb-linux-arm64.zip depending on your architecture
```
## Install it
Unzip and install it
```
$ unzip Coco-AI-x.y.z-bbbb-deb-linux-amd64.zip
$ sudo dpkg -i Coco-AI-x.y.z-bbbb-deb-linux-amd64.deb
```

View File

@@ -7,16 +7,89 @@ title: "Release Notes"
Information about release notes of Coco Server is provided here.
## Latest (In development)
## Latest (In development)
### ❌ Breaking changes
### 🚀 Features
### 🐛 Bug fix
### ✈️ Improvements
## 0.5.1 (2025-05-31)
### ❌ Breaking changes
### 🚀 Features
- feat: check or enter to close the list of assistants #469
- feat: add dimness settings for pinned window #470
- feat: supports Shift + Enter input box line feeds #472
- feat: support for snapshot version updates #480
- feat: history list add put away button #482
- feat: the chat input box supports multi-line input #490
- feat: add `~/Applications` to the search path #493
- feat: the chat content has added a button to return to the bottom #495
- feat: the search input box supports multi-line input #501
- feat: websocket support self-signed TLS #504
- feat: add option to allow self-signed certificates #509
- feat: add AI summary component #518
- feat: dynamic log level via env var COCO_LOG #535
- feat: add quick AI access to search mode #556
- feat: rerank search results #561
### 🐛 Bug fix
- fix: solve the problem of modifying the assistant in the chat #476
- fix: several issues around search #502
- fix: fixed the newly created session has no title when it is deleted #511
- fix: loading chat history for potential empty attachments
- fix: datasource & MCP list synchronization update #521
- fix: app icon & category icon #529
- fix: show only enabled datasource & MCP list
- fix: server image loading failure #534
- fix: panic when fetching app metadata on Windows #538
- fix: service switching error #539
- fix: switch server assistant and session unchanged #540
- fix: history list height #550
- fix: secondary page cannot be searched #551
- fix: the scroll button is not displayed by default #552
- fix: suggestion list position #553
- fix: independent chat window has no data #554
- fix: resolved navigation error on continue chat action #558
- fix: make extension search source respect parameter datasource #576
### ✈️ Improvements
- chore: adjust list error message #475
- chore: refine wording on search failure
- choresearch and MCP show hidden logic #494
- chore: greetings show hidden logic #496
- refactor: fetch app list in settings in real time #498
- chore: UpdateApp component loading location #499
- chore: add clear monitoring & cache calculation to optimize performance #500
- refactor: optimizing the code #505
- refactor: optimized the modification operation of the numeric input box #508
- style: modify the style of the search input box #513
- style: chat input icons show #515
- refactor: refactoring icon component #514
- refactor: optimizing list styles in markdown content #520
- feat: add a component for text reading aloud #522
- style: history component styles #528
- style: search error styles #533
- chore: skip register server that not logged in #536
- refactor: service info related components #537
- chore: chat content can be copied #539
- refactor: refactoring search error #541
- chore: add assistant count #542
- chore: add global login judgment #544
- chore: mark server offline on user logout #546
- chore: logout update server profile #549
- chore: assistant keyboard events and mouse events #559
- chore: web component start page config #560
- chore: assistant chat placeholder & refactor input box components #566
- refactor: input box related components #568
- chore: mark unavailable server to offline on refresh info #569
- chore: only show available servers in chat #570
- refactor: search result related components #571
## 0.4.0 (2025-04-27)
### Breaking changes
@@ -45,6 +118,8 @@ Information about release notes of Coco Server is provided here.
- feat: data sources support displaying customized icons #432
- feat: add shortcut key conflict hint and reset function #442
- feat: updated to include error message #465
- feat: support third party extensions #572
- feat: support ai overview #572
### Bug fix

View File

@@ -1,7 +1,7 @@
{
"name": "coco",
"private": true,
"version": "0.4.0",
"version": "0.5.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -19,36 +19,37 @@
},
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@headlessui/react": "^2.2.0",
"@tauri-apps/api": "^2.4.0",
"@headlessui/react": "^2.2.2",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "~2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@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-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.0",
"@tauri-apps/plugin-shell": "^2.2.0",
"@tauri-apps/plugin-updater": "^2.6.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",
"@wavesurfer/react": "^1.0.9",
"@wavesurfer/react": "^1.0.11",
"ahooks": "^3.8.4",
"axios": "^1.8.4",
"axios": "^1.9.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"dotenv": "^16.5.0",
"filesize": "^10.1.6",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4",
"i18next-browser-languagedetector": "^8.1.0",
"lodash-es": "^4.17.21",
"lucide-react": "^0.461.0",
"mermaid": "^11.5.0",
"mermaid": "^11.6.0",
"nanoid": "^5.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^15.4.1",
"react-hotkeys-hook": "^4.6.2",
"react-i18next": "^15.5.1",
"react-markdown": "^9.1.0",
"react-router-dom": "^6.30.0",
"react-window": "^1.8.11",
@@ -58,25 +59,26 @@
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tauri-plugin-fs-pro-api": "^2.4.0",
"tauri-plugin-macos-permissions-api": "^2.2.0",
"tauri-plugin-screenshots-api": "^2.1.0",
"tauri-plugin-macos-permissions-api": "^2.3.0",
"tauri-plugin-screenshots-api": "^2.2.0",
"tauri-plugin-windows-version-api": "^2.0.0",
"type-fest": "^4.41.0",
"use-debounce": "^10.0.4",
"uuid": "^11.1.0",
"wavesurfer.js": "^7.9.3",
"zustand": "^5.0.3"
"wavesurfer.js": "^7.9.5",
"zustand": "^5.0.4"
},
"devDependencies": {
"@tauri-apps/cli": "^2.4.0",
"@tauri-apps/cli": "^2.5.0",
"@types/dom-speech-recognition": "^0.0.4",
"@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.13.11",
"@types/react": "^18.3.19",
"@types/react-dom": "^18.3.5",
"@types/node": "^22.15.17",
"@types/react": "^18.3.21",
"@types/react-dom": "^18.3.7",
"@types/react-katex": "^3.0.4",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"cross-env": "^7.0.3",
"immer": "^10.1.1",
@@ -85,8 +87,9 @@
"sass": "^1.87.0",
"tailwindcss": "^3.4.17",
"tsup": "^8.4.0",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vite": "^5.4.14"
}
"tsx": "^4.19.4",
"typescript": "^5.8.3",
"vite": "^5.4.19"
},
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
}

1759
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

1717
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "coco"
version = "0.4.0"
version = "0.5.2"
description = "Search, connect, collaborate all in one place."
authors = ["INFINI Labs"]
edition = "2021"
@@ -20,6 +20,26 @@ tauri-build = { version = "2", features = ["default"] }
default = ["desktop"]
desktop = []
cargo-clippy = []
# If enabled, code that relies on pizza_engine will be activated.
#
# Only do this if:
# 1. Pizza engine is listed in the `dependencies` section
#
# ```toml
# [dependencies]
# pizza-engine = { git = "ssh://git@github.com/infinilabs/pizza.git", features = ["query_string_parser", "persistence"] }
# ```
#
# 2. It is a private repo, you have access to it.
#
# So, for external contributors, do NOT enable this feature.
#
# Previously, We listed it in the dependencies and marked it optional, but cargo
# would fetch all the dependencies regardless of wheterh they are optional or not,
# so we removed it.
#
# https://github.com/rust-lang/cargo/issues/4544#issuecomment-1906902755
use_pizza_engine = []
[dependencies]
pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "main" }
@@ -37,17 +57,15 @@ tauri-plugin-store = "2.2.0"
tauri-plugin-os = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
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 = "fb8f475993a2a774ce08d7a58f9f2ac264248a24" }
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "7bb507e6b12f73c96f3a52f0578d0246a689f381" }
tokio-native-tls = "0.3" # For wss connections
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.20", features = ["rustls-tls-webpki-roots"] }
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
hyper = { version = "0.14", features = ["client"] }
reqwest = { version = "0.12", features = ["json", "multipart"] }
futures = "0.3.31"
@@ -62,19 +80,24 @@ hostname = "0.3"
plist = "1.7"
base64 = "0.13"
walkdir = "2"
fuzzy_prefix_search = "0.2"
log = "0.4"
strsim = "0.10"
futures-util = "0.3.31"
url = "2.5.2"
http = "1.1.0"
tungstenite = "0.24.0"
env_logger = "0.11.5"
tokio-util = "0.7.14"
tauri-plugin-windows-version = "2"
meval = "0.2"
chinese-number = "0.7"
num2words = "1"
tauri-plugin-log = "2"
chrono = "0.4.41"
serde_plain = "1.0.2"
derive_more = { version = "2.0.1", features = ["display"] }
anyhow = "1.0.98"
function_name = "0.3.0"
regex = "1.11.1"
[target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
@@ -82,7 +105,6 @@ tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
[profile.dev]
incremental = true # Compile your binary in smaller steps.
@@ -96,4 +118,7 @@ strip = true # Ensures debug symbols are removed.
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "^2.2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = "2"
tauri-plugin-updater = { git = "https://github.com/infinilabs/plugins-workspace", branch = "v2" }
[target."cfg(target_os = \"windows\")".dependencies]
enigo="0.3"

View File

@@ -0,0 +1,8 @@
{
"id": "AIOverview",
"title": "AI Overview",
"description": "...",
"icon": "font_a-AIOverview",
"type": "ai_extension",
"enabled": true
}

View File

@@ -0,0 +1,9 @@
{
"id": "Applications",
"platforms": ["macos", "linux", "windows"],
"title": "Applications",
"description": "...",
"icon": "font_Application",
"type": "group",
"enabled": true
}

View File

@@ -0,0 +1,9 @@
{
"id": "Calculator",
"title": "Calculator",
"platforms": ["macos", "linux", "windows"],
"description": "...",
"icon": "font_Calculator",
"type": "calculator",
"enabled": true
}

View File

@@ -0,0 +1,8 @@
{
"id": "QuickAIAccess",
"title": "Quick AI Access",
"description": "...",
"icon": "font_a-QuickAIAccess",
"type": "ai_extension",
"enabled": true
}

View File

@@ -70,6 +70,7 @@
"core:window:allow-set-theme",
"process:default",
"updater:default",
"windows-version:default"
"windows-version:default",
"log:default"
]
}

View File

@@ -0,0 +1,2 @@
[toolchain]
channel = "nightly-2025-02-28"

View File

@@ -1,10 +1,16 @@
use crate::common;
use crate::common::assistant::ChatRequestMessage;
use crate::common::http::GetResponse;
use crate::common::register::SearchSourceRegistry;
use crate::server::http_client::HttpClient;
use crate::{common, server::servers::COCO_SERVERS};
use futures::stream::FuturesUnordered;
use futures::StreamExt;
use futures_util::TryStreamExt;
use http::Method;
use serde_json::Value;
use std::collections::HashMap;
use tauri::{AppHandle, Runtime};
use tauri::{AppHandle, Emitter, Manager, Runtime};
use tokio::io::AsyncBufReadExt;
#[tauri::command]
pub async fn chat_history<R: Runtime>(
@@ -141,8 +147,10 @@ pub async fn new_chat<R: Runtime>(
let body_text = common::http::get_response_body_text(response).await?;
let chat_response: GetResponse =
serde_json::from_str(&body_text).map_err(|e| format!("Failed to parse response JSON: {}", e))?;
log::debug!("New chat response: {}", &body_text);
let chat_response: GetResponse = serde_json::from_str(&body_text)
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
if chat_response.result != "created" {
return Err(format!("Unexpected result: {}", chat_response.result));
@@ -179,6 +187,7 @@ pub async fn send_message<R: Runtime>(
.await
.map_err(|e| format!("Error cancel session: {}", e))?;
common::http::get_response_body_text(response).await
}
@@ -248,11 +257,180 @@ pub async fn assistant_search<R: Runtime>(
None,
Some(reqwest::Body::from(body.to_string())),
)
.await
.map_err(|e| format!("Error searching assistants: {}", e))?;
.await
.map_err(|e| format!("Error searching assistants: {}", e))?;
response
.json::<Value>()
.await
.map_err(|err| err.to_string())
}
#[tauri::command]
pub async fn assistant_get<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
assistant_id: String,
) -> Result<Value, String> {
let response = HttpClient::get(
&server_id,
&format!("/assistant/{}", assistant_id),
None, // headers
)
.await
.map_err(|e| format!("Error getting assistant: {}", e))?;
response
.json::<Value>()
.await
.map_err(|err| err.to_string())
}
/// Gets the information of the assistant specified by `assistant_id` by querying **all**
/// Coco servers.
///
/// Returns as soon as the assistant is found on any Coco server.
#[tauri::command]
pub async fn assistant_get_multi<R: Runtime>(
app_handle: AppHandle<R>,
assistant_id: String,
) -> Result<Value, String> {
let search_sources = app_handle.state::<SearchSourceRegistry>();
let sources_future = search_sources.get_sources();
let sources_list = sources_future.await;
let mut futures = FuturesUnordered::new();
for query_source in &sources_list {
let query_source_type = query_source.get_type();
if query_source_type.r#type != COCO_SERVERS {
// Assistants only exists on Coco servers.
continue;
}
let coco_server_id = query_source_type.id.clone();
let path = format!("/assistant/{}", assistant_id);
let fut = async move {
let res_response = HttpClient::get(
&coco_server_id,
&path,
None, // headers
)
.await;
match res_response {
Ok(response) => response
.json::<serde_json::Value>()
.await
.map_err(|e| e.to_string()),
Err(e) => Err(e),
}
};
futures.push(fut);
}
while let Some(res_response_json) = futures.next().await {
let response_json = match res_response_json {
Ok(json) => json,
Err(e) => return Err(e),
};
// Example response JSON
//
// When assistant is not found:
// ```json
// {
// "_id": "ID",
// "result": "not_found"
// }
// ```
//
// When assistant is found:
// ```json
// {
// "_id": "ID",
// "_source": {...}
// "found": true
// }
// ```
if let Some(found) = response_json.get("found") {
if found == true {
return Ok(response_json);
}
}
}
Err(format!(
"could not find Assistant [{}] on all the Coco servers",
assistant_id
))
}
use regex::Regex;
/// Remove all `"icon": "..."` fields from a JSON string
pub fn remove_icon_fields(json: &str) -> String {
// Regex to match `"icon": "..."` fields, including base64 or escaped strings
let re = Regex::new(r#""icon"\s*:\s*"[^"]*"(,?)"#).unwrap();
// Replace with empty string, or just remove trailing comma if needed
re.replace_all(json, |caps: &regex::Captures| {
if &caps[1] == "," {
"".to_string() // keep comma removal logic safe
} else {
"".to_string()
}
}).to_string()
}
#[tauri::command]
pub async fn ask_ai<R: Runtime>(
app_handle: AppHandle<R>,
message: String,
server_id: String,
assistant_id: String,
client_id: String,
) -> Result<(), String> {
let cleaned = remove_icon_fields(message.as_str());
let body = serde_json::json!({ "message": cleaned });
let path = format!("/assistant/{}/_ask", assistant_id);
println!("Sending request to {}", &path);
let response = HttpClient::send_request(
server_id.as_str(),
Method::POST,
path.as_str(),
None,
None,
Some(reqwest::Body::from(body.to_string())),
)
.await?;
if response.status() == 429 {
log::warn!("Rate limit exceeded for assistant: {}", &assistant_id);
return Ok(());
}
if !response.status().is_success() {
return Err(format!("Request Failed: {}", response.status()));
}
let stream = response.bytes_stream();
let reader = tokio_util::io::StreamReader::new(
stream.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)),
);
let mut lines = tokio::io::BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
dbg!("Received line: {}", &line);
let _ = app_handle.emit(&client_id, line).map_err(|err| {
println!("Failed to emit: {:?}", err);
});
}
Ok(())
}

View File

@@ -9,13 +9,13 @@ pub struct ChatRequestMessage {
#[allow(dead_code)]
pub struct NewChatResponse {
pub _id: String,
pub _source: Source,
pub _source: Session,
pub result: String,
pub payload: Option<Value>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Source {
pub struct Session {
pub id: String,
pub created: String,
pub updated: String,
@@ -23,4 +23,11 @@ pub struct Source {
pub title: Option<String>,
pub summary: Option<String>,
pub manually_renamed_title: bool,
pub visible: Option<bool>,
pub context: Option<SessionContext>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SessionContext {
pub attachments: Option<Vec<String>>,
}

View File

@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::hide_coco;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RichLabel {
pub label: Option<String>,
@@ -29,6 +31,72 @@ pub struct EditorInfo {
pub timestamp: Option<String>,
}
/// Defines the action that would be performed when a document gets opened.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) enum OnOpened {
/// Launch the application
Application { app_path: String },
/// Open the URL.
Document { url: String },
/// Spawn a child process to run the `CommandAction`.
Command {
action: crate::extension::CommandAction,
},
}
impl OnOpened {
pub(crate) fn url(&self) -> String {
match self {
Self::Application { app_path } => app_path.clone(),
Self::Document { url } => url.clone(),
Self::Command { action } => {
const WHITESPACE: &str = " ";
let mut ret = action.exec.clone();
ret.push_str(WHITESPACE);
ret.push_str(action.args.join(WHITESPACE).as_str());
ret
}
}
}
}
#[tauri::command]
pub(crate) async fn open(on_opened: OnOpened) -> Result<(), String> {
log::debug!("open({})", on_opened.url());
use crate::util::open as homemade_tauri_shell_open;
use crate::GLOBAL_TAURI_APP_HANDLE;
use std::process::Command;
let global_tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
match on_opened {
OnOpened::Application { app_path } => {
homemade_tauri_shell_open(global_tauri_app_handle.clone(), app_path).await?
}
OnOpened::Document { url } => {
homemade_tauri_shell_open(global_tauri_app_handle.clone(), url).await?
}
OnOpened::Command { action } => {
let mut cmd = Command::new(action.exec);
cmd.args(action.args);
let output = cmd.output().map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(format!(
"Command failed, stderr [{}]",
String::from_utf8_lossy(&output.stderr)
));
}
}
}
hide_coco(global_tauri_app_handle.clone()).await;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Document {
pub id: String,
@@ -48,6 +116,8 @@ pub struct Document {
pub thumbnail: Option<String>,
pub cover: Option<String>,
pub tags: Option<Vec<String>>,
/// What will happen if we open this document.
pub on_opened: Option<OnOpened>,
pub url: Option<String>,
pub size: Option<i64>,
pub metadata: Option<HashMap<String, serde_json::Value>>,
@@ -55,39 +125,3 @@ pub struct Document {
pub owner: Option<UserInfo>,
pub last_updated_by: Option<EditorInfo>,
}
impl Document {
pub fn new(
source: Option<DataSourceReference>,
id: String,
category: String,
name: String,
url: String,
) -> Self {
Self {
id,
created: None,
updated: None,
source,
r#type: None,
category: Some(category),
subcategory: None,
categories: None,
rich_categories: None,
title: Some(name),
summary: None,
lang: None,
content: None,
icon: None,
thumbnail: None,
cover: None,
tags: None,
url: Some(url),
size: None,
metadata: None,
payload: None,
owner: None,
last_updated_by: None,
}
}
}

View File

@@ -2,32 +2,52 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Deserialize)]
pub struct ErrorDetail {
pub reason: String,
pub status: u16,
#[allow(dead_code)]
pub struct ErrorCause {
#[serde(default)]
pub r#type: Option<String>,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct ErrorDetail {
#[serde(default)]
pub root_cause: Option<Vec<ErrorCause>>,
#[serde(default)]
pub r#type: Option<String>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub caused_by: Option<ErrorCause>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct ErrorResponse {
pub error: ErrorDetail,
#[serde(default)]
pub error: Option<ErrorDetail>,
#[serde(default)]
pub status: Option<u16>,
}
#[derive(Debug, Error, Serialize)]
pub enum SearchError {
#[error("HTTP request failed: {0}")]
#[error("HttpError: {0}")]
HttpError(String),
#[error("Invalid response format: {0}")]
#[error("ParseError: {0}")]
ParseError(String),
#[error("Timeout occurred")]
Timeout,
#[error("Unknown error: {0}")]
#[error("UnknownError: {0}")]
#[allow(dead_code)]
Unknown(String),
#[error("InternalError error: {0}")]
#[error("InternalError: {0}")]
#[allow(dead_code)]
InternalError(String),
}
@@ -42,4 +62,4 @@ impl From<reqwest::Error> for SearchError {
SearchError::HttpError(err.to_string())
}
}
}
}

View File

@@ -24,7 +24,9 @@ pub async fn get_response_body_text(response: Response) -> Result<String, String
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response body: {}", e))?;
.map_err(|e| format!("Failed to read response body: {}, code: {}", e, status))?;
log::debug!("Response status: {}, body: {}", status, &body);
if status < 200 || status >= 400 {
// Try to parse the error body
@@ -34,17 +36,21 @@ pub async fn get_response_body_text(response: Response) -> Result<String, String
return Err(fallback_error);
}
match serde_json::from_str::<common::error::ErrorResponse>(&body) {
Ok(parsed_error) => {
dbg!(&parsed_error);
Err(format!(
"Server error ({}): {}",
parsed_error.error.status, parsed_error.error.reason
"Server error ({}): {:?}",
status, parsed_error.error
))
}
Err(_) => Err(fallback_error),
Err(_) => {
log::warn!("Failed to parse error response: {}", &body);
Err(fallback_error)
}
}
} else {
Ok(body)
}
}
}

View File

@@ -25,7 +25,7 @@ pub struct Shards {
pub struct Hits<T> {
pub total: Total,
pub max_score: Option<f32>,
pub hits: Vec<SearchHit<T>>,
pub hits: Option<Vec<SearchHit<T>>>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -36,9 +36,9 @@ pub struct Total {
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchHit<T> {
pub _index: String,
pub _type: String,
pub _id: String,
pub _index: Option<String>,
pub _type: Option<String>,
pub _id: Option<String>,
pub _score: Option<f64>,
pub _source: T, // This will hold the type we pass in (e.g., DataSource)
}
@@ -58,13 +58,18 @@ where
Ok(search_response)
}
use serde::de::DeserializeOwned;
pub async fn parse_search_hits<T>(response: Response) -> Result<Vec<SearchHit<T>>, Box<dyn Error>>
where
T: for<'de> Deserialize<'de> + std::fmt::Debug,
T: DeserializeOwned + std::fmt::Debug,
{
let response = parse_search_response(response).await?;
Ok(response.hits.hits)
match response.hits.hits {
Some(hits) => Ok(hits),
None => Ok(Vec::new()),
}
}
pub async fn parse_search_results<T>(response: Response) -> Result<Vec<T>, Box<dyn Error>>

View File

@@ -1,6 +1,8 @@
use crate::common::health::Health;
use crate::common::profile::UserProfile;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -60,6 +62,7 @@ pub struct Server {
pub auth_provider: AuthProvider,
#[serde(default = "default_priority_type")]
pub priority: u32,
pub stats: Option<HashMap<String, Value>>,
}
impl PartialEq for Server {

View File

@@ -1,5 +1,4 @@
use crate::common::error::SearchError;
// use std::{future::Future, pin::Pin};
use crate::common::search::SearchQuery;
use crate::common::search::{QueryResponse, QuerySource};
use async_trait::async_trait;
@@ -10,4 +9,3 @@ pub trait SearchSource: Send + Sync {
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>;
}

View File

@@ -0,0 +1 @@
pub(super) const EXTENSION_ID: &str = "AIOverview";

View File

@@ -0,0 +1,35 @@
use serde::Serialize;
#[cfg(feature = "use_pizza_engine")]
mod with_feature;
#[cfg(not(feature = "use_pizza_engine"))]
mod without_feature;
#[cfg(feature = "use_pizza_engine")]
pub use with_feature::*;
#[cfg(not(feature = "use_pizza_engine"))]
pub use without_feature::*;
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AppEntry {
path: String,
name: String,
icon_path: String,
alias: String,
hotkey: String,
is_disabled: bool,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppMetadata {
name: String,
r#where: String,
size: u64,
created: u128,
modified: u128,
last_opened: u128,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
use super::super::Extension;
use super::AppMetadata;
use crate::common::error::SearchError;
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
use crate::common::traits::SearchSource;
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
use async_trait::async_trait;
use tauri::{AppHandle, Runtime};
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> {
Ok(())
}
}
#[async_trait]
impl SearchSource for ApplicationSearchSource {
fn get_type(&self) -> QuerySource {
QuerySource {
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
name: hostname::get()
.unwrap_or("My Computer".into())
.to_string_lossy()
.into(),
id: QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into(),
}
}
async fn search(&self, _query: SearchQuery) -> Result<QueryResponse, SearchError> {
Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
})
}
}
pub fn set_app_alias<R: Runtime>(_tauri_app_handle: &AppHandle<R>, _app_path: &str, _alias: &str) {
unreachable!("app list should be empty, there is no way this can be invoked")
}
pub fn register_app_hotkey<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
_app_path: &str,
_hotkey: &str,
) -> Result<(), String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}
pub fn unregister_app_hotkey<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
_app_path: &str,
) -> Result<(), String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}
pub fn disable_app_search<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
_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> {
// no-op
Ok(())
}
pub fn is_app_search_enabled(_app_path: &str) -> bool {
false
}
#[tauri::command]
pub async fn add_app_search_path<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_search_path: String,
) -> Result<(), String> {
// no-op
Ok(())
}
#[tauri::command]
pub async fn remove_app_search_path<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_search_path: String,
) -> Result<(), String> {
// no-op
Ok(())
}
#[tauri::command]
pub async fn get_app_search_path<R: Runtime>(_tauri_app_handle: AppHandle<R>) -> 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> {
// Return an empty list
Ok(Vec::new())
}
#[tauri::command]
pub async fn get_app_metadata<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_app_path: String,
) -> Result<AppMetadata, String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}

View File

@@ -1,4 +1,4 @@
use super::LOCAL_QUERY_SOURCE_TYPE;
use super::super::LOCAL_QUERY_SOURCE_TYPE;
use crate::common::{
document::{DataSourceReference, Document},
error::SearchError,
@@ -11,7 +11,7 @@ use num2words::Num2Words;
use serde_json::Value;
use std::collections::HashMap;
const DATA_SOURCE_ID: &str = "Calculator";
pub(crate) const DATA_SOURCE_ID: &str = "Calculator";
pub struct CalculatorSource {
base_score: f64,
@@ -108,11 +108,17 @@ impl SearchSource for CalculatorSource {
}
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
let query_string = query
.query_strings
.get("query")
.unwrap_or(&"".to_string())
.to_string();
let Some(query_string) = query.query_strings.get("query") else {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
};
// Trim the leading and tailing whitespace so that our later if condition
// will only be evaluated against non-whitespace characters.
let query_string = query_string.trim();
if query_string.is_empty() || query_string.len() == 1 {
return Ok(QueryResponse {
@@ -122,11 +128,11 @@ impl SearchSource for CalculatorSource {
});
}
match meval::eval_str(&query_string) {
match meval::eval_str(query_string) {
Ok(num) => {
let mut payload: HashMap<String, Value> = HashMap::new();
let payload_query = parse_query(query_string);
let payload_query = parse_query(query_string.into());
let payload_result = parse_result(num);
payload.insert("query".to_string(), payload_query);
@@ -140,7 +146,7 @@ impl SearchSource for CalculatorSource {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some(DATA_SOURCE_ID.into()),
id: Some(DATA_SOURCE_ID.into()),
icon: None,
icon: Some(String::from("font_Calculator")),
}),
..Default::default()
};

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,310 @@
//! Built-in extensions and related stuff.
pub mod ai_overview;
pub mod application;
pub mod calculator;
pub mod file_system;
pub mod pizza_engine_runtime;
pub mod quick_ai_access;
use super::Extension;
use crate::extension::{alter_extension_json_file, load_extension_from_json_file};
use crate::{SearchSourceRegistry, GLOBAL_TAURI_APP_HANDLE};
use std::path::PathBuf;
use std::sync::LazyLock;
use tauri::path::BaseDirectory;
use tauri::Manager;
pub(crate) static BUILT_IN_EXTENSION_DIRECTORY: LazyLock<PathBuf> = LazyLock::new(|| {
let mut resource_dir = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set")
.path()
.resolve("assets", BaseDirectory::Resource)
.expect(
"User home directory not found, which should be impossible on desktop environments",
);
resource_dir.push("extension");
resource_dir
});
pub(super) async fn init_built_in_extension(
extension: &Extension,
search_source_registry: &SearchSourceRegistry,
) {
log::trace!("initializing built-in extensions");
if extension.id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
search_source_registry
.register_source(application::ApplicationSearchSource)
.await;
log::debug!("built-in extension [{}] initialized", extension.id);
}
if extension.id == calculator::DATA_SOURCE_ID {
let calculator_search = calculator::CalculatorSource::new(2000f64);
search_source_registry
.register_source(calculator_search)
.await;
log::debug!("built-in extension [{}] initialized", extension.id);
}
}
pub(crate) fn is_extension_built_in(extension_id: &str) -> bool {
if extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
return true;
}
if extension_id.starts_with(&format!(
"{}.",
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
)) {
return true;
}
if extension_id == calculator::DATA_SOURCE_ID {
return true;
}
if extension_id == quick_ai_access::EXTENSION_ID {
return true;
}
if extension_id == ai_overview::EXTENSION_ID {
return true;
}
false
}
pub(crate) async fn enable_built_in_extension(extension_id: &str) -> Result<(), String> {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
let update_extension = |extension: &mut Extension| -> Result<(), String> {
extension.enabled = true;
Ok(())
};
if extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
search_source_registry_tauri_state
.register_source(application::ApplicationSearchSource)
.await;
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
extension_id,
update_extension,
)?;
return Ok(());
}
// Check if this is an application
let application_prefix = format!(
"{}.",
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
);
if extension_id.starts_with(&application_prefix) {
let app_path = &extension_id[application_prefix.len()..];
application::enable_app_search(tauri_app_handle, app_path)?;
return Ok(());
}
if extension_id == calculator::DATA_SOURCE_ID {
let calculator_search = calculator::CalculatorSource::new(2000f64);
search_source_registry_tauri_state
.register_source(calculator_search)
.await;
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
extension_id,
update_extension,
)?;
return Ok(());
}
if extension_id == quick_ai_access::EXTENSION_ID {
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
extension_id,
update_extension,
)?;
return Ok(());
}
if extension_id == ai_overview::EXTENSION_ID {
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
extension_id,
update_extension,
)?;
return Ok(());
}
Ok(())
}
pub(crate) async fn disable_built_in_extension(extension_id: &str) -> Result<(), String> {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
let update_extension = |extension: &mut Extension| -> Result<(), String> {
extension.enabled = false;
Ok(())
};
if extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
search_source_registry_tauri_state
.remove_source(extension_id)
.await;
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
extension_id,
update_extension,
)?;
return Ok(());
}
// Check if this is an application
let application_prefix = format!(
"{}.",
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
);
if extension_id.starts_with(&application_prefix) {
let app_path = &extension_id[application_prefix.len()..];
application::disable_app_search(tauri_app_handle, app_path)?;
return Ok(());
}
if extension_id == calculator::DATA_SOURCE_ID {
search_source_registry_tauri_state
.remove_source(extension_id)
.await;
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
extension_id,
update_extension,
)?;
return Ok(());
}
if extension_id == quick_ai_access::EXTENSION_ID {
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
extension_id,
update_extension,
)?;
return Ok(());
}
if extension_id == ai_overview::EXTENSION_ID {
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
extension_id,
update_extension,
)?;
return Ok(());
}
Ok(())
}
pub(crate) fn set_built_in_extension_alias(extension_id: &str, alias: &str) {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let application_prefix = format!(
"{}.",
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
);
if extension_id.starts_with(&application_prefix) {
let app_path = &extension_id[application_prefix.len()..];
application::set_app_alias(tauri_app_handle, app_path, alias);
}
}
pub(crate) fn register_built_in_extension_hotkey(
extension_id: &str,
hotkey: &str,
) -> Result<(), String> {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let application_prefix = format!(
"{}.",
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
);
if extension_id.starts_with(&application_prefix) {
let app_path = &extension_id[application_prefix.len()..];
application::register_app_hotkey(&tauri_app_handle, app_path, hotkey)?;
}
Ok(())
}
pub(crate) fn unregister_built_in_extension_hotkey(extension_id: &str) -> Result<(), String> {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let application_prefix = format!(
"{}.",
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
);
if extension_id.starts_with(&application_prefix) {
let app_path = &extension_id[application_prefix.len()..];
application::unregister_app_hotkey(&tauri_app_handle, app_path)?;
}
Ok(())
}
pub(crate) async fn is_built_in_extension_enabled(extension_id: &str) -> Result<bool, String> {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
if extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
return Ok(search_source_registry_tauri_state
.get_source(extension_id)
.await
.is_some());
}
// Check if this is an application
let application_prefix = format!(
"{}.",
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
);
if extension_id.starts_with(&application_prefix) {
let app_path = &extension_id[application_prefix.len()..];
return Ok(application::is_app_search_enabled(app_path));
}
if extension_id == calculator::DATA_SOURCE_ID {
return Ok(search_source_registry_tauri_state
.get_source(extension_id)
.await
.is_some());
}
if extension_id == quick_ai_access::EXTENSION_ID {
let extension =
load_extension_from_json_file(&BUILT_IN_EXTENSION_DIRECTORY.as_path(), extension_id)?;
return Ok(extension.enabled);
}
if extension_id == ai_overview::EXTENSION_ID {
let extension =
load_extension_from_json_file(&BUILT_IN_EXTENSION_DIRECTORY.as_path(), extension_id)?;
return Ok(extension.enabled);
}
unreachable!("extension [{}] is not a built-in extension", extension_id)
}

View File

@@ -0,0 +1,51 @@
//! We use Pizza Engine to index applications and local files. The engine will be
//! run in the thread/runtime defined in this file.
//!
//! # Why such a thread/runtime is needed
//!
//! Generally, Tokio async runtime requires all the async tasks running on it to be
//! `Send` and `Sync`, but the async tasks created by Pizza Engine are not,
//! which forces us to create a dedicated thread/runtime to execute them.
use std::any::Any;
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::sync::OnceLock;
pub(crate) trait SearchSourceState {
#[cfg_attr(not(feature = "use_pizza_engine"), allow(unused))]
fn as_mut_any(&mut self) -> &mut dyn Any;
}
#[async_trait::async_trait(?Send)]
pub(crate) trait Task: Send + Sync {
fn search_source_id(&self) -> &'static str;
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>);
}
pub(crate) static RUNTIME_TX: OnceLock<tokio::sync::mpsc::UnboundedSender<Box<dyn Task>>> =
OnceLock::new();
pub(crate) fn start_pizza_engine_runtime() {
std::thread::spawn(|| {
let rt = tokio::runtime::Runtime::new().unwrap();
let main = async {
let mut states: HashMap<String, Option<Box<dyn SearchSourceState>>> = HashMap::new();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
RUNTIME_TX.set(tx).unwrap();
while let Some(mut task) = rx.recv().await {
let opt_search_source_state = match states.entry(task.search_source_id().into()) {
Entry::Occupied(o) => o.into_mut(),
Entry::Vacant(v) => v.insert(None),
};
task.exec(opt_search_source_state).await;
}
};
rt.block_on(main);
});
}

View File

@@ -0,0 +1 @@
pub(super) const EXTENSION_ID: &str = "QuickAIAccess";

View File

@@ -0,0 +1,825 @@
pub(crate) mod built_in;
mod third_party;
use crate::common::document::OnOpened;
use crate::{common::register::SearchSourceRegistry, GLOBAL_TAURI_APP_HANDLE};
use anyhow::Context;
use derive_more::Display;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as Json;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::path::Path;
use tauri::Manager;
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
const ASSETS_DIRECTORY_FILE_NAME: &str = "assets";
#[derive(Debug, Deserialize, Serialize, Copy, Clone, Hash, PartialEq, Eq, Display)]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
enum Platform {
#[display("macOS")]
Macos,
#[display("Linux")]
Linux,
#[display("windows")]
Windows,
}
/// Helper function to determine the current platform.
fn current_platform() -> Platform {
let os_str = std::env::consts::OS;
serde_plain::from_str(os_str).unwrap_or_else(|_e| {
panic!("std::env::consts::OS is [{}], which is not a valid value for [enum Platform], valid values: ['macos', 'linux', 'windows']", os_str)
})
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Extension {
/// Unique extension identifier.
id: String,
/// Extension name.
title: String,
/// Platforms supported by this extension.
///
/// If `None`, then this extension can be used on all the platforms.
#[serde(skip_serializing_if = "Option::is_none")]
platforms: Option<HashSet<Platform>>,
/// Extension description.
description: String,
//// Specify the icon for this extension, multi options are available:
///
/// 1. It can be a path to the icon file, the path can be
///
/// * relative (relative to the "assets" directory)
/// * absolute
/// 2. It can be a font class code, e.g., 'font_coco', if you want to use
/// Coco's built-in icons.
///
/// In cases where your icon file is named similarly to a font class code, Coco
/// will treat it as an icon file if it exists, i.e., if file `<extension>/assets/font_coco`
/// exists, then Coco will use this file rather than the built-in 'font_coco' icon.
icon: String,
r#type: ExtensionType,
/// If this is a Command extension, then action defines the operation to execute
/// when the it is triggered.
#[serde(skip_serializing_if = "Option::is_none")]
action: Option<CommandAction>,
/// The link to open if this is a QuickLink extension.
#[serde(skip_serializing_if = "Option::is_none")]
quick_link: Option<QuickLink>,
// If this extension is of type Group or Extension, then it behaves like a
// directory, i.e., it could contain sub items.
commands: Option<Vec<Extension>>,
scripts: Option<Vec<Extension>>,
quick_links: Option<Vec<Extension>>,
/// The alias of the extension.
///
/// Extension of type Group and Extension cannot have alias.
///
#[serde(skip_serializing_if = "Option::is_none")]
alias: Option<String>,
/// The hotkey of the extension.
///
/// Extension of type Group and Extension cannot have hotkey.
#[serde(skip_serializing_if = "Option::is_none")]
hotkey: Option<String>,
/// Is this extension enabled.
enabled: bool,
/// Extension settings
#[serde(skip_serializing_if = "Option::is_none")]
settings: Option<Json>,
}
impl Extension {
/// Whether this extension could be searched.
pub(crate) fn searchable(&self) -> bool {
self.on_opened().is_some()
}
/// Return what will happen when we open this extension.
///
/// `None` if it cannot be opened.
pub(crate) fn on_opened(&self) -> Option<OnOpened> {
match self.r#type {
ExtensionType::Group => None,
ExtensionType::Extension => None,
ExtensionType::Command => Some(OnOpened::Command {
action: self.action.clone().unwrap_or_else(|| {
panic!(
"Command extension [{}]'s [action] field is not set, something wrong with your extension validity check", self.id
)
}),
}),
ExtensionType::Application => Some(OnOpened::Application {
app_path: self.id.clone(),
}),
ExtensionType::Script => todo!("not supported yet"),
ExtensionType::Quicklink => todo!("not supported yet"),
ExtensionType::Setting => todo!("not supported yet"),
ExtensionType::Calculator => None,
ExtensionType::AiExtension => None,
}
}
/// Perform `how` against the extension specified by `extension_id`.
///
/// Please note that `extension_id` could point to a sub extension.
pub(crate) fn modify(
&mut self,
extension_id: &str,
how: impl FnOnce(&mut Self) -> Result<(), String>,
) -> Result<(), String> {
let (parent_extension_id, opt_sub_extension_id) = split_extension_id(extension_id);
assert_eq!(
parent_extension_id, self.id,
"modify() should be invoked against a parent extension"
);
let Some(sub_extension_id) = opt_sub_extension_id else {
how(self)?;
return Ok(());
};
// Search in commands
if let Some(ref mut commands) = self.commands {
if let Some(command) = commands.iter_mut().find(|cmd| cmd.id == sub_extension_id) {
how(command)?;
return Ok(());
}
}
// Search in scripts
if let Some(ref mut scripts) = self.scripts {
if let Some(script) = scripts.iter_mut().find(|scr| scr.id == sub_extension_id) {
how(script)?;
return Ok(());
}
}
// Search in quick_links
if let Some(ref mut quick_links) = self.quick_links {
if let Some(link) = quick_links
.iter_mut()
.find(|lnk| lnk.id == sub_extension_id)
{
how(link)?;
return Ok(());
}
}
Err(format!(
"extension [{}] not found in {:?}",
extension_id, self
))
}
/// Get the extension specified by `extension_id`.
///
/// Please note that `extension_id` could point to a sub extension.
pub(crate) fn get_extension_mut(&mut self, extension_id: &str) -> Option<&mut Self> {
let (parent_extension_id, opt_sub_extension_id) = split_extension_id(extension_id);
if parent_extension_id != self.id {
return None;
}
let Some(sub_extension_id) = opt_sub_extension_id else {
return Some(self);
};
self.get_sub_extension_mut(sub_extension_id)
}
pub(crate) fn get_sub_extension_mut(&mut self, sub_extension_id: &str) -> Option<&mut Self> {
if !self.r#type.contains_sub_items() {
return None;
}
if let Some(ref mut commands) = self.commands {
if let Some(sub_ext) = commands.iter_mut().find(|cmd| cmd.id == sub_extension_id) {
return Some(sub_ext);
}
}
if let Some(ref mut scripts) = self.scripts {
if let Some(sub_ext) = scripts
.iter_mut()
.find(|script| script.id == sub_extension_id)
{
return Some(sub_ext);
}
}
if let Some(ref mut quick_links) = self.quick_links {
if let Some(sub_ext) = quick_links
.iter_mut()
.find(|link| link.id == sub_extension_id)
{
return Some(sub_ext);
}
}
None
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub(crate) struct CommandAction {
pub(crate) exec: String,
pub(crate) args: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct QuickLink {
link: String,
}
#[derive(Debug, PartialEq, Deserialize, Serialize, Clone, Display)]
#[serde(rename_all(serialize = "snake_case", deserialize = "snake_case"))]
pub enum ExtensionType {
#[display("Group")]
Group,
#[display("Extension")]
Extension,
#[display("Command")]
Command,
#[display("Application")]
Application,
#[display("Script")]
Script,
#[display("Quicklink")]
Quicklink,
#[display("Setting")]
Setting,
#[display("Calculator")]
Calculator,
#[display("AI Extension")]
AiExtension,
}
impl ExtensionType {
pub(crate) fn contains_sub_items(&self) -> bool {
self == &Self::Group || self == &Self::Extension
}
}
fn canonicalize_relative_icon_path(
extension_dir: &Path,
extension: &mut Extension,
) -> Result<(), String> {
fn _canonicalize_relative_icon_path(
extension_dir: &Path,
extension: &mut Extension,
) -> Result<(), String> {
let icon_str = &extension.icon;
let icon_path = Path::new(icon_str);
if icon_path.is_relative() {
let absolute_icon_path = {
let mut assets_directory = extension_dir.join(ASSETS_DIRECTORY_FILE_NAME);
assets_directory.push(icon_path);
assets_directory
};
if absolute_icon_path.try_exists().map_err(|e| e.to_string())? {
extension.icon = absolute_icon_path
.into_os_string()
.into_string()
.expect("path should be UTF-8 encoded");
}
}
Ok(())
}
_canonicalize_relative_icon_path(extension_dir, extension)?;
if let Some(commands) = &mut extension.commands {
for command in commands {
_canonicalize_relative_icon_path(extension_dir, command)?;
}
}
if let Some(scripts) = &mut extension.scripts {
for script in scripts {
_canonicalize_relative_icon_path(extension_dir, script)?;
}
}
if let Some(quick_links) = &mut extension.quick_links {
for quick_link in quick_links {
_canonicalize_relative_icon_path(extension_dir, quick_link)?;
}
}
Ok(())
}
fn list_extensions_under_directory(directory: &Path) -> Result<(bool, Vec<Extension>), String> {
let mut found_invalid_extensions = false;
let extension_directory = std::fs::read_dir(&directory).map_err(|e| e.to_string())?;
let current_platform = current_platform();
let mut extensions = Vec::new();
for res_extension_dir in extension_directory {
let extension_dir = res_extension_dir.map_err(|e| e.to_string())?;
let file_type = extension_dir.file_type().map_err(|e| e.to_string())?;
if !file_type.is_dir() {
found_invalid_extensions = true;
log::warn!(
"invalid extension [{}]: a valid extension should be a directory, but it is not",
extension_dir.file_name().display()
);
// Skip invalid extension
continue;
}
let plugin_json_file_path = {
let mut path = extension_dir.path();
path.push(PLUGIN_JSON_FILE_NAME);
path
};
if !plugin_json_file_path.is_file() {
found_invalid_extensions = true;
log::warn!(
"invalid extension: [{}]: extension file [{}] should be a JSON file, but it is not",
extension_dir.file_name().display(),
plugin_json_file_path.display()
);
// Skip invalid extension
continue;
}
let mut extension = match serde_json::from_reader::<_, Extension>(
std::fs::File::open(&plugin_json_file_path).map_err(|e| e.to_string())?,
) {
Ok(extension) => extension,
Err(e) => {
found_invalid_extensions = true;
log::warn!(
"invalid extension: [{}]: extension file [{}] is invalid, error: '{}'",
extension_dir.file_name().display(),
plugin_json_file_path.display(),
e
);
continue;
}
};
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
if !validate_extension(
&extension,
&extension_dir.file_name(),
&extensions,
current_platform,
) {
found_invalid_extensions = true;
// Skip invalid extension
continue;
}
extensions.push(extension);
}
log::debug!(
"loaded extensions: {:?}",
extensions
.iter()
.map(|ext| ext.id.as_str())
.collect::<Vec<_>>()
);
Ok((found_invalid_extensions, extensions))
}
/// Return value:
///
/// * boolean: indicates if we found any invalid extensions
/// * Vec<Extension>: loaded extensions
#[tauri::command]
pub(crate) async fn list_extensions() -> Result<(bool, Vec<Extension>), String> {
log::trace!("loading extensions");
let third_party_dir = third_party::THIRD_PARTY_EXTENSION_DIRECTORY.as_path();
if !third_party_dir.try_exists().map_err(|e| e.to_string())? {
tokio::fs::create_dir_all(third_party_dir)
.await
.map_err(|e| e.to_string())?;
}
let (third_party_found_invalid_extension, mut third_party_extensions) =
list_extensions_under_directory(third_party_dir)?;
let built_in_dir = built_in::BUILT_IN_EXTENSION_DIRECTORY.as_path();
let (built_in_found_invalid_extension, built_in_extensions) =
list_extensions_under_directory(built_in_dir)?;
let found_invalid_extension =
third_party_found_invalid_extension || built_in_found_invalid_extension;
let extensions = {
third_party_extensions.extend(built_in_extensions);
third_party_extensions
};
Ok((found_invalid_extension, extensions))
}
/// Helper function to validate `extension`, return `true` if it is valid.
fn validate_extension(
extension: &Extension,
extension_dir_name: &OsStr,
listed_extensions: &[Extension],
current_platform: Platform,
) -> bool {
if OsStr::new(&extension.id) != extension_dir_name {
log::warn!(
"invalid extension []: id [{}] and extension directory name [{}] do not match",
extension.id,
extension_dir_name.display()
);
return false;
}
// Extension ID should be unique
if listed_extensions.iter().any(|ext| ext.id == extension.id) {
log::warn!(
"invalid extension []: extension with id [{}] already exists",
extension.id,
);
return false;
}
if !validate_extension_or_sub_item(extension) {
return false;
}
// Extension is incompatible
if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&current_platform) {
log::warn!("extension [{}] is not compatible with the current platform [{}], it is available to {:?}", extension.id, current_platform, platforms.iter().map(|os|os.to_string()).collect::<Vec<_>>());
return false;
}
}
if let Some(ref commands) = extension.commands {
if !validate_sub_items(&extension.id, commands) {
return false;
}
}
if let Some(ref scripts) = extension.scripts {
if !validate_sub_items(&extension.id, scripts) {
return false;
}
}
if let Some(ref quick_links) = extension.quick_links {
if !validate_sub_items(&extension.id, quick_links) {
return false;
}
}
true
}
/// Checks that can be performed against an extension or a sub item.
fn validate_extension_or_sub_item(extension: &Extension) -> bool {
// Only
//
// 1. letters
// 2. hyphens
// 3. numbers
//
// are allowed in the ID.
if !extension
.id
.chars()
.all(|c| c.is_ascii_alphabetic() || c == '-')
{
log::warn!(
"invalid extension [{}], [id] should contain only letters, numbers, or hyphens",
extension.id
);
return false;
}
// If field `action` is Some, then it should be a Command
if extension.action.is_some() && extension.r#type != ExtensionType::Command {
log::warn!(
"invalid extension [{}], [action] is set for a non-Command extension",
extension.id
);
return false;
}
if extension.r#type == ExtensionType::Command && extension.action.is_none() {
log::warn!(
"invalid extension [{}], [action] should be set for a Command extension",
extension.id
);
return false;
}
// If field `quick_link` is Some, then it should be a QuickLink
if extension.quick_link.is_some() && extension.r#type != ExtensionType::Quicklink {
log::warn!(
"invalid extension [{}], [quick_link] is set for a non-QuickLink extension",
extension.id
);
return false;
}
if extension.r#type == ExtensionType::Quicklink && extension.quick_link.is_none() {
log::warn!(
"invalid extension [{}], [quick_link] should be set for a QuickLink extension",
extension.id
);
return false;
}
// Group and Extension cannot have alias
if extension.alias.is_some() {
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
{
log::warn!(
"invalid extension [{}], extension of type [{:?}] cannot have alias",
extension.id,
extension.r#type
);
return false;
}
}
// Group and Extension cannot have hotkey
if extension.hotkey.is_some() {
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
{
log::warn!(
"invalid extension [{}], extension of type [{:?}] cannot have hotkey",
extension.id,
extension.r#type
);
return false;
}
}
if extension.commands.is_some()
|| extension.scripts.is_some()
|| extension.quick_links.is_some()
{
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
{
log::warn!(
"invalid extension [{}], only extension of type [Group] and [Extension] can have sub-items",
extension.id,
);
return false;
}
}
true
}
/// Helper function to check sub-items.
fn validate_sub_items(extension_id: &str, sub_items: &[Extension]) -> bool {
for (sub_item_index, sub_item) in sub_items.iter().enumerate() {
// If field `action` is Some, then it should be a Command
if sub_item.action.is_some() && sub_item.r#type != ExtensionType::Command {
log::warn!(
"invalid extension sub-item [{}-{}]: [action] is set for a non-Command extension",
extension_id,
sub_item.id
);
return false;
}
if sub_item.r#type == ExtensionType::Group || sub_item.r#type == ExtensionType::Extension {
log::warn!(
"invalid extension sub-item [{}-{}]: sub-item should not be of type [Group] or [Extension]",
extension_id, sub_item.id
);
return false;
}
let sub_item_with_same_id_count = sub_items
.iter()
.enumerate()
.filter(|(_idx, ext)| ext.id == sub_item.id)
.filter(|(idx, _ext)| *idx != sub_item_index)
.count();
if sub_item_with_same_id_count != 0 {
log::warn!(
"invalid extension [{}]: found more than one sub-items with the same ID [{}]",
extension_id,
sub_item.id
);
return false;
}
if !validate_extension_or_sub_item(sub_item) {
return false;
}
if sub_item.platforms.is_some() {
log::warn!(
"invalid extension [{}]: key [platforms] should not be set in sub-items",
extension_id,
);
return false;
}
}
true
}
pub(crate) async fn init_extensions(mut extensions: Vec<Extension>) -> Result<(), String> {
log::trace!("initializing extensions");
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
built_in::application::ApplicationSearchSource::init(tauri_app_handle.clone()).await?;
// Init the built-in enabled extensions
for built_in_extension in extensions
.extract_if(.., |ext| built_in::is_extension_built_in(&ext.id))
.filter(|ext| ext.enabled)
{
built_in::init_built_in_extension(&built_in_extension, &search_source_registry_tauri_state)
.await;
}
// Now the third-party extensions
let third_party_search_source = third_party::ThirdPartyExtensionsSearchSource::new(extensions);
third_party_search_source
.restore_extensions_hotkey()
.await?;
let third_party_search_source_clone = third_party_search_source.clone();
// Set the global search source so that we can access it in `#[tauri::command]`s
// ignore the result because this function will be invoked twice, which
// means this global variable will be set twice.
let _ = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.set(third_party_search_source_clone);
search_source_registry_tauri_state
.register_source(third_party_search_source)
.await;
Ok(())
}
#[tauri::command]
pub(crate) async fn enable_extension(extension_id: String) -> Result<(), String> {
println!("enable_extension: {}", extension_id);
if built_in::is_extension_built_in(&extension_id) {
built_in::enable_built_in_extension(&extension_id).await?;
return Ok(());
}
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").enable_extension(&extension_id).await
}
#[tauri::command]
pub(crate) async fn disable_extension(extension_id: String) -> Result<(), String> {
println!("disable_extension: {}", extension_id);
if built_in::is_extension_built_in(&extension_id) {
built_in::disable_built_in_extension(&extension_id).await?;
return Ok(());
}
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").disable_extension(&extension_id).await
}
#[tauri::command]
pub(crate) async fn set_extension_alias(extension_id: String, alias: String) -> Result<(), String> {
if built_in::is_extension_built_in(&extension_id) {
built_in::set_built_in_extension_alias(&extension_id, &alias);
return Ok(());
}
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").set_extension_alias(&extension_id, &alias).await
}
#[tauri::command]
pub(crate) async fn register_extension_hotkey(
extension_id: String,
hotkey: String,
) -> Result<(), String> {
println!("register_extension_hotkey: {}, {}", extension_id, hotkey);
if built_in::is_extension_built_in(&extension_id) {
built_in::register_built_in_extension_hotkey(&extension_id, &hotkey)?;
return Ok(());
}
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").register_extension_hotkey(&extension_id, &hotkey).await
}
/// NOTE: this function won't error out if the extension specified by `extension_id`
/// has no hotkey set because we need it to behave like this.
#[tauri::command]
pub(crate) async fn unregister_extension_hotkey(extension_id: String) -> Result<(), String> {
if built_in::is_extension_built_in(&extension_id) {
built_in::unregister_built_in_extension_hotkey(&extension_id)?;
return Ok(());
}
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").unregister_extension_hotkey(&extension_id).await?;
Ok(())
}
#[tauri::command]
pub(crate) async fn is_extension_enabled(extension_id: String) -> Result<bool, String> {
if built_in::is_extension_built_in(&extension_id) {
return built_in::is_built_in_extension_enabled(&extension_id).await;
}
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").is_extension_enabled(&extension_id).await
}
fn split_extension_id(extension_id: &str) -> (&str, Option<&str>) {
match extension_id.find('.') {
Some(idx) => (&extension_id[..idx], Some(&extension_id[idx + 1..])),
None => (extension_id, None),
}
}
fn load_extension_from_json_file(
extension_directory: &Path,
extension_id: &str,
) -> Result<Extension, String> {
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
let json_file_path = {
let mut extension_directory_path = extension_directory.join(parent_extension_id);
extension_directory_path.push(PLUGIN_JSON_FILE_NAME);
extension_directory_path
};
let mut extension = serde_json::from_reader::<_, Extension>(
std::fs::File::open(&json_file_path)
.with_context(|| {
format!(
"the [{}] file for extension [{}] is missing or broken",
PLUGIN_JSON_FILE_NAME, parent_extension_id
)
})
.map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
canonicalize_relative_icon_path(extension_directory, &mut extension)?;
Ok(extension)
}
fn alter_extension_json_file(
extension_directory: &Path,
extension_id: &str,
how: impl Fn(&mut Extension) -> Result<(), String>,
) -> Result<(), String> {
log::debug!(
"altering extension JSON file for extension [{}]",
extension_id
);
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
let json_file_path = {
let mut extension_directory_path = extension_directory.join(parent_extension_id);
extension_directory_path.push(PLUGIN_JSON_FILE_NAME);
extension_directory_path
};
let mut extension = serde_json::from_reader::<_, Extension>(
std::fs::File::open(&json_file_path)
.with_context(|| {
format!(
"the [{}] file for extension [{}] is missing or broken",
PLUGIN_JSON_FILE_NAME, parent_extension_id
)
})
.map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
extension.modify(extension_id, how)?;
std::fs::write(
&json_file_path,
serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -0,0 +1,755 @@
use super::alter_extension_json_file;
use super::Extension;
use super::LOCAL_QUERY_SOURCE_TYPE;
use crate::common::document::open;
use crate::common::document::DataSourceReference;
use crate::common::document::Document;
use crate::common::error::SearchError;
use crate::common::search::QueryResponse;
use crate::common::search::QuerySource;
use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource;
use crate::extension::split_extension_id;
use crate::GLOBAL_TAURI_APP_HANDLE;
use async_trait::async_trait;
use function_name::named;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::LazyLock;
use std::sync::OnceLock;
use tauri::async_runtime;
use tauri::Manager;
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::ShortcutState;
use tokio::sync::RwLock;
pub(crate) static THIRD_PARTY_EXTENSION_DIRECTORY: LazyLock<PathBuf> = LazyLock::new(|| {
let mut app_data_dir = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set")
.path()
.app_data_dir()
.expect(
"User home directory not found, which should be impossible on desktop environments",
);
app_data_dir.push("extension");
app_data_dir
});
/// All the third-party extensions will be registered as one search source.
///
/// Since some `#[tauri::command]`s need to access it, we store it in a global
/// static variable as well.
#[derive(Debug, Clone)]
pub(super) struct ThirdPartyExtensionsSearchSource {
inner: Arc<ThirdPartyExtensionsSearchSourceInner>,
}
impl ThirdPartyExtensionsSearchSource {
pub(super) fn new(extensions: Vec<Extension>) -> Self {
Self {
inner: Arc::new(ThirdPartyExtensionsSearchSourceInner {
extensions: RwLock::new(extensions),
}),
}
}
#[named]
pub(super) async fn enable_extension(&self, extension_id: &str) -> Result<(), String> {
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
let mut extensions_write_lock = self.inner.extensions.write().await;
let opt_index = extensions_write_lock
.iter()
.position(|ext| ext.id == parent_extension_id);
let Some(index) = opt_index else {
return Err(format!(
"{} invoked with an extension that does not exist [{}]",
function_name!(),
extension_id
));
};
let extension = extensions_write_lock
.get_mut(index)
.expect("just checked this extension exists");
let update_extension = |ext: &mut Extension| -> Result<(), String> {
if ext.enabled {
return Err(format!(
"{} invoked with an extension that is already enabled [{}]",
function_name!(),
extension_id
));
}
ext.enabled = true;
Ok(())
};
extension.modify(extension_id, update_extension)?;
alter_extension_json_file(
&THIRD_PARTY_EXTENSION_DIRECTORY,
extension_id,
update_extension,
)?;
Ok(())
}
#[named]
pub(super) async fn disable_extension(&self, extension_id: &str) -> Result<(), String> {
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
let mut extensions_write_lock = self.inner.extensions.write().await;
let opt_index = extensions_write_lock
.iter()
.position(|ext| ext.id == parent_extension_id);
let Some(index) = opt_index else {
return Err(format!(
"{} invoked with an extension that does not exist [{}]",
function_name!(),
extension_id
));
};
let extension = extensions_write_lock
.get_mut(index)
.expect("just checked this extension exists");
let update_extension = |ext: &mut Extension| -> Result<(), String> {
if !ext.enabled {
return Err(format!(
"{} invoked with an extension that is already enabled [{}]",
function_name!(),
extension_id
));
}
ext.enabled = false;
Ok(())
};
extension.modify(extension_id, update_extension)?;
alter_extension_json_file(
&THIRD_PARTY_EXTENSION_DIRECTORY,
extension_id,
update_extension,
)?;
Ok(())
}
#[named]
pub(super) async fn set_extension_alias(
&self,
extension_id: &str,
alias: &str,
) -> Result<(), String> {
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
let mut extensions_write_lock = self.inner.extensions.write().await;
let opt_index = extensions_write_lock
.iter()
.position(|ext| ext.id == parent_extension_id);
let Some(index) = opt_index else {
log::warn!(
"{} invoked with an extension that does not exist [{}]",
function_name!(),
extension_id
);
return Ok(());
};
let extension = extensions_write_lock
.get_mut(index)
.expect("just checked this extension exists");
let update_extension = |ext: &mut Extension| -> Result<(), String> {
ext.alias = Some(alias.to_string());
Ok(())
};
extension.modify(extension_id, update_extension)?;
alter_extension_json_file(
&THIRD_PARTY_EXTENSION_DIRECTORY,
extension_id,
update_extension,
)?;
Ok(())
}
pub(super) async fn restore_extensions_hotkey(&self) -> Result<(), String> {
fn set_up_hotkey<R: tauri::Runtime>(
tauri_app_handle: &tauri::AppHandle<R>,
extension: &Extension,
) -> Result<(), String> {
if let Some(ref hotkey) = extension.hotkey {
let on_opened = extension.on_opened().unwrap_or_else(|| panic!( "extension has hotkey, but on_open() returns None, extension ID [{}], extension type [{:?}]", extension.id, extension.r#type));
let extension_id_clone = extension.id.clone();
tauri_app_handle
.global_shortcut()
.on_shortcut(hotkey.as_str(), move |_tauri_app_handle, _hotkey, event| {
let on_opened_clone = on_opened.clone();
let extension_id_clone = extension_id_clone.clone();
if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move {
let result = open(on_opened_clone).await;
if let Err(msg) = result {
log::warn!(
"failed to open extension [{}], error [{}]",
extension_id_clone,
msg
);
}
});
}
})
.map_err(|e| e.to_string())?;
}
Ok(())
}
let extensions_read_lock = self.inner.extensions.read().await;
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
for extension in extensions_read_lock.iter() {
if extension.r#type.contains_sub_items() {
if let Some(commands) = &extension.commands {
for command in commands.iter().filter(|cmd| cmd.enabled) {
set_up_hotkey(tauri_app_handle, command)?;
}
}
if let Some(scripts) = &extension.scripts {
for script in scripts.iter().filter(|script| script.enabled) {
set_up_hotkey(tauri_app_handle, script)?;
}
}
if let Some(quick_links) = &extension.quick_links {
for quick_link in quick_links.iter().filter(|link| link.enabled) {
set_up_hotkey(tauri_app_handle, quick_link)?;
}
}
} else {
set_up_hotkey(tauri_app_handle, extension)?;
}
}
Ok(())
}
#[named]
pub(super) async fn register_extension_hotkey(
&self,
extension_id: &str,
hotkey: &str,
) -> Result<(), String> {
self.unregister_extension_hotkey(extension_id).await?;
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
let mut extensions_write_lock = self.inner.extensions.write().await;
let opt_index = extensions_write_lock
.iter()
.position(|ext| ext.id == parent_extension_id);
let Some(index) = opt_index else {
return Err(format!(
"{} invoked with an extension that does not exist [{}]",
function_name!(),
extension_id
));
};
let mut extension = extensions_write_lock
.get_mut(index)
.expect("just checked this extension exists");
let update_extension = |ext: &mut Extension| -> Result<(), String> {
ext.hotkey = Some(hotkey.into());
Ok(())
};
// Update extension (memory and file)
extension.modify(extension_id, update_extension)?;
alter_extension_json_file(
&THIRD_PARTY_EXTENSION_DIRECTORY,
extension_id,
update_extension,
)?;
// To make borrow checker happy
let extension_dbg_string = format!("{:?}", extension);
extension = match extension.get_extension_mut(extension_id) {
Some(ext) => ext,
None => {
panic!(
"extension [{}] should be found in {}",
extension_id, extension_dbg_string
)
}
};
// Set hotkey
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let on_opened = extension.on_opened().unwrap_or_else(|| panic!(
"setting hotkey for an extension that cannot be opened, extension ID [{}], extension type [{:?}]", extension_id, extension.r#type,
));
let extension_id_clone = extension_id.to_string();
tauri_app_handle
.global_shortcut()
.on_shortcut(hotkey, move |_tauri_app_handle, _hotkey, event| {
let on_opened_clone = on_opened.clone();
let extension_id_clone = extension_id_clone.clone();
if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move {
let result = open(on_opened_clone).await;
if let Err(msg) = result {
log::warn!(
"failed to open extension [{}], error [{}]",
extension_id_clone,
msg
);
}
});
}
})
.map_err(|e| e.to_string())?;
Ok(())
}
/// NOTE: this function won't error out if the extension specified by `extension_id`
/// has no hotkey set because we need it to behave like this.
#[named]
pub(super) async fn unregister_extension_hotkey(
&self,
extension_id: &str,
) -> Result<(), String> {
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
let mut extensions_write_lock = self.inner.extensions.write().await;
let opt_index = extensions_write_lock
.iter()
.position(|ext| ext.id == parent_extension_id);
let Some(index) = opt_index else {
return Err(format!(
"{} invoked with an extension that does not exist [{}]",
function_name!(),
extension_id
));
};
let parent_extension = extensions_write_lock
.get_mut(index)
.expect("just checked this extension exists");
let Some(extension) = parent_extension.get_extension_mut(extension_id) else {
return Err(format!(
"{} invoked with an extension that does not exist [{}]",
function_name!(),
extension_id
));
};
let Some(hotkey) = extension.hotkey.clone() else {
log::warn!(
"extension [{}] has no hotkey set, but we are trying to unregister it",
extension_id
);
return Ok(());
};
let update_extension = |extension: &mut Extension| -> Result<(), String> {
extension.hotkey = None;
Ok(())
};
parent_extension.modify(extension_id, update_extension)?;
alter_extension_json_file(
&THIRD_PARTY_EXTENSION_DIRECTORY,
extension_id,
update_extension,
)?;
// Set hotkey
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
tauri_app_handle
.global_shortcut()
.unregister(hotkey.as_str())
.map_err(|e| e.to_string())?;
Ok(())
}
#[named]
pub(super) async fn is_extension_enabled(&self, extension_id: &str) -> Result<bool, String> {
let (parent_extension_id, opt_sub_extension_id) = split_extension_id(extension_id);
let extensions_read_lock = self.inner.extensions.read().await;
let opt_index = extensions_read_lock
.iter()
.position(|ext| ext.id == parent_extension_id);
let Some(index) = opt_index else {
return Err(format!(
"{} invoked with an extension that does not exist [{}]",
function_name!(),
extension_id
));
};
let extension = extensions_read_lock
.get(index)
.expect("just checked this extension exists");
if let Some(sub_extension_id) = opt_sub_extension_id {
// For a sub-extension, it is enabled iff:
//
// 1. Its parent extension is enabled, and
// 2. It is enabled
if !extension.enabled {
return Ok(false);
}
if let Some(ref commands) = extension.commands {
if let Some(sub_ext) = commands.iter().find(|cmd| cmd.id == sub_extension_id) {
return Ok(sub_ext.enabled);
}
}
if let Some(ref scripts) = extension.scripts {
if let Some(sub_ext) = scripts.iter().find(|script| script.id == sub_extension_id) {
return Ok(sub_ext.enabled);
}
}
if let Some(ref commands) = extension.commands {
if let Some(sub_ext) = commands
.iter()
.find(|quick_link| quick_link.id == sub_extension_id)
{
return Ok(sub_ext.enabled);
}
}
Err(format!(
"{} invoked with a sub-extension that does not exist [{}/{}]",
function_name!(),
parent_extension_id,
sub_extension_id
))
} else {
Ok(extension.enabled)
}
}
}
pub(super) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
OnceLock::new();
#[derive(Debug)]
struct ThirdPartyExtensionsSearchSourceInner {
extensions: RwLock<Vec<Extension>>,
}
#[async_trait]
impl SearchSource for ThirdPartyExtensionsSearchSource {
fn get_type(&self) -> QuerySource {
QuerySource {
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
name: hostname::get()
.unwrap_or("My Computer".into())
.to_string_lossy()
.into(),
id: "extensions".into(),
}
}
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
let Some(query_string) = query.query_strings.get("query") else {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
};
let opt_data_source = query
.query_strings
.get("datasource")
.map(|owned_str| owned_str.as_str());
let mut hits = Vec::new();
let extensions_read_lock = self.inner.extensions.read().await;
let query_lower = query_string.to_lowercase();
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
if extension.r#type.contains_sub_items() {
if let Some(ref commands) = extension.commands {
for command in commands.iter().filter(|cmd| cmd.enabled) {
if let Some(hit) = extension_to_hit(command, &query_lower, opt_data_source)
{
hits.push(hit);
}
}
}
if let Some(ref scripts) = extension.scripts {
for script in scripts.iter().filter(|script| script.enabled) {
if let Some(hit) = extension_to_hit(script, &query_lower, opt_data_source) {
hits.push(hit);
}
}
}
if let Some(ref quick_links) = extension.quick_links {
for quick_link in quick_links.iter().filter(|link| link.enabled) {
if let Some(hit) =
extension_to_hit(quick_link, &query_lower, opt_data_source)
{
hits.push(hit);
}
}
}
} else {
if let Some(hit) = extension_to_hit(extension, &query_lower, opt_data_source) {
hits.push(hit);
}
}
}
let total_hits = hits.len();
Ok(QueryResponse {
source: self.get_type(),
hits,
total_hits,
})
}
}
fn extension_to_hit(
extension: &Extension,
query_lower: &str,
opt_data_source: Option<&str>,
) -> Option<(Document, f64)> {
if !extension.searchable() {
return None;
}
let extension_type_string = extension.r#type.to_string();
if let Some(data_source) = opt_data_source {
let document_data_source_id = extension_type_string.as_str();
if document_data_source_id != data_source {
return None;
}
}
let mut total_score = 0.0;
// Score based on title match
// Title is considered more important, so it gets a higher weight.
if let Some(title_score) =
calculate_text_similarity(&query_lower, &extension.title.to_lowercase())
{
total_score += title_score * 1.0; // Weight for title
}
// Score based on alias match if available
// Alias is considered less important than title, so it gets a lower weight.
if let Some(alias) = &extension.alias {
if let Some(alias_score) = calculate_text_similarity(&query_lower, &alias.to_lowercase()) {
total_score += alias_score * 0.7; // Weight for alias
}
}
// Only include if there's some relevance (score is meaningfully positive)
if total_score > 0.01 {
let on_opened = extension.on_opened().unwrap_or_else(|| {
panic!(
"extension (id [{}], type [{:?}]) is searchable, and should have a valid on_opened",
extension.id, extension.r#type
)
});
let url = on_opened.url();
let document = Document {
id: extension.id.clone(),
title: Some(extension.title.clone()),
icon: Some(extension.icon.clone()),
on_opened: Some(on_opened),
url: Some(url),
category: Some(extension_type_string.clone()),
source: Some(DataSourceReference {
id: Some(extension_type_string.clone()),
name: Some(extension_type_string.clone()),
icon: None,
r#type: Some(extension_type_string),
}),
..Default::default()
};
Some((document, total_score))
} else {
None
}
}
// Calculates a similarity score between a query and a text, aiming for a [0, 1] range.
// Assumes query and text are already lowercased.
fn calculate_text_similarity(query: &str, text: &str) -> Option<f64> {
if query.is_empty() || text.is_empty() {
return None;
}
if text == query {
return Some(1.0); // Perfect match
}
let query_len = query.len() as f64;
let text_len = text.len() as f64;
let ratio = query_len / text_len;
let mut score: f64 = 0.0;
// Case 1: Text starts with the query (prefix match)
// Score: base 0.5, bonus up to 0.4 for how much of `text` is covered by `query`. Max 0.9.
if text.starts_with(query) {
score = score.max(0.5 + 0.4 * ratio);
}
// Case 2: Text contains the query (substring match, not necessarily prefix)
// Score: base 0.3, bonus up to 0.3. Max 0.6.
// `score.max` ensures that if it's both a prefix and contains, the higher score (prefix) is taken.
if text.contains(query) {
score = score.max(0.3 + 0.3 * ratio);
}
// Case 3: Fallback for "all query characters exist in text" (order-independent)
if score < 0.2 {
if query.chars().all(|c_q| text.contains(c_q)) {
score = score.max(0.15); // Fixed low score for this weaker match type
}
}
if score > 0.0 {
// Cap non-perfect matches slightly below 1.0 to make perfect (1.0) distinct.
Some(score.min(0.95))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
// Helper function for approximate floating point comparison
fn approx_eq(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-10
}
#[test]
fn test_empty_strings() {
assert_eq!(calculate_text_similarity("", "text"), None);
assert_eq!(calculate_text_similarity("query", ""), None);
assert_eq!(calculate_text_similarity("", ""), None);
}
#[test]
fn test_perfect_match() {
assert_eq!(calculate_text_similarity("text", "text"), Some(1.0));
assert_eq!(calculate_text_similarity("a", "a"), Some(1.0));
}
#[test]
fn test_prefix_match() {
// For "te" and "text":
// score = 0.5 + 0.4 * (2/4) = 0.5 + 0.2 = 0.7
let score = calculate_text_similarity("te", "text").unwrap();
assert!(approx_eq(score, 0.7));
// For "tex" and "text":
// score = 0.5 + 0.4 * (3/4) = 0.5 + 0.3 = 0.8
let score = calculate_text_similarity("tex", "text").unwrap();
assert!(approx_eq(score, 0.8));
}
#[test]
fn test_substring_match() {
// For "ex" and "text":
// score = 0.3 + 0.3 * (2/4) = 0.3 + 0.15 = 0.45
let score = calculate_text_similarity("ex", "text").unwrap();
assert!(approx_eq(score, 0.45));
// Prefix should score higher than substring
assert!(
calculate_text_similarity("te", "text").unwrap()
> calculate_text_similarity("ex", "text").unwrap()
);
}
#[test]
fn test_character_presence() {
// Characters present but not in sequence
// "tac" in "contact" - not a substring, but all chars exist
let score = calculate_text_similarity("tac", "contact").unwrap();
assert!(approx_eq(0.3 + 0.3 * (3.0 / 7.0), score));
assert!(calculate_text_similarity("ac", "contact").is_some());
// Should not apply if some characters are missing
assert_eq!(calculate_text_similarity("xyz", "contact"), None);
}
#[test]
fn test_combined_scenarios() {
// Test that character presence fallback doesn't override higher scores
// "tex" is a prefix of "text" with score 0.8
let score = calculate_text_similarity("tex", "text").unwrap();
assert!(approx_eq(score, 0.8));
// Test a case where the characters exist but it's already a substring
// "act" is a substring of "contact" with score > 0.2, so fallback won't apply
let expected_score = 0.3 + 0.3 * (3.0 / 7.0);
let actual_score = calculate_text_similarity("act", "contact").unwrap();
assert!(approx_eq(actual_score, expected_score));
}
#[test]
fn test_no_similarity() {
assert_eq!(calculate_text_similarity("xyz", "test"), None);
}
#[test]
fn test_score_capping() {
// Use a long query that's a prefix of a slightly longer text
let long_text = "abcdefghijklmnopqrstuvwxyz";
let long_prefix = "abcdefghijklmnopqrstuvwxy"; // All but last letter
// Expected score would be 0.5 + 0.4 * (25/26) = 0.5 + 0.385 = 0.885
let expected_score = 0.5 + 0.4 * (25.0 / 26.0);
let actual_score = calculate_text_similarity(long_prefix, long_text).unwrap();
assert!(approx_eq(actual_score, expected_score));
// Verify that non-perfect matches are capped at 0.95
assert!(calculate_text_similarity("almost", "almost perfect").unwrap() <= 0.95);
}
}

View File

@@ -1,9 +1,10 @@
mod assistant;
mod autostart;
mod common;
mod local;
mod extension;
mod search;
mod server;
mod settings;
mod setup;
mod shortcut;
mod util;
@@ -15,7 +16,9 @@ use crate::server::servers::{load_or_insert_default_server, load_servers_token};
use autostart::{change_autostart, enable_autostart};
use lazy_static::lazy_static;
use std::sync::Mutex;
use std::sync::OnceLock;
use tauri::async_runtime::block_on;
use tauri::plugin::TauriPlugin;
#[cfg(target_os = "macos")]
use tauri::ActivationPolicy;
use tauri::{
@@ -30,6 +33,10 @@ lazy_static! {
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
}
/// To allow us to access tauri's `AppHandle` when its context is inaccessible,
/// store it globally. It will be set in `init()`.
pub(crate) static GLOBAL_TAURI_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
#[tauri::command]
async fn change_window_height(handle: AppHandle, height: u32) {
let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
@@ -55,15 +62,13 @@ struct Payload {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let ctx = tauri::generate_context!();
// Initialize logger
env_logger::init();
let mut app_builder = tauri::Builder::default();
#[cfg(desktop)]
{
app_builder = app_builder.plugin(tauri_plugin_single_instance::init(|_app, argv, _cwd| {
println!("a new app instance was opened with {argv:?} and the deep link event was already triggered");
log::debug!("a new app instance was opened with {argv:?} and the deep link event was already triggered");
// when defining deep link schemes at runtime, you must also check `argv` here
}));
}
@@ -83,7 +88,8 @@ pub fn run() {
.plugin(tauri_plugin_screenshots::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_windows_version::init());
.plugin(tauri_plugin_windows_version::init())
.plugin(set_up_tauri_logger());
// Conditional compilation for macOS
#[cfg(target_os = "macos")]
@@ -125,6 +131,8 @@ pub fn run() {
assistant::delete_session_chat,
assistant::update_session_chat,
assistant::assistant_search,
assistant::assistant_get,
assistant::assistant_get_multi,
// server::get_coco_server_datasources,
// server::get_coco_server_connectors,
server::websocket::connect_to_server,
@@ -134,12 +142,31 @@ pub fn run() {
server::attachment::get_attachment,
server::attachment::delete_attachment,
server::transcription::transcription,
local::application::get_default_search_paths,
local::application::list_app_with_metadata_in,
util::open,
server::system_settings::get_system_settings
server::system_settings::get_system_settings,
simulate_mouse_click,
extension::built_in::application::get_app_list,
extension::built_in::application::get_app_search_path,
extension::built_in::application::get_app_metadata,
extension::built_in::application::add_app_search_path,
extension::built_in::application::remove_app_search_path,
extension::list_extensions,
extension::enable_extension,
extension::disable_extension,
extension::set_extension_alias,
extension::register_extension_hotkey,
extension::unregister_extension_hotkey,
extension::is_extension_enabled,
settings::set_allow_self_signature,
settings::get_allow_self_signature,
assistant::ask_ai,
crate::common::document::open,
])
.setup(|app| {
let app_handle = app.handle().clone();
GLOBAL_TAURI_APP_HANDLE
.set(app_handle.clone())
.expect("variable already initialized");
let registry = SearchSourceRegistry::default();
app.manage(registry); // Store registry in Tauri's app state
@@ -159,7 +186,7 @@ pub fn run() {
// app.listen("theme-changed", move |event| {
// if let Ok(payload) = serde_json::from_str::<ThemeChangedPayload>(event.payload()) {
// // switch_tray_icon(app.app_handle(), payload.is_dark_mode);
// println!("Theme changed: is_dark_mode = {}", payload.is_dark_mode);
// log::debug!("Theme changed: is_dark_mode = {}", payload.is_dark_mode);
// }
// });
@@ -185,7 +212,7 @@ pub fn run() {
})
.on_window_event(|window, event| match event {
WindowEvent::CloseRequested { api, .. } => {
dbg!("Close requested event received");
//dbg!("Close requested event received");
window.hide().unwrap();
api.prevent_close();
}
@@ -200,10 +227,10 @@ pub fn run() {
has_visible_windows,
..
} => {
dbg!(
"Reopen event received: has_visible_windows = {}",
has_visible_windows
);
// dbg!(
// "Reopen event received: has_visible_windows = {}",
// has_visible_windows
// );
if has_visible_windows {
return;
}
@@ -217,11 +244,11 @@ pub fn run() {
pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
// Await the async functions to load the servers and tokens
if let Err(err) = load_or_insert_default_server(app_handle).await {
eprintln!("Failed to load servers: {}", err);
log::error!("Failed to load servers: {}", err);
}
if let Err(err) = load_servers_token(app_handle).await {
eprintln!("Failed to load server tokens: {}", err);
log::error!("Failed to load server tokens: {}", err);
}
let coco_servers = server::servers::get_all_servers();
@@ -233,31 +260,20 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
crate::server::servers::try_register_server_to_search_source(app_handle.clone(), &server)
.await;
}
}
async fn init_app_search_source<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
let application_search =
local::application::ApplicationSearchSource::new(app_handle.clone(), 1000f64).await?;
let calculator_search = local::calculator::CalculatorSource::new(2000f64);
// Register the application search source
let registry = app_handle.state::<SearchSourceRegistry>();
registry.register_source(application_search).await;
registry.register_source(calculator_search).await;
Ok(())
extension::built_in::pizza_engine_runtime::start_pizza_engine_runtime();
}
#[tauri::command]
async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
if let Some(window) = app_handle.get_window(MAIN_WINDOW_LABEL) {
let _ = app_handle.emit("show-coco", ());
move_window_to_active_monitor(&window);
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
let _ = app_handle.emit("show-coco", ());
}
}
@@ -265,22 +281,22 @@ async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
if let Err(err) = window.hide() {
eprintln!("Failed to hide the window: {}", err);
log::error!("Failed to hide the window: {}", err);
} else {
println!("Window successfully hidden.");
log::debug!("Window successfully hidden.");
}
} else {
eprintln!("Main window not found.");
log::error!("Main window not found.");
}
}
fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
dbg!("Moving window to active monitor");
//dbg!("Moving window to active monitor");
// Try to get the available monitors, handle failure gracefully
let available_monitors = match window.available_monitors() {
Ok(monitors) => monitors,
Err(e) => {
eprintln!("Failed to get monitors: {}", e);
log::error!("Failed to get monitors: {}", e);
return;
}
};
@@ -289,7 +305,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
let cursor_position = match window.cursor_position() {
Ok(pos) => Some(pos),
Err(e) => {
eprintln!("Failed to get cursor position: {}", e);
log::error!("Failed to get cursor position: {}", e);
None
}
};
@@ -318,7 +334,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
let monitor = match target_monitor.or_else(|| window.primary_monitor().ok().flatten()) {
Some(monitor) => monitor,
None => {
eprintln!("No monitor found!");
log::error!("No monitor found!");
return;
}
};
@@ -328,7 +344,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
if let Some(ref prev_name) = *previous_monitor_name {
if name.to_string() == *prev_name {
println!("Currently on the same monitor");
log::debug!("Currently on the same monitor");
return;
}
@@ -342,7 +358,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
let window_size = match window.inner_size() {
Ok(size) => size,
Err(e) => {
eprintln!("Failed to get window size: {}", e);
log::error!("Failed to get window size: {}", e);
return;
}
};
@@ -356,11 +372,11 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
// Move the window to the new position
if let Err(e) = window.set_position(PhysicalPosition::new(window_x, window_y)) {
eprintln!("Failed to move window: {}", e);
log::error!("Failed to move window: {}", e);
}
if let Some(name) = monitor.name() {
println!("Window moved to monitor: {}", name);
log::debug!("Window moved to monitor: {}", name);
let mut previous_monitor = PREVIOUS_MONITOR_NAME.lock().unwrap();
*previous_monitor = Some(name.to_string());
@@ -370,7 +386,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
#[allow(dead_code)]
fn open_settings(app: &tauri::AppHandle) {
use tauri::webview::WebviewBuilder;
println!("settings menu item was clicked");
log::debug!("settings menu item was clicked");
let window = app.get_webview_window("settings");
if let Some(window) = window {
let _ = window.show();
@@ -401,7 +417,11 @@ fn open_settings(app: &tauri::AppHandle) {
#[tauri::command]
async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> {
init_app_search_source(&app_handle).await?;
let (_found_invalid_extensions, extensions) = extension::list_extensions()
.await
.map_err(|e| e.to_string())?;
extension::init_extensions(extensions).await?;
let _ = server::connector::refresh_all_connectors(&app_handle).await;
let _ = server::datasource::refresh_all_datasources(&app_handle).await;
@@ -412,3 +432,170 @@ async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(
async fn show_settings(app_handle: AppHandle) {
open_settings(&app_handle);
}
#[tauri::command]
async fn simulate_mouse_click<R: Runtime>(window: WebviewWindow<R>, is_chat_mode: bool) {
#[cfg(target_os = "windows")]
{
use enigo::{Button, Coordinate, Direction, Enigo, Mouse, Settings};
use std::{thread, time::Duration};
if let Ok(mut enigo) = Enigo::new(&Settings::default()) {
// Save the current mouse position
if let Ok((original_x, original_y)) = enigo.location() {
// Retrieve the window's outer position (top-left corner)
if let Ok(position) = window.outer_position() {
// Retrieve the window's inner size (client area)
if let Ok(size) = window.inner_size() {
// Calculate the center position of the title bar
let x = position.x + (size.width as i32 / 2);
let y = if is_chat_mode {
position.y + size.height as i32 - 50
} else {
position.y + 30
};
// Move the mouse cursor to the calculated position
if enigo.move_mouse(x, y, Coordinate::Abs).is_ok() {
// // Simulate a left mouse click
let _ = enigo.button(Button::Left, Direction::Click);
// let _ = enigo.button(Button::Left, Direction::Release);
thread::sleep(Duration::from_millis(100));
// Move the mouse cursor back to the original position
let _ = enigo.move_mouse(original_x, original_y, Coordinate::Abs);
}
}
}
}
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = window;
let _ = is_chat_mode;
}
}
/// Log format:
///
/// ```text
/// [time] [log level] [file module:line] message
/// ```
///
/// Example:
///
///
/// ```text
/// [05-11 17:00:00] [INF] [coco_lib:625] Coco-AI started
/// ```
fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
use log::Level;
use log::LevelFilter;
use tauri_plugin_log::Builder;
/// Coco-AI app's default log level.
const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info;
const LOG_LEVEL_ENV_VAR: &str = "COCO_LOG";
fn format_log_level(level: Level) -> &'static str {
match level {
Level::Trace => "TRC",
Level::Debug => "DBG",
Level::Info => "INF",
Level::Warn => "WAR",
Level::Error => "ERR",
}
}
fn format_target_and_line(record: &log::Record) -> String {
let mut str = record.target().to_string();
if let Some(line) = record.line() {
str.push(':');
str.push_str(&line.to_string());
}
str
}
/// Allow us to configure dynamic log levels via environment variable `COCO_LOG`.
///
/// Generally, it mirros the behavior of `env_logger`. Syntax: `COCO_LOG=[target][=][level][,...]`
///
/// * If this environment variable is not set, use the default log level.
/// * If it is set, respect it:
///
/// * `COCO_LOG=coco_lib` turns on all logging for the `coco_lib` module, which is
/// equivalent to `COCO_LOG=coco_lib=trace`
/// * `COCO_LOG=trace` turns on all logging for the application, regardless of its name
/// * `COCO_LOG=TRACE` turns on all logging for the application, regardless of its name (same as previous)
/// * `COCO_LOG=reqwest=debug` turns on debug logging for `reqwest`
/// * `COCO_LOG=trace,tauri=off` turns on all the logging except for the logs come from `tauri`
/// * `COCO_LOG=off` turns off all logging for the application
/// * `COCO_LOG=` Since the value is empty, turns off all logging for the application as well
fn dynamic_log_level(mut builder: Builder) -> Builder {
let Some(log_levels) = std::env::var_os(LOG_LEVEL_ENV_VAR) else {
return builder.level(DEFAULT_LOG_LEVEL);
};
builder = builder.level(LevelFilter::Off);
let log_levels = log_levels.into_string().unwrap_or_else(|e| {
panic!(
"The value '{}' set in environment varaible '{}' is not UTF-8 encoded",
// Cannot use `.display()` here becuase that requires MSRV 1.87.0
e.to_string_lossy(),
LOG_LEVEL_ENV_VAR
)
});
// COCO_LOG=[target][=][level][,...]
let target_log_levels = log_levels.split(',');
for target_log_level in target_log_levels {
#[allow(clippy::collapsible_else_if)]
if let Some(char_index) = target_log_level.chars().position(|c| c == '=') {
let (target, equal_sign_and_level) = target_log_level.split_at(char_index);
// Remove the equal sign, we know it takes 1 byte
let level = &equal_sign_and_level[1..];
if let Ok(level) = level.parse::<LevelFilter>() {
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
builder = builder.level_for(target.to_string(), level);
} else {
panic!(
"log level '{}' set in '{}={}' is invalid",
level, target, level
);
}
} else {
if let Ok(level) = target_log_level.parse::<LevelFilter>() {
// This is a level
builder = builder.level(level);
} else {
// This is a target, enable all the logging
//
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
builder = builder.level_for(target_log_level.to_string(), LevelFilter::Trace);
}
}
}
builder
}
let mut builder = tauri_plugin_log::Builder::new();
builder = builder.format(|out, message, record| {
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
let level = format_log_level(record.level());
let target_and_line = format_target_and_line(record);
out.finish(format_args!(
"[{}] [{}] [{}] {}",
now, level, target_and_line, message
));
});
builder = dynamic_log_level(builder);
builder.build()
}

View File

@@ -1,313 +0,0 @@
use crate::common::document::{DataSourceReference, Document};
use crate::common::error::SearchError;
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
use crate::common::traits::SearchSource;
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
use applications::App;
use async_trait::async_trait;
use fuzzy_prefix_search::Trie;
use std::collections::HashMap;
use std::path::PathBuf;
use tauri::{AppHandle, Runtime};
use tauri_plugin_fs_pro::{icon, metadata, name, IconOptions};
const DATA_SOURCE_ID: &str = "Applications";
#[tauri::command]
pub fn get_default_search_paths() -> Vec<String> {
#[cfg(target_os = "macos")]
return vec![
"/Applications".into(),
"/System/Applications".into(),
"/System/Library/CoreServices".into(),
];
#[cfg(not(target_os = "macos"))]
{
let paths = applications::get_default_search_paths();
let mut ret = Vec::with_capacity(paths.len());
for search_path in paths {
let path_string = search_path
.into_os_string()
.into_string()
.expect("path should be UTF-8 encoded");
ret.push(path_string);
}
ret
}
}
/// Helper function to return `app`'s path.
///
/// * Windows: return the path to application's exe
/// * macOS: return the path to the `.app` bundle
/// * Linux: return the path to the `.desktop` file
fn get_app_path(app: &App) -> PathBuf {
if cfg!(target_os = "windows") {
assert!(
app.icon_path.is_some(),
"we only accept Applications with icons"
);
app.app_path_exe
.as_ref()
.expect("icon is Some, exe path should be Some as well")
.to_path_buf()
} else {
app.app_desktop_path.clone()
}
}
/// Helper function to return `app`'s path.
///
/// * Windows/macOS: extract `app_path`'s file name and remove the file extension
/// * Linux: return the name specified in `.desktop` file
async fn get_app_name(app: &App) -> String {
if cfg!(target_os = "linux") {
app.name.clone()
} else {
let app_path = get_app_path(app);
name(app_path.clone()).await
}
}
/// Helper function to return an absolute path to `app`'s icon.
///
/// On macOS/Windows, we cache icons in our data directory using the `icon()` function.
async fn get_app_icon_path<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app: &App,
) -> Result<PathBuf, String> {
if cfg!(target_os = "linux") {
let icon_path = app
.icon_path
.as_ref()
.expect("We only accept applications with icons")
.to_path_buf();
Ok(icon_path)
} else {
let app_path = get_app_path(app);
let options = IconOptions {
size: Some(256),
save_path: None,
};
icon(tauri_app_handle.clone(), app_path, Some(options))
.await
.map_err(|err| err.to_string())
}
}
/// Return all the Apps found under `search_path`.
///
/// Note: apps with no icons will be filtered out.
fn list_app_in(search_path: Vec<String>) -> Result<Vec<App>, String> {
let search_path = search_path
.into_iter()
.map(PathBuf::from)
.collect::<Vec<_>>();
let apps = applications::get_all_apps(&search_path).map_err(|err| err.to_string())?;
Ok(apps
.into_iter()
.filter(|app| app.icon_path.is_some())
.collect())
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppMetadata {
name: String,
r#where: PathBuf,
size: u64,
icon: PathBuf,
created: u128,
modified: u128,
last_opened: u128,
}
/// List apps that are in the `search_path`.
///
/// Different from `list_app_in()`, every app is JSON object containing its metadata, e.g.:
///
/// ```json
/// {
/// "name": "Finder",
/// "where": "/System/Library/CoreServices",
/// "size": 49283072,
/// "icon": "/xxx.png",
/// "created": 1744625204,
/// "modified": 1744625204,
/// "lastOpened": 1744625250
/// }
/// ```
#[tauri::command]
pub async fn list_app_with_metadata_in<R: Runtime>(
app_handle: AppHandle<R>,
search_path: Vec<String>,
) -> Result<Vec<AppMetadata>, String> {
let apps = list_app_in(search_path)?;
let mut apps_with_meta = Vec::with_capacity(apps.len());
// name version where Type(hardcoded Application) Size Created Modify
for app in apps.iter() {
let app_path = get_app_path(app);
let app_name = get_app_name(app).await;
let app_path_where = {
let mut app_path_clone = app_path.clone();
let truncated = app_path_clone.pop();
if !truncated {
panic!("every app file should live somewhere");
}
app_path_clone
};
let icon = get_app_icon_path(&app_handle, app).await?;
let raw_app_metadata = metadata(app_path.clone(), None).await?;
let app_metadata = AppMetadata {
name: app_name,
r#where: app_path_where,
size: raw_app_metadata.size,
icon,
created: raw_app_metadata.created_at,
modified: raw_app_metadata.modified_at,
last_opened: raw_app_metadata.accessed_at,
};
apps_with_meta.push(app_metadata);
}
Ok(apps_with_meta)
}
pub struct ApplicationSearchSource {
base_score: f64,
// app name -> app icon path
icons: HashMap<String, PathBuf>,
application_paths: Trie<PathBuf>,
}
impl ApplicationSearchSource {
pub async fn new<R: Runtime>(
app_handle: AppHandle<R>,
base_score: f64,
) -> Result<Self, String> {
let application_paths = Trie::new();
let mut icons = HashMap::new();
let default_search_path = get_default_search_paths();
let apps = list_app_in(default_search_path)?;
for app in &apps {
let app_path = get_app_path(app);
let app_name = get_app_name(app).await;
let app_icon_path = get_app_icon_path(&app_handle, app).await?;
if app_name.is_empty() || app_name.eq("Coco-AI") {
continue;
}
application_paths.insert(&app_name, app_path);
icons.insert(app_name, app_icon_path);
}
Ok(ApplicationSearchSource {
base_score,
icons,
application_paths,
})
}
}
#[async_trait]
impl SearchSource for ApplicationSearchSource {
fn get_type(&self) -> QuerySource {
QuerySource {
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
name: hostname::get()
.unwrap_or("My Computer".into())
.to_string_lossy()
.into(),
id: DATA_SOURCE_ID.into(),
}
}
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
let query_string = query
.query_strings
.get("query")
.unwrap_or(&"".to_string())
.to_lowercase();
if query_string.is_empty() {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
}
let mut total_hits = 0;
let mut hits = Vec::new();
let query_string_len = query_string.len();
let mut results = self
.application_paths
.search_within_distance_scored(&query_string, query_string_len - 1);
// Check for NaN or extreme score values and handle them properly
results.sort_by(|a, b| {
// If either score is NaN, consider them equal (you can customize this logic as needed)
if a.score.is_nan() || b.score.is_nan() {
std::cmp::Ordering::Equal
} else {
// Otherwise, compare the scores as usual
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
}
});
if !results.is_empty() {
for result in results {
let app_name = result.word;
let app_path = result.data.first().unwrap().clone();
let app_path_string = app_path.to_string_lossy().into_owned();
total_hits += 1;
let mut doc = Document::new(
Some(DataSourceReference {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some(DATA_SOURCE_ID.into()),
id: Some(DATA_SOURCE_ID.into()),
icon: None,
}),
app_path_string.clone(),
"Application".to_string(),
app_name.clone(),
app_path_string.clone(),
);
// Attach icon if available
if let Some(icon_path) = self.icons.get(app_name.as_str()) {
doc.icon = Some(icon_path.as_os_str().to_str().unwrap().to_string());
}
hits.push((doc, self.base_score + result.score as f64));
}
}
Ok(QueryResponse {
source: self.get_type(),
hits,
total_hits,
})
}
}

View File

@@ -1,5 +0,0 @@
pub mod application;
pub mod calculator;
pub mod file_system;
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";

View File

@@ -1,15 +1,55 @@
use crate::common::error::SearchError;
use crate::common::register::SearchSourceRegistry;
use crate::common::search::{
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
FailedRequest, MultiSourceQueryResponse, QueryHits, QueryResponse, QuerySource, SearchQuery,
};
use crate::common::traits::SearchSource;
use function_name::named;
use futures::stream::FuturesUnordered;
use futures::StreamExt;
use std::cmp::Reverse;
use std::collections::HashMap;
use std::collections::HashSet;
use std::future::Future;
use std::sync::Arc;
use tauri::{AppHandle, Manager, Runtime};
use tokio::time::error::Elapsed;
use tokio::time::{timeout, Duration};
/// Helper function to return the Future used for querying querysources.
///
/// It is a workaround for the limitations:
///
/// 1. 2 async blocks have different types in Rust's type system even though
/// they are literally same
/// 2. `futures::stream::FuturesUnordered` needs the `Futures` pushed to it to
/// have only 1 type
///
/// Putting the async block in a function to unify the types.
fn same_type_futures(
query_source: QuerySource,
query_source_trait_object: Arc<dyn SearchSource>,
timeout_duration: Duration,
search_query: SearchQuery,
) -> impl Future<
Output=(
QuerySource,
Result<Result<QueryResponse, SearchError>, Elapsed>,
),
> + 'static {
async move {
(
// Store `query_source` as part of future for debugging purposes.
query_source,
timeout(timeout_duration, async {
query_source_trait_object.search(search_query).await
})
.await,
)
}
}
#[named]
#[tauri::command]
pub async fn query_coco_fusion<R: Runtime>(
app_handle: AppHandle<R>,
@@ -18,113 +58,134 @@ pub async fn query_coco_fusion<R: Runtime>(
query_strings: HashMap<String, String>,
query_timeout: u64,
) -> Result<MultiSourceQueryResponse, SearchError> {
let query_source_to_search = query_strings.get("querysource");
let query_keyword = query_strings
.get("query")
.unwrap_or(&"".to_string())
.clone();
let opt_query_source_id = query_strings.get("querysource");
let search_sources = app_handle.state::<SearchSourceRegistry>();
let sources_future = search_sources.get_sources();
let mut futures = FuturesUnordered::new();
let mut sources = HashMap::new();
let sources_list = sources_future.await;
let mut sources_list = sources_future.await;
let sources_list_len = sources_list.len();
// Time limit for each query
let timeout_duration = Duration::from_millis(query_timeout);
// Push all queries into futures
for query_source in sources_list {
let query_source_type = query_source.get_type().clone();
log::debug!(
"{}(): {:?}, timeout: {:?}",
function_name!(),
query_strings,
timeout_duration
);
if let Some(query_source_to_search) = query_source_to_search {
// We should not search this data source
if &query_source_type.id != query_source_to_search {
continue;
}
let search_query = SearchQuery::new(from, size, query_strings.clone());
if let Some(query_source_id) = opt_query_source_id {
// If this query source ID is specified, we only query this query source.
log::debug!(
"parameter [querysource={}] specified, will only query this querysource",
query_source_id
);
let query_source_trait_object_index = sources_list
.iter()
.position(|query_source| &query_source.get_type().id == query_source_id).unwrap_or_else(|| {
panic!("frontend code invoked {}() with parameter [querysource={}], but we do not have this query source, the states are inconsistent! Available query sources {:?}", function_name!(), query_source_id, sources_list.iter().map(|qs| qs.get_type().id).collect::<Vec<_>>());
});
let query_source_trait_object = sources_list.remove(query_source_trait_object_index);
let query_source = query_source_trait_object.get_type();
futures.push(same_type_futures(
query_source,
query_source_trait_object,
timeout_duration,
search_query,
));
} else {
for query_source_trait_object in sources_list {
let query_source = query_source_trait_object.get_type().clone();
log::debug!("will query querysource [{}]", query_source.id);
futures.push(same_type_futures(
query_source,
query_source_trait_object,
timeout_duration,
search_query.clone(),
));
}
sources.insert(query_source_type.id.clone(), query_source_type);
let query = SearchQuery::new(from, size, query_strings.clone());
let query_source_clone = query_source.clone(); // Clone Arc to avoid ownership issues
futures.push(tokio::spawn(async move {
// Timeout each query execution
timeout(timeout_duration, async {
query_source_clone.search(query).await
})
.await
}));
}
let mut total_hits = 0;
let mut need_rerank = true; //TODO set default to false when boost supported in Pizza
let mut failed_requests = Vec::new();
let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::new();
let mut hits_per_source: HashMap<String, Vec<(QueryHits, f64)>> = HashMap::new();
while let Some(result) = futures.next().await {
match result {
Ok(Ok(Ok(response))) => {
total_hits += response.total_hits;
let source_id = response.source.id.clone();
if sources_list_len > 1 {
need_rerank = true; // If we have more than one source, we need to rerank the hits
}
for (doc, score) in response.hits {
let query_hit = QueryHits {
source: Some(response.source.clone()),
score,
document: doc,
};
while let Some((query_source, timeout_result)) = futures.next().await {
match timeout_result {
// Ignore the `_timeout` variable as it won't provide any useful debugging information.
Err(_timeout) => {
log::warn!(
"searching query source [{}] timed out, skip this request",
query_source.id
);
// failed_requests.push(FailedRequest {
// source: query_source,
// status: 0,
// error: Some("querying timed out".into()),
// reason: None,
// });
}
Ok(query_result) => match query_result {
Ok(response) => {
total_hits += response.total_hits;
let source_id = response.source.id.clone();
all_hits.push((source_id.clone(), query_hit.clone(), score));
for (doc, score) in response.hits {
log::debug!("doc: {}, {:?}, {}", doc.id, doc.title, score);
hits_per_source
.entry(source_id.clone())
.or_insert_with(Vec::new)
.push((query_hit, score));
let query_hit = QueryHits {
source: Some(response.source.clone()),
score,
document: doc,
};
all_hits.push((source_id.clone(), query_hit.clone(), score));
hits_per_source
.entry(source_id.clone())
.or_insert_with(Vec::new)
.push((query_hit, score));
}
}
}
Ok(Err(err)) => {
failed_requests.push(FailedRequest {
source: QuerySource {
r#type: "N/A".into(),
name: "N/A".into(),
id: "N/A".into(),
},
status: 0,
error: Some(err.to_string()),
reason: None,
});
}
// Timeout reached, skip this request
Ok(_) => {
failed_requests.push(FailedRequest {
source: QuerySource {
r#type: "N/A".into(),
name: "N/A".into(),
id: "N/A".into(),
},
status: 0,
error: Some("Query source timed out".to_string()),
reason: None,
});
}
Err(_) => {
failed_requests.push(FailedRequest {
source: QuerySource {
r#type: "N/A".into(),
name: "N/A".into(),
id: "N/A".into(),
},
status: 0,
error: Some("Task panicked".to_string()),
reason: None,
});
}
Err(search_error) => {
log::error!(
"searching query source [{}] failed, error [{}]",
query_source.id,
search_error
);
failed_requests.push(FailedRequest {
source: query_source,
status: 0,
error: Some(search_error.to_string()),
reason: None,
});
}
},
}
}
// Sort hits within each source by score (descending)
for hits in hits_per_source.values_mut() {
hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Greater));
}
let total_sources = hits_per_source.len();
@@ -140,16 +201,71 @@ pub async fn query_coco_fusion<R: Runtime>(
// Distribute hits fairly across sources
for (_source_id, hits) in &mut hits_per_source {
let take_count = hits.len().min(max_hits_per_source);
for (doc, _) in hits.drain(0..take_count) {
for (doc, score) in hits.drain(0..take_count) {
if !seen_docs.contains(&doc.document.id) {
seen_docs.insert(doc.document.id.clone());
log::debug!(
"collect doc: {}, {:?}, {}",
doc.document.id,
doc.document.title,
score
);
final_hits.push(doc);
}
}
}
// If we still need more hits, take the highest-scoring remaining ones
if final_hits.len() < size as usize {
log::debug!("final hits: {:?}", final_hits.len());
let mut unique_sources = HashSet::new();
for hit in &final_hits {
if let Some(source) = &hit.source {
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
unique_sources.insert(&source.id);
}
}
}
log::debug!(
"Multiple sources found: {:?}, no rerank needed",
unique_sources
);
if unique_sources.len() < 1 {
need_rerank = false; // If we have hits from multiple sources, we don't need to rerank
}
if need_rerank && final_hits.len() > 1 {
// Precollect (index, title)
let titles_to_score: Vec<(usize, &str)> = final_hits
.iter()
.enumerate()
.filter_map(|(idx, hit)| {
let source = hit.source.as_ref()?;
let title = hit.document.title.as_deref()?;
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
Some((idx, title))
} else {
None
}
})
.collect();
// Score them
let scored_hits = boosted_levenshtein_rerank(query_keyword.as_str(), titles_to_score);
// Sort descending by score
let mut scored_hits = scored_hits;
scored_hits.sort_by_key(|&(_, score)| Reverse((score * 1000.0) as u64));
// Apply new scores to final_hits
for (idx, score) in scored_hits.into_iter().take(size as usize) {
final_hits[idx].score = score;
}
} else if final_hits.len() < size as usize {
// If we still need more hits, take the highest-scoring remaining ones
let remaining_needed = size as usize - final_hits.len();
// Sort all hits by score descending, removing duplicates by document ID
@@ -179,9 +295,45 @@ pub async fn query_coco_fusion<R: Runtime>(
.unwrap_or(std::cmp::Ordering::Equal)
});
if final_hits.len() < 5 {
//TODO: Add a recommendation system to suggest more sources
log::info!(
"Less than 5 hits found, consider using recommendation to find more suggestions."
);
//local: recent history, local extensions
//remote: ai agents, quick links, other tasks, managed by server
}
Ok(MultiSourceQueryResponse {
failed: failed_requests,
hits: final_hits,
total_hits,
})
}
fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(usize, f64)> {
use strsim::levenshtein;
let query_lower = query.to_lowercase();
titles
.into_iter()
.map(|(idx, title)| {
let mut score = 0.0;
if title.contains(query) {
score += 0.4;
} else if title.to_lowercase().contains(&query_lower) {
score += 0.2;
}
let dist = levenshtein(&query_lower, &title.to_lowercase());
let max_len = query_lower.len().max(title.len());
if max_len > 0 {
score += (1.0 - (dist as f64 / max_len as f64)) as f32;
}
(idx, score.min(1.0) as f64)
})
.collect()
}

View File

@@ -40,14 +40,14 @@ pub struct AttachmentHit {
pub struct AttachmentHits {
pub total: Value,
pub max_score: Option<f64>,
pub hits: Vec<AttachmentHit>,
pub hits: Option<Vec<AttachmentHit>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetAttachmentResponse {
pub took: u32,
pub timed_out: bool,
pub _shards: Value,
pub _shards: Option<Value>,
pub hits: AttachmentHits,
}
@@ -119,7 +119,7 @@ pub async fn get_attachment(
.map_err(|e| format!("Request error: {}", e))?;
let body = get_response_body_text(response).await?;
serde_json::from_str::<GetAttachmentResponse>(&body)
.map_err(|e| format!("Failed to parse attachment response: {}", e))
}

View File

@@ -34,6 +34,10 @@ pub async fn refresh_all_connectors<R: Runtime>(app_handle: &AppHandle<R>) -> Re
// Collect all the tasks for fetching and refreshing connectors
let mut server_map = HashMap::new();
for server in servers {
if !server.enabled {
continue;
}
// dbg!("start fetch connectors for server: {}", &server.id);
let connectors = match get_connectors_by_server(app_handle.clone(), server.id.clone()).await
{

View File

@@ -4,6 +4,7 @@ use crate::server::connector::get_connector_by_id;
use crate::server::http_client::HttpClient;
use crate::server::servers::get_all_servers;
use lazy_static::lazy_static;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tauri::{AppHandle, Runtime};
@@ -12,7 +13,7 @@ use tauri::{AppHandle, Runtime};
pub struct GetDatasourcesByServerOptions {
pub from: Option<u32>,
pub size: Option<u32>,
pub query: Option<String>,
pub query: Option<serde_json::Value>,
}
lazy_static! {
@@ -47,6 +48,10 @@ pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) ->
for server in servers {
// dbg!("fetch datasources for server: {}", &server.id);
if !server.enabled {
continue;
}
// Attempt to get datasources by server, and continue even if it fails
let connectors = match datasource_search(server.id.as_str(), None).await {
Ok(connectors) => {
@@ -96,31 +101,14 @@ pub async fn datasource_search(
) -> Result<Vec<DataSource>, String> {
let from = options.as_ref().and_then(|opt| opt.from).unwrap_or(0);
let size = options.as_ref().and_then(|opt| opt.size).unwrap_or(10000);
let query = options
.and_then(|opt| opt.query)
.unwrap_or(String::default());
let mut body = serde_json::json!({
"from": from,
"size": size,
});
if !query.is_empty() {
body["query"] = serde_json::json!({
"bool": {
"must": [{
"query_string": {
"fields": ["combined_fulltext"],
"query": query,
"fuzziness": "AUTO",
"fuzzy_prefix_length": 2,
"fuzzy_max_expansions": 10,
"fuzzy_transpositions": true,
"allow_leading_wildcard": false
}
}]
}
});
if let Some(q) = options.and_then(|get_data_source_options| get_data_source_options.query ) {
body["query"] = q;
}
// Perform the async HTTP request outside the cache lock
@@ -135,7 +123,7 @@ pub async fn datasource_search(
// Parse the search results from the response
let datasources: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
dbg!("Error parsing search results: {}", &e);
//dbg!("Error parsing search results: {}", &e);
e.to_string()
})?;
@@ -148,35 +136,17 @@ pub async fn datasource_search(
#[tauri::command]
pub async fn mcp_server_search(
id: &str,
options: Option<GetDatasourcesByServerOptions>,
from: u32,
size: u32,
query: Option<HashMap<String, Value>>,
) -> Result<Vec<DataSource>, String> {
let from = options.as_ref().and_then(|opt| opt.from).unwrap_or(0);
let size = options.as_ref().and_then(|opt| opt.size).unwrap_or(10000);
let query = options
.and_then(|opt| opt.query)
.unwrap_or(String::default());
let mut body = serde_json::json!({
"from": from,
"size": size,
"from": from,
"size": size,
});
if !query.is_empty() {
body["query"] = serde_json::json!({
"bool": {
"must": [{
"query_string": {
"fields": ["combined_fulltext"],
"query": query,
"fuzziness": "AUTO",
"fuzzy_prefix_length": 2,
"fuzzy_max_expansions": 10,
"fuzzy_transpositions": true,
"allow_leading_wildcard": false
}
}]
}
});
if let Some(q) = query {
body["query"] = serde_json::to_value(q).map_err(|e| e.to_string())?;
}
// Perform the async HTTP request outside the cache lock
@@ -191,7 +161,7 @@ pub async fn mcp_server_search(
// Parse the search results from the response
let mcp_server: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
dbg!("Error parsing search results: {}", &e);
//dbg!("Error parsing search results: {}", &e);
e.to_string()
})?;

View File

@@ -7,15 +7,24 @@ use std::time::Duration;
use tauri_plugin_store::JsonValue;
use tokio::sync::Mutex;
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
let client = Client::builder()
pub(crate) fn new_reqwest_http_client(accept_invalid_certs: bool) -> Client {
Client::builder()
.read_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
.connect_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
.timeout(Duration::from_secs(10)) // Set a timeout of 10 seconds
.danger_accept_invalid_certs(true) // example for self-signed certificates
.danger_accept_invalid_certs(accept_invalid_certs) // allow self-signed certificates
.build()
.expect("Failed to build client");
Mutex::new(client)
.expect("Failed to build client")
}
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
let allow_self_signature = crate::settings::_get_allow_self_signature(
crate::GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app store not set")
.clone(),
);
Mutex::new(new_reqwest_http_client(allow_self_signature))
});
pub struct HttpClient;
@@ -35,13 +44,29 @@ impl HttpClient {
headers: Option<HashMap<String, String>>,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
log::debug!(
"Sending Request: {}, query_params: {:?}, header: {:?}, body: {:?}",
&url,
&query_params,
&headers,
&body
);
let request_builder =
Self::get_request_builder(method, url, headers, query_params, body).await;
let response = request_builder.send().await.map_err(|e| {
dbg!("Failed to send request: {}", &e);
//dbg!("Failed to send request: {}", &e);
format!("Failed to send request: {}", e)
})?;
log::debug!(
"Request: {}, Response status: {:?}, header: {:?}",
&url,
&response.status(),
&response.headers()
);
Ok(response)
}
@@ -140,9 +165,12 @@ impl HttpClient {
headers.insert("X-API-TOKEN".to_string(), t);
}
// dbg!(&server_id);
// dbg!(&url);
// dbg!(&headers);
// log::debug!(
// "Sending request to server: {}, url: {}, headers: {:?}",
// &server_id,
// &url,
// &headers
// );
Self::send_raw_request(method, &url, query_params, Some(headers), body).await
} else {
@@ -184,7 +212,7 @@ impl HttpClient {
query_params,
body,
)
.await
.await
}
// Convenience method for PUT requests
@@ -204,7 +232,7 @@ impl HttpClient {
query_params,
body,
)
.await
.await
}
// Convenience method for DELETE requests
@@ -223,6 +251,6 @@ impl HttpClient {
query_params,
None,
)
.await
.await
}
}

View File

@@ -1,16 +1,15 @@
use crate::common::document::Document;
use crate::common::document::{Document, OnOpened};
use crate::common::error::SearchError;
use crate::common::http::get_response_body_text;
use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery, SearchResponse};
use crate::common::server::Server;
use crate::common::traits::SearchSource;
use crate::server::http_client::HttpClient;
use crate::server::servers::get_server_token;
use async_trait::async_trait;
// use futures::stream::StreamExt;
use ordered_float::OrderedFloat;
use reqwest::{Client, Method, RequestBuilder};
use std::collections::HashMap;
use tauri_plugin_store::JsonValue;
// use std::hash::Hash;
#[allow(dead_code)]
@@ -74,45 +73,11 @@ const COCO_SERVERS: &str = "coco-servers";
pub struct CocoSearchSource {
server: Server,
client: Client,
}
impl CocoSearchSource {
pub fn new(server: Server, client: Client) -> Self {
CocoSearchSource { server, client }
}
async fn build_request_from_query(
&self,
query: &SearchQuery,
) -> Result<RequestBuilder, String> {
self.build_request(query.from, query.size, &query.query_strings)
.await
}
async fn build_request(
&self,
from: u64,
size: u64,
query_strings: &HashMap<String, String>,
) -> Result<RequestBuilder, String> {
let url = HttpClient::join_url(&self.server.endpoint, "/query/_search");
let mut request_builder = self.client.request(Method::GET, url);
if !self.server.public {
if let Some(token) = get_server_token(&self.server.id)
.await?
.map(|t| t.access_token)
{
request_builder = request_builder.header("X-API-TOKEN", token);
}
}
let result = request_builder
.query(&[("from", &from.to_string()), ("size", &size.to_string())])
.query(query_strings);
Ok(result)
pub fn new(server: Server) -> Self {
CocoSearchSource { server }
}
}
@@ -127,35 +92,55 @@ impl SearchSource for CocoSearchSource {
}
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
// Build the request from the provided query
let request_builder = self
.build_request_from_query(&query)
.await
.map_err(|e| SearchError::InternalError(e.to_string()))?;
let url = "/query/_search";
let mut total_hits = 0;
let mut hits: Vec<(Document, f64)> = Vec::new();
// Send the HTTP request and handle errors
let response = request_builder
.send()
let mut query_args: HashMap<String, JsonValue> = HashMap::new();
query_args.insert("from".into(), JsonValue::Number(query.from.into()));
query_args.insert("size".into(), JsonValue::Number(query.size.into()));
for (key, value) in query.query_strings {
query_args.insert(key, JsonValue::String(value));
}
let response = HttpClient::get(&self.server.id, &url, Some(query_args))
.await
.map_err(|e| SearchError::HttpError(format!("Failed to send search request: {}", e)))?;
.map_err(|e| SearchError::HttpError(format!("{}", e)))?;
// Use the helper function to parse the response body
let response_body = get_response_body_text(response)
.await
.map_err(|e| SearchError::ParseError(format!("Failed to read response body: {}", e)))?;
.map_err(|e| SearchError::ParseError(e))?;
// Parse the search response from the body text
let parsed: SearchResponse<Document> = serde_json::from_str(&response_body)
.map_err(|e| SearchError::ParseError(format!("Failed to parse search response: {}", e)))?;
// Check if the response body is empty
if !response_body.is_empty() {
// log::info!("Search response body: {}", &response_body);
// Process the parsed response
let total_hits = parsed.hits.total.value as usize;
let hits: Vec<(Document, f64)> = parsed
.hits
.hits
.into_iter()
.map(|hit| (hit._source, hit._score.unwrap_or(0.0))) // Default _score to 0.0 if None
.collect();
// Parse the search response from the body text
let parsed: SearchResponse<Document> = serde_json::from_str(&response_body)
.map_err(|e| SearchError::ParseError(format!("{}", e)))?;
// Process the parsed response
total_hits = parsed.hits.total.value as usize;
if let Some(items) = parsed.hits.hits {
for hit in items {
let mut document = hit._source;
// Default _score to 0.0 if None
let score = hit._score.unwrap_or(0.0);
let on_opened = document
.url
.as_ref()
.map(|url| OnOpened::Document { url: url.clone() });
// Set the `on_opened` field as it won't be returned from Coco server
document.on_opened = on_opened;
hits.push((document, score));
}
}
}
// Return the final result
Ok(QueryResponse {

View File

@@ -7,7 +7,7 @@ use crate::server::http_client::HttpClient;
use crate::server::search::CocoSearchSource;
use crate::COCO_TAURI_STORE;
use lazy_static::lazy_static;
use reqwest::{Client, Method};
use reqwest::Method;
use serde_json::from_value;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
@@ -59,7 +59,7 @@ pub fn save_server(server: &Server) -> bool {
}
fn remove_server_by_id(id: String) -> bool {
dbg!("remove server by id:", &id);
log::debug!("remove server by id: {}", &id);
let mut cache = SERVER_CACHE.write().unwrap();
let deleted = cache.remove(id.as_str());
deleted.is_some()
@@ -87,7 +87,7 @@ pub async fn persist_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<()
}
pub fn remove_server_token(id: &str) -> bool {
dbg!("remove server token by id:", &id);
log::debug!("remove server token by id: {}", &id);
let mut cache = SERVER_TOKEN.write().unwrap();
cache.remove(id).is_some()
}
@@ -104,7 +104,7 @@ pub fn persist_servers_token<R: Runtime>(app_handle: &AppHandle<R>) -> Result<()
.map(|server| serde_json::to_value(server).expect("Failed to serialize access_tokens")) // Automatically serialize all fields
.collect();
dbg!(format!("persist servers token: {:?}", &json_servers));
log::debug!("persist servers token: {:?}", &json_servers);
// Save the serialized servers to Tauri's store
app_handle
@@ -143,17 +143,18 @@ fn get_default_server() -> Server {
profile: None,
auth_provider: AuthProvider {
sso: Sso {
url: "https://coco.infini.cloud/sso/login/".to_string(),
url: "https://coco.infini.cloud/sso/login/cloud?provider=coco-cloud&product=coco".to_string(),
},
},
priority: 0,
stats: None,
}
}
pub async fn load_servers_token<R: Runtime>(
app_handle: &AppHandle<R>,
) -> Result<Vec<ServerAccessToken>, String> {
dbg!("Attempting to load servers token");
log::debug!("Attempting to load servers token");
let store = app_handle
.store(COCO_TAURI_STORE)
@@ -187,10 +188,7 @@ pub async fn load_servers_token<R: Runtime>(
save_access_token(server.id.clone(), server.clone());
}
dbg!(format!(
"loaded {:?} servers's token",
&deserialized_tokens.len()
));
log::debug!("loaded {:?} servers's token", &deserialized_tokens.len());
Ok(deserialized_tokens)
} else {
@@ -231,7 +229,7 @@ pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<S
save_server(&server);
}
// dbg!(format!("load servers: {:?}", &deserialized_servers));
log::debug!("load servers: {:?}", &deserialized_servers);
Ok(deserialized_servers)
} else {
@@ -243,18 +241,18 @@ pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<S
pub async fn load_or_insert_default_server<R: Runtime>(
app_handle: &AppHandle<R>,
) -> Result<Vec<Server>, String> {
dbg!("Attempting to load or insert default server");
log::debug!("Attempting to load or insert default server");
let exists_servers = load_servers(&app_handle).await;
if exists_servers.is_ok() && !exists_servers.as_ref()?.is_empty() {
dbg!(format!("loaded {} servers", &exists_servers.clone()?.len()));
log::debug!("loaded {} servers", &exists_servers.clone()?.len());
return exists_servers;
}
let default = get_default_server();
save_server(&default);
dbg!("loaded default servers");
log::debug!("loaded default servers");
Ok(vec![default])
}
@@ -317,10 +315,19 @@ pub async fn refresh_coco_server_info<R: Runtime>(
// Send request to fetch updated server info
let response = HttpClient::get(&id, "/provider/_info", None)
.await
.map_err(|e| format!("Failed to contact the server: {}", e))?;
.map_err(|e| {
format!("Failed to contact the server: {}", e)
});
if response.is_err() {
let _ = mark_server_as_offline(app_handle, &id).await;
return Err(response.err().unwrap());
}
let response = response?;
if !response.status().is_success() {
mark_server_as_offline(&id).await;
let _ = mark_server_as_offline(app_handle, &id).await;
return Err(format!("Request failed with status: {}", response.status()));
}
@@ -364,10 +371,10 @@ pub async fn add_coco_server<R: Runtime>(
let endpoint = endpoint.trim_end_matches('/');
if check_endpoint_exists(endpoint) {
dbg!(format!(
log::debug!(
"This Coco server has already been registered: {:?}",
&endpoint
));
);
return Err("This Coco server has already been registered.".into());
}
@@ -376,7 +383,7 @@ pub async fn add_coco_server<R: Runtime>(
.await
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
dbg!(format!("Get provider info response: {:?}", &response));
log::debug!("Get provider info response: {:?}", &response);
let body = get_response_body_text(response).await?;
@@ -400,7 +407,7 @@ pub async fn add_coco_server<R: Runtime>(
.await
.map_err(|e| format!("Failed to persist Coco servers: {}", e))?;
dbg!(format!("Successfully registered server: {:?}", &endpoint));
log::debug!("Successfully registered server: {:?}", &endpoint);
Ok(server)
}
@@ -446,26 +453,46 @@ pub async fn try_register_server_to_search_source(
server: &Server,
) {
if server.enabled {
log::trace!(
"Server {} is public: {} and available: {}",
&server.name,
&server.public,
&server.available
);
if !server.public {
let token = get_server_token(&server.id).await;
if !token.is_ok() || token.is_ok() && token.unwrap().is_none() {
log::debug!("Server {} is not public and no token was found", &server.id);
return;
}
}
let registry = app_handle.state::<SearchSourceRegistry>();
let source = CocoSearchSource::new(server.clone(), Client::new());
let source = CocoSearchSource::new(server.clone());
registry.register_source(source).await;
}
}
pub async fn mark_server_as_offline(id: &str) {
#[tauri::command]
pub async fn mark_server_as_offline<R: Runtime>(
app_handle: AppHandle<R>, id: &str) -> Result<(), ()> {
// println!("server_is_offline: {}", id);
let server = get_server_by_id(id);
if let Some(mut server) = server {
server.available = false;
server.health = None;
save_server(&server);
let registry = app_handle.state::<SearchSourceRegistry>();
registry.remove_source(id).await;
}
Ok(())
}
#[tauri::command]
pub async fn disable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> {
println!("disable_server: {}", id);
let server = get_server_by_id(id.as_str());
if let Some(mut server) = server {
server.enabled = false;
@@ -486,47 +513,48 @@ pub async fn logout_coco_server<R: Runtime>(
app_handle: AppHandle<R>,
id: String,
) -> Result<(), String> {
dbg!("Attempting to log out server by id:", &id);
log::debug!("Attempting to log out server by id: {}", &id);
// Check if server token exists
if let Some(_token) = get_server_token(id.as_str()).await? {
dbg!("Found server token for id:", &id);
log::debug!("Found server token for id: {}", &id);
// Remove the server token from cache
remove_server_token(id.as_str());
// Persist the updated tokens
if let Err(e) = persist_servers_token(&app_handle) {
dbg!("Failed to save tokens for id: {}. Error: {:?}", &id, &e);
log::debug!("Failed to save tokens for id: {}. Error: {:?}", &id, &e);
return Err(format!("Failed to save tokens: {}", &e));
}
} else {
// Log the case where server token is not found
dbg!("No server token found for id: {}", &id);
log::debug!("No server token found for id: {}", &id);
}
// Check if the server exists
if let Some(mut server) = get_server_by_id(id.as_str()) {
dbg!("Found server for id:", &id);
log::debug!("Found server for id: {}", &id);
// Clear server profile
server.profile = None;
let _ = mark_server_as_offline(app_handle.clone(), id.as_str()).await;
// Save the updated server data
save_server(&server);
// Persist the updated server data
if let Err(e) = persist_servers(&app_handle).await {
dbg!("Failed to save server for id: {}. Error: {:?}", &id, &e);
log::debug!("Failed to save server for id: {}. Error: {:?}", &id, &e);
return Err(format!("Failed to save server: {}", &e));
}
} else {
// Log the case where server is not found
dbg!("No server found for id: {}", &id);
log::debug!("No server found for id: {}", &id);
return Err(format!("No server found for id: {}", id));
}
dbg!("Successfully logged out server with id:", &id);
log::debug!("Successfully logged out server with id: {}", &id);
Ok(())
}
@@ -577,6 +605,7 @@ fn test_trim_endpoint_last_forward_slash() {
},
},
priority: 0,
stats: None,
};
trim_endpoint_last_forward_slash(&mut server);

View File

@@ -2,14 +2,14 @@ use crate::server::servers::{get_server_by_id, get_server_token};
use futures::StreamExt;
use std::collections::HashMap;
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tauri::{AppHandle, Emitter, Runtime};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::{connect_async, MaybeTlsStream};
use tokio_tungstenite::{connect_async_tls_with_config, Connector};
#[derive(Default)]
pub struct WebSocketManager {
connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>,
@@ -22,9 +22,15 @@ struct WebSocketInstance {
fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?;
let ws_protocol = if url.scheme() == "https" { "wss://" } else { "ws://" };
let ws_protocol = if url.scheme() == "https" {
"wss://"
} else {
"ws://"
};
let host = url.host_str().ok_or("No host found in URL")?;
let port = url.port_or_known_default().unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
let port = url
.port_or_known_default()
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
let ws_endpoint = if port == 80 || port == 443 {
format!("{}{}{}", ws_protocol, host, "/ws")
@@ -35,7 +41,8 @@ fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
}
#[tauri::command]
pub async fn connect_to_server(
pub async fn connect_to_server<R: Runtime>(
tauri_app_handle: AppHandle<R>,
id: String,
client_id: String,
state: tauri::State<'_, WebSocketManager>,
@@ -54,16 +61,43 @@ pub async fn connect_to_server(
tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint)
.map_err(|e| format!("Failed to create WebSocket request: {}", e))?;
request.headers_mut().insert("Connection", "Upgrade".parse().unwrap());
request.headers_mut().insert("Upgrade", "websocket".parse().unwrap());
request.headers_mut().insert("Sec-WebSocket-Version", "13".parse().unwrap());
request.headers_mut().insert("Sec-WebSocket-Key", generate_key().parse().unwrap());
request
.headers_mut()
.insert("Connection", "Upgrade".parse().unwrap());
request
.headers_mut()
.insert("Upgrade", "websocket".parse().unwrap());
request
.headers_mut()
.insert("Sec-WebSocket-Version", "13".parse().unwrap());
request
.headers_mut()
.insert("Sec-WebSocket-Key", generate_key().parse().unwrap());
if let Some(token) = token {
request.headers_mut().insert("X-API-TOKEN", token.parse().unwrap());
request
.headers_mut()
.insert("X-API-TOKEN", token.parse().unwrap());
}
let (ws_stream, _) = connect_async(request).await.map_err(|e| format!("WebSocket error: {:?}", e))?;
let allow_self_signature =
crate::settings::get_allow_self_signature(tauri_app_handle.clone()).await;
let tls_connector = tokio_native_tls::native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(allow_self_signature)
.build()
.map_err(|e| format!("TLS build error: {:?}", e))?;
let connector = Connector::NativeTls(tls_connector.into());
let (ws_stream, _) = connect_async_tls_with_config(
request,
None, // WebSocketConfig
true, // disable_nagle
Some(connector), // Connector
)
.await
.map_err(|e| format!("WebSocket TLS error: {:?}", e))?;
let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
let instance = Arc::new(WebSocketInstance {
@@ -91,6 +125,7 @@ pub async fn connect_to_server(
let _ = app_handle_clone.emit(&format!("ws-message-{}", client_id_clone), text);
},
Some(Err(_)) | None => {
log::debug!("WebSocket connection closed or error");
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
break;
}
@@ -98,7 +133,8 @@ pub async fn connect_to_server(
}
}
_ = cancel_rx.recv() => {
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
log::debug!("WebSocket connection cancelled");
let _ = app_handle_clone.emit(&format!("ws-cancel-{}", client_id_clone), id.clone());
break;
}
}
@@ -112,9 +148,11 @@ pub async fn connect_to_server(
Ok(())
}
#[tauri::command]
pub async fn disconnect(client_id: String, state: tauri::State<'_, WebSocketManager>) -> Result<(), String> {
pub async fn disconnect(
client_id: String,
state: tauri::State<'_, WebSocketManager>,
) -> Result<(), String> {
let instance = {
let mut connections = state.connections.lock().await;
connections.remove(&client_id)
@@ -129,4 +167,4 @@ pub async fn disconnect(client_id: String, state: tauri::State<'_, WebSocketMana
}
Ok(())
}
}

72
src-tauri/src/settings.rs Normal file
View File

@@ -0,0 +1,72 @@
use crate::COCO_TAURI_STORE;
use serde_json::Value as Json;
use tauri::{AppHandle, Runtime};
use tauri_plugin_store::StoreExt;
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
#[tauri::command]
pub async fn set_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>, value: bool) {
use crate::server::http_client;
let store = tauri_app_handle
.store(COCO_TAURI_STORE)
.unwrap_or_else(|e| {
panic!(
"store [{}] not found/loaded, error [{}]",
COCO_TAURI_STORE, e
)
});
let old_value = match store
.get(SETTINGS_ALLOW_SELF_SIGNATURE)
.expect("should be initialized upon first get call")
{
Json::Bool(b) => b,
_ => unreachable!(
"{} should be stored in a boolean",
SETTINGS_ALLOW_SELF_SIGNATURE
),
};
if old_value == value {
return;
}
store.set(SETTINGS_ALLOW_SELF_SIGNATURE, value);
let mut guard = http_client::HTTP_CLIENT.lock().await;
*guard = http_client::new_reqwest_http_client(value)
}
/// Synchronous version of `async get_allow_self_signature()`.
pub fn _get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) -> bool {
let store = tauri_app_handle
.store(COCO_TAURI_STORE)
.unwrap_or_else(|e| {
panic!(
"store [{}] not found/loaded, error [{}]",
COCO_TAURI_STORE, e
)
});
if !store.has(SETTINGS_ALLOW_SELF_SIGNATURE) {
// default to false
store.set(SETTINGS_ALLOW_SELF_SIGNATURE, false);
}
match store
.get(SETTINGS_ALLOW_SELF_SIGNATURE)
.expect("should be Some")
{
Json::Bool(b) => b,
_ => unreachable!(
"{} should be stored in a boolean",
SETTINGS_ALLOW_SELF_SIGNATURE
),
}
}
#[tauri::command]
pub async fn get_allow_self_signature<R: Runtime>(tauri_app_handle: AppHandle<R>) -> bool {
_get_allow_self_signature(tauri_app_handle)
}

View File

@@ -20,7 +20,7 @@ pub use linux::*;
pub fn default(app: &mut App, main_window: WebviewWindow, settings_window: WebviewWindow) {
// Development mode automatically opens the console: https://tauri.app/develop/debug
#[cfg(any(dev, debug_assertions))]
#[cfg(all(dev, debug_assertions))]
main_window.open_devtools();
platform(app, main_window.clone(), settings_window.clone());

View File

@@ -67,7 +67,6 @@ fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
//
// tauri_plugin_shell::open() is deprecated, but we still use it.
#[allow(deprecated)]
#[tauri::command]
pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<(), String> {
if cfg!(target_os = "linux") {
let borrowed_path = Path::new(&path);

View File

@@ -42,6 +42,8 @@
"url": "/ui/settings",
"width": 1000,
"height": 700,
"minHeight": 700,
"minWidth": 1000,
"center": true,
"transparent": true,
"maximizable": false,
@@ -92,7 +94,7 @@
"icons/StoreLogo.png"
],
"macOS": {
"minimumSystemVersion": "12.0",
"minimumSystemVersion": "10.12",
"hardenedRuntime": true,
"dmg": {
"appPosition": {
@@ -105,7 +107,7 @@
}
}
},
"resources": ["assets", "icons"]
"resources": ["assets/**/*", "icons"]
},
"plugins": {
"features": {

View File

@@ -59,6 +59,7 @@ export const handleApiError = (error: any) => {
message = error.message;
}
console.error(error);
addError(message, "error");
return error;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

View File

@@ -16,11 +16,31 @@ import {
MultiSourceQueryResponse,
} from "@/types/commands";
import { useAppStore } from "@/stores/appStore";
import { useAuthStore } from "@/stores/authStore";
// Endpoints that don't require authentication
const WHITELIST_SERVERS = [
"list_coco_servers",
"add_coco_server",
"enable_server",
"disable_server",
"remove_coco_server",
"logout_coco_server",
"refresh_coco_server_info",
"handle_sso_callback",
"query_coco_fusion",
];
async function invokeWithErrorHandler<T>(
command: string,
args?: Record<string, any>
): Promise<T> {
const isCurrentLogin = useAuthStore.getState().isCurrentLogin;
if (!WHITELIST_SERVERS.includes(command) && !isCurrentLogin) {
console.error("This command requires authentication");
throw new Error("This command requires authentication");
}
//
const addError = useAppStore.getState().addError;
try {
const result = await invoke<T>(command, args);
@@ -28,10 +48,10 @@ async function invokeWithErrorHandler<T>(
if (result && typeof result === "object" && "failed" in result) {
const failedResult = result as any;
if (failedResult.failed?.length > 0) {
if (failedResult.failed?.length > 0 && failedResult?.hits?.length == 0) {
failedResult.failed.forEach((error: any) => {
// addError(error.error, 'error');
console.error(error.error);
addError(error.error, 'error');
// console.error(error.error);
});
}
}
@@ -261,6 +281,19 @@ export const assistant_search = (payload: {
return invokeWithErrorHandler<boolean>("assistant_search", payload);
};
export const assistant_get = (payload: {
serverId: string;
assistantId: string;
}): Promise<boolean> => {
return invokeWithErrorHandler<boolean>("assistant_get", payload);
};
export const assistant_get_multi = (payload: {
assistantId: string;
}): Promise<boolean> => {
return invokeWithErrorHandler<boolean>("assistant_get_multi", payload);
};
export const upload_attachment = async (payload: UploadAttachmentPayload) => {
const response = await invokeWithErrorHandler<UploadAttachmentResponse>(
"upload_attachment",

View File

@@ -0,0 +1,117 @@
import { useRef } from "react";
import { Post } from "@/api/axiosRequest";
import platformAdapter from "@/utils/platformAdapter";
import { useConnectStore } from "@/stores/connectStore";
import { useAppStore } from "@/stores/appStore";
interface AssistantFetcherProps {
debounceKeyword?: string;
assistantIDs?: string[];
}
export const AssistantFetcher = ({
debounceKeyword = "",
assistantIDs = [],
}: AssistantFetcherProps) => {
const isTauri = useAppStore((state) => state.isTauri);
const currentService = useConnectStore((state) => state.currentService);
const currentAssistant = useConnectStore((state) => state.currentAssistant);
const setCurrentAssistant = useConnectStore((state) => {
return state.setCurrentAssistant;
});
const lastServerId = useRef<string | null>(null);
const fetchAssistant = async (params: {
current: number;
pageSize: number;
serverId?: string;
}) => {
try {
const { pageSize, current, serverId = currentService?.id } = params;
const from = (current - 1) * pageSize;
const size = pageSize;
let response: any;
const body: Record<string, any> = {
serverId,
from,
size,
};
body.query = {
bool: {
must: [{ term: { enabled: true } }],
},
};
if (debounceKeyword) {
body.query.bool.must.push({
query_string: {
fields: ["combined_fulltext"],
query: debounceKeyword,
fuzziness: "AUTO",
fuzzy_prefix_length: 2,
fuzzy_max_expansions: 10,
fuzzy_transpositions: true,
allow_leading_wildcard: false,
},
});
}
if (assistantIDs.length > 0) {
body.query.bool.must.push({
terms: {
id: assistantIDs.map((id) => id),
},
});
}
if (isTauri) {
if (!currentService?.id) {
throw new Error("currentService is undefined");
}
response = await platformAdapter.commands("assistant_search", body);
} else {
const [error, res] = await Post(`/assistant/_search`, body);
if (error) {
throw new Error(error);
}
response = res;
}
let assistantList = response?.hits?.hits ?? [];
console.log("assistantList", assistantList);
if (
!currentAssistant?._id ||
currentService?.id !== lastServerId.current
) {
setCurrentAssistant(assistantList[0]);
}
lastServerId.current = currentService?.id;
return {
total: response.hits.total.value,
list: assistantList,
};
} catch (error) {
setCurrentAssistant(null);
console.error("assistant_search", error);
return {
total: 0,
list: [],
};
}
};
return { fetchAssistant };
};

View File

@@ -0,0 +1,70 @@
import { memo } from "react";
import clsx from "clsx";
import { Check } from "lucide-react";
import VisibleKey from "@/components/Common/VisibleKey";
import FontIcon from "@/components/Common/Icons/FontIcon";
import logoImg from "@/assets/icon.svg";
interface AssistantItemProps {
_id: string;
_source?: {
icon?: string;
name?: string;
description?: string;
};
name?: string;
isActive: boolean;
isHighlight: boolean;
isKeyboardActive: boolean;
onClick: () => void;
}
const AssistantItem = memo(
({
_id,
_source,
name,
isActive,
isHighlight,
isKeyboardActive = false,
onClick,
}: AssistantItemProps) => (
<button
key={_id}
className={clsx(
"w-full flex items-center h-[50px] gap-2 rounded-lg p-2 mb-1 transition",
{
"hover:bg-[#E6E6E6] dark:hover:bg-[#1F2937]": !isKeyboardActive,
"bg-[#E6E6E6] dark:bg-[#1F2937]": isHighlight || isActive,
}
)}
onClick={onClick}
>
<div className="flex items-center justify-center size-6 bg-white border border-[#E6E6E6] rounded-full overflow-hidden">
{_source?.icon?.startsWith("font_") ? (
<FontIcon name={_source?.icon} className="size-4" />
) : (
<img src={logoImg} className="size-4" alt={name} />
)}
</div>
<div className="text-left flex-1 min-w-0">
<div className="font-medium text-gray-900 dark:text-white truncate">
{_source?.name || "-"}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{_source?.description || ""}
</div>
</div>
{isActive && (
<div className="flex items-center">
<VisibleKey shortcut="↓↑" shortcutClassName="w-6 -translate-x-4">
<Check className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</VisibleKey>
</div>
)}
</button>
)
);
export default AssistantItem;

View File

@@ -1,48 +1,30 @@
import { useState, useRef, useCallback, useMemo } from "react";
import {
ChevronDownIcon,
RefreshCw,
Check,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useState, useRef, useCallback, useEffect } from "react";
import { ChevronDownIcon, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { isNil } from "lodash-es";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { useDebounce, useKeyPress, usePagination } from "ahooks";
import clsx from "clsx";
import { useAppStore } from "@/stores/appStore";
import logoImg from "@/assets/icon.svg";
import platformAdapter from "@/utils/platformAdapter";
import VisibleKey from "@/components/Common/VisibleKey";
import { useConnectStore } from "@/stores/connectStore";
import FontIcon from "@/components/Common/Icons/FontIcon";
import { useChatStore } from "@/stores/chatStore";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { Post } from "@/api/axiosRequest";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import {
useAsyncEffect,
useDebounce,
useKeyPress,
usePagination,
useReactive,
} from "ahooks";
import clsx from "clsx";
import NoDataImage from "../Common/NoDataImage";
import PopoverInput from "../Common/PopoverInput";
import { isNil } from "lodash-es";
import NoDataImage from "@/components/Common/NoDataImage";
import PopoverInput from "@/components/Common/PopoverInput";
import { AssistantFetcher } from "./AssistantFetcher";
import AssistantItem from "./AssistantItem";
import Pagination from "@/components/Common/Pagination";
import { useSearchStore } from "@/stores/searchStore";
interface AssistantListProps {
assistantIDs?: string[];
}
interface State {
allAssistants: any[];
}
export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
const { t } = useTranslation();
const { connected } = useChatStore();
const isTauri = useAppStore((state) => state.isTauri);
const setAssistantList = useConnectStore((state) => state.setAssistantList);
const currentService = useConnectStore((state) => state.currentService);
const currentAssistant = useConnectStore((state) => state.currentAssistant);
const setCurrentAssistant = useConnectStore((state) => {
@@ -51,137 +33,25 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
const aiAssistant = useShortcutsStore((state) => state.aiAssistant);
const [assistants, setAssistants] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const popoverButtonRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const [keyword, setKeyword] = useState("");
const debounceKeyword = useDebounce(keyword, { wait: 500 });
const state = useReactive<State>({
allAssistants: [],
const askAiAssistantId = useSearchStore((state) => state.askAiAssistantId);
const setAskAiAssistantId = useSearchStore((state) => {
return state.setAskAiAssistantId;
});
const assistantList = useConnectStore((state) => state.assistantList);
const currentServiceId = useMemo(() => {
return currentService?.id;
}, [connected, currentService?.id]);
const fetchAssistant = async (params: {
current: number;
pageSize: number;
}) => {
try {
const { pageSize, current } = params;
const from = (current - 1) * pageSize;
const size = pageSize;
let response: any;
const body: Record<string, any> = {
serverId: currentServiceId,
from,
size,
};
if (debounceKeyword || assistantIDs.length > 0) {
body.query = {
bool: {
must: [],
},
};
if (debounceKeyword) {
body.query.bool.must.push({
query_string: {
fields: ["combined_fulltext"],
query: debounceKeyword,
fuzziness: "AUTO",
fuzzy_prefix_length: 2,
fuzzy_max_expansions: 10,
fuzzy_transpositions: true,
allow_leading_wildcard: false,
},
});
}
if (assistantIDs.length > 0) {
body.query.bool.must.push({
terms: {
id: assistantIDs.map((id) => id),
},
});
}
}
if (isTauri) {
if (!currentServiceId) {
throw new Error("currentServiceId is undefined");
}
response = await platformAdapter.commands("assistant_search", body);
} else {
const [error, res] = await Post(`/assistant/_search`, body);
if (error) {
throw new Error(error);
}
response = res;
}
console.log("assistant_search", response);
let assistantList = response?.hits?.hits ?? [];
console.log("assistantList", assistantList);
for (const item of assistantList) {
const index = state.allAssistants.findIndex((allItem: any) => {
return item._id === allItem._id;
});
if (index === -1) {
state.allAssistants.push(item);
} else {
state.allAssistants[index] = item;
}
}
console.log("state.allAssistants", state.allAssistants);
const matched = state.allAssistants.find((item: any) => {
return item._id === currentAssistant?._id;
});
console.log("matched", matched);
if (matched) {
setCurrentAssistant(matched);
} else {
setCurrentAssistant(assistantList[0]);
}
return {
total: response.hits.total.value,
list: assistantList,
};
} catch (error) {
setCurrentAssistant(null);
console.error("assistant_search", error);
return {
total: 0,
list: [],
};
}
};
useAsyncEffect(async () => {
const data = await fetchAssistant({ current: 1, pageSize: 1000 });
setAssistantList(data.list);
}, [currentServiceId]);
const { fetchAssistant } = AssistantFetcher({
debounceKeyword,
assistantIDs,
});
const { pagination, runAsync } = usePagination(fetchAssistant, {
defaultPageSize: 5,
refreshDeps: [currentServiceId, debounceKeyword],
refreshDeps: [currentService?.id, debounceKeyword],
onSuccess(data) {
setAssistants(data.list);
},
@@ -195,25 +65,60 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
setTimeout(() => setIsRefreshing(false), 1000);
};
useKeyPress(["uparrow", "downarrow"], (_, key) => {
const isClose = isNil(popoverButtonRef.current?.dataset["open"]);
const index = assistants.findIndex(
(item) => item._id === currentAssistant?._id
);
const length = assistants.length;
const [highlightIndex, setHighlightIndex] = useState<number>(-1);
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
if (isClose || length <= 1) return;
useEffect(() => {
if (!askAiAssistantId || assistantList.length === 0) return;
let nextIndex = index;
const matched = assistantList.find((item) => {
return item._id === askAiAssistantId;
});
if (key === "uparrow") {
nextIndex = index > 0 ? index - 1 : length - 1;
} else {
nextIndex = index < length - 1 ? index + 1 : 0;
if (!matched) return;
setCurrentAssistant(matched);
setAskAiAssistantId(void 0);
}, [assistantList, askAiAssistantId]);
useKeyPress(
["uparrow", "downarrow", "enter"],
(event, key) => {
const isClose = isNil(popoverButtonRef.current?.dataset["open"]);
if (isClose) return;
event.stopPropagation();
event.preventDefault();
setIsKeyboardActive(true);
const index = assistants.findIndex(
(item) => item._id === currentAssistant?._id
);
const length = assistants.length;
if (length <= 1) return;
let nextIndex = highlightIndex === -1 ? index : highlightIndex;
if (key === "uparrow") {
nextIndex = nextIndex > 0 ? nextIndex - 1 : length - 1;
} else if (key === "downarrow") {
nextIndex = nextIndex < length - 1 ? nextIndex + 1 : 0;
}
if (key === "enter") {
setCurrentAssistant(assistants[nextIndex]);
return popoverButtonRef.current?.click();
}
setHighlightIndex(nextIndex);
},
{
target: popoverRef,
}
setCurrentAssistant(assistants[nextIndex]);
});
);
const handlePrev = useCallback(() => {
if (pagination.current <= 1) return;
@@ -229,9 +134,14 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
pagination.changeCurrent(pagination.current + 1);
}, [pagination]);
const handleMouseMove = useCallback(() => {
setHighlightIndex(-1);
setIsKeyboardActive(false);
}, []);
return (
<div className="relative">
<Popover>
<Popover ref={popoverRef}>
<PopoverButton
ref={popoverButtonRef}
className="h-6 p-1 px-1.5 flex items-center gap-1 rounded-full bg-white dark:bg-[#202126] text-sm/6 font-semibold text-gray-800 dark:text-[#d8d8d8] border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
@@ -263,7 +173,10 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
</VisibleKey>
</PopoverButton>
<PopoverPanel className="absolute z-50 top-full mt-1 left-0 w-60 rounded-xl bg-white dark:bg-[#202126] p-3 text-sm/6 text-[#333] dark:text-[#D8D8D8] shadow-lg border dark:border-white/10 focus:outline-none max-h-[calc(100vh-80px)] overflow-y-auto">
<PopoverPanel
className="absolute z-50 top-full mt-1 left-0 w-60 rounded-xl bg-white dark:bg-[#202126] p-3 text-sm/6 text-[#333] dark:text-[#D8D8D8] shadow-lg border dark:border-white/10 focus:outline-none max-h-[calc(100vh-80px)] overflow-y-auto"
onMouseMove={handleMouseMove}
>
<div className="flex items-center justify-between text-sm font-bold">
<div>
{t("assistant.popover.title")}{pagination.total}
@@ -302,7 +215,6 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
placeholder={t("assistant.popover.search")}
className="w-full h-8 px-2 bg-transparent border rounded-md dark:border-white/10"
onChange={(event) => {
console.log("onChange", event.target.value);
setKeyword(event.target.value.trim());
}}
/>
@@ -310,72 +222,29 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
{assistants.length > 0 ? (
<>
{assistants.map((assistant) => {
const { _id, _source, name } = assistant;
const isActive = currentAssistant?._id === _id;
{assistants.map((assistant, index) => {
return (
<button
key={_id}
className={clsx(
"w-full flex items-center h-[50px] gap-2 rounded-lg p-2 mb-1 hover:bg-[#E6E6E6] dark:hover:bg-[#1F2937] transition",
{
"bg-[#E6E6E6] dark:bg-[#1F2937]": isActive,
}
)}
<AssistantItem
key={assistant._id}
{...assistant}
isActive={currentAssistant?._id === assistant._id}
isHighlight={highlightIndex === index}
isKeyboardActive={isKeyboardActive}
onClick={() => {
setCurrentAssistant(assistant);
popoverButtonRef.current?.click();
}}
>
<div className="flex items-center justify-center size-6 bg-white border border-[#E6E6E6] rounded-full overflow-hidden">
{_source?.icon?.startsWith("font_") ? (
<FontIcon name={_source?.icon} className="size-4" />
) : (
<img src={logoImg} className="size-4" alt={name} />
)}
</div>
<div className="text-left flex-1 min-w-0">
<div className="font-medium text-gray-900 dark:text-white truncate">
{_source?.name || "-"}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{_source?.description || ""}
</div>
</div>
{isActive && (
<div className="flex items-center">
<VisibleKey
shortcut="↓↑"
shortcutClassName="w-6 -translate-x-4"
>
<Check className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</VisibleKey>
</div>
)}
</button>
/>
);
})}
<div className="flex items-center justify-between h-8 -mx-3 -mb-3 px-3 text-[#999] border-t dark:border-t-white/10">
<VisibleKey shortcut="leftarrow" onKeyPress={handlePrev}>
<ChevronLeft
className="size-4 cursor-pointer"
onClick={handlePrev}
/>
</VisibleKey>
<div className="text-xs">
{pagination.current}/{pagination.totalPage}
</div>
<VisibleKey shortcut="rightarrow" onKeyPress={handleNext}>
<ChevronRight
className="size-4 cursor-pointer"
onClick={handleNext}
/>
</VisibleKey>
</div>
<Pagination
current={pagination.current}
totalPage={pagination.totalPage}
onPrev={handlePrev}
onNext={handleNext}
className="-mx-3 -mb-3"
/>
</>
) : (
<div className="flex justify-center items-center py-2">

View File

@@ -19,9 +19,13 @@ import { ChatSidebar } from "./ChatSidebar";
import { ChatHeader } from "./ChatHeader";
import { ChatContent } from "./ChatContent";
import ConnectPrompt from "./ConnectPrompt";
import type { Chat } from "./types";
import type { Chat, StartPage } from "@/types/chat";
import PrevSuggestion from "@/components/ChatMessage/PrevSuggestion";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
// import ReadAloud from "./ReadAloud";
import { useAuthStore } from "@/stores/authStore";
import Splash from "./Splash";
interface ChatAIProps {
isSearchActive?: boolean;
@@ -36,6 +40,7 @@ interface ChatAIProps {
getFileUrl: (path: string) => string;
showChatHistory?: boolean;
assistantIDs?: string[];
startPage?: StartPage;
}
export interface ChatAIRef {
@@ -61,6 +66,7 @@ const ChatAI = memo(
getFileUrl,
showChatHistory,
assistantIDs,
startPage,
},
ref
) => {
@@ -74,7 +80,8 @@ const ChatAI = memo(
const { curChatEnd, setCurChatEnd, connected, setConnected } =
useChatStore();
const currentService = useConnectStore((state) => state.currentService);
const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
const visibleStartPage = useConnectStore((state) => {
return state.visibleStartPage;
});
@@ -83,17 +90,35 @@ const ChatAI = memo(
const [activeChat, setActiveChat] = useState<Chat>();
const [timedoutShow, setTimedoutShow] = useState(false);
const [isLogin, setIsLogin] = useState(true);
const curIdRef = useRef("");
const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen);
const [chats, setChats] = useState<Chat[]>([]);
const askAiSessionId = useSearchStore((state) => state.askAiSessionId);
const setAskAiSessionId = useSearchStore(
(state) => state.setAskAiSessionId
);
const askAiServerId = useSearchStore((state) => {
return state.askAiServerId;
});
useEffect(() => {
activeChatProp && setActiveChat(activeChatProp);
}, [activeChatProp]);
useEffect(() => {
if (askAiServerId || !askAiSessionId || chats.length === 0) return;
const matched = chats.find((item) => item._id === askAiSessionId);
if (matched) {
onSelectChat(matched);
setAskAiSessionId(void 0);
}
}, [chats, askAiSessionId, askAiServerId]);
const [Question, setQuestion] = useState<string>("");
const [websocketSessionId, setWebsocketSessionId] = useState("");
@@ -129,11 +154,10 @@ const ChatAI = memo(
const dealMsgRef = useRef<((msg: string) => void) | null>(null);
const clientId = isChatPage ? "standalone" : "popup";
const { reconnect, disconnectWS, updateDealMsg } = useWebSocket({
const { reconnect, updateDealMsg } = useWebSocket({
clientId,
connected,
setConnected,
currentService,
dealMsgRef,
onWebsocketSessionId,
});
@@ -151,7 +175,6 @@ const ChatAI = memo(
handleRename,
handleDelete,
} = useChatActions(
currentService?.id,
setActiveChat,
setCurChatEnd,
setTimedoutShow,
@@ -164,10 +187,10 @@ const ChatAI = memo(
isMCPActive,
changeInput,
websocketSessionId,
showChatHistory,
showChatHistory
);
const { dealMsg, messageTimeoutRef } = useMessageHandler(
const { dealMsg } = useMessageHandler(
curIdRef,
setCurChatEnd,
setTimedoutShow,
@@ -184,25 +207,19 @@ const ChatAI = memo(
}, [dealMsg, updateDealMsg]);
const clearChat = useCallback(() => {
console.log("clearChat");
//console.log("clearChat");
setTimedoutShow(false);
chatClose(activeChat);
setActiveChat(undefined);
setCurChatEnd(true);
clearChatPage && clearChatPage();
}, [
activeChat,
chatClose,
clearChatPage,
setCurChatEnd,
setTimedoutShow,
]);
}, [activeChat, chatClose]);
const init = useCallback(
async (value: string) => {
try {
console.log("init", isLogin, curChatEnd, activeChat?._id);
if (!isLogin) {
//console.log("init", curChatEnd, activeChat?._id);
if (!isCurrentLogin) {
addError("Please login to continue chatting");
return;
}
@@ -220,9 +237,9 @@ const ChatAI = memo(
}
},
[
isLogin,
isCurrentLogin,
curChatEnd,
activeChat,
activeChat?._id,
createNewChat,
handleSendMessage,
websocketSessionId,
@@ -234,21 +251,6 @@ const ChatAI = memo(
createChatWindow(createWin);
}, [createChatWindow, createWin]);
useEffect(() => {
setCurChatEnd(true);
return () => {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
Promise.resolve().then(() => {
chatClose(activeChat);
setActiveChat(undefined);
setCurChatEnd(true);
disconnectWS();
});
};
}, [chatClose, setCurChatEnd]);
const onSelectChat = useCallback(
async (chat: Chat) => {
setTimedoutShow(false);
@@ -260,33 +262,28 @@ const ChatAI = memo(
chatHistory(response);
}
},
[
clearAllChunkData,
cancelChat,
activeChat,
chatClose,
openSessionChat,
chatHistory,
]
[cancelChat, activeChat, chatClose, openSessionChat, chatHistory]
);
const deleteChat = useCallback(
(chatId: string) => {
handleDelete(chatId);
setChats((prev) => prev.filter((chat) => chat._id !== chatId));
setChats((prev) => {
const updatedChats = prev.filter((chat) => chat._id !== chatId);
if (activeChat?._id === chatId) {
const remainingChats = chats.filter((chat) => chat._id !== chatId);
if (remainingChats.length > 0) {
setActiveChat(remainingChats[0]);
} else {
init("");
if (activeChat?._id === chatId) {
if (updatedChats.length > 0) {
setActiveChat(updatedChats[0]);
} else {
init("");
}
}
}
return updatedChats;
});
},
[activeChat, chats, init, setActiveChat]
[activeChat?._id, handleDelete, init]
);
const handleOutsideClick = useCallback((e: MouseEvent) => {
@@ -317,50 +314,41 @@ const ChatAI = memo(
!isSidebarOpenChat && getChatHistory();
}, [isSidebarOpenChat, setIsSidebarOpen, getChatHistory]);
const renameChat = (chatId: string, title: string) => {
setChats((prev) => {
const updatedChats = prev.map((item) => {
if (item._id !== chatId) return item;
const renameChat = useCallback(
(chatId: string, title: string) => {
setChats((prev) => {
const chatIndex = prev.findIndex((chat) => chat._id === chatId);
if (chatIndex === -1) return prev;
return { ...item, _source: { ...item._source, title } };
const modifiedChat = {
...prev[chatIndex],
_source: { ...prev[chatIndex]._source, title },
};
const result = [...prev];
result.splice(chatIndex, 1);
return [modifiedChat, ...result];
});
const modifiedChat = updatedChats.find((item) => {
return item._id === chatId;
});
if (!modifiedChat) {
return updatedChats;
if (activeChat?._id === chatId) {
setActiveChat((prev) => {
if (!prev) return prev;
return { ...prev, _source: { ...prev._source, title } };
});
}
return [
modifiedChat,
...updatedChats.filter((item) => item._id !== chatId),
];
});
if (activeChat?._id === chatId) {
setActiveChat((prev) => {
if (!prev) return prev;
return { ...prev, _source: { ...prev._source, title } };
});
}
handleRename(chatId, title);
};
handleRename(chatId, title);
},
[activeChat?._id, handleRename]
);
return (
<div
data-tauri-drag-region
className={`h-full flex flex-col rounded-md relative`}
>
<>
{showChatHistory && !setIsSidebarOpen && (
<ChatSidebar
isSidebarOpen={isSidebarOpenChat}
chats={chats}
activeChat={activeChat}
// onNewChat={clearChat}
onSelectChat={onSelectChat}
onDeleteChat={deleteChat}
fetchChatHistory={getChatHistory}
@@ -368,47 +356,56 @@ const ChatAI = memo(
onRename={renameChat}
/>
)}
<ChatHeader
onCreateNewChat={clearChat}
onOpenChatAI={openChatAI}
setIsSidebarOpen={toggleSidebar}
isSidebarOpen={isSidebarOpenChat}
activeChat={activeChat}
reconnect={reconnect}
isChatPage={isChatPage}
isLogin={isLogin}
setIsLogin={setIsLogin}
showChatHistory={showChatHistory}
assistantIDs={assistantIDs}
/>
{isLogin ? (
<ChatContent
<div
data-tauri-drag-region
className={`flex flex-col rounded-md h-full overflow-hidden relative`}
>
<ChatHeader
clearChat={clearChat}
onOpenChatAI={openChatAI}
setIsSidebarOpen={toggleSidebar}
isSidebarOpen={isSidebarOpenChat}
activeChat={activeChat}
curChatEnd={curChatEnd}
query_intent={query_intent}
tools={tools}
fetch_source={fetch_source}
pick_source={pick_source}
deep_read={deep_read}
think={think}
response={response}
loadingStep={loadingStep}
timedoutShow={timedoutShow}
Question={Question}
handleSendMessage={(value) =>
handleSendMessage(value, activeChat)
}
getFileUrl={getFileUrl}
reconnect={reconnect}
isChatPage={isChatPage}
showChatHistory={showChatHistory}
assistantIDs={assistantIDs}
/>
) : (
<ConnectPrompt />
)}
{!activeChat?._id && !visibleStartPage && (
<PrevSuggestion sendMessage={init} />
)}
</div>
{isCurrentLogin ? (<>
<ChatContent
activeChat={activeChat}
curChatEnd={curChatEnd}
query_intent={query_intent}
tools={tools}
fetch_source={fetch_source}
pick_source={pick_source}
deep_read={deep_read}
think={think}
response={response}
loadingStep={loadingStep}
timedoutShow={timedoutShow}
Question={Question}
handleSendMessage={(value) =>
handleSendMessage(value, activeChat)
}
getFileUrl={getFileUrl}
/>
<Splash assistantIDs={assistantIDs} startPage={startPage}/>
</>
) : (
<ConnectPrompt />
)}
{!activeChat?._id && !visibleStartPage && (
<PrevSuggestion sendMessage={init} />
)}
{/* <ReadAloud /> */}
</div>
</>
);
}
)

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect } from "react";
import { useRef, useEffect, UIEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChatMessage } from "@/components/ChatMessage";
@@ -6,11 +6,10 @@ import { Greetings } from "./Greetings";
import FileList from "@/components/Assistant/FileList";
import { useChatScroll } from "@/hooks/useChatScroll";
import { useChatStore } from "@/stores/chatStore";
import type { Chat, IChunkData } from "./types";
// import SessionFile from "./SessionFile";
import type { Chat, IChunkData } from "@/types/chat";
import { useConnectStore } from "@/stores/connectStore";
import SessionFile from "./SessionFile";
import Splash from "./Splash";
import ScrollToBottom from "@/components/Common/ScrollToBottom";
interface ChatContentProps {
activeChat?: Chat;
@@ -50,22 +49,24 @@ export const ChatContent = ({
return state.setCurrentSessionId;
});
useEffect(() => {
setCurrentSessionId(activeChat?._id);
}, [activeChat]);
const { t } = useTranslation();
const uploadFiles = useChatStore((state) => state.uploadFiles);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { scrollToBottom } = useChatScroll(messagesEndRef);
const scrollRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
useEffect(() => {
setIsAtBottom(true);
setCurrentSessionId(activeChat?._id);
}, [activeChat?._id]);
useEffect(() => {
scrollToBottom();
}, [
activeChat?.messages,
activeChat?.id,
query_intent?.message_chunk,
fetch_source?.message_chunk,
pick_source?.message_chunk,
@@ -81,10 +82,25 @@ export const ChatContent = ({
};
}, [scrollToBottom]);
const allMessages = activeChat?.messages || [];
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const { scrollHeight, scrollTop, clientHeight } =
event.currentTarget as HTMLDivElement;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
setIsAtBottom(isAtBottom);
};
return (
<div className="relative flex flex-col h-full justify-between overflow-hidden">
<div className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative">
<Greetings />
<div className="flex-1 overflow-hidden flex flex-col justify-between relative user-select-text">
<div
ref={scrollRef}
className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative"
onScroll={handleScroll}
>
{(!activeChat || activeChat?.messages?.length === 0) && <Greetings />}
{activeChat?.messages?.map((message, index) => (
<ChatMessage
@@ -94,6 +110,7 @@ export const ChatContent = ({
onResend={handleSendMessage}
/>
))}
{(!curChatEnd ||
query_intent ||
tools ||
@@ -109,6 +126,8 @@ export const ChatContent = ({
_id: "current",
_source: {
type: "assistant",
assistant_id:
allMessages[allMessages.length - 1]?._source?.assistant_id,
message: "",
question: Question,
},
@@ -125,6 +144,7 @@ export const ChatContent = ({
loadingStep={loadingStep}
/>
) : null}
{timedoutShow ? (
<ChatMessage
key={"timedout"}
@@ -151,7 +171,7 @@ export const ChatContent = ({
{sessionId && <SessionFile sessionId={sessionId} />}
<Splash />
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
</div>
);
};

View File

@@ -5,38 +5,36 @@ import HistoryIcon from "@/icons/History";
import PinOffIcon from "@/icons/PinOff";
import PinIcon from "@/icons/Pin";
import WindowsFullIcon from "@/icons/WindowsFull";
import { useAppStore, IServer } from "@/stores/appStore";
import type { Chat } from "./types";
import { useAppStore } from "@/stores/appStore";
import type { Chat } from "@/types/chat";
import platformAdapter from "@/utils/platformAdapter";
import VisibleKey from "../Common/VisibleKey";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { HISTORY_PANEL_ID } from "@/constants";
import { AssistantList } from "./AssistantList";
import { ServerList } from "./ServerList";
import { Server } from "@/types/server"
interface ChatHeaderProps {
onCreateNewChat: () => void;
clearChat: () => void;
onOpenChatAI: () => void;
setIsSidebarOpen: () => void;
isSidebarOpen: boolean;
activeChat: Chat | undefined;
reconnect: (server?: IServer) => void;
isLogin: boolean;
setIsLogin: (isLogin: boolean) => void;
reconnect: (server?: Server) => void;
isChatPage?: boolean;
showChatHistory?: boolean;
assistantIDs?: string[];
}
export function ChatHeader({
onCreateNewChat,
clearChat,
onOpenChatAI,
isSidebarOpen,
setIsSidebarOpen,
activeChat,
reconnect,
isLogin,
setIsLogin,
isChatPage = false,
showChatHistory = true,
assistantIDs,
@@ -97,10 +95,10 @@ export function ChatHeader({
{showChatHistory ? (
<button
onClick={onCreateNewChat}
onClick={clearChat}
className="p-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
>
<VisibleKey shortcut={newSession} onKeyPress={onCreateNewChat}>
<VisibleKey shortcutClassName="top-2.5" shortcut={newSession} onKeyPress={clearChat}>
<MessageSquarePlus className="h-4 w-4 relative top-0.5" />
</VisibleKey>
</button>
@@ -112,6 +110,7 @@ export function ChatHeader({
activeChat?._source?.message ||
activeChat?._id}
</h2>
{isTauri ? (
<div className="flex items-center gap-2">
<button
@@ -126,10 +125,8 @@ export function ChatHeader({
</button>
<ServerList
isLogin={isLogin}
setIsLogin={setIsLogin}
reconnect={reconnect}
onCreateNewChat={onCreateNewChat}
clearChat={clearChat}
/>
{isChatPage ? null : (

View File

@@ -1,7 +1,6 @@
import React from "react";
// import { Sidebar } from "@/components/Assistant/Sidebar";
import type { Chat } from "./types";
import type { Chat } from "@/types/chat";
import HistoryList from "../Common/HistoryList";
import { HISTORY_PANEL_ID } from "@/constants";
@@ -9,10 +8,9 @@ interface ChatSidebarProps {
isSidebarOpen: boolean;
chats: Chat[];
activeChat?: Chat;
// onNewChat: () => void;
onSelectChat: (chat: any) => void;
onDeleteChat: (chatId: string) => void;
fetchChatHistory: () => Promise<void>;
fetchChatHistory: () => void;
onSearch: (keyword: string) => void;
onRename: (chat: any, title: string) => void;
}
@@ -21,7 +19,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
isSidebarOpen,
chats,
activeChat,
// onNewChat,
onSelectChat,
onDeleteChat,
fetchChatHistory,
@@ -32,7 +29,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
<div
data-sidebar
className={`
h-[calc(100%+90px)] absolute top-0 left-0 z-10 w-64
h-screen absolute top-0 left-0 z-100 w-64
transform transition-all duration-300 ease-in-out
${isSidebarOpen ? "translate-x-0" : "-translate-x-full"}
bg-gray-100 dark:bg-gray-800
@@ -52,14 +49,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
onRemove={onDeleteChat}
/>
)}
{/* <Sidebar
chats={chats}
activeChat={activeChat}
onNewChat={onNewChat}
onSelectChat={onSelectChat}
onDeleteChat={onDeleteChat}
fetchChatHistory={fetchChatHistory}
/> */}
</div>
);
};

View File

@@ -0,0 +1,135 @@
import { useEffect, useMemo, useRef } from "react";
import { useReactive } from "ahooks";
import dayjs from "dayjs";
import durationPlugin from "dayjs/plugin/duration";
import { useThemeStore } from "@/stores/themeStore";
import loadingLight from "@/assets/images/ReadAloud/loading-light.png";
import loadingDark from "@/assets/images/ReadAloud/loading-dark.png";
import playLight from "@/assets/images/ReadAloud/play-light.png";
import playDark from "@/assets/images/ReadAloud/play-dark.png";
import pauseLight from "@/assets/images/ReadAloud/pause-light.png";
import pauseDark from "@/assets/images/ReadAloud/pause-dark.png";
import backLight from "@/assets/images/ReadAloud/back-light.png";
import backDark from "@/assets/images/ReadAloud/back-dark.png";
import forwardLight from "@/assets/images/ReadAloud/forward-light.png";
import forwardDark from "@/assets/images/ReadAloud/forward-dark.png";
import closeLight from "@/assets/images/ReadAloud/close-light.png";
import closeDark from "@/assets/images/ReadAloud/close-dark.png";
dayjs.extend(durationPlugin);
interface State {
loading: boolean;
playing: boolean;
totalDuration: number;
currentDuration: number;
}
const ReadAloud = () => {
const isDark = useThemeStore((state) => state.isDark);
const state = useReactive<State>({
loading: false,
playing: true,
totalDuration: 300,
currentDuration: 0,
});
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const formatTime = useMemo(() => {
return dayjs.duration(state.currentDuration * 1000).format("mm:ss");
}, [state.currentDuration]);
useEffect(() => {
if (state.playing && state.currentDuration >= state.totalDuration) {
state.currentDuration = 0;
}
changeCurrentDuration();
}, [state.playing]);
const changeCurrentDuration = (duration = state.currentDuration) => {
clearTimeout(timerRef.current);
let nextDuration = duration;
if (duration < 0) {
nextDuration = 0;
}
if (duration >= state.totalDuration) {
state.currentDuration = state.totalDuration;
state.playing = false;
}
if (!state.playing) return;
state.currentDuration = nextDuration;
timerRef.current = setTimeout(() => {
changeCurrentDuration(duration + 1);
}, 1000);
};
return (
<div className="fixed top-[60px] left-1/2 z-1000 w-[200px] h-12 px-4 flex items-center justify-between -translate-x-1/2 border rounded-lg text-[#333] dark:text-[#D8D8D8] bg-white dark:bg-black dark:border-[#272828] shadow-[0_4px_8px_rgba(0,0,0,0.2)] dark:shadow-[0_4px_8px_rgba(255,255,255,0.15)]">
<div className="flex items-center gap-2">
{state.loading ? (
<img
src={isDark ? loadingDark : loadingLight}
className="size-4 animate-spin"
/>
) : (
<div
onClick={() => {
state.playing = !state.playing;
}}
>
{state.playing ? (
<img
src={isDark ? playDark : playLight}
className="size-4 cursor-pointer"
/>
) : (
<img
src={isDark ? pauseDark : pauseLight}
className="size-4 cursor-pointer"
/>
)}
</div>
)}
<span className="text-sm">{formatTime}</span>
</div>
<div className="flex gap-3">
{!state.loading && (
<>
<img
src={isDark ? backDark : backLight}
className="size-4 cursor-pointer"
onClick={() => {
changeCurrentDuration(state.currentDuration - 15);
}}
/>
<img
src={isDark ? forwardDark : forwardLight}
className="size-4 cursor-pointer"
onClick={() => {
changeCurrentDuration(state.currentDuration + 15);
}}
/>
</>
)}
<img
src={isDark ? closeDark : closeLight}
className="size-4 cursor-pointer"
/>
</div>
</div>
);
};
export default ReadAloud;

View File

@@ -3,32 +3,31 @@ import { Settings, RefreshCw, Check, Server } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { useKeyPress } from "ahooks";
import { isNil } from "lodash-es";
import logoImg from "@/assets/icon.svg";
import ServerIcon from "@/icons/Server";
import VisibleKey from "../Common/VisibleKey";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import platformAdapter from "@/utils/platformAdapter";
import { useAppStore, IServer } from "@/stores/appStore";
import { useAppStore } from "@/stores/appStore";
import { useChatStore } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore";
import { isNil } from "lodash-es";
import { Server as IServer } from "@/types/server";
import StatusIndicator from "@/components/Cloud/StatusIndicator";
import { useAuthStore } from "@/stores/authStore";
import { useSearchStore } from "@/stores/searchStore";
interface ServerListProps {
isLogin: boolean;
setIsLogin: (isLogin: boolean) => void;
reconnect: (server?: IServer) => void;
onCreateNewChat: () => void;
clearChat: () => void;
}
export function ServerList({
isLogin,
setIsLogin,
reconnect,
onCreateNewChat,
}: ServerListProps) {
export function ServerList({ reconnect, clearChat }: ServerListProps) {
const { t } = useTranslation();
const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
const setIsCurrentLogin = useAuthStore((state) => state.setIsCurrentLogin);
const serviceList = useShortcutsStore((state) => state.serviceList);
const setEndpoint = useAppStore((state) => state.setEndpoint);
const setCurrentService = useConnectStore((state) => state.setCurrentService);
@@ -39,7 +38,12 @@ export function ServerList({
const [serverList, setServerList] = useState<IServer[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const askAiServerId = useSearchStore((state) => {
return state.askAiServerId;
});
const setAskAiServerId = useSearchStore((state) => {
return state.setAskAiServerId;
});
const serverListButtonRef = useRef<HTMLButtonElement>(null);
const fetchServers = useCallback(
@@ -48,7 +52,7 @@ export function ServerList({
.commands("list_coco_servers")
.then((res: any) => {
const enabledServers = (res as IServer[]).filter(
(server) => server.enabled !== false
(server) => server.enabled && server.available
);
//console.log("list_coco_servers", enabledServers);
setServerList(enabledServers);
@@ -72,15 +76,28 @@ export function ServerList({
[currentService?.id]
);
useEffect(() => {
if (!askAiServerId || serverList.length === 0) return;
const matched = serverList.find((server) => {
return server.id === askAiServerId;
});
if (!matched) return;
switchServer(matched);
setAskAiServerId(void 0);
}, [serverList, askAiServerId]);
useEffect(() => {
if (!isTauri) return;
fetchServers(true);
const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => {
console.log("Login or Logout:", currentService, event.payload);
if (event.payload !== isLogin) {
setIsLogin(!!event.payload);
//console.log("Login or Logout:", currentService, event.payload);
if (event.payload !== isCurrentLogin) {
setIsCurrentLogin(!!event.payload);
}
fetchServers(true);
});
@@ -108,13 +125,14 @@ export function ServerList({
setCurrentService(server);
setEndpoint(server.endpoint);
setMessages(""); // Clear previous messages
onCreateNewChat();
clearChat();
//
if (!server.public && !server.profile) {
setIsLogin(false);
setIsCurrentLogin(false);
return;
}
setIsLogin(true);
//
setIsCurrentLogin(true);
// The Rust backend will automatically disconnect,
// so we don't need to handle disconnection on the frontend
// src-tauri/src/server/websocket.rs
@@ -205,23 +223,26 @@ export function ServerList({
src={server?.provider?.icon || logoImg}
alt={server.name}
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = logoImg;
}}
/>
<div className="text-left flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
{server.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
AI Assistant: {server.assistantCount || 1}
AI Assistant: {server.stats?.assistant_count || 1}
</div>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<span
className={`w-3 h-3 rounded-full ${
server.health?.status
? `bg-[${server.health?.status}]`
: "bg-gray-400 dark:bg-gray-600"
}`}
<StatusIndicator
enabled={server.enabled}
public={server.public}
hasProfile={!!server?.profile}
status={server.health?.status}
/>
<div className="size-4 flex justify-end">
{currentService?.id === server.id && (

View File

@@ -1,173 +1,174 @@
import clsx from "clsx";
import { filesize } from "filesize";
import { Files, Trash2, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {filesize} from "filesize";
import {Files, Trash2, X} from "lucide-react";
import {useEffect, useMemo, useState} from "react";
import {useTranslation} from "react-i18next";
import { useConnectStore } from "@/stores/connectStore";
import {useConnectStore} from "@/stores/connectStore";
import Checkbox from "@/components/Common/Checkbox";
import FileIcon from "@/components/Common/Icons/FileIcon";
import { AttachmentHit } from "@/types/commands";
import { useAppStore } from "@/stores/appStore";
import {AttachmentHit} from "@/types/commands";
import {useAppStore} from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
interface SessionFileProps {
sessionId: string;
sessionId: string;
}
const SessionFile = (props: SessionFileProps) => {
const { sessionId } = props;
const { t } = useTranslation();
const {sessionId} = props;
const {t} = useTranslation();
const isTauri = useAppStore((state) => state.isTauri);
const currentService = useConnectStore((state) => state.currentService);
const [uploadedFiles, setUploadedFiles] = useState<AttachmentHit[]>([]);
const [visible, setVisible] = useState(false);
const [checkList, setCheckList] = useState<string[]>([]);
const isTauri = useAppStore((state) => state.isTauri);
const currentService = useConnectStore((state) => state.currentService);
const [uploadedFiles, setUploadedFiles] = useState<AttachmentHit[]>([]);
const [visible, setVisible] = useState(false);
const [checkList, setCheckList] = useState<string[]>([]);
const serverId = useMemo(() => {
return currentService.id;
}, [currentService]);
const serverId = useMemo(() => {
return currentService.id;
}, [currentService]);
useEffect(() => {
setUploadedFiles([]);
useEffect(() => {
setUploadedFiles([]);
getUploadedFiles();
}, [sessionId]);
getUploadedFiles();
}, [sessionId]);
const getUploadedFiles = async () => {
if (isTauri) {
const response: any = await platformAdapter.commands("get_attachment", {
serverId,
sessionId,
});
const getUploadedFiles = async () => {
if (isTauri) {
const response: any = await platformAdapter.commands("get_attachment", {
serverId,
sessionId,
});
setUploadedFiles(response.hits.hits);
} else {
}
};
setUploadedFiles(response?.hits?.hits ?? []);
} else {
}
};
const handleDelete = async (id: string) => {
let result;
if (isTauri) {
result = await platformAdapter.commands("delete_attachment", {
serverId,
id,
});
} else {
}
if (!result) return;
const handleDelete = async (id: string) => {
let result;
if (isTauri) {
result = await platformAdapter.commands("delete_attachment", {
serverId,
id,
});
} else {
}
if (!result) return;
getUploadedFiles();
};
getUploadedFiles();
};
const handleCheckAll = (checked: boolean) => {
if (checked) {
setCheckList(uploadedFiles.map((item) => item._source.id));
} else {
setCheckList([]);
}
};
const handleCheckAll = (checked: boolean) => {
if (checked) {
setCheckList(uploadedFiles?.map((item) => item?._source?.id));
} else {
setCheckList([]);
}
};
const handleCheck = (checked: boolean, id: string) => {
if (checked) {
setCheckList([...checkList, id]);
} else {
setCheckList(checkList.filter((item) => item !== id));
}
};
const handleCheck = (checked: boolean, id: string) => {
if (checked) {
setCheckList([...checkList, id]);
} else {
setCheckList(checkList.filter((item) => item !== id));
}
};
return (
<div
className={clsx("select-none", {
hidden: uploadedFiles.length === 0,
})}
>
<div
className="absolute top-4 right-4 flex items-center justify-center size-8 rounded-lg bg-[#0072FF] cursor-pointer"
onClick={() => {
setVisible(true);
}}
>
<Files className="size-5 text-white" />
return (
<div
className={clsx("select-none", {
hidden: uploadedFiles?.length === 0,
})}
>
<div
className="absolute top-4 right-4 flex items-center justify-center size-8 rounded-lg bg-[#0072FF] cursor-pointer"
onClick={() => {
setVisible(true);
}}
>
<Files className="size-5 text-white"/>
<div className="absolute -top-2 -right-2 flex items-center justify-center min-w-4 h-4 px-1 text-white text-xs rounded-full bg-[#3DB954]">
{uploadedFiles.length}
</div>
</div>
<div
className="absolute -top-2 -right-2 flex items-center justify-center min-w-4 h-4 px-1 text-white text-xs rounded-full bg-[#3DB954]">
{uploadedFiles?.length}
</div>
</div>
<div
className={clsx(
"absolute inset-0 flex flex-col p-4 bg-white dark:bg-black",
{
hidden: !visible,
}
)}
>
<X
className="absolute top-4 right-4 size-5 text-[#999] cursor-pointer"
onClick={() => {
setVisible(false);
}}
/>
<div
className={clsx(
"absolute inset-0 flex flex-col p-4 bg-white dark:bg-black",
{
hidden: !visible,
}
)}
>
<X
className="absolute top-4 right-4 size-5 text-[#999] cursor-pointer"
onClick={() => {
setVisible(false);
}}
/>
<div className="mb-2 text-sm text-[#333] dark:text-[#D8D8D8] font-bold">
{t("assistant.sessionFile.title")}
</div>
<div className="flex items-center justify-between pr-2">
<div className="mb-2 text-sm text-[#333] dark:text-[#D8D8D8] font-bold">
{t("assistant.sessionFile.title")}
</div>
<div className="flex items-center justify-between pr-2">
<span className="text-sm text-[#999]">
{t("assistant.sessionFile.description")}
</span>
<Checkbox
indeterminate
checked={checkList.length === uploadedFiles.length}
onChange={handleCheckAll}
/>
</div>
<ul className="flex-1 overflow-auto flex flex-col gap-2 mt-6">
{uploadedFiles.map((item) => {
const { id, name, icon, size } = item._source;
<Checkbox
indeterminate
checked={checkList?.length === uploadedFiles?.length}
onChange={handleCheckAll}
/>
</div>
<ul className="flex-1 overflow-auto flex flex-col gap-2 mt-6 p-0">
{uploadedFiles?.map((item) => {
const {id, name, icon, size} = item._source;
return (
<li
key={id}
className="flex items-center justify-between min-h-12 px-2 rounded-[4px] bg-[#ededed] dark:bg-[#202126]"
>
<div className="flex items-center gap-2">
<FileIcon extname={icon} />
return (
<li
key={id}
className="flex items-center justify-between min-h-12 px-2 rounded-[4px] bg-[#ededed] dark:bg-[#202126]"
>
<div className="flex items-center gap-2">
<FileIcon extname={icon}/>
<div>
<div className="text-sm leading-4 text-[#333] dark:text-[#D8D8D8]">
{name}
</div>
<div className="text-xs text-[#999]">
<span>{icon}</span>
<span className="pl-2">
{filesize(size, { standard: "jedec", spacer: "" })}
<div>
<div className="text-sm leading-4 text-[#333] dark:text-[#D8D8D8]">
{name}
</div>
<div className="text-xs text-[#999]">
<span>{icon}</span>
<span className="pl-2">
{filesize(size, {standard: "jedec", spacer: ""})}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Trash2
className="size-4 text-[#999] cursor-pointer"
onClick={() => handleDelete(id)}
/>
<div className="flex items-center gap-2">
<Trash2
className="size-4 text-[#999] cursor-pointer"
onClick={() => handleDelete(id)}
/>
<Checkbox
checked={checkList.includes(id)}
onChange={(checked) => handleCheck(checked, id)}
/>
</div>
</li>
);
})}
</ul>
</div>
</div>
);
<Checkbox
checked={checkList.includes(id)}
onChange={(checked) => handleCheck(checked, id)}
/>
</div>
</li>
);
})}
</ul>
</div>
</div>
);
};
export default SessionFile;

View File

@@ -1,94 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { MessageSquare, Plus, RefreshCw } from "lucide-react";
import type { Chat } from "./types";
interface SidebarProps {
chats: Chat[];
activeChat: Chat | undefined;
onNewChat: () => void;
onSelectChat: (chat: Chat) => void;
onDeleteChat: (chatId: string) => void;
className?: string;
fetchChatHistory: () => void;
}
export function Sidebar({
chats,
activeChat,
onNewChat,
onSelectChat,
className = "",
fetchChatHistory,
}: SidebarProps) {
const { t } = useTranslation();
const [isRefreshing, setIsRefreshing] = useState(false);
return (
<div className={`h-full flex flex-col ${className}`}>
<div className="flex justify-between gap-1 p-4">
<button
onClick={onNewChat}
className={`flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all border border-[#E6E6E6] dark:border-[#272626] text-gray-700 hover:bg-gray-50/80 active:bg-gray-100/80 dark:text-white dark:hover:bg-gray-600/50 dark:active:bg-gray-500/50`}
>
<Plus className={`h-4 w-4 text-[#0072FF] dark:text-[#0072FF]`} />
{t("assistant.sidebar.newChat")}
</button>
<button
onClick={async () => {
setIsRefreshing(true);
fetchChatHistory();
setTimeout(() => setIsRefreshing(false), 1000);
}}
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
disabled={isRefreshing}
>
<RefreshCw
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
isRefreshing ? "animate-spin" : ""
}`}
/>
</button>
</div>
<div className="flex-1 overflow-y-auto px-3 pb-3 space-y-2 custom-scrollbar">
{chats.map((chat) => (
<div
key={chat._id}
className={`group relative rounded-xl transition-all ${
activeChat?._id === chat._id
? "bg-gray-100/80 dark:bg-gray-700/50"
: "hover:bg-gray-50/80 dark:hover:bg-gray-600/30"
}`}
>
<button
className="w-full flex items-center gap-3 px-4 py-3 text-sm text-left"
onClick={() => onSelectChat(chat)}
>
<MessageSquare
className={`h-4 w-4 flex-shrink-0 ${
activeChat?._id === chat._id
? "text-[#0072FF] dark:text-[#0072FF]"
: "text-gray-400 dark:text-gray-500"
}`}
/>
<span
className={`truncate ${
activeChat?._id === chat._id
? "text-gray-900 dark:text-white font-medium"
: "text-gray-600 dark:text-gray-300"
}`}
>
{chat?._source?.title || chat?._id}
</span>
</button>
{activeChat?._id === chat._id && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-[#0072FF]" />
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -1,24 +1,14 @@
import { useMemo, useState } from "react";
import { useMemo, useState, useEffect } from "react";
import { CircleX, MoveRight } from "lucide-react";
import { useMount } from "ahooks";
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
import { useConnectStore } from "@/stores/connectStore";
import { useThemeStore } from "@/stores/themeStore";
import FontIcon from "../Common/Icons/FontIcon";
import FontIcon from "@/components/Common/Icons/FontIcon";
import logoImg from "@/assets/icon.svg";
import { Get } from "@/api/axiosRequest";
interface StartPage {
enabled?: boolean;
logo?: {
light?: string;
dark?: string;
};
introduction?: string;
display_assistants?: string[];
}
import { AssistantFetcher } from "./AssistantFetcher";
import type { StartPage } from "@/types/chat";
export interface Response {
app_settings?: {
@@ -28,57 +18,61 @@ export interface Response {
};
}
const Splash = () => {
interface SplashProps {
assistantIDs?: string[];
startPage?: StartPage;
}
const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
const isTauri = useAppStore((state) => state.isTauri);
const currentService = useConnectStore((state) => state.currentService);
const [settings, setSettings] = useState<StartPage>();
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
const setVisibleStartPage = useConnectStore((state) => {
return state.setVisibleStartPage;
});
const addError = useAppStore((state) => state.addError);
const isDark = useThemeStore((state) => state.isDark);
const assistantList = useConnectStore((state) => state.assistantList);
const setAssistantList = useConnectStore((state) => state.setAssistantList);
const setCurrentAssistant = useConnectStore((state) => {
return state.setCurrentAssistant;
});
useMount(async () => {
try {
const serverId = currentService.id;
const [settings, setSettings] = useState<StartPage>();
let response: Response = {};
if (isTauri) {
response = await platformAdapter.invokeBackend<Response>(
"get_system_settings",
{
serverId,
}
);
} else {
const [err, result] = await Get("/settings");
if (err) {
throw new Error(err);
}
response = result as Response;
}
const settings = response?.app_settings?.chat?.start_page;
setVisibleStartPage(Boolean(settings?.enabled));
setSettings(settings);
} catch (error) {
addError(String(error), "error");
}
const { fetchAssistant } = AssistantFetcher({
assistantIDs,
});
const settingsAssistantList = useMemo(() => {
console.log("assistantList", assistantList);
const fetchData = async () => {
const data = await fetchAssistant({ current: 1, pageSize: 1000 });
setAssistantList(data.list || []);
};
const getSettings = async () => {
const serverId = currentService.id;
let response: any;
if (isTauri) {
response = await platformAdapter.invokeBackend<Response>(
"get_system_settings",
{
serverId,
}
);
response = response?.app_settings?.chat?.start_page;
} else {
response = startPage;
}
setVisibleStartPage(Boolean(response?.enabled));
setSettings(response);
};
useEffect(() => {
getSettings();
fetchData();
}, [currentService?.id]);
const settingsAssistantList = useMemo(() => {
return assistantList.filter((item) => {
return settings?.display_assistants?.includes(item?._source?.id);
});
@@ -96,7 +90,7 @@ const Splash = () => {
return (
visibleStartPage && (
<div className="absolute inset-0 flex flex-col items-center px-6 pt-6 text-[#333] dark:text-white select-none">
<div className="absolute top-12 inset-0 flex flex-col items-center px-6 pt-6 text-[#333] dark:text-white select-none overflow-y-auto custom-scrollbar">
<CircleX
className="absolute top-3 right-3 size-4 text-[#999] cursor-pointer"
onClick={() => {

View File

@@ -2,7 +2,7 @@ import { Loader, Hammer, ChevronDown, ChevronUp } from "lucide-react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types";
import type { IChunkData } from "@/types/chat";
import Markdown from "./Markdown";
interface CallToolsProps {
@@ -15,7 +15,7 @@ export const CallTools = ({ Detail, ChunkData, loading }: CallToolsProps) => {
const { t } = useTranslation();
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [Data, setData] = useState("");
const [data, setData] = useState("");
useEffect(() => {
if (!Detail?.description) return;
@@ -25,7 +25,7 @@ export const CallTools = ({ Detail, ChunkData, loading }: CallToolsProps) => {
useEffect(() => {
if (!ChunkData?.message_chunk) return;
setData(ChunkData?.message_chunk);
}, [ChunkData?.message_chunk, Data]);
}, [ChunkData?.message_chunk, data]);
// Must be after hooks !!!
if (!ChunkData && !Detail) return null;
@@ -62,11 +62,11 @@ export const CallTools = ({ Detail, ChunkData, loading }: CallToolsProps) => {
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<Markdown
content={Data || ""}
content={data || ""}
loading={loading}
onDoubleClickCapture={() => {}}
/>
{/* {Data?.split("\n").map(
{/* {data?.split("\n").map(
(paragraph, idx) =>
paragraph.trim() && (
<p key={idx} className="text-sm">

View File

@@ -2,7 +2,7 @@ import { ChevronDown, ChevronUp, Loader } from "lucide-react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types";
import type { IChunkData } from "@/types/chat";
import ReadingIcon from "@/icons/Reading";
interface DeepReadeProps {
@@ -20,7 +20,7 @@ export const DeepRead = ({
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [Data, setData] = useState<string[]>([]);
const [data, setData] = useState<string[]>([]);
const [description, setDescription] = useState("");
useEffect(() => {
@@ -71,7 +71,7 @@ export const DeepRead = ({
ChunkData?.chunk_type || Detail?.type
}`,
{
count: Number(Data.length),
count: Number(data.length),
}
)}
</span>
@@ -87,7 +87,7 @@ export const DeepRead = ({
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-3 text-xs">
{Data?.map((item) => (
{data?.map((item) => (
<div key={item} className="flex flex-col gap-2">
<div className="text-xs text-[#999999] dark:text-[#808080]">
- {item}

View File

@@ -8,7 +8,7 @@ import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { OpenURLWithBrowser } from "@/utils/index";
import type { IChunkData } from "@/components/Assistant/types";
import type { IChunkData } from "@/types/chat";
import RetrieveIcon from "@/icons/Retrieve";
interface FetchSourceProps {
@@ -129,17 +129,17 @@ export const FetchSource = ({
className="group flex items-center p-2 hover:bg-[#F7F7F7] dark:hover:bg-[#2C2C2C] border-b border-[#E6E6E6] dark:border-[#272626] last:border-b-0 cursor-pointer transition-colors"
>
<div className="w-full flex items-center gap-2">
<div className="w-full md:w-[75%] flex items-center gap-1">
<div className="w-[75%] mobile:w-full flex items-center gap-1">
<Globe className="w-3 h-3 flex-shrink-0" />
<div className="text-xs text-[#333333] dark:text-[#D8D8D8] truncate font-normal group-hover:text-[#0072FF] dark:group-hover:text-[#0072FF]">
{item.title || item.category}
</div>
</div>
<div
className={`flex mobile:hidden w-[25%] items-center justify-end gap-2`}
className={`flex-1 mobile:hidden flex items-center justify-end gap-2`}
>
<span className="text-xs text-[#999999] dark:text-[#999999] truncate">
{item.source?.name}
{item.source?.name || item?.category}
</span>
<SquareArrowOutUpRight className="w-3 h-3 text-[#999999] dark:text-[#999999] flex-shrink-0" />
</div>

View File

@@ -1,3 +1,6 @@
import { COPY_BUTTON_ID } from "@/constants";
import { useSearchStore } from "@/stores/searchStore";
import clsx from "clsx";
import {
Check,
Copy,
@@ -12,6 +15,8 @@ interface MessageActionsProps {
id: string;
content: string;
question?: string;
actionClassName?: string;
actionIconSize?: number;
onResend?: () => void;
}
@@ -21,6 +26,8 @@ export const MessageActions = ({
id,
content,
question,
actionClassName,
actionIconSize,
onResend,
}: MessageActionsProps) => {
const [copied, setCopied] = useState(false);
@@ -84,18 +91,32 @@ export const MessageActions = ({
}, 1000);
}
};
const goAskAi = useSearchStore((state) => state.goAskAi);
return (
<div className="flex items-center gap-1 mt-2">
<div className={clsx("flex items-center gap-1 mt-2", actionClassName)}>
{!isRefreshOnly && (
<button
id={goAskAi ? COPY_BUTTON_ID : ""}
onClick={handleCopy}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors"
>
{copied ? (
<Check className="w-4 h-4 text-[#38C200] dark:text-[#38C200]" />
<Check
className="w-4 h-4 text-[#38C200] dark:text-[#38C200]"
style={{
width: actionIconSize,
height: actionIconSize,
}}
/>
) : (
<Copy className="w-4 h-4 text-[#666666] dark:text-[#A3A3A3]" />
<Copy
className="w-4 h-4 text-[#666666] dark:text-[#A3A3A3]"
style={{
width: actionIconSize,
height: actionIconSize,
}}
/>
)}
</button>
)}
@@ -112,6 +133,10 @@ export const MessageActions = ({
? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]"
}`}
style={{
width: actionIconSize,
height: actionIconSize,
}}
/>
</button>
)}
@@ -128,6 +153,10 @@ export const MessageActions = ({
? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]"
}`}
style={{
width: actionIconSize,
height: actionIconSize,
}}
/>
</button>
)}
@@ -142,6 +171,10 @@ export const MessageActions = ({
? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]"
}`}
style={{
width: actionIconSize,
height: actionIconSize,
}}
/>
</button>
)}
@@ -158,6 +191,10 @@ export const MessageActions = ({
? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]"
}`}
style={{
width: actionIconSize,
height: actionIconSize,
}}
/>
</button>
)}

View File

@@ -2,7 +2,7 @@ import { ChevronDown, ChevronUp, Loader } from "lucide-react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types";
import type { IChunkData } from "@/types/chat";
import SelectionIcon from "@/icons/Selection";
interface PickSourceProps {
@@ -26,7 +26,7 @@ export const PickSource = ({
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [Data, setData] = useState<IData[]>([]);
const [data, setData] = useState<IData[]>([]);
useEffect(() => {
if (!Detail?.payload) return;
@@ -90,7 +90,7 @@ export const PickSource = ({
ChunkData?.chunk_type || Detail.type
}`,
{
count: Data?.length,
count: data?.length,
}
)}
</span>
@@ -106,7 +106,7 @@ export const PickSource = ({
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-3 text-xs">
{Data?.map((item) => (
{data?.map((item) => (
<div
key={item.id}
className="p-3 rounded-lg border border-[#E6E6E6] dark:border-[#272626] bg-white dark:bg-[#1E1E1E] hover:bg-gray-50 dark:hover:bg-[#2C2C2C] transition-colors"

View File

@@ -24,7 +24,7 @@ const PrevSuggestion: FC<PrevSuggestionProps> = (props) => {
}, [JSON.stringify(currentAssistant)]);
return (
<ul className="absolute left-2 bottom-2 flex flex-col gap-2">
<ul className="absolute left-2 bottom-2 flex flex-col gap-2 p-0">
{list.map((item) => {
return (
<li

View File

@@ -2,7 +2,7 @@ import { ChevronDown, ChevronUp, Loader } from "lucide-react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types";
import type { IChunkData } from "@/types/chat";
import UnderstandIcon from "@/icons/Understand";
interface QueryIntentProps {
@@ -30,7 +30,7 @@ export const QueryIntent = ({
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [Data, setData] = useState<IQueryData | null>(null);
const [data, setData] = useState<IQueryData | null>(null);
useEffect(() => {
if (!Detail?.payload) return;
@@ -100,13 +100,13 @@ export const QueryIntent = ({
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-2 text-xs">
{Data?.keyword ? (
{data?.keyword ? (
<div className="flex gap-1">
<span className="text-[#999999]">
- {t("assistant.message.steps.keywords")}
</span>
<div className="flex flex-wrap gap-1">
{Data?.keyword?.map((keyword, index) => (
{data?.keyword?.map((keyword, index) => (
<span
key={keyword + index}
className="text-[#333333] dark:text-[#D8D8D8]"
@@ -118,33 +118,33 @@ export const QueryIntent = ({
</div>
</div>
) : null}
{Data?.category ? (
{data?.category ? (
<div className="flex items-center gap-1">
<span className="text-[#999999]">
- {t("assistant.message.steps.questionType")}
</span>
<span className="text-[#333333] dark:text-[#D8D8D8]">
{Data?.category}
{data?.category}
</span>
</div>
) : null}
{Data?.intent ? (
{data?.intent ? (
<div className="flex items-start gap-1">
<span className="text-[#999999]">
- {t("assistant.message.steps.userIntent")}
</span>
<div className="flex-1 text-[#333333] dark:text-[#D8D8D8]">
{Data?.intent}
{data?.intent}
</div>
</div>
) : null}
{Data?.query ? (
{data?.query ? (
<div className="flex items-start gap-1">
<span className="text-[#999999]">
- {t("assistant.message.steps.relatedQuestions")}
</span>
<div className="flex-1 flex flex-col text-[#333333] dark:text-[#D8D8D8]">
{Data?.query?.map((question, qIndex) => (
{data?.query?.map((question, qIndex) => (
<span key={question + qIndex}>- {question}</span>
))}
</div>

View File

@@ -2,7 +2,7 @@ import {Loader, Brain, ChevronDown, ChevronUp } from "lucide-react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types";
import type { IChunkData } from "@/types/chat";
interface ThinkProps {
Detail?: any;
@@ -14,7 +14,7 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
const { t } = useTranslation();
const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
const [Data, setData] = useState("");
const [data, setData] = useState("");
useEffect(() => {
if (!Detail?.description) return;
@@ -24,7 +24,7 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
useEffect(() => {
if (!ChunkData?.message_chunk) return;
setData(ChunkData?.message_chunk);
}, [ChunkData?.message_chunk, Data]);
}, [ChunkData?.message_chunk, data]);
// Must be after hooks !!!
if (!ChunkData && !Detail) return null;
@@ -59,7 +59,7 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
{isThinkingExpanded && (
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
{Data?.split("\n").map(
{data?.split("\n").map(
(paragraph, idx) =>
paragraph.trim() && (
<p key={idx} className="text-sm">

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import clsx from "clsx";
import { CopyButton } from "@/components/Common/CopyButton";
@@ -15,7 +16,13 @@ export const UserMessage = ({ messageContent }: UserMessageProps) => {
onMouseEnter={() => setShowCopyButton(true)}
onMouseLeave={() => setShowCopyButton(false)}
>
{showCopyButton && <CopyButton textToCopy={messageContent} />}
<div
className={clsx("size-6 transition", {
"opacity-0": !showCopyButton,
})}
>
<CopyButton textToCopy={messageContent} />
</div>
<div
className="px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer select-none"
onDoubleClick={(e) => {

View File

@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import clsx from "clsx";
import logoImg from "@/assets/icon.svg";
import type { Message, IChunkData } from "@/components/Assistant/types";
import type { Message, IChunkData } from "@/types/chat";
import { QueryIntent } from "./QueryIntent";
import { CallTools } from "./CallTools";
import { FetchSource } from "./FetchSource";
@@ -29,6 +29,10 @@ interface ChatMessageProps {
response?: IChunkData;
onResend?: (value: string) => void;
loadingStep?: Record<string, boolean>;
hide_assistant?: boolean;
rootClassName?: string;
actionClassName?: string;
actionIconSize?: number;
}
export const ChatMessage = memo(function ChatMessage({
@@ -43,6 +47,10 @@ export const ChatMessage = memo(function ChatMessage({
response,
onResend,
loadingStep,
hide_assistant = false,
rootClassName,
actionClassName,
actionIconSize,
}: ChatMessageProps) {
const { t } = useTranslation();
@@ -52,17 +60,28 @@ export const ChatMessage = memo(function ChatMessage({
const isAssistant = message?._source?.type === "assistant";
const assistant_id = message?._source?.assistant_id;
const assistant_item = message?._source?.assistant_item;
useEffect(() => {
let target = currentAssistant;
if (isAssistant && assistant_id && Array.isArray(assistantList)) {
const found = assistantList.find((item) => item._id === assistant_id);
if (found) {
target = found;
if (assistant_item) {
setAssistant(assistant_item);
} else {
let target = currentAssistant;
if (isAssistant && assistant_id && Array.isArray(assistantList)) {
const found = assistantList.find((item) => item._id === assistant_id);
if (found) {
target = found;
}
}
setAssistant(target);
}
setAssistant(target);
}, [isAssistant, assistant_id, assistantList, currentAssistant]);
}, [
isAssistant,
assistant_item,
assistant_id,
assistantList,
currentAssistant,
]);
const messageContent = message?._source?.message || "";
const details = message?._source?.details || [];
@@ -131,6 +150,8 @@ export const ChatMessage = memo(function ChatMessage({
id={message._id}
content={messageContent || response?.message_chunk || ""}
question={question}
actionClassName={actionClassName}
actionIconSize={actionIconSize}
onResend={() => {
onResend && onResend(question);
}}
@@ -153,7 +174,8 @@ export const ChatMessage = memo(function ChatMessage({
[isAssistant ? "justify-start" : "justify-end"],
{
hidden: visibleStartPage,
}
},
rootClassName
)}
>
<div
@@ -166,22 +188,27 @@ export const ChatMessage = memo(function ChatMessage({
isAssistant ? "text-left" : "text-right"
}`}
>
<div className="w-full flex items-center gap-1 font-semibold text-sm text-[#333] dark:text-[#d8d8d8]">
{isAssistant ? (
<div className="w-6 h-6 flex justify-center items-center rounded-full bg-white border border-[#E6E6E6]">
{assistant?._source?.icon?.startsWith("font_") ? (
<FontIcon name={assistant._source.icon} className="w-4 h-4" />
) : (
<img
src={logoImg}
className="w-4 h-4"
alt={t("assistant.message.logo")}
/>
)}
</div>
) : null}
{isAssistant ? assistant?._source?.name || "Coco AI" : ""}
</div>
{!hide_assistant && (
<div className="w-full flex items-center gap-1 font-semibold text-sm text-[#333] dark:text-[#d8d8d8]">
{isAssistant ? (
<div className="w-6 h-6 flex justify-center items-center rounded-full bg-white border border-[#E6E6E6]">
{assistant?._source?.icon?.startsWith("font_") ? (
<FontIcon
name={assistant._source.icon}
className="w-4 h-4"
/>
) : (
<img
src={logoImg}
className="w-4 h-4"
alt={t("assistant.message.logo")}
/>
)}
</div>
) : null}
{isAssistant ? assistant?._source?.name || "Coco AI" : ""}
</div>
)}
<div className="w-full prose dark:prose-invert prose-sm max-w-none">
<div className="w-full pl-7 text-[#333] dark:text-[#d8d8d8] leading-relaxed">
{renderContent()}

View File

@@ -112,7 +112,6 @@
.markdown-body {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
margin: 16px 0 0;
color: var(--color-fg-default);
background-color: var(--color-canvas-default);
font-size: 14px;
@@ -125,6 +124,11 @@
border: 1px solid var(--color-border-muted) !important;
}
}
ul,
ol {
all: revert !important;
}
}
.markdown-body .octicon {
@@ -415,25 +419,6 @@
border-left: 0.25em solid var(--color-border-default);
}
.markdown-body ul,
.markdown-body ol {
margin-top: 0;
margin-bottom: 0;
padding-left: 2em;
}
.markdown-body ol ol,
.markdown-body ul ol {
list-style-type: lower-roman;
}
.markdown-body ul ul ol,
.markdown-body ul ol ol,
.markdown-body ol ul ol,
.markdown-body ol ol ol {
list-style-type: lower-alpha;
}
.markdown-body dd {
margin-left: 0;
}
@@ -510,8 +495,6 @@
.markdown-body p,
.markdown-body blockquote,
.markdown-body ul,
.markdown-body ol,
.markdown-body dl,
.markdown-body table,
.markdown-body pre,
@@ -627,14 +610,6 @@
list-style-type: decimal;
}
.markdown-body ul ul,
.markdown-body ul ol,
.markdown-body ol ol,
.markdown-body ol ul {
margin-top: 0;
margin-bottom: 0;
}
.markdown-body li > p {
margin-top: 16px;
}
@@ -679,11 +654,14 @@
white-space: pre-wrap;
}
.markdown-body p br,
.markdown-body table td br,
.markdown-body table th br {
display: block;
content: "";
margin-top: 8px;
display: block !important;
content: "" !important;
margin-top: 8px !important;
height: 1px !important;
background-color: transparent !important;
}
.markdown-body table tr {

View File

@@ -1,64 +1,32 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
CalendarSync,
Copy,
GitFork,
Globe,
PackageOpen,
RefreshCcw,
Trash2,
} from "lucide-react";
import { v4 as uuidv4 } from "uuid";
import { getCurrentWindow } from "@tauri-apps/api/window";
import {
getCurrent as getCurrentDeepLinkUrls,
onOpenUrl,
} from "@tauri-apps/plugin-deep-link";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { useEffect, useRef, useState, useCallback } from "react";
import { emit } from "@tauri-apps/api/event";
import { UserProfile } from "./UserProfile";
import { DataSourcesList } from "./DataSourcesList";
import { Sidebar } from "./Sidebar";
import { Connect } from "./Connect";
import { OpenURLWithBrowser } from "@/utils";
import { useAppStore } from "@/stores/appStore";
import { useConnectStore } from "@/stores/connectStore";
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import Tooltip from "@/components/Common/Tooltip";
import {
list_coco_servers,
add_coco_server,
enable_server,
disable_server,
logout_coco_server,
remove_coco_server,
refresh_coco_server_info,
handle_sso_callback,
} from "@/commands";
import ServiceInfo from "./ServiceInfo";
import ServiceAuth from "./ServiceAuth";
export default function Cloud() {
const { t } = useTranslation();
const SidebarRef = useRef<{ refreshData: () => void }>(null);
const errors = useAppStore((state) => state.errors);
const addError = useAppStore((state) => state.addError);
const [isConnect, setIsConnect] = useState(true);
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore((state) => state.setCurrentService);
const serverList = useConnectStore((state) => state.serverList);
const setServerList = useConnectStore((state) => state.setServerList);
const [loading, setLoading] = useState(false);
const [refreshLoading, setRefreshLoading] = useState(false);
// fetch the servers
@@ -68,7 +36,6 @@ export default function Cloud() {
useEffect(() => {
console.log("currentService", currentService);
setLoading(false);
setRefreshLoading(false);
setIsConnect(true);
}, [JSON.stringify(currentService)]);
@@ -131,331 +98,47 @@ export default function Cloud() {
});
};
const handleOAuthCallback = useCallback(
async (code: string | null, serverId: string | null) => {
if (!code || !serverId) {
addError("No authorization code received");
return;
}
try {
console.log("Handling OAuth callback:", { code, serverId });
await handle_sso_callback({
serverId: serverId, // Make sure 'server_id' is the correct argument
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
code: code,
const refreshClick = useCallback(
(id: string) => {
setRefreshLoading(true);
refresh_coco_server_info(id)
.then((res: any) => {
console.log("refresh_coco_server_info", id, res);
fetchServers(false).then((r) => {
console.log("fetchServers", r);
});
// update currentService
setCurrentService(res);
emit("login_or_logout", true);
})
.finally(() => {
setRefreshLoading(false);
});
if (serverId != null) {
refreshClick(serverId);
}
getCurrentWindow().setFocus();
} catch (e) {
console.error("Sign in failed:", e);
} finally {
setLoading(false);
}
},
[ssoRequestID]
);
const handleUrl = (url: string) => {
try {
const urlObject = new URL(url.trim());
console.log("handle urlObject:", urlObject);
// pass request_id and check with local, if the request_id are same, then continue
const reqId = urlObject.searchParams.get("request_id");
const code = urlObject.searchParams.get("code");
if (reqId != ssoRequestID) {
console.log("Request ID not matched, skip");
addError("Request ID not matched, skip");
return;
}
const serverId = currentService?.id;
handleOAuthCallback(code, serverId);
} catch (err) {
console.error("Failed to parse URL:", err);
addError("Invalid URL format: " + err);
}
};
// Fetch the initial deep link intent
useEffect(() => {
// Function to handle pasted URL
const handlePaste = (event: any) => {
const pastedText = event.clipboardData.getData("text").trim();
console.log("handle paste text:", pastedText);
if (isValidCallbackUrl(pastedText)) {
// Handle the URL as if it's a deep link
console.log("handle callback on paste:", pastedText);
handleUrl(pastedText);
}
};
// Function to check if the pasted URL is valid for our deep link scheme
const isValidCallbackUrl = (url: string) => {
return url && url.startsWith("coco://oauth_callback");
};
// Adding event listener for paste events
document.addEventListener("paste", handlePaste);
getCurrentDeepLinkUrls()
.then((urls) => {
console.log("URLs:", urls);
if (urls && urls.length > 0) {
if (isValidCallbackUrl(urls[0].trim())) {
handleUrl(urls[0]);
}
}
})
.catch((err) => {
console.error("Failed to get initial URLs:", err);
addError("Failed to get initial URLs: " + err);
});
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
return () => {
unlisten.then((fn) => fn());
document.removeEventListener("paste", handlePaste);
};
}, [ssoRequestID]);
const LoginClick = useCallback(() => {
if (loading) return; // Prevent multiple clicks if already loading
let requestID = uuidv4();
setSSORequestID(requestID);
// Generate the login URL with the current appUid
const url = `${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${requestID}`;
console.log("Open SSO link, requestID:", ssoRequestID, url);
// Open the URL in a browser
OpenURLWithBrowser(url);
// Start loading state
setLoading(true);
}, [ssoRequestID, loading, currentService]);
const refreshClick = (id: string) => {
setRefreshLoading(true);
refresh_coco_server_info(id)
.then((res: any) => {
console.log("refresh_coco_server_info", id, res);
fetchServers(false).then((r) => {
console.log("fetchServers", r);
});
// update currentService
setCurrentService(res);
emit("login_or_logout", true);
})
.finally(() => {
setRefreshLoading(false);
});
};
function onAddServer() {
setIsConnect(false);
}
function onLogout(id: string) {
console.log("onLogout", id);
setRefreshLoading(true);
logout_coco_server(id)
.then((res: any) => {
console.log("logout_coco_server", id, JSON.stringify(res));
refreshClick(id);
emit("login_or_logout", false);
})
.finally(() => {
setRefreshLoading(false);
});
}
const removeServer = (id: string) => {
remove_coco_server(id).then((res: any) => {
console.log("remove_coco_server", id, JSON.stringify(res));
fetchServers(true).then((r) => {
console.log("fetchServers", r);
});
});
};
const enable_coco_server = useCallback(
async (enabled: boolean) => {
if (enabled) {
await enable_server(currentService?.id);
} else {
await disable_server(currentService?.id);
}
setCurrentService({ ...currentService, enabled });
await fetchServers(false);
},
[currentService?.id]
[fetchServers]
);
return (
<div className="flex bg-gray-50 dark:bg-gray-900">
<Sidebar
ref={SidebarRef}
onAddServer={onAddServer}
setIsConnect={setIsConnect}
serverList={serverList}
/>
<main className="flex-1 p-4 py-8">
{isConnect ? (
<div className="max-w-4xl mx-auto">
<div className="w-full rounded-[4px] bg-[rgba(229,229,229,1)] dark:bg-gray-800 mb-6">
<img
width="100%"
src={currentService?.provider?.banner || bannerImg}
alt="banner"
/>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<Tooltip content={currentService?.endpoint}>
<div className="flex items-center text-gray-900 dark:text-white font-medium cursor-pointer">
{currentService?.name}
</div>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<SettingsToggle
checked={currentService?.enabled}
className={clsx({
"bg-red-600 focus:ring-red-500": !currentService?.enabled,
})}
label={
currentService?.enabled
? t("cloud.enable_server")
: t("cloud.disable_server")
}
onChange={enable_coco_server}
/>
<ServiceInfo
refreshLoading={refreshLoading}
refreshClick={refreshClick}
fetchServers={fetchServers}
/>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() =>
OpenURLWithBrowser(currentService?.provider?.website)
}
>
<Globe className="w-3.5 h-3.5" />
</button>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => refreshClick(currentService?.id)}
>
<RefreshCcw
className={`w-3.5 h-3.5 ${
refreshLoading ? "animate-spin" : ""
}`}
/>
</button>
{!currentService?.builtin && (
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => removeServer(currentService?.id)}
>
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
</button>
)}
</div>
</div>
<div className="mb-8">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2 flex">
<span className="flex items-center gap-1">
<PackageOpen className="w-4 h-4" />{" "}
{currentService?.provider?.name}
</span>
<span className="mx-4">|</span>
<span className="flex items-center gap-1">
<GitFork className="w-4 h-4" />{" "}
{currentService?.version?.number}
</span>
<span className="mx-4">|</span>
<span className="flex items-center gap-1">
<CalendarSync className="w-4 h-4" /> {currentService?.updated}
</span>
</div>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
{currentService?.provider?.description}
</p>
</div>
{currentService?.auth_provider?.sso?.url ? (
<div className="mb-8">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
{t("cloud.accountInfo")}
</h2>
{currentService?.profile ? (
<UserProfile
server={currentService?.id}
userInfo={currentService?.profile}
onLogout={onLogout}
/>
) : (
<div>
{/* Login Button (conditionally rendered when not loading) */}
{!loading && (
<button
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
onClick={LoginClick}
>
{t("cloud.login")}
</button>
)}
{/* Cancel Button and Copy URL button while loading */}
{loading && (
<div className="flex items-center space-x-2">
<button
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors mb-3"
onClick={() => setLoading(false)} // Reset loading state
>
{t("cloud.cancel")}
</button>
<button
onClick={() => {
navigator.clipboard.writeText(
`${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${ssoRequestID}`
);
}}
className="text-xl text-blue-500 hover:text-blue-600"
>
<Copy className="inline mr-2" />{" "}
</button>
<div className="text-justify italic text-xs">
{t("cloud.manualCopyLink")}
</div>
</div>
)}
{/* Privacy Policy Link */}
<button
className="text-xs text-[#0096FB] dark:text-blue-400 block"
onClick={() =>
OpenURLWithBrowser(
currentService?.provider?.privacy_policy
)
}
>
{t("cloud.privacyPolicy")}
</button>
</div>
)}
</div>
) : null}
<ServiceAuth
setRefreshLoading={setRefreshLoading}
refreshClick={refreshClick}
/>
{currentService?.profile ? (
<DataSourcesList server={currentService?.id} />

View File

@@ -2,8 +2,6 @@ import React, { useState } from "react";
import { ChevronLeft } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/stores/appStore";
interface ConnectServiceProps {
setIsConnect: (isConnect: boolean) => void;
onAddServer: (endpoint: string) => void;
@@ -14,8 +12,6 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
const [endpointLink, setEndpointLink] = useState("");
const [refreshLoading] = useState(false);
const addError = useAppStore((state) => state.addError);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
};
@@ -26,17 +22,8 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
const onAddServerClick = async (endpoint: string) => {
console.log("onAddServer", endpoint);
try {
await onAddServer(endpoint);
setIsConnect(true); // Only set as connected if the server is added successfully
} catch (err: any) {
// Handle the error if something goes wrong
const errorMessage =
typeof err === "string"
? err
: err?.message || "An unknown error occurred.";
addError(errorMessage);
}
await onAddServer(endpoint);
setIsConnect(true);
};
return (

View File

@@ -0,0 +1,261 @@
import { memo, useCallback, useEffect, useState } from "react";
import { Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { v4 as uuidv4 } from "uuid";
import { emit } from "@tauri-apps/api/event";
import {
getCurrent as getCurrentDeepLinkUrls,
onOpenUrl,
} from "@tauri-apps/plugin-deep-link";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { UserProfile } from "./UserProfile";
import { OpenURLWithBrowser } from "@/utils";
import { useConnectStore } from "@/stores/connectStore";
import { useAppStore } from "@/stores/appStore";
import { logout_coco_server, handle_sso_callback } from "@/commands";
interface ServiceAuthProps {
setRefreshLoading: (loading: boolean) => void;
refreshClick: (id: string) => void;
}
const ServiceAuth = memo(
({ setRefreshLoading, refreshClick }: ServiceAuthProps) => {
const { t } = useTranslation();
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
const addError = useAppStore((state) => state.addError);
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore((state) => state.setCurrentService);
const serverList = useConnectStore((state) => state.serverList);
const setServerList = useConnectStore((state) => state.setServerList);
const [loading, setLoading] = useState(false);
const LoginClick = useCallback(() => {
if (loading) return; // Prevent multiple clicks if already loading
let requestID = uuidv4();
setSSORequestID(requestID);
// Generate the login URL with the current appUid
const url = `${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${requestID}`;
console.log("Open SSO link, requestID:", ssoRequestID, url);
// Open the URL in a browser
OpenURLWithBrowser(url);
// Start loading state
setLoading(true);
}, [ssoRequestID, loading, currentService]);
const onLogout = useCallback(
(id: string) => {
setRefreshLoading(true);
logout_coco_server(id)
.then((res: any) => {
console.log("logout_coco_server", id, JSON.stringify(res));
emit("login_or_logout", false);
// update server profile
setCurrentService({ ...currentService, profile: null });
const updatedServerList = serverList.map(server =>
server.id === id ? { ...server, profile: null } : server
);
console.log("updatedServerList", updatedServerList);
setServerList(updatedServerList);
})
.finally(() => {
setRefreshLoading(false);
});
},
[currentService, serverList]
);
const handleOAuthCallback = useCallback(
async (code: string | null, serverId: string | null) => {
if (!code || !serverId) {
addError("No authorization code received");
return;
}
try {
console.log("Handling OAuth callback:", { code, serverId });
await handle_sso_callback({
serverId: serverId, // Make sure 'server_id' is the correct argument
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
code: code,
});
if (serverId != null) {
refreshClick(serverId);
}
getCurrentWindow().setFocus();
} catch (e) {
console.error("Sign in failed:", e);
} finally {
setLoading(false);
}
},
[ssoRequestID]
);
const handleUrl = (url: string) => {
try {
const urlObject = new URL(url.trim());
console.log("handle urlObject:", urlObject);
// pass request_id and check with local, if the request_id are same, then continue
const reqId = urlObject.searchParams.get("request_id");
const code = urlObject.searchParams.get("code");
if (reqId != ssoRequestID) {
console.log("Request ID not matched, skip");
addError("Request ID not matched, skip");
return;
}
const serverId = currentService?.id;
handleOAuthCallback(code, serverId);
} catch (err) {
console.error("Failed to parse URL:", err);
addError("Invalid URL format: " + err);
}
};
// Fetch the initial deep link intent
useEffect(() => {
setLoading(false);
// Function to handle pasted URL
const handlePaste = (event: any) => {
const pastedText = event.clipboardData.getData("text").trim();
console.log("handle paste text:", pastedText);
if (isValidCallbackUrl(pastedText)) {
// Handle the URL as if it's a deep link
console.log("handle callback on paste:", pastedText);
handleUrl(pastedText);
}
};
// Function to check if the pasted URL is valid for our deep link scheme
const isValidCallbackUrl = (url: string) => {
return url && url.startsWith("coco://oauth_callback");
};
// Adding event listener for paste events
document.addEventListener("paste", handlePaste);
getCurrentDeepLinkUrls()
.then((urls) => {
console.log("URLs:", urls);
if (urls && urls.length > 0) {
if (isValidCallbackUrl(urls[0].trim())) {
handleUrl(urls[0]);
}
}
})
.catch((err) => {
console.error("Failed to get initial URLs:", err);
addError("Failed to get initial URLs: " + err);
});
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
return () => {
unlisten.then((fn) => fn());
document.removeEventListener("paste", handlePaste);
};
}, [ssoRequestID]);
if (!currentService?.auth_provider?.sso?.url) {
return null;
}
return (
<div className="mb-8">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
{t("cloud.accountInfo")}
</h2>
{currentService?.profile ? (
<UserProfile
server={currentService?.id}
userInfo={currentService?.profile}
onLogout={onLogout}
/>
) : (
<div>
{/* Login Button (conditionally rendered when not loading) */}
{!loading && <LoginButton LoginClick={LoginClick} />}
{/* Cancel Button and Copy URL button while loading */}
{loading && (
<LoadingState
onCancel={() => setLoading(false)}
onCopy={() => {
navigator.clipboard.writeText(
`${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${ssoRequestID}`
);
}}
/>
)}
{/* Privacy Policy Link */}
<button
className="text-xs text-[#0096FB] dark:text-blue-400 block"
onClick={() =>
OpenURLWithBrowser(currentService?.provider?.privacy_policy)
}
>
{t("cloud.privacyPolicy")}
</button>
</div>
)}
</div>
);
}
);
export default ServiceAuth;
const LoginButton = memo(({ LoginClick }: { LoginClick: () => void }) => {
const { t } = useTranslation();
return (
<button
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
onClick={LoginClick}
aria-label={t("cloud.login")}
>
{t("cloud.login")}
</button>
);
});
const LoadingState = memo(
({ onCancel, onCopy }: { onCancel: () => void; onCopy: () => void }) => {
const { t } = useTranslation();
return (
<div className="flex items-center space-x-2">
<button
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors mb-3"
onClick={onCancel}
>
{t("cloud.cancel")}
</button>
<button
onClick={onCopy}
className="text-xl text-blue-500 hover:text-blue-600"
>
<Copy className="inline mr-2" />{" "}
</button>
<div className="text-justify italic text-xs">
{t("cloud.manualCopyLink")}
</div>
</div>
);
}
);

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
import { memo } from "react";
import ServiceBanner from "./ServiceBanner";
import ServiceHeader from "./ServiceHeader";
import ServiceMetadata from "./ServiceMetadata";
interface ServiceInfoProps {
refreshLoading?: boolean;
refreshClick: (id: string) => void;
fetchServers: (force: boolean) => Promise<void>;
}
const ServiceInfo = memo(
({ refreshLoading, refreshClick, fetchServers }: ServiceInfoProps) => {
return (
<>
<ServiceBanner />
<ServiceHeader
refreshLoading={refreshLoading}
refreshClick={refreshClick}
fetchServers={fetchServers}
/>
<ServiceMetadata />
</>
);
}
);
export default ServiceInfo;

View File

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

View File

@@ -1,82 +1,113 @@
import { useTranslation } from "react-i18next";
import { forwardRef } from "react";
import { forwardRef, useMemo, useCallback } from "react";
import { Plus } from "lucide-react";
import cocoLogoImg from "@/assets/app-icon.png";
import { useConnectStore } from "@/stores/connectStore";
import { Server } from "@/types/server";
import StatusIndicator from "./StatusIndicator";
interface SidebarProps {
onAddServer: () => void;
serverList: any[];
setIsConnect: (isConnect: boolean) => void;
serverList: Server[];
}
interface ServerGroups {
builtinServers: JSX.Element[];
customServers: JSX.Element[];
}
export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
({ onAddServer, serverList }, _ref) => {
({ setIsConnect, serverList }, _ref) => {
const { t } = useTranslation();
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore(
(state) => state.setCurrentService
);
const onAddServerClick = () => {
onAddServer();
const selectService = (item: Server) => {
setCurrentService(item);
setIsConnect(true);
};
const getServerItemClassName = useCallback((isSelected: boolean) => {
return `flex cursor-pointer items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg mb-2 whitespace-nowrap ${
isSelected
? "dark:bg-blue-900/20 dark:bg-blue-900 border border-[#0087ff]"
: "bg-gray-50 dark:bg-gray-900 border border-[#e6e6e6] dark:border-gray-700"
}`;
}, []);
// Extracted server item rendering
const renderServerItem = (item: any) => {
return (
<div
key={item?.id}
className={`flex cursor-pointer items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg mb-2 whitespace-nowrap ${
currentService?.id === item?.id
? "dark:bg-blue-900/20 dark:bg-blue-900 border border-[#0087ff]"
: "bg-gray-50 dark:bg-gray-900 border border-[#e6e6e6] dark:border-gray-700"
}`}
onClick={() => setCurrentService(item)}
>
<img
src={item?.provider?.icon || cocoLogoImg}
alt="LogoImg"
className="w-5 h-5 flex-shrink-0"
/>
<span className="font-medium truncate max-w-[140px]">{item?.name}</span>
<div className="flex-1" />
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
{item.health?.status ? (
<div className={`w-3 h-3 rounded-full bg-[${item.health?.status}]`} />
) : (
<div className="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-600" />
)}
</button>
</div>
const renderServerItem = useCallback(
(item: Server) => {
const isSelected = currentService?.id === item.id;
return (
<div
key={item.id}
className={getServerItemClassName(isSelected)}
onClick={() => selectService(item)}
>
<img
src={item?.provider?.icon || cocoLogoImg}
alt={`${item.name} logo`}
className="w-5 h-5 flex-shrink-0"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = cocoLogoImg;
}}
/>
<span className="font-medium truncate max-w-[140px]">
{item.name}
</span>
<div className="flex-1" />
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
<StatusIndicator
enabled={item.enabled}
public={item.public}
hasProfile={!!item?.profile}
status={item.health?.status}
/>
</button>
</div>
);
},
[currentService]
);
const { builtinServers, customServers } = useMemo(() => {
const groups = serverList.reduce<ServerGroups>(
(acc, item) => {
const renderedItem = renderServerItem(item);
if (item?.builtin) {
acc.builtinServers.push(renderedItem);
} else {
acc.customServers.push(renderedItem);
}
return acc;
},
{ builtinServers: [], customServers: [] }
);
};
return groups;
}, [serverList, renderServerItem]);
return (
<div className="w-64 min-h-[550px] border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div className="p-4 py-8">
{/* Render Built-in Servers */}
<div>
{serverList
.filter((item) => item?.builtin)
.map((item) => renderServerItem(item))}
</div>
{builtinServers}
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2 mt-6">
{t("cloud.sidebar.yourServers")}
</div>
{/* Render Non-Built-in Servers */}
<div>
{serverList
.filter((item) => !item?.builtin)
.map((item) => renderServerItem(item))}
</div>
{customServers}
<div className="space-y-2">
<button
className="w-full flex items-center justify-center p-2 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"
onClick={onAddServerClick}
onClick={() => setIsConnect(false)}
>
<Plus className="w-5 h-5" />
</button>

View File

@@ -0,0 +1,43 @@
import { useMemo, memo } from "react";
import { Status } from "@/types/server";
type StatusIndicatorProps = {
enabled: boolean;
public: boolean;
hasProfile: boolean;
status?: Status;
};
const StatusIndicator = memo(
({ enabled, public: isPublic, hasProfile, status }: StatusIndicatorProps) => {
// If service is enabled AND (public OR (private AND logged in)) AND service is available
// Otherwise show gray status
const isActive =
enabled && (isPublic || (!isPublic && hasProfile)) && status;
const statusColorClass = useMemo(() => {
if (!isActive) return "bg-gray-400 dark:bg-gray-600";
switch (status) {
case "green":
return "bg-green-500";
case "yellow":
return "bg-yellow-500";
case "red":
return "bg-red-500";
default:
return "bg-gray-400 dark:bg-gray-600";
}
}, [isActive, status]);
return (
<div
className={`w-3 h-3 rounded-full ${statusColorClass}`}
role="status"
aria-label={isActive ? "active" : "inactive"}
/>
);
}
);
export default StatusIndicator;

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