241 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
medcl
47d2e46b72 v0.4.0 2025-04-28 17:58:37 +08:00
ayangweb
414bc78aaf feat: updated to include error message (#465)
* feat: updated to include error message

* docs: update changelog
2025-04-28 16:41:15 +08:00
ayangweb
9fd4a16df3 fix: fixed carriage return problem with chinese input method (#464)
* fix: fixed carriage return problem with chinese input method

* docs: update changelog
2025-04-28 16:36:20 +08:00
BiggerRain
0e9e8bf653 fix: deep_think param type (#463)
* fix: deep_think param type

* chore: deep think params
2025-04-28 12:56:55 +08:00
BiggerRain
c14b9fa0be chore: historical message corresponding assistant (#462)
* chore: historical message corresponding assistant

* chore: addjust code
2025-04-28 11:36:44 +08:00
ayangweb
8477c7ce95 refactor: optimizing markdown styles (#461)
* refactor: optimizing markdown styles

* style: delete test code
2025-04-28 11:34:31 +08:00
ayangweb
3e48eae749 refactor: optimized getting data sources and mcp lists (#460) 2025-04-28 09:52:39 +08:00
ayangweb
5764b72f1e refactor: modifying the macos window hierarchy (#459) 2025-04-28 09:35:22 +08:00
Hardy
bff86c327a chore: update release notes for publish 0.4.0-2018 (#455)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-27 22:21:28 +08:00
BiggerRain
e60915443a build: build web 1.1.14 (#456) 2025-04-27 22:19:38 +08:00
ayangweb
c86c768960 refactor: optimizing parameter variables (#454) 2025-04-27 21:49:29 +08:00
ayangweb
a6a84f3df5 refactor: mcp list adaptation font icon (#453) 2025-04-27 21:41:56 +08:00
ayangweb
0a231b80d0 refactor: fixing and optimizing known bugs (#452)
* refactor: optimized the color of error messages

* refactor: optimize the selection problem in the list of little helpers

* refactor: updated translation and internationalization support for extension modules

* refactor: optimize shortcut key display

* fix: fix startup page style issues
2025-04-27 20:12:46 +08:00
ayangweb
5272c3dab9 refactor: rename queryTimeoutRenamed to querySourceTimeout (#451) 2025-04-27 20:10:54 +08:00
SteveLauC
256262ec2e refactor: rename the key used to store queryTimeout (#450) 2025-04-27 16:54:09 +08:00
BiggerRain
4508c292eb fix: Solves the issue of bottom toolbar overlapping content in mobile Safari (#448)
* style: adjust style

* fix: close Splash
2025-04-26 14:27:00 +08:00
Medcl
f4a3838844 chore: update default query timeout to 500ms (#447) 2025-04-26 11:29:39 +08:00
ayangweb
6e07cacae2 reactor: replacing the default key (#446) 2025-04-25 22:57:45 +08:00
ayangweb
191f34905e feat: add useEscape and useModifierKeyPress hooks (#445) 2025-04-25 22:30:18 +08:00
BiggerRain
f876fc24f2 fix: herder width (#444) 2025-04-25 19:03:30 +08:00
ayangweb
05f1459f8d refactor: removed the selection effect (#443) 2025-04-25 17:18:34 +08:00
ayangweb
78a7bfb4c4 feat: add shortcut key conflict hint and reset function (#442)
* feat: add shortcut key conflict hint and reset function

* docs: update changelog
2025-04-25 17:08:38 +08:00
ayangweb
9078c99e25 feat: right-click menu search supports shortcuts (#441) 2025-04-25 15:06:10 +08:00
BiggerRain
a044642636 chore: icons border color (#440)
* chore: icons border color

* build: publish web 1.1.9
2025-04-25 14:54:32 +08:00
ayangweb
0f18c0a597 feat: add MCP search-related shortcut configurations (#439) 2025-04-25 14:47:34 +08:00
ayangweb
86836bf756 refactor: optimized the up and down keys (#438)
* refactor: optimized the up and down keys

* style: remove useless code
2025-04-25 14:19:15 +08:00
ayangweb
70f876fd4a refactor: optimized the logic of esc key handling (#437) 2025-04-25 13:25:19 +08:00
Medcl
3826346fdf chore: update response handle (#433) 2025-04-24 19:01:56 +08:00
BiggerRain
79b998da1b feat: add MCP & call tools (#430)
* feat: add call tools

* feat: add chat call tools

* feat: add MCP & call LLM tools

* docs: update notes

* build: build error

* chore: replace iconfont

* chore: web icon

* chore: add
2025-04-24 19:00:16 +08:00
SteveLauC
839a51bb3c refactor: use the Runtime created by tauri (#436) 2025-04-24 18:15:24 +08:00
ayangweb
f7c7c0cc1e feat: auto becomes semi-transparent when it loses focus (#435) 2025-04-24 18:15:08 +08:00
ayangweb
61e253ca2c refactor: replacing images with SVG to support dynamic colors (#434) 2025-04-24 17:07:53 +08:00
ayangweb
ab16543e65 feat: data sources support displaying customized icons (#432)
* feat: data sources support displaying customized icons

* docs: update changelog
2025-04-24 16:28:34 +08:00
ayangweb
c095ad4d29 feat: ai assistant supports search and paging (#431) 2025-04-24 16:03:34 +08:00
BiggerRain
af63bab7bd fix: web iocn (#429)
* fix: web iocn

* fix: serarch icon

* build: build web 1.1.6

* chore: remove console
2025-04-24 09:38:38 +08:00
ayangweb
80ac8baca5 feat: add chat mode launch page #424 2025-04-23 19:03:09 +08:00
ayangweb
bde658b981 feat: add chat mode launch page (#424) 2025-04-23 18:50:32 +08:00
BiggerRain
4380b56a30 fix: query_coco_fusion params error (#425) 2025-04-23 18:49:42 +08:00
ayangweb
54364565e2 feat: right-click menu support for search (#423)
* feat: right-click menu support for search

* docs: update changelog
2025-04-23 18:42:17 +08:00
BiggerRain
ee4a06b6de feat: web components assistant (#422)
* chore: web components assistant

* chore: web components assistant

* docs: update notes
2025-04-23 18:23:40 +08:00
ayangweb
9715a92f36 refactor: change the selected background color of the item (#421) 2025-04-23 17:54:41 +08:00
BiggerRain
2caeb4090a docs: update README (#418) 2025-04-23 14:49:23 +08:00
ayangweb
983e65ee61 refactor: right-click menu returns to execute whichever one is selected (#417) 2025-04-23 10:30:56 +08:00
BiggerRain
ec37cfe68f fix: current conversation tip (#416) 2025-04-23 10:30:14 +08:00
BiggerRain
db66d81bd0 fix: fixed several search & chat bugs (#412)
* fix: chat error

* chore: add querysource

* refactor: filter query source rather than data source

* fix: fixed several search bugs

* docs: update notes

* feat: chat error

* chore: websocket

* chore: chat

* chore: chat

* fix: history search error

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-04-23 00:12:22 +08:00
ayangweb
5b0fdbcb2c feat: support esc to exit right-click menu (#415) 2025-04-22 19:58:47 +08:00
ayangweb
88955e0b95 feat: ai assistant supports shortcuts (#414) 2025-04-22 18:36:59 +08:00
SteveLauC
aee7df608f refactor: use timeout value specified in settings in query_coco_fusion() (#413)
* refactor: allow setting connection_timeout in query_coco_fusion()

* refactor: adding dynamic parameters to a request

* refactor: rename `connection_timeout` to `connectionTimeout`.

* refactor: simplifying object property assignment syntax

* feat: add query timeout function

* refactor: set min query_timeout to 1s

* refactor: rename connection_timeout to query_timeout

* fix: persist the setting entry

---------

Co-authored-by: ayang <473033518@qq.com>
2025-04-22 18:32:20 +08:00
SteveLauC
6d8fa81141 revert: Document constructor changed in #399 (#410) 2025-04-22 16:15:04 +08:00
ayangweb
d67d6645fe feat: automatically selects the first entry after searching (#411)
* feat: automatically selects the first entry after searching

* docs: update changelog

* refactor: remove debug log statements
2025-04-22 16:13:23 +08:00
ayangweb
6329354243 feat: add keyboard event handling and double-click copying (#409)
* feat: add keyboard event handling and double-click copying

* docs: delete duplicate release note entries
2025-04-22 15:01:00 +08:00
ayangweb
3ef5226e11 refactor: add empty data prompt to search scope (#406) 2025-04-22 13:36:00 +08:00
ayangweb
eebf49d7e0 refactor: optimize the style of the calculator (#405)
* refactor: optimize the style of the calculator

* docs: update changelog
2025-04-22 12:17:40 +08:00
BiggerRain
04903a09cd build: build web components and publish (#404)
* build: build web components and publish

* docs: update notes
2025-04-22 09:35:04 +08:00
ayangweb
44b5f8400e feat: added support for the calculator function (#399)
* feat: added support for the calculator function

* chore: deletion of duplicate files

* refactor: rust implements the conversion logic

* refactor: optimize string handling logic for number to word conversion

* refactor: adjusting styles to improve text overflow

* feat: adding tips

* feat(utils): adjust copy success message according to language settings

* feat(TypeIcon): add support for Calculator icons

* refactor(Search): refactoring context menu logic and component structure
2025-04-21 19:40:46 +08:00
ayangweb
77e6b58381 refactor: show placeholder image when history is empty (#398) 2025-04-21 14:39:06 +08:00
BiggerRain
f6e5e826fd chore: update assistant icon & think mode (#397)
* fix: assistant icon & think model

* docs: update notes

* chore: optimize the code
2025-04-21 12:18:45 +08:00
SteveLauC
886400bcbc fix: correct datasource ID in returned documents (#396)
* fix: correct datasource ID in returned documents
2025-04-21 10:23:55 +08:00
BiggerRain
53258ee834 feat: add support for switching AI assistants (#395)
* feat: add assistant

* build: build warning

* fix: filter http query_args and convert only supported values

* chore: server name truncate

* feat: add support for AI assistant

* feat: add support for AI assistant

---------

Co-authored-by: medcl <m@medcl.net>
2025-04-20 21:27:25 +08:00
Medcl
e8d197fb32 fix: filter http query_args and convert only supported values (#394) 2025-04-19 09:59:24 +08:00
ayangweb
195b6e7af1 refactor: optimization misc issues (#388)
* refactor: optimization problem

* refactor: optimize icon display logic

* refactor: optimized code

* style: remove useless import

* refactor: new shortcut hints for deleting popup boxes

* refactor: remove shortcut scopes from the history list of the standalone window

* refactor: change the key value of the shortcut fixed window from "F" to "P".

* refactor: shortcut to reset a fixed window

* refactor: persistence

* fix: fix shortcut key duplication problem

* style: temporarily annotate unused components

* refactor: remove unused imports

* refactor: change font size

* refactor: refresh to add rotation status

* refactor: show session files
2025-04-18 16:17:05 +08:00
Medcl
6f08d1e934 fix: get attachments in chat sessioins (#392) 2025-04-18 16:14:18 +08:00
SteveLauC
de89ad8d9a fix: app launching on Linux (#390)
* feat: custom open() interface

* refactor: front-end invocation

* refactor: async open()

* refactor: use gtk-launch instead

* style: fmt

---------

Co-authored-by: ayang <473033518@qq.com>
2025-04-18 15:02:45 +08:00
BiggerRain
a5657e61c0 chore: debug the code (#391) 2025-04-18 14:39:27 +08:00
SteveLauC
20e8658da8 fix: linux app search (#389) 2025-04-18 11:32:56 +08:00
BiggerRain
caf9f0238f feat: add error notification (#386)
* feat: add error notification

* feat: add error collection

* chore: error display

* chore: error string

* docs: update notes

* docs: update notes

* build: build error

* chore: errors 5 and link length truncation
2025-04-18 10:36:00 +08:00
ayangweb
f18f94ea6d refactor: replace the app icon after search with an absolute path (#387) 2025-04-18 10:18:28 +08:00
Medcl
bbb517237f refactor: refactoring api error handling (#382)
* refactor: refactoring api error handling

* chore: update release notes

* chore: merge from main
2025-04-17 18:42:28 +08:00
ayangweb
0bf6686494 feat: add keyboard-only operation to history list (#385)
* feat: add keyboard-only operation to history list

* docs: update changelog
2025-04-17 18:41:54 +08:00
BiggerRain
9f04fb1e0f style: search data display (#380)
* style: search date display

* style: adjust style

* style: search detail display

* docs: update notes

* build: build error
2025-04-17 17:34:42 +08:00
ayangweb
542fd5b233 refactor: upgrade tauri-plugin-fs-pro version and optimize code (#383)
* refactor: upgrade `tauri-plugin-fs-pro` version and optimize code

* style: delete useless tunes
2025-04-17 16:15:05 +08:00
SteveLauC
26bf391937 fix: correct app default search path on macOS (#381) 2025-04-17 11:57:14 +08:00
ayangweb
20b653391c chore: lock tauri-plugin-fs-pro version to 2.3.1 (#379) 2025-04-17 10:30:53 +08:00
BiggerRain
a9aab4e4d5 style: search list details display (#378)
* style: search list detail show

* style: search list detail show

* docs: update notes
2025-04-17 09:04:36 +08:00
Medcl
b25f820288 fix: chat history was not show up (#377)
* fix: query chat history

* chore: update release notes

* chore: update parameter check
2025-04-17 07:34:05 +08:00
BiggerRain
a6205eff1b chore: moblie & web display (#376)
* style: adjust style

* chore: moblie & web display
2025-04-16 20:19:12 +08:00
ayangweb
af70639eb3 feat: add application management to the plugin (#374)
* feat: add application management to the plugin

* refactor: add dark color mode support

* docs: update changelog

* style: add a full note
2025-04-16 19:06:31 +08:00
ayangweb
bd5015efeb refactor: add shortcut key hints to data source list (#375)
* refactor: add shortcut key hints to data source list

* refactor: flex layout implementation
2025-04-16 18:29:28 +08:00
BiggerRain
1c59a88a38 style: dark bg color (#373) 2025-04-16 15:53:10 +08:00
ayangweb
8fef0a5d8b style: remove unused code (#372) 2025-04-16 11:55:55 +08:00
ayangweb
4eed4cb1d9 refactor: shortcuts take effect in the popup box when opening the popup box (#371) 2025-04-16 11:46:32 +08:00
rain
eff37d6764 style: adjust moblie height & rounded 2025-04-16 11:45:09 +08:00
BiggerRain
a22024f640 style: modify the style (#370)
* style: modify the style

* style: adjust page style

* style: web style

* docs: update notes
2025-04-16 11:19:23 +08:00
ayangweb
c3bef7e46b refactor: disable system shortcuts (#369) 2025-04-16 10:06:51 +08:00
ayangweb
0703808009 refactor: optimization of search box styles for networked search data sources (#368) 2025-04-16 09:24:46 +08:00
ayangweb
23ae478e47 feat: networked search data sources support search and keyboard-only operation (#367)
* feat: networked search data sources support search and keyboard-only operation

* docs: update changelog
2025-04-15 20:13:42 +08:00
Medcl
6ecb232685 chore: update release notes (#366) 2025-04-15 18:22:00 +08:00
Medcl
e4785f0654 chore: update user profile (#365) 2025-04-15 18:18:02 +08:00
ayangweb
fc2c311624 refactor: unify platform adapter interfaces and optimize code structure (#363)
* refactor: unify platform adapter interfaces and optimize code structure

* style: remove unused comments
2025-04-15 10:50:26 +08:00
BiggerRain
0d15b3b6be chore: adjust web component styles (#362)
* chore: adjust web component styles

* docs: update notes
2025-04-15 08:55:06 +08:00
ayangweb
689631cde2 refactor: optimized history search and renaming (#360)
* refactor: optimized history search and renaming

* docs: update changelogs
2025-04-14 22:09:13 +08:00
SteveLauC
326b1f5bff feat: impl list_app_with_metadata_in() (#361)
* feat: impl list_app_with_metadata_in()

* docs: add link to serde_json::Number::from_u128()

* fix: app search

* chore: more default search paths for macOS
2025-04-14 20:30:25 +08:00
ayangweb
0a7b445661 feat: service list popup box supports keyboard-only operation (#359)
* feat: service list popup box supports keyboard-only operation

* docs: update changelog
2025-04-14 15:01:58 +08:00
ayangweb
62cbb95000 refactor: optimize history deletion (#358) 2025-04-11 17:33:58 +08:00
BiggerRain
2b11d4a2a8 chore: add translate (#357) 2025-04-11 17:11:52 +08:00
ayangweb
2cc3bf55c7 refactor: add autofocus to input boxes and block menu item default events (#356)
* refactor: add autofocus to input boxes and block menu item default events

* refactor: remove the portal attribute

* refactor: removing unnecessary event.preventDefault calls
2025-04-11 17:08:48 +08:00
ayangweb
76880460c5 feat: add a shortcut to open a network search range (#355) 2025-04-11 14:59:37 +08:00
ayangweb
42fb9563a7 refactor: displays the default mode when the app is launched for the first time (#353)
* refactor: displays the default mode when the app is launched for the first time

* refactor: optimize startup mode judgment logic

* style: removing warnings about unused variables and allowing dead code
2025-04-11 14:18:53 +08:00
BiggerRain
e088f5dcbe fix: active shadow setting (#354)
* chore: active shadow setting

* chore: add isTauri

* chore: web build

* docs: update notes
2025-04-11 14:17:42 +08:00
SteveLauC
024dc3155d feat: expose applications::get_default_search_paths (#352) 2025-04-11 11:48:48 +08:00
ayangweb
0948ab1035 refactor: fix chat window style issues (#351) 2025-04-11 10:33:40 +08:00
rain
19e2f5eb4f build: build tauri 2025-04-10 16:25:10 +08:00
BiggerRain
935cdef391 style: add width (#349) 2025-04-10 16:13:35 +08:00
BiggerRain
7e4f4b5303 feat: mobile terminal adaptation about style (#348)
* feat: mobile terminal adaptation

* feat: mobile terminal adaptation

* feat: mobile terminal adaptation

* docs: update notes

* chore: remove log
2025-04-10 16:03:38 +08:00
ayangweb
c053b55759 docs: update changelog (#345) 2025-04-10 09:14:44 +08:00
Medcl
7fa56cfc7d refactor: refactoring login callback, receive access_token from coco-server (#344) 2025-04-10 07:12:17 +08:00
ayangweb
c15fd2ce73 feat: add a border to the main window in Windows 10 (#343)
* feat: add a border to the main window in Windows 10

* refactor: remove unused code

* refactor: add dark themed borders
2025-04-09 17:04:20 +08:00
BiggerRain
6c90f42da0 feat: add font icon (#342)
* feat: add font icon

* docs: update notes

* chore: cleanup the uncessary change
2025-04-08 22:58:53 +08:00
ayangweb
72e5224e39 refactor: replace the history list in the main window (#341) 2025-04-08 21:43:41 +08:00
ayangweb
b602121cd3 refactor: replacing the open method (#340) 2025-04-08 16:27:45 +08:00
ayangweb
211ba463d0 fix: fix apps and articles not opening (#339)
* fix: fix apps and articles not opening

* style: adjusting the order of import statements
2025-04-08 15:48:59 +08:00
ayangweb
b45eb0b91d fix: fixed the problem of not being able to search in secondary directories (#338)
* fix: fixed the problem of not being able to search in secondary directories

* docs: update changelog
2025-04-08 15:35:10 +08:00
BiggerRain
57b2a20c56 fix: load app & web utils (#337) 2025-04-08 15:24:12 +08:00
ayangweb
59622a768b refactor: improvement of internationalization content (#336) 2025-04-07 18:25:22 +08:00
ayangweb
1cace28760 feat: add shortcuts for some icon buttons (#334)
* feat: add shortcuts for some icon buttons

* feat: support for switching the fixed state of the window

* refactor: optimize the issue of page jumping caused by the display of shortcut keys

* feat: deep thinking and networking search add shortcuts

* refactor: changing the default shortcut keys

* refactor: hide the voice input function button

* docs: update changelog
2025-04-07 16:20:13 +08:00
BiggerRain
eb32b03b48 chore: optimizing the code (#335) 2025-04-07 16:09:05 +08:00
BiggerRain
04d00c808d style: compatible with css style (#333)
* style: compatible with css style

* style: compatible with css style
2025-04-07 12:14:35 +08:00
BiggerRain
73a65718ef chore: app css & utils (#332) 2025-04-07 11:37:43 +08:00
BiggerRain
e15baef8f9 refactor: web components (#331)
* refactor: web components

* chore: web component

* chore: web

* chore: web

* docs: update notes
2025-04-07 11:19:09 +08:00
ayangweb
7225635f08 feat: linux support for application search (#330)
* feat: linux support for application search

* docs: update changelog
2025-04-03 15:36:30 +08:00
Medcl
ecc5757af6 chore: update preview (#327) 2025-04-03 09:42:09 +08:00
ayangweb
6a9b1b53b9 refactor: disable outline for all elements (#326)
* refactor: disable outline for all elements

* refactor: modify list item hover background color
2025-04-02 16:01:57 +08:00
ayangweb
a3663703e4 refactor: migration of attachments and transcription functionality to the commands module (#324) 2025-04-02 14:45:39 +08:00
ayangweb
3aed3a0df4 feat: history added search and action menus (#322)
* feat: history added search and action menus

* refactor: refinement of the dark theme

* feat: add renamed input box style

* feat: internalization

* refactor: optimize the bright theme style

* refactor: change dark theme style

* feat: added api for deleting and modifying conversations

* feat: supported search

* feat: support for modifying the title

* feat: support for deleting sessions

* refactor: remove popup internationalization
2025-04-02 14:03:40 +08:00
256 changed files with 23343 additions and 8414 deletions

6
.env
View File

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

View File

@@ -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" - platform: "ubuntu-22.04"
target: "x86_64-unknown-linux-gnu" target: "x86_64-unknown-linux-gnu"
- platform: "ubuntu-22.04-arm"
target: "aarch64-unknown-linux-gnu"
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
steps: steps:
@@ -67,10 +69,10 @@ jobs:
run: rustup target add ${{ matrix.target }} run: rustup target add ${{ matrix.target }}
- name: Install dependencies (ubuntu only) - name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04' if: startsWith(matrix.platform, 'ubuntu-22.04')
run: | run: |
sudo apt-get update 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 - name: Install Rust stable
run: rustup toolchain install stable run: rustup toolchain install stable
@@ -89,8 +91,51 @@ jobs:
- name: Install app dependencies and build web - name: Install app dependencies and build web
run: pnpm install --frozen-lockfile 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 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: env:
CI: false CI: false
PLATFORM: ${{ matrix.platform }} PLATFORM: ${{ matrix.platform }}

1
.gitignore vendored
View File

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

View File

@@ -11,10 +11,13 @@
"dtolnay", "dtolnay",
"dyld", "dyld",
"elif", "elif",
"errmsg",
"fullscreen", "fullscreen",
"fulltext",
"headlessui", "headlessui",
"Icdbb", "Icdbb",
"icns", "icns",
"iconfont",
"INFINI", "INFINI",
"infinilabs", "infinilabs",
"inputbox", "inputbox",
@@ -57,6 +60,7 @@
"uuidv", "uuidv",
"VITE", "VITE",
"walkdir", "walkdir",
"wavesurfer",
"webviews", "webviews",
"xzvf", "xzvf",
"yuque", "yuque",

View File

@@ -76,3 +76,10 @@ clean-rebuild:
@echo "Cleaning up and rebuilding..." @echo "Cleaning up and rebuilding..."
rm -rf node_modules rm -rf node_modules
$(MAKE) dev-build $(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

@@ -1,7 +1,15 @@
# Coco AI - Connect & Collaborate # Coco AI - Connect & Collaborate
<div align="center">
**Tagline**: _"Coco AI - search, connect, collaborate all in one place."_ **Tagline**: _"Coco AI - search, connect, collaborate all in one place."_
Visit our website: [https://coco.rs](https://coco.rs)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Tauri 2.0](https://img.shields.io/badge/Tauri-2.0-blue)](https://tauri.app/) [![React](https://img.shields.io/badge/React-18-blue)](https://react.dev/) [![TypeScript](https://img.shields.io/badge/TypeScript-5-blue)](https://www.typescriptlang.org/) [![Rust](https://img.shields.io/badge/Rust-latest-orange)](https://www.rust-lang.org/) [![Node](https://img.shields.io/badge/Node-%3E%3D18.12-green)](https://nodejs.org/) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/infinilabs/coco-app/pulls) [![Version](https://img.shields.io/github/v/release/infinilabs/coco-app)](https://github.com/infinilabs/coco-app/releases) [![Build Status](https://img.shields.io/github/actions/workflow/status/infinilabs/coco-app/ci.yml)](https://github.com/infinilabs/coco-app/actions) [![Discord](https://img.shields.io/discord/1122384609359966313)](https://discord.com/invite/4tKTMkkvVX)
</div>
Coco AI is a unified search platform that connects all your enterprise applications and data—Google Workspace, Dropbox, Coco AI is a unified search platform that connects all your enterprise applications and data—Google Workspace, Dropbox,
Confluent Wiki, GitHub, and more—into a single, powerful search interface. This repository contains the **Coco App**, Confluent Wiki, GitHub, and more—into a single, powerful search interface. This repository contains the **Coco App**,
built for both **desktop and mobile**. The app allows users to search and interact with their enterprise data across built for both **desktop and mobile**. The app allows users to search and interact with their enterprise data across
@@ -12,16 +20,15 @@ and internal resources. Coco enhances collaboration by making information instan
insights based on your enterprise's specific data. insights based on your enterprise's specific data.
> **Note**: Backend services, including data indexing and search functionality, are handled in a > **Note**: Backend services, including data indexing and search functionality, are handled in a
> separate [repository](https://github.com/infinilabs/coco-server). separate [repository](https://github.com/infinilabs/coco-server).
## Vision ![Coco AI](./docs/static/img/coco-preview.gif)
At Coco AI, we aim to streamline workplace collaboration by centralizing access to enterprise data. The Coco ## 🚀 Vision
App
provides a seamless, cross-platform experience, enabling teams to easily search, connect, and collaborate within their
workspace.
## Use Cases At Coco AI, we aim to streamline workplace collaboration by centralizing access to enterprise data. The Coco App provides a seamless, cross-platform experience, enabling teams to easily search, connect, and collaborate within their workspace.
## 💡 Use Cases
- **Unified Search Across Platforms**: Coco integrates with all your enterprise apps, letting you search documents, - **Unified Search Across Platforms**: Coco integrates with all your enterprise apps, letting you search documents,
conversations, and files across Google Workspace, Dropbox, GitHub, etc. conversations, and files across Google Workspace, Dropbox, GitHub, etc.
@@ -32,37 +39,73 @@ workspace.
- **Simplified Data Access**: By removing the friction between various tools, Coco enhances your workflow and increases - **Simplified Data Access**: By removing the friction between various tools, Coco enhances your workflow and increases
productivity. productivity.
## Getting Started ## ✨ Key Features
### Initial Setup - 🔍 **Unified Search**: One-stop enterprise search with multi-platform integration
- Supports major collaboration platforms: Google Workspace, Dropbox, Confluence Wiki, GitHub, etc.
- Real-time search across documents, conversations, and files
- Smart search intent understanding with relevance ranking
- Cross-platform data correlation and context display
- 🤖 **AI-Powered Chat**: Team-specific ChatGPT-like assistant trained on your enterprise data
- 🌐 **Cross-Platform**: Available for Windows, macOS, Linux and Web
- 🔒 **Security-First**: Support for private deployment and data sovereignty
-**High Performance**: Built with Rust and Tauri 2.0
- 🎨 **Modern UI**: Sleek interface designed for productivity
**This version of pnpm requires at least Node.js v18.12** ## 🛠️ Technology Stack
To set up the Coco App for development: - **Frontend**: React + TypeScript
- **Desktop Framework**: Tauri 2.0
- **Styling**: Tailwind CSS
- **State Management**: Zustand
- **Build Tool**: Vite
## 🚀 Getting Started
### Prerequisites
- Node.js >= 18.12
- Rust (latest stable)
- pnpm (package manager)
### Development Setup
```bash ```bash
cd coco-app # Install pnpm
npm install -g pnpm npm install -g pnpm
# Install dependencies
pnpm install pnpm install
# Start development server
pnpm tauri dev pnpm tauri dev
``` ```
#### Desktop Development: ### Production Build
To start desktop development, run:
```bash ```bash
pnpm tauri dev pnpm tauri build
``` ```
## Documentation ## 📚 Documentation
For full documentation on Coco AI, please visit the [Coco AI Documentation](https://docs.infinilabs.com/coco-app/main/). - [Coco App Documentation](https://docs.infinilabs.com/coco-app/main/)
- [Coco Server Documentation](https://docs.infinilabs.com/coco-server/main/)
- [Tauri Documentation](https://tauri.app/)
## License ## Contributors
Coco AI is an open-source project licensed under <a href="https://github.com/infinilabs/coco-app/graphs/contributors">
the [MIT License](https://github.com/infinilabs/coco-app/blob/main/LICENSE). <img src="https://contrib.rocks/image?repo=infinilabs/coco-app" />
</a>
This means that you can freely use, modify, and ## 📄 License
Coco AI is an open-source project licensed under the [MIT License](LICENSE). You can freely use, modify, and
distribute the software for both personal and commercial purposes, including hosting it on your own servers. distribute the software for both personal and commercial purposes, including hosting it on your own servers.
---
<div align="center">
Built with ❤️ by <a href="https://infinilabs.com">INFINI Labs</a>
</div>

View File

@@ -7,8 +7,7 @@ type: docs
Coco AI is a fully open-source, cross-platform unified search and productivity tool that connects and searches across various data sources, including applications, files, Google Drive, Notion, Yuque, Hugo, and more, both local and cloud-based. By integrating with large models like DeepSeek, Coco AI enables intelligent personal knowledge management, emphasizing privacy and supporting private deployment, helping users quickly and intelligently access their information. Coco AI is a fully open-source, cross-platform unified search and productivity tool that connects and searches across various data sources, including applications, files, Google Drive, Notion, Yuque, Hugo, and more, both local and cloud-based. By integrating with large models like DeepSeek, Coco AI enables intelligent personal knowledge management, emphasizing privacy and supporting private deployment, helping users quickly and intelligently access their information.
{{% load-img "/img/screenshot/fusion-search-across-datasources.png" "" %}} {{% load-img "/img/coco-preview.gif" "" %}}
{{% load-img "/img/screenshot/coco-chat.png" "" %}}
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/). For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).

View File

@@ -7,8 +7,7 @@ type: docs
Coco AI is a fully open-source, cross-platform unified search and productivity tool that connects and searches across various data sources, including applications, files, Google Drive, Notion, Yuque, Hugo, and more, both local and cloud-based. By integrating with large models like DeepSeek, Coco AI enables intelligent personal knowledge management, emphasizing privacy and supporting private deployment, helping users quickly and intelligently access their information. Coco AI is a fully open-source, cross-platform unified search and productivity tool that connects and searches across various data sources, including applications, files, Google Drive, Notion, Yuque, Hugo, and more, both local and cloud-based. By integrating with large models like DeepSeek, Coco AI enables intelligent personal knowledge management, emphasizing privacy and supporting private deployment, helping users quickly and intelligently access their information.
{{% load-img "/img/screenshot/fusion-search-across-datasources.png" "" %}} {{% load-img "/img/coco-preview.gif" "" %}}
{{% load-img "/img/screenshot/coco-chat.png" "" %}}
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/). For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).

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

@@ -8,24 +8,148 @@ title: "Release Notes"
Information about release notes of Coco Server is provided here. 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 ### Breaking changes
### Features ### Features
- feat: history support for searching, renaming and deleting #322
- feat: linux support for application search #330
- feat: add shortcuts to most icon buttons #334
- feat: add font icon for search list #342
- feat: add a border to the main window in Windows 10 #343
- feat: mobile terminal adaptation about style #348
- feat: service list popup box supports keyboard-only operation #359
- feat: networked search data sources support search and keyboard-only operation #367
- feat: add application management to the plugin #374
- feat: add keyboard-only operation to history list #385
- feat: add error notification #386
- feat: add support for AI assistant #394
- feat: add support for calculator function #399
- feat: auto selects the first item after searching #411
- feat: web components assistant #422
- feat: right-click menu support for search #423
- feat: add chat mode launch page #424
- feat: add MCP & call LLM tools #430
- feat: ai assistant supports search and paging #431
- 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 ### Bug fix
- fix: fixed the problem of not being able to search in secondary directories #338
- fix: active shadow setting #354
- fix: chat history was not show up #377
- fix: get attachments in chat sessions
- fix: filter http query_args and convert only supported values
- fixfixed several search & chat bugs #412
- fix: fixed carriage return problem with chinese input method #464
### Improvements ### Improvements
- refactor: web components #331
- refactor: refactoring login callback, receive access_token from coco-server
- chore: adjust web component styles #362
- style: modify the style #370
- style: search list details display #378
- refactor: refactoring api error handling #382
- chore: update assistant icon & think mode #397
- build: build web components and publish #404
## 0.3.0 (2025-03-31) ## 0.3.0 (2025-03-31)
### Breaking changes ### Breaking changes
- feat: add web pages components #277
- feat: support for customizing some of the preset shortcuts #316
### Features ### Features
- feat: add web pages components #277
- feat: support for customizing some of the preset shortcuts #316
- feat: support multi websocket connections #314 - feat: support multi websocket connections #314
- feat: add support for embeddable web widget #277 - feat: add support for embeddable web widget #277
@@ -55,10 +179,10 @@ Information about release notes of Coco Server is provided here.
### Improvements ### Improvements
- Refactor: chat components #273 - Refactor: chat components #273
- Featadd endpoint display #282 - Feat: add endpoint display #282
- Chore: chat window min width & remove input bg #284 - Chore: chat window min width & remove input bg #284
- Chore: remove selected function & add hide_coco #286 - Chore: remove selected function & add hide_coco #286
- Chorewebsocket timeout increased to 2 minutes #289 - Chore: websocket timeout increased to 2 minutes #289
- Chore: remove chat input border & clear input #295 - Chore: remove chat input border & clear input #295
## 0.2.0 (2025-03-07) ## 0.2.0 (2025-03-07)

BIN
docs/static/img/coco-preview.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

View File

@@ -7,7 +7,7 @@
<title>Coco</title> <title>Coco</title>
</head> </head>
<body> <body class="coco-container">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>

View File

@@ -1,13 +1,13 @@
{ {
"name": "coco", "name": "coco",
"private": true, "private": true,
"version": "0.3.0", "version": "0.5.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"build:web": "tsc && tsup", "build:web": "cross-env BUILD_TARGET=web tsc && cross-env BUILD_TARGET=web tsup --format esm",
"publish:web": "cd dist/search-chat && npm publish", "publish:web": "cd out/search-chat && npm publish",
"publish:web:beta": "cd dist/search-chat && npm publish --tag beta", "publish:web:beta": "cd dist/search-chat && npm publish --tag beta",
"publish:web:alpha": "cd dist/search-chat && npm publish --tag alpha", "publish:web:alpha": "cd dist/search-chat && npm publish --tag alpha",
"publish:web:rc": "cd dist/search-chat && npm publish --tag rc", "publish:web:rc": "cd dist/search-chat && npm publish --tag rc",
@@ -19,34 +19,37 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.0.0", "@ant-design/icons": "^6.0.0",
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.2",
"@tauri-apps/api": "^2.4.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "~2.2.0", "@tauri-apps/plugin-autostart": "~2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.0", "@tauri-apps/plugin-deep-link": "^2.2.1",
"@tauri-apps/plugin-dialog": "^2.2.0", "@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-global-shortcut": "~2.0.0", "@tauri-apps/plugin-global-shortcut": "~2.0.0",
"@tauri-apps/plugin-http": "~2.0.2", "@tauri-apps/plugin-http": "~2.0.2",
"@tauri-apps/plugin-log": "~2.4.0",
"@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.0", "@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.0", "@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.6.1", "@tauri-apps/plugin-updater": "github:infinilabs/tauri-plugin-updater#v2",
"@tauri-apps/plugin-websocket": "~2.3.0", "@tauri-apps/plugin-websocket": "~2.3.0",
"@tauri-apps/plugin-window": "2.0.0-alpha.1", "@tauri-apps/plugin-window": "2.0.0-alpha.1",
"@wavesurfer/react": "^1.0.9", "@wavesurfer/react": "^1.0.11",
"ahooks": "^3.8.4", "ahooks": "^3.8.4",
"axios": "^1.9.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.4.7", "dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"filesize": "^10.1.6", "filesize": "^10.1.6",
"i18next": "^23.16.8", "i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.461.0", "lucide-react": "^0.461.0",
"mermaid": "^11.5.0", "mermaid": "^11.6.0",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.6.1", "react-hotkeys-hook": "^4.6.2",
"react-i18next": "^15.4.1", "react-i18next": "^15.5.1",
"react-markdown": "^9.1.0", "react-markdown": "^9.1.0",
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.0",
"react-window": "^1.8.11", "react-window": "^1.8.11",
@@ -55,33 +58,38 @@
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"tauri-plugin-fs-pro-api": "^2.3.1", "tauri-plugin-fs-pro-api": "^2.4.0",
"tauri-plugin-macos-permissions-api": "^2.2.0", "tauri-plugin-macos-permissions-api": "^2.3.0",
"tauri-plugin-screenshots-api": "^2.1.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", "use-debounce": "^10.0.4",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"wavesurfer.js": "^7.9.3", "wavesurfer.js": "^7.9.5",
"zustand": "^5.0.3" "zustand": "^5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.4.0", "@tauri-apps/cli": "^2.5.0",
"@types/dom-speech-recognition": "^0.0.4", "@types/dom-speech-recognition": "^0.0.4",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^22.13.11", "@types/node": "^22.15.17",
"@types/react": "^18.3.19", "@types/react": "^18.3.21",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.7",
"@types/react-katex": "^3.0.4", "@types/react-katex": "^3.0.4",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"cross-env": "^7.0.3",
"immer": "^10.1.1", "immer": "^10.1.1",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"release-it": "^18.1.2", "release-it": "^18.1.2",
"sass": "^1.87.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsup": "^8.4.0", "tsup": "^8.4.0",
"tsx": "^4.19.3", "tsx": "^4.19.4",
"typescript": "^5.8.2", "typescript": "^5.8.3",
"vite": "^5.4.14" "vite": "^5.4.19"
} },
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
} }

2110
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2000
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "coco" name = "coco"
version = "0.3.0" version = "0.5.2"
description = "Search, connect, collaborate all in one place." description = "Search, connect, collaborate all in one place."
authors = ["INFINI Labs"] authors = ["INFINI Labs"]
edition = "2021" edition = "2021"
@@ -20,6 +20,26 @@ tauri-build = { version = "2", features = ["default"] }
default = ["desktop"] default = ["desktop"]
desktop = [] desktop = []
cargo-clippy = [] 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] [dependencies]
pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "main" } pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "main" }
@@ -27,7 +47,9 @@ pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "m
tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png", "unstable"] } tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png", "unstable"] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" # Need `arbitrary_precision` feature to support storing u128
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
serde_json = { version = "1", features = ["arbitrary_precision"] }
tauri-plugin-http = "2" tauri-plugin-http = "2"
tauri-plugin-websocket = "2" tauri-plugin-websocket = "2"
tauri-plugin-deep-link = "2.0.0" tauri-plugin-deep-link = "2.0.0"
@@ -35,17 +57,15 @@ tauri-plugin-store = "2.2.0"
tauri-plugin-os = "2" tauri-plugin-os = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-drag = "2" tauri-plugin-drag = "2"
tauri-plugin-macos-permissions = "2" tauri-plugin-macos-permissions = "2"
tauri-plugin-fs-pro = "2" tauri-plugin-fs-pro = "2"
tauri-plugin-screenshots = "2" tauri-plugin-screenshots = "2"
applications = "0.3.0" applications = { git = "https://github.com/infinilabs/applications-rs", rev = "7bb507e6b12f73c96f3a52f0578d0246a689f381" }
tokio-native-tls = "0.3" # For wss connections tokio-native-tls = "0.3" # For wss connections
tokio = { version = "1", features = ["full"] } 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"] } hyper = { version = "0.14", features = ["client"] }
reqwest = { version = "0.12", features = ["json", "multipart"] } reqwest = { version = "0.12", features = ["json", "multipart"] }
futures = "0.3.31" futures = "0.3.31"
@@ -60,15 +80,24 @@ hostname = "0.3"
plist = "1.7" plist = "1.7"
base64 = "0.13" base64 = "0.13"
walkdir = "2" walkdir = "2"
fuzzy_prefix_search = "0.2"
log = "0.4" log = "0.4"
strsim = "0.10"
futures-util = "0.3.31" futures-util = "0.3.31"
url = "2.5.2" url = "2.5.2"
http = "1.1.0" http = "1.1.0"
tungstenite = "0.24.0" tungstenite = "0.24.0"
env_logger = "0.11.5"
tokio-util = "0.7.14" 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] [target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" } tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
@@ -76,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] [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] } tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
[profile.dev] [profile.dev]
incremental = true # Compile your binary in smaller steps. incremental = true # Compile your binary in smaller steps.
@@ -90,4 +118,7 @@ strip = true # Ensures debug symbols are removed.
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "^2.2" tauri-plugin-autostart = "^2.2"
tauri-plugin-global-shortcut = "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

@@ -29,6 +29,7 @@
"core:window:allow-set-focus", "core:window:allow-set-focus",
"core:window:allow-set-always-on-top", "core:window:allow-set-always-on-top",
"core:window:deny-internal-toggle-maximize", "core:window:deny-internal-toggle-maximize",
"core:window:allow-set-shadow",
"core:app:allow-set-app-theme", "core:app:allow-set-app-theme",
"shell:default", "shell:default",
"http:default", "http:default",
@@ -68,6 +69,8 @@
"screenshots:default", "screenshots:default",
"core:window:allow-set-theme", "core:window:allow-set-theme",
"process:default", "process:default",
"updater:default" "updater: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::assistant::ChatRequestMessage; use crate::common::assistant::ChatRequestMessage;
use crate::common::http::GetResponse; use crate::common::http::GetResponse;
use crate::common::register::SearchSourceRegistry;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use reqwest::Response; 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 serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Emitter, Manager, Runtime};
use tokio::io::AsyncBufReadExt;
#[tauri::command] #[tauri::command]
pub async fn chat_history<R: Runtime>( pub async fn chat_history<R: Runtime>(
@@ -12,6 +18,7 @@ pub async fn chat_history<R: Runtime>(
server_id: String, server_id: String,
from: u32, from: u32,
size: u32, size: u32,
query: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
let mut query_params: HashMap<String, Value> = HashMap::new(); let mut query_params: HashMap<String, Value> = HashMap::new();
if from > 0 { if from > 0 {
@@ -21,25 +28,20 @@ pub async fn chat_history<R: Runtime>(
query_params.insert("size".to_string(), size.into()); query_params.insert("size".to_string(), size.into());
} }
if let Some(query) = query {
if !query.is_empty() {
query_params.insert("query".to_string(), query.into());
}
}
let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params)) let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params))
.await .await
.map_err(|e| format!("Error get sessions: {}", e))?; .map_err(|e| {
dbg!("Error get history: {}", &e);
format!("Error get history: {}", e)
})?;
handle_raw_response(response).await? common::http::get_response_body_text(response).await
}
async fn handle_raw_response(response: Response) -> Result<Result<String, String>, String> {
Ok(
if response.status().as_u16() < 200 || response.status().as_u16() >= 400 {
Err("Failed to send message".to_string())
} else {
let body = response
.text()
.await
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
Ok(body)
},
)
} }
#[tauri::command] #[tauri::command]
@@ -64,7 +66,7 @@ pub async fn session_chat_history<R: Runtime>(
.await .await
.map_err(|e| format!("Error get session message: {}", e))?; .map_err(|e| format!("Error get session message: {}", e))?;
handle_raw_response(response).await? common::http::get_response_body_text(response).await
} }
#[tauri::command] #[tauri::command]
@@ -80,7 +82,7 @@ pub async fn open_session_chat<R: Runtime>(
.await .await
.map_err(|e| format!("Error open session: {}", e))?; .map_err(|e| format!("Error open session: {}", e))?;
handle_raw_response(response).await? common::http::get_response_body_text(response).await
} }
#[tauri::command] #[tauri::command]
@@ -96,7 +98,7 @@ pub async fn close_session_chat<R: Runtime>(
.await .await
.map_err(|e| format!("Error close session: {}", e))?; .map_err(|e| format!("Error close session: {}", e))?;
handle_raw_response(response).await? common::http::get_response_body_text(response).await
} }
#[tauri::command] #[tauri::command]
pub async fn cancel_session_chat<R: Runtime>( pub async fn cancel_session_chat<R: Runtime>(
@@ -111,7 +113,7 @@ pub async fn cancel_session_chat<R: Runtime>(
.await .await
.map_err(|e| format!("Error cancel session: {}", e))?; .map_err(|e| format!("Error cancel session: {}", e))?;
handle_raw_response(response).await? common::http::get_response_body_text(response).await
} }
#[tauri::command] #[tauri::command]
@@ -120,14 +122,17 @@ pub async fn new_chat<R: Runtime>(
server_id: String, server_id: String,
websocket_id: String, websocket_id: String,
message: String, message: String,
query_params: Option<HashMap<String, Value>>, //search,deep_thinking query_params: Option<HashMap<String, Value>>,
) -> Result<GetResponse, String> { ) -> Result<GetResponse, String> {
let body = if !message.is_empty() { let body = if !message.is_empty() {
let message = ChatRequestMessage { let message = ChatRequestMessage {
message: Some(message), message: Some(message),
}; };
let body = reqwest::Body::from(serde_json::to_string(&message).unwrap()); Some(
Some(body) serde_json::to_string(&message)
.map_err(|e| format!("Failed to serialize message: {}", e))?
.into(),
)
} else { } else {
None None
}; };
@@ -135,20 +140,18 @@ pub async fn new_chat<R: Runtime>(
let mut headers = HashMap::new(); let mut headers = HashMap::new();
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into()); headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
let response = HttpClient::advanced_post(&server_id, "/chat/_new", Some(headers), query_params, body) let response =
HttpClient::advanced_post(&server_id, "/chat/_new", Some(headers), query_params, body)
.await .await
.map_err(|e| format!("Error sending message: {}", e))?; .map_err(|e| format!("Error sending message: {}", e))?;
if response.status().as_u16() < 200 || response.status().as_u16() >= 400 { let body_text = common::http::get_response_body_text(response).await?;
return Err("Failed to send message".to_string());
}
let chat_response: GetResponse = response log::debug!("New chat response: {}", &body_text);
.json()
.await let chat_response: GetResponse = serde_json::from_str(&body_text)
.map_err(|e| format!("Failed to parse response JSON: {}", e))?; .map_err(|e| format!("Failed to parse response JSON: {}", e))?;
// Check the result and status fields
if chat_response.result != "created" { if chat_response.result != "created" {
return Err(format!("Unexpected result: {}", chat_response.result)); return Err(format!("Unexpected result: {}", chat_response.result));
} }
@@ -174,10 +177,260 @@ pub async fn send_message<R: Runtime>(
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into()); headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap()); let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap());
let response = let response = HttpClient::advanced_post(
HttpClient::advanced_post(&server_id, path.as_str(), Some(headers), query_params, Some(body)) &server_id,
path.as_str(),
Some(headers),
query_params,
Some(body),
)
.await .await
.map_err(|e| format!("Error cancel session: {}", e))?; .map_err(|e| format!("Error cancel session: {}", e))?;
handle_raw_response(response).await?
common::http::get_response_body_text(response).await
}
#[tauri::command]
pub async fn delete_session_chat(server_id: String, session_id: String) -> Result<bool, String> {
let response =
HttpClient::delete(&server_id, &format!("/chat/{}", session_id), None, None).await?;
if response.status().is_success() {
Ok(true)
} else {
Err(format!("Delete failed with status: {}", response.status()))
}
}
#[tauri::command]
pub async fn update_session_chat(
server_id: String,
session_id: String,
title: Option<String>,
context: Option<HashMap<String, Value>>,
) -> Result<bool, String> {
let mut body = HashMap::new();
if let Some(title) = title {
body.insert("title".to_string(), Value::String(title));
}
if let Some(context) = context {
body.insert(
"context".to_string(),
Value::Object(context.into_iter().collect()),
);
}
let response = HttpClient::put(
&server_id,
&format!("/chat/{}", session_id),
None,
None,
Some(reqwest::Body::from(serde_json::to_string(&body).unwrap())),
)
.await
.map_err(|e| format!("Error updating session: {}", e))?;
Ok(response.status().is_success())
}
#[tauri::command]
pub async fn assistant_search<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
from: u32,
size: u32,
query: Option<HashMap<String, Value>>,
) -> Result<Value, String> {
let mut body = serde_json::json!({
"from": from,
"size": size,
});
if let Some(q) = query {
body["query"] = serde_json::to_value(q).map_err(|e| e.to_string())?;
}
let response = HttpClient::post(
&server_id,
"/assistant/_search",
None,
Some(reqwest::Body::from(body.to_string())),
)
.await
.map_err(|e| format!("Error searching assistants: {}", e))?;
response
.json::<Value>()
.await
.map_err(|err| err.to_string())
}
#[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)] #[allow(dead_code)]
pub struct NewChatResponse { pub struct NewChatResponse {
pub _id: String, pub _id: String,
pub _source: Source, pub _source: Session,
pub result: String, pub result: String,
pub payload: Option<Value>, pub payload: Option<Value>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Source { pub struct Session {
pub id: String, pub id: String,
pub created: String, pub created: String,
pub updated: String, pub updated: String,
@@ -23,4 +23,11 @@ pub struct Source {
pub title: Option<String>, pub title: Option<String>,
pub summary: Option<String>, pub summary: Option<String>,
pub manually_renamed_title: bool, 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 serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use crate::hide_coco;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RichLabel { pub struct RichLabel {
pub label: Option<String>, pub label: Option<String>,
@@ -29,7 +31,73 @@ pub struct EditorInfo {
pub timestamp: Option<String>, pub timestamp: Option<String>,
} }
/// Defines the action that would be performed when a document gets opened.
#[derive(Debug, Clone, Serialize, Deserialize)] #[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 struct Document {
pub id: String, pub id: String,
pub created: Option<String>, pub created: Option<String>,
@@ -48,6 +116,8 @@ pub struct Document {
pub thumbnail: Option<String>, pub thumbnail: Option<String>,
pub cover: Option<String>, pub cover: Option<String>,
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
/// What will happen if we open this document.
pub on_opened: Option<OnOpened>,
pub url: Option<String>, pub url: Option<String>,
pub size: Option<i64>, pub size: Option<i64>,
pub metadata: Option<HashMap<String, serde_json::Value>>, pub metadata: Option<HashMap<String, serde_json::Value>>,
@@ -55,32 +125,3 @@ pub struct Document {
pub owner: Option<UserInfo>, pub owner: Option<UserInfo>,
pub last_updated_by: Option<EditorInfo>, 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

@@ -0,0 +1,65 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Deserialize)]
#[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 {
#[serde(default)]
pub error: Option<ErrorDetail>,
#[serde(default)]
pub status: Option<u16>,
}
#[derive(Debug, Error, Serialize)]
pub enum SearchError {
#[error("HttpError: {0}")]
HttpError(String),
#[error("ParseError: {0}")]
ParseError(String),
#[error("Timeout occurred")]
Timeout,
#[error("UnknownError: {0}")]
#[allow(dead_code)]
Unknown(String),
#[error("InternalError: {0}")]
#[allow(dead_code)]
InternalError(String),
}
impl From<reqwest::Error> for SearchError {
fn from(err: reqwest::Error) -> Self {
if err.is_timeout() {
SearchError::Timeout
} else if err.is_decode() {
SearchError::ParseError(err.to_string())
} else {
SearchError::HttpError(err.to_string())
}
}
}

View File

@@ -1,3 +1,5 @@
use crate::common;
use reqwest::Response;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -16,3 +18,39 @@ pub struct Source {
pub updated: String, pub updated: String,
pub status: String, pub status: String,
} }
pub async fn get_response_body_text(response: Response) -> Result<String, String> {
let status = response.status().as_u16();
let body = response
.text()
.await
.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
let fallback_error = "Failed to send message".to_string();
if body.trim().is_empty() {
return Err(fallback_error);
}
match serde_json::from_str::<common::error::ErrorResponse>(&body) {
Ok(parsed_error) => {
dbg!(&parsed_error);
Err(format!(
"Server error ({}): {:?}",
status, parsed_error.error
))
}
Err(_) => {
log::warn!("Failed to parse error response: {}", &body);
Err(fallback_error)
}
}
} else {
Ok(body)
}
}

View File

@@ -10,6 +10,7 @@ pub mod traits;
pub mod register; pub mod register;
pub mod assistant; pub mod assistant;
pub mod http; pub mod http;
pub mod error;
pub static MAIN_WINDOW_LABEL: &str = "main"; pub static MAIN_WINDOW_LABEL: &str = "main";
pub static SETTINGS_WINDOW_LABEL: &str = "settings"; pub static SETTINGS_WINDOW_LABEL: &str = "settings";

View File

@@ -1,15 +1,16 @@
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
#[derive(Debug,Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Preferences { pub struct Preferences {
pub theme: String, pub theme: Option<String>,
pub language: String, pub language: Option<String>,
} }
#[derive(Debug,Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserProfile { pub struct UserProfile {
pub id: String,
pub name: String, pub name: String,
pub email: String, pub email: String,
pub avatar: String, pub avatar: Option<String>,
pub preferences: Preferences, pub preferences: Option<Preferences>,
} }

View File

@@ -1,9 +1,10 @@
use crate::common::document::Document; use crate::common::document::Document;
use crate::common::http::get_response_body_text;
use reqwest::Response; use reqwest::Response;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::error::Error; use std::error::Error;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct SearchResponse<T> { pub struct SearchResponse<T> {
pub took: u64, pub took: u64,
@@ -24,7 +25,7 @@ pub struct Shards {
pub struct Hits<T> { pub struct Hits<T> {
pub total: Total, pub total: Total,
pub max_score: Option<f32>, pub max_score: Option<f32>,
pub hits: Vec<SearchHit<T>>, pub hits: Option<Vec<SearchHit<T>>>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -35,9 +36,9 @@ pub struct Total {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct SearchHit<T> { pub struct SearchHit<T> {
pub _index: String, pub _index: Option<String>,
pub _type: String, pub _type: Option<String>,
pub _id: String, pub _id: Option<String>,
pub _score: Option<f64>, pub _score: Option<f64>,
pub _source: T, // This will hold the type we pass in (e.g., DataSource) pub _source: T, // This will hold the type we pass in (e.g., DataSource)
} }
@@ -47,26 +48,28 @@ pub async fn parse_search_response<T>(
where where
T: for<'de> Deserialize<'de> + std::fmt::Debug, T: for<'de> Deserialize<'de> + std::fmt::Debug,
{ {
let body = response let body_text = get_response_body_text(response).await?;
.json::<Value>()
.await
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
// dbg!(&body); // dbg!(&body_text);
let search_response: SearchResponse<T> = serde_json::from_value(body) let search_response: SearchResponse<T> = serde_json::from_str(&body_text)
.map_err(|e| format!("Failed to deserialize search response: {}", e))?; .map_err(|e| format!("Failed to deserialize search response: {}", e))?;
Ok(search_response) Ok(search_response)
} }
use serde::de::DeserializeOwned;
pub async fn parse_search_hits<T>(response: Response) -> Result<Vec<SearchHit<T>>, Box<dyn Error>> pub async fn parse_search_hits<T>(response: Response) -> Result<Vec<SearchHit<T>>, Box<dyn Error>>
where where
T: for<'de> Deserialize<'de> + std::fmt::Debug, T: DeserializeOwned + std::fmt::Debug,
{ {
let response = parse_search_response(response).await?; 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>> 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::health::Health;
use crate::common::profile::UserProfile; use crate::common::profile::UserProfile;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -60,6 +62,7 @@ pub struct Server {
pub auth_provider: AuthProvider, pub auth_provider: AuthProvider,
#[serde(default = "default_priority_type")] #[serde(default = "default_priority_type")]
pub priority: u32, pub priority: u32,
pub stats: Option<HashMap<String, Value>>,
} }
impl PartialEq for Server { impl PartialEq for Server {

View File

@@ -1,10 +1,7 @@
use crate::common::search::{QueryResponse, QuerySource}; use crate::common::error::SearchError;
use thiserror::Error;
use async_trait::async_trait;
// use std::{future::Future, pin::Pin};
use crate::common::search::SearchQuery; use crate::common::search::SearchQuery;
use serde::Serialize; use crate::common::search::{QueryResponse, QuerySource};
use async_trait::async_trait;
#[async_trait] #[async_trait]
pub trait SearchSource: Send + Sync { pub trait SearchSource: Send + Sync {
@@ -12,35 +9,3 @@ pub trait SearchSource: Send + Sync {
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>; async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>;
} }
#[derive(Debug, Error, Serialize)]
pub enum SearchError {
#[error("HTTP request failed: {0}")]
HttpError(String),
#[error("Invalid response format: {0}")]
ParseError(String),
#[error("Timeout occurred")]
Timeout,
#[error("Unknown error: {0}")]
#[allow(dead_code)]
Unknown(String),
#[error("InternalError error: {0}")]
#[allow(dead_code)]
InternalError(String),
}
impl From<reqwest::Error> for SearchError {
fn from(err: reqwest::Error) -> Self {
if err.is_timeout() {
SearchError::Timeout
} else if err.is_decode() {
SearchError::ParseError(err.to_string())
} else {
SearchError::HttpError(err.to_string())
}
}
}

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

@@ -0,0 +1,169 @@
use super::super::LOCAL_QUERY_SOURCE_TYPE;
use crate::common::{
document::{DataSourceReference, Document},
error::SearchError,
search::{QueryResponse, QuerySource, SearchQuery},
traits::SearchSource,
};
use async_trait::async_trait;
use chinese_number::{ChineseCase, ChineseCountMethod, ChineseVariant, NumberToChinese};
use num2words::Num2Words;
use serde_json::Value;
use std::collections::HashMap;
pub(crate) const DATA_SOURCE_ID: &str = "Calculator";
pub struct CalculatorSource {
base_score: f64,
}
impl CalculatorSource {
pub fn new(base_score: f64) -> Self {
CalculatorSource { base_score }
}
}
fn parse_query(query: String) -> Value {
let mut query_json = serde_json::Map::new();
let operators = ["+", "-", "*", "/", "%"];
let found_operators: Vec<_> = query
.chars()
.filter(|c| operators.contains(&c.to_string().as_str()))
.collect();
if found_operators.len() == 1 {
let operation = match found_operators[0] {
'+' => "sum",
'-' => "subtract",
'*' => "multiply",
'/' => "divide",
'%' => "remainder",
_ => "expression",
};
query_json.insert("type".to_string(), Value::String(operation.to_string()));
} else {
query_json.insert("type".to_string(), Value::String("expression".to_string()));
}
query_json.insert("value".to_string(), Value::String(query));
Value::Object(query_json)
}
fn parse_result(num: f64) -> Value {
let mut result_json = serde_json::Map::new();
let to_zh = num
.to_chinese(
ChineseVariant::Simple,
ChineseCase::Upper,
ChineseCountMethod::TenThousand,
)
.unwrap_or(num.to_string());
let to_en = Num2Words::new(num)
.to_words()
.map(|s| {
let mut chars = s.chars();
let mut result = String::new();
let mut capitalize = true;
while let Some(c) = chars.next() {
if c == ' ' || c == '-' {
result.push(c);
capitalize = true;
} else if capitalize {
result.extend(c.to_uppercase());
capitalize = false;
} else {
result.push(c);
}
}
result
})
.unwrap_or(num.to_string());
result_json.insert("value".to_string(), Value::String(num.to_string()));
result_json.insert("toZh".to_string(), Value::String(to_zh));
result_json.insert("toEn".to_string(), Value::String(to_en));
Value::Object(result_json)
}
#[async_trait]
impl SearchSource for CalculatorSource {
fn get_type(&self) -> QuerySource {
QuerySource {
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
name: hostname::get()
.unwrap_or(DATA_SOURCE_ID.into())
.to_string_lossy()
.into(),
id: DATA_SOURCE_ID.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,
});
};
// 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 {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
}
match meval::eval_str(query_string) {
Ok(num) => {
let mut payload: HashMap<String, Value> = HashMap::new();
let payload_query = parse_query(query_string.into());
let payload_result = parse_result(num);
payload.insert("query".to_string(), payload_query);
payload.insert("result".to_string(), payload_result);
let doc = Document {
id: DATA_SOURCE_ID.to_string(),
category: Some(DATA_SOURCE_ID.to_string()),
payload: Some(payload),
source: Some(DataSourceReference {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some(DATA_SOURCE_ID.into()),
id: Some(DATA_SOURCE_ID.into()),
icon: Some(String::from("font_Calculator")),
}),
..Default::default()
};
return Ok(QueryResponse {
source: self.get_type(),
hits: vec![(doc, self.base_score)],
total_hits: 1,
});
}
Err(_) => {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
}
};
}
}

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 assistant;
mod autostart; mod autostart;
mod common; mod common;
mod local; mod extension;
mod search; mod search;
mod server; mod server;
mod settings;
mod setup; mod setup;
mod shortcut; mod shortcut;
mod util; mod util;
@@ -15,13 +16,15 @@ use crate::server::servers::{load_or_insert_default_server, load_servers_token};
use autostart::{change_autostart, enable_autostart}; use autostart::{change_autostart, enable_autostart};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::OnceLock;
use tauri::async_runtime::block_on;
use tauri::plugin::TauriPlugin;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use tauri::ActivationPolicy; use tauri::ActivationPolicy;
use tauri::{ use tauri::{
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, Window, WindowEvent, AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, Window, WindowEvent,
}; };
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
use tokio::runtime::Runtime as RT;
/// Tauri store name /// Tauri store name
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store"; pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
@@ -30,6 +33,10 @@ lazy_static! {
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None); 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] #[tauri::command]
async fn change_window_height(handle: AppHandle, height: u32) { async fn change_window_height(handle: AppHandle, height: u32) {
let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap(); let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
@@ -55,15 +62,13 @@ struct Payload {
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let ctx = tauri::generate_context!(); let ctx = tauri::generate_context!();
// Initialize logger
env_logger::init();
let mut app_builder = tauri::Builder::default(); let mut app_builder = tauri::Builder::default();
#[cfg(desktop)] #[cfg(desktop)]
{ {
app_builder = app_builder.plugin(tauri_plugin_single_instance::init(|_app, argv, _cwd| { 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 // when defining deep link schemes at runtime, you must also check `argv` here
})); }));
} }
@@ -82,7 +87,9 @@ pub fn run() {
.plugin(tauri_plugin_macos_permissions::init()) .plugin(tauri_plugin_macos_permissions::init())
.plugin(tauri_plugin_screenshots::init()) .plugin(tauri_plugin_screenshots::init())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build()); .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_windows_version::init())
.plugin(set_up_tauri_logger());
// Conditional compilation for macOS // Conditional compilation for macOS
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -110,7 +117,8 @@ pub fn run() {
server::servers::disable_server, server::servers::disable_server,
server::auth::handle_sso_callback, server::auth::handle_sso_callback,
server::profile::get_user_profiles, server::profile::get_user_profiles,
server::datasource::get_datasources_by_server, server::datasource::datasource_search,
server::datasource::mcp_server_search,
server::connector::get_connectors_by_server, server::connector::get_connectors_by_server,
search::query_coco_fusion, search::query_coco_fusion,
assistant::chat_history, assistant::chat_history,
@@ -120,6 +128,11 @@ pub fn run() {
assistant::open_session_chat, assistant::open_session_chat,
assistant::close_session_chat, assistant::close_session_chat,
assistant::cancel_session_chat, assistant::cancel_session_chat,
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_datasources,
// server::get_coco_server_connectors, // server::get_coco_server_connectors,
server::websocket::connect_to_server, server::websocket::connect_to_server,
@@ -128,27 +141,42 @@ pub fn run() {
server::attachment::upload_attachment, server::attachment::upload_attachment,
server::attachment::get_attachment, server::attachment::get_attachment,
server::attachment::delete_attachment, server::attachment::delete_attachment,
server::transcription::transcription server::transcription::transcription,
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| { .setup(|app| {
let app_handle = app.handle().clone();
GLOBAL_TAURI_APP_HANDLE
.set(app_handle.clone())
.expect("variable already initialized");
let registry = SearchSourceRegistry::default(); let registry = SearchSourceRegistry::default();
app.manage(registry); // Store registry in Tauri's app state app.manage(registry); // Store registry in Tauri's app state
app.manage(server::websocket::WebSocketManager::default()); app.manage(server::websocket::WebSocketManager::default());
// Get app handle block_on(async {
// let app_handle = app.handle().clone(); init(app.handle()).await;
// Create a single Tokio runtime instance
let rt = RT::new().expect("Failed to create Tokio runtime");
// Use the runtime to spawn the async initialization tasks
let init_app_handle = app.handle().clone();
rt.spawn(async move {
init(&init_app_handle).await; // Pass a reference to `app_handle`
}); });
shortcut::enable_shortcut(&app); shortcut::enable_shortcut(app);
enable_autostart(app); enable_autostart(app);
@@ -158,7 +186,7 @@ pub fn run() {
// app.listen("theme-changed", move |event| { // app.listen("theme-changed", move |event| {
// if let Ok(payload) = serde_json::from_str::<ThemeChangedPayload>(event.payload()) { // if let Ok(payload) = serde_json::from_str::<ThemeChangedPayload>(event.payload()) {
// // switch_tray_icon(app.app_handle(), payload.is_dark_mode); // // 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);
// } // }
// }); // });
@@ -184,7 +212,7 @@ pub fn run() {
}) })
.on_window_event(|window, event| match event { .on_window_event(|window, event| match event {
WindowEvent::CloseRequested { api, .. } => { WindowEvent::CloseRequested { api, .. } => {
dbg!("Close requested event received"); //dbg!("Close requested event received");
window.hide().unwrap(); window.hide().unwrap();
api.prevent_close(); api.prevent_close();
} }
@@ -199,10 +227,10 @@ pub fn run() {
has_visible_windows, has_visible_windows,
.. ..
} => { } => {
dbg!( // dbg!(
"Reopen event received: has_visible_windows = {}", // "Reopen event received: has_visible_windows = {}",
has_visible_windows // has_visible_windows
); // );
if has_visible_windows { if has_visible_windows {
return; return;
} }
@@ -216,11 +244,11 @@ pub fn run() {
pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) { pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
// Await the async functions to load the servers and tokens // Await the async functions to load the servers and tokens
if let Err(err) = load_or_insert_default_server(app_handle).await { if let Err(err) = load_or_insert_default_server(app_handle).await {
eprintln!("Failed to load servers: {}", err); log::error!("Failed to load servers: {}", err);
} }
if let Err(err) = load_servers_token(app_handle).await { 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(); let coco_servers = server::servers::get_all_servers();
@@ -232,29 +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) crate::server::servers::try_register_server_to_search_source(app_handle.clone(), &server)
.await; .await;
} }
}
async fn init_app_search_source<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> { extension::built_in::pizza_engine_runtime::start_pizza_engine_runtime();
let application_search =
local::application::ApplicationSearchSource::new(app_handle.clone(), 1000f64).await?;
// Register the application search source
let registry = app_handle.state::<SearchSourceRegistry>();
registry.register_source(application_search).await;
Ok(())
} }
#[tauri::command] #[tauri::command]
async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) { async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
if let Some(window) = app_handle.get_window(MAIN_WINDOW_LABEL) { if let Some(window) = app_handle.get_window(MAIN_WINDOW_LABEL) {
let _ = app_handle.emit("show-coco", ());
move_window_to_active_monitor(&window); move_window_to_active_monitor(&window);
let _ = window.show(); let _ = window.show();
let _ = window.unminimize(); let _ = window.unminimize();
let _ = window.set_focus(); let _ = window.set_focus();
let _ = app_handle.emit("show-coco", ());
} }
} }
@@ -262,22 +281,22 @@ async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
async fn hide_coco<R: Runtime>(app: AppHandle<R>) { async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) { if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
if let Err(err) = window.hide() { if let Err(err) = window.hide() {
eprintln!("Failed to hide the window: {}", err); log::error!("Failed to hide the window: {}", err);
} else { } else {
println!("Window successfully hidden."); log::debug!("Window successfully hidden.");
} }
} else { } else {
eprintln!("Main window not found."); log::error!("Main window not found.");
} }
} }
fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) { 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 // Try to get the available monitors, handle failure gracefully
let available_monitors = match window.available_monitors() { let available_monitors = match window.available_monitors() {
Ok(monitors) => monitors, Ok(monitors) => monitors,
Err(e) => { Err(e) => {
eprintln!("Failed to get monitors: {}", e); log::error!("Failed to get monitors: {}", e);
return; return;
} }
}; };
@@ -286,7 +305,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
let cursor_position = match window.cursor_position() { let cursor_position = match window.cursor_position() {
Ok(pos) => Some(pos), Ok(pos) => Some(pos),
Err(e) => { Err(e) => {
eprintln!("Failed to get cursor position: {}", e); log::error!("Failed to get cursor position: {}", e);
None None
} }
}; };
@@ -315,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()) { let monitor = match target_monitor.or_else(|| window.primary_monitor().ok().flatten()) {
Some(monitor) => monitor, Some(monitor) => monitor,
None => { None => {
eprintln!("No monitor found!"); log::error!("No monitor found!");
return; return;
} }
}; };
@@ -325,7 +344,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
if let Some(ref prev_name) = *previous_monitor_name { if let Some(ref prev_name) = *previous_monitor_name {
if name.to_string() == *prev_name { if name.to_string() == *prev_name {
println!("Currently on the same monitor"); log::debug!("Currently on the same monitor");
return; return;
} }
@@ -339,7 +358,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
let window_size = match window.inner_size() { let window_size = match window.inner_size() {
Ok(size) => size, Ok(size) => size,
Err(e) => { Err(e) => {
eprintln!("Failed to get window size: {}", e); log::error!("Failed to get window size: {}", e);
return; return;
} }
}; };
@@ -353,11 +372,11 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
// Move the window to the new position // Move the window to the new position
if let Err(e) = window.set_position(PhysicalPosition::new(window_x, window_y)) { 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() { 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(); let mut previous_monitor = PREVIOUS_MONITOR_NAME.lock().unwrap();
*previous_monitor = Some(name.to_string()); *previous_monitor = Some(name.to_string());
@@ -367,7 +386,7 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
#[allow(dead_code)] #[allow(dead_code)]
fn open_settings(app: &tauri::AppHandle) { fn open_settings(app: &tauri::AppHandle) {
use tauri::webview::WebviewBuilder; use tauri::webview::WebviewBuilder;
println!("settings menu item was clicked"); log::debug!("settings menu item was clicked");
let window = app.get_webview_window("settings"); let window = app.get_webview_window("settings");
if let Some(window) = window { if let Some(window) = window {
let _ = window.show(); let _ = window.show();
@@ -398,7 +417,11 @@ fn open_settings(app: &tauri::AppHandle) {
#[tauri::command] #[tauri::command]
async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> { 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::connector::refresh_all_connectors(&app_handle).await;
let _ = server::datasource::refresh_all_datasources(&app_handle).await; let _ = server::datasource::refresh_all_datasources(&app_handle).await;
@@ -409,3 +432,170 @@ async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(
async fn show_settings(app_handle: AppHandle) { async fn show_settings(app_handle: AppHandle) {
open_settings(&app_handle); 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,163 +0,0 @@
use crate::common::document::{DataSourceReference, Document};
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
use crate::common::traits::{SearchError, SearchSource};
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
use applications::{AppInfo, AppInfoContext};
use async_trait::async_trait;
use base64::encode;
use fuzzy_prefix_search::Trie;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Runtime};
use tauri_plugin_fs_pro::{icon, name};
pub struct ApplicationSearchSource {
base_score: f64,
icons: HashMap<String, PathBuf>,
application_paths: Trie<String>,
}
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 mut ctx = AppInfoContext::new(vec![]);
ctx.refresh_apps().map_err(|err| err.to_string())?; // must refresh apps before getting them
let apps = ctx.get_all_apps();
for app in &apps {
if app.icon_path.is_none() {
continue;
}
let path = if cfg!(target_os = "macos") {
app.app_desktop_path.clone()
} else {
app.app_path_exe
.clone()
.unwrap_or(PathBuf::from("Path not found"))
};
let search_word = name(path.clone()).await;
let icon = icon(app_handle.clone(), path.clone(), Some(256))
.await
.map_err(|err| err.to_string())?;
let path_string = path.to_string_lossy().into_owned();
if search_word.is_empty() || search_word.eq("coco-ai") {
continue;
}
application_paths.insert(&search_word, path_string.clone());
icons.insert(path_string, icon);
}
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: "local_applications".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 mut results = self
.application_paths
.search_within_distance_scored(&query_string, 3);
// 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 file_name_str = result.word;
let file_path_str = result.data.get(0).unwrap().to_string();
let file_path = PathBuf::from(file_path_str.clone());
let cleaned_file_name = name(file_path).await;
total_hits += 1;
let mut doc = Document::new(
Some(DataSourceReference {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some("Applications".into()),
id: Some(file_name_str.clone()),
icon: None,
}),
file_path_str.clone(),
"Application".to_string(),
cleaned_file_name,
file_path_str.clone(),
);
// Attach icon if available
if let Some(icon_path) = self.icons.get(file_path_str.as_str()) {
// doc.icon = Some(format!("file://{}", icon_path.to_string_lossy()));
// dbg!(&doc.icon);
if let Ok(icon_data) = read_icon_and_encode(icon_path) {
doc.icon = Some(format!("data:image/png;base64,{}", icon_data));
}
}
hits.push((doc, self.base_score + result.score as f64));
}
}
Ok(QueryResponse {
source: self.get_type(),
hits,
total_hits,
})
}
}
// Function to read the icon file and convert it to base64
fn read_icon_and_encode(icon_path: &Path) -> Result<String, std::io::Error> {
// Read the icon file as binary data
let icon_data = fs::read(icon_path)?;
// Encode the data to base64
Ok(encode(&icon_data))
}

View File

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

View File

@@ -1,62 +1,157 @@
use crate::common::error::SearchError;
use crate::common::register::SearchSourceRegistry; use crate::common::register::SearchSourceRegistry;
use crate::common::search::{ use crate::common::search::{
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery, FailedRequest, MultiSourceQueryResponse, QueryHits, QueryResponse, QuerySource, SearchQuery,
}; };
use crate::common::traits::SearchError; use crate::common::traits::SearchSource;
use function_name::named;
use futures::stream::FuturesUnordered; use futures::stream::FuturesUnordered;
use futures::StreamExt; use futures::StreamExt;
use std::cmp::Reverse;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet; use std::collections::HashSet;
use std::future::Future;
use std::sync::Arc;
use tauri::{AppHandle, Manager, Runtime}; use tauri::{AppHandle, Manager, Runtime};
use tokio::time::error::Elapsed;
use tokio::time::{timeout, Duration}; 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] #[tauri::command]
pub async fn query_coco_fusion<R: Runtime>( pub async fn query_coco_fusion<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
from: u64, from: u64,
size: u64, size: u64,
query_strings: HashMap<String, String>, query_strings: HashMap<String, String>,
query_timeout: u64,
) -> Result<MultiSourceQueryResponse, SearchError> { ) -> Result<MultiSourceQueryResponse, SearchError> {
let query_keyword = query_strings
.get("query")
.unwrap_or(&"".to_string())
.clone();
let opt_query_source_id = query_strings.get("querysource");
let search_sources = app_handle.state::<SearchSourceRegistry>(); let search_sources = app_handle.state::<SearchSourceRegistry>();
let sources_future = search_sources.get_sources(); let sources_future = search_sources.get_sources();
let mut futures = FuturesUnordered::new(); 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 // Time limit for each query
let timeout_duration = Duration::from_millis(500); //TODO, settings let timeout_duration = Duration::from_millis(query_timeout);
// Push all queries into futures log::debug!(
for query_source in sources_list { "{}(): {:?}, timeout: {:?}",
let query_source_type = query_source.get_type().clone(); function_name!(),
sources.insert(query_source_type.id.clone(), query_source_type); query_strings,
timeout_duration
);
let query = SearchQuery::new(from, size, query_strings.clone()); let search_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 { if let Some(query_source_id) = opt_query_source_id {
// Timeout each query execution // If this query source ID is specified, we only query this query source.
timeout(timeout_duration, async { log::debug!(
query_source_clone.search(query).await "parameter [querysource={}] specified, will only query this querysource",
}) query_source_id
.await );
}));
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(),
));
}
} }
let mut total_hits = 0; 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 failed_requests = Vec::new();
let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::new(); let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::new();
let mut hits_per_source: HashMap<String, Vec<(QueryHits, f64)>> = HashMap::new(); let mut hits_per_source: HashMap<String, Vec<(QueryHits, f64)>> = HashMap::new();
while let Some(result) = futures.next().await { if sources_list_len > 1 {
match result { need_rerank = true; // If we have more than one source, we need to rerank the hits
Ok(Ok(Ok(response))) => { }
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; total_hits += response.total_hits;
let source_id = response.source.id.clone(); let source_id = response.source.id.clone();
for (doc, score) in response.hits { for (doc, score) in response.hits {
log::debug!("doc: {}, {:?}, {}", doc.id, doc.title, score);
let query_hit = QueryHits { let query_hit = QueryHits {
source: Some(response.source.clone()), source: Some(response.source.clone()),
score, score,
@@ -71,49 +166,26 @@ pub async fn query_coco_fusion<R: Runtime>(
.push((query_hit, score)); .push((query_hit, score));
} }
} }
Ok(Err(err)) => { Err(search_error) => {
log::error!(
"searching query source [{}] failed, error [{}]",
query_source.id,
search_error
);
failed_requests.push(FailedRequest { failed_requests.push(FailedRequest {
source: QuerySource { source: query_source,
r#type: "N/A".into(),
name: "N/A".into(),
id: "N/A".into(),
},
status: 0, status: 0,
error: Some(err.to_string()), error: Some(search_error.to_string()),
reason: None, 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,
});
}
} }
} }
// Sort hits within each source by score (descending) // Sort hits within each source by score (descending)
for hits in hits_per_source.values_mut() { 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(); let total_sources = hits_per_source.len();
@@ -129,16 +201,71 @@ pub async fn query_coco_fusion<R: Runtime>(
// Distribute hits fairly across sources // Distribute hits fairly across sources
for (_source_id, hits) in &mut hits_per_source { for (_source_id, hits) in &mut hits_per_source {
let take_count = hits.len().min(max_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) { if !seen_docs.contains(&doc.document.id) {
seen_docs.insert(doc.document.id.clone()); seen_docs.insert(doc.document.id.clone());
log::debug!(
"collect doc: {}, {:?}, {}",
doc.document.id,
doc.document.title,
score
);
final_hits.push(doc); final_hits.push(doc);
} }
} }
} }
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 // If we still need more hits, take the highest-scoring remaining ones
if final_hits.len() < size as usize {
let remaining_needed = size as usize - final_hits.len(); let remaining_needed = size as usize - final_hits.len();
// Sort all hits by score descending, removing duplicates by document ID // Sort all hits by score descending, removing duplicates by document ID
@@ -168,9 +295,45 @@ pub async fn query_coco_fusion<R: Runtime>(
.unwrap_or(std::cmp::Ordering::Equal) .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 { Ok(MultiSourceQueryResponse {
failed: failed_requests, failed: failed_requests,
hits: final_hits, hits: final_hits,
total_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

@@ -1,4 +1,5 @@
use super::servers::{get_server_by_id, get_server_token}; use super::servers::{get_server_by_id, get_server_token};
use crate::common::http::get_response_body_text;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use reqwest::multipart::{Form, Part}; use reqwest::multipart::{Form, Part};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -29,24 +30,24 @@ pub struct AttachmentSource {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct AttachmentHit { pub struct AttachmentHit {
pub _index: String, pub _index: String,
pub _type: String, pub _type: Option<String>,
pub _id: String, pub _id: String,
pub _score: f64, pub _score: Option<f64>,
pub _source: AttachmentSource, pub _source: AttachmentSource,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct AttachmentHits { pub struct AttachmentHits {
pub total: Value, pub total: Value,
pub max_score: f64, pub max_score: Option<f64>,
pub hits: Vec<AttachmentHit>, pub hits: Option<Vec<AttachmentHit>>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct GetAttachmentResponse { pub struct GetAttachmentResponse {
pub took: u32, pub took: u32,
pub timed_out: bool, pub timed_out: bool,
pub _shards: Value, pub _shards: Option<Value>,
pub hits: AttachmentHits, pub hits: AttachmentHits,
} }
@@ -99,16 +100,10 @@ pub async fn upload_attachment(
.await .await
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
if response.status().is_success() { let body = get_response_body_text(response).await?;
let result = response
.json::<UploadAttachmentResponse>()
.await
.map_err(|err| err.to_string())?;
Ok(result) serde_json::from_str::<UploadAttachmentResponse>(&body)
} else { .map_err(|e| format!("Failed to parse upload response: {}", e))
Err(format!("Upload failed with status: {}", response.status()))
}
} }
#[command] #[command]
@@ -119,33 +114,30 @@ pub async fn get_attachment(
let mut query_params = HashMap::new(); let mut query_params = HashMap::new();
query_params.insert("session".to_string(), serde_json::Value::String(session_id)); query_params.insert("session".to_string(), serde_json::Value::String(session_id));
let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params)).await?; let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params))
if response.status().is_success() {
response
.json::<GetAttachmentResponse>()
.await .await
.map_err(|e| e.to_string()) .map_err(|e| format!("Request error: {}", e))?;
} else {
Err(format!("Request failed with status: {}", response.status())) let body = get_response_body_text(response).await?;
}
serde_json::from_str::<GetAttachmentResponse>(&body)
.map_err(|e| format!("Failed to parse attachment response: {}", e))
} }
#[command] #[command]
pub async fn delete_attachment(server_id: String, id: String) -> Result<bool, String> { pub async fn delete_attachment(server_id: String, id: String) -> Result<bool, String> {
let response = let response = HttpClient::delete(&server_id, &format!("/attachment/{}", id), None, None)
HttpClient::delete(&server_id, &format!("/attachment/{}", id), None, None).await?;
if response.status().is_success() {
response
.json::<DeleteAttachmentResponse>()
.await .await
.map_err(|e| e.to_string())? .map_err(|e| format!("Request error: {}", e))?;
let body = get_response_body_text(response).await?;
let parsed: DeleteAttachmentResponse = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse delete response: {}", e))?;
parsed
.result .result
.eq("deleted") .eq("deleted")
.then_some(true) .then_some(true)
.ok_or("Delete operation was not successful".to_string()) .ok_or_else(|| "Delete operation was not successful".to_string())
} else {
Err(format!("Delete failed with status: {}", response.status()))
}
} }

View File

@@ -1,11 +1,12 @@
use crate::common::auth::RequestAccessTokenResponse;
use crate::common::server::ServerAccessToken; use crate::common::server::ServerAccessToken;
use crate::server::http_client::HttpClient;
use crate::server::profile::get_user_profiles; use crate::server::profile::get_user_profiles;
use crate::server::servers::{get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server, try_register_server_to_search_source}; use crate::server::servers::{
use reqwest::StatusCode; get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server,
use std::collections::HashMap; try_register_server_to_search_source,
};
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime};
#[allow(dead_code)]
fn request_access_token_url(request_id: &str) -> String { fn request_access_token_url(request_id: &str) -> String {
// Remove the endpoint part and keep just the path for the request // Remove the endpoint part and keep just the path for the request
format!("/auth/request_access_token?request_id={}", request_id) format!("/auth/request_access_token?request_id={}", request_id)
@@ -21,33 +22,10 @@ pub async fn handle_sso_callback<R: Runtime>(
// Retrieve the server details using the server ID // Retrieve the server details using the server ID
let server = get_server_by_id(&server_id); let server = get_server_by_id(&server_id);
let expire_in = 3600; // TODO, need to update to actual expire_in value
if let Some(mut server) = server { if let Some(mut server) = server {
// Prepare the URL for requesting the access token (endpoint is base URL, path is relative)
// save_access_token(server_id.clone(), ServerAccessToken::new(server_id.clone(), code.clone(), 60 * 15));
let path = request_access_token_url(&request_id);
// Send the request for the access token using the util::http::HttpClient::get method
let mut header = HashMap::new();
header.insert("Authorization".to_string(), format!("Bearer {}", code).to_string());
let response = HttpClient::advanced_post(&server_id, &path, Some(header), None, None)
.await
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
if response.status() == StatusCode::OK {
// Check if the response has a valid content length
if let Some(content_length) = response.content_length() {
if content_length > 0 {
// Deserialize the response body to get the access token
let token_result: Result<RequestAccessTokenResponse, _> = response.json().await;
match token_result {
Ok(token) => {
// Save the access token for the server // Save the access token for the server
let access_token = ServerAccessToken::new( let access_token = ServerAccessToken::new(server_id.clone(), code.clone(), expire_in);
server_id.clone(),
token.access_token.clone(),
token.expire_in,
);
// dbg!(&server_id, &request_id, &code, &token); // dbg!(&server_id, &request_id, &code, &token);
save_access_token(server_id.clone(), access_token); save_access_token(server_id.clone(), access_token);
persist_servers_token(&app_handle)?; persist_servers_token(&app_handle)?;
@@ -69,24 +47,6 @@ pub async fn handle_sso_callback<R: Runtime>(
} }
Err(e) => Err(format!("Failed to get user profile: {}", e)), Err(e) => Err(format!("Failed to get user profile: {}", e)),
} }
}
Err(e) => Err(format!("Failed to deserialize the token response: {}", e)),
}
} else {
Err("Received empty response body.".to_string())
}
} else {
Err("Could not determine the content length.".to_string())
}
} else {
Err(format!(
"Request failed with status: {}, URL: {}, Code: {}, Response: {:?}",
response.status(),
path,
code,
response
))
}
} else { } else {
Err(format!( Err(format!(
"Server not found for ID: {}, Request ID: {}, Code: {}", "Server not found for ID: {}, Request ID: {}, Code: {}",

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 // Collect all the tasks for fetching and refreshing connectors
let mut server_map = HashMap::new(); let mut server_map = HashMap::new();
for server in servers { for server in servers {
if !server.enabled {
continue;
}
// dbg!("start fetch connectors for server: {}", &server.id); // dbg!("start fetch connectors for server: {}", &server.id);
let connectors = match get_connectors_by_server(app_handle.clone(), server.id.clone()).await let connectors = match get_connectors_by_server(app_handle.clone(), server.id.clone()).await
{ {

View File

@@ -4,10 +4,18 @@ use crate::server::connector::get_connector_by_id;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use crate::server::servers::get_all_servers; use crate::server::servers::get_all_servers;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime};
#[derive(serde::Deserialize, Debug)]
pub struct GetDatasourcesByServerOptions {
pub from: Option<u32>,
pub size: Option<u32>,
pub query: Option<serde_json::Value>,
}
lazy_static! { lazy_static! {
static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String, HashMap<String, DataSource>>>> = static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String, HashMap<String, DataSource>>>> =
Arc::new(RwLock::new(HashMap::new())); Arc::new(RwLock::new(HashMap::new()));
@@ -40,8 +48,12 @@ pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) ->
for server in servers { for server in servers {
// dbg!("fetch datasources for server: {}", &server.id); // dbg!("fetch datasources for server: {}", &server.id);
if !server.enabled {
continue;
}
// Attempt to get datasources by server, and continue even if it fails // Attempt to get datasources by server, and continue even if it fails
let connectors = match get_datasources_by_server(server.id.as_str()).await { let connectors = match datasource_search(server.id.as_str(), None).await {
Ok(connectors) => { Ok(connectors) => {
// Process connectors only after fetching them // Process connectors only after fetching them
let connectors_map: HashMap<String, DataSource> = connectors let connectors_map: HashMap<String, DataSource> = connectors
@@ -83,17 +95,35 @@ pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) ->
} }
#[tauri::command] #[tauri::command]
pub async fn get_datasources_by_server(id: &str) -> Result<Vec<DataSource>, String> { pub async fn datasource_search(
id: &str,
options: Option<GetDatasourcesByServerOptions>,
) -> 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 mut body = serde_json::json!({
"from": from,
"size": size,
});
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 // Perform the async HTTP request outside the cache lock
let resp = HttpClient::get(id, "/datasource/_search", None) let resp = HttpClient::post(
id,
"/datasource/_search",
None,
Some(reqwest::Body::from(body.to_string())),
)
.await .await
.map_err(|e| { .map_err(|e| format!("Error fetching datasource: {}", e))?;
format!("Error fetching datasource: {}", e)
})?;
// Parse the search results from the response // Parse the search results from the response
let datasources: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| { 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() e.to_string()
})?; })?;
@@ -102,3 +132,41 @@ pub async fn get_datasources_by_server(id: &str) -> Result<Vec<DataSource>, Stri
Ok(datasources) Ok(datasources)
} }
#[tauri::command]
pub async fn mcp_server_search(
id: &str,
from: u32,
size: u32,
query: Option<HashMap<String, Value>>,
) -> Result<Vec<DataSource>, String> {
let mut body = serde_json::json!({
"from": from,
"size": size,
});
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
let resp = HttpClient::post(
id,
"/mcp_server/_search",
None,
Some(reqwest::Body::from(body.to_string())),
)
.await
.map_err(|e| format!("Error fetching datasource: {}", e))?;
// Parse the search results from the response
let mcp_server: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
//dbg!("Error parsing search results: {}", &e);
e.to_string()
})?;
// Save the updated mcp_server to cache
// save_datasource_to_cache(&id, mcp_server.clone());
Ok(mcp_server)
}

View File

@@ -1,21 +1,30 @@
use crate::server::servers::{get_server_by_id, get_server_token}; use crate::server::servers::{get_server_by_id, get_server_token};
use http::HeaderName; use http::{HeaderName, HeaderValue};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use reqwest::{Client, Method, RequestBuilder}; use reqwest::{Client, Method, RequestBuilder};
use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use tauri_plugin_store::JsonValue;
use tokio::sync::Mutex; use tokio::sync::Mutex;
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| { pub(crate) fn new_reqwest_http_client(accept_invalid_certs: bool) -> Client {
let client = Client::builder() Client::builder()
.read_timeout(Duration::from_secs(3)) // Set a timeout of 3 second .read_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
.connect_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 .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() .build()
.expect("Failed to build client"); .expect("Failed to build client")
Mutex::new(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; pub struct HttpClient;
@@ -31,17 +40,33 @@ impl HttpClient {
pub async fn send_raw_request( pub async fn send_raw_request(
method: Method, method: Method,
url: &str, url: &str,
query_params: Option<HashMap<String, Value>>, query_params: Option<HashMap<String, JsonValue>>,
headers: Option<HashMap<String, String>>, headers: Option<HashMap<String, String>>,
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
log::debug!(
"Sending Request: {}, query_params: {:?}, header: {:?}, body: {:?}",
&url,
&query_params,
&headers,
&body
);
let request_builder = let request_builder =
Self::get_request_builder(method, url, headers, query_params, body).await; Self::get_request_builder(method, url, headers, query_params, body).await;
let response = request_builder let response = request_builder.send().await.map_err(|e| {
.send() //dbg!("Failed to send request: {}", &e);
.await format!("Failed to send request: {}", e)
.map_err(|e| format!("Failed to send request: {}", e))?; })?;
log::debug!(
"Request: {}, Response status: {:?}, header: {:?}",
&url,
&response.status(),
&response.headers()
);
Ok(response) Ok(response)
} }
@@ -49,7 +74,7 @@ impl HttpClient {
method: Method, method: Method,
url: &str, url: &str,
headers: Option<HashMap<String, String>>, headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> RequestBuilder { ) -> RequestBuilder {
let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT
@@ -60,17 +85,48 @@ impl HttpClient {
if let Some(h) = headers { if let Some(h) = headers {
let mut req_headers = reqwest::header::HeaderMap::new(); let mut req_headers = reqwest::header::HeaderMap::new();
for (key, value) in h.into_iter() { for (key, value) in h.into_iter() {
let _ = req_headers.insert( match (
HeaderName::from_bytes(key.as_bytes()).unwrap(), HeaderName::from_bytes(key.as_bytes()),
reqwest::header::HeaderValue::from_str(&value).unwrap(), HeaderValue::from_str(value.trim()),
) {
(Ok(name), Ok(val)) => {
req_headers.insert(name, val);
}
(Err(e), _) => {
eprintln!("Invalid header name: {:?}, error: {}", key, e);
}
(_, Err(e)) => {
eprintln!(
"Invalid header value for {}: {:?}, error: {}",
key, value, e
); );
} }
}
}
request_builder = request_builder.headers(req_headers); request_builder = request_builder.headers(req_headers);
} }
if let Some(query) = query_params { if let Some(query) = query_params {
// Convert only supported value types into strings
let query: HashMap<String, String> = query
.into_iter()
.filter_map(|(k, v)| {
match v {
JsonValue::String(s) => Some((k, s)),
JsonValue::Number(n) => Some((k, n.to_string())),
JsonValue::Bool(b) => Some((k, b.to_string())),
_ => {
dbg!(
"Unsupported query parameter type. Only strings, numbers, and booleans are supported.",k,v,
);
None
} // skip arrays, objects, nulls
}
})
.collect();
request_builder = request_builder.query(&query); request_builder = request_builder.query(&query);
} }
// Add body if present // Add body if present
if let Some(b) = body { if let Some(b) = body {
request_builder = request_builder.body(b); request_builder = request_builder.body(b);
@@ -84,7 +140,7 @@ impl HttpClient {
method: Method, method: Method,
path: &str, path: &str,
custom_headers: Option<HashMap<String, String>>, custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, query_params: Option<HashMap<String, JsonValue>>,
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
// Fetch the server using the server_id // Fetch the server using the server_id
@@ -109,9 +165,12 @@ impl HttpClient {
headers.insert("X-API-TOKEN".to_string(), t); headers.insert("X-API-TOKEN".to_string(), t);
} }
// dbg!(&server_id); // log::debug!(
// dbg!(&url); // "Sending request to server: {}, url: {}, headers: {:?}",
// dbg!(&headers); // &server_id,
// &url,
// &headers
// );
Self::send_raw_request(method, &url, query_params, Some(headers), body).await Self::send_raw_request(method, &url, query_params, Some(headers), body).await
} else { } else {
@@ -123,7 +182,7 @@ impl HttpClient {
pub async fn get( pub async fn get(
server_id: &str, server_id: &str,
path: &str, path: &str,
query_params: Option<HashMap<String, Value>>, // Add query parameters query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::GET, path, None, query_params, None).await HttpClient::send_request(server_id, Method::GET, path, None, query_params, None).await
} }
@@ -132,7 +191,7 @@ impl HttpClient {
pub async fn post( pub async fn post(
server_id: &str, server_id: &str,
path: &str, path: &str,
query_params: Option<HashMap<String, Value>>, // Add query parameters query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::POST, path, None, query_params, body).await HttpClient::send_request(server_id, Method::POST, path, None, query_params, body).await
@@ -142,7 +201,7 @@ impl HttpClient {
server_id: &str, server_id: &str,
path: &str, path: &str,
custom_headers: Option<HashMap<String, String>>, custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request( HttpClient::send_request(
@@ -162,7 +221,7 @@ impl HttpClient {
server_id: &str, server_id: &str,
path: &str, path: &str,
custom_headers: Option<HashMap<String, String>>, custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request( HttpClient::send_request(
@@ -182,7 +241,7 @@ impl HttpClient {
server_id: &str, server_id: &str,
path: &str, path: &str,
custom_headers: Option<HashMap<String, String>>, custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request( HttpClient::send_request(
server_id, server_id,

View File

@@ -8,5 +8,6 @@ pub mod http_client;
pub mod profile; pub mod profile;
pub mod search; pub mod search;
pub mod servers; pub mod servers;
pub mod system_settings;
pub mod transcription; pub mod transcription;
pub mod websocket; pub mod websocket;

View File

@@ -1,3 +1,4 @@
use crate::common::http::get_response_body_text;
use crate::common::profile::UserProfile; use crate::common::profile::UserProfile;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime};
@@ -12,15 +13,17 @@ pub async fn get_user_profiles<R: Runtime>(
.await .await
.map_err(|e| format!("Error fetching profile: {}", e))?; .map_err(|e| format!("Error fetching profile: {}", e))?;
if let Some(content_length) = response.content_length() { // Use get_response_body_text to extract the body content
if content_length > 0 { let response_body = get_response_body_text(response)
let profile: UserProfile = response
.json()
.await .await
.map_err(|e| format!("Failed to read response body: {}", e))?;
// Check if the response body is not empty before deserializing
if !response_body.is_empty() {
let profile: UserProfile = serde_json::from_str(&response_body)
.map_err(|e| format!("Failed to parse response: {}", e))?; .map_err(|e| format!("Failed to parse response: {}", e))?;
return Ok(profile); return Ok(profile);
} }
}
Err("Profile not found or empty response".to_string()) Err("Profile not found or empty response".to_string())
} }

View File

@@ -1,16 +1,15 @@
use crate::common::document::Document; use crate::common::document::{Document, OnOpened};
use crate::common::search::{ use crate::common::error::SearchError;
parse_search_response, QueryHits, QueryResponse, QuerySource, SearchQuery, 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::server::Server;
use crate::common::traits::{SearchError, SearchSource}; use crate::common::traits::SearchSource;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use crate::server::servers::get_server_token;
use async_trait::async_trait; use async_trait::async_trait;
// use futures::stream::StreamExt; // use futures::stream::StreamExt;
use ordered_float::OrderedFloat; use ordered_float::OrderedFloat;
use reqwest::{Client, Method, RequestBuilder};
use std::collections::HashMap; use std::collections::HashMap;
use tauri_plugin_store::JsonValue;
// use std::hash::Hash; // use std::hash::Hash;
#[allow(dead_code)] #[allow(dead_code)]
@@ -46,7 +45,7 @@ impl DocumentsSizedCollector {
} }
} }
fn documents(self) -> impl ExactSizeIterator<Item = Document> { fn documents(self) -> impl ExactSizeIterator<Item=Document> {
self.docs.into_iter().map(|(_, doc, _)| doc) self.docs.into_iter().map(|(_, doc, _)| doc)
} }
@@ -74,45 +73,11 @@ const COCO_SERVERS: &str = "coco-servers";
pub struct CocoSearchSource { pub struct CocoSearchSource {
server: Server, server: Server,
client: Client,
} }
impl CocoSearchSource { impl CocoSearchSource {
pub fn new(server: Server, client: Client) -> Self { pub fn new(server: Server) -> Self {
CocoSearchSource { server, client } CocoSearchSource { server }
}
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)
} }
} }
@@ -126,58 +91,62 @@ impl SearchSource for CocoSearchSource {
} }
} }
// Directly return Result<QueryResponse, SearchError> instead of Future
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> { async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
let _server_id = self.server.id.clone(); let url = "/query/_search";
let _server_name = self.server.name.clone(); let mut total_hits = 0;
let request_builder = self.build_request_from_query(&query).await.unwrap(); let mut hits: Vec<(Document, f64)> = Vec::new();
// Send the HTTP request asynchronously let mut query_args: HashMap<String, JsonValue> = HashMap::new();
let response = request_builder.send().await; 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));
}
match response { let response = HttpClient::get(&self.server.id, &url, Some(query_args))
Ok(response) => { .await
let status_code = response.status().as_u16(); .map_err(|e| SearchError::HttpError(format!("{}", e)))?;
if status_code >= 200 && status_code < 400 { // Use the helper function to parse the response body
// Parse the response only if the status code is successful let response_body = get_response_body_text(response)
match parse_search_response(response).await { .await
Ok(response) => { .map_err(|e| SearchError::ParseError(e))?;
let total_hits = response.hits.total.value as usize;
let hits: Vec<(Document, f64)> = response
.hits
.hits
.into_iter()
.map(|hit| {
// Handling Option<f64> in hit._score by defaulting to 0.0 if None
(hit._source, hit._score.unwrap_or(0.0)) // Use 0.0 if _score is None
})
.collect();
// Return the QueryResponse with hits and total hits // Check if the response body is empty
if !response_body.is_empty() {
// log::info!("Search response body: {}", &response_body);
// 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 { Ok(QueryResponse {
source: self.get_type(), source: self.get_type(),
hits, hits,
total_hits, total_hits,
}) })
} }
Err(err) => {
// Parse error when response parsing fails
Err(SearchError::ParseError(err.to_string()))
}
}
} else {
// Handle unsuccessful HTTP status codes (e.g., 4xx, 5xx)
Err(SearchError::HttpError(format!(
"Request failed with status code: {}",
status_code
)))
}
}
Err(err) => {
// Handle error from the request itself
Err(SearchError::HttpError(err.to_string()))
}
}
}
} }

View File

@@ -1,12 +1,13 @@
use crate::common::http::get_response_body_text;
use crate::common::register::SearchSourceRegistry; use crate::common::register::SearchSourceRegistry;
use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version}; use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version};
use crate::server::connector::fetch_connectors_by_server; use crate::server::connector::fetch_connectors_by_server;
use crate::server::datasource::get_datasources_by_server; use crate::server::datasource::datasource_search;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use crate::server::search::CocoSearchSource; use crate::server::search::CocoSearchSource;
use crate::COCO_TAURI_STORE; use crate::COCO_TAURI_STORE;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use reqwest::{Client, Method, StatusCode}; use reqwest::Method;
use serde_json::from_value; use serde_json::from_value;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use std::collections::HashMap; use std::collections::HashMap;
@@ -58,7 +59,7 @@ pub fn save_server(server: &Server) -> bool {
} }
fn remove_server_by_id(id: String) -> 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 mut cache = SERVER_CACHE.write().unwrap();
let deleted = cache.remove(id.as_str()); let deleted = cache.remove(id.as_str());
deleted.is_some() deleted.is_some()
@@ -86,7 +87,7 @@ pub async fn persist_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<()
} }
pub fn remove_server_token(id: &str) -> bool { 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(); let mut cache = SERVER_TOKEN.write().unwrap();
cache.remove(id).is_some() cache.remove(id).is_some()
} }
@@ -103,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 .map(|server| serde_json::to_value(server).expect("Failed to serialize access_tokens")) // Automatically serialize all fields
.collect(); .collect();
dbg!(format!("persist servers token: {:?}", &json_servers)); log::debug!("persist servers token: {:?}", &json_servers);
// Save the serialized servers to Tauri's store // Save the serialized servers to Tauri's store
app_handle app_handle
@@ -142,17 +143,18 @@ fn get_default_server() -> Server {
profile: None, profile: None,
auth_provider: AuthProvider { auth_provider: AuthProvider {
sso: Sso { 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, priority: 0,
stats: None,
} }
} }
pub async fn load_servers_token<R: Runtime>( pub async fn load_servers_token<R: Runtime>(
app_handle: &AppHandle<R>, app_handle: &AppHandle<R>,
) -> Result<Vec<ServerAccessToken>, String> { ) -> Result<Vec<ServerAccessToken>, String> {
dbg!("Attempting to load servers token"); log::debug!("Attempting to load servers token");
let store = app_handle let store = app_handle
.store(COCO_TAURI_STORE) .store(COCO_TAURI_STORE)
@@ -186,10 +188,7 @@ pub async fn load_servers_token<R: Runtime>(
save_access_token(server.id.clone(), server.clone()); save_access_token(server.id.clone(), server.clone());
} }
dbg!(format!( log::debug!("loaded {:?} servers's token", &deserialized_tokens.len());
"loaded {:?} servers's token",
&deserialized_tokens.len()
));
Ok(deserialized_tokens) Ok(deserialized_tokens)
} else { } else {
@@ -230,7 +229,7 @@ pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<S
save_server(&server); save_server(&server);
} }
// dbg!(format!("load servers: {:?}", &deserialized_servers)); log::debug!("load servers: {:?}", &deserialized_servers);
Ok(deserialized_servers) Ok(deserialized_servers)
} else { } else {
@@ -242,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>( pub async fn load_or_insert_default_server<R: Runtime>(
app_handle: &AppHandle<R>, app_handle: &AppHandle<R>,
) -> Result<Vec<Server>, String> { ) -> 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; let exists_servers = load_servers(&app_handle).await;
if exists_servers.is_ok() && !exists_servers.as_ref()?.is_empty() { 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; return exists_servers;
} }
let default = get_default_server(); let default = get_default_server();
save_server(&default); save_server(&default);
dbg!("loaded default servers"); log::debug!("loaded default servers");
Ok(vec![default]) Ok(vec![default])
} }
@@ -298,61 +297,66 @@ pub async fn refresh_coco_server_info<R: Runtime>(
id: String, id: String,
) -> Result<Server, String> { ) -> Result<Server, String> {
// Retrieve the server from the cache // Retrieve the server from the cache
let server = { let cached_server = {
let cache = SERVER_CACHE.read().unwrap(); let cache = SERVER_CACHE.read().unwrap();
cache.get(&id).cloned() cache.get(&id).cloned()
}; };
if let Some(server) = server { let server = match cached_server {
Some(server) => server,
None => return Err("Server not found.".into()),
};
// Preserve important local state
let is_enabled = server.enabled; let is_enabled = server.enabled;
let is_builtin = server.builtin; let is_builtin = server.builtin;
let profile = server.profile; let profile = server.profile;
// Use the HttpClient to send the request // Send request to fetch updated server info
let response = HttpClient::get(&id, "/provider/_info", None) // Assuming "/provider-info" is the endpoint let response = HttpClient::get(&id, "/provider/_info", None)
.await .await
.map_err(|e| format!("Failed to send request to the server: {}", e))?; .map_err(|e| {
format!("Failed to contact the server: {}", e)
});
if response.status() == StatusCode::OK { if response.is_err() {
if let Some(content_length) = response.content_length() { let _ = mark_server_as_offline(app_handle, &id).await;
if content_length > 0 { return Err(response.err().unwrap());
let new_coco_server: Result<Server, _> = response.json().await; }
match new_coco_server {
Ok(mut server) => { let response = response?;
server.id = id.clone();
server.builtin = is_builtin; if !response.status().is_success() {
server.enabled = is_enabled; let _ = mark_server_as_offline(app_handle, &id).await;
server.available = true; return Err(format!("Request failed with status: {}", response.status()));
server.profile = profile; }
trim_endpoint_last_forward_slash(&mut server);
save_server(&server); // Get body text via helper
let body = get_response_body_text(response).await?;
// Deserialize server
let mut updated_server: Server = serde_json::from_str(&body)
.map_err(|e| format!("Failed to deserialize the response: {}", e))?;
// Restore local state
updated_server.id = id.clone();
updated_server.builtin = is_builtin;
updated_server.enabled = is_enabled;
updated_server.available = true;
updated_server.profile = profile;
trim_endpoint_last_forward_slash(&mut updated_server);
// Save and persist
save_server(&updated_server);
persist_servers(&app_handle) persist_servers(&app_handle)
.await .await
.expect("Failed to persist coco servers."); .map_err(|e| format!("Failed to persist servers: {}", e))?;
//refresh connectors and datasources // Refresh connectors and datasources (best effort)
let _ = fetch_connectors_by_server(&id).await; let _ = fetch_connectors_by_server(&id).await;
let _ = datasource_search(&id, None).await;
let _ = get_datasources_by_server(&id).await; Ok(updated_server)
Ok(server)
}
Err(e) => Err(format!("Failed to deserialize the response: {:?}", e)),
}
} else {
Err("Received empty response body.".to_string())
}
} else {
mark_server_as_offline(id.as_str()).await;
Err("Could not determine the content length.".to_string())
}
} else {
mark_server_as_offline(id.as_str()).await;
Err(format!("Request failed with status: {}", response.status()))
}
} else {
Err("Server not found.".to_string())
}
} }
#[tauri::command] #[tauri::command]
@@ -362,38 +366,30 @@ pub async fn add_coco_server<R: Runtime>(
) -> Result<Server, String> { ) -> Result<Server, String> {
load_or_insert_default_server(&app_handle) load_or_insert_default_server(&app_handle)
.await .await
.expect("Failed to load default servers"); .map_err(|e| format!("Failed to load default servers: {}", e))?;
// Remove the trailing '/' from the endpoint to ensure correct URL construction
let endpoint = endpoint.trim_end_matches('/'); let endpoint = endpoint.trim_end_matches('/');
// Check if the server with this endpoint already exists
if check_endpoint_exists(endpoint) { if check_endpoint_exists(endpoint) {
dbg!(format!( log::debug!(
"This Coco server has already been registered: {:?}", "This Coco server has already been registered: {:?}",
&endpoint &endpoint
)); );
return Err("This Coco server has already been registered.".into()); return Err("This Coco server has already been registered.".into());
} }
let url = provider_info_url(&endpoint); let url = provider_info_url(endpoint);
// Use the HttpClient to fetch provider information
let response = HttpClient::send_raw_request(Method::GET, url.as_str(), None, None, None) let response = HttpClient::send_raw_request(Method::GET, url.as_str(), None, None, None)
.await .await
.map_err(|e| format!("Failed to send request to the server: {}", e))?; .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);
// Check if the response status is OK (200) let body = get_response_body_text(response).await?;
if response.status() == StatusCode::OK {
if let Some(content_length) = response.content_length() { let mut server: Server = serde_json::from_str(&body)
if content_length > 0 { .map_err(|e| format!("Failed to deserialize the response: {}", e))?;
let new_coco_server: Result<Server, _> = response.json().await;
match new_coco_server {
Ok(mut server) => {
// Perform necessary checks and adjustments on the server data
trim_endpoint_last_forward_slash(&mut server); trim_endpoint_last_forward_slash(&mut server);
if server.id.is_empty() { if server.id.is_empty() {
@@ -401,34 +397,18 @@ pub async fn add_coco_server<R: Runtime>(
} }
if server.name.is_empty() { if server.name.is_empty() {
server.name = "Coco Cloud".to_string(); server.name = "Coco Server".to_string();
} }
// Save the new server to the cache
save_server(&server); save_server(&server);
// Register the server to the search source
try_register_server_to_search_source(app_handle.clone(), &server).await; try_register_server_to_search_source(app_handle.clone(), &server).await;
// Persist the servers to the store
persist_servers(&app_handle) persist_servers(&app_handle)
.await .await
.expect("Failed to persist Coco servers."); .map_err(|e| format!("Failed to persist Coco servers: {}", e))?;
dbg!(format!("Successfully registered server: {:?}", &endpoint)); log::debug!("Successfully registered server: {:?}", &endpoint);
Ok(server) Ok(server)
}
Err(e) => Err(format!("Failed to deserialize the response: {}", e)),
}
} else {
Err("Received empty response body.".to_string())
}
} else {
Err("Could not determine the content length.".to_string())
}
} else {
Err(format!("Request failed with status: {}", response.status()))
}
} }
#[tauri::command] #[tauri::command]
@@ -473,26 +453,46 @@ pub async fn try_register_server_to_search_source(
server: &Server, server: &Server,
) { ) {
if server.enabled { 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 registry = app_handle.state::<SearchSourceRegistry>();
let source = CocoSearchSource::new(server.clone(), Client::new()); let source = CocoSearchSource::new(server.clone());
registry.register_source(source).await; 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); // println!("server_is_offline: {}", id);
let server = get_server_by_id(id); let server = get_server_by_id(id);
if let Some(mut server) = server { if let Some(mut server) = server {
server.available = false; server.available = false;
server.health = None; server.health = None;
save_server(&server); save_server(&server);
let registry = app_handle.state::<SearchSourceRegistry>();
registry.remove_source(id).await;
} }
Ok(())
} }
#[tauri::command] #[tauri::command]
pub async fn disable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) -> Result<(), ()> { 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()); let server = get_server_by_id(id.as_str());
if let Some(mut server) = server { if let Some(mut server) = server {
server.enabled = false; server.enabled = false;
@@ -513,47 +513,48 @@ pub async fn logout_coco_server<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
id: String, id: String,
) -> Result<(), 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 // Check if server token exists
if let Some(_token) = get_server_token(id.as_str()).await? { 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 the server token from cache
remove_server_token(id.as_str()); remove_server_token(id.as_str());
// Persist the updated tokens // Persist the updated tokens
if let Err(e) = persist_servers_token(&app_handle) { 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)); return Err(format!("Failed to save tokens: {}", &e));
} }
} else { } else {
// Log the case where server token is not found // 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 // Check if the server exists
if let Some(mut server) = get_server_by_id(id.as_str()) { 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 // Clear server profile
server.profile = None; server.profile = None;
let _ = mark_server_as_offline(app_handle.clone(), id.as_str()).await;
// Save the updated server data // Save the updated server data
save_server(&server); save_server(&server);
// Persist the updated server data // Persist the updated server data
if let Err(e) = persist_servers(&app_handle).await { 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)); return Err(format!("Failed to save server: {}", &e));
} }
} else { } else {
// Log the case where server is not found // 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)); 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(()) Ok(())
} }
@@ -604,6 +605,7 @@ fn test_trim_endpoint_last_forward_slash() {
}, },
}, },
priority: 0, priority: 0,
stats: None,
}; };
trim_endpoint_last_forward_slash(&mut server); trim_endpoint_last_forward_slash(&mut server);

View File

@@ -0,0 +1,15 @@
use crate::server::http_client::HttpClient;
use serde_json::Value;
use tauri::command;
#[command]
pub async fn get_system_settings(server_id: String) -> Result<Value, String> {
let response = HttpClient::get(&server_id, "/settings", None)
.await
.map_err(|err| err.to_string())?;
response
.json::<Value>()
.await
.map_err(|err| err.to_string())
}

View File

@@ -1,3 +1,4 @@
use crate::common::http::get_response_body_text;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
@@ -19,23 +20,24 @@ pub async fn transcription(
query_params.insert("type".to_string(), JsonValue::String(audio_type)); query_params.insert("type".to_string(), JsonValue::String(audio_type));
query_params.insert("content".to_string(), JsonValue::String(audio_content)); query_params.insert("content".to_string(), JsonValue::String(audio_content));
// Send the HTTP POST request
let response = HttpClient::post( let response = HttpClient::post(
&server_id, &server_id,
"/services/audio/transcription", "/services/audio/transcription",
Some(query_params), Some(query_params),
None, None,
) )
.await?;
if response.status().is_success() {
response
.json::<TranscriptionResponse>()
.await .await
.map_err(|e| e.to_string()) .map_err(|e| format!("Error sending transcription request: {}", e))?;
} else {
Err(format!( // Use get_response_body_text to extract the response body as text
"Transcription failed with status: {}", let response_body = get_response_body_text(response)
response.status() .await
)) .map_err(|e| format!("Failed to read response body: {}", e))?;
}
// Deserialize the response body into TranscriptionResponse
let transcription_response: TranscriptionResponse = serde_json::from_str(&response_body)
.map_err(|e| format!("Failed to parse transcription response: {}", e))?;
Ok(transcription_response)
} }

View File

@@ -2,14 +2,14 @@ use crate::server::servers::{get_server_by_id, get_server_token};
use futures::StreamExt; use futures::StreamExt;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter, Runtime};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::handshake::client::generate_key; use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::{connect_async, MaybeTlsStream}; use tokio_tungstenite::{connect_async_tls_with_config, Connector};
#[derive(Default)] #[derive(Default)]
pub struct WebSocketManager { pub struct WebSocketManager {
connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>, connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>,
@@ -22,9 +22,15 @@ struct WebSocketInstance {
fn convert_to_websocket(endpoint: &str) -> Result<String, String> { fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?; 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 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 { let ws_endpoint = if port == 80 || port == 443 {
format!("{}{}{}", ws_protocol, host, "/ws") format!("{}{}{}", ws_protocol, host, "/ws")
@@ -35,7 +41,8 @@ fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
} }
#[tauri::command] #[tauri::command]
pub async fn connect_to_server( pub async fn connect_to_server<R: Runtime>(
tauri_app_handle: AppHandle<R>,
id: String, id: String,
client_id: String, client_id: String,
state: tauri::State<'_, WebSocketManager>, state: tauri::State<'_, WebSocketManager>,
@@ -54,16 +61,43 @@ pub async fn connect_to_server(
tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint) tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint)
.map_err(|e| format!("Failed to create WebSocket request: {}", e))?; .map_err(|e| format!("Failed to create WebSocket request: {}", e))?;
request.headers_mut().insert("Connection", "Upgrade".parse().unwrap()); request
request.headers_mut().insert("Upgrade", "websocket".parse().unwrap()); .headers_mut()
request.headers_mut().insert("Sec-WebSocket-Version", "13".parse().unwrap()); .insert("Connection", "Upgrade".parse().unwrap());
request.headers_mut().insert("Sec-WebSocket-Key", generate_key().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 { 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 (cancel_tx, mut cancel_rx) = mpsc::channel(1);
let instance = Arc::new(WebSocketInstance { 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); let _ = app_handle_clone.emit(&format!("ws-message-{}", client_id_clone), text);
}, },
Some(Err(_)) | None => { Some(Err(_)) | None => {
log::debug!("WebSocket connection closed or error");
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone()); let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
break; break;
} }
@@ -98,7 +133,8 @@ pub async fn connect_to_server(
} }
} }
_ = cancel_rx.recv() => { _ = 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; break;
} }
} }
@@ -112,9 +148,11 @@ pub async fn connect_to_server(
Ok(()) Ok(())
} }
#[tauri::command] #[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 instance = {
let mut connections = state.connections.lock().await; let mut connections = state.connections.lock().await;
connections.remove(&client_id) connections.remove(&client_id)

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

@@ -1,9 +1,6 @@
//credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs //credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
use tauri::{ActivationPolicy, App, Emitter, EventTarget, WebviewWindow}; use tauri::{ActivationPolicy, App, Emitter, EventTarget, WebviewWindow};
use tauri_nspanel::{ use tauri_nspanel::{cocoa::appkit::NSWindowCollectionBehavior, panel_delegate, WebviewWindowExt};
cocoa::appkit::{NSMainMenuWindowLevel, NSWindowCollectionBehavior},
panel_delegate, WebviewWindowExt,
};
use crate::common::MAIN_WINDOW_LABEL; use crate::common::MAIN_WINDOW_LABEL;
@@ -22,7 +19,7 @@ pub fn platform(app: &mut App, main_window: WebviewWindow, _settings_window: Web
let panel = main_window.to_panel().unwrap(); let panel = main_window.to_panel().unwrap();
// Make the window above the dock // Make the window above the dock
panel.set_level(NSMainMenuWindowLevel + 1); panel.set_level(20);
// Do not steal focus from other windows // Do not steal focus from other windows
panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel); panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel);

View File

@@ -20,7 +20,7 @@ pub use linux::*;
pub fn default(app: &mut App, main_window: WebviewWindow, settings_window: WebviewWindow) { pub fn default(app: &mut App, main_window: WebviewWindow, settings_window: WebviewWindow) {
// Development mode automatically opens the console: https://tauri.app/develop/debug // 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(); main_window.open_devtools();
platform(app, main_window.clone(), settings_window.clone()); platform(app, main_window.clone(), settings_window.clone());

View File

@@ -0,0 +1,85 @@
use std::{path::Path, process::Command};
use tauri::{AppHandle, Runtime};
use tauri_plugin_shell::ShellExt;
enum LinuxDesktopEnvironment {
Gnome,
Kde,
}
impl LinuxDesktopEnvironment {
// This impl is based on: https://wiki.archlinux.org/title/Desktop_entries#Usage
fn launch_app_via_desktop_file<P: AsRef<Path>>(&self, file: P) -> Result<(), String> {
let path = file.as_ref();
if !path.try_exists().map_err(|e| e.to_string())? {
return Err(format!("desktop file [{}] does not exist", path.display()));
}
let cmd_output = match self {
Self::Gnome => {
let uri = path
.file_stem()
.expect("the desktop file should contain a file stem part");
Command::new("gtk-launch")
.arg(uri)
.output()
.map_err(|e| e.to_string())?
}
Self::Kde => Command::new("kde-open")
.arg(path)
.output()
.map_err(|e| e.to_string())?,
};
if !cmd_output.status.success() {
return Err(format!(
"failed to launch app via desktop file [{}], underlying command stderr [{}]",
path.display(),
String::from_utf8_lossy(&cmd_output.stderr)
));
}
Ok(())
}
}
fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
let de_os_str = std::env::var_os("XDG_CURRENT_DESKTOP")?;
let de_str = de_os_str
.into_string()
.expect("$XDG_CURRENT_DESKTOP should be UTF-8 encoded");
let de = match de_str.as_str() {
"GNOME" => LinuxDesktopEnvironment::Gnome,
"KDE" => LinuxDesktopEnvironment::Kde,
unsupported_de => unimplemented!(
"This desktop environment [{}] has not been supported yet",
unsupported_de
),
};
Some(de)
}
/// Homemade open() function to support open Linux applications via the `.desktop` file.
//
// tauri_plugin_shell::open() is deprecated, but we still use it.
#[allow(deprecated)]
pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<(), String> {
if cfg!(target_os = "linux") {
let borrowed_path = Path::new(&path);
if let Some(file_extension) = borrowed_path.extension() {
if file_extension == "desktop" {
let desktop_environment = get_linux_desktop_environment().expect("The Linux OS is running without a desktop, Coco could never run in such a environment");
return desktop_environment.launch_app_via_desktop_file(path);
}
}
}
app_handle
.shell()
.open(path, None)
.map_err(|e| e.to_string())
}

View File

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

View File

@@ -1,73 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
interface UploadAttachmentPayload {
serverId: string;
sessionId: string;
filePaths: string[];
}
interface UploadAttachmentResponse {
acknowledged: boolean;
attachments: string[];
}
type GetAttachmentPayload = Omit<UploadAttachmentPayload, "filePaths">;
export interface AttachmentHit {
_index: string;
_type: string;
_id: string;
_score: number;
_source: {
id: string;
created: string;
updated: string;
session: string;
name: string;
icon: string;
url: string;
size: number;
};
}
interface GetAttachmentResponse {
took: number;
timed_out: boolean;
_shards: {
total: number;
successful: number;
skipped: number;
failed: number;
};
hits: {
total: {
value: number;
relation: string;
};
max_score: number;
hits: AttachmentHit[];
};
}
interface DeleteAttachmentPayload {
serverId: string;
id: string;
}
export const uploadAttachment = async (payload: UploadAttachmentPayload) => {
const response = await invoke<UploadAttachmentResponse>("upload_attachment", {
...payload,
});
if (response?.acknowledged) {
return response.attachments;
}
};
export const getAttachment = (payload: GetAttachmentPayload) => {
return invoke<GetAttachmentResponse>("get_attachment", { ...payload });
};
export const deleteAttachment = (payload: DeleteAttachmentPayload) => {
return invoke<boolean>("delete_attachment", { ...payload });
};

124
src/api/axiosRequest.ts Normal file
View File

@@ -0,0 +1,124 @@
import axios from "axios";
import { useAppStore } from "@/stores/appStore";
import {
handleChangeRequestHeader,
handleConfigureAuth,
// handleAuthError,
// handleGeneralError,
handleNetworkError,
} from "./tools";
type Fn = (data: FcResponse<any>) => unknown;
interface IAnyObj {
[index: string]: unknown;
}
interface FcResponse<T> {
errno: string;
errmsg: string;
data: T;
}
axios.interceptors.request.use((config) => {
config = handleChangeRequestHeader(config);
config = handleConfigureAuth(config);
// console.log("config", config);
return config;
});
axios.interceptors.response.use(
(response) => {
if (response.status !== 200) return Promise.reject(response.data);
// handleAuthError(response.data.errno);
// handleGeneralError(response.data.errno, response.data.errmsg);
return response;
},
(err) => {
handleNetworkError(err?.response?.status);
return Promise.reject(err?.response);
}
);
export const handleApiError = (error: any) => {
const addError = useAppStore.getState().addError;
let message = "Request failed";
if (error.response) {
// Server error response
message =
error.response.data?.message || `Error (${error.response.status})`;
} else if (error.request) {
// Request failed to send
message = "Network connection failed";
} else {
// Other errors
message = error.message;
}
console.error(error);
addError(message, "error");
return error;
};
export const Get = <T>(
url: string,
params: IAnyObj = {},
clearFn?: Fn
): Promise<[any, FcResponse<T> | undefined]> =>
new Promise((resolve) => {
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
let baseURL = appStore.state?.endpoint_http;
if (!baseURL || baseURL === "undefined") {
baseURL = "";
}
axios
.get(baseURL + url, { params })
.then((result) => {
let res: FcResponse<T>;
if (clearFn !== undefined) {
res = clearFn(result?.data) as unknown as FcResponse<T>;
} else {
res = result?.data as FcResponse<T>;
}
resolve([null, res as FcResponse<T>]);
})
.catch((err) => {
handleApiError(err);
resolve([err, undefined]);
});
});
export const Post = <T>(
url: string,
data: IAnyObj,
params: IAnyObj = {},
headers: IAnyObj = {}
): Promise<[any, FcResponse<T> | undefined]> => {
return new Promise((resolve) => {
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
let baseURL = appStore.state?.endpoint_http
if (!baseURL || baseURL === "undefined") {
baseURL = "";
}
axios
.post(baseURL + url, data, {
params,
headers,
} as any)
.then((result) => {
resolve([null, result.data as FcResponse<T>]);
})
.catch((err) => {
handleApiError(err);
resolve([err, undefined]);
});
});
};

73
src/api/tools.ts Normal file
View File

@@ -0,0 +1,73 @@
export const handleChangeRequestHeader = (config: any) => {
config["xxxx"] = "xxx";
return config;
};
export const handleConfigureAuth = (config: any) => {
// config.headers["X-API-TOKEN"] = localStorage.getItem("token") || "";
const headersStr = localStorage.getItem("headers") || "{}";
const headers = JSON.parse(headersStr);
// console.log("headers:", headers);
config.headers = {
...config.headers,
...headers,
}
// console.log("config.headers", config.headers)
return config;
};
export const handleNetworkError = (errStatus?: number): void => {
const networkErrMap: any = {
"400": "Bad Request", // token invalid
"401": "Unauthorized, please login again",
"403": "Access Denied",
"404": "Resource Not Found",
"405": "Method Not Allowed",
"408": "Request Timeout",
"500": "Internal Server Error",
"501": "Not Implemented",
"502": "Bad Gateway",
"503": "Service Unavailable",
"504": "Gateway Timeout",
"505": "HTTP Version Not Supported",
};
if (errStatus) {
console.error(networkErrMap[errStatus] ?? `Other Connection Error --${errStatus}`);
return;
}
console.error("Unable to connect to server!");
};
export const handleAuthError = (errno: string): boolean => {
const authErrMap: any = {
"10031": "Login expired, please login again", // token invalid
"10032": "Session timeout, please login again", // token expired
"10033": "Account not bound to role, please contact administrator",
"10034": "User not registered, please contact administrator",
"10035": "Unable to get third-party platform user with code",
"10036": "Account not linked to employee, please contact administrator",
"10037": "Account is invalid",
"10038": "Account not found",
};
if (authErrMap.hasOwnProperty(errno)) {
console.error(authErrMap[errno]);
// Authorization error, logout account
// logout();
return false;
}
return true;
};
export const handleGeneralError = (errno: string, errmsg: string): boolean => {
if (errno !== "0") {
console.error(errmsg);
return false;
}
return true;
};

View File

@@ -1,15 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
interface TranscriptionPayload {
serverId: string;
audioType: string;
audioContent: string;
}
interface TranscriptionResponse {
text: string;
}
export const transcription = (payload: TranscriptionPayload) => {
return invoke<TranscriptionResponse>("transcription", { ...payload });
};

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -1,37 +1,106 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from "@tauri-apps/api/core";
import { ServerTokenResponse, Server, Connector, DataSource, GetResponse } from "@/types/commands" import {
ServerTokenResponse,
Server,
Connector,
DataSource,
GetResponse,
UploadAttachmentPayload,
UploadAttachmentResponse,
GetAttachmentPayload,
GetAttachmentResponse,
DeleteAttachmentPayload,
TranscriptionPayload,
TranscriptionResponse,
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);
// console.log(command, result);
if (result && typeof result === "object" && "failed" in result) {
const failedResult = result as any;
if (failedResult.failed?.length > 0 && failedResult?.hits?.length == 0) {
failedResult.failed.forEach((error: any) => {
addError(error.error, 'error');
// console.error(error.error);
});
}
}
if (typeof result === "string") {
const res = JSON.parse(result);
if (typeof res === "string") {
throw new Error(result);
}
}
return result;
} catch (error: any) {
const errorMessage = error || "Command execution failed";
addError(command + ":" + errorMessage, "error");
throw error;
}
}
export function get_server_token(id: string): Promise<ServerTokenResponse> { export function get_server_token(id: string): Promise<ServerTokenResponse> {
return invoke(`get_server_token`, { id }); return invokeWithErrorHandler(`get_server_token`, { id });
} }
export function list_coco_servers(): Promise<Server[]> { export function list_coco_servers(): Promise<Server[]> {
return invoke(`list_coco_servers`); return invokeWithErrorHandler(`list_coco_servers`);
} }
export function add_coco_server(endpoint: string): Promise<Server> { export function add_coco_server(endpoint: string): Promise<Server> {
return invoke(`add_coco_server`, { endpoint }); return invokeWithErrorHandler(`add_coco_server`, { endpoint });
} }
export function enable_server(id: string): Promise<void> { export function enable_server(id: string): Promise<void> {
return invoke(`enable_server`, { id }); return invokeWithErrorHandler(`enable_server`, { id });
} }
export function disable_server(id: string): Promise<void> { export function disable_server(id: string): Promise<void> {
return invoke(`disable_server`, { id }); return invokeWithErrorHandler(`disable_server`, { id });
} }
export function remove_coco_server(id: string): Promise<void> { export function remove_coco_server(id: string): Promise<void> {
return invoke(`remove_coco_server`, { id }); return invokeWithErrorHandler(`remove_coco_server`, { id });
} }
export function logout_coco_server(id: string): Promise<void> { export function logout_coco_server(id: string): Promise<void> {
return invoke(`logout_coco_server`, { id }); return invokeWithErrorHandler(`logout_coco_server`, { id });
} }
export function refresh_coco_server_info(id: string): Promise<Server> { export function refresh_coco_server_info(id: string): Promise<Server> {
return invoke(`refresh_coco_server_info`, { id }); return invokeWithErrorHandler(`refresh_coco_server_info`, { id });
} }
export function handle_sso_callback({ export function handle_sso_callback({
@@ -43,7 +112,7 @@ export function handle_sso_callback({
requestId: string; requestId: string;
code: string; code: string;
}): Promise<void> { }): Promise<void> {
return invoke(`handle_sso_callback`, { return invokeWithErrorHandler(`handle_sso_callback`, {
serverId, serverId,
requestId, requestId,
code, code,
@@ -51,34 +120,41 @@ export function handle_sso_callback({
} }
export function get_connectors_by_server(id: string): Promise<Connector[]> { export function get_connectors_by_server(id: string): Promise<Connector[]> {
return invoke(`get_connectors_by_server`, { id }); return invokeWithErrorHandler(`get_connectors_by_server`, { id });
} }
export function get_datasources_by_server(id: string): Promise<DataSource[]> { export function datasource_search(id: string): Promise<DataSource[]> {
return invoke(`get_datasources_by_server`, { id }); return invokeWithErrorHandler(`datasource_search`, { id });
}
export function mcp_server_search(id: string): Promise<DataSource[]> {
return invokeWithErrorHandler(`mcp_server_search`, { id });
} }
export function connect_to_server(id: string, clientId: string): Promise<void> { export function connect_to_server(id: string, clientId: string): Promise<void> {
return invoke(`connect_to_server`, { id, clientId }); return invokeWithErrorHandler(`connect_to_server`, { id, clientId });
} }
export function disconnect(clientId: string): Promise<void> { export function disconnect(clientId: string): Promise<void> {
return invoke(`disconnect`, { clientId }); return invokeWithErrorHandler(`disconnect`, { clientId });
} }
export function chat_history({ export function chat_history({
serverId, serverId,
from = 0, from = 0,
size = 20, size = 20,
query = "",
}: { }: {
serverId: string; serverId: string;
from?: number; from?: number;
size?: number; size?: number;
query?: string;
}): Promise<string> { }): Promise<string> {
return invoke(`chat_history`, { return invokeWithErrorHandler(`chat_history`, {
serverId, serverId,
from, from,
size, size,
query,
}); });
} }
@@ -93,7 +169,7 @@ export function session_chat_history({
from?: number; from?: number;
size?: number; size?: number;
}): Promise<string> { }): Promise<string> {
return invoke(`session_chat_history`, { return invokeWithErrorHandler(`session_chat_history`, {
serverId, serverId,
sessionId, sessionId,
from, from,
@@ -108,7 +184,7 @@ export function close_session_chat({
serverId: string; serverId: string;
sessionId: string; sessionId: string;
}): Promise<string> { }): Promise<string> {
return invoke(`close_session_chat`, { return invokeWithErrorHandler(`close_session_chat`, {
serverId, serverId,
sessionId, sessionId,
}); });
@@ -121,7 +197,7 @@ export function open_session_chat({
serverId: string; serverId: string;
sessionId: string; sessionId: string;
}): Promise<string> { }): Promise<string> {
return invoke(`open_session_chat`, { return invokeWithErrorHandler(`open_session_chat`, {
serverId, serverId,
sessionId, sessionId,
}); });
@@ -134,7 +210,7 @@ export function cancel_session_chat({
serverId: string; serverId: string;
sessionId: string; sessionId: string;
}): Promise<string> { }): Promise<string> {
return invoke(`cancel_session_chat`, { return invokeWithErrorHandler(`cancel_session_chat`, {
serverId, serverId,
sessionId, sessionId,
}); });
@@ -151,7 +227,7 @@ export function new_chat({
message: string; message: string;
queryParams?: Record<string, any>; queryParams?: Record<string, any>;
}): Promise<GetResponse> { }): Promise<GetResponse> {
return invoke(`new_chat`, { return invokeWithErrorHandler(`new_chat`, {
serverId, serverId,
websocketId, websocketId,
message, message,
@@ -172,7 +248,7 @@ export function send_message({
message: string; message: string;
queryParams?: Record<string, any>; queryParams?: Record<string, any>;
}): Promise<string> { }): Promise<string> {
return invoke(`send_message`, { return invokeWithErrorHandler(`send_message`, {
serverId, serverId,
websocketId, websocketId,
sessionId, sessionId,
@@ -180,3 +256,80 @@ export function send_message({
queryParams, queryParams,
}); });
} }
export const delete_session_chat = (serverId: string, sessionId: string) => {
return invokeWithErrorHandler<boolean>(`delete_session_chat`, {
serverId,
sessionId,
});
};
export const update_session_chat = (payload: {
serverId: string;
sessionId: string;
title?: string;
context?: {
attachments?: string[];
};
}): Promise<boolean> => {
return invokeWithErrorHandler<boolean>("update_session_chat", payload);
};
export const assistant_search = (payload: {
serverId: string;
}): Promise<boolean> => {
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",
{
...payload,
}
);
if (response?.acknowledged) {
return response.attachments;
}
};
export const get_attachment = (payload: GetAttachmentPayload) => {
return invokeWithErrorHandler<GetAttachmentResponse>("get_attachment", {
...payload,
});
};
export const delete_attachment = (payload: DeleteAttachmentPayload) => {
return invokeWithErrorHandler<boolean>("delete_attachment", { ...payload });
};
export const transcription = (payload: TranscriptionPayload) => {
return invokeWithErrorHandler<TranscriptionResponse>("transcription", {
...payload,
});
};
export const query_coco_fusion = (payload: {
from: number;
size: number;
queryStrings: Record<string, string>;
queryTimeout: number;
}) => {
return invokeWithErrorHandler<MultiSourceQueryResponse>("query_coco_fusion", {
...payload,
});
};

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

@@ -0,0 +1,258 @@
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 logoImg from "@/assets/icon.svg";
import VisibleKey from "@/components/Common/VisibleKey";
import { useConnectStore } from "@/stores/connectStore";
import FontIcon from "@/components/Common/Icons/FontIcon";
import { useShortcutsStore } from "@/stores/shortcutsStore";
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[];
}
export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
const { t } = useTranslation();
const currentService = useConnectStore((state) => state.currentService);
const currentAssistant = useConnectStore((state) => state.currentAssistant);
const setCurrentAssistant = useConnectStore((state) => {
return state.setCurrentAssistant;
});
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 askAiAssistantId = useSearchStore((state) => state.askAiAssistantId);
const setAskAiAssistantId = useSearchStore((state) => {
return state.setAskAiAssistantId;
});
const assistantList = useConnectStore((state) => state.assistantList);
const { fetchAssistant } = AssistantFetcher({
debounceKeyword,
assistantIDs,
});
const { pagination, runAsync } = usePagination(fetchAssistant, {
defaultPageSize: 5,
refreshDeps: [currentService?.id, debounceKeyword],
onSuccess(data) {
setAssistants(data.list);
},
});
const handleRefresh = async () => {
setIsRefreshing(true);
await runAsync({ current: 1, pageSize: 5 });
setTimeout(() => setIsRefreshing(false), 1000);
};
const [highlightIndex, setHighlightIndex] = useState<number>(-1);
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
useEffect(() => {
if (!askAiAssistantId || assistantList.length === 0) return;
const matched = assistantList.find((item) => {
return item._id === askAiAssistantId;
});
if (!matched) return;
setCurrentAssistant(matched);
setAskAiAssistantId(void 0);
}, [assistantList, askAiAssistantId]);
useKeyPress(
["uparrow", "downarrow", "enter"],
(event, key) => {
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,
}
);
const handlePrev = useCallback(() => {
if (pagination.current <= 1) return;
pagination.changeCurrent(pagination.current - 1);
}, [pagination]);
const handleNext = useCallback(() => {
if (pagination.current >= pagination.totalPage) {
return;
}
pagination.changeCurrent(pagination.current + 1);
}, [pagination]);
const handleMouseMove = useCallback(() => {
setHighlightIndex(-1);
setIsKeyboardActive(false);
}, []);
return (
<div className="relative">
<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"
>
<div className="w-4 h-4 flex justify-center items-center rounded-full bg-white border border-[#E6E6E6]">
{currentAssistant?._source?.icon?.startsWith("font_") ? (
<FontIcon
name={currentAssistant._source.icon}
className="w-3 h-3"
/>
) : (
<img
src={logoImg}
className="w-3 h-3"
alt={t("assistant.message.logo")}
/>
)}
</div>
<div className="max-w-[100px] truncate">
{currentAssistant?._source?.name || "Coco AI"}
</div>
<VisibleKey
shortcut={aiAssistant}
onKeyPress={() => {
popoverButtonRef.current?.click();
}}
>
<ChevronDownIcon className="size-4 text-gray-500 dark:text-gray-400 transition-transform" />
</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"
onMouseMove={handleMouseMove}
>
<div className="flex items-center justify-between text-sm font-bold">
<div>
{t("assistant.popover.title")}{pagination.total}
</div>
<button
onClick={handleRefresh}
className="flex items-center justify-center size-6 bg-white dark:bg-[#202126] rounded-lg border dark:border-white/10"
disabled={isRefreshing}
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCw
className={clsx(
"size-3 text-[#0287FF] transition-transform duration-1000",
{
"animate-spin": isRefreshing,
}
)}
/>
</VisibleKey>
</button>
</div>
<VisibleKey
shortcut="F"
rootClassName="w-full my-3"
shortcutClassName="left-4"
onKeyPress={() => {
searchInputRef.current?.focus();
}}
>
<PopoverInput
ref={searchInputRef}
autoFocus
value={keyword}
placeholder={t("assistant.popover.search")}
className="w-full h-8 px-2 bg-transparent border rounded-md dark:border-white/10"
onChange={(event) => {
setKeyword(event.target.value.trim());
}}
/>
</VisibleKey>
{assistants.length > 0 ? (
<>
{assistants.map((assistant, index) => {
return (
<AssistantItem
key={assistant._id}
{...assistant}
isActive={currentAssistant?._id === assistant._id}
isHighlight={highlightIndex === index}
isKeyboardActive={isKeyboardActive}
onClick={() => {
setCurrentAssistant(assistant);
popoverButtonRef.current?.click();
}}
/>
);
})}
<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">
<NoDataImage />
</div>
)}
</PopoverPanel>
</Popover>
</div>
);
}

View File

@@ -10,7 +10,6 @@ import {
import { useChatStore } from "@/stores/chatStore"; import { useChatStore } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { useSearchStore } from "@/stores/searchStore";
import { useWindows } from "@/hooks/useWindows"; import { useWindows } from "@/hooks/useWindows";
import useMessageChunkData from "@/hooks/useMessageChunkData"; import useMessageChunkData from "@/hooks/useMessageChunkData";
import useWebSocket from "@/hooks/useWebSocket"; import useWebSocket from "@/hooks/useWebSocket";
@@ -20,12 +19,18 @@ import { ChatSidebar } from "./ChatSidebar";
import { ChatHeader } from "./ChatHeader"; import { ChatHeader } from "./ChatHeader";
import { ChatContent } from "./ChatContent"; import { ChatContent } from "./ChatContent";
import ConnectPrompt from "./ConnectPrompt"; 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 { interface ChatAIProps {
isTransitioned: boolean;
isSearchActive?: boolean; isSearchActive?: boolean;
isDeepThinkActive?: boolean; isDeepThinkActive?: boolean;
isMCPActive?: boolean;
activeChatProp?: Chat; activeChatProp?: Chat;
changeInput?: (val: string) => void; changeInput?: (val: string) => void;
setIsSidebarOpen?: (value: boolean) => void; setIsSidebarOpen?: (value: boolean) => void;
@@ -33,6 +38,9 @@ interface ChatAIProps {
clearChatPage?: () => void; clearChatPage?: () => void;
isChatPage?: boolean; isChatPage?: boolean;
getFileUrl: (path: string) => string; getFileUrl: (path: string) => string;
showChatHistory?: boolean;
assistantIDs?: string[];
startPage?: StartPage;
} }
export interface ChatAIRef { export interface ChatAIRef {
@@ -46,21 +54,22 @@ const ChatAI = memo(
forwardRef<ChatAIRef, ChatAIProps>( forwardRef<ChatAIRef, ChatAIProps>(
( (
{ {
isTransitioned,
changeInput, changeInput,
isSearchActive, isSearchActive,
isDeepThinkActive, isDeepThinkActive,
isMCPActive,
activeChatProp, activeChatProp,
setIsSidebarOpen, setIsSidebarOpen,
isSidebarOpen = false, isSidebarOpen = false,
clearChatPage, clearChatPage,
isChatPage = false, isChatPage = false,
getFileUrl, getFileUrl,
showChatHistory,
assistantIDs,
startPage,
}, },
ref ref
) => { ) => {
if (!isTransitioned) return null;
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
init: init, init: init,
cancelChat: () => cancelChat(activeChat), cancelChat: () => cancelChat(activeChat),
@@ -71,25 +80,48 @@ const ChatAI = memo(
const { curChatEnd, setCurChatEnd, connected, setConnected } = const { curChatEnd, setCurChatEnd, connected, setConnected } =
useChatStore(); useChatStore();
const currentService = useConnectStore((state) => state.currentService); const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
const visibleStartPage = useConnectStore((state) => {
return state.visibleStartPage;
});
const addError = useAppStore.getState().addError;
const [activeChat, setActiveChat] = useState<Chat>(); const [activeChat, setActiveChat] = useState<Chat>();
const [timedoutShow, setTimedoutShow] = useState(false); const [timedoutShow, setTimedoutShow] = useState(false);
const [isLogin, setIsLogin] = useState(true);
const curIdRef = useRef(""); const curIdRef = useRef("");
const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen); const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen);
const [chats, setChats] = useState<Chat[]>([]); const [chats, setChats] = useState<Chat[]>([]);
const sourceDataIds = useSearchStore((state) => state.sourceDataIds); const askAiSessionId = useSearchStore((state) => state.askAiSessionId);
const setAskAiSessionId = useSearchStore(
(state) => state.setAskAiSessionId
);
const askAiServerId = useSearchStore((state) => {
return state.askAiServerId;
});
useEffect(() => { useEffect(() => {
activeChatProp && setActiveChat(activeChatProp); activeChatProp && setActiveChat(activeChatProp);
}, [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 [Question, setQuestion] = useState<string>("");
const [websocketSessionId, setWebsocketSessionId] = useState(''); const [websocketSessionId, setWebsocketSessionId] = useState("");
const onWebsocketSessionId = useCallback((sessionId: string) => { const onWebsocketSessionId = useCallback((sessionId: string) => {
setWebsocketSessionId(sessionId); setWebsocketSessionId(sessionId);
@@ -98,6 +130,7 @@ const ChatAI = memo(
const { const {
data: { data: {
query_intent, query_intent,
tools,
fetch_source, fetch_source,
pick_source, pick_source,
deep_read, deep_read,
@@ -110,6 +143,7 @@ const ChatAI = memo(
const [loadingStep, setLoadingStep] = useState<Record<string, boolean>>({ const [loadingStep, setLoadingStep] = useState<Record<string, boolean>>({
query_intent: false, query_intent: false,
tools: false,
fetch_source: false, fetch_source: false,
pick_source: false, pick_source: false,
deep_read: false, deep_read: false,
@@ -119,13 +153,11 @@ const ChatAI = memo(
const dealMsgRef = useRef<((msg: string) => void) | null>(null); const dealMsgRef = useRef<((msg: string) => void) | null>(null);
const clientId = isChatPage ? "standalone" : "popup" const clientId = isChatPage ? "standalone" : "popup";
const { errorShow, setErrorShow, reconnect, disconnectWS, updateDealMsg } = const { reconnect, updateDealMsg } = useWebSocket({
useWebSocket({
clientId, clientId,
connected, connected,
setConnected, setConnected,
currentService,
dealMsgRef, dealMsgRef,
onWebsocketSessionId, onWebsocketSessionId,
}); });
@@ -139,29 +171,32 @@ const ChatAI = memo(
openSessionChat, openSessionChat,
getChatHistory, getChatHistory,
createChatWindow, createChatWindow,
handleSearch,
handleRename,
handleDelete,
} = useChatActions( } = useChatActions(
currentService?.id,
setActiveChat, setActiveChat,
setCurChatEnd, setCurChatEnd,
setErrorShow,
setTimedoutShow, setTimedoutShow,
clearAllChunkData, clearAllChunkData,
setQuestion, setQuestion,
curIdRef, curIdRef,
setChats,
isSearchActive, isSearchActive,
isDeepThinkActive, isDeepThinkActive,
sourceDataIds, isMCPActive,
changeInput, changeInput,
websocketSessionId websocketSessionId,
showChatHistory
); );
const { dealMsg, messageTimeoutRef } = useMessageHandler( const { dealMsg } = useMessageHandler(
curIdRef, curIdRef,
setCurChatEnd, setCurChatEnd,
setTimedoutShow, setTimedoutShow,
(chat) => cancelChat(chat || activeChat), (chat) => cancelChat(chat || activeChat),
setLoadingStep, setLoadingStep,
handlers, handlers
); );
useEffect(() => { useEffect(() => {
@@ -172,33 +207,43 @@ const ChatAI = memo(
}, [dealMsg, updateDealMsg]); }, [dealMsg, updateDealMsg]);
const clearChat = useCallback(() => { const clearChat = useCallback(() => {
console.log("clearChat"); //console.log("clearChat");
setTimedoutShow(false); setTimedoutShow(false);
setErrorShow(false);
chatClose(activeChat); chatClose(activeChat);
setActiveChat(undefined); setActiveChat(undefined);
setCurChatEnd(true); setCurChatEnd(true);
clearChatPage && clearChatPage(); clearChatPage && clearChatPage();
}, [ }, [activeChat, chatClose]);
activeChat,
chatClose,
clearChatPage,
setCurChatEnd,
setErrorShow,
setTimedoutShow,
]);
const init = useCallback( const init = useCallback(
(value: string) => { async (value: string) => {
if (!isLogin) return; try {
if (!curChatEnd) return; //console.log("init", curChatEnd, activeChat?._id);
if (!isCurrentLogin) {
addError("Please login to continue chatting");
return;
}
if (!curChatEnd) {
addError("Please wait for the current conversation to complete");
return;
}
if (!activeChat?._id) { if (!activeChat?._id) {
createNewChat(value, activeChat, websocketSessionId); await createNewChat(value, activeChat, websocketSessionId);
} else { } else {
handleSendMessage(value, activeChat, websocketSessionId); await handleSendMessage(value, activeChat, websocketSessionId);
}
} catch (error) {
console.error("Failed to initialize chat:", error);
} }
}, },
[isLogin, curChatEnd, activeChat, createNewChat, handleSendMessage, websocketSessionId] [
isCurrentLogin,
curChatEnd,
activeChat?._id,
createNewChat,
handleSendMessage,
websocketSessionId,
]
); );
const { createWin } = useWindows(); const { createWin } = useWindows();
@@ -206,22 +251,9 @@ const ChatAI = memo(
createChatWindow(createWin); createChatWindow(createWin);
}, [createChatWindow, createWin]); }, [createChatWindow, createWin]);
useEffect(() => {
return () => {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
chatClose(activeChat);
setActiveChat(undefined);
setCurChatEnd(true);
disconnectWS();
};
}, [chatClose, setCurChatEnd]);
const onSelectChat = useCallback( const onSelectChat = useCallback(
async (chat: Chat) => { async (chat: Chat) => {
setTimedoutShow(false); setTimedoutShow(false);
setErrorShow(false);
clearAllChunkData(); clearAllChunkData();
await cancelChat(activeChat); await cancelChat(activeChat);
await chatClose(activeChat); await chatClose(activeChat);
@@ -230,27 +262,29 @@ const ChatAI = memo(
chatHistory(response); chatHistory(response);
} }
}, },
[ [cancelChat, activeChat, chatClose, openSessionChat, chatHistory]
clearAllChunkData,
cancelChat,
activeChat,
chatClose,
openSessionChat,
chatHistory,
]
); );
const deleteChat = useCallback((chatId: string) => { const deleteChat = useCallback(
setChats((prev) => prev.filter((chat) => chat._id !== chatId)); (chatId: string) => {
handleDelete(chatId);
setChats((prev) => {
const updatedChats = prev.filter((chat) => chat._id !== chatId);
if (activeChat?._id === chatId) { if (activeChat?._id === chatId) {
const remainingChats = chats.filter((chat) => chat._id !== chatId); if (updatedChats.length > 0) {
if (remainingChats.length > 0) { setActiveChat(updatedChats[0]);
setActiveChat(remainingChats[0]);
} else { } else {
init(""); init("");
} }
} }
}, [activeChat, chats, init, setActiveChat]);
return updatedChats;
});
},
[activeChat?._id, handleDelete, init]
);
const handleOutsideClick = useCallback((e: MouseEvent) => { const handleOutsideClick = useCallback((e: MouseEvent) => {
const sidebar = document.querySelector("[data-sidebar]"); const sidebar = document.querySelector("[data-sidebar]");
@@ -274,58 +308,76 @@ const ChatAI = memo(
}; };
}, [isSidebarOpenChat, handleOutsideClick]); }, [isSidebarOpenChat, handleOutsideClick]);
const fetchChatHistory = useCallback(async () => {
const hits = await getChatHistory();
setChats(hits);
}, [getChatHistory]);
const setIsLoginChat = useCallback(
(value: boolean) => {
setIsLogin(value);
value && currentService && !setIsSidebarOpen && fetchChatHistory();
!value && setChats([]);
},
[currentService, setIsSidebarOpen, fetchChatHistory]
);
const toggleSidebar = useCallback(() => { const toggleSidebar = useCallback(() => {
setIsSidebarOpenChat(!isSidebarOpenChat); setIsSidebarOpenChat(!isSidebarOpenChat);
setIsSidebarOpen && setIsSidebarOpen(!isSidebarOpenChat); setIsSidebarOpen && setIsSidebarOpen(!isSidebarOpenChat);
!isSidebarOpenChat && fetchChatHistory(); !isSidebarOpenChat && getChatHistory();
}, [isSidebarOpenChat, setIsSidebarOpen, fetchChatHistory]); }, [isSidebarOpenChat, setIsSidebarOpen, getChatHistory]);
const renameChat = useCallback(
(chatId: string, title: string) => {
setChats((prev) => {
const chatIndex = prev.findIndex((chat) => chat._id === chatId);
if (chatIndex === -1) return prev;
const modifiedChat = {
...prev[chatIndex],
_source: { ...prev[chatIndex]._source, title },
};
const result = [...prev];
result.splice(chatIndex, 1);
return [modifiedChat, ...result];
});
if (activeChat?._id === chatId) {
setActiveChat((prev) => {
if (!prev) return prev;
return { ...prev, _source: { ...prev._source, title } };
});
}
handleRename(chatId, title);
},
[activeChat?._id, handleRename]
);
return ( return (
<div <>
data-tauri-drag-region {showChatHistory && !setIsSidebarOpen && (
className={`h-full flex flex-col rounded-xl overflow-hidden`}
>
{!setIsSidebarOpen && (
<ChatSidebar <ChatSidebar
isSidebarOpen={isSidebarOpenChat} isSidebarOpen={isSidebarOpenChat}
chats={chats} chats={chats}
activeChat={activeChat} activeChat={activeChat}
onNewChat={clearChat}
onSelectChat={onSelectChat} onSelectChat={onSelectChat}
onDeleteChat={deleteChat} onDeleteChat={deleteChat}
fetchChatHistory={fetchChatHistory} fetchChatHistory={getChatHistory}
onSearch={handleSearch}
onRename={renameChat}
/> />
)} )}
<div
data-tauri-drag-region
className={`flex flex-col rounded-md h-full overflow-hidden relative`}
>
<ChatHeader <ChatHeader
onCreateNewChat={clearChat} clearChat={clearChat}
onOpenChatAI={openChatAI} onOpenChatAI={openChatAI}
setIsSidebarOpen={toggleSidebar} setIsSidebarOpen={toggleSidebar}
isSidebarOpen={isSidebarOpenChat} isSidebarOpen={isSidebarOpenChat}
activeChat={activeChat} activeChat={activeChat}
reconnect={reconnect} reconnect={reconnect}
isChatPage={isChatPage} isChatPage={isChatPage}
setIsLogin={setIsLoginChat} showChatHistory={showChatHistory}
assistantIDs={assistantIDs}
/> />
{isLogin ? (
{isCurrentLogin ? (<>
<ChatContent <ChatContent
activeChat={activeChat} activeChat={activeChat}
curChatEnd={curChatEnd} curChatEnd={curChatEnd}
query_intent={query_intent} query_intent={query_intent}
tools={tools}
fetch_source={fetch_source} fetch_source={fetch_source}
pick_source={pick_source} pick_source={pick_source}
deep_read={deep_read} deep_read={deep_read}
@@ -333,15 +385,27 @@ const ChatAI = memo(
response={response} response={response}
loadingStep={loadingStep} loadingStep={loadingStep}
timedoutShow={timedoutShow} timedoutShow={timedoutShow}
errorShow={errorShow}
Question={Question} Question={Question}
handleSendMessage={(value) => handleSendMessage(value, activeChat)} handleSendMessage={(value) =>
handleSendMessage(value, activeChat)
}
getFileUrl={getFileUrl} getFileUrl={getFileUrl}
/> />
<Splash assistantIDs={assistantIDs} startPage={startPage}/>
</>
) : ( ) : (
<ConnectPrompt /> <ConnectPrompt />
)} )}
{!activeChat?._id && !visibleStartPage && (
<PrevSuggestion sendMessage={init} />
)}
{/* <ReadAloud /> */}
</div> </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 { useTranslation } from "react-i18next";
import { ChatMessage } from "@/components/ChatMessage"; import { ChatMessage } from "@/components/ChatMessage";
@@ -6,14 +6,16 @@ import { Greetings } from "./Greetings";
import FileList from "@/components/Assistant/FileList"; import FileList from "@/components/Assistant/FileList";
import { useChatScroll } from "@/hooks/useChatScroll"; import { useChatScroll } from "@/hooks/useChatScroll";
import { useChatStore } from "@/stores/chatStore"; import { useChatStore } from "@/stores/chatStore";
import type { Chat, IChunkData } from "./types"; import type { Chat, IChunkData } from "@/types/chat";
import SessionFile from "./SessionFile";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import SessionFile from "./SessionFile";
import ScrollToBottom from "@/components/Common/ScrollToBottom";
interface ChatContentProps { interface ChatContentProps {
activeChat?: Chat; activeChat?: Chat;
curChatEnd: boolean; curChatEnd: boolean;
query_intent?: IChunkData; query_intent?: IChunkData;
tools?: IChunkData;
fetch_source?: IChunkData; fetch_source?: IChunkData;
pick_source?: IChunkData; pick_source?: IChunkData;
deep_read?: IChunkData; deep_read?: IChunkData;
@@ -21,7 +23,6 @@ interface ChatContentProps {
response?: IChunkData; response?: IChunkData;
loadingStep?: Record<string, boolean>; loadingStep?: Record<string, boolean>;
timedoutShow: boolean; timedoutShow: boolean;
errorShow: boolean;
Question: string; Question: string;
handleSendMessage: (content: string, newChat?: Chat) => void; handleSendMessage: (content: string, newChat?: Chat) => void;
getFileUrl: (path: string) => string; getFileUrl: (path: string) => string;
@@ -31,6 +32,7 @@ export const ChatContent = ({
activeChat, activeChat,
curChatEnd, curChatEnd,
query_intent, query_intent,
tools,
fetch_source, fetch_source,
pick_source, pick_source,
deep_read, deep_read,
@@ -38,7 +40,6 @@ export const ChatContent = ({
response, response,
loadingStep, loadingStep,
timedoutShow, timedoutShow,
errorShow,
Question, Question,
handleSendMessage, handleSendMessage,
getFileUrl, getFileUrl,
@@ -48,22 +49,24 @@ export const ChatContent = ({
return state.setCurrentSessionId; return state.setCurrentSessionId;
}); });
useEffect(() => {
setCurrentSessionId(activeChat?._id);
}, [activeChat]);
const { t } = useTranslation(); const { t } = useTranslation();
const uploadFiles = useChatStore((state) => state.uploadFiles); const uploadFiles = useChatStore((state) => state.uploadFiles);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const { scrollToBottom } = useChatScroll(messagesEndRef); const { scrollToBottom } = useChatScroll(messagesEndRef);
const scrollRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
useEffect(() => {
setIsAtBottom(true);
setCurrentSessionId(activeChat?._id);
}, [activeChat?._id]);
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
}, [ }, [
activeChat?.messages, activeChat?.id,
query_intent?.message_chunk, query_intent?.message_chunk,
fetch_source?.message_chunk, fetch_source?.message_chunk,
pick_source?.message_chunk, pick_source?.message_chunk,
@@ -79,10 +82,25 @@ export const ChatContent = ({
}; };
}, [scrollToBottom]); }, [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 ( return (
<div className="relative flex flex-col h-full justify-between overflow-hidden"> <div className="flex-1 overflow-hidden flex flex-col justify-between relative user-select-text">
<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"> <div
<Greetings /> 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) => ( {activeChat?.messages?.map((message, index) => (
<ChatMessage <ChatMessage
@@ -92,8 +110,10 @@ export const ChatContent = ({
onResend={handleSendMessage} onResend={handleSendMessage}
/> />
))} ))}
{(!curChatEnd || {(!curChatEnd ||
query_intent || query_intent ||
tools ||
fetch_source || fetch_source ||
pick_source || pick_source ||
deep_read || deep_read ||
@@ -106,6 +126,8 @@ export const ChatContent = ({
_id: "current", _id: "current",
_source: { _source: {
type: "assistant", type: "assistant",
assistant_id:
allMessages[allMessages.length - 1]?._source?.assistant_id,
message: "", message: "",
question: Question, question: Question,
}, },
@@ -113,6 +135,7 @@ export const ChatContent = ({
onResend={handleSendMessage} onResend={handleSendMessage}
isTyping={!curChatEnd} isTyping={!curChatEnd}
query_intent={query_intent} query_intent={query_intent}
tools={tools}
fetch_source={fetch_source} fetch_source={fetch_source}
pick_source={pick_source} pick_source={pick_source}
deep_read={deep_read} deep_read={deep_read}
@@ -121,6 +144,7 @@ export const ChatContent = ({
loadingStep={loadingStep} loadingStep={loadingStep}
/> />
) : null} ) : null}
{timedoutShow ? ( {timedoutShow ? (
<ChatMessage <ChatMessage
key={"timedout"} key={"timedout"}
@@ -136,21 +160,6 @@ export const ChatContent = ({
isTyping={false} isTyping={false}
/> />
) : null} ) : null}
{errorShow ? (
<ChatMessage
key={"error"}
message={{
_id: "error",
_source: {
type: "assistant",
message: t("assistant.chat.error"),
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={false}
/>
) : null}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
@@ -161,6 +170,8 @@ export const ChatContent = ({
)} )}
{sessionId && <SessionFile sessionId={sessionId} />} {sessionId && <SessionFile sessionId={sessionId} />}
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
</div> </div>
); );
}; };

View File

@@ -1,132 +1,59 @@
import { import { MessageSquarePlus } from "lucide-react";
MessageSquarePlus, import clsx from "clsx";
ChevronDownIcon,
Settings,
RefreshCw,
Check,
Server,
} from "lucide-react";
import { useState, useEffect, useCallback } from "react";
import {
Menu,
MenuButton,
MenuItem,
MenuItems,
Popover,
PopoverButton,
PopoverPanel,
} from "@headlessui/react";
import { useTranslation } from "react-i18next";
import logoImg from "@/assets/icon.svg";
import HistoryIcon from "@/icons/History"; import HistoryIcon from "@/icons/History";
import PinOffIcon from "@/icons/PinOff"; import PinOffIcon from "@/icons/PinOff";
import PinIcon from "@/icons/Pin"; import PinIcon from "@/icons/Pin";
import ServerIcon from "@/icons/Server";
import WindowsFullIcon from "@/icons/WindowsFull"; import WindowsFullIcon from "@/icons/WindowsFull";
import { useAppStore, IServer } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { useChatStore } from "@/stores/chatStore"; import type { Chat } from "@/types/chat";
import type { Chat } from "./types";
import { useConnectStore } from "@/stores/connectStore";
import platformAdapter from "@/utils/platformAdapter"; 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 { interface ChatHeaderProps {
onCreateNewChat: () => void; clearChat: () => void;
onOpenChatAI: () => void; onOpenChatAI: () => void;
setIsSidebarOpen: () => void; setIsSidebarOpen: () => void;
isSidebarOpen: boolean; isSidebarOpen: boolean;
activeChat: Chat | undefined; activeChat: Chat | undefined;
reconnect: (server?: IServer) => void; reconnect: (server?: Server) => void;
setIsLogin: (isLogin: boolean) => void;
isChatPage?: boolean; isChatPage?: boolean;
showChatHistory?: boolean;
assistantIDs?: string[];
} }
export function ChatHeader({ export function ChatHeader({
onCreateNewChat, clearChat,
onOpenChatAI, onOpenChatAI,
isSidebarOpen,
setIsSidebarOpen, setIsSidebarOpen,
activeChat, activeChat,
reconnect, reconnect,
setIsLogin,
isChatPage = false, isChatPage = false,
showChatHistory = true,
assistantIDs,
}: ChatHeaderProps) { }: ChatHeaderProps) {
const { t } = useTranslation();
const setEndpoint = useAppStore((state) => state.setEndpoint);
const isPinned = useAppStore((state) => state.isPinned); const isPinned = useAppStore((state) => state.isPinned);
const setIsPinned = useAppStore((state) => state.setIsPinned); const setIsPinned = useAppStore((state) => state.setIsPinned);
const { setMessages } = useChatStore(); const isTauri = useAppStore((state) => state.isTauri);
const historicalRecords = useShortcutsStore((state) => {
const [serverList, setServerList] = useState<IServer[]>([]); return state.historicalRecords;
const [isRefreshing, setIsRefreshing] = useState(false);
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore((state) => state.setCurrentService);
const fetchServers = useCallback(async (resetSelection: boolean) => {
platformAdapter.invokeBackend("list_coco_servers")
.then((res: any) => {
const enabledServers = (res as IServer[]).filter(
(server) => server.enabled !== false
);
//console.log("list_coco_servers", enabledServers);
setServerList(enabledServers);
if (resetSelection && enabledServers.length > 0) {
const currentServiceExists = enabledServers.find(
(server) => server.id === currentService?.id
);
if (currentServiceExists) {
switchServer(currentServiceExists);
} else {
switchServer(enabledServers[enabledServers.length - 1]);
}
}
})
.catch((err: any) => {
console.error(err);
}); });
}, [currentService?.id]); const newSession = useShortcutsStore((state) => {
return state.newSession;
useEffect(() => { });
fetchServers(true); const fixedWindow = useShortcutsStore((state) => {
return state.fixedWindow;
const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => {
console.log("Login or Logout:", currentService, event);
fetchServers(true);
}); });
return () => { const external = useShortcutsStore((state) => state.external);
// Cleanup logic if needed
unlisten.then((fn) => fn());
};
}, []);
const switchServer = async (server: IServer) => {
if (!server) return;
try {
// Switch UI first, then switch server connection
setCurrentService(server);
setEndpoint(server.endpoint);
setMessages(""); // Clear previous messages
onCreateNewChat();
//
if (!server.public && !server.profile) {
setIsLogin(false);
return;
}
setIsLogin(true);
// The Rust backend will automatically disconnect,
// so we don't need to handle disconnection on the frontend
// src-tauri/src/server/websocket.rs
reconnect && reconnect(server);
} catch (error) {
console.error("switchServer:", error);
}
};
const togglePin = async () => { const togglePin = async () => {
try { try {
@@ -139,183 +66,80 @@ export function ChatHeader({
} }
}; };
const openSettings = async () => {
platformAdapter.emitEvent("open_settings", "connect");
};
return ( return (
<header <header
className="flex items-center justify-between py-2 px-3" className="flex items-center justify-between py-2 px-3 select-none"
data-tauri-drag-region data-tauri-drag-region
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{showChatHistory && (
<button <button
data-sidebar-button data-sidebar-button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setIsSidebarOpen(); setIsSidebarOpen();
}} }}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800" aria-controls={isSidebarOpen ? HISTORY_PANEL_ID : void 0}
className="py-1 px-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
> >
<HistoryIcon /> <VisibleKey
</button> shortcut={historicalRecords}
onKeyPress={setIsSidebarOpen}
<Menu>
<MenuButton className="flex items-center gap-1 rounded-full bg-white dark:bg-[#202126] p-1 text-sm/6 font-semibold text-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none">
<img
src={logoImg}
className="w-4 h-4"
alt={t("assistant.message.logo")}
/>
Coco AI
<ChevronDownIcon className="size-4 text-gray-500 dark:text-gray-400" />
</MenuButton>
<MenuItems
transition
anchor="bottom end"
className="w-28 origin-top-right rounded-xl bg-white dark:bg-[#202126] p-1 text-sm/6 text-gray-800 dark:text-white shadow-lg border border-gray-200 dark:border-gray-700 focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0"
> >
<MenuItem> <HistoryIcon className="h-4 w-4" />
<button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 hover:bg-gray-100 dark:hover:bg-gray-700"> </VisibleKey>
<img
src={logoImg}
className="w-4 h-4"
alt={t("assistant.message.logo")}
/>
Coco AI
</button> </button>
</MenuItem> )}
</MenuItems>
</Menu>
<AssistantList assistantIDs={assistantIDs} />
{showChatHistory ? (
<button <button
onClick={onCreateNewChat} onClick={clearChat}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800" className="p-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
> >
<MessageSquarePlus className="h-4 w-4" /> <VisibleKey shortcutClassName="top-2.5" shortcut={newSession} onKeyPress={clearChat}>
<MessageSquarePlus className="h-4 w-4 relative top-0.5" />
</VisibleKey>
</button> </button>
) : null}
</div> </div>
<div> <h2 className="max-w-[calc(100%-200px)] text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
<h2 className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{activeChat?._source?.title || {activeChat?._source?.title ||
activeChat?._source?.message || activeChat?._source?.message ||
activeChat?._id} activeChat?._id}
</h2> </h2>
</div>
{isTauri ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={togglePin} onClick={togglePin}
className={`${isPinned ? "text-blue-500" : ""}`} className={clsx("inline-flex", {
"text-blue-500": isPinned,
})}
> >
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
{isPinned ? <PinIcon /> : <PinOffIcon />} {isPinned ? <PinIcon /> : <PinOffIcon />}
</VisibleKey>
</button> </button>
<Popover className="relative"> <ServerList
<PopoverButton className="flex items-center"> reconnect={reconnect}
<ServerIcon /> clearChat={clearChat}
</PopoverButton>
<PopoverPanel className="absolute right-0 z-10 mt-2 min-w-[240px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<div className="p-3">
<div className="flex items-center justify-between mb-3 whitespace-nowrap">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
Servers
</h3>
<div className="flex items-center gap-2">
<button
onClick={openSettings}
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
>
<Settings className="h-4 w-4 text-[#0287FF]" />
</button>
<button
onClick={async () => {
setIsRefreshing(true);
await fetchServers(false);
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>
<div className="space-y-1">
{serverList.length > 0 ? (
serverList.map((server) => (
<div
key={server.id}
onClick={() => switchServer(server)}
className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap ${
currentService?.id === server.id
? "bg-gray-100 dark:bg-gray-800"
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
}`}
>
<div className="flex items-center gap-2 overflow-hidden min-w-0">
<img
src={server?.provider?.icon || logoImg}
alt={server.name}
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800"
/>
<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}
</div>
</div>
</div>
<div className="flex 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"
}`}
/>
<div className="w-4 h-4">
{currentService?.id === server.id && (
<Check className="w-full h-full text-gray-500 dark:text-gray-400" />
)}
</div>
</div>
</div>
))
) : (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Server className="w-8 h-8 text-gray-400 dark:text-gray-600 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("assistant.chat.noServers")}
</p>
<button
onClick={openSettings}
className="mt-2 text-xs text-[#0287FF] hover:underline"
>
{t("assistant.chat.addServer")}
</button>
</div>
)}
</div>
</div>
</PopoverPanel>
</Popover>
{isChatPage ? null : ( {isChatPage ? null : (
<button onClick={onOpenChatAI}> <button className="inline-flex" onClick={onOpenChatAI}>
<VisibleKey shortcut={external} onKeyPress={onOpenChatAI}>
<WindowsFullIcon className="rotate-30 scale-x-[-1]" /> <WindowsFullIcon className="rotate-30 scale-x-[-1]" />
</VisibleKey>
</button> </button>
)} )}
</div> </div>
) : (
<div />
)}
</header> </header>
); );
} }

View File

@@ -1,44 +1,54 @@
import React from "react"; import React from "react";
import { Sidebar } from "@/components/Assistant/Sidebar"; import type { Chat } from "@/types/chat";
import type { Chat } from "./types"; import HistoryList from "../Common/HistoryList";
import { HISTORY_PANEL_ID } from "@/constants";
interface ChatSidebarProps { interface ChatSidebarProps {
isSidebarOpen: boolean; isSidebarOpen: boolean;
chats: Chat[]; chats: Chat[];
activeChat?: Chat; activeChat?: Chat;
onNewChat: () => void;
onSelectChat: (chat: any) => void; onSelectChat: (chat: any) => void;
onDeleteChat: (chatId: string) => void; onDeleteChat: (chatId: string) => void;
fetchChatHistory: () => void; fetchChatHistory: () => void;
onSearch: (keyword: string) => void;
onRename: (chat: any, title: string) => void;
} }
export const ChatSidebar: React.FC<ChatSidebarProps> = ({ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
isSidebarOpen, isSidebarOpen,
chats, chats,
activeChat, activeChat,
onNewChat,
onSelectChat, onSelectChat,
onDeleteChat, onDeleteChat,
fetchChatHistory, fetchChatHistory,
onSearch,
onRename,
}) => { }) => {
return ( return (
<div <div
data-sidebar data-sidebar
className={`fixed inset-y-0 left-0 z-50 w-64 transform transition-all duration-300 ease-in-out className={`
${isSidebarOpen ? "translate-x-0" : "-translate-x-[calc(100%)]"} h-screen absolute top-0 left-0 z-100 w-64
md:relative md:translate-x-0 bg-gray-100 dark:bg-gray-800 transform transition-all duration-300 ease-in-out
${isSidebarOpen ? "translate-x-0" : "-translate-x-full"}
bg-gray-100 dark:bg-gray-800
border-r border-gray-200 dark:border-gray-700 rounded-tl-xl rounded-bl-xl border-r border-gray-200 dark:border-gray-700 rounded-tl-xl rounded-bl-xl
overflow-hidden`} overflow-hidden
`}
> >
<Sidebar {isSidebarOpen && (
chats={chats} <HistoryList
activeChat={activeChat} id={HISTORY_PANEL_ID}
onNewChat={onNewChat} list={chats}
onSelectChat={onSelectChat} active={activeChat}
onDeleteChat={onDeleteChat} onSearch={onSearch}
fetchChatHistory={fetchChatHistory} onRefresh={fetchChatHistory}
onSelect={onSelectChat}
onRename={onRename}
onRemove={onDeleteChat}
/> />
)}
</div> </div>
); );
}; };

View File

@@ -6,8 +6,8 @@ import { useTranslation } from "react-i18next";
import { useChatStore } from "@/stores/chatStore"; import { useChatStore } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { deleteAttachment, uploadAttachment } from "@/api/attachment";
import FileIcon from "../Common/Icons/FileIcon"; import FileIcon from "../Common/Icons/FileIcon";
import platformAdapter from "@/utils/platformAdapter";
interface FileListProps { interface FileListProps {
sessionId: string; sessionId: string;
@@ -39,11 +39,14 @@ const FileList = (props: FileListProps) => {
if (uploaded) continue; if (uploaded) continue;
const attachmentIds = await uploadAttachment({ const attachmentIds: any = await platformAdapter.commands(
"upload_attachment",
{
serverId, serverId,
sessionId, sessionId,
filePaths: [path], filePaths: [path],
}); }
);
if (!attachmentIds) continue; if (!attachmentIds) continue;
@@ -59,7 +62,10 @@ const FileList = (props: FileListProps) => {
const deleteFile = async (id: string, attachmentId: string) => { const deleteFile = async (id: string, attachmentId: string) => {
setUploadFiles(uploadFiles.filter((file) => file.id !== id)); setUploadFiles(uploadFiles.filter((file) => file.id !== id));
deleteAttachment({ serverId, id: attachmentId }); platformAdapter.commands("delete_attachment", {
serverId,
id: attachmentId,
});
}; };
return ( return (

View File

@@ -1,9 +1,11 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChatMessage } from "@/components/ChatMessage"; import { ChatMessage } from "@/components/ChatMessage";
import { useConnectStore } from "@/stores/connectStore";
export const Greetings = () => { export const Greetings = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const currentAssistant = useConnectStore((state) => state.currentAssistant);
return ( return (
<ChatMessage <ChatMessage
@@ -12,7 +14,9 @@ export const Greetings = () => {
_id: "greetings", _id: "greetings",
_source: { _source: {
type: "assistant", type: "assistant",
message: t("assistant.chat.greetings"), message:
currentAssistant?._source?.chat_settings?.greeting_message ||
t("assistant.chat.greetings"),
}, },
}} }}
/> />

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

@@ -0,0 +1,279 @@
import { useState, useCallback, useEffect, useRef } from "react";
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 } from "@/stores/appStore";
import { useChatStore } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore";
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 {
reconnect: (server?: IServer) => void;
clearChat: () => void;
}
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);
const isTauri = useAppStore((state) => state.isTauri);
const currentService = useConnectStore((state) => state.currentService);
const { setMessages } = useChatStore();
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(
async (resetSelection: boolean) => {
platformAdapter
.commands("list_coco_servers")
.then((res: any) => {
const enabledServers = (res as IServer[]).filter(
(server) => server.enabled && server.available
);
//console.log("list_coco_servers", enabledServers);
setServerList(enabledServers);
if (resetSelection && enabledServers.length > 0) {
const currentServiceExists = enabledServers.find(
(server) => server.id === currentService?.id
);
if (currentServiceExists) {
switchServer(currentServiceExists);
} else {
switchServer(enabledServers[enabledServers.length - 1]);
}
}
})
.catch((err: any) => {
console.error(err);
});
},
[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 !== isCurrentLogin) {
setIsCurrentLogin(!!event.payload);
}
fetchServers(true);
});
return () => {
// Cleanup logic if needed
unlisten.then((fn) => fn());
};
}, []);
const handleRefresh = async () => {
setIsRefreshing(true);
await fetchServers(false);
setTimeout(() => setIsRefreshing(false), 1000);
};
const openSettings = async () => {
platformAdapter.emitEvent("open_settings", "connect");
};
const switchServer = async (server: IServer) => {
if (!server) return;
try {
// Switch UI first, then switch server connection
setCurrentService(server);
setEndpoint(server.endpoint);
setMessages(""); // Clear previous messages
clearChat();
//
if (!server.public && !server.profile) {
setIsCurrentLogin(false);
return;
}
//
setIsCurrentLogin(true);
// The Rust backend will automatically disconnect,
// so we don't need to handle disconnection on the frontend
// src-tauri/src/server/websocket.rs
reconnect && reconnect(server);
} catch (error) {
console.error("switchServer:", error);
}
};
useKeyPress(["uparrow", "downarrow"], (_, key) => {
const isClose = isNil(serverListButtonRef.current?.dataset["open"]);
const length = serverList.length;
if (isClose || length <= 1) return;
const currentIndex = serverList.findIndex((server) => {
return server.id === currentService?.id;
});
let nextIndex = currentIndex;
if (key === "uparrow") {
nextIndex = currentIndex > 0 ? currentIndex - 1 : length - 1;
} else if (key === "downarrow") {
nextIndex = currentIndex < serverList.length - 1 ? currentIndex + 1 : 0;
}
switchServer(serverList[nextIndex]);
});
return (
<Popover className="relative">
<PopoverButton ref={serverListButtonRef} className="flex items-center">
<VisibleKey
shortcut={serviceList}
onKeyPress={() => {
serverListButtonRef.current?.click();
}}
>
<ServerIcon />
</VisibleKey>
</PopoverButton>
<PopoverPanel className="absolute right-0 z-10 mt-2 min-w-[240px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<div className="p-3">
<div className="flex items-center justify-between mb-3 whitespace-nowrap">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
Servers
</h3>
<div className="flex items-center gap-2">
<button
onClick={openSettings}
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
>
<VisibleKey shortcut=",">
<Settings className="h-4 w-4 text-[#0287FF]" />
</VisibleKey>
</button>
<button
onClick={handleRefresh}
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
disabled={isRefreshing}
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCw
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
isRefreshing ? "animate-spin" : ""
}`}
/>
</VisibleKey>
</button>
</div>
</div>
<div className="space-y-1">
{serverList.length > 0 ? (
serverList.map((server) => (
<div
key={server.id}
onClick={() => switchServer(server)}
className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap ${
currentService?.id === server.id
? "bg-gray-100 dark:bg-gray-800"
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
}`}
>
<div className="flex items-center gap-2 overflow-hidden min-w-0">
<img
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.stats?.assistant_count || 1}
</div>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<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 && (
<VisibleKey
shortcut="↓↑"
shortcutClassName="w-6 -translate-x-4"
>
<Check className="w-full h-full text-gray-500 dark:text-gray-400" />
</VisibleKey>
)}
</div>
</div>
</div>
))
) : (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Server className="w-8 h-8 text-gray-400 dark:text-gray-600 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("assistant.chat.noServers")}
</p>
<button
onClick={openSettings}
className="mt-2 text-xs text-[#0287FF] hover:underline"
>
{t("assistant.chat.addServer")}
</button>
</div>
)}
</div>
</div>
</PopoverPanel>
</Popover>
);
}

View File

@@ -1,24 +1,25 @@
import {
AttachmentHit,
deleteAttachment,
getAttachment,
} from "@/api/attachment";
import { useConnectStore } from "@/stores/connectStore";
import clsx from "clsx"; import clsx from "clsx";
import { filesize } from "filesize"; import {filesize} from "filesize";
import { Files, Trash2, X } from "lucide-react"; import {Files, Trash2, X} from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import {useEffect, useMemo, useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import Checkbox from "../Common/Checkbox";
import FileIcon from "../Common/Icons/FileIcon"; 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 platformAdapter from "@/utils/platformAdapter";
interface SessionFileProps { interface SessionFileProps {
sessionId: string; sessionId: string;
} }
const SessionFile = (props: SessionFileProps) => { const SessionFile = (props: SessionFileProps) => {
const { sessionId } = props; const {sessionId} = props;
const { t } = useTranslation(); const {t} = useTranslation();
const isTauri = useAppStore((state) => state.isTauri);
const currentService = useConnectStore((state) => state.currentService); const currentService = useConnectStore((state) => state.currentService);
const [uploadedFiles, setUploadedFiles] = useState<AttachmentHit[]>([]); const [uploadedFiles, setUploadedFiles] = useState<AttachmentHit[]>([]);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
@@ -35,14 +36,26 @@ const SessionFile = (props: SessionFileProps) => {
}, [sessionId]); }, [sessionId]);
const getUploadedFiles = async () => { const getUploadedFiles = async () => {
const response = await getAttachment({ serverId, sessionId }); if (isTauri) {
const response: any = await platformAdapter.commands("get_attachment", {
serverId,
sessionId,
});
setUploadedFiles(response.hits.hits); setUploadedFiles(response?.hits?.hits ?? []);
} else {
}
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
const result = await deleteAttachment({ serverId, id }); let result;
if (isTauri) {
result = await platformAdapter.commands("delete_attachment", {
serverId,
id,
});
} else {
}
if (!result) return; if (!result) return;
getUploadedFiles(); getUploadedFiles();
@@ -50,7 +63,7 @@ const SessionFile = (props: SessionFileProps) => {
const handleCheckAll = (checked: boolean) => { const handleCheckAll = (checked: boolean) => {
if (checked) { if (checked) {
setCheckList(uploadedFiles.map((item) => item._source.id)); setCheckList(uploadedFiles?.map((item) => item?._source?.id));
} else { } else {
setCheckList([]); setCheckList([]);
} }
@@ -67,7 +80,7 @@ const SessionFile = (props: SessionFileProps) => {
return ( return (
<div <div
className={clsx("select-none", { className={clsx("select-none", {
hidden: uploadedFiles.length === 0, hidden: uploadedFiles?.length === 0,
})} })}
> >
<div <div
@@ -76,10 +89,11 @@ const SessionFile = (props: SessionFileProps) => {
setVisible(true); setVisible(true);
}} }}
> >
<Files className="size-5 text-white" /> <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]"> <div
{uploadedFiles.length} 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> </div>
@@ -108,13 +122,13 @@ const SessionFile = (props: SessionFileProps) => {
<Checkbox <Checkbox
indeterminate indeterminate
checked={checkList.length === uploadedFiles.length} checked={checkList?.length === uploadedFiles?.length}
onChange={handleCheckAll} onChange={handleCheckAll}
/> />
</div> </div>
<ul className="flex-1 overflow-auto flex flex-col gap-2 mt-6"> <ul className="flex-1 overflow-auto flex flex-col gap-2 mt-6 p-0">
{uploadedFiles.map((item) => { {uploadedFiles?.map((item) => {
const { id, name, icon, size } = item._source; const {id, name, icon, size} = item._source;
return ( return (
<li <li
@@ -122,7 +136,7 @@ const SessionFile = (props: SessionFileProps) => {
className="flex items-center justify-between min-h-12 px-2 rounded-[4px] bg-[#ededed] dark:bg-[#202126]" 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"> <div className="flex items-center gap-2">
<FileIcon extname={icon} /> <FileIcon extname={icon}/>
<div> <div>
<div className="text-sm leading-4 text-[#333] dark:text-[#D8D8D8]"> <div className="text-sm leading-4 text-[#333] dark:text-[#D8D8D8]">
@@ -131,7 +145,7 @@ const SessionFile = (props: SessionFileProps) => {
<div className="text-xs text-[#999]"> <div className="text-xs text-[#999]">
<span>{icon}</span> <span>{icon}</span>
<span className="pl-2"> <span className="pl-2">
{filesize(size, { standard: "jedec", spacer: "" })} {filesize(size, {standard: "jedec", spacer: ""})}
</span> </span>
</div> </div>
</div> </div>

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