206 Commits

Author SHA1 Message Date
Hardy
2ac81566c6 Fix run shell (#730)
* fix: windows platform run with shell

* chore: add rust target

* fix: fix app version and release body

* chore: update step id

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-06-30 14:29:52 +08:00
Hardy
b004670dec fix: windows platform run with shell (#729)
* fix: windows platform run with shell

* chore: add rust target

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-06-30 14:11:51 +08:00
Hardy
a426e33e6b fix: feature dependcy local path (#728)
* fix: feature dependcy local path

* chore: use build args from env

* chore: remove no use step

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-06-30 13:16:54 +08:00
Hardy
bb7dd6bf7c fix: build error on windows platform with cargo add git repo (#727)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-06-30 12:13:46 +08:00
BiggerRain
37c5f2de24 fix: tray not on display (#726) 2025-06-30 10:53:40 +08:00
SteveLauC
ab6c25fe96 chore: release notes for 0.6.0 (#725) 2025-06-29 17:42:32 +08:00
BiggerRain
1fb464df09 fix: open extension store display (#724) 2025-06-29 17:38:14 +08:00
SteveLauC
65aa75043f chore: bump version number to 0.6.0 (#723) 2025-06-29 17:08:06 +08:00
BiggerRain
79dcc7b4ec fix: text display error (#722)
* fix: text  display error

* fix: text  display error

* fix: select extension display install

* fix: select extension display install
2025-06-29 16:47:02 +08:00
BiggerRain
3d29cfe235 chore: rebuild index position (#721) 2025-06-29 15:53:43 +08:00
BiggerRain
aea3a7ba98 chore: rebuild index (#720)
* chore: rebuild index

* chore: rebuild index
2025-06-29 15:39:01 +08:00
BiggerRain
190dfc6ecd chore: adjust styles and add button reindex (#719)
* chore: adjust styles and add button reindex

* docs: update notes

* style: remove margin bottom
2025-06-29 13:32:07 +08:00
SteveLauC
316a7940d6 chore: log command execution results (#718)
* chore: log command execution results

* release note
2025-06-29 10:46:47 +08:00
SteveLauC
acfc1bb32d feat: interface reindex_applications() (#704)
* feat: impl re-indexing applications

* drop pizza engine
2025-06-29 10:27:02 +08:00
ayangweb
c4d178dc2d feat: support back navigation via delete key (#717)
* feat: support back navigation via delete key

* docs: update changelog
2025-06-27 19:17:27 +08:00
ayangweb
6333c697d5 refactor: support large preview for extensions (#716) 2025-06-27 17:30:49 +08:00
ayangweb
810541494f refactor: update extension detail page ui (#715) 2025-06-27 15:07:34 +08:00
ayangweb
e45dc2acbe fix: context menu search not working (#713) 2025-06-27 14:18:54 +08:00
ayangweb
2d1ccb9744 refactor: improve layout of the extension list (#714) 2025-06-27 14:18:32 +08:00
SteveLauC
406f3b31e9 chore: change extension store request URL to default coco server (#712) 2025-06-27 10:40:12 +08:00
ayangweb
f51dd81014 refactor: optimized some issues with extensions (#711) 2025-06-27 10:22:51 +08:00
SteveLauC
3b38cbfb6c chore: update category name and icon (#710) 2025-06-27 10:16:27 +08:00
ayangweb
a4483ba277 fix: some input fields couldn’t accept spaces (#709)
* fix: some input fields couldn’t accept spaces

* docs: update changelog

* update
2025-06-27 10:16:02 +08:00
ayangweb
bf46979b80 refactor: remove special character filtering and clean up related code (#708) 2025-06-27 10:08:33 +08:00
ayangweb
070f171ad4 refactor: update context menu color for the delete action (#707) 2025-06-27 09:43:35 +08:00
ayangweb
3180704a0d refactor: show all extensions by default in the extension store (#706) 2025-06-27 09:36:20 +08:00
SteveLauC
b3f68697ce feat: impl extension store (#699)
Implements extension store so that users can install extensions from a GUI interface


---------

Co-authored-by: ayang <473033518@qq.com>
2025-06-26 18:40:33 +08:00
BiggerRain
69d2b4b834 chore: add message for latest version check (#703)
* chore: add message for latest version check

* docs: update notes
2025-06-25 10:38:38 +08:00
BiggerRain
6837286061 feat: add manual check for updates (#701)
* feat: add check for update

* feat: add Check for Updates

* docs: update notes

* build: build bundle test

* docs: update notes

* chore: recovering files
2025-06-19 20:58:54 +08:00
ayangweb
a431ead22a feat: support Tab and Enter for delete dialog buttons (#700)
* feat: support `Tab` and `Enter` for delete dialog buttons

* docs: update changelog

* refactor: update
2025-06-19 08:59:01 +08:00
ayangweb
7ec41dfe80 refactor: request data when service is available (#698) 2025-06-18 15:47:49 +08:00
ayangweb
06053e9fd9 refactor: getting service info only when a profile is available (#697)
* refactor: getting service info only when a profile is available

* refactor: update
2025-06-18 14:47:21 +08:00
Medcl
70b048fba3 fix: take coco server back on refresh (#696)
* fix: take coco server back on refresh

* chore: update release notes:
2025-06-18 13:33:59 +08:00
ayangweb
45083f829b refactor: optimized the style of the drop-down selection box (#695)
* refactor: optimized the style of the drop-down selection box

* refactor: update
2025-06-17 18:15:40 +08:00
SteveLauC
e4f6fb8e98 fix: toggle extension should register/unregister hotkey (#691) 2025-06-17 16:56:06 +08:00
BiggerRain
ee182b22da chore: keeping windows and documents safe (#694) 2025-06-17 15:39:18 +08:00
BiggerRain
a37e22c227 fix: quick ai state synchronous (#693)
* fix: quick ai state synchronous

* docs: update notes
2025-06-17 15:38:39 +08:00
BiggerRain
d75ab1018d chore: improve server list selection with enter key (#692)
* chore: server list enter selected

* docs: update notes

* chore: remove log
2025-06-17 09:36:04 +08:00
Medcl
40ad066e69 refactor: refactoring search api (#679)
* refactor: refactoring search api

* chore: interface type

* chore: interface type

* refactor: assistant search

* refactor: arrays into multiple fields

* refactor: update

* feat: search to add fuzziness to 5

* refactor: update

* chore: update release notes

---------

Co-authored-by: rain9 <15911122312@163.com>
Co-authored-by: ayang <473033518@qq.com>
Co-authored-by: ayangweb <75017711+ayangweb@users.noreply.github.com>
2025-06-17 09:31:43 +08:00
BiggerRain
a2a5a9f8fe chore: continue to chat page display (#690)
* chore: Continue to chat page display

* docs: update notes
2025-06-16 18:02:47 +08:00
SteveLauC
5fd9339e56 refactor: use author/ext_id as extension unique identifier (#643)
* refactor: use author/ext_id as extension unique identifier

* refactor: refactoring extended component interfaces and logic

* refactor: update

* style: remove console

* refactor: update

* drop pizza engine

* refactor: restore hotkey upon start no matter if the ext is enabled or not

* chore: release note

---------

Co-authored-by: ayang <473033518@qq.com>
2025-06-16 10:52:01 +08:00
Hardy
a8a9208b1f fix: no make target with project (#689)
* fix: no make with project

* chore: set working directory

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-06-13 22:17:37 +08:00
medcl
8c9a2ff441 v0.5.0 2025-06-13 19:28:38 +08:00
Medcl
2251b0af95 chore: update release notes (#687) 2025-06-13 18:37:47 +08:00
BiggerRain
560a12ab93 fix: search & chat dispaly (#686) 2025-06-13 18:18:46 +08:00
ayangweb
2ff66c0b91 fix: arrow inserting escape sequences (#683)
* fix: arrow inserting escape sequences

* fix build

* docs: update changelog

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-06-13 18:06:21 +08:00
ayangweb
ef4a184233 refactor: optimize the operation of the small assistant on the secondary page (#685)
* refactor: optimize the operation of the small assistant on the secondary page

* refactor: update
2025-06-13 16:13:31 +08:00
ayangweb
8422bc03e7 refactor: optimize the timing of arrow key triggers on secondary pages (#684) 2025-06-13 15:52:20 +08:00
BiggerRain
370113129c fix: web component start page (#681) 2025-06-13 15:17:52 +08:00
ayangweb
cb758ef452 feat: context menu support for secondary pages (#680)
* feat: context menu support for secondary pages

* docs: update changelog
2025-06-13 15:07:05 +08:00
ayangweb
12b9b4bb81 refactor: blocking the default behavior of the tab key (#678)
* refactor: blocking the default behavior of the tab key

* refactor: update

* refactor: update

* refactor: update
2025-06-13 14:19:27 +08:00
BiggerRain
562db19f16 fix: filter services for unlogged-in users (#677) 2025-06-13 11:04:58 +08:00
ayangweb
dc5cd9aecb fix: fix problem with up and down key indexing (#676)
* fix: fix problem with up and down key indexing

* refactor: update

* docs: update changelog
2025-06-13 10:39:27 +08:00
BiggerRain
0b018cd24f chore: search & deep think & mcp (#675)
* fix: keep line breaks

* chore: search & deep think & mcp
2025-06-12 22:06:48 +08:00
BiggerRain
2ed22d3d7c fix: keep line breaks (#674) 2025-06-12 18:20:44 +08:00
BiggerRain
4ce9561eb7 style: safari styles (#673) 2025-06-12 14:50:02 +08:00
BiggerRain
3aeb39b3af refactor: optimize global state synchronization (#672)
* refactor: optimize global state synchronization

* refactor: reconstruct the language change processing logic

---------

Co-authored-by: ayang <473033518@qq.com>
2025-06-12 14:45:33 +08:00
BiggerRain
27e99d4629 fix: web assistant list (#671) 2025-06-12 11:28:10 +08:00
ayangweb
df70276a54 refactor: ai assistant hides the copy menu (#670)
* refactor: ai assistant hides the copy menu

* style: remove console
2025-06-12 10:39:37 +08:00
BiggerRain
6553a8f5d3 chore: add special character filtering (#668)
* chore: add special character filtering

* docs: update notes
2025-06-12 10:31:15 +08:00
ayangweb
4ebbc9ec6e refactor: improved ai overview and ai quick access blank issue (#669)
* refactor: improved ai overview and ai quick access blank issue

* refactor: update
2025-06-12 10:30:41 +08:00
BiggerRain
4208633556 fix: Fix Special Character input (#667) 2025-06-11 17:50:39 +08:00
ayangweb
fc43fbe798 refactor: improve AI assistant interaction logic and Tab key handling (#666)
* refactor: improve AI assistant interaction logic and Tab key handling

* refactor: update

* style: remove
2025-06-11 17:49:05 +08:00
ayangweb
b5bb9105d4 refactor: re-enable the service to get a list of assistants (#665) 2025-06-11 16:28:53 +08:00
BiggerRain
b6ebd6e5f8 fix: web component dispaly (#663)
* fix: web component dispaly

* fix: web component dispaly

* fix: add showChatHistory & connected

* fix: add isCurrentLogin

* chore: add history
2025-06-11 16:28:43 +08:00
ayangweb
22216491b6 refactor: dynamically generated copy button id (#664) 2025-06-11 15:52:49 +08:00
ayangweb
44ca66259c refactor: don't hide pinned window on search result open (#662)
* refactor: don't hide pinned window on search result open

* refactor: update
2025-06-11 15:26:06 +08:00
ayangweb
be3cae36e2 fix: number keys not following settings (#661)
* fix: number keys not following settings

* refactor: remove unused `modifierKey` dependencies

* docs: update changelog
2025-06-11 14:15:32 +08:00
ayangweb
35ea30626f refactor: improve tooltip display in chinese (#660) 2025-06-11 14:01:08 +08:00
BiggerRain
4bcae5cffb fix: delete history (#659) 2025-06-11 13:36:21 +08:00
BiggerRain
76458db8ab chore: remove enter disabled (#658) 2025-06-11 12:10:37 +08:00
BiggerRain
5b41e190d3 chore: add i18n to services (#657) 2025-06-11 11:03:42 +08:00
ayangweb
43ac9a054c refactor: remove the behavior that organizes event bubbling (#656) 2025-06-11 10:14:05 +08:00
BiggerRain
ac485a32cc style: user message styles (#655) 2025-06-10 19:25:54 +08:00
ayangweb
e10908a095 refactor: optimize the timing of the enter key (#654)
* refactor: optimize the timing of the enter key

* fix: remove input element

---------

Co-authored-by: rain <15911122312@163.com>
2025-06-10 19:01:25 +08:00
BiggerRain
78b8908ac8 fix: stop event bubbling (#653) 2025-06-10 18:22:54 +08:00
ayangweb
3c54cb84a8 refactor: filter unavailable servers (#652) 2025-06-10 17:37:57 +08:00
ayangweb
8ed808c591 fix: fix the problem of local path not opening (#650)
* fix: fix the problem of local path not opening

* docs: update changelog

* chore: remove pizza-engine
2025-06-10 17:26:19 +08:00
ayangweb
7a2dde7448 refactor: check if the message block is purely blank (#651) 2025-06-10 17:22:13 +08:00
BiggerRain
65451fc63e style: user message line break (#648) 2025-06-10 15:41:08 +08:00
BiggerRain
5d108a46d3 style: differentiate between hover and selected styles (#649) 2025-06-10 15:37:17 +08:00
BiggerRain
f9567c2d46 chore: remove defalut current service (#647) 2025-06-10 14:54:07 +08:00
BiggerRain
da917e6012 fix: web page unmount event (#645)
* fix: web page unmont event

* docs: update notes
2025-06-10 14:28:00 +08:00
ayangweb
335a906674 refactor: refactoring shortcut reset logic and optimizing UI interactions (#646) 2025-06-10 14:27:23 +08:00
ayangweb
a50a636d59 fix: input lost when reopening dialog after search (#644)
* fix: input lost when reopening dialog after search

* docs: update changelog
2025-06-10 11:45:45 +08:00
ayangweb
2dd3f776e6 fix: arrow keys still navigated search when menu opened with Cmd+K (#642)
* fix: arrow keys still navigated search when menu opened with `Cmd+K`

* docs: update changelog
2025-06-10 09:56:27 +08:00
BiggerRain
40f6aa0ccd chore: copy supports http protocol (#639)
* chore: copy supports http protocol

* docs: update notes
2025-06-09 18:12:43 +08:00
ayangweb
4da9e024e0 refactor: update login status when service is not enabled (#638) 2025-06-09 18:11:35 +08:00
ayangweb
c20bba51f5 fix: tab key hides window in chat mode (#641)
* fix: tab key hides window in chat mode

* docs: update changelog
2025-06-09 18:10:56 +08:00
BiggerRain
0a62a2095b fix: add shift line break to chat input (#637) 2025-06-09 15:06:59 +08:00
SteveLauC
5677995185 chore: more logs for the setup process (#634)
* chore: more logs for the setup process

* chore: more logs for the setup process

* chore: more logs for the setup process

* chore: release note
2025-06-09 14:46:06 +08:00
BiggerRain
ec4e5e7d1d fix: remove stopImmediatePropagation event (#636) 2025-06-09 12:05:27 +08:00
BiggerRain
1df5265b1a chore: add onContextMenu event (#629) 2025-06-09 11:57:48 +08:00
ayangweb
fb8a4684dc refactor: improved page content after disabling the service (#635)
* refactor: improved page content after disabling the service

* style: remove unless code

* style: remove unless code
2025-06-09 11:54:44 +08:00
BiggerRain
0b609e570d chore: web component default mode (#627) 2025-06-09 09:54:09 +08:00
BiggerRain
f91f6bdc17 fix: web component set IsDark (#630) 2025-06-07 10:49:16 +08:00
ayangweb
57590f3b57 feat: add internationalized translations of AI-related extensions (#632)
* feat: add internationalized translations of AI-related extensions

* docs: update changelog

* refactor: update
2025-06-07 10:48:55 +08:00
ayangweb
c18f9ea154 refactor: optimized input box logic for transparency (#628) 2025-06-06 17:58:18 +08:00
ayangweb
441875d9b4 refactor: optimize data filtering logic (#626) 2025-06-06 17:20:45 +08:00
ayangweb
eddf9075bb feat: add ai overview minimum number of search results configuration (#625)
* feat: add ai overview minimum number of search results configuration

* docs: update changelog

* style: remove unless code
2025-06-06 17:05:20 +08:00
ayangweb
9eac8f8a8e feat: support right-click actions after text selection (#624)
* feat: support right-click actions after text selection

* docs: update changelog

* feat: support for selecting messages sent by users
2025-06-06 16:43:27 +08:00
ayangweb
515260c43f feat: calculator extension add description (#623)
* feat: calculator extension add description

* docs: update changelog
2025-06-06 15:43:24 +08:00
ayangweb
118de0e80b fix: fix ai overview hidden height before message (#622)
* fix: fix ai overview hidden height before message

* docs: update changelog
2025-06-06 15:30:42 +08:00
SteveLauC
19ce896fdc chore: release note for PR 620 (#621) 2025-06-06 15:17:59 +08:00
SteveLauC
4a41ea5d8b fix: invalid DSL error if input contains multiple lines (#620) 2025-06-06 14:58:45 +08:00
ayangweb
880e1206ce fix: fixed modifier keys not working with continue chat (#619)
* fix: fixed modifier keys not working with continue chat

* docs: update changelog
2025-06-06 14:24:36 +08:00
SteveLauC
1e6d9f9550 fix: do not panic when the datasource specified does not exist (#618)
* fix: do not panic when the datasource specified does not exist

* release note
2025-06-06 14:07:27 +08:00
BiggerRain
ff0faf425f fix: only select history and then set the assistant (#617)
* fix: only select history and then set the assistant

* fix: only select history and then set the assistant
2025-06-06 14:06:49 +08:00
ayangweb
1fbf5d6552 fix: resolved an issue where number keys were not working on the web (#616)
* fix: resolved an issue where number keys were not working on the web

* docs: update changelog
2025-06-06 11:47:38 +08:00
ayangweb
db41e817c3 feat: add key monitoring during reset (#615)
* feat: add key monitoring during reset

* docs: update changelog
2025-06-06 11:23:40 +08:00
BiggerRain
1296755bc5 fix: datasource and mcp data updates (#614) 2025-06-06 11:11:33 +08:00
ayangweb
d410f20864 refactor: remove footer from standalone history window (#613) 2025-06-06 11:11:06 +08:00
ayangweb
61d0a3b79a fix: fix chat log update and sorting issues (#612)
* fix: fix chat log update and sorting issues

* docs: update changelog
2025-06-06 10:52:47 +08:00
BiggerRain
b24319b649 fix: datasource refresh status feedback (#611) 2025-06-06 10:51:31 +08:00
BiggerRain
3c0fb24548 fix: shortcut key prompts cannot be hidden (#610) 2025-06-06 10:51:09 +08:00
BiggerRain
2fcbed0381 fix: i18n is not accurate (#609) 2025-06-06 10:50:36 +08:00
SteveLauC
7444347e0c docs: new doc for macOS (#608) 2025-06-05 19:23:14 +08:00
SteveLauC
725ce042de docs: remove the hyperlink in title (#607) 2025-06-05 18:26:09 +08:00
BiggerRain
3b67de5387 chore: initialize current assistant from history (#606)
* chore: the last assistant in history is set as current

* docs: update notes

* docs: update notes
2025-06-05 08:54:39 +08:00
SteveLauC
9b53a026ff refactor: execute Calculator/Extension search() in spawn_blocking (#601) 2025-06-04 18:45:17 +08:00
ayangweb
9ea7dbf3aa fix: resolve regex error on older macOS versions (#605)
* fix: fix: resolve regex error on older macOS versions

* docs: update changelog

* style: remove unless code

* style: remove unless code
2025-06-04 18:38:34 +08:00
BiggerRain
55622911ac style: Switch selected color in dark mode (#604) 2025-06-04 14:10:17 +08:00
BiggerRain
92f78ad08c fix: new chat assistant id not found (#603)
* fix: new chat assistant id

* docs: update notes
2025-06-04 13:06:30 +08:00
ayangweb
f690dbaab2 refactor: web use the default icon for now (#602) 2025-06-04 11:30:59 +08:00
ayangweb
210efe763d fix: fixed issue with incorrect login status (#600)
* fix: fixed issue with incorrect login status

* style: remove unless code

* fix: user avatar error

* refactor: replace with default svg icon

* style: remove unless code

* docs: update changelog

---------

Co-authored-by: rain <15911122312@163.com>
2025-06-04 10:24:56 +08:00
BiggerRain
f23498afa0 fix: web icon isAbsolute (#599) 2025-06-03 19:28:26 +08:00
BiggerRain
a80a5d928f fix: app icon load console error (#598) 2025-06-03 15:47:58 +08:00
ayangweb
b733bb5516 feat: ai overview support is enabled with shortcut (#597)
* feat: ai overview support is enabled with shortcut

* docs: update changelog
2025-06-03 15:01:29 +08:00
ayangweb
5046754534 refactor: optimized loading of font icons on the web side (#596)
* refactor: optimized loading of font icons on the web side

* refactor: update
2025-06-03 11:22:22 +08:00
SteveLauC
f557f7e780 chore: set log level to coco_lib=trace for built Coco app (#595) 2025-06-03 11:18:28 +08:00
BiggerRain
18feb2d690 fix: set chat message assistant (#594) 2025-06-03 10:53:01 +08:00
BiggerRain
af59f2fe9f fix: web component removes redundant parameters (#593) 2025-06-03 10:35:26 +08:00
BiggerRain
5e1bb54d5e chore: web component adds variable process (#592) 2025-06-03 10:12:22 +08:00
Hardy
33fa516aad fix: rustup for i688 (#590)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-06-01 07:19:28 +08:00
Hardy
d2c1cf513d chore: use version fix (#591)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-05-31 20:22:53 +08:00
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
218 changed files with 13979 additions and 5221 deletions

View File

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

View File

@@ -9,10 +9,16 @@ on:
jobs: jobs:
create-release: create-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
APP_VERSION: ${{ steps.get-version.outputs.APP_VERSION }}
RELEASE_BODY: ${{ steps.get-changelog.outputs.RELEASE_BODY }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set output - name: Set output
id: vars id: vars
run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
@@ -22,11 +28,28 @@ jobs:
with: with:
node-version: 20 node-version: 20
- name: Get build version
shell: bash
id: get-version
run: |
PACKAGE_VERSION=$(jq -r '.version' package.json)
CARGO_VERSION=$(grep -m 1 '^version =' src-tauri/Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
if [ "$PACKAGE_VERSION" != "$CARGO_VERSION" ]; then
echo "::error::Version mismatch!"
else
echo "Version match: $PACKAGE_VERSION"
fi
echo "APP_VERSION=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
- name: Generate changelog - name: Generate changelog
id: create_release id: get-changelog
run: npx changelogithub --draft --name ${{ steps.vars.outputs.tag }} run: |
CHANGELOG_BODY=$(npx changelogithub --draft --name ${{ steps.vars.outputs.tag }})
echo "RELEASE_BODY<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
env: env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-app: build-app:
needs: create-release needs: create-release
@@ -52,11 +75,24 @@ jobs:
target: "x86_64-unknown-linux-gnu" target: "x86_64-unknown-linux-gnu"
- platform: "ubuntu-22.04-arm" - platform: "ubuntu-22.04-arm"
target: "aarch64-unknown-linux-gnu" target: "aarch64-unknown-linux-gnu"
env:
APP_VERSION: ${{ needs.create-release.outputs.APP_VERSION }}
RELEASE_BODY: ${{ needs.create-release.outputs.RELEASE_BODY }}
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Checkout dependency repository
uses: actions/checkout@v4
with:
repository: 'infinilabs/pizza'
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
submodules: recursive
ref: main
path: pizza
- name: Setup node - name: Setup node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@@ -65,17 +101,31 @@ jobs:
with: with:
version: latest version: latest
- name: Install rust target
run: rustup target add ${{ matrix.target }}
- name: Install dependencies (ubuntu only) - name: Install dependencies (ubuntu only)
if: startsWith(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 xdg-utils sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Install Rust stable - name: Add Rust build target at ${{ matrix.platform}} for ${{ matrix.target }}
run: rustup toolchain install stable working-directory: src-tauri
shell: bash
run: |
rustup target add ${{ matrix.target }} || true
- name: Add pizza engine as a dependency
working-directory: src-tauri
shell: bash
run: |
BUILD_ARGS="--target ${{ matrix.target }}"
if [[ "${{matrix.target }}" != "i686-pc-windows-msvc" ]]; then
echo "Adding pizza engine as a dependency for ${{matrix.platform }}-${{matrix.target }}"
( cargo add --path ../pizza/lib/engine --features query_string_parser,persistence )
BUILD_ARGS+=" --features use_pizza_engine"
else
echo "Skipping pizza engine dependency for ${{matrix.platform }}-${{matrix.target }}"
fi
echo "BUILD_ARGS=${BUILD_ARGS}" >> $GITHUB_ENV
- name: Rust cache - name: Rust cache
uses: swatinem/rust-cache@v2 uses: swatinem/rust-cache@v2
@@ -91,7 +141,7 @@ 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: Build the coco at ${{ matrix.platform}} for ${{ matrix.target }} @ ${{ env.APP_VERSION }}
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
CI: false CI: false
@@ -107,8 +157,8 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with: with:
tagName: ${{ github.ref_name }} tagName: ${{ github.ref_name }}
releaseName: Coco ${{ needs.create-release.outputs.APP_VERSION }} releaseName: Coco ${{ env.APP_VERSION }}
releaseBody: "" releaseBody: "${{ env.RELEASE_BODY }}"
releaseDraft: true releaseDraft: true
prerelease: false prerelease: false
args: --target ${{ matrix.target }} args: ${{ env.BUILD_ARGS }}

View File

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

View File

@@ -79,3 +79,7 @@ clean-rebuild:
add-dep-pizza-engine: add-dep-pizza-engine:
cd src-tauri && cargo add --git ssh://git@github.com/infinilabs/pizza.git pizza-engine --features query_string_parser,persistence cd src-tauri && cargo add --git ssh://git@github.com/infinilabs/pizza.git pizza-engine --features query_string_parser,persistence
dev-build-with-pizza: add-dep-pizza-engine
@echo "Starting desktop development with Pizza Engine pulled in..."
RUST_BACKTRACE=1 pnpm tauri dev --features use_pizza_engine

View File

@@ -1,21 +1,35 @@
--- ---
weight: 10 weight: 10
title: "Mac OS" title: "macOS"
asciinema: true asciinema: true
--- ---
# Mac OS # macOS
## Download Coco AI ## Download Coco AI
Goto [https://coco.rs/](https://coco.rs/) Go to [coco.rs](https://coco.rs/) and download the package of your architecture:
{{% load-img "/img/download-mac-app.png" "" %}} {{% load-img "/img/macos/mac-download-app.png" "" %}}
It should be placed in your "Downloads" folder:
{{% load-img "/img/macos/mac-zip-file.png" "" %}}
## Unzip DMG file ## Unzip DMG file
{{% load-img "/img/unzip-dmg-file.png" "" %}} Unzip the file:
{{% load-img "/img/macos/mac-unzip-zip-file.png" "" %}}
You will get a `dmg` file:
{{% load-img "/img/macos/mac-dmg.png" "" %}}
## Drag to Application Folder ## Drag to Application Folder
{{% load-img "/img/drag-to-application-folder.png" "" %}} Double click the `dmg` file, a window will pop up. Then drag the "Coco-AI" app to
your "Applications" folder:
{{% load-img "/img/macos/drag-to-app-folder.png" "" %}}

View File

@@ -14,7 +14,9 @@ asciinema: true
[if_x11]: https://unix.stackexchange.com/q/202891/498440 [if_x11]: https://unix.stackexchange.com/q/202891/498440
## Goto [https://coco.rs/](https://coco.rs/) ## Go to the download page
Download page: [link](https://coco.rs/#install)
## Download the package ## Download the package

View File

@@ -9,6 +9,43 @@ Information about release notes of Coco Server is provided here.
## Latest (In development) ## Latest (In development)
### ❌ Breaking changes
### 🚀 Features
### 🐛 Bug fix
### ✈️ Improvements
## 0.6.0 (2025-06-29)
### ❌ Breaking changes
### 🚀 Features
- feat: support `Tab` and `Enter` for delete dialog buttons #700
- feat: add check for updates #701
- feat: impl extension store #699
- feat: support back navigation via delete key #717
### 🐛 Bug fix
- fix: quick ai state synchronous #693
- fix: toggle extension should register/unregister hotkey #691
- fix: take coco server back on refresh #696
- fix: some input fields couldnt accept spaces #709
- fix: context menu search not working #713
- fix: open extension store display #724
### ✈️ Improvements
- refactor: use author/ext_id as extension unique identifier #643
- refactor: refactoring search api #679
- chore: continue to chat page display #690
- chore: improve server list selection with enter key #692
- chore: add message for latest version check #703
- chore: log command execution results #718
- chore: adjust styles and add button reindex #719
## 0.5.0 (2025-06-13)
### ❌ Breaking changes ### ❌ Breaking changes
### 🚀 Features ### 🚀 Features
@@ -24,17 +61,59 @@ Information about release notes of Coco Server is provided here.
- feat: the search input box supports multi-line input #501 - feat: the search input box supports multi-line input #501
- feat: websocket support self-signed TLS #504 - feat: websocket support self-signed TLS #504
- feat: add option to allow self-signed certificates #509 - 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
- feat: ai overview support is enabled with shortcut #597
- feat: add key monitoring during reset #615
- feat: calculator extension add description #623
- feat: support right-click actions after text selection #624
- feat: add ai overview minimum number of search results configuration #625
- feat: add internationalized translations of AI-related extensions #632
- feat: context menu support for secondary pages #680
### 🐛 Bug fix ### 🐛 Bug fix
- fix: solve the problem of modifying the assistant in the chat #476
- fix: several issues around search #502 - fix: several issues around search #502
- fix: fixed the newly created session has no title when it is deleted #511 - fix: fixed the newly created session has no title when it is deleted #511
- fix: loading chat history for potential empty attachments - 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
- fix: fixed issue with incorrect login status #600
- fix: new chat assistant id not found #603
- fix: resolve regex error on older macOS versions #605
- fix: fix chat log update and sorting issues #612
- fix: resolved an issue where number keys were not working on the web #616
- fix: do not panic when the datasource specified does not exist #618
- fix: fixed modifier keys not working with continue chat #619
- fix: invalid DSL error if input contains multiple lines #620
- fix: fix ai overview hidden height before message #622
- fix: tab key hides window in chat mode #641
- fix: arrow keys still navigated search when menu opened with Cmd+K #642
- fix: input lost when reopening dialog after search #644
- fix: web page unmount event #645
- fix: fix the problem of local path not opening #650
- fix: number keys not following settings #661
- fix: fix problem with up and down key indexing #676
- fix: arrow inserting escape sequences #683
### ✈️ Improvements ### ✈️ Improvements
- chore: adjust list error message #475 - chore: adjust list error message #475
- fix: solve the problem of modifying the assistant in the chat #476
- chore: refine wording on search failure - chore: refine wording on search failure
- choresearch and MCP show hidden logic #494 - choresearch and MCP show hidden logic #494
- chore: greetings show hidden logic #496 - chore: greetings show hidden logic #496
@@ -45,6 +124,32 @@ Information about release notes of Coco Server is provided here.
- refactor: optimized the modification operation of the numeric input box #508 - refactor: optimized the modification operation of the numeric input box #508
- style: modify the style of the search input box #513 - style: modify the style of the search input box #513
- style: chat input icons show #515 - 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
- chore: initialize current assistant from history #606
- chore: add onContextMenu event #629
- chore: more logs for the setup process #634
- chore: copy supports http protocol #639
- refactor: use author/ext_id as extension unique identifier #643
- chore: add special character filtering #668
## 0.4.0 (2025-04-27) ## 0.4.0 (2025-04-27)
@@ -74,6 +179,8 @@ Information about release notes of Coco Server is provided here.
- feat: data sources support displaying customized icons #432 - feat: data sources support displaying customized icons #432
- feat: add shortcut key conflict hint and reset function #442 - feat: add shortcut key conflict hint and reset function #442
- feat: updated to include error message #465 - feat: updated to include error message #465
- feat: support third party extensions #572
- feat: support ai overview #572
### Bug fix ### Bug fix

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

BIN
docs/static/img/macos/mac-dmg.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

BIN
docs/static/img/macos/mac-zip-file.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -1,7 +1,7 @@
{ {
"name": "coco", "name": "coco",
"private": true, "private": true,
"version": "0.4.0", "version": "0.6.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -27,6 +27,7 @@
"@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-log": "~2.4.0",
"@tauri-apps/plugin-opener": "^2.2.7",
"@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1", "@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.1", "@tauri-apps/plugin-shell": "^2.2.1",
@@ -44,6 +45,7 @@
"i18next-browser-languagedetector": "^8.1.0", "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",
"mdast-util-gfm-autolink-literal": "2.0.0",
"mermaid": "^11.6.0", "mermaid": "^11.6.0",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"react": "^18.3.1", "react": "^18.3.1",
@@ -58,10 +60,12 @@
"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",
"tailwind-merge": "^3.3.1",
"tauri-plugin-fs-pro-api": "^2.4.0", "tauri-plugin-fs-pro-api": "^2.4.0",
"tauri-plugin-macos-permissions-api": "^2.3.0", "tauri-plugin-macos-permissions-api": "^2.3.0",
"tauri-plugin-screenshots-api": "^2.2.0", "tauri-plugin-screenshots-api": "^2.2.0",
"tauri-plugin-windows-version-api": "^2.0.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.5", "wavesurfer.js": "^7.9.5",
@@ -89,5 +93,6 @@
"tsx": "^4.19.4", "tsx": "^4.19.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^5.4.19" "vite": "^5.4.19"
} },
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
} }

86
pnpm-lock.yaml generated
View File

@@ -35,6 +35,9 @@ importers:
'@tauri-apps/plugin-log': '@tauri-apps/plugin-log':
specifier: ~2.4.0 specifier: ~2.4.0
version: 2.4.0 version: 2.4.0
'@tauri-apps/plugin-opener':
specifier: ^2.2.7
version: 2.2.7
'@tauri-apps/plugin-os': '@tauri-apps/plugin-os':
specifier: ^2.2.1 specifier: ^2.2.1
version: 2.2.1 version: 2.2.1
@@ -86,6 +89,9 @@ importers:
lucide-react: lucide-react:
specifier: ^0.461.0 specifier: ^0.461.0
version: 0.461.0(react@18.3.1) version: 0.461.0(react@18.3.1)
mdast-util-gfm-autolink-literal:
specifier: 2.0.0
version: 2.0.0
mermaid: mermaid:
specifier: ^11.6.0 specifier: ^11.6.0
version: 11.6.0 version: 11.6.0
@@ -128,6 +134,9 @@ importers:
remark-math: remark-math:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
tauri-plugin-fs-pro-api: tauri-plugin-fs-pro-api:
specifier: ^2.4.0 specifier: ^2.4.0
version: 2.4.0 version: 2.4.0
@@ -140,6 +149,9 @@ importers:
tauri-plugin-windows-version-api: tauri-plugin-windows-version-api:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
type-fest:
specifier: ^4.41.0
version: 4.41.0
use-debounce: use-debounce:
specifier: ^10.0.4 specifier: ^10.0.4
version: 10.0.4(react@18.3.1) version: 10.0.4(react@18.3.1)
@@ -182,7 +194,7 @@ importers:
version: 1.8.8 version: 1.8.8
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.4.1 specifier: ^4.4.1
version: 4.4.1(vite@5.4.19(@types/node@22.15.17)(sass@1.87.0)) version: 4.4.1(vite@5.4.19(@types/node@22.15.17)(sass@1.87.0)(terser@5.40.0))
autoprefixer: autoprefixer:
specifier: ^10.4.21 specifier: ^10.4.21
version: 10.4.21(postcss@8.5.3) version: 10.4.21(postcss@8.5.3)
@@ -215,7 +227,7 @@ importers:
version: 5.8.3 version: 5.8.3
vite: vite:
specifier: ^5.4.19 specifier: ^5.4.19
version: 5.4.19(@types/node@22.15.17)(sass@1.87.0) version: 5.4.19(@types/node@22.15.17)(sass@1.87.0)(terser@5.40.0)
packages: packages:
@@ -813,6 +825,9 @@ packages:
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
'@jridgewell/source-map@0.3.6':
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
'@jridgewell/sourcemap-codec@1.5.0': '@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
@@ -1256,6 +1271,9 @@ packages:
'@tauri-apps/plugin-log@2.4.0': '@tauri-apps/plugin-log@2.4.0':
resolution: {integrity: sha512-j7yrDtLNmayCBOO2esl3aZv9jSXy2an8MDLry3Ys9ZXerwUg35n1Y2uD8HoCR+8Ng/EUgx215+qOUfJasjYrHw==} resolution: {integrity: sha512-j7yrDtLNmayCBOO2esl3aZv9jSXy2an8MDLry3Ys9ZXerwUg35n1Y2uD8HoCR+8Ng/EUgx215+qOUfJasjYrHw==}
'@tauri-apps/plugin-opener@2.2.7':
resolution: {integrity: sha512-uduEyvOdjpPOEeDRrhwlCspG/f9EQalHumWBtLBnp3fRp++fKGLqDOyUhSIn7PzX45b/rKep//ZQSAQoIxobLA==}
'@tauri-apps/plugin-os@2.2.1': '@tauri-apps/plugin-os@2.2.1':
resolution: {integrity: sha512-cNYpNri2CCc6BaNeB6G/mOtLvg8dFyFQyCUdf2y0K8PIAKGEWdEcu8DECkydU2B+oj4OJihDPD2de5K6cbVl9A==} resolution: {integrity: sha512-cNYpNri2CCc6BaNeB6G/mOtLvg8dFyFQyCUdf2y0K8PIAKGEWdEcu8DECkydU2B+oj4OJihDPD2de5K6cbVl9A==}
@@ -1583,6 +1601,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true hasBin: true
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
bundle-name@4.1.0: bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1695,6 +1716,9 @@ packages:
comma-separated-tokens@2.0.3: comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
commander@4.1.1: commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -2640,8 +2664,8 @@ packages:
mdast-util-from-markdown@2.0.2: mdast-util-from-markdown@2.0.2:
resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
mdast-util-gfm-autolink-literal@2.0.1: mdast-util-gfm-autolink-literal@2.0.0:
resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} resolution: {integrity: sha512-FyzMsduZZHSc3i0Px3PQcBT4WJY/X/RCtEJKuybiC6sjPqLv7h1yqAkmILZtuxMSsUyaLUWNp71+vQH2zqp5cg==}
mdast-util-gfm-footnote@2.1.0: mdast-util-gfm-footnote@2.1.0:
resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
@@ -3346,6 +3370,9 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
source-map-support@0.5.21:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
source-map@0.6.1: source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3423,6 +3450,9 @@ packages:
tabbable@6.2.0: tabbable@6.2.0:
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
tailwindcss@3.4.17: tailwindcss@3.4.17:
resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==} resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -3440,6 +3470,11 @@ packages:
tauri-plugin-windows-version-api@2.0.0: tauri-plugin-windows-version-api@2.0.0:
resolution: {integrity: sha512-tty5n4ASYbXpnsD5ws2iTcTTpDCrSbzRTVp5Bo3UTpYGqlN1gBn2Zk8s3oO4w7VIM5WtJhDM9Jr/UgoTk7tFJQ==} resolution: {integrity: sha512-tty5n4ASYbXpnsD5ws2iTcTTpDCrSbzRTVp5Bo3UTpYGqlN1gBn2Zk8s3oO4w7VIM5WtJhDM9Jr/UgoTk7tFJQ==}
terser@5.40.0:
resolution: {integrity: sha512-cfeKl/jjwSR5ar7d0FGmave9hFGJT8obyo0z+CrQOylLDbk7X81nPU6vq9VORa5jU30SkDnT2FXjLbR8HLP+xA==}
engines: {node: '>=10'}
hasBin: true
thenify-all@1.6.0: thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@@ -4260,6 +4295,12 @@ snapshots:
'@jridgewell/set-array@1.2.1': {} '@jridgewell/set-array@1.2.1': {}
'@jridgewell/source-map@0.3.6':
dependencies:
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
optional: true
'@jridgewell/sourcemap-codec@1.5.0': {} '@jridgewell/sourcemap-codec@1.5.0': {}
'@jridgewell/trace-mapping@0.3.25': '@jridgewell/trace-mapping@0.3.25':
@@ -4637,6 +4678,10 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.5.0 '@tauri-apps/api': 2.5.0
'@tauri-apps/plugin-opener@2.2.7':
dependencies:
'@tauri-apps/api': 2.5.0
'@tauri-apps/plugin-os@2.2.1': '@tauri-apps/plugin-os@2.2.1':
dependencies: dependencies:
'@tauri-apps/api': 2.5.0 '@tauri-apps/api': 2.5.0
@@ -4878,14 +4923,14 @@ snapshots:
'@ungap/structured-clone@1.3.0': {} '@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@4.4.1(vite@5.4.19(@types/node@22.15.17)(sass@1.87.0))': '@vitejs/plugin-react@4.4.1(vite@5.4.19(@types/node@22.15.17)(sass@1.87.0)(terser@5.40.0))':
dependencies: dependencies:
'@babel/core': 7.27.1 '@babel/core': 7.27.1
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.1) '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.1)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.1) '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.1)
'@types/babel__core': 7.20.5 '@types/babel__core': 7.20.5
react-refresh: 0.17.0 react-refresh: 0.17.0
vite: 5.4.19(@types/node@22.15.17)(sass@1.87.0) vite: 5.4.19(@types/node@22.15.17)(sass@1.87.0)(terser@5.40.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5014,6 +5059,9 @@ snapshots:
node-releases: 2.0.19 node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.24.5) update-browserslist-db: 1.1.3(browserslist@4.24.5)
buffer-from@1.1.2:
optional: true
bundle-name@4.1.0: bundle-name@4.1.0:
dependencies: dependencies:
run-applescript: 7.0.0 run-applescript: 7.0.0
@@ -5110,6 +5158,9 @@ snapshots:
comma-separated-tokens@2.0.3: {} comma-separated-tokens@2.0.3: {}
commander@2.20.3:
optional: true
commander@4.1.1: {} commander@4.1.1: {}
commander@7.2.0: {} commander@7.2.0: {}
@@ -6114,7 +6165,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
mdast-util-gfm-autolink-literal@2.0.1: mdast-util-gfm-autolink-literal@2.0.0:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
ccount: 2.0.1 ccount: 2.0.1
@@ -6162,7 +6213,7 @@ snapshots:
mdast-util-gfm@3.1.0: mdast-util-gfm@3.1.0:
dependencies: dependencies:
mdast-util-from-markdown: 2.0.2 mdast-util-from-markdown: 2.0.2
mdast-util-gfm-autolink-literal: 2.0.1 mdast-util-gfm-autolink-literal: 2.0.0
mdast-util-gfm-footnote: 2.1.0 mdast-util-gfm-footnote: 2.1.0
mdast-util-gfm-strikethrough: 2.0.0 mdast-util-gfm-strikethrough: 2.0.0
mdast-util-gfm-table: 2.0.0 mdast-util-gfm-table: 2.0.0
@@ -7121,6 +7172,12 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
source-map-support@0.5.21:
dependencies:
buffer-from: 1.1.2
source-map: 0.6.1
optional: true
source-map@0.6.1: source-map@0.6.1:
optional: true optional: true
@@ -7197,6 +7254,8 @@ snapshots:
tabbable@6.2.0: {} tabbable@6.2.0: {}
tailwind-merge@3.3.1: {}
tailwindcss@3.4.17: tailwindcss@3.4.17:
dependencies: dependencies:
'@alloc/quick-lru': 5.2.0 '@alloc/quick-lru': 5.2.0
@@ -7240,6 +7299,14 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.5.0 '@tauri-apps/api': 2.5.0
terser@5.40.0:
dependencies:
'@jridgewell/source-map': 0.3.6
acorn: 8.14.1
commander: 2.20.3
source-map-support: 0.5.21
optional: true
thenify-all@1.6.0: thenify-all@1.6.0:
dependencies: dependencies:
thenify: 3.3.1 thenify: 3.3.1
@@ -7426,7 +7493,7 @@ snapshots:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
vfile-message: 4.0.2 vfile-message: 4.0.2
vite@5.4.19(@types/node@22.15.17)(sass@1.87.0): vite@5.4.19(@types/node@22.15.17)(sass@1.87.0)(terser@5.40.0):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
postcss: 8.5.3 postcss: 8.5.3
@@ -7435,6 +7502,7 @@ snapshots:
'@types/node': 22.15.17 '@types/node': 22.15.17
fsevents: 2.3.3 fsevents: 2.3.3
sass: 1.87.0 sass: 1.87.0
terser: 5.40.0
void-elements@3.1.0: {} void-elements@3.1.0: {}

File diff suppressed because one or more lines are too long

1
scripts/devWeb.ts Normal file
View File

@@ -0,0 +1 @@
(() => {})();

307
src-tauri/Cargo.lock generated
View File

@@ -17,6 +17,17 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.7.8" version = "0.7.8"
@@ -518,6 +529,26 @@ dependencies = [
"piper", "piper",
] ]
[[package]]
name = "borrowme"
version = "0.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc75de882fa904cab1f2efe7184a871121d12eacebd181cbd2a15ff7af84c519"
dependencies = [
"borrowme-macros",
]
[[package]]
name = "borrowme-macros"
version = "0.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b00f4d9b344db5782c18429dac7a9af047d522f1d9ba1617d715aaffbdc040d0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]] [[package]]
name = "borsh" name = "borsh"
version = "1.5.7" version = "1.5.7"
@@ -634,6 +665,25 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "bzip2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]] [[package]]
name = "cairo-rs" name = "cairo-rs"
version = "0.18.5" version = "0.18.5"
@@ -794,6 +844,16 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "3.2.25" version = "3.2.25"
@@ -821,15 +881,20 @@ dependencies = [
[[package]] [[package]]
name = "coco" name = "coco"
version = "0.4.0" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow",
"applications", "applications",
"async-recursion",
"async-trait", "async-trait",
"base64 0.13.1", "base64 0.13.1",
"borrowme",
"chinese-number", "chinese-number",
"chrono", "chrono",
"derive_more 2.0.1",
"dirs 5.0.1", "dirs 5.0.1",
"enigo", "enigo",
"function_name",
"futures", "futures",
"futures-util", "futures-util",
"hostname", "hostname",
@@ -844,9 +909,12 @@ dependencies = [
"ordered-float", "ordered-float",
"pizza-common", "pizza-common",
"plist", "plist",
"regex",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"serde_plain",
"strsim 0.10.0",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-nspanel", "tauri-nspanel",
@@ -860,6 +928,7 @@ dependencies = [
"tauri-plugin-http", "tauri-plugin-http",
"tauri-plugin-log", "tauri-plugin-log",
"tauri-plugin-macos-permissions", "tauri-plugin-macos-permissions",
"tauri-plugin-opener",
"tauri-plugin-os", "tauri-plugin-os",
"tauri-plugin-process", "tauri-plugin-process",
"tauri-plugin-screenshots", "tauri-plugin-screenshots",
@@ -877,6 +946,7 @@ dependencies = [
"tungstenite 0.24.0", "tungstenite 0.24.0",
"url", "url",
"walkdir", "walkdir",
"zip 4.0.0",
] ]
[[package]] [[package]]
@@ -984,6 +1054,12 @@ dependencies = [
"tiny-keccak", "tiny-keccak",
] ]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.4.0" version = "0.4.0"
@@ -1256,6 +1332,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "deflate64"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.4.0" version = "0.4.0"
@@ -1290,6 +1372,27 @@ dependencies = [
"syn 2.0.101", "syn 2.0.101",
] ]
[[package]]
name = "derive_more"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
"unicode-xid",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -1298,6 +1401,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@@ -1740,6 +1844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"libz-rs-sys",
"miniz_oxide", "miniz_oxide",
] ]
@@ -1825,6 +1930,21 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "function_name"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1ab577a896d09940b5fe12ec5ae71f9d8211fff62c919c03a3750a9901e98a7"
dependencies = [
"function_name-proc-macro",
]
[[package]]
name = "function_name-proc-macro"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673464e1e314dd67a0fd9544abc99e8eb28d0c7e3b69b033bcff9b2d00b87333"
[[package]] [[package]]
name = "funty" name = "funty"
version = "2.0.0" version = "2.0.0"
@@ -2462,6 +2582,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "hostname" name = "hostname"
version = "0.3.1" version = "0.3.1"
@@ -2905,6 +3034,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "interpolate_name" name = "interpolate_name"
version = "0.2.4" version = "0.2.4"
@@ -3170,6 +3308,26 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "liblzma"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66352d7a8ac12d4877b6e6ea5a9b7650ee094257dc40889955bea5bc5b08c1d0"
dependencies = [
"liblzma-sys",
]
[[package]]
name = "liblzma-sys"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.3" version = "0.1.3"
@@ -3181,6 +3339,15 @@ dependencies = [
"redox_syscall", "redox_syscall",
] ]
[[package]]
name = "libz-rs-sys"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221"
dependencies = [
"zlib-rs",
]
[[package]] [[package]]
name = "linked-hash-map" name = "linked-hash-map"
version = "0.5.6" version = "0.5.6"
@@ -4228,6 +4395,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.1" version = "2.3.1"
@@ -5327,7 +5504,7 @@ checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"cssparser", "cssparser",
"derive_more", "derive_more 0.99.20",
"fxhash", "fxhash",
"log", "log",
"matches", "matches",
@@ -5396,12 +5573,22 @@ version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [ dependencies = [
"indexmap 2.9.0",
"itoa 1.0.15", "itoa 1.0.15",
"memchr", "memchr",
"ryu", "ryu",
"serde", "serde",
] ]
[[package]]
name = "serde_plain"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_repr" name = "serde_repr"
version = "0.1.20" version = "0.1.20"
@@ -6228,6 +6415,28 @@ dependencies = [
"thiserror 2.0.12", "thiserror 2.0.12",
] ]
[[package]]
name = "tauri-plugin-opener"
version = "2.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66644b71a31ec1a8a52c4a16575edd28cf763c87cf4a7da24c884122b5c77097"
dependencies = [
"dunce",
"glob",
"objc2-app-kit 0.3.1",
"objc2-foundation 0.3.1",
"open",
"schemars",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.12",
"url",
"windows 0.61.1",
"zbus",
]
[[package]] [[package]]
name = "tauri-plugin-os" name = "tauri-plugin-os"
version = "2.2.1" version = "2.2.1"
@@ -6350,7 +6559,7 @@ dependencies = [
"tokio", "tokio",
"url", "url",
"windows-sys 0.59.0", "windows-sys 0.59.0",
"zip", "zip 2.6.1",
] ]
[[package]] [[package]]
@@ -7039,6 +7248,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -8369,6 +8584,20 @@ name = "zeroize"
version = "1.8.1" version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
@@ -8416,6 +8645,78 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "zip"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "153a6fff49d264c4babdcfa6b4d534747f520e56e8f0f384f3b808c4b64cc1fd"
dependencies = [
"aes",
"arbitrary",
"bzip2",
"constant_time_eq",
"crc32fast",
"deflate64",
"flate2",
"getrandom 0.3.2",
"hmac",
"indexmap 2.9.0",
"liblzma",
"memchr",
"pbkdf2",
"sha1",
"time",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zlib-rs"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a"
[[package]]
name = "zopfli"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.15+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237"
dependencies = [
"cc",
"pkg-config",
]
[[package]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.4.12" version = "0.4.12"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "coco" name = "coco"
version = "0.4.0" version = "0.6.0"
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"
@@ -44,12 +44,12 @@ 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" }
tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png", "unstable"] } tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png"] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
# Need `arbitrary_precision` feature to support storing u128 # Need `arbitrary_precision` feature to support storing u128
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128 # see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
serde_json = { version = "1", features = ["arbitrary_precision"] } serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] }
tauri-plugin-http = "2" tauri-plugin-http = "2"
tauri-plugin-websocket = "2" tauri-plugin-websocket = "2"
tauri-plugin-deep-link = "2.0.0" tauri-plugin-deep-link = "2.0.0"
@@ -81,9 +81,8 @@ plist = "1.7"
base64 = "0.13" base64 = "0.13"
walkdir = "2" walkdir = "2"
log = "0.4" log = "0.4"
strsim = "0.10"
futures-util = "0.3.31" futures-util = "0.3.31"
url = "2.5.2"
http = "1.1.0" http = "1.1.0"
tungstenite = "0.24.0" tungstenite = "0.24.0"
tokio-util = "0.7.14" tokio-util = "0.7.14"
@@ -93,6 +92,16 @@ chinese-number = "0.7"
num2words = "1" num2words = "1"
tauri-plugin-log = "2" tauri-plugin-log = "2"
chrono = "0.4.41" 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"
borrowme = "0.0.15"
tauri-plugin-opener = "2"
async-recursion = "1.1.1"
zip = "4.0.0"
url = "2.5.2"
[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" }

View File

@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main", "chat", "settings"], "windows": ["main", "chat", "settings", "check"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:event:allow-emit", "core:event:allow-emit",
@@ -71,6 +71,7 @@
"process:default", "process:default",
"updater:default", "updater:default",
"windows-version:default", "windows-version:default",
"log:default" "log:default",
"opener:default"
] ]
} }

View File

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

View File

@@ -1,10 +1,16 @@
use crate::common;
use crate::common::assistant::ChatRequestMessage; use crate::common::assistant::ChatRequestMessage;
use crate::common::http::GetResponse; use crate::common::http::{convert_query_params_to_strings, GetResponse};
use crate::common::register::SearchSourceRegistry;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use crate::{common, server::servers::COCO_SERVERS};
use futures::stream::FuturesUnordered;
use futures::StreamExt;
use futures_util::TryStreamExt;
use http::Method;
use serde_json::Value; use 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>(
@@ -14,17 +20,15 @@ pub async fn chat_history<R: Runtime>(
size: u32, size: u32,
query: Option<String>, query: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
let mut query_params: HashMap<String, Value> = HashMap::new(); let mut query_params = Vec::new();
if from > 0 {
query_params.insert("from".to_string(), from.into()); // Add from/size as number values
} query_params.push(format!("from={}", from));
if size > 0 { query_params.push(format!("size={}", size));
query_params.insert("size".to_string(), size.into());
}
if let Some(query) = query { if let Some(query) = query {
if !query.is_empty() { if !query.is_empty() {
query_params.insert("query".to_string(), query.into()); query_params.push(format!("query={}", query.to_string()));
} }
} }
@@ -46,13 +50,11 @@ pub async fn session_chat_history<R: Runtime>(
from: u32, from: u32,
size: u32, size: u32,
) -> Result<String, String> { ) -> Result<String, String> {
let mut query_params: HashMap<String, Value> = HashMap::new(); let mut query_params = Vec::new();
if from > 0 {
query_params.insert("from".to_string(), from.into()); // Add from/size as number values
} query_params.push(format!("from={}", from));
if size > 0 { query_params.push(format!("size={}", size));
query_params.insert("size".to_string(), size.into());
}
let path = format!("/chat/{}/_history", session_id); let path = format!("/chat/{}/_history", session_id);
@@ -69,10 +71,9 @@ pub async fn open_session_chat<R: Runtime>(
server_id: String, server_id: String,
session_id: String, session_id: String,
) -> Result<String, String> { ) -> Result<String, String> {
let query_params = HashMap::new();
let path = format!("/chat/{}/_open", session_id); let path = format!("/chat/{}/_open", session_id);
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None) let response = HttpClient::post(&server_id, path.as_str(), None, None)
.await .await
.map_err(|e| format!("Error open session: {}", e))?; .map_err(|e| format!("Error open session: {}", e))?;
@@ -85,10 +86,9 @@ pub async fn close_session_chat<R: Runtime>(
server_id: String, server_id: String,
session_id: String, session_id: String,
) -> Result<String, String> { ) -> Result<String, String> {
let query_params = HashMap::new();
let path = format!("/chat/{}/_close", session_id); let path = format!("/chat/{}/_close", session_id);
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None) let response = HttpClient::post(&server_id, path.as_str(), None, None)
.await .await
.map_err(|e| format!("Error close session: {}", e))?; .map_err(|e| format!("Error close session: {}", e))?;
@@ -100,10 +100,9 @@ pub async fn cancel_session_chat<R: Runtime>(
server_id: String, server_id: String,
session_id: String, session_id: String,
) -> Result<String, String> { ) -> Result<String, String> {
let query_params = HashMap::new();
let path = format!("/chat/{}/_cancel", session_id); let path = format!("/chat/{}/_cancel", session_id);
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None) let response = HttpClient::post(&server_id, path.as_str(), None, None)
.await .await
.map_err(|e| format!("Error cancel session: {}", e))?; .map_err(|e| format!("Error cancel session: {}", e))?;
@@ -134,15 +133,22 @@ 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 = let response = HttpClient::advanced_post(
HttpClient::advanced_post(&server_id, "/chat/_new", Some(headers), query_params, body) &server_id,
"/chat/_new",
Some(headers),
convert_query_params_to_strings(query_params),
body,
)
.await .await
.map_err(|e| format!("Error sending message: {}", e))?; .map_err(|e| format!("Error sending message: {}", e))?;
let body_text = common::http::get_response_body_text(response).await?; let body_text = common::http::get_response_body_text(response).await?;
let chat_response: GetResponse = log::debug!("New chat response: {}", &body_text);
serde_json::from_str(&body_text).map_err(|e| format!("Failed to parse response JSON: {}", e))?;
let chat_response: GetResponse = serde_json::from_str(&body_text)
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
if chat_response.result != "created" { if chat_response.result != "created" {
return Err(format!("Unexpected result: {}", chat_response.result)); return Err(format!("Unexpected result: {}", chat_response.result));
@@ -173,7 +179,7 @@ pub async fn send_message<R: Runtime>(
&server_id, &server_id,
path.as_str(), path.as_str(),
Some(headers), Some(headers),
query_params, convert_query_params_to_strings(query_params),
Some(body), Some(body),
) )
.await .await
@@ -229,25 +235,9 @@ pub async fn update_session_chat(
pub async fn assistant_search<R: Runtime>( pub async fn assistant_search<R: Runtime>(
_app_handle: AppHandle<R>, _app_handle: AppHandle<R>,
server_id: String, server_id: String,
from: u32, query_params: Option<Vec<String>>,
size: u32,
query: Option<HashMap<String, Value>>,
) -> Result<Value, String> { ) -> Result<Value, String> {
let mut body = serde_json::json!({ let response = HttpClient::post(&server_id, "/assistant/_search", query_params, None)
"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 .await
.map_err(|e| format!("Error searching assistants: {}", e))?; .map_err(|e| format!("Error searching assistants: {}", e))?;
@@ -256,3 +246,173 @@ pub async fn assistant_search<R: Runtime>(
.await .await
.map_err(|err| err.to_string()) .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

@@ -3,38 +3,43 @@ use std::{fs::create_dir, io::Read};
use tauri::{Manager, Runtime}; use tauri::{Manager, Runtime};
use tauri_plugin_autostart::ManagerExt; use tauri_plugin_autostart::ManagerExt;
// Start or stop according to configuration /// If the state reported from the OS and the state stored by us differ, our state is
pub fn enable_autostart(app: &mut tauri::App) { /// prioritized and seen as the correct one. Update the OS state to make them consistent.
use tauri_plugin_autostart::MacosLauncher; pub fn ensure_autostart_state_consistent(app: &mut tauri::App) -> Result<(), String> {
use tauri_plugin_autostart::ManagerExt;
app.handle()
.plugin(tauri_plugin_autostart::init(
MacosLauncher::AppleScript,
None,
))
.unwrap();
let autostart_manager = app.autolaunch(); let autostart_manager = app.autolaunch();
// close autostart let os_state = autostart_manager.is_enabled().map_err(|e| e.to_string())?;
// autostart_manager.disable().unwrap(); let coco_stored_state = current_autostart(app.app_handle()).map_err(|e| e.to_string())?;
// return;
match ( if os_state != coco_stored_state {
autostart_manager.is_enabled(), log::warn!(
current_autostart(app.app_handle()), "autostart inconsistent states, OS state [{}], Coco state [{}], config file could be deleted or corrupted",
) { os_state,
(Ok(false), Ok(true)) => match autostart_manager.enable() { coco_stored_state
Ok(_) => println!("Autostart enabled successfully."), );
Err(err) => eprintln!("Failed to enable autostart: {}", err), log::info!("trying to correct the inconsistent states");
},
(Ok(true), Ok(false)) => match autostart_manager.disable() { let result = if coco_stored_state {
Ok(_) => println!("Autostart disable successfully."), autostart_manager.enable()
Err(err) => eprintln!("Failed to disable autostart: {}", err), } else {
}, autostart_manager.disable()
_ => (), };
match result {
Ok(_) => {
log::info!("inconsistent autostart states fixed");
} }
Err(e) => {
log::error!(
"failed to fix inconsistent autostart state due to error [{}]",
e
);
return Err(e.to_string());
}
}
}
Ok(())
} }
fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, String> { fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, String> {

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

@@ -29,6 +29,89 @@ 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)]
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);
if let Some(ref args) = action.args {
ret.push_str(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);
if let Some(args) = action.args {
cmd.args(args);
}
let output = cmd.output().map_err(|e| e.to_string())?;
// Sometimes, we wanna see the result in logs even though it doesn't fail.
log::debug!(
"executing open(Command) result, exit code: [{}], stdout: [{}], stderr: [{}]",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if !output.status.success() {
log::warn!(
"executing open(Command) failed, exit code: [{}], stdout: [{}], stderr: [{}]",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return Err(format!(
"Command failed, stderr [{}]",
String::from_utf8_lossy(&output.stderr)
));
}
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Document { pub struct Document {
pub id: String, pub id: String,
@@ -48,6 +131,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>>,

View File

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

View File

@@ -2,6 +2,8 @@ use crate::common;
use reqwest::Response; use reqwest::Response;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap;
use tauri_plugin_store::JsonValue;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct GetResponse { pub struct GetResponse {
@@ -36,17 +38,43 @@ pub async fn get_response_body_text(response: Response) -> Result<String, String
return Err(fallback_error); return Err(fallback_error);
} }
match serde_json::from_str::<common::error::ErrorResponse>(&body) { match serde_json::from_str::<common::error::ErrorResponse>(&body) {
Ok(parsed_error) => { Ok(parsed_error) => {
dbg!(&parsed_error); dbg!(&parsed_error);
Err(format!( Err(format!(
"Server error ({}): {}", "Server error ({}): {:?}",
parsed_error.error.status, parsed_error.error.reason status, parsed_error.error
)) ))
} }
Err(_) => Err(fallback_error), Err(_) => {
log::warn!("Failed to parse error response: {}", &body);
Err(fallback_error)
}
} }
} else { } else {
Ok(body) Ok(body)
} }
} }
pub fn convert_query_params_to_strings(
query_params: Option<HashMap<String, JsonValue>>,
) -> Option<Vec<String>> {
query_params.map(|map| {
map.into_iter()
.filter_map(|(k, v)| match v {
JsonValue::String(s) => Some(format!("{}={}", k, s)),
JsonValue::Number(n) => Some(format!("{}={}", k, n)),
JsonValue::Bool(b) => Some(format!("{}={}", k, b)),
_ => {
eprintln!(
"Skipping unsupported query value for key '{}': {:?}",
k, v
);
None
}
})
.collect()
})
}

View File

@@ -1,16 +1,17 @@
pub mod health;
pub mod profile;
pub mod server;
pub mod auth;
pub mod datasource;
pub mod connector;
pub mod search;
pub mod document;
pub mod traits;
pub mod register;
pub mod assistant; pub mod assistant;
pub mod http; pub mod auth;
pub mod connector;
pub mod datasource;
pub mod document;
pub mod error; pub mod error;
pub mod health;
pub mod http;
pub mod profile;
pub mod register;
pub mod search;
pub mod server;
pub mod traits;
pub static MAIN_WINDOW_LABEL: &str = "main"; pub static MAIN_WINDOW_LABEL: &str = "main";
pub static SETTINGS_WINDOW_LABEL: &str = "settings"; pub static SETTINGS_WINDOW_LABEL: &str = "settings";
pub static CHECK_WINDOW_LABEL: &str = "check";

View File

@@ -25,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)]
@@ -36,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)
} }
@@ -58,13 +58,18 @@ where
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,5 +1,4 @@
use crate::common::error::SearchError; use crate::common::error::SearchError;
// use std::{future::Future, pin::Pin};
use crate::common::search::SearchQuery; use crate::common::search::SearchQuery;
use crate::common::search::{QueryResponse, QuerySource}; use crate::common::search::{QueryResponse, QuerySource};
use async_trait::async_trait; use async_trait::async_trait;
@@ -10,4 +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>;
} }

View File

@@ -0,0 +1,13 @@
pub(super) const EXTENSION_ID: &str = "AIOverview";
/// JSON file for this extension.
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
{
"id": "AIOverview",
"name": "AI Overview",
"description": "...",
"icon": "font_a-AIOverview",
"type": "ai_extension",
"enabled": true
}
"#;

View File

@@ -12,7 +12,6 @@ pub use with_feature::*;
#[cfg(not(feature = "use_pizza_engine"))] #[cfg(not(feature = "use_pizza_engine"))]
pub use without_feature::*; pub use without_feature::*;
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AppEntry { pub struct AppEntry {
@@ -24,15 +23,26 @@ pub struct AppEntry {
is_disabled: bool, is_disabled: bool,
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AppMetadata { pub struct AppMetadata {
name: String, name: String,
r#where: String, r#where: String,
size: u64, size: u64,
icon: String,
created: u128, created: u128,
modified: u128, modified: u128,
last_opened: u128, last_opened: u128,
} }
/// JSON file for this extension.
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
{
"id": "Applications",
"platforms": ["macos", "linux", "windows"],
"name": "Applications",
"description": "Application search",
"icon": "font_Application",
"type": "group",
"enabled": true
}
"#;

View File

@@ -1,18 +1,19 @@
use super::super::SearchSourceState; use super::super::pizza_engine_runtime::SearchSourceState;
use super::super::Task; use super::super::pizza_engine_runtime::Task;
use super::super::RUNTIME_TX; use super::super::pizza_engine_runtime::RUNTIME_TX;
use super::AppEntry; use super::super::Extension;
use super::AppMetadata; use super::AppMetadata;
use crate::common::document::{DataSourceReference, Document}; use crate::common::document::{DataSourceReference, Document, OnOpened};
use crate::common::error::SearchError; use crate::common::error::SearchError;
use crate::common::search::{QueryResponse, QuerySource, SearchQuery}; use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
use crate::common::traits::SearchSource; use crate::common::traits::SearchSource;
use crate::local::LOCAL_QUERY_SOURCE_TYPE; use crate::extension::ExtensionType;
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
use crate::util::open; use crate::util::open;
use crate::GLOBAL_TAURI_APP_HANDLE; use crate::GLOBAL_TAURI_APP_HANDLE;
use applications::{App, AppTrait}; use applications::{App, AppTrait};
use async_trait::async_trait; use async_trait::async_trait;
use log::{debug, info, warn}; use log::{error, warn};
use pizza_engine::document::FieldType; use pizza_engine::document::FieldType;
use pizza_engine::document::{ use pizza_engine::document::{
Document as PizzaEngineDocument, DraftDoc as PizzaEngineDraftDoc, FieldValue, Document as PizzaEngineDocument, DraftDoc as PizzaEngineDraftDoc, FieldValue,
@@ -24,12 +25,14 @@ use pizza_engine::store::{DiskStore, DiskStoreSnapshot};
use pizza_engine::writer::Writer; use pizza_engine::writer::Writer;
use pizza_engine::{doc, Engine, EngineBuilder}; use pizza_engine::{doc, Engine, EngineBuilder};
use serde_json::Value as Json; use serde_json::Value as Json;
use std::collections::HashMap; use std::path::Path;
use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
use tauri::{async_runtime, AppHandle, Manager, Runtime}; use tauri::{async_runtime, AppHandle, Manager, Runtime};
use tauri_plugin_fs_pro::{icon, metadata, name, IconOptions}; use tauri_plugin_fs_pro::{icon, metadata, name, IconOptions};
use tauri_plugin_global_shortcut::GlobalShortcutExt; use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::Shortcut;
use tauri_plugin_global_shortcut::ShortcutEvent;
use tauri_plugin_global_shortcut::ShortcutState;
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
use tokio::sync::oneshot::Sender as OneshotSender; use tokio::sync::oneshot::Sender as OneshotSender;
@@ -45,6 +48,8 @@ const TAURI_STORE_APP_ALIAS: &str = "app_alias";
const TAURI_STORE_KEY_SEARCH_PATH: &str = "search_path"; const TAURI_STORE_KEY_SEARCH_PATH: &str = "search_path";
const TAURI_STORE_KEY_DISABLED_APP_LIST: &str = "disabled_app_list"; const TAURI_STORE_KEY_DISABLED_APP_LIST: &str = "disabled_app_list";
const INDEX_DIR: &str = "local_application_index";
/// We use this as: /// We use this as:
/// ///
/// 1. querysource ID /// 1. querysource ID
@@ -112,10 +117,10 @@ fn get_app_path(app: &App) -> String {
/// Helper function to return `app`'s path. /// Helper function to return `app`'s path.
/// ///
/// * Windows/macOS: extract `app_path`'s file name and remove the file extension /// * macOS: extract `app_path`'s file name and remove the file extension
/// * Linux: return the name specified in `.desktop` file /// * Windows/Linux: return the name specified in `.desktop` file
async fn get_app_name(app: &App) -> String { async fn get_app_name(app: &App) -> String {
if cfg!(target_os = "linux") { if cfg!(any(target_os = "linux", target_os = "windows")) {
app.name.clone() app.name.clone()
} else { } else {
let app_path = get_app_path(app); let app_path = get_app_path(app);
@@ -191,6 +196,9 @@ macro_rules! task_exec_try {
}; };
} }
// Fields `engine` and `writer` become unused without app list synchronizer, allow
// this rather than removing these fields as we will bring the synchronizer back.
#[allow(dead_code)]
struct ApplicationSearchSourceState { struct ApplicationSearchSourceState {
engine: Engine<DiskStore>, engine: Engine<DiskStore>,
writer: Writer<DiskStore>, writer: Writer<DiskStore>,
@@ -204,31 +212,15 @@ impl SearchSourceState for ApplicationSearchSourceState {
} }
} }
/// Upon application start, index all the applications found in the `get_default_search_paths()`. /// Index applications if they have not been indexed (by checking if `app_index_dir` exists).
struct IndexAllApplicationsTask<R: Runtime> { async fn index_applications_if_not_indexed<R: Runtime>(
tauri_app_handle: AppHandle<R>, tauri_app_handle: &AppHandle<R>,
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>, app_index_dir: &Path,
} ) -> anyhow::Result<ApplicationSearchSourceState> {
#[async_trait::async_trait(?Send)]
impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
fn search_source_id(&self) -> &'static str {
APPLICATION_SEARCH_SOURCE_ID
}
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>) {
let callback = self.callback.take().unwrap();
let mut app_index_dir = self
.tauri_app_handle
.path()
.app_data_dir()
.expect("failed to find the local dir");
app_index_dir.push("local_application_index");
let index_exists = app_index_dir.exists(); let index_exists = app_index_dir.exists();
let mut pizza_engine_builder = EngineBuilder::new(); let mut pizza_engine_builder = EngineBuilder::new();
let disk_store = task_exec_try!(DiskStore::new(&app_index_dir), callback); let disk_store = DiskStore::new(&app_index_dir)?;
pizza_engine_builder.set_data_store(disk_store); pizza_engine_builder.set_data_store(disk_store);
let mut schema = Schema::new(); let mut schema = Schema::new();
@@ -254,47 +246,112 @@ impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
if !index_exists { if !index_exists {
let default_search_path = get_default_search_paths(); let default_search_path = get_default_search_paths();
let apps = task_exec_try!(list_app_in(default_search_path), callback); let apps = list_app_in(default_search_path).map_err(|str| anyhow::anyhow!(str))?;
for app in apps.iter() { for app in apps.iter() {
let app_path = get_app_path(app); let app_path = get_app_path(app);
let app_name = get_app_name(app).await; let app_name = get_app_name(app).await;
let app_icon_path = task_exec_try!( let app_icon_path = get_app_icon_path(&tauri_app_handle, app)
get_app_icon_path(&self.tauri_app_handle, app).await, .await
callback .map_err(|str| anyhow::anyhow!(str))?;
); let app_alias = get_app_alias(&tauri_app_handle, &app_path).unwrap_or(String::new());
let app_alias = get_app_alias(&self.tauri_app_handle, &app_path).unwrap_or(String::new());
if app_name.is_empty() || app_name.eq(&self.tauri_app_handle.package_info().name) { if app_name.is_empty() || app_name.eq(&tauri_app_handle.package_info().name) {
continue; continue;
} }
let document = doc!( app_path, { // You cannot write `app_name.clone()` within the `doc!()` macro, we should fix this.
FIELD_APP_NAME => app_name, let app_name_clone = app_name.clone();
let app_path_clone = app_path.clone();
let document = doc!( app_path_clone, {
FIELD_APP_NAME => app_name_clone,
FIELD_ICON_PATH => app_icon_path, FIELD_ICON_PATH => app_icon_path,
FIELD_APP_ALIAS => app_alias, FIELD_APP_ALIAS => app_alias,
} }
); );
task_exec_try!(writer.create_document(document).await, callback); // We don't error out because one failure won't break the whole thing
if let Err(e) = writer.create_document(document).await {
warn!(
"failed to index application [app name: '{}', app path: '{}'] due to error [{}]", app_name, app_path, e
)
}
} }
task_exec_try!(writer.commit(), callback); writer.commit()?;
} }
let snapshot = pizza_engine.create_snapshot(); let snapshot = pizza_engine.create_snapshot();
let searcher = pizza_engine.acquire_searcher(); let searcher = pizza_engine.acquire_searcher();
let state_to_store = Box::new(ApplicationSearchSourceState { Ok(ApplicationSearchSourceState {
searcher, searcher,
snapshot, snapshot,
engine: pizza_engine, engine: pizza_engine,
writer, writer,
}) as Box<dyn SearchSourceState>; })
}
*state = Some(state_to_store); /// Upon application start, index all the applications found in the `get_default_search_paths()`.
struct IndexAllApplicationsTask<R: Runtime> {
tauri_app_handle: AppHandle<R>,
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
}
callback.send(Ok(())).unwrap(); #[async_trait::async_trait(?Send)]
impl<R: Runtime> Task for IndexAllApplicationsTask<R> {
fn search_source_id(&self) -> &'static str {
APPLICATION_SEARCH_SOURCE_ID
}
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>) {
let callback = self.callback.take().unwrap();
let mut app_index_dir = self
.tauri_app_handle
.path()
.app_data_dir()
.expect("failed to find the local dir");
app_index_dir.push(INDEX_DIR);
let app_search_source_state = task_exec_try!(
index_applications_if_not_indexed(&self.tauri_app_handle, &app_index_dir).await,
callback
);
*state = Some(Box::new(app_search_source_state));
callback.send(Ok(())).expect("rx dropped");
}
}
struct ReindexAllApplicationsTask<R: Runtime> {
tauri_app_handle: AppHandle<R>,
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
}
#[async_trait::async_trait(?Send)]
impl<R: Runtime> Task for ReindexAllApplicationsTask<R> {
fn search_source_id(&self) -> &'static str {
APPLICATION_SEARCH_SOURCE_ID
}
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>) {
let callback = self.callback.take().unwrap();
// Clear the state
*state = None;
let mut app_index_dir = self
.tauri_app_handle
.path()
.app_data_dir()
.expect("failed to find the local dir");
app_index_dir.push(INDEX_DIR);
task_exec_try!(tokio::fs::remove_dir_all(&app_index_dir).await, callback);
// Then re-index the apps
let app_search_source_state = task_exec_try!(
index_applications_if_not_indexed(&self.tauri_app_handle, &app_index_dir).await,
callback
);
*state = Some(Box::new(app_search_source_state));
callback.send(Ok(())).expect("rx dropped");
} }
} }
@@ -312,15 +369,41 @@ impl<R: Runtime> Task for SearchApplicationsTask<R> {
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>) { async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>) {
let callback = self.callback.take().unwrap(); let callback = self.callback.take().unwrap();
let disabled_app_list = get_disabled_app_list(self.tauri_app_handle.clone());
let Some(state) = state.as_mut() else {
let empty_hits = SearchResult {
tracing_id: String::new(),
explains: None,
total_hits: 0,
hits: None,
};
let rx_dropped_error = callback.send(Ok(empty_hits)).is_err();
if rx_dropped_error {
warn!("failed to send local app search result back because the corresponding channel receiver end has been unexpected dropped, which could happen due to a low query timeout")
}
return;
};
let disabled_app_list = get_disabled_app_list(&self.tauri_app_handle);
// TODO: search via alias, implement this when Pizza engine supports update // TODO: search via alias, implement this when Pizza engine supports update
//
// NOTE: we use the Debug impl rather than Display for `self.query_string` as String's Debug
// impl won't interrupt escape characters. So for input like:
//
// ```text
// Google
// Chrome
// ```
//
// It will be passed to Pizza like "Google\nChrome". Using Display impl would result
// in an invalid query DSL and serde will complain.
let dsl = format!( let dsl = format!(
"{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME}\": \"{}\" }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": \"{}\" }} }} ] }} }} }}", self.query_string, self.query_string); "{{ \"query\": {{ \"bool\": {{ \"should\": [ {{ \"match\": {{ \"{FIELD_APP_NAME}\": {:?} }} }}, {{ \"prefix\": {{ \"{FIELD_APP_NAME}\": {:?} }} }} ] }} }} }}", self.query_string, self.query_string);
let state = state let state = state
.as_mut()
.expect("should be set before")
.as_mut_any() .as_mut_any()
.downcast_mut::<ApplicationSearchSourceState>() .downcast_mut::<ApplicationSearchSourceState>()
.unwrap(); .unwrap();
@@ -359,6 +442,10 @@ impl<R: Runtime> Task for SearchApplicationsTask<R> {
/// 2. New search paths have been added by the user /// 2. New search paths have been added by the user
/// ///
/// We use this task to index them. /// We use this task to index them.
//
// This become unused without app list synchronizer, allow this rather than
// removing the task as we will bring the synchronizer back.
#[allow(dead_code)]
struct IndexNewApplicationsTask { struct IndexNewApplicationsTask {
applications: Vec<PizzaEngineDraftDoc>, applications: Vec<PizzaEngineDraftDoc>,
callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>, callback: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
@@ -399,7 +486,9 @@ impl Task for IndexNewApplicationsTask {
pub struct ApplicationSearchSource; pub struct ApplicationSearchSource;
impl ApplicationSearchSource { impl ApplicationSearchSource {
pub async fn init<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> { pub async fn prepare_index_and_store<R: Runtime>(
app_handle: AppHandle<R>,
) -> Result<(), String> {
let (tx, rx) = tokio::sync::oneshot::channel(); let (tx, rx) = tokio::sync::oneshot::channel();
let index_applications_task = IndexAllApplicationsTask { let index_applications_task = IndexAllApplicationsTask {
tauri_app_handle: app_handle.clone(), tauri_app_handle: app_handle.clone(),
@@ -412,7 +501,13 @@ impl ApplicationSearchSource {
.send(Box::new(index_applications_task)) .send(Box::new(index_applications_task))
.unwrap(); .unwrap();
rx.await.unwrap()?; let indexing_applications_result = rx.await.unwrap();
if let Err(ref e) = indexing_applications_result {
error!(
"indexing local applications failed, app search won't work, error [{}]",
e
)
}
app_handle app_handle
.store(TAURI_STORE_APP_HOTKEY) .store(TAURI_STORE_APP_HOTKEY)
@@ -437,114 +532,6 @@ impl ApplicationSearchSource {
.set(TAURI_STORE_KEY_SEARCH_PATH, default_search_path); .set(TAURI_STORE_KEY_SEARCH_PATH, default_search_path);
} }
register_app_hotkey_upon_start(app_handle.clone())?;
let app_handle_clone = app_handle.clone();
std::thread::Builder::new()
.name("local app search - app list synchronizer".into())
.spawn(move || {
let tokio_rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to start a tokio runtime");
tokio_rt.block_on(async move {
info!("app list synchronizer started");
loop {
tokio::time::sleep(std::time::Duration::from_secs(60 * 2)).await;
debug!("app list synchronizer working");
let stored_app_list = get_app_list(app_handle_clone.clone())
.await
.expect("failed to fetch the stored app list");
let store = app_handle_clone
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_e| {
panic!(
"store [{}] not found/loaded",
TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH
)
});
let search_path_json =
store.get(TAURI_STORE_KEY_SEARCH_PATH).unwrap_or_else(|| {
panic!("key [{}] not found", TAURI_STORE_KEY_SEARCH_PATH)
});
let search_paths: Vec<String> = match search_path_json {
Json::Array(array) => array
.into_iter()
.map(|json| match json {
Json::String(str) => str,
_ => unreachable!("search path should be a string"),
})
.collect(),
_ => unreachable!("search paths should be stored in an array"),
};
let mut current_app_list = list_app_in(search_paths).unwrap_or_else(|e| {
panic!("failed to fetch app list due to error [{}]", e)
});
// filter out Coco-AI
current_app_list.retain(|app| app.name != app_handle.package_info().name);
let current_app_list_path_hash_index = {
let mut index = HashMap::new();
for (idx, app) in current_app_list.iter().enumerate() {
index.insert(get_app_path(app), idx);
}
index
};
let current_app_path_list: HashSet<String> =
current_app_list.iter().map(get_app_path).collect();
let stored_app_path_list: HashSet<String> = stored_app_list
.iter()
.map(|app_entry| app_entry.path.clone())
.collect();
let new_apps = current_app_path_list.difference(&stored_app_path_list);
debug!("found new apps [{:?}]", new_apps);
// Synchronize the stored app list
let mut new_apps_pizza_engine_documents = Vec::new();
for new_app_path in new_apps {
let idx = *current_app_list_path_hash_index.get(new_app_path).unwrap();
let new_app = current_app_list.get(idx).unwrap();
let new_app_name = get_app_name(new_app).await;
let new_app_icon_path =
get_app_icon_path(&app_handle_clone, new_app).await.unwrap();
let new_app_alias = get_app_alias(&app_handle_clone, &new_app_path).unwrap_or(String::new());
let new_app_pizza_engine_document = doc!(new_app_path.clone(), {
FIELD_APP_NAME => new_app_name,
FIELD_ICON_PATH => new_app_icon_path,
FIELD_APP_ALIAS => new_app_alias,
}
);
new_apps_pizza_engine_documents.push(new_app_pizza_engine_document);
}
let (callback, wait_for_complete) = tokio::sync::oneshot::channel();
let index_new_apps_task = Box::new(IndexNewApplicationsTask {
applications: new_apps_pizza_engine_documents,
callback: Some(callback),
});
RUNTIME_TX
.get()
.unwrap()
.send(index_new_apps_task)
.expect("rx dropped, pizza runtime could possibly be dead");
wait_for_complete
.await
.expect("tx dropped, pizza runtime could possibly be dead")
.unwrap_or_else(|e| {
panic!("failed to index new apps due to error [{}]", e)
});
}
});
})
.unwrap();
Ok(()) Ok(())
} }
} }
@@ -633,19 +620,24 @@ fn pizza_engine_hits_to_coco_hits(
FieldValue::Text(string) => string, FieldValue::Text(string) => string,
_ => unreachable!("field icon is of type Text"), _ => unreachable!("field icon is of type Text"),
}; };
let on_opened = OnOpened::Application {
app_path: app_path.clone(),
};
let url = on_opened.url();
let coco_document = Document { let coco_document = Document {
source: Some(DataSourceReference { source: Some(DataSourceReference {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()), r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some(QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into()), name: Some(QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into()),
id: Some(QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into()), id: Some(QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into()),
icon: None, icon: Some(String::from("font_Application")),
}), }),
id: app_path.clone(), id: app_path.clone(),
category: Some("Application".to_string()), category: Some("Application".to_string()),
title: Some(app_name.clone()), title: Some(app_name.clone()),
url: Some(app_path),
icon: Some(app_icon_path), icon: Some(app_icon_path),
on_opened: Some(on_opened),
url: Some(url),
..Default::default() ..Default::default()
}; };
@@ -656,11 +648,10 @@ fn pizza_engine_hits_to_coco_hits(
coco_hits coco_hits
} }
#[tauri::command] pub fn set_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str, alias: &str) {
pub async fn set_app_alias<R: Runtime>(tauri_app_handle: AppHandle<R>, app_path: String, alias: String) { let store = tauri_app_handle
let store = tauri_app_handle.store(TAURI_STORE_APP_ALIAS).unwrap_or_else(|_| { .store(TAURI_STORE_APP_ALIAS)
panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS) .unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
});
store.set(app_path, alias); store.set(app_path, alias);
@@ -671,9 +662,9 @@ pub async fn set_app_alias<R: Runtime>(tauri_app_handle: AppHandle<R>, app_path:
} }
fn get_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str) -> Option<String> { fn get_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str) -> Option<String> {
let store = tauri_app_handle.store(TAURI_STORE_APP_ALIAS).unwrap_or_else(|_| { let store = tauri_app_handle
panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS) .store(TAURI_STORE_APP_ALIAS)
}); .unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
let json = store.get(app_path)?; let json = store.get(app_path)?;
@@ -685,14 +676,63 @@ fn get_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str) ->
Some(string) Some(string)
} }
fn register_app_hotkey_upon_start<R: Runtime>( /// The handler that will be invoked when an application hotkey is pressed.
tauri_app_handle: AppHandle<R>, ///
) -> Result<(), String> { /// The `app_path` argument is for logging-only.
fn app_hotkey_handler<R: Runtime>(
app_path: String,
) -> impl Fn(&AppHandle<R>, &Shortcut, ShortcutEvent) + Send + Sync + 'static {
move |tauri_app_handle, _hot_key, event| {
if event.state() == ShortcutState::Pressed {
let app_path_clone = app_path.clone();
let tauri_app_handle_clone = tauri_app_handle.clone();
// This closure will be executed on the main thread, so we spawn to reduce the potential UI lag.
async_runtime::spawn(async move {
if let Err(e) = open(tauri_app_handle_clone, app_path_clone).await {
warn!("failed to open app due to [{}]", e);
}
});
}
}
}
/// For all the applications, if it is enabled & has hotkey set, then set it up.
pub(crate) fn set_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
let app_hotkey_store = tauri_app_handle let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY) .store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY)); .unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
let disabled_app_list = get_disabled_app_list(&tauri_app_handle);
for (app_path, hotkey) in app_hotkey_store.entries() { for (app_path, hotkey) in app_hotkey_store.entries() {
if disabled_app_list.contains(&app_path) {
continue;
}
let hotkey = match hotkey {
Json::String(str) => str,
_ => unreachable!("hotkey should be stored in a string"),
};
set_app_hotkey(&tauri_app_handle, &app_path, &hotkey)?;
}
Ok(())
}
/// For all the applications, if it is enabled & has hotkey set, then unset it.
pub(crate) fn unset_apps_hotkey<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
let disabled_app_list = get_disabled_app_list(&tauri_app_handle);
for (app_path, hotkey) in app_hotkey_store.entries() {
if disabled_app_list.contains(&app_path) {
continue;
}
let hotkey = match hotkey { let hotkey = match hotkey {
Json::String(str) => str, Json::String(str) => str,
_ => unreachable!("hotkey should be stored in a string"), _ => unreachable!("hotkey should be stored in a string"),
@@ -700,73 +740,57 @@ fn register_app_hotkey_upon_start<R: Runtime>(
tauri_app_handle tauri_app_handle
.global_shortcut() .global_shortcut()
.on_shortcut( .unregister(hotkey.as_str())
hotkey.as_str(),
move |tauri_app_handle, _hot_key, _event| {
let app_path_clone = app_path.clone();
let tauri_app_handle_clone = tauri_app_handle.clone();
// This closure will be executed on the main thread, so we spawn to reduce the potential UI lag.
async_runtime::spawn(async move {
if let Err(e) = open(tauri_app_handle_clone, app_path_clone).await {
warn!("failed to open app due to [{}]", e);
}
});
},
)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
Ok(()) Ok(())
} }
#[tauri::command] /// Set the hotkey but won't persist this settings change.
pub async fn register_app_hotkey<R: Runtime>( pub(crate) fn set_app_hotkey<R: Runtime>(
tauri_app_handle: AppHandle<R>, tauri_app_handle: &AppHandle<R>,
app_path: String, app_path: &str,
hotkey: String, hotkey: &str,
) -> Result<(), String> { ) -> Result<(), String> {
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
app_hotkey_store.set(app_path.clone(), hotkey.as_str());
tauri_app_handle tauri_app_handle
.global_shortcut() .global_shortcut()
.on_shortcut( .on_shortcut(hotkey, app_hotkey_handler(app_path.into()))
hotkey.as_str(), .map_err(|e| e.to_string())
move |tauri_app_handle, _hot_key, _event| { }
let app_path_clone = app_path.clone();
let tauri_app_handle_clone = tauri_app_handle.clone(); pub fn register_app_hotkey<R: Runtime>(
// This closure will be executed on the main thread, so we spawn to reduce the potential UI lag. tauri_app_handle: &AppHandle<R>,
async_runtime::spawn(async move { app_path: &str,
if let Err(e) = open(tauri_app_handle_clone, app_path_clone).await { hotkey: &str,
warn!("failed to open app due to [{}]", e); ) -> Result<(), String> {
} // Ignore the error as it may not be registered
}); unregister_app_hotkey(tauri_app_handle, app_path)?;
},
) let app_hotkey_store = tauri_app_handle
.map_err(|e| e.to_string())?; .store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
app_hotkey_store.set(app_path, hotkey);
set_app_hotkey(tauri_app_handle, app_path, hotkey)?;
Ok(()) Ok(())
} }
#[tauri::command] pub fn unregister_app_hotkey<R: Runtime>(
pub async fn unregister_app_hotkey<R: Runtime>( tauri_app_handle: &AppHandle<R>,
tauri_app_handle: AppHandle<R>, app_path: &str,
app_path: String,
) -> Result<(), String> { ) -> Result<(), String> {
let app_hotkey_store = tauri_app_handle let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY) .store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY)); .unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
let Some(hotkey) = app_hotkey_store.get(app_path.as_str()) else { let Some(hotkey) = app_hotkey_store.get(app_path) else {
let error_msg = format!( warn!(
"unregister an Application hotkey that does not exist app: [{}]", "unregister an Application hotkey that does not exist app: [{}]",
app_path, app_path,
); );
warn!("{}", error_msg); return Ok(());
return Err(error_msg);
}; };
let hotkey = match hotkey { let hotkey = match hotkey {
@@ -774,11 +798,18 @@ pub async fn unregister_app_hotkey<R: Runtime>(
_ => unreachable!("hotkey should be stored in a string"), _ => unreachable!("hotkey should be stored in a string"),
}; };
let deleted = app_hotkey_store.delete(app_path.as_str()); let deleted = app_hotkey_store.delete(app_path);
if !deleted { if !deleted {
return Err("failed to delete application hotkey from store".into()); return Err("failed to delete application hotkey from store".into());
} }
if !tauri_app_handle
.global_shortcut()
.is_registered(hotkey.as_str())
{
panic!("inconsistent state, tauri store a hotkey is stored in the tauri store but it is not registered");
}
tauri_app_handle tauri_app_handle
.global_shortcut() .global_shortcut()
.unregister(hotkey.as_str()) .unregister(hotkey.as_str())
@@ -787,7 +818,7 @@ pub async fn unregister_app_hotkey<R: Runtime>(
Ok(()) Ok(())
} }
fn get_disabled_app_list<R: Runtime>(tauri_app_handle: AppHandle<R>) -> Vec<String> { fn get_disabled_app_list<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Vec<String> {
let store = tauri_app_handle let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH) .store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| { .unwrap_or_else(|_| {
@@ -814,10 +845,19 @@ fn get_disabled_app_list<R: Runtime>(tauri_app_handle: AppHandle<R>) -> Vec<Stri
disabled_app_list disabled_app_list
} }
#[tauri::command] pub fn is_app_search_enabled(app_path: &str) -> bool {
pub async fn disable_app_search<R: Runtime>( let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
tauri_app_handle: AppHandle<R>, .get()
app_path: String, .expect("global tauri app handle not set");
let disabled_app_list = get_disabled_app_list(tauri_app_handle);
disabled_app_list.iter().all(|path| path != app_path)
}
pub fn disable_app_search<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
) -> Result<(), String> { ) -> Result<(), String> {
let store = tauri_app_handle let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH) .store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
@@ -830,24 +870,41 @@ pub async fn disable_app_search<R: Runtime>(
let mut disabled_app_list = get_disabled_app_list(tauri_app_handle); let mut disabled_app_list = get_disabled_app_list(tauri_app_handle);
if disabled_app_list.contains(&app_path) { if disabled_app_list
.iter()
.any(|disabled_app| disabled_app == app_path)
{
return Err(format!( return Err(format!(
"trying to disable an app that is disabled [{}]", "trying to disable an app that is disabled [{}]",
app_path app_path
)); ));
} }
disabled_app_list.push(app_path); disabled_app_list.push(app_path.into());
store.set(TAURI_STORE_KEY_DISABLED_APP_LIST, disabled_app_list); store.set(TAURI_STORE_KEY_DISABLED_APP_LIST, disabled_app_list);
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
let opt_hokey = app_hotkey_store.get(app_path).map(|json| match json {
Json::String(s) => s,
_ => panic!("hotkey should be stored in a string"),
});
if let Some(hotkey) = opt_hokey {
tauri_app_handle
.global_shortcut()
.unregister(hotkey.as_str())
.map_err(|e| e.to_string())?;
}
Ok(()) Ok(())
} }
#[tauri::command] pub fn enable_app_search<R: Runtime>(
pub async fn enable_app_search<R: Runtime>( tauri_app_handle: &AppHandle<R>,
tauri_app_handle: AppHandle<R>, app_path: &str,
app_path: String,
) -> Result<(), String> { ) -> Result<(), String> {
let store = tauri_app_handle let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH) .store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
@@ -868,6 +925,18 @@ pub async fn enable_app_search<R: Runtime>(
disabled_app_list.remove(index); disabled_app_list.remove(index);
store.set(TAURI_STORE_KEY_DISABLED_APP_LIST, disabled_app_list); store.set(TAURI_STORE_KEY_DISABLED_APP_LIST, disabled_app_list);
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
let opt_hokey = app_hotkey_store.get(app_path).map(|json| match json {
Json::String(s) => s,
_ => panic!("hotkey should be stored in a string"),
});
if let Some(hotkey) = opt_hokey {
set_app_hotkey(tauri_app_handle, app_path, &hotkey)?;
}
Ok(()) Ok(())
} }
None => Err(format!( None => Err(format!(
@@ -961,7 +1030,7 @@ pub async fn get_app_search_path<R: Runtime>(tauri_app_handle: AppHandle<R>) ->
#[tauri::command] #[tauri::command]
pub async fn get_app_list<R: Runtime>( pub async fn get_app_list<R: Runtime>(
tauri_app_handle: AppHandle<R>, tauri_app_handle: AppHandle<R>,
) -> Result<Vec<AppEntry>, String> { ) -> Result<Vec<Extension>, String> {
let search_paths = get_app_search_path(tauri_app_handle.clone()).await; let search_paths = get_app_search_path(tauri_app_handle.clone()).await;
let apps = list_app_in(search_paths)?; let apps = list_app_in(search_paths)?;
@@ -992,14 +1061,12 @@ pub async fn get_app_list<R: Runtime>(
let store = tauri_app_handle let store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY) .store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY)); .unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
let opt_string = store.get(&path).map(|json| match json { store.get(&path).map(|json| match json {
Json::String(s) => s, Json::String(s) => s,
_ => unreachable!("app hotkey should be stored in a string"), _ => unreachable!("app hotkey should be stored in a string"),
}); })
opt_string.unwrap_or(String::new())
}; };
let is_disabled = { let enabled = {
let store = tauri_app_handle let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH) .store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY)); .unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
@@ -1014,25 +1081,40 @@ pub async fn get_app_list<R: Runtime>(
}); });
let disabled_app_list = match disabled_app_list_json { let disabled_app_list = match disabled_app_list_json {
Json::Array(v) => v.into_iter().map(|json| { Json::Array(v) => v
match json { .into_iter()
.map(|json| match json {
Json::String(str) => str, Json::String(str) => str,
_ => unreachable!("app path should be stored in a string"), _ => unreachable!("app path should be stored in a string"),
} })
}).collect::<Vec<String>>(), .collect::<Vec<String>>(),
_ => unreachable!("disabled app list should be stored in an array"), _ => unreachable!("disabled app list should be stored in an array"),
}; };
disabled_app_list.contains(&path) !disabled_app_list.contains(&path)
}; };
let app_entry = AppEntry { let app_entry = Extension {
path, id: path,
name, name,
icon_path, platforms: None,
alias, developer: None,
// Leave it empty as it won't be used
description: String::new(),
icon: icon_path,
r#type: ExtensionType::Application,
action: None,
quicklink: None,
commands: None,
scripts: None,
quicklinks: None,
alias: Some(alias),
hotkey, hotkey,
is_disabled, enabled,
settings: None,
screenshots: None,
url: None,
version: None,
}; };
app_entries.push(app_entry); app_entries.push(app_entry);
@@ -1042,15 +1124,7 @@ pub async fn get_app_list<R: Runtime>(
} }
#[tauri::command] #[tauri::command]
pub async fn get_app_metadata<R: Runtime>( pub async fn get_app_metadata(app_name: String, app_path: String) -> Result<AppMetadata, String> {
tauri_app_handle: AppHandle<R>,
app_path: String,
) -> Result<AppMetadata, String> {
let app =
App::from_path(std::path::Path::new(&app_path)).expect("frontend sends an invalid app");
let app_path = get_app_path(&app);
let app_name = get_app_name(&app).await;
let app_path_where = { let app_path_where = {
let app_path_borrowed_path = std::path::Path::new(app_path.as_str()); let app_path_borrowed_path = std::path::Path::new(app_path.as_str());
let app_path_where = app_path_borrowed_path let app_path_where = app_path_borrowed_path
@@ -1062,12 +1136,18 @@ pub async fn get_app_metadata<R: Runtime>(
.expect("it is guaranteed to be UTF-8 encoded") .expect("it is guaranteed to be UTF-8 encoded")
.to_string() .to_string()
}; };
let icon = get_app_icon_path(&tauri_app_handle, &app).await?;
let raw_app_metadata = metadata(app_path.into(), None).await?; let raw_app_metadata = metadata(app_path.clone().into(), None).await?;
let last_opened = if cfg!(any(target_os = "macos", target_os = "windows")) { let last_opened = if cfg!(target_os = "macos") {
let app_exe_path = app.app_path_exe.as_ref().expect("exe path should be Some").clone(); let app = App::from_path(std::path::Path::new(&app_path))
.unwrap_or_else(|e| panic!("App::from_path({}) failed due to error '{}'", app_path, e));
let app_exe_path = app
.app_path_exe
.as_ref()
.expect("exe path should be Some")
.clone();
let raw_app_exe_metadata = metadata(app_exe_path, None).await?; let raw_app_exe_metadata = metadata(app_exe_path, None).await?;
raw_app_exe_metadata.accessed_at raw_app_exe_metadata.accessed_at
} else { } else {
@@ -1078,9 +1158,35 @@ pub async fn get_app_metadata<R: Runtime>(
name: app_name, name: app_name,
r#where: app_path_where, r#where: app_path_where,
size: raw_app_metadata.size, size: raw_app_metadata.size,
icon,
created: raw_app_metadata.created_at, created: raw_app_metadata.created_at,
modified: raw_app_metadata.modified_at, modified: raw_app_metadata.modified_at,
last_opened, last_opened,
}) })
} }
#[tauri::command]
pub async fn reindex_applications<R: Runtime>(
tauri_app_handle: AppHandle<R>,
) -> Result<(), String> {
let (tx, rx) = tokio::sync::oneshot::channel();
let reindex_applications_task = ReindexAllApplicationsTask {
tauri_app_handle: tauri_app_handle.clone(),
callback: Some(tx),
};
RUNTIME_TX
.get()
.unwrap()
.send(Box::new(reindex_applications_task))
.unwrap();
let reindexing_applications_result = rx.await.unwrap();
if let Err(ref e) = reindexing_applications_result {
error!(
"re-indexing local applications failed, app search won't work, error [{}]",
e
)
}
reindexing_applications_result
}

View File

@@ -1,18 +1,20 @@
use super::super::Extension;
use super::AppMetadata;
use crate::common::error::SearchError; use crate::common::error::SearchError;
use crate::common::search::{QueryResponse, QuerySource, SearchQuery}; use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
use crate::common::traits::SearchSource; use crate::common::traits::SearchSource;
use crate::local::LOCAL_QUERY_SOURCE_TYPE; use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
use async_trait::async_trait; use async_trait::async_trait;
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime};
use super::AppEntry;
use super::AppMetadata;
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications"; pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
pub struct ApplicationSearchSource; pub struct ApplicationSearchSource;
impl ApplicationSearchSource { impl ApplicationSearchSource {
pub async fn init<R: Runtime>(_app_handle: AppHandle<R>) -> Result<(), String> { pub async fn prepare_index_and_store<R: Runtime>(
_app_handle: AppHandle<R>,
) -> Result<(), String> {
Ok(()) Ok(())
} }
} }
@@ -39,46 +41,45 @@ impl SearchSource for ApplicationSearchSource {
} }
} }
#[tauri::command] pub fn set_app_alias<R: Runtime>(_tauri_app_handle: &AppHandle<R>, _app_path: &str, _alias: &str) {
pub async fn set_app_alias(_app_path: String, _alias: String) -> Result<(), String> {
unreachable!("app list should be empty, there is no way this can be invoked") unreachable!("app list should be empty, there is no way this can be invoked")
} }
#[tauri::command] pub fn register_app_hotkey<R: Runtime>(
pub async fn register_app_hotkey<R: Runtime>( _tauri_app_handle: &AppHandle<R>,
_tauri_app_handle: AppHandle<R>, _app_path: &str,
_app_path: String, _hotkey: &str,
_hotkey: String,
) -> Result<(), String> { ) -> Result<(), String> {
unreachable!("app list should be empty, there is no way this can be invoked") unreachable!("app list should be empty, there is no way this can be invoked")
} }
#[tauri::command] pub fn unregister_app_hotkey<R: Runtime>(
pub async fn unregister_app_hotkey<R: Runtime>( _tauri_app_handle: &AppHandle<R>,
_tauri_app_handle: AppHandle<R>, _app_path: &str,
_app_path: String,
) -> Result<(), String> { ) -> Result<(), String> {
unreachable!("app list should be empty, there is no way this can be invoked") unreachable!("app list should be empty, there is no way this can be invoked")
} }
#[tauri::command] pub fn disable_app_search<R: Runtime>(
pub async fn disable_app_search<R: Runtime>( _tauri_app_handle: &AppHandle<R>,
_tauri_app_handle: AppHandle<R>, _app_path: &str,
_app_path: String,
) -> Result<(), String> { ) -> Result<(), String> {
// no-op // no-op
Ok(()) Ok(())
} }
#[tauri::command] pub fn enable_app_search<R: Runtime>(
pub async fn enable_app_search<R: Runtime>( _tauri_app_handle: &AppHandle<R>,
_tauri_app_handle: AppHandle<R>, _app_path: &str,
_app_path: String,
) -> Result<(), String> { ) -> Result<(), String> {
// no-op // no-op
Ok(()) Ok(())
} }
pub fn is_app_search_enabled(_app_path: &str) -> bool {
false
}
#[tauri::command] #[tauri::command]
pub async fn add_app_search_path<R: Runtime>( pub async fn add_app_search_path<R: Runtime>(
_tauri_app_handle: AppHandle<R>, _tauri_app_handle: AppHandle<R>,
@@ -103,11 +104,10 @@ pub async fn get_app_search_path<R: Runtime>(_tauri_app_handle: AppHandle<R>) ->
Vec::new() Vec::new()
} }
#[tauri::command] #[tauri::command]
pub async fn get_app_list<R: Runtime>( pub async fn get_app_list<R: Runtime>(
_tauri_app_handle: AppHandle<R>, _tauri_app_handle: AppHandle<R>,
) -> Result<Vec<AppEntry>, String> { ) -> Result<Vec<Extension>, String> {
// Return an empty list // Return an empty list
Ok(Vec::new()) Ok(Vec::new())
} }
@@ -119,3 +119,23 @@ pub async fn get_app_metadata<R: Runtime>(
) -> Result<AppMetadata, String> { ) -> Result<AppMetadata, String> {
unreachable!("app list should be empty, there is no way this can be invoked") unreachable!("app list should be empty, there is no way this can be invoked")
} }
pub(crate) fn set_apps_hotkey<R: Runtime>(_tauri_app_handle: &AppHandle<R>) -> Result<(), String> {
// no-op
Ok(())
}
pub(crate) fn unset_apps_hotkey<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
) -> Result<(), String> {
// no-op
Ok(())
}
#[tauri::command]
pub async fn reindex_applications<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
) -> Result<(), String> {
// no-op
Ok(())
}

View File

@@ -1,4 +1,4 @@
use super::LOCAL_QUERY_SOURCE_TYPE; use super::super::LOCAL_QUERY_SOURCE_TYPE;
use crate::common::{ use crate::common::{
document::{DataSourceReference, Document}, document::{DataSourceReference, Document},
error::SearchError, error::SearchError,
@@ -13,6 +13,19 @@ use std::collections::HashMap;
pub(crate) const DATA_SOURCE_ID: &str = "Calculator"; pub(crate) const DATA_SOURCE_ID: &str = "Calculator";
/// JSON file for this extension.
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
{
"id": "Calculator",
"name": "Calculator",
"platforms": ["macos", "linux", "windows"],
"description": "...",
"icon": "font_Calculator",
"type": "calculator",
"enabled": true
}
"#;
pub struct CalculatorSource { pub struct CalculatorSource {
base_score: f64, base_score: f64,
} }
@@ -23,7 +36,7 @@ impl CalculatorSource {
} }
} }
fn parse_query(query: String) -> Value { fn parse_query(query: &str) -> Value {
let mut query_json = serde_json::Map::new(); let mut query_json = serde_json::Map::new();
let operators = ["+", "-", "*", "/", "%"]; let operators = ["+", "-", "*", "/", "%"];
@@ -48,7 +61,7 @@ fn parse_query(query: String) -> Value {
query_json.insert("type".to_string(), Value::String("expression".to_string())); query_json.insert("type".to_string(), Value::String("expression".to_string()));
} }
query_json.insert("value".to_string(), Value::String(query)); query_json.insert("value".to_string(), Value::String(query.to_string()));
Value::Object(query_json) Value::Object(query_json)
} }
@@ -108,11 +121,17 @@ impl SearchSource for CalculatorSource {
} }
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> { async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
let query_string = query let Some(query_string) = query.query_strings.get("query") else {
.query_strings return Ok(QueryResponse {
.get("query") source: self.get_type(),
.unwrap_or(&"".to_string()) hits: Vec::new(),
.to_string(); 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 { if query_string.is_empty() || query_string.len() == 1 {
return Ok(QueryResponse { return Ok(QueryResponse {
@@ -122,11 +141,17 @@ impl SearchSource for CalculatorSource {
}); });
} }
match meval::eval_str(&query_string) { let query_string_clone = query_string.to_string();
let query_source = self.get_type();
let base_score = self.base_score;
let closure = move || -> QueryResponse {
let res_num = meval::eval_str(&query_string_clone);
match res_num {
Ok(num) => { Ok(num) => {
let mut payload: HashMap<String, Value> = HashMap::new(); let mut payload: HashMap<String, Value> = HashMap::new();
let payload_query = parse_query(query_string); let payload_query = parse_query(&query_string_clone);
let payload_result = parse_result(num); let payload_result = parse_result(num);
payload.insert("query".to_string(), payload_query); payload.insert("query".to_string(), payload_query);
@@ -140,24 +165,32 @@ impl SearchSource for CalculatorSource {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()), r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some(DATA_SOURCE_ID.into()), name: Some(DATA_SOURCE_ID.into()),
id: Some(DATA_SOURCE_ID.into()), id: Some(DATA_SOURCE_ID.into()),
icon: None, icon: Some(String::from("font_Calculator")),
}), }),
..Default::default() ..Default::default()
}; };
return Ok(QueryResponse { QueryResponse {
source: self.get_type(), source: query_source,
hits: vec![(doc, self.base_score)], hits: vec![(doc, base_score)],
total_hits: 1, total_hits: 1,
}); }
} }
Err(_) => { Err(_) => {
return Ok(QueryResponse { QueryResponse {
source: self.get_type(), source: query_source,
hits: Vec::new(), hits: Vec::new(),
total_hits: 0, total_hits: 0,
}); }
}
} }
}; };
let spawn_result = tokio::task::spawn_blocking(closure).await;
match spawn_result {
Ok(response) => Ok(response),
Err(e) => std::panic::resume_unwind(e.into_panic()),
}
} }
} }

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,482 @@
//! 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::built_in::application::{set_apps_hotkey, unset_apps_hotkey};
use crate::extension::{
alter_extension_json_file, ExtensionBundleIdBorrowed, PLUGIN_JSON_FILE_NAME,
};
use crate::{SearchSourceRegistry, GLOBAL_TAURI_APP_HANDLE};
use anyhow::Context;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use tauri::{AppHandle, Manager, Runtime};
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()
.app_data_dir()
.expect(
"User home directory not found, which should be impossible on desktop environments",
);
resource_dir.push("built_in_extensions");
resource_dir
});
/// Helper function to load the built-in extension specified by `extension_id`, used
/// in `list_built_in_extensions()`.
///
/// For built-in extensions, users are only allowed to edit these fields:
///
/// 1. alias (if this extension supports alias)
/// 2. hotkey (if this extension supports hotkey)
/// 3. enabled
///
/// If
///
/// 1. The above fields have invalid value
/// 2. Other fields are modified
///
/// we ignore and reset them to the default value.
async fn load_built_in_extension(
built_in_extensions_dir: &Path,
extension_id: &str,
default_plugin_json_file: &str,
) -> Result<Extension, String> {
let mut extension_dir = built_in_extensions_dir.join(extension_id);
let mut default_plugin_json = serde_json::from_str::<Extension>(&default_plugin_json_file).unwrap_or_else( |e| {
panic!("the default extension {} file of built-in extension [{}] cannot be parsed as a valid [struct Extension], error [{}]", PLUGIN_JSON_FILE_NAME, extension_id, e);
});
if !extension_dir.try_exists().map_err(|e| e.to_string())? {
tokio::fs::create_dir_all(extension_dir.as_path())
.await
.map_err(|e| e.to_string())?;
}
let plugin_json_file_path = {
extension_dir.push(PLUGIN_JSON_FILE_NAME);
extension_dir
};
// If the JSON file does not exist, create a file with the default template and return.
if !plugin_json_file_path
.try_exists()
.map_err(|e| e.to_string())?
{
tokio::fs::write(plugin_json_file_path, default_plugin_json_file)
.await
.map_err(|e| e.to_string())?;
return Ok(default_plugin_json);
}
let plugin_json_file_content = tokio::fs::read_to_string(plugin_json_file_path.as_path())
.await
.map_err(|e| e.to_string())?;
let res_plugin_json = serde_json::from_str::<Extension>(&plugin_json_file_content);
let Ok(plugin_json) = res_plugin_json else {
log::warn!("user invalidated built-in extension [{}] file, overwriting it with the default template", extension_id);
// If the JSON file cannot be parsed as `struct Extension`, overwrite it with the default template and return.
tokio::fs::write(plugin_json_file_path, default_plugin_json_file)
.await
.map_err(|e| e.to_string())?;
return Ok(default_plugin_json);
};
// Users are only allowed to edit the below fields
// 1. alias (if this extension supports alias)
// 2. hotkey (if this extension supports hotkey)
// 3. enabled
// so we ignore all other fields.
let alias = if default_plugin_json.supports_alias_hotkey() {
plugin_json.alias.clone()
} else {
None
};
let hotkey = if default_plugin_json.supports_alias_hotkey() {
plugin_json.hotkey.clone()
} else {
None
};
let enabled = plugin_json.enabled;
default_plugin_json.alias = alias;
default_plugin_json.hotkey = hotkey;
default_plugin_json.enabled = enabled;
let final_plugin_json_file_content = serde_json::to_string_pretty(&default_plugin_json)
.expect("failed to serialize `struct Extension`");
tokio::fs::write(plugin_json_file_path, final_plugin_json_file_content)
.await
.map_err(|e| e.to_string())?;
Ok(default_plugin_json)
}
/// Return the built-in extension list.
///
/// Will create extension files when they are not found.
///
/// Users may put extension files in the built-in extension directory, but
/// we do not care and will ignore them.
///
/// We only read alias/hotkey/enabled from the JSON file, we have ensured that if
/// alias/hotkey is not supported, then it will be `None`. Besides that, no further
/// validation is needed because nothing could go wrong.
pub(crate) async fn list_built_in_extensions() -> Result<Vec<Extension>, String> {
let dir = BUILT_IN_EXTENSION_DIRECTORY.as_path();
let mut built_in_extensions = Vec::new();
built_in_extensions.push(
load_built_in_extension(
dir,
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME,
application::PLUGIN_JSON_FILE,
)
.await?,
);
built_in_extensions.push(
load_built_in_extension(
dir,
calculator::DATA_SOURCE_ID,
calculator::PLUGIN_JSON_FILE,
)
.await?,
);
built_in_extensions.push(
load_built_in_extension(
dir,
ai_overview::EXTENSION_ID,
ai_overview::PLUGIN_JSON_FILE,
)
.await?,
);
built_in_extensions.push(
load_built_in_extension(
dir,
quick_ai_access::EXTENSION_ID,
quick_ai_access::PLUGIN_JSON_FILE,
)
.await?,
);
Ok(built_in_extensions)
}
pub(super) async fn init_built_in_extension<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
extension: &Extension,
search_source_registry: &SearchSourceRegistry,
) -> Result<(), String> {
log::trace!("initializing built-in extensions");
if extension.id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
search_source_registry
.register_source(application::ApplicationSearchSource)
.await;
set_apps_hotkey(&tauri_app_handle)?;
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);
}
Ok(())
}
pub(crate) fn is_extension_built_in(bundle_id: &ExtensionBundleIdBorrowed<'_>) -> bool {
bundle_id.developer.is_none()
}
pub(crate) async fn enable_built_in_extension(
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> 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 bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
&& bundle_id.sub_extension_id.is_none()
{
search_source_registry_tauri_state
.register_source(application::ApplicationSearchSource)
.await;
set_apps_hotkey(tauri_app_handle)?;
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
bundle_id,
update_extension,
)?;
return Ok(());
}
// Check if this is an application
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
&& bundle_id.sub_extension_id.is_some()
{
let app_path = bundle_id.sub_extension_id.expect("just checked it is Some");
application::enable_app_search(tauri_app_handle, app_path)?;
return Ok(());
}
if bundle_id.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(),
bundle_id,
update_extension,
)?;
return Ok(());
}
if bundle_id.extension_id == quick_ai_access::EXTENSION_ID {
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
bundle_id,
update_extension,
)?;
return Ok(());
}
if bundle_id.extension_id == ai_overview::EXTENSION_ID {
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
bundle_id,
update_extension,
)?;
return Ok(());
}
Ok(())
}
pub(crate) async fn disable_built_in_extension(
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> 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 bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
&& bundle_id.sub_extension_id.is_none()
{
search_source_registry_tauri_state
.remove_source(bundle_id.extension_id)
.await;
unset_apps_hotkey(tauri_app_handle)?;
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
bundle_id,
update_extension,
)?;
return Ok(());
}
// Check if this is an application
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
&& bundle_id.sub_extension_id.is_some()
{
let app_path = bundle_id.sub_extension_id.expect("just checked it is Some");
application::disable_app_search(tauri_app_handle, app_path)?;
return Ok(());
}
if bundle_id.extension_id == calculator::DATA_SOURCE_ID {
search_source_registry_tauri_state
.remove_source(bundle_id.extension_id)
.await;
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
bundle_id,
update_extension,
)?;
return Ok(());
}
if bundle_id.extension_id == quick_ai_access::EXTENSION_ID {
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
bundle_id,
update_extension,
)?;
return Ok(());
}
if bundle_id.extension_id == ai_overview::EXTENSION_ID {
alter_extension_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
bundle_id,
update_extension,
)?;
return Ok(());
}
Ok(())
}
pub(crate) fn set_built_in_extension_alias(bundle_id: &ExtensionBundleIdBorrowed<'_>, alias: &str) {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
if let Some(app_path) = bundle_id.sub_extension_id {
application::set_app_alias(tauri_app_handle, app_path, alias);
}
}
}
pub(crate) fn register_built_in_extension_hotkey(
bundle_id: &ExtensionBundleIdBorrowed<'_>,
hotkey: &str,
) -> Result<(), String> {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
if let Some(app_path) = bundle_id.sub_extension_id {
application::register_app_hotkey(&tauri_app_handle, app_path, hotkey)?;
}
}
Ok(())
}
pub(crate) fn unregister_built_in_extension_hotkey(
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), String> {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
if let Some(app_path) = bundle_id.sub_extension_id {
application::unregister_app_hotkey(&tauri_app_handle, app_path)?;
}
}
Ok(())
}
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())?;
super::canonicalize_relative_icon_path(extension_directory, &mut extension)?;
Ok(extension)
}
pub(crate) async fn is_built_in_extension_enabled(
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> 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 bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
&& bundle_id.sub_extension_id.is_none()
{
return Ok(search_source_registry_tauri_state
.get_source(bundle_id.extension_id)
.await
.is_some());
}
// Check if this is an application
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
if let Some(app_path) = bundle_id.sub_extension_id {
return Ok(application::is_app_search_enabled(app_path));
}
}
if bundle_id.extension_id == calculator::DATA_SOURCE_ID {
return Ok(search_source_registry_tauri_state
.get_source(bundle_id.extension_id)
.await
.is_some());
}
if bundle_id.extension_id == quick_ai_access::EXTENSION_ID {
let extension = load_extension_from_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
bundle_id.extension_id,
)?;
return Ok(extension.enabled);
}
if bundle_id.extension_id == ai_overview::EXTENSION_ID {
let extension = load_extension_from_json_file(
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
bundle_id.extension_id,
)?;
return Ok(extension.enabled);
}
unreachable!("extension [{:?}] is not a built-in extension", bundle_id)
}

View File

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

View File

@@ -0,0 +1,12 @@
pub(super) const EXTENSION_ID: &str = "QuickAIAccess";
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
{
"id": "QuickAIAccess",
"name": "Quick AI Access",
"description": "...",
"icon": "font_a-QuickAIAccess",
"type": "ai_extension",
"enabled": true
}
"#;

View File

@@ -0,0 +1,757 @@
pub(crate) mod built_in;
pub(crate) mod store;
mod third_party;
use crate::common::document::OnOpened;
use crate::{common::register::SearchSourceRegistry, GLOBAL_TAURI_APP_HANDLE};
use anyhow::Context;
use borrowme::{Borrow, ToOwned};
use derive_more::Display;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as Json;
use std::collections::HashSet;
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";
fn default_true() -> bool {
true
}
#[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,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Extension {
/// Extension ID.
///
/// The ID doesn't uniquely identifies an extension; Its bundle ID (ID & developer) does.
id: String,
/// Extension name.
name: String,
/// ID of the developer.
///
/// * For built-in extensions, this will always be None.
/// * For third-party first-layer extensions, the on-disk plugin.json file
/// won't contain this field, but we will set this field for them after reading them into the memory.
/// * For third-party sub extensions, this field will be None.
developer: Option<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")]
quicklink: 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>>,
quicklinks: 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.
#[serde(default = "default_true")]
enabled: bool,
/// Extension settings
#[serde(skip_serializing_if = "Option::is_none")]
settings: Option<Json>,
// We do not care about these fields, just take it regardless of what it is.
screenshots: Option<Json>,
url: Option<Json>,
version: Option<Json>,
}
/// Bundle ID uniquely identifies an extension.
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
pub(crate) struct ExtensionBundleId {
developer: Option<String>,
extension_id: String,
sub_extension_id: Option<String>,
}
impl Borrow for ExtensionBundleId {
type Target<'a> = ExtensionBundleIdBorrowed<'a>;
fn borrow(&self) -> Self::Target<'_> {
ExtensionBundleIdBorrowed {
developer: self.developer.as_deref(),
extension_id: &self.extension_id,
sub_extension_id: self.sub_extension_id.as_deref(),
}
}
}
/// Reference version of `ExtensionBundleId`.
#[derive(Debug, Serialize, PartialEq)]
pub(crate) struct ExtensionBundleIdBorrowed<'ext> {
developer: Option<&'ext str>,
extension_id: &'ext str,
sub_extension_id: Option<&'ext str>,
}
impl ToOwned for ExtensionBundleIdBorrowed<'_> {
type Owned = ExtensionBundleId;
fn to_owned(&self) -> Self::Owned {
ExtensionBundleId {
developer: self.developer.map(|s| s.to_string()),
extension_id: self.extension_id.to_string(),
sub_extension_id: self.sub_extension_id.map(|s| s.to_string()),
}
}
}
impl<'ext> PartialEq<ExtensionBundleIdBorrowed<'ext>> for ExtensionBundleId {
fn eq(&self, other: &ExtensionBundleIdBorrowed<'ext>) -> bool {
self.developer.as_deref() == other.developer
&& self.extension_id == other.extension_id
&& self.sub_extension_id.as_deref() == other.sub_extension_id
}
}
impl<'ext> PartialEq<ExtensionBundleId> for ExtensionBundleIdBorrowed<'ext> {
fn eq(&self, other: &ExtensionBundleId) -> bool {
self.developer == other.developer.as_deref()
&& self.extension_id == other.extension_id
&& self.sub_extension_id == other.sub_extension_id.as_deref()
}
}
impl Extension {
/// WARNING: the bundle ID returned from this function always has its `sub_extension_id`
/// set to `None`, this may not be what you want.
pub(crate) fn bundle_id_borrowed(&self) -> ExtensionBundleIdBorrowed<'_> {
ExtensionBundleIdBorrowed {
developer: self.developer.as_deref(),
extension_id: &self.id,
sub_extension_id: None,
}
}
/// 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,
}
}
pub(crate) fn get_sub_extension(&self, sub_extension_id: &str) -> Option<&Self> {
if !self.r#type.contains_sub_items() {
return None;
}
if let Some(ref commands) = self.commands {
if let Some(sub_ext) = commands.iter().find(|cmd| cmd.id == sub_extension_id) {
return Some(sub_ext);
}
}
if let Some(ref scripts) = self.scripts {
if let Some(sub_ext) = scripts.iter().find(|script| script.id == sub_extension_id) {
return Some(sub_ext);
}
}
if let Some(ref quick_links) = self.quicklinks {
if let Some(sub_ext) = quick_links.iter().find(|link| link.id == sub_extension_id) {
return Some(sub_ext);
}
}
None
}
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.quicklinks {
if let Some(sub_ext) = quick_links
.iter_mut()
.find(|link| link.id == sub_extension_id)
{
return Some(sub_ext);
}
}
None
}
pub(crate) fn supports_alias_hotkey(&self) -> bool {
let ty = self.r#type;
ty != ExtensionType::Group && ty != ExtensionType::Extension
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub(crate) struct CommandAction {
pub(crate) exec: String,
pub(crate) args: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct QuickLink {
link: String,
}
#[derive(Debug, PartialEq, Deserialize, Serialize, Clone, Display, Copy)]
#[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
}
}
/// Helper function to filter out the extensions that do not satisfy the specifies conditions.
///
/// used in `list_extensions()`
fn filter_out_extensions(
extensions: &mut Vec<Extension>,
query: Option<&str>,
extension_type: Option<ExtensionType>,
list_enabled: bool,
) {
// apply `list_enabled`
if list_enabled {
extensions.retain(|ext| ext.enabled);
for extension in extensions.iter_mut() {
if extension.r#type.contains_sub_items() {
if let Some(ref mut commands) = extension.commands {
commands.retain(|cmd| cmd.enabled);
}
if let Some(ref mut scripts) = extension.scripts {
scripts.retain(|script| script.enabled);
}
if let Some(ref mut quicklinks) = extension.quicklinks {
quicklinks.retain(|link| link.enabled);
}
}
}
}
// apply extension type filter to non-group/extension extensions
if let Some(extension_type) = extension_type {
assert!(
extension_type != ExtensionType::Group && extension_type != ExtensionType::Extension,
"filtering in folder extensions is pointless"
);
extensions.retain(|ext| {
let ty = ext.r#type;
ty == ExtensionType::Group || ty == ExtensionType::Extension || ty == extension_type
});
// Filter sub-extensions to only include the requested type
for extension in extensions.iter_mut() {
if extension.r#type.contains_sub_items() {
if let Some(ref mut commands) = extension.commands {
commands.retain(|cmd| cmd.r#type == extension_type);
}
if let Some(ref mut scripts) = extension.scripts {
scripts.retain(|script| script.r#type == extension_type);
}
if let Some(ref mut quicklinks) = extension.quicklinks {
quicklinks.retain(|link| link.r#type == extension_type);
}
}
}
// Application is special, technically, it should never be filtered out by
// this condition. But if our users will be surprising if they choose a
// non-Application type and see it in the results. So we do this to remedy the
// issue
if let Some(idx) = extensions.iter().position(|ext| {
ext.developer.is_none()
&& ext.id == built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
}) {
if extension_type != ExtensionType::Application {
extensions.remove(idx);
}
}
}
// apply query filter
if let Some(query) = query {
let match_closure = |ext: &Extension| {
let lowercase_title = ext.name.to_lowercase();
let lowercase_alias = ext.alias.as_ref().map(|alias| alias.to_lowercase());
let lowercase_query = query.to_lowercase();
lowercase_title.contains(&lowercase_query)
|| lowercase_alias.map_or(false, |alias| alias.contains(&lowercase_query))
};
extensions.retain(|ext| {
if ext.r#type.contains_sub_items() {
// Keep all group/extension types
true
} else {
// Apply filter to non-group/extension types
match_closure(ext)
}
});
// Filter sub-extensions in groups and extensions
for extension in extensions.iter_mut() {
if extension.r#type.contains_sub_items() {
if let Some(ref mut commands) = extension.commands {
commands.retain(&match_closure);
}
if let Some(ref mut scripts) = extension.scripts {
scripts.retain(&match_closure);
}
if let Some(ref mut quicklinks) = extension.quicklinks {
quicklinks.retain(&match_closure);
}
}
}
}
// Remove parent extensions (Group/Extension types) that have no sub-items after filtering
extensions.retain(|ext| {
if !ext.r#type.contains_sub_items() {
return true;
}
// We don't do this filter to applications since it is always empty, load at runtime.
if ext.developer.is_none()
&& ext.id == built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
{
return true;
}
let has_commands = ext
.commands
.as_ref()
.map_or(false, |commands| !commands.is_empty());
let has_scripts = ext
.scripts
.as_ref()
.map_or(false, |scripts| !scripts.is_empty());
let has_quicklinks = ext
.quicklinks
.as_ref()
.map_or(false, |quicklinks| !quicklinks.is_empty());
has_commands || has_scripts || has_quicklinks
});
}
/// Return value:
///
/// * boolean: indicates if we found any invalid extensions
/// * Vec<Extension>: loaded extensions
#[tauri::command]
pub(crate) async fn list_extensions(
query: Option<String>,
extension_type: Option<ExtensionType>,
list_enabled: bool,
) -> Result<(bool, Vec<Extension>), String> {
log::trace!("loading extensions");
let third_party_dir = third_party::THIRD_PARTY_EXTENSIONS_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) =
third_party::list_third_party_extensions(third_party_dir).await?;
let built_in_extensions = built_in::list_built_in_extensions().await?;
let found_invalid_extension = third_party_found_invalid_extension;
let mut extensions = {
third_party_extensions.extend(built_in_extensions);
third_party_extensions
};
filter_out_extensions(
&mut extensions,
query.as_deref(),
extension_type,
list_enabled,
);
Ok((found_invalid_extension, extensions))
}
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::prepare_index_and_store(
tauri_app_handle.clone(),
)
.await?;
// extension store
search_source_registry_tauri_state .register_source(store::ExtensionStore).await;
// Init the built-in enabled extensions
for built_in_extension in extensions
.extract_if(.., |ext| {
built_in::is_extension_built_in(&ext.bundle_id_borrowed())
})
.filter(|ext| ext.enabled)
{
built_in::init_built_in_extension(
tauri_app_handle,
&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.init().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(bundle_id: ExtensionBundleId) -> Result<(), String> {
let bundle_id_borrowed = bundle_id.borrow();
if built_in::is_extension_built_in(&bundle_id_borrowed) {
built_in::enable_built_in_extension(&bundle_id_borrowed).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(&bundle_id_borrowed).await
}
#[tauri::command]
pub(crate) async fn disable_extension(bundle_id: ExtensionBundleId) -> Result<(), String> {
let bundle_id_borrowed = bundle_id.borrow();
if built_in::is_extension_built_in(&bundle_id_borrowed) {
built_in::disable_built_in_extension(&bundle_id_borrowed).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(&bundle_id_borrowed).await
}
#[tauri::command]
pub(crate) async fn set_extension_alias(
bundle_id: ExtensionBundleId,
alias: String,
) -> Result<(), String> {
let bundle_id_borrowed = bundle_id.borrow();
if built_in::is_extension_built_in(&bundle_id_borrowed) {
built_in::set_built_in_extension_alias(&bundle_id_borrowed, &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(&bundle_id_borrowed, &alias).await
}
#[tauri::command]
pub(crate) async fn register_extension_hotkey(
bundle_id: ExtensionBundleId,
hotkey: String,
) -> Result<(), String> {
let bundle_id_borrowed = bundle_id.borrow();
if built_in::is_extension_built_in(&bundle_id_borrowed) {
built_in::register_built_in_extension_hotkey(&bundle_id_borrowed, &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(&bundle_id_borrowed, &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(
bundle_id: ExtensionBundleId,
) -> Result<(), String> {
let bundle_id_borrowed = bundle_id.borrow();
if built_in::is_extension_built_in(&bundle_id_borrowed) {
built_in::unregister_built_in_extension_hotkey(&bundle_id_borrowed)?;
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(&bundle_id_borrowed).await?;
Ok(())
}
#[tauri::command]
pub(crate) async fn is_extension_enabled(bundle_id: ExtensionBundleId) -> Result<bool, String> {
let bundle_id_borrowed = bundle_id.borrow();
if built_in::is_extension_built_in(&bundle_id_borrowed) {
return built_in::is_built_in_extension_enabled(&bundle_id_borrowed).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(&bundle_id_borrowed).await
}
pub(crate) 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.quicklinks {
for quick_link in quick_links {
_canonicalize_relative_icon_path(extension_dir, quick_link)?;
}
}
Ok(())
}
fn alter_extension_json_file(
extension_directory: &Path,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
how: impl Fn(&mut Extension) -> Result<(), String>,
) -> Result<(), String> {
/// Perform `how` against the extension specified by `extension_id`.
///
/// Please note that `bundle` could point to a sub extension if `sub_extension_id` is Some.
pub(crate) fn modify(
root_extension: &mut Extension,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
how: impl FnOnce(&mut Extension) -> Result<(), String>,
) -> Result<(), String> {
let (parent_extension_id, opt_sub_extension_id) =
(bundle_id.extension_id, bundle_id.sub_extension_id);
assert_eq!(
parent_extension_id, root_extension.id,
"modify() should be invoked against a parent extension"
);
let Some(sub_extension_id) = opt_sub_extension_id else {
how(root_extension)?;
return Ok(());
};
// Search in commands
if let Some(ref mut commands) = root_extension.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) = root_extension.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) = root_extension.quicklinks {
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 {:?}",
bundle_id, root_extension
))
}
log::debug!(
"altering extension JSON file for extension [{:?}]",
bundle_id
);
let json_file_path = {
let mut path = extension_directory.to_path_buf();
if let Some(developer) = bundle_id.developer {
path.push(developer);
}
path.push(bundle_id.extension_id);
path.push(PLUGIN_JSON_FILE_NAME);
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, bundle_id
)
})
.map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
modify(&mut extension, bundle_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,345 @@
//! Extension store related stuff.
use super::LOCAL_QUERY_SOURCE_TYPE;
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::canonicalize_relative_icon_path;
use crate::extension::third_party::THIRD_PARTY_EXTENSIONS_DIRECTORY;
use crate::extension::Extension;
use crate::extension::PLUGIN_JSON_FILE_NAME;
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
use crate::server::http_client::HttpClient;
use async_trait::async_trait;
use reqwest::StatusCode;
use serde_json::Map as JsonObject;
use serde_json::Value as Json;
const DATA_SOURCE_ID: &str = "Extension Store";
pub(crate) struct ExtensionStore;
#[async_trait]
impl SearchSource for ExtensionStore {
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> {
const SCORE: f64 = 2000.0;
let Some(query_string) = query.query_strings.get("query") else {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
};
let lowercase_query_string = query_string.to_lowercase();
let expected_str = "extension store";
if expected_str.contains(&lowercase_query_string) {
let doc = Document {
id: DATA_SOURCE_ID.to_string(),
category: Some(DATA_SOURCE_ID.to_string()),
title: Some(DATA_SOURCE_ID.to_string()),
icon: Some("font_Store".to_string()),
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("font_Store".to_string()),
}),
..Default::default()
};
Ok(QueryResponse {
source: self.get_type(),
hits: vec![(doc, SCORE)],
total_hits: 1,
})
} else {
Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
})
}
}
}
#[tauri::command]
pub(crate) async fn search_extension(
query_params: Option<Vec<String>>,
) -> Result<Vec<Json>, String> {
let response = HttpClient::get(
"default_coco_server",
"store/extension/_search",
query_params,
)
.await
.map_err(|e| format!("Failed to send request: {:?}", e))?;
// The response of a ES style search request
let mut response: JsonObject<String, Json> = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {:?}", e))?;
let hits_json = response
.remove("hits")
.expect("the JSON response should contain field [hits]");
let mut hits = match hits_json {
Json::Object(obj) => obj,
_ => panic!(
"field [hits] should be a JSON object, but it is not, value: [{}]",
hits_json
),
};
let Some(hits_hits_json) = hits.remove("hits") else {
return Ok(Vec::new());
};
let hits_hits = match hits_hits_json {
Json::Array(arr) => arr,
_ => panic!(
"field [hits.hits] should be an array, but it is not, value: [{}]",
hits_hits_json
),
};
let mut extensions = Vec::with_capacity(hits_hits.len());
for hit in hits_hits {
let mut hit_obj = match hit {
Json::Object(obj) => obj,
_ => panic!(
"each hit in [hits.hits] should be a JSON object, but it is not, value: [{}]",
hit
),
};
let source = hit_obj
.remove("_source")
.expect("each hit should contain field [_source]");
let mut source_obj = match source {
Json::Object(obj) => obj,
_ => panic!(
"field [_source] should be a JSON object, but it is not, value: [{}]",
source
),
};
let developer_id = source_obj
.get("developer")
.and_then(|dev| dev.get("id"))
.and_then(|id| id.as_str())
.expect("developer.id should exist")
.to_string();
let extension_id = source_obj
.get("id")
.and_then(|id| id.as_str())
.expect("extension id should exist")
.to_string();
let installed = is_extension_installed(developer_id, extension_id).await;
source_obj.insert("installed".to_string(), Json::Bool(installed));
extensions.push(Json::Object(source_obj));
}
Ok(extensions)
}
async fn is_extension_installed(developer: String, extension_id: String) -> bool {
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.extension_exists(&developer, &extension_id)
.await
}
#[tauri::command]
pub(crate) async fn install_extension(id: String) -> Result<(), String> {
let path = format!("store/extension/{}/_download", id);
let response = HttpClient::get("default_coco_server", &path, None)
.await
.map_err(|e| format!("Failed to download extension: {}", e))?;
if response.status() == StatusCode::NOT_FOUND {
return Err(format!("extension [{}] not found", id));
}
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read response bytes: {}", e))?;
let cursor = std::io::Cursor::new(bytes);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
let mut plugin_json = archive.by_name("plugin.json").map_err(|e| e.to_string())?;
let mut plugin_json_content = String::new();
std::io::Read::read_to_string(&mut plugin_json, &mut plugin_json_content)
.map_err(|e| e.to_string())?;
let mut extension: Json = serde_json::from_str(&plugin_json_content)
.map_err(|e| format!("Failed to parse plugin.json: {}", e))?;
let mut_ref_to_developer_object: &mut Json = extension
.as_object_mut()
.expect("plugin.json should be an object")
.get_mut("developer")
.expect("plugin.json should contain field [developer]");
let developer_id = mut_ref_to_developer_object
.get("id")
.expect("plugin.json should contain [developer.id]")
.as_str()
.expect("plugin.json field [developer.id] should be a string");
*mut_ref_to_developer_object = Json::String(developer_id.into());
// Set IDs for sub-extensions (commands, quicklinks, scripts)
let mut counter = 0;
// Set IDs for commands
// Helper function to set IDs for array fields
fn set_ids_for_field(extension: &mut Json, field_name: &str, counter: &mut i32) {
if let Some(field) = extension.as_object_mut().unwrap().get_mut(field_name) {
if let Some(array) = field.as_array_mut() {
for item in array {
if let Some(item_obj) = item.as_object_mut() {
if !item_obj.contains_key("id") {
item_obj.insert("id".to_string(), Json::String(counter.to_string()));
*counter += 1;
}
}
}
}
}
}
// Set IDs for sub-extensions
set_ids_for_field(&mut extension, "commands", &mut counter);
set_ids_for_field(&mut extension, "quicklinks", &mut counter);
set_ids_for_field(&mut extension, "scripts", &mut counter);
let mut extension: Extension = serde_json::from_value(extension).unwrap_or_else(|e| {
panic!(
"cannot parse plugin.json as struct Extension, error [{:?}]",
e
);
});
drop(plugin_json);
let developer = extension.developer.clone().unwrap_or_default();
let extension_id = extension.id.clone();
// Extract the zip file
let extension_directory = {
let mut path = THIRD_PARTY_EXTENSIONS_DIRECTORY.to_path_buf();
path.push(developer);
path.push(extension_id.as_str());
path
};
tokio::fs::create_dir_all(extension_directory.as_path())
.await
.map_err(|e| e.to_string())?;
// Extract all files except plugin.json
for i in 0..archive.len() {
let mut file = archive.by_index(i).map_err(|e| e.to_string())?;
let outpath = match file.enclosed_name() {
Some(path) => extension_directory.join(path),
None => continue,
};
// Skip the plugin.json file as we'll create it from the extension variable
if file.name() == "plugin.json" {
continue;
}
if file.name().ends_with('/') {
tokio::fs::create_dir_all(&outpath)
.await
.map_err(|e| e.to_string())?;
} else {
if let Some(p) = outpath.parent() {
if !p.exists() {
tokio::fs::create_dir_all(p)
.await
.map_err(|e| e.to_string())?;
}
}
let mut outfile = tokio::fs::File::create(&outpath)
.await
.map_err(|e| e.to_string())?;
let mut content = Vec::new();
std::io::Read::read_to_end(&mut file, &mut content).map_err(|e| e.to_string())?;
tokio::io::AsyncWriteExt::write_all(&mut outfile, &content)
.await
.map_err(|e| e.to_string())?;
}
}
// Create plugin.json from the extension variable
let plugin_json_path = extension_directory.join(PLUGIN_JSON_FILE_NAME);
let extension_json = serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?;
tokio::fs::write(&plugin_json_path, extension_json)
.await
.map_err(|e| e.to_string())?;
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.add_extension(extension)
.await;
Ok(())
}
#[tauri::command]
pub(crate) async fn uninstall_extension(
developer: String,
extension_id: String,
) -> Result<(), String> {
let extension_dir = {
let mut path = THIRD_PARTY_EXTENSIONS_DIRECTORY.join(developer.as_str());
path.push(extension_id.as_str());
path
};
if !extension_dir.try_exists().map_err(|e| e.to_string())? {
panic!(
"we are uninstalling extension [{}/{}], but there is no such extension files on disk",
developer, extension_id
)
}
tokio::fs::remove_dir_all(extension_dir.as_path())
.await
.map_err(|e| e.to_string())?;
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.remove_extension(&developer, &extension_id)
.await;
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
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 settings;
@@ -11,19 +11,15 @@ mod util;
use crate::common::register::SearchSourceRegistry; use crate::common::register::SearchSourceRegistry;
// use crate::common::traits::SearchSource; // use crate::common::traits::SearchSource;
use crate::common::{MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL}; use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
use crate::server::servers::{load_or_insert_default_server, load_servers_token}; use crate::server::servers::{load_or_insert_default_server, load_servers_token};
use autostart::{change_autostart, enable_autostart}; use autostart::{change_autostart, ensure_autostart_state_consistent};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::OnceLock; use std::sync::OnceLock;
use tauri::async_runtime::block_on; use tauri::async_runtime::block_on;
use tauri::plugin::TauriPlugin; use tauri::plugin::TauriPlugin;
#[cfg(target_os = "macos")] use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, WindowEvent};
use tauri::ActivationPolicy;
use tauri::{
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, Window, WindowEvent,
};
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
/// Tauri store name /// Tauri store name
@@ -64,11 +60,13 @@ pub fn run() {
let ctx = tauri::generate_context!(); let ctx = tauri::generate_context!();
let mut app_builder = tauri::Builder::default(); let mut app_builder = tauri::Builder::default();
// Set up logger first
app_builder = app_builder.plugin(set_up_tauri_logger());
#[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
})); }));
} }
@@ -77,7 +75,7 @@ pub fn run() {
.plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_autostart::init( .plugin(tauri_plugin_autostart::init(
MacosLauncher::AppleScript, MacosLauncher::LaunchAgent,
None, None,
)) ))
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
@@ -89,7 +87,7 @@ pub fn run() {
.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(tauri_plugin_windows_version::init())
.plugin(set_up_tauri_logger()); .plugin(tauri_plugin_opener::init());
// Conditional compilation for macOS // Conditional compilation for macOS
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -107,6 +105,8 @@ pub fn run() {
show_coco, show_coco,
hide_coco, hide_coco,
show_settings, show_settings,
show_check,
hide_check,
server::servers::get_server_token, server::servers::get_server_token,
server::servers::add_coco_server, server::servers::add_coco_server,
server::servers::remove_coco_server, server::servers::remove_coco_server,
@@ -131,6 +131,8 @@ pub fn run() {
assistant::delete_session_chat, assistant::delete_session_chat,
assistant::update_session_chat, assistant::update_session_chat,
assistant::assistant_search, 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,
@@ -140,30 +142,42 @@ pub fn run() {
server::attachment::get_attachment, server::attachment::get_attachment,
server::attachment::delete_attachment, server::attachment::delete_attachment,
server::transcription::transcription, server::transcription::transcription,
util::open,
server::system_settings::get_system_settings, server::system_settings::get_system_settings,
simulate_mouse_click, simulate_mouse_click,
local::get_disabled_local_query_sources, extension::built_in::application::get_app_list,
local::enable_local_query_source, extension::built_in::application::get_app_search_path,
local::disable_local_query_source, extension::built_in::application::get_app_metadata,
local::application::get_app_list, extension::built_in::application::add_app_search_path,
local::application::get_app_search_path, extension::built_in::application::remove_app_search_path,
local::application::get_app_metadata, extension::built_in::application::reindex_applications,
local::application::set_app_alias, extension::list_extensions,
local::application::register_app_hotkey, extension::enable_extension,
local::application::unregister_app_hotkey, extension::disable_extension,
local::application::disable_app_search, extension::set_extension_alias,
local::application::enable_app_search, extension::register_extension_hotkey,
local::application::add_app_search_path, extension::unregister_extension_hotkey,
local::application::remove_app_search_path, extension::is_extension_enabled,
extension::store::search_extension,
extension::store::install_extension,
extension::store::uninstall_extension,
settings::set_allow_self_signature, settings::set_allow_self_signature,
settings::get_allow_self_signature, settings::get_allow_self_signature,
assistant::ask_ai,
crate::common::document::open,
]) ])
.setup(|app| { .setup(|app| {
#[cfg(target_os = "macos")]
{
log::trace!("hiding Dock icon on macOS");
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
log::trace!("Dock icon should be hidden now");
}
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
GLOBAL_TAURI_APP_HANDLE GLOBAL_TAURI_APP_HANDLE
.set(app_handle.clone()) .set(app_handle.clone())
.expect("variable already initialized"); .expect("variable already initialized");
log::trace!("global Tauri app handle set");
let registry = SearchSourceRegistry::default(); let registry = SearchSourceRegistry::default();
@@ -176,15 +190,12 @@ pub fn run() {
shortcut::enable_shortcut(app); shortcut::enable_shortcut(app);
enable_autostart(app); ensure_autostart_state_consistent(app)?;
#[cfg(target_os = "macos")]
app.set_activation_policy(ActivationPolicy::Accessory);
// 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);
// } // }
// }); // });
@@ -204,13 +215,19 @@ pub fn run() {
let main_window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap(); let main_window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
let settings_window = app.get_webview_window(SETTINGS_WINDOW_LABEL).unwrap(); let settings_window = app.get_webview_window(SETTINGS_WINDOW_LABEL).unwrap();
setup::default(app, main_window.clone(), settings_window.clone()); let check_window = app.get_webview_window(CHECK_WINDOW_LABEL).unwrap();
setup::default(
app,
main_window.clone(),
settings_window.clone(),
check_window.clone(),
);
Ok(()) Ok(())
}) })
.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();
} }
@@ -225,10 +242,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;
} }
@@ -242,11 +259,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();
@@ -259,12 +276,12 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
.await; .await;
} }
local::start_pizza_engine_runtime(); extension::built_in::pizza_engine_runtime::start_pizza_engine_runtime().await;
} }
#[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_webview_window(MAIN_WINDOW_LABEL) {
move_window_to_active_monitor(&window); move_window_to_active_monitor(&window);
let _ = window.show(); let _ = window.show();
@@ -277,24 +294,24 @@ async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
#[tauri::command] #[tauri::command]
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_webview_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: &WebviewWindow<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;
} }
}; };
@@ -303,7 +320,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
} }
}; };
@@ -332,7 +349,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;
} }
}; };
@@ -342,7 +359,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;
} }
@@ -356,7 +373,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;
} }
}; };
@@ -370,52 +387,25 @@ 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());
} }
} }
#[allow(dead_code)]
fn open_settings(app: &tauri::AppHandle) {
use tauri::webview::WebviewBuilder;
println!("settings menu item was clicked");
let window = app.get_webview_window("settings");
if let Some(window) = window {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
} else {
let window = tauri::window::WindowBuilder::new(app, "settings")
.title("Settings Window")
.fullscreen(false)
.resizable(false)
.minimizable(false)
.maximizable(false)
.inner_size(800.0, 600.0)
.build()
.unwrap();
let webview_builder =
WebviewBuilder::new("settings", tauri::WebviewUrl::App("/ui/settings".into()));
let _webview = window
.add_child(
webview_builder,
tauri::LogicalPosition::new(0, 0),
window.inner_size().unwrap(),
)
.unwrap();
}
}
#[tauri::command] #[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> {
local::init_local_search_source(&app_handle).await?; // We want all the extensions here, so no filter condition specified.
let (_found_invalid_extensions, extensions) = extension::list_extensions(None, None, false)
.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;
@@ -424,7 +414,36 @@ async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(
#[tauri::command] #[tauri::command]
async fn show_settings(app_handle: AppHandle) { async fn show_settings(app_handle: AppHandle) {
open_settings(&app_handle); log::debug!("settings menu item was clicked");
let window = app_handle
.get_webview_window(SETTINGS_WINDOW_LABEL)
.expect("we have a settings window");
window.show().unwrap();
window.unminimize().unwrap();
window.set_focus().unwrap();
}
#[tauri::command]
async fn show_check(app_handle: AppHandle) {
log::debug!("check menu item was clicked");
let window = app_handle
.get_webview_window(CHECK_WINDOW_LABEL)
.expect("we have a check window");
window.show().unwrap();
window.unminimize().unwrap();
window.set_focus().unwrap();
}
#[tauri::command]
async fn hide_check(app_handle: AppHandle) {
log::debug!("check window was closed");
let window = &app_handle
.get_webview_window(CHECK_WINDOW_LABEL)
.expect("we have a check window");
window.hide().unwrap();
} }
#[tauri::command] #[tauri::command]
@@ -487,6 +506,12 @@ async fn simulate_mouse_click<R: Runtime>(window: WebviewWindow<R>, is_chat_mode
/// ``` /// ```
fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> { fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
use log::Level; 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 { fn format_log_level(level: Level) -> &'static str {
match level { match level {
@@ -508,8 +533,79 @@ fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
str str
} }
tauri_plugin_log::Builder::new() /// Allow us to configure dynamic log levels via environment variable `COCO_LOG`.
.format(|out, message, record| { ///
/// Generally, it mirros the behavior of `env_logger`. Syntax: `COCO_LOG=[target][=][level][,...]`
///
/// * If this environment variable is not set, use the default log level.
/// * If it is set, respect it:
///
/// * `COCO_LOG=coco_lib` turns on all logging for the `coco_lib` module, which is
/// equivalent to `COCO_LOG=coco_lib=trace`
/// * `COCO_LOG=trace` turns on all logging for the application, regardless of its name
/// * `COCO_LOG=TRACE` turns on all logging for the application, regardless of its name (same as previous)
/// * `COCO_LOG=reqwest=debug` turns on debug logging for `reqwest`
/// * `COCO_LOG=trace,tauri=off` turns on all the logging except for the logs come from `tauri`
/// * `COCO_LOG=off` turns off all logging for the application
/// * `COCO_LOG=` Since the value is empty, turns off all logging for the application as well
fn dynamic_log_level(mut builder: Builder) -> Builder {
let Some(log_levels) = std::env::var_os(LOG_LEVEL_ENV_VAR) else {
return builder.level(DEFAULT_LOG_LEVEL);
};
builder = builder.level(LevelFilter::Off);
let log_levels = log_levels.into_string().unwrap_or_else(|e| {
panic!(
"The value '{}' set in environment varaible '{}' is not UTF-8 encoded",
// Cannot use `.display()` here becuase that requires MSRV 1.87.0
e.to_string_lossy(),
LOG_LEVEL_ENV_VAR
)
});
// COCO_LOG=[target][=][level][,...]
let target_log_levels = log_levels.split(',');
for target_log_level in target_log_levels {
#[allow(clippy::collapsible_else_if)]
if let Some(char_index) = target_log_level.chars().position(|c| c == '=') {
let (target, equal_sign_and_level) = target_log_level.split_at(char_index);
// Remove the equal sign, we know it takes 1 byte
let level = &equal_sign_and_level[1..];
if let Ok(level) = level.parse::<LevelFilter>() {
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
builder = builder.level_for(target.to_string(), level);
} else {
panic!(
"log level '{}' set in '{}={}' is invalid",
level, target, level
);
}
} else {
if let Ok(level) = target_log_level.parse::<LevelFilter>() {
// This is a level
builder = builder.level(level);
} else {
// This is a target, enable all the logging
//
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
builder = builder.level_for(target_log_level.to_string(), LevelFilter::Trace);
}
}
}
builder
}
// When running the built binary, set `COCO_LOG` to `coco_lib=trace` to capture all logs
// that come from Coco in the log file, which helps with debugging.
if !tauri::is_dev() {
std::env::set_var("COCO_LOG", "coco_lib=trace");
}
let mut builder = tauri_plugin_log::Builder::new();
builder = builder.format(|out, message, record| {
let now = chrono::Local::now().format("%m-%d %H:%M:%S"); let now = chrono::Local::now().format("%m-%d %H:%M:%S");
let level = format_log_level(record.level()); let level = format_log_level(record.level());
let target_and_line = format_target_and_line(record); let target_and_line = format_target_and_line(record);
@@ -517,7 +613,8 @@ fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
"[{}] [{}] [{}] {}", "[{}] [{}] [{}] {}",
now, level, target_and_line, message now, level, target_and_line, message
)); ));
}) });
.level(log::LevelFilter::Debug) builder = dynamic_log_level(builder);
.build()
builder.build()
} }

View File

@@ -1,164 +0,0 @@
pub mod application;
pub mod calculator;
pub mod file_system;
use std::any::Any;
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::common::register::SearchSourceRegistry;
use serde_json::Value as Json;
use tauri::{AppHandle, Manager, Runtime};
use tauri_plugin_store::StoreExt;
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
pub const TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE: &str = "local_query_source_enabled_state";
trait SearchSourceState {
#[cfg_attr(not(feature = "use_pizza_engine"), allow(unused))]
fn as_mut_any(&mut self) -> &mut dyn Any;
}
#[async_trait::async_trait(?Send)]
trait Task: Send + Sync {
fn search_source_id(&self) -> &'static str;
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>);
}
static RUNTIME_TX: OnceLock<tokio::sync::mpsc::UnboundedSender<Box<dyn Task>>> = OnceLock::new();
pub(crate) fn start_pizza_engine_runtime() {
std::thread::spawn(|| {
let rt = tokio::runtime::Runtime::new().unwrap();
let main = async {
let mut states: HashMap<String, Option<Box<dyn SearchSourceState>>> = HashMap::new();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
RUNTIME_TX.set(tx).unwrap();
while let Some(mut task) = rx.recv().await {
let opt_search_source_state = match states.entry(task.search_source_id().into()) {
Entry::Occupied(o) => o.into_mut(),
Entry::Vacant(v) => v.insert(None),
};
task.exec(opt_search_source_state).await;
}
};
rt.block_on(main);
});
}
pub(crate) async fn init_local_search_source<R: Runtime>(
app_handle: &AppHandle<R>,
) -> Result<(), String> {
let enabled_status_store = app_handle
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
.map_err(|e| e.to_string())?;
if enabled_status_store.is_empty() {
enabled_status_store.set(
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME,
Json::Bool(true),
);
enabled_status_store.set(calculator::DATA_SOURCE_ID, Json::Bool(true));
}
let registry = app_handle.state::<SearchSourceRegistry>();
application::ApplicationSearchSource::init(app_handle.clone()).await?;
for (id, enabled) in enabled_status_store.entries() {
let enabled = match enabled {
Json::Bool(b) => b,
_ => unreachable!("enabled state should be stored as a boolean"),
};
if enabled {
if id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
registry
.register_source(application::ApplicationSearchSource)
.await;
}
if id == calculator::DATA_SOURCE_ID {
let calculator_search = calculator::CalculatorSource::new(2000f64);
registry.register_source(calculator_search).await;
}
}
}
Ok(())
}
#[tauri::command]
pub async fn get_disabled_local_query_sources<R: Runtime>(app_handle: AppHandle<R>) -> Vec<String> {
let enabled_status_store = app_handle
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
.unwrap_or_else(|e| {
panic!(
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
)
});
let mut disabled_local_query_sources = Vec::new();
for (id, enabled) in enabled_status_store.entries() {
let enabled = match enabled {
Json::Bool(b) => b,
_ => unreachable!("enabled state should be stored as a boolean"),
};
if !enabled {
disabled_local_query_sources.push(id);
}
}
disabled_local_query_sources
}
#[tauri::command]
pub async fn enable_local_query_source<R: Runtime>(
app_handle: AppHandle<R>,
query_source_id: String,
) {
let registry = app_handle.state::<SearchSourceRegistry>();
if query_source_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
let application_search = application::ApplicationSearchSource;
registry.register_source(application_search).await;
}
if query_source_id == calculator::DATA_SOURCE_ID {
let calculator_search = calculator::CalculatorSource::new(2000f64);
registry.register_source(calculator_search).await;
}
let enabled_status_store = app_handle
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
.unwrap_or_else(|e| {
panic!(
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
)
});
enabled_status_store.set(query_source_id, Json::Bool(true));
}
#[tauri::command]
pub async fn disable_local_query_source<R: Runtime>(
app_handle: AppHandle<R>,
query_source_id: String,
) {
let registry = app_handle.state::<SearchSourceRegistry>();
registry.remove_source(&query_source_id).await;
let enabled_status_store = app_handle
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
.unwrap_or_else(|e| {
panic!(
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
)
});
enabled_status_store.set(query_source_id, Json::Bool(false));
}

View File

@@ -1,15 +1,55 @@
use crate::common::error::SearchError; 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::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>,
@@ -18,56 +58,119 @@ pub async fn query_coco_fusion<R: Runtime>(
query_strings: HashMap<String, String>, query_strings: HashMap<String, String>,
query_timeout: u64, query_timeout: u64,
) -> Result<MultiSourceQueryResponse, SearchError> { ) -> Result<MultiSourceQueryResponse, SearchError> {
let query_source_to_search = query_strings.get("querysource"); let query_keyword = query_strings
.get("query")
.unwrap_or(&"".to_string())
.clone();
let opt_query_source_id = query_strings.get("querysource");
let search_sources = app_handle.state::<SearchSourceRegistry>(); let 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(query_timeout); 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!(),
query_strings,
timeout_duration
);
if let Some(query_source_to_search) = query_source_to_search { let search_query = SearchQuery::new(from, size, query_strings.clone());
// We should not search this data source
if &query_source_type.id != query_source_to_search { if let Some(query_source_id) = opt_query_source_id {
continue; // If this query source ID is specified, we only query this query source.
log::debug!(
"parameter [querysource={}] specified, will only query this querysource",
query_source_id
);
let opt_query_source_trait_object_index = sources_list
.iter()
.position(|query_source| &query_source.get_type().id == query_source_id);
let Some(query_source_trait_object_index) = opt_query_source_trait_object_index else {
// It is possible (an edge case) that the frontend invokes `query_coco_fusion()` with a
// datasource that does not exist in the source list:
//
// 1. Search applications
// 2. Navigate to the application sub page
// 3. Disable the application extension in settings
// 4. hide the search window
// 5. Re-open the search window and search for something
//
// The application search source is not in the source list because the extension
// has been disabled, but the last search is indeed invoked with parameter
// `datasource=application`.
return Ok(MultiSourceQueryResponse {
failed: Vec::new(),
hits: Vec::new(),
total_hits: 0,
});
};
let query_source_trait_object = sources_list.remove(query_source_trait_object_index);
let query_source = query_source_trait_object.get_type();
futures.push(same_type_futures(
query_source,
query_source_trait_object,
timeout_duration,
search_query,
));
} else {
for query_source_trait_object in sources_list {
let query_source = query_source_trait_object.get_type().clone();
log::debug!("will query querysource [{}]", query_source.id);
futures.push(same_type_futures(
query_source,
query_source_trait_object,
timeout_duration,
search_query.clone(),
));
} }
} }
sources.insert(query_source_type.id.clone(), query_source_type);
let query = SearchQuery::new(from, size, query_strings.clone());
let query_source_clone = query_source.clone(); // Clone Arc to avoid ownership issues
futures.push(tokio::spawn(async move {
// Timeout each query execution
timeout(timeout_duration, async {
query_source_clone.search(query).await
})
.await
}));
}
let mut total_hits = 0; let mut 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,
@@ -82,49 +185,26 @@ pub async fn query_coco_fusion<R: Runtime>(
.push((query_hit, score)); .push((query_hit, score));
} }
} }
Ok(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,
}); });
} }
Ok(Err(err)) => {
failed_requests.push(FailedRequest {
source: QuerySource {
r#type: "N/A".into(),
name: "N/A".into(),
id: "N/A".into(),
}, },
status: 0,
error: Some(err.to_string()),
reason: None,
});
}
// Timeout reached, skip this request
_ => {
failed_requests.push(FailedRequest {
source: QuerySource {
r#type: "N/A".into(),
name: "N/A".into(),
id: "N/A".into(),
},
status: 0,
error: Some(format!("{:?}", &result)),
reason: None,
});
}
} }
} }
// 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();
@@ -140,16 +220,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
@@ -179,9 +314,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

@@ -111,8 +111,8 @@ pub async fn get_attachment(
server_id: String, server_id: String,
session_id: String, session_id: String,
) -> Result<GetAttachmentResponse, String> { ) -> Result<GetAttachmentResponse, String> {
let mut query_params = HashMap::new(); let mut query_params = Vec::new();
query_params.insert("session".to_string(), serde_json::Value::String(session_id)); query_params.push(format!("session={}", session_id));
let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params)) let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params))
.await .await

View File

@@ -8,13 +8,6 @@ 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<String>,
}
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()));
@@ -96,50 +89,16 @@ pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) ->
#[tauri::command] #[tauri::command]
pub async fn datasource_search( pub async fn datasource_search(
id: &str, id: &str,
options: Option<GetDatasourcesByServerOptions>, query_params: Option<Vec<String>>, //["query=abc", "filter=er", "filter=efg", "from=0", "size=5"],
) -> Result<Vec<DataSource>, String> { ) -> Result<Vec<DataSource>, String> {
let from = options.as_ref().and_then(|opt| opt.from).unwrap_or(0);
let size = options.as_ref().and_then(|opt| opt.size).unwrap_or(10000);
let query = options
.and_then(|opt| opt.query)
.unwrap_or(String::default());
let mut body = serde_json::json!({
"from": from,
"size": size,
});
if !query.is_empty() {
body["query"] = serde_json::json!({
"bool": {
"must": [{
"query_string": {
"fields": ["combined_fulltext"],
"query": query,
"fuzziness": "AUTO",
"fuzzy_prefix_length": 2,
"fuzzy_max_expansions": 10,
"fuzzy_transpositions": true,
"allow_leading_wildcard": false
}
}]
}
});
}
// Perform the async HTTP request outside the cache lock // Perform the async HTTP request outside the cache lock
let resp = HttpClient::post( let resp = HttpClient::post(id, "/datasource/_search", query_params, None)
id,
"/datasource/_search",
None,
Some(reqwest::Body::from(body.to_string())),
)
.await .await
.map_err(|e| format!("Error fetching datasource: {}", e))?; .map_err(|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()
})?; })?;
@@ -152,50 +111,16 @@ pub async fn datasource_search(
#[tauri::command] #[tauri::command]
pub async fn mcp_server_search( pub async fn mcp_server_search(
id: &str, id: &str,
options: Option<GetDatasourcesByServerOptions>, query_params: Option<Vec<String>>,
) -> Result<Vec<DataSource>, String> { ) -> Result<Vec<DataSource>, String> {
let from = options.as_ref().and_then(|opt| opt.from).unwrap_or(0);
let size = options.as_ref().and_then(|opt| opt.size).unwrap_or(10000);
let query = options
.and_then(|opt| opt.query)
.unwrap_or(String::default());
let mut body = serde_json::json!({
"from": from,
"size": size,
});
if !query.is_empty() {
body["query"] = serde_json::json!({
"bool": {
"must": [{
"query_string": {
"fields": ["combined_fulltext"],
"query": query,
"fuzziness": "AUTO",
"fuzzy_prefix_length": 2,
"fuzzy_max_expansions": 10,
"fuzzy_transpositions": true,
"allow_leading_wildcard": false
}
}]
}
});
}
// Perform the async HTTP request outside the cache lock // Perform the async HTTP request outside the cache lock
let resp = HttpClient::post( let resp = HttpClient::post(id, "/mcp_server/_search", query_params, None)
id,
"/mcp_server/_search",
None,
Some(reqwest::Body::from(body.to_string())),
)
.await .await
.map_err(|e| format!("Error fetching datasource: {}", e))?; .map_err(|e| format!("Error fetching datasource: {}", e))?;
// Parse the search results from the response // Parse the search results from the response
let mcp_server: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| { let mcp_server: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
dbg!("Error parsing search results: {}", &e); //dbg!("Error parsing search results: {}", &e);
e.to_string() e.to_string()
})?; })?;

View File

@@ -4,7 +4,6 @@ use once_cell::sync::Lazy;
use reqwest::{Client, Method, RequestBuilder}; use reqwest::{Client, Method, RequestBuilder};
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(crate) fn new_reqwest_http_client(accept_invalid_certs: bool) -> Client { pub(crate) fn new_reqwest_http_client(accept_invalid_certs: bool) -> Client {
@@ -40,7 +39,7 @@ 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, JsonValue>>, query_params: Option<Vec<String>>,
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> {
@@ -56,7 +55,7 @@ impl HttpClient {
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.send().await.map_err(|e| { let response = request_builder.send().await.map_err(|e| {
dbg!("Failed to send request: {}", &e); //dbg!("Failed to send request: {}", &e);
format!("Failed to send request: {}", e) format!("Failed to send request: {}", e)
})?; })?;
@@ -74,7 +73,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, JsonValue>>, // Add query parameters query_params: Option<Vec<String>>, // 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
@@ -106,23 +105,10 @@ impl HttpClient {
request_builder = request_builder.headers(req_headers); request_builder = request_builder.headers(req_headers);
} }
if let Some(query) = query_params { if let Some(params) = query_params {
// Convert only supported value types into strings let query: Vec<(&str, &str)> = params
let query: HashMap<String, String> = query .iter()
.into_iter() .filter_map(|s| s.split_once('='))
.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(); .collect();
request_builder = request_builder.query(&query); request_builder = request_builder.query(&query);
} }
@@ -135,12 +121,13 @@ impl HttpClient {
request_builder request_builder
} }
pub async fn send_request( pub async fn send_request(
server_id: &str, server_id: &str,
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, JsonValue>>, query_params: Option<Vec<String>>,
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
@@ -165,12 +152,12 @@ impl HttpClient {
headers.insert("X-API-TOKEN".to_string(), t); headers.insert("X-API-TOKEN".to_string(), t);
} }
log::debug!( // log::debug!(
"Sending request to server: {}, url: {}, headers: {:?}", // "Sending request to server: {}, url: {}, headers: {:?}",
&server_id, // &server_id,
&url, // &url,
&headers // &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 {
@@ -182,16 +169,17 @@ 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, JsonValue>>, // Add query parameters query_params: Option<Vec<String>>,
) -> 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
} }
// Convenience method for POST requests // Convenience method for POST requests
pub async fn post( pub async fn post(
server_id: &str, server_id: &str,
path: &str, path: &str,
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters query_params: Option<Vec<String>>,
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
@@ -201,7 +189,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, JsonValue>>, // Add query parameters query_params: Option<Vec<String>>,
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request( HttpClient::send_request(
@@ -221,7 +209,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, JsonValue>>, // Add query parameters query_params: Option<Vec<String>>,
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request( HttpClient::send_request(
@@ -241,7 +229,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, JsonValue>>, // Add query parameters query_params: Option<Vec<String>>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request( HttpClient::send_request(
server_id, server_id,

View File

@@ -1,4 +1,4 @@
use crate::common::document::Document; use crate::common::document::{Document, OnOpened};
use crate::common::error::SearchError; use crate::common::error::SearchError;
use crate::common::http::get_response_body_text; use crate::common::http::get_response_body_text;
use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery, SearchResponse}; use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery, SearchResponse};
@@ -9,7 +9,6 @@ use async_trait::async_trait;
// use futures::stream::StreamExt; // use futures::stream::StreamExt;
use ordered_float::OrderedFloat; use ordered_float::OrderedFloat;
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)]
@@ -93,39 +92,58 @@ impl SearchSource for CocoSearchSource {
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> { async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
let url = "/query/_search"; let url = "/query/_search";
let mut total_hits = 0;
let mut hits: Vec<(Document, f64)> = Vec::new();
let mut query_args: HashMap<String, JsonValue> = HashMap::new(); let mut query_params = Vec::new();
query_args.insert("from".into(), JsonValue::Number(query.from.into()));
query_args.insert("size".into(), JsonValue::Number(query.size.into())); // Add from/size as number values
query_params.push(format!("from={}", query.from));
query_params.push(format!("size={}", query.size));
// Add query strings
for (key, value) in query.query_strings { for (key, value) in query.query_strings {
query_args.insert(key, JsonValue::String(value)); query_params.push(format!("{}={}", key, value));
} }
let response = HttpClient::get( let response = HttpClient::get(&self.server.id, &url, Some(query_params))
&self.server.id,
&url,
Some(query_args),
)
.await .await
.map_err(|e| SearchError::HttpError(format!("Error to send search request: {}", e)))?; .map_err(|e| SearchError::HttpError(format!("{}", e)))?;
// Use the helper function to parse the response body // Use the helper function to parse the response body
let response_body = get_response_body_text(response) let response_body = get_response_body_text(response)
.await .await
.map_err(|e| SearchError::ParseError(format!("Failed to read response body: {}", e)))?; .map_err(|e| SearchError::ParseError(e))?;
// 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 // Parse the search response from the body text
let parsed: SearchResponse<Document> = serde_json::from_str(&response_body) let parsed: SearchResponse<Document> = serde_json::from_str(&response_body)
.map_err(|e| SearchError::ParseError(format!("Failed to parse search response: {}", e)))?; .map_err(|e| SearchError::ParseError(format!("{}", e)))?;
// Process the parsed response // Process the parsed response
let total_hits = parsed.hits.total.value as usize; total_hits = parsed.hits.total.value as usize;
let hits: Vec<(Document, f64)> = parsed
.hits if let Some(items) = parsed.hits.hits {
.hits for hit in items {
.into_iter() let mut document = hit._source;
.map(|hit| (hit._source, hit._score.unwrap_or(0.0))) // Default _score to 0.0 if None // Default _score to 0.0 if None
.collect(); 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 // Return the final result
Ok(QueryResponse { Ok(QueryResponse {

View File

@@ -59,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()
@@ -87,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()
} }
@@ -104,7 +104,7 @@ pub fn persist_servers_token<R: Runtime>(app_handle: &AppHandle<R>) -> Result<()
.map(|server| serde_json::to_value(server).expect("Failed to serialize access_tokens")) // Automatically serialize all fields .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
@@ -143,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)
@@ -187,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 {
@@ -231,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 {
@@ -243,18 +241,18 @@ pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<S
pub async fn load_or_insert_default_server<R: Runtime>( 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])
} }
@@ -317,10 +315,17 @@ pub async fn refresh_coco_server_info<R: Runtime>(
// Send request to fetch updated server info // Send request to fetch updated server info
let response = HttpClient::get(&id, "/provider/_info", None) let response = HttpClient::get(&id, "/provider/_info", None)
.await .await
.map_err(|e| format!("Failed to contact the server: {}", e))?; .map_err(|e| format!("Failed to contact the server: {}", e));
if response.is_err() {
let _ = mark_server_as_offline(app_handle, &id).await;
return Err(response.err().unwrap());
}
let response = response?;
if !response.status().is_success() { if !response.status().is_success() {
mark_server_as_offline(&id).await; let _ = mark_server_as_offline(app_handle, &id).await;
return Err(format!("Request failed with status: {}", response.status())); return Err(format!("Request failed with status: {}", response.status()));
} }
@@ -331,6 +336,9 @@ pub async fn refresh_coco_server_info<R: Runtime>(
let mut updated_server: Server = serde_json::from_str(&body) let mut updated_server: Server = serde_json::from_str(&body)
.map_err(|e| format!("Failed to deserialize the response: {}", e))?; .map_err(|e| format!("Failed to deserialize the response: {}", e))?;
// Mark server as online
let _ = mark_server_as_online(app_handle.clone(), &id).await;
// Restore local state // Restore local state
updated_server.id = id.clone(); updated_server.id = id.clone();
updated_server.builtin = is_builtin; updated_server.builtin = is_builtin;
@@ -364,10 +372,10 @@ pub async fn add_coco_server<R: Runtime>(
let endpoint = endpoint.trim_end_matches('/'); let endpoint = endpoint.trim_end_matches('/');
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());
} }
@@ -376,7 +384,7 @@ pub async fn add_coco_server<R: Runtime>(
.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);
let body = get_response_body_text(response).await?; let body = get_response_body_text(response).await?;
@@ -400,7 +408,7 @@ pub async fn add_coco_server<R: Runtime>(
.await .await
.map_err(|e| format!("Failed to persist Coco servers: {}", e))?; .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)
} }
@@ -446,26 +454,63 @@ 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()); 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_online<R: Runtime>(
app_handle: AppHandle<R>, id: &str) -> Result<(), ()> {
// println!("server_is_offline: {}", id);
let server = get_server_by_id(id);
if let Some(mut server) = server {
server.available = true;
server.health = None;
save_server(&server);
try_register_server_to_search_source(app_handle.clone(), &server).await;
}
Ok(())
}
#[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;
@@ -486,47 +531,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(())
} }
@@ -577,6 +623,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

@@ -1,8 +1,6 @@
use crate::common::http::get_response_body_text; 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 std::collections::HashMap;
use tauri::command; use tauri::command;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -13,18 +11,18 @@ pub struct TranscriptionResponse {
#[command] #[command]
pub async fn transcription( pub async fn transcription(
server_id: String, server_id: String,
audio_type: String, _audio_type: String,
audio_content: String, _audio_content: String,
) -> Result<TranscriptionResponse, String> { ) -> Result<TranscriptionResponse, String> {
let mut query_params = HashMap::new(); // let mut query_params = HashMap::new();
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 // 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), None,
None, None,
) )
.await .await

View File

@@ -125,6 +125,7 @@ pub async fn connect_to_server<R: Runtime>(
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;
} }
@@ -132,7 +133,8 @@ pub async fn connect_to_server<R: Runtime>(
} }
} }
_ = 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;
} }
} }

View File

@@ -1,3 +1,9 @@
use tauri::{App, WebviewWindow}; use tauri::{App, WebviewWindow};
pub fn platform(_app: &mut App, _main_window: WebviewWindow, _settings_window: WebviewWindow) {} pub fn platform(
_app: &mut App,
_main_window: WebviewWindow,
_settings_window: WebviewWindow,
_check_window: WebviewWindow,
) {
}

View File

@@ -1,5 +1,5 @@
//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::{App, Emitter, EventTarget, WebviewWindow};
use tauri_nspanel::{cocoa::appkit::NSWindowCollectionBehavior, panel_delegate, WebviewWindowExt}; use tauri_nspanel::{cocoa::appkit::NSWindowCollectionBehavior, panel_delegate, WebviewWindowExt};
use crate::common::MAIN_WINDOW_LABEL; use crate::common::MAIN_WINDOW_LABEL;
@@ -12,9 +12,12 @@ const WINDOW_BLUR_EVENT: &str = "tauri://blur";
const WINDOW_MOVED_EVENT: &str = "tauri://move"; const WINDOW_MOVED_EVENT: &str = "tauri://move";
const WINDOW_RESIZED_EVENT: &str = "tauri://resize"; const WINDOW_RESIZED_EVENT: &str = "tauri://resize";
pub fn platform(app: &mut App, main_window: WebviewWindow, _settings_window: WebviewWindow) { pub fn platform(
app.set_activation_policy(ActivationPolicy::Accessory); _app: &mut App,
main_window: WebviewWindow,
_settings_window: WebviewWindow,
_check_window: WebviewWindow,
) {
// Convert ns_window to ns_panel // Convert ns_window to ns_panel
let panel = main_window.to_panel().unwrap(); let panel = main_window.to_panel().unwrap();

View File

@@ -18,10 +18,20 @@ pub use windows::*;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub use linux::*; 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,
check_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(all(dev, debug_assertions))] #[cfg(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(),
check_window.clone(),
);
} }

View File

@@ -1,3 +1,9 @@
use tauri::{App, WebviewWindow}; use tauri::{App, WebviewWindow};
pub fn platform(_app: &mut App, _main_window: WebviewWindow, _settings_window: WebviewWindow) {} pub fn platform(
_app: &mut App,
_main_window: WebviewWindow,
_settings_window: WebviewWindow,
_check_window: WebviewWindow,
) {
}

View File

@@ -17,6 +17,7 @@ const DEFAULT_SHORTCUT: &str = "ctrl+shift+space";
/// Set up the shortcut upon app start. /// Set up the shortcut upon app start.
pub fn enable_shortcut(app: &App) { pub fn enable_shortcut(app: &App) {
log::trace!("setting up Coco hotkey");
let store = app let store = app
.store(COCO_TAURI_STORE) .store(COCO_TAURI_STORE)
.expect("creating a store should not fail"); .expect("creating a store should not fail");
@@ -43,6 +44,7 @@ pub fn enable_shortcut(app: &App) {
.expect("default shortcut should never be invalid"); .expect("default shortcut should never be invalid");
_register_shortcut_upon_start(app, default_shortcut); _register_shortcut_upon_start(app, default_shortcut);
} }
log::trace!("Coco hotkey has been set");
} }
/// Get the stored shortcut as a string, same as [`_get_shortcut()`], except that /// Get the stored shortcut as a string, same as [`_get_shortcut()`], except that
@@ -97,7 +99,7 @@ fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
.on_shortcut(shortcut, move |app, scut, event| { .on_shortcut(shortcut, move |app, scut, event| {
if scut == &shortcut { if scut == &shortcut {
dbg!("shortcut pressed"); dbg!("shortcut pressed");
let main_window = app.get_window(MAIN_WINDOW_LABEL).unwrap(); let main_window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
if let ShortcutState::Pressed = event.state() { if let ShortcutState::Pressed = event.state() {
let app_handle = app.clone(); let app_handle = app.clone();
if main_window.is_visible().unwrap() { if main_window.is_visible().unwrap() {
@@ -126,7 +128,7 @@ fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
tauri_plugin_global_shortcut::Builder::new() tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app, scut, event| { .with_handler(move |app, scut, event| {
if scut == &shortcut { if scut == &shortcut {
let window = app.get_window(MAIN_WINDOW_LABEL).unwrap(); let window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
if let ShortcutState::Pressed = event.state() { if let ShortcutState::Pressed = event.state() {
let app_handle = app.clone(); let app_handle = app.clone();

View File

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

View File

@@ -41,7 +41,9 @@
"title": "Coco AI Settings", "title": "Coco AI Settings",
"url": "/ui/settings", "url": "/ui/settings",
"width": 1000, "width": 1000,
"minWidth": 1000,
"height": 700, "height": 700,
"minHeight": 700,
"center": true, "center": true,
"transparent": true, "transparent": true,
"maximizable": false, "maximizable": false,
@@ -53,6 +55,26 @@
"effects": ["sidebar"], "effects": ["sidebar"],
"state": "active" "state": "active"
} }
},
{
"label": "check",
"title": "Coco AI Update",
"url": "/ui/check",
"width": 340,
"minWidth": 340,
"height": 260,
"minHeight": 260,
"center": false,
"transparent": true,
"maximizable": false,
"skipTaskbar": false,
"dragDropEnabled": false,
"hiddenTitle": true,
"visible": false,
"windowEffects": {
"effects": ["sidebar"],
"state": "active"
}
} }
], ],
"security": { "security": {
@@ -92,7 +114,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 +127,7 @@
} }
} }
}, },
"resources": ["assets", "icons"] "resources": ["assets/**/*", "icons"]
}, },
"plugins": { "plugins": {
"features": { "features": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -16,11 +16,32 @@ import {
MultiSourceQueryResponse, MultiSourceQueryResponse,
} from "@/types/commands"; } from "@/types/commands";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { useAuthStore } from "@/stores/authStore";
// Endpoints that don't require authentication
const WHITELIST_SERVERS = [
"list_coco_servers",
"add_coco_server",
"enable_server",
"disable_server",
"remove_coco_server",
"logout_coco_server",
"refresh_coco_server_info",
"handle_sso_callback",
"query_coco_fusion",
"open_session_chat", // TODO: quick ai access is a configured service, even if the current service is not logged in, it should not affect the configured service.
];
async function invokeWithErrorHandler<T>( async function invokeWithErrorHandler<T>(
command: string, command: string,
args?: Record<string, any> args?: Record<string, any>
): Promise<T> { ): 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; const addError = useAppStore.getState().addError;
try { try {
const result = await invoke<T>(command, args); const result = await invoke<T>(command, args);
@@ -30,7 +51,7 @@ async function invokeWithErrorHandler<T>(
const failedResult = result as any; const failedResult = result as any;
if (failedResult.failed?.length > 0 && failedResult?.hits?.length == 0) { if (failedResult.failed?.length > 0 && failedResult?.hits?.length == 0) {
failedResult.failed.forEach((error: any) => { failedResult.failed.forEach((error: any) => {
addError(error.error, 'error'); addError(error.error, "error");
// console.error(error.error); // console.error(error.error);
}); });
} }
@@ -103,12 +124,26 @@ export function get_connectors_by_server(id: string): Promise<Connector[]> {
return invokeWithErrorHandler(`get_connectors_by_server`, { id }); return invokeWithErrorHandler(`get_connectors_by_server`, { id });
} }
export function datasource_search(id: string): Promise<DataSource[]> { export function datasource_search({
return invokeWithErrorHandler(`datasource_search`, { id }); id,
queryParams,
}: {
id: string;
//["query=abc", "filter=er", "filter=efg", "from=0", "size=5"]
queryParams?: string[];
}): Promise<DataSource[]> {
return invokeWithErrorHandler(`datasource_search`, { id, queryParams });
} }
export function mcp_server_search(id: string): Promise<DataSource[]> { export function mcp_server_search({
return invokeWithErrorHandler(`mcp_server_search`, { id }); id,
queryParams,
}: {
id: string;
//["query=abc", "filter=er", "filter=efg", "from=0", "size=5"]
queryParams?: string[];
}): Promise<DataSource[]> {
return invokeWithErrorHandler(`mcp_server_search`, { id, queryParams });
} }
export function connect_to_server(id: string, clientId: string): Promise<void> { export function connect_to_server(id: string, clientId: string): Promise<void> {
@@ -203,7 +238,7 @@ export function new_chat({
queryParams, queryParams,
}: { }: {
serverId: string; serverId: string;
websocketId?: string; websocketId: string;
message: string; message: string;
queryParams?: Record<string, any>; queryParams?: Record<string, any>;
}): Promise<GetResponse> { }): Promise<GetResponse> {
@@ -223,7 +258,7 @@ export function send_message({
queryParams, queryParams,
}: { }: {
serverId: string; serverId: string;
websocketId?: string; websocketId: string;
sessionId: string; sessionId: string;
message: string; message: string;
queryParams?: Record<string, any>; queryParams?: Record<string, any>;
@@ -248,19 +283,33 @@ export const update_session_chat = (payload: {
serverId: string; serverId: string;
sessionId: string; sessionId: string;
title?: string; title?: string;
context?: { context?: Record<string, any>;
attachments?: string[];
};
}): Promise<boolean> => { }): Promise<boolean> => {
return invokeWithErrorHandler<boolean>("update_session_chat", payload); return invokeWithErrorHandler<boolean>("update_session_chat", payload);
}; };
export const assistant_search = (payload: { export const assistant_search = (payload: {
serverId: string; serverId: string;
from: number;
size: number;
query?: Record<string, any>;
}): Promise<boolean> => { }): Promise<boolean> => {
return invokeWithErrorHandler<boolean>("assistant_search", payload); return invokeWithErrorHandler<boolean>("assistant_search", payload);
}; };
export const assistant_get = (payload: {
serverId: string;
assistantId: string;
}): Promise<boolean> => {
return invokeWithErrorHandler<boolean>("assistant_get", payload);
};
export const assistant_get_multi = (payload: {
assistantId: string;
}): Promise<boolean> => {
return invokeWithErrorHandler<boolean>("assistant_get_multi", payload);
};
export const upload_attachment = async (payload: UploadAttachmentPayload) => { export const upload_attachment = async (payload: UploadAttachmentPayload) => {
const response = await invokeWithErrorHandler<UploadAttachmentResponse>( const response = await invokeWithErrorHandler<UploadAttachmentResponse>(
"upload_attachment", "upload_attachment",

View File

@@ -27,3 +27,11 @@ export function show_coco(): Promise<void> {
export function show_settings(): Promise<void> { export function show_settings(): Promise<void> {
return invoke('show_settings'); return invoke('show_settings');
} }
export function show_check(): Promise<void> {
return invoke('show_check');
}
export function hide_check(): Promise<void> {
return invoke('hide_check');
}

View File

@@ -0,0 +1,110 @@
import { useRef } from "react";
import { Post } from "@/api/axiosRequest";
import platformAdapter from "@/utils/platformAdapter";
import { useConnectStore } from "@/stores/connectStore";
import { useAppStore } from "@/stores/appStore";
import { parseSearchQuery, SearchQuery, unrequitable } from "@/utils";
interface AssistantFetcherProps {
debounceKeyword?: string;
assistantIDs?: string[];
}
export const AssistantFetcher = ({
debounceKeyword = "",
assistantIDs = [],
}: AssistantFetcherProps) => {
const isTauri = useAppStore((state) => state.isTauri);
const { currentService, currentAssistant, setCurrentAssistant } =
useConnectStore();
const lastServerId = useRef<string | null>(null);
const fetchAssistant = async (params: {
current: number;
pageSize: number;
serverId?: string;
query?: string;
}) => {
try {
if (unrequitable()) {
return {
total: 0,
list: [],
};
}
const {
pageSize,
current,
serverId = currentService?.id,
query,
} = params;
const searchQuery: SearchQuery = {
from: (current - 1) * pageSize,
size: pageSize,
query: query ?? debounceKeyword,
fuzziness: 5,
filters: {
enabled: true,
id: assistantIDs,
},
};
const queryParams = parseSearchQuery(searchQuery);
const body: Record<string, any> = {
serverId,
queryParams,
};
let response: any;
if (isTauri) {
if (!currentService?.id) {
throw new Error("currentService is undefined");
}
response = await platformAdapter.commands("assistant_search", body);
} else {
body.serverId = undefined;
const [error, res] = await Post(`/assistant/_search`, body);
if (error) {
throw new Error(error);
}
response = res;
}
let assistantList = response?.hits?.hits ?? [];
console.log("assistantList", assistantList);
if (
!currentAssistant?._id ||
currentService?.id !== lastServerId.current
) {
setCurrentAssistant(assistantList[0]);
}
lastServerId.current = currentService?.id;
return {
total: response.hits.total.value,
list: assistantList,
};
} catch (error) {
setCurrentAssistant(null);
console.error("assistant_search", error);
return {
total: 0,
list: [],
};
}
};
return { fetchAssistant };
};

View File

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

View File

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

View File

@@ -19,9 +19,13 @@ 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 PrevSuggestion from "@/components/ChatMessage/PrevSuggestion";
import { useAppStore } from "@/stores/appStore"; 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 {
isSearchActive?: boolean; isSearchActive?: boolean;
@@ -36,6 +40,7 @@ interface ChatAIProps {
getFileUrl: (path: string) => string; getFileUrl: (path: string) => string;
showChatHistory?: boolean; showChatHistory?: boolean;
assistantIDs?: string[]; assistantIDs?: string[];
startPage?: StartPage;
} }
export interface ChatAIRef { export interface ChatAIRef {
@@ -61,6 +66,7 @@ const ChatAI = memo(
getFileUrl, getFileUrl,
showChatHistory, showChatHistory,
assistantIDs, assistantIDs,
startPage,
}, },
ref ref
) => { ) => {
@@ -74,7 +80,13 @@ const ChatAI = memo(
const { curChatEnd, setCurChatEnd, connected, setConnected } = const { curChatEnd, setCurChatEnd, connected, setConnected } =
useChatStore(); useChatStore();
const currentService = useConnectStore((state) => state.currentService); const isTauri = useAppStore((state) => state.isTauri);
const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
const setIsCurrentLogin = useAuthStore((state) => {
return state.setIsCurrentLogin;
});
const visibleStartPage = useConnectStore((state) => { const visibleStartPage = useConnectStore((state) => {
return state.visibleStartPage; return state.visibleStartPage;
}); });
@@ -83,17 +95,47 @@ const ChatAI = memo(
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 askAiSessionId = useSearchStore((state) => state.askAiSessionId);
const setAskAiSessionId = useSearchStore(
(state) => state.setAskAiSessionId
);
const askAiServerId = useSearchStore((state) => {
return state.askAiServerId;
});
const currentService = useConnectStore((state) => {
return state.currentService;
});
useEffect(() => { useEffect(() => {
activeChatProp && setActiveChat(activeChatProp); activeChatProp && setActiveChat(activeChatProp);
}, [activeChatProp]); }, [activeChatProp]);
useEffect(() => {
if (!isTauri) return;
if (!currentService?.enabled) {
setActiveChat(void 0);
setIsCurrentLogin(false);
}
if (showChatHistory && connected) {
getChatHistory();
}
}, [currentService?.enabled, showChatHistory, connected]);
useEffect(() => {
if (askAiServerId || !askAiSessionId) return;
onSelectChat({ _id: askAiSessionId });
setAskAiSessionId(void 0);
}, [askAiSessionId, askAiServerId]);
const [Question, setQuestion] = useState<string>(""); const [Question, setQuestion] = useState<string>("");
const [websocketSessionId, setWebsocketSessionId] = useState(""); const [websocketSessionId, setWebsocketSessionId] = useState("");
@@ -133,7 +175,6 @@ const ChatAI = memo(
clientId, clientId,
connected, connected,
setConnected, setConnected,
currentService,
dealMsgRef, dealMsgRef,
onWebsocketSessionId, onWebsocketSessionId,
}); });
@@ -151,7 +192,6 @@ const ChatAI = memo(
handleRename, handleRename,
handleDelete, handleDelete,
} = useChatActions( } = useChatActions(
currentService?.id,
setActiveChat, setActiveChat,
setCurChatEnd, setCurChatEnd,
setTimedoutShow, setTimedoutShow,
@@ -195,8 +235,8 @@ const ChatAI = memo(
const init = useCallback( const init = useCallback(
async (value: string) => { async (value: string) => {
try { try {
//console.log("init", isLogin, curChatEnd, activeChat?._id); //console.log("init", curChatEnd, activeChat?._id);
if (!isLogin) { if (!isCurrentLogin) {
addError("Please login to continue chatting"); addError("Please login to continue chatting");
return; return;
} }
@@ -214,7 +254,7 @@ const ChatAI = memo(
} }
}, },
[ [
isLogin, isCurrentLogin,
curChatEnd, curChatEnd,
activeChat?._id, activeChat?._id,
createNewChat, createNewChat,
@@ -295,6 +335,7 @@ const ChatAI = memo(
(chatId: string, title: string) => { (chatId: string, title: string) => {
setChats((prev) => { setChats((prev) => {
const chatIndex = prev.findIndex((chat) => chat._id === chatId); const chatIndex = prev.findIndex((chat) => chat._id === chatId);
if (chatIndex === -1) return prev; if (chatIndex === -1) return prev;
const modifiedChat = { const modifiedChat = {
@@ -303,8 +344,8 @@ const ChatAI = memo(
}; };
const result = [...prev]; const result = [...prev];
result.splice(chatIndex, 1); result.splice(chatIndex, 1, modifiedChat);
return [modifiedChat, ...result]; return result;
}); });
if (activeChat?._id === chatId) { if (activeChat?._id === chatId) {
@@ -320,16 +361,12 @@ const ChatAI = memo(
); );
return ( return (
<div <>
data-tauri-drag-region
className={`flex flex-col rounded-md relative h-full overflow-hidden`}
>
{showChatHistory && !setIsSidebarOpen && ( {showChatHistory && !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={getChatHistory} fetchChatHistory={getChatHistory}
@@ -337,22 +374,24 @@ const ChatAI = memo(
onRename={renameChat} 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}
isLogin={isLogin}
setIsLogin={setIsLogin}
showChatHistory={showChatHistory} showChatHistory={showChatHistory}
assistantIDs={assistantIDs} assistantIDs={assistantIDs}
/> />
{isLogin ? ( {isCurrentLogin ? (
<>
<ChatContent <ChatContent
activeChat={activeChat} activeChat={activeChat}
curChatEnd={curChatEnd} curChatEnd={curChatEnd}
@@ -371,6 +410,8 @@ const ChatAI = memo(
} }
getFileUrl={getFileUrl} getFileUrl={getFileUrl}
/> />
<Splash assistantIDs={assistantIDs} startPage={startPage} />
</>
) : ( ) : (
<ConnectPrompt /> <ConnectPrompt />
)} )}
@@ -378,7 +419,10 @@ const ChatAI = memo(
{!activeChat?._id && !visibleStartPage && ( {!activeChat?._id && !visibleStartPage && (
<PrevSuggestion sendMessage={init} /> <PrevSuggestion sendMessage={init} />
)} )}
{/* <ReadAloud /> */}
</div> </div>
</>
); );
} }
) )

View File

@@ -6,13 +6,10 @@ 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 SessionFile from "./SessionFile";
import Splash from "./Splash"; import ScrollToBottom from "@/components/Common/ScrollToBottom";
import { ArrowDown } from "lucide-react";
import clsx from "clsx";
interface ChatContentProps { interface ChatContentProps {
activeChat?: Chat; activeChat?: Chat;
@@ -52,10 +49,6 @@ export const ChatContent = ({
return state.setCurrentSessionId; return state.setCurrentSessionId;
}); });
useEffect(() => {
setCurrentSessionId(activeChat?._id);
}, [activeChat?._id]);
const { t } = useTranslation(); const { t } = useTranslation();
const uploadFiles = useChatStore((state) => state.uploadFiles); const uploadFiles = useChatStore((state) => state.uploadFiles);
@@ -64,11 +57,17 @@ export const ChatContent = ({
const { scrollToBottom } = useChatScroll(messagesEndRef); const { scrollToBottom } = useChatScroll(messagesEndRef);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
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,
@@ -96,13 +95,14 @@ export const ChatContent = ({
}; };
return ( return (
<div className="flex-1 overflow-hidden flex flex-col justify-between relative"> <div className="flex-1 overflow-hidden flex flex-col justify-between relative user-select-text">
<div <div
ref={scrollRef} 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" 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} onScroll={handleScroll}
> >
{(!activeChat || activeChat?.messages?.length === 0) && <Greetings />} {(!activeChat || activeChat?.messages?.length === 0) &&
!visibleStartPage && <Greetings />}
{activeChat?.messages?.map((message, index) => ( {activeChat?.messages?.map((message, index) => (
<ChatMessage <ChatMessage
@@ -173,24 +173,7 @@ export const ChatContent = ({
{sessionId && <SessionFile sessionId={sessionId} />} {sessionId && <SessionFile sessionId={sessionId} />}
<Splash /> <ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
<button
className={clsx(
"absolute right-4 bottom-4 flex items-center justify-center size-8 border bg-white rounded-full shadow dark:border-[#272828] dark:bg-black dark:shadow-white/15",
{
hidden: isAtBottom,
}
)}
onClick={() => {
scrollRef.current?.scrollTo({
top: scrollRef.current?.scrollHeight,
behavior: "smooth",
});
}}
>
<ArrowDown className="size-5" />
</button>
</div> </div>
); );
}; };

View File

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

View File

@@ -1,7 +1,6 @@
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 HistoryList from "../Common/HistoryList";
import { HISTORY_PANEL_ID } from "@/constants"; import { HISTORY_PANEL_ID } from "@/constants";
@@ -9,7 +8,6 @@ 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;
@@ -21,7 +19,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
isSidebarOpen, isSidebarOpen,
chats, chats,
activeChat, activeChat,
// onNewChat,
onSelectChat, onSelectChat,
onDeleteChat, onDeleteChat,
fetchChatHistory, fetchChatHistory,
@@ -32,7 +29,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
<div <div
data-sidebar data-sidebar
className={` className={`
h-screen fixed top-0 left-0 z-100 w-64 h-screen absolute top-0 left-0 z-100 w-64
transform transition-all duration-300 ease-in-out transform transition-all duration-300 ease-in-out
${isSidebarOpen ? "translate-x-0" : "-translate-x-full"} ${isSidebarOpen ? "translate-x-0" : "-translate-x-full"}
bg-gray-100 dark:bg-gray-800 bg-gray-100 dark:bg-gray-800
@@ -42,8 +39,8 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
> >
{isSidebarOpen && ( {isSidebarOpen && (
<HistoryList <HistoryList
id={HISTORY_PANEL_ID} historyPanelId={HISTORY_PANEL_ID}
list={chats} chats={chats}
active={activeChat} active={activeChat}
onSearch={onSearch} onSearch={onSearch}
onRefresh={fetchChatHistory} onRefresh={fetchChatHistory}
@@ -52,14 +49,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
onRemove={onDeleteChat} onRemove={onDeleteChat}
/> />
)} )}
{/* <Sidebar
chats={chats}
activeChat={activeChat}
onNewChat={onNewChat}
onSelectChat={onSelectChat}
onDeleteChat={onDeleteChat}
fetchChatHistory={fetchChatHistory}
/> */}
</div> </div>
); );
}; };

View File

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

View File

@@ -3,32 +3,31 @@ import { Settings, RefreshCw, Check, Server } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { useKeyPress } from "ahooks"; import { useKeyPress } from "ahooks";
import { isNil } from "lodash-es";
import logoImg from "@/assets/icon.svg"; import logoImg from "@/assets/icon.svg";
import ServerIcon from "@/icons/Server"; import ServerIcon from "@/icons/Server";
import VisibleKey from "../Common/VisibleKey"; import VisibleKey from "../Common/VisibleKey";
import { useShortcutsStore } from "@/stores/shortcutsStore"; import { useShortcutsStore } from "@/stores/shortcutsStore";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import { useAppStore, IServer } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { useChatStore } from "@/stores/chatStore"; import { useChatStore } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { isNil } from "lodash-es"; import { Server as IServer } from "@/types/server";
import StatusIndicator from "@/components/Cloud/StatusIndicator";
import { useAuthStore } from "@/stores/authStore";
import { useSearchStore } from "@/stores/searchStore";
interface ServerListProps { interface ServerListProps {
isLogin: boolean;
setIsLogin: (isLogin: boolean) => void;
reconnect: (server?: IServer) => void; reconnect: (server?: IServer) => void;
onCreateNewChat: () => void; clearChat: () => void;
} }
export function ServerList({ export function ServerList({ reconnect, clearChat }: ServerListProps) {
isLogin,
setIsLogin,
reconnect,
onCreateNewChat,
}: ServerListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
const setIsCurrentLogin = useAuthStore((state) => state.setIsCurrentLogin);
const serviceList = useShortcutsStore((state) => state.serviceList); const serviceList = useShortcutsStore((state) => state.serviceList);
const setEndpoint = useAppStore((state) => state.setEndpoint); const setEndpoint = useAppStore((state) => state.setEndpoint);
const setCurrentService = useConnectStore((state) => state.setCurrentService); const setCurrentService = useConnectStore((state) => state.setCurrentService);
@@ -39,7 +38,16 @@ export function ServerList({
const [serverList, setServerList] = useState<IServer[]>([]); const [serverList, setServerList] = useState<IServer[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [highlightId, setHighlightId] = useState<string>("");
const askAiServerId = useSearchStore((state) => {
return state.askAiServerId;
});
const setAskAiServerId = useSearchStore((state) => {
return state.setAskAiServerId;
});
const popoverRef = useRef<HTMLDivElement>(null);
const serverListButtonRef = useRef<HTMLButtonElement>(null); const serverListButtonRef = useRef<HTMLButtonElement>(null);
const fetchServers = useCallback( const fetchServers = useCallback(
@@ -48,7 +56,7 @@ export function ServerList({
.commands("list_coco_servers") .commands("list_coco_servers")
.then((res: any) => { .then((res: any) => {
const enabledServers = (res as IServer[]).filter( const enabledServers = (res as IServer[]).filter(
(server) => server.enabled !== false (server) => server.enabled && server.available
); );
//console.log("list_coco_servers", enabledServers); //console.log("list_coco_servers", enabledServers);
setServerList(enabledServers); setServerList(enabledServers);
@@ -72,6 +80,23 @@ export function ServerList({
[currentService?.id] [currentService?.id]
); );
useEffect(() => {
fetchServers(true);
}, [currentService?.enabled]);
useEffect(() => {
if (!askAiServerId || serverList.length === 0) return;
const matched = serverList.find((server) => {
return server.id === askAiServerId;
});
if (!matched) return;
switchServer(matched);
setAskAiServerId(void 0);
}, [serverList, askAiServerId]);
useEffect(() => { useEffect(() => {
if (!isTauri) return; if (!isTauri) return;
@@ -79,8 +104,8 @@ export function ServerList({
const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => { const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => {
//console.log("Login or Logout:", currentService, event.payload); //console.log("Login or Logout:", currentService, event.payload);
if (event.payload !== isLogin) { if (event.payload !== isCurrentLogin) {
setIsLogin(!!event.payload); setIsCurrentLogin(!!event.payload);
} }
fetchServers(true); fetchServers(true);
}); });
@@ -108,13 +133,14 @@ export function ServerList({
setCurrentService(server); setCurrentService(server);
setEndpoint(server.endpoint); setEndpoint(server.endpoint);
setMessages(""); // Clear previous messages setMessages(""); // Clear previous messages
onCreateNewChat(); clearChat();
// //
if (!server.public && !server.profile) { if (!server.public && !server.profile) {
setIsLogin(false); setIsCurrentLogin(false);
return; return;
} }
setIsLogin(true); //
setIsCurrentLogin(true);
// The Rust backend will automatically disconnect, // The Rust backend will automatically disconnect,
// so we don't need to handle disconnection on the frontend // so we don't need to handle disconnection on the frontend
// src-tauri/src/server/websocket.rs // src-tauri/src/server/websocket.rs
@@ -124,29 +150,45 @@ export function ServerList({
} }
}; };
useKeyPress(["uparrow", "downarrow"], (_, key) => { useKeyPress(["uparrow", "downarrow", "enter"], (event, key) => {
const isClose = isNil(serverListButtonRef.current?.dataset["open"]); const isClose = isNil(serverListButtonRef.current?.dataset["open"]);
const length = serverList.length; const length = serverList.length;
if (isClose || length <= 1) return; if (isClose || length <= 1) return;
event.stopPropagation();
event.preventDefault();
const currentIndex = serverList.findIndex((server) => { const currentIndex = serverList.findIndex((server) => {
return server.id === currentService?.id; return server.id === (highlightId === '' ? currentService?.id : highlightId);
}); });
let nextIndex = currentIndex; let nextIndex = currentIndex;
if (key === "uparrow") { if (key === "uparrow") {
nextIndex = currentIndex > 0 ? currentIndex - 1 : length - 1; nextIndex = currentIndex > 0 ? currentIndex - 1 : length - 1;
setHighlightId(serverList[nextIndex].id);
} else if (key === "downarrow") { } else if (key === "downarrow") {
nextIndex = currentIndex < serverList.length - 1 ? currentIndex + 1 : 0; nextIndex = currentIndex < serverList.length - 1 ? currentIndex + 1 : 0;
setHighlightId(serverList[nextIndex].id);
} else if (key === "enter" && currentIndex >= 0) {
if (document.activeElement instanceof HTMLTextAreaElement) return;
const selectedServer = serverList[currentIndex];
if (selectedServer) {
switchServer(selectedServer);
serverListButtonRef.current?.click();
} }
}
switchServer(serverList[nextIndex]); }, {
target: popoverRef,
}); });
const handleMouseMove = useCallback(() => {
setHighlightId("");
}, []);
return ( return (
<Popover className="relative"> <Popover ref={popoverRef} className="relative">
<PopoverButton ref={serverListButtonRef} className="flex items-center"> <PopoverButton ref={serverListButtonRef} className="flex items-center">
<VisibleKey <VisibleKey
shortcut={serviceList} shortcut={serviceList}
@@ -158,11 +200,13 @@ export function ServerList({
</VisibleKey> </VisibleKey>
</PopoverButton> </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"> <PopoverPanel
onMouseMove={handleMouseMove}
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="p-3">
<div className="flex items-center justify-between mb-3 whitespace-nowrap"> <div className="flex items-center justify-between mb-3 whitespace-nowrap">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100"> <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
Servers {t("assistant.chat.servers")}
</h3> </h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@@ -180,8 +224,7 @@ export function ServerList({
> >
<VisibleKey shortcut="R" onKeyPress={handleRefresh}> <VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCw <RefreshCw
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${ className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${isRefreshing ? "animate-spin" : ""
isRefreshing ? "animate-spin" : ""
}`} }`}
/> />
</VisibleKey> </VisibleKey>
@@ -194,8 +237,8 @@ export function ServerList({
<div <div
key={server.id} key={server.id}
onClick={() => switchServer(server)} onClick={() => switchServer(server)}
className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap ${ className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap
currentService?.id === server.id ${currentService?.id === server.id || highlightId === server.id
? "bg-gray-100 dark:bg-gray-800" ? "bg-gray-100 dark:bg-gray-800"
: "hover:bg-gray-50 dark:hover:bg-gray-800/50" : "hover:bg-gray-50 dark:hover:bg-gray-800/50"
}`} }`}
@@ -205,23 +248,27 @@ export function ServerList({
src={server?.provider?.icon || logoImg} src={server?.provider?.icon || logoImg}
alt={server.name} alt={server.name}
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800" 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-left flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]"> <div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
{server.name} {server.name}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]"> <div className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
AI Assistant: {server.assistantCount || 1} {t("assistant.chat.aiAssistant")}:{" "}
{server.stats?.assistant_count || 1}
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<span <StatusIndicator
className={`w-3 h-3 rounded-full ${ enabled={server.enabled}
server.health?.status public={server.public}
? `bg-[${server.health?.status}]` hasProfile={!!server?.profile}
: "bg-gray-400 dark:bg-gray-600" status={server.health?.status}
}`}
/> />
<div className="size-4 flex justify-end"> <div className="size-4 flex justify-end">
{currentService?.id === server.id && ( {currentService?.id === server.id && (

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { Loader, Hammer, ChevronDown, ChevronUp } from "lucide-react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types"; import type { IChunkData } from "@/types/chat";
import Markdown from "./Markdown"; import Markdown from "./Markdown";
interface CallToolsProps { interface CallToolsProps {

View File

@@ -2,7 +2,7 @@ import { ChevronDown, ChevronUp, Loader } from "lucide-react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types"; import type { IChunkData } from "@/types/chat";
import ReadingIcon from "@/icons/Reading"; import ReadingIcon from "@/icons/Reading";
interface DeepReadeProps { interface DeepReadeProps {

View File

@@ -8,7 +8,7 @@ import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { OpenURLWithBrowser } from "@/utils/index"; import { OpenURLWithBrowser } from "@/utils/index";
import type { IChunkData } from "@/components/Assistant/types"; import type { IChunkData } from "@/types/chat";
import RetrieveIcon from "@/icons/Retrieve"; import RetrieveIcon from "@/icons/Retrieve";
interface FetchSourceProps { interface FetchSourceProps {

View File

@@ -1,3 +1,5 @@
import { useState } from "react";
import clsx from "clsx";
import { import {
Check, Check,
Copy, Copy,
@@ -6,12 +8,16 @@ import {
Volume2, Volume2,
RotateCcw, RotateCcw,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react";
import { copyToClipboard } from "@/utils";
interface MessageActionsProps { interface MessageActionsProps {
id: string; id: string;
content: string; content: string;
question?: string; question?: string;
actionClassName?: string;
actionIconSize?: number;
copyButtonId?: string;
onResend?: () => void; onResend?: () => void;
} }
@@ -21,6 +27,9 @@ export const MessageActions = ({
id, id,
content, content,
question, question,
actionClassName,
actionIconSize,
copyButtonId,
onResend, onResend,
}: MessageActionsProps) => { }: MessageActionsProps) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -33,7 +42,7 @@ export const MessageActions = ({
const handleCopy = async () => { const handleCopy = async () => {
try { try {
await navigator.clipboard.writeText(content); await copyToClipboard(content);
setCopied(true); setCopied(true);
const timerID = setTimeout(() => { const timerID = setTimeout(() => {
setCopied(false); setCopied(false);
@@ -86,16 +95,29 @@ export const MessageActions = ({
}; };
return ( return (
<div className="flex items-center gap-1 mt-2"> <div className={clsx("flex items-center gap-1 mt-2", actionClassName)}>
{!isRefreshOnly && ( {!isRefreshOnly && (
<button <button
id={copyButtonId}
onClick={handleCopy} onClick={handleCopy}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors" className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors"
> >
{copied ? ( {copied ? (
<Check className="w-4 h-4 text-[#38C200] dark:text-[#38C200]" /> <Check
className="w-4 h-4 text-[#38C200] dark:text-[#38C200]"
style={{
width: actionIconSize,
height: actionIconSize,
}}
/>
) : ( ) : (
<Copy className="w-4 h-4 text-[#666666] dark:text-[#A3A3A3]" /> <Copy
className="w-4 h-4 text-[#666666] dark:text-[#A3A3A3]"
style={{
width: actionIconSize,
height: actionIconSize,
}}
/>
)} )}
</button> </button>
)} )}
@@ -112,6 +134,10 @@ export const MessageActions = ({
? "text-[#1990FF] dark:text-[#1990FF]" ? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]" : "text-[#666666] dark:text-[#A3A3A3]"
}`} }`}
style={{
width: actionIconSize,
height: actionIconSize,
}}
/> />
</button> </button>
)} )}
@@ -128,6 +154,10 @@ export const MessageActions = ({
? "text-[#1990FF] dark:text-[#1990FF]" ? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]" : "text-[#666666] dark:text-[#A3A3A3]"
}`} }`}
style={{
width: actionIconSize,
height: actionIconSize,
}}
/> />
</button> </button>
)} )}
@@ -142,6 +172,10 @@ export const MessageActions = ({
? "text-[#1990FF] dark:text-[#1990FF]" ? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]" : "text-[#666666] dark:text-[#A3A3A3]"
}`} }`}
style={{
width: actionIconSize,
height: actionIconSize,
}}
/> />
</button> </button>
)} )}
@@ -158,6 +192,10 @@ export const MessageActions = ({
? "text-[#1990FF] dark:text-[#1990FF]" ? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]" : "text-[#666666] dark:text-[#A3A3A3]"
}`} }`}
style={{
width: actionIconSize,
height: actionIconSize,
}}
/> />
</button> </button>
)} )}

View File

@@ -2,7 +2,7 @@ import { ChevronDown, ChevronUp, Loader } from "lucide-react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types"; import type { IChunkData } from "@/types/chat";
import SelectionIcon from "@/icons/Selection"; import SelectionIcon from "@/icons/Selection";
interface PickSourceProps { interface PickSourceProps {

View File

@@ -2,7 +2,7 @@ import { ChevronDown, ChevronUp, Loader } from "lucide-react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types"; import type { IChunkData } from "@/types/chat";
import UnderstandIcon from "@/icons/Understand"; import UnderstandIcon from "@/icons/Understand";
interface QueryIntentProps { interface QueryIntentProps {

View File

@@ -2,7 +2,7 @@ import {Loader, Brain, ChevronDown, ChevronUp } from "lucide-react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types"; import type { IChunkData } from "@/types/chat";
interface ThinkProps { interface ThinkProps {
Detail?: any; Detail?: any;

View File

@@ -10,9 +10,26 @@ interface UserMessageProps {
export const UserMessage = ({ messageContent }: UserMessageProps) => { export const UserMessage = ({ messageContent }: UserMessageProps) => {
const [showCopyButton, setShowCopyButton] = useState(false); const [showCopyButton, setShowCopyButton] = useState(false);
const handleDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
const selection = window.getSelection();
const range = document.createRange();
if (e.currentTarget && selection && range) {
try {
range.selectNodeContents(e.currentTarget);
selection.removeAllRanges();
selection.addRange(range);
} catch (error) {
console.error('Selection failed:', error);
}
}
}
};
return ( return (
<div <div
className="flex gap-1 items-center" className="flex gap-1 items-center justify-end"
onMouseEnter={() => setShowCopyButton(true)} onMouseEnter={() => setShowCopyButton(true)}
onMouseLeave={() => setShowCopyButton(false)} onMouseLeave={() => setShowCopyButton(false)}
> >
@@ -24,14 +41,8 @@ export const UserMessage = ({ messageContent }: UserMessageProps) => {
<CopyButton textToCopy={messageContent} /> <CopyButton textToCopy={messageContent} />
</div> </div>
<div <div
className="px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer select-none" className="max-w-[85%] overflow-auto text-left px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer user-select-text whitespace-pre-wrap"
onDoubleClick={(e) => { onDoubleClick={handleDoubleClick}
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(e.currentTarget);
selection?.removeAllRanges();
selection?.addRange(range);
}}
> >
{messageContent} {messageContent}
</div> </div>

View File

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

View File

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

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