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

3
.gitignore vendored
View File

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

16
.vscode/settings.json vendored
View File

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

View File

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

View File

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

View File

@@ -7,6 +7,12 @@ theme: book
disablePathToLower: true disablePathToLower: true
enableGitInfo: false enableGitInfo: false
outputs:
home:
- HTML
- RSS
- JSON
# Needed for mermaid/katex shortcodes # Needed for mermaid/katex shortcodes
markup: markup:
goldmark: 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. Coco AI is a fully open-source, cross-platform unified search and productivity tool that connects and searches across various data sources, including applications, files, Google Drive, Notion, Yuque, Hugo, and more, both local and cloud-based. By integrating with large models like DeepSeek, Coco AI enables intelligent personal knowledge management, emphasizing privacy and supporting private deployment, helping users quickly and intelligently access their information.
{{% load-img "/img/screenshot/fusion-search-across-datasources.png" "" %}} {{% load-img "/img/coco-preview.gif" "" %}}
{{% load-img "/img/screenshot/coco-chat.png" "" %}}
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/). For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).

View File

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

View File

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

View File

@@ -9,14 +9,136 @@ Information about release notes of Coco Server is provided here.
## Latest (In development) ## 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 ### 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 ### Breaking changes
### Bug fix ### 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 ### 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) ## 0.2.0 (2025-03-07)
### Features ### Features
@@ -54,7 +176,6 @@ Information about release notes of Coco Server is provided here.
- Allow to switch servers in the settings page - Allow to switch servers in the settings page
- etc. - etc.
## 0.1.0 (2025-02-16) ## 0.1.0 (2025-02-16)
### Features ### 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> <title>Coco</title>
</head> </head>
<body> <body class="coco-container">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>

View File

@@ -1,11 +1,16 @@
{ {
"name": "coco", "name": "coco",
"private": true, "private": true,
"version": "0.2.0", "version": "0.4.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "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", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"release": "release-it", "release": "release-it",
@@ -13,33 +18,38 @@
"release-beta": "release-it --preRelease=beta --preReleaseBase=1" "release-beta": "release-it --preRelease=beta --preReleaseBase=1"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.0", "@ant-design/icons": "^6.0.0",
"@tauri-apps/api": "^2.3.0", "@headlessui/react": "^2.2.2",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "~2.2.0", "@tauri-apps/plugin-autostart": "~2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.0", "@tauri-apps/plugin-deep-link": "^2.2.1",
"@tauri-apps/plugin-dialog": "^2.2.0", "@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-global-shortcut": "~2.0.0", "@tauri-apps/plugin-global-shortcut": "~2.0.0",
"@tauri-apps/plugin-http": "~2.0.2", "@tauri-apps/plugin-http": "~2.0.2",
"@tauri-apps/plugin-os": "^2.2.0", "@tauri-apps/plugin-log": "~2.4.0",
"@tauri-apps/plugin-process": "^2.2.0", "@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.0", "@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.5.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-websocket": "~2.3.0",
"@tauri-apps/plugin-window": "2.0.0-alpha.1", "@tauri-apps/plugin-window": "2.0.0-alpha.1",
"@wavesurfer/react": "^1.0.11",
"ahooks": "^3.8.4", "ahooks": "^3.8.4",
"axios": "^1.9.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.4.7", "dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"filesize": "^10.1.6", "filesize": "^10.1.6",
"i18next": "^23.16.8", "i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.461.0", "lucide-react": "^0.461.0",
"mermaid": "^11.4.1", "mermaid": "^11.6.0",
"nanoid": "^5.1.2", "nanoid": "^5.1.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.6.1", "react-hotkeys-hook": "^4.6.2",
"react-i18next": "^15.4.1", "react-i18next": "^15.5.1",
"react-markdown": "^9.1.0", "react-markdown": "^9.1.0",
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.0",
"react-window": "^1.8.11", "react-window": "^1.8.11",
@@ -48,31 +58,36 @@
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"tauri-plugin-fs-pro-api": "^2.3.1", "tauri-plugin-fs-pro-api": "^2.4.0",
"tauri-plugin-macos-permissions-api": "^2.1.1", "tauri-plugin-macos-permissions-api": "^2.3.0",
"tauri-plugin-screenshots-api": "^2.1.0", "tauri-plugin-screenshots-api": "^2.2.0",
"tauri-plugin-windows-version-api": "^2.0.0",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"zustand": "^5.0.3" "wavesurfer.js": "^7.9.5",
"zustand": "^5.0.4"
}, },
"devDependencies": { "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/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^22.13.9", "@types/node": "^22.15.17",
"@types/react": "^18.3.18", "@types/react": "^18.3.21",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.7",
"@types/react-i18next": "^8.1.0",
"@types/react-katex": "^3.0.4", "@types/react-katex": "^3.0.4",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.21",
"cross-env": "^7.0.3",
"immer": "^10.1.1", "immer": "^10.1.1",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"release-it": "^18.1.2", "release-it": "^18.1.2",
"sass": "^1.87.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsx": "^4.19.3", "tsup": "^8.4.0",
"typescript": "^5.8.2", "tsx": "^4.19.4",
"vite": "^5.4.14" "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] [package]
name = "coco" name = "coco"
version = "0.2.0" version = "0.4.0"
description = "Search, connect, collaborate all in one place." description = "Search, connect, collaborate all in one place."
authors = ["INFINI Labs"] authors = ["INFINI Labs"]
edition = "2021" edition = "2021"
@@ -20,6 +20,26 @@ tauri-build = { version = "2", features = ["default"] }
default = ["desktop"] default = ["desktop"]
desktop = [] desktop = []
cargo-clippy = [] cargo-clippy = []
# If enabled, code that relies on pizza_engine will be activated.
#
# Only do this if:
# 1. Pizza engine is listed in the `dependencies` section
#
# ```toml
# [dependencies]
# pizza-engine = { git = "ssh://git@github.com/infinilabs/pizza.git", features = ["query_string_parser", "persistence"] }
# ```
#
# 2. It is a private repo, you have access to it.
#
# So, for external contributors, do NOT enable this feature.
#
# Previously, We listed it in the dependencies and marked it optional, but cargo
# would fetch all the dependencies regardless of wheterh they are optional or not,
# so we removed it.
#
# https://github.com/rust-lang/cargo/issues/4544#issuecomment-1906902755
use_pizza_engine = []
[dependencies] [dependencies]
pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "main" } pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "main" }
@@ -27,7 +47,9 @@ pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "m
tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png", "unstable"] } tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png", "unstable"] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" # Need `arbitrary_precision` feature to support storing u128
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
serde_json = { version = "1", features = ["arbitrary_precision"] }
tauri-plugin-http = "2" tauri-plugin-http = "2"
tauri-plugin-websocket = "2" tauri-plugin-websocket = "2"
tauri-plugin-deep-link = "2.0.0" tauri-plugin-deep-link = "2.0.0"
@@ -35,19 +57,17 @@ tauri-plugin-store = "2.2.0"
tauri-plugin-os = "2" tauri-plugin-os = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-drag = "2" tauri-plugin-drag = "2"
tauri-plugin-macos-permissions = "2" tauri-plugin-macos-permissions = "2"
tauri-plugin-fs-pro = "2" tauri-plugin-fs-pro = "2"
tauri-plugin-screenshots = "2" tauri-plugin-screenshots = "2"
applications = "0.3.0" applications = { git = "https://github.com/infinilabs/applications-rs", rev = "7bb507e6b12f73c96f3a52f0578d0246a689f381" }
tokio-native-tls = "0.3" # For wss connections tokio-native-tls = "0.3" # For wss connections
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.20", features = ["rustls-tls-webpki-roots"] } tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
hyper = { version = "0.14", features = ["client"] } hyper = { version = "0.14", features = ["client"] }
reqwest = "0.12.12" reqwest = { version = "0.12", features = ["json", "multipart"] }
futures = "0.3.31" futures = "0.3.31"
ordered-float = { version = "4.6.0", default-features = false } ordered-float = { version = "4.6.0", default-features = false }
lazy_static = "1.5.0" lazy_static = "1.5.0"
@@ -60,14 +80,19 @@ hostname = "0.3"
plist = "1.7" plist = "1.7"
base64 = "0.13" base64 = "0.13"
walkdir = "2" walkdir = "2"
fuzzy_prefix_search = "0.2"
log = "0.4" log = "0.4"
futures-util = "0.3.31" futures-util = "0.3.31"
url = "2.5.2" url = "2.5.2"
http = "1.1.0" http = "1.1.0"
tungstenite = "0.24.0" tungstenite = "0.24.0"
env_logger = "0.11.5" tokio-util = "0.7.14"
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] [target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" } tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
@@ -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] [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] } tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
[profile.dev] [profile.dev]
incremental = true # Compile your binary in smaller steps. incremental = true # Compile your binary in smaller steps.
@@ -89,3 +113,7 @@ strip = true # Ensures debug symbols are removed.
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "^2.2" tauri-plugin-autostart = "^2.2"
tauri-plugin-global-shortcut = "2" tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = { 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> </array>
</dict> </dict>
</array> </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> </dict>
</plist> </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-focus",
"core:window:allow-set-always-on-top", "core:window:allow-set-always-on-top",
"core:window:deny-internal-toggle-maximize", "core:window:deny-internal-toggle-maximize",
"core:window:allow-set-shadow",
"core:app:allow-set-app-theme", "core:app:allow-set-app-theme",
"shell:default", "shell:default",
"http:default", "http:default",
@@ -67,6 +68,9 @@
"macos-permissions:default", "macos-permissions:default",
"screenshots:default", "screenshots:default",
"core:window:allow-set-theme", "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-enable",
"autostart:allow-disable", "autostart:allow-disable",
"autostart:allow-is-enabled", "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::assistant::ChatRequestMessage;
use crate::common::http::GetResponse; use crate::common::http::GetResponse;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use reqwest::Response;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime};
#[tauri::command] #[tauri::command]
pub async fn chat_history<R: Runtime>( pub async fn chat_history<R: Runtime>(
app_handle: AppHandle<R>, _app_handle: AppHandle<R>,
server_id: String, server_id: String,
from: u32, from: u32,
size: u32, size: u32,
query: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
let mut query_params: HashMap<String, Value> = HashMap::new(); let mut query_params: HashMap<String, Value> = HashMap::new();
if from > 0 { if from > 0 {
@@ -21,30 +22,25 @@ pub async fn chat_history<R: Runtime>(
query_params.insert("size".to_string(), size.into()); query_params.insert("size".to_string(), size.into());
} }
if let Some(query) = query {
if !query.is_empty() {
query_params.insert("query".to_string(), query.into());
}
}
let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params)) let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params))
.await .await
.map_err(|e| format!("Error get sessions: {}", e))?; .map_err(|e| {
dbg!("Error get history: {}", &e);
format!("Error get history: {}", e)
})?;
handle_raw_response(response).await? common::http::get_response_body_text(response).await
}
async fn handle_raw_response(response: Response) -> Result<Result<String, String>, String> {
Ok(
if response.status().as_u16() < 200 || response.status().as_u16() >= 400 {
Err("Failed to send message".to_string())
} else {
let body = response
.text()
.await
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
Ok(body)
},
)
} }
#[tauri::command] #[tauri::command]
pub async fn session_chat_history<R: Runtime>( pub async fn session_chat_history<R: Runtime>(
app_handle: AppHandle<R>, _app_handle: AppHandle<R>,
server_id: String, server_id: String,
session_id: String, session_id: String,
from: u32, from: u32,
@@ -64,87 +60,90 @@ pub async fn session_chat_history<R: Runtime>(
.await .await
.map_err(|e| format!("Error get session message: {}", e))?; .map_err(|e| format!("Error get session message: {}", e))?;
handle_raw_response(response).await? common::http::get_response_body_text(response).await
} }
#[tauri::command] #[tauri::command]
pub async fn open_session_chat<R: Runtime>( pub async fn open_session_chat<R: Runtime>(
app_handle: AppHandle<R>, _app_handle: AppHandle<R>,
server_id: String, server_id: String,
session_id: String, session_id: String,
) -> Result<String, String> { ) -> Result<String, String> {
let mut query_params = HashMap::new(); let query_params = HashMap::new();
let path = format!("/chat/{}/_open", session_id); let path = format!("/chat/{}/_open", session_id);
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None) let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
.await .await
.map_err(|e| format!("Error open session: {}", e))?; .map_err(|e| format!("Error open session: {}", e))?;
handle_raw_response(response).await? common::http::get_response_body_text(response).await
} }
#[tauri::command] #[tauri::command]
pub async fn close_session_chat<R: Runtime>( pub async fn close_session_chat<R: Runtime>(
app_handle: AppHandle<R>, _app_handle: AppHandle<R>,
server_id: String, server_id: String,
session_id: String, session_id: String,
) -> Result<String, String> { ) -> Result<String, String> {
let mut query_params = HashMap::new(); let query_params = HashMap::new();
let path = format!("/chat/{}/_close", session_id); let path = format!("/chat/{}/_close", session_id);
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None) let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
.await .await
.map_err(|e| format!("Error close session: {}", e))?; .map_err(|e| format!("Error close session: {}", e))?;
handle_raw_response(response).await? common::http::get_response_body_text(response).await
} }
#[tauri::command] #[tauri::command]
pub async fn cancel_session_chat<R: Runtime>( pub async fn cancel_session_chat<R: Runtime>(
app_handle: AppHandle<R>, _app_handle: AppHandle<R>,
server_id: String, server_id: String,
session_id: String, session_id: String,
) -> Result<String, String> { ) -> Result<String, String> {
let mut query_params = HashMap::new(); let query_params = HashMap::new();
let path = format!("/chat/{}/_cancel", session_id); let path = format!("/chat/{}/_cancel", session_id);
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None) let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
.await .await
.map_err(|e| format!("Error cancel session: {}", e))?; .map_err(|e| format!("Error cancel session: {}", e))?;
handle_raw_response(response).await? common::http::get_response_body_text(response).await
} }
#[tauri::command] #[tauri::command]
pub async fn new_chat<R: Runtime>( pub async fn new_chat<R: Runtime>(
app_handle: AppHandle<R>, _app_handle: AppHandle<R>,
server_id: String, server_id: String,
websocket_id: String,
message: String, message: String,
query_params: Option<HashMap<String, Value>>, //search,deep_thinking query_params: Option<HashMap<String, Value>>,
) -> Result<GetResponse, String> { ) -> Result<GetResponse, String> {
let body = if !message.is_empty() { let body = if !message.is_empty() {
let message = ChatRequestMessage { let message = ChatRequestMessage {
message: Some(message), message: Some(message),
}; };
let body = reqwest::Body::from(serde_json::to_string(&message).unwrap()); Some(
Some(body) serde_json::to_string(&message)
.map_err(|e| format!("Failed to serialize message: {}", e))?
.into(),
)
} else { } else {
None None
}; };
let response = HttpClient::post(&server_id, "/chat/_new", query_params, body) let mut headers = HashMap::new();
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
let response =
HttpClient::advanced_post(&server_id, "/chat/_new", Some(headers), query_params, body)
.await .await
.map_err(|e| format!("Error sending message: {}", e))?; .map_err(|e| format!("Error sending message: {}", e))?;
if response.status().as_u16() < 200 || response.status().as_u16() >= 400 { let body_text = common::http::get_response_body_text(response).await?;
return Err("Failed to send message".to_string());
}
let chat_response: GetResponse = response let chat_response: GetResponse =
.json() serde_json::from_str(&body_text).map_err(|e| format!("Failed to parse response JSON: {}", e))?;
.await
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
// Check the result and status fields
if chat_response.result != "created" { if chat_response.result != "created" {
return Err(format!("Unexpected result: {}", chat_response.result)); return Err(format!("Unexpected result: {}", chat_response.result));
} }
@@ -154,8 +153,9 @@ pub async fn new_chat<R: Runtime>(
#[tauri::command] #[tauri::command]
pub async fn send_message<R: Runtime>( pub async fn send_message<R: Runtime>(
app_handle: AppHandle<R>, _app_handle: AppHandle<R>,
server_id: String, server_id: String,
websocket_id: String,
session_id: String, session_id: String,
message: String, message: String,
query_params: Option<HashMap<String, Value>>, //search,deep_thinking query_params: Option<HashMap<String, Value>>, //search,deep_thinking
@@ -165,11 +165,94 @@ pub async fn send_message<R: Runtime>(
message: Some(message), message: Some(message),
}; };
let mut headers = HashMap::new();
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap()); let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap());
let response = let response = HttpClient::advanced_post(
HttpClient::advanced_post(&server_id, path.as_str(), None, query_params, Some(body)) &server_id,
path.as_str(),
Some(headers),
query_params,
Some(body),
)
.await .await
.map_err(|e| format!("Error cancel session: {}", e))?; .map_err(|e| format!("Error cancel session: {}", e))?;
handle_raw_response(response).await? common::http::get_response_body_text(response).await
}
#[tauri::command]
pub async fn delete_session_chat(server_id: String, session_id: String) -> Result<bool, String> {
let response =
HttpClient::delete(&server_id, &format!("/chat/{}", session_id), None, None).await?;
if response.status().is_success() {
Ok(true)
} else {
Err(format!("Delete failed with status: {}", response.status()))
}
}
#[tauri::command]
pub async fn update_session_chat(
server_id: String,
session_id: String,
title: Option<String>,
context: Option<HashMap<String, Value>>,
) -> Result<bool, String> {
let mut body = HashMap::new();
if let Some(title) = title {
body.insert("title".to_string(), Value::String(title));
}
if let Some(context) = context {
body.insert(
"context".to_string(),
Value::Object(context.into_iter().collect()),
);
}
let response = HttpClient::put(
&server_id,
&format!("/chat/{}", session_id),
None,
None,
Some(reqwest::Body::from(serde_json::to_string(&body).unwrap())),
)
.await
.map_err(|e| format!("Error updating session: {}", e))?;
Ok(response.status().is_success())
}
#[tauri::command]
pub async fn assistant_search<R: Runtime>(
_app_handle: AppHandle<R>,
server_id: String,
from: u32,
size: u32,
query: Option<HashMap<String, Value>>,
) -> Result<Value, String> {
let mut body = serde_json::json!({
"from": from,
"size": size,
});
if let Some(q) = query {
body["query"] = serde_json::to_value(q).map_err(|e| e.to_string())?;
}
let response = HttpClient::post(
&server_id,
"/assistant/_search",
None,
Some(reqwest::Body::from(body.to_string())),
)
.await
.map_err(|e| format!("Error searching assistants: {}", e))?;
response
.json::<Value>()
.await
.map_err(|err| err.to_string())
} }

View File

@@ -60,7 +60,10 @@ fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, Stri
} }
#[tauri::command] #[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::fs::File;
use std::io::Write; use std::io::Write;

View File

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

View File

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

View File

@@ -0,0 +1,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::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -16,3 +18,35 @@ pub struct Source {
pub updated: String, pub updated: String,
pub status: String, pub status: String,
} }
pub async fn get_response_body_text(response: Response) -> Result<String, String> {
let status = response.status().as_u16();
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response body: {}, code: {}", e, status))?;
log::debug!("Response status: {}, body: {}", status, &body);
if status < 200 || status >= 400 {
// Try to parse the error body
let fallback_error = "Failed to send message".to_string();
if body.trim().is_empty() {
return Err(fallback_error);
}
match serde_json::from_str::<common::error::ErrorResponse>(&body) {
Ok(parsed_error) => {
dbg!(&parsed_error);
Err(format!(
"Server error ({}): {}",
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 register;
pub mod assistant; pub mod assistant;
pub mod http; pub mod http;
pub mod error;
pub static MAIN_WINDOW_LABEL: &str = "main"; pub static MAIN_WINDOW_LABEL: &str = "main";
pub static SETTINGS_WINDOW_LABEL: &str = "settings"; pub static SETTINGS_WINDOW_LABEL: &str = "settings";

View File

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

View File

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

View File

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

View File

@@ -29,6 +29,11 @@ pub struct AuthProvider {
pub sso: Sso, pub sso: Sso,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MinimalClientVersion {
number: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Server { pub struct Server {
#[serde(default = "default_empty_string")] // Custom default function for empty string #[serde(default = "default_empty_string")] // Custom default function for empty string
@@ -39,6 +44,7 @@ pub struct Server {
pub endpoint: String, pub endpoint: String,
pub provider: Provider, pub provider: Provider,
pub version: Version, pub version: Version,
pub minimal_client_version: Option<MinimalClientVersion>,
pub updated: String, pub updated: String,
#[serde(default = "default_enabled_type")] #[serde(default = "default_enabled_type")]
pub enabled: bool, pub enabled: bool,
@@ -70,7 +76,6 @@ impl Hash for Server {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerAccessToken { pub struct ServerAccessToken {
#[serde(default = "default_empty_string")] // Custom default function for empty string #[serde(default = "default_empty_string")] // Custom default function for empty string

View File

@@ -1,10 +1,8 @@
use crate::common::search::{QueryResponse, QuerySource}; use crate::common::error::SearchError;
use thiserror::Error;
use async_trait::async_trait;
// use std::{future::Future, pin::Pin}; // use std::{future::Future, pin::Pin};
use crate::common::search::SearchQuery; use crate::common::search::SearchQuery;
use serde::Serialize; use crate::common::search::{QueryResponse, QuerySource};
use async_trait::async_trait;
#[async_trait] #[async_trait]
pub trait SearchSource: Send + Sync { pub trait SearchSource: Send + Sync {
@@ -13,32 +11,3 @@ pub trait SearchSource: Send + Sync {
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>; async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>;
} }
#[derive(Debug, Error, Serialize)]
pub enum SearchError {
#[error("HTTP request failed: {0}")]
HttpError(String),
#[error("Invalid response format: {0}")]
ParseError(String),
#[error("Timeout occurred")]
Timeout,
#[error("Unknown error: {0}")]
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 local;
mod search; mod search;
mod server; mod server;
mod settings;
mod setup; mod setup;
mod shortcut; mod shortcut;
mod util; mod util;
@@ -11,21 +12,19 @@ mod util;
use crate::common::register::SearchSourceRegistry; use crate::common::register::SearchSourceRegistry;
// use crate::common::traits::SearchSource; // use crate::common::traits::SearchSource;
use crate::common::{MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL}; use crate::common::{MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
use crate::server::search::CocoSearchSource;
use crate::server::servers::{load_or_insert_default_server, load_servers_token}; use crate::server::servers::{load_or_insert_default_server, load_servers_token};
use autostart::{change_autostart, enable_autostart}; use autostart::{change_autostart, enable_autostart};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use reqwest::Client;
use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::OnceLock;
use tauri::async_runtime::block_on;
use tauri::plugin::TauriPlugin;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use tauri::ActivationPolicy; use tauri::ActivationPolicy;
use tauri::{ use tauri::{
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, State, WebviewWindow, Window, AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, Window, WindowEvent,
WindowEvent,
}; };
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
use tokio::runtime::Runtime as RT;
/// Tauri store name /// Tauri store name
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store"; pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
@@ -34,8 +33,12 @@ lazy_static! {
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None); static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
} }
/// To allow us to access tauri's `AppHandle` when its context is inaccessible,
/// store it globally. It will be set in `init()`.
pub(crate) static GLOBAL_TAURI_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
#[tauri::command] #[tauri::command]
fn change_window_height(handle: AppHandle, height: u32) { async fn change_window_height(handle: AppHandle, height: u32) {
let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap(); let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
let mut size = window.outer_size().unwrap(); let mut size = window.outer_size().unwrap();
@@ -45,10 +48,12 @@ fn change_window_height(handle: AppHandle, height: u32) {
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct ThemeChangedPayload { struct ThemeChangedPayload {
#[allow(dead_code)]
is_dark_mode: bool, is_dark_mode: bool,
} }
#[derive(Clone, serde::Serialize)] #[derive(Clone, serde::Serialize)]
#[allow(dead_code)]
struct Payload { struct Payload {
args: Vec<String>, args: Vec<String>,
cwd: String, cwd: String,
@@ -56,9 +61,7 @@ struct Payload {
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let mut ctx = tauri::generate_context!(); let ctx = tauri::generate_context!();
// Initialize logger
env_logger::init();
let mut app_builder = tauri::Builder::default(); let mut app_builder = tauri::Builder::default();
@@ -83,7 +86,10 @@ pub fn run() {
.plugin(tauri_plugin_fs_pro::init()) .plugin(tauri_plugin_fs_pro::init())
.plugin(tauri_plugin_macos_permissions::init()) .plugin(tauri_plugin_macos_permissions::init())
.plugin(tauri_plugin_screenshots::init()) .plugin(tauri_plugin_screenshots::init())
.plugin(tauri_plugin_process::init()); .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_windows_version::init())
.plugin(set_up_tauri_logger());
// Conditional compilation for macOS // Conditional compilation for macOS
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -111,7 +117,8 @@ pub fn run() {
server::servers::disable_server, server::servers::disable_server,
server::auth::handle_sso_callback, server::auth::handle_sso_callback,
server::profile::get_user_profiles, server::profile::get_user_profiles,
server::datasource::get_datasources_by_server, server::datasource::datasource_search,
server::datasource::mcp_server_search,
server::connector::get_connectors_by_server, server::connector::get_connectors_by_server,
search::query_coco_fusion, search::query_coco_fusion,
assistant::chat_history, assistant::chat_history,
@@ -121,32 +128,54 @@ pub fn run() {
assistant::open_session_chat, assistant::open_session_chat,
assistant::close_session_chat, assistant::close_session_chat,
assistant::cancel_session_chat, assistant::cancel_session_chat,
assistant::delete_session_chat,
assistant::update_session_chat,
assistant::assistant_search,
// server::get_coco_server_datasources, // server::get_coco_server_datasources,
// server::get_coco_server_connectors, // server::get_coco_server_connectors,
server::websocket::connect_to_server, server::websocket::connect_to_server,
server::websocket::disconnect, 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| { .setup(|app| {
let app_handle = app.handle().clone();
GLOBAL_TAURI_APP_HANDLE
.set(app_handle.clone())
.expect("variable already initialized");
let registry = SearchSourceRegistry::default(); let registry = SearchSourceRegistry::default();
app.manage(registry); // Store registry in Tauri's app state app.manage(registry); // Store registry in Tauri's app state
app.manage(server::websocket::WebSocketManager::default()); app.manage(server::websocket::WebSocketManager::default());
// Get app handle block_on(async {
let app_handle = app.handle().clone(); init(app.handle()).await;
// Create a single Tokio runtime instance
let rt = RT::new().expect("Failed to create Tokio runtime");
// Use the runtime to spawn the async initialization tasks
let init_app_handle = app.handle().clone();
rt.spawn(async move {
init(&init_app_handle).await; // Pass a reference to `app_handle`
}); });
shortcut::enable_shortcut(&app); shortcut::enable_shortcut(app);
// enable_tray(app);
enable_autostart(app); enable_autostart(app);
#[cfg(target_os = "macos")] #[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(); let coco_servers = server::servers::get_all_servers();
// Get the registry from Tauri's state // 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 { for server in coco_servers {
let source = CocoSearchSource::new(server.clone(), Client::new()); crate::server::servers::try_register_server_to_search_source(app_handle.clone(), &server)
registry.register_source(source).await; .await;
} }
}
async fn init_app_search_source<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> { local::start_pizza_engine_runtime();
let application_search =
local::application::ApplicationSearchSource::new(app_handle.clone(), 1000f64).await?;
// Register the application search source
let registry = app_handle.state::<SearchSourceRegistry>();
registry.register_source(application_search).await;
Ok(())
} }
#[tauri::command] #[tauri::command]
async fn show_coco(app_handle: AppHandle) { async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
handle_open_coco(&app_handle); if let Some(window) = app_handle.get_window(MAIN_WINDOW_LABEL) {
}
#[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) {
move_window_to_active_monitor(&window); move_window_to_active_monitor(&window);
window.show().unwrap(); let _ = window.show();
window.set_visible_on_all_workspaces(true).unwrap(); let _ = window.unminimize();
window.set_always_on_top(true).unwrap(); let _ = window.set_focus();
window.set_focus().unwrap();
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)] #[allow(dead_code)]
fn open_settings(app: &tauri::AppHandle) { fn open_settings(app: &tauri::AppHandle) {
use tauri::webview::WebviewBuilder; use tauri::webview::WebviewBuilder;
println!("settings menu item was clicked"); println!("settings menu item was clicked");
let window = app.get_webview_window("settings"); let window = app.get_webview_window("settings");
if let Some(window) = window { if let Some(window) = window {
window.show().unwrap(); let _ = window.show();
window.set_focus().unwrap(); let _ = window.unminimize();
let _ = window.set_focus();
} else { } else {
let window = tauri::window::WindowBuilder::new(app, "settings") let window = tauri::window::WindowBuilder::new(app, "settings")
.title("Settings Window") .title("Settings Window")
@@ -477,7 +415,7 @@ fn open_settings(app: &tauri::AppHandle) {
#[tauri::command] #[tauri::command]
async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> { async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> {
init_app_search_source(&app_handle).await?; local::init_local_search_source(&app_handle).await?;
let _ = server::connector::refresh_all_connectors(&app_handle).await; let _ = server::connector::refresh_all_connectors(&app_handle).await;
let _ = server::datasource::refresh_all_datasources(&app_handle).await; let _ = server::datasource::refresh_all_datasources(&app_handle).await;
@@ -488,3 +426,98 @@ async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(
async fn show_settings(app_handle: AppHandle) { async fn show_settings(app_handle: AppHandle) {
open_settings(&app_handle); open_settings(&app_handle);
} }
#[tauri::command]
async fn simulate_mouse_click<R: Runtime>(window: WebviewWindow<R>, is_chat_mode: bool) {
#[cfg(target_os = "windows")]
{
use enigo::{Button, Coordinate, Direction, Enigo, Mouse, Settings};
use std::{thread, time::Duration};
if let Ok(mut enigo) = Enigo::new(&Settings::default()) {
// Save the current mouse position
if let Ok((original_x, original_y)) = enigo.location() {
// Retrieve the window's outer position (top-left corner)
if let Ok(position) = window.outer_position() {
// Retrieve the window's inner size (client area)
if let Ok(size) = window.inner_size() {
// Calculate the center position of the title bar
let x = position.x + (size.width as i32 / 2);
let y = if is_chat_mode {
position.y + size.height as i32 - 50
} else {
position.y + 30
};
// Move the mouse cursor to the calculated position
if enigo.move_mouse(x, y, Coordinate::Abs).is_ok() {
// // Simulate a left mouse click
let _ = enigo.button(Button::Left, Direction::Click);
// let _ = enigo.button(Button::Left, Direction::Release);
thread::sleep(Duration::from_millis(100));
// Move the mouse cursor back to the original position
let _ = enigo.move_mouse(original_x, original_y, Coordinate::Abs);
}
}
}
}
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = window;
let _ = is_chat_mode;
}
}
/// Log format:
///
/// ```text
/// [time] [log level] [file module:line] message
/// ```
///
/// Example:
///
///
/// ```text
/// [05-11 17:00:00] [INF] [coco_lib:625] Coco-AI started
/// ```
fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
use log::Level;
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 application;
pub mod calculator;
pub mod file_system; pub mod file_system;
use std::any::Any;
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::common::register::SearchSourceRegistry;
use serde_json::Value as Json;
use tauri::{AppHandle, Manager, Runtime};
use tauri_plugin_store::StoreExt;
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local"; pub const 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::register::SearchSourceRegistry;
use crate::common::search::{ use crate::common::search::{
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery, FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
}; };
use crate::common::traits::SearchError;
use futures::stream::FuturesUnordered; use futures::stream::FuturesUnordered;
use futures::StreamExt; use futures::StreamExt;
use std::collections::HashMap; use std::collections::HashMap;
@@ -16,7 +16,10 @@ pub async fn query_coco_fusion<R: Runtime>(
from: u64, from: u64,
size: u64, size: u64,
query_strings: HashMap<String, String>, query_strings: HashMap<String, String>,
query_timeout: u64,
) -> Result<MultiSourceQueryResponse, SearchError> { ) -> Result<MultiSourceQueryResponse, SearchError> {
let query_source_to_search = query_strings.get("querysource");
let search_sources = app_handle.state::<SearchSourceRegistry>(); let search_sources = app_handle.state::<SearchSourceRegistry>();
let sources_future = search_sources.get_sources(); let sources_future = search_sources.get_sources();
@@ -26,11 +29,19 @@ pub async fn query_coco_fusion<R: Runtime>(
let sources_list = sources_future.await; let sources_list = sources_future.await;
// Time limit for each query // Time limit for each query
let timeout_duration = Duration::from_millis(500); //TODO, settings let timeout_duration = Duration::from_millis(query_timeout);
// Push all queries into futures // Push all queries into futures
for query_source in sources_list { for query_source in sources_list {
let query_source_type = query_source.get_type().clone(); 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); sources.insert(query_source_type.id.clone(), query_source_type);
let query = SearchQuery::new(from, size, query_strings.clone()); 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)); .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)) => { Ok(Err(err)) => {
failed_requests.push(FailedRequest { failed_requests.push(FailedRequest {
source: QuerySource { source: QuerySource {
@@ -84,7 +107,7 @@ pub async fn query_coco_fusion<R: Runtime>(
}); });
} }
// Timeout reached, skip this request // Timeout reached, skip this request
Ok(_) => { _ => {
failed_requests.push(FailedRequest { failed_requests.push(FailedRequest {
source: QuerySource { source: QuerySource {
r#type: "N/A".into(), r#type: "N/A".into(),
@@ -92,19 +115,7 @@ pub async fn query_coco_fusion<R: Runtime>(
id: "N/A".into(), id: "N/A".into(),
}, },
status: 0, status: 0,
error: Some("Query source timed out".to_string()), error: Some(format!("{:?}", &result)),
reason: None,
});
}
Err(_) => {
failed_requests.push(FailedRequest {
source: QuerySource {
r#type: "N/A".into(),
name: "N/A".into(),
id: "N/A".into(),
},
status: 0,
error: Some("Task panicked".to_string()),
reason: None, 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::common::server::ServerAccessToken;
use crate::server::http_client::HttpClient;
use crate::server::profile::get_user_profiles; use crate::server::profile::get_user_profiles;
use crate::server::search::CocoSearchSource; use crate::server::servers::{
use crate::server::servers::{get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server}; get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server,
use reqwest::{Client, StatusCode}; try_register_server_to_search_source,
use std::collections::HashMap; };
use tauri::{AppHandle, Manager, Runtime}; use tauri::{AppHandle, Runtime};
#[allow(dead_code)]
fn request_access_token_url(request_id: &str) -> String { fn request_access_token_url(request_id: &str) -> String {
// Remove the endpoint part and keep just the path for the request // Remove the endpoint part and keep just the path for the request
format!("/auth/request_access_token?request_id={}", request_id) format!("/auth/request_access_token?request_id={}", request_id)
@@ -23,40 +22,16 @@ pub async fn handle_sso_callback<R: Runtime>(
// Retrieve the server details using the server ID // Retrieve the server details using the server ID
let server = get_server_by_id(&server_id); let server = get_server_by_id(&server_id);
let expire_in = 3600; // TODO, need to update to actual expire_in value
if let Some(mut server) = server { if let Some(mut server) = server {
// Prepare the URL for requesting the access token (endpoint is base URL, path is relative)
// save_access_token(server_id.clone(), ServerAccessToken::new(server_id.clone(), code.clone(), 60 * 15));
let path = request_access_token_url(&request_id);
// Send the request for the access token using the util::http::HttpClient::get method
let mut header = HashMap::new();
header.insert("Authorization".to_string(), format!("Bearer {}", code).to_string());
let response = HttpClient::advanced_post(&server_id, &path, Some(header), None, None)
.await
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
if response.status() == StatusCode::OK {
// Check if the response has a valid content length
if let Some(content_length) = response.content_length() {
if content_length > 0 {
// Deserialize the response body to get the access token
let token_result: Result<RequestAccessTokenResponse, _> = response.json().await;
match token_result {
Ok(token) => {
// Save the access token for the server // Save the access token for the server
let access_token = ServerAccessToken::new( let access_token = ServerAccessToken::new(server_id.clone(), code.clone(), expire_in);
server_id.clone(),
token.access_token.clone(),
token.expire_in,
);
// dbg!(&server_id, &request_id, &code, &token); // dbg!(&server_id, &request_id, &code, &token);
save_access_token(server_id.clone(), access_token); save_access_token(server_id.clone(), access_token);
persist_servers_token(&app_handle)?; persist_servers_token(&app_handle)?;
let registry = app_handle.state::<SearchSourceRegistry>(); // Register the server to the search source
let source = CocoSearchSource::new(server.clone(), Client::new()); try_register_server_to_search_source(app_handle.clone(), &server).await;
registry.register_source(source).await;
// Update the server's profile using the util::http::HttpClient::get method // Update the server's profile using the util::http::HttpClient::get method
let profile = get_user_profiles(app_handle.clone(), server_id.clone()).await; let profile = get_user_profiles(app_handle.clone(), server_id.clone()).await;
@@ -72,24 +47,6 @@ pub async fn handle_sso_callback<R: Runtime>(
} }
Err(e) => Err(format!("Failed to get user profile: {}", e)), Err(e) => Err(format!("Failed to get user profile: {}", e)),
} }
}
Err(e) => Err(format!("Failed to deserialize the token response: {}", e)),
}
} else {
Err("Received empty response body.".to_string())
}
} else {
Err("Could not determine the content length.".to_string())
}
} else {
Err(format!(
"Request failed with status: {}, URL: {}, Code: {}, Response: {:?}",
response.status(),
path,
code,
response
))
}
} else { } else {
Err(format!( Err(format!(
"Server not found for ID: {}, Request ID: {}, Code: {}", "Server not found for ID: {}, Request ID: {}, Code: {}",

View File

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

View File

@@ -8,6 +8,13 @@ use std::collections::HashMap;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime};
#[derive(serde::Deserialize, Debug)]
pub struct GetDatasourcesByServerOptions {
pub from: Option<u32>,
pub size: Option<u32>,
pub query: Option<String>,
}
lazy_static! { lazy_static! {
static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String, HashMap<String, DataSource>>>> = static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String, HashMap<String, DataSource>>>> =
Arc::new(RwLock::new(HashMap::new())); Arc::new(RwLock::new(HashMap::new()));
@@ -22,6 +29,7 @@ pub fn save_datasource_to_cache(server_id: &str, datasources: Vec<DataSource>) {
cache.insert(server_id.to_string(), datasources_map); cache.insert(server_id.to_string(), datasources_map);
} }
#[allow(dead_code)]
pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, DataSource>> { pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, DataSource>> {
let cache = DATASOURCE_CACHE.read().unwrap(); // Acquire read lock let cache = DATASOURCE_CACHE.read().unwrap(); // Acquire read lock
// dbg!("cache: {:?}", &cache); // dbg!("cache: {:?}", &cache);
@@ -29,7 +37,7 @@ pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, Dat
Some(server_cache.clone()) 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"); // dbg!("Attempting to refresh all datasources");
let servers = get_all_servers(); let servers = get_all_servers();
@@ -39,9 +47,12 @@ pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> R
for server in servers { for server in servers {
// dbg!("fetch datasources for server: {}", &server.id); // dbg!("fetch datasources for server: {}", &server.id);
if !server.enabled {
continue;
}
// Attempt to get datasources by server, and continue even if it fails // Attempt to get datasources by server, and continue even if it fails
let connectors = let connectors = match datasource_search(server.id.as_str(), None).await {
match get_datasources_by_server(server.id.as_str()).await {
Ok(connectors) => { Ok(connectors) => {
// Process connectors only after fetching them // Process connectors only after fetching them
let connectors_map: HashMap<String, DataSource> = connectors let connectors_map: HashMap<String, DataSource> = connectors
@@ -79,23 +90,52 @@ pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> R
cache.extend(server_map); cache.extend(server_map);
cache.len() cache.len()
}; };
// dbg!("datasource_map size: {:?}", cache_size);
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
pub async fn get_datasources_by_server( pub async fn datasource_search(
id: &str, id: &str,
options: Option<GetDatasourcesByServerOptions>,
) -> Result<Vec<DataSource>, String> { ) -> Result<Vec<DataSource>, String> {
let from = options.as_ref().and_then(|opt| opt.from).unwrap_or(0);
let size = options.as_ref().and_then(|opt| opt.size).unwrap_or(10000);
let query = options
.and_then(|opt| opt.query)
.unwrap_or(String::default());
let mut body = serde_json::json!({
"from": from,
"size": size,
});
if !query.is_empty() {
body["query"] = serde_json::json!({
"bool": {
"must": [{
"query_string": {
"fields": ["combined_fulltext"],
"query": query,
"fuzziness": "AUTO",
"fuzzy_prefix_length": 2,
"fuzzy_max_expansions": 10,
"fuzzy_transpositions": true,
"allow_leading_wildcard": false
}
}]
}
});
}
// Perform the async HTTP request outside the cache lock // Perform the async HTTP request outside the cache lock
let resp = HttpClient::get(id, "/datasource/_search", None) let resp = HttpClient::post(
id,
"/datasource/_search",
None,
Some(reqwest::Body::from(body.to_string())),
)
.await .await
.map_err(|e| { .map_err(|e| format!("Error fetching datasource: {}", e))?;
// dbg!("Error fetching datasource: {}", &e);
format!("Error fetching datasource: {}", e)
})?;
// Parse the search results from the response // Parse the search results from the response
let datasources: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| { let datasources: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
@@ -108,3 +148,59 @@ pub async fn get_datasources_by_server(
Ok(datasources) 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 crate::server::servers::{get_server_by_id, get_server_token};
use http::HeaderName; use http::{HeaderName, HeaderValue};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use reqwest::{Client, Method, RequestBuilder}; use reqwest::{Client, Method, RequestBuilder};
use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use tauri::ipc::RuntimeCapability; use tauri_plugin_store::JsonValue;
use tokio::sync::Mutex; use tokio::sync::Mutex;
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| { pub(crate) fn new_reqwest_http_client(accept_invalid_certs: bool) -> Client {
let client = Client::builder() Client::builder()
.read_timeout(Duration::from_secs(3)) // Set a timeout of 3 second .read_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
.connect_timeout(Duration::from_secs(3)) // Set a timeout of 3 second .connect_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
.timeout(Duration::from_secs(10)) // Set a timeout of 10 seconds .timeout(Duration::from_secs(10)) // Set a timeout of 10 seconds
.danger_accept_invalid_certs(true) // example for self-signed certificates .danger_accept_invalid_certs(accept_invalid_certs) // allow self-signed certificates
.build() .build()
.expect("Failed to build client"); .expect("Failed to build client")
Mutex::new(client) }
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
let allow_self_signature = crate::settings::_get_allow_self_signature(
crate::GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app store not set")
.clone(),
);
Mutex::new(new_reqwest_http_client(allow_self_signature))
}); });
pub struct HttpClient; pub struct HttpClient;
@@ -32,14 +40,33 @@ impl HttpClient {
pub async fn send_raw_request( pub async fn send_raw_request(
method: Method, method: Method,
url: &str, url: &str,
query_params: Option<HashMap<String, Value>>, query_params: Option<HashMap<String, JsonValue>>,
headers: Option<HashMap<String, String>>, headers: Option<HashMap<String, String>>,
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
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) Ok(response)
} }
@@ -47,7 +74,7 @@ impl HttpClient {
method: Method, method: Method,
url: &str, url: &str,
headers: Option<HashMap<String, String>>, headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> RequestBuilder { ) -> RequestBuilder {
let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT
@@ -55,21 +82,51 @@ impl HttpClient {
// Build the request // Build the request
let mut request_builder = client.request(method.clone(), url); let mut request_builder = client.request(method.clone(), url);
if let Some(h) = headers { if let Some(h) = headers {
let mut req_headers = reqwest::header::HeaderMap::new(); let mut req_headers = reqwest::header::HeaderMap::new();
for (key, value) in h.into_iter() { for (key, value) in h.into_iter() {
let _ = req_headers.insert( match (
HeaderName::from_bytes(key.as_bytes()).unwrap(), HeaderName::from_bytes(key.as_bytes()),
reqwest::header::HeaderValue::from_str(&value).unwrap(), HeaderValue::from_str(value.trim()),
) {
(Ok(name), Ok(val)) => {
req_headers.insert(name, val);
}
(Err(e), _) => {
eprintln!("Invalid header name: {:?}, error: {}", key, e);
}
(_, Err(e)) => {
eprintln!(
"Invalid header value for {}: {:?}, error: {}",
key, value, e
); );
} }
}
}
request_builder = request_builder.headers(req_headers); request_builder = request_builder.headers(req_headers);
} }
if let Some(query) = query_params { if let Some(query) = query_params {
// Convert only supported value types into strings
let query: HashMap<String, String> = query
.into_iter()
.filter_map(|(k, v)| {
match v {
JsonValue::String(s) => Some((k, s)),
JsonValue::Number(n) => Some((k, n.to_string())),
JsonValue::Bool(b) => Some((k, b.to_string())),
_ => {
dbg!(
"Unsupported query parameter type. Only strings, numbers, and booleans are supported.",k,v,
);
None
} // skip arrays, objects, nulls
}
})
.collect();
request_builder = request_builder.query(&query); request_builder = request_builder.query(&query);
} }
// Add body if present // Add body if present
if let Some(b) = body { if let Some(b) = body {
request_builder = request_builder.body(b); request_builder = request_builder.body(b);
@@ -83,7 +140,7 @@ impl HttpClient {
method: Method, method: Method,
path: &str, path: &str,
custom_headers: Option<HashMap<String, String>>, custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, query_params: Option<HashMap<String, JsonValue>>,
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
// Fetch the server using the server_id // Fetch the server using the server_id
@@ -93,26 +150,27 @@ impl HttpClient {
let url = HttpClient::join_url(&s.endpoint, path); let url = HttpClient::join_url(&s.endpoint, path);
// Retrieve the token for the server (token is optional) // 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 { let mut headers = if let Some(custom_headers) = custom_headers {
custom_headers custom_headers
} else { } else {
let mut headers = HashMap::new(); let headers = HashMap::new();
headers headers
}; };
if let Some(t) = token { if let Some(t) = token {
headers.insert( headers.insert("X-API-TOKEN".to_string(), t);
"X-API-TOKEN".to_string(),
t,
);
} }
log::debug!(
// dbg!(&server_id); "Sending request to server: {}, url: {}, headers: {:?}",
// dbg!(&url); &server_id,
// dbg!(&headers); &url,
&headers
);
Self::send_raw_request(method, &url, query_params, Some(headers), body).await Self::send_raw_request(method, &url, query_params, Some(headers), body).await
} else { } else {
@@ -121,7 +179,10 @@ impl HttpClient {
} }
// Convenience method for GET requests (as it's the most common) // 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> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::GET, path, None, query_params, None).await HttpClient::send_request(server_id, Method::GET, path, None, query_params, None).await
} }
@@ -130,7 +191,7 @@ impl HttpClient {
pub async fn post( pub async fn post(
server_id: &str, server_id: &str,
path: &str, path: &str,
query_params: Option<HashMap<String, Value>>, // Add query parameters query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::POST, path, None, query_params, body).await HttpClient::send_request(server_id, Method::POST, path, None, query_params, body).await
@@ -140,27 +201,56 @@ impl HttpClient {
server_id: &str, server_id: &str,
path: &str, path: &str,
custom_headers: Option<HashMap<String, String>>, custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request(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 // Convenience method for PUT requests
#[allow(dead_code)]
pub async fn put( pub async fn put(
server_id: &str, server_id: &str,
path: &str, path: &str,
custom_headers: Option<HashMap<String, String>>, custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
body: Option<reqwest::Body>, body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> { ) -> Result<reqwest::Response, String> {
HttpClient::send_request(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 // Convenience method for DELETE requests
pub async fn delete(server_id: &str, path: &str, custom_headers: Option<HashMap<String, String>>, #[allow(dead_code)]
query_params: Option<HashMap<String, Value>>, // Add query parameters 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> { ) -> 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. //! This file contains Rust APIs related to Coco Server management.
pub mod attachment;
pub mod auth; pub mod auth;
pub mod servers;
pub mod connector; pub mod connector;
pub mod datasource; pub mod datasource;
pub mod http_client; pub mod http_client;
pub mod profile; pub mod profile;
pub mod search; pub mod search;
pub mod servers;
pub mod system_settings;
pub mod transcription;
pub mod websocket; pub mod websocket;

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
use crate::common::http::get_response_body_text;
use crate::common::register::SearchSourceRegistry; use crate::common::register::SearchSourceRegistry;
use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version}; use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version};
use crate::server::connector::fetch_connectors_by_server; use crate::server::connector::fetch_connectors_by_server;
use crate::server::datasource::get_datasources_by_server; use crate::server::datasource::datasource_search;
use crate::server::http_client::HttpClient; use crate::server::http_client::HttpClient;
use crate::server::search::CocoSearchSource; use crate::server::search::CocoSearchSource;
use crate::COCO_TAURI_STORE; use crate::COCO_TAURI_STORE;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use reqwest::{Client, Method, StatusCode}; use reqwest::Method;
use serde_json::from_value; use serde_json::from_value;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use std::collections::HashMap; use std::collections::HashMap;
@@ -24,6 +25,7 @@ lazy_static! {
Arc::new(RwLock::new(HashMap::new())); Arc::new(RwLock::new(HashMap::new()));
} }
#[allow(dead_code)]
fn check_server_exists(id: &str) -> bool { fn check_server_exists(id: &str) -> bool {
let cache = SERVER_CACHE.read().unwrap(); // Acquire read lock let cache = SERVER_CACHE.read().unwrap(); // Acquire read lock
cache.contains_key(id) cache.contains_key(id)
@@ -35,9 +37,10 @@ pub fn get_server_by_id(id: &str) -> Option<Server> {
} }
#[tauri::command] #[tauri::command]
pub fn get_server_token(id: &str) -> Option<ServerAccessToken> { pub async fn get_server_token(id: &str) -> Result<Option<ServerAccessToken>, String> {
let cache = SERVER_TOKEN.read().unwrap(); // Acquire read lock let cache = SERVER_TOKEN.read().map_err(|err| err.to_string())?;
cache.get(id).cloned()
Ok(cache.get(id).cloned())
} }
pub fn save_access_token(server_id: String, token: ServerAccessToken) -> bool { pub fn save_access_token(server_id: String, token: ServerAccessToken) -> bool {
@@ -132,6 +135,7 @@ fn get_default_server() -> Server {
version: Version { version: Version {
number: "1.0.0_SNAPSHOT".to_string(), number: "1.0.0_SNAPSHOT".to_string(),
}, },
minimal_client_version: None,
updated: "2025-01-24T12:12:17.326286927+08:00".to_string(), updated: "2025-01-24T12:12:17.326286927+08:00".to_string(),
public: false, public: false,
available: true, available: true,
@@ -259,7 +263,6 @@ pub async fn load_or_insert_default_server<R: Runtime>(
pub async fn list_coco_servers<R: Runtime>( pub async fn list_coco_servers<R: Runtime>(
_app_handle: AppHandle<R>, _app_handle: AppHandle<R>,
) -> Result<Vec<Server>, String> { ) -> Result<Vec<Server>, String> {
//hard fresh all server's info, in order to get the actual health //hard fresh all server's info, in order to get the actual health
refresh_all_coco_server_info(_app_handle.clone()).await; refresh_all_coco_server_info(_app_handle.clone()).await;
@@ -267,6 +270,7 @@ pub async fn list_coco_servers<R: Runtime>(
Ok(servers) Ok(servers)
} }
#[allow(dead_code)]
pub fn get_servers_as_hashmap() -> HashMap<String, Server> { pub fn get_servers_as_hashmap() -> HashMap<String, Server> {
let cache = SERVER_CACHE.read().unwrap(); let cache = SERVER_CACHE.read().unwrap();
cache.clone() cache.clone()
@@ -282,9 +286,7 @@ pub const COCO_SERVERS: &str = "coco_servers";
const COCO_SERVER_TOKENS: &str = "coco_server_tokens"; const COCO_SERVER_TOKENS: &str = "coco_server_tokens";
pub async fn refresh_all_coco_server_info<R: Runtime>( pub async fn refresh_all_coco_server_info<R: Runtime>(app_handle: AppHandle<R>) {
app_handle: AppHandle<R>,
) {
let servers = get_all_servers(); let servers = get_all_servers();
for server in servers { for server in servers {
let _ = refresh_coco_server_info(app_handle.clone(), server.id.clone()).await; 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, id: String,
) -> Result<Server, String> { ) -> Result<Server, String> {
// Retrieve the server from the cache // Retrieve the server from the cache
let server = { let cached_server = {
let cache = SERVER_CACHE.read().unwrap(); let cache = SERVER_CACHE.read().unwrap();
cache.get(&id).cloned() cache.get(&id).cloned()
}; };
if let Some(server) = server { let server = match cached_server {
Some(server) => server,
None => return Err("Server not found.".into()),
};
// Preserve important local state
let is_enabled = server.enabled; let is_enabled = server.enabled;
let is_builtin = server.builtin; let is_builtin = server.builtin;
let profile = server.profile; let profile = server.profile;
// Use the HttpClient to send the request // Send request to fetch updated server info
let response = HttpClient::get(&id, "/provider/_info", None) // Assuming "/provider-info" is the endpoint let response = HttpClient::get(&id, "/provider/_info", None)
.await .await
.map_err(|e| format!("Failed to send request to the server: {}", e))?; .map_err(|e| format!("Failed to contact the server: {}", e))?;
if response.status() == StatusCode::OK { if !response.status().is_success() {
if let Some(content_length) = response.content_length() { mark_server_as_offline(&id).await;
if content_length > 0 { return Err(format!("Request failed with status: {}", response.status()));
let new_coco_server: Result<Server, _> = response.json().await; }
match new_coco_server {
Ok(mut server) => { // Get body text via helper
server.id = id.clone(); let body = get_response_body_text(response).await?;
server.builtin = is_builtin;
server.enabled = is_enabled; // Deserialize server
server.available = true; let mut updated_server: Server = serde_json::from_str(&body)
server.profile = profile; .map_err(|e| format!("Failed to deserialize the response: {}", e))?;
trim_endpoint_last_forward_slash(&mut server);
save_server(&server); // Restore local state
updated_server.id = id.clone();
updated_server.builtin = is_builtin;
updated_server.enabled = is_enabled;
updated_server.available = true;
updated_server.profile = profile;
trim_endpoint_last_forward_slash(&mut updated_server);
// Save and persist
save_server(&updated_server);
persist_servers(&app_handle) persist_servers(&app_handle)
.await .await
.expect("Failed to persist coco servers."); .map_err(|e| format!("Failed to persist servers: {}", e))?;
//refresh connectors and datasources // Refresh connectors and datasources (best effort)
let _ = fetch_connectors_by_server(&id).await; let _ = fetch_connectors_by_server(&id).await;
let _ = datasource_search(&id, None).await;
let _ = get_datasources_by_server(&id).await; Ok(updated_server)
Ok(server)
}
Err(e) => Err(format!("Failed to deserialize the response: {:?}", e)),
}
} else {
Err("Received empty response body.".to_string())
}
} else {
mark_server_as_offline(id.as_str()).await;
Err("Could not determine the content length.".to_string())
}
} else {
mark_server_as_offline(id.as_str()).await;
Err(format!("Request failed with status: {}", response.status()))
}
} else {
Err("Server not found.".to_string())
}
} }
#[tauri::command] #[tauri::command]
@@ -362,12 +359,10 @@ pub async fn add_coco_server<R: Runtime>(
) -> Result<Server, String> { ) -> Result<Server, String> {
load_or_insert_default_server(&app_handle) load_or_insert_default_server(&app_handle)
.await .await
.expect("Failed to load default servers"); .map_err(|e| format!("Failed to load default servers: {}", e))?;
// Remove the trailing '/' from the endpoint to ensure correct URL construction
let endpoint = endpoint.trim_end_matches('/'); let endpoint = endpoint.trim_end_matches('/');
// Check if the server with this endpoint already exists
if check_endpoint_exists(endpoint) { if check_endpoint_exists(endpoint) {
dbg!(format!( dbg!(format!(
"This Coco server has already been registered: {:?}", "This Coco server has already been registered: {:?}",
@@ -376,24 +371,18 @@ pub async fn add_coco_server<R: Runtime>(
return Err("This Coco server has already been registered.".into()); return Err("This Coco server has already been registered.".into());
} }
let url = provider_info_url(&endpoint); let url = provider_info_url(endpoint);
// Use the HttpClient to fetch provider information
let response = HttpClient::send_raw_request(Method::GET, url.as_str(), None, None, None) let response = HttpClient::send_raw_request(Method::GET, url.as_str(), None, None, None)
.await .await
.map_err(|e| format!("Failed to send request to the server: {}", e))?; .map_err(|e| format!("Failed to send request to the server: {}", e))?;
dbg!(format!("Get provider info response: {:?}", &response)); dbg!(format!("Get provider info response: {:?}", &response));
// Check if the response status is OK (200) let body = get_response_body_text(response).await?;
if response.status() == StatusCode::OK {
if let Some(content_length) = response.content_length() { let mut server: Server = serde_json::from_str(&body)
if content_length > 0 { .map_err(|e| format!("Failed to deserialize the response: {}", e))?;
let new_coco_server: Result<Server, _> = response.json().await;
match new_coco_server {
Ok(mut server) => {
// Perform necessary checks and adjustments on the server data
trim_endpoint_last_forward_slash(&mut server); trim_endpoint_last_forward_slash(&mut server);
if server.id.is_empty() { if server.id.is_empty() {
@@ -401,35 +390,18 @@ pub async fn add_coco_server<R: Runtime>(
} }
if server.name.is_empty() { if server.name.is_empty() {
server.name = "Coco Cloud".to_string(); server.name = "Coco Server".to_string();
} }
// Save the new server to the cache
save_server(&server); save_server(&server);
try_register_server_to_search_source(app_handle.clone(), &server).await;
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) persist_servers(&app_handle)
.await .await
.expect("Failed to persist Coco servers."); .map_err(|e| format!("Failed to persist Coco servers: {}", e))?;
dbg!(format!("Successfully registered server: {:?}", &endpoint)); dbg!(format!("Successfully registered server: {:?}", &endpoint));
Ok(server) Ok(server)
}
Err(e) => Err(format!("Failed to deserialize the response: {}", e)),
}
} else {
Err("Received empty response body.".to_string())
}
} else {
Err("Could not determine the content length.".to_string())
}
} else {
Err(format!("Request failed with status: {}", response.status()))
}
} }
#[tauri::command] #[tauri::command]
@@ -459,9 +431,8 @@ pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
server.enabled = true; server.enabled = true;
save_server(&server); save_server(&server);
let registry = app_handle.state::<SearchSourceRegistry>(); // Register the server to the search source
let source = CocoSearchSource::new(server.clone(), Client::new()); try_register_server_to_search_source(app_handle.clone(), &server).await;
registry.register_source(source).await;
persist_servers(&app_handle) persist_servers(&app_handle)
.await .await
@@ -470,6 +441,16 @@ pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
Ok(()) 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) { pub async fn mark_server_as_offline(id: &str) {
// println!("server_is_offline: {}", id); // 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); dbg!("Attempting to log out server by id:", &id);
// Check if server token exists // Check if server token exists
if let Some(_token) = get_server_token(id.as_str()) { if let Some(_token) = get_server_token(id.as_str()).await? {
dbg!("Found server token for id:", &id); dbg!("Found server token for id:", &id);
// Remove the server token from cache // Remove the server token from cache
@@ -584,6 +565,7 @@ fn test_trim_endpoint_last_forward_slash() {
version: Version { version: Version {
number: "".to_string(), number: "".to_string(),
}, },
minimal_client_version: None,
updated: "".to_string(), updated: "".to_string(),
public: false, public: false,
available: 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 crate::server::servers::{get_server_by_id, get_server_token};
use futures_util::{SinkExt, StreamExt}; use futures::StreamExt;
use http::{HeaderMap, HeaderName, HeaderValue}; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tauri::Emitter; use tauri::{AppHandle, Emitter, Runtime};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::Error; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::Error as WsError; use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::{ use tokio_tungstenite::WebSocketStream;
connect_async, tungstenite::protocol::Message, MaybeTlsStream, WebSocketStream, use tokio_tungstenite::{connect_async_tls_with_config, Connector};
};
use tungstenite::handshake::client::generate_key;
#[derive(Default)] #[derive(Default)]
pub struct WebSocketManager { pub struct WebSocketManager {
ws_connection: Arc<Mutex<Option<WebSocketStream<MaybeTlsStream<TcpStream>>>>>, connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>,
cancel_tx: Arc<Mutex<Option<mpsc::Sender<()>>>>, }
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> { fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?; let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?;
// Determine WebSocket protocol based on the scheme
let ws_protocol = if url.scheme() == "https" { let ws_protocol = if url.scheme() == "https" {
"wss://" "wss://"
} else { } else {
"ws://" "ws://"
}; };
let host = url.host_str().ok_or("No host found in URL")?;
// Extract host and port (if present)
let host = url.host_str().ok_or_else(|| "No host found in URL")?;
let port = url let port = url
.port_or_known_default() .port_or_known_default()
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 }); .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 { let ws_endpoint = if port == 80 || port == 443 {
format!("{}{}{}", ws_protocol, host, "/ws") format!("{}{}{}", ws_protocol, host, "/ws")
} else { } else {
format!("{}{}:{}/ws", ws_protocol, host, port) format!("{}{}:{}/ws", ws_protocol, host, port)
}; };
Ok(ws_endpoint) 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] #[tauri::command]
pub async fn connect_to_server( pub async fn connect_to_server<R: Runtime>(
tauri_app_handle: AppHandle<R>,
id: String, id: String,
client_id: String,
state: tauri::State<'_, WebSocketManager>, state: tauri::State<'_, WebSocketManager>,
app_handle: tauri::AppHandle, app_handle: AppHandle,
) -> Result<(), String> { ) -> Result<(), String> {
// Disconnect any existing connection first let connections_clone = state.connections.clone();
disconnect(state.clone()).await?;
// Retrieve server details // Disconnect old connection first
let server = disconnect(client_id.clone(), state.clone()).await.ok();
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())?;
// Retrieve the token for the server (token is optional) let server = get_server_by_id(&id).ok_or(format!("Server with ID {} not found", id))?;
let token = get_server_token(id.as_str()).map(|t| t.access_token.clone()); 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 = let mut request =
tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint) tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint)
.map_err(|e| format!("Failed to create WebSocket request: {}", e))?; .map_err(|e| format!("Failed to create WebSocket request: {}", e))?;
// Add necessary headers
request request
.headers_mut() .headers_mut()
.insert("Connection", "Upgrade".parse().unwrap()); .insert("Connection", "Upgrade".parse().unwrap());
@@ -95,88 +74,93 @@ pub async fn connect_to_server(
.headers_mut() .headers_mut()
.insert("Sec-WebSocket-Key", generate_key().parse().unwrap()); .insert("Sec-WebSocket-Key", generate_key().parse().unwrap());
// If a token exists, add it to the headers
if let Some(token) = token { if let Some(token) = token {
request request
.headers_mut() .headers_mut()
.insert("X-API-TOKEN", token.parse().unwrap()); .insert("X-API-TOKEN", token.parse().unwrap());
} }
// Establish the WebSocket connection let allow_self_signature =
// dbg!(&request); crate::settings::get_allow_self_signature(tauri_app_handle.clone()).await;
let (mut ws_remote, _) = connect_async(request).await.map_err(|e| match e { let tls_connector = tokio_native_tls::native_tls::TlsConnector::builder()
Error::ConnectionClosed => "WebSocket connection was closed".to_string(), .danger_accept_invalid_certs(allow_self_signature)
Error::Protocol(protocol_error) => format!("Protocol error: {}", protocol_error), .build()
Error::Utf8 => "UTF-8 error in WebSocket data".to_string(), .map_err(|e| format!("TLS build error: {:?}", e))?;
_ => format!("Unknown 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); let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
// Store connection and cancellation sender let instance = Arc::new(WebSocketInstance {
*state.ws_connection.lock().await = Some(ws_remote); ws_connection: Mutex::new(ws_stream),
*state.cancel_tx.lock().await = Some(cancel_tx); cancel_tx,
// Spawn listener task with cancellation });
// 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 app_handle_clone = app_handle.clone();
let connection_clone = state.ws_connection.clone(); let client_id_clone = client_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
let mut connection = connection_clone.lock().await; let ws = &mut *instance.ws_connection.lock().await;
if let Some(ws) = connection.as_mut() {
loop { loop {
tokio::select! { tokio::select! {
msg = ws.next() => { msg = ws.next() => {
match msg { match msg {
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {
//println!("Received message: {}", text); let _ = app_handle_clone.emit(&format!("ws-message-{}", client_id_clone), text);
let _ = app_handle_clone.emit("ws-message", text);
}, },
Some(Err(WsError::ConnectionClosed)) => { Some(Err(_)) | None => {
let _ = app_handle_clone.emit("ws-error", id); let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
eprintln!("WebSocket connection closed by the server.");
break; 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,
} }
} }
_ = cancel_rx.recv() => { _ = cancel_rx.recv() => {
let _ = app_handle_clone.emit("ws-error", id); let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
dbg!("Cancelling WebSocket connection");
break; break;
} }
} }
} }
}
// Remove connection after it closes
let mut connections = connections_clone.lock().await;
connections.remove(&client_id_clone);
}); });
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
pub async fn disconnect(state: tauri::State<'_, WebSocketManager>) -> Result<(), String> { pub async fn disconnect(
// Send cancellation signal client_id: String,
if let Some(cancel_tx) = state.cancel_tx.lock().await.take() { state: tauri::State<'_, WebSocketManager>,
let _ = cancel_tx.send(()).await; ) -> Result<(), String> {
} let instance = {
let mut connections = state.connections.lock().await;
connections.remove(&client_id)
};
// Close connection if let Some(instance) = instance {
let mut connection = state.ws_connection.lock().await; let _ = instance.cancel_tx.send(()).await;
if let Some(mut ws) = connection.take() {
// Close WebSocket (lock only the connection, not the whole map)
let mut ws = instance.ws_connection.lock().await;
let _ = ws.close(None).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 //credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
use tauri::{ActivationPolicy, App, Emitter, EventTarget, WebviewWindow}; use tauri::{ActivationPolicy, App, Emitter, EventTarget, WebviewWindow};
use tauri_nspanel::{ use tauri_nspanel::{cocoa::appkit::NSWindowCollectionBehavior, panel_delegate, WebviewWindowExt};
cocoa::appkit::{NSMainMenuWindowLevel, NSWindowCollectionBehavior},
panel_delegate, WebviewWindowExt,
};
use crate::common::MAIN_WINDOW_LABEL; use crate::common::MAIN_WINDOW_LABEL;
@@ -22,7 +19,7 @@ pub fn platform(app: &mut App, main_window: WebviewWindow, _settings_window: Web
let panel = main_window.to_panel().unwrap(); let panel = main_window.to_panel().unwrap();
// Make the window above the dock // Make the window above the dock
panel.set_level(NSMainMenuWindowLevel + 1); panel.set_level(20);
// Do not steal focus from other windows // Do not steal focus from other windows
panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel); panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel);

View File

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

View File

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

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, "visible": false,
"windowEffects": { "windowEffects": {
"effects": [], "effects": [],
"radius": 12 "radius": 6
} },
"visibleOnAllWorkspaces": true,
"alwaysOnTop": true
}, },
{ {
"label": "settings", "label": "settings",
@@ -113,7 +115,7 @@
"updater": { "updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlDRjNDRUU0NTdBMzdCRTMKUldUamU2Tlg1TTd6bkUwZWM0d2Zjdk0wdXJmendWVlpMMmhKN25EcmprYmIydnJ3dmFUME9QYXkK", "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlDRjNDRUU0NTdBMzdCRTMKUldUamU2Tlg1TTd6bkUwZWM0d2Zjdk0wdXJmendWVlpMMmhKN25EcmprYmIydnJ3dmFUME9QYXkK",
"endpoints": [ "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": {}, "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 { fetch } from "@tauri-apps/plugin-http";
import { invoke } from "@tauri-apps/api/core";
import { clientEnv } from "@/utils/env"; import { clientEnv } from "@/utils/env";
import { useLogStore } from "@/stores/logStore"; import { useLogStore } from "@/stores/logStore";
import { get_server_token } from "@/commands";
interface FetchRequestConfig { interface FetchRequestConfig {
url: string; url: string;
method?: "GET" | "POST" | "PUT" | "DELETE"; method?: "GET" | "POST" | "PUT" | "DELETE";
@@ -63,7 +62,7 @@ export const tauriFetch = async <T = any>({
} }
const server_id = connectStore.state?.currentService?.id || "default_coco_server" 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; headers["X-API-TOKEN"] = headers["X-API-TOKEN"] || res?.access_token || undefined;

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

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

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, useCallback,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo,
useRef, useRef,
useState, useState,
} from "react"; } 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 { 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 { useConnectStore } from "@/stores/connectStore";
import { useSearchStore } from "@/stores/searchStore"; import { useWindows } from "@/hooks/useWindows";
import FileList from "@/components/Search/FileList";
import { Greetings } from "./Greetings";
import ConnectPrompt from "./ConnectPrompt";
import useMessageChunkData from "@/hooks/useMessageChunkData"; import useMessageChunkData from "@/hooks/useMessageChunkData";
import useWebSocket from "@/hooks/useWebSocket"; 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 { interface ChatAIProps {
isTransitioned: boolean;
isSearchActive?: boolean; isSearchActive?: boolean;
isDeepThinkActive?: boolean; isDeepThinkActive?: boolean;
isMCPActive?: boolean;
activeChatProp?: Chat; activeChatProp?: Chat;
changeInput?: (val: string) => void; changeInput?: (val: string) => void;
setIsSidebarOpen?: (value: boolean) => void; setIsSidebarOpen?: (value: boolean) => void;
isSidebarOpen?: boolean; isSidebarOpen?: boolean;
clearChatPage?: () => void; clearChatPage?: () => void;
isChatPage?: boolean; isChatPage?: boolean;
getFileUrl: (path: string) => string;
showChatHistory?: boolean;
assistantIDs?: string[];
} }
export interface ChatAIRef { export interface ChatAIRef {
@@ -49,60 +49,63 @@ const ChatAI = memo(
forwardRef<ChatAIRef, ChatAIProps>( forwardRef<ChatAIRef, ChatAIProps>(
( (
{ {
isTransitioned,
changeInput, changeInput,
isSearchActive, isSearchActive,
isDeepThinkActive, isDeepThinkActive,
isMCPActive,
activeChatProp, activeChatProp,
setIsSidebarOpen, setIsSidebarOpen,
isSidebarOpen = false, isSidebarOpen = false,
clearChatPage, clearChatPage,
isChatPage = false, isChatPage = false,
getFileUrl,
showChatHistory,
assistantIDs,
}, },
ref ref
) => { ) => {
if (!isTransitioned) return null;
const { t } = useTranslation();
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
init: init, init: init,
cancelChat: cancelChat, cancelChat: () => cancelChat(activeChat),
reconnect: reconnect, reconnect: reconnect,
clearChat: clearChat, clearChat: clearChat,
})); }));
const { createWin } = useWindows();
const { curChatEnd, setCurChatEnd, connected, setConnected } = const { curChatEnd, setCurChatEnd, connected, setConnected } =
useChatStore(); useChatStore();
const currentService = useConnectStore((state) => state.currentService); const currentService = useConnectStore((state) => state.currentService);
const visibleStartPage = useConnectStore((state) => {
return state.visibleStartPage;
});
const addError = useAppStore.getState().addError;
const [activeChat, setActiveChat] = useState<Chat>(); const [activeChat, setActiveChat] = useState<Chat>();
const [timedoutShow, setTimedoutShow] = useState(false); const [timedoutShow, setTimedoutShow] = useState(false);
const [IsLogin, setIsLogin] = useState(true); const [isLogin, setIsLogin] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const curIdRef = useRef(""); const curIdRef = useRef("");
const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen); const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen);
const [chats, setChats] = useState<Chat[]>([]); const [chats, setChats] = useState<Chat[]>([]);
const sourceDataIds = useSearchStore((state) => state.sourceDataIds);
const uploadFiles = useChatStore((state) => state.uploadFiles);
useEffect(() => { useEffect(() => {
activeChatProp && setActiveChat(activeChatProp); activeChatProp && setActiveChat(activeChatProp);
}, [activeChatProp]); }, [activeChatProp]);
const messageTimeoutRef = useRef<NodeJS.Timeout>();
const [Question, setQuestion] = useState<string>(""); const [Question, setQuestion] = useState<string>("");
const [websocketSessionId, setWebsocketSessionId] = useState("");
const onWebsocketSessionId = useCallback((sessionId: string) => {
setWebsocketSessionId(sessionId);
}, []);
const { const {
data: { data: {
query_intent, query_intent,
tools,
fetch_source, fetch_source,
pick_source, pick_source,
deep_read, deep_read,
@@ -115,6 +118,7 @@ const ChatAI = memo(
const [loadingStep, setLoadingStep] = useState<Record<string, boolean>>({ const [loadingStep, setLoadingStep] = useState<Record<string, boolean>>({
query_intent: false, query_intent: false,
tools: false,
fetch_source: false, fetch_source: false,
pick_source: false, pick_source: false,
deep_read: false, deep_read: false,
@@ -122,395 +126,142 @@ const ChatAI = memo(
response: false, response: false,
}); });
const dealMsg = useCallback( const dealMsgRef = useRef<((msg: string) => void) | null>(null);
(msg: string) => {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
if (!msg.includes("PRIVATE")) return; const clientId = isChatPage ? "standalone" : "popup";
const { reconnect, updateDealMsg } = useWebSocket({
messageTimeoutRef.current = setTimeout(() => { clientId,
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({
connected, connected,
setConnected, setConnected,
currentService, currentService,
dealMsg, dealMsgRef,
onWebsocketSessionId,
}); });
const updatedChat = useMemo(() => { const {
if (!activeChat?._id) return null; chatClose,
return { cancelChat,
...activeChat, chatHistory,
messages: [...(activeChat.messages || [])], createNewChat,
}; handleSendMessage,
}, [activeChat]); 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(() => { const { dealMsg } = useMessageHandler(
if (!updatedChat) return; curIdRef,
setCurChatEnd,
// console.log("updatedChat:", updatedChat); setTimedoutShow,
setActiveChat(updatedChat); (chat) => cancelChat(chat || activeChat),
}, [updatedChat]); setLoadingStep,
handlers
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]
); );
useEffect(() => { useEffect(() => {
const container = messagesEndRef.current?.parentElement; if (dealMsg) {
if (!container) return; dealMsgRef.current = dealMsg;
updateDealMsg && updateDealMsg(dealMsg);
const handleScroll = () => {
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
} }
}, [dealMsg, updateDealMsg]);
const { scrollTop, scrollHeight, clientHeight } = container; const clearChat = useCallback(() => {
const isAtBottom = //console.log("clearChat");
Math.abs(scrollHeight - scrollTop - clientHeight) < 10; setTimedoutShow(false);
chatClose(activeChat);
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();
setActiveChat(undefined); setActiveChat(undefined);
setCurChatEnd(true); setCurChatEnd(true);
clearChatPage && clearChatPage(); clearChatPage && clearChatPage();
}; }, [activeChat, chatClose]);
const createNewChat = useCallback( const init = useCallback(
async (value: string = "") => { async (value: string) => {
setTimedoutShow(false);
setErrorShow(false);
chatClose();
clearAllChunkData();
setQuestion(value);
try { try {
console.log("sourceDataIds", sourceDataIds); //console.log("init", isLogin, curChatEnd, activeChat?._id);
let response: any = await invoke("new_chat", { if (!isLogin) {
serverId: currentService?.id, addError("Please login to continue chatting");
message: value, return;
queryParams: { }
search: isSearchActive, if (!curChatEnd) {
deep_thinking: isDeepThinkActive, addError("Please wait for the current conversation to complete");
datasource: sourceDataIds.join(","), return;
},
});
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);
} 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) { if (!activeChat?._id) {
createNewChat(value); await createNewChat(value, activeChat, websocketSessionId);
} else { } else {
handleSendMessage(value); await handleSendMessage(value, activeChat, websocketSessionId);
} }
};
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) { } catch (error) {
setErrorShow(true); console.error("Failed to initialize chat:", error);
console.error("sendMessage:", error);
} }
}, },
[ [
JSON.stringify(activeChat?.messages), isLogin,
currentService?.id, curChatEnd,
sourceDataIds, activeChat?._id,
isSearchActive, createNewChat,
isDeepThinkActive, handleSendMessage,
websocketSessionId,
] ]
); );
const handleSendMessage = useCallback( const { createWin } = useWindows();
async (content: string, newChat?: Chat) => { const openChatAI = useCallback(() => {
newChat = newChat || activeChat; createChatWindow(createWin);
if (!newChat?._id || !content) return; }, [createChatWindow, createWin]);
setQuestion(content);
await chatHistory(newChat, (chat) => sendMessage(content, chat));
const onSelectChat = useCallback(
async (chat: Chat) => {
setTimedoutShow(false); setTimedoutShow(false);
setErrorShow(false);
clearAllChunkData(); 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 () => { const deleteChat = useCallback(
if (!activeChat?._id) return; (chatId: string) => {
try { handleDelete(chatId);
let response: any = await invoke("close_session_chat", {
serverId: currentService?.id,
sessionId: activeChat?._id,
});
response = JSON.parse(response || "");
console.log("_close", response);
} catch (error) {
console.error("chatClose:", error);
}
};
const cancelChat = async () => { setChats((prev) => {
setCurChatEnd(true); const updatedChats = prev.filter((chat) => chat._id !== chatId);
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) { if (activeChat?._id === chatId) {
const remainingChats = chats.filter((chat) => chat._id !== chatId); if (updatedChats.length > 0) {
if (remainingChats.length > 0) { setActiveChat(updatedChats[0]);
setActiveChat(remainingChats[0]);
} else { } else {
init(""); init("");
} }
} }
};
return updatedChats;
});
},
[activeChat?._id, handleDelete, init]
);
const handleOutsideClick = useCallback((e: MouseEvent) => { const handleOutsideClick = useCallback((e: MouseEvent) => {
const sidebar = document.querySelector("[data-sidebar]"); const sidebar = document.querySelector("[data-sidebar]");
@@ -534,155 +285,99 @@ const ChatAI = memo(
}; };
}, [isSidebarOpenChat, handleOutsideClick]); }, [isSidebarOpenChat, handleOutsideClick]);
const getChatHistory = useCallback(async () => { const toggleSidebar = useCallback(() => {
if (!currentService?.id) return; setIsSidebarOpenChat(!isSidebarOpenChat);
try { setIsSidebarOpen && setIsSidebarOpen(!isSidebarOpenChat);
let response: any = await invoke("chat_history", { !isSidebarOpenChat && getChatHistory();
serverId: currentService?.id, }, [isSidebarOpenChat, setIsSidebarOpen, getChatHistory]);
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 setIsLoginChat = useCallback( const renameChat = useCallback(
(value: boolean) => { (chatId: string, title: string) => {
setIsLogin(value); setChats((prev) => {
value && currentService && !setIsSidebarOpen && getChatHistory(); const chatIndex = prev.findIndex((chat) => chat._id === chatId);
!value && setChats([]); 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 ( return (
<div <div
data-tauri-drag-region 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 : ( {showChatHistory && !setIsSidebarOpen && (
<div <ChatSidebar
data-sidebar isSidebarOpen={isSidebarOpenChat}
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} chats={chats}
activeChat={activeChat} activeChat={activeChat}
onNewChat={clearChat} // onNewChat={clearChat}
onSelectChat={onSelectChat} onSelectChat={onSelectChat}
onDeleteChat={deleteChat} onDeleteChat={deleteChat}
fetchChatHistory={getChatHistory}
onSearch={handleSearch}
onRename={renameChat}
/> />
</div>
)} )}
<ChatHeader <ChatHeader
onCreateNewChat={clearChat} onCreateNewChat={clearChat}
onOpenChatAI={openChatAI} onOpenChatAI={openChatAI}
setIsSidebarOpen={() => { setIsSidebarOpen={toggleSidebar}
setIsSidebarOpenChat(!isSidebarOpenChat);
setIsSidebarOpen && setIsSidebarOpen(!isSidebarOpenChat);
!isSidebarOpenChat && getChatHistory();
}}
isSidebarOpen={isSidebarOpenChat} isSidebarOpen={isSidebarOpenChat}
activeChat={activeChat} activeChat={activeChat}
reconnect={reconnect} reconnect={reconnect}
isChatPage={isChatPage} isChatPage={isChatPage}
setIsLogin={setIsLoginChat} isLogin={isLogin}
setIsLogin={setIsLogin}
showChatHistory={showChatHistory}
assistantIDs={assistantIDs}
/> />
{IsLogin ? (
<div className="flex flex-col h-full justify-between overflow-hidden"> {isLogin ? (
<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"> <ChatContent
<Greetings /> activeChat={activeChat}
{activeChat?.messages?.map((message, index) => ( curChatEnd={curChatEnd}
<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} query_intent={query_intent}
tools={tools}
fetch_source={fetch_source} fetch_source={fetch_source}
pick_source={pick_source} pick_source={pick_source}
deep_read={deep_read} deep_read={deep_read}
think={think} think={think}
response={response} response={response}
loadingStep={loadingStep} loadingStep={loadingStep}
timedoutShow={timedoutShow}
Question={Question}
handleSendMessage={(value) =>
handleSendMessage(value, activeChat)
}
getFileUrl={getFileUrl}
/> />
) : 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>
) : ( ) : (
<ConnectPrompt /> <ConnectPrompt />
)} )}
{!activeChat?._id && !visibleStartPage && (
<PrevSuggestion sendMessage={init} />
)}
</div> </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 { import { MessageSquarePlus } from "lucide-react";
MessageSquarePlus, import clsx from "clsx";
ChevronDownIcon,
Settings,
RefreshCw,
Check,
Server,
} from "lucide-react";
import { useState, useEffect, useCallback } from "react";
import {
Menu,
MenuButton,
MenuItem,
MenuItems,
Popover,
PopoverButton,
PopoverPanel,
} from "@headlessui/react";
import { useTranslation } from "react-i18next";
import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import logoImg from "@/assets/icon.svg";
import HistoryIcon from "@/icons/History"; import HistoryIcon from "@/icons/History";
import PinOffIcon from "@/icons/PinOff"; import PinOffIcon from "@/icons/PinOff";
import PinIcon from "@/icons/Pin"; import PinIcon from "@/icons/Pin";
import ServerIcon from "@/icons/Server";
import WindowsFullIcon from "@/icons/WindowsFull"; import WindowsFullIcon from "@/icons/WindowsFull";
import { useAppStore, IServer } from "@/stores/appStore"; import { useAppStore, IServer } from "@/stores/appStore";
import { useChatStore } from "@/stores/chatStore";
import type { Chat } from "./types"; 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 { interface ChatHeaderProps {
onCreateNewChat: () => void; onCreateNewChat: () => void;
@@ -39,110 +21,46 @@ interface ChatHeaderProps {
isSidebarOpen: boolean; isSidebarOpen: boolean;
activeChat: Chat | undefined; activeChat: Chat | undefined;
reconnect: (server?: IServer) => void; reconnect: (server?: IServer) => void;
isLogin: boolean;
setIsLogin: (isLogin: boolean) => void; setIsLogin: (isLogin: boolean) => void;
isChatPage?: boolean; isChatPage?: boolean;
showChatHistory?: boolean;
assistantIDs?: string[];
} }
export function ChatHeader({ export function ChatHeader({
onCreateNewChat, onCreateNewChat,
onOpenChatAI, onOpenChatAI,
isSidebarOpen,
setIsSidebarOpen, setIsSidebarOpen,
activeChat, activeChat,
reconnect, reconnect,
isLogin,
setIsLogin, setIsLogin,
isChatPage = false, isChatPage = false,
showChatHistory = true,
assistantIDs,
}: ChatHeaderProps) { }: ChatHeaderProps) {
const { t } = useTranslation();
const setEndpoint = useAppStore((state) => state.setEndpoint);
const isPinned = useAppStore((state) => state.isPinned); const isPinned = useAppStore((state) => state.isPinned);
const setIsPinned = useAppStore((state) => state.setIsPinned); const setIsPinned = useAppStore((state) => state.setIsPinned);
const { connected, setMessages } = useChatStore(); const isTauri = useAppStore((state) => state.isTauri);
const historicalRecords = useShortcutsStore((state) => {
const [serverList, setServerList] = useState<IServer[]>([]); return state.historicalRecords;
const [isRefreshing, setIsRefreshing] = useState(false);
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore((state) => state.setCurrentService);
const fetchServers = useCallback(async (resetSelection: boolean) => {
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]); const newSession = useShortcutsStore((state) => {
return state.newSession;
useEffect(() => { });
fetchServers(true); const fixedWindow = useShortcutsStore((state) => {
return state.fixedWindow;
const unlisten = listen("login_or_logout", (event) => {
console.log("Login or Logout:", currentService, event);
fetchServers(true);
}); });
return () => { const external = useShortcutsStore((state) => state.external);
// 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 togglePin = async () => { const togglePin = async () => {
try { try {
const newPinned = !isPinned; const newPinned = !isPinned;
await getCurrentWindow().setAlwaysOnTop(newPinned); await platformAdapter.setAlwaysOnTop(newPinned);
setIsPinned(newPinned); setIsPinned(newPinned);
} catch (err) { } catch (err) {
console.error("Failed to toggle window pin state:", 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 ( return (
<header <header
className="flex items-center justify-between py-2 px-3" className="flex items-center justify-between py-2 px-3 select-none"
data-tauri-drag-region data-tauri-drag-region
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{showChatHistory && (
<button <button
data-sidebar-button data-sidebar-button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setIsSidebarOpen(); setIsSidebarOpen();
}} }}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800" aria-controls={isSidebarOpen ? HISTORY_PANEL_ID : void 0}
className="py-1 px-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
> >
<HistoryIcon /> <VisibleKey
</button> shortcut={historicalRecords}
onKeyPress={setIsSidebarOpen}
<Menu>
<MenuButton className="flex items-center gap-1 rounded-full bg-white dark:bg-[#202126] p-1 text-sm/6 font-semibold text-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none">
<img
src={logoImg}
className="w-4 h-4"
alt={t("assistant.message.logo")}
/>
Coco AI
<ChevronDownIcon className="size-4 text-gray-500 dark:text-gray-400" />
</MenuButton>
<MenuItems
transition
anchor="bottom end"
className="w-28 origin-top-right rounded-xl bg-white dark:bg-[#202126] p-1 text-sm/6 text-gray-800 dark:text-white shadow-lg border border-gray-200 dark:border-gray-700 focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0"
> >
<MenuItem> <HistoryIcon className="h-4 w-4" />
<button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 hover:bg-gray-100 dark:hover:bg-gray-700"> </VisibleKey>
<img
src={logoImg}
className="w-4 h-4"
alt={t("assistant.message.logo")}
/>
Coco AI
</button> </button>
</MenuItem> )}
</MenuItems>
</Menu>
<AssistantList assistantIDs={assistantIDs} />
{showChatHistory ? (
<button <button
onClick={onCreateNewChat} onClick={onCreateNewChat}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800" className="p-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
> >
<MessageSquarePlus className="h-4 w-4" /> <VisibleKey shortcut={newSession} onKeyPress={onCreateNewChat}>
<MessageSquarePlus className="h-4 w-4 relative top-0.5" />
</VisibleKey>
</button> </button>
) : null}
</div> </div>
<div> <h2 className="max-w-[calc(100%-200px)] text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
<h2 className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{activeChat?._source?.title || {activeChat?._source?.title ||
activeChat?._source?.message || activeChat?._source?.message ||
activeChat?._id} activeChat?._id}
</h2> </h2>
</div> {isTauri ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={togglePin} onClick={togglePin}
className={`${isPinned ? "text-blue-500" : ""}`} className={clsx("inline-flex", {
"text-blue-500": isPinned,
})}
> >
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
{isPinned ? <PinIcon /> : <PinOffIcon />} {isPinned ? <PinIcon /> : <PinOffIcon />}
</VisibleKey>
</button> </button>
<Popover className="relative"> <ServerList
<PopoverButton className="flex items-center"> isLogin={isLogin}
<ServerIcon /> setIsLogin={setIsLogin}
</PopoverButton> reconnect={reconnect}
onCreateNewChat={onCreateNewChat}
<PopoverPanel className="absolute right-0 z-10 mt-2 min-w-[240px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<div className="p-3">
<div className="flex items-center justify-between mb-3 whitespace-nowrap">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
Servers
</h3>
<div className="flex items-center gap-2">
<button
onClick={openSettings}
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
>
<Settings className="h-4 w-4 text-[#0287FF]" />
</button>
<button
onClick={async () => {
setIsRefreshing(true);
await fetchServers(false);
setTimeout(() => setIsRefreshing(false), 1000);
}}
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
disabled={isRefreshing}
>
<RefreshCw
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
isRefreshing ? "animate-spin" : ""
}`}
/> />
</button>
</div>
</div>
<div className="space-y-1">
{serverList.length > 0 ? (
serverList.map((server) => (
<div
key={server.id}
onClick={() => switchServer(server)}
className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap ${
currentService?.id === server.id
? "bg-gray-100 dark:bg-gray-800"
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
}`}
>
<div className="flex items-center gap-2 overflow-hidden min-w-0">
<img
src={server?.provider?.icon || logoImg}
alt={server.name}
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800"
/>
<div className="text-left flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
{server.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
AI Assistant: {server.assistantCount || 1}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span
className={`w-3 h-3 rounded-full ${
server.health?.status
? `bg-[${server.health?.status}]`
: "bg-gray-400 dark:bg-gray-600"
}`}
/>
<div className="w-4 h-4">
{currentService?.id === server.id && (
<Check className="w-full h-full text-gray-500 dark:text-gray-400" />
)}
</div>
</div>
</div>
))
) : (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Server className="w-8 h-8 text-gray-400 dark:text-gray-600 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("assistant.chat.noServers")}
</p>
<button
onClick={openSettings}
className="mt-2 text-xs text-[#0287FF] hover:underline"
>
{t("assistant.chat.addServer")}
</button>
</div>
)}
</div>
</div>
</PopoverPanel>
</Popover>
{isChatPage ? null : ( {isChatPage ? null : (
<button onClick={onOpenChatAI}> <button className="inline-flex" onClick={onOpenChatAI}>
<VisibleKey shortcut={external} onKeyPress={onOpenChatAI}>
<WindowsFullIcon className="rotate-30 scale-x-[-1]" /> <WindowsFullIcon className="rotate-30 scale-x-[-1]" />
</VisibleKey>
</button> </button>
)} )}
</div> </div>
) : (
<div />
)}
</header> </header>
); );
} }

View File

@@ -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 { ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { emit } from "@tauri-apps/api/event";
import LoginDark from "@/assets/images/login-dark.svg"; import LoginDark from "@/assets/images/login-dark.svg";
import LoginLight from "@/assets/images/login-light.svg"; import LoginLight from "@/assets/images/login-light.svg";
import { useThemeStore } from "@/stores/themeStore"; import { useThemeStore } from "@/stores/themeStore";
import platformAdapter from "@/utils/platformAdapter";
const ConnectPrompt = () => { const ConnectPrompt = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -13,7 +13,7 @@ const ConnectPrompt = () => {
const logo = isDark ? LoginDark : LoginLight; const logo = isDark ? LoginDark : LoginLight;
const handleConnect = async () => { const handleConnect = async () => {
emit("open_settings", "connect"); platformAdapter.emitEvent("open_settings", "connect");
}; };
return ( 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 { useTranslation } from "react-i18next";
import { ChatMessage } from "@/components/ChatMessage"; import { ChatMessage } from "@/components/ChatMessage";
import { useConnectStore } from "@/stores/connectStore";
export const Greetings = () => { export const Greetings = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const currentAssistant = useConnectStore((state) => state.currentAssistant);
return ( return (
<ChatMessage <ChatMessage
@@ -12,7 +14,9 @@ export const Greetings = () => {
_id: "greetings", _id: "greetings",
_source: { _source: {
type: "assistant", type: "assistant",
message: t("assistant.chat.greetings"), message:
currentAssistant?._source?.chat_settings?.greeting_message ||
t("assistant.chat.greetings"),
}, },
}} }}
/> />

View File

@@ -0,0 +1,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 { useTranslation } from "react-i18next";
import { MessageSquare, Plus } from "lucide-react"; import { MessageSquare, Plus, RefreshCw } from "lucide-react";
import type { Chat } from "./types"; import type { Chat } from "./types";
@@ -10,6 +11,7 @@ interface SidebarProps {
onSelectChat: (chat: Chat) => void; onSelectChat: (chat: Chat) => void;
onDeleteChat: (chatId: string) => void; onDeleteChat: (chatId: string) => void;
className?: string; className?: string;
fetchChatHistory: () => void;
} }
export function Sidebar({ export function Sidebar({
@@ -18,19 +20,37 @@ export function Sidebar({
onNewChat, onNewChat,
onSelectChat, onSelectChat,
className = "", className = "",
fetchChatHistory,
}: SidebarProps) { }: SidebarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isRefreshing, setIsRefreshing] = useState(false);
return ( return (
<div className={`h-full flex flex-col ${className}`}> <div className={`h-full flex flex-col ${className}`}>
<div className="p-4"> <div className="flex justify-between gap-1 p-4">
<button <button
onClick={onNewChat} 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]`} /> <Plus className={`h-4 w-4 text-[#0072FF] dark:text-[#0072FF]`} />
{t("assistant.sidebar.newChat")} {t("assistant.sidebar.newChat")}
</button> </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>
<div className="flex-1 overflow-y-auto px-3 pb-3 space-y-2 custom-scrollbar"> <div className="flex-1 overflow-y-auto px-3 pb-3 space-y-2 custom-scrollbar">
{chats.map((chat) => ( {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; title?: string;
question?: string; question?: string;
details?: any[]; details?: any[];
assistant_id?: string;
} }
export interface Chat { export interface Chat {
_id: string; _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 [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [Data, setData] = useState<string[]>([]); const [data, setData] = useState<string[]>([]);
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
useEffect(() => { useEffect(() => {
@@ -42,7 +42,7 @@ export const DeepRead = ({
} }
}, [ChunkData?.message_chunk]); }, [ChunkData?.message_chunk]);
// Must be after hooks // Must be after hooks !!!
if (!ChunkData && !Detail) return null; if (!ChunkData && !Detail) return null;
return ( return (
@@ -71,7 +71,7 @@ export const DeepRead = ({
ChunkData?.chunk_type || Detail?.type ChunkData?.chunk_type || Detail?.type
}`, }`,
{ {
count: Number(Data.length), count: Number(data.length),
} }
)} )}
</span> </span>
@@ -84,10 +84,10 @@ export const DeepRead = ({
)} )}
</button> </button>
{isThinkingExpanded && ( {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="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-3 text-xs"> <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 key={item} className="flex flex-col gap-2">
<div className="text-xs text-[#999999] dark:text-[#808080]"> <div className="text-xs text-[#999999] dark:text-[#808080]">
- {item} - {item}

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ export const PickSource = ({
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false); const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [Data, setData] = useState<IData[]>([]); const [data, setData] = useState<IData[]>([]);
useEffect(() => { useEffect(() => {
if (!Detail?.payload) return; if (!Detail?.payload) return;
@@ -36,7 +36,7 @@ export const PickSource = ({
useEffect(() => { useEffect(() => {
if (!ChunkData?.message_chunk) return; if (!ChunkData?.message_chunk) return;
if (loading) { if (!loading) {
try { try {
const cleanContent = ChunkData.message_chunk.replace(/^"|"$/g, ""); const cleanContent = ChunkData.message_chunk.replace(/^"|"$/g, "");
const allMatches = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/g); const allMatches = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/g);
@@ -44,7 +44,7 @@ export const PickSource = ({
if (allMatches) { if (allMatches) {
for (let i = allMatches.length - 1; i >= 0; i--) { for (let i = allMatches.length - 1; i >= 0; i--) {
try { 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()); const data = JSON.parse(jsonString.trim());
if ( if (
@@ -65,7 +65,7 @@ export const PickSource = ({
} }
}, [ChunkData?.message_chunk, loading]); }, [ChunkData?.message_chunk, loading]);
// Must be after hooks // Must be after hooks !!!
if (!ChunkData && !Detail) return null; if (!ChunkData && !Detail) return null;
return ( return (
@@ -90,7 +90,7 @@ export const PickSource = ({
ChunkData?.chunk_type || Detail.type ChunkData?.chunk_type || Detail.type
}`, }`,
{ {
count: Data?.length, count: data?.length,
} }
)} )}
</span> </span>
@@ -103,10 +103,10 @@ export const PickSource = ({
)} )}
</button> </button>
{isThinkingExpanded && ( {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="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-3 text-xs"> <div className="mb-4 space-y-3 text-xs">
{Data?.map((item) => ( {data?.map((item) => (
<div <div
key={item.id} 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" 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 [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
const [Data, setData] = useState<IQueryData | null>(null); const [data, setData] = useState<IQueryData | null>(null);
useEffect(() => { useEffect(() => {
if (!Detail?.payload) return; if (!Detail?.payload) return;
@@ -42,7 +42,7 @@ export const QueryIntent = ({
useEffect(() => { useEffect(() => {
if (!ChunkData?.message_chunk) return; if (!ChunkData?.message_chunk) return;
if (loading) { if (!loading) {
const cleanContent = ChunkData.message_chunk.replace(/^"|"$/g, ""); const cleanContent = ChunkData.message_chunk.replace(/^"|"$/g, "");
const allMatches = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/g); const allMatches = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/g);
if (allMatches) { if (allMatches) {
@@ -66,7 +66,7 @@ export const QueryIntent = ({
} }
}, [ChunkData?.message_chunk]); }, [ChunkData?.message_chunk]);
// Must be after hooks // Must be after hooks !!!
if (!ChunkData && !Detail) return null; if (!ChunkData && !Detail) return null;
return ( return (
@@ -97,18 +97,18 @@ export const QueryIntent = ({
)} )}
</button> </button>
{isThinkingExpanded && ( {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="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-2 text-xs"> <div className="mb-4 space-y-2 text-xs">
{Data?.keyword ? ( {data?.keyword ? (
<div className="flex gap-1"> <div className="flex gap-1">
<span className="text-[#999999]"> <span className="text-[#999999]">
- {t("assistant.message.steps.keywords")} - {t("assistant.message.steps.keywords")}
</span> </span>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{Data?.keyword?.map((keyword, index) => ( {data?.keyword?.map((keyword, index) => (
<span <span
key={index} key={keyword + index}
className="text-[#333333] dark:text-[#D8D8D8]" className="text-[#333333] dark:text-[#D8D8D8]"
> >
{keyword} {keyword}
@@ -118,34 +118,34 @@ export const QueryIntent = ({
</div> </div>
</div> </div>
) : null} ) : null}
{Data?.category ? ( {data?.category ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-[#999999]"> <span className="text-[#999999]">
- {t("assistant.message.steps.questionType")} - {t("assistant.message.steps.questionType")}
</span> </span>
<span className="text-[#333333] dark:text-[#D8D8D8]"> <span className="text-[#333333] dark:text-[#D8D8D8]">
{Data?.category} {data?.category}
</span> </span>
</div> </div>
) : null} ) : null}
{Data?.intent ? ( {data?.intent ? (
<div className="flex items-start gap-1"> <div className="flex items-start gap-1">
<span className="text-[#999999]"> <span className="text-[#999999]">
- {t("assistant.message.steps.userIntent")} - {t("assistant.message.steps.userIntent")}
</span> </span>
<div className="flex-1 text-[#333333] dark:text-[#D8D8D8]"> <div className="flex-1 text-[#333333] dark:text-[#D8D8D8]">
{Data?.intent} {data?.intent}
</div> </div>
</div> </div>
) : null} ) : null}
{Data?.query ? ( {data?.query ? (
<div className="flex items-start gap-1"> <div className="flex items-start gap-1">
<span className="text-[#999999]"> <span className="text-[#999999]">
- {t("assistant.message.steps.relatedQuestions")} - {t("assistant.message.steps.relatedQuestions")}
</span> </span>
<div className="flex-1 flex flex-col text-[#333333] dark:text-[#D8D8D8]"> <div className="flex-1 flex flex-col text-[#333333] dark:text-[#D8D8D8]">
{Data?.query?.map((question) => ( {data?.query?.map((question, qIndex) => (
<span key={question}>- {question}</span> <span key={question + qIndex}>- {question}</span>
))} ))}
</div> </div>
</div> </div>

View File

@@ -14,7 +14,7 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isThinkingExpanded, setIsThinkingExpanded] = useState(true); const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
const [Data, setData] = useState(""); const [data, setData] = useState("");
useEffect(() => { useEffect(() => {
if (!Detail?.description) return; if (!Detail?.description) return;
@@ -24,9 +24,9 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
useEffect(() => { useEffect(() => {
if (!ChunkData?.message_chunk) return; if (!ChunkData?.message_chunk) return;
setData(ChunkData?.message_chunk); 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; if (!ChunkData && !Detail) return null;
return ( return (
@@ -57,9 +57,9 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
)} )}
</button> </button>
{isThinkingExpanded && ( {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="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
{Data?.split("\n").map( {data?.split("\n").map(
(paragraph, idx) => (paragraph, idx) =>
paragraph.trim() && ( paragraph.trim() && (
<p key={idx} className="text-sm"> <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 { useTranslation } from "react-i18next";
import clsx from "clsx";
import logoImg from "@/assets/icon.svg"; import logoImg from "@/assets/icon.svg";
import type { Message, IChunkData } from "@/components/Assistant/types"; import type { Message, IChunkData } from "@/components/Assistant/types";
import { QueryIntent } from "./QueryIntent"; import { QueryIntent } from "./QueryIntent";
import { CallTools } from "./CallTools";
import { FetchSource } from "./FetchSource"; import { FetchSource } from "./FetchSource";
import { PickSource } from "./PickSource"; import { PickSource } from "./PickSource";
import { DeepRead } from "./DeepRead"; import { DeepRead } from "./DeepRead";
@@ -11,11 +13,15 @@ import { Think } from "./Think";
import { MessageActions } from "./MessageActions"; import { MessageActions } from "./MessageActions";
import Markdown from "./Markdown"; import Markdown from "./Markdown";
import { SuggestionList } from "./SuggestionList"; import { SuggestionList } from "./SuggestionList";
import { UserMessage } from "./UserMessage";
import { useConnectStore } from "@/stores/connectStore";
import FontIcon from "@/components/Common/Icons/FontIcon";
interface ChatMessageProps { interface ChatMessageProps {
message: Message; message: Message;
isTyping?: boolean; isTyping?: boolean;
query_intent?: IChunkData; query_intent?: IChunkData;
tools?: IChunkData;
fetch_source?: IChunkData; fetch_source?: IChunkData;
pick_source?: IChunkData; pick_source?: IChunkData;
deep_read?: IChunkData; deep_read?: IChunkData;
@@ -29,6 +35,7 @@ export const ChatMessage = memo(function ChatMessage({
message, message,
isTyping, isTyping,
query_intent, query_intent,
tools,
fetch_source, fetch_source,
pick_source, pick_source,
deep_read, deep_read,
@@ -39,7 +46,24 @@ export const ChatMessage = memo(function ChatMessage({
}: ChatMessageProps) { }: ChatMessageProps) {
const { t } = useTranslation(); 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 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 messageContent = message?._source?.message || "";
const details = message?._source?.details || []; const details = message?._source?.details || [];
const question = message?._source?.question || ""; const question = message?._source?.question || "";
@@ -48,6 +72,7 @@ export const ChatMessage = memo(function ChatMessage({
isTyping === false && (messageContent || response?.message_chunk); isTyping === false && (messageContent || response?.message_chunk);
const [suggestion, setSuggestion] = useState<string[]>([]); const [suggestion, setSuggestion] = useState<string[]>([]);
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
const getSuggestion = (suggestion: string[]) => { const getSuggestion = (suggestion: string[]) => {
setSuggestion(suggestion); setSuggestion(suggestion);
@@ -55,11 +80,7 @@ export const ChatMessage = memo(function ChatMessage({
const renderContent = () => { const renderContent = () => {
if (!isAssistant) { if (!isAssistant) {
return ( return <UserMessage messageContent={messageContent} />;
<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 ( return (
@@ -70,6 +91,13 @@ export const ChatMessage = memo(function ChatMessage({
getSuggestion={getSuggestion} getSuggestion={getSuggestion}
loading={loadingStep?.query_intent} loading={loadingStep?.query_intent}
/> />
<CallTools
Detail={details.find((item) => item.type === "tools")}
ChunkData={tools}
loading={loadingStep?.tools}
/>
<FetchSource <FetchSource
Detail={details.find((item) => item.type === "fetch_source")} Detail={details.find((item) => item.type === "fetch_source")}
ChunkData={fetch_source} ChunkData={fetch_source}
@@ -120,7 +148,13 @@ export const ChatMessage = memo(function ChatMessage({
return ( return (
<div <div
className={`py-8 flex ${isAssistant ? "justify-start" : "justify-end"}`} className={clsx(
"py-8 flex",
[isAssistant ? "justify-start" : "justify-end"],
{
hidden: visibleStartPage,
}
)}
> >
<div <div
className={`px-4 flex gap-4 ${ className={`px-4 flex gap-4 ${
@@ -128,20 +162,28 @@ export const ChatMessage = memo(function ChatMessage({
}`} }`}
> >
<div <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 ? ( {isAssistant ? (
<div className="w-6 h-6 flex justify-center items-center rounded-full bg-white border border-[#E6E6E6]">
{assistant?._source?.icon?.startsWith("font_") ? (
<FontIcon name={assistant._source.icon} className="w-4 h-4" />
) : (
<img <img
src={logoImg} src={logoImg}
className="w-6 h-6" className="w-4 h-4"
alt={t("assistant.message.logo")} alt={t("assistant.message.logo")}
/> />
)}
</div>
) : null} ) : null}
{isAssistant ? t("assistant.message.aiName") : ""} {isAssistant ? assistant?._source?.name || "Coco AI" : ""}
</p> </div>
<div className="prose dark:prose-invert prose-sm max-w-none"> <div className="w-full prose dark:prose-invert prose-sm max-w-none">
<div className="pl-7 text-[#333] dark:text-[#d8d8d8] leading-relaxed"> <div className="w-full pl-7 text-[#333] dark:text-[#d8d8d8] leading-relaxed">
{renderContent()} {renderContent()}
</div> </div>
</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, getCurrent as getCurrentDeepLinkUrls,
onOpenUrl, onOpenUrl,
} from "@tauri-apps/plugin-deep-link"; } from "@tauri-apps/plugin-deep-link";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import clsx from "clsx"; import clsx from "clsx";
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
@@ -28,14 +27,25 @@ import { useAppStore } from "@/stores/appStore";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg"; import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
import SettingsToggle from "@/components/Settings/SettingsToggle"; 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() { export default function Cloud() {
const { t } = useTranslation(); const { t } = useTranslation();
const SidebarRef = useRef<{ refreshData: () => void }>(null); const SidebarRef = useRef<{ refreshData: () => void }>(null);
const error = useAppStore((state) => state.error); const errors = useAppStore((state) => state.errors);
const setError = useAppStore((state) => state.setError); const addError = useAppStore((state) => state.addError);
const [isConnect, setIsConnect] = useState(true); const [isConnect, setIsConnect] = useState(true);
@@ -60,14 +70,13 @@ export default function Cloud() {
console.log("currentService", currentService); console.log("currentService", currentService);
setLoading(false); setLoading(false);
setRefreshLoading(false); setRefreshLoading(false);
setError("");
setIsConnect(true); setIsConnect(true);
}, [JSON.stringify(currentService)]); }, [JSON.stringify(currentService)]);
const fetchServers = async (resetSelection: boolean) => { const fetchServers = async (resetSelection: boolean) => {
invoke("list_coco_servers") list_coco_servers()
.then((res: any) => { .then((res: any) => {
if (error) { if (errors.length > 0) {
res = (res || []).map((item: any) => { res = (res || []).map((item: any) => {
if (item.id === currentService?.id) { if (item.id === currentService?.id) {
item.health = { item.health = {
@@ -92,12 +101,11 @@ export default function Cloud() {
} }
}) })
.catch((err: any) => { .catch((err: any) => {
setError(err);
console.error(err); console.error(err);
}); });
}; };
const add_coco_server = (endpointLink: string) => { const addServer = (endpointLink: string) => {
if (!endpointLink) { if (!endpointLink) {
throw new Error("Endpoint is required"); throw new Error("Endpoint is required");
} }
@@ -110,26 +118,14 @@ export default function Cloud() {
setRefreshLoading(true); setRefreshLoading(true);
return invoke("add_coco_server", { endpoint: endpointLink }) return add_coco_server(endpointLink)
.then((res: any) => { .then((res: any) => {
// console.log("add_coco_server", res); // console.log("add_coco_server", res);
fetchServers(false) fetchServers(false).then((r) => {
.then((r) => {
console.log("fetchServers", r); console.log("fetchServers", r);
setCurrentService(res); 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
})
.finally(() => { .finally(() => {
setRefreshLoading(false); setRefreshLoading(false);
}); });
@@ -137,14 +133,14 @@ export default function Cloud() {
const handleOAuthCallback = useCallback( const handleOAuthCallback = useCallback(
async (code: string | null, serverId: string | null) => { async (code: string | null, serverId: string | null) => {
if (!code) { if (!code || !serverId) {
setError("No authorization code received"); addError("No authorization code received");
return; return;
} }
try { try {
console.log("Handling OAuth callback:", { code, serverId }); 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 serverId: serverId, // Make sure 'server_id' is the correct argument
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
code: code, code: code,
@@ -154,15 +150,9 @@ export default function Cloud() {
refreshClick(serverId); refreshClick(serverId);
} }
getCurrentWindow() getCurrentWindow().setFocus();
.setFocus()
.catch((err) => {
setError(err);
});
} catch (e) { } catch (e) {
console.error("Sign in failed:", e); console.error("Sign in failed:", e);
setError("SSO login failed: " + e);
throw error;
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -181,7 +171,7 @@ export default function Cloud() {
if (reqId != ssoRequestID) { if (reqId != ssoRequestID) {
console.log("Request ID not matched, skip"); console.log("Request ID not matched, skip");
setError("Request ID not matched, skip"); addError("Request ID not matched, skip");
return; return;
} }
@@ -189,7 +179,7 @@ export default function Cloud() {
handleOAuthCallback(code, serverId); handleOAuthCallback(code, serverId);
} catch (err) { } catch (err) {
console.error("Failed to parse URL:", 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) => { .catch((err) => {
console.error("Failed to get initial URLs:", 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])); const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
@@ -256,7 +246,7 @@ export default function Cloud() {
const refreshClick = (id: string) => { const refreshClick = (id: string) => {
setRefreshLoading(true); setRefreshLoading(true);
invoke("refresh_coco_server_info", { id }) refresh_coco_server_info(id)
.then((res: any) => { .then((res: any) => {
console.log("refresh_coco_server_info", id, res); console.log("refresh_coco_server_info", id, res);
fetchServers(false).then((r) => { fetchServers(false).then((r) => {
@@ -266,10 +256,6 @@ export default function Cloud() {
setCurrentService(res); setCurrentService(res);
emit("login_or_logout", true); emit("login_or_logout", true);
}) })
.catch((err: any) => {
setError(err);
console.error(err);
})
.finally(() => { .finally(() => {
setRefreshLoading(false); setRefreshLoading(false);
}); });
@@ -282,49 +268,40 @@ export default function Cloud() {
function onLogout(id: string) { function onLogout(id: string) {
console.log("onLogout", id); console.log("onLogout", id);
setRefreshLoading(true); setRefreshLoading(true);
invoke("logout_coco_server", { id }) logout_coco_server(id)
.then((res: any) => { .then((res: any) => {
console.log("logout_coco_server", id, JSON.stringify(res)); console.log("logout_coco_server", id, JSON.stringify(res));
refreshClick(id); refreshClick(id);
emit("login_or_logout", false); emit("login_or_logout", false);
}) })
.catch((err: any) => {
setError(err);
console.error(err);
})
.finally(() => { .finally(() => {
setRefreshLoading(false); setRefreshLoading(false);
}); });
} }
const remove_coco_server = (id: string) => { const removeServer = (id: string) => {
invoke("remove_coco_server", { id }) remove_coco_server(id).then((res: any) => {
.then((res: any) => {
console.log("remove_coco_server", id, JSON.stringify(res)); console.log("remove_coco_server", id, JSON.stringify(res));
fetchServers(true).then((r) => { fetchServers(true).then((r) => {
console.log("fetchServers", r); console.log("fetchServers", r);
}); });
})
.catch((err: any) => {
// TODO display the error message
setError(err);
console.error(err);
}); });
}; };
const enable_coco_server = useCallback(async (enabled: boolean) => { const enable_coco_server = useCallback(
try { async (enabled: boolean) => {
const command = enabled ? "enable_server" : "disable_server"; if (enabled) {
await enable_server(currentService?.id);
await invoke(command, { id: currentService?.id }); } else {
await disable_server(currentService?.id);
}
setCurrentService({ ...currentService, enabled }); setCurrentService({ ...currentService, enabled });
await fetchServers(false); await fetchServers(false);
} catch (error) { },
setError(error); [currentService?.id]
} );
}, [currentService?.id]);
return ( return (
<div className="flex bg-gray-50 dark:bg-gray-900"> <div className="flex bg-gray-50 dark:bg-gray-900">
@@ -346,9 +323,11 @@ export default function Cloud() {
</div> </div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="flex items-center text-gray-900 dark:text-white font-medium"> <Tooltip content={currentService?.endpoint}>
<div className="flex items-center text-gray-900 dark:text-white font-medium cursor-pointer">
{currentService?.name} {currentService?.name}
</div> </div>
</Tooltip>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SettingsToggle <SettingsToggle
@@ -385,7 +364,7 @@ export default function Cloud() {
{!currentService?.builtin && ( {!currentService?.builtin && (
<button <button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700" 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]" /> <Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
</button> </button>
@@ -457,8 +436,7 @@ export default function Cloud() {
<Copy className="inline mr-2" />{" "} <Copy className="inline mr-2" />{" "}
</button> </button>
<div className="text-justify italic text-xs"> <div className="text-justify italic text-xs">
If the link did not open automatically, please copy {t("cloud.manualCopyLink")}
and paste it into your browser manually.
</div> </div>
</div> </div>
)} )}
@@ -484,7 +462,7 @@ export default function Cloud() {
) : null} ) : null}
</div> </div>
) : ( ) : (
<Connect setIsConnect={setIsConnect} onAddServer={add_coco_server} /> <Connect setIsConnect={setIsConnect} onAddServer={addServer} />
)} )}
</main> </main>
</div> </div>

View File

@@ -13,9 +13,8 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [endpointLink, setEndpointLink] = useState(""); const [endpointLink, setEndpointLink] = useState("");
const [refreshLoading] = useState(false); 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) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -36,17 +35,10 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
typeof err === "string" typeof err === "string"
? err ? err
: err?.message || "An unknown error occurred."; : err?.message || "An unknown error occurred.";
setErrorMessage("ERR:" + errorMessage); addError(errorMessage);
setError(errorMessage);
console.error("Error:", errorMessage);
} }
}; };
// Function to close the error message
const closeError = () => {
setErrorMessage("");
};
return ( return (
<div className="max-w-4xl"> <div className="max-w-4xl">
<div className="flex items-center gap-2 mb-8"> <div className="flex items-center gap-2 mb-8">
@@ -96,31 +88,6 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
</div> </div>
</div> </div>
</form> </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> </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 source_default_dark_img from "@/assets/images/source_default_dark.png";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { useThemeStore } from "@/stores/themeStore"; import { useThemeStore } from "@/stores/themeStore";
import FontIcon from "../Common/Icons/FontIcon";
interface Account { interface Account {
email: string; email: string;
@@ -13,11 +14,12 @@ interface Account {
interface DataSourceItemProps { interface DataSourceItemProps {
name: string; name: string;
icon?: string;
connector: any; connector: any;
accounts?: Account[]; accounts?: Account[];
} }
export function DataSourceItem({ name, connector }: DataSourceItemProps) { export function DataSourceItem({ name, icon, connector }: DataSourceItemProps) {
// const isConnected = true; // const isConnected = true;
const isDark = useThemeStore((state) => state.isDark); 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="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 justify-between mb-4">
<div className="flex items-center space-x-3"> <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"> <span className="font-medium text-gray-900 dark:text-white">
{name} {name}
</span> </span>

View File

@@ -1,43 +1,37 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { RefreshCcw } from "lucide-react"; import { RefreshCcw } from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
import { DataSourceItem } from "./DataSourceItem"; import { DataSourceItem } from "./DataSourceItem";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { useAppStore } from "@/stores/appStore"; import {
get_connectors_by_server,
datasource_search,
} from "@/commands";
export function DataSourcesList({ server }: { server: string }) { export function DataSourcesList({ server }: { server: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const datasourceData = useConnectStore((state) => state.datasourceData); const datasourceData = useConnectStore((state) => state.datasourceData);
const setError = useAppStore((state) => state.setError);
const [refreshLoading, setRefreshLoading] = useState(false); const [refreshLoading, setRefreshLoading] = useState(false);
const setDatasourceData = useConnectStore((state) => state.setDatasourceData); const setDatasourceData = useConnectStore((state) => state.setDatasourceData);
const setConnectorData = useConnectStore((state) => state.setConnectorData); const setConnectorData = useConnectStore((state) => state.setConnectorData);
function initServerAppData({ server }: { server: string }) { function initServerAppData({ server }: { server: string }) {
//fetch datasource data //fetch datasource data
invoke("get_connectors_by_server", { id: server }) get_connectors_by_server(server)
.then((res: any) => { .then((res: any) => {
// console.log("get_connectors_by_server", res); // console.log("get_connectors_by_server", res);
setConnectorData(res, server); setConnectorData(res, server);
}) })
.catch((err: any) => {
setError(err);
throw err; // Propagate error back up
})
.finally(() => {}); .finally(() => {});
//fetch datasource data //fetch datasource data
invoke("get_datasources_by_server", { id: server }) datasource_search(server)
.then((res: any) => { .then((res: any) => {
// console.log("get_datasources_by_server", res); // console.log("datasource_search", res);
setDatasourceData(res, server); setDatasourceData(res, server);
}) })
.catch((err: any) => {
setError(err);
throw err; // Propagate error back up
})
.finally(() => {}); .finally(() => {});
} }
@@ -45,8 +39,6 @@ export function DataSourcesList({ server }: { server: string }) {
setRefreshLoading(true); setRefreshLoading(true);
try { try {
initServerAppData({ server }); initServerAppData({ server });
} catch (e) {
setError(e);
} finally { } finally {
setRefreshLoading(false); setRefreshLoading(false);
} }

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