46 Commits

Author SHA1 Message Date
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
126 changed files with 6668 additions and 3448 deletions

View File

@@ -73,7 +73,7 @@ jobs:
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
- 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

2
.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/*

13
.vscode/settings.json vendored
View File

@@ -4,34 +4,46 @@
"autolaunch", "autolaunch",
"Avenir", "Avenir",
"callout", "callout",
"changelogithub",
"clsx", "clsx",
"codegen", "codegen",
"dataurl",
"dtolnay",
"dyld", "dyld",
"elif",
"fullscreen", "fullscreen",
"headlessui", "headlessui",
"Icdbb", "Icdbb",
"icns", "icns",
"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",
@@ -46,6 +58,7 @@
"VITE", "VITE",
"walkdir", "walkdir",
"webviews", "webviews",
"xzvf",
"yuque", "yuque",
"zustand" "zustand"
], ],

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

@@ -9,14 +9,58 @@ Information about release notes of Coco Server is provided here.
## Latest (In development) ## Latest (In development)
### Breaking changes
### Features ### Features
### Bug fix
### Improvements
## 0.3.0 (2025-03-31)
### Breaking changes
- feat: add web pages components #277
- feat: support for customizing some of the preset shortcuts #316
### Features
- 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
- Featadd endpoint display #282
- Chore: chat window min width & remove input bg #284
- Chore: remove selected function & add hide_coco #286
- Chorewebsocket 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
@@ -25,7 +69,7 @@ Information about release notes of Coco Server is provided here.
- Add api to disable or enable server #185 - Add api to disable or enable server #185
- Networked search supports selection of data sources #209 - Networked search supports selection of data sources #209
- Add deepthink and knowledge search options to RAG based chat - Add deepthink and knowledge search options to RAG based chat
- Support i18n, add Chinese language support - Support i18n, add Chinese language support
- Support Windows platform - Support Windows platform
- etc. - etc.
@@ -54,7 +98,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

View File

@@ -1,11 +1,16 @@
{ {
"name": "coco", "name": "coco",
"private": true, "private": true,
"version": "0.2.0", "version": "0.3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"build:web": "tsc && tsup",
"publish:web": "cd dist/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,19 +18,21 @@
"release-beta": "release-it --preRelease=beta --preReleaseBase=1" "release-beta": "release-it --preRelease=beta --preReleaseBase=1"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.0.0",
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@tauri-apps/api": "^2.3.0", "@tauri-apps/api": "^2.4.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.0",
"@tauri-apps/plugin-dialog": "^2.2.0", "@tauri-apps/plugin-dialog": "^2.2.0",
"@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-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.0", "@tauri-apps/plugin-process": "^2.2.0",
"@tauri-apps/plugin-shell": "^2.2.0", "@tauri-apps/plugin-shell": "^2.2.0",
"@tauri-apps/plugin-updater": "^2.5.1", "@tauri-apps/plugin-updater": "^2.6.1",
"@tauri-apps/plugin-websocket": "~2.3.0", "@tauri-apps/plugin-websocket": "~2.3.0",
"@tauri-apps/plugin-window": "2.0.0-alpha.1", "@tauri-apps/plugin-window": "2.0.0-alpha.1",
"@wavesurfer/react": "^1.0.9",
"ahooks": "^3.8.4", "ahooks": "^3.8.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
@@ -34,8 +41,8 @@
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",
"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.5.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.1",
@@ -49,28 +56,30 @@
"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.3.1",
"tauri-plugin-macos-permissions-api": "^2.1.1", "tauri-plugin-macos-permissions-api": "^2.2.0",
"tauri-plugin-screenshots-api": "^2.1.0", "tauri-plugin-screenshots-api": "^2.1.0",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"wavesurfer.js": "^7.9.3",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.3.1", "@tauri-apps/cli": "^2.4.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.13.11",
"@types/react": "^18.3.18", "@types/react": "^18.3.19",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@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.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.21",
"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",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsup": "^8.4.0",
"tsx": "^4.19.3", "tsx": "^4.19.3",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"vite": "^5.4.14" "vite": "^5.4.14"

1459
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

752
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.3.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"
@@ -47,7 +47,7 @@ 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 = ["rustls-tls-webpki-roots"] }
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"
@@ -68,6 +68,7 @@ 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" env_logger = "0.11.5"
tokio-util = "0.7.14"
[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" }
@@ -89,3 +90,4 @@ strip = true # Ensures debug symbols are removed.
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "^2.2" tauri-plugin-autostart = "^2.2"
tauri-plugin-global-shortcut = "2" tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = "2"

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

@@ -67,6 +67,7 @@
"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"
] ]
} }

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

@@ -8,7 +8,7 @@ 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,
@@ -44,7 +44,7 @@ async fn handle_raw_response(response: Response) -> Result<Result<String, String
#[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,
@@ -69,11 +69,11 @@ pub async fn session_chat_history<R: Runtime>(
#[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)
@@ -85,11 +85,11 @@ pub async fn open_session_chat<R: Runtime>(
#[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)
@@ -100,11 +100,11 @@ pub async fn close_session_chat<R: Runtime>(
} }
#[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)
@@ -116,8 +116,9 @@ pub async fn cancel_session_chat<R: Runtime>(
#[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>>, //search,deep_thinking
) -> Result<GetResponse, String> { ) -> Result<GetResponse, String> {
@@ -131,7 +132,10 @@ pub async fn new_chat<R: Runtime>(
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))?;
@@ -154,8 +158,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,9 +170,12 @@ 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(&server_id, path.as_str(), None, query_params, Some(body)) HttpClient::advanced_post(&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))?;

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,
@@ -22,4 +23,4 @@ pub struct Source {
pub title: Option<String>, pub title: Option<String>,
pub summary: Option<String>, pub summary: Option<String>,
pub manually_renamed_title: bool, pub manually_renamed_title: bool,
} }

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)]

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()
@@ -34,4 +36,4 @@ impl SearchSourceRegistry {
let sources = self.sources.read().await; let sources = self.sources.read().await;
sources.values().cloned().collect() // Returns Vec<Arc<dyn SearchSource>> sources.values().cloned().collect() // Returns Vec<Arc<dyn SearchSource>>
} }
} }

View File

@@ -8,7 +8,7 @@ use std::error::Error;
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>,
} }
@@ -80,6 +80,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,12 +44,13 @@ 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,
#[serde(default = "default_bool_type")] #[serde(default = "default_bool_type")]
pub public: bool, pub public: bool,
#[serde(default = "default_available_type")] #[serde(default = "default_available_type")]
pub available: bool, pub available: 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
@@ -104,11 +109,11 @@ impl Hash for ServerAccessToken {
} }
fn default_empty_string() -> String { fn default_empty_string() -> String {
"".to_string() // Default to empty string if not provided "".to_string() // Default to empty string if not provided
} }
fn default_bool_type() -> bool { fn default_bool_type() -> bool {
false // Default to false if not provided false // Default to false if not provided
} }
fn default_enabled_type() -> bool { fn default_enabled_type() -> bool {
@@ -123,4 +128,4 @@ fn default_priority_type() -> u32 {
} }
fn default_user_profile_type() -> Option<UserProfile> { fn default_user_profile_type() -> Option<UserProfile> {
None None
} }

View File

@@ -25,9 +25,11 @@ pub enum SearchError {
Timeout, Timeout,
#[error("Unknown error: {0}")] #[error("Unknown error: {0}")]
#[allow(dead_code)]
Unknown(String), Unknown(String),
#[error("InternalError error: {0}")] #[error("InternalError error: {0}")]
#[allow(dead_code)]
InternalError(String), InternalError(String),
} }

View File

@@ -11,18 +11,14 @@ 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;
#[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; use tokio::runtime::Runtime as RT;
@@ -35,7 +31,7 @@ lazy_static! {
} }
#[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 +41,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,7 +54,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 // Initialize logger
env_logger::init(); env_logger::init();
@@ -83,7 +81,8 @@ 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());
// Conditional compilation for macOS // Conditional compilation for macOS
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -125,7 +124,11 @@ pub fn run() {
// 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
]) ])
.setup(|app| { .setup(|app| {
let registry = SearchSourceRegistry::default(); let registry = SearchSourceRegistry::default();
@@ -134,7 +137,7 @@ pub fn run() {
app.manage(server::websocket::WebSocketManager::default()); app.manage(server::websocket::WebSocketManager::default());
// Get app handle // Get app handle
let app_handle = app.handle().clone(); // let app_handle = app.handle().clone();
// Create a single Tokio runtime instance // Create a single Tokio runtime instance
let rt = RT::new().expect("Failed to create Tokio runtime"); let rt = RT::new().expect("Failed to create Tokio runtime");
@@ -146,7 +149,7 @@ pub fn run() {
}); });
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,11 +226,11 @@ 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;
} }
} }
@@ -243,37 +246,28 @@ async fn init_app_search_source<R: Runtime>(app_handle: &AppHandle<R>) -> Result
} }
#[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) {
} let _ = app_handle.emit("show-coco", ());
#[tauri::command] move_window_to_active_monitor(&window);
fn hide_coco(app: tauri::AppHandle) {
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) { let _ = window.show();
match window.is_visible() { let _ = window.unminimize();
Ok(true) => { let _ = window.set_focus();
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) { #[tauri::command]
async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) { if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
move_window_to_active_monitor(&window); if let Err(err) = window.hide() {
eprintln!("Failed to hide the window: {}", err);
window.show().unwrap(); } else {
window.set_visible_on_all_workspaces(true).unwrap(); println!("Window successfully hidden.");
window.set_always_on_top(true).unwrap(); }
window.set_focus().unwrap(); } else {
eprintln!("Main window not found.");
} }
} }
@@ -370,88 +364,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")

View File

@@ -31,6 +31,10 @@ impl ApplicationSearchSource {
let apps = ctx.get_all_apps(); let apps = ctx.get_all_apps();
for app in &apps { for app in &apps {
if app.icon_path.is_none() {
continue;
}
let path = if cfg!(target_os = "macos") { let path = if cfg!(target_os = "macos") {
app.app_desktop_path.clone() app.app_desktop_path.clone()
} else { } else {
@@ -120,6 +124,7 @@ impl SearchSource for ApplicationSearchSource {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()), r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some("Applications".into()), name: Some("Applications".into()),
id: Some(file_name_str.clone()), id: Some(file_name_str.clone()),
icon: None,
}), }),
file_path_str.clone(), file_path_str.clone(),
"Application".to_string(), "Application".to_string(),

View File

@@ -0,0 +1,151 @@
use super::servers::{get_server_by_id, get_server_token};
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: String,
pub _id: String,
pub _score: f64,
pub _source: AttachmentSource,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AttachmentHits {
pub total: Value,
pub max_score: f64,
pub hits: Vec<AttachmentHit>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetAttachmentResponse {
pub took: u32,
pub timed_out: bool,
pub _shards: 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())?;
if response.status().is_success() {
let result = response
.json::<UploadAttachmentResponse>()
.await
.map_err(|err| err.to_string())?;
Ok(result)
} else {
Err(format!("Upload failed with status: {}", response.status()))
}
}
#[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?;
if response.status().is_success() {
response
.json::<GetAttachmentResponse>()
.await
.map_err(|e| e.to_string())
} else {
Err(format!("Request failed with status: {}", response.status()))
}
}
#[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?;
if response.status().is_success() {
response
.json::<DeleteAttachmentResponse>()
.await
.map_err(|e| e.to_string())?
.result
.eq("deleted")
.then_some(true)
.ok_or("Delete operation was not successful".to_string())
} else {
Err(format!("Delete failed with status: {}", response.status()))
}
}

View File

@@ -1,13 +1,11 @@
use crate::common::auth::RequestAccessTokenResponse; 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::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::{get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server, try_register_server_to_search_source};
use crate::server::servers::{get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server}; use reqwest::StatusCode;
use reqwest::{Client, StatusCode};
use std::collections::HashMap; use std::collections::HashMap;
use tauri::{AppHandle, Manager, Runtime}; use tauri::{AppHandle, Runtime};
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)
@@ -54,9 +52,8 @@ pub async fn handle_sso_callback<R: Runtime>(
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;

View File

@@ -65,6 +65,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 +97,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 +105,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

@@ -22,6 +22,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 +30,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();
@@ -40,22 +41,21 @@ pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> R
// dbg!("fetch datasources for server: {}", &server.id); // dbg!("fetch datasources for server: {}", &server.id);
// 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 get_datasources_by_server(server.id.as_str()).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 .into_iter()
.into_iter() .map(|connector| (connector.id.clone(), connector))
.map(|connector| (connector.id.clone(), connector)) .collect();
.collect(); // dbg!("connectors_map: {:?}", &connectors_map);
// dbg!("connectors_map: {:?}", &connectors_map); connectors_map
connectors_map }
} Err(_e) => {
Err(_e) => { // dbg!("Failed to get dataSources for server {}: {}", &server.id, e);
// dbg!("Failed to get dataSources for server {}: {}", &server.id, e); HashMap::new()
HashMap::new() }
} };
};
let mut new_map = HashMap::new(); let mut new_map = HashMap::new();
for (id, datasource) in connectors.iter() { for (id, datasource) in connectors.iter() {
@@ -79,21 +79,15 @@ 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 get_datasources_by_server(id: &str) -> Result<Vec<DataSource>, String> {
id: &str,
) -> Result<Vec<DataSource>, String> {
// 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::get(id, "/datasource/_search", None)
.await .await
.map_err(|e| { .map_err(|e| {
// dbg!("Error fetching datasource: {}", &e);
format!("Error fetching datasource: {}", e) format!("Error fetching datasource: {}", e)
})?; })?;

View File

@@ -5,7 +5,6 @@ use reqwest::{Client, Method, RequestBuilder};
use serde_json::Value; 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 tokio::sync::Mutex; use tokio::sync::Mutex;
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| { pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
@@ -36,9 +35,12 @@ impl HttpClient {
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; let request_builder =
Self::get_request_builder(method, url, headers, query_params, body).await;
let response = request_builder.send().await let response = request_builder
.send()
.await
.map_err(|e| format!("Failed to send request: {}", e))?; .map_err(|e| format!("Failed to send request: {}", e))?;
Ok(response) Ok(response)
} }
@@ -55,7 +57,6 @@ 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() {
@@ -93,23 +94,21 @@ 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,
);
} }
// dbg!(&server_id); // dbg!(&server_id);
// dbg!(&url); // dbg!(&url);
// dbg!(&headers); // dbg!(&headers);
@@ -121,7 +120,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, Value>>, // 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
} }
@@ -143,10 +145,19 @@ impl HttpClient {
query_params: Option<HashMap<String, Value>>, // Add query parameters query_params: Option<HashMap<String, Value>>, // 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,
@@ -154,13 +165,33 @@ impl HttpClient {
query_params: Option<HashMap<String, Value>>, // Add query parameters query_params: Option<HashMap<String, Value>>, // 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, Value>>, // 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,12 @@
//! 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 transcription;
pub mod websocket; pub mod websocket;

View File

@@ -12,6 +12,8 @@ use ordered_float::OrderedFloat;
use reqwest::{Client, Method, RequestBuilder}; use reqwest::{Client, Method, RequestBuilder};
use std::collections::HashMap; use std::collections::HashMap;
// 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 +22,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
@@ -79,28 +82,37 @@ impl CocoSearchSource {
CocoSearchSource { server, client } CocoSearchSource { server, client }
} }
fn build_request_from_query(&self, query: &SearchQuery) -> RequestBuilder { async fn build_request_from_query(
&self,
query: &SearchQuery,
) -> Result<RequestBuilder, String> {
self.build_request(query.from, query.size, &query.query_strings) self.build_request(query.from, query.size, &query.query_strings)
.await
} }
fn build_request( async fn build_request(
&self, &self,
from: u64, from: u64,
size: u64, size: u64,
query_strings: &HashMap<String, String>, query_strings: &HashMap<String, String>,
) -> RequestBuilder { ) -> Result<RequestBuilder, String> {
let url = HttpClient::join_url(&self.server.endpoint, "/query/_search"); let url = HttpClient::join_url(&self.server.endpoint, "/query/_search");
let mut request_builder = self.client.request(Method::GET, url); let mut request_builder = self.client.request(Method::GET, url);
if !self.server.public { if !self.server.public {
if let Some(token) = get_server_token(&self.server.id).map(|t| t.access_token) { if let Some(token) = get_server_token(&self.server.id)
.await?
.map(|t| t.access_token)
{
request_builder = request_builder.header("X-API-TOKEN", token); request_builder = request_builder.header("X-API-TOKEN", token);
} }
} }
request_builder let result = request_builder
.query(&[("from", &from.to_string()), ("size", &size.to_string())]) .query(&[("from", &from.to_string()), ("size", &size.to_string())])
.query(query_strings) .query(query_strings);
Ok(result)
} }
} }
@@ -118,7 +130,7 @@ impl SearchSource for CocoSearchSource {
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 _server_id = self.server.id.clone();
let _server_name = self.server.name.clone(); let _server_name = self.server.name.clone();
let request_builder = self.build_request_from_query(&query); let request_builder = self.build_request_from_query(&query).await.unwrap();
// Send the HTTP request asynchronously // Send the HTTP request asynchronously
let response = request_builder.send().await; let response = request_builder.send().await;

View File

@@ -24,6 +24,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 +36,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 +134,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 +262,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 +269,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 +285,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;
@@ -334,7 +335,6 @@ pub async fn refresh_coco_server_info<R: Runtime>(
let _ = get_datasources_by_server(&id).await; let _ = get_datasources_by_server(&id).await;
Ok(server) Ok(server)
} }
Err(e) => Err(format!("Failed to deserialize the response: {:?}", e)), Err(e) => Err(format!("Failed to deserialize the response: {:?}", e)),
@@ -407,9 +407,8 @@ pub async fn add_coco_server<R: Runtime>(
// Save the new server to the cache // Save the new server to the cache
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 the servers to the store // Persist the servers to the store
persist_servers(&app_handle) persist_servers(&app_handle)
@@ -459,9 +458,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 +468,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(), Client::new());
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 +516,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 +592,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,41 @@
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));
let response = HttpClient::post(
&server_id,
"/services/audio/transcription",
Some(query_params),
None,
)
.await?;
if response.status().is_success() {
response
.json::<TranscriptionResponse>()
.await
.map_err(|e| e.to_string())
} else {
Err(format!(
"Transcription failed with status: {}",
response.status()
))
}
}

View File

@@ -1,184 +1,132 @@
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};
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::WebSocketStream;
use tokio_tungstenite::{ use tokio_tungstenite::{connect_async, MaybeTlsStream};
connect_async, tungstenite::protocol::Message, MaybeTlsStream, WebSocketStream,
};
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))?;
let ws_protocol = if url.scheme() == "https" { "wss://" } else { "ws://" };
let host = url.host_str().ok_or("No host found in URL")?;
let port = url.port_or_known_default().unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
// Determine WebSocket protocol based on the scheme
let ws_protocol = if url.scheme() == "https" {
"wss://"
} else {
"ws://"
};
// Extract host and port (if present)
let host = url.host_str().ok_or_else(|| "No host found in URL")?;
let port = url
.port_or_known_default()
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
// Build WebSocket URL, include the port if not the default
let ws_endpoint = if port == 80 || port == 443 { 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(
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.headers_mut().insert("Connection", "Upgrade".parse().unwrap());
request request.headers_mut().insert("Upgrade", "websocket".parse().unwrap());
.headers_mut() request.headers_mut().insert("Sec-WebSocket-Version", "13".parse().unwrap());
.insert("Connection", "Upgrade".parse().unwrap()); request.headers_mut().insert("Sec-WebSocket-Key", generate_key().parse().unwrap());
request
.headers_mut()
.insert("Upgrade", "websocket".parse().unwrap());
request
.headers_mut()
.insert("Sec-WebSocket-Version", "13".parse().unwrap());
request
.headers_mut()
.insert("Sec-WebSocket-Key", generate_key().parse().unwrap());
// If a token exists, add it to the headers
if let Some(token) = token { if let Some(token) = token {
request request.headers_mut().insert("X-API-TOKEN", token.parse().unwrap());
.headers_mut()
.insert("X-API-TOKEN", token.parse().unwrap());
} }
// Establish the WebSocket connection let (ws_stream, _) = connect_async(request).await.map_err(|e| format!("WebSocket error: {:?}", e))?;
// dbg!(&request);
let (mut ws_remote, _) = connect_async(request).await.map_err(|e| match e {
Error::ConnectionClosed => "WebSocket connection was closed".to_string(),
Error::Protocol(protocol_error) => format!("Protocol error: {}", protocol_error),
Error::Utf8 => "UTF-8 error in WebSocket data".to_string(),
_ => format!("Unknown error: {:?}", e),
})?;
// 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(_)) | None => {
Some(Err(WsError::ConnectionClosed)) => { let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
let _ = app_handle_clone.emit("ws-error", id); break;
eprintln!("WebSocket connection closed by the server.");
break;
},
Some(Err(WsError::Protocol(e))) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("Protocol error: {}", e);
break;
},
Some(Err(WsError::Utf8)) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("Received invalid UTF-8 data.");
break;
},
Some(Err(_)) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("WebSocket error encountered.");
break;
},
_ => continue,
} }
_ => {}
} }
_ = cancel_rx.recv() => { }
let _ = app_handle_clone.emit("ws-error", id); _ = cancel_rx.recv() => {
dbg!("Cancelling WebSocket connection"); let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
break; break;
}
} }
} }
} }
// Remove connection after it closes
let mut connections = connections_clone.lock().await;
connections.remove(&client_id_clone);
}); });
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn disconnect(state: tauri::State<'_, WebSocketManager>) -> Result<(), String> {
// Send cancellation signal
if let Some(cancel_tx) = state.cancel_tx.lock().await.take() {
let _ = cancel_tx.send(()).await;
}
// Close connection #[tauri::command]
let mut connection = state.ws_connection.lock().await; pub async fn disconnect(client_id: String, state: tauri::State<'_, WebSocketManager>) -> Result<(), String> {
if let Some(mut ws) = connection.take() { let instance = {
let mut connections = state.connections.lock().await;
connections.remove(&client_id)
};
if let Some(instance) = instance {
let _ = instance.cancel_tx.send(()).await;
// Close WebSocket (lock only the connection, not the whole map)
let mut ws = instance.ws_connection.lock().await;
let _ = ws.close(None).await; let _ = ws.close(None).await;
} }
Ok(()) Ok(())
} }

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

@@ -32,7 +32,9 @@
"windowEffects": { "windowEffects": {
"effects": [], "effects": [],
"radius": 12 "radius": 12
} },
"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": {},

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

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

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,8 +62,8 @@ 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;
// debug API // debug API

15
src/api/transcription.ts Normal file
View File

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

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

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

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

@@ -0,0 +1,182 @@
import { invoke } from '@tauri-apps/api/core';
import { ServerTokenResponse, Server, Connector, DataSource, GetResponse } from "@/types/commands"
export function get_server_token(id: string): Promise<ServerTokenResponse> {
return invoke(`get_server_token`, { id });
}
export function list_coco_servers(): Promise<Server[]> {
return invoke(`list_coco_servers`);
}
export function add_coco_server(endpoint: string): Promise<Server> {
return invoke(`add_coco_server`, { endpoint });
}
export function enable_server(id: string): Promise<void> {
return invoke(`enable_server`, { id });
}
export function disable_server(id: string): Promise<void> {
return invoke(`disable_server`, { id });
}
export function remove_coco_server(id: string): Promise<void> {
return invoke(`remove_coco_server`, { id });
}
export function logout_coco_server(id: string): Promise<void> {
return invoke(`logout_coco_server`, { id });
}
export function refresh_coco_server_info(id: string): Promise<Server> {
return invoke(`refresh_coco_server_info`, { id });
}
export function handle_sso_callback({
serverId,
requestId,
code,
}: {
serverId: string;
requestId: string;
code: string;
}): Promise<void> {
return invoke(`handle_sso_callback`, {
serverId,
requestId,
code,
});
}
export function get_connectors_by_server(id: string): Promise<Connector[]> {
return invoke(`get_connectors_by_server`, { id });
}
export function get_datasources_by_server(id: string): Promise<DataSource[]> {
return invoke(`get_datasources_by_server`, { id });
}
export function connect_to_server(id: string, clientId: string): Promise<void> {
return invoke(`connect_to_server`, { id, clientId });
}
export function disconnect(clientId: string): Promise<void> {
return invoke(`disconnect`, { clientId });
}
export function chat_history({
serverId,
from = 0,
size = 20,
}: {
serverId: string;
from?: number;
size?: number;
}): Promise<string> {
return invoke(`chat_history`, {
serverId,
from,
size,
});
}
export function session_chat_history({
serverId,
sessionId,
from = 0,
size = 20,
}: {
serverId: string;
sessionId: string;
from?: number;
size?: number;
}): Promise<string> {
return invoke(`session_chat_history`, {
serverId,
sessionId,
from,
size,
});
}
export function close_session_chat({
serverId,
sessionId,
}: {
serverId: string;
sessionId: string;
}): Promise<string> {
return invoke(`close_session_chat`, {
serverId,
sessionId,
});
}
export function open_session_chat({
serverId,
sessionId,
}: {
serverId: string;
sessionId: string;
}): Promise<string> {
return invoke(`open_session_chat`, {
serverId,
sessionId,
});
}
export function cancel_session_chat({
serverId,
sessionId,
}: {
serverId: string;
sessionId: string;
}): Promise<string> {
return invoke(`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 invoke(`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 invoke(`send_message`, {
serverId,
websocketId,
sessionId,
message,
queryParams,
});
}

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

@@ -4,27 +4,23 @@ 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 { useSearchStore } from "@/stores/searchStore";
import FileList from "@/components/Search/FileList"; import { useWindows } from "@/hooks/useWindows";
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";
interface ChatAIProps { interface ChatAIProps {
isTransitioned: boolean; isTransitioned: boolean;
@@ -36,6 +32,7 @@ interface ChatAIProps {
isSidebarOpen?: boolean; isSidebarOpen?: boolean;
clearChatPage?: () => void; clearChatPage?: () => void;
isChatPage?: boolean; isChatPage?: boolean;
getFileUrl: (path: string) => string;
} }
export interface ChatAIRef { export interface ChatAIRef {
@@ -58,22 +55,19 @@ const ChatAI = memo(
isSidebarOpen = false, isSidebarOpen = false,
clearChatPage, clearChatPage,
isChatPage = false, isChatPage = false,
getFileUrl,
}, },
ref ref
) => { ) => {
if (!isTransitioned) return null; 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();
@@ -81,25 +75,26 @@ const ChatAI = memo(
const [activeChat, setActiveChat] = useState<Chat>(); const [activeChat, setActiveChat] = useState<Chat>();
const [timedoutShow, setTimedoutShow] = useState(false); const [timedoutShow, setTimedoutShow] = useState(false);
const [IsLogin, setIsLogin] = useState(true); const [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 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,
@@ -122,385 +117,130 @@ 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 { errorShow, setErrorShow, reconnect, disconnectWS, updateDealMsg } =
useWebSocket({
clientId,
connected,
setConnected,
currentService,
dealMsgRef,
onWebsocketSessionId,
});
messageTimeoutRef.current = setTimeout(() => { const {
if (!curChatEnd) { chatClose,
console.log("AI response timeout"); cancelChat,
setTimedoutShow(true); chatHistory,
cancelChat(); createNewChat,
} handleSendMessage,
}, 60000); openSessionChat,
getChatHistory,
if (msg.includes("assistant finished output")) { createChatWindow,
clearTimeout(messageTimeoutRef.current); } = useChatActions(
console.log("AI finished output"); currentService?.id,
setCurChatEnd(true); setActiveChat,
return; setCurChatEnd,
} setErrorShow,
setTimedoutShow,
const cleanedData = msg.replace(/^PRIVATE /, ""); clearAllChunkData,
try { setQuestion,
const chunkData = JSON.parse(cleanedData); curIdRef,
isSearchActive,
if (chunkData.reply_to_message !== curIdRef.current) return; isDeepThinkActive,
sourceDataIds,
setLoadingStep(() => ({ changeInput,
query_intent: false, websocketSessionId
fetch_source: false,
pick_source: false,
deep_read: false,
think: false,
response: false,
[chunkData.chunk_type]: true,
}));
// ['query_intent', 'fetch_source', 'pick_source', 'deep_read', 'think', 'response'];
if (chunkData.chunk_type === "query_intent") {
handlers.deal_query_intent(chunkData);
} else if (chunkData.chunk_type === "fetch_source") {
handlers.deal_fetch_source(chunkData);
} else if (chunkData.chunk_type === "pick_source") {
handlers.deal_pick_source(chunkData);
} else if (chunkData.chunk_type === "deep_read") {
handlers.deal_deep_read(chunkData);
} else if (chunkData.chunk_type === "think") {
handlers.deal_think(chunkData);
} else if (chunkData.chunk_type === "response") {
handlers.deal_response(chunkData);
}
} catch (error) {
console.error("parse error:", error);
}
},
[curChatEnd]
); );
const { errorShow, setErrorShow, reconnect } = useWebSocket({ const { dealMsg, messageTimeoutRef } = useMessageHandler(
connected, curIdRef,
setConnected, setCurChatEnd,
currentService, setTimedoutShow,
dealMsg, (chat) => cancelChat(chat || activeChat),
}); setLoadingStep,
handlers,
const updatedChat = useMemo(() => { );
if (!activeChat?._id) return null;
return {
...activeChat,
messages: [...(activeChat.messages || [])],
};
}, [activeChat]);
const simulateAssistantResponse = useCallback(() => {
if (!updatedChat) return;
// console.log("updatedChat:", updatedChat);
setActiveChat(updatedChat);
}, [updatedChat]);
useEffect(() => { useEffect(() => {
if (curChatEnd) { if (dealMsg) {
simulateAssistantResponse(); dealMsgRef.current = dealMsg;
updateDealMsg && updateDealMsg(dealMsg);
} }
}, [curChatEnd]); }, [dealMsg, updateDealMsg]);
const [userScrolling, setUserScrolling] = useState(false); const clearChat = useCallback(() => {
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(() => {
const container = messagesEndRef.current?.parentElement;
if (!container) return;
const handleScroll = () => {
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
const { scrollTop, scrollHeight, clientHeight } = container;
const isAtBottom =
Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setUserScrolling(!isAtBottom);
if (isAtBottom) {
setUserScrolling(false);
}
scrollTimeoutRef.current = setTimeout(() => {
const {
scrollTop: newScrollTop,
scrollHeight: newScrollHeight,
clientHeight: newClientHeight,
} = container;
const nowAtBottom =
Math.abs(newScrollHeight - newScrollTop - newClientHeight) < 10;
if (nowAtBottom) {
setUserScrolling(false);
}
}, 500);
};
container.addEventListener("scroll", handleScroll);
return () => {
container.removeEventListener("scroll", handleScroll);
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, []);
useEffect(() => {
scrollToBottom();
}, [
activeChat?.messages,
query_intent?.message_chunk,
fetch_source?.message_chunk,
pick_source?.message_chunk,
deep_read?.message_chunk,
think?.message_chunk,
response?.message_chunk,
]);
const clearChat = () => {
console.log("clearChat"); console.log("clearChat");
chatClose(); setTimedoutShow(false);
setErrorShow(false);
chatClose(activeChat);
setActiveChat(undefined); setActiveChat(undefined);
setCurChatEnd(true); setCurChatEnd(true);
clearChatPage && clearChatPage(); clearChatPage && clearChatPage();
}; }, [
activeChat,
chatClose,
clearChatPage,
setCurChatEnd,
setErrorShow,
setTimedoutShow,
]);
const createNewChat = useCallback( const init = useCallback(
async (value: string = "") => { (value: string) => {
setTimedoutShow(false); if (!isLogin) return;
setErrorShow(false); if (!curChatEnd) return;
chatClose(); if (!activeChat?._id) {
clearAllChunkData(); createNewChat(value, activeChat, websocketSessionId);
setQuestion(value); } else {
try { handleSendMessage(value, activeChat, websocketSessionId);
console.log("sourceDataIds", sourceDataIds);
let response: any = await invoke("new_chat", {
serverId: currentService?.id,
message: value,
queryParams: {
search: isSearchActive,
deep_thinking: isDeepThinkActive,
datasource: sourceDataIds.join(","),
},
});
console.log("_new", response);
const newChat: Chat = response;
curIdRef.current = response?.payload?.id;
newChat._source = {
message: value,
};
const updatedChat: Chat = {
...newChat,
messages: [newChat],
};
changeInput && changeInput("");
//console.log("updatedChat2", updatedChat);
setActiveChat(updatedChat);
setCurChatEnd(false);
} catch (error) {
setErrorShow(true);
console.error("createNewChat:", error);
} }
}, },
[currentService?.id, sourceDataIds, isSearchActive, isDeepThinkActive] [isLogin, curChatEnd, activeChat, createNewChat, handleSendMessage, websocketSessionId]
); );
const init = (value: string) => { const { createWin } = useWindows();
if (!IsLogin) return; const openChatAI = useCallback(() => {
if (!curChatEnd) return; createChatWindow(createWin);
if (!activeChat?._id) { }, [createChatWindow, createWin]);
createNewChat(value);
} else {
handleSendMessage(value);
}
};
const sendMessage = useCallback(
async (content: string, newChat: Chat) => {
if (!newChat?._id || !content) return;
try {
//console.log("sourceDataIds", sourceDataIds);
let response: any = await invoke("send_message", {
serverId: currentService?.id,
sessionId: newChat?._id,
queryParams: {
search: isSearchActive,
deep_thinking: isDeepThinkActive,
datasource: sourceDataIds.join(","),
},
message: content,
});
response = JSON.parse(response || "");
console.log("_send", response);
curIdRef.current = response[0]?._id;
const updatedChat: Chat = {
...newChat,
messages: [...(newChat?.messages || []), ...(response || [])],
};
changeInput && changeInput("");
//console.log("updatedChat2", updatedChat);
setActiveChat(updatedChat);
setCurChatEnd(false);
} catch (error) {
setErrorShow(true);
console.error("sendMessage:", error);
}
},
[
JSON.stringify(activeChat?.messages),
currentService?.id,
sourceDataIds,
isSearchActive,
isDeepThinkActive,
]
);
const handleSendMessage = useCallback(
async (content: string, newChat?: Chat) => {
newChat = newChat || activeChat;
if (!newChat?._id || !content) return;
setQuestion(content);
await chatHistory(newChat, (chat) => sendMessage(content, chat));
setTimedoutShow(false);
setErrorShow(false);
clearAllChunkData();
},
[activeChat, sendMessage]
);
const chatClose = async () => {
if (!activeChat?._id) return;
try {
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 () => {
setCurChatEnd(true);
if (!activeChat?._id) return;
try {
let response: any = await invoke("cancel_session_chat", {
serverId: currentService?.id,
sessionId: activeChat?._id,
});
response = JSON.parse(response || "");
console.log("_cancel", response);
} catch (error) {
console.error("cancelChat:", error);
}
};
async function openChatAI() {
if (isTauri()) {
createWin &&
createWin({
label: "chat",
title: "Coco Chat",
dragDropEnabled: true,
center: true,
width: 1000,
height: 800,
alwaysOnTop: false,
skipTaskbar: false,
decorations: true,
closable: true,
url: "/ui/chat",
});
}
}
useEffect(() => { useEffect(() => {
return () => { return () => {
if (messageTimeoutRef.current) { if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current); clearTimeout(messageTimeoutRef.current);
} }
chatClose(); chatClose(activeChat);
setActiveChat(undefined); setActiveChat(undefined);
setCurChatEnd(true); setCurChatEnd(true);
scrollToBottom.cancel(); disconnectWS();
}; };
}, []); }, [chatClose, setCurChatEnd]);
const chatHistory = async ( const onSelectChat = useCallback(
chat: Chat, async (chat: Chat) => {
callback?: (chat: Chat) => void setTimedoutShow(false);
) => { setErrorShow(false);
try { clearAllChunkData();
let response: any = await invoke("session_chat_history", { await cancelChat(activeChat);
serverId: currentService?.id, await chatClose(activeChat);
sessionId: chat?._id, const response = await openSessionChat(chat);
from: 0, if (response) {
size: 20, chatHistory(response);
}); }
response = JSON.parse(response || ""); },
const hits = response?.hits?.hits || []; [
const updatedChat: Chat = { clearAllChunkData,
...chat, cancelChat,
messages: hits, activeChat,
}; chatClose,
console.log("id_history", response, updatedChat); openSessionChat,
setActiveChat(updatedChat); chatHistory,
callback && callback(updatedChat); ]
} catch (error) { );
console.error("chatHistory:", error);
}
};
const onSelectChat = async (chat: any) => { const deleteChat = useCallback((chatId: string) => {
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)); setChats((prev) => prev.filter((chat) => chat._id !== chatId));
if (activeChat?._id === chatId) { if (activeChat?._id === chatId) {
const remainingChats = chats.filter((chat) => chat._id !== chatId); const remainingChats = chats.filter((chat) => chat._id !== chatId);
@@ -510,7 +250,7 @@ const ChatAI = memo(
init(""); init("");
} }
} }
}; }, [activeChat, chats, init, setActiveChat]);
const handleOutsideClick = useCallback((e: MouseEvent) => { const handleOutsideClick = useCallback((e: MouseEvent) => {
const sidebar = document.querySelector("[data-sidebar]"); const sidebar = document.querySelector("[data-sidebar]");
@@ -534,152 +274,70 @@ const ChatAI = memo(
}; };
}, [isSidebarOpenChat, handleOutsideClick]); }, [isSidebarOpenChat, handleOutsideClick]);
const getChatHistory = useCallback(async () => { const fetchChatHistory = useCallback(async () => {
if (!currentService?.id) return; const hits = await getChatHistory();
try { setChats(hits);
let response: any = await invoke("chat_history", { }, [getChatHistory]);
serverId: currentService?.id,
from: 0,
size: 20,
});
response = JSON.parse(response || "");
console.log("_history", response);
const hits = response?.hits?.hits || [];
setChats(hits);
} catch (error) {
console.error("chat_history:", error);
}
}, [currentService?.id]);
const setIsLoginChat = useCallback( const setIsLoginChat = useCallback(
(value: boolean) => { (value: boolean) => {
setIsLogin(value); setIsLogin(value);
value && currentService && !setIsSidebarOpen && getChatHistory(); value && currentService && !setIsSidebarOpen && fetchChatHistory();
!value && setChats([]); !value && setChats([]);
}, },
[currentService] [currentService, setIsSidebarOpen, fetchChatHistory]
); );
const toggleSidebar = useCallback(() => {
setIsSidebarOpenChat(!isSidebarOpenChat);
setIsSidebarOpen && setIsSidebarOpen(!isSidebarOpenChat);
!isSidebarOpenChat && fetchChatHistory();
}, [isSidebarOpenChat, setIsSidebarOpen, fetchChatHistory]);
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
className={`h-full flex flex-col rounded-xl overflow-hidden`} className={`h-full flex flex-col rounded-xl overflow-hidden`}
> >
{setIsSidebarOpen ? null : ( {!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 chats={chats}
${ activeChat={activeChat}
isSidebarOpenChat onNewChat={clearChat}
? "translate-x-0" onSelectChat={onSelectChat}
: "-translate-x-[calc(100%)]" onDeleteChat={deleteChat}
} fetchChatHistory={fetchChatHistory}
md:relative md:translate-x-0 bg-gray-100 dark:bg-gray-800 />
border-r border-gray-200 dark:border-gray-700 rounded-tl-xl rounded-bl-xl
overflow-hidden`}
>
<Sidebar
chats={chats}
activeChat={activeChat}
onNewChat={clearChat}
onSelectChat={onSelectChat}
onDeleteChat={deleteChat}
/>
</div>
)} )}
<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} setIsLogin={setIsLoginChat}
/> />
{IsLogin ? ( {isLogin ? (
<div className="flex flex-col h-full justify-between overflow-hidden"> <ChatContent
<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"> activeChat={activeChat}
<Greetings /> curChatEnd={curChatEnd}
{activeChat?.messages?.map((message, index) => ( query_intent={query_intent}
<ChatMessage fetch_source={fetch_source}
key={message._id + index} pick_source={pick_source}
message={message} deep_read={deep_read}
isTyping={false} think={think}
onResend={handleSendMessage} response={response}
/> loadingStep={loadingStep}
))} timedoutShow={timedoutShow}
{(query_intent || errorShow={errorShow}
fetch_source || Question={Question}
pick_source || handleSendMessage={(value) => handleSendMessage(value, activeChat)}
deep_read || getFileUrl={getFileUrl}
think || />
response) &&
activeChat?._id ? (
<ChatMessage
key={"current"}
message={{
_id: "current",
_source: {
type: "assistant",
message: "",
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={!curChatEnd}
query_intent={query_intent}
fetch_source={fetch_source}
pick_source={pick_source}
deep_read={deep_read}
think={think}
response={response}
loadingStep={loadingStep}
/>
) : null}
{timedoutShow ? (
<ChatMessage
key={"timedout"}
message={{
_id: "timedout",
_source: {
type: "assistant",
message: t("assistant.chat.timedout"),
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={false}
/>
) : null}
{errorShow ? (
<ChatMessage
key={"error"}
message={{
_id: "error",
_source: {
type: "assistant",
message: t("assistant.chat.error"),
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={false}
/>
) : null}
<div ref={messagesEndRef} />
</div>
{uploadFiles.length > 0 && (
<div className="max-h-[120px] overflow-auto p-2">
<FileList />
</div>
)}
</div>
) : ( ) : (
<ConnectPrompt /> <ConnectPrompt />
)} )}

View File

@@ -0,0 +1,166 @@
import { useRef, useEffect } 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";
interface ChatContentProps {
activeChat?: Chat;
curChatEnd: boolean;
query_intent?: IChunkData;
fetch_source?: IChunkData;
pick_source?: IChunkData;
deep_read?: IChunkData;
think?: IChunkData;
response?: IChunkData;
loadingStep?: Record<string, boolean>;
timedoutShow: boolean;
errorShow: boolean;
Question: string;
handleSendMessage: (content: string, newChat?: Chat) => void;
getFileUrl: (path: string) => string;
}
export const ChatContent = ({
activeChat,
curChatEnd,
query_intent,
fetch_source,
pick_source,
deep_read,
think,
response,
loadingStep,
timedoutShow,
errorShow,
Question,
handleSendMessage,
getFileUrl,
}: ChatContentProps) => {
const sessionId = useConnectStore((state) => state.currentSessionId);
const setCurrentSessionId = useConnectStore((state) => {
return state.setCurrentSessionId;
});
useEffect(() => {
setCurrentSessionId(activeChat?._id);
}, [activeChat]);
const { t } = useTranslation();
const uploadFiles = useChatStore((state) => state.uploadFiles);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { scrollToBottom } = useChatScroll(messagesEndRef);
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]);
return (
<div className="relative flex flex-col h-full justify-between overflow-hidden">
<div className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative">
<Greetings />
{activeChat?.messages?.map((message, index) => (
<ChatMessage
key={message._id + index}
message={message}
isTyping={false}
onResend={handleSendMessage}
/>
))}
{(!curChatEnd ||
query_intent ||
fetch_source ||
pick_source ||
deep_read ||
think ||
response) &&
activeChat?._id ? (
<ChatMessage
key={"current"}
message={{
_id: "current",
_source: {
type: "assistant",
message: "",
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={!curChatEnd}
query_intent={query_intent}
fetch_source={fetch_source}
pick_source={pick_source}
deep_read={deep_read}
think={think}
response={response}
loadingStep={loadingStep}
/>
) : null}
{timedoutShow ? (
<ChatMessage
key={"timedout"}
message={{
_id: "timedout",
_source: {
type: "assistant",
message: t("assistant.chat.timedout"),
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={false}
/>
) : null}
{errorShow ? (
<ChatMessage
key={"error"}
message={{
_id: "error",
_source: {
type: "assistant",
message: t("assistant.chat.error"),
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={false}
/>
) : null}
<div ref={messagesEndRef} />
</div>
{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} />}
</div>
);
};

View File

@@ -17,9 +17,6 @@ import {
PopoverPanel, PopoverPanel,
} from "@headlessui/react"; } from "@headlessui/react";
import { useTranslation } from "react-i18next"; 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 logoImg from "@/assets/icon.svg";
import HistoryIcon from "@/icons/History"; import HistoryIcon from "@/icons/History";
@@ -31,7 +28,7 @@ import { useAppStore, IServer } from "@/stores/appStore";
import { useChatStore } from "@/stores/chatStore"; import { useChatStore } from "@/stores/chatStore";
import type { Chat } from "./types"; import type { Chat } from "./types";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import platformAdapter from "@/utils/platformAdapter";
interface ChatHeaderProps { interface ChatHeaderProps {
onCreateNewChat: () => void; onCreateNewChat: () => void;
onOpenChatAI: () => void; onOpenChatAI: () => void;
@@ -58,7 +55,7 @@ export function ChatHeader({
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 { setMessages } = useChatStore();
const [serverList, setServerList] = useState<IServer[]>([]); const [serverList, setServerList] = useState<IServer[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
@@ -67,7 +64,7 @@ export function ChatHeader({
const setCurrentService = useConnectStore((state) => state.setCurrentService); const setCurrentService = useConnectStore((state) => state.setCurrentService);
const fetchServers = useCallback(async (resetSelection: boolean) => { const fetchServers = useCallback(async (resetSelection: boolean) => {
invoke("list_coco_servers") platformAdapter.invokeBackend("list_coco_servers")
.then((res: any) => { .then((res: any) => {
const enabledServers = (res as IServer[]).filter( const enabledServers = (res as IServer[]).filter(
(server) => server.enabled !== false (server) => server.enabled !== false
@@ -95,27 +92,18 @@ export function ChatHeader({
useEffect(() => { useEffect(() => {
fetchServers(true); fetchServers(true);
const unlisten = listen("login_or_logout", (event) => { const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => {
console.log("Login or Logout:", currentService, event); console.log("Login or Logout:", currentService, event);
fetchServers(true); fetchServers(true);
}); });
return () => { return () => {
// Cleanup logic if needed // Cleanup logic if needed
disconnect();
unlisten.then((fn) => fn()); 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) => { const switchServer = async (server: IServer) => {
if (!server) return; if (!server) return;
@@ -131,8 +119,9 @@ export function ChatHeader({
return; return;
} }
setIsLogin(true); setIsLogin(true);
// // The Rust backend will automatically disconnect,
await disconnect(); // so we don't need to handle disconnection on the frontend
// src-tauri/src/server/websocket.rs
reconnect && reconnect(server); reconnect && reconnect(server);
} catch (error) { } catch (error) {
console.error("switchServer:", error); console.error("switchServer:", error);
@@ -142,7 +131,7 @@ export function ChatHeader({
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);
@@ -151,7 +140,7 @@ export function ChatHeader({
}; };
const openSettings = async () => { const openSettings = async () => {
emit("open_settings", "connect"); platformAdapter.emitEvent("open_settings", "connect");
}; };
return ( return (

View File

@@ -0,0 +1,44 @@
import React from "react";
import { Sidebar } from "@/components/Assistant/Sidebar";
import type { Chat } from "./types";
interface ChatSidebarProps {
isSidebarOpen: boolean;
chats: Chat[];
activeChat?: Chat;
onNewChat: () => void;
onSelectChat: (chat: any) => void;
onDeleteChat: (chatId: string) => void;
fetchChatHistory: () => void;
}
export const ChatSidebar: React.FC<ChatSidebarProps> = ({
isSidebarOpen,
chats,
activeChat,
onNewChat,
onSelectChat,
onDeleteChat,
fetchChatHistory,
}) => {
return (
<div
data-sidebar
className={`fixed inset-y-0 left-0 z-50 w-64 transform transition-all duration-300 ease-in-out
${isSidebarOpen ? "translate-x-0" : "-translate-x-[calc(100%)]"}
md:relative md:translate-x-0 bg-gray-100 dark:bg-gray-800
border-r border-gray-200 dark:border-gray-700 rounded-tl-xl rounded-bl-xl
overflow-hidden`}
>
<Sidebar
chats={chats}
activeChat={activeChat}
onNewChat={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,112 @@
import { useEffect, useMemo } from "react";
import { filesize } from "filesize";
import { X } from "lucide-react";
import { useAsyncEffect } from "ahooks";
import { useTranslation } from "react-i18next";
import { useChatStore } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore";
import { deleteAttachment, uploadAttachment } from "@/api/attachment";
import FileIcon from "../Common/Icons/FileIcon";
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;
const attachmentIds = await uploadAttachment({
serverId,
sessionId,
filePaths: [path],
});
if (!attachmentIds) continue;
Object.assign(item, {
uploaded: true,
attachmentId: attachmentIds[0],
});
setUploadFiles(uploadFiles);
}
}, [uploadFiles]);
const deleteFile = async (id: string, attachmentId: string) => {
setUploadFiles(uploadFiles.filter((file) => file.id !== id));
deleteAttachment({ 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 } = 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]">
{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(id, attachmentId);
}}
>
<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 text-[#999999]">
{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>
);
};
export default FileList;

View File

@@ -0,0 +1,160 @@
import {
AttachmentHit,
deleteAttachment,
getAttachment,
} from "@/api/attachment";
import { useConnectStore } from "@/stores/connectStore";
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 Checkbox from "../Common/Checkbox";
import FileIcon from "../Common/Icons/FileIcon";
interface SessionFileProps {
sessionId: string;
}
const SessionFile = (props: SessionFileProps) => {
const { sessionId } = props;
const { t } = useTranslation();
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 () => {
const response = await getAttachment({ serverId, sessionId });
setUploadedFiles(response.hits.hits);
};
const handleDelete = async (id: string) => {
const result = await deleteAttachment({ serverId, id });
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">
{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,238 @@
import { useAppStore } from "@/stores/appStore";
import { useKeyPress, 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 { transcription } from "@/api/transcription";
import { useConnectStore } from "@/stores/connectStore";
import { useShortcutsStore } from "@/stores/shortcutsStore";
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 modifierKeyPressed = useShortcutsStore((state) => {
return state.modifierKeyPressed;
});
const modifierKey = useShortcutsStore((state) => {
return state.modifierKey;
});
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 = await 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]);
useKeyPress(`${modifierKey}.${voiceInput}`, () => {
startRecording();
});
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,
}
)}
>
<Mic
className={clsx("size-4 text-[#999]", {
hidden: modifierKeyPressed,
})}
onClick={startRecording}
/>
<div
className={clsx(
"w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]",
{
hidden: !modifierKeyPressed,
}
)}
>
{voiceInput}
</div>
</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

@@ -9,7 +9,9 @@ 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.css";
import "./highlight.css"; import "./highlight.css";
@@ -67,9 +69,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 +79,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(() => {

View File

@@ -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 (

View File

@@ -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) {
@@ -108,7 +108,7 @@ export const QueryIntent = ({
<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}
@@ -144,8 +144,8 @@ export const QueryIntent = ({
- {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

@@ -0,0 +1,33 @@
import { useState } from "react";
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)}
>
{showCopyButton && <CopyButton textToCopy={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] 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

@@ -11,6 +11,7 @@ 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";
interface ChatMessageProps { interface ChatMessageProps {
message: Message; message: Message;
@@ -55,11 +56,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 (

View File

@@ -235,7 +235,6 @@
padding: 0; padding: 0;
margin: 24px 0; margin: 24px 0;
background-color: var(--color-border-default); background-color: var(--color-border-default);
border: 0;
} }
.markdown-body input { .markdown-body input {

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,6 +27,17 @@ 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();
@@ -65,7 +75,7 @@ export default function Cloud() {
}, [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 (error) {
res = (res || []).map((item: any) => { res = (res || []).map((item: any) => {
@@ -97,7 +107,7 @@ export default function Cloud() {
}); });
}; };
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,7 +120,7 @@ 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)
@@ -125,7 +135,6 @@ export default function Cloud() {
}); });
}) })
.catch((err: any) => { .catch((err: any) => {
// Handle the invoke error
console.error("add coco server failed:", err); console.error("add coco server failed:", err);
setError(err); setError(err);
throw err; // Propagate error back up throw err; // Propagate error back up
@@ -137,14 +146,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"); setError("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,
@@ -256,7 +265,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) => {
@@ -282,7 +291,7 @@ 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);
@@ -297,8 +306,8 @@ export default function Cloud() {
}); });
} }
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) => {
@@ -312,19 +321,24 @@ export default function Cloud() {
}); });
}; };
const enable_coco_server = useCallback(async (enabled: boolean) => { const enable_coco_server = useCallback(
try { async (enabled: boolean) => {
const command = enabled ? "enable_server" : "disable_server"; try {
if (enabled) {
await enable_server(currentService?.id);
} else {
await disable_server(currentService?.id);
}
await invoke(command, { id: currentService?.id }); setCurrentService({ ...currentService, enabled });
setCurrentService({ ...currentService, enabled }); await fetchServers(false);
} catch (error) {
await fetchServers(false); setError(error);
} 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 +360,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}>
{currentService?.name} <div className="flex items-center text-gray-900 dark:text-white font-medium cursor-pointer">
</div> {currentService?.name}
</div>
</Tooltip>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SettingsToggle <SettingsToggle
@@ -385,7 +401,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>
@@ -484,7 +500,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

@@ -1,11 +1,14 @@
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 { useAppStore } from "@/stores/appStore";
import {
get_connectors_by_server,
get_datasources_by_server,
} from "@/commands";
export function DataSourcesList({ server }: { server: string }) { export function DataSourcesList({ server }: { server: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -17,7 +20,7 @@ export function DataSourcesList({ server }: { server: string }) {
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);
@@ -29,7 +32,7 @@ export function DataSourcesList({ server }: { server: string }) {
.finally(() => {}); .finally(() => {});
//fetch datasource data //fetch datasource data
invoke("get_datasources_by_server", { id: server }) get_datasources_by_server(server)
.then((res: any) => { .then((res: any) => {
// console.log("get_datasources_by_server", res); // console.log("get_datasources_by_server", res);
setDatasourceData(res, server); setDatasourceData(res, server);

View File

@@ -1,54 +0,0 @@
import { useRef, useImperativeHandle, forwardRef } from "react";
interface AutoResizeTextareaProps {
input: string;
setInput: (value: string) => void;
handleKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
}
// Forward ref to allow parent to interact with this component
const AutoResizeTextarea = forwardRef<
{ reset: () => void; focus: () => void },
AutoResizeTextareaProps
>(({ input, setInput, handleKeyDown }, ref) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Expose methods to the parent via ref
useImperativeHandle(ref, () => ({
reset: () => {
setInput("");
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "auto";
}
},
focus: () => {
textareaRef.current?.focus();
},
}));
return (
<textarea
ref={textareaRef}
autoFocus
autoComplete="off"
autoCapitalize="none"
spellCheck="false"
className="text-xs flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent"
placeholder="Ask whatever you want ..."
aria-label="Ask whatever you want ..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => handleKeyDown?.(e)}
rows={1}
style={{
resize: "none", // Prevent manual resize
overflow: "auto", // Enable scrollbars when needed
maxHeight: "4.5rem", // Limit height to 3 rows (3 * 1.5 line-height)
lineHeight: "1.5rem", // Line height to match row height
}}
/>
);
});
export default AutoResizeTextarea;

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useCallback } from "react"; import React, { useEffect, useCallback } from "react";
import { Bot, Search } from "lucide-react"; import { Bot, Search } from "lucide-react";
import { isMetaOrCtrlKey } from "@/utils/keyboardUtils"; import { useShortcutsStore } from "@/stores/shortcutsStore";
interface ChatSwitchProps { interface ChatSwitchProps {
isChatMode: boolean; isChatMode: boolean;
@@ -9,19 +9,26 @@ interface ChatSwitchProps {
} }
const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => { const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
const modifierKeyPressed = useShortcutsStore((state) => {
return state.modifierKeyPressed;
});
const modeSwitch = useShortcutsStore((state) => {
return state.modeSwitch;
});
const handleToggle = useCallback(() => { const handleToggle = useCallback(() => {
onChange?.(!isChatMode); onChange?.(!isChatMode);
}, [onChange, isChatMode]); }, [onChange, isChatMode]);
const handleKeydown = useCallback( const handleKeydown = useCallback(
(event: KeyboardEvent) => { (event: KeyboardEvent) => {
if (isMetaOrCtrlKey(event) && event.key === "t") { if (modifierKeyPressed && event.key === modeSwitch.toLowerCase()) {
event.preventDefault(); event.preventDefault();
// console.log("Switch mode triggered"); // console.log("Switch mode triggered");
handleToggle(); handleToggle();
} }
}, },
[handleToggle] [handleToggle, modifierKeyPressed, modeSwitch]
); );
useEffect(() => { useEffect(() => {

View File

@@ -0,0 +1,34 @@
import {
CheckboxProps as HeadlessCheckboxProps,
Checkbox as HeadlessCheckbox,
} from "@headlessui/react";
import clsx from "clsx";
import { CheckIcon } from "lucide-react";
interface CheckboxProps extends HeadlessCheckboxProps {
indeterminate?: boolean;
}
const Checkbox = (props: CheckboxProps) => {
const { indeterminate, className, ...rest } = props;
return (
<HeadlessCheckbox
{...rest}
className={clsx(
"group size-4 rounded-sm border border-black/30 dark:border-white/30 data-[checked]:bg-[#2F54EB] data-[checked]:!border-[#2F54EB] transition cursor-pointer",
className
)}
>
{indeterminate && (
<div className="size-full flex items-center justify-center group-data-[checked]:hidden">
<div className="size-2 bg-[#2F54EB]"></div>
</div>
)}
<CheckIcon className="hidden size-[14px] text-white group-data-[checked]:block" />
</HeadlessCheckbox>
);
};
export default Checkbox;

View File

@@ -0,0 +1,36 @@
import { useState } from "react";
import { Copy, Check } from "lucide-react";
interface CopyButtonProps {
textToCopy: string;
}
export const CopyButton = ({ textToCopy }: CopyButtonProps) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(textToCopy);
setCopied(true);
const timerID = setTimeout(() => {
setCopied(false);
clearTimeout(timerID);
}, 2000);
} catch (err) {
console.error("copy error:", err);
}
};
return (
<button
className={`p-1 bg-gray-200 dark:bg-gray-700 rounded`}
onClick={handleCopy}
>
{copied ? (
<Check className="w-4 h-4 text-[#38C200] dark:text-[#38C200]" />
) : (
<Copy className="w-4 h-4 text-gray-600 dark:text-gray-300" />
)}
</button>
);
};

View File

@@ -1,163 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { isTauri } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-shell";
import { metaOrCtrlKey, isMetaOrCtrlKey } from "@/utils/keyboardUtils";
interface DropdownListProps {
selected: (item: any) => void;
suggests: any[];
isSearchComplete: boolean;
}
function DropdownList({ selected, suggests }: DropdownListProps) {
const [selectedItem, setSelectedItem] = useState<number | null>(null);
const [showIndex, setShowIndex] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const handleOpenURL = async (url: string) => {
if (!url) return;
try {
if (isTauri()) {
await open(url);
// console.log("URL opened in default browser");
}
} catch (error) {
console.error("Failed to open URL:", error);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
// console.log(
// "handleKeyDown",
// e.key,
// showIndex,
// e.key >= "0" && e.key <= "9" && showIndex
// );
if (!suggests.length) return;
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedItem((prev) =>
prev === null || prev === 0 ? suggests.length - 1 : prev - 1
);
} else if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedItem((prev) =>
prev === null || prev === suggests.length - 1 ? 0 : prev + 1
);
} else if (e.key === metaOrCtrlKey()) {
e.preventDefault();
setShowIndex(true);
}
if (e.key === "Enter" && selectedItem !== null) {
// console.log("Enter key pressed", selectedItem);
const item = suggests[selectedItem];
if (item?.url) {
handleOpenURL(item?.url);
} else {
selected(item);
}
}
if (e.key >= "0" && e.key <= "9" && showIndex) {
// console.log(`number ${e.key}`);
const item = suggests[parseInt(e.key, 10)];
if (item?.url) {
handleOpenURL(item?.url);
} else {
selected(item);
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
// console.log("handleKeyUp", e.key);
if (!suggests.length) return;
if (!isMetaOrCtrlKey(e)) {
setShowIndex(false);
}
};
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [showIndex, selectedItem, suggests]);
// useEffect(() => {
// if (suggests.length > 0) {
// setSelectedItem(0);
// }
// }, [JSON.stringify(suggests)]);
useEffect(() => {
if (selectedItem !== null && itemRefs.current[selectedItem]) {
itemRefs.current[selectedItem]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [selectedItem]);
return (
<div
ref={containerRef}
data-tauri-drag-region
className="max-h-[458px] w-full p-2 flex flex-col rounded-xl overflow-y-auto overflow-hidden custom-scrollbar focus:outline-none"
tabIndex={0}
>
<div className="p-2 text-xs text-[#999] dark:text-[#666]">Results</div>
{suggests?.map((item, index) => {
const isSelected = selectedItem === index;
return (
<div
key={item._id}
ref={(el) => (itemRefs.current[index] = el)}
onMouseEnter={() => setSelectedItem(index)}
onClick={() => {
if (item?.url) {
handleOpenURL(item?.url);
} else {
selected(item);
}
}}
className={`w-full px-2 py-2.5 text-sm flex items-center justify-between rounded-lg transition-colors ${
isSelected
? "bg-[rgba(0,0,0,0.1)] dark:bg-[rgba(255,255,255,0.1)] hover:bg-[rgba(0,0,0,0.1)] dark:hover:bg-[rgba(255,255,255,0.1)]"
: ""
}`}
>
<div className="flex gap-2 items-center">
<img className="w-5 h-5" src={item?.icon} alt="icon" />
<span className="text-[#333] dark:text-[#d8d8d8] truncate w-80 text-left">
{item?.title}
</span>
</div>
<div className="flex gap-2 items-center relative">
<span className="text-sm text-[#666] dark:text-[#666] truncate w-52 text-right">
{item?.source}
</span>
{showIndex && index < 10 ? (
<div
className={`absolute right-0 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] shadow-[-6px_0px_6px_2px_#e6e6e6] dark:shadow-[-6px_0px_6px_2px_#000] rounded-md`}
>
{index}
</div>
) : null}
</div>
</div>
);
})}
</div>
);
}
export default DropdownList;

View File

@@ -0,0 +1,47 @@
import { Component, ErrorInfo, ReactNode } from "react";
import { ErrorDisplay } from "@/components/Common/ErrorDisplay";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
class ErrorBoundaryClass extends Component<
Props & { onError: (error: Error, errorInfo: ErrorInfo) => void }
> {
state = { hasError: false, error: null as Error | null };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.props.onError(error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<ErrorDisplay errorMessage={this.state.error?.message} />
)
);
}
return this.props.children;
}
}
const ErrorBoundary = ({ children, fallback }: Props) => {
const handleError = (error: Error, errorInfo: ErrorInfo) => {
console.error("Uncaught error:", error, errorInfo);
};
return (
<ErrorBoundaryClass onError={handleError} fallback={fallback}>
{children}
</ErrorBoundaryClass>
);
};
export default ErrorBoundary;

View File

@@ -0,0 +1,32 @@
import React from "react";
import { useTranslation } from "react-i18next";
import errorImg from "@/assets/error_page.png";
interface ErrorDisplayProps {
errorMessage?: string;
}
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ errorMessage }) => {
const { t } = useTranslation();
return (
<div className="w-full h-screen bg-white shadow-[0px_16px_32px_0px_rgba(0,0,0,0.4)] rounded-xl border-[2px] border-[#E6E6E6] m-auto">
<div className="flex flex-col justify-center items-center">
<img
src={errorImg}
alt="error-page"
className="w-[221px] h-[154px] mb-8 mt-[72px]"
/>
<div className="w-[380px] h-[46px] px-5 font-normal text-base text-[rgba(0,0,0,0.85)] leading-[25px] text-center mb-4">
{t('error.message')}
</div>
{errorMessage && (
<div className="w-[380px] h-[45px] font-normal text-[10px] text-[rgba(135,135,135,0.85)] leading-[16px] text-center">
<i>{errorMessage}</i>
</div>
)}
</div>
</div>
);
};

View File

@@ -1,144 +0,0 @@
import {
// Settings,
// LogOut,
Command,
// User,
// Home,
// ChevronUp,
ArrowDown01,
AppWindowMac,
// ArrowDownUp,
CornerDownLeft,
} from "lucide-react";
// import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/react";
// import { Link } from "react-router-dom";
// import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
interface FooterProps {
isChat: boolean;
name?: string;
}
export const Footer = ({ name }: FooterProps) => {
// async function openWebviewWindowSettings() {
// const webview = new WebviewWindow("settings", {
// title: "Coco Settings",
// dragDropEnabled: true,
// center: true,
// width: 900,
// height: 700,
// alwaysOnTop: true,
// skipTaskbar: true,
// decorations: true,
// closable: true,
// url: "/ui/settings",
// });
// webview.once("tauri://created", function () {
// console.log("webview created");
// });
// webview.once("tauri://error", function (e) {
// console.log("error creating webview", e);
// });
// }
return (
<div
data-tauri-drag-region
className="px-4 z-999 mx-[1px] h-10 absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-xl rounded-t-none overflow-hidden"
>
<div className="flex items-center">
{
name ? (
<div className="flex gap-2 items-center text-[#666] text-xs">
<AppWindowMac className="w-5 h-5" /> {name}
</div>
) : null
// <Menu as="div" className="relative">
// <MenuButton className="h-7 flex items-center space-x-2 px-3 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
// <Command className="w-5 h-5 text-gray-600 dark:text-gray-400" />
// <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
// Coco
// </span>
// <ChevronUp className="w-4 h-4 text-gray-500 dark:text-gray-400" />
// </MenuButton>
// <MenuItems className="absolute bottom-full mb-2 left-0 w-64 rounded-lg bg-white dark:bg-gray-700 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
// <div className="p-1">
// <MenuItem>
// {({ active }) => (
// <button
// className={`${
// active
// ? "bg-gray-100 dark:bg-gray-700"
// : "text-gray-900 dark:text-gray-100"
// } group flex w-full items-center rounded-md px-3 py-2 text-sm`}
// >
// <Home className="w-4 h-4 mr-2" />
// <Link to={`/`}>Home</Link>
// </button>
// )}
// </MenuItem>
// {/* <MenuItem>
// {({ active }) => (
// <button
// className={`${
// active
// ? "bg-gray-100 dark:bg-gray-700"
// : "text-gray-900 dark:text-gray-100"
// } group flex w-full items-center rounded-md px-3 py-2 text-sm`}
// >
// <User className="w-4 h-4 mr-2" />
// Profile
// </button>
// )}
// </MenuItem> */}
// <MenuItem>
// {({ active }) => (
// <button
// className={`${
// active
// ? "bg-gray-100 dark:bg-gray-700"
// : "text-gray-900 dark:text-gray-100"
// } group flex w-full items-center rounded-md px-3 py-2 text-sm`}
// onClick={openWebviewWindowSettings}
// >
// <Settings className="w-4 h-4 mr-2" />
// Settings
// </button>
// )}
// </MenuItem>
// {/* <div className="h-px bg-gray-200 dark:bg-gray-700 my-1" />
// <MenuItem>
// {({ active }) => (
// <button
// className={`${
// active
// ? "bg-gray-100 dark:bg-gray-700"
// : "text-gray-900 dark:text-gray-100"
// } group flex w-full items-center rounded-md px-3 py-2 text-sm`}
// >
// <LogOut className="w-4 h-4 mr-2" />
// Sign Out
// </button>
// )}
// </MenuItem> */}
// </div>
// </MenuItems>
// </Menu>
}
</div>
<div className="flex items-center gap-3">
<div className="gap-1 flex items-center text-[#666] dark:text-[#666] text-sm">
<span className="mr-1.5 ">Quick open</span>
<Command className="w-5 h-5 p-1 border rounded-[6px] dark:text-[#666] dark:border-[rgba(255,255,255,0.15)]" />
<ArrowDown01 className="w-5 h-5 p-1 border rounded-[6px] dark:text-[#666] dark:border-[rgba(255,255,255,0.15)]" />
</div>
<div className="flex items-center text-[#666] dark:text-[#666] text-sm">
<span className="mr-1.5 ">Open</span>
<CornerDownLeft className="w-5 h-5 p-1 border rounded-[6px] dark:text-[#666] dark:border-[rgba(255,255,255,0.15)]" />
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,21 @@
const AudioIcon = () => {
return (
<svg
width="1em"
height="1em"
viewBox="0 0 16 16"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<title>audio</title>
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<path
d="M14.1178571,4.0125 C14.225,4.11964286 14.2857143,4.26428571 14.2857143,4.41607143 L14.2857143,15.4285714 C14.2857143,15.7446429 14.0303571,16 13.7142857,16 L2.28571429,16 C1.96964286,16 1.71428571,15.7446429 1.71428571,15.4285714 L1.71428571,0.571428571 C1.71428571,0.255357143 1.96964286,0 2.28571429,0 L9.86964286,0 C10.0214286,0 10.1678571,0.0607142857 10.275,0.167857143 L14.1178571,4.0125 Z M10.7315824,7.11216117 C10.7428131,7.15148751 10.7485063,7.19218979 10.7485063,7.23309113 L10.7485063,8.07742614 C10.7484199,8.27364959 10.6183424,8.44607275 10.4296853,8.50003683 L8.32984514,9.09986306 L8.32984514,11.7071803 C8.32986605,12.5367078 7.67249692,13.217028 6.84345686,13.2454634 L6.79068592,13.2463395 C6.12766108,13.2463395 5.53916361,12.8217001 5.33010655,12.1924966 C5.1210495,11.563293 5.33842118,10.8709227 5.86959669,10.4741173 C6.40077221,10.0773119 7.12636292,10.0652587 7.67042486,10.4442027 L7.67020842,7.74937024 L7.68449368,7.74937024 C7.72405122,7.59919041 7.83988806,7.48101083 7.98924584,7.4384546 L10.1880418,6.81004755 C10.42156,6.74340323 10.6648954,6.87865515 10.7315824,7.11216117 Z M9.60714286,1.31785714 L12.9678571,4.67857143 L9.60714286,4.67857143 L9.60714286,1.31785714 Z"
fill="currentColor"
/>
</g>
</svg>
);
};
export default AudioIcon;

View File

@@ -0,0 +1,21 @@
const VideoIcon = () => {
return (
<svg
width="1em"
height="1em"
viewBox="0 0 16 16"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<title>video</title>
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<path
d="M14.1178571,4.0125 C14.225,4.11964286 14.2857143,4.26428571 14.2857143,4.41607143 L14.2857143,15.4285714 C14.2857143,15.7446429 14.0303571,16 13.7142857,16 L2.28571429,16 C1.96964286,16 1.71428571,15.7446429 1.71428571,15.4285714 L1.71428571,0.571428571 C1.71428571,0.255357143 1.96964286,0 2.28571429,0 L9.86964286,0 C10.0214286,0 10.1678571,0.0607142857 10.275,0.167857143 L14.1178571,4.0125 Z M12.9678571,4.67857143 L9.60714286,1.31785714 L9.60714286,4.67857143 L12.9678571,4.67857143 Z M10.5379461,10.3101106 L6.68957555,13.0059749 C6.59910784,13.0693494 6.47439406,13.0473861 6.41101953,12.9569184 C6.3874624,12.9232903 6.37482581,12.8832269 6.37482581,12.8421686 L6.37482581,7.45043999 C6.37482581,7.33998304 6.46436886,7.25043999 6.57482581,7.25043999 C6.61588409,7.25043999 6.65594753,7.26307658 6.68957555,7.28663371 L10.5379461,9.98249803 C10.6284138,10.0458726 10.6503772,10.1705863 10.5870027,10.2610541 C10.5736331,10.2801392 10.5570312,10.2967411 10.5379461,10.3101106 Z"
fill="currentColor"
/>
</g>
</svg>
);
};
export default VideoIcon;

View File

@@ -0,0 +1,154 @@
import {
FileExcelFilled,
FileImageFilled,
FileMarkdownFilled,
FilePdfFilled,
FilePptFilled,
FileTextFilled,
FileWordFilled,
FileZipFilled,
} from "@ant-design/icons";
import AudioIcon from "./AudioIcon";
import VideoIcon from "./VideoIcon";
import { FC, useMemo } from "react";
import clsx from "clsx";
interface FileIconProps {
extname: string;
className?: string;
}
const FileIcon: FC<FileIconProps> = (props) => {
const { extname, className } = props;
const presetFileIcons = [
{
icon: <FileExcelFilled />,
color: "#22b35e",
extnames: ["xlsx", "xls", "csv", "xlsm", "xltx", "xltm", "xlsb"],
},
{
icon: <FileImageFilled />,
color: "#13c2c2",
extnames: [
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"svg",
"ico",
"tiff",
"raw",
"heic",
"psd",
"ai",
],
},
{
icon: <FileMarkdownFilled />,
color: "#722ed1",
extnames: ["md", "mdx", "markdown", "mdown", "mkd", "mkdn"],
},
{
icon: <FilePdfFilled />,
color: "#ff4d4f",
extnames: ["pdf", "xps", "oxps"],
},
{
icon: <FilePptFilled />,
color: "#d04423",
extnames: [
"ppt",
"pptx",
"pps",
"ppsx",
"pot",
"potx",
"pptm",
"potm",
"ppsm",
],
},
{
icon: <FileWordFilled />,
color: "#1677ff",
extnames: ["doc", "docx", "dot", "dotx", "docm", "dotm", "rtf", "odt"],
},
{
icon: <FileZipFilled />,
color: "#fab714",
extnames: [
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
"tgz",
"iso",
"dmg",
],
},
{
icon: <VideoIcon />,
color: "#7b61ff",
extnames: [
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"m4v",
"mpeg",
"mpg",
"3gp",
"rmvb",
"ts",
],
},
{
icon: <AudioIcon />,
color: "#eb2f96",
extnames: [
"mp3",
"wav",
"flac",
"ape",
"aac",
"ogg",
"wma",
"m4a",
"opus",
"ac3",
"mid",
"midi",
],
},
];
const [icon, iconColor] = useMemo(() => {
for (const item of presetFileIcons) {
const { icon, color, extnames } = item;
if (extnames.includes(extname)) {
return [icon, color];
}
}
return [<FileTextFilled key="defaultIcon" />, "#8c8c8c"];
}, [extname]);
return (
<div className={clsx("text-3xl", className)} style={{ color: iconColor }}>
{icon}
</div>
);
};
export default FileIcon;

View File

@@ -32,7 +32,11 @@ function ItemIcon({
); );
} }
const selectedIcon = icons[item?.icon]; let selectedIcon = icons[item?.icon];
if (!selectedIcon) {
selectedIcon=item?.icon
}
if (!selectedIcon) { if (!selectedIcon) {
return ( return (
<IconWrapper className={className} onClick={onClick}> <IconWrapper className={className} onClick={onClick}>

View File

@@ -1,312 +0,0 @@
import { Library, Send, Plus, AudioLines, Image } from "lucide-react";
import { useRef, useState, useEffect, useCallback } from "react";
import { listen } from "@tauri-apps/api/event";
import ChatSwitch from "@/components/Common/ChatSwitch";
import AutoResizeTextarea from "./AutoResizeTextarea";
import { useChatStore } from "../../stores/chatStore";
import StopIcon from "../../icons/Stop";
import { useAppStore } from "../../stores/appStore";
import { metaOrCtrlKey, isMetaOrCtrlKey } from "@/utils/keyboardUtils";
interface ChatInputProps {
onSend: (message: string) => void;
disabled: boolean;
disabledChange: () => void;
changeMode: (isChatMode: boolean) => void;
isChatMode: boolean;
inputValue: string;
changeInput: (val: string) => void;
}
export default function ChatInput({
onSend,
disabled,
changeMode,
isChatMode,
inputValue,
changeInput,
disabledChange,
}: ChatInputProps) {
const showTooltip = useAppStore((state) => state.showTooltip);
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<{ reset: () => void; focus: () => void }>(null);
const { curChatEnd } = useChatStore();
const [isCommandPressed, setIsCommandPressed] = useState(false);
const handleToggleFocus = useCallback(() => {
if (isChatMode) {
textareaRef.current?.focus();
} else {
inputRef.current?.focus();
}
}, [isChatMode, textareaRef, inputRef]);
const handleSubmit = useCallback(() => {
const trimmedValue = inputValue.trim();
if (trimmedValue && !disabled) {
onSend(trimmedValue);
}
}, [inputValue, disabled, onSend]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === metaOrCtrlKey()) {
setIsCommandPressed(true);
}
if (isMetaOrCtrlKey(e)) {
switch (e.code) {
case "KeyI":
handleToggleFocus();
break;
case "KeyM":
console.log("KeyM");
break;
case "Enter":
isChatMode && handleSubmit();
break;
case "KeyO":
console.log("KeyO");
break;
case "KeyU":
console.log("KeyU");
break;
case "KeyN":
console.log("KeyN");
break;
case "KeyG":
console.log("KeyG");
break;
default:
break;
}
}
},
[handleToggleFocus, isChatMode, handleSubmit]
);
const handleKeyUp = useCallback((e: KeyboardEvent) => {
if (e.key === metaOrCtrlKey()) {
setIsCommandPressed(false);
}
}, []);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [handleKeyDown, handleKeyUp]);
useEffect(() => {
const setupListener = async () => {
const unlisten = await listen("tauri://focus", () => {
// console.log("Window focused!");
if (isChatMode) {
textareaRef.current?.focus();
} else {
inputRef.current?.focus();
}
});
return unlisten;
};
let unlisten: (() => void) | undefined;
setupListener().then((unlistener) => {
unlisten = unlistener;
});
return () => {
unlisten?.();
};
}, [isChatMode]);
const openChatAI = async () => {
console.log("Chat AI opened.");
};
return (
<div className="w-full rounded-xl overflow-hidden relative">
<div className="rounded-xl">
<div className="p-[13px] flex items-center dark:text-[#D8D8D8] bg-white dark:bg-[#202126] rounded-xl transition-all relative">
<div className="flex flex-wrap gap-2 flex-1 items-center relative">
{isChatMode ? (
<AutoResizeTextarea
ref={textareaRef}
input={inputValue}
setInput={changeInput}
handleKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
}}
/>
) : (
<input
ref={inputRef}
type="text"
autoFocus
autoComplete="off"
autoCapitalize="none"
spellCheck="false"
className="text-xs leading-6 font-normal flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent"
placeholder="Search whatever you want ..."
value={inputValue}
onChange={(e) => {
onSend(e.target.value);
}}
/>
)}
{showTooltip && isCommandPressed ? (
<div
className={`absolute bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
>
+ i
</div>
) : null}
</div>
{/* {isChatMode ? (
<button
className="p-1 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition-colors"
type="button"
>
<Mic className="w-4 h-4 text-[#999] dark:text-[#999]" />
</button>
) : null} */}
{isChatMode && curChatEnd ? (
<button
className={`ml-1 p-1 ${
inputValue
? "bg-[#0072FF]"
: "bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]"
} rounded-full transition-colors`}
type="submit"
onClick={() => onSend(inputValue.trim())}
>
<Send className="w-4 h-4 text-white" />
</button>
) : null}
{isChatMode && !curChatEnd ? (
<button
className={`ml-1 px-1 bg-[#0072FF] rounded-full transition-colors`}
type="submit"
onClick={() => disabledChange()}
>
<StopIcon
size={16}
className="w-4 h-4 text-white"
aria-label="Stop message"
/>
</button>
) : null}
{showTooltip && isChatMode && isCommandPressed ? (
<div
className={`absolute right-16 bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
>
+ m
</div>
) : null}
{showTooltip && isChatMode && isCommandPressed ? (
<div
className={`absolute right-1 bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
>
+
</div>
) : null}
</div>
<div
data-tauri-drag-region
className="flex justify-between items-center p-2 rounded-xl"
>
{isChatMode ? (
<div className="flex gap-1 text-xs text-[#333] dark:text-[#d8d8d8]">
<button
className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors relative"
onClick={openChatAI}
>
<Library className="w-4 h-4 mr-1 text-[#000] dark:text-[#d8d8d8]" />
Coco
{showTooltip && isCommandPressed ? (
<div
className={`absolute left-0 bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
>
+ o
</div>
) : null}
</button>
<button className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-color relative">
<Plus className="w-4 h-4 mr-1 text-[#000] dark:text-[#d8d8d8]" />
Upload
{showTooltip && isCommandPressed ? (
<div
className={`absolute left-1 bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
>
+ u
</div>
) : null}
</button>
</div>
) : (
<div className="w-28 flex gap-1 relative">
<button
className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors relative"
onClick={openChatAI}
>
<AudioLines className="w-4 h-4 text-[#000] dark:text-[#d8d8d8]" />
</button>
<button className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-color relative">
<Image className="w-4 h-4 text-[#000] dark:text-[#d8d8d8]" />
</button>
{showTooltip && isCommandPressed ? (
<div
className={`absolute left-0 bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
>
+ n
</div>
) : null}
{showTooltip && isCommandPressed ? (
<div
className={`absolute left-14 bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
>
+ g
</div>
) : null}
</div>
)}
<div className="relative w-24 flex justify-end items-center">
{showTooltip && isCommandPressed ? (
<div
className={`absolute left-0 z-10 bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
>
+ t
</div>
) : null}
<ChatSwitch
isChatMode={isChatMode}
onChange={(value) => {
value && disabledChange();
changeMode(value);
}}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,129 +0,0 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { isTauri, invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { LogicalSize } from "@tauri-apps/api/dpi";
import DropdownList from "./DropdownList";
import { Footer } from "./Footer";
import { SearchResults } from "../Search/SearchResults";
import { useAppStore } from '@/stores/appStore';
interface SearchProps {
changeInput: (val: string) => void;
isTransitioned: boolean;
isChatMode: boolean;
input: string;
}
function Search({ isTransitioned, isChatMode, input }: SearchProps) {
const initializeListeners = useAppStore(state => state.initializeListeners);
useEffect(() => {
initializeListeners();
}, []);
const [suggests, setSuggests] = useState<any[]>([]);
const [isSearchComplete, setIsSearchComplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<any>();
const mainWindowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isTauri()) return;
const element = mainWindowRef.current;
if (!element) return;
const resizeObserver = new ResizeObserver(async (entries) => {
for (let entry of entries) {
let newHeight = entry.contentRect.height;
console.log("Height updated:", newHeight);
newHeight = newHeight + 90 + (newHeight === 0 ? 0 : 46);
await getCurrentWebviewWindow()?.setSize(
new LogicalSize(680, newHeight)
);
}
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}, [suggests]);
const getSuggest = async () => {
if (!input) return
try {
const response: any = await invoke("query_coco_fusion", {
from: 0,
size: 10,
queryStrings: { query: input },
});
console.log("_suggest", input, response);
const data = response.data?.hits?.hits || [];
setSuggests(data);
//
// const list = [];
// for (let i = 0; i < input.length; i++) {
// list.push({
// _source: { url: `https://www.google.com/search?q=${i}` },
// });
// }
// setSuggests(list);
//
setIsSearchComplete(true);
} catch (error) {
console.error("query_coco_fusion:", error);
}
};
function debounce(fn: Function, delay: number) {
let timer: NodeJS.Timeout;
return (...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const debouncedSearch = useCallback(debounce(getSuggest, 300), [input]);
useEffect(() => {
!isChatMode && debouncedSearch();
if (!input) setSuggests([]);
}, [input]);
if (isChatMode || suggests.length === 0) return null;
return (
<div
className={`rounded-xl overflow-hidden bg-search_bg_light dark:bg-search_bg_dark bg-cover border border-[#E6E6E6] dark:border-[#272626] absolute w-full transition-opacity ${
isTransitioned ? "opacity-0 pointer-events-none" : "opacity-100"
} top-[96px]`}
style={{
backgroundPosition: "-1px 0",
backgroundSize: "101% 100%",
}}
>
{!isChatMode ? (
<div
ref={mainWindowRef}
className={`max-h-[498px] pb-10 w-full relative`}
>
{/* Search Results Panel */}
{suggests.length > 0 && !selectedItem ? (
<DropdownList
suggests={suggests}
isSearchComplete={isSearchComplete}
selected={(item) => setSelectedItem(item)}
/>
) : null}
{selectedItem ? <SearchResults input={input} isChatMode={isChatMode} /> : null}
{suggests.length > 0 || selectedItem ? (
<Footer isChat={false} name={selectedItem?.source} />
) : null}
</div>
) : null}
</div>
);
}
export default Search;

View File

@@ -1,48 +0,0 @@
import { useState } from "react";
const TransitionComponent = () => {
const [isTransitioned, setIsTransitioned] = useState(false);
const handleToggle = () => {
setIsTransitioned(!isTransitioned);
};
return (
<div
data-tauri-drag-region
className="w-[680px] h-[596px] mx-auto overflow-hidden relative"
>
<div
data-tauri-drag-region
className={`shadow-window-custom border border-[#E6E6E6] dark:border-[#272626] absolute w-full bg-red-500 text-white flex items-center justify-center transition-all duration-500 ${
isTransitioned ? "top-[506px] h-[90px]" : "top-0 h-[90px]"
}`}
>
<button
className="px-4 py-2 bg-white text-black rounded"
onClick={handleToggle}
>
Toggle
</button>
</div>
<div
data-tauri-drag-region
className={`shadow-window-custom border border-[#E6E6E6] dark:border-[#272626] absolute w-full bg-green-500 transition-opacity duration-500 ${
isTransitioned ? "opacity-0 pointer-events-none" : "opacity-100"
} bottom-0 h-[calc(100vh-90px)]`}
></div>
<div
data-tauri-drag-region
className={`shadow-window-custom border border-[#E6E6E6] dark:border-[#272626] absolute w-full bg-yellow-500 transition-all duration-500 ${
isTransitioned
? "top-0 opacity-100 pointer-events-auto"
: "-top-[506px] opacity-0 pointer-events-none"
} h-[calc(100vh-90px)]`}
></div>
</div>
);
};
export default TransitionComponent;

View File

@@ -1,12 +0,0 @@
export interface SearchItem {
id: number;
name: string;
description: string;
}
export interface SearchCategory {
id: string;
icon: React.ComponentType<{ className?: string }>;
title: string;
items: SearchItem[];
}

View File

@@ -1,8 +1,3 @@
import { useOSKeyPress } from "@/hooks/useOSKeyPress";
import { useSearchStore } from "@/stores/searchStore";
import { copyToClipboard, OpenURLWithBrowser } from "@/utils";
import { isMac } from "@/utils/platform";
import { invoke } from "@tauri-apps/api/core";
import { import {
useClickAway, useClickAway,
useCreation, useCreation,
@@ -15,11 +10,20 @@ import { Link, SquareArrowOutUpRight } from "lucide-react";
import { cloneElement, useEffect, useRef } from "react"; import { cloneElement, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useOSKeyPress } from "@/hooks/useOSKeyPress";
import { useSearchStore } from "@/stores/searchStore";
import { copyToClipboard, OpenURLWithBrowser } from "@/utils";
import { isMac } from "@/utils/platform";
interface State { interface State {
activeMenuIndex: number; activeMenuIndex: number;
} }
const ContextMenu = () => { interface ContextMenuProps {
hideCoco: () => Promise<void>;
}
const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -47,6 +51,10 @@ const ContextMenu = () => {
shortcut: "enter", shortcut: "enter",
clickEvent: () => { clickEvent: () => {
OpenURLWithBrowser(selectedSearchContent?.url); OpenURLWithBrowser(selectedSearchContent?.url);
setVisibleContextMenu(false);
hideCoco();
}, },
}, },
{ {
@@ -56,6 +64,8 @@ const ContextMenu = () => {
shortcut: isMac ? "meta.l" : "ctrl.l", shortcut: isMac ? "meta.l" : "ctrl.l",
clickEvent: () => { clickEvent: () => {
copyToClipboard(selectedSearchContent?.url); copyToClipboard(selectedSearchContent?.url);
setVisibleContextMenu(false);
}, },
}, },
]; ];
@@ -108,7 +118,7 @@ const ContextMenu = () => {
const item = menus.find((item) => item.shortcut === key); const item = menus.find((item) => item.shortcut === key);
handleClick(item?.clickEvent); item?.clickEvent();
} }
); );
@@ -118,14 +128,6 @@ const ContextMenu = () => {
event.stopImmediatePropagation(); event.stopImmediatePropagation();
}); });
const handleClick = (clickEvent?: () => void) => {
clickEvent?.();
setVisibleContextMenu(false);
invoke("hide_coco");
};
return ( return (
<> <>
{visibleContextMenu && ( {visibleContextMenu && (
@@ -165,7 +167,7 @@ const ContextMenu = () => {
onMouseEnter={() => { onMouseEnter={() => {
state.activeMenuIndex = index; state.activeMenuIndex = index;
}} }}
onClick={() => handleClick(clickEvent)} onClick={clickEvent}
> >
<div className="flex items-center gap-2 text-black/80 dark:text-white/80"> <div className="flex items-center gap-2 text-black/80 dark:text-white/80">
{cloneElement(icon, { className: "size-4" })} {cloneElement(icon, { className: "size-4" })}

View File

@@ -1,7 +1,5 @@
import React, { useState, useRef, useEffect, useCallback } from "react"; import React, { useState, useRef, useEffect, useCallback } from "react";
import { useInfiniteScroll } from "ahooks"; import { useInfiniteScroll } from "ahooks";
import { isTauri, invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-shell";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FixedSizeList } from "react-window"; import { FixedSizeList } from "react-window";
@@ -10,6 +8,7 @@ import { SearchHeader } from "./SearchHeader";
import noDataImg from "@/assets/coconut-tree.png"; import noDataImg from "@/assets/coconut-tree.png";
import { metaOrCtrlKey } from "@/utils/keyboardUtils"; import { metaOrCtrlKey } from "@/utils/keyboardUtils";
import SearchListItem from "./SearchListItem"; import SearchListItem from "./SearchListItem";
import { OpenURLWithBrowser } from "@/utils/index";
interface DocumentListProps { interface DocumentListProps {
onSelectDocument: (id: string) => void; onSelectDocument: (id: string) => void;
@@ -19,6 +18,11 @@ interface DocumentListProps {
selectedId?: string; selectedId?: string;
viewMode: "detail" | "list"; viewMode: "detail" | "list";
setViewMode: (mode: "detail" | "list") => void; setViewMode: (mode: "detail" | "list") => void;
queryDocuments: (
from: number,
size: number,
queryStrings: any
) => Promise<any>;
} }
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@@ -30,6 +34,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
isChatMode, isChatMode,
viewMode, viewMode,
setViewMode, setViewMode,
queryDocuments,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const sourceData = useSearchStore((state) => state.sourceData); const sourceData = useSearchStore((state) => state.sourceData);
@@ -58,11 +63,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
} }
try { try {
const response: any = await invoke("query_coco_fusion", { const response = await queryDocuments(from, PAGE_SIZE, queryStrings);
from: from,
size: PAGE_SIZE,
queryStrings,
});
const list = response?.hits || []; const list = response?.hits || [];
const total = response?.total_hits || 0; const total = response?.total_hits || 0;
@@ -111,18 +112,6 @@ export const DocumentList: React.FC<DocumentListProps> = ({
setIsKeyboardMode(false); setIsKeyboardMode(false);
}, [isChatMode, input]); }, [isChatMode, input]);
const handleOpenURL = async (url: string) => {
if (!url) return;
try {
if (isTauri()) {
await open(url);
// console.log("URL opened in default browser");
}
} catch (error) {
console.error("Failed to open URL:", error);
}
};
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
if (!data?.list?.length) return; if (!data?.list?.length) return;
@@ -158,7 +147,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
if (e.key === "Enter" && selectedItem !== null) { if (e.key === "Enter" && selectedItem !== null) {
const item = data?.list?.[selectedItem]; const item = data?.list?.[selectedItem];
if (item?.url) { if (item?.url) {
handleOpenURL(item?.url); OpenURLWithBrowser(item?.url);
} }
} }
}, },
@@ -211,7 +200,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
onMouseEnter={() => onMouseEnter(index, item)} onMouseEnter={() => onMouseEnter(index, item)}
onItemClick={() => { onItemClick={() => {
if (item?.url) { if (item?.url) {
handleOpenURL(item?.url); OpenURLWithBrowser(item?.url);
} }
}} }}
showListRight={viewMode === "list"} showListRight={viewMode === "list"}
@@ -219,7 +208,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
</div> </div>
); );
}, },
[data, selectedItem, viewMode, onMouseEnter, handleOpenURL] [data, selectedItem, viewMode, onMouseEnter]
); );
return ( return (
@@ -238,7 +227,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{data?.list && data.list.length > 0 ? ( {data?.list && data.list.length > 0 ? (
<div ref={containerRef} style={{ height: '100%' }}> <div ref={containerRef} style={{ height: "100%" }}>
<FixedSizeList <FixedSizeList
ref={listRef} ref={listRef}
height={containerRef.current?.clientHeight || 400} height={containerRef.current?.clientHeight || 400}
@@ -250,8 +239,13 @@ export const DocumentList: React.FC<DocumentListProps> = ({
if (!scrollUpdateWasRequested && containerRef.current) { if (!scrollUpdateWasRequested && containerRef.current) {
const threshold = 100; const threshold = 100;
const { scrollHeight, clientHeight } = containerRef.current; const { scrollHeight, clientHeight } = containerRef.current;
const remainingScroll = scrollHeight - (scrollOffset + clientHeight); const remainingScroll =
if (remainingScroll <= threshold && !loading && data?.hasMore) { scrollHeight - (scrollOffset + clientHeight);
if (
remainingScroll <= threshold &&
!loading &&
data?.hasMore
) {
data?.loadMore && data.loadMore(); data?.loadMore && data.loadMore();
} }
} }

View File

@@ -14,7 +14,6 @@ import { OpenURLWithBrowser } from "@/utils/index";
type ISearchData = Record<string, any[]>; type ISearchData = Record<string, any[]>;
interface DropdownListProps { interface DropdownListProps {
selected: (item: any) => void;
suggests: any[]; suggests: any[];
SearchData: ISearchData; SearchData: ISearchData;
IsError: boolean; IsError: boolean;
@@ -23,7 +22,6 @@ interface DropdownListProps {
} }
function DropdownList({ function DropdownList({
selected,
suggests, suggests,
SearchData, SearchData,
IsError, IsError,
@@ -110,8 +108,6 @@ function DropdownList({
const item = globalItemIndexMap[selectedItem]; const item = globalItemIndexMap[selectedItem];
if (item?.url) { if (item?.url) {
OpenURLWithBrowser(item?.url); OpenURLWithBrowser(item?.url);
} else {
selected(item);
} }
} }
@@ -120,12 +116,10 @@ function DropdownList({
const item = globalItemIndexMap[parseInt(e.key, 10)]; const item = globalItemIndexMap[parseInt(e.key, 10)];
if (item?.url) { if (item?.url) {
OpenURLWithBrowser(item?.url); OpenURLWithBrowser(item?.url);
} else {
selected(item);
} }
} }
}, },
[suggests, selectedItem, showIndex, selected, globalItemIndexMap] [suggests, selectedItem, showIndex, globalItemIndexMap]
); );
const handleKeyUp = useCallback((e: KeyboardEvent) => { const handleKeyUp = useCallback((e: KeyboardEvent) => {
@@ -158,7 +152,6 @@ function DropdownList({
function goToTwoPage(item: any) { function goToTwoPage(item: any) {
setSourceData(item); setSourceData(item);
selected && selected(item);
} }
return ( return (
@@ -219,8 +212,6 @@ function DropdownList({
onItemClick={() => { onItemClick={() => {
if (item?.url) { if (item?.url) {
OpenURLWithBrowser(item?.url); OpenURLWithBrowser(item?.url);
} else {
selected(item);
} }
}} }}
goToTwoPage={goToTwoPage} goToTwoPage={goToTwoPage}

View File

@@ -1,59 +0,0 @@
import { useChatStore } from "@/stores/chatStore";
import { isImage } from "@/utils";
import { convertFileSrc } from "@tauri-apps/api/core";
import { filesize } from "filesize";
import { X } from "lucide-react";
const FileList = () => {
const uploadFiles = useChatStore((state) => state.uploadFiles);
const setUploadFiles = useChatStore((state) => state.setUploadFiles);
const deleteFile = (id: string) => {
setUploadFiles(uploadFiles.filter((file) => file.id !== id));
};
return (
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
{uploadFiles.map((file) => {
const { id, path, icon, name, extname, size } = 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]">
<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(id);
}}
>
<X className="size-[10px] text-white" />
</div>
<img
src={convertFileSrc(isImage(path) ? path : icon)}
className="size-[40px]"
/>
<div className="flex flex-col justify-between overflow-hidden">
<div className="truncate text-[#333333] dark:text-[#D8D8D8]">
{name}
</div>
<div className="text-xs text-[#999999]">
<div className="flex gap-2">
{extname && <span>{extname}</span>}
<span>
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
);
};
export default FileList;

View File

@@ -1,7 +1,6 @@
import { ArrowDown01, Command, CornerDownLeft } from "lucide-react"; import { ArrowDown01, Command, CornerDownLeft } from "lucide-react";
import { emit } from "@tauri-apps/api/event";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getCurrentWindow } from "@tauri-apps/api/window"; import clsx from "clsx";
import logoImg from "@/assets/icon.svg"; import logoImg from "@/assets/icon.svg";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
@@ -10,27 +9,29 @@ import { useAppStore } from "@/stores/appStore";
import { isMac } from "@/utils/platform"; import { isMac } from "@/utils/platform";
import PinOffIcon from "@/icons/PinOff"; import PinOffIcon from "@/icons/PinOff";
import PinIcon from "@/icons/Pin"; import PinIcon from "@/icons/Pin";
import { useUpdateStore } from "@/stores/updateStore";
interface FooterProps { interface FooterProps {
isChat: boolean; openSetting: () => void;
name?: string; setWindowAlwaysOnTop: (isPinned: boolean) => Promise<void>;
} }
export default function Footer({}: FooterProps) { export default function Footer({
openSetting,
setWindowAlwaysOnTop,
}: FooterProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const sourceData = useSearchStore((state) => state.sourceData); const sourceData = useSearchStore((state) => state.sourceData);
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 setVisible = useUpdateStore((state) => state.setVisible);
function openSetting() { const updateInfo = useUpdateStore((state) => state.updateInfo);
emit("open_settings", "");
}
const togglePin = async () => { const togglePin = async () => {
try { try {
const newPinned = !isPinned; const newPinned = !isPinned;
await getCurrentWindow().setAlwaysOnTop(newPinned); await setWindowAlwaysOnTop(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);
@@ -55,16 +56,26 @@ export default function Footer({}: FooterProps) {
alt={t("search.footer.logoAlt")} alt={t("search.footer.logoAlt")}
/> />
)} )}
<span className="text-xs text-gray-500 dark:text-gray-400"> <div className="relative text-xs text-gray-500 dark:text-gray-400">
{sourceData?.source?.name || {updateInfo?.available ? (
<div className="cursor-pointer" onClick={() => setVisible(true)}>
<span>{t("search.footer.updateAvailable")}</span>
<span className="absolute top-0 -right-2 size-1.5 bg-[#FF3434] rounded-full"></span>
</div>
) : (
sourceData?.source?.name ||
t("search.footer.version", { t("search.footer.version", {
version: process.env.VERSION || "v1.0.0", version: process.env.VERSION || "v1.0.0",
})} })
</span> )}
</div>
<button <button
onClick={togglePin} onClick={togglePin}
className={`${isPinned ? "text-blue-500" : ""}`} className={clsx({
"text-blue-500": isPinned,
"pl-2": updateInfo?.available,
})}
> >
{isPinned ? <PinIcon /> : <PinOffIcon />} {isPinned ? <PinIcon /> : <PinOffIcon />}
</button> </button>

View File

@@ -1,7 +1,5 @@
import { ArrowBigLeft, Search, Send, Brain } from "lucide-react"; import { ArrowBigLeft, Search, Send, Brain } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import clsx from "clsx"; import clsx from "clsx";
@@ -13,6 +11,13 @@ import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { metaOrCtrlKey } from "@/utils/keyboardUtils"; import { metaOrCtrlKey } from "@/utils/keyboardUtils";
import SearchPopover from "./SearchPopover"; import SearchPopover from "./SearchPopover";
// import AudioRecording from "../AudioRecording";
import { hide_coco } from "@/commands";
import { DataSource } from "@/types/commands";
// import InputExtra from "./InputExtra";
// import { useConnectStore } from "@/stores/connectStore";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useKeyPress } from "ahooks";
interface ChatInputProps { interface ChatInputProps {
onSend: (message: string) => void; onSend: (message: string) => void;
@@ -28,6 +33,19 @@ interface ChatInputProps {
isDeepThinkActive: boolean; isDeepThinkActive: boolean;
setIsDeepThinkActive: () => void; setIsDeepThinkActive: () => void;
isChatPage?: boolean; isChatPage?: boolean;
getDataSourcesByServer: (serverId: string) => Promise<DataSource[]>;
setupWindowFocusListener: (callback: () => void) => Promise<() => void>;
checkScreenPermission: () => Promise<boolean>;
requestScreenPermission: () => void;
getScreenMonitors: () => Promise<any[]>;
getScreenWindows: () => Promise<any[]>;
captureMonitorScreenshot: (id: number) => Promise<string>;
captureWindowScreenshot: (id: number) => Promise<string>;
openFileDialog: (options: {
multiple: boolean;
}) => Promise<string | string[] | null>;
getFileMetadata: (path: string) => Promise<any>;
getFileIcon: (path: string, size: number) => Promise<string>;
} }
export default function ChatInput({ export default function ChatInput({
@@ -44,7 +62,18 @@ export default function ChatInput({
isDeepThinkActive, isDeepThinkActive,
setIsDeepThinkActive, setIsDeepThinkActive,
isChatPage = false, isChatPage = false,
}: ChatInputProps) { getDataSourcesByServer,
setupWindowFocusListener,
}: // checkScreenPermission,
// requestScreenPermission,
// getScreenMonitors,
// getScreenWindows,
// captureMonitorScreenshot,
// captureWindowScreenshot,
// openFileDialog,
// getFileMetadata,
// getFileIcon,
ChatInputProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const showTooltip = useAppStore( const showTooltip = useAppStore(
@@ -60,6 +89,16 @@ export default function ChatInput({
(state: { setSourceData: any }) => state.setSourceData (state: { setSourceData: any }) => state.setSourceData
); );
// const sessionId = useConnectStore((state) => state.currentSessionId);
const modifierKey = useShortcutsStore((state) => {
return state.modifierKey;
});
const modifierKeyPressed = useShortcutsStore((state) => {
return state.modifierKeyPressed;
});
const modeSwitch = useShortcutsStore((state) => state.modeSwitch);
const returnToInput = useShortcutsStore((state) => state.returnToInput);
useEffect(() => { useEffect(() => {
return () => { return () => {
changeInput(""); changeInput("");
@@ -74,7 +113,38 @@ export default function ChatInput({
const { curChatEnd, connected } = useChatStore(); const { curChatEnd, connected } = useChatStore();
const [reconnectCountdown, setReconnectCountdown] = useState<number>(0);
useEffect(() => {
if (!reconnectCountdown || connected) {
setReconnectCountdown(0);
return;
}
if (reconnectCountdown > 0) {
const timer = setTimeout(() => {
setReconnectCountdown(reconnectCountdown - 1);
}, 1000);
return () => clearTimeout(timer);
}
}, [reconnectCountdown, connected]);
const [isCommandPressed, setIsCommandPressed] = useState(false); const [isCommandPressed, setIsCommandPressed] = useState(false);
const setModifierKeyPressed = useShortcutsStore((state) => {
return state.setModifierKeyPressed;
});
useEffect(() => {
const handleFocus = () => {
setIsCommandPressed(false);
setModifierKeyPressed(false);
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, []);
const handleToggleFocus = useCallback(() => { const handleToggleFocus = useCallback(() => {
if (isChatMode) { if (isChatMode) {
@@ -98,10 +168,12 @@ export default function ChatInput({
if (inputValue) { if (inputValue) {
changeInput(""); changeInput("");
} else if (!isPinned) { } else if (!isPinned) {
invoke("hide_coco").then(() => console.log("Hide Coco")); hide_coco();
} }
}, [inputValue, isPinned]); }, [inputValue, isPinned]);
useKeyPress(`${modifierKey}.${returnToInput}`, handleToggleFocus);
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
// console.log("handleKeyDown", e.code, e.key); // console.log("handleKeyDown", e.code, e.key);
@@ -123,8 +195,6 @@ export default function ChatInput({
case "Comma": case "Comma":
setIsCommandPressed(false); setIsCommandPressed(false);
break; break;
case "KeyI":
handleToggleFocus();
break; break;
case "ArrowLeft": case "ArrowLeft":
setSourceData(undefined); setSourceData(undefined);
@@ -181,24 +251,15 @@ export default function ChatInput({
}, [handleKeyDown, handleKeyUp]); }, [handleKeyDown, handleKeyUp]);
useEffect(() => { useEffect(() => {
if (!isTauri()) return;
const setupListener = async () => {
const unlisten = await listen("tauri://focus", () => {
// console.log("Window focused!");
if (isChatMode) {
textareaRef.current?.focus();
} else {
inputRef.current?.focus();
}
});
return unlisten;
};
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
setupListener().then((unlistener) => { setupWindowFocusListener(() => {
if (isChatMode) {
textareaRef.current?.focus();
} else {
inputRef.current?.focus();
}
}).then((unlistener) => {
unlisten = unlistener; unlisten = unlistener;
}); });
@@ -212,15 +273,9 @@ export default function ChatInput({
}; };
return ( return (
<div <div className={`w-full relative`}>
className={`w-full relative ${
isChatPage
? "bg-inputbox_bg_light dark:bg-inputbox_bg_dark bg-cover rounded-xl border border-[#E6E6E6] dark:border-[#272626]"
: ""
}`}
>
<div <div
className={`p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded transition-all relative `} className={`p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded transition-all relative overflow-hidden`}
> >
<div className="flex flex-wrap gap-2 flex-1 items-center relative"> <div className="flex flex-wrap gap-2 flex-1 items-center relative">
{!isChatMode && !sourceData ? ( {!isChatMode && !sourceData ? (
@@ -270,34 +325,23 @@ export default function ChatInput({
</div> </div>
) : null} ) : null}
{showTooltip && isCommandPressed ? ( {showTooltip && modifierKeyPressed ? (
<div <div
className={`absolute ${ className={`absolute ${
!isChatMode && sourceData ? "left-7" : "" !isChatMode && sourceData ? "left-7" : ""
} w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#ededed] dark:shadow-[-6px_0px_6px_2px_#202126]`} } w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#ededed] dark:shadow-[-6px_0px_6px_2px_#202126]`}
> >
I {returnToInput}
</div> </div>
) : null} ) : null}
</div> </div>
{/* {isChatMode ? ( {/* <AudioRecording
<button key={isChatMode ? "chat" : "search"}
className={`p-1 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition-colors ${ onChange={(text) => {
isListening ? "bg-blue-100 dark:bg-blue-900" : "" changeInput(inputValue + text);
}`} }}
type="button" /> */}
onClick={() => {}}
>
<Mic
className={`w-4 h-4 ${
isListening
? "text-blue-500 animate-pulse"
: "text-[#999] dark:text-[#999]"
}`}
/>
</button>
) : null} */}
{isChatMode && curChatEnd ? ( {isChatMode && curChatEnd ? (
<button <button
@@ -326,13 +370,13 @@ export default function ChatInput({
</button> </button>
) : null} ) : null}
{showTooltip && isChatMode && isCommandPressed ? ( {/* {showTooltip && isChatMode && isCommandPressed ? (
<div <div
className={`absolute right-10 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]`} className={`absolute right-10 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]`}
> >
M M
</div> </div>
) : null} ) : null} */}
{showTooltip && isChatMode && isCommandPressed ? ( {showTooltip && isChatMode && isCommandPressed ? (
<div <div
@@ -346,10 +390,15 @@ export default function ChatInput({
<div className="absolute top-0 right-0 bottom-0 left-0 px-2 py-4 bg-red-500/10 rounded-md font-normal text-xs text-gray-400 flex items-center gap-4"> <div className="absolute top-0 right-0 bottom-0 left-0 px-2 py-4 bg-red-500/10 rounded-md font-normal text-xs text-gray-400 flex items-center gap-4">
{t("search.input.connectionError")} {t("search.input.connectionError")}
<div <div
className="w-[96px] h-[24px] bg-[#0061FF] rounded-[12px] font-normal text-xs text-white flex items-center justify-center cursor-pointer" className="h-[24px] px-2 bg-[#0061FF] rounded-[12px] font-normal text-xs text-white flex items-center justify-center cursor-pointer"
onClick={reconnect} onClick={() => {
reconnect();
setReconnectCountdown(10);
}}
> >
{t("search.input.reconnect")} {reconnectCountdown > 0
? `${t("search.input.connecting")}(${reconnectCountdown}s)`
: t("search.input.reconnect")}
</div> </div>
</div> </div>
) : null} ) : null}
@@ -361,7 +410,19 @@ export default function ChatInput({
> >
{isChatMode ? ( {isChatMode ? (
<div className="flex gap-2 text-sm text-[#333] dark:text-[#d8d8d8]"> <div className="flex gap-2 text-sm text-[#333] dark:text-[#d8d8d8]">
{/* <InputExtra /> */} {/* {sessionId && (
<InputExtra
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
getScreenWindows={getScreenWindows}
captureMonitorScreenshot={captureMonitorScreenshot}
captureWindowScreenshot={captureWindowScreenshot}
openFileDialog={openFileDialog}
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
/>
)} */}
<button <button
className={clsx( className={clsx(
@@ -393,19 +454,23 @@ export default function ChatInput({
<SearchPopover <SearchPopover
isSearchActive={isSearchActive} isSearchActive={isSearchActive}
setIsSearchActive={setIsSearchActive} setIsSearchActive={setIsSearchActive}
getDataSourcesByServer={getDataSourcesByServer}
/> />
</div> </div>
) : ( ) : (
<div className="w-28 flex gap-2 relative"></div> <div
data-tauri-drag-region
className="w-28 flex gap-2 relative"
></div>
)} )}
{isChatPage ? null : ( {isChatPage ? null : (
<div className="relative w-16 flex justify-end items-center"> <div className="relative w-16 flex justify-end items-center">
{showTooltip && isCommandPressed ? ( {showTooltip && modifierKeyPressed ? (
<div <div
className={`absolute left-1 z-10 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]`} className={`absolute left-1 z-10 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]`}
> >
T {modeSwitch}
</div> </div>
) : null} ) : null}
<ChatSwitch <ChatSwitch

View File

@@ -1,3 +1,5 @@
import { Fragment, MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, Plus } from "lucide-react"; import { ChevronRight, Plus } from "lucide-react";
import { import {
Menu, Menu,
@@ -8,33 +10,20 @@ import {
PopoverButton, PopoverButton,
PopoverPanel, PopoverPanel,
} from "@headlessui/react"; } from "@headlessui/react";
import { open } from "@tauri-apps/plugin-dialog";
import { castArray, find, isNil } from "lodash-es"; import { castArray, find, isNil } from "lodash-es";
import { useChatStore } from "@/stores/chatStore";
import { metadata, icon } from "tauri-plugin-fs-pro-api";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import Tooltip from "../Common/Tooltip"; import { useCreation, useKeyPress, useMount, useReactive } from "ahooks";
import { useChatStore } from "@/stores/chatStore";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { useCreation, useMount, useReactive } from "ahooks"; import Tooltip from "@/components/Common/Tooltip";
import { import { useShortcutsStore } from "@/stores/shortcutsStore";
checkScreenRecordingPermission, import clsx from "clsx";
requestScreenRecordingPermission,
} from "tauri-plugin-macos-permissions-api";
import {
getScreenshotableMonitors,
getScreenshotableWindows,
ScreenshotableMonitor,
ScreenshotableWindow,
getMonitorScreenshot,
getWindowScreenshot,
} from "tauri-plugin-screenshots-api";
import { Fragment, MouseEvent } from "react";
import { useTranslation } from "react-i18next";
interface State { interface State {
screenRecordingPermission?: boolean; screenRecordingPermission?: boolean;
screenshotableMonitors: ScreenshotableMonitor[]; screenshotableMonitors: any[];
screenshotableWindows: ScreenshotableWindow[]; screenshotableWindows: any[];
} }
interface MenuItem { interface MenuItem {
@@ -46,11 +35,44 @@ interface MenuItem {
clickEvent?: (event: MouseEvent) => void; clickEvent?: (event: MouseEvent) => void;
} }
const InputExtra = () => { interface InputExtraProps {
checkScreenPermission: () => Promise<boolean>;
requestScreenPermission: () => void;
getScreenMonitors: () => Promise<any[]>;
getScreenWindows: () => Promise<any[]>;
captureMonitorScreenshot: (id: number) => Promise<string>;
captureWindowScreenshot: (id: number) => Promise<string>;
openFileDialog: (options: {
multiple: boolean;
}) => Promise<string | string[] | null>;
getFileMetadata: (path: string) => Promise<any>;
getFileIcon: (path: string, size: number) => Promise<string>;
}
const InputExtra = ({
checkScreenPermission,
requestScreenPermission,
getScreenMonitors,
getScreenWindows,
captureMonitorScreenshot,
captureWindowScreenshot,
openFileDialog,
getFileMetadata,
getFileIcon,
}: InputExtraProps) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const uploadFiles = useChatStore((state) => state.uploadFiles); const uploadFiles = useChatStore((state) => state.uploadFiles);
const setUploadFiles = useChatStore((state) => state.setUploadFiles); const setUploadFiles = useChatStore((state) => state.setUploadFiles);
const setIsPinned = useAppStore((state) => state.setIsPinned); const withVisibility = useAppStore((state) => state.withVisibility);
const modifierKey = useShortcutsStore((state) => {
return state.modifierKey;
});
const addFile = useShortcutsStore((state) => {
return state.addFile;
});
const modifierKeyPressed = useShortcutsStore((state) => {
return state.modifierKeyPressed;
});
const state = useReactive<State>({ const state = useReactive<State>({
screenshotableMonitors: [], screenshotableMonitors: [],
@@ -58,16 +80,28 @@ const InputExtra = () => {
}); });
useMount(async () => { useMount(async () => {
state.screenRecordingPermission = await checkScreenRecordingPermission(); state.screenRecordingPermission = await checkScreenPermission();
}); });
const handleSelectFile = async () => {
const selectedFiles = await withVisibility(() => {
return openFileDialog({
multiple: true,
});
});
if (isNil(selectedFiles)) return;
handleUploadFiles(selectedFiles);
};
const handleUploadFiles = async (paths: string | string[]) => { const handleUploadFiles = async (paths: string | string[]) => {
const files: typeof uploadFiles = []; const files: typeof uploadFiles = [];
for await (const path of castArray(paths)) { for await (const path of castArray(paths)) {
if (find(uploadFiles, { path })) continue; if (find(uploadFiles, { path })) continue;
const stat = await metadata(path); const stat = await getFileMetadata(path);
if (stat.size / 1024 / 1024 > 100) { if (stat.size / 1024 / 1024 > 100) {
continue; continue;
@@ -77,7 +111,7 @@ const InputExtra = () => {
...stat, ...stat,
id: nanoid(), id: nanoid(),
path, path,
icon: await icon(path, 256), icon: await getFileIcon(path, 256),
}); });
} }
@@ -88,30 +122,18 @@ const InputExtra = () => {
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ {
label: t("search.input.uploadFile"), label: t("search.input.uploadFile"),
clickEvent: async () => { clickEvent: handleSelectFile,
setIsPinned(true);
const selectedFiles = await open({
multiple: true,
});
setIsPinned(false);
if (isNil(selectedFiles)) return;
handleUploadFiles(selectedFiles);
},
}, },
{ {
label: t("search.input.screenshot"), label: t("search.input.screenshot"),
clickEvent: async (event) => { clickEvent: async (event) => {
if (state.screenRecordingPermission) { if (state.screenRecordingPermission) {
state.screenshotableMonitors = await getScreenshotableMonitors(); state.screenshotableMonitors = await getScreenMonitors();
state.screenshotableWindows = await getScreenshotableWindows(); state.screenshotableWindows = await getScreenWindows();
} else { } else {
event.preventDefault(); event.preventDefault();
requestScreenRecordingPermission(); requestScreenPermission();
} }
}, },
children: [ children: [
@@ -124,7 +146,7 @@ const InputExtra = () => {
id, id,
label: name, label: name,
clickEvent: async () => { clickEvent: async () => {
const path = await getMonitorScreenshot(id); const path = await captureMonitorScreenshot(id);
handleUploadFiles(path); handleUploadFiles(path);
}, },
@@ -140,7 +162,7 @@ const InputExtra = () => {
id, id,
label: name, label: name,
clickEvent: async () => { clickEvent: async () => {
const path = await getWindowScreenshot(id); const path = await captureWindowScreenshot(id);
handleUploadFiles(path); handleUploadFiles(path);
}, },
@@ -158,12 +180,29 @@ const InputExtra = () => {
i18n.language, i18n.language,
]); ]);
useKeyPress(`${modifierKey}.${addFile}`, handleSelectFile);
return ( return (
<Menu> <Menu>
<MenuButton> <MenuButton className="size-6">
<Tooltip content="支持截图、上传文件,最多 50个单个文件最大 100 MB。"> <Tooltip content="支持截图、上传文件,最多 50个单个文件最大 100 MB。">
<div className="size-6 flex justify-center items-center rounded-lg transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]"> <div className="size-full flex justify-center items-center rounded-lg transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<Plus className="size-5" /> <Plus
className={clsx("size-5", {
hidden: modifierKeyPressed,
})}
/>
<div
className={clsx(
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]",
{
hidden: !modifierKeyPressed,
}
)}
>
{addFile}
</div>
</div> </div>
</Tooltip> </Tooltip>
</MenuButton> </MenuButton>

View File

@@ -0,0 +1,38 @@
import { Command } from "lucide-react";
import { useTranslation } from "react-i18next";
import { isMac } from "@/utils/platform";
import noDataImg from "@/assets/coconut-tree.png";
export const NoResults = () => {
const { t } = useTranslation();
return (
<div
data-tauri-drag-region
className="h-full w-full flex flex-col items-center"
>
<img src={noDataImg} alt="no-data" className="w-16 h-16 mt-24" />
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
{t("search.main.noResults")}
</div>
<div className="mt-10 text-sm text-[#333] dark:text-[#D8D8D8] flex">
{t("search.main.askCoco")}
{isMac ? (
<span className="ml-3 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
<Command className="w-3 h-3" />
</span>
) : (
<span className="ml-3 w-8 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
<span className="h-3 leading-3 inline-flex items-center text-xs">
Ctrl
</span>
</span>
)}
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
T
</span>
</div>
</div>
);
};

View File

@@ -1,43 +1,50 @@
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { Command } from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "react-i18next";
import { debounce } from "lodash-es"; import { debounce } from "lodash-es";
import DropdownList from "./DropdownList"; import DropdownList from "./DropdownList";
import Footer from "./Footer"; import Footer from "./Footer";
import noDataImg from "@/assets/coconut-tree.png";
import { SearchResults } from "@/components/Search/SearchResults"; import { SearchResults } from "@/components/Search/SearchResults";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { isMac } from "@/utils/platform";
import ContextMenu from "./ContextMenu"; import ContextMenu from "./ContextMenu";
import { NoResults } from "./NoResults";
interface SearchProps { interface SearchProps {
changeInput: (val: string) => void; changeInput: (val: string) => void;
isChatMode: boolean; isChatMode: boolean;
input: string; input: string;
querySearch: (input: string) => Promise<any>;
queryDocuments: (
from: number,
size: number,
queryStrings: any
) => Promise<any>;
hideCoco: () => Promise<any>;
openSetting: () => void;
setWindowAlwaysOnTop: (isPinned: boolean) => Promise<void>;
} }
function Search({ isChatMode, input }: SearchProps) { function Search({
const { t } = useTranslation(); isChatMode,
input,
querySearch,
queryDocuments,
hideCoco,
openSetting,
setWindowAlwaysOnTop,
}: SearchProps) {
const sourceData = useSearchStore((state) => state.sourceData); const sourceData = useSearchStore((state) => state.sourceData);
const [IsError, setIsError] = useState<boolean>(false); const [IsError, setIsError] = useState<boolean>(false);
const [suggests, setSuggests] = useState<any[]>([]); const [suggests, setSuggests] = useState<any[]>([]);
const [SearchData, setSearchData] = useState<any>({}); const [SearchData, setSearchData] = useState<any>({});
const [isSearchComplete, setIsSearchComplete] = useState(false); const [isSearchComplete, setIsSearchComplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<any>();
const mainWindowRef = useRef<HTMLDivElement>(null); const mainWindowRef = useRef<HTMLDivElement>(null);
const getSuggest = async () => { const getSuggest = async () => {
if (!input) return; if (!input) return;
try { try {
const response: any = await invoke("query_coco_fusion", { const response = await querySearch(input);
from: 0,
size: 10,
queryStrings: { query: input },
});
console.log("_suggest", input, response); console.log("_suggest", input, response);
let data = response?.hits || []; let data = response?.hits || [];
@@ -79,7 +86,11 @@ function Search({ isChatMode, input }: SearchProps) {
{/* Search Results Panel */} {/* Search Results Panel */}
{suggests.length > 0 ? ( {suggests.length > 0 ? (
sourceData ? ( sourceData ? (
<SearchResults input={input} isChatMode={isChatMode} /> <SearchResults
input={input}
isChatMode={isChatMode}
queryDocuments={queryDocuments}
/>
) : ( ) : (
<DropdownList <DropdownList
suggests={suggests} suggests={suggests}
@@ -87,41 +98,18 @@ function Search({ isChatMode, input }: SearchProps) {
IsError={IsError} IsError={IsError}
isSearchComplete={isSearchComplete} isSearchComplete={isSearchComplete}
isChatMode={isChatMode} isChatMode={isChatMode}
selected={(item) => setSelectedItem(item)}
/> />
) )
) : ( ) : (
<div <NoResults />
data-tauri-drag-region
className="h-full w-full flex flex-col items-center"
>
<img src={noDataImg} alt="no-data" className="w-16 h-16 mt-24" />
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
{t("search.main.noResults")}
</div>
<div className="mt-10 text-sm text-[#333] dark:text-[#D8D8D8] flex">
{t("search.main.askCoco")}
{isMac ? (
<span className="ml-3 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
<Command className="w-3 h-3" />
</span>
) : (
<span className="ml-3 w-8 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
<span className="h-3 leading-3 inline-flex items-center text-xs">
Ctrl
</span>
</span>
)}
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
T
</span>
</div>
</div>
)} )}
<Footer isChat={false} name={selectedItem?.source?.name} /> <Footer
openSetting={openSetting}
setWindowAlwaysOnTop={setWindowAlwaysOnTop}
/>
<ContextMenu /> <ContextMenu hideCoco={hideCoco} />
</div> </div>
); );
} }

View File

@@ -1,39 +1,25 @@
import { useState, useEffect, useCallback, useMemo } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
Checkbox, import { ChevronDownIcon, RefreshCw, Layers, Globe } from "lucide-react";
Popover,
PopoverButton,
PopoverPanel,
} from "@headlessui/react";
import {
ChevronDownIcon,
RefreshCw,
Layers,
CheckIcon,
Globe,
} from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { invoke } from "@tauri-apps/api/core";
import TypeIcon from "@/components/Common/Icons/TypeIcon"; import TypeIcon from "@/components/Common/Icons/TypeIcon";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { DataSource } from "@/types/commands";
interface DataSource { import Checkbox from "../Common/Checkbox";
id: string;
name: string;
[key: string]: any;
}
interface SearchPopoverProps { interface SearchPopoverProps {
isSearchActive: boolean; isSearchActive: boolean;
setIsSearchActive: () => void; setIsSearchActive: () => void;
getDataSourcesByServer: (serverId: string) => Promise<DataSource[]>;
} }
export default function SearchPopover({ export default function SearchPopover({
isSearchActive, isSearchActive,
setIsSearchActive, setIsSearchActive,
getDataSourcesByServer,
}: SearchPopoverProps) { }: SearchPopoverProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isRefreshDataSource, setIsRefreshDataSource] = useState(false); const [isRefreshDataSource, setIsRefreshDataSource] = useState(false);
@@ -46,9 +32,9 @@ export default function SearchPopover({
const getDataSourceList = useCallback(async () => { const getDataSourceList = useCallback(async () => {
try { try {
const res: DataSource[] = await invoke("get_datasources_by_server", { const res: DataSource[] = await getDataSourcesByServer(
id: currentService?.id, currentService?.id
}); );
const data = [ const data = [
{ {
id: "all", id: "all",
@@ -126,7 +112,7 @@ export default function SearchPopover({
{dataSourceList?.length > 0 && ( {dataSourceList?.length > 0 && (
<Popover> <Popover>
<PopoverButton className={clsx("flex items-center")}> <PopoverButton as="span" className={clsx("flex items-center")}>
<ChevronDownIcon <ChevronDownIcon
className={clsx("size-5", [ className={clsx("size-5", [
isSearchActive isSearchActive
@@ -186,7 +172,7 @@ export default function SearchPopover({
<TypeIcon item={item} className="size-[16px]" /> <TypeIcon item={item} className="size-[16px]" />
)} )}
<span>{isAll ? t(name) : name}</span> <span>{isAll && name ? t(name) : name}</span>
</div> </div>
<div className="flex justify-center items-center size-[24px]"> <div className="flex justify-center items-center size-[24px]">
@@ -197,19 +183,11 @@ export default function SearchPopover({
dataSourceList.length - 1 dataSourceList.length - 1
: sourceDataIds?.includes(id) : sourceDataIds?.includes(id)
} }
indeterminate={isAll}
onChange={(value) => onChange={(value) =>
onSelectDataSource(id, value, isAll) onSelectDataSource(id, value, isAll)
} }
className="group size-[14px] rounded-sm border border-black/30 dark:border-white/30 data-[checked]:bg-[#2F54EB] data-[checked]:!border-[#2F54EB] transition cursor-pointer" />
>
{isAll && (
<div className="size-full flex items-center justify-center group-data-[checked]:hidden">
<div className="size-[6px] bg-[#2F54EB]"></div>
</div>
)}
<CheckIcon className="hidden size-[12px] text-white group-data-[checked]:block" />
</Checkbox>
</div> </div>
</li> </li>
); );

View File

@@ -6,9 +6,10 @@ import { DocumentDetail } from "./DocumentDetail";
interface SearchResultsProps { interface SearchResultsProps {
input: string; input: string;
isChatMode: boolean; isChatMode: boolean;
queryDocuments: (from: number, size: number, queryStrings: any) => Promise<any>;
} }
export function SearchResults({ input, isChatMode }: SearchResultsProps) { export function SearchResults({ input, isChatMode, queryDocuments }: SearchResultsProps) {
const [selectedDocumentId, setSelectedDocumentId] = useState("1"); const [selectedDocumentId, setSelectedDocumentId] = useState("1");
const [detailData, setDetailData] = useState<any>({}); const [detailData, setDetailData] = useState<any>({});
@@ -30,6 +31,7 @@ export function SearchResults({ input, isChatMode }: SearchResultsProps) {
isChatMode={isChatMode} isChatMode={isChatMode}
viewMode={viewMode} viewMode={viewMode}
setViewMode={setViewMode} setViewMode={setViewMode}
queryDocuments={queryDocuments}
/> />
{/* Right Panel */} {/* Right Panel */}

View File

@@ -0,0 +1,150 @@
import { ModifierKey, useShortcutsStore } from "@/stores/shortcutsStore";
import { useTranslation } from "react-i18next";
import { formatKey } from "@/utils/keyboardUtils";
import SettingsItem from "@/components/Settings/SettingsItem";
import { Command } from "lucide-react";
import { ChangeEvent, useEffect } from "react";
import { emit } from "@tauri-apps/api/event";
import { isMac } from "@/utils/platform";
export const modifierKeys: ModifierKey[] = isMac
? ["meta", "ctrl"]
: ["ctrl", "alt"];
const Shortcuts = () => {
const { t } = useTranslation();
const modifierKey = useShortcutsStore((state) => state.modifierKey);
const setModifierKey = useShortcutsStore((state) => state.setModifierKey);
const modeSwitch = useShortcutsStore((state) => state.modeSwitch);
const setModeSwitch = useShortcutsStore((state) => state.setModeSwitch);
const returnToInput = useShortcutsStore((state) => state.returnToInput);
const setReturnToInput = useShortcutsStore((state) => state.setReturnToInput);
const voiceInput = useShortcutsStore((state) => state.voiceInput);
const setVoiceInput = useShortcutsStore((state) => state.setVoiceInput);
// const addImage = useShortcutsStore((state) => state.addImage);
// const setAddImage = useShortcutsStore((state) => state.setAddImage);
// const selectLlmModel = useShortcutsStore((state) => state.selectLlmModel);
// const setSelectLlmModel = useShortcutsStore((state) => {
// return state.setSelectLlmModel;
// });
const addFile = useShortcutsStore((state) => state.addFile);
const setAddFile = useShortcutsStore((state) => state.setAddFile);
useEffect(() => {
const unlisten = useShortcutsStore.subscribe((state) => {
emit("change-shortcuts-store", state);
});
return unlisten;
}, []);
const list = [
{
title: "settings.advanced.shortcuts.modeSwitch.title",
description: "settings.advanced.shortcuts.modeSwitch.description",
value: modeSwitch,
setValue: setModeSwitch,
},
{
title: "settings.advanced.shortcuts.returnToInput.title",
description: "settings.advanced.shortcuts.returnToInput.description",
value: returnToInput,
setValue: setReturnToInput,
},
{
title: "settings.advanced.shortcuts.voiceInput.title",
description: "settings.advanced.shortcuts.voiceInput.description",
value: voiceInput,
setValue: setVoiceInput,
},
// {
// title: "settings.advanced.shortcuts.addImage.title",
// description: "settings.advanced.shortcuts.addImage.description",
// value: addImage,
// setValue: setAddImage,
// },
// {
// title: "settings.advanced.shortcuts.selectLlmModel.title",
// description: "settings.advanced.shortcuts.selectLlmModel.description",
// value: selectLlmModel,
// setValue: setSelectLlmModel,
// },
{
title: "settings.advanced.shortcuts.addFile.title",
description: "settings.advanced.shortcuts.addFile.description",
value: addFile,
setValue: setAddFile,
},
];
const handleChange = (
event: ChangeEvent<HTMLInputElement>,
setValue: (value: string) => void
) => {
const value = event.target.value.toUpperCase();
if (value.length > 1) return;
const state = useShortcutsStore.getState();
if (Object.values(state).includes(value)) return;
setValue(value);
};
return (
<div className="space-y-8">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t("settings.advanced.shortcuts.title")}
</h2>
<div className="space-y-6">
<SettingsItem
icon={Command}
title={t("settings.advanced.shortcuts.modifierKey.title")}
description={t("settings.advanced.shortcuts.modifierKey.description")}
>
<select
value={modifierKey}
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(event) => {
setModifierKey(event.target.value as ModifierKey);
}}
>
{modifierKeys.map((item) => {
return <option value={item}>{formatKey(item)}</option>;
})}
</select>
</SettingsItem>
{list.map((item) => {
const { title, description, value, setValue } = item;
return (
<SettingsItem
key={title}
icon={Command}
title={t(title)}
description={t(description)}
>
<div className="flex items-center gap-2">
<span>{formatKey(modifierKey)}</span>
<span>+</span>
<input
className="w-20 h-8 px-2 rounded-md border bg-transparent border-black/5 dark:border-white/10"
value={value}
maxLength={1}
onChange={(event) => {
handleChange(event, setValue);
}}
/>
</div>
</SettingsItem>
);
})}
</div>
</div>
);
};
export default Shortcuts;

View File

@@ -0,0 +1,170 @@
import { useTranslation } from "react-i18next";
import Shortcuts from "./components/Shortcuts";
import SettingsItem from "../SettingsItem";
import { AppWindowMac, MessageSquareMore, Search, Unplug } from "lucide-react";
import { useStartupStore } from "@/stores/startupStore";
import { useEffect } from "react";
import { emit } from "@tauri-apps/api/event";
import { useConnectStore } from "@/stores/connectStore";
const Advanced = () => {
const { t } = useTranslation();
const defaultStartupWindow = useStartupStore((state) => {
return state.defaultStartupWindow;
});
const setDefaultStartupWindow = useStartupStore((state) => {
return state.setDefaultStartupWindow;
});
const defaultContentForSearchWindow = useStartupStore((state) => {
return state.defaultContentForSearchWindow;
});
const setDefaultContentForSearchWindow = useStartupStore((state) => {
return state.setDefaultContentForSearchWindow;
});
const defaultContentForChatWindow = useStartupStore((state) => {
return state.defaultContentForChatWindow;
});
const setDefaultContentForChatWindow = useStartupStore((state) => {
return state.setDefaultContentForChatWindow;
});
const connectionTimeout = useConnectStore((state) => {
return state.connectionTimeout;
});
const setConnectionTimeout = useConnectStore((state) => {
return state.setConnectionTimeout;
});
useEffect(() => {
const unlisten = useStartupStore.subscribe((state) => {
emit("change-startup-store", state);
});
return unlisten;
}, []);
const startupList = [
{
icon: AppWindowMac,
title: "settings.advanced.startup.defaultStartupWindow.title",
description: "settings.advanced.startup.defaultStartupWindow.description",
value: defaultStartupWindow,
items: [
{
label:
"settings.advanced.startup.defaultStartupWindow.select.searchMode",
value: "searchMode",
},
{
label:
"settings.advanced.startup.defaultStartupWindow.select.chatMode",
value: "chatMode",
},
],
onChange: setDefaultStartupWindow,
},
{
icon: Search,
title: "settings.advanced.startup.defaultContentForSearchWindow.title",
description:
"settings.advanced.startup.defaultContentForSearchWindow.description",
value: defaultContentForSearchWindow,
items: [
{
label:
"settings.advanced.startup.defaultContentForSearchWindow.select.systemDefault",
value: "systemDefault",
},
],
onChange: setDefaultContentForSearchWindow,
},
{
icon: MessageSquareMore,
title: "settings.advanced.startup.defaultContentForChatWindow.title",
description:
"settings.advanced.startup.defaultContentForChatWindow.description",
value: defaultContentForChatWindow,
items: [
{
label:
"settings.advanced.startup.defaultContentForChatWindow.select.newChat",
value: "newChat",
},
{
label:
"settings.advanced.startup.defaultContentForChatWindow.select.oldChat",
value: "oldChat",
},
],
onChange: setDefaultContentForChatWindow,
},
];
return (
<div className="space-y-8">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t("settings.advanced.startup.title")}
</h2>
<div className="space-y-6">
{startupList.map((item) => {
const { icon, title, description, value, items, onChange } = item;
return (
<SettingsItem
key={title}
icon={icon}
title={t(title)}
description={t(description)}
>
<select
value={value}
onChange={(event) => {
onChange(event.target.value as never);
}}
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{items.map((item) => {
const { label, value } = item;
return (
<option key={value} value={value}>
{t(label)}
</option>
);
})}
</select>
</SettingsItem>
);
})}
</div>
<Shortcuts />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t("settings.advanced.connect.title")}
</h2>
<div className="space-y-6">
<SettingsItem
icon={Unplug}
title={t("settings.advanced.connect.connectionTimeout.title")}
description={t(
"settings.advanced.connect.connectionTimeout.description"
)}
>
<input
type="number"
min={10}
value={connectionTimeout}
className="w-20 h-8 px-2 rounded-md border bg-transparent border-black/5 dark:border-white/10"
onChange={(event) => {
setConnectionTimeout(Number(event.target.value) || 120);
}}
/>
</SettingsItem>
</div>
</div>
);
};
export default Advanced;

View File

@@ -11,10 +11,9 @@ import {
Globe, Globe,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isTauri, invoke } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { import {
isEnabled, isEnabled,
// enable, disable
} from "@tauri-apps/plugin-autostart"; } from "@tauri-apps/plugin-autostart";
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
import { useCreation } from "ahooks"; import { useCreation } from "ahooks";
@@ -27,6 +26,7 @@ import { useShortcutEditor } from "@/hooks/useShortcutEditor";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { AppTheme } from "@/utils/tauri"; import { AppTheme } from "@/utils/tauri";
import { useThemeStore } from "@/stores/themeStore"; import { useThemeStore } from "@/stores/themeStore";
import { change_autostart, get_current_shortcut, change_shortcut, unregister_shortcut } from "@/commands"
export function ThemeOption({ export function ThemeOption({
icon: Icon, icon: Icon,
@@ -90,8 +90,7 @@ export default function GeneralSettings() {
const enableAutoStart = async () => { const enableAutoStart = async () => {
if (isTauri()) { if (isTauri()) {
try { try {
// await enable(); await change_autostart(true);
invoke("change_autostart", { open: true });
} catch (error) { } catch (error) {
console.error("Failed to enable autostart:", error); console.error("Failed to enable autostart:", error);
} }
@@ -102,8 +101,7 @@ export default function GeneralSettings() {
const disableAutoStart = async () => { const disableAutoStart = async () => {
if (isTauri()) { if (isTauri()) {
try { try {
// await disable(); await change_autostart(false);
invoke("change_autostart", { open: false });
} catch (error) { } catch (error) {
console.error("Failed to disable autostart:", error); console.error("Failed to disable autostart:", error);
} }
@@ -123,7 +121,7 @@ export default function GeneralSettings() {
async function getCurrentShortcut() { async function getCurrentShortcut() {
try { try {
const res: any = await invoke("get_current_shortcut"); const res: any = await get_current_shortcut();
// console.log("get_current_shortcut: ", res); // console.log("get_current_shortcut: ", res);
setShortcut(res?.split("+")); setShortcut(res?.split("+"));
} catch (err) { } catch (err) {
@@ -143,7 +141,7 @@ export default function GeneralSettings() {
setShortcut(key); setShortcut(key);
// //
if (key.length === 0) return; if (key.length === 0) return;
invoke("change_shortcut", { key: key?.join("+") }).catch((err) => { change_shortcut(key?.join("+")).catch((err) => {
console.error("Failed to save hotkey:", err); console.error("Failed to save hotkey:", err);
}); });
}; };
@@ -154,7 +152,7 @@ export default function GeneralSettings() {
const onEditShortcut = async () => { const onEditShortcut = async () => {
startEditing(); startEditing();
// //
invoke("unregister_shortcut").catch((err) => { unregister_shortcut().catch((err) => {
console.error("Failed to save hotkey:", err); console.error("Failed to save hotkey:", err);
}); });
}; };
@@ -162,7 +160,7 @@ export default function GeneralSettings() {
const onCancelShortcut = async () => { const onCancelShortcut = async () => {
cancelEditing(); cancelEditing();
// //
invoke("change_shortcut", { key: shortcut?.join("+") }).catch((err) => { change_shortcut(shortcut?.join("+")).catch((err) => {
console.error("Failed to save hotkey:", err); console.error("Failed to save hotkey:", err);
}); });
}; };

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,185 @@
import { useCallback, useMemo } from "react";
import { Button, Dialog, DialogPanel } from "@headlessui/react";
import { useTranslation } from "react-i18next";
import { noop } from "lodash-es";
import { LoaderCircle, X } from "lucide-react";
import { useInterval, useReactive } from "ahooks";
import clsx from "clsx";
import lightIcon from "./imgs/light-icon.png";
import darkIcon from "./imgs/dark-icon.png";
import { useThemeStore } from "@/stores/themeStore";
import { useUpdateStore } from "@/stores/updateStore";
import { OpenURLWithBrowser } from "@/utils/index";
interface State {
loading?: boolean;
total?: number;
download: number;
}
interface UpdateAppProps {
checkUpdate: () => Promise<any>;
relaunchApp: () => Promise<void>;
}
const UpdateApp = ({ checkUpdate, relaunchApp }: UpdateAppProps) => {
const { t } = useTranslation();
const isDark = useThemeStore((state) => state.isDark);
const visible = useUpdateStore((state) => state.visible);
const setVisible = useUpdateStore((state) => state.setVisible);
const skipVersion = useUpdateStore((state) => state.skipVersion);
const setSkipVersion = useUpdateStore((state) => state.setSkipVersion);
const isOptional = useUpdateStore((state) => state.isOptional);
const updateInfo = useUpdateStore((state) => state.updateInfo);
const setUpdateInfo = useUpdateStore((state) => state.setUpdateInfo);
const state = useReactive<State>({ download: 0 });
useInterval(() => checkUpdateStatus(), 1000 * 60 * 60 * 24, {
immediate: true,
});
const checkUpdateStatus = useCallback(async () => {
const update = await checkUpdate();
if (update?.available) {
setUpdateInfo(update);
if (skipVersion === update.version) return;
setVisible(true);
}
}, [skipVersion]);
const cursorClassName = useMemo(() => {
return state.loading ? "cursor-not-allowed" : "cursor-pointer";
}, [state.loading]);
const percent = useMemo(() => {
const { total, download } = state;
if (!total) return 0;
return ((download / total) * 100).toFixed(2);
}, [state.total, state.download]);
const handleDownload = async () => {
if (state.loading) return;
state.loading = true;
await updateInfo?.downloadAndInstall((progress) => {
switch (progress.event) {
case "Started":
state.total = progress.data.contentLength;
break;
case "Progress":
state.download += progress.data.chunkLength;
break;
}
});
state.loading = false;
relaunchApp();
};
const handleCancel = () => {
if (state.loading) return;
setVisible(false);
};
const handleSkip = () => {
if (state.loading) return;
setSkipVersion(updateInfo?.version);
setVisible(false);
};
return (
<Dialog
open={visible}
as="div"
className="relative z-10 focus:outline-none"
onClose={noop}
>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<DialogPanel
transition
className="relative w-[340px] py-8 flex flex-col items-center bg-white shadow-md border border-[#EDEDED] rounded-lg dark:bg-[#333] dark:border-black/20"
>
<X
className={clsx(
"absolute size-5 text-[#999] top-3 right-3 dark:text-[#D8D8D8]",
cursorClassName,
{
hidden: !isOptional,
}
)}
onClick={handleCancel}
/>
<img src={isDark ? darkIcon : lightIcon} className="h-6" />
<div className="text-[#333] text-sm leading-5 py-2 dark:text-[#D8D8D8]">
{isOptional ? (
t("update.optional_description")
) : (
<div className="leading-5 text-center">
<p>{t("update.force_description1")}</p>
<p>{t("update.force_description2")}</p>
</div>
)}
</div>
<div
className="text-xs text-[#0072FF] cursor-pointer"
onClick={() => {
OpenURLWithBrowser(
"https://docs.infinilabs.com/coco-app/main/docs/release-notes"
);
}}
>
v{updateInfo?.version} {t("update.releaseNotes")}
</div>
<Button
className={clsx(
"mb-3 mt-6 bg-[#0072FF] text-white text-sm px-[14px] py-[8px] rounded-lg",
cursorClassName,
{
"opacity-50": state.loading,
}
)}
onClick={handleDownload}
>
{state.loading ? (
<div className="flex justify-center items-center gap-2">
<LoaderCircle className="animate-spin size-5" />
{percent}%
</div>
) : (
t("update.button.download")
)}
</Button>
<div
className={clsx("text-xs text-[#999]", cursorClassName, {
hidden: !isOptional,
})}
onClick={handleSkip}
>
{t("update.skip_version")}
</div>
</DialogPanel>
</div>
</div>
</Dialog>
);
};
export default UpdateApp;

View File

@@ -1,27 +0,0 @@
import { useRouteError } from "react-router-dom";
import errorImg from "./assets/error_page.png";
export default function ErrorPage() {
const error: any = useRouteError();
console.error(error);
return (
<div className="w-full h-screen bg-white shadow-[0px_16px_32px_0px_rgba(0,0,0,0.4)] rounded-xl border-[2px] border-[#E6E6E6] m-auto">
<div className="flex flex-col justify-center items-center">
<img
src={errorImg}
alt="error-page"
className="w-[221px] h-[154px] mb-8 mt-[72px]"
/>
<div className="w-[380px] h-[46px] px-5 font-normal text-base text-[rgba(0,0,0,0.85)] leading-[25px] text-center mb-4">
Sorry, there is an error in your Coco App. Please contact the
administrator.
</div>
<div className="w-[380px] h-[45px] font-normal text-[10px] text-[rgba(135,135,135,0.85)] leading-[16px] text-center">
<i>{error.statusText || error.message}</i>
</div>
</div>
</div>
);
}

View File

@@ -1,33 +1,245 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { invoke } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { IServer } from "@/stores/appStore";
import type { Chat } from "@/components/Assistant/types"; import type { Chat } from "@/components/Assistant/types";
import { close_session_chat, cancel_session_chat, session_chat_history, new_chat, send_message, open_session_chat, chat_history } from "@/commands"
export default function useChatActions(currentService: IServer, activeChat?: Chat) { export function useChatActions(
const chatClose = useCallback(async () => { currentServiceId: string | undefined,
if (!activeChat?._id) return; setActiveChat: (chat: Chat | undefined) => void,
setCurChatEnd: (value: boolean) => void,
setErrorShow: (value: boolean) => void,
setTimedoutShow: (value: boolean) => void,
clearAllChunkData: () => void,
setQuestion: (value: string) => void,
curIdRef: React.MutableRefObject<string>,
isSearchActive?: boolean,
isDeepThinkActive?: boolean,
sourceDataIds?: string[],
changeInput?: (val: string) => void,
websocketSessionId?: string,
) {
const chatClose = useCallback(async (activeChat?: Chat) => {
if (!activeChat?._id || !currentServiceId) return;
try { try {
await invoke("close_session_chat", { let response: any = await close_session_chat({
serverId: currentService?.id, serverId: currentServiceId,
sessionId: activeChat?._id, sessionId: activeChat?._id,
}); });
response = JSON.parse(response || "");
console.log("_close", response);
} catch (error) { } catch (error) {
console.error("Failed to close chat:", error); console.error("chatClose:", error);
} }
}, [currentService?.id, activeChat?._id]); }, [currentServiceId]);
const cancelChat = useCallback(async () => { const cancelChat = useCallback(async (activeChat?: Chat) => {
if (!activeChat?._id) return; setCurChatEnd(true);
if (!activeChat?._id || !currentServiceId) return;
try { try {
await invoke("cancel_session_chat", { let response: any = await cancel_session_chat({
serverId: currentService?.id, serverId: currentServiceId,
sessionId: activeChat?._id, sessionId: activeChat?._id,
}); });
response = JSON.parse(response || "");
console.log("_cancel", response);
} catch (error) { } catch (error) {
console.error("Failed to cancel chat:", error); console.error("cancelChat:", error);
} }
}, [currentService?.id, activeChat?._id]); }, [currentServiceId, setCurChatEnd]);
return { chatClose, cancelChat }; const chatHistory = useCallback(async (
chat: Chat,
callback?: (chat: Chat) => void
) => {
if (!chat?._id || !currentServiceId) return;
try {
let response: any = await session_chat_history({
serverId: currentServiceId,
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);
}
}, [currentServiceId, setActiveChat]);
const createNewChat = useCallback(
async (value: string = "", activeChat?: Chat, id?: string) => {
setTimedoutShow(false);
setErrorShow(false);
chatClose(activeChat);
clearAllChunkData();
setQuestion(value);
if (!currentServiceId) return;
try {
if (!(websocketSessionId || id)){
setErrorShow(true);
console.error("websocketSessionId", websocketSessionId, id);
return;
}
console.log("sourceDataIds", sourceDataIds, websocketSessionId, id);
let response: any = await new_chat({
serverId: currentServiceId,
websocketId: websocketSessionId || id,
message: value,
queryParams: {
search: isSearchActive,
deep_thinking: isDeepThinkActive,
datasource: sourceDataIds?.join(",") || "",
},
});
console.log("_new", response);
const newChat: Chat = response;
curIdRef.current = response?.payload?.id;
newChat._source = {
message: value,
};
const updatedChat: Chat = {
...newChat,
messages: [newChat],
};
changeInput && changeInput("");
setActiveChat(updatedChat);
setCurChatEnd(false);
} catch (error) {
setErrorShow(true);
console.error("createNewChat:", error);
}
},
[currentServiceId, sourceDataIds, isSearchActive, isDeepThinkActive, curIdRef, websocketSessionId]
);
const sendMessage = useCallback(
async (content: string, newChat: Chat, id?: string) => {
if (!newChat?._id || !currentServiceId || !content) return;
clearAllChunkData();
try {
if (!(websocketSessionId || id)){
setErrorShow(true);
console.error("websocketSessionId", websocketSessionId, id);
return;
}
let response: any = await send_message({
serverId: currentServiceId,
websocketId: websocketSessionId || 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("");
setActiveChat(updatedChat);
setCurChatEnd(false);
} catch (error) {
setErrorShow(true);
console.error("sendMessage:", error);
}
},
[currentServiceId, sourceDataIds, isSearchActive, isDeepThinkActive, curIdRef, setActiveChat, setCurChatEnd, setErrorShow, changeInput, websocketSessionId]
);
const handleSendMessage = useCallback(
async (content: string, activeChat?: Chat, id?: string) => {
if (!activeChat?._id || !content) return;
setQuestion(content);
setTimedoutShow(false);
setErrorShow(false);
await chatHistory(activeChat, (chat) => sendMessage(content, chat, id));
},
[chatHistory, sendMessage, setQuestion, setTimedoutShow, setErrorShow, clearAllChunkData]
);
const openSessionChat = useCallback(async (chat: Chat) => {
if (!chat?._id || !currentServiceId) return;
try {
let response: any = await open_session_chat({
serverId: currentServiceId,
sessionId: chat?._id,
});
response = JSON.parse(response || "");
console.log("_open", response);
return response;
} catch (error) {
console.error("open_session_chat:", error);
return null;
}
}, [currentServiceId]);
const getChatHistory = useCallback(async () => {
if (!currentServiceId) return [];
try {
let response: any = await chat_history({
serverId: currentServiceId,
from: 0,
size: 20,
});
response = JSON.parse(response || "");
console.log("_history", response);
const hits = response?.hits?.hits || [];
return hits;
} catch (error) {
console.error("chat_history:", error);
return [];
}
}, [currentServiceId]);
const createChatWindow = useCallback(async (createWin: any) => {
if (isTauri()) {
createWin && createWin({
label: "chat",
title: "Coco Chat",
dragDropEnabled: true,
center: true,
width: 1000,
height: 800,
minWidth: 1000,
minHeight: 800,
alwaysOnTop: false,
skipTaskbar: false,
decorations: true,
closable: true,
url: "/ui/chat",
});
}
}, []);
return {
chatClose,
cancelChat,
chatHistory,
createNewChat,
sendMessage,
handleSendMessage,
openSessionChat,
getChatHistory,
createChatWindow
};
} }

View File

@@ -0,0 +1,69 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { debounce } from "lodash-es";
export function useChatScroll(messagesEndRef: React.RefObject<HTMLDivElement>) {
const [userScrolling, setUserScrolling] = useState(false);
const scrollTimeoutRef = useRef<NodeJS.Timeout>();
const lastScrollHeightRef = useRef<number>(0);
const isNearBottom = (container: HTMLElement) => {
const { scrollTop, scrollHeight, clientHeight } = container;
return Math.abs(scrollHeight - scrollTop - clientHeight) < 150;
};
const scrollToBottom = useCallback(
debounce(() => {
const container = messagesEndRef.current?.parentElement;
if (!container) return;
const contentChanged = lastScrollHeightRef.current !== container.scrollHeight;
lastScrollHeightRef.current = container.scrollHeight;
if (!userScrolling || (contentChanged && isNearBottom(container))) {
container.scrollTo({
top: container.scrollHeight,
behavior: "smooth",
});
}
}, 50),
[userScrolling, messagesEndRef]
);
useEffect(() => {
const container = messagesEndRef.current?.parentElement;
if (!container) return;
lastScrollHeightRef.current = container.scrollHeight;
const handleScroll = () => {
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
const near = isNearBottom(container);
if (!near) {
setUserScrolling(true);
}
scrollTimeoutRef.current = setTimeout(() => {
if (isNearBottom(container)) {
setUserScrolling(false);
}
}, 300);
};
container.addEventListener("scroll", handleScroll);
return () => {
container.removeEventListener("scroll", handleScroll);
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, [messagesEndRef]);
return {
userScrolling,
scrollToBottom
};
}

View File

@@ -1,36 +1,38 @@
import {useEffect} from "react"; import { useEffect } from "react";
import {invoke, isTauri} from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import {listen} from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { hide_coco } from "@/commands"
const useEscape = () => { const useEscape = () => {
const handleEscape = async (event: KeyboardEvent) => { const handleEscape = async (event: KeyboardEvent) => {
if (event.key === "Escape") { if (event.key === "Escape") {
console.log("Escape key pressed."); console.log("Escape key pressed.");
event.preventDefault(); event.preventDefault();
// Hide the Tauri app window when 'Esc' is pressed // Hide the Tauri app window when 'Esc' is pressed
await invoke("hide_coco"); await hide_coco()
console.log("App window hidden successfully."); console.log("App window hidden successfully.");
} }
};
useEffect(() => {
if (!isTauri()) return;
const unlisten = listen("tauri://focus", () => {
// Add event listener for keydown
window.addEventListener("keydown", handleEscape);
});
// Cleanup event listener on component unmount
return () => {
unlisten.then((unlistenFn) => unlistenFn());
window.removeEventListener("keydown", handleEscape);
}; };
}, []);
useEffect(() => {
if (!isTauri()) return;
const unlisten = listen("tauri://focus", () => {
// Add event listener for keydown
window.addEventListener("keydown", handleEscape);
});
// Cleanup event listener on component unmount
return () => {
unlisten.then((unlistenFn) => unlistenFn());
window.removeEventListener("keydown", handleEscape);
};
}, []);
}; };
export default useEscape; export default useEscape;

View File

@@ -0,0 +1,95 @@
import { useCallback, useRef } from "react";
import type { IChunkData, Chat } from "@/components/Assistant/types";
import { useConnectStore } from "@/stores/connectStore";
export function useMessageHandler(
curIdRef: React.MutableRefObject<string>,
setCurChatEnd: (value: boolean) => void,
setTimedoutShow: (value: boolean) => void,
onCancel: (chat?: Chat) => void,
setLoadingStep: (
value:
| Record<string, boolean>
| ((prev: Record<string, boolean>) => Record<string, boolean>)
) => void,
handlers: {
deal_query_intent: (data: IChunkData) => void;
deal_fetch_source: (data: IChunkData) => void;
deal_pick_source: (data: IChunkData) => void;
deal_deep_read: (data: IChunkData) => void;
deal_think: (data: IChunkData) => void;
deal_response: (data: IChunkData) => void;
}
) {
const messageTimeoutRef = useRef<NodeJS.Timeout>();
const connectionTimeout = useConnectStore((state) => state.connectionTimeout);
const dealMsg = useCallback(
(msg: string) => {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
if (!msg.includes("PRIVATE")) return;
messageTimeoutRef.current = setTimeout(() => {
console.log("AI response timeout");
setTimedoutShow(true);
onCancel();
}, (connectionTimeout ?? 120) * 1000);
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,
}));
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);
} else if (chunkData.chunk_type === "reply_end") {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
setCurChatEnd(true);
console.log("AI finished output");
return;
}
} catch (error) {
console.error("parse error:", error);
}
},
[
onCancel,
setCurChatEnd,
setTimedoutShow,
curIdRef.current,
connectionTimeout,
]
);
return {
dealMsg,
messageTimeoutRef,
};
}

View File

@@ -1,8 +1,7 @@
import { useEffect, useCallback } from "react"; import { useEffect, useCallback } from "react";
import { listen, emit } from "@tauri-apps/api/event";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { isMetaOrCtrlKey } from "@/utils/keyboardUtils"; import { isMetaOrCtrlKey } from "@/utils/keyboardUtils";
import platformAdapter from "@/utils/platformAdapter";
interface CreateWindowOptions { interface CreateWindowOptions {
label?: string; label?: string;
@@ -41,13 +40,13 @@ export default function useSettingsWindow() {
}; };
// Check if the window already exists // Check if the window already exists
WebviewWindow.getByLabel(options.label!).then((existingWindow) => { platformAdapter.getWindowByLabel(options.label!).then((existingWindow) => {
if (existingWindow) { if (existingWindow) {
existingWindow.show(); existingWindow.show();
existingWindow.setFocus(); existingWindow.setFocus();
existingWindow.center(); existingWindow.center();
} else { } else {
new WebviewWindow(options.label!, options); platformAdapter.createWindow(options.label!, options);
} }
}); });
}, []); }, []);
@@ -68,11 +67,11 @@ export default function useSettingsWindow() {
); );
useEffect(() => { useEffect(() => {
const unlisten = listen("open_settings", async (event) => { const unlisten = platformAdapter.listenEvent("open_settings", async (event) => {
console.log("open_settings event received:", event); console.log("open_settings event received:", event);
const tab = event.payload as string | ""; const tab = event.payload as string | "";
emit("tab_index", tab); platformAdapter.emitEvent("tab_index", tab);
openSettingsWindow(tab); openSettingsWindow(tab);
}); });
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);

View File

@@ -1,12 +1,13 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TrayIcon, type TrayIconOptions } from "@tauri-apps/api/tray"; import { TrayIcon, type TrayIconOptions } from "@tauri-apps/api/tray";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { isMac } from "@/utils/platform";
import { resolveResource } from "@tauri-apps/api/path"; import { resolveResource } from "@tauri-apps/api/path";
import { useUpdateEffect } from "ahooks"; import { useUpdateEffect } from "ahooks";
import { exit } from "@tauri-apps/plugin-process"; import { exit } from "@tauri-apps/plugin-process";
import { invoke } from "@tauri-apps/api/core";
import { isMac } from "@/utils/platform";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { show_coco, show_settings } from "@/commands";
const TRAY_ID = "COCO_TRAY"; const TRAY_ID = "COCO_TRAY";
@@ -50,7 +51,7 @@ export const useTray = () => {
text: t("tray.showCoco"), text: t("tray.showCoco"),
accelerator: showCocoShortcuts.join("+"), accelerator: showCocoShortcuts.join("+"),
action: () => { action: () => {
invoke("show_coco"); show_coco()
}, },
}), }),
PredefinedMenuItem.new({ item: "Separator" }), PredefinedMenuItem.new({ item: "Separator" }),
@@ -58,7 +59,7 @@ export const useTray = () => {
text: t("tray.settings"), text: t("tray.settings"),
// accelerator: "CommandOrControl+,", // accelerator: "CommandOrControl+,",
action: () => { action: () => {
invoke("show_settings"); show_settings()
}, },
}), }),
PredefinedMenuItem.new({ item: "Separator" }), PredefinedMenuItem.new({ item: "Separator" }),

View File

@@ -1,66 +1,104 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { IServer } from "@/stores/appStore"; import { IServer } from "@/stores/appStore";
import { connect_to_server, disconnect } from "@/commands"
interface WebSocketProps { interface WebSocketProps {
clientId: string;
connected: boolean; connected: boolean;
setConnected: (connected: boolean) => void; setConnected: (connected: boolean) => void;
currentService: IServer | null; currentService: IServer | null;
dealMsg: (msg: string) => void; dealMsgRef: React.MutableRefObject<((msg: string) => void) | null>;
onWebsocketSessionId?: (sessionId: string) => void;
} }
export default function useWebSocket({ export default function useWebSocket({
clientId,
connected, connected,
setConnected, setConnected,
currentService, currentService,
dealMsg dealMsgRef,
onWebsocketSessionId,
}: WebSocketProps) { }: WebSocketProps) {
const [errorShow, setErrorShow] = useState(false); const [errorShow, setErrorShow] = useState(false);
// 1. WebSocket connects when loading or switching services
// src/components/Assistant/ChatHeader.tsx
// 2. If not connected or disconnected, input box has a connect button, clicking it will connect to WebSocket
// src/components/Search/InputBox.tsx
const reconnect = useCallback(async (server?: IServer) => { const reconnect = useCallback(async (server?: IServer) => {
const targetServer = server || currentService; const targetServer = server || currentService;
console.log("reconnect_targetServer", targetServer?.id);
if (!targetServer?.id) return; if (!targetServer?.id) return;
try { try {
console.log("reconnect", targetServer.id); console.log("reconnect", targetServer.id, clientId);
await invoke("connect_to_server", { id: targetServer.id }); await connect_to_server(targetServer.id, clientId);
setConnected(true);
} catch (error) { } catch (error) {
setConnected(false); setConnected(false);
console.error("Failed to connect:", error); console.error("Failed to connect:", error);
} }
}, [currentService]); }, [currentService]);
const disconnectWS = async () => {
if (!connected) return;
try {
console.log("disconnect");
await disconnect(clientId);
setConnected(false);
} catch (error) {
console.error("Failed to disconnect:", error);
}
};
const updateDealMsg = useCallback((newDealMsg: (msg: string) => void) => {
dealMsgRef.current = newDealMsg;
}, [dealMsgRef]);
const websocketIdRef = useRef<string>('')
useEffect(() => { useEffect(() => {
if (!currentService?.id) return;
let unlisten_error = null; let unlisten_error = null;
let unlisten_message = null; let unlisten_message = null;
if (connected) { setErrorShow(false);
setErrorShow(false); unlisten_error = listen(`ws-error-${clientId}`, (event) => {
unlisten_error = listen("ws-error", (event) => { // {
// { // "error": {
// "error": { // "reason": "invalid login"
// "reason": "invalid login" // },
// }, // "status": 401
// "status": 401 // }
// } console.error(`ws-error-${clientId}`, event.payload);
console.log("ws-error", event.payload); setConnected(false);
console.error("WebSocket error:", event.payload); setErrorShow(true);
setConnected(false); });
setErrorShow(true);
}); unlisten_message = listen(`ws-message-${clientId}`, (event) => {
const msg = event.payload as string;
unlisten_message = listen("ws-message", (event) => { console.log(`ws-message-${clientId}`, msg);
dealMsg(String(event.payload)); if (msg.includes("websocket-session-id")) {
}); console.log("websocket-session-id:", msg);
} const sessionId = msg.split(":")[1].trim();
websocketIdRef.current = sessionId;
console.log("sessionId:", sessionId);
setConnected(true);
if (onWebsocketSessionId) {
onWebsocketSessionId(sessionId);
}
return;
}
dealMsgRef.current && dealMsgRef.current(msg);
});
return () => { return () => {
unlisten_error?.then((fn: any) => fn()); unlisten_error?.then((fn: any) => fn());
unlisten_message?.then((fn: any) => fn()); unlisten_message?.then((fn: any) => fn());
}; };
}, [connected, dealMsg]); }, [dealMsgRef]);
return { errorShow, setErrorShow, reconnect }; return { errorShow, setErrorShow, reconnect, disconnectWS, updateDealMsg };
} }

View File

@@ -0,0 +1,28 @@
import { useEffect } from "react";
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
export function useWindowEvents() {
const isPinned = useAppStore((state) => state.isPinned);
const visible = useAppStore((state) => state.visible);
useEffect(() => {
const handleBlur = async () => {
console.log("Window blurred");
if (isPinned || visible) {
return;
}
await platformAdapter.hideWindow();
console.log("Hide Coco");
};
window.addEventListener("blur", handleBlur);
// Clean up event listeners on component unmount
return () => {
window.removeEventListener("blur", handleBlur);
};
}, [isPinned, visible]);
}

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