218 Commits

Author SHA1 Message Date
ayang
9ee6b9a6c9 feat: add file upload failure handling and alert message 2025-05-16 14:32:11 +08:00
ayang
24b1758b11 refactor: enabling the InputExtra component 2025-05-15 15:50:03 +08:00
Medcl
ac21074db6 fix: loading chat history for potential empty attachments (#516)
* fix: loading chat history for potential empty attachments

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

* style: chat input icons show

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

* refactor: store setting allowSelfSignature in backend

* refactor: only reinit client when config gets updated

* refactor: docking api

* unused import cleanup

---------

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

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

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

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

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

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

* chore: update release notes

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

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

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

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

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

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

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

* chore: add enabled param judge

* chore: add enabled param judge

* chore: add enabled param judge

* docs: update notes

---------

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

* docs: update changelog

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

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

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

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

* docs: update notes

* chore: remove env record

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

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

---------

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

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

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

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

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

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

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

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

* docs: update notes

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

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

* docs: update changelog

* refactor: optimize translation content

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

* docs: update changelog
2025-04-29 15:16:46 +08:00
medcl
47d2e46b72 v0.4.0 2025-04-28 17:58:37 +08:00
ayangweb
414bc78aaf feat: updated to include error message (#465)
* feat: updated to include error message

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

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

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

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

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

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

* refactor: updated translation and internationalization support for extension modules

* refactor: optimize shortcut key display

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

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

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

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

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

* feat: add chat call tools

* feat: add MCP & call LLM tools

* docs: update notes

* build: build error

* chore: replace iconfont

* chore: web icon

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

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

* fix: serarch icon

* build: build web 1.1.6

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

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

* chore: web components assistant

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

* chore: add querysource

* refactor: filter query source rather than data source

* fix: fixed several search bugs

* docs: update notes

* feat: chat error

* chore: websocket

* chore: chat

* chore: chat

* fix: history search error

---------

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

* refactor: adding dynamic parameters to a request

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

* refactor: simplifying object property assignment syntax

* feat: add query timeout function

* refactor: set min query_timeout to 1s

* refactor: rename connection_timeout to query_timeout

* fix: persist the setting entry

---------

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

* docs: update changelog

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

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

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

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

* chore: deletion of duplicate files

* refactor: rust implements the conversion logic

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

* refactor: adjusting styles to improve text overflow

* feat: adding tips

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

* feat(TypeIcon): add support for Calculator icons

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

* docs: update notes

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

* build: build warning

* fix: filter http query_args and convert only supported values

* chore: server name truncate

* feat: add support for AI assistant

* feat: add support for AI assistant

---------

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

* refactor: optimize icon display logic

* refactor: optimized code

* style: remove useless import

* refactor: new shortcut hints for deleting popup boxes

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

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

* refactor: shortcut to reset a fixed window

* refactor: persistence

* fix: fix shortcut key duplication problem

* style: temporarily annotate unused components

* refactor: remove unused imports

* refactor: change font size

* refactor: refresh to add rotation status

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

* refactor: front-end invocation

* refactor: async open()

* refactor: use gtk-launch instead

* style: fmt

---------

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

* feat: add error collection

* chore: error display

* chore: error string

* docs: update notes

* docs: update notes

* build: build error

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

* chore: update release notes

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

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

* style: adjust style

* style: search detail display

* docs: update notes

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

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

* style: search list detail show

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

* chore: update release notes

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

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

* refactor: add dark color mode support

* docs: update changelog

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

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

* style: adjust page style

* style: web style

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

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

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

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

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

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

* fix: app search

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

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

* refactor: remove the portal attribute

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

* refactor: optimize startup mode judgment logic

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

* chore: add isTauri

* chore: web build

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

* feat: mobile terminal adaptation

* feat: mobile terminal adaptation

* docs: update notes

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

* refactor: remove unused code

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

* docs: update notes

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

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

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

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

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

* feat: deep thinking and networking search add shortcuts

* refactor: changing the default shortcut keys

* refactor: hide the voice input function button

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

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

* chore: web component

* chore: web

* chore: web

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

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

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

* refactor: refinement of the dark theme

* feat: add renamed input box style

* feat: internalization

* refactor: optimize the bright theme style

* refactor: change dark theme style

* feat: added api for deleting and modifying conversations

* feat: supported search

* feat: support for modifying the title

* feat: support for deleting sessions

* refactor: remove popup internationalization
2025-04-02 14:03:40 +08:00
ayang
569a61841c v0.3.0 2025-03-31 21:45:50 +08:00
ayang
8b2fc07519 docs: update changelog 2025-03-31 21:44:08 +08:00
ayangweb
bf145c8697 style: commenting out unused variables (#320) 2025-03-31 20:57:51 +08:00
ayangweb
0c3606820c docs: update changelog (#319) 2025-03-31 18:36:37 +08:00
ayangweb
3df86fc1c4 refactor: hide voice input and file upload functions (#318) 2025-03-31 18:35:06 +08:00
ayangweb
d01cbe1541 refactor: different platforms support different modifier keys (#317) 2025-03-31 17:17:39 +08:00
ayangweb
89a763dff7 feat: supports keyboard shortcuts with immediate effect (#316)
* feat: supports keyboard shortcuts with immediate effect

* feat: customize mode switching shortcuts

* refactor: remove the shift

* fix: voice input audio input device number anomaly issue

* feat: support for changing the focus state of the input box

* refactor: shortcuts for handling input box focus separately

* feat: upload file support shortcuts

* refactor: the connection timeout is specified with the variable

* refactor: shortcut keys to modify the input box before displaying modifier keys

* docs: update changelog

* style: remove useless import

* refactor: window focus changes modifier key press status to false

* refactor: correcting errors of judgment

* docs: update changelog
2025-03-31 17:07:34 +08:00
Medcl
0c42a51cb5 chore: support icon url parsed by server (#315)
* chore: support icon url parsed by server

* chore: update to support full url based icon
2025-03-30 22:20:15 +08:00
Medcl
f514e5a5c9 chore: support multi websocket connections (#314)
* chore: temp commit

* chore: add WebSocket session ID to chat message API headers

* chore: add param clientId

* feat: add websocket id

* chore: add debug logs

* chore: add log

* chore: add connecting

* chore: remove partialize

* fix: fix to support multi websocket connection

* chore: update release notes

---------

Co-authored-by: rain <15911122312@163.com>
2025-03-30 19:33:49 +08:00
ayangweb
b3aff2b353 refactor: added the voice to text api (#313)
* refactor: added the voice to text api

* refactor: update field name
2025-03-30 19:28:15 +08:00
ayangweb
bcb92bfd49 refactor: hide apps without icon (#312)
* refactor: hide apps without icon

* docs: update changelog
2025-03-28 17:56:58 +08:00
ayangweb
d9dea0ea38 feat: support for uploading files to the server (#310)
* feat: support for uploading files to the server

* feat: field Internationalization

* refactor: encapsulation attachment-related requests

* feat: support for getting a list of attachments that have been uploaded for a session

* feat: the session displays the number and list of uploaded files

* feat: internalization

* feat: wrapping the Checkbox component

* feat: add checkbox

* feat: support for deleting uploaded files

* feat: support for selecting uploaded files

* refactor: optimize the display of file icons

* refactor: hide file uploads when there is no sessionId
2025-03-28 13:50:14 +08:00
BiggerRain
d2eed4a1c4 refactor: refactor invoke related code (#309)
* refactor: refactor invoke related code

* refactor: refactor invoke related code

* docs: update release notes
2025-03-25 20:57:46 +08:00
ayangweb
c7e547b5fa refactor: encapsulates show and hide methods (#308)
* refactor: encapsulates show and hide methods

* style: remove comments
2025-03-24 17:19:19 +08:00
ayangweb
eadd0988ba chore: eliminate all warnings for rust (#307) 2025-03-24 14:55:35 +08:00
ayangweb
78bc83f38a refactor: all commands methods have been changed to asynchronous (#306) 2025-03-24 14:39:08 +08:00
ayangweb
84d9c6cdf0 refactor: hide voice input when no radio device is available (#305)
* refactor: hide voice input when no radio device is available

* style: delete Printing
2025-03-24 12:00:42 +08:00
BiggerRain
0769545a92 chore: remove lazy (#304) 2025-03-24 12:00:11 +08:00
ayangweb
118eaa55e3 feat: voice input support for search and chat (#302)
* feat: voice input support for search and chat

* chore: add mic-recorder plugin

* refactor: check microphone permission before recording

* feat: realize sound wave effects

* chore: remove mic-recorder plugin
2025-03-24 09:17:09 +08:00
BiggerRain
ef1304ce5e feat: add web pages (#277)
* feat: add web pages

* feat: add web page

* refactor: search page

* feat: add tsup build web componet

* chore: update timeout time

* build: build web page

* build: build search chat

* chore: add web page

* docs: update release note
2025-03-17 16:24:18 +08:00
Medcl
51d3a9d090 chore: remove dmg-background.png (#301) 2025-03-17 15:24:42 +08:00
ayangweb
7d0eced55a refactor: resolving code conflicts (#300) 2025-03-17 09:29:34 +08:00
ayangweb
e81c5bbb6e feat: advanced settings content improvement (#281)
* feat: advanced settings content improvement

* feat: support for switching to the default mode

* refactor: shortcut keys support only one letter

* refactor: fix key reporting errors

* feat: listen for changes to `ShortcutsStore`

* feat: add configuration items for modifier keys

* feat: new connection settings configuration item

* refactor: replacing the connection timeout icon

* refactor: optimized the style of the input box

* refactor: update Icons

* refactor: defaults to last chat
2025-03-17 09:19:59 +08:00
BiggerRain
bfc7b488ad fix: store data is not shared among multiple windows (#298)
* fix: store data is not shared among multiple windows

* docs: update release notes
2025-03-14 18:41:48 +08:00
Hardy
249cc2eae4 chore: add settings to output docs json (#297)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-03-14 18:37:02 +08:00
BiggerRain
388dac6452 chore: chat input border & clear input (#295)
* chore: chat input border & clear input

* docs: update release notes
2025-03-14 17:19:39 +08:00
ayangweb
dc8d1b5054 refactor: hide voice input buttons (#294) 2025-03-14 17:07:46 +08:00
ayangweb
046c3dda82 chore: update release notes (#293) 2025-03-14 16:27:51 +08:00
medcl
60ce678e3e v0.2.1 2025-03-14 16:06:08 +08:00
Medcl
8d79b9ba1a chore: update release notes (#290) 2025-03-14 15:49:24 +08:00
BiggerRain
969126ed89 chore: websocket timeout increased to 2 minutes (#289)
* chore: websocket timeout increased to 2 minutes

* docs: update release notes
2025-03-14 11:14:03 +08:00
ayangweb
e2df2b583a refactor: optimize the outline of the button (#288) 2025-03-14 11:07:19 +08:00
BiggerRain
9d948d4fc6 chore: remove selected function (#286)
* chore: remove selected function

* docs: update release notes
2025-03-14 10:50:51 +08:00
ayangweb
81c770ba7e refactor: optimize voice input (#285)
* refactor: optimize voice input

* refactor: `useState` instead of `useReactive`
2025-03-14 10:49:44 +08:00
BiggerRain
c9e9a72a0e chore: chat window min width & remove input bg (#284)
* chore: chat window min width & remove input bg

* docs: update release notes

* chore: remove error
2025-03-13 14:41:41 +08:00
SteveLauC
96e6aae30b ci: remove unneeded rust-toolchain action (#283) 2025-03-12 18:06:36 +08:00
BiggerRain
d319f5ebc7 fix: the chat scrolling and chat rendering (#282)
* fix: the chat scrolling and chat rendering

* docs: update release notes
2025-03-12 16:50:35 +08:00
BiggerRain
04ff358dc7 fix: chat end type (#280)
* fix: chat end type

* docs: update release notes
2025-03-12 14:24:24 +08:00
Medcl
22872ab02f fix: incorrect version type (#279)
* fix: incorrect version type

* chore: update release notes
2025-03-12 13:28:43 +08:00
ayangweb
fcfd21be45 refactor: disable manual input during voice input (#278) 2025-03-12 10:46:16 +08:00
ayangweb
0044e9a536 feat: chat supports voice input (#276)
* feat: chat supports voice input

* refactor: hide window out of focus

* feat: search supports voice input
2025-03-11 16:36:51 +08:00
BiggerRain
44a3ea3868 fix: add history reload for coco chat (#275) 2025-03-11 15:48:45 +08:00
BiggerRain
b444dc35ae refactor: chat components (#273)
* refactor: chat components

* refactor: chat components

* docs: update release notes

* docs: update release notes

* chore: history reload
2025-03-11 11:02:30 +08:00
ayangweb
8c9ccef218 feat: support for automatic app updates (#274)
* feat: support for automatic app updates

* refactor: add force update instructions

* refactor: optimize version update alerts

* chore: updating configuration files
2025-03-11 10:36:42 +08:00
ayangweb
a3bc997efe refactor: window not hidden after copying (#272) 2025-03-10 14:52:47 +08:00
Medcl
910841013f fix: fusion search should excluded disabled servers (#271) 2025-03-10 12:08:39 +08:00
229 changed files with 21207 additions and 7711 deletions

6
.env
View File

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

View File

@@ -50,6 +50,8 @@ jobs:
- platform: "ubuntu-22.04"
target: "x86_64-unknown-linux-gnu"
- platform: "ubuntu-22.04-arm"
target: "aarch64-unknown-linux-gnu"
runs-on: ${{ matrix.platform }}
steps:
@@ -67,13 +69,13 @@ jobs:
run: rustup target add ${{ matrix.target }}
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
if: startsWith(matrix.platform, 'ubuntu-22.04')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
run: rustup toolchain install stable
- name: Rust cache
uses: swatinem/rust-cache@v2

3
.gitignore vendored
View File

@@ -11,6 +11,8 @@ node_modules
dist
dist-ssr
*.local
out
src/components/web
# Editor directories and files
# .vscode/*
@@ -23,3 +25,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env

16
.vscode/settings.json vendored
View File

@@ -4,34 +4,48 @@
"autolaunch",
"Avenir",
"callout",
"changelogithub",
"clsx",
"codegen",
"dataurl",
"dtolnay",
"dyld",
"elif",
"errmsg",
"fullscreen",
"headlessui",
"Icdbb",
"icns",
"iconfont",
"INFINI",
"infinilabs",
"inputbox",
"katex",
"khtml",
"languagedetector",
"libappindicator",
"librsvg",
"libwebkit",
"localstorage",
"lucide",
"maximizable",
"Minimizable",
"msvc",
"nord",
"nowrap",
"nspanel",
"nsstring",
"overscan",
"partialize",
"patchelf",
"Raycast",
"rehype",
"reqwest",
"rgba",
"rustup",
"screenshotable",
"serde",
"swatinem",
"tailwindcss",
"tauri",
"thiserror",
@@ -45,7 +59,9 @@
"uuidv",
"VITE",
"walkdir",
"wavesurfer",
"webviews",
"xzvf",
"yuque",
"zustand"
],

View File

@@ -76,3 +76,6 @@ clean-rebuild:
@echo "Cleaning up and rebuilding..."
rm -rf node_modules
$(MAKE) dev-build
add-dep-pizza-engine:
cd src-tauri && cargo add --git ssh://git@github.com/infinilabs/pizza.git pizza-engine --features query_string_parser,persistence

View File

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

View File

@@ -7,6 +7,12 @@ theme: book
disablePathToLower: true
enableGitInfo: false
outputs:
home:
- HTML
- RSS
- JSON
# Needed for mermaid/katex shortcodes
markup:
goldmark:

View File

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

View File

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

View File

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

View File

@@ -9,14 +9,136 @@ Information about release notes of Coco Server is provided here.
## Latest (In development)
### ❌ Breaking changes
### 🚀 Features
- feat: check or enter to close the list of assistants #469
- feat: add dimness settings for pinned window #470
- feat: supports Shift + Enter input box line feeds #472
- feat: support for snapshot version updates #480
- feat: history list add put away button #482
- feat: the chat input box supports multi-line input #490
- feat: add `~/Applications` to the search path #493
- feat: the chat content has added a button to return to the bottom #495
- feat: the search input box supports multi-line input #501
- feat: websocket support self-signed TLS #504
- feat: add option to allow self-signed certificates #509
### 🐛 Bug fix
- fix: several issues around search #502
- fix: fixed the newly created session has no title when it is deleted #511
- fix: loading chat history for potential empty attachments
### ✈️ Improvements
- chore: adjust list error message #475
- fix: solve the problem of modifying the assistant in the chat #476
- chore: refine wording on search failure
- choresearch and MCP show hidden logic #494
- chore: greetings show hidden logic #496
- refactor: fetch app list in settings in real time #498
- chore: UpdateApp component loading location #499
- chore: add clear monitoring & cache calculation to optimize performance #500
- refactor: optimizing the code #505
- refactor: optimized the modification operation of the numeric input box #508
- style: modify the style of the search input box #513
- style: chat input icons show #515
## 0.4.0 (2025-04-27)
### Breaking changes
### Features
- feat: history support for searching, renaming and deleting #322
- feat: linux support for application search #330
- feat: add shortcuts to most icon buttons #334
- feat: add font icon for search list #342
- feat: add a border to the main window in Windows 10 #343
- feat: mobile terminal adaptation about style #348
- feat: service list popup box supports keyboard-only operation #359
- feat: networked search data sources support search and keyboard-only operation #367
- feat: add application management to the plugin #374
- feat: add keyboard-only operation to history list #385
- feat: add error notification #386
- feat: add support for AI assistant #394
- feat: add support for calculator function #399
- feat: auto selects the first item after searching #411
- feat: web components assistant #422
- feat: right-click menu support for search #423
- feat: add chat mode launch page #424
- feat: add MCP & call LLM tools #430
- feat: ai assistant supports search and paging #431
- feat: data sources support displaying customized icons #432
- feat: add shortcut key conflict hint and reset function #442
- feat: updated to include error message #465
### Bug fix
- fix: fixed the problem of not being able to search in secondary directories #338
- fix: active shadow setting #354
- fix: chat history was not show up #377
- fix: get attachments in chat sessions
- fix: filter http query_args and convert only supported values
- fixfixed several search & chat bugs #412
- fix: fixed carriage return problem with chinese input method #464
### Improvements
- refactor: web components #331
- refactor: refactoring login callback, receive access_token from coco-server
- chore: adjust web component styles #362
- style: modify the style #370
- style: search list details display #378
- refactor: refactoring api error handling #382
- chore: update assistant icon & think mode #397
- build: build web components and publish #404
## 0.3.0 (2025-03-31)
### Breaking changes
### Features
- feat: add web pages components #277
- feat: support for customizing some of the preset shortcuts #316
- feat: support multi websocket connections #314
- feat: add support for embeddable web widget #277
### Bug fix
### Improvements
- refactor: refactor invoke related code #309
- refactor: hide apps without icon #312
## 0.2.1 (2025-03-14)
### Features
- support for automatic in-app updates #274
### Breaking changes
### Bug fix
- Fix the issue that the fusion search include disabled servers
- Fix incorrect version type: should be string instead of u32
- Fix the chat end judgment type #280
- Fix the chat scrolling and chat rendering #282
- Fix: store data is not shared among multiple windows #298
### Improvements
- Refactor: chat components #273
- Feat: add endpoint display #282
- Chore: chat window min width & remove input bg #284
- Chore: remove selected function & add hide_coco #286
- Chore: websocket timeout increased to 2 minutes #289
- Chore: remove chat input border & clear input #295
## 0.2.0 (2025-03-07)
### Features
@@ -25,7 +147,7 @@ Information about release notes of Coco Server is provided here.
- Add api to disable or enable server #185
- Networked search supports selection of data sources #209
- Add deepthink and knowledge search options to RAG based chat
- Support i18n, add Chinese language support
- Support i18n, add Chinese language support
- Support Windows platform
- etc.
@@ -54,7 +176,6 @@ Information about release notes of Coco Server is provided here.
- Allow to switch servers in the settings page
- etc.
## 0.1.0 (2025-02-16)
### Features

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

View File

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

View File

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

2500
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

File diff suppressed because one or more lines are too long

2277
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "coco"
version = "0.2.0"
version = "0.4.0"
description = "Search, connect, collaborate all in one place."
authors = ["INFINI Labs"]
edition = "2021"
@@ -20,6 +20,26 @@ tauri-build = { version = "2", features = ["default"] }
default = ["desktop"]
desktop = []
cargo-clippy = []
# If enabled, code that relies on pizza_engine will be activated.
#
# Only do this if:
# 1. Pizza engine is listed in the `dependencies` section
#
# ```toml
# [dependencies]
# pizza-engine = { git = "ssh://git@github.com/infinilabs/pizza.git", features = ["query_string_parser", "persistence"] }
# ```
#
# 2. It is a private repo, you have access to it.
#
# So, for external contributors, do NOT enable this feature.
#
# Previously, We listed it in the dependencies and marked it optional, but cargo
# would fetch all the dependencies regardless of wheterh they are optional or not,
# so we removed it.
#
# https://github.com/rust-lang/cargo/issues/4544#issuecomment-1906902755
use_pizza_engine = []
[dependencies]
pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "main" }
@@ -27,7 +47,9 @@ pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "m
tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png", "unstable"] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Need `arbitrary_precision` feature to support storing u128
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
serde_json = { version = "1", features = ["arbitrary_precision"] }
tauri-plugin-http = "2"
tauri-plugin-websocket = "2"
tauri-plugin-deep-link = "2.0.0"
@@ -35,19 +57,17 @@ tauri-plugin-store = "2.2.0"
tauri-plugin-os = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-drag = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-fs-pro = "2"
tauri-plugin-screenshots = "2"
applications = "0.3.0"
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "7bb507e6b12f73c96f3a52f0578d0246a689f381" }
tokio-native-tls = "0.3" # For wss connections
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.20", features = ["rustls-tls-webpki-roots"] }
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
hyper = { version = "0.14", features = ["client"] }
reqwest = "0.12.12"
reqwest = { version = "0.12", features = ["json", "multipart"] }
futures = "0.3.31"
ordered-float = { version = "4.6.0", default-features = false }
lazy_static = "1.5.0"
@@ -60,14 +80,19 @@ hostname = "0.3"
plist = "1.7"
base64 = "0.13"
walkdir = "2"
fuzzy_prefix_search = "0.2"
log = "0.4"
futures-util = "0.3.31"
url = "2.5.2"
http = "1.1.0"
tungstenite = "0.24.0"
env_logger = "0.11.5"
tokio-util = "0.7.14"
tauri-plugin-windows-version = "2"
meval = "0.2"
chinese-number = "0.7"
num2words = "1"
tauri-plugin-log = "2"
chrono = "0.4.41"
[target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
@@ -75,7 +100,6 @@ tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
[profile.dev]
incremental = true # Compile your binary in smaller steps.
@@ -89,3 +113,7 @@ strip = true # Ensures debug symbols are removed.
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "^2.2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = { git = "https://github.com/infinilabs/plugins-workspace", branch = "v2" }
[target."cfg(target_os = \"windows\")".dependencies]
enigo="0.3"

View File

@@ -31,5 +31,12 @@
</array>
</dict>
</array>
<key>NSMicrophoneUsageDescription</key>
<string>Coco AI needs access to your microphone for voice input and audio recording features.</string>
<key>NSCameraUsageDescription</key>
<string>Coco AI requires camera access for scanning documents and capturing images.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Coco AI uses speech recognition to convert your voice into text for a hands-free experience.</string>
</dict>
</plist>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -29,6 +29,7 @@
"core:window:allow-set-focus",
"core:window:allow-set-always-on-top",
"core:window:deny-internal-toggle-maximize",
"core:window:allow-set-shadow",
"core:app:allow-set-app-theme",
"shell:default",
"http:default",
@@ -67,6 +68,9 @@
"macos-permissions:default",
"screenshots:default",
"core:window:allow-set-theme",
"process:default"
"process:default",
"updater:default",
"windows-version:default",
"log:default"
]
}

View File

@@ -9,6 +9,7 @@
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",
"global-shortcut:default"
"global-shortcut:default",
"updater:default"
]
}

View File

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

View File

@@ -1,17 +1,18 @@
use crate::common;
use crate::common::assistant::ChatRequestMessage;
use crate::common::http::GetResponse;
use crate::server::http_client::HttpClient;
use reqwest::Response;
use serde_json::Value;
use std::collections::HashMap;
use tauri::{AppHandle, Runtime};
#[tauri::command]
pub async fn chat_history<R: Runtime>(
app_handle: AppHandle<R>,
_app_handle: AppHandle<R>,
server_id: String,
from: u32,
size: u32,
query: Option<String>,
) -> Result<String, String> {
let mut query_params: HashMap<String, Value> = HashMap::new();
if from > 0 {
@@ -21,30 +22,25 @@ pub async fn chat_history<R: Runtime>(
query_params.insert("size".to_string(), size.into());
}
if let Some(query) = query {
if !query.is_empty() {
query_params.insert("query".to_string(), query.into());
}
}
let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params))
.await
.map_err(|e| format!("Error get sessions: {}", e))?;
.map_err(|e| {
dbg!("Error get history: {}", &e);
format!("Error get history: {}", e)
})?;
handle_raw_response(response).await?
}
async fn handle_raw_response(response: Response) -> Result<Result<String, String>, String> {
Ok(
if response.status().as_u16() < 200 || response.status().as_u16() >= 400 {
Err("Failed to send message".to_string())
} else {
let body = response
.text()
.await
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
Ok(body)
},
)
common::http::get_response_body_text(response).await
}
#[tauri::command]
pub async fn session_chat_history<R: Runtime>(
app_handle: AppHandle<R>,
_app_handle: AppHandle<R>,
server_id: String,
session_id: String,
from: u32,
@@ -64,87 +60,90 @@ pub async fn session_chat_history<R: Runtime>(
.await
.map_err(|e| format!("Error get session message: {}", e))?;
handle_raw_response(response).await?
common::http::get_response_body_text(response).await
}
#[tauri::command]
pub async fn open_session_chat<R: Runtime>(
app_handle: AppHandle<R>,
_app_handle: AppHandle<R>,
server_id: String,
session_id: String,
) -> Result<String, String> {
let mut query_params = HashMap::new();
let query_params = HashMap::new();
let path = format!("/chat/{}/_open", session_id);
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
.await
.map_err(|e| format!("Error open session: {}", e))?;
handle_raw_response(response).await?
common::http::get_response_body_text(response).await
}
#[tauri::command]
pub async fn close_session_chat<R: Runtime>(
app_handle: AppHandle<R>,
_app_handle: AppHandle<R>,
server_id: String,
session_id: String,
) -> Result<String, String> {
let mut query_params = HashMap::new();
let query_params = HashMap::new();
let path = format!("/chat/{}/_close", session_id);
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
.await
.map_err(|e| format!("Error close session: {}", e))?;
handle_raw_response(response).await?
common::http::get_response_body_text(response).await
}
#[tauri::command]
pub async fn cancel_session_chat<R: Runtime>(
app_handle: AppHandle<R>,
_app_handle: AppHandle<R>,
server_id: String,
session_id: String,
) -> Result<String, String> {
let mut query_params = HashMap::new();
let query_params = HashMap::new();
let path = format!("/chat/{}/_cancel", session_id);
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
.await
.map_err(|e| format!("Error cancel session: {}", e))?;
handle_raw_response(response).await?
common::http::get_response_body_text(response).await
}
#[tauri::command]
pub async fn new_chat<R: Runtime>(
app_handle: AppHandle<R>,
_app_handle: AppHandle<R>,
server_id: String,
websocket_id: String,
message: String,
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
query_params: Option<HashMap<String, Value>>,
) -> Result<GetResponse, String> {
let body = if !message.is_empty() {
let message = ChatRequestMessage {
message: Some(message),
};
let body = reqwest::Body::from(serde_json::to_string(&message).unwrap());
Some(body)
Some(
serde_json::to_string(&message)
.map_err(|e| format!("Failed to serialize message: {}", e))?
.into(),
)
} else {
None
};
let response = HttpClient::post(&server_id, "/chat/_new", query_params, body)
.await
.map_err(|e| format!("Error sending message: {}", e))?;
let mut headers = HashMap::new();
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
if response.status().as_u16() < 200 || response.status().as_u16() >= 400 {
return Err("Failed to send message".to_string());
}
let response =
HttpClient::advanced_post(&server_id, "/chat/_new", Some(headers), query_params, body)
.await
.map_err(|e| format!("Error sending message: {}", e))?;
let chat_response: GetResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
let body_text = common::http::get_response_body_text(response).await?;
let chat_response: GetResponse =
serde_json::from_str(&body_text).map_err(|e| format!("Failed to parse response JSON: {}", e))?;
// Check the result and status fields
if chat_response.result != "created" {
return Err(format!("Unexpected result: {}", chat_response.result));
}
@@ -154,8 +153,9 @@ pub async fn new_chat<R: Runtime>(
#[tauri::command]
pub async fn send_message<R: Runtime>(
app_handle: AppHandle<R>,
_app_handle: AppHandle<R>,
server_id: String,
websocket_id: String,
session_id: String,
message: String,
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
@@ -165,11 +165,94 @@ pub async fn send_message<R: Runtime>(
message: Some(message),
};
let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap());
let response =
HttpClient::advanced_post(&server_id, path.as_str(), None, query_params, Some(body))
.await
.map_err(|e| format!("Error cancel session: {}", e))?;
let mut headers = HashMap::new();
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
handle_raw_response(response).await?
let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap());
let response = HttpClient::advanced_post(
&server_id,
path.as_str(),
Some(headers),
query_params,
Some(body),
)
.await
.map_err(|e| format!("Error cancel session: {}", e))?;
common::http::get_response_body_text(response).await
}
#[tauri::command]
pub async fn delete_session_chat(server_id: String, session_id: String) -> Result<bool, String> {
let response =
HttpClient::delete(&server_id, &format!("/chat/{}", session_id), None, None).await?;
if response.status().is_success() {
Ok(true)
} else {
Err(format!("Delete failed with status: {}", response.status()))
}
}
#[tauri::command]
pub async fn update_session_chat(
server_id: String,
session_id: String,
title: Option<String>,
context: Option<HashMap<String, Value>>,
) -> Result<bool, String> {
let mut body = HashMap::new();
if let Some(title) = title {
body.insert("title".to_string(), Value::String(title));
}
if let Some(context) = context {
body.insert(
"context".to_string(),
Value::Object(context.into_iter().collect()),
);
}
let response = HttpClient::put(
&server_id,
&format!("/chat/{}", session_id),
None,
None,
Some(reqwest::Body::from(serde_json::to_string(&body).unwrap())),
)
.await
.map_err(|e| format!("Error updating session: {}", e))?;
Ok(response.status().is_success())
}
#[tauri::command]
pub async fn assistant_search<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
from: u32,
size: u32,
query: Option<HashMap<String, Value>>,
) -> Result<Value, String> {
let mut body = serde_json::json!({
"from": from,
"size": size,
});
if let Some(q) = query {
body["query"] = serde_json::to_value(q).map_err(|e| e.to_string())?;
}
let response = HttpClient::post(
&server_id,
"/assistant/_search",
None,
Some(reqwest::Body::from(body.to_string())),
)
.await
.map_err(|e| format!("Error searching assistants: {}", e))?;
response
.json::<Value>()
.await
.map_err(|err| err.to_string())
}

View File

@@ -60,7 +60,10 @@ fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, Stri
}
#[tauri::command]
pub fn change_autostart<R: Runtime>(app: tauri::AppHandle<R>, open: bool) -> Result<(), String> {
pub async fn change_autostart<R: Runtime>(
app: tauri::AppHandle<R>,
open: bool,
) -> Result<(), String> {
use std::fs::File;
use std::io::Write;

View File

@@ -6,6 +6,7 @@ pub struct ChatRequestMessage {
pub message: Option<String>,
}
#[allow(dead_code)]
pub struct NewChatResponse {
pub _id: String,
pub _source: Source,
@@ -22,4 +23,4 @@ pub struct Source {
pub title: Option<String>,
pub summary: Option<String>,
pub manually_renamed_title: bool,
}
}

View File

@@ -13,6 +13,7 @@ pub struct DataSourceReference {
pub r#type: Option<String>,
pub name: Option<String>,
pub id: Option<String>,
pub icon: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -28,7 +29,7 @@ pub struct EditorInfo {
pub timestamp: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Document {
pub id: String,
pub created: Option<String>,
@@ -54,32 +55,3 @@ pub struct Document {
pub owner: Option<UserInfo>,
pub last_updated_by: Option<EditorInfo>,
}
impl Document {
pub fn new(source: Option<DataSourceReference>, id: String, category: String, name: String, url: String) -> Self {
Self {
id,
created: None,
updated: None,
source,
r#type: None,
category: Some(category),
subcategory: None,
categories: None,
rich_categories: None,
title: Some(name),
summary: None,
lang: None,
content: None,
icon: None,
thumbnail: None,
cover: None,
tags: None,
url: Some(url),
size: None,
metadata: None,
payload: None,
owner: None,
last_updated_by: None,
}
}
}

View File

@@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Deserialize)]
pub struct ErrorDetail {
pub reason: String,
pub status: u16,
}
#[derive(Debug, Deserialize)]
pub struct ErrorResponse {
pub error: ErrorDetail,
}
#[derive(Debug, Error, Serialize)]
pub enum SearchError {
#[error("HTTP request failed: {0}")]
HttpError(String),
#[error("Invalid response format: {0}")]
ParseError(String),
#[error("Timeout occurred")]
Timeout,
#[error("Unknown error: {0}")]
#[allow(dead_code)]
Unknown(String),
#[error("InternalError error: {0}")]
#[allow(dead_code)]
InternalError(String),
}
impl From<reqwest::Error> for SearchError {
fn from(err: reqwest::Error) -> Self {
if err.is_timeout() {
SearchError::Timeout
} else if err.is_decode() {
SearchError::ParseError(err.to_string())
} else {
SearchError::HttpError(err.to_string())
}
}
}

View File

@@ -1,3 +1,5 @@
use crate::common;
use reqwest::Response;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -15,4 +17,36 @@ pub struct Source {
pub created: String,
pub updated: String,
pub status: String,
}
pub async fn get_response_body_text(response: Response) -> Result<String, String> {
let status = response.status().as_u16();
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response body: {}, code: {}", e, status))?;
log::debug!("Response status: {}, body: {}", status, &body);
if status < 200 || status >= 400 {
// Try to parse the error body
let fallback_error = "Failed to send message".to_string();
if body.trim().is_empty() {
return Err(fallback_error);
}
match serde_json::from_str::<common::error::ErrorResponse>(&body) {
Ok(parsed_error) => {
dbg!(&parsed_error);
Err(format!(
"Server error ({}): {}",
parsed_error.error.status, parsed_error.error.reason
))
}
Err(_) => Err(fallback_error),
}
} else {
Ok(body)
}
}

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ impl SearchSourceRegistry {
sources.insert(source_id, Arc::new(source));
}
#[allow(dead_code)]
pub async fn clear(&self) {
let mut sources = self.sources.write().await;
sources.clear();
@@ -26,6 +27,7 @@ impl SearchSourceRegistry {
sources.remove(id);
}
#[allow(dead_code)]
pub async fn get_source(&self, id: &str) -> Option<Arc<dyn SearchSource>> {
let sources = self.sources.read().await;
sources.get(id).cloned()
@@ -34,4 +36,4 @@ impl SearchSourceRegistry {
let sources = self.sources.read().await;
sources.values().cloned().collect() // Returns Vec<Arc<dyn SearchSource>>
}
}
}

View File

@@ -1,14 +1,15 @@
use crate::common::document::Document;
use crate::common::http::get_response_body_text;
use reqwest::Response;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::error::Error;
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchResponse<T> {
pub took: u64,
pub timed_out: bool,
pub _shards: Shards,
pub _shards: Option<Shards>,
pub hits: Hits<T>,
}
@@ -47,14 +48,11 @@ pub async fn parse_search_response<T>(
where
T: for<'de> Deserialize<'de> + std::fmt::Debug,
{
let body = response
.json::<Value>()
.await
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
let body_text = get_response_body_text(response).await?;
// dbg!(&body);
// dbg!(&body_text);
let search_response: SearchResponse<T> = serde_json::from_value(body)
let search_response: SearchResponse<T> = serde_json::from_str(&body_text)
.map_err(|e| format!("Failed to deserialize search response: {}", e))?;
Ok(search_response)
@@ -80,6 +78,7 @@ where
.collect())
}
#[allow(dead_code)]
pub async fn parse_search_results_with_score<T>(
response: Response,
) -> Result<Vec<(T, Option<f64>)>, Box<dyn Error>>

View File

@@ -29,6 +29,11 @@ pub struct AuthProvider {
pub sso: Sso,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MinimalClientVersion {
number: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Server {
#[serde(default = "default_empty_string")] // Custom default function for empty string
@@ -39,12 +44,13 @@ pub struct Server {
pub endpoint: String,
pub provider: Provider,
pub version: Version,
pub minimal_client_version: Option<MinimalClientVersion>,
pub updated: String,
#[serde(default = "default_enabled_type")]
pub enabled: bool,
#[serde(default = "default_bool_type")]
pub public: bool,
#[serde(default = "default_available_type")]
pub available: bool,
@@ -70,7 +76,6 @@ impl Hash for Server {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerAccessToken {
#[serde(default = "default_empty_string")] // Custom default function for empty string
@@ -104,11 +109,11 @@ impl Hash for ServerAccessToken {
}
fn default_empty_string() -> String {
"".to_string() // Default to empty string if not provided
"".to_string() // Default to empty string if not provided
}
fn default_bool_type() -> bool {
false // Default to false if not provided
false // Default to false if not provided
}
fn default_enabled_type() -> bool {
@@ -123,4 +128,4 @@ fn default_priority_type() -> u32 {
}
fn default_user_profile_type() -> Option<UserProfile> {
None
}
}

View File

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

View File

@@ -4,6 +4,7 @@ mod common;
mod local;
mod search;
mod server;
mod settings;
mod setup;
mod shortcut;
mod util;
@@ -11,21 +12,19 @@ mod util;
use crate::common::register::SearchSourceRegistry;
// use crate::common::traits::SearchSource;
use crate::common::{MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
use crate::server::search::CocoSearchSource;
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
use autostart::{change_autostart, enable_autostart};
use lazy_static::lazy_static;
use reqwest::Client;
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::OnceLock;
use tauri::async_runtime::block_on;
use tauri::plugin::TauriPlugin;
#[cfg(target_os = "macos")]
use tauri::ActivationPolicy;
use tauri::{
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, State, WebviewWindow, Window,
WindowEvent,
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, Window, WindowEvent,
};
use tauri_plugin_autostart::MacosLauncher;
use tokio::runtime::Runtime as RT;
/// Tauri store name
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
@@ -34,8 +33,12 @@ lazy_static! {
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
}
/// To allow us to access tauri's `AppHandle` when its context is inaccessible,
/// store it globally. It will be set in `init()`.
pub(crate) static GLOBAL_TAURI_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
#[tauri::command]
fn change_window_height(handle: AppHandle, height: u32) {
async fn change_window_height(handle: AppHandle, height: u32) {
let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
let mut size = window.outer_size().unwrap();
@@ -45,10 +48,12 @@ fn change_window_height(handle: AppHandle, height: u32) {
#[derive(serde::Deserialize)]
struct ThemeChangedPayload {
#[allow(dead_code)]
is_dark_mode: bool,
}
#[derive(Clone, serde::Serialize)]
#[allow(dead_code)]
struct Payload {
args: Vec<String>,
cwd: String,
@@ -56,9 +61,7 @@ struct Payload {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut ctx = tauri::generate_context!();
// Initialize logger
env_logger::init();
let ctx = tauri::generate_context!();
let mut app_builder = tauri::Builder::default();
@@ -83,7 +86,10 @@ pub fn run() {
.plugin(tauri_plugin_fs_pro::init())
.plugin(tauri_plugin_macos_permissions::init())
.plugin(tauri_plugin_screenshots::init())
.plugin(tauri_plugin_process::init());
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_windows_version::init())
.plugin(set_up_tauri_logger());
// Conditional compilation for macOS
#[cfg(target_os = "macos")]
@@ -111,7 +117,8 @@ pub fn run() {
server::servers::disable_server,
server::auth::handle_sso_callback,
server::profile::get_user_profiles,
server::datasource::get_datasources_by_server,
server::datasource::datasource_search,
server::datasource::mcp_server_search,
server::connector::get_connectors_by_server,
search::query_coco_fusion,
assistant::chat_history,
@@ -121,32 +128,54 @@ pub fn run() {
assistant::open_session_chat,
assistant::close_session_chat,
assistant::cancel_session_chat,
assistant::delete_session_chat,
assistant::update_session_chat,
assistant::assistant_search,
// server::get_coco_server_datasources,
// server::get_coco_server_connectors,
server::websocket::connect_to_server,
server::websocket::disconnect,
get_app_search_source
get_app_search_source,
server::attachment::upload_attachment,
server::attachment::get_attachment,
server::attachment::delete_attachment,
server::transcription::transcription,
util::open,
server::system_settings::get_system_settings,
simulate_mouse_click,
local::get_disabled_local_query_sources,
local::enable_local_query_source,
local::disable_local_query_source,
local::application::get_app_list,
local::application::get_app_search_path,
local::application::get_app_metadata,
local::application::set_app_alias,
local::application::register_app_hotkey,
local::application::unregister_app_hotkey,
local::application::disable_app_search,
local::application::enable_app_search,
local::application::add_app_search_path,
local::application::remove_app_search_path,
settings::set_allow_self_signature,
settings::get_allow_self_signature,
])
.setup(|app| {
let app_handle = app.handle().clone();
GLOBAL_TAURI_APP_HANDLE
.set(app_handle.clone())
.expect("variable already initialized");
let registry = SearchSourceRegistry::default();
app.manage(registry); // Store registry in Tauri's app state
app.manage(server::websocket::WebSocketManager::default());
// Get app handle
let app_handle = app.handle().clone();
// Create a single Tokio runtime instance
let rt = RT::new().expect("Failed to create Tokio runtime");
// Use the runtime to spawn the async initialization tasks
let init_app_handle = app.handle().clone();
rt.spawn(async move {
init(&init_app_handle).await; // Pass a reference to `app_handle`
block_on(async {
init(app.handle()).await;
});
shortcut::enable_shortcut(&app);
// enable_tray(app);
shortcut::enable_shortcut(app);
enable_autostart(app);
#[cfg(target_os = "macos")]
@@ -223,57 +252,39 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
let coco_servers = server::servers::get_all_servers();
// Get the registry from Tauri's state
let registry: State<SearchSourceRegistry> = app_handle.state::<SearchSourceRegistry>();
// let registry: State<SearchSourceRegistry> = app_handle.state::<SearchSourceRegistry>();
for server in coco_servers {
let source = CocoSearchSource::new(server.clone(), Client::new());
registry.register_source(source).await;
crate::server::servers::try_register_server_to_search_source(app_handle.clone(), &server)
.await;
}
}
async fn init_app_search_source<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
let application_search =
local::application::ApplicationSearchSource::new(app_handle.clone(), 1000f64).await?;
// Register the application search source
let registry = app_handle.state::<SearchSourceRegistry>();
registry.register_source(application_search).await;
Ok(())
local::start_pizza_engine_runtime();
}
#[tauri::command]
async fn show_coco(app_handle: AppHandle) {
handle_open_coco(&app_handle);
}
#[tauri::command]
fn hide_coco(app: tauri::AppHandle) {
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
match window.is_visible() {
Ok(true) => {
if let Err(err) = window.hide() {
eprintln!("Failed to hide the window: {}", err);
}
}
Ok(false) => {
println!("Window is already hidden.");
}
Err(err) => {
eprintln!("Failed to check window visibility: {}", err);
}
}
}
}
fn handle_open_coco(app: &AppHandle) {
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
if let Some(window) = app_handle.get_window(MAIN_WINDOW_LABEL) {
move_window_to_active_monitor(&window);
window.show().unwrap();
window.set_visible_on_all_workspaces(true).unwrap();
window.set_always_on_top(true).unwrap();
window.set_focus().unwrap();
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
let _ = app_handle.emit("show-coco", ());
}
}
#[tauri::command]
async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
if let Err(err) = window.hide() {
eprintln!("Failed to hide the window: {}", err);
} else {
println!("Window successfully hidden.");
}
} else {
eprintln!("Main window not found.");
}
}
@@ -370,88 +381,15 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
}
}
fn handle_hide_coco(app: &AppHandle) {
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
if let Err(err) = window.hide() {
eprintln!("Failed to hide the window: {}", err);
} else {
println!("Window successfully hidden.");
}
} else {
eprintln!("Main window not found.");
}
}
fn enable_tray(app: &mut tauri::App) {
use tauri::{
image::Image,
menu::{MenuBuilder, MenuItem},
tray::TrayIconBuilder,
};
let quit_i = MenuItem::with_id(app, "quit", "Quit Coco", true, None::<&str>).unwrap();
let settings_i = MenuItem::with_id(app, "settings", "Settings...", true, None::<&str>).unwrap();
let open_i = MenuItem::with_id(app, "open", "Show Coco", true, None::<&str>).unwrap();
// let about_i = MenuItem::with_id(app, "about", "About Coco", true, None::<&str>).unwrap();
// let hide_i = MenuItem::with_id(app, "hide", "Hide Coco", true, None::<&str>).unwrap();
let menu = MenuBuilder::new(app)
.item(&open_i)
.separator()
// .item(&hide_i)
// .item(&about_i)
.item(&settings_i)
.separator()
.item(&quit_i)
.build()
.unwrap();
let _tray = TrayIconBuilder::with_id("tray")
.icon_as_template(true)
// .icon(app.default_window_icon().unwrap().clone())
.icon(
Image::from_bytes(include_bytes!("../assets/tray-mac.ico"))
.expect("Failed to load icon"),
)
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => {
handle_open_coco(app);
}
"hide" => {
handle_hide_coco(app);
}
"about" => {
let _ = app.emit("open_settings", "about");
}
"settings" => {
// windows failed to open second window, issue: https://github.com/tauri-apps/tauri/issues/11144 https://github.com/tauri-apps/tauri/issues/8196
//#[cfg(windows)]
let _ = app.emit("open_settings", "settings");
// #[cfg(not(windows))]
// open_settings(&app);
}
"quit" => {
println!("quit menu item was clicked");
app.exit(0);
}
_ => {
println!("menu item {:?} not handled", event.id);
}
})
.build(app)
.unwrap();
}
#[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 {
window.show().unwrap();
window.set_focus().unwrap();
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
} else {
let window = tauri::window::WindowBuilder::new(app, "settings")
.title("Settings Window")
@@ -477,7 +415,7 @@ fn open_settings(app: &tauri::AppHandle) {
#[tauri::command]
async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> {
init_app_search_source(&app_handle).await?;
local::init_local_search_source(&app_handle).await?;
let _ = server::connector::refresh_all_connectors(&app_handle).await;
let _ = server::datasource::refresh_all_datasources(&app_handle).await;
@@ -488,3 +426,98 @@ async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(
async fn show_settings(app_handle: AppHandle) {
open_settings(&app_handle);
}
#[tauri::command]
async fn simulate_mouse_click<R: Runtime>(window: WebviewWindow<R>, is_chat_mode: bool) {
#[cfg(target_os = "windows")]
{
use enigo::{Button, Coordinate, Direction, Enigo, Mouse, Settings};
use std::{thread, time::Duration};
if let Ok(mut enigo) = Enigo::new(&Settings::default()) {
// Save the current mouse position
if let Ok((original_x, original_y)) = enigo.location() {
// Retrieve the window's outer position (top-left corner)
if let Ok(position) = window.outer_position() {
// Retrieve the window's inner size (client area)
if let Ok(size) = window.inner_size() {
// Calculate the center position of the title bar
let x = position.x + (size.width as i32 / 2);
let y = if is_chat_mode {
position.y + size.height as i32 - 50
} else {
position.y + 30
};
// Move the mouse cursor to the calculated position
if enigo.move_mouse(x, y, Coordinate::Abs).is_ok() {
// // Simulate a left mouse click
let _ = enigo.button(Button::Left, Direction::Click);
// let _ = enigo.button(Button::Left, Direction::Release);
thread::sleep(Duration::from_millis(100));
// Move the mouse cursor back to the original position
let _ = enigo.move_mouse(original_x, original_y, Coordinate::Abs);
}
}
}
}
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = window;
let _ = is_chat_mode;
}
}
/// Log format:
///
/// ```text
/// [time] [log level] [file module:line] message
/// ```
///
/// Example:
///
///
/// ```text
/// [05-11 17:00:00] [INF] [coco_lib:625] Coco-AI started
/// ```
fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
use log::Level;
fn format_log_level(level: Level) -> &'static str {
match level {
Level::Trace => "TRC",
Level::Debug => "DBG",
Level::Info => "INF",
Level::Warn => "WAR",
Level::Error => "ERR",
}
}
fn format_target_and_line(record: &log::Record) -> String {
let mut str = record.target().to_string();
if let Some(line) = record.line() {
str.push(':');
str.push_str(&line.to_string());
}
str
}
tauri_plugin_log::Builder::new()
.format(|out, message, record| {
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
let level = format_log_level(record.level());
let target_and_line = format_target_and_line(record);
out.finish(format_args!(
"[{}] [{}] [{}] {}",
now, level, target_and_line, message
));
})
.level(log::LevelFilter::Debug)
.build()
}

View File

@@ -1,158 +0,0 @@
use crate::common::document::{DataSourceReference, Document};
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
use crate::common::traits::{SearchError, SearchSource};
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
use applications::{AppInfo, AppInfoContext};
use async_trait::async_trait;
use base64::encode;
use fuzzy_prefix_search::Trie;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Runtime};
use tauri_plugin_fs_pro::{icon, name};
pub struct ApplicationSearchSource {
base_score: f64,
icons: HashMap<String, PathBuf>,
application_paths: Trie<String>,
}
impl ApplicationSearchSource {
pub async fn new<R: Runtime>(
app_handle: AppHandle<R>,
base_score: f64,
) -> Result<Self, String> {
let application_paths = Trie::new();
let mut icons = HashMap::new();
let mut ctx = AppInfoContext::new(vec![]);
ctx.refresh_apps().map_err(|err| err.to_string())?; // must refresh apps before getting them
let apps = ctx.get_all_apps();
for app in &apps {
let path = if cfg!(target_os = "macos") {
app.app_desktop_path.clone()
} else {
app.app_path_exe
.clone()
.unwrap_or(PathBuf::from("Path not found"))
};
let search_word = name(path.clone()).await;
let icon = icon(app_handle.clone(), path.clone(), Some(256))
.await
.map_err(|err| err.to_string())?;
let path_string = path.to_string_lossy().into_owned();
if search_word.is_empty() || search_word.eq("coco-ai") {
continue;
}
application_paths.insert(&search_word, path_string.clone());
icons.insert(path_string, icon);
}
Ok(ApplicationSearchSource {
base_score,
icons,
application_paths,
})
}
}
#[async_trait]
impl SearchSource for ApplicationSearchSource {
fn get_type(&self) -> QuerySource {
QuerySource {
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
name: hostname::get()
.unwrap_or("My Computer".into())
.to_string_lossy()
.into(),
id: "local_applications".into(),
}
}
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
let query_string = query
.query_strings
.get("query")
.unwrap_or(&"".to_string())
.to_lowercase();
if query_string.is_empty() {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
}
let mut total_hits = 0;
let mut hits = Vec::new();
let mut results = self
.application_paths
.search_within_distance_scored(&query_string, 3);
// Check for NaN or extreme score values and handle them properly
results.sort_by(|a, b| {
// If either score is NaN, consider them equal (you can customize this logic as needed)
if a.score.is_nan() || b.score.is_nan() {
std::cmp::Ordering::Equal
} else {
// Otherwise, compare the scores as usual
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
}
});
if !results.is_empty() {
for result in results {
let file_name_str = result.word;
let file_path_str = result.data.get(0).unwrap().to_string();
let file_path = PathBuf::from(file_path_str.clone());
let cleaned_file_name = name(file_path).await;
total_hits += 1;
let mut doc = Document::new(
Some(DataSourceReference {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some("Applications".into()),
id: Some(file_name_str.clone()),
}),
file_path_str.clone(),
"Application".to_string(),
cleaned_file_name,
file_path_str.clone(),
);
// Attach icon if available
if let Some(icon_path) = self.icons.get(file_path_str.as_str()) {
// doc.icon = Some(format!("file://{}", icon_path.to_string_lossy()));
// dbg!(&doc.icon);
if let Ok(icon_data) = read_icon_and_encode(icon_path) {
doc.icon = Some(format!("data:image/png;base64,{}", icon_data));
}
}
hits.push((doc, self.base_score + result.score as f64));
}
}
Ok(QueryResponse {
source: self.get_type(),
hits,
total_hits,
})
}
}
// Function to read the icon file and convert it to base64
fn read_icon_and_encode(icon_path: &Path) -> Result<String, std::io::Error> {
// Read the icon file as binary data
let icon_data = fs::read(icon_path)?;
// Encode the data to base64
Ok(encode(&icon_data))
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,4 +1,164 @@
pub mod application;
pub mod calculator;
pub mod file_system;
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
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,8 +1,8 @@
use crate::common::error::SearchError;
use crate::common::register::SearchSourceRegistry;
use crate::common::search::{
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
};
use crate::common::traits::SearchError;
use futures::stream::FuturesUnordered;
use futures::StreamExt;
use std::collections::HashMap;
@@ -16,7 +16,10 @@ pub async fn query_coco_fusion<R: Runtime>(
from: u64,
size: u64,
query_strings: HashMap<String, String>,
query_timeout: u64,
) -> Result<MultiSourceQueryResponse, SearchError> {
let query_source_to_search = query_strings.get("querysource");
let search_sources = app_handle.state::<SearchSourceRegistry>();
let sources_future = search_sources.get_sources();
@@ -26,11 +29,19 @@ pub async fn query_coco_fusion<R: Runtime>(
let sources_list = sources_future.await;
// Time limit for each query
let timeout_duration = Duration::from_millis(500); //TODO, settings
let timeout_duration = Duration::from_millis(query_timeout);
// Push all queries into futures
for query_source in sources_list {
let query_source_type = query_source.get_type().clone();
if let Some(query_source_to_search) = query_source_to_search {
// We should not search this data source
if &query_source_type.id != query_source_to_search {
continue;
}
}
sources.insert(query_source_type.id.clone(), query_source_type);
let query = SearchQuery::new(from, size, query_strings.clone());
@@ -71,6 +82,18 @@ pub async fn query_coco_fusion<R: Runtime>(
.push((query_hit, score));
}
}
Ok(Ok(Err(err))) => {
failed_requests.push(FailedRequest {
source: QuerySource {
r#type: "N/A".into(),
name: "N/A".into(),
id: "N/A".into(),
},
status: 0,
error: Some(err.to_string()),
reason: None,
});
}
Ok(Err(err)) => {
failed_requests.push(FailedRequest {
source: QuerySource {
@@ -84,7 +107,7 @@ pub async fn query_coco_fusion<R: Runtime>(
});
}
// Timeout reached, skip this request
Ok(_) => {
_ => {
failed_requests.push(FailedRequest {
source: QuerySource {
r#type: "N/A".into(),
@@ -92,19 +115,7 @@ pub async fn query_coco_fusion<R: Runtime>(
id: "N/A".into(),
},
status: 0,
error: Some("Query source timed out".to_string()),
reason: None,
});
}
Err(_) => {
failed_requests.push(FailedRequest {
source: QuerySource {
r#type: "N/A".into(),
name: "N/A".into(),
id: "N/A".into(),
},
status: 0,
error: Some("Task panicked".to_string()),
error: Some(format!("{:?}", &result)),
reason: None,
});
}

View File

@@ -0,0 +1,143 @@
use super::servers::{get_server_by_id, get_server_token};
use crate::common::http::get_response_body_text;
use crate::server::http_client::HttpClient;
use reqwest::multipart::{Form, Part};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{collections::HashMap, path::PathBuf};
use tauri::command;
use tokio::fs::File;
use tokio_util::codec::{BytesCodec, FramedRead};
#[derive(Debug, Serialize, Deserialize)]
pub struct UploadAttachmentResponse {
pub acknowledged: bool,
pub attachments: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AttachmentSource {
pub id: String,
pub created: String,
pub updated: String,
pub session: String,
pub name: String,
pub icon: String,
pub url: String,
pub size: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AttachmentHit {
pub _index: String,
pub _type: Option<String>,
pub _id: String,
pub _score: Option<f64>,
pub _source: AttachmentSource,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AttachmentHits {
pub total: Value,
pub max_score: Option<f64>,
pub hits: Option<Vec<AttachmentHit>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetAttachmentResponse {
pub took: u32,
pub timed_out: bool,
pub _shards: Option<Value>,
pub hits: AttachmentHits,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteAttachmentResponse {
pub _id: String,
pub result: String,
}
#[command]
pub async fn upload_attachment(
server_id: String,
session_id: String,
file_paths: Vec<PathBuf>,
) -> Result<UploadAttachmentResponse, String> {
let mut form = Form::new();
for file_path in file_paths {
let file = File::open(&file_path)
.await
.map_err(|err| err.to_string())?;
let stream = FramedRead::new(file, BytesCodec::new());
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.ok_or("Invalid filename")?;
let part =
Part::stream(reqwest::Body::wrap_stream(stream)).file_name(file_name.to_string());
form = form.part("files", part);
}
let server = get_server_by_id(&server_id).ok_or("Server not found")?;
let url = HttpClient::join_url(&server.endpoint, &format!("chat/{}/_upload", session_id));
let token = get_server_token(&server_id).await?;
let mut headers = HashMap::new();
if let Some(token) = token {
headers.insert("X-API-TOKEN".to_string(), token.access_token);
}
let client = reqwest::Client::new();
let response = client
.post(url)
.multipart(form)
.headers((&headers).try_into().map_err(|err| format!("{}", err))?)
.send()
.await
.map_err(|err| err.to_string())?;
let body = get_response_body_text(response).await?;
serde_json::from_str::<UploadAttachmentResponse>(&body)
.map_err(|e| format!("Failed to parse upload response: {}", e))
}
#[command]
pub async fn get_attachment(
server_id: String,
session_id: String,
) -> Result<GetAttachmentResponse, String> {
let mut query_params = HashMap::new();
query_params.insert("session".to_string(), serde_json::Value::String(session_id));
let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params))
.await
.map_err(|e| format!("Request error: {}", e))?;
let body = get_response_body_text(response).await?;
serde_json::from_str::<GetAttachmentResponse>(&body)
.map_err(|e| format!("Failed to parse attachment response: {}", e))
}
#[command]
pub async fn delete_attachment(server_id: String, id: String) -> Result<bool, String> {
let response = HttpClient::delete(&server_id, &format!("/attachment/{}", id), None, None)
.await
.map_err(|e| format!("Request error: {}", e))?;
let body = get_response_body_text(response).await?;
let parsed: DeleteAttachmentResponse = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse delete response: {}", e))?;
parsed
.result
.eq("deleted")
.then_some(true)
.ok_or_else(|| "Delete operation was not successful".to_string())
}

View File

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

View File

@@ -34,6 +34,10 @@ pub async fn refresh_all_connectors<R: Runtime>(app_handle: &AppHandle<R>) -> Re
// Collect all the tasks for fetching and refreshing connectors
let mut server_map = HashMap::new();
for server in servers {
if !server.enabled {
continue;
}
// dbg!("start fetch connectors for server: {}", &server.id);
let connectors = match get_connectors_by_server(app_handle.clone(), server.id.clone()).await
{
@@ -65,6 +69,7 @@ pub async fn refresh_all_connectors<R: Runtime>(app_handle: &AppHandle<R>) -> Re
Ok(())
}
#[allow(dead_code)]
pub async fn get_connectors_from_cache_or_remote(
server_id: &str,
) -> Result<Vec<Connector>, String> {
@@ -96,7 +101,7 @@ pub async fn get_connectors_from_cache_or_remote(
pub async fn fetch_connectors_by_server(id: &str) -> Result<Vec<Connector>, String> {
// Use the generic GET method from HttpClient
let resp = HttpClient::get(&id, "/connector/_search",None)
let resp = HttpClient::get(&id, "/connector/_search", None)
.await
.map_err(|e| {
// dbg!("Error fetching connector for id {}: {}", &id, &e);
@@ -104,9 +109,9 @@ pub async fn fetch_connectors_by_server(id: &str) -> Result<Vec<Connector>, Stri
})?;
// Parse the search results directly from the response body
let datasource: Vec<Connector> = parse_search_results(resp).await.map_err(|e| {
e.to_string()
})?;
let datasource: Vec<Connector> = parse_search_results(resp)
.await
.map_err(|e| e.to_string())?;
// Save the connectors to the cache
save_connectors_to_cache(&id, datasource.clone());

View File

@@ -8,6 +8,13 @@ use std::collections::HashMap;
use std::sync::{Arc, RwLock};
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! {
static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String, HashMap<String, DataSource>>>> =
Arc::new(RwLock::new(HashMap::new()));
@@ -22,6 +29,7 @@ pub fn save_datasource_to_cache(server_id: &str, datasources: Vec<DataSource>) {
cache.insert(server_id.to_string(), datasources_map);
}
#[allow(dead_code)]
pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, DataSource>> {
let cache = DATASOURCE_CACHE.read().unwrap(); // Acquire read lock
// dbg!("cache: {:?}", &cache);
@@ -29,7 +37,7 @@ pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, Dat
Some(server_cache.clone())
}
pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) -> Result<(), String> {
// dbg!("Attempting to refresh all datasources");
let servers = get_all_servers();
@@ -39,23 +47,26 @@ pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> R
for server in servers {
// dbg!("fetch datasources for server: {}", &server.id);
if !server.enabled {
continue;
}
// Attempt to get datasources by server, and continue even if it fails
let connectors =
match get_datasources_by_server(server.id.as_str()).await {
Ok(connectors) => {
// Process connectors only after fetching them
let connectors_map: HashMap<String, DataSource> = connectors
.into_iter()
.map(|connector| (connector.id.clone(), connector))
.collect();
// dbg!("connectors_map: {:?}", &connectors_map);
connectors_map
}
Err(_e) => {
// dbg!("Failed to get dataSources for server {}: {}", &server.id, e);
HashMap::new()
}
};
let connectors = match datasource_search(server.id.as_str(), None).await {
Ok(connectors) => {
// Process connectors only after fetching them
let connectors_map: HashMap<String, DataSource> = connectors
.into_iter()
.map(|connector| (connector.id.clone(), connector))
.collect();
// dbg!("connectors_map: {:?}", &connectors_map);
connectors_map
}
Err(_e) => {
// dbg!("Failed to get dataSources for server {}: {}", &server.id, e);
HashMap::new()
}
};
let mut new_map = HashMap::new();
for (id, datasource) in connectors.iter() {
@@ -79,23 +90,52 @@ pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> R
cache.extend(server_map);
cache.len()
};
// dbg!("datasource_map size: {:?}", cache_size);
Ok(())
}
#[tauri::command]
pub async fn get_datasources_by_server(
pub async fn datasource_search(
id: &str,
options: Option<GetDatasourcesByServerOptions>,
) -> Result<Vec<DataSource>, String> {
let from = options.as_ref().and_then(|opt| opt.from).unwrap_or(0);
let size = options.as_ref().and_then(|opt| opt.size).unwrap_or(10000);
let 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
let resp = HttpClient::get(id, "/datasource/_search", None)
let resp = HttpClient::post(
id,
"/datasource/_search",
None,
Some(reqwest::Body::from(body.to_string())),
)
.await
.map_err(|e| {
// dbg!("Error fetching datasource: {}", &e);
format!("Error fetching datasource: {}", e)
})?;
.map_err(|e| format!("Error fetching datasource: {}", e))?;
// Parse the search results from the response
let datasources: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
@@ -108,3 +148,59 @@ pub async fn get_datasources_by_server(
Ok(datasources)
}
#[tauri::command]
pub async fn mcp_server_search(
id: &str,
options: Option<GetDatasourcesByServerOptions>,
) -> Result<Vec<DataSource>, String> {
let from = options.as_ref().and_then(|opt| opt.from).unwrap_or(0);
let size = options.as_ref().and_then(|opt| opt.size).unwrap_or(10000);
let 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
let resp = HttpClient::post(
id,
"/mcp_server/_search",
None,
Some(reqwest::Body::from(body.to_string())),
)
.await
.map_err(|e| format!("Error fetching datasource: {}", e))?;
// Parse the search results from the response
let mcp_server: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
dbg!("Error parsing search results: {}", &e);
e.to_string()
})?;
// Save the updated mcp_server to cache
// save_datasource_to_cache(&id, mcp_server.clone());
Ok(mcp_server)
}

View File

@@ -1,22 +1,30 @@
use crate::server::servers::{get_server_by_id, get_server_token};
use http::HeaderName;
use http::{HeaderName, HeaderValue};
use once_cell::sync::Lazy;
use reqwest::{Client, Method, RequestBuilder};
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
use tauri::ipc::RuntimeCapability;
use tauri_plugin_store::JsonValue;
use tokio::sync::Mutex;
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
let client = Client::builder()
pub(crate) fn new_reqwest_http_client(accept_invalid_certs: bool) -> Client {
Client::builder()
.read_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
.connect_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
.timeout(Duration::from_secs(10)) // Set a timeout of 10 seconds
.danger_accept_invalid_certs(true) // example for self-signed certificates
.danger_accept_invalid_certs(accept_invalid_certs) // allow self-signed certificates
.build()
.expect("Failed to build client");
Mutex::new(client)
.expect("Failed to build client")
}
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
let allow_self_signature = crate::settings::_get_allow_self_signature(
crate::GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app store not set")
.clone(),
);
Mutex::new(new_reqwest_http_client(allow_self_signature))
});
pub struct HttpClient;
@@ -32,14 +40,33 @@ impl HttpClient {
pub async fn send_raw_request(
method: Method,
url: &str,
query_params: Option<HashMap<String, Value>>,
query_params: Option<HashMap<String, JsonValue>>,
headers: Option<HashMap<String, String>>,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
let mut request_builder = Self::get_request_builder(method, url, headers, query_params, body).await;
log::debug!(
"Sending Request: {}, query_params: {:?}, header: {:?}, body: {:?}",
&url,
&query_params,
&headers,
&body
);
let request_builder =
Self::get_request_builder(method, url, headers, query_params, body).await;
let response = request_builder.send().await.map_err(|e| {
dbg!("Failed to send request: {}", &e);
format!("Failed to send request: {}", e)
})?;
log::debug!(
"Request: {}, Response status: {:?}, header: {:?}",
&url,
&response.status(),
&response.headers()
);
let response = request_builder.send().await
.map_err(|e| format!("Failed to send request: {}", e))?;
Ok(response)
}
@@ -47,7 +74,7 @@ impl HttpClient {
method: Method,
url: &str,
headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
body: Option<reqwest::Body>,
) -> RequestBuilder {
let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT
@@ -55,21 +82,51 @@ impl HttpClient {
// Build the request
let mut request_builder = client.request(method.clone(), url);
if let Some(h) = headers {
let mut req_headers = reqwest::header::HeaderMap::new();
for (key, value) in h.into_iter() {
let _ = req_headers.insert(
HeaderName::from_bytes(key.as_bytes()).unwrap(),
reqwest::header::HeaderValue::from_str(&value).unwrap(),
);
match (
HeaderName::from_bytes(key.as_bytes()),
HeaderValue::from_str(value.trim()),
) {
(Ok(name), Ok(val)) => {
req_headers.insert(name, val);
}
(Err(e), _) => {
eprintln!("Invalid header name: {:?}, error: {}", key, e);
}
(_, Err(e)) => {
eprintln!(
"Invalid header value for {}: {:?}, error: {}",
key, value, e
);
}
}
}
request_builder = request_builder.headers(req_headers);
}
if let Some(query) = query_params {
// Convert only supported value types into strings
let query: HashMap<String, String> = query
.into_iter()
.filter_map(|(k, v)| {
match v {
JsonValue::String(s) => Some((k, s)),
JsonValue::Number(n) => Some((k, n.to_string())),
JsonValue::Bool(b) => Some((k, b.to_string())),
_ => {
dbg!(
"Unsupported query parameter type. Only strings, numbers, and booleans are supported.",k,v,
);
None
} // skip arrays, objects, nulls
}
})
.collect();
request_builder = request_builder.query(&query);
}
// Add body if present
if let Some(b) = body {
request_builder = request_builder.body(b);
@@ -83,7 +140,7 @@ impl HttpClient {
method: Method,
path: &str,
custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>,
query_params: Option<HashMap<String, JsonValue>>,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
// Fetch the server using the server_id
@@ -93,26 +150,27 @@ impl HttpClient {
let url = HttpClient::join_url(&s.endpoint, path);
// Retrieve the token for the server (token is optional)
let token = get_server_token(server_id).map(|t| t.access_token.clone());
let token = get_server_token(server_id)
.await?
.map(|t| t.access_token.clone());
let mut headers = if let Some(custom_headers) = custom_headers {
custom_headers
} else {
let mut headers = HashMap::new();
let headers = HashMap::new();
headers
};
if let Some(t) = token {
headers.insert(
"X-API-TOKEN".to_string(),
t,
);
headers.insert("X-API-TOKEN".to_string(), t);
}
// dbg!(&server_id);
// dbg!(&url);
// dbg!(&headers);
log::debug!(
"Sending request to server: {}, url: {}, headers: {:?}",
&server_id,
&url,
&headers
);
Self::send_raw_request(method, &url, query_params, Some(headers), body).await
} else {
@@ -121,7 +179,10 @@ impl HttpClient {
}
// Convenience method for GET requests (as it's the most common)
pub async fn get(server_id: &str, path: &str, query_params: Option<HashMap<String, Value>>, // Add query parameters
pub async fn get(
server_id: &str,
path: &str,
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::GET, path, None, query_params, None).await
}
@@ -130,7 +191,7 @@ impl HttpClient {
pub async fn post(
server_id: &str,
path: &str,
query_params: Option<HashMap<String, Value>>, // Add query parameters
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::POST, path, None, query_params, body).await
@@ -140,27 +201,56 @@ impl HttpClient {
server_id: &str,
path: &str,
custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::POST, path, custom_headers, query_params, body).await
HttpClient::send_request(
server_id,
Method::POST,
path,
custom_headers,
query_params,
body,
)
.await
}
// Convenience method for PUT requests
#[allow(dead_code)]
pub async fn put(
server_id: &str,
path: &str,
custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::PUT, path, custom_headers, query_params, body).await
HttpClient::send_request(
server_id,
Method::PUT,
path,
custom_headers,
query_params,
body,
)
.await
}
// Convenience method for DELETE requests
pub async fn delete(server_id: &str, path: &str, custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters
#[allow(dead_code)]
pub async fn delete(
server_id: &str,
path: &str,
custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::DELETE, path, custom_headers, query_params, None).await
HttpClient::send_request(
server_id,
Method::DELETE,
path,
custom_headers,
query_params,
None,
)
.await
}
}

View File

@@ -1,10 +1,13 @@
//! This file contains Rust APIs related to Coco Server management.
pub mod attachment;
pub mod auth;
pub mod servers;
pub mod connector;
pub mod datasource;
pub mod http_client;
pub mod profile;
pub mod search;
pub mod servers;
pub mod system_settings;
pub mod transcription;
pub mod websocket;

View File

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

View File

@@ -1,17 +1,18 @@
use crate::common::document::Document;
use crate::common::search::{
parse_search_response, QueryHits, QueryResponse, QuerySource, SearchQuery,
};
use crate::common::error::SearchError;
use crate::common::http::get_response_body_text;
use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery, SearchResponse};
use crate::common::server::Server;
use crate::common::traits::{SearchError, SearchSource};
use crate::common::traits::SearchSource;
use crate::server::http_client::HttpClient;
use crate::server::servers::get_server_token;
use async_trait::async_trait;
// use futures::stream::StreamExt;
use ordered_float::OrderedFloat;
use reqwest::{Client, Method, RequestBuilder};
use std::collections::HashMap;
use tauri_plugin_store::JsonValue;
// use std::hash::Hash;
#[allow(dead_code)]
pub(crate) struct DocumentsSizedCollector {
size: u64,
/// Documents and scores
@@ -20,6 +21,7 @@ pub(crate) struct DocumentsSizedCollector {
docs: Vec<(String, Document, OrderedFloat<f64>)>,
}
#[allow(dead_code)]
impl DocumentsSizedCollector {
pub(crate) fn new(size: u64) -> Self {
// there will be size + 1 documents in docs at max
@@ -43,7 +45,7 @@ impl DocumentsSizedCollector {
}
}
fn documents(self) -> impl ExactSizeIterator<Item = Document> {
fn documents(self) -> impl ExactSizeIterator<Item=Document> {
self.docs.into_iter().map(|(_, doc, _)| doc)
}
@@ -71,36 +73,11 @@ const COCO_SERVERS: &str = "coco-servers";
pub struct CocoSearchSource {
server: Server,
client: Client,
}
impl CocoSearchSource {
pub fn new(server: Server, client: Client) -> Self {
CocoSearchSource { server, client }
}
fn build_request_from_query(&self, query: &SearchQuery) -> RequestBuilder {
self.build_request(query.from, query.size, &query.query_strings)
}
fn build_request(
&self,
from: u64,
size: u64,
query_strings: &HashMap<String, String>,
) -> RequestBuilder {
let url = HttpClient::join_url(&self.server.endpoint, "/query/_search");
let mut request_builder = self.client.request(Method::GET, url);
if !self.server.public {
if let Some(token) = get_server_token(&self.server.id).map(|t| t.access_token) {
request_builder = request_builder.header("X-API-TOKEN", token);
}
}
request_builder
.query(&[("from", &from.to_string()), ("size", &size.to_string())])
.query(query_strings)
pub fn new(server: Server) -> Self {
CocoSearchSource { server }
}
}
@@ -114,58 +91,47 @@ impl SearchSource for CocoSearchSource {
}
}
// Directly return Result<QueryResponse, SearchError> instead of Future
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
let _server_id = self.server.id.clone();
let _server_name = self.server.name.clone();
let request_builder = self.build_request_from_query(&query);
let url = "/query/_search";
// Send the HTTP request asynchronously
let response = request_builder.send().await;
match response {
Ok(response) => {
let status_code = response.status().as_u16();
if status_code >= 200 && status_code < 400 {
// Parse the response only if the status code is successful
match parse_search_response(response).await {
Ok(response) => {
let total_hits = response.hits.total.value as usize;
let hits: Vec<(Document, f64)> = response
.hits
.hits
.into_iter()
.map(|hit| {
// Handling Option<f64> in hit._score by defaulting to 0.0 if None
(hit._source, hit._score.unwrap_or(0.0)) // Use 0.0 if _score is None
})
.collect();
// Return the QueryResponse with hits and total hits
Ok(QueryResponse {
source: self.get_type(),
hits,
total_hits,
})
}
Err(err) => {
// Parse error when response parsing fails
Err(SearchError::ParseError(err.to_string()))
}
}
} else {
// Handle unsuccessful HTTP status codes (e.g., 4xx, 5xx)
Err(SearchError::HttpError(format!(
"Request failed with status code: {}",
status_code
)))
}
}
Err(err) => {
// Handle error from the request itself
Err(SearchError::HttpError(err.to_string()))
}
let mut query_args: HashMap<String, JsonValue> = HashMap::new();
query_args.insert("from".into(), JsonValue::Number(query.from.into()));
query_args.insert("size".into(), JsonValue::Number(query.size.into()));
for (key, value) in query.query_strings {
query_args.insert(key, JsonValue::String(value));
}
let response = HttpClient::get(
&self.server.id,
&url,
Some(query_args),
)
.await
.map_err(|e| SearchError::HttpError(format!("Error to send search request: {}", e)))?;
// Use the helper function to parse the response body
let response_body = get_response_body_text(response)
.await
.map_err(|e| SearchError::ParseError(format!("Failed to read response body: {}", e)))?;
// Parse the search response from the body text
let parsed: SearchResponse<Document> = serde_json::from_str(&response_body)
.map_err(|e| SearchError::ParseError(format!("Failed to parse search response: {}", e)))?;
// Process the parsed response
let total_hits = parsed.hits.total.value as usize;
let hits: Vec<(Document, f64)> = parsed
.hits
.hits
.into_iter()
.map(|hit| (hit._source, hit._score.unwrap_or(0.0))) // Default _score to 0.0 if None
.collect();
// Return the final result
Ok(QueryResponse {
source: self.get_type(),
hits,
total_hits,
})
}
}

View File

@@ -1,12 +1,13 @@
use crate::common::http::get_response_body_text;
use crate::common::register::SearchSourceRegistry;
use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version};
use crate::server::connector::fetch_connectors_by_server;
use crate::server::datasource::get_datasources_by_server;
use crate::server::datasource::datasource_search;
use crate::server::http_client::HttpClient;
use crate::server::search::CocoSearchSource;
use crate::COCO_TAURI_STORE;
use lazy_static::lazy_static;
use reqwest::{Client, Method, StatusCode};
use reqwest::Method;
use serde_json::from_value;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
@@ -24,6 +25,7 @@ lazy_static! {
Arc::new(RwLock::new(HashMap::new()));
}
#[allow(dead_code)]
fn check_server_exists(id: &str) -> bool {
let cache = SERVER_CACHE.read().unwrap(); // Acquire read lock
cache.contains_key(id)
@@ -35,9 +37,10 @@ pub fn get_server_by_id(id: &str) -> Option<Server> {
}
#[tauri::command]
pub fn get_server_token(id: &str) -> Option<ServerAccessToken> {
let cache = SERVER_TOKEN.read().unwrap(); // Acquire read lock
cache.get(id).cloned()
pub async fn get_server_token(id: &str) -> Result<Option<ServerAccessToken>, String> {
let cache = SERVER_TOKEN.read().map_err(|err| err.to_string())?;
Ok(cache.get(id).cloned())
}
pub fn save_access_token(server_id: String, token: ServerAccessToken) -> bool {
@@ -132,6 +135,7 @@ fn get_default_server() -> Server {
version: Version {
number: "1.0.0_SNAPSHOT".to_string(),
},
minimal_client_version: None,
updated: "2025-01-24T12:12:17.326286927+08:00".to_string(),
public: false,
available: true,
@@ -259,7 +263,6 @@ pub async fn load_or_insert_default_server<R: Runtime>(
pub async fn list_coco_servers<R: Runtime>(
_app_handle: AppHandle<R>,
) -> Result<Vec<Server>, String> {
//hard fresh all server's info, in order to get the actual health
refresh_all_coco_server_info(_app_handle.clone()).await;
@@ -267,6 +270,7 @@ pub async fn list_coco_servers<R: Runtime>(
Ok(servers)
}
#[allow(dead_code)]
pub fn get_servers_as_hashmap() -> HashMap<String, Server> {
let cache = SERVER_CACHE.read().unwrap();
cache.clone()
@@ -282,9 +286,7 @@ pub const COCO_SERVERS: &str = "coco_servers";
const COCO_SERVER_TOKENS: &str = "coco_server_tokens";
pub async fn refresh_all_coco_server_info<R: Runtime>(
app_handle: AppHandle<R>,
) {
pub async fn refresh_all_coco_server_info<R: Runtime>(app_handle: AppHandle<R>) {
let servers = get_all_servers();
for server in servers {
let _ = refresh_coco_server_info(app_handle.clone(), server.id.clone()).await;
@@ -297,62 +299,57 @@ pub async fn refresh_coco_server_info<R: Runtime>(
id: String,
) -> Result<Server, String> {
// Retrieve the server from the cache
let server = {
let cached_server = {
let cache = SERVER_CACHE.read().unwrap();
cache.get(&id).cloned()
};
if let Some(server) = server {
let is_enabled = server.enabled;
let is_builtin = server.builtin;
let profile = server.profile;
let server = match cached_server {
Some(server) => server,
None => return Err("Server not found.".into()),
};
// Use the HttpClient to send the request
let response = HttpClient::get(&id, "/provider/_info", None) // Assuming "/provider-info" is the endpoint
.await
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
// Preserve important local state
let is_enabled = server.enabled;
let is_builtin = server.builtin;
let profile = server.profile;
if response.status() == StatusCode::OK {
if let Some(content_length) = response.content_length() {
if content_length > 0 {
let new_coco_server: Result<Server, _> = response.json().await;
match new_coco_server {
Ok(mut server) => {
server.id = id.clone();
server.builtin = is_builtin;
server.enabled = is_enabled;
server.available = true;
server.profile = profile;
trim_endpoint_last_forward_slash(&mut server);
save_server(&server);
persist_servers(&app_handle)
.await
.expect("Failed to persist coco servers.");
// Send request to fetch updated server info
let response = HttpClient::get(&id, "/provider/_info", None)
.await
.map_err(|e| format!("Failed to contact the server: {}", e))?;
//refresh connectors and datasources
let _ = fetch_connectors_by_server(&id).await;
let _ = get_datasources_by_server(&id).await;
Ok(server)
}
Err(e) => Err(format!("Failed to deserialize the response: {:?}", e)),
}
} else {
Err("Received empty response body.".to_string())
}
} else {
mark_server_as_offline(id.as_str()).await;
Err("Could not determine the content length.".to_string())
}
} else {
mark_server_as_offline(id.as_str()).await;
Err(format!("Request failed with status: {}", response.status()))
}
} else {
Err("Server not found.".to_string())
if !response.status().is_success() {
mark_server_as_offline(&id).await;
return Err(format!("Request failed with status: {}", response.status()));
}
// Get body text via helper
let body = get_response_body_text(response).await?;
// Deserialize server
let mut updated_server: Server = serde_json::from_str(&body)
.map_err(|e| format!("Failed to deserialize the response: {}", e))?;
// Restore local state
updated_server.id = id.clone();
updated_server.builtin = is_builtin;
updated_server.enabled = is_enabled;
updated_server.available = true;
updated_server.profile = profile;
trim_endpoint_last_forward_slash(&mut updated_server);
// Save and persist
save_server(&updated_server);
persist_servers(&app_handle)
.await
.map_err(|e| format!("Failed to persist servers: {}", e))?;
// Refresh connectors and datasources (best effort)
let _ = fetch_connectors_by_server(&id).await;
let _ = datasource_search(&id, None).await;
Ok(updated_server)
}
#[tauri::command]
@@ -362,12 +359,10 @@ pub async fn add_coco_server<R: Runtime>(
) -> Result<Server, String> {
load_or_insert_default_server(&app_handle)
.await
.expect("Failed to load default servers");
.map_err(|e| format!("Failed to load default servers: {}", e))?;
// Remove the trailing '/' from the endpoint to ensure correct URL construction
let endpoint = endpoint.trim_end_matches('/');
// Check if the server with this endpoint already exists
if check_endpoint_exists(endpoint) {
dbg!(format!(
"This Coco server has already been registered: {:?}",
@@ -376,60 +371,37 @@ pub async fn add_coco_server<R: Runtime>(
return Err("This Coco server has already been registered.".into());
}
let url = provider_info_url(&endpoint);
// Use the HttpClient to fetch provider information
let url = provider_info_url(endpoint);
let response = HttpClient::send_raw_request(Method::GET, url.as_str(), None, None, None)
.await
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
dbg!(format!("Get provider info response: {:?}", &response));
// Check if the response status is OK (200)
if response.status() == StatusCode::OK {
if let Some(content_length) = response.content_length() {
if content_length > 0 {
let new_coco_server: Result<Server, _> = response.json().await;
let body = get_response_body_text(response).await?;
match new_coco_server {
Ok(mut server) => {
// Perform necessary checks and adjustments on the server data
trim_endpoint_last_forward_slash(&mut server);
let mut server: Server = serde_json::from_str(&body)
.map_err(|e| format!("Failed to deserialize the response: {}", e))?;
if server.id.is_empty() {
server.id = pizza_common::utils::uuid::Uuid::new().to_string();
}
trim_endpoint_last_forward_slash(&mut server);
if server.name.is_empty() {
server.name = "Coco Cloud".to_string();
}
// Save the new server to the cache
save_server(&server);
let registry = app_handle.state::<SearchSourceRegistry>();
let source = CocoSearchSource::new(server.clone(), Client::new());
registry.register_source(source).await;
// Persist the servers to the store
persist_servers(&app_handle)
.await
.expect("Failed to persist Coco servers.");
dbg!(format!("Successfully registered server: {:?}", &endpoint));
Ok(server)
}
Err(e) => Err(format!("Failed to deserialize the response: {}", e)),
}
} else {
Err("Received empty response body.".to_string())
}
} else {
Err("Could not determine the content length.".to_string())
}
} else {
Err(format!("Request failed with status: {}", response.status()))
if server.id.is_empty() {
server.id = pizza_common::utils::uuid::Uuid::new().to_string();
}
if server.name.is_empty() {
server.name = "Coco Server".to_string();
}
save_server(&server);
try_register_server_to_search_source(app_handle.clone(), &server).await;
persist_servers(&app_handle)
.await
.map_err(|e| format!("Failed to persist Coco servers: {}", e))?;
dbg!(format!("Successfully registered server: {:?}", &endpoint));
Ok(server)
}
#[tauri::command]
@@ -459,9 +431,8 @@ pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
server.enabled = true;
save_server(&server);
let registry = app_handle.state::<SearchSourceRegistry>();
let source = CocoSearchSource::new(server.clone(), Client::new());
registry.register_source(source).await;
// Register the server to the search source
try_register_server_to_search_source(app_handle.clone(), &server).await;
persist_servers(&app_handle)
.await
@@ -470,6 +441,16 @@ pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
Ok(())
}
pub async fn try_register_server_to_search_source(
app_handle: AppHandle<impl Runtime>,
server: &Server,
) {
if server.enabled {
let registry = app_handle.state::<SearchSourceRegistry>();
let source = CocoSearchSource::new(server.clone());
registry.register_source(source).await;
}
}
pub async fn mark_server_as_offline(id: &str) {
// println!("server_is_offline: {}", id);
@@ -508,7 +489,7 @@ pub async fn logout_coco_server<R: Runtime>(
dbg!("Attempting to log out server by id:", &id);
// Check if server token exists
if let Some(_token) = get_server_token(id.as_str()) {
if let Some(_token) = get_server_token(id.as_str()).await? {
dbg!("Found server token for id:", &id);
// Remove the server token from cache
@@ -584,6 +565,7 @@ fn test_trim_endpoint_last_forward_slash() {
version: Version {
number: "".to_string(),
},
minimal_client_version: None,
updated: "".to_string(),
public: false,
available: false,

View File

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

View File

@@ -0,0 +1,43 @@
use crate::common::http::get_response_body_text;
use crate::server::http_client::HttpClient;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use tauri::command;
#[derive(Debug, Serialize, Deserialize)]
pub struct TranscriptionResponse {
pub text: String,
}
#[command]
pub async fn transcription(
server_id: String,
audio_type: String,
audio_content: String,
) -> Result<TranscriptionResponse, String> {
let mut query_params = HashMap::new();
query_params.insert("type".to_string(), JsonValue::String(audio_type));
query_params.insert("content".to_string(), JsonValue::String(audio_content));
// Send the HTTP POST request
let response = HttpClient::post(
&server_id,
"/services/audio/transcription",
Some(query_params),
None,
)
.await
.map_err(|e| format!("Error sending transcription request: {}", e))?;
// Use get_response_body_text to extract the response body as text
let response_body = get_response_body_text(response)
.await
.map_err(|e| format!("Failed to read response body: {}", e))?;
// Deserialize the response body into TranscriptionResponse
let transcription_response: TranscriptionResponse = serde_json::from_str(&response_body)
.map_err(|e| format!("Failed to parse transcription response: {}", e))?;
Ok(transcription_response)
}

View File

@@ -1,87 +1,66 @@
use crate::server::servers::{get_server_by_id, get_server_token};
use futures_util::{SinkExt, StreamExt};
use http::{HeaderMap, HeaderName, HeaderValue};
use futures::StreamExt;
use std::collections::HashMap;
use std::sync::Arc;
use tauri::Emitter;
use tauri::{AppHandle, Emitter, Runtime};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Error;
use tokio_tungstenite::tungstenite::Error as WsError;
use tokio_tungstenite::{
connect_async, tungstenite::protocol::Message, MaybeTlsStream, WebSocketStream,
};
use tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::{connect_async_tls_with_config, Connector};
#[derive(Default)]
pub struct WebSocketManager {
ws_connection: Arc<Mutex<Option<WebSocketStream<MaybeTlsStream<TcpStream>>>>>,
cancel_tx: Arc<Mutex<Option<mpsc::Sender<()>>>>,
connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>,
}
struct WebSocketInstance {
ws_connection: Mutex<WebSocketStream<MaybeTlsStream<TcpStream>>>, // No need to lock the entire map
cancel_tx: mpsc::Sender<()>,
}
// Function to convert the HTTP endpoint to WebSocket endpoint
fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?;
// Determine WebSocket protocol based on the scheme
let ws_protocol = if url.scheme() == "https" {
"wss://"
} else {
"ws://"
};
// Extract host and port (if present)
let host = url.host_str().ok_or_else(|| "No host found in URL")?;
let host = url.host_str().ok_or("No host found in URL")?;
let port = url
.port_or_known_default()
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
// Build WebSocket URL, include the port if not the default
let ws_endpoint = if port == 80 || port == 443 {
format!("{}{}{}", ws_protocol, host, "/ws")
} else {
format!("{}{}:{}/ws", ws_protocol, host, port)
};
Ok(ws_endpoint)
}
// Function to build a HeaderMap from a vector of key-value pairs
fn build_header_map(headers: Vec<(String, String)>) -> Result<HeaderMap, String> {
let mut header_map = HeaderMap::new();
for (key, value) in headers {
let header_name = HeaderName::from_bytes(key.as_bytes())
.map_err(|e| format!("Invalid header name: {}", e))?;
let header_value =
HeaderValue::from_str(&value).map_err(|e| format!("Invalid header value: {}", e))?;
header_map.insert(header_name, header_value);
}
Ok(header_map)
}
#[tauri::command]
pub async fn connect_to_server(
pub async fn connect_to_server<R: Runtime>(
tauri_app_handle: AppHandle<R>,
id: String,
client_id: String,
state: tauri::State<'_, WebSocketManager>,
app_handle: tauri::AppHandle,
app_handle: AppHandle,
) -> Result<(), String> {
// Disconnect any existing connection first
disconnect(state.clone()).await?;
let connections_clone = state.connections.clone();
// Retrieve server details
let server =
get_server_by_id(id.as_str()).ok_or_else(|| format!("Server with ID {} not found", id))?;
let endpoint = convert_to_websocket(server.endpoint.as_str())?;
// Disconnect old connection first
disconnect(client_id.clone(), state.clone()).await.ok();
// Retrieve the token for the server (token is optional)
let token = get_server_token(id.as_str()).map(|t| t.access_token.clone());
let server = get_server_by_id(&id).ok_or(format!("Server with ID {} not found", id))?;
let endpoint = convert_to_websocket(&server.endpoint)?;
let token = get_server_token(&id).await?.map(|t| t.access_token.clone());
// Create the WebSocket request
let mut request =
tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint)
.map_err(|e| format!("Failed to create WebSocket request: {}", e))?;
// Add necessary headers
request
.headers_mut()
.insert("Connection", "Upgrade".parse().unwrap());
@@ -95,88 +74,93 @@ pub async fn connect_to_server(
.headers_mut()
.insert("Sec-WebSocket-Key", generate_key().parse().unwrap());
// If a token exists, add it to the headers
if let Some(token) = token {
request
.headers_mut()
.insert("X-API-TOKEN", token.parse().unwrap());
}
// Establish the WebSocket connection
// dbg!(&request);
let (mut ws_remote, _) = connect_async(request).await.map_err(|e| match e {
Error::ConnectionClosed => "WebSocket connection was closed".to_string(),
Error::Protocol(protocol_error) => format!("Protocol error: {}", protocol_error),
Error::Utf8 => "UTF-8 error in WebSocket data".to_string(),
_ => format!("Unknown error: {:?}", e),
})?;
let allow_self_signature =
crate::settings::get_allow_self_signature(tauri_app_handle.clone()).await;
let tls_connector = tokio_native_tls::native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(allow_self_signature)
.build()
.map_err(|e| format!("TLS build error: {:?}", e))?;
let connector = Connector::NativeTls(tls_connector.into());
let (ws_stream, _) = connect_async_tls_with_config(
request,
None, // WebSocketConfig
true, // disable_nagle
Some(connector), // Connector
)
.await
.map_err(|e| format!("WebSocket TLS error: {:?}", e))?;
// Create cancellation channel
let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
// Store connection and cancellation sender
*state.ws_connection.lock().await = Some(ws_remote);
*state.cancel_tx.lock().await = Some(cancel_tx);
// Spawn listener task with cancellation
let instance = Arc::new(WebSocketInstance {
ws_connection: Mutex::new(ws_stream),
cancel_tx,
});
// Insert connection into the map (lock is held briefly)
{
let mut connections = connections_clone.lock().await;
connections.insert(client_id.clone(), instance.clone());
}
// Spawn WebSocket handler in a separate task
let app_handle_clone = app_handle.clone();
let connection_clone = state.ws_connection.clone();
let client_id_clone = client_id.clone();
tokio::spawn(async move {
let mut connection = connection_clone.lock().await;
if let Some(ws) = connection.as_mut() {
loop {
tokio::select! {
msg = ws.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
//println!("Received message: {}", text);
let _ = app_handle_clone.emit("ws-message", text);
},
Some(Err(WsError::ConnectionClosed)) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("WebSocket connection closed by the server.");
break;
},
Some(Err(WsError::Protocol(e))) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("Protocol error: {}", e);
break;
},
Some(Err(WsError::Utf8)) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("Received invalid UTF-8 data.");
break;
},
Some(Err(_)) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("WebSocket error encountered.");
break;
},
_ => continue,
let ws = &mut *instance.ws_connection.lock().await;
loop {
tokio::select! {
msg = ws.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
let _ = app_handle_clone.emit(&format!("ws-message-{}", client_id_clone), text);
},
Some(Err(_)) | None => {
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
break;
}
_ => {}
}
_ = cancel_rx.recv() => {
let _ = app_handle_clone.emit("ws-error", id);
dbg!("Cancelling WebSocket connection");
break;
}
}
_ = cancel_rx.recv() => {
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
break;
}
}
}
// Remove connection after it closes
let mut connections = connections_clone.lock().await;
connections.remove(&client_id_clone);
});
Ok(())
}
#[tauri::command]
pub async fn disconnect(state: tauri::State<'_, WebSocketManager>) -> Result<(), String> {
// Send cancellation signal
if let Some(cancel_tx) = state.cancel_tx.lock().await.take() {
let _ = cancel_tx.send(()).await;
}
pub async fn disconnect(
client_id: String,
state: tauri::State<'_, WebSocketManager>,
) -> Result<(), String> {
let instance = {
let mut connections = state.connections.lock().await;
connections.remove(&client_id)
};
// Close connection
let mut connection = state.ws_connection.lock().await;
if let Some(mut ws) = connection.take() {
if let Some(instance) = instance {
let _ = instance.cancel_tx.send(()).await;
// Close WebSocket (lock only the connection, not the whole map)
let mut ws = instance.ws_connection.lock().await;
let _ = ws.close(None).await;
}

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

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
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) {}

View File

@@ -1,13 +1,7 @@
use crate::{move_window_to_active_monitor, COCO_TAURI_STORE};
use tauri::App;
use tauri::AppHandle;
use tauri::Manager;
use tauri::Runtime;
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::Shortcut;
use tauri_plugin_global_shortcut::ShortcutState;
use tauri_plugin_store::JsonValue;
use tauri_plugin_store::StoreExt;
use crate::{hide_coco, show_coco, COCO_TAURI_STORE};
use tauri::{async_runtime, App, AppHandle, Manager, Runtime};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use tauri_plugin_store::{JsonValue, StoreExt};
/// Tauri's store is a key-value database, we use it to store our registered
/// global shortcut.
@@ -54,14 +48,14 @@ pub fn enable_shortcut(app: &App) {
/// Get the stored shortcut as a string, same as [`_get_shortcut()`], except that
/// this is a `tauri::command` interface.
#[tauri::command]
pub fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
pub async fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
let shortcut = _get_shortcut(&app);
Ok(shortcut)
}
/// Get the current shortcut and unregister it on the tauri side.
#[tauri::command]
pub fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
pub async fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
let shortcut_str = _get_shortcut(&app);
let shortcut = shortcut_str
.parse::<Shortcut>()
@@ -74,7 +68,7 @@ pub fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
/// Change the global shortcut to `key`.
#[tauri::command]
pub fn change_shortcut<R: Runtime>(
pub async fn change_shortcut<R: Runtime>(
app: AppHandle<R>,
_window: tauri::Window<R>,
key: String,
@@ -105,16 +99,15 @@ fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
dbg!("shortcut pressed");
let main_window = app.get_window(MAIN_WINDOW_LABEL).unwrap();
if let ShortcutState::Pressed = event.state() {
let app_handle = app.clone();
if main_window.is_visible().unwrap() {
dbg!("hiding window");
main_window.hide().unwrap();
async_runtime::spawn(async move {
hide_coco(app_handle).await;
});
} else {
dbg!("showing window");
move_window_to_active_monitor(&main_window);
main_window.set_visible_on_all_workspaces(true).unwrap();
main_window.set_always_on_top(true).unwrap();
main_window.set_focus().unwrap();
main_window.show().unwrap();
async_runtime::spawn(async move {
show_coco(app_handle).await;
});
}
}
}
@@ -135,15 +128,16 @@ fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
if scut == &shortcut {
let window = app.get_window(MAIN_WINDOW_LABEL).unwrap();
if let ShortcutState::Pressed = event.state() {
let app_handle = app.clone();
if window.is_visible().unwrap() {
window.hide().unwrap();
async_runtime::spawn(async move {
hide_coco(app_handle).await;
});
} else {
// dbg!("showing window");
move_window_to_active_monitor(&window);
window.set_visible_on_all_workspaces(true).unwrap();
window.set_always_on_top(true).unwrap();
window.set_focus().unwrap();
window.show().unwrap();
async_runtime::spawn(async move {
show_coco(app_handle).await;
});
}
}
}

View File

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

View File

@@ -31,8 +31,10 @@
"visible": false,
"windowEffects": {
"effects": [],
"radius": 12
}
"radius": 6
},
"visibleOnAllWorkspaces": true,
"alwaysOnTop": true
},
{
"label": "settings",
@@ -113,7 +115,7 @@
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlDRjNDRUU0NTdBMzdCRTMKUldUamU2Tlg1TTd6bkUwZWM0d2Zjdk0wdXJmendWVlpMMmhKN25EcmprYmIydnJ3dmFUME9QYXkK",
"endpoints": [
"https://api.coco.rs/update/{{target}}/{{arch}}/{{current_version}}"
"https://release.infinilabs.com/coco/app/.latest.json?target={{target}}&arch={{arch}}&current_version={{current_version}}"
]
},
"websocket": {},

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

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

View File

@@ -1,9 +1,8 @@
import { fetch } from "@tauri-apps/plugin-http";
import { invoke } from "@tauri-apps/api/core";
import { clientEnv } from "@/utils/env";
import { useLogStore } from "@/stores/logStore";
import { get_server_token } from "@/commands";
interface FetchRequestConfig {
url: string;
method?: "GET" | "POST" | "PUT" | "DELETE";
@@ -63,8 +62,8 @@ export const tauriFetch = async <T = any>({
}
const server_id = connectStore.state?.currentService?.id || "default_coco_server"
const res: any = await invoke("get_server_token", {id: server_id});
const res: any = await get_server_token(server_id);
headers["X-API-TOKEN"] = headers["X-API-TOKEN"] || res?.access_token || undefined;
// debug API

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

2
src/commands/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './servers';
export * from './system';

302
src/commands/servers.ts Normal file
View File

@@ -0,0 +1,302 @@
import { invoke } from "@tauri-apps/api/core";
import {
ServerTokenResponse,
Server,
Connector,
DataSource,
GetResponse,
UploadAttachmentPayload,
UploadAttachmentResponse,
GetAttachmentPayload,
GetAttachmentResponse,
DeleteAttachmentPayload,
TranscriptionPayload,
TranscriptionResponse,
MultiSourceQueryResponse,
} from "@/types/commands";
import { useAppStore } from "@/stores/appStore";
async function invokeWithErrorHandler<T>(
command: string,
args?: Record<string, any>
): Promise<T> {
const addError = useAppStore.getState().addError;
try {
const result = await invoke<T>(command, args);
// console.log(command, result);
if (result && typeof result === "object" && "failed" in result) {
const failedResult = result as any;
if (failedResult.failed?.length > 0 && failedResult?.hits?.length == 0) {
failedResult.failed.forEach((error: any) => {
addError(error.error, 'error');
// console.error(error.error);
});
}
}
if (typeof result === "string") {
const res = JSON.parse(result);
if (typeof res === "string") {
throw new Error(result);
}
}
return result;
} catch (error: any) {
const errorMessage = error || "Command execution failed";
addError(command + ":" + errorMessage, "error");
throw error;
}
}
export function get_server_token(id: string): Promise<ServerTokenResponse> {
return invokeWithErrorHandler(`get_server_token`, { id });
}
export function list_coco_servers(): Promise<Server[]> {
return invokeWithErrorHandler(`list_coco_servers`);
}
export function add_coco_server(endpoint: string): Promise<Server> {
return invokeWithErrorHandler(`add_coco_server`, { endpoint });
}
export function enable_server(id: string): Promise<void> {
return invokeWithErrorHandler(`enable_server`, { id });
}
export function disable_server(id: string): Promise<void> {
return invokeWithErrorHandler(`disable_server`, { id });
}
export function remove_coco_server(id: string): Promise<void> {
return invokeWithErrorHandler(`remove_coco_server`, { id });
}
export function logout_coco_server(id: string): Promise<void> {
return invokeWithErrorHandler(`logout_coco_server`, { id });
}
export function refresh_coco_server_info(id: string): Promise<Server> {
return invokeWithErrorHandler(`refresh_coco_server_info`, { id });
}
export function handle_sso_callback({
serverId,
requestId,
code,
}: {
serverId: string;
requestId: string;
code: string;
}): Promise<void> {
return invokeWithErrorHandler(`handle_sso_callback`, {
serverId,
requestId,
code,
});
}
export function get_connectors_by_server(id: string): Promise<Connector[]> {
return invokeWithErrorHandler(`get_connectors_by_server`, { id });
}
export function datasource_search(id: string): Promise<DataSource[]> {
return invokeWithErrorHandler(`datasource_search`, { id });
}
export function mcp_server_search(id: string): Promise<DataSource[]> {
return invokeWithErrorHandler(`mcp_server_search`, { id });
}
export function connect_to_server(id: string, clientId: string): Promise<void> {
return invokeWithErrorHandler(`connect_to_server`, { id, clientId });
}
export function disconnect(clientId: string): Promise<void> {
return invokeWithErrorHandler(`disconnect`, { clientId });
}
export function chat_history({
serverId,
from = 0,
size = 20,
query = "",
}: {
serverId: string;
from?: number;
size?: number;
query?: string;
}): Promise<string> {
return invokeWithErrorHandler(`chat_history`, {
serverId,
from,
size,
query,
});
}
export function session_chat_history({
serverId,
sessionId,
from = 0,
size = 20,
}: {
serverId: string;
sessionId: string;
from?: number;
size?: number;
}): Promise<string> {
return invokeWithErrorHandler(`session_chat_history`, {
serverId,
sessionId,
from,
size,
});
}
export function close_session_chat({
serverId,
sessionId,
}: {
serverId: string;
sessionId: string;
}): Promise<string> {
return invokeWithErrorHandler(`close_session_chat`, {
serverId,
sessionId,
});
}
export function open_session_chat({
serverId,
sessionId,
}: {
serverId: string;
sessionId: string;
}): Promise<string> {
return invokeWithErrorHandler(`open_session_chat`, {
serverId,
sessionId,
});
}
export function cancel_session_chat({
serverId,
sessionId,
}: {
serverId: string;
sessionId: string;
}): Promise<string> {
return invokeWithErrorHandler(`cancel_session_chat`, {
serverId,
sessionId,
});
}
export function new_chat({
serverId,
websocketId,
message,
queryParams,
}: {
serverId: string;
websocketId?: string;
message: string;
queryParams?: Record<string, any>;
}): Promise<GetResponse> {
return invokeWithErrorHandler(`new_chat`, {
serverId,
websocketId,
message,
queryParams,
});
}
export function send_message({
serverId,
websocketId,
sessionId,
message,
queryParams,
}: {
serverId: string;
websocketId?: string;
sessionId: string;
message: string;
queryParams?: Record<string, any>;
}): Promise<string> {
return invokeWithErrorHandler(`send_message`, {
serverId,
websocketId,
sessionId,
message,
queryParams,
});
}
export const delete_session_chat = (serverId: string, sessionId: string) => {
return invokeWithErrorHandler<boolean>(`delete_session_chat`, {
serverId,
sessionId,
});
};
export const update_session_chat = (payload: {
serverId: string;
sessionId: string;
title?: string;
context?: {
attachments?: string[];
};
}): Promise<boolean> => {
return invokeWithErrorHandler<boolean>("update_session_chat", payload);
};
export const assistant_search = (payload: {
serverId: string;
}): Promise<boolean> => {
return invokeWithErrorHandler<boolean>("assistant_search", payload);
};
export const upload_attachment = async (payload: UploadAttachmentPayload) => {
const response = await invokeWithErrorHandler<UploadAttachmentResponse>(
"upload_attachment",
{
...payload,
}
);
if (response?.acknowledged) {
return response.attachments;
}
};
export const get_attachment = (payload: GetAttachmentPayload) => {
return invokeWithErrorHandler<GetAttachmentResponse>("get_attachment", {
...payload,
});
};
export const delete_attachment = (payload: DeleteAttachmentPayload) => {
return invokeWithErrorHandler<boolean>("delete_attachment", { ...payload });
};
export const transcription = (payload: TranscriptionPayload) => {
return invokeWithErrorHandler<TranscriptionResponse>("transcription", {
...payload,
});
};
export const query_coco_fusion = (payload: {
from: number;
size: number;
queryStrings: Record<string, string>;
queryTimeout: number;
}) => {
return invokeWithErrorHandler<MultiSourceQueryResponse>("query_coco_fusion", {
...payload,
});
};

29
src/commands/system.ts Normal file
View File

@@ -0,0 +1,29 @@
import { invoke } from '@tauri-apps/api/core';
export function change_autostart(open: boolean): Promise<void> {
return invoke('change_autostart', { open });
}
export function get_current_shortcut(): Promise<string> {
return invoke('get_current_shortcut');
}
export function change_shortcut(key: string): Promise<void> {
return invoke('change_shortcut', { key });
}
export function unregister_shortcut(): Promise<void> {
return invoke('unregister_shortcut');
}
export function hide_coco(): Promise<void> {
return invoke('hide_coco');
}
export function show_coco(): Promise<void> {
return invoke('show_coco');
}
export function show_settings(): Promise<void> {
return invoke('show_settings');
}

View File

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

View File

@@ -4,38 +4,38 @@ import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { useTranslation } from "react-i18next";
import { debounce } from "lodash-es";
import { ChatMessage } from "@/components/ChatMessage";
import type { Chat } from "./types";
import { useChatStore } from "@/stores/chatStore";
import { useWindows } from "@/hooks/useWindows";
import { ChatHeader } from "./ChatHeader";
import { Sidebar } from "@/components/Assistant/Sidebar";
import { useConnectStore } from "@/stores/connectStore";
import { useSearchStore } from "@/stores/searchStore";
import FileList from "@/components/Search/FileList";
import { Greetings } from "./Greetings";
import ConnectPrompt from "./ConnectPrompt";
import { useWindows } from "@/hooks/useWindows";
import useMessageChunkData from "@/hooks/useMessageChunkData";
import useWebSocket from "@/hooks/useWebSocket";
import { useChatActions } from "@/hooks/useChatActions";
import { useMessageHandler } from "@/hooks/useMessageHandler";
import { ChatSidebar } from "./ChatSidebar";
import { ChatHeader } from "./ChatHeader";
import { ChatContent } from "./ChatContent";
import ConnectPrompt from "./ConnectPrompt";
import type { Chat } from "./types";
import PrevSuggestion from "@/components/ChatMessage/PrevSuggestion";
import { useAppStore } from "@/stores/appStore";
interface ChatAIProps {
isTransitioned: boolean;
isSearchActive?: boolean;
isDeepThinkActive?: boolean;
isMCPActive?: boolean;
activeChatProp?: Chat;
changeInput?: (val: string) => void;
setIsSidebarOpen?: (value: boolean) => void;
isSidebarOpen?: boolean;
clearChatPage?: () => void;
isChatPage?: boolean;
getFileUrl: (path: string) => string;
showChatHistory?: boolean;
assistantIDs?: string[];
}
export interface ChatAIRef {
@@ -49,60 +49,63 @@ const ChatAI = memo(
forwardRef<ChatAIRef, ChatAIProps>(
(
{
isTransitioned,
changeInput,
isSearchActive,
isDeepThinkActive,
isMCPActive,
activeChatProp,
setIsSidebarOpen,
isSidebarOpen = false,
clearChatPage,
isChatPage = false,
getFileUrl,
showChatHistory,
assistantIDs,
},
ref
) => {
if (!isTransitioned) return null;
const { t } = useTranslation();
useImperativeHandle(ref, () => ({
init: init,
cancelChat: cancelChat,
cancelChat: () => cancelChat(activeChat),
reconnect: reconnect,
clearChat: clearChat,
}));
const { createWin } = useWindows();
const { curChatEnd, setCurChatEnd, connected, setConnected } =
useChatStore();
const currentService = useConnectStore((state) => state.currentService);
const visibleStartPage = useConnectStore((state) => {
return state.visibleStartPage;
});
const addError = useAppStore.getState().addError;
const [activeChat, setActiveChat] = useState<Chat>();
const [timedoutShow, setTimedoutShow] = useState(false);
const [IsLogin, setIsLogin] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [isLogin, setIsLogin] = useState(true);
const curIdRef = useRef("");
const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen);
const [chats, setChats] = useState<Chat[]>([]);
const sourceDataIds = useSearchStore((state) => state.sourceDataIds);
const uploadFiles = useChatStore((state) => state.uploadFiles);
useEffect(() => {
activeChatProp && setActiveChat(activeChatProp);
}, [activeChatProp]);
const messageTimeoutRef = useRef<NodeJS.Timeout>();
const [Question, setQuestion] = useState<string>("");
const [websocketSessionId, setWebsocketSessionId] = useState("");
const onWebsocketSessionId = useCallback((sessionId: string) => {
setWebsocketSessionId(sessionId);
}, []);
const {
data: {
query_intent,
tools,
fetch_source,
pick_source,
deep_read,
@@ -115,6 +118,7 @@ const ChatAI = memo(
const [loadingStep, setLoadingStep] = useState<Record<string, boolean>>({
query_intent: false,
tools: false,
fetch_source: false,
pick_source: false,
deep_read: false,
@@ -122,395 +126,142 @@ const ChatAI = memo(
response: false,
});
const dealMsg = useCallback(
(msg: string) => {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
const dealMsgRef = useRef<((msg: string) => void) | null>(null);
if (!msg.includes("PRIVATE")) return;
messageTimeoutRef.current = setTimeout(() => {
if (!curChatEnd) {
console.log("AI response timeout");
setTimedoutShow(true);
cancelChat();
}
}, 60000);
if (msg.includes("assistant finished output")) {
clearTimeout(messageTimeoutRef.current);
console.log("AI finished output");
setCurChatEnd(true);
return;
}
const cleanedData = msg.replace(/^PRIVATE /, "");
try {
const chunkData = JSON.parse(cleanedData);
if (chunkData.reply_to_message !== curIdRef.current) return;
setLoadingStep(() => ({
query_intent: false,
fetch_source: false,
pick_source: false,
deep_read: false,
think: false,
response: false,
[chunkData.chunk_type]: true,
}));
// ['query_intent', 'fetch_source', 'pick_source', 'deep_read', 'think', 'response'];
if (chunkData.chunk_type === "query_intent") {
handlers.deal_query_intent(chunkData);
} else if (chunkData.chunk_type === "fetch_source") {
handlers.deal_fetch_source(chunkData);
} else if (chunkData.chunk_type === "pick_source") {
handlers.deal_pick_source(chunkData);
} else if (chunkData.chunk_type === "deep_read") {
handlers.deal_deep_read(chunkData);
} else if (chunkData.chunk_type === "think") {
handlers.deal_think(chunkData);
} else if (chunkData.chunk_type === "response") {
handlers.deal_response(chunkData);
}
} catch (error) {
console.error("parse error:", error);
}
},
[curChatEnd]
);
const { errorShow, setErrorShow, reconnect } = useWebSocket({
const clientId = isChatPage ? "standalone" : "popup";
const { reconnect, updateDealMsg } = useWebSocket({
clientId,
connected,
setConnected,
currentService,
dealMsg,
dealMsgRef,
onWebsocketSessionId,
});
const updatedChat = useMemo(() => {
if (!activeChat?._id) return null;
return {
...activeChat,
messages: [...(activeChat.messages || [])],
};
}, [activeChat]);
const {
chatClose,
cancelChat,
chatHistory,
createNewChat,
handleSendMessage,
openSessionChat,
getChatHistory,
createChatWindow,
handleSearch,
handleRename,
handleDelete,
} = useChatActions(
currentService?.id,
setActiveChat,
setCurChatEnd,
setTimedoutShow,
clearAllChunkData,
setQuestion,
curIdRef,
setChats,
isSearchActive,
isDeepThinkActive,
isMCPActive,
changeInput,
websocketSessionId,
showChatHistory
);
const simulateAssistantResponse = useCallback(() => {
if (!updatedChat) return;
// console.log("updatedChat:", updatedChat);
setActiveChat(updatedChat);
}, [updatedChat]);
useEffect(() => {
if (curChatEnd) {
simulateAssistantResponse();
}
}, [curChatEnd]);
const [userScrolling, setUserScrolling] = useState(false);
const scrollTimeoutRef = useRef<NodeJS.Timeout>();
const scrollToBottom = useCallback(
debounce(() => {
if (!userScrolling) {
const container = messagesEndRef.current?.parentElement;
if (container) {
container.scrollTo({
top: container.scrollHeight,
behavior: "smooth",
});
}
}
}, 100),
[userScrolling]
const { dealMsg } = useMessageHandler(
curIdRef,
setCurChatEnd,
setTimedoutShow,
(chat) => cancelChat(chat || activeChat),
setLoadingStep,
handlers
);
useEffect(() => {
const container = messagesEndRef.current?.parentElement;
if (!container) return;
if (dealMsg) {
dealMsgRef.current = dealMsg;
updateDealMsg && updateDealMsg(dealMsg);
}
}, [dealMsg, updateDealMsg]);
const handleScroll = () => {
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
const { scrollTop, scrollHeight, clientHeight } = container;
const isAtBottom =
Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setUserScrolling(!isAtBottom);
if (isAtBottom) {
setUserScrolling(false);
}
scrollTimeoutRef.current = setTimeout(() => {
const {
scrollTop: newScrollTop,
scrollHeight: newScrollHeight,
clientHeight: newClientHeight,
} = container;
const nowAtBottom =
Math.abs(newScrollHeight - newScrollTop - newClientHeight) < 10;
if (nowAtBottom) {
setUserScrolling(false);
}
}, 500);
};
container.addEventListener("scroll", handleScroll);
return () => {
container.removeEventListener("scroll", handleScroll);
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, []);
useEffect(() => {
scrollToBottom();
}, [
activeChat?.messages,
query_intent?.message_chunk,
fetch_source?.message_chunk,
pick_source?.message_chunk,
deep_read?.message_chunk,
think?.message_chunk,
response?.message_chunk,
]);
const clearChat = () => {
console.log("clearChat");
chatClose();
const clearChat = useCallback(() => {
//console.log("clearChat");
setTimedoutShow(false);
chatClose(activeChat);
setActiveChat(undefined);
setCurChatEnd(true);
clearChatPage && clearChatPage();
};
}, [activeChat, chatClose]);
const createNewChat = useCallback(
async (value: string = "") => {
setTimedoutShow(false);
setErrorShow(false);
chatClose();
clearAllChunkData();
setQuestion(value);
const init = useCallback(
async (value: string) => {
try {
console.log("sourceDataIds", sourceDataIds);
let response: any = await invoke("new_chat", {
serverId: currentService?.id,
message: value,
queryParams: {
search: isSearchActive,
deep_thinking: isDeepThinkActive,
datasource: sourceDataIds.join(","),
},
});
console.log("_new", response);
const newChat: Chat = response;
curIdRef.current = response?.payload?.id;
newChat._source = {
message: value,
};
const updatedChat: Chat = {
...newChat,
messages: [newChat],
};
changeInput && changeInput("");
//console.log("updatedChat2", updatedChat);
setActiveChat(updatedChat);
setCurChatEnd(false);
//console.log("init", isLogin, curChatEnd, activeChat?._id);
if (!isLogin) {
addError("Please login to continue chatting");
return;
}
if (!curChatEnd) {
addError("Please wait for the current conversation to complete");
return;
}
if (!activeChat?._id) {
await createNewChat(value, activeChat, websocketSessionId);
} else {
await handleSendMessage(value, activeChat, websocketSessionId);
}
} catch (error) {
setErrorShow(true);
console.error("createNewChat:", error);
}
},
[currentService?.id, sourceDataIds, isSearchActive, isDeepThinkActive]
);
const init = (value: string) => {
if (!IsLogin) return;
if (!curChatEnd) return;
if (!activeChat?._id) {
createNewChat(value);
} else {
handleSendMessage(value);
}
};
const sendMessage = useCallback(
async (content: string, newChat: Chat) => {
if (!newChat?._id || !content) return;
try {
//console.log("sourceDataIds", sourceDataIds);
let response: any = await invoke("send_message", {
serverId: currentService?.id,
sessionId: newChat?._id,
queryParams: {
search: isSearchActive,
deep_thinking: isDeepThinkActive,
datasource: sourceDataIds.join(","),
},
message: content,
});
response = JSON.parse(response || "");
console.log("_send", response);
curIdRef.current = response[0]?._id;
const updatedChat: Chat = {
...newChat,
messages: [...(newChat?.messages || []), ...(response || [])],
};
changeInput && changeInput("");
//console.log("updatedChat2", updatedChat);
setActiveChat(updatedChat);
setCurChatEnd(false);
} catch (error) {
setErrorShow(true);
console.error("sendMessage:", error);
console.error("Failed to initialize chat:", error);
}
},
[
JSON.stringify(activeChat?.messages),
currentService?.id,
sourceDataIds,
isSearchActive,
isDeepThinkActive,
isLogin,
curChatEnd,
activeChat?._id,
createNewChat,
handleSendMessage,
websocketSessionId,
]
);
const handleSendMessage = useCallback(
async (content: string, newChat?: Chat) => {
newChat = newChat || activeChat;
if (!newChat?._id || !content) return;
setQuestion(content);
await chatHistory(newChat, (chat) => sendMessage(content, chat));
const { createWin } = useWindows();
const openChatAI = useCallback(() => {
createChatWindow(createWin);
}, [createChatWindow, createWin]);
const onSelectChat = useCallback(
async (chat: Chat) => {
setTimedoutShow(false);
setErrorShow(false);
clearAllChunkData();
await cancelChat(activeChat);
await chatClose(activeChat);
const response = await openSessionChat(chat);
if (response) {
chatHistory(response);
}
},
[activeChat, sendMessage]
[cancelChat, activeChat, chatClose, openSessionChat, chatHistory]
);
const chatClose = async () => {
if (!activeChat?._id) return;
try {
let response: any = await invoke("close_session_chat", {
serverId: currentService?.id,
sessionId: activeChat?._id,
const deleteChat = useCallback(
(chatId: string) => {
handleDelete(chatId);
setChats((prev) => {
const updatedChats = prev.filter((chat) => chat._id !== chatId);
if (activeChat?._id === chatId) {
if (updatedChats.length > 0) {
setActiveChat(updatedChats[0]);
} else {
init("");
}
}
return updatedChats;
});
response = JSON.parse(response || "");
console.log("_close", response);
} catch (error) {
console.error("chatClose:", error);
}
};
const cancelChat = async () => {
setCurChatEnd(true);
if (!activeChat?._id) return;
try {
let response: any = await invoke("cancel_session_chat", {
serverId: currentService?.id,
sessionId: activeChat?._id,
});
response = JSON.parse(response || "");
console.log("_cancel", response);
} catch (error) {
console.error("cancelChat:", error);
}
};
async function openChatAI() {
if (isTauri()) {
createWin &&
createWin({
label: "chat",
title: "Coco Chat",
dragDropEnabled: true,
center: true,
width: 1000,
height: 800,
alwaysOnTop: false,
skipTaskbar: false,
decorations: true,
closable: true,
url: "/ui/chat",
});
}
}
useEffect(() => {
return () => {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
chatClose();
setActiveChat(undefined);
setCurChatEnd(true);
scrollToBottom.cancel();
};
}, []);
const chatHistory = async (
chat: Chat,
callback?: (chat: Chat) => void
) => {
try {
let response: any = await invoke("session_chat_history", {
serverId: currentService?.id,
sessionId: chat?._id,
from: 0,
size: 20,
});
response = JSON.parse(response || "");
const hits = response?.hits?.hits || [];
const updatedChat: Chat = {
...chat,
messages: hits,
};
console.log("id_history", response, updatedChat);
setActiveChat(updatedChat);
callback && callback(updatedChat);
} catch (error) {
console.error("chatHistory:", error);
}
};
const onSelectChat = async (chat: any) => {
chatClose();
clearAllChunkData();
try {
let response: any = await invoke("open_session_chat", {
serverId: currentService?.id,
sessionId: chat?._id,
});
response = JSON.parse(response || "");
console.log("_open", response);
chatHistory(response);
} catch (error) {
console.error("open_session_chat:", error);
}
};
const deleteChat = (chatId: string) => {
setChats((prev) => prev.filter((chat) => chat._id !== chatId));
if (activeChat?._id === chatId) {
const remainingChats = chats.filter((chat) => chat._id !== chatId);
if (remainingChats.length > 0) {
setActiveChat(remainingChats[0]);
} else {
init("");
}
}
};
},
[activeChat?._id, handleDelete, init]
);
const handleOutsideClick = useCallback((e: MouseEvent) => {
const sidebar = document.querySelector("[data-sidebar]");
@@ -534,155 +285,99 @@ const ChatAI = memo(
};
}, [isSidebarOpenChat, handleOutsideClick]);
const getChatHistory = useCallback(async () => {
if (!currentService?.id) return;
try {
let response: any = await invoke("chat_history", {
serverId: currentService?.id,
from: 0,
size: 20,
});
response = JSON.parse(response || "");
console.log("_history", response);
const hits = response?.hits?.hits || [];
setChats(hits);
} catch (error) {
console.error("chat_history:", error);
}
}, [currentService?.id]);
const toggleSidebar = useCallback(() => {
setIsSidebarOpenChat(!isSidebarOpenChat);
setIsSidebarOpen && setIsSidebarOpen(!isSidebarOpenChat);
!isSidebarOpenChat && getChatHistory();
}, [isSidebarOpenChat, setIsSidebarOpen, getChatHistory]);
const setIsLoginChat = useCallback(
(value: boolean) => {
setIsLogin(value);
value && currentService && !setIsSidebarOpen && getChatHistory();
!value && setChats([]);
const renameChat = useCallback(
(chatId: string, title: string) => {
setChats((prev) => {
const chatIndex = prev.findIndex((chat) => chat._id === chatId);
if (chatIndex === -1) return prev;
const modifiedChat = {
...prev[chatIndex],
_source: { ...prev[chatIndex]._source, title },
};
const result = [...prev];
result.splice(chatIndex, 1);
return [modifiedChat, ...result];
});
if (activeChat?._id === chatId) {
setActiveChat((prev) => {
if (!prev) return prev;
return { ...prev, _source: { ...prev._source, title } };
});
}
handleRename(chatId, title);
},
[currentService]
[activeChat?._id, handleRename]
);
return (
<div
data-tauri-drag-region
className={`h-full flex flex-col rounded-xl overflow-hidden`}
className={`flex flex-col rounded-md relative h-full overflow-hidden`}
>
{setIsSidebarOpen ? null : (
<div
data-sidebar
className={`fixed inset-y-0 left-0 z-50 w-64 transform transition-all duration-300 ease-in-out
${
isSidebarOpenChat
? "translate-x-0"
: "-translate-x-[calc(100%)]"
}
md:relative md:translate-x-0 bg-gray-100 dark:bg-gray-800
border-r border-gray-200 dark:border-gray-700 rounded-tl-xl rounded-bl-xl
overflow-hidden`}
>
<Sidebar
chats={chats}
activeChat={activeChat}
onNewChat={clearChat}
onSelectChat={onSelectChat}
onDeleteChat={deleteChat}
/>
</div>
{showChatHistory && !setIsSidebarOpen && (
<ChatSidebar
isSidebarOpen={isSidebarOpenChat}
chats={chats}
activeChat={activeChat}
// onNewChat={clearChat}
onSelectChat={onSelectChat}
onDeleteChat={deleteChat}
fetchChatHistory={getChatHistory}
onSearch={handleSearch}
onRename={renameChat}
/>
)}
<ChatHeader
onCreateNewChat={clearChat}
onOpenChatAI={openChatAI}
setIsSidebarOpen={() => {
setIsSidebarOpenChat(!isSidebarOpenChat);
setIsSidebarOpen && setIsSidebarOpen(!isSidebarOpenChat);
!isSidebarOpenChat && getChatHistory();
}}
setIsSidebarOpen={toggleSidebar}
isSidebarOpen={isSidebarOpenChat}
activeChat={activeChat}
reconnect={reconnect}
isChatPage={isChatPage}
setIsLogin={setIsLoginChat}
isLogin={isLogin}
setIsLogin={setIsLogin}
showChatHistory={showChatHistory}
assistantIDs={assistantIDs}
/>
{IsLogin ? (
<div className="flex flex-col h-full justify-between overflow-hidden">
<div className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative">
<Greetings />
{activeChat?.messages?.map((message, index) => (
<ChatMessage
key={message._id + index}
message={message}
isTyping={false}
onResend={handleSendMessage}
/>
))}
{(query_intent ||
fetch_source ||
pick_source ||
deep_read ||
think ||
response) &&
activeChat?._id ? (
<ChatMessage
key={"current"}
message={{
_id: "current",
_source: {
type: "assistant",
message: "",
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={!curChatEnd}
query_intent={query_intent}
fetch_source={fetch_source}
pick_source={pick_source}
deep_read={deep_read}
think={think}
response={response}
loadingStep={loadingStep}
/>
) : null}
{timedoutShow ? (
<ChatMessage
key={"timedout"}
message={{
_id: "timedout",
_source: {
type: "assistant",
message: t("assistant.chat.timedout"),
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={false}
/>
) : null}
{errorShow ? (
<ChatMessage
key={"error"}
message={{
_id: "error",
_source: {
type: "assistant",
message: t("assistant.chat.error"),
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={false}
/>
) : null}
<div ref={messagesEndRef} />
</div>
{uploadFiles.length > 0 && (
<div className="max-h-[120px] overflow-auto p-2">
<FileList />
</div>
)}
</div>
{isLogin ? (
<ChatContent
activeChat={activeChat}
curChatEnd={curChatEnd}
query_intent={query_intent}
tools={tools}
fetch_source={fetch_source}
pick_source={pick_source}
deep_read={deep_read}
think={think}
response={response}
loadingStep={loadingStep}
timedoutShow={timedoutShow}
Question={Question}
handleSendMessage={(value) =>
handleSendMessage(value, activeChat)
}
getFileUrl={getFileUrl}
/>
) : (
<ConnectPrompt />
)}
{!activeChat?._id && !visibleStartPage && (
<PrevSuggestion sendMessage={init} />
)}
</div>
);
}

View File

@@ -0,0 +1,196 @@
import { useRef, useEffect, UIEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChatMessage } from "@/components/ChatMessage";
import { Greetings } from "./Greetings";
import FileList from "@/components/Assistant/FileList";
import { useChatScroll } from "@/hooks/useChatScroll";
import { useChatStore } from "@/stores/chatStore";
import type { Chat, IChunkData } from "./types";
// import SessionFile from "./SessionFile";
import { useConnectStore } from "@/stores/connectStore";
import SessionFile from "./SessionFile";
import Splash from "./Splash";
import { ArrowDown } from "lucide-react";
import clsx from "clsx";
interface ChatContentProps {
activeChat?: Chat;
curChatEnd: boolean;
query_intent?: IChunkData;
tools?: IChunkData;
fetch_source?: IChunkData;
pick_source?: IChunkData;
deep_read?: IChunkData;
think?: IChunkData;
response?: IChunkData;
loadingStep?: Record<string, boolean>;
timedoutShow: boolean;
Question: string;
handleSendMessage: (content: string, newChat?: Chat) => void;
getFileUrl: (path: string) => string;
}
export const ChatContent = ({
activeChat,
curChatEnd,
query_intent,
tools,
fetch_source,
pick_source,
deep_read,
think,
response,
loadingStep,
timedoutShow,
Question,
handleSendMessage,
getFileUrl,
}: ChatContentProps) => {
const sessionId = useConnectStore((state) => state.currentSessionId);
const setCurrentSessionId = useConnectStore((state) => {
return state.setCurrentSessionId;
});
useEffect(() => {
setCurrentSessionId(activeChat?._id);
}, [activeChat?._id]);
const { t } = useTranslation();
const uploadFiles = useChatStore((state) => state.uploadFiles);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { scrollToBottom } = useChatScroll(messagesEndRef);
const scrollRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
useEffect(() => {
scrollToBottom();
}, [
activeChat?.messages,
query_intent?.message_chunk,
fetch_source?.message_chunk,
pick_source?.message_chunk,
deep_read?.message_chunk,
think?.message_chunk,
response?.message_chunk,
curChatEnd,
]);
useEffect(() => {
return () => {
scrollToBottom.cancel();
};
}, [scrollToBottom]);
const allMessages = activeChat?.messages || [];
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const { scrollHeight, scrollTop, clientHeight } =
event.currentTarget as HTMLDivElement;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
setIsAtBottom(isAtBottom);
};
return (
<div className="flex-1 overflow-hidden flex flex-col justify-between relative">
<div
ref={scrollRef}
className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative"
onScroll={handleScroll}
>
{(!activeChat || activeChat?.messages?.length === 0) && <Greetings />}
{activeChat?.messages?.map((message, index) => (
<ChatMessage
key={message._id + index}
message={message}
isTyping={false}
onResend={handleSendMessage}
/>
))}
{(!curChatEnd ||
query_intent ||
tools ||
fetch_source ||
pick_source ||
deep_read ||
think ||
response) &&
activeChat?._id ? (
<ChatMessage
key={"current"}
message={{
_id: "current",
_source: {
type: "assistant",
assistant_id:
allMessages[allMessages.length - 1]?._source?.assistant_id,
message: "",
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={!curChatEnd}
query_intent={query_intent}
tools={tools}
fetch_source={fetch_source}
pick_source={pick_source}
deep_read={deep_read}
think={think}
response={response}
loadingStep={loadingStep}
/>
) : null}
{timedoutShow ? (
<ChatMessage
key={"timedout"}
message={{
_id: "timedout",
_source: {
type: "assistant",
message: t("assistant.chat.timedout"),
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={false}
/>
) : null}
<div ref={messagesEndRef} />
</div>
{sessionId && uploadFiles.length > 0 && (
<div key={sessionId} className="max-h-[120px] overflow-auto p-2">
<FileList sessionId={sessionId} getFileUrl={getFileUrl} />
</div>
)}
{sessionId && <SessionFile sessionId={sessionId} />}
<Splash />
<button
className={clsx(
"absolute right-4 bottom-4 flex items-center justify-center size-8 border bg-white rounded-full shadow dark:border-[#272828] dark:bg-black dark:shadow-white/15",
{
hidden: isAtBottom,
}
)}
onClick={() => {
scrollRef.current?.scrollTo({
top: scrollRef.current?.scrollHeight,
behavior: "smooth",
});
}}
>
<ArrowDown className="size-5" />
</button>
</div>
);
};

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import { ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next";
import { emit } from "@tauri-apps/api/event";
import LoginDark from "@/assets/images/login-dark.svg";
import LoginLight from "@/assets/images/login-light.svg";
import { useThemeStore } from "@/stores/themeStore";
import platformAdapter from "@/utils/platformAdapter";
const ConnectPrompt = () => {
const { t } = useTranslation();
@@ -13,7 +13,7 @@ const ConnectPrompt = () => {
const logo = isDark ? LoginDark : LoginLight;
const handleConnect = async () => {
emit("open_settings", "connect");
platformAdapter.emitEvent("open_settings", "connect");
};
return (

View File

@@ -0,0 +1,149 @@
import { useEffect, useMemo } from "react";
import { filesize } from "filesize";
import { X } from "lucide-react";
import { useAsyncEffect } from "ahooks";
import { useTranslation } from "react-i18next";
import { UploadFile, useChatStore } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore";
import FileIcon from "../Common/Icons/FileIcon";
import platformAdapter from "@/utils/platformAdapter";
import Tooltip2 from "../Common/Tooltip2";
interface FileListProps {
sessionId: string;
getFileUrl: (path: string) => string;
}
const FileList = (props: FileListProps) => {
const { sessionId } = props;
const { t } = useTranslation();
const uploadFiles = useChatStore((state) => state.uploadFiles);
const setUploadFiles = useChatStore((state) => state.setUploadFiles);
const currentService = useConnectStore((state) => state.currentService);
const serverId = useMemo(() => {
return currentService.id;
}, [currentService]);
useEffect(() => {
return () => {
setUploadFiles([]);
};
}, []);
useAsyncEffect(async () => {
if (uploadFiles.length === 0) return;
for await (const item of uploadFiles) {
const { uploaded, path } = item;
if (uploaded) continue;
try {
const attachmentIds: any = await platformAdapter.commands(
"upload_attachment",
{
serverId,
sessionId,
filePaths: [path],
}
);
if (!attachmentIds) {
throw new Error("Failed to get attachment id");
} else {
Object.assign(item, {
uploaded: true,
attachmentId: attachmentIds[0],
});
}
setUploadFiles(uploadFiles);
} catch (error) {
Object.assign(item, {
uploadFailed: true,
failedMessage: String(error),
});
}
}
}, [uploadFiles]);
const deleteFile = async (file: UploadFile) => {
const { id, uploadFailed, attachmentId } = file;
setUploadFiles(uploadFiles.filter((file) => file.id !== id));
if (uploadFailed) return;
platformAdapter.commands("delete_attachment", {
serverId,
id: attachmentId,
});
};
return (
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
{uploadFiles.map((file) => {
const {
id,
name,
extname,
size,
uploaded,
attachmentId,
uploadFailed,
failedMessage,
} = file;
return (
<div key={id} className="w-1/3 px-1">
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
{(uploadFailed || attachmentId) && (
<div
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
onClick={() => {
deleteFile(file);
}}
>
<X className="size-[10px] text-white" />
</div>
)}
<FileIcon extname={extname} />
<div className="flex flex-col justify-between overflow-hidden">
<div className="truncate text-[#333333] dark:text-[#D8D8D8]">
{name}
</div>
<div className="text-xs">
{uploadFailed && failedMessage ? (
<Tooltip2 content={failedMessage}>
<span className="text-red-500">Upload Failed</span>
</Tooltip2>
) : (
<div className="text-[#999]">
{uploaded ? (
<div className="flex gap-2">
{extname && <span>{extname}</span>}
<span>
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
</div>
) : (
<span>{t("assistant.fileList.uploading")}</span>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
);
};
export default FileList;

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { MessageSquare, Plus } from "lucide-react";
import { MessageSquare, Plus, RefreshCw } from "lucide-react";
import type { Chat } from "./types";
@@ -10,6 +11,7 @@ interface SidebarProps {
onSelectChat: (chat: Chat) => void;
onDeleteChat: (chatId: string) => void;
className?: string;
fetchChatHistory: () => void;
}
export function Sidebar({
@@ -18,19 +20,37 @@ export function Sidebar({
onNewChat,
onSelectChat,
className = "",
fetchChatHistory,
}: SidebarProps) {
const { t } = useTranslation();
const [isRefreshing, setIsRefreshing] = useState(false);
return (
<div className={`h-full flex flex-col ${className}`}>
<div className="p-4">
<div className="flex justify-between gap-1 p-4">
<button
onClick={onNewChat}
className={`w-full 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`}
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) => (

View File

@@ -0,0 +1,160 @@
import { useMemo, useState } from "react";
import { CircleX, MoveRight } from "lucide-react";
import { useMount } from "ahooks";
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
import { useConnectStore } from "@/stores/connectStore";
import { useThemeStore } from "@/stores/themeStore";
import FontIcon from "../Common/Icons/FontIcon";
import logoImg from "@/assets/icon.svg";
import { Get } from "@/api/axiosRequest";
interface StartPage {
enabled?: boolean;
logo?: {
light?: string;
dark?: string;
};
introduction?: string;
display_assistants?: string[];
}
export interface Response {
app_settings?: {
chat?: {
start_page?: StartPage;
};
};
}
const Splash = () => {
const isTauri = useAppStore((state) => state.isTauri);
const currentService = useConnectStore((state) => state.currentService);
const [settings, setSettings] = useState<StartPage>();
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
const setVisibleStartPage = useConnectStore((state) => {
return state.setVisibleStartPage;
});
const addError = useAppStore((state) => state.addError);
const isDark = useThemeStore((state) => state.isDark);
const assistantList = useConnectStore((state) => state.assistantList);
const setCurrentAssistant = useConnectStore((state) => {
return state.setCurrentAssistant;
});
useMount(async () => {
try {
const serverId = currentService.id;
let response: Response = {};
if (isTauri) {
response = await platformAdapter.invokeBackend<Response>(
"get_system_settings",
{
serverId,
}
);
} else {
const [err, result] = await Get("/settings");
if (err) {
throw new Error(err);
}
response = result as Response;
}
const settings = response?.app_settings?.chat?.start_page;
setVisibleStartPage(Boolean(settings?.enabled));
setSettings(settings);
} catch (error) {
addError(String(error), "error");
}
});
const settingsAssistantList = useMemo(() => {
console.log("assistantList", assistantList);
return assistantList.filter((item) => {
return settings?.display_assistants?.includes(item?._source?.id);
});
}, [settings, assistantList]);
const logo = useMemo(() => {
const { light, dark } = settings?.logo || {};
if (isDark) {
return dark || light;
}
return light || dark;
}, [settings, isDark]);
return (
visibleStartPage && (
<div className="absolute inset-0 flex flex-col items-center px-6 pt-6 text-[#333] dark:text-white select-none overflow-y-auto custom-scrollbar">
<CircleX
className="absolute top-3 right-3 size-4 text-[#999] cursor-pointer"
onClick={() => {
setVisibleStartPage(false);
}}
/>
<img src={logo} className="h-8" />
<div className="mt-3 mb-6 text-lg font-medium">
{settings?.introduction}
</div>
<ul className="flex flex-wrap -m-1 w-full p-0">
{settingsAssistantList?.map((item) => {
const { id, name, description, icon } = item._source;
return (
<li key={id} className="w-1/2 p-1">
<div
className="group h-[74px] px-3 py-2 text-sm rounded-xl border dark:border-[#262626] bg-white dark:bg-black cursor-pointer transition hover:!border-[#0087FF]"
onClick={() => {
setCurrentAssistant(item);
setVisibleStartPage(false);
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
{icon?.startsWith("font_") ? (
<div className="size-4 flex items-center justify-center rounded-full bg-white">
<FontIcon name={icon} className="w-5 h-5" />
</div>
) : (
<img
src={logoImg}
className="size-4 rounded-full"
alt={name}
/>
)}
<span>{name}</span>
</div>
<MoveRight className="size-4 transition group-hover:text-[#0087FF]" />
</div>
<div className="mt-1 text-xs text-[#999] line-clamp-2">
{description}
</div>
</div>
</li>
);
})}
</ul>
</div>
)
);
};
export default Splash;

View File

@@ -15,6 +15,7 @@ export interface ISource {
title?: string;
question?: string;
details?: any[];
assistant_id?: string;
}
export interface Chat {
_id: string;

View File

@@ -0,0 +1,216 @@
import { useReactive } from "ahooks";
import clsx from "clsx";
import { Check, Loader, Mic, X } from "lucide-react";
import { FC, useEffect, useRef } from "react";
import {
checkMicrophonePermission,
requestMicrophonePermission,
} from "tauri-plugin-macos-permissions-api";
import { useWavesurfer } from "@wavesurfer/react";
import RecordPlugin from "wavesurfer.js/dist/plugins/record.esm.js";
import { useConnectStore } from "@/stores/connectStore";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import VisibleKey from "@/components/Common/VisibleKey";
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
interface AudioRecordingProps {
onChange?: (text: string) => void;
}
interface State {
audioDevices: MediaDeviceInfo[];
isRecording: boolean;
converting: boolean;
countdown: number;
}
const INITIAL_STATE: State = {
audioDevices: [],
isRecording: false,
converting: false,
countdown: 30,
};
let interval: ReturnType<typeof setInterval>;
const AudioRecording: FC<AudioRecordingProps> = (props) => {
const { onChange } = props;
const state = useReactive({ ...INITIAL_STATE });
const containerRef = useRef<HTMLDivElement>(null);
const recordRef = useRef<RecordPlugin>();
const withVisibility = useAppStore((state) => state.withVisibility);
const currentService = useConnectStore((state) => state.currentService);
const voiceInput = useShortcutsStore((state) => state.voiceInput);
const { wavesurfer } = useWavesurfer({
container: containerRef,
height: 20,
waveColor: "#0072ff",
progressColor: "#999",
barWidth: 4,
barRadius: 4,
barGap: 2,
});
useEffect(() => {
getAvailableAudioDevices();
return resetState;
}, []);
useEffect(() => {
if (!wavesurfer) return;
const record = wavesurfer.registerPlugin(
RecordPlugin.create({
scrollingWaveform: true,
renderRecordedAudio: false,
})
);
record.on("record-end", (blob) => {
if (!state.converting) return;
const reader = new FileReader();
reader.onloadend = async () => {
const base64Audio = (reader.result as string).split(",")[1];
const response: any = await platformAdapter.commands("transcription", {
serverId: currentService.id,
audioType: "mp3",
audioContent: base64Audio,
});
if (!response) return;
onChange?.(response.text);
resetState();
};
reader.readAsDataURL(blob);
});
recordRef.current = record;
}, [wavesurfer]);
useEffect(() => {
if (!state.isRecording) return;
interval = setInterval(() => {
if (state.countdown <= 0) {
handleOk();
}
state.countdown--;
}, 1000);
}, [state.isRecording]);
const getAvailableAudioDevices = async () => {
state.audioDevices = await RecordPlugin.getAvailableAudioDevices();
};
const resetState = (otherState: Partial<State> = {}) => {
clearInterval(interval);
recordRef.current?.stopRecording();
Object.assign(state, {
...INITIAL_STATE,
...otherState,
audioDevices: state.audioDevices,
});
};
const checkPermission = async () => {
const authorized = await checkMicrophonePermission();
if (authorized) return;
requestMicrophonePermission();
return new Promise(async (resolved) => {
const timer = setInterval(async () => {
const authorized = await checkMicrophonePermission();
if (!authorized) return;
clearInterval(timer);
resolved(true);
}, 500);
});
};
const startRecording = async () => {
await withVisibility(checkPermission);
state.isRecording = true;
recordRef.current?.startRecording();
};
const handleOk = () => {
resetState({ converting: true, countdown: state.countdown });
};
return (
<>
<div
className={clsx(
"p-1 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition cursor-pointer",
{
hidden: state.audioDevices.length === 0,
}
)}
>
<VisibleKey shortcut={voiceInput} onKeyPress={startRecording}>
<Mic className="size-4 text-[#999]" onClick={startRecording} />
</VisibleKey>
</div>
<div
className={clsx(
"absolute inset-0 flex items-center gap-1 px-1 rounded translate-x-full transition-all bg-[#ededed] dark:bg-[#202126]",
{
"!translate-x-0": state.isRecording || state.converting,
}
)}
>
<button
disabled={state.converting}
className={clsx(
"flex items-center justify-center size-6 bg-white dark:bg-black rounded-full transition cursor-pointer",
{
"!cursor-not-allowed opacity-50": state.converting,
}
)}
onClick={() => resetState()}
>
<X className="size-4 text-[#0C0C0C] dark:text-[#999999]" />
</button>
<div className="flex items-center gap-1 flex-1 h-6 px-2 bg-white dark:bg-black rounded-full transition">
<div ref={containerRef} className="flex-1"></div>
<span className="text-xs text-[#333] dark:text-[#999]">
{state.countdown}
</span>
</div>
<button
disabled={state.converting}
className="flex items-center justify-center size-6 text-white bg-[#0072FF] rounded-full transition cursor-pointer"
onClick={handleOk}
>
{state.converting ? (
<Loader className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
</button>
</div>
</>
);
};
export default AudioRecording;

View File

@@ -0,0 +1,82 @@
import { Loader, Hammer, ChevronDown, ChevronUp } from "lucide-react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types";
import Markdown from "./Markdown";
interface CallToolsProps {
Detail?: any;
ChunkData?: IChunkData;
loading?: boolean;
}
export const CallTools = ({ Detail, ChunkData, loading }: CallToolsProps) => {
const { t } = useTranslation();
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [data, setData] = useState("");
useEffect(() => {
if (!Detail?.description) return;
setData(Detail?.description);
}, [Detail?.description]);
useEffect(() => {
if (!ChunkData?.message_chunk) return;
setData(ChunkData?.message_chunk);
}, [ChunkData?.message_chunk, data]);
// Must be after hooks !!!
if (!ChunkData && !Detail) return null;
return (
<div className="space-y-2 mb-3 w-full">
<button
onClick={() => setIsThinkingExpanded((prev) => !prev)}
className="inline-flex items-center gap-2 px-2 py-1 rounded-xl transition-colors border border-[#E6E6E6] dark:border-[#272626]"
>
{loading ? (
<>
<Loader className="w-4 h-4 animate-spin text-[#1990FF]" />
<span className="text-xs text-[#999999] italic">
{t(`assistant.message.steps.${ChunkData?.chunk_type}`)}
</span>
</>
) : (
<>
<Hammer className="w-4 h-4 text-[#38C200]" />
<span className="text-xs text-[#999999]">
{t(`assistant.message.steps.${ChunkData?.chunk_type}`)}
</span>
</>
)}
{isThinkingExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{isThinkingExpanded && (
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<Markdown
content={data || ""}
loading={loading}
onDoubleClickCapture={() => {}}
/>
{/* {data?.split("\n").map(
(paragraph, idx) =>
paragraph.trim() && (
<p key={idx} className="text-sm">
{paragraph}
</p>
)
)} */}
</div>
</div>
)}
</div>
);
};

View File

@@ -20,7 +20,7 @@ export const DeepRead = ({
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [Data, setData] = useState<string[]>([]);
const [data, setData] = useState<string[]>([]);
const [description, setDescription] = useState("");
useEffect(() => {
@@ -42,7 +42,7 @@ export const DeepRead = ({
}
}, [ChunkData?.message_chunk]);
// Must be after hooks
// Must be after hooks !!!
if (!ChunkData && !Detail) return null;
return (
@@ -71,7 +71,7 @@ export const DeepRead = ({
ChunkData?.chunk_type || Detail?.type
}`,
{
count: Number(Data.length),
count: Number(data.length),
}
)}
</span>
@@ -84,10 +84,10 @@ export const DeepRead = ({
)}
</button>
{isThinkingExpanded && (
<div className="pl-2 border-l-2 border-[e5e5e5]">
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-3 text-xs">
{Data?.map((item) => (
{data?.map((item) => (
<div key={item} className="flex flex-col gap-2">
<div className="text-xs text-[#999999] dark:text-[#808080]">
- {item}

View File

@@ -34,8 +34,13 @@ interface ISourceData {
url: string;
}
export const FetchSource = ({ Detail, ChunkData }: FetchSourceProps) => {
export const FetchSource = ({
Detail,
ChunkData,
loading,
}: FetchSourceProps) => {
const { t } = useTranslation();
const [isSourceExpanded, setIsSourceExpanded] = useState(false);
const [total, setTotal] = useState(0);
@@ -51,30 +56,34 @@ export const FetchSource = ({ Detail, ChunkData }: FetchSourceProps) => {
useEffect(() => {
if (!ChunkData?.message_chunk) return;
try {
const match = ChunkData.message_chunk.match(
/\u003cPayload total=(\d+)\u003e/
);
if (match) {
setTotal(Number(match[1]));
}
if (!loading) {
try {
const match = ChunkData.message_chunk.match(
// /\u003cPayload total=(\d+)\u003e/
/<Payload total=(\d+)>/
);
if (match) {
setTotal(Number(match[1]));
}
const jsonMatch = ChunkData.message_chunk.match(/\[(.*)\]/s);
if (jsonMatch) {
const jsonData = JSON.parse(jsonMatch[0]);
setData(jsonData);
// const jsonMatch = ChunkData.message_chunk.match(/\[(.*)\]/s);
const jsonMatch = ChunkData.message_chunk.match(/\[([\s\S]*)\]/);
if (jsonMatch) {
const jsonData = JSON.parse(jsonMatch[0]);
setData(jsonData);
}
} catch (e) {
console.error("Failed to parse fetch source data:", e);
}
} catch (e) {
console.error("Failed to parse fetch source data:", e);
}
}, [ChunkData?.message_chunk]);
}, [ChunkData?.message_chunk, loading]);
// Must be after hooks
// Must be after hooks !!!
if (!ChunkData && !Detail) return null;
return (
<div
className={`mt-2 mb-2 w-[610px] ${
className={`mt-2 mb-2 max-w-full w-full md:w-[610px] ${
isSourceExpanded
? "rounded-lg overflow-hidden border border-[#E6E6E6] dark:border-[#272626]"
: ""
@@ -120,15 +129,17 @@ export const FetchSource = ({ Detail, ChunkData }: FetchSourceProps) => {
className="group flex items-center p-2 hover:bg-[#F7F7F7] dark:hover:bg-[#2C2C2C] border-b border-[#E6E6E6] dark:border-[#272626] last:border-b-0 cursor-pointer transition-colors"
>
<div className="w-full flex items-center gap-2">
<div className="w-[75%] flex items-center gap-1">
<div className="w-[75%] mobile:w-full flex items-center gap-1">
<Globe className="w-3 h-3 flex-shrink-0" />
<div className="text-xs text-[#333333] dark:text-[#D8D8D8] truncate font-normal group-hover:text-[#0072FF] dark:group-hover:text-[#0072FF]">
{item.title || item.category}
</div>
</div>
<div className="w-[25%] flex items-center justify-end gap-2">
<div
className={`flex-1 mobile:hidden flex items-center justify-end gap-2`}
>
<span className="text-xs text-[#999999] dark:text-[#999999] truncate">
{item.source?.name}
{item.source?.name || item?.category}
</span>
<SquareArrowOutUpRight className="w-3 h-3 text-[#999999] dark:text-[#999999] flex-shrink-0" />
</div>

View File

@@ -9,9 +9,12 @@ import RehypeHighlight from "rehype-highlight";
import mermaid from "mermaid";
import { useDebouncedCallback } from "use-debounce";
import { copyToClipboard, useWindowSize } from "@/utils";
import {
copyToClipboard,
// useWindowSize
} from "@/utils";
import "./markdown.css";
import "./markdown.scss";
import "./highlight.css";
// 8
@@ -67,9 +70,9 @@ function PreCode(props: { children?: any }) {
const ref = useRef<HTMLPreElement>(null);
// const previewRef = useRef<HTMLPreviewHander>(null);
const [mermaidCode, setMermaidCode] = useState("");
const [htmlCode, setHtmlCode] = useState("");
const { height } = useWindowSize();
console.log(htmlCode, height);
// const [htmlCode, setHtmlCode] = useState("");
// const { height } = useWindowSize();
// console.log(htmlCode, height);
const renderArtifacts = useDebouncedCallback(() => {
if (!ref.current) return;
@@ -77,17 +80,17 @@ function PreCode(props: { children?: any }) {
if (mermaidDom) {
setMermaidCode((mermaidDom as HTMLElement).innerText);
}
const htmlDom = ref.current.querySelector("code.language-html");
const refText = ref.current.querySelector("code")?.innerText;
if (htmlDom) {
setHtmlCode((htmlDom as HTMLElement).innerText);
} else if (refText?.startsWith("<!DOCTYPE")) {
setHtmlCode(refText);
}
// const htmlDom = ref.current.querySelector("code.language-html");
// const refText = ref.current.querySelector("code")?.innerText;
// if (htmlDom) {
// setHtmlCode((htmlDom as HTMLElement).innerText);
// } else if (refText?.startsWith("<!DOCTYPE")) {
// setHtmlCode(refText);
// }
}, 600);
const enableArtifacts = true;
console.log(enableArtifacts);
// const enableArtifacts = true;
// console.log(enableArtifacts);
//Wrap the paragraph for plain-text
useEffect(() => {
@@ -294,18 +297,20 @@ export default function Markdown(
const mdRef = useRef<HTMLDivElement>(null);
return (
<div
className="markdown-body"
style={{
fontSize: `${props.fontSize ?? 14}px`,
fontFamily: props.fontFamily || "inherit",
}}
ref={mdRef}
onContextMenu={props.onContextMenu}
onDoubleClickCapture={props.onDoubleClickCapture}
dir="auto"
>
<MarkdownContent content={props.content} />
<div className="coco-chat">
<div
className="markdown-body"
style={{
fontSize: `${props.fontSize ?? 14}px`,
fontFamily: props.fontFamily || "inherit",
}}
ref={mdRef}
onContextMenu={props.onContextMenu}
onDoubleClickCapture={props.onDoubleClickCapture}
dir="auto"
>
<MarkdownContent content={props.content} />
</div>
</div>
);
}

View File

@@ -26,7 +26,7 @@ export const PickSource = ({
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [Data, setData] = useState<IData[]>([]);
const [data, setData] = useState<IData[]>([]);
useEffect(() => {
if (!Detail?.payload) return;
@@ -36,7 +36,7 @@ export const PickSource = ({
useEffect(() => {
if (!ChunkData?.message_chunk) return;
if (loading) {
if (!loading) {
try {
const cleanContent = ChunkData.message_chunk.replace(/^"|"$/g, "");
const allMatches = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/g);
@@ -44,7 +44,7 @@ export const PickSource = ({
if (allMatches) {
for (let i = allMatches.length - 1; i >= 0; i--) {
try {
const jsonString = allMatches[i].replace(/<JSON>|<\/JSON>/g, "");
const jsonString = allMatches[i].replace(/<JSON>|<\/JSON>|<think>|<\/think>/g, "");
const data = JSON.parse(jsonString.trim());
if (
@@ -65,7 +65,7 @@ export const PickSource = ({
}
}, [ChunkData?.message_chunk, loading]);
// Must be after hooks
// Must be after hooks !!!
if (!ChunkData && !Detail) return null;
return (
@@ -90,7 +90,7 @@ export const PickSource = ({
ChunkData?.chunk_type || Detail.type
}`,
{
count: Data?.length,
count: data?.length,
}
)}
</span>
@@ -103,10 +103,10 @@ export const PickSource = ({
)}
</button>
{isThinkingExpanded && (
<div className="pl-2 border-l-2 border-[e5e5e5]">
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-3 text-xs">
{Data?.map((item) => (
{data?.map((item) => (
<div
key={item.id}
className="p-3 rounded-lg border border-[#E6E6E6] dark:border-[#272626] bg-white dark:bg-[#1E1E1E] hover:bg-gray-50 dark:hover:bg-[#2C2C2C] transition-colors"

View File

@@ -0,0 +1,45 @@
import { MoveRight } from "lucide-react";
import { FC, useEffect, useState } from "react";
import { useConnectStore } from "@/stores/connectStore";
interface PrevSuggestionProps {
sendMessage: (message: string) => void;
}
const PrevSuggestion: FC<PrevSuggestionProps> = (props) => {
const { sendMessage } = props;
const currentAssistant = useConnectStore((state) => state.currentAssistant);
const [list, setList] = useState<string[]>([]);
useEffect(() => {
const suggested = currentAssistant?._source?.chat_settings?.suggested || {};
if (suggested.enabled) {
setList(suggested.questions || []);
} else {
setList([]);
}
}, [JSON.stringify(currentAssistant)]);
return (
<ul className="absolute left-2 bottom-2 flex flex-col gap-2 p-0">
{list.map((item) => {
return (
<li
key={item}
className="flex items-center self-start gap-2 px-3 py-2 leading-4 text-sm text-[#333] dark:text-[#d8d8d8] rounded-xl border border-black/15 dark:border-white/15 hover:!border-[#0072ff] hover:!text-[#0072ff] transition cursor-pointer"
onClick={() => sendMessage(item)}
>
{item}
<MoveRight className="size-4" />
</li>
);
})}
</ul>
);
};
export default PrevSuggestion;

View File

@@ -30,7 +30,7 @@ export const QueryIntent = ({
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [Data, setData] = useState<IQueryData | null>(null);
const [data, setData] = useState<IQueryData | null>(null);
useEffect(() => {
if (!Detail?.payload) return;
@@ -42,7 +42,7 @@ export const QueryIntent = ({
useEffect(() => {
if (!ChunkData?.message_chunk) return;
if (loading) {
if (!loading) {
const cleanContent = ChunkData.message_chunk.replace(/^"|"$/g, "");
const allMatches = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/g);
if (allMatches) {
@@ -66,7 +66,7 @@ export const QueryIntent = ({
}
}, [ChunkData?.message_chunk]);
// Must be after hooks
// Must be after hooks !!!
if (!ChunkData && !Detail) return null;
return (
@@ -97,18 +97,18 @@ export const QueryIntent = ({
)}
</button>
{isThinkingExpanded && (
<div className="pl-2 border-l-2 border-[e5e5e5]">
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-2 text-xs">
{Data?.keyword ? (
{data?.keyword ? (
<div className="flex gap-1">
<span className="text-[#999999]">
- {t("assistant.message.steps.keywords")}
</span>
<div className="flex flex-wrap gap-1">
{Data?.keyword?.map((keyword, index) => (
{data?.keyword?.map((keyword, index) => (
<span
key={index}
key={keyword + index}
className="text-[#333333] dark:text-[#D8D8D8]"
>
{keyword}
@@ -118,34 +118,34 @@ export const QueryIntent = ({
</div>
</div>
) : null}
{Data?.category ? (
{data?.category ? (
<div className="flex items-center gap-1">
<span className="text-[#999999]">
- {t("assistant.message.steps.questionType")}
</span>
<span className="text-[#333333] dark:text-[#D8D8D8]">
{Data?.category}
{data?.category}
</span>
</div>
) : null}
{Data?.intent ? (
{data?.intent ? (
<div className="flex items-start gap-1">
<span className="text-[#999999]">
- {t("assistant.message.steps.userIntent")}
</span>
<div className="flex-1 text-[#333333] dark:text-[#D8D8D8]">
{Data?.intent}
{data?.intent}
</div>
</div>
) : null}
{Data?.query ? (
{data?.query ? (
<div className="flex items-start gap-1">
<span className="text-[#999999]">
- {t("assistant.message.steps.relatedQuestions")}
</span>
<div className="flex-1 flex flex-col text-[#333333] dark:text-[#D8D8D8]">
{Data?.query?.map((question) => (
<span key={question}>- {question}</span>
{data?.query?.map((question, qIndex) => (
<span key={question + qIndex}>- {question}</span>
))}
</div>
</div>

View File

@@ -14,7 +14,7 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
const { t } = useTranslation();
const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
const [Data, setData] = useState("");
const [data, setData] = useState("");
useEffect(() => {
if (!Detail?.description) return;
@@ -24,9 +24,9 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
useEffect(() => {
if (!ChunkData?.message_chunk) return;
setData(ChunkData?.message_chunk);
}, [ChunkData?.message_chunk, Data]);
}, [ChunkData?.message_chunk, data]);
// Must be after hooks
// Must be after hooks !!!
if (!ChunkData && !Detail) return null;
return (
@@ -57,9 +57,9 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
)}
</button>
{isThinkingExpanded && (
<div className="pl-2 border-l-2 border-[e5e5e5]">
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
{Data?.split("\n").map(
{data?.split("\n").map(
(paragraph, idx) =>
paragraph.trim() && (
<p key={idx} className="text-sm">

View File

@@ -0,0 +1,40 @@
import { useState } from "react";
import clsx from "clsx";
import { CopyButton } from "@/components/Common/CopyButton";
interface UserMessageProps {
messageContent: string;
}
export const UserMessage = ({ messageContent }: UserMessageProps) => {
const [showCopyButton, setShowCopyButton] = useState(false);
return (
<div
className="flex gap-1 items-center"
onMouseEnter={() => setShowCopyButton(true)}
onMouseLeave={() => setShowCopyButton(false)}
>
<div
className={clsx("size-6 transition", {
"opacity-0": !showCopyButton,
})}
>
<CopyButton textToCopy={messageContent} />
</div>
<div
className="px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer select-none"
onDoubleClick={(e) => {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(e.currentTarget);
selection?.removeAllRanges();
selection?.addRange(range);
}}
>
{messageContent}
</div>
</div>
);
};

View File

@@ -1,9 +1,11 @@
import { memo, useState } from "react";
import { memo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import logoImg from "@/assets/icon.svg";
import type { Message, IChunkData } from "@/components/Assistant/types";
import { QueryIntent } from "./QueryIntent";
import { CallTools } from "./CallTools";
import { FetchSource } from "./FetchSource";
import { PickSource } from "./PickSource";
import { DeepRead } from "./DeepRead";
@@ -11,11 +13,15 @@ import { Think } from "./Think";
import { MessageActions } from "./MessageActions";
import Markdown from "./Markdown";
import { SuggestionList } from "./SuggestionList";
import { UserMessage } from "./UserMessage";
import { useConnectStore } from "@/stores/connectStore";
import FontIcon from "@/components/Common/Icons/FontIcon";
interface ChatMessageProps {
message: Message;
isTyping?: boolean;
query_intent?: IChunkData;
tools?: IChunkData;
fetch_source?: IChunkData;
pick_source?: IChunkData;
deep_read?: IChunkData;
@@ -29,6 +35,7 @@ export const ChatMessage = memo(function ChatMessage({
message,
isTyping,
query_intent,
tools,
fetch_source,
pick_source,
deep_read,
@@ -39,7 +46,24 @@ export const ChatMessage = memo(function ChatMessage({
}: ChatMessageProps) {
const { t } = useTranslation();
const currentAssistant = useConnectStore((state) => state.currentAssistant);
const assistantList = useConnectStore((state) => state.assistantList);
const [assistant, setAssistant] = useState<any>({});
const isAssistant = message?._source?.type === "assistant";
const assistant_id = message?._source?.assistant_id;
useEffect(() => {
let target = currentAssistant;
if (isAssistant && assistant_id && Array.isArray(assistantList)) {
const found = assistantList.find((item) => item._id === assistant_id);
if (found) {
target = found;
}
}
setAssistant(target);
}, [isAssistant, assistant_id, assistantList, currentAssistant]);
const messageContent = message?._source?.message || "";
const details = message?._source?.details || [];
const question = message?._source?.question || "";
@@ -48,6 +72,7 @@ export const ChatMessage = memo(function ChatMessage({
isTyping === false && (messageContent || response?.message_chunk);
const [suggestion, setSuggestion] = useState<string[]>([]);
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
const getSuggestion = (suggestion: string[]) => {
setSuggestion(suggestion);
@@ -55,11 +80,7 @@ export const ChatMessage = memo(function ChatMessage({
const renderContent = () => {
if (!isAssistant) {
return (
<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]">
{messageContent}
</div>
);
return <UserMessage messageContent={messageContent} />;
}
return (
@@ -70,6 +91,13 @@ export const ChatMessage = memo(function ChatMessage({
getSuggestion={getSuggestion}
loading={loadingStep?.query_intent}
/>
<CallTools
Detail={details.find((item) => item.type === "tools")}
ChunkData={tools}
loading={loadingStep?.tools}
/>
<FetchSource
Detail={details.find((item) => item.type === "fetch_source")}
ChunkData={fetch_source}
@@ -120,7 +148,13 @@ export const ChatMessage = memo(function ChatMessage({
return (
<div
className={`py-8 flex ${isAssistant ? "justify-start" : "justify-end"}`}
className={clsx(
"py-8 flex",
[isAssistant ? "justify-start" : "justify-end"],
{
hidden: visibleStartPage,
}
)}
>
<div
className={`px-4 flex gap-4 ${
@@ -128,20 +162,28 @@ export const ChatMessage = memo(function ChatMessage({
}`}
>
<div
className={`space-y-2 ${isAssistant ? "text-left" : "text-right"}`}
className={`w-full space-y-2 ${
isAssistant ? "text-left" : "text-right"
}`}
>
<p className="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 ? (
<img
src={logoImg}
className="w-6 h-6"
alt={t("assistant.message.logo")}
/>
<div className="w-6 h-6 flex justify-center items-center rounded-full bg-white border border-[#E6E6E6]">
{assistant?._source?.icon?.startsWith("font_") ? (
<FontIcon name={assistant._source.icon} className="w-4 h-4" />
) : (
<img
src={logoImg}
className="w-4 h-4"
alt={t("assistant.message.logo")}
/>
)}
</div>
) : null}
{isAssistant ? t("assistant.message.aiName") : ""}
</p>
<div className="prose dark:prose-invert prose-sm max-w-none">
<div className="pl-7 text-[#333] dark:text-[#d8d8d8] leading-relaxed">
{isAssistant ? assistant?._source?.name || "Coco AI" : ""}
</div>
<div className="w-full prose dark:prose-invert prose-sm max-w-none">
<div className="w-full pl-7 text-[#333] dark:text-[#d8d8d8] leading-relaxed">
{renderContent()}
</div>
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,6 @@ import {
getCurrent as getCurrentDeepLinkUrls,
onOpenUrl,
} from "@tauri-apps/plugin-deep-link";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { emit } from "@tauri-apps/api/event";
@@ -28,14 +27,25 @@ import { useAppStore } from "@/stores/appStore";
import { useConnectStore } from "@/stores/connectStore";
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import Tooltip from "@/components/Common/Tooltip";
import {
list_coco_servers,
add_coco_server,
enable_server,
disable_server,
logout_coco_server,
remove_coco_server,
refresh_coco_server_info,
handle_sso_callback,
} from "@/commands";
export default function Cloud() {
const { t } = useTranslation();
const SidebarRef = useRef<{ refreshData: () => void }>(null);
const error = useAppStore((state) => state.error);
const setError = useAppStore((state) => state.setError);
const errors = useAppStore((state) => state.errors);
const addError = useAppStore((state) => state.addError);
const [isConnect, setIsConnect] = useState(true);
@@ -60,14 +70,13 @@ export default function Cloud() {
console.log("currentService", currentService);
setLoading(false);
setRefreshLoading(false);
setError("");
setIsConnect(true);
}, [JSON.stringify(currentService)]);
const fetchServers = async (resetSelection: boolean) => {
invoke("list_coco_servers")
list_coco_servers()
.then((res: any) => {
if (error) {
if (errors.length > 0) {
res = (res || []).map((item: any) => {
if (item.id === currentService?.id) {
item.health = {
@@ -92,12 +101,11 @@ export default function Cloud() {
}
})
.catch((err: any) => {
setError(err);
console.error(err);
});
};
const add_coco_server = (endpointLink: string) => {
const addServer = (endpointLink: string) => {
if (!endpointLink) {
throw new Error("Endpoint is required");
}
@@ -110,25 +118,13 @@ export default function Cloud() {
setRefreshLoading(true);
return invoke("add_coco_server", { endpoint: endpointLink })
return add_coco_server(endpointLink)
.then((res: any) => {
// console.log("add_coco_server", res);
fetchServers(false)
.then((r) => {
console.log("fetchServers", r);
setCurrentService(res);
})
.catch((err: any) => {
console.error("fetchServers failed:", err);
setError(err);
throw err; // Propagate error back up to outer promise chain
});
})
.catch((err: any) => {
// Handle the invoke error
console.error("add coco server failed:", err);
setError(err);
throw err; // Propagate error back up
fetchServers(false).then((r) => {
console.log("fetchServers", r);
setCurrentService(res);
});
})
.finally(() => {
setRefreshLoading(false);
@@ -137,14 +133,14 @@ export default function Cloud() {
const handleOAuthCallback = useCallback(
async (code: string | null, serverId: string | null) => {
if (!code) {
setError("No authorization code received");
if (!code || !serverId) {
addError("No authorization code received");
return;
}
try {
console.log("Handling OAuth callback:", { code, serverId });
await invoke("handle_sso_callback", {
await handle_sso_callback({
serverId: serverId, // Make sure 'server_id' is the correct argument
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
code: code,
@@ -154,15 +150,9 @@ export default function Cloud() {
refreshClick(serverId);
}
getCurrentWindow()
.setFocus()
.catch((err) => {
setError(err);
});
getCurrentWindow().setFocus();
} catch (e) {
console.error("Sign in failed:", e);
setError("SSO login failed: " + e);
throw error;
} finally {
setLoading(false);
}
@@ -181,7 +171,7 @@ export default function Cloud() {
if (reqId != ssoRequestID) {
console.log("Request ID not matched, skip");
setError("Request ID not matched, skip");
addError("Request ID not matched, skip");
return;
}
@@ -189,7 +179,7 @@ export default function Cloud() {
handleOAuthCallback(code, serverId);
} catch (err) {
console.error("Failed to parse URL:", err);
setError("Invalid URL format: " + err);
addError("Invalid URL format: " + err);
}
};
@@ -225,7 +215,7 @@ export default function Cloud() {
})
.catch((err) => {
console.error("Failed to get initial URLs:", err);
setError("Failed to get initial URLs: " + err);
addError("Failed to get initial URLs: " + err);
});
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
@@ -256,7 +246,7 @@ export default function Cloud() {
const refreshClick = (id: string) => {
setRefreshLoading(true);
invoke("refresh_coco_server_info", { id })
refresh_coco_server_info(id)
.then((res: any) => {
console.log("refresh_coco_server_info", id, res);
fetchServers(false).then((r) => {
@@ -266,10 +256,6 @@ export default function Cloud() {
setCurrentService(res);
emit("login_or_logout", true);
})
.catch((err: any) => {
setError(err);
console.error(err);
})
.finally(() => {
setRefreshLoading(false);
});
@@ -282,49 +268,40 @@ export default function Cloud() {
function onLogout(id: string) {
console.log("onLogout", id);
setRefreshLoading(true);
invoke("logout_coco_server", { id })
logout_coco_server(id)
.then((res: any) => {
console.log("logout_coco_server", id, JSON.stringify(res));
refreshClick(id);
emit("login_or_logout", false);
})
.catch((err: any) => {
setError(err);
console.error(err);
})
.finally(() => {
setRefreshLoading(false);
});
}
const remove_coco_server = (id: string) => {
invoke("remove_coco_server", { id })
.then((res: any) => {
console.log("remove_coco_server", id, JSON.stringify(res));
fetchServers(true).then((r) => {
console.log("fetchServers", r);
});
})
.catch((err: any) => {
// TODO display the error message
setError(err);
console.error(err);
const removeServer = (id: string) => {
remove_coco_server(id).then((res: any) => {
console.log("remove_coco_server", id, JSON.stringify(res));
fetchServers(true).then((r) => {
console.log("fetchServers", r);
});
});
};
const enable_coco_server = useCallback(async (enabled: boolean) => {
try {
const command = enabled ? "enable_server" : "disable_server";
await invoke(command, { id: currentService?.id });
const enable_coco_server = useCallback(
async (enabled: boolean) => {
if (enabled) {
await enable_server(currentService?.id);
} else {
await disable_server(currentService?.id);
}
setCurrentService({ ...currentService, enabled });
await fetchServers(false);
} catch (error) {
setError(error);
}
}, [currentService?.id]);
},
[currentService?.id]
);
return (
<div className="flex bg-gray-50 dark:bg-gray-900">
@@ -346,9 +323,11 @@ export default function Cloud() {
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="flex items-center text-gray-900 dark:text-white font-medium">
{currentService?.name}
</div>
<Tooltip content={currentService?.endpoint}>
<div className="flex items-center text-gray-900 dark:text-white font-medium cursor-pointer">
{currentService?.name}
</div>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<SettingsToggle
@@ -385,7 +364,7 @@ export default function Cloud() {
{!currentService?.builtin && (
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => remove_coco_server(currentService?.id)}
onClick={() => removeServer(currentService?.id)}
>
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
</button>
@@ -457,8 +436,7 @@ export default function Cloud() {
<Copy className="inline mr-2" />{" "}
</button>
<div className="text-justify italic text-xs">
If the link did not open automatically, please copy
and paste it into your browser manually.
{t("cloud.manualCopyLink")}
</div>
</div>
)}
@@ -484,7 +462,7 @@ export default function Cloud() {
) : null}
</div>
) : (
<Connect setIsConnect={setIsConnect} onAddServer={add_coco_server} />
<Connect setIsConnect={setIsConnect} onAddServer={addServer} />
)}
</main>
</div>

View File

@@ -13,9 +13,8 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
const { t } = useTranslation();
const [endpointLink, setEndpointLink] = useState("");
const [refreshLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState(""); // State to store the error message
const setError = useAppStore((state) => state.setError);
const addError = useAppStore((state) => state.addError);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -36,17 +35,10 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
typeof err === "string"
? err
: err?.message || "An unknown error occurred.";
setErrorMessage("ERR:" + errorMessage);
setError(errorMessage);
console.error("Error:", errorMessage);
addError(errorMessage);
}
};
// Function to close the error message
const closeError = () => {
setErrorMessage("");
};
return (
<div className="max-w-4xl">
<div className="flex items-center gap-2 mb-8">
@@ -96,31 +88,6 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
</div>
</div>
</form>
{/*//TODO move to outer container, move error state to global*/}
{errorMessage && (
<div className="mb-8">
<div
style={{
color: "red",
marginTop: "10px",
display: "block", // Makes sure the error message starts on a new line
marginBottom: "10px",
}}
>
<span>{errorMessage}</span>
<button
onClick={closeError}
style={{
background: "none",
border: "none",
color: "red",
cursor: "pointer",
}}
></button>
</div>
</div>
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import source_default_img from "@/assets/images/source_default.png";
import source_default_dark_img from "@/assets/images/source_default_dark.png";
import { useConnectStore } from "@/stores/connectStore";
import { useThemeStore } from "@/stores/themeStore";
import FontIcon from "../Common/Icons/FontIcon";
interface Account {
email: string;
@@ -13,11 +14,12 @@ interface Account {
interface DataSourceItemProps {
name: string;
icon?: string;
connector: any;
accounts?: Account[];
}
export function DataSourceItem({ name, connector }: DataSourceItemProps) {
export function DataSourceItem({ name, icon, connector }: DataSourceItemProps) {
// const isConnected = true;
const isDark = useThemeStore((state) => state.isDark);
@@ -56,7 +58,12 @@ export function DataSourceItem({ name, connector }: DataSourceItemProps) {
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<img src={getTypeIcon()} alt={name} className="w-6 h-6" />
{icon?.startsWith("font_") ? (
<FontIcon name={icon} className="size-6" />
) : (
<img src={getTypeIcon()} alt={name} className="size-6" />
)}
<span className="font-medium text-gray-900 dark:text-white">
{name}
</span>

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