mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-18 12:37:45 +01:00
Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47d2e46b72 | ||
|
|
414bc78aaf | ||
|
|
9fd4a16df3 | ||
|
|
0e9e8bf653 | ||
|
|
c14b9fa0be | ||
|
|
8477c7ce95 | ||
|
|
3e48eae749 | ||
|
|
5764b72f1e | ||
|
|
bff86c327a | ||
|
|
e60915443a | ||
|
|
c86c768960 | ||
|
|
a6a84f3df5 | ||
|
|
0a231b80d0 | ||
|
|
5272c3dab9 | ||
|
|
256262ec2e | ||
|
|
4508c292eb | ||
|
|
f4a3838844 | ||
|
|
6e07cacae2 | ||
|
|
191f34905e | ||
|
|
f876fc24f2 | ||
|
|
05f1459f8d | ||
|
|
78a7bfb4c4 | ||
|
|
9078c99e25 | ||
|
|
a044642636 | ||
|
|
0f18c0a597 | ||
|
|
86836bf756 | ||
|
|
70f876fd4a | ||
|
|
3826346fdf | ||
|
|
79b998da1b | ||
|
|
839a51bb3c | ||
|
|
f7c7c0cc1e | ||
|
|
61e253ca2c | ||
|
|
ab16543e65 | ||
|
|
c095ad4d29 | ||
|
|
af63bab7bd | ||
|
|
80ac8baca5 | ||
|
|
bde658b981 | ||
|
|
4380b56a30 | ||
|
|
54364565e2 | ||
|
|
ee4a06b6de | ||
|
|
9715a92f36 | ||
|
|
2caeb4090a | ||
|
|
983e65ee61 | ||
|
|
ec37cfe68f | ||
|
|
db66d81bd0 | ||
|
|
5b0fdbcb2c | ||
|
|
88955e0b95 | ||
|
|
aee7df608f | ||
|
|
6d8fa81141 | ||
|
|
d67d6645fe | ||
|
|
6329354243 | ||
|
|
3ef5226e11 | ||
|
|
eebf49d7e0 | ||
|
|
04903a09cd | ||
|
|
44b5f8400e | ||
|
|
77e6b58381 | ||
|
|
f6e5e826fd | ||
|
|
886400bcbc | ||
|
|
53258ee834 | ||
|
|
e8d197fb32 | ||
|
|
195b6e7af1 | ||
|
|
6f08d1e934 | ||
|
|
de89ad8d9a | ||
|
|
a5657e61c0 | ||
|
|
20e8658da8 | ||
|
|
caf9f0238f | ||
|
|
f18f94ea6d | ||
|
|
bbb517237f | ||
|
|
0bf6686494 | ||
|
|
9f04fb1e0f | ||
|
|
542fd5b233 | ||
|
|
26bf391937 | ||
|
|
20b653391c | ||
|
|
a9aab4e4d5 | ||
|
|
b25f820288 | ||
|
|
a6205eff1b | ||
|
|
af70639eb3 | ||
|
|
bd5015efeb | ||
|
|
1c59a88a38 | ||
|
|
8fef0a5d8b | ||
|
|
4eed4cb1d9 | ||
|
|
eff37d6764 | ||
|
|
a22024f640 | ||
|
|
c3bef7e46b | ||
|
|
0703808009 | ||
|
|
23ae478e47 | ||
|
|
6ecb232685 | ||
|
|
e4785f0654 | ||
|
|
fc2c311624 | ||
|
|
0d15b3b6be | ||
|
|
689631cde2 | ||
|
|
326b1f5bff | ||
|
|
0a7b445661 | ||
|
|
62cbb95000 | ||
|
|
2b11d4a2a8 | ||
|
|
2cc3bf55c7 | ||
|
|
76880460c5 | ||
|
|
42fb9563a7 | ||
|
|
e088f5dcbe | ||
|
|
024dc3155d | ||
|
|
0948ab1035 | ||
|
|
19e2f5eb4f | ||
|
|
935cdef391 | ||
|
|
7e4f4b5303 | ||
|
|
c053b55759 | ||
|
|
7fa56cfc7d | ||
|
|
c15fd2ce73 | ||
|
|
6c90f42da0 | ||
|
|
72e5224e39 | ||
|
|
b602121cd3 | ||
|
|
211ba463d0 | ||
|
|
b45eb0b91d | ||
|
|
57b2a20c56 | ||
|
|
59622a768b | ||
|
|
1cace28760 | ||
|
|
eb32b03b48 | ||
|
|
04d00c808d | ||
|
|
73a65718ef | ||
|
|
e15baef8f9 | ||
|
|
7225635f08 | ||
|
|
ecc5757af6 | ||
|
|
6a9b1b53b9 | ||
|
|
a3663703e4 | ||
|
|
3aed3a0df4 | ||
|
|
569a61841c | ||
|
|
8b2fc07519 | ||
|
|
bf145c8697 | ||
|
|
0c3606820c | ||
|
|
3df86fc1c4 | ||
|
|
d01cbe1541 | ||
|
|
89a763dff7 | ||
|
|
0c42a51cb5 | ||
|
|
f514e5a5c9 | ||
|
|
b3aff2b353 | ||
|
|
bcb92bfd49 | ||
|
|
d9dea0ea38 | ||
|
|
d2eed4a1c4 | ||
|
|
c7e547b5fa | ||
|
|
eadd0988ba | ||
|
|
78bc83f38a | ||
|
|
84d9c6cdf0 | ||
|
|
0769545a92 | ||
|
|
118eaa55e3 | ||
|
|
ef1304ce5e | ||
|
|
51d3a9d090 | ||
|
|
7d0eced55a | ||
|
|
e81c5bbb6e | ||
|
|
bfc7b488ad | ||
|
|
249cc2eae4 | ||
|
|
388dac6452 | ||
|
|
dc8d1b5054 | ||
|
|
046c3dda82 | ||
|
|
60ce678e3e | ||
|
|
8d79b9ba1a | ||
|
|
969126ed89 | ||
|
|
e2df2b583a | ||
|
|
9d948d4fc6 | ||
|
|
81c770ba7e | ||
|
|
c9e9a72a0e | ||
|
|
96e6aae30b | ||
|
|
d319f5ebc7 | ||
|
|
04ff358dc7 | ||
|
|
22872ab02f | ||
|
|
fcfd21be45 | ||
|
|
0044e9a536 | ||
|
|
44a3ea3868 | ||
|
|
b444dc35ae | ||
|
|
8c9ccef218 | ||
|
|
a3bc997efe | ||
|
|
910841013f |
6
.env
6
.env
@@ -1,3 +1,5 @@
|
||||
COCO_SERVER_URL=https://coco.infini.cloud #http://localhost:9000
|
||||
COCO_SERVER_URL=http://localhost:9000 #https://coco.infini.cloud #http://localhost:9000
|
||||
|
||||
COCO_WEBSOCKET_URL=wss://coco.infini.cloud/ws #ws://localhost:9000/ws
|
||||
COCO_WEBSOCKET_URL=ws://localhost:9000/ws #wss://coco.infini.cloud/ws #ws://localhost:9000/ws
|
||||
|
||||
#TAURI_DEV_HOST=0.0.0.0
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -73,7 +73,7 @@ jobs:
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
run: rustup toolchain install stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,8 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
out
|
||||
src/components/web
|
||||
|
||||
# Editor directories and files
|
||||
# .vscode/*
|
||||
|
||||
16
.vscode/settings.json
vendored
16
.vscode/settings.json
vendored
@@ -4,34 +4,48 @@
|
||||
"autolaunch",
|
||||
"Avenir",
|
||||
"callout",
|
||||
"changelogithub",
|
||||
"clsx",
|
||||
"codegen",
|
||||
"dataurl",
|
||||
"dtolnay",
|
||||
"dyld",
|
||||
"elif",
|
||||
"errmsg",
|
||||
"fullscreen",
|
||||
"headlessui",
|
||||
"Icdbb",
|
||||
"icns",
|
||||
"iconfont",
|
||||
"INFINI",
|
||||
"infinilabs",
|
||||
"inputbox",
|
||||
"katex",
|
||||
"khtml",
|
||||
"languagedetector",
|
||||
"libappindicator",
|
||||
"librsvg",
|
||||
"libwebkit",
|
||||
"localstorage",
|
||||
"lucide",
|
||||
"maximizable",
|
||||
"Minimizable",
|
||||
"msvc",
|
||||
"nord",
|
||||
"nowrap",
|
||||
"nspanel",
|
||||
"nsstring",
|
||||
"overscan",
|
||||
"partialize",
|
||||
"patchelf",
|
||||
"Raycast",
|
||||
"rehype",
|
||||
"reqwest",
|
||||
"rgba",
|
||||
"rustup",
|
||||
"screenshotable",
|
||||
"serde",
|
||||
"swatinem",
|
||||
"tailwindcss",
|
||||
"tauri",
|
||||
"thiserror",
|
||||
@@ -45,7 +59,9 @@
|
||||
"uuidv",
|
||||
"VITE",
|
||||
"walkdir",
|
||||
"wavesurfer",
|
||||
"webviews",
|
||||
"xzvf",
|
||||
"yuque",
|
||||
"zustand"
|
||||
],
|
||||
|
||||
83
README.md
83
README.md
@@ -1,7 +1,15 @@
|
||||
# Coco AI - Connect & Collaborate
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Tagline**: _"Coco AI - search, connect, collaborate – all in one place."_
|
||||
|
||||
Visit our website: [https://coco.rs](https://coco.rs)
|
||||
|
||||
[](LICENSE) [](https://tauri.app/) [](https://react.dev/) [](https://www.typescriptlang.org/) [](https://www.rust-lang.org/) [](https://nodejs.org/) [](https://github.com/infinilabs/coco-app/pulls) [](https://github.com/infinilabs/coco-app/releases) [](https://github.com/infinilabs/coco-app/actions) [](https://discord.com/invite/4tKTMkkvVX)
|
||||
|
||||
</div>
|
||||
|
||||
Coco AI is a unified search platform that connects all your enterprise applications and data—Google Workspace, Dropbox,
|
||||
Confluent Wiki, GitHub, and more—into a single, powerful search interface. This repository contains the **Coco App**,
|
||||
built for both **desktop and mobile**. The app allows users to search and interact with their enterprise data across
|
||||
@@ -12,16 +20,15 @@ and internal resources. Coco enhances collaboration by making information instan
|
||||
insights based on your enterprise's specific data.
|
||||
|
||||
> **Note**: Backend services, including data indexing and search functionality, are handled in a
|
||||
> separate [repository](https://github.com/infinilabs/coco-server).
|
||||
separate [repository](https://github.com/infinilabs/coco-server).
|
||||
|
||||
## Vision
|
||||

|
||||
|
||||
At Coco AI, we aim to streamline workplace collaboration by centralizing access to enterprise data. The Coco
|
||||
App
|
||||
provides a seamless, cross-platform experience, enabling teams to easily search, connect, and collaborate within their
|
||||
workspace.
|
||||
## 🚀 Vision
|
||||
|
||||
## Use Cases
|
||||
At Coco AI, we aim to streamline workplace collaboration by centralizing access to enterprise data. The Coco App provides a seamless, cross-platform experience, enabling teams to easily search, connect, and collaborate within their workspace.
|
||||
|
||||
## 💡 Use Cases
|
||||
|
||||
- **Unified Search Across Platforms**: Coco integrates with all your enterprise apps, letting you search documents,
|
||||
conversations, and files across Google Workspace, Dropbox, GitHub, etc.
|
||||
@@ -32,37 +39,67 @@ workspace.
|
||||
- **Simplified Data Access**: By removing the friction between various tools, Coco enhances your workflow and increases
|
||||
productivity.
|
||||
|
||||
## Getting Started
|
||||
## ✨ Key Features
|
||||
|
||||
### Initial Setup
|
||||
- 🔍 **Unified Search**: One-stop enterprise search with multi-platform integration
|
||||
- Supports major collaboration platforms: Google Workspace, Dropbox, Confluence Wiki, GitHub, etc.
|
||||
- Real-time search across documents, conversations, and files
|
||||
- Smart search intent understanding with relevance ranking
|
||||
- Cross-platform data correlation and context display
|
||||
- 🤖 **AI-Powered Chat**: Team-specific ChatGPT-like assistant trained on your enterprise data
|
||||
- 🌐 **Cross-Platform**: Available for Windows, macOS, Linux and Web
|
||||
- 🔒 **Security-First**: Support for private deployment and data sovereignty
|
||||
- ⚡ **High Performance**: Built with Rust and Tauri 2.0
|
||||
- 🎨 **Modern UI**: Sleek interface designed for productivity
|
||||
|
||||
**This version of pnpm requires at least Node.js v18.12**
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
To set up the Coco App for development:
|
||||
- **Frontend**: React + TypeScript
|
||||
- **Desktop Framework**: Tauri 2.0
|
||||
- **Styling**: Tailwind CSS
|
||||
- **State Management**: Zustand
|
||||
- **Build Tool**: Vite
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 18.12
|
||||
- Rust (latest stable)
|
||||
- pnpm (package manager)
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
cd coco-app
|
||||
# Install pnpm
|
||||
npm install -g pnpm
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start development server
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
#### Desktop Development:
|
||||
|
||||
To start desktop development, run:
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
pnpm tauri dev
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
## Documentation
|
||||
## 📚 Documentation
|
||||
|
||||
For full documentation on Coco AI, please visit the [Coco AI Documentation](https://docs.infinilabs.com/coco-app/main/).
|
||||
- [Coco App Documentation](https://docs.infinilabs.com/coco-app/main/)
|
||||
- [Coco Server Documentation](https://docs.infinilabs.com/coco-server/main/)
|
||||
- [Tauri Documentation](https://tauri.app/)
|
||||
|
||||
## License
|
||||
## 📄 License
|
||||
|
||||
Coco AI is an open-source project licensed under
|
||||
the [MIT License](https://github.com/infinilabs/coco-app/blob/main/LICENSE).
|
||||
|
||||
This means that you can freely use, modify, and
|
||||
Coco AI is an open-source project licensed under the [MIT License](LICENSE). You can freely use, modify, and
|
||||
distribute the software for both personal and commercial purposes, including hosting it on your own servers.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
Built with ❤️ by <a href="https://infinilabs.com">INFINI Labs</a>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,12 @@ theme: book
|
||||
disablePathToLower: true
|
||||
enableGitInfo: false
|
||||
|
||||
outputs:
|
||||
home:
|
||||
- HTML
|
||||
- RSS
|
||||
- JSON
|
||||
|
||||
# Needed for mermaid/katex shortcodes
|
||||
markup:
|
||||
goldmark:
|
||||
|
||||
@@ -7,8 +7,7 @@ type: docs
|
||||
|
||||
Coco AI is a fully open-source, cross-platform unified search and productivity tool that connects and searches across various data sources, including applications, files, Google Drive, Notion, Yuque, Hugo, and more, both local and cloud-based. By integrating with large models like DeepSeek, Coco AI enables intelligent personal knowledge management, emphasizing privacy and supporting private deployment, helping users quickly and intelligently access their information.
|
||||
|
||||
{{% load-img "/img/screenshot/fusion-search-across-datasources.png" "" %}}
|
||||
{{% load-img "/img/screenshot/coco-chat.png" "" %}}
|
||||
{{% load-img "/img/coco-preview.gif" "" %}}
|
||||
|
||||
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ type: docs
|
||||
|
||||
Coco AI is a fully open-source, cross-platform unified search and productivity tool that connects and searches across various data sources, including applications, files, Google Drive, Notion, Yuque, Hugo, and more, both local and cloud-based. By integrating with large models like DeepSeek, Coco AI enables intelligent personal knowledge management, emphasizing privacy and supporting private deployment, helping users quickly and intelligently access their information.
|
||||
|
||||
{{% load-img "/img/screenshot/fusion-search-across-datasources.png" "" %}}
|
||||
{{% load-img "/img/screenshot/coco-chat.png" "" %}}
|
||||
{{% load-img "/img/coco-preview.gif" "" %}}
|
||||
|
||||
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).
|
||||
|
||||
|
||||
@@ -9,14 +9,107 @@ Information about release notes of Coco Server is provided here.
|
||||
|
||||
## Latest (In development)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
## 0.4.0 (2025-04-27)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
### Features
|
||||
|
||||
- feat: history support for searching, renaming and deleting #322
|
||||
- feat: linux support for application search #330
|
||||
- feat: add shortcuts to most icon buttons #334
|
||||
- feat: add font icon for search list #342
|
||||
- feat: add a border to the main window in Windows 10 #343
|
||||
- feat: mobile terminal adaptation about style #348
|
||||
- feat: service list popup box supports keyboard-only operation #359
|
||||
- feat: networked search data sources support search and keyboard-only operation #367
|
||||
- feat: add application management to the plugin #374
|
||||
- feat: add keyboard-only operation to history list #385
|
||||
- feat: add error notification #386
|
||||
- feat: add support for AI assistant #394
|
||||
- feat: add support for calculator function #399
|
||||
- feat: auto selects the first item after searching #411
|
||||
- feat: web components assistant #422
|
||||
- feat: right-click menu support for search #423
|
||||
- feat: add chat mode launch page #424
|
||||
- feat: add MCP & call LLM tools #430
|
||||
- feat: ai assistant supports search and paging #431
|
||||
- feat: data sources support displaying customized icons #432
|
||||
- feat: add shortcut key conflict hint and reset function #442
|
||||
- feat: updated to include error message #465
|
||||
|
||||
### Bug fix
|
||||
|
||||
- fix: fixed the problem of not being able to search in secondary directories #338
|
||||
- fix: active shadow setting #354
|
||||
- fix: chat history was not show up #377
|
||||
- fix: get attachments in chat sessions
|
||||
- fix: filter http query_args and convert only supported values
|
||||
- fix:fixed several search & chat bugs #412
|
||||
- fix: fixed carriage return problem with chinese input method #464
|
||||
|
||||
### Improvements
|
||||
|
||||
- refactor: web components #331
|
||||
- refactor: refactoring login callback, receive access_token from coco-server
|
||||
- chore: adjust web component styles #362
|
||||
- style: modify the style #370
|
||||
- style: search list details display #378
|
||||
- refactor: refactoring api error handling #382
|
||||
- chore: update assistant icon & think mode #397
|
||||
- build: build web components and publish #404
|
||||
|
||||
## 0.3.0 (2025-03-31)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
### Features
|
||||
|
||||
- feat: add web pages components #277
|
||||
- feat: support for customizing some of the preset shortcuts #316
|
||||
- feat: support multi websocket connections #314
|
||||
- feat: add support for embeddable web widget #277
|
||||
|
||||
### Bug fix
|
||||
|
||||
### Improvements
|
||||
|
||||
- refactor: refactor invoke related code #309
|
||||
- refactor: hide apps without icon #312
|
||||
|
||||
## 0.2.1 (2025-03-14)
|
||||
|
||||
### Features
|
||||
|
||||
- support for automatic in-app updates #274
|
||||
|
||||
### Breaking changes
|
||||
|
||||
### Bug fix
|
||||
|
||||
- Fix the issue that the fusion search include disabled servers
|
||||
- Fix incorrect version type: should be string instead of u32
|
||||
- Fix the chat end judgment type #280
|
||||
- Fix the chat scrolling and chat rendering #282
|
||||
- Fix: store data is not shared among multiple windows #298
|
||||
|
||||
### Improvements
|
||||
|
||||
- Refactor: chat components #273
|
||||
- Feat: add endpoint display #282
|
||||
- Chore: chat window min width & remove input bg #284
|
||||
- Chore: remove selected function & add hide_coco #286
|
||||
- Chore: websocket timeout increased to 2 minutes #289
|
||||
- Chore: remove chat input border & clear input #295
|
||||
|
||||
## 0.2.0 (2025-03-07)
|
||||
|
||||
### Features
|
||||
@@ -54,7 +147,6 @@ Information about release notes of Coco Server is provided here.
|
||||
- Allow to switch servers in the settings page
|
||||
- etc.
|
||||
|
||||
|
||||
## 0.1.0 (2025-02-16)
|
||||
|
||||
### Features
|
||||
|
||||
BIN
docs/static/img/coco-preview.gif
vendored
Normal file
BIN
docs/static/img/coco-preview.gif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 MiB |
@@ -7,7 +7,7 @@
|
||||
<title>Coco</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body class="coco-container">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
40
package.json
40
package.json
@@ -1,11 +1,16 @@
|
||||
{
|
||||
"name": "coco",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:web": "cross-env BUILD_TARGET=web tsc && cross-env BUILD_TARGET=web tsup --format esm",
|
||||
"publish:web": "cd out/search-chat && npm publish",
|
||||
"publish:web:beta": "cd dist/search-chat && npm publish --tag beta",
|
||||
"publish:web:alpha": "cd dist/search-chat && npm publish --tag alpha",
|
||||
"publish:web:rc": "cd dist/search-chat && npm publish --tag rc",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"release": "release-it",
|
||||
@@ -13,29 +18,33 @@
|
||||
"release-beta": "release-it --preRelease=beta --preReleaseBase=1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.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-deep-link": "^2.2.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
|
||||
"@tauri-apps/plugin-http": "~2.0.2",
|
||||
"@tauri-apps/plugin-os": "^2.2.0",
|
||||
"@tauri-apps/plugin-os": "^2.2.1",
|
||||
"@tauri-apps/plugin-process": "^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-window": "2.0.0-alpha.1",
|
||||
"@wavesurfer/react": "^1.0.9",
|
||||
"ahooks": "^3.8.4",
|
||||
"axios": "^1.8.4",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"filesize": "^10.1.6",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.461.0",
|
||||
"mermaid": "^11.4.1",
|
||||
"nanoid": "^5.1.2",
|
||||
"mermaid": "^11.5.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
@@ -48,29 +57,34 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tauri-plugin-fs-pro-api": "^2.3.1",
|
||||
"tauri-plugin-macos-permissions-api": "^2.1.1",
|
||||
"tauri-plugin-fs-pro-api": "^2.4.0",
|
||||
"tauri-plugin-macos-permissions-api": "^2.2.0",
|
||||
"tauri-plugin-screenshots-api": "^2.1.0",
|
||||
"tauri-plugin-windows-version-api": "^2.0.0",
|
||||
"use-debounce": "^10.0.4",
|
||||
"uuid": "^11.1.0",
|
||||
"wavesurfer.js": "^7.9.3",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"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/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.13.9",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/node": "^22.13.11",
|
||||
"@types/react": "^18.3.19",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-i18next": "^8.1.0",
|
||||
"@types/react-katex": "^3.0.4",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"cross-env": "^7.0.3",
|
||||
"immer": "^10.1.1",
|
||||
"postcss": "^8.5.3",
|
||||
"release-it": "^18.1.2",
|
||||
"sass": "^1.87.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^5.4.14"
|
||||
|
||||
1830
pnpm-lock.yaml
generated
1830
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/assets/calculator.png
Normal file
BIN
public/assets/calculator.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 845 B |
1
public/assets/fonts/icons/iconfont.js
Normal file
1
public/assets/fonts/icons/iconfont.js
Normal file
File diff suppressed because one or more lines are too long
1071
src-tauri/Cargo.lock
generated
1071
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coco"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
description = "Search, connect, collaborate – all in one place."
|
||||
authors = ["INFINI Labs"]
|
||||
edition = "2021"
|
||||
@@ -27,7 +27,9 @@ pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "m
|
||||
tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png", "unstable"] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Need `arbitrary_precision` feature to support storing u128
|
||||
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
|
||||
serde_json = { version = "1", features = ["arbitrary_precision"] }
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-websocket = "2"
|
||||
tauri-plugin-deep-link = "2.0.0"
|
||||
@@ -41,13 +43,13 @@ tauri-plugin-drag = "2"
|
||||
tauri-plugin-macos-permissions = "2"
|
||||
tauri-plugin-fs-pro = "2"
|
||||
tauri-plugin-screenshots = "2"
|
||||
applications = "0.3.0"
|
||||
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "fb8f475993a2a774ce08d7a58f9f2ac264248a24" }
|
||||
|
||||
tokio-native-tls = "0.3" # For wss connections
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.20", features = ["rustls-tls-webpki-roots"] }
|
||||
hyper = { version = "0.14", features = ["client"] }
|
||||
reqwest = "0.12.12"
|
||||
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||
futures = "0.3.31"
|
||||
ordered-float = { version = "4.6.0", default-features = false }
|
||||
lazy_static = "1.5.0"
|
||||
@@ -68,6 +70,11 @@ url = "2.5.2"
|
||||
http = "1.1.0"
|
||||
tungstenite = "0.24.0"
|
||||
env_logger = "0.11.5"
|
||||
tokio-util = "0.7.14"
|
||||
tauri-plugin-windows-version = "2"
|
||||
meval = "0.2"
|
||||
chinese-number = "0.7"
|
||||
num2words = "1"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||
@@ -89,3 +96,4 @@ strip = true # Ensures debug symbols are removed.
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-autostart = "^2.2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
@@ -31,5 +31,12 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Coco AI needs access to your microphone for voice input and audio recording features.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Coco AI requires camera access for scanning documents and capturing images.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Coco AI uses speech recognition to convert your voice into text for a hands-free experience.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
@@ -29,6 +29,7 @@
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:deny-internal-toggle-maximize",
|
||||
"core:window:allow-set-shadow",
|
||||
"core:app:allow-set-app-theme",
|
||||
"shell:default",
|
||||
"http:default",
|
||||
@@ -67,6 +68,8 @@
|
||||
"macos-permissions:default",
|
||||
"screenshots:default",
|
||||
"core:window:allow-set-theme",
|
||||
"process:default"
|
||||
"process:default",
|
||||
"updater:default",
|
||||
"windows-version:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled",
|
||||
"global-shortcut:default"
|
||||
"global-shortcut:default",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
use crate::common;
|
||||
use crate::common::assistant::ChatRequestMessage;
|
||||
use crate::common::http::GetResponse;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use reqwest::Response;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn chat_history<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
from: u32,
|
||||
size: u32,
|
||||
query: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let mut query_params: HashMap<String, Value> = HashMap::new();
|
||||
if from > 0 {
|
||||
@@ -21,30 +22,25 @@ pub async fn chat_history<R: Runtime>(
|
||||
query_params.insert("size".to_string(), size.into());
|
||||
}
|
||||
|
||||
let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params))
|
||||
.await
|
||||
.map_err(|e| format!("Error get sessions: {}", e))?;
|
||||
|
||||
handle_raw_response(response).await?
|
||||
if let Some(query) = query {
|
||||
if !query.is_empty() {
|
||||
query_params.insert("query".to_string(), query.into());
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_raw_response(response: Response) -> Result<Result<String, String>, String> {
|
||||
Ok(
|
||||
if response.status().as_u16() < 200 || response.status().as_u16() >= 400 {
|
||||
Err("Failed to send message".to_string())
|
||||
} else {
|
||||
let body = response
|
||||
.text()
|
||||
let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
||||
Ok(body)
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
dbg!("Error get history: {}", &e);
|
||||
format!("Error get history: {}", e)
|
||||
})?;
|
||||
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn session_chat_history<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
from: u32,
|
||||
@@ -64,87 +60,90 @@ pub async fn session_chat_history<R: Runtime>(
|
||||
.await
|
||||
.map_err(|e| format!("Error get session message: {}", e))?;
|
||||
|
||||
handle_raw_response(response).await?
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_session_chat<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<String, String> {
|
||||
let mut query_params = HashMap::new();
|
||||
let query_params = HashMap::new();
|
||||
let path = format!("/chat/{}/_open", session_id);
|
||||
|
||||
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
||||
.await
|
||||
.map_err(|e| format!("Error open session: {}", e))?;
|
||||
|
||||
handle_raw_response(response).await?
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn close_session_chat<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<String, String> {
|
||||
let mut query_params = HashMap::new();
|
||||
let query_params = HashMap::new();
|
||||
let path = format!("/chat/{}/_close", session_id);
|
||||
|
||||
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
||||
.await
|
||||
.map_err(|e| format!("Error close session: {}", e))?;
|
||||
|
||||
handle_raw_response(response).await?
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn cancel_session_chat<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<String, String> {
|
||||
let mut query_params = HashMap::new();
|
||||
let query_params = HashMap::new();
|
||||
let path = format!("/chat/{}/_cancel", session_id);
|
||||
|
||||
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
||||
.await
|
||||
.map_err(|e| format!("Error cancel session: {}", e))?;
|
||||
|
||||
handle_raw_response(response).await?
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn new_chat<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
websocket_id: String,
|
||||
message: String,
|
||||
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
||||
query_params: Option<HashMap<String, Value>>,
|
||||
) -> Result<GetResponse, String> {
|
||||
let body = if !message.is_empty() {
|
||||
let message = ChatRequestMessage {
|
||||
message: Some(message),
|
||||
};
|
||||
let body = reqwest::Body::from(serde_json::to_string(&message).unwrap());
|
||||
Some(body)
|
||||
Some(
|
||||
serde_json::to_string(&message)
|
||||
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = HttpClient::post(&server_id, "/chat/_new", query_params, body)
|
||||
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
|
||||
.map_err(|e| format!("Error sending message: {}", e))?;
|
||||
|
||||
if response.status().as_u16() < 200 || response.status().as_u16() >= 400 {
|
||||
return Err("Failed to send message".to_string());
|
||||
}
|
||||
let body_text = common::http::get_response_body_text(response).await?;
|
||||
|
||||
let chat_response: GetResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
||||
let chat_response: GetResponse =
|
||||
serde_json::from_str(&body_text).map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
||||
|
||||
// Check the result and status fields
|
||||
if chat_response.result != "created" {
|
||||
return Err(format!("Unexpected result: {}", chat_response.result));
|
||||
}
|
||||
@@ -154,8 +153,9 @@ pub async fn new_chat<R: Runtime>(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_message<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
websocket_id: String,
|
||||
session_id: String,
|
||||
message: String,
|
||||
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
||||
@@ -165,11 +165,94 @@ pub async fn send_message<R: Runtime>(
|
||||
message: Some(message),
|
||||
};
|
||||
|
||||
let 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 response =
|
||||
HttpClient::advanced_post(&server_id, path.as_str(), None, query_params, Some(body))
|
||||
let response = HttpClient::advanced_post(
|
||||
&server_id,
|
||||
path.as_str(),
|
||||
Some(headers),
|
||||
query_params,
|
||||
Some(body),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error cancel session: {}", e))?;
|
||||
|
||||
handle_raw_response(response).await?
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_session_chat(server_id: String, session_id: String) -> Result<bool, String> {
|
||||
let response =
|
||||
HttpClient::delete(&server_id, &format!("/chat/{}", session_id), None, None).await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(true)
|
||||
} else {
|
||||
Err(format!("Delete failed with status: {}", response.status()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_session_chat(
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
title: Option<String>,
|
||||
context: Option<HashMap<String, Value>>,
|
||||
) -> Result<bool, String> {
|
||||
let mut body = HashMap::new();
|
||||
if let Some(title) = title {
|
||||
body.insert("title".to_string(), Value::String(title));
|
||||
}
|
||||
if let Some(context) = context {
|
||||
body.insert(
|
||||
"context".to_string(),
|
||||
Value::Object(context.into_iter().collect()),
|
||||
);
|
||||
}
|
||||
|
||||
let response = HttpClient::put(
|
||||
&server_id,
|
||||
&format!("/chat/{}", session_id),
|
||||
None,
|
||||
None,
|
||||
Some(reqwest::Body::from(serde_json::to_string(&body).unwrap())),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error updating session: {}", e))?;
|
||||
|
||||
Ok(response.status().is_success())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assistant_search<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
from: u32,
|
||||
size: u32,
|
||||
query: Option<HashMap<String, Value>>,
|
||||
) -> Result<Value, String> {
|
||||
let mut body = serde_json::json!({
|
||||
"from": from,
|
||||
"size": size,
|
||||
});
|
||||
|
||||
if let Some(q) = query {
|
||||
body["query"] = serde_json::to_value(q).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let response = HttpClient::post(
|
||||
&server_id,
|
||||
"/assistant/_search",
|
||||
None,
|
||||
Some(reqwest::Body::from(body.to_string())),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error searching assistants: {}", e))?;
|
||||
|
||||
response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
@@ -60,7 +60,10 @@ fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn change_autostart<R: Runtime>(app: tauri::AppHandle<R>, open: bool) -> Result<(), String> {
|
||||
pub async fn change_autostart<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
open: bool,
|
||||
) -> Result<(), String> {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ pub struct ChatRequestMessage {
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct NewChatResponse {
|
||||
pub _id: String,
|
||||
pub _source: Source,
|
||||
|
||||
@@ -13,6 +13,7 @@ pub struct DataSourceReference {
|
||||
pub r#type: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub id: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -28,7 +29,7 @@ pub struct EditorInfo {
|
||||
pub timestamp: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Document {
|
||||
pub id: String,
|
||||
pub created: Option<String>,
|
||||
@@ -54,8 +55,15 @@ pub struct Document {
|
||||
pub owner: Option<UserInfo>,
|
||||
pub last_updated_by: Option<EditorInfo>,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn new(source: Option<DataSourceReference>, id: String, category: String, name: String, url: String) -> Self {
|
||||
pub fn new(
|
||||
source: Option<DataSourceReference>,
|
||||
id: String,
|
||||
category: String,
|
||||
name: String,
|
||||
url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
created: None,
|
||||
|
||||
45
src-tauri/src/common/error.rs
Normal file
45
src-tauri/src/common/error.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ErrorDetail {
|
||||
pub reason: String,
|
||||
pub status: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: ErrorDetail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Serialize)]
|
||||
pub enum SearchError {
|
||||
#[error("HTTP request failed: {0}")]
|
||||
HttpError(String),
|
||||
|
||||
#[error("Invalid response format: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Timeout occurred")]
|
||||
Timeout,
|
||||
|
||||
#[error("Unknown error: {0}")]
|
||||
#[allow(dead_code)]
|
||||
Unknown(String),
|
||||
|
||||
#[error("InternalError error: {0}")]
|
||||
#[allow(dead_code)]
|
||||
InternalError(String),
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for SearchError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
if err.is_timeout() {
|
||||
SearchError::Timeout
|
||||
} else if err.is_decode() {
|
||||
SearchError::ParseError(err.to_string())
|
||||
} else {
|
||||
SearchError::HttpError(err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::common;
|
||||
use reqwest::Response;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -16,3 +18,33 @@ pub struct Source {
|
||||
pub updated: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
pub async fn get_response_body_text(response: Response) -> Result<String, String> {
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {}", e))?;
|
||||
|
||||
if status < 200 || status >= 400 {
|
||||
// Try to parse the error body
|
||||
let fallback_error = "Failed to send message".to_string();
|
||||
|
||||
if body.trim().is_empty() {
|
||||
return Err(fallback_error);
|
||||
}
|
||||
|
||||
match serde_json::from_str::<common::error::ErrorResponse>(&body) {
|
||||
Ok(parsed_error) => {
|
||||
dbg!(&parsed_error);
|
||||
Err(format!(
|
||||
"Server error ({}): {}",
|
||||
parsed_error.error.status, parsed_error.error.reason
|
||||
))
|
||||
}
|
||||
Err(_) => Err(fallback_error),
|
||||
}
|
||||
} else {
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod traits;
|
||||
pub mod register;
|
||||
pub mod assistant;
|
||||
pub mod http;
|
||||
pub mod error;
|
||||
|
||||
pub static MAIN_WINDOW_LABEL: &str = "main";
|
||||
pub static SETTINGS_WINDOW_LABEL: &str = "settings";
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Preferences {
|
||||
pub theme: String,
|
||||
pub language: String,
|
||||
pub theme: Option<String>,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub avatar: String,
|
||||
pub preferences: Preferences,
|
||||
pub avatar: Option<String>,
|
||||
pub preferences: Option<Preferences>,
|
||||
}
|
||||
@@ -16,6 +16,7 @@ impl SearchSourceRegistry {
|
||||
sources.insert(source_id, Arc::new(source));
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn clear(&self) {
|
||||
let mut sources = self.sources.write().await;
|
||||
sources.clear();
|
||||
@@ -26,6 +27,7 @@ impl SearchSourceRegistry {
|
||||
sources.remove(id);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_source(&self, id: &str) -> Option<Arc<dyn SearchSource>> {
|
||||
let sources = self.sources.read().await;
|
||||
sources.get(id).cloned()
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use crate::common::document::Document;
|
||||
use crate::common::http::get_response_body_text;
|
||||
use reqwest::Response;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SearchResponse<T> {
|
||||
pub took: u64,
|
||||
pub timed_out: bool,
|
||||
pub _shards: Shards,
|
||||
pub _shards: Option<Shards>,
|
||||
pub hits: Hits<T>,
|
||||
}
|
||||
|
||||
@@ -47,14 +48,11 @@ pub async fn parse_search_response<T>(
|
||||
where
|
||||
T: for<'de> Deserialize<'de> + std::fmt::Debug,
|
||||
{
|
||||
let body = response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
let body_text = get_response_body_text(response).await?;
|
||||
|
||||
// dbg!(&body);
|
||||
// dbg!(&body_text);
|
||||
|
||||
let search_response: SearchResponse<T> = serde_json::from_value(body)
|
||||
let search_response: SearchResponse<T> = serde_json::from_str(&body_text)
|
||||
.map_err(|e| format!("Failed to deserialize search response: {}", e))?;
|
||||
|
||||
Ok(search_response)
|
||||
@@ -80,6 +78,7 @@ where
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn parse_search_results_with_score<T>(
|
||||
response: Response,
|
||||
) -> Result<Vec<(T, Option<f64>)>, Box<dyn Error>>
|
||||
|
||||
@@ -29,6 +29,11 @@ pub struct AuthProvider {
|
||||
pub sso: Sso,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MinimalClientVersion {
|
||||
number: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Server {
|
||||
#[serde(default = "default_empty_string")] // Custom default function for empty string
|
||||
@@ -39,6 +44,7 @@ pub struct Server {
|
||||
pub endpoint: String,
|
||||
pub provider: Provider,
|
||||
pub version: Version,
|
||||
pub minimal_client_version: Option<MinimalClientVersion>,
|
||||
pub updated: String,
|
||||
#[serde(default = "default_enabled_type")]
|
||||
pub enabled: bool,
|
||||
@@ -70,7 +76,6 @@ impl Hash for Server {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerAccessToken {
|
||||
#[serde(default = "default_empty_string")] // Custom default function for empty string
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use crate::common::search::{QueryResponse, QuerySource};
|
||||
use thiserror::Error;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crate::common::error::SearchError;
|
||||
// use std::{future::Future, pin::Pin};
|
||||
use crate::common::search::SearchQuery;
|
||||
use serde::Serialize;
|
||||
use crate::common::search::{QueryResponse, QuerySource};
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[async_trait]
|
||||
pub trait SearchSource: Send + Sync {
|
||||
@@ -13,32 +11,3 @@ pub trait SearchSource: Send + Sync {
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Serialize)]
|
||||
pub enum SearchError {
|
||||
#[error("HTTP request failed: {0}")]
|
||||
HttpError(String),
|
||||
|
||||
#[error("Invalid response format: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Timeout occurred")]
|
||||
Timeout,
|
||||
|
||||
#[error("Unknown error: {0}")]
|
||||
Unknown(String),
|
||||
|
||||
#[error("InternalError error: {0}")]
|
||||
InternalError(String),
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for SearchError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
if err.is_timeout() {
|
||||
SearchError::Timeout
|
||||
} else if err.is_decode() {
|
||||
SearchError::ParseError(err.to_string())
|
||||
} else {
|
||||
SearchError::HttpError(err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,21 +11,17 @@ mod util;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
// use crate::common::traits::SearchSource;
|
||||
use crate::common::{MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
|
||||
use crate::server::search::CocoSearchSource;
|
||||
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
||||
use autostart::{change_autostart, enable_autostart};
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::Client;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::async_runtime::block_on;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::ActivationPolicy;
|
||||
use tauri::{
|
||||
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, State, WebviewWindow, Window,
|
||||
WindowEvent,
|
||||
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, Window, WindowEvent,
|
||||
};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tokio::runtime::Runtime as RT;
|
||||
|
||||
/// Tauri store name
|
||||
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
|
||||
@@ -35,7 +31,7 @@ lazy_static! {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn change_window_height(handle: AppHandle, height: u32) {
|
||||
async fn change_window_height(handle: AppHandle, height: u32) {
|
||||
let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
|
||||
let mut size = window.outer_size().unwrap();
|
||||
@@ -45,10 +41,12 @@ fn change_window_height(handle: AppHandle, height: u32) {
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ThemeChangedPayload {
|
||||
#[allow(dead_code)]
|
||||
is_dark_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[allow(dead_code)]
|
||||
struct Payload {
|
||||
args: Vec<String>,
|
||||
cwd: String,
|
||||
@@ -56,7 +54,7 @@ struct Payload {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let mut ctx = tauri::generate_context!();
|
||||
let ctx = tauri::generate_context!();
|
||||
// Initialize logger
|
||||
env_logger::init();
|
||||
|
||||
@@ -83,7 +81,9 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_fs_pro::init())
|
||||
.plugin(tauri_plugin_macos_permissions::init())
|
||||
.plugin(tauri_plugin_screenshots::init())
|
||||
.plugin(tauri_plugin_process::init());
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_windows_version::init());
|
||||
|
||||
// Conditional compilation for macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -111,7 +111,8 @@ pub fn run() {
|
||||
server::servers::disable_server,
|
||||
server::auth::handle_sso_callback,
|
||||
server::profile::get_user_profiles,
|
||||
server::datasource::get_datasources_by_server,
|
||||
server::datasource::datasource_search,
|
||||
server::datasource::mcp_server_search,
|
||||
server::connector::get_connectors_by_server,
|
||||
search::query_coco_fusion,
|
||||
assistant::chat_history,
|
||||
@@ -121,11 +122,22 @@ pub fn run() {
|
||||
assistant::open_session_chat,
|
||||
assistant::close_session_chat,
|
||||
assistant::cancel_session_chat,
|
||||
assistant::delete_session_chat,
|
||||
assistant::update_session_chat,
|
||||
assistant::assistant_search,
|
||||
// server::get_coco_server_datasources,
|
||||
// server::get_coco_server_connectors,
|
||||
server::websocket::connect_to_server,
|
||||
server::websocket::disconnect,
|
||||
get_app_search_source
|
||||
get_app_search_source,
|
||||
server::attachment::upload_attachment,
|
||||
server::attachment::get_attachment,
|
||||
server::attachment::delete_attachment,
|
||||
server::transcription::transcription,
|
||||
local::application::get_default_search_paths,
|
||||
local::application::list_app_with_metadata_in,
|
||||
util::open,
|
||||
server::system_settings::get_system_settings
|
||||
])
|
||||
.setup(|app| {
|
||||
let registry = SearchSourceRegistry::default();
|
||||
@@ -133,20 +145,12 @@ pub fn run() {
|
||||
app.manage(registry); // Store registry in Tauri's app state
|
||||
app.manage(server::websocket::WebSocketManager::default());
|
||||
|
||||
// Get app handle
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
// Create a single Tokio runtime instance
|
||||
let rt = RT::new().expect("Failed to create Tokio runtime");
|
||||
|
||||
// Use the runtime to spawn the async initialization tasks
|
||||
let init_app_handle = app.handle().clone();
|
||||
rt.spawn(async move {
|
||||
init(&init_app_handle).await; // Pass a reference to `app_handle`
|
||||
block_on(async {
|
||||
init(app.handle()).await;
|
||||
});
|
||||
|
||||
shortcut::enable_shortcut(&app);
|
||||
// enable_tray(app);
|
||||
shortcut::enable_shortcut(app);
|
||||
|
||||
enable_autostart(app);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -223,57 +227,50 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
let coco_servers = server::servers::get_all_servers();
|
||||
|
||||
// Get the registry from Tauri's state
|
||||
let registry: State<SearchSourceRegistry> = app_handle.state::<SearchSourceRegistry>();
|
||||
// let registry: State<SearchSourceRegistry> = app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
for server in coco_servers {
|
||||
let source = CocoSearchSource::new(server.clone(), Client::new());
|
||||
registry.register_source(source).await;
|
||||
crate::server::servers::try_register_server_to_search_source(app_handle.clone(), &server)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn init_app_search_source<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
let application_search =
|
||||
local::application::ApplicationSearchSource::new(app_handle.clone(), 1000f64).await?;
|
||||
let calculator_search = local::calculator::CalculatorSource::new(2000f64);
|
||||
|
||||
// Register the application search source
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
registry.register_source(application_search).await;
|
||||
registry.register_source(calculator_search).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn show_coco(app_handle: AppHandle) {
|
||||
handle_open_coco(&app_handle);
|
||||
async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
|
||||
if let Some(window) = app_handle.get_window(MAIN_WINDOW_LABEL) {
|
||||
let _ = app_handle.emit("show-coco", ());
|
||||
|
||||
move_window_to_active_monitor(&window);
|
||||
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn hide_coco(app: tauri::AppHandle) {
|
||||
async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
|
||||
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
|
||||
match window.is_visible() {
|
||||
Ok(true) => {
|
||||
if let Err(err) = window.hide() {
|
||||
eprintln!("Failed to hide the window: {}", err);
|
||||
} else {
|
||||
println!("Window successfully hidden.");
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
println!("Window is already hidden.");
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Failed to check window visibility: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_open_coco(app: &AppHandle) {
|
||||
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
|
||||
move_window_to_active_monitor(&window);
|
||||
|
||||
window.show().unwrap();
|
||||
window.set_visible_on_all_workspaces(true).unwrap();
|
||||
window.set_always_on_top(true).unwrap();
|
||||
window.set_focus().unwrap();
|
||||
} else {
|
||||
eprintln!("Main window not found.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,88 +367,15 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_hide_coco(app: &AppHandle) {
|
||||
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
|
||||
if let Err(err) = window.hide() {
|
||||
eprintln!("Failed to hide the window: {}", err);
|
||||
} else {
|
||||
println!("Window successfully hidden.");
|
||||
}
|
||||
} else {
|
||||
eprintln!("Main window not found.");
|
||||
}
|
||||
}
|
||||
|
||||
fn enable_tray(app: &mut tauri::App) {
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{MenuBuilder, MenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
};
|
||||
|
||||
let quit_i = MenuItem::with_id(app, "quit", "Quit Coco", true, None::<&str>).unwrap();
|
||||
let settings_i = MenuItem::with_id(app, "settings", "Settings...", true, None::<&str>).unwrap();
|
||||
let open_i = MenuItem::with_id(app, "open", "Show Coco", true, None::<&str>).unwrap();
|
||||
// let about_i = MenuItem::with_id(app, "about", "About Coco", true, None::<&str>).unwrap();
|
||||
// let hide_i = MenuItem::with_id(app, "hide", "Hide Coco", true, None::<&str>).unwrap();
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&open_i)
|
||||
.separator()
|
||||
// .item(&hide_i)
|
||||
// .item(&about_i)
|
||||
.item(&settings_i)
|
||||
.separator()
|
||||
.item(&quit_i)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let _tray = TrayIconBuilder::with_id("tray")
|
||||
.icon_as_template(true)
|
||||
// .icon(app.default_window_icon().unwrap().clone())
|
||||
.icon(
|
||||
Image::from_bytes(include_bytes!("../assets/tray-mac.ico"))
|
||||
.expect("Failed to load icon"),
|
||||
)
|
||||
.menu(&menu)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"open" => {
|
||||
handle_open_coco(app);
|
||||
}
|
||||
"hide" => {
|
||||
handle_hide_coco(app);
|
||||
}
|
||||
"about" => {
|
||||
let _ = app.emit("open_settings", "about");
|
||||
}
|
||||
"settings" => {
|
||||
// windows failed to open second window, issue: https://github.com/tauri-apps/tauri/issues/11144 https://github.com/tauri-apps/tauri/issues/8196
|
||||
//#[cfg(windows)]
|
||||
let _ = app.emit("open_settings", "settings");
|
||||
|
||||
// #[cfg(not(windows))]
|
||||
// open_settings(&app);
|
||||
}
|
||||
"quit" => {
|
||||
println!("quit menu item was clicked");
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {
|
||||
println!("menu item {:?} not handled", event.id);
|
||||
}
|
||||
})
|
||||
.build(app)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn open_settings(app: &tauri::AppHandle) {
|
||||
use tauri::webview::WebviewBuilder;
|
||||
println!("settings menu item was clicked");
|
||||
let window = app.get_webview_window("settings");
|
||||
if let Some(window) = window {
|
||||
window.show().unwrap();
|
||||
window.set_focus().unwrap();
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
} else {
|
||||
let window = tauri::window::WindowBuilder::new(app, "settings")
|
||||
.title("Settings Window")
|
||||
|
||||
@@ -1,21 +1,196 @@
|
||||
use crate::common::document::{DataSourceReference, Document};
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
||||
use crate::common::traits::{SearchError, SearchSource};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use applications::{AppInfo, AppInfoContext};
|
||||
use applications::App;
|
||||
use async_trait::async_trait;
|
||||
use base64::encode;
|
||||
use fuzzy_prefix_search::Trie;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri_plugin_fs_pro::{icon, name};
|
||||
use tauri_plugin_fs_pro::{icon, metadata, name, IconOptions};
|
||||
|
||||
const DATA_SOURCE_ID: &str = "Applications";
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_default_search_paths() -> Vec<String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return vec![
|
||||
"/Applications".into(),
|
||||
"/System/Applications".into(),
|
||||
"/System/Library/CoreServices".into(),
|
||||
];
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let paths = applications::get_default_search_paths();
|
||||
let mut ret = Vec::with_capacity(paths.len());
|
||||
for search_path in paths {
|
||||
let path_string = search_path
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("path should be UTF-8 encoded");
|
||||
|
||||
ret.push(path_string);
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to return `app`'s path.
|
||||
///
|
||||
/// * Windows: return the path to application's exe
|
||||
/// * macOS: return the path to the `.app` bundle
|
||||
/// * Linux: return the path to the `.desktop` file
|
||||
fn get_app_path(app: &App) -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
assert!(
|
||||
app.icon_path.is_some(),
|
||||
"we only accept Applications with icons"
|
||||
);
|
||||
app.app_path_exe
|
||||
.as_ref()
|
||||
.expect("icon is Some, exe path should be Some as well")
|
||||
.to_path_buf()
|
||||
} else {
|
||||
app.app_desktop_path.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to return `app`'s path.
|
||||
///
|
||||
/// * Windows/macOS: extract `app_path`'s file name and remove the file extension
|
||||
/// * Linux: return the name specified in `.desktop` file
|
||||
async fn get_app_name(app: &App) -> String {
|
||||
if cfg!(target_os = "linux") {
|
||||
app.name.clone()
|
||||
} else {
|
||||
let app_path = get_app_path(app);
|
||||
name(app_path.clone()).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to return an absolute path to `app`'s icon.
|
||||
///
|
||||
/// On macOS/Windows, we cache icons in our data directory using the `icon()` function.
|
||||
async fn get_app_icon_path<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app: &App,
|
||||
) -> Result<PathBuf, String> {
|
||||
if cfg!(target_os = "linux") {
|
||||
let icon_path = app
|
||||
.icon_path
|
||||
.as_ref()
|
||||
.expect("We only accept applications with icons")
|
||||
.to_path_buf();
|
||||
|
||||
Ok(icon_path)
|
||||
} else {
|
||||
let app_path = get_app_path(app);
|
||||
let options = IconOptions {
|
||||
size: Some(256),
|
||||
save_path: None,
|
||||
};
|
||||
|
||||
icon(tauri_app_handle.clone(), app_path, Some(options))
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Return all the Apps found under `search_path`.
|
||||
///
|
||||
/// Note: apps with no icons will be filtered out.
|
||||
fn list_app_in(search_path: Vec<String>) -> Result<Vec<App>, String> {
|
||||
let search_path = search_path
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let apps = applications::get_all_apps(&search_path).map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(apps
|
||||
.into_iter()
|
||||
.filter(|app| app.icon_path.is_some())
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppMetadata {
|
||||
name: String,
|
||||
r#where: PathBuf,
|
||||
size: u64,
|
||||
icon: PathBuf,
|
||||
created: u128,
|
||||
modified: u128,
|
||||
last_opened: u128,
|
||||
}
|
||||
|
||||
/// List apps that are in the `search_path`.
|
||||
///
|
||||
/// Different from `list_app_in()`, every app is JSON object containing its metadata, e.g.:
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "name": "Finder",
|
||||
/// "where": "/System/Library/CoreServices",
|
||||
/// "size": 49283072,
|
||||
/// "icon": "/xxx.png",
|
||||
/// "created": 1744625204,
|
||||
/// "modified": 1744625204,
|
||||
/// "lastOpened": 1744625250
|
||||
/// }
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn list_app_with_metadata_in<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
search_path: Vec<String>,
|
||||
) -> Result<Vec<AppMetadata>, String> {
|
||||
let apps = list_app_in(search_path)?;
|
||||
|
||||
let mut apps_with_meta = Vec::with_capacity(apps.len());
|
||||
|
||||
// name version where Type(hardcoded Application) Size Created Modify
|
||||
for app in apps.iter() {
|
||||
let app_path = get_app_path(app);
|
||||
let app_name = get_app_name(app).await;
|
||||
let app_path_where = {
|
||||
let mut app_path_clone = app_path.clone();
|
||||
let truncated = app_path_clone.pop();
|
||||
if !truncated {
|
||||
panic!("every app file should live somewhere");
|
||||
}
|
||||
|
||||
app_path_clone
|
||||
};
|
||||
let icon = get_app_icon_path(&app_handle, app).await?;
|
||||
|
||||
let raw_app_metadata = metadata(app_path.clone(), None).await?;
|
||||
|
||||
let app_metadata = AppMetadata {
|
||||
name: app_name,
|
||||
r#where: app_path_where,
|
||||
size: raw_app_metadata.size,
|
||||
icon,
|
||||
created: raw_app_metadata.created_at,
|
||||
modified: raw_app_metadata.modified_at,
|
||||
last_opened: raw_app_metadata.accessed_at,
|
||||
};
|
||||
|
||||
apps_with_meta.push(app_metadata);
|
||||
}
|
||||
|
||||
Ok(apps_with_meta)
|
||||
}
|
||||
|
||||
pub struct ApplicationSearchSource {
|
||||
base_score: f64,
|
||||
// app name -> app icon path
|
||||
icons: HashMap<String, PathBuf>,
|
||||
application_paths: Trie<String>,
|
||||
application_paths: Trie<PathBuf>,
|
||||
}
|
||||
|
||||
impl ApplicationSearchSource {
|
||||
@@ -26,30 +201,20 @@ impl ApplicationSearchSource {
|
||||
let application_paths = Trie::new();
|
||||
let mut icons = HashMap::new();
|
||||
|
||||
let mut ctx = AppInfoContext::new(vec![]);
|
||||
ctx.refresh_apps().map_err(|err| err.to_string())?; // must refresh apps before getting them
|
||||
let apps = ctx.get_all_apps();
|
||||
let default_search_path = get_default_search_paths();
|
||||
let apps = list_app_in(default_search_path)?;
|
||||
|
||||
for app in &apps {
|
||||
let path = if cfg!(target_os = "macos") {
|
||||
app.app_desktop_path.clone()
|
||||
} else {
|
||||
app.app_path_exe
|
||||
.clone()
|
||||
.unwrap_or(PathBuf::from("Path not found"))
|
||||
};
|
||||
let search_word = name(path.clone()).await;
|
||||
let icon = icon(app_handle.clone(), path.clone(), Some(256))
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
let path_string = path.to_string_lossy().into_owned();
|
||||
let app_path = get_app_path(app);
|
||||
let app_name = get_app_name(app).await;
|
||||
let app_icon_path = get_app_icon_path(&app_handle, app).await?;
|
||||
|
||||
if search_word.is_empty() || search_word.eq("coco-ai") {
|
||||
if app_name.is_empty() || app_name.eq("Coco-AI") {
|
||||
continue;
|
||||
}
|
||||
|
||||
application_paths.insert(&search_word, path_string.clone());
|
||||
icons.insert(path_string, icon);
|
||||
application_paths.insert(&app_name, app_path);
|
||||
icons.insert(app_name, app_icon_path);
|
||||
}
|
||||
|
||||
Ok(ApplicationSearchSource {
|
||||
@@ -69,7 +234,7 @@ impl SearchSource for ApplicationSearchSource {
|
||||
.unwrap_or("My Computer".into())
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
id: "local_applications".into(),
|
||||
id: DATA_SOURCE_ID.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,9 +256,10 @@ impl SearchSource for ApplicationSearchSource {
|
||||
let mut total_hits = 0;
|
||||
let mut hits = Vec::new();
|
||||
|
||||
let query_string_len = query_string.len();
|
||||
let mut results = self
|
||||
.application_paths
|
||||
.search_within_distance_scored(&query_string, 3);
|
||||
.search_within_distance_scored(&query_string, query_string_len - 1);
|
||||
|
||||
// Check for NaN or extreme score values and handle them properly
|
||||
results.sort_by(|a, b| {
|
||||
@@ -110,30 +276,28 @@ impl SearchSource for ApplicationSearchSource {
|
||||
|
||||
if !results.is_empty() {
|
||||
for result in results {
|
||||
let file_name_str = result.word;
|
||||
let file_path_str = result.data.get(0).unwrap().to_string();
|
||||
let file_path = PathBuf::from(file_path_str.clone());
|
||||
let cleaned_file_name = name(file_path).await;
|
||||
let app_name = result.word;
|
||||
let app_path = result.data.first().unwrap().clone();
|
||||
let app_path_string = app_path.to_string_lossy().into_owned();
|
||||
|
||||
total_hits += 1;
|
||||
|
||||
let mut doc = Document::new(
|
||||
Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some("Applications".into()),
|
||||
id: Some(file_name_str.clone()),
|
||||
name: Some(DATA_SOURCE_ID.into()),
|
||||
id: Some(DATA_SOURCE_ID.into()),
|
||||
icon: None,
|
||||
}),
|
||||
file_path_str.clone(),
|
||||
app_path_string.clone(),
|
||||
"Application".to_string(),
|
||||
cleaned_file_name,
|
||||
file_path_str.clone(),
|
||||
app_name.clone(),
|
||||
app_path_string.clone(),
|
||||
);
|
||||
|
||||
// Attach icon if available
|
||||
if let Some(icon_path) = self.icons.get(file_path_str.as_str()) {
|
||||
// doc.icon = Some(format!("file://{}", icon_path.to_string_lossy()));
|
||||
// dbg!(&doc.icon);
|
||||
if let Ok(icon_data) = read_icon_and_encode(icon_path) {
|
||||
doc.icon = Some(format!("data:image/png;base64,{}", icon_data));
|
||||
}
|
||||
if let Some(icon_path) = self.icons.get(app_name.as_str()) {
|
||||
doc.icon = Some(icon_path.as_os_str().to_str().unwrap().to_string());
|
||||
}
|
||||
|
||||
hits.push((doc, self.base_score + result.score as f64));
|
||||
@@ -147,12 +311,3 @@ impl SearchSource for ApplicationSearchSource {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Function to read the icon file and convert it to base64
|
||||
fn read_icon_and_encode(icon_path: &Path) -> Result<String, std::io::Error> {
|
||||
// Read the icon file as binary data
|
||||
let icon_data = fs::read(icon_path)?;
|
||||
|
||||
// Encode the data to base64
|
||||
Ok(encode(&icon_data))
|
||||
}
|
||||
|
||||
163
src-tauri/src/local/calculator.rs
Normal file
163
src-tauri/src/local/calculator.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::common::{
|
||||
document::{DataSourceReference, Document},
|
||||
error::SearchError,
|
||||
search::{QueryResponse, QuerySource, SearchQuery},
|
||||
traits::SearchSource,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chinese_number::{ChineseCase, ChineseCountMethod, ChineseVariant, NumberToChinese};
|
||||
use num2words::Num2Words;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const DATA_SOURCE_ID: &str = "Calculator";
|
||||
|
||||
pub struct CalculatorSource {
|
||||
base_score: f64,
|
||||
}
|
||||
|
||||
impl CalculatorSource {
|
||||
pub fn new(base_score: f64) -> Self {
|
||||
CalculatorSource { base_score }
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_query(query: String) -> Value {
|
||||
let mut query_json = serde_json::Map::new();
|
||||
|
||||
let operators = ["+", "-", "*", "/", "%"];
|
||||
|
||||
let found_operators: Vec<_> = query
|
||||
.chars()
|
||||
.filter(|c| operators.contains(&c.to_string().as_str()))
|
||||
.collect();
|
||||
|
||||
if found_operators.len() == 1 {
|
||||
let operation = match found_operators[0] {
|
||||
'+' => "sum",
|
||||
'-' => "subtract",
|
||||
'*' => "multiply",
|
||||
'/' => "divide",
|
||||
'%' => "remainder",
|
||||
_ => "expression",
|
||||
};
|
||||
|
||||
query_json.insert("type".to_string(), Value::String(operation.to_string()));
|
||||
} else {
|
||||
query_json.insert("type".to_string(), Value::String("expression".to_string()));
|
||||
}
|
||||
|
||||
query_json.insert("value".to_string(), Value::String(query));
|
||||
|
||||
Value::Object(query_json)
|
||||
}
|
||||
|
||||
fn parse_result(num: f64) -> Value {
|
||||
let mut result_json = serde_json::Map::new();
|
||||
|
||||
let to_zh = num
|
||||
.to_chinese(
|
||||
ChineseVariant::Simple,
|
||||
ChineseCase::Upper,
|
||||
ChineseCountMethod::TenThousand,
|
||||
)
|
||||
.unwrap_or(num.to_string());
|
||||
|
||||
let to_en = Num2Words::new(num)
|
||||
.to_words()
|
||||
.map(|s| {
|
||||
let mut chars = s.chars();
|
||||
let mut result = String::new();
|
||||
let mut capitalize = true;
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == ' ' || c == '-' {
|
||||
result.push(c);
|
||||
capitalize = true;
|
||||
} else if capitalize {
|
||||
result.extend(c.to_uppercase());
|
||||
capitalize = false;
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
})
|
||||
.unwrap_or(num.to_string());
|
||||
|
||||
result_json.insert("value".to_string(), Value::String(num.to_string()));
|
||||
result_json.insert("toZh".to_string(), Value::String(to_zh));
|
||||
result_json.insert("toEn".to_string(), Value::String(to_en));
|
||||
|
||||
Value::Object(result_json)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SearchSource for CalculatorSource {
|
||||
fn get_type(&self) -> QuerySource {
|
||||
QuerySource {
|
||||
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
|
||||
name: hostname::get()
|
||||
.unwrap_or(DATA_SOURCE_ID.into())
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
id: DATA_SOURCE_ID.into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
|
||||
let query_string = query
|
||||
.query_strings
|
||||
.get("query")
|
||||
.unwrap_or(&"".to_string())
|
||||
.to_string();
|
||||
|
||||
if query_string.is_empty() || query_string.len() == 1 {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
match meval::eval_str(&query_string) {
|
||||
Ok(num) => {
|
||||
let mut payload: HashMap<String, Value> = HashMap::new();
|
||||
|
||||
let payload_query = parse_query(query_string);
|
||||
let payload_result = parse_result(num);
|
||||
|
||||
payload.insert("query".to_string(), payload_query);
|
||||
payload.insert("result".to_string(), payload_result);
|
||||
|
||||
let doc = Document {
|
||||
id: DATA_SOURCE_ID.to_string(),
|
||||
category: Some(DATA_SOURCE_ID.to_string()),
|
||||
payload: Some(payload),
|
||||
source: Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(DATA_SOURCE_ID.into()),
|
||||
id: Some(DATA_SOURCE_ID.into()),
|
||||
icon: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: vec![(doc, self.base_score)],
|
||||
total_hits: 1,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod application;
|
||||
pub mod calculator;
|
||||
pub mod file_system;
|
||||
|
||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::common::search::{
|
||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||
};
|
||||
use crate::common::traits::SearchError;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use futures::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
@@ -16,7 +16,10 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
from: u64,
|
||||
size: u64,
|
||||
query_strings: HashMap<String, String>,
|
||||
query_timeout: u64,
|
||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
||||
let query_source_to_search = query_strings.get("querysource");
|
||||
|
||||
let search_sources = app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
let sources_future = search_sources.get_sources();
|
||||
@@ -26,11 +29,19 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
let sources_list = sources_future.await;
|
||||
|
||||
// Time limit for each query
|
||||
let timeout_duration = Duration::from_millis(500); //TODO, settings
|
||||
let timeout_duration = Duration::from_millis(query_timeout);
|
||||
|
||||
// Push all queries into futures
|
||||
for query_source in sources_list {
|
||||
let query_source_type = query_source.get_type().clone();
|
||||
|
||||
if let Some(query_source_to_search) = query_source_to_search {
|
||||
// We should not search this data source
|
||||
if &query_source_type.id != query_source_to_search {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
sources.insert(query_source_type.id.clone(), query_source_type);
|
||||
|
||||
let query = SearchQuery::new(from, size, query_strings.clone());
|
||||
|
||||
143
src-tauri/src/server/attachment.rs
Normal file
143
src-tauri/src/server/attachment.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
use super::servers::{get_server_by_id, get_server_token};
|
||||
use crate::common::http::get_response_body_text;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
use tauri::command;
|
||||
use tokio::fs::File;
|
||||
use tokio_util::codec::{BytesCodec, FramedRead};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UploadAttachmentResponse {
|
||||
pub acknowledged: bool,
|
||||
pub attachments: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AttachmentSource {
|
||||
pub id: String,
|
||||
pub created: String,
|
||||
pub updated: String,
|
||||
pub session: String,
|
||||
pub name: String,
|
||||
pub icon: String,
|
||||
pub url: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AttachmentHit {
|
||||
pub _index: String,
|
||||
pub _type: Option<String>,
|
||||
pub _id: String,
|
||||
pub _score: Option<f64>,
|
||||
pub _source: AttachmentSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AttachmentHits {
|
||||
pub total: Value,
|
||||
pub max_score: Option<f64>,
|
||||
pub hits: 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())?;
|
||||
|
||||
let body = get_response_body_text(response).await?;
|
||||
|
||||
serde_json::from_str::<UploadAttachmentResponse>(&body)
|
||||
.map_err(|e| format!("Failed to parse upload response: {}", e))
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn get_attachment(
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<GetAttachmentResponse, String> {
|
||||
let mut query_params = HashMap::new();
|
||||
query_params.insert("session".to_string(), serde_json::Value::String(session_id));
|
||||
|
||||
let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params))
|
||||
.await
|
||||
.map_err(|e| format!("Request error: {}", e))?;
|
||||
|
||||
let body = get_response_body_text(response).await?;
|
||||
|
||||
serde_json::from_str::<GetAttachmentResponse>(&body)
|
||||
.map_err(|e| format!("Failed to parse attachment response: {}", e))
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn delete_attachment(server_id: String, id: String) -> Result<bool, String> {
|
||||
let response = HttpClient::delete(&server_id, &format!("/attachment/{}", id), None, None)
|
||||
.await
|
||||
.map_err(|e| format!("Request error: {}", e))?;
|
||||
|
||||
let body = get_response_body_text(response).await?;
|
||||
|
||||
let parsed: DeleteAttachmentResponse = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("Failed to parse delete response: {}", e))?;
|
||||
|
||||
parsed
|
||||
.result
|
||||
.eq("deleted")
|
||||
.then_some(true)
|
||||
.ok_or_else(|| "Delete operation was not successful".to_string())
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
use crate::common::auth::RequestAccessTokenResponse;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::common::server::ServerAccessToken;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::server::profile::get_user_profiles;
|
||||
use crate::server::search::CocoSearchSource;
|
||||
use crate::server::servers::{get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use crate::server::servers::{
|
||||
get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server,
|
||||
try_register_server_to_search_source,
|
||||
};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn request_access_token_url(request_id: &str) -> String {
|
||||
// Remove the endpoint part and keep just the path for the request
|
||||
format!("/auth/request_access_token?request_id={}", request_id)
|
||||
@@ -23,40 +22,16 @@ pub async fn handle_sso_callback<R: Runtime>(
|
||||
// Retrieve the server details using the server ID
|
||||
let server = get_server_by_id(&server_id);
|
||||
|
||||
let expire_in = 3600; // TODO, need to update to actual expire_in value
|
||||
if let Some(mut server) = server {
|
||||
// Prepare the URL for requesting the access token (endpoint is base URL, path is relative)
|
||||
// save_access_token(server_id.clone(), ServerAccessToken::new(server_id.clone(), code.clone(), 60 * 15));
|
||||
let path = request_access_token_url(&request_id);
|
||||
|
||||
// Send the request for the access token using the util::http::HttpClient::get method
|
||||
let mut header = HashMap::new();
|
||||
header.insert("Authorization".to_string(), format!("Bearer {}", code).to_string());
|
||||
let response = HttpClient::advanced_post(&server_id, &path, Some(header), None, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
|
||||
|
||||
if response.status() == StatusCode::OK {
|
||||
// Check if the response has a valid content length
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > 0 {
|
||||
// Deserialize the response body to get the access token
|
||||
let token_result: Result<RequestAccessTokenResponse, _> = response.json().await;
|
||||
|
||||
match token_result {
|
||||
Ok(token) => {
|
||||
// Save the access token for the server
|
||||
let access_token = ServerAccessToken::new(
|
||||
server_id.clone(),
|
||||
token.access_token.clone(),
|
||||
token.expire_in,
|
||||
);
|
||||
let access_token = ServerAccessToken::new(server_id.clone(), code.clone(), expire_in);
|
||||
// dbg!(&server_id, &request_id, &code, &token);
|
||||
save_access_token(server_id.clone(), access_token);
|
||||
persist_servers_token(&app_handle)?;
|
||||
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
let source = CocoSearchSource::new(server.clone(), Client::new());
|
||||
registry.register_source(source).await;
|
||||
// Register the server to the search source
|
||||
try_register_server_to_search_source(app_handle.clone(), &server).await;
|
||||
|
||||
// Update the server's profile using the util::http::HttpClient::get method
|
||||
let profile = get_user_profiles(app_handle.clone(), server_id.clone()).await;
|
||||
@@ -72,24 +47,6 @@ pub async fn handle_sso_callback<R: Runtime>(
|
||||
}
|
||||
Err(e) => Err(format!("Failed to get user profile: {}", e)),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("Failed to deserialize the token response: {}", e)),
|
||||
}
|
||||
} else {
|
||||
Err("Received empty response body.".to_string())
|
||||
}
|
||||
} else {
|
||||
Err("Could not determine the content length.".to_string())
|
||||
}
|
||||
} else {
|
||||
Err(format!(
|
||||
"Request failed with status: {}, URL: {}, Code: {}, Response: {:?}",
|
||||
response.status(),
|
||||
path,
|
||||
code,
|
||||
response
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Err(format!(
|
||||
"Server not found for ID: {}, Request ID: {}, Code: {}",
|
||||
|
||||
@@ -65,6 +65,7 @@ pub async fn refresh_all_connectors<R: Runtime>(app_handle: &AppHandle<R>) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_connectors_from_cache_or_remote(
|
||||
server_id: &str,
|
||||
) -> Result<Vec<Connector>, String> {
|
||||
@@ -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
|
||||
let datasource: Vec<Connector> = parse_search_results(resp).await.map_err(|e| {
|
||||
e.to_string()
|
||||
})?;
|
||||
let datasource: Vec<Connector> = parse_search_results(resp)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Save the connectors to the cache
|
||||
save_connectors_to_cache(&id, datasource.clone());
|
||||
|
||||
@@ -8,6 +8,13 @@ use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
pub struct GetDatasourcesByServerOptions {
|
||||
pub from: Option<u32>,
|
||||
pub size: Option<u32>,
|
||||
pub query: Option<String>,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String, HashMap<String, DataSource>>>> =
|
||||
Arc::new(RwLock::new(HashMap::new()));
|
||||
@@ -22,6 +29,7 @@ pub fn save_datasource_to_cache(server_id: &str, datasources: Vec<DataSource>) {
|
||||
cache.insert(server_id.to_string(), datasources_map);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, DataSource>> {
|
||||
let cache = DATASOURCE_CACHE.read().unwrap(); // Acquire read lock
|
||||
// dbg!("cache: {:?}", &cache);
|
||||
@@ -29,7 +37,7 @@ pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, Dat
|
||||
Some(server_cache.clone())
|
||||
}
|
||||
|
||||
pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
// dbg!("Attempting to refresh all datasources");
|
||||
|
||||
let servers = get_all_servers();
|
||||
@@ -40,8 +48,7 @@ pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> R
|
||||
// dbg!("fetch datasources for server: {}", &server.id);
|
||||
|
||||
// Attempt to get datasources by server, and continue even if it fails
|
||||
let connectors =
|
||||
match get_datasources_by_server(server.id.as_str()).await {
|
||||
let connectors = match datasource_search(server.id.as_str(), None).await {
|
||||
Ok(connectors) => {
|
||||
// Process connectors only after fetching them
|
||||
let connectors_map: HashMap<String, DataSource> = connectors
|
||||
@@ -79,23 +86,52 @@ pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> R
|
||||
cache.extend(server_map);
|
||||
cache.len()
|
||||
};
|
||||
// dbg!("datasource_map size: {:?}", cache_size);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_datasources_by_server(
|
||||
pub async fn datasource_search(
|
||||
id: &str,
|
||||
options: Option<GetDatasourcesByServerOptions>,
|
||||
) -> Result<Vec<DataSource>, String> {
|
||||
let from = options.as_ref().and_then(|opt| opt.from).unwrap_or(0);
|
||||
let size = options.as_ref().and_then(|opt| opt.size).unwrap_or(10000);
|
||||
let query = options
|
||||
.and_then(|opt| opt.query)
|
||||
.unwrap_or(String::default());
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"from": from,
|
||||
"size": size,
|
||||
});
|
||||
|
||||
if !query.is_empty() {
|
||||
body["query"] = serde_json::json!({
|
||||
"bool": {
|
||||
"must": [{
|
||||
"query_string": {
|
||||
"fields": ["combined_fulltext"],
|
||||
"query": query,
|
||||
"fuzziness": "AUTO",
|
||||
"fuzzy_prefix_length": 2,
|
||||
"fuzzy_max_expansions": 10,
|
||||
"fuzzy_transpositions": true,
|
||||
"allow_leading_wildcard": false
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Perform the async HTTP request outside the cache lock
|
||||
let resp = HttpClient::get(id, "/datasource/_search", None)
|
||||
let resp = HttpClient::post(
|
||||
id,
|
||||
"/datasource/_search",
|
||||
None,
|
||||
Some(reqwest::Body::from(body.to_string())),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// dbg!("Error fetching datasource: {}", &e);
|
||||
format!("Error fetching datasource: {}", e)
|
||||
})?;
|
||||
.map_err(|e| format!("Error fetching datasource: {}", e))?;
|
||||
|
||||
// Parse the search results from the response
|
||||
let datasources: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
|
||||
@@ -108,3 +144,59 @@ pub async fn get_datasources_by_server(
|
||||
|
||||
Ok(datasources)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mcp_server_search(
|
||||
id: &str,
|
||||
options: Option<GetDatasourcesByServerOptions>,
|
||||
) -> Result<Vec<DataSource>, String> {
|
||||
let from = options.as_ref().and_then(|opt| opt.from).unwrap_or(0);
|
||||
let size = options.as_ref().and_then(|opt| opt.size).unwrap_or(10000);
|
||||
let query = options
|
||||
.and_then(|opt| opt.query)
|
||||
.unwrap_or(String::default());
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"from": from,
|
||||
"size": size,
|
||||
});
|
||||
|
||||
if !query.is_empty() {
|
||||
body["query"] = serde_json::json!({
|
||||
"bool": {
|
||||
"must": [{
|
||||
"query_string": {
|
||||
"fields": ["combined_fulltext"],
|
||||
"query": query,
|
||||
"fuzziness": "AUTO",
|
||||
"fuzzy_prefix_length": 2,
|
||||
"fuzzy_max_expansions": 10,
|
||||
"fuzzy_transpositions": true,
|
||||
"allow_leading_wildcard": false
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Perform the async HTTP request outside the cache lock
|
||||
let resp = HttpClient::post(
|
||||
id,
|
||||
"/mcp_server/_search",
|
||||
None,
|
||||
Some(reqwest::Body::from(body.to_string())),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error fetching datasource: {}", e))?;
|
||||
|
||||
// Parse the search results from the response
|
||||
let mcp_server: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
|
||||
dbg!("Error parsing search results: {}", &e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
// Save the updated mcp_server to cache
|
||||
// save_datasource_to_cache(&id, mcp_server.clone());
|
||||
|
||||
Ok(mcp_server)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use crate::server::servers::{get_server_by_id, get_server_token};
|
||||
use http::HeaderName;
|
||||
use http::{HeaderName, HeaderValue};
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::{Client, Method, RequestBuilder};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tauri::ipc::RuntimeCapability;
|
||||
use tauri_plugin_store::JsonValue;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
|
||||
@@ -32,14 +31,17 @@ impl HttpClient {
|
||||
pub async fn send_raw_request(
|
||||
method: Method,
|
||||
url: &str,
|
||||
query_params: Option<HashMap<String, Value>>,
|
||||
query_params: Option<HashMap<String, JsonValue>>,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
let mut request_builder = Self::get_request_builder(method, url, headers, query_params, body).await;
|
||||
let request_builder =
|
||||
Self::get_request_builder(method, url, headers, query_params, body).await;
|
||||
|
||||
let response = request_builder.send().await
|
||||
.map_err(|e| format!("Failed to send request: {}", e))?;
|
||||
let response = request_builder.send().await.map_err(|e| {
|
||||
dbg!("Failed to send request: {}", &e);
|
||||
format!("Failed to send request: {}", e)
|
||||
})?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -47,7 +49,7 @@ impl HttpClient {
|
||||
method: Method,
|
||||
url: &str,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||
body: Option<reqwest::Body>,
|
||||
) -> RequestBuilder {
|
||||
let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT
|
||||
@@ -55,21 +57,51 @@ impl HttpClient {
|
||||
// Build the request
|
||||
let mut request_builder = client.request(method.clone(), url);
|
||||
|
||||
|
||||
if let Some(h) = headers {
|
||||
let mut req_headers = reqwest::header::HeaderMap::new();
|
||||
for (key, value) in h.into_iter() {
|
||||
let _ = req_headers.insert(
|
||||
HeaderName::from_bytes(key.as_bytes()).unwrap(),
|
||||
reqwest::header::HeaderValue::from_str(&value).unwrap(),
|
||||
match (
|
||||
HeaderName::from_bytes(key.as_bytes()),
|
||||
HeaderValue::from_str(value.trim()),
|
||||
) {
|
||||
(Ok(name), Ok(val)) => {
|
||||
req_headers.insert(name, val);
|
||||
}
|
||||
(Err(e), _) => {
|
||||
eprintln!("Invalid header name: {:?}, error: {}", key, e);
|
||||
}
|
||||
(_, Err(e)) => {
|
||||
eprintln!(
|
||||
"Invalid header value for {}: {:?}, error: {}",
|
||||
key, value, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
request_builder = request_builder.headers(req_headers);
|
||||
}
|
||||
|
||||
if let Some(query) = query_params {
|
||||
// Convert only supported value types into strings
|
||||
let query: HashMap<String, String> = query
|
||||
.into_iter()
|
||||
.filter_map(|(k, v)| {
|
||||
match v {
|
||||
JsonValue::String(s) => Some((k, s)),
|
||||
JsonValue::Number(n) => Some((k, n.to_string())),
|
||||
JsonValue::Bool(b) => Some((k, b.to_string())),
|
||||
_ => {
|
||||
dbg!(
|
||||
"Unsupported query parameter type. Only strings, numbers, and booleans are supported.",k,v,
|
||||
);
|
||||
None
|
||||
} // skip arrays, objects, nulls
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
request_builder = request_builder.query(&query);
|
||||
}
|
||||
|
||||
// Add body if present
|
||||
if let Some(b) = body {
|
||||
request_builder = request_builder.body(b);
|
||||
@@ -83,7 +115,7 @@ impl HttpClient {
|
||||
method: Method,
|
||||
path: &str,
|
||||
custom_headers: Option<HashMap<String, String>>,
|
||||
query_params: Option<HashMap<String, Value>>,
|
||||
query_params: Option<HashMap<String, JsonValue>>,
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
// Fetch the server using the server_id
|
||||
@@ -93,23 +125,21 @@ impl HttpClient {
|
||||
let url = HttpClient::join_url(&s.endpoint, path);
|
||||
|
||||
// Retrieve the token for the server (token is optional)
|
||||
let token = get_server_token(server_id).map(|t| t.access_token.clone());
|
||||
let token = get_server_token(server_id)
|
||||
.await?
|
||||
.map(|t| t.access_token.clone());
|
||||
|
||||
let mut headers = if let Some(custom_headers) = custom_headers {
|
||||
custom_headers
|
||||
} else {
|
||||
let mut headers = HashMap::new();
|
||||
let headers = HashMap::new();
|
||||
headers
|
||||
};
|
||||
|
||||
if let Some(t) = token {
|
||||
headers.insert(
|
||||
"X-API-TOKEN".to_string(),
|
||||
t,
|
||||
);
|
||||
headers.insert("X-API-TOKEN".to_string(), t);
|
||||
}
|
||||
|
||||
|
||||
// dbg!(&server_id);
|
||||
// dbg!(&url);
|
||||
// dbg!(&headers);
|
||||
@@ -121,7 +151,10 @@ impl HttpClient {
|
||||
}
|
||||
|
||||
// Convenience method for GET requests (as it's the most common)
|
||||
pub async fn get(server_id: &str, path: &str, query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
pub async fn get(
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(server_id, Method::GET, path, None, query_params, None).await
|
||||
}
|
||||
@@ -130,7 +163,7 @@ impl HttpClient {
|
||||
pub async fn post(
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(server_id, Method::POST, path, None, query_params, body).await
|
||||
@@ -140,27 +173,56 @@ impl HttpClient {
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
custom_headers: Option<HashMap<String, String>>,
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(server_id, Method::POST, path, custom_headers, query_params, body).await
|
||||
HttpClient::send_request(
|
||||
server_id,
|
||||
Method::POST,
|
||||
path,
|
||||
custom_headers,
|
||||
query_params,
|
||||
body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Convenience method for PUT requests
|
||||
#[allow(dead_code)]
|
||||
pub async fn put(
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
custom_headers: Option<HashMap<String, String>>,
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(server_id, Method::PUT, path, custom_headers, query_params, body).await
|
||||
HttpClient::send_request(
|
||||
server_id,
|
||||
Method::PUT,
|
||||
path,
|
||||
custom_headers,
|
||||
query_params,
|
||||
body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Convenience method for DELETE requests
|
||||
pub async fn delete(server_id: &str, path: &str, custom_headers: Option<HashMap<String, String>>,
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
#[allow(dead_code)]
|
||||
pub async fn delete(
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
custom_headers: Option<HashMap<String, String>>,
|
||||
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(server_id, Method::DELETE, path, custom_headers, query_params, None).await
|
||||
HttpClient::send_request(
|
||||
server_id,
|
||||
Method::DELETE,
|
||||
path,
|
||||
custom_headers,
|
||||
query_params,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
//! This file contains Rust APIs related to Coco Server management.
|
||||
|
||||
pub mod attachment;
|
||||
pub mod auth;
|
||||
pub mod servers;
|
||||
pub mod connector;
|
||||
pub mod datasource;
|
||||
pub mod http_client;
|
||||
pub mod profile;
|
||||
pub mod search;
|
||||
pub mod servers;
|
||||
pub mod system_settings;
|
||||
pub mod transcription;
|
||||
pub mod websocket;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::common::http::get_response_body_text;
|
||||
use crate::common::profile::UserProfile;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
@@ -12,15 +13,17 @@ pub async fn get_user_profiles<R: Runtime>(
|
||||
.await
|
||||
.map_err(|e| format!("Error fetching profile: {}", e))?;
|
||||
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > 0 {
|
||||
let profile: UserProfile = response
|
||||
.json()
|
||||
// Use get_response_body_text to extract the body content
|
||||
let response_body = get_response_body_text(response)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {}", e))?;
|
||||
|
||||
// Check if the response body is not empty before deserializing
|
||||
if !response_body.is_empty() {
|
||||
let profile: UserProfile = serde_json::from_str(&response_body)
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
return Ok(profile);
|
||||
}
|
||||
}
|
||||
|
||||
Err("Profile not found or empty response".to_string())
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::common::document::Document;
|
||||
use crate::common::search::{
|
||||
parse_search_response, QueryHits, QueryResponse, QuerySource, SearchQuery,
|
||||
};
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::http::get_response_body_text;
|
||||
use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery, SearchResponse};
|
||||
use crate::common::server::Server;
|
||||
use crate::common::traits::{SearchError, SearchSource};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::server::servers::get_server_token;
|
||||
use async_trait::async_trait;
|
||||
@@ -12,6 +12,8 @@ use ordered_float::OrderedFloat;
|
||||
use reqwest::{Client, Method, RequestBuilder};
|
||||
use std::collections::HashMap;
|
||||
// use std::hash::Hash;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct DocumentsSizedCollector {
|
||||
size: u64,
|
||||
/// Documents and scores
|
||||
@@ -20,6 +22,7 @@ pub(crate) struct DocumentsSizedCollector {
|
||||
docs: Vec<(String, Document, OrderedFloat<f64>)>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl DocumentsSizedCollector {
|
||||
pub(crate) fn new(size: u64) -> Self {
|
||||
// there will be size + 1 documents in docs at max
|
||||
@@ -79,28 +82,37 @@ impl CocoSearchSource {
|
||||
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)
|
||||
.await
|
||||
}
|
||||
|
||||
fn build_request(
|
||||
async fn build_request(
|
||||
&self,
|
||||
from: u64,
|
||||
size: u64,
|
||||
query_strings: &HashMap<String, String>,
|
||||
) -> RequestBuilder {
|
||||
) -> Result<RequestBuilder, String> {
|
||||
let url = HttpClient::join_url(&self.server.endpoint, "/query/_search");
|
||||
let mut request_builder = self.client.request(Method::GET, url);
|
||||
|
||||
if !self.server.public {
|
||||
if let Some(token) = get_server_token(&self.server.id).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
|
||||
let result = request_builder
|
||||
.query(&[("from", &from.to_string()), ("size", &size.to_string())])
|
||||
.query(query_strings)
|
||||
.query(query_strings);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,58 +126,42 @@ impl SearchSource for CocoSearchSource {
|
||||
}
|
||||
}
|
||||
|
||||
// Directly return Result<QueryResponse, SearchError> instead of Future
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
|
||||
let _server_id = self.server.id.clone();
|
||||
let _server_name = self.server.name.clone();
|
||||
let request_builder = self.build_request_from_query(&query);
|
||||
// Build the request from the provided query
|
||||
let request_builder = self
|
||||
.build_request_from_query(&query)
|
||||
.await
|
||||
.map_err(|e| SearchError::InternalError(e.to_string()))?;
|
||||
|
||||
// Send the HTTP request asynchronously
|
||||
let response = request_builder.send().await;
|
||||
// Send the HTTP request and handle errors
|
||||
let response = request_builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SearchError::HttpError(format!("Failed to send search request: {}", e)))?;
|
||||
|
||||
match response {
|
||||
Ok(response) => {
|
||||
let status_code = response.status().as_u16();
|
||||
// Use the helper function to parse the response body
|
||||
let response_body = get_response_body_text(response)
|
||||
.await
|
||||
.map_err(|e| SearchError::ParseError(format!("Failed to read response body: {}", e)))?;
|
||||
|
||||
if status_code >= 200 && status_code < 400 {
|
||||
// Parse the response only if the status code is successful
|
||||
match parse_search_response(response).await {
|
||||
Ok(response) => {
|
||||
let total_hits = response.hits.total.value as usize;
|
||||
let hits: Vec<(Document, f64)> = response
|
||||
// Parse the search response from the body text
|
||||
let parsed: SearchResponse<Document> = serde_json::from_str(&response_body)
|
||||
.map_err(|e| SearchError::ParseError(format!("Failed to parse search response: {}", e)))?;
|
||||
|
||||
// Process the parsed response
|
||||
let total_hits = parsed.hits.total.value as usize;
|
||||
let hits: Vec<(Document, f64)> = parsed
|
||||
.hits
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|hit| {
|
||||
// Handling Option<f64> in hit._score by defaulting to 0.0 if None
|
||||
(hit._source, hit._score.unwrap_or(0.0)) // Use 0.0 if _score is None
|
||||
})
|
||||
.map(|hit| (hit._source, hit._score.unwrap_or(0.0))) // Default _score to 0.0 if None
|
||||
.collect();
|
||||
|
||||
// Return the QueryResponse with hits and total hits
|
||||
// Return the final result
|
||||
Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits,
|
||||
total_hits,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
// Parse error when response parsing fails
|
||||
Err(SearchError::ParseError(err.to_string()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle unsuccessful HTTP status codes (e.g., 4xx, 5xx)
|
||||
Err(SearchError::HttpError(format!(
|
||||
"Request failed with status code: {}",
|
||||
status_code
|
||||
)))
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Handle error from the request itself
|
||||
Err(SearchError::HttpError(err.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use crate::common::http::get_response_body_text;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version};
|
||||
use crate::server::connector::fetch_connectors_by_server;
|
||||
use crate::server::datasource::get_datasources_by_server;
|
||||
use crate::server::datasource::datasource_search;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::server::search::CocoSearchSource;
|
||||
use crate::COCO_TAURI_STORE;
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::{Client, Method, StatusCode};
|
||||
use reqwest::{Client, Method};
|
||||
use serde_json::from_value;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
@@ -24,6 +25,7 @@ lazy_static! {
|
||||
Arc::new(RwLock::new(HashMap::new()));
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn check_server_exists(id: &str) -> bool {
|
||||
let cache = SERVER_CACHE.read().unwrap(); // Acquire read lock
|
||||
cache.contains_key(id)
|
||||
@@ -35,9 +37,10 @@ pub fn get_server_by_id(id: &str) -> Option<Server> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_server_token(id: &str) -> Option<ServerAccessToken> {
|
||||
let cache = SERVER_TOKEN.read().unwrap(); // Acquire read lock
|
||||
cache.get(id).cloned()
|
||||
pub async fn get_server_token(id: &str) -> Result<Option<ServerAccessToken>, String> {
|
||||
let cache = SERVER_TOKEN.read().map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(cache.get(id).cloned())
|
||||
}
|
||||
|
||||
pub fn save_access_token(server_id: String, token: ServerAccessToken) -> bool {
|
||||
@@ -132,6 +135,7 @@ fn get_default_server() -> Server {
|
||||
version: Version {
|
||||
number: "1.0.0_SNAPSHOT".to_string(),
|
||||
},
|
||||
minimal_client_version: None,
|
||||
updated: "2025-01-24T12:12:17.326286927+08:00".to_string(),
|
||||
public: false,
|
||||
available: true,
|
||||
@@ -259,7 +263,6 @@ pub async fn load_or_insert_default_server<R: Runtime>(
|
||||
pub async fn list_coco_servers<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<Server>, String> {
|
||||
|
||||
//hard fresh all server's info, in order to get the actual health
|
||||
refresh_all_coco_server_info(_app_handle.clone()).await;
|
||||
|
||||
@@ -267,6 +270,7 @@ pub async fn list_coco_servers<R: Runtime>(
|
||||
Ok(servers)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_servers_as_hashmap() -> HashMap<String, Server> {
|
||||
let cache = SERVER_CACHE.read().unwrap();
|
||||
cache.clone()
|
||||
@@ -282,9 +286,7 @@ pub const COCO_SERVERS: &str = "coco_servers";
|
||||
|
||||
const COCO_SERVER_TOKENS: &str = "coco_server_tokens";
|
||||
|
||||
pub async fn refresh_all_coco_server_info<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
) {
|
||||
pub async fn refresh_all_coco_server_info<R: Runtime>(app_handle: AppHandle<R>) {
|
||||
let servers = get_all_servers();
|
||||
for server in servers {
|
||||
let _ = refresh_coco_server_info(app_handle.clone(), server.id.clone()).await;
|
||||
@@ -297,62 +299,57 @@ pub async fn refresh_coco_server_info<R: Runtime>(
|
||||
id: String,
|
||||
) -> Result<Server, String> {
|
||||
// Retrieve the server from the cache
|
||||
let server = {
|
||||
let cached_server = {
|
||||
let cache = SERVER_CACHE.read().unwrap();
|
||||
cache.get(&id).cloned()
|
||||
};
|
||||
|
||||
if let Some(server) = server {
|
||||
let server = match cached_server {
|
||||
Some(server) => server,
|
||||
None => return Err("Server not found.".into()),
|
||||
};
|
||||
|
||||
// Preserve important local state
|
||||
let is_enabled = server.enabled;
|
||||
let is_builtin = server.builtin;
|
||||
let profile = server.profile;
|
||||
|
||||
// Use the HttpClient to send the request
|
||||
let response = HttpClient::get(&id, "/provider/_info", None) // Assuming "/provider-info" is the endpoint
|
||||
// Send request to fetch updated server info
|
||||
let response = HttpClient::get(&id, "/provider/_info", None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
|
||||
.map_err(|e| format!("Failed to contact the server: {}", e))?;
|
||||
|
||||
if response.status() == StatusCode::OK {
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > 0 {
|
||||
let new_coco_server: Result<Server, _> = response.json().await;
|
||||
match new_coco_server {
|
||||
Ok(mut server) => {
|
||||
server.id = id.clone();
|
||||
server.builtin = is_builtin;
|
||||
server.enabled = is_enabled;
|
||||
server.available = true;
|
||||
server.profile = profile;
|
||||
trim_endpoint_last_forward_slash(&mut server);
|
||||
save_server(&server);
|
||||
if !response.status().is_success() {
|
||||
mark_server_as_offline(&id).await;
|
||||
return Err(format!("Request failed with status: {}", response.status()));
|
||||
}
|
||||
|
||||
// Get body text via helper
|
||||
let body = get_response_body_text(response).await?;
|
||||
|
||||
// Deserialize server
|
||||
let mut updated_server: Server = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("Failed to deserialize the response: {}", e))?;
|
||||
|
||||
// Restore local state
|
||||
updated_server.id = id.clone();
|
||||
updated_server.builtin = is_builtin;
|
||||
updated_server.enabled = is_enabled;
|
||||
updated_server.available = true;
|
||||
updated_server.profile = profile;
|
||||
trim_endpoint_last_forward_slash(&mut updated_server);
|
||||
|
||||
// Save and persist
|
||||
save_server(&updated_server);
|
||||
persist_servers(&app_handle)
|
||||
.await
|
||||
.expect("Failed to persist coco servers.");
|
||||
.map_err(|e| format!("Failed to persist servers: {}", e))?;
|
||||
|
||||
//refresh connectors and datasources
|
||||
// Refresh connectors and datasources (best effort)
|
||||
let _ = fetch_connectors_by_server(&id).await;
|
||||
let _ = datasource_search(&id, None).await;
|
||||
|
||||
let _ = get_datasources_by_server(&id).await;
|
||||
|
||||
|
||||
Ok(server)
|
||||
}
|
||||
Err(e) => Err(format!("Failed to deserialize the response: {:?}", e)),
|
||||
}
|
||||
} else {
|
||||
Err("Received empty response body.".to_string())
|
||||
}
|
||||
} else {
|
||||
mark_server_as_offline(id.as_str()).await;
|
||||
Err("Could not determine the content length.".to_string())
|
||||
}
|
||||
} else {
|
||||
mark_server_as_offline(id.as_str()).await;
|
||||
Err(format!("Request failed with status: {}", response.status()))
|
||||
}
|
||||
} else {
|
||||
Err("Server not found.".to_string())
|
||||
}
|
||||
Ok(updated_server)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -362,12 +359,10 @@ pub async fn add_coco_server<R: Runtime>(
|
||||
) -> Result<Server, String> {
|
||||
load_or_insert_default_server(&app_handle)
|
||||
.await
|
||||
.expect("Failed to load default servers");
|
||||
.map_err(|e| format!("Failed to load default servers: {}", e))?;
|
||||
|
||||
// Remove the trailing '/' from the endpoint to ensure correct URL construction
|
||||
let endpoint = endpoint.trim_end_matches('/');
|
||||
|
||||
// Check if the server with this endpoint already exists
|
||||
if check_endpoint_exists(endpoint) {
|
||||
dbg!(format!(
|
||||
"This Coco server has already been registered: {:?}",
|
||||
@@ -376,24 +371,18 @@ pub async fn add_coco_server<R: Runtime>(
|
||||
return Err("This Coco server has already been registered.".into());
|
||||
}
|
||||
|
||||
let url = provider_info_url(&endpoint);
|
||||
|
||||
// Use the HttpClient to fetch provider information
|
||||
let url = provider_info_url(endpoint);
|
||||
let response = HttpClient::send_raw_request(Method::GET, url.as_str(), None, None, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
|
||||
|
||||
dbg!(format!("Get provider info response: {:?}", &response));
|
||||
|
||||
// Check if the response status is OK (200)
|
||||
if response.status() == StatusCode::OK {
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > 0 {
|
||||
let new_coco_server: Result<Server, _> = response.json().await;
|
||||
let body = get_response_body_text(response).await?;
|
||||
|
||||
let mut server: Server = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("Failed to deserialize the response: {}", e))?;
|
||||
|
||||
match new_coco_server {
|
||||
Ok(mut server) => {
|
||||
// Perform necessary checks and adjustments on the server data
|
||||
trim_endpoint_last_forward_slash(&mut server);
|
||||
|
||||
if server.id.is_empty() {
|
||||
@@ -401,36 +390,19 @@ pub async fn add_coco_server<R: Runtime>(
|
||||
}
|
||||
|
||||
if server.name.is_empty() {
|
||||
server.name = "Coco Cloud".to_string();
|
||||
server.name = "Coco Server".to_string();
|
||||
}
|
||||
|
||||
// Save the new server to the cache
|
||||
save_server(&server);
|
||||
try_register_server_to_search_source(app_handle.clone(), &server).await;
|
||||
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
let source = CocoSearchSource::new(server.clone(), Client::new());
|
||||
registry.register_source(source).await;
|
||||
|
||||
// Persist the servers to the store
|
||||
persist_servers(&app_handle)
|
||||
.await
|
||||
.expect("Failed to persist Coco servers.");
|
||||
.map_err(|e| format!("Failed to persist Coco servers: {}", e))?;
|
||||
|
||||
dbg!(format!("Successfully registered server: {:?}", &endpoint));
|
||||
Ok(server)
|
||||
}
|
||||
Err(e) => Err(format!("Failed to deserialize the response: {}", e)),
|
||||
}
|
||||
} else {
|
||||
Err("Received empty response body.".to_string())
|
||||
}
|
||||
} else {
|
||||
Err("Could not determine the content length.".to_string())
|
||||
}
|
||||
} else {
|
||||
Err(format!("Request failed with status: {}", response.status()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_coco_server<R: Runtime>(
|
||||
@@ -459,9 +431,8 @@ pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
|
||||
server.enabled = true;
|
||||
save_server(&server);
|
||||
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
let source = CocoSearchSource::new(server.clone(), Client::new());
|
||||
registry.register_source(source).await;
|
||||
// Register the server to the search source
|
||||
try_register_server_to_search_source(app_handle.clone(), &server).await;
|
||||
|
||||
persist_servers(&app_handle)
|
||||
.await
|
||||
@@ -470,6 +441,16 @@ pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn try_register_server_to_search_source(
|
||||
app_handle: AppHandle<impl Runtime>,
|
||||
server: &Server,
|
||||
) {
|
||||
if server.enabled {
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
let source = CocoSearchSource::new(server.clone(), Client::new());
|
||||
registry.register_source(source).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mark_server_as_offline(id: &str) {
|
||||
// println!("server_is_offline: {}", id);
|
||||
@@ -508,7 +489,7 @@ pub async fn logout_coco_server<R: Runtime>(
|
||||
dbg!("Attempting to log out server by id:", &id);
|
||||
|
||||
// Check if server token exists
|
||||
if let Some(_token) = get_server_token(id.as_str()) {
|
||||
if let Some(_token) = get_server_token(id.as_str()).await? {
|
||||
dbg!("Found server token for id:", &id);
|
||||
|
||||
// Remove the server token from cache
|
||||
@@ -584,6 +565,7 @@ fn test_trim_endpoint_last_forward_slash() {
|
||||
version: Version {
|
||||
number: "".to_string(),
|
||||
},
|
||||
minimal_client_version: None,
|
||||
updated: "".to_string(),
|
||||
public: false,
|
||||
available: false,
|
||||
|
||||
15
src-tauri/src/server/system_settings.rs
Normal file
15
src-tauri/src/server/system_settings.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use crate::server::http_client::HttpClient;
|
||||
use serde_json::Value;
|
||||
use tauri::command;
|
||||
|
||||
#[command]
|
||||
pub async fn get_system_settings(server_id: String) -> Result<Value, String> {
|
||||
let response = HttpClient::get(&server_id, "/settings", None)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
43
src-tauri/src/server/transcription.rs
Normal file
43
src-tauri/src/server/transcription.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use crate::common::http::get_response_body_text;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
use tauri::command;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TranscriptionResponse {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn transcription(
|
||||
server_id: String,
|
||||
audio_type: String,
|
||||
audio_content: String,
|
||||
) -> Result<TranscriptionResponse, String> {
|
||||
let mut query_params = HashMap::new();
|
||||
query_params.insert("type".to_string(), JsonValue::String(audio_type));
|
||||
query_params.insert("content".to_string(), JsonValue::String(audio_content));
|
||||
|
||||
// Send the HTTP POST request
|
||||
let response = HttpClient::post(
|
||||
&server_id,
|
||||
"/services/audio/transcription",
|
||||
Some(query_params),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error sending transcription request: {}", e))?;
|
||||
|
||||
// Use get_response_body_text to extract the response body as text
|
||||
let response_body = get_response_body_text(response)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {}", e))?;
|
||||
|
||||
// Deserialize the response body into TranscriptionResponse
|
||||
let transcription_response: TranscriptionResponse = serde_json::from_str(&response_body)
|
||||
.map_err(|e| format!("Failed to parse transcription response: {}", e))?;
|
||||
|
||||
Ok(transcription_response)
|
||||
}
|
||||
@@ -1,182 +1,130 @@
|
||||
use crate::server::servers::{get_server_by_id, get_server_token};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use futures::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Error;
|
||||
use tokio_tungstenite::tungstenite::Error as WsError;
|
||||
use tokio_tungstenite::{
|
||||
connect_async, tungstenite::protocol::Message, MaybeTlsStream, WebSocketStream,
|
||||
};
|
||||
use tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WebSocketManager {
|
||||
ws_connection: Arc<Mutex<Option<WebSocketStream<MaybeTlsStream<TcpStream>>>>>,
|
||||
cancel_tx: Arc<Mutex<Option<mpsc::Sender<()>>>>,
|
||||
connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>,
|
||||
}
|
||||
|
||||
struct WebSocketInstance {
|
||||
ws_connection: Mutex<WebSocketStream<MaybeTlsStream<TcpStream>>>, // No need to lock the entire map
|
||||
cancel_tx: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
// Function to convert the HTTP endpoint to WebSocket endpoint
|
||||
fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
|
||||
let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
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 {
|
||||
format!("{}{}{}", ws_protocol, host, "/ws")
|
||||
} else {
|
||||
format!("{}{}:{}/ws", ws_protocol, host, port)
|
||||
};
|
||||
|
||||
Ok(ws_endpoint)
|
||||
}
|
||||
|
||||
// Function to build a HeaderMap from a vector of key-value pairs
|
||||
fn build_header_map(headers: Vec<(String, String)>) -> Result<HeaderMap, String> {
|
||||
let mut header_map = HeaderMap::new();
|
||||
for (key, value) in headers {
|
||||
let header_name = HeaderName::from_bytes(key.as_bytes())
|
||||
.map_err(|e| format!("Invalid header name: {}", e))?;
|
||||
let header_value =
|
||||
HeaderValue::from_str(&value).map_err(|e| format!("Invalid header value: {}", e))?;
|
||||
header_map.insert(header_name, header_value);
|
||||
}
|
||||
Ok(header_map)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect_to_server(
|
||||
id: String,
|
||||
client_id: String,
|
||||
state: tauri::State<'_, WebSocketManager>,
|
||||
app_handle: tauri::AppHandle,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
// Disconnect any existing connection first
|
||||
disconnect(state.clone()).await?;
|
||||
let connections_clone = state.connections.clone();
|
||||
|
||||
// Retrieve server details
|
||||
let server =
|
||||
get_server_by_id(id.as_str()).ok_or_else(|| format!("Server with ID {} not found", id))?;
|
||||
let endpoint = convert_to_websocket(server.endpoint.as_str())?;
|
||||
// Disconnect old connection first
|
||||
disconnect(client_id.clone(), state.clone()).await.ok();
|
||||
|
||||
// Retrieve the token for the server (token is optional)
|
||||
let token = get_server_token(id.as_str()).map(|t| t.access_token.clone());
|
||||
let server = get_server_by_id(&id).ok_or(format!("Server with ID {} not found", id))?;
|
||||
let endpoint = convert_to_websocket(&server.endpoint)?;
|
||||
let token = get_server_token(&id).await?.map(|t| t.access_token.clone());
|
||||
|
||||
// Create the WebSocket request
|
||||
let mut request =
|
||||
tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint)
|
||||
.map_err(|e| format!("Failed to create WebSocket request: {}", e))?;
|
||||
|
||||
// Add necessary headers
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Connection", "Upgrade".parse().unwrap());
|
||||
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());
|
||||
request.headers_mut().insert("Connection", "Upgrade".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 {
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("X-API-TOKEN", token.parse().unwrap());
|
||||
request.headers_mut().insert("X-API-TOKEN", token.parse().unwrap());
|
||||
}
|
||||
|
||||
// Establish the WebSocket connection
|
||||
// dbg!(&request);
|
||||
let (mut ws_remote, _) = connect_async(request).await.map_err(|e| match e {
|
||||
Error::ConnectionClosed => "WebSocket connection was closed".to_string(),
|
||||
Error::Protocol(protocol_error) => format!("Protocol error: {}", protocol_error),
|
||||
Error::Utf8 => "UTF-8 error in WebSocket data".to_string(),
|
||||
_ => format!("Unknown error: {:?}", e),
|
||||
})?;
|
||||
|
||||
// Create cancellation channel
|
||||
let (ws_stream, _) = connect_async(request).await.map_err(|e| format!("WebSocket error: {:?}", e))?;
|
||||
let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
|
||||
|
||||
// Store connection and cancellation sender
|
||||
*state.ws_connection.lock().await = Some(ws_remote);
|
||||
*state.cancel_tx.lock().await = Some(cancel_tx);
|
||||
// Spawn listener task with cancellation
|
||||
let instance = Arc::new(WebSocketInstance {
|
||||
ws_connection: Mutex::new(ws_stream),
|
||||
cancel_tx,
|
||||
});
|
||||
|
||||
// Insert connection into the map (lock is held briefly)
|
||||
{
|
||||
let mut connections = connections_clone.lock().await;
|
||||
connections.insert(client_id.clone(), instance.clone());
|
||||
}
|
||||
|
||||
// Spawn WebSocket handler in a separate task
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let connection_clone = state.ws_connection.clone();
|
||||
let client_id_clone = client_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut connection = connection_clone.lock().await;
|
||||
if let Some(ws) = connection.as_mut() {
|
||||
let ws = &mut *instance.ws_connection.lock().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = ws.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
//println!("Received message: {}", text);
|
||||
let _ = app_handle_clone.emit("ws-message", text);
|
||||
let _ = app_handle_clone.emit(&format!("ws-message-{}", client_id_clone), text);
|
||||
},
|
||||
Some(Err(WsError::ConnectionClosed)) => {
|
||||
let _ = app_handle_clone.emit("ws-error", id);
|
||||
eprintln!("WebSocket connection closed by the server.");
|
||||
Some(Err(_)) | None => {
|
||||
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
|
||||
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);
|
||||
dbg!("Cancelling WebSocket connection");
|
||||
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove connection after it closes
|
||||
let mut connections = connections_clone.lock().await;
|
||||
connections.remove(&client_id_clone);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(state: tauri::State<'_, WebSocketManager>) -> Result<(), String> {
|
||||
// Send cancellation signal
|
||||
if let Some(cancel_tx) = state.cancel_tx.lock().await.take() {
|
||||
let _ = cancel_tx.send(()).await;
|
||||
}
|
||||
|
||||
// Close connection
|
||||
let mut connection = state.ws_connection.lock().await;
|
||||
if let Some(mut ws) = connection.take() {
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(client_id: String, state: tauri::State<'_, WebSocketManager>) -> Result<(), String> {
|
||||
let instance = {
|
||||
let mut connections = state.connections.lock().await;
|
||||
connections.remove(&client_id)
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
||||
use tauri::{ActivationPolicy, App, Emitter, EventTarget, WebviewWindow};
|
||||
use tauri_nspanel::{
|
||||
cocoa::appkit::{NSMainMenuWindowLevel, NSWindowCollectionBehavior},
|
||||
panel_delegate, WebviewWindowExt,
|
||||
};
|
||||
use tauri_nspanel::{cocoa::appkit::NSWindowCollectionBehavior, panel_delegate, WebviewWindowExt};
|
||||
|
||||
use crate::common::MAIN_WINDOW_LABEL;
|
||||
|
||||
@@ -22,7 +19,7 @@ pub fn platform(app: &mut App, main_window: WebviewWindow, _settings_window: Web
|
||||
let panel = main_window.to_panel().unwrap();
|
||||
|
||||
// Make the window above the dock
|
||||
panel.set_level(NSMainMenuWindowLevel + 1);
|
||||
panel.set_level(20);
|
||||
|
||||
// Do not steal focus from other windows
|
||||
panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel);
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
use crate::{move_window_to_active_monitor, COCO_TAURI_STORE};
|
||||
use tauri::App;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
use tauri::Runtime;
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use tauri_plugin_global_shortcut::Shortcut;
|
||||
use tauri_plugin_global_shortcut::ShortcutState;
|
||||
use tauri_plugin_store::JsonValue;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use crate::{hide_coco, show_coco, COCO_TAURI_STORE};
|
||||
use tauri::{async_runtime, App, AppHandle, Manager, Runtime};
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
use tauri_plugin_store::{JsonValue, StoreExt};
|
||||
|
||||
/// Tauri's store is a key-value database, we use it to store our registered
|
||||
/// global shortcut.
|
||||
@@ -54,14 +48,14 @@ pub fn enable_shortcut(app: &App) {
|
||||
/// Get the stored shortcut as a string, same as [`_get_shortcut()`], except that
|
||||
/// this is a `tauri::command` interface.
|
||||
#[tauri::command]
|
||||
pub fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
|
||||
pub async fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
|
||||
let shortcut = _get_shortcut(&app);
|
||||
Ok(shortcut)
|
||||
}
|
||||
|
||||
/// Get the current shortcut and unregister it on the tauri side.
|
||||
#[tauri::command]
|
||||
pub fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
|
||||
pub async fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
|
||||
let shortcut_str = _get_shortcut(&app);
|
||||
let shortcut = shortcut_str
|
||||
.parse::<Shortcut>()
|
||||
@@ -74,7 +68,7 @@ pub fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
|
||||
|
||||
/// Change the global shortcut to `key`.
|
||||
#[tauri::command]
|
||||
pub fn change_shortcut<R: Runtime>(
|
||||
pub async fn change_shortcut<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
_window: tauri::Window<R>,
|
||||
key: String,
|
||||
@@ -105,16 +99,15 @@ fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
|
||||
dbg!("shortcut pressed");
|
||||
let main_window = app.get_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
if let ShortcutState::Pressed = event.state() {
|
||||
let app_handle = app.clone();
|
||||
if main_window.is_visible().unwrap() {
|
||||
dbg!("hiding window");
|
||||
main_window.hide().unwrap();
|
||||
async_runtime::spawn(async move {
|
||||
hide_coco(app_handle).await;
|
||||
});
|
||||
} else {
|
||||
dbg!("showing window");
|
||||
move_window_to_active_monitor(&main_window);
|
||||
main_window.set_visible_on_all_workspaces(true).unwrap();
|
||||
main_window.set_always_on_top(true).unwrap();
|
||||
main_window.set_focus().unwrap();
|
||||
main_window.show().unwrap();
|
||||
async_runtime::spawn(async move {
|
||||
show_coco(app_handle).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,15 +128,16 @@ fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
|
||||
if scut == &shortcut {
|
||||
let window = app.get_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
if let ShortcutState::Pressed = event.state() {
|
||||
let app_handle = app.clone();
|
||||
|
||||
if window.is_visible().unwrap() {
|
||||
window.hide().unwrap();
|
||||
async_runtime::spawn(async move {
|
||||
hide_coco(app_handle).await;
|
||||
});
|
||||
} else {
|
||||
// dbg!("showing window");
|
||||
move_window_to_active_monitor(&window);
|
||||
window.set_visible_on_all_workspaces(true).unwrap();
|
||||
window.set_always_on_top(true).unwrap();
|
||||
window.set_focus().unwrap();
|
||||
window.show().unwrap();
|
||||
async_runtime::spawn(async move {
|
||||
show_coco(app_handle).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
use std::{path::Path, process::Command};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
enum LinuxDesktopEnvironment {
|
||||
Gnome,
|
||||
Kde,
|
||||
}
|
||||
|
||||
impl LinuxDesktopEnvironment {
|
||||
// This impl is based on: https://wiki.archlinux.org/title/Desktop_entries#Usage
|
||||
fn launch_app_via_desktop_file<P: AsRef<Path>>(&self, file: P) -> Result<(), String> {
|
||||
let path = file.as_ref();
|
||||
if !path.try_exists().map_err(|e| e.to_string())? {
|
||||
return Err(format!("desktop file [{}] does not exist", path.display()));
|
||||
}
|
||||
|
||||
let cmd_output = match self {
|
||||
Self::Gnome => {
|
||||
let uri = path
|
||||
.file_stem()
|
||||
.expect("the desktop file should contain a file stem part");
|
||||
|
||||
Command::new("gtk-launch")
|
||||
.arg(uri)
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
Self::Kde => Command::new("kde-open")
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?,
|
||||
};
|
||||
|
||||
if !cmd_output.status.success() {
|
||||
return Err(format!(
|
||||
"failed to launch app via desktop file [{}], underlying command stderr [{}]",
|
||||
path.display(),
|
||||
String::from_utf8_lossy(&cmd_output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
|
||||
let de_os_str = std::env::var_os("XDG_CURRENT_DESKTOP")?;
|
||||
let de_str = de_os_str
|
||||
.into_string()
|
||||
.expect("$XDG_CURRENT_DESKTOP should be UTF-8 encoded");
|
||||
|
||||
let de = match de_str.as_str() {
|
||||
"GNOME" => LinuxDesktopEnvironment::Gnome,
|
||||
"KDE" => LinuxDesktopEnvironment::Kde,
|
||||
|
||||
unsupported_de => unimplemented!(
|
||||
"This desktop environment [{}] has not been supported yet",
|
||||
unsupported_de
|
||||
),
|
||||
};
|
||||
|
||||
Some(de)
|
||||
}
|
||||
|
||||
/// Homemade open() function to support open Linux applications via the `.desktop` file.
|
||||
//
|
||||
// tauri_plugin_shell::open() is deprecated, but we still use it.
|
||||
#[allow(deprecated)]
|
||||
#[tauri::command]
|
||||
pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<(), String> {
|
||||
if cfg!(target_os = "linux") {
|
||||
let borrowed_path = Path::new(&path);
|
||||
if let Some(file_extension) = borrowed_path.extension() {
|
||||
if file_extension == "desktop" {
|
||||
let desktop_environment = get_linux_desktop_environment().expect("The Linux OS is running without a desktop, Coco could never run in such a environment");
|
||||
return desktop_environment.launch_app_via_desktop_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app_handle
|
||||
.shell()
|
||||
.open(path, None)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@
|
||||
"visible": false,
|
||||
"windowEffects": {
|
||||
"effects": [],
|
||||
"radius": 12
|
||||
}
|
||||
"radius": 6
|
||||
},
|
||||
"visibleOnAllWorkspaces": true,
|
||||
"alwaysOnTop": true
|
||||
},
|
||||
{
|
||||
"label": "settings",
|
||||
@@ -113,7 +115,7 @@
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlDRjNDRUU0NTdBMzdCRTMKUldUamU2Tlg1TTd6bkUwZWM0d2Zjdk0wdXJmendWVlpMMmhKN25EcmprYmIydnJ3dmFUME9QYXkK",
|
||||
"endpoints": [
|
||||
"https://api.coco.rs/update/{{target}}/{{arch}}/{{current_version}}"
|
||||
"https://release.infinilabs.com/coco/app/.latest.json?target={{target}}&arch={{arch}}¤t_version={{current_version}}"
|
||||
]
|
||||
},
|
||||
"websocket": {},
|
||||
|
||||
123
src/api/axiosRequest.ts
Normal file
123
src/api/axiosRequest.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
import {
|
||||
handleChangeRequestHeader,
|
||||
handleConfigureAuth,
|
||||
// handleAuthError,
|
||||
// handleGeneralError,
|
||||
handleNetworkError,
|
||||
} from "./tools";
|
||||
|
||||
type Fn = (data: FcResponse<any>) => unknown;
|
||||
|
||||
interface IAnyObj {
|
||||
[index: string]: unknown;
|
||||
}
|
||||
|
||||
interface FcResponse<T> {
|
||||
errno: string;
|
||||
errmsg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use((config) => {
|
||||
config = handleChangeRequestHeader(config);
|
||||
config = handleConfigureAuth(config);
|
||||
// console.log("config", config);
|
||||
return config;
|
||||
});
|
||||
|
||||
axios.interceptors.response.use(
|
||||
(response) => {
|
||||
if (response.status !== 200) return Promise.reject(response.data);
|
||||
// handleAuthError(response.data.errno);
|
||||
// handleGeneralError(response.data.errno, response.data.errmsg);
|
||||
return response;
|
||||
},
|
||||
(err) => {
|
||||
handleNetworkError(err?.response?.status);
|
||||
return Promise.reject(err?.response);
|
||||
}
|
||||
);
|
||||
|
||||
export const handleApiError = (error: any) => {
|
||||
const addError = useAppStore.getState().addError;
|
||||
|
||||
let message = "Request failed";
|
||||
|
||||
if (error.response) {
|
||||
// Server error response
|
||||
message =
|
||||
error.response.data?.message || `Error (${error.response.status})`;
|
||||
} else if (error.request) {
|
||||
// Request failed to send
|
||||
message = "Network connection failed";
|
||||
} else {
|
||||
// Other errors
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
addError(message, "error");
|
||||
return error;
|
||||
};
|
||||
|
||||
export const Get = <T>(
|
||||
url: string,
|
||||
params: IAnyObj = {},
|
||||
clearFn?: Fn
|
||||
): Promise<[any, FcResponse<T> | undefined]> =>
|
||||
new Promise((resolve) => {
|
||||
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
|
||||
|
||||
let baseURL = appStore.state?.endpoint_http;
|
||||
if (!baseURL || baseURL === "undefined") {
|
||||
baseURL = "";
|
||||
}
|
||||
|
||||
axios
|
||||
.get(baseURL + url, { params })
|
||||
.then((result) => {
|
||||
let res: FcResponse<T>;
|
||||
if (clearFn !== undefined) {
|
||||
res = clearFn(result?.data) as unknown as FcResponse<T>;
|
||||
} else {
|
||||
res = result?.data as FcResponse<T>;
|
||||
}
|
||||
resolve([null, res as FcResponse<T>]);
|
||||
})
|
||||
.catch((err) => {
|
||||
handleApiError(err);
|
||||
resolve([err, undefined]);
|
||||
});
|
||||
});
|
||||
|
||||
export const Post = <T>(
|
||||
url: string,
|
||||
data: IAnyObj,
|
||||
params: IAnyObj = {},
|
||||
headers: IAnyObj = {}
|
||||
): Promise<[any, FcResponse<T> | undefined]> => {
|
||||
return new Promise((resolve) => {
|
||||
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
|
||||
|
||||
let baseURL = appStore.state?.endpoint_http
|
||||
if (!baseURL || baseURL === "undefined") {
|
||||
baseURL = "";
|
||||
}
|
||||
|
||||
axios
|
||||
.post(baseURL + url, data, {
|
||||
params,
|
||||
headers,
|
||||
} as any)
|
||||
.then((result) => {
|
||||
resolve([null, result.data as FcResponse<T>]);
|
||||
})
|
||||
.catch((err) => {
|
||||
handleApiError(err);
|
||||
resolve([err, undefined]);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,9 +1,8 @@
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { clientEnv } from "@/utils/env";
|
||||
import { useLogStore } from "@/stores/logStore";
|
||||
|
||||
import { get_server_token } from "@/commands";
|
||||
interface FetchRequestConfig {
|
||||
url: string;
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
@@ -63,7 +62,7 @@ export const tauriFetch = async <T = any>({
|
||||
}
|
||||
|
||||
const server_id = connectStore.state?.currentService?.id || "default_coco_server"
|
||||
const res: any = await invoke("get_server_token", {id: server_id});
|
||||
const res: any = await get_server_token(server_id);
|
||||
|
||||
headers["X-API-TOKEN"] = headers["X-API-TOKEN"] || res?.access_token || undefined;
|
||||
|
||||
|
||||
73
src/api/tools.ts
Normal file
73
src/api/tools.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export const handleChangeRequestHeader = (config: any) => {
|
||||
config["xxxx"] = "xxx";
|
||||
return config;
|
||||
};
|
||||
|
||||
export const handleConfigureAuth = (config: any) => {
|
||||
// config.headers["X-API-TOKEN"] = localStorage.getItem("token") || "";
|
||||
|
||||
const headersStr = localStorage.getItem("headers") || "{}";
|
||||
const headers = JSON.parse(headersStr);
|
||||
// console.log("headers:", headers);
|
||||
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
...headers,
|
||||
}
|
||||
// console.log("config.headers", config.headers)
|
||||
return config;
|
||||
};
|
||||
|
||||
export const handleNetworkError = (errStatus?: number): void => {
|
||||
const networkErrMap: any = {
|
||||
"400": "Bad Request", // token invalid
|
||||
"401": "Unauthorized, please login again",
|
||||
"403": "Access Denied",
|
||||
"404": "Resource Not Found",
|
||||
"405": "Method Not Allowed",
|
||||
"408": "Request Timeout",
|
||||
"500": "Internal Server Error",
|
||||
"501": "Not Implemented",
|
||||
"502": "Bad Gateway",
|
||||
"503": "Service Unavailable",
|
||||
"504": "Gateway Timeout",
|
||||
"505": "HTTP Version Not Supported",
|
||||
};
|
||||
if (errStatus) {
|
||||
console.error(networkErrMap[errStatus] ?? `Other Connection Error --${errStatus}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Unable to connect to server!");
|
||||
};
|
||||
|
||||
export const handleAuthError = (errno: string): boolean => {
|
||||
const authErrMap: any = {
|
||||
"10031": "Login expired, please login again", // token invalid
|
||||
"10032": "Session timeout, please login again", // token expired
|
||||
"10033": "Account not bound to role, please contact administrator",
|
||||
"10034": "User not registered, please contact administrator",
|
||||
"10035": "Unable to get third-party platform user with code",
|
||||
"10036": "Account not linked to employee, please contact administrator",
|
||||
"10037": "Account is invalid",
|
||||
"10038": "Account not found",
|
||||
};
|
||||
|
||||
if (authErrMap.hasOwnProperty(errno)) {
|
||||
console.error(authErrMap[errno]);
|
||||
// Authorization error, logout account
|
||||
// logout();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const handleGeneralError = (errno: string, errmsg: string): boolean => {
|
||||
if (errno !== "0") {
|
||||
console.error(errmsg);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
BIN
src/assets/images/logo-dark.png
Normal file
BIN
src/assets/images/logo-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src/assets/images/logo-light.png
Normal file
BIN
src/assets/images/logo-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
2
src/commands/index.ts
Normal file
2
src/commands/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './servers';
|
||||
export * from './system';
|
||||
302
src/commands/servers.ts
Normal file
302
src/commands/servers.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import {
|
||||
ServerTokenResponse,
|
||||
Server,
|
||||
Connector,
|
||||
DataSource,
|
||||
GetResponse,
|
||||
UploadAttachmentPayload,
|
||||
UploadAttachmentResponse,
|
||||
GetAttachmentPayload,
|
||||
GetAttachmentResponse,
|
||||
DeleteAttachmentPayload,
|
||||
TranscriptionPayload,
|
||||
TranscriptionResponse,
|
||||
MultiSourceQueryResponse,
|
||||
} from "@/types/commands";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
async function invokeWithErrorHandler<T>(
|
||||
command: string,
|
||||
args?: Record<string, any>
|
||||
): Promise<T> {
|
||||
const addError = useAppStore.getState().addError;
|
||||
try {
|
||||
const result = await invoke<T>(command, args);
|
||||
// console.log(command, result);
|
||||
|
||||
if (result && typeof result === "object" && "failed" in result) {
|
||||
const failedResult = result as any;
|
||||
if (failedResult.failed?.length > 0) {
|
||||
failedResult.failed.forEach((error: any) => {
|
||||
// addError(error.error, 'error');
|
||||
console.error(error.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof result === "string") {
|
||||
const res = JSON.parse(result);
|
||||
if (typeof res === "string") {
|
||||
throw new Error(result);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const errorMessage = error || "Command execution failed";
|
||||
addError(command + ":" + errorMessage, "error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function get_server_token(id: string): Promise<ServerTokenResponse> {
|
||||
return invokeWithErrorHandler(`get_server_token`, { id });
|
||||
}
|
||||
|
||||
export function list_coco_servers(): Promise<Server[]> {
|
||||
return invokeWithErrorHandler(`list_coco_servers`);
|
||||
}
|
||||
|
||||
export function add_coco_server(endpoint: string): Promise<Server> {
|
||||
return invokeWithErrorHandler(`add_coco_server`, { endpoint });
|
||||
}
|
||||
|
||||
export function enable_server(id: string): Promise<void> {
|
||||
return invokeWithErrorHandler(`enable_server`, { id });
|
||||
}
|
||||
|
||||
export function disable_server(id: string): Promise<void> {
|
||||
return invokeWithErrorHandler(`disable_server`, { id });
|
||||
}
|
||||
|
||||
export function remove_coco_server(id: string): Promise<void> {
|
||||
return invokeWithErrorHandler(`remove_coco_server`, { id });
|
||||
}
|
||||
|
||||
export function logout_coco_server(id: string): Promise<void> {
|
||||
return invokeWithErrorHandler(`logout_coco_server`, { id });
|
||||
}
|
||||
|
||||
export function refresh_coco_server_info(id: string): Promise<Server> {
|
||||
return invokeWithErrorHandler(`refresh_coco_server_info`, { id });
|
||||
}
|
||||
|
||||
export function handle_sso_callback({
|
||||
serverId,
|
||||
requestId,
|
||||
code,
|
||||
}: {
|
||||
serverId: string;
|
||||
requestId: string;
|
||||
code: string;
|
||||
}): Promise<void> {
|
||||
return invokeWithErrorHandler(`handle_sso_callback`, {
|
||||
serverId,
|
||||
requestId,
|
||||
code,
|
||||
});
|
||||
}
|
||||
|
||||
export function get_connectors_by_server(id: string): Promise<Connector[]> {
|
||||
return invokeWithErrorHandler(`get_connectors_by_server`, { id });
|
||||
}
|
||||
|
||||
export function datasource_search(id: string): Promise<DataSource[]> {
|
||||
return invokeWithErrorHandler(`datasource_search`, { id });
|
||||
}
|
||||
|
||||
export function mcp_server_search(id: string): Promise<DataSource[]> {
|
||||
return invokeWithErrorHandler(`mcp_server_search`, { id });
|
||||
}
|
||||
|
||||
export function connect_to_server(id: string, clientId: string): Promise<void> {
|
||||
return invokeWithErrorHandler(`connect_to_server`, { id, clientId });
|
||||
}
|
||||
|
||||
export function disconnect(clientId: string): Promise<void> {
|
||||
return invokeWithErrorHandler(`disconnect`, { clientId });
|
||||
}
|
||||
|
||||
export function chat_history({
|
||||
serverId,
|
||||
from = 0,
|
||||
size = 20,
|
||||
query = "",
|
||||
}: {
|
||||
serverId: string;
|
||||
from?: number;
|
||||
size?: number;
|
||||
query?: string;
|
||||
}): Promise<string> {
|
||||
return invokeWithErrorHandler(`chat_history`, {
|
||||
serverId,
|
||||
from,
|
||||
size,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
export function session_chat_history({
|
||||
serverId,
|
||||
sessionId,
|
||||
from = 0,
|
||||
size = 20,
|
||||
}: {
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
from?: number;
|
||||
size?: number;
|
||||
}): Promise<string> {
|
||||
return invokeWithErrorHandler(`session_chat_history`, {
|
||||
serverId,
|
||||
sessionId,
|
||||
from,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
export function close_session_chat({
|
||||
serverId,
|
||||
sessionId,
|
||||
}: {
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
}): Promise<string> {
|
||||
return invokeWithErrorHandler(`close_session_chat`, {
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
export function open_session_chat({
|
||||
serverId,
|
||||
sessionId,
|
||||
}: {
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
}): Promise<string> {
|
||||
return invokeWithErrorHandler(`open_session_chat`, {
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
export function cancel_session_chat({
|
||||
serverId,
|
||||
sessionId,
|
||||
}: {
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
}): Promise<string> {
|
||||
return invokeWithErrorHandler(`cancel_session_chat`, {
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
export function new_chat({
|
||||
serverId,
|
||||
websocketId,
|
||||
message,
|
||||
queryParams,
|
||||
}: {
|
||||
serverId: string;
|
||||
websocketId?: string;
|
||||
message: string;
|
||||
queryParams?: Record<string, any>;
|
||||
}): Promise<GetResponse> {
|
||||
return invokeWithErrorHandler(`new_chat`, {
|
||||
serverId,
|
||||
websocketId,
|
||||
message,
|
||||
queryParams,
|
||||
});
|
||||
}
|
||||
|
||||
export function send_message({
|
||||
serverId,
|
||||
websocketId,
|
||||
sessionId,
|
||||
message,
|
||||
queryParams,
|
||||
}: {
|
||||
serverId: string;
|
||||
websocketId?: string;
|
||||
sessionId: string;
|
||||
message: string;
|
||||
queryParams?: Record<string, any>;
|
||||
}): Promise<string> {
|
||||
return invokeWithErrorHandler(`send_message`, {
|
||||
serverId,
|
||||
websocketId,
|
||||
sessionId,
|
||||
message,
|
||||
queryParams,
|
||||
});
|
||||
}
|
||||
|
||||
export const delete_session_chat = (serverId: string, sessionId: string) => {
|
||||
return invokeWithErrorHandler<boolean>(`delete_session_chat`, {
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
};
|
||||
|
||||
export const update_session_chat = (payload: {
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
title?: string;
|
||||
context?: {
|
||||
attachments?: string[];
|
||||
};
|
||||
}): Promise<boolean> => {
|
||||
return invokeWithErrorHandler<boolean>("update_session_chat", payload);
|
||||
};
|
||||
|
||||
export const assistant_search = (payload: {
|
||||
serverId: string;
|
||||
}): Promise<boolean> => {
|
||||
return invokeWithErrorHandler<boolean>("assistant_search", payload);
|
||||
};
|
||||
|
||||
export const upload_attachment = async (payload: UploadAttachmentPayload) => {
|
||||
const response = await invokeWithErrorHandler<UploadAttachmentResponse>(
|
||||
"upload_attachment",
|
||||
{
|
||||
...payload,
|
||||
}
|
||||
);
|
||||
|
||||
if (response?.acknowledged) {
|
||||
return response.attachments;
|
||||
}
|
||||
};
|
||||
|
||||
export const get_attachment = (payload: GetAttachmentPayload) => {
|
||||
return invokeWithErrorHandler<GetAttachmentResponse>("get_attachment", {
|
||||
...payload,
|
||||
});
|
||||
};
|
||||
|
||||
export const delete_attachment = (payload: DeleteAttachmentPayload) => {
|
||||
return invokeWithErrorHandler<boolean>("delete_attachment", { ...payload });
|
||||
};
|
||||
|
||||
export const transcription = (payload: TranscriptionPayload) => {
|
||||
return invokeWithErrorHandler<TranscriptionResponse>("transcription", {
|
||||
...payload,
|
||||
});
|
||||
};
|
||||
|
||||
export const query_coco_fusion = (payload: {
|
||||
from: number;
|
||||
size: number;
|
||||
queryStrings: Record<string, string>;
|
||||
queryTimeout: number;
|
||||
}) => {
|
||||
return invokeWithErrorHandler<MultiSourceQueryResponse>("query_coco_fusion", {
|
||||
...payload,
|
||||
});
|
||||
};
|
||||
29
src/commands/system.ts
Normal file
29
src/commands/system.ts
Normal 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');
|
||||
}
|
||||
389
src/components/Assistant/AssistantList.tsx
Normal file
389
src/components/Assistant/AssistantList.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import { useState, useRef, useCallback, useMemo } from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
RefreshCw,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import VisibleKey from "@/components/Common/VisibleKey";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import FontIcon from "@/components/Common/Icons/FontIcon";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { Post } from "@/api/axiosRequest";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import {
|
||||
useAsyncEffect,
|
||||
useDebounce,
|
||||
useKeyPress,
|
||||
usePagination,
|
||||
useReactive,
|
||||
} from "ahooks";
|
||||
import clsx from "clsx";
|
||||
import NoDataImage from "../Common/NoDataImage";
|
||||
import PopoverInput from "../Common/PopoverInput";
|
||||
import { isNil } from "lodash-es";
|
||||
|
||||
interface AssistantListProps {
|
||||
assistantIDs?: string[];
|
||||
}
|
||||
|
||||
interface State {
|
||||
allAssistants: any[];
|
||||
}
|
||||
|
||||
export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { connected } = useChatStore();
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const setAssistantList = useConnectStore((state) => state.setAssistantList);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||||
const setCurrentAssistant = useConnectStore((state) => {
|
||||
return state.setCurrentAssistant;
|
||||
});
|
||||
const aiAssistant = useShortcutsStore((state) => state.aiAssistant);
|
||||
const [assistants, setAssistants] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const popoverButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const debounceKeyword = useDebounce(keyword, { wait: 500 });
|
||||
const state = useReactive<State>({
|
||||
allAssistants: [],
|
||||
});
|
||||
|
||||
const currentServiceId = useMemo(() => {
|
||||
return currentService?.id;
|
||||
}, [connected, currentService?.id]);
|
||||
|
||||
const fetchAssistant = async (params: {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
}) => {
|
||||
try {
|
||||
const { pageSize, current } = params;
|
||||
|
||||
const from = (current - 1) * pageSize;
|
||||
const size = pageSize;
|
||||
|
||||
let response: any;
|
||||
|
||||
const body: Record<string, any> = {
|
||||
serverId: currentServiceId,
|
||||
from,
|
||||
size,
|
||||
};
|
||||
|
||||
if (debounceKeyword || assistantIDs.length > 0) {
|
||||
body.query = {
|
||||
bool: {
|
||||
must: [],
|
||||
},
|
||||
};
|
||||
if (debounceKeyword) {
|
||||
body.query.bool.must.push({
|
||||
query_string: {
|
||||
fields: ["combined_fulltext"],
|
||||
query: debounceKeyword,
|
||||
fuzziness: "AUTO",
|
||||
fuzzy_prefix_length: 2,
|
||||
fuzzy_max_expansions: 10,
|
||||
fuzzy_transpositions: true,
|
||||
allow_leading_wildcard: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (assistantIDs.length > 0) {
|
||||
body.query.bool.must.push({
|
||||
terms: {
|
||||
id: assistantIDs.map((id) => id),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isTauri) {
|
||||
if (!currentServiceId) {
|
||||
throw new Error("currentServiceId is undefined");
|
||||
}
|
||||
|
||||
response = await platformAdapter.commands("assistant_search", body);
|
||||
} else {
|
||||
const [error, res] = await Post(`/assistant/_search`, body);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
response = res;
|
||||
}
|
||||
|
||||
console.log("assistant_search", response);
|
||||
|
||||
let assistantList = response?.hits?.hits ?? [];
|
||||
|
||||
console.log("assistantList", assistantList);
|
||||
|
||||
for (const item of assistantList) {
|
||||
const index = state.allAssistants.findIndex((allItem: any) => {
|
||||
return item._id === allItem._id;
|
||||
});
|
||||
|
||||
if (index === -1) {
|
||||
state.allAssistants.push(item);
|
||||
} else {
|
||||
state.allAssistants[index] = item;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("state.allAssistants", state.allAssistants);
|
||||
|
||||
const matched = state.allAssistants.find((item: any) => {
|
||||
return item._id === currentAssistant?._id;
|
||||
});
|
||||
|
||||
console.log("matched", matched);
|
||||
|
||||
if (matched) {
|
||||
setCurrentAssistant(matched);
|
||||
} else {
|
||||
setCurrentAssistant(assistantList[0]);
|
||||
}
|
||||
|
||||
return {
|
||||
total: response.hits.total.value,
|
||||
list: assistantList,
|
||||
};
|
||||
} catch (error) {
|
||||
setCurrentAssistant(null);
|
||||
|
||||
console.error("assistant_search", error);
|
||||
|
||||
return {
|
||||
total: 0,
|
||||
list: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const data = await fetchAssistant({ current: 1, pageSize: 1000 });
|
||||
|
||||
setAssistantList(data.list);
|
||||
}, [currentServiceId]);
|
||||
|
||||
const { pagination, runAsync } = usePagination(fetchAssistant, {
|
||||
defaultPageSize: 5,
|
||||
refreshDeps: [currentServiceId, debounceKeyword],
|
||||
onSuccess(data) {
|
||||
setAssistants(data.list);
|
||||
},
|
||||
});
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
|
||||
await runAsync({ current: 1, pageSize: 5 });
|
||||
|
||||
setTimeout(() => setIsRefreshing(false), 1000);
|
||||
};
|
||||
|
||||
useKeyPress(["uparrow", "downarrow"], (_, key) => {
|
||||
const isClose = isNil(popoverButtonRef.current?.dataset["open"]);
|
||||
const index = assistants.findIndex(
|
||||
(item) => item._id === currentAssistant?._id
|
||||
);
|
||||
const length = assistants.length;
|
||||
|
||||
if (isClose || length <= 1) return;
|
||||
|
||||
let nextIndex = index;
|
||||
|
||||
if (key === "uparrow") {
|
||||
nextIndex = index > 0 ? index - 1 : length - 1;
|
||||
} else {
|
||||
nextIndex = index < length - 1 ? index + 1 : 0;
|
||||
}
|
||||
|
||||
setCurrentAssistant(assistants[nextIndex]);
|
||||
});
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
if (pagination.current <= 1) return;
|
||||
|
||||
pagination.changeCurrent(pagination.current - 1);
|
||||
}, [pagination]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (pagination.current >= pagination.totalPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
pagination.changeCurrent(pagination.current + 1);
|
||||
}, [pagination]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Popover>
|
||||
<PopoverButton
|
||||
ref={popoverButtonRef}
|
||||
className="h-6 p-1 px-1.5 flex items-center gap-1 rounded-full bg-white dark:bg-[#202126] text-sm/6 font-semibold text-gray-800 dark:text-[#d8d8d8] border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
|
||||
>
|
||||
<div className="w-4 h-4 flex justify-center items-center rounded-full bg-white border border-[#E6E6E6]">
|
||||
{currentAssistant?._source?.icon?.startsWith("font_") ? (
|
||||
<FontIcon
|
||||
name={currentAssistant._source.icon}
|
||||
className="w-3 h-3"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={logoImg}
|
||||
className="w-3 h-3"
|
||||
alt={t("assistant.message.logo")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-w-[100px] truncate">
|
||||
{currentAssistant?._source?.name || "Coco AI"}
|
||||
</div>
|
||||
<VisibleKey
|
||||
shortcut={aiAssistant}
|
||||
onKeyPress={() => {
|
||||
popoverButtonRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<ChevronDownIcon className="size-4 text-gray-500 dark:text-gray-400 transition-transform" />
|
||||
</VisibleKey>
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel className="absolute z-50 top-full mt-1 left-0 w-60 rounded-xl bg-white dark:bg-[#202126] p-3 text-sm/6 text-[#333] dark:text-[#D8D8D8] shadow-lg border dark:border-white/10 focus:outline-none max-h-[calc(100vh-80px)] overflow-y-auto">
|
||||
<div className="flex items-center justify-between text-sm font-bold">
|
||||
<div>
|
||||
{t("assistant.popover.title")}({pagination.total})
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center justify-center size-6 bg-white dark:bg-[#202126] rounded-lg border dark:border-white/10"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
|
||||
<RefreshCw
|
||||
className={clsx(
|
||||
"size-3 text-[#0287FF] transition-transform duration-1000",
|
||||
{
|
||||
"animate-spin": isRefreshing,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</VisibleKey>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<VisibleKey
|
||||
shortcut="F"
|
||||
rootClassName="w-full my-3"
|
||||
shortcutClassName="left-4"
|
||||
onKeyPress={() => {
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<PopoverInput
|
||||
ref={searchInputRef}
|
||||
autoFocus
|
||||
value={keyword}
|
||||
placeholder={t("assistant.popover.search")}
|
||||
className="w-full h-8 px-2 bg-transparent border rounded-md dark:border-white/10"
|
||||
onChange={(event) => {
|
||||
console.log("onChange", event.target.value);
|
||||
setKeyword(event.target.value.trim());
|
||||
}}
|
||||
/>
|
||||
</VisibleKey>
|
||||
|
||||
{assistants.length > 0 ? (
|
||||
<>
|
||||
{assistants.map((assistant) => {
|
||||
const { _id, _source, name } = assistant;
|
||||
|
||||
const isActive = currentAssistant?._id === _id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={_id}
|
||||
className={clsx(
|
||||
"w-full flex items-center h-[50px] gap-2 rounded-lg p-2 mb-1 hover:bg-[#E6E6E6] dark:hover:bg-[#1F2937] transition",
|
||||
{
|
||||
"bg-[#E6E6E6] dark:bg-[#1F2937]": isActive,
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentAssistant(assistant);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center size-6 bg-white border border-[#E6E6E6] rounded-full overflow-hidden">
|
||||
{_source?.icon?.startsWith("font_") ? (
|
||||
<FontIcon name={_source?.icon} className="size-4" />
|
||||
) : (
|
||||
<img src={logoImg} className="size-4" alt={name} />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{_source?.name || "-"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{_source?.description || ""}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="flex items-center">
|
||||
<VisibleKey
|
||||
shortcut="↓↑"
|
||||
shortcutClassName="w-6 -translate-x-4"
|
||||
>
|
||||
<Check className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</VisibleKey>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex items-center justify-between h-8 -mx-3 -mb-3 px-3 text-[#999] border-t dark:border-t-white/10">
|
||||
<VisibleKey shortcut="leftarrow" onKeyPress={handlePrev}>
|
||||
<ChevronLeft
|
||||
className="size-4 cursor-pointer"
|
||||
onClick={handlePrev}
|
||||
/>
|
||||
</VisibleKey>
|
||||
|
||||
<div className="text-xs">
|
||||
{pagination.current}/{pagination.totalPage}
|
||||
</div>
|
||||
|
||||
<VisibleKey shortcut="rightarrow" onKeyPress={handleNext}>
|
||||
<ChevronRight
|
||||
className="size-4 cursor-pointer"
|
||||
onClick={handleNext}
|
||||
/>
|
||||
</VisibleKey>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-2">
|
||||
<NoDataImage />
|
||||
</div>
|
||||
)}
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,38 +4,38 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { debounce } from "lodash-es";
|
||||
|
||||
import { ChatMessage } from "@/components/ChatMessage";
|
||||
import type { Chat } from "./types";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useWindows } from "@/hooks/useWindows";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { Sidebar } from "@/components/Assistant/Sidebar";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import FileList from "@/components/Search/FileList";
|
||||
import { Greetings } from "./Greetings";
|
||||
import ConnectPrompt from "./ConnectPrompt";
|
||||
import { useWindows } from "@/hooks/useWindows";
|
||||
import useMessageChunkData from "@/hooks/useMessageChunkData";
|
||||
import useWebSocket from "@/hooks/useWebSocket";
|
||||
import { useChatActions } from "@/hooks/useChatActions";
|
||||
import { useMessageHandler } from "@/hooks/useMessageHandler";
|
||||
import { ChatSidebar } from "./ChatSidebar";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { ChatContent } from "./ChatContent";
|
||||
import ConnectPrompt from "./ConnectPrompt";
|
||||
import type { Chat } from "./types";
|
||||
import PrevSuggestion from "@/components/ChatMessage/PrevSuggestion";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
interface ChatAIProps {
|
||||
isTransitioned: boolean;
|
||||
isSearchActive?: boolean;
|
||||
isDeepThinkActive?: boolean;
|
||||
isMCPActive?: boolean;
|
||||
activeChatProp?: Chat;
|
||||
changeInput?: (val: string) => void;
|
||||
setIsSidebarOpen?: (value: boolean) => void;
|
||||
isSidebarOpen?: boolean;
|
||||
clearChatPage?: () => void;
|
||||
isChatPage?: boolean;
|
||||
getFileUrl: (path: string) => string;
|
||||
showChatHistory?: boolean;
|
||||
assistantIDs?: string[];
|
||||
}
|
||||
|
||||
export interface ChatAIRef {
|
||||
@@ -49,60 +49,63 @@ const ChatAI = memo(
|
||||
forwardRef<ChatAIRef, ChatAIProps>(
|
||||
(
|
||||
{
|
||||
isTransitioned,
|
||||
changeInput,
|
||||
isSearchActive,
|
||||
isDeepThinkActive,
|
||||
isMCPActive,
|
||||
activeChatProp,
|
||||
setIsSidebarOpen,
|
||||
isSidebarOpen = false,
|
||||
clearChatPage,
|
||||
isChatPage = false,
|
||||
getFileUrl,
|
||||
showChatHistory,
|
||||
assistantIDs,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
if (!isTransitioned) return null;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
init: init,
|
||||
cancelChat: cancelChat,
|
||||
cancelChat: () => cancelChat(activeChat),
|
||||
reconnect: reconnect,
|
||||
clearChat: clearChat,
|
||||
}));
|
||||
|
||||
const { createWin } = useWindows();
|
||||
|
||||
const { curChatEnd, setCurChatEnd, connected, setConnected } =
|
||||
useChatStore();
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const visibleStartPage = useConnectStore((state) => {
|
||||
return state.visibleStartPage;
|
||||
});
|
||||
|
||||
const addError = useAppStore.getState().addError;
|
||||
|
||||
const [activeChat, setActiveChat] = useState<Chat>();
|
||||
const [timedoutShow, setTimedoutShow] = useState(false);
|
||||
const [IsLogin, setIsLogin] = useState(true);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
|
||||
const curIdRef = useRef("");
|
||||
|
||||
const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen);
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const sourceDataIds = useSearchStore((state) => state.sourceDataIds);
|
||||
const uploadFiles = useChatStore((state) => state.uploadFiles);
|
||||
|
||||
useEffect(() => {
|
||||
activeChatProp && setActiveChat(activeChatProp);
|
||||
}, [activeChatProp]);
|
||||
|
||||
const messageTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const [Question, setQuestion] = useState<string>("");
|
||||
|
||||
const [websocketSessionId, setWebsocketSessionId] = useState("");
|
||||
|
||||
const onWebsocketSessionId = useCallback((sessionId: string) => {
|
||||
setWebsocketSessionId(sessionId);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: {
|
||||
query_intent,
|
||||
tools,
|
||||
fetch_source,
|
||||
pick_source,
|
||||
deep_read,
|
||||
@@ -115,6 +118,7 @@ const ChatAI = memo(
|
||||
|
||||
const [loadingStep, setLoadingStep] = useState<Record<string, boolean>>({
|
||||
query_intent: false,
|
||||
tools: false,
|
||||
fetch_source: false,
|
||||
pick_source: false,
|
||||
deep_read: false,
|
||||
@@ -122,395 +126,168 @@ const ChatAI = memo(
|
||||
response: false,
|
||||
});
|
||||
|
||||
const dealMsg = useCallback(
|
||||
(msg: string) => {
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
const dealMsgRef = useRef<((msg: string) => void) | null>(null);
|
||||
|
||||
if (!msg.includes("PRIVATE")) return;
|
||||
|
||||
messageTimeoutRef.current = setTimeout(() => {
|
||||
if (!curChatEnd) {
|
||||
console.log("AI response timeout");
|
||||
setTimedoutShow(true);
|
||||
cancelChat();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
if (msg.includes("assistant finished output")) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
console.log("AI finished output");
|
||||
setCurChatEnd(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanedData = msg.replace(/^PRIVATE /, "");
|
||||
try {
|
||||
const chunkData = JSON.parse(cleanedData);
|
||||
|
||||
if (chunkData.reply_to_message !== curIdRef.current) return;
|
||||
|
||||
setLoadingStep(() => ({
|
||||
query_intent: false,
|
||||
fetch_source: false,
|
||||
pick_source: false,
|
||||
deep_read: false,
|
||||
think: false,
|
||||
response: false,
|
||||
[chunkData.chunk_type]: true,
|
||||
}));
|
||||
|
||||
// ['query_intent', 'fetch_source', 'pick_source', 'deep_read', 'think', 'response'];
|
||||
if (chunkData.chunk_type === "query_intent") {
|
||||
handlers.deal_query_intent(chunkData);
|
||||
} else if (chunkData.chunk_type === "fetch_source") {
|
||||
handlers.deal_fetch_source(chunkData);
|
||||
} else if (chunkData.chunk_type === "pick_source") {
|
||||
handlers.deal_pick_source(chunkData);
|
||||
} else if (chunkData.chunk_type === "deep_read") {
|
||||
handlers.deal_deep_read(chunkData);
|
||||
} else if (chunkData.chunk_type === "think") {
|
||||
handlers.deal_think(chunkData);
|
||||
} else if (chunkData.chunk_type === "response") {
|
||||
handlers.deal_response(chunkData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("parse error:", error);
|
||||
}
|
||||
},
|
||||
[curChatEnd]
|
||||
);
|
||||
|
||||
const { errorShow, setErrorShow, reconnect } = useWebSocket({
|
||||
const clientId = isChatPage ? "standalone" : "popup";
|
||||
const { reconnect, disconnectWS, updateDealMsg } = useWebSocket({
|
||||
clientId,
|
||||
connected,
|
||||
setConnected,
|
||||
currentService,
|
||||
dealMsg,
|
||||
dealMsgRef,
|
||||
onWebsocketSessionId,
|
||||
});
|
||||
|
||||
const updatedChat = useMemo(() => {
|
||||
if (!activeChat?._id) return null;
|
||||
return {
|
||||
...activeChat,
|
||||
messages: [...(activeChat.messages || [])],
|
||||
};
|
||||
}, [activeChat]);
|
||||
const {
|
||||
chatClose,
|
||||
cancelChat,
|
||||
chatHistory,
|
||||
createNewChat,
|
||||
handleSendMessage,
|
||||
openSessionChat,
|
||||
getChatHistory,
|
||||
createChatWindow,
|
||||
handleSearch,
|
||||
handleRename,
|
||||
handleDelete,
|
||||
} = useChatActions(
|
||||
currentService?.id,
|
||||
setActiveChat,
|
||||
setCurChatEnd,
|
||||
setTimedoutShow,
|
||||
clearAllChunkData,
|
||||
setQuestion,
|
||||
curIdRef,
|
||||
setChats,
|
||||
isSearchActive,
|
||||
isDeepThinkActive,
|
||||
isMCPActive,
|
||||
changeInput,
|
||||
websocketSessionId,
|
||||
showChatHistory,
|
||||
);
|
||||
|
||||
const simulateAssistantResponse = useCallback(() => {
|
||||
if (!updatedChat) return;
|
||||
|
||||
// console.log("updatedChat:", updatedChat);
|
||||
setActiveChat(updatedChat);
|
||||
}, [updatedChat]);
|
||||
|
||||
useEffect(() => {
|
||||
if (curChatEnd) {
|
||||
simulateAssistantResponse();
|
||||
}
|
||||
}, [curChatEnd]);
|
||||
|
||||
const [userScrolling, setUserScrolling] = useState(false);
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
debounce(() => {
|
||||
if (!userScrolling) {
|
||||
const container = messagesEndRef.current?.parentElement;
|
||||
if (container) {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 100),
|
||||
[userScrolling]
|
||||
const { dealMsg, messageTimeoutRef } = useMessageHandler(
|
||||
curIdRef,
|
||||
setCurChatEnd,
|
||||
setTimedoutShow,
|
||||
(chat) => cancelChat(chat || activeChat),
|
||||
setLoadingStep,
|
||||
handlers
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = messagesEndRef.current?.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
if (dealMsg) {
|
||||
dealMsgRef.current = dealMsg;
|
||||
updateDealMsg && updateDealMsg(dealMsg);
|
||||
}
|
||||
}, [dealMsg, updateDealMsg]);
|
||||
|
||||
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 = () => {
|
||||
const clearChat = useCallback(() => {
|
||||
console.log("clearChat");
|
||||
chatClose();
|
||||
setTimedoutShow(false);
|
||||
chatClose(activeChat);
|
||||
setActiveChat(undefined);
|
||||
setCurChatEnd(true);
|
||||
clearChatPage && clearChatPage();
|
||||
};
|
||||
}, [
|
||||
activeChat,
|
||||
chatClose,
|
||||
clearChatPage,
|
||||
setCurChatEnd,
|
||||
setTimedoutShow,
|
||||
]);
|
||||
|
||||
const createNewChat = useCallback(
|
||||
async (value: string = "") => {
|
||||
setTimedoutShow(false);
|
||||
setErrorShow(false);
|
||||
chatClose();
|
||||
clearAllChunkData();
|
||||
setQuestion(value);
|
||||
const init = useCallback(
|
||||
async (value: string) => {
|
||||
try {
|
||||
console.log("sourceDataIds", sourceDataIds);
|
||||
let response: any = await invoke("new_chat", {
|
||||
serverId: currentService?.id,
|
||||
message: value,
|
||||
queryParams: {
|
||||
search: isSearchActive,
|
||||
deep_thinking: isDeepThinkActive,
|
||||
datasource: sourceDataIds.join(","),
|
||||
},
|
||||
});
|
||||
console.log("_new", response);
|
||||
const newChat: Chat = response;
|
||||
curIdRef.current = response?.payload?.id;
|
||||
|
||||
newChat._source = {
|
||||
message: value,
|
||||
};
|
||||
const updatedChat: Chat = {
|
||||
...newChat,
|
||||
messages: [newChat],
|
||||
};
|
||||
|
||||
changeInput && changeInput("");
|
||||
//console.log("updatedChat2", updatedChat);
|
||||
setActiveChat(updatedChat);
|
||||
setCurChatEnd(false);
|
||||
} catch (error) {
|
||||
setErrorShow(true);
|
||||
console.error("createNewChat:", error);
|
||||
console.log("init", isLogin, curChatEnd, activeChat?._id);
|
||||
if (!isLogin) {
|
||||
addError("Please login to continue chatting");
|
||||
return;
|
||||
}
|
||||
if (!curChatEnd) {
|
||||
addError("Please wait for the current conversation to complete");
|
||||
return;
|
||||
}
|
||||
},
|
||||
[currentService?.id, sourceDataIds, isSearchActive, isDeepThinkActive]
|
||||
);
|
||||
|
||||
const init = (value: string) => {
|
||||
if (!IsLogin) return;
|
||||
if (!curChatEnd) return;
|
||||
if (!activeChat?._id) {
|
||||
createNewChat(value);
|
||||
await createNewChat(value, activeChat, websocketSessionId);
|
||||
} else {
|
||||
handleSendMessage(value);
|
||||
await handleSendMessage(value, activeChat, websocketSessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, newChat: Chat) => {
|
||||
if (!newChat?._id || !content) return;
|
||||
|
||||
try {
|
||||
//console.log("sourceDataIds", sourceDataIds);
|
||||
let response: any = await invoke("send_message", {
|
||||
serverId: currentService?.id,
|
||||
sessionId: newChat?._id,
|
||||
queryParams: {
|
||||
search: isSearchActive,
|
||||
deep_thinking: isDeepThinkActive,
|
||||
datasource: sourceDataIds.join(","),
|
||||
},
|
||||
message: content,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
console.log("_send", response);
|
||||
curIdRef.current = response[0]?._id;
|
||||
|
||||
const updatedChat: Chat = {
|
||||
...newChat,
|
||||
messages: [...(newChat?.messages || []), ...(response || [])],
|
||||
};
|
||||
|
||||
changeInput && changeInput("");
|
||||
//console.log("updatedChat2", updatedChat);
|
||||
setActiveChat(updatedChat);
|
||||
setCurChatEnd(false);
|
||||
} catch (error) {
|
||||
setErrorShow(true);
|
||||
console.error("sendMessage:", error);
|
||||
console.error("Failed to initialize chat:", error);
|
||||
}
|
||||
},
|
||||
[
|
||||
JSON.stringify(activeChat?.messages),
|
||||
currentService?.id,
|
||||
sourceDataIds,
|
||||
isSearchActive,
|
||||
isDeepThinkActive,
|
||||
isLogin,
|
||||
curChatEnd,
|
||||
activeChat,
|
||||
createNewChat,
|
||||
handleSendMessage,
|
||||
websocketSessionId,
|
||||
]
|
||||
);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (content: string, newChat?: Chat) => {
|
||||
newChat = newChat || activeChat;
|
||||
if (!newChat?._id || !content) return;
|
||||
setQuestion(content);
|
||||
await chatHistory(newChat, (chat) => sendMessage(content, chat));
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
const { createWin } = useWindows();
|
||||
const openChatAI = useCallback(() => {
|
||||
createChatWindow(createWin);
|
||||
}, [createChatWindow, createWin]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurChatEnd(true);
|
||||
return () => {
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
chatClose();
|
||||
Promise.resolve().then(() => {
|
||||
chatClose(activeChat);
|
||||
setActiveChat(undefined);
|
||||
setCurChatEnd(true);
|
||||
scrollToBottom.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const chatHistory = async (
|
||||
chat: Chat,
|
||||
callback?: (chat: Chat) => void
|
||||
) => {
|
||||
try {
|
||||
let response: any = await invoke("session_chat_history", {
|
||||
serverId: currentService?.id,
|
||||
sessionId: chat?._id,
|
||||
from: 0,
|
||||
size: 20,
|
||||
disconnectWS();
|
||||
});
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, [chatClose, setCurChatEnd]);
|
||||
|
||||
const onSelectChat = async (chat: any) => {
|
||||
chatClose();
|
||||
const onSelectChat = useCallback(
|
||||
async (chat: Chat) => {
|
||||
setTimedoutShow(false);
|
||||
clearAllChunkData();
|
||||
try {
|
||||
let response: any = await invoke("open_session_chat", {
|
||||
serverId: currentService?.id,
|
||||
sessionId: chat?._id,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
console.log("_open", response);
|
||||
await cancelChat(activeChat);
|
||||
await chatClose(activeChat);
|
||||
const response = await openSessionChat(chat);
|
||||
if (response) {
|
||||
chatHistory(response);
|
||||
} catch (error) {
|
||||
console.error("open_session_chat:", error);
|
||||
}
|
||||
};
|
||||
},
|
||||
[
|
||||
clearAllChunkData,
|
||||
cancelChat,
|
||||
activeChat,
|
||||
chatClose,
|
||||
openSessionChat,
|
||||
chatHistory,
|
||||
]
|
||||
);
|
||||
|
||||
const deleteChat = useCallback(
|
||||
(chatId: string) => {
|
||||
handleDelete(chatId);
|
||||
|
||||
const deleteChat = (chatId: string) => {
|
||||
setChats((prev) => prev.filter((chat) => chat._id !== chatId));
|
||||
|
||||
if (activeChat?._id === chatId) {
|
||||
const remainingChats = chats.filter((chat) => chat._id !== chatId);
|
||||
|
||||
if (remainingChats.length > 0) {
|
||||
setActiveChat(remainingChats[0]);
|
||||
} else {
|
||||
init("");
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[activeChat, chats, init, setActiveChat]
|
||||
);
|
||||
|
||||
const handleOutsideClick = useCallback((e: MouseEvent) => {
|
||||
const sidebar = document.querySelector("[data-sidebar]");
|
||||
@@ -534,155 +311,103 @@ const ChatAI = memo(
|
||||
};
|
||||
}, [isSidebarOpenChat, handleOutsideClick]);
|
||||
|
||||
const getChatHistory = useCallback(async () => {
|
||||
if (!currentService?.id) return;
|
||||
try {
|
||||
let response: any = await invoke("chat_history", {
|
||||
serverId: currentService?.id,
|
||||
from: 0,
|
||||
size: 20,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
console.log("_history", response);
|
||||
const hits = response?.hits?.hits || [];
|
||||
setChats(hits);
|
||||
} catch (error) {
|
||||
console.error("chat_history:", error);
|
||||
}
|
||||
}, [currentService?.id]);
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setIsSidebarOpenChat(!isSidebarOpenChat);
|
||||
setIsSidebarOpen && setIsSidebarOpen(!isSidebarOpenChat);
|
||||
!isSidebarOpenChat && getChatHistory();
|
||||
}, [isSidebarOpenChat, setIsSidebarOpen, getChatHistory]);
|
||||
|
||||
const setIsLoginChat = useCallback(
|
||||
(value: boolean) => {
|
||||
setIsLogin(value);
|
||||
value && currentService && !setIsSidebarOpen && getChatHistory();
|
||||
!value && setChats([]);
|
||||
},
|
||||
[currentService]
|
||||
);
|
||||
const renameChat = (chatId: string, title: string) => {
|
||||
setChats((prev) => {
|
||||
const updatedChats = prev.map((item) => {
|
||||
if (item._id !== chatId) return item;
|
||||
|
||||
return { ...item, _source: { ...item._source, title } };
|
||||
});
|
||||
|
||||
const modifiedChat = updatedChats.find((item) => {
|
||||
return item._id === chatId;
|
||||
});
|
||||
|
||||
if (!modifiedChat) {
|
||||
return updatedChats;
|
||||
}
|
||||
|
||||
return [
|
||||
modifiedChat,
|
||||
...updatedChats.filter((item) => item._id !== chatId),
|
||||
];
|
||||
});
|
||||
|
||||
if (activeChat?._id === chatId) {
|
||||
setActiveChat((prev) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
return { ...prev, _source: { ...prev._source, title } };
|
||||
});
|
||||
}
|
||||
|
||||
handleRename(chatId, title);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={`h-full flex flex-col rounded-xl overflow-hidden`}
|
||||
className={`h-full flex flex-col rounded-md relative`}
|
||||
>
|
||||
{setIsSidebarOpen ? null : (
|
||||
<div
|
||||
data-sidebar
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 transform transition-all duration-300 ease-in-out
|
||||
${
|
||||
isSidebarOpenChat
|
||||
? "translate-x-0"
|
||||
: "-translate-x-[calc(100%)]"
|
||||
}
|
||||
md:relative md:translate-x-0 bg-gray-100 dark:bg-gray-800
|
||||
border-r border-gray-200 dark:border-gray-700 rounded-tl-xl rounded-bl-xl
|
||||
overflow-hidden`}
|
||||
>
|
||||
<Sidebar
|
||||
{showChatHistory && !setIsSidebarOpen && (
|
||||
<ChatSidebar
|
||||
isSidebarOpen={isSidebarOpenChat}
|
||||
chats={chats}
|
||||
activeChat={activeChat}
|
||||
onNewChat={clearChat}
|
||||
// onNewChat={clearChat}
|
||||
onSelectChat={onSelectChat}
|
||||
onDeleteChat={deleteChat}
|
||||
fetchChatHistory={getChatHistory}
|
||||
onSearch={handleSearch}
|
||||
onRename={renameChat}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatHeader
|
||||
onCreateNewChat={clearChat}
|
||||
onOpenChatAI={openChatAI}
|
||||
setIsSidebarOpen={() => {
|
||||
setIsSidebarOpenChat(!isSidebarOpenChat);
|
||||
setIsSidebarOpen && setIsSidebarOpen(!isSidebarOpenChat);
|
||||
!isSidebarOpenChat && getChatHistory();
|
||||
}}
|
||||
setIsSidebarOpen={toggleSidebar}
|
||||
isSidebarOpen={isSidebarOpenChat}
|
||||
activeChat={activeChat}
|
||||
reconnect={reconnect}
|
||||
isChatPage={isChatPage}
|
||||
setIsLogin={setIsLoginChat}
|
||||
isLogin={isLogin}
|
||||
setIsLogin={setIsLogin}
|
||||
showChatHistory={showChatHistory}
|
||||
assistantIDs={assistantIDs}
|
||||
/>
|
||||
{IsLogin ? (
|
||||
<div className="flex flex-col h-full justify-between overflow-hidden">
|
||||
<div className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative">
|
||||
<Greetings />
|
||||
{activeChat?.messages?.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={message._id + index}
|
||||
message={message}
|
||||
isTyping={false}
|
||||
onResend={handleSendMessage}
|
||||
/>
|
||||
))}
|
||||
{(query_intent ||
|
||||
fetch_source ||
|
||||
pick_source ||
|
||||
deep_read ||
|
||||
think ||
|
||||
response) &&
|
||||
activeChat?._id ? (
|
||||
<ChatMessage
|
||||
key={"current"}
|
||||
message={{
|
||||
_id: "current",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: "",
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={!curChatEnd}
|
||||
{isLogin ? (
|
||||
<ChatContent
|
||||
activeChat={activeChat}
|
||||
curChatEnd={curChatEnd}
|
||||
query_intent={query_intent}
|
||||
tools={tools}
|
||||
fetch_source={fetch_source}
|
||||
pick_source={pick_source}
|
||||
deep_read={deep_read}
|
||||
think={think}
|
||||
response={response}
|
||||
loadingStep={loadingStep}
|
||||
timedoutShow={timedoutShow}
|
||||
Question={Question}
|
||||
handleSendMessage={(value) =>
|
||||
handleSendMessage(value, activeChat)
|
||||
}
|
||||
getFileUrl={getFileUrl}
|
||||
/>
|
||||
) : 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 />
|
||||
)}
|
||||
|
||||
{!activeChat?._id && !visibleStartPage && (
|
||||
<PrevSuggestion sendMessage={init} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
157
src/components/Assistant/ChatContent.tsx
Normal file
157
src/components/Assistant/ChatContent.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
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";
|
||||
import SessionFile from "./SessionFile";
|
||||
import Splash from "./Splash";
|
||||
|
||||
interface ChatContentProps {
|
||||
activeChat?: Chat;
|
||||
curChatEnd: boolean;
|
||||
query_intent?: IChunkData;
|
||||
tools?: IChunkData;
|
||||
fetch_source?: IChunkData;
|
||||
pick_source?: IChunkData;
|
||||
deep_read?: IChunkData;
|
||||
think?: IChunkData;
|
||||
response?: IChunkData;
|
||||
loadingStep?: Record<string, boolean>;
|
||||
timedoutShow: boolean;
|
||||
Question: string;
|
||||
handleSendMessage: (content: string, newChat?: Chat) => void;
|
||||
getFileUrl: (path: string) => string;
|
||||
}
|
||||
|
||||
export const ChatContent = ({
|
||||
activeChat,
|
||||
curChatEnd,
|
||||
query_intent,
|
||||
tools,
|
||||
fetch_source,
|
||||
pick_source,
|
||||
deep_read,
|
||||
think,
|
||||
response,
|
||||
loadingStep,
|
||||
timedoutShow,
|
||||
Question,
|
||||
handleSendMessage,
|
||||
getFileUrl,
|
||||
}: ChatContentProps) => {
|
||||
const sessionId = useConnectStore((state) => state.currentSessionId);
|
||||
const setCurrentSessionId = useConnectStore((state) => {
|
||||
return state.setCurrentSessionId;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSessionId(activeChat?._id);
|
||||
}, [activeChat]);
|
||||
|
||||
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 ||
|
||||
tools ||
|
||||
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}
|
||||
tools={tools}
|
||||
fetch_source={fetch_source}
|
||||
pick_source={pick_source}
|
||||
deep_read={deep_read}
|
||||
think={think}
|
||||
response={response}
|
||||
loadingStep={loadingStep}
|
||||
/>
|
||||
) : null}
|
||||
{timedoutShow ? (
|
||||
<ChatMessage
|
||||
key={"timedout"}
|
||||
message={{
|
||||
_id: "timedout",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: t("assistant.chat.timedout"),
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={false}
|
||||
/>
|
||||
) : null}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{sessionId && uploadFiles.length > 0 && (
|
||||
<div key={sessionId} className="max-h-[120px] overflow-auto p-2">
|
||||
<FileList sessionId={sessionId} getFileUrl={getFileUrl} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionId && <SessionFile sessionId={sessionId} />}
|
||||
|
||||
<Splash />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +1,18 @@
|
||||
import {
|
||||
MessageSquarePlus,
|
||||
ChevronDownIcon,
|
||||
Settings,
|
||||
RefreshCw,
|
||||
Check,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
} from "@headlessui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import HistoryIcon from "@/icons/History";
|
||||
import PinOffIcon from "@/icons/PinOff";
|
||||
import PinIcon from "@/icons/Pin";
|
||||
import ServerIcon from "@/icons/Server";
|
||||
import WindowsFullIcon from "@/icons/WindowsFull";
|
||||
import { useAppStore, IServer } from "@/stores/appStore";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import type { Chat } from "./types";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import VisibleKey from "../Common/VisibleKey";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { HISTORY_PANEL_ID } from "@/constants";
|
||||
import { AssistantList } from "./AssistantList";
|
||||
import { ServerList } from "./ServerList";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
onCreateNewChat: () => void;
|
||||
@@ -39,110 +21,46 @@ interface ChatHeaderProps {
|
||||
isSidebarOpen: boolean;
|
||||
activeChat: Chat | undefined;
|
||||
reconnect: (server?: IServer) => void;
|
||||
isLogin: boolean;
|
||||
setIsLogin: (isLogin: boolean) => void;
|
||||
isChatPage?: boolean;
|
||||
showChatHistory?: boolean;
|
||||
assistantIDs?: string[];
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
onCreateNewChat,
|
||||
onOpenChatAI,
|
||||
isSidebarOpen,
|
||||
setIsSidebarOpen,
|
||||
activeChat,
|
||||
reconnect,
|
||||
isLogin,
|
||||
setIsLogin,
|
||||
isChatPage = false,
|
||||
showChatHistory = true,
|
||||
assistantIDs,
|
||||
}: ChatHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
const isPinned = useAppStore((state) => state.isPinned);
|
||||
const setIsPinned = useAppStore((state) => state.setIsPinned);
|
||||
|
||||
const { connected, setMessages } = useChatStore();
|
||||
|
||||
const [serverList, setServerList] = useState<IServer[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
||||
|
||||
const fetchServers = useCallback(async (resetSelection: boolean) => {
|
||||
invoke("list_coco_servers")
|
||||
.then((res: any) => {
|
||||
const enabledServers = (res as IServer[]).filter(
|
||||
(server) => server.enabled !== false
|
||||
);
|
||||
//console.log("list_coco_servers", enabledServers);
|
||||
setServerList(enabledServers);
|
||||
|
||||
if (resetSelection && enabledServers.length > 0) {
|
||||
const currentServiceExists = enabledServers.find(
|
||||
(server) => server.id === currentService?.id
|
||||
);
|
||||
|
||||
if (currentServiceExists) {
|
||||
switchServer(currentServiceExists);
|
||||
} else {
|
||||
switchServer(enabledServers[enabledServers.length - 1]);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error(err);
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const historicalRecords = useShortcutsStore((state) => {
|
||||
return state.historicalRecords;
|
||||
});
|
||||
}, [currentService?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers(true);
|
||||
|
||||
const unlisten = listen("login_or_logout", (event) => {
|
||||
console.log("Login or Logout:", currentService, event);
|
||||
fetchServers(true);
|
||||
const newSession = useShortcutsStore((state) => {
|
||||
return state.newSession;
|
||||
});
|
||||
const fixedWindow = useShortcutsStore((state) => {
|
||||
return state.fixedWindow;
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cleanup logic if needed
|
||||
disconnect();
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const disconnect = async () => {
|
||||
if (!connected) return;
|
||||
try {
|
||||
console.log("disconnect");
|
||||
await invoke("disconnect");
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const switchServer = async (server: IServer) => {
|
||||
if (!server) return;
|
||||
try {
|
||||
// Switch UI first, then switch server connection
|
||||
setCurrentService(server);
|
||||
setEndpoint(server.endpoint);
|
||||
setMessages(""); // Clear previous messages
|
||||
onCreateNewChat();
|
||||
//
|
||||
if (!server.public && !server.profile) {
|
||||
setIsLogin(false);
|
||||
return;
|
||||
}
|
||||
setIsLogin(true);
|
||||
//
|
||||
await disconnect();
|
||||
reconnect && reconnect(server);
|
||||
} catch (error) {
|
||||
console.error("switchServer:", error);
|
||||
}
|
||||
};
|
||||
const external = useShortcutsStore((state) => state.external);
|
||||
|
||||
const togglePin = async () => {
|
||||
try {
|
||||
const newPinned = !isPinned;
|
||||
await getCurrentWindow().setAlwaysOnTop(newPinned);
|
||||
await platformAdapter.setAlwaysOnTop(newPinned);
|
||||
setIsPinned(newPinned);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle window pin state:", err);
|
||||
@@ -150,183 +68,81 @@ export function ChatHeader({
|
||||
}
|
||||
};
|
||||
|
||||
const openSettings = async () => {
|
||||
emit("open_settings", "connect");
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className="flex items-center justify-between py-2 px-3"
|
||||
className="flex items-center justify-between py-2 px-3 select-none"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{showChatHistory && (
|
||||
<button
|
||||
data-sidebar-button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSidebarOpen();
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
aria-controls={isSidebarOpen ? HISTORY_PANEL_ID : void 0}
|
||||
className="py-1 px-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<HistoryIcon />
|
||||
</button>
|
||||
|
||||
<Menu>
|
||||
<MenuButton className="flex items-center gap-1 rounded-full bg-white dark:bg-[#202126] p-1 text-sm/6 font-semibold text-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none">
|
||||
<img
|
||||
src={logoImg}
|
||||
className="w-4 h-4"
|
||||
alt={t("assistant.message.logo")}
|
||||
/>
|
||||
Coco AI
|
||||
<ChevronDownIcon className="size-4 text-gray-500 dark:text-gray-400" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuItems
|
||||
transition
|
||||
anchor="bottom end"
|
||||
className="w-28 origin-top-right rounded-xl bg-white dark:bg-[#202126] p-1 text-sm/6 text-gray-800 dark:text-white shadow-lg border border-gray-200 dark:border-gray-700 focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0"
|
||||
<VisibleKey
|
||||
shortcut={historicalRecords}
|
||||
onKeyPress={setIsSidebarOpen}
|
||||
>
|
||||
<MenuItem>
|
||||
<button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<img
|
||||
src={logoImg}
|
||||
className="w-4 h-4"
|
||||
alt={t("assistant.message.logo")}
|
||||
/>
|
||||
Coco AI
|
||||
<HistoryIcon className="h-4 w-4" />
|
||||
</VisibleKey>
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
<AssistantList assistantIDs={assistantIDs} />
|
||||
|
||||
{showChatHistory ? (
|
||||
<button
|
||||
onClick={onCreateNewChat}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
className="p-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<MessageSquarePlus className="h-4 w-4" />
|
||||
<VisibleKey shortcut={newSession} onKeyPress={onCreateNewChat}>
|
||||
<MessageSquarePlus className="h-4 w-4 relative top-0.5" />
|
||||
</VisibleKey>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
<h2 className="max-w-[calc(100%-200px)] text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{activeChat?._source?.title ||
|
||||
activeChat?._source?.message ||
|
||||
activeChat?._id}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isTauri ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={togglePin}
|
||||
className={`${isPinned ? "text-blue-500" : ""}`}
|
||||
className={clsx("inline-flex", {
|
||||
"text-blue-500": isPinned,
|
||||
})}
|
||||
>
|
||||
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||
</VisibleKey>
|
||||
</button>
|
||||
|
||||
<Popover className="relative">
|
||||
<PopoverButton className="flex items-center">
|
||||
<ServerIcon />
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel className="absolute right-0 z-10 mt-2 min-w-[240px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between mb-3 whitespace-nowrap">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Servers
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<Settings className="h-4 w-4 text-[#0287FF]" />
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
await fetchServers(false);
|
||||
setTimeout(() => setIsRefreshing(false), 1000);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
|
||||
isRefreshing ? "animate-spin" : ""
|
||||
}`}
|
||||
<ServerList
|
||||
isLogin={isLogin}
|
||||
setIsLogin={setIsLogin}
|
||||
reconnect={reconnect}
|
||||
onCreateNewChat={onCreateNewChat}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{serverList.length > 0 ? (
|
||||
serverList.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
onClick={() => switchServer(server)}
|
||||
className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap ${
|
||||
currentService?.id === server.id
|
||||
? "bg-gray-100 dark:bg-gray-800"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden min-w-0">
|
||||
<img
|
||||
src={server?.provider?.icon || logoImg}
|
||||
alt={server.name}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800"
|
||||
/>
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
|
||||
{server.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
|
||||
AI Assistant: {server.assistantCount || 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
server.health?.status
|
||||
? `bg-[${server.health?.status}]`
|
||||
: "bg-gray-400 dark:bg-gray-600"
|
||||
}`}
|
||||
/>
|
||||
<div className="w-4 h-4">
|
||||
{currentService?.id === server.id && (
|
||||
<Check className="w-full h-full text-gray-500 dark:text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<Server className="w-8 h-8 text-gray-400 dark:text-gray-600 mb-2" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("assistant.chat.noServers")}
|
||||
</p>
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="mt-2 text-xs text-[#0287FF] hover:underline"
|
||||
>
|
||||
{t("assistant.chat.addServer")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
|
||||
{isChatPage ? null : (
|
||||
<button onClick={onOpenChatAI}>
|
||||
<button className="inline-flex" onClick={onOpenChatAI}>
|
||||
<VisibleKey shortcut={external} onKeyPress={onOpenChatAI}>
|
||||
<WindowsFullIcon className="rotate-30 scale-x-[-1]" />
|
||||
</VisibleKey>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
65
src/components/Assistant/ChatSidebar.tsx
Normal file
65
src/components/Assistant/ChatSidebar.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
|
||||
// import { Sidebar } from "@/components/Assistant/Sidebar";
|
||||
import type { Chat } from "./types";
|
||||
import HistoryList from "../Common/HistoryList";
|
||||
import { HISTORY_PANEL_ID } from "@/constants";
|
||||
|
||||
interface ChatSidebarProps {
|
||||
isSidebarOpen: boolean;
|
||||
chats: Chat[];
|
||||
activeChat?: Chat;
|
||||
// onNewChat: () => void;
|
||||
onSelectChat: (chat: any) => void;
|
||||
onDeleteChat: (chatId: string) => void;
|
||||
fetchChatHistory: () => Promise<void>;
|
||||
onSearch: (keyword: string) => void;
|
||||
onRename: (chat: any, title: string) => void;
|
||||
}
|
||||
|
||||
export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
isSidebarOpen,
|
||||
chats,
|
||||
activeChat,
|
||||
// onNewChat,
|
||||
onSelectChat,
|
||||
onDeleteChat,
|
||||
fetchChatHistory,
|
||||
onSearch,
|
||||
onRename,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
data-sidebar
|
||||
className={`
|
||||
h-[calc(100%+90px)] absolute top-0 left-0 z-10 w-64
|
||||
transform transition-all duration-300 ease-in-out
|
||||
${isSidebarOpen ? "translate-x-0" : "-translate-x-full"}
|
||||
bg-gray-100 dark:bg-gray-800
|
||||
border-r border-gray-200 dark:border-gray-700 rounded-tl-xl rounded-bl-xl
|
||||
overflow-hidden
|
||||
`}
|
||||
>
|
||||
{isSidebarOpen && (
|
||||
<HistoryList
|
||||
id={HISTORY_PANEL_ID}
|
||||
list={chats}
|
||||
active={activeChat}
|
||||
onSearch={onSearch}
|
||||
onRefresh={fetchChatHistory}
|
||||
onSelect={onSelectChat}
|
||||
onRename={onRename}
|
||||
onRemove={onDeleteChat}
|
||||
/>
|
||||
)}
|
||||
{/* <Sidebar
|
||||
chats={chats}
|
||||
activeChat={activeChat}
|
||||
onNewChat={onNewChat}
|
||||
onSelectChat={onSelectChat}
|
||||
onDeleteChat={onDeleteChat}
|
||||
fetchChatHistory={fetchChatHistory}
|
||||
/> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
|
||||
import LoginDark from "@/assets/images/login-dark.svg";
|
||||
import LoginLight from "@/assets/images/login-light.svg";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
const ConnectPrompt = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -13,7 +13,7 @@ const ConnectPrompt = () => {
|
||||
const logo = isDark ? LoginDark : LoginLight;
|
||||
|
||||
const handleConnect = async () => {
|
||||
emit("open_settings", "connect");
|
||||
platformAdapter.emitEvent("open_settings", "connect");
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
118
src/components/Assistant/FileList.tsx
Normal file
118
src/components/Assistant/FileList.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
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 FileIcon from "../Common/Icons/FileIcon";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
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: any = await platformAdapter.commands(
|
||||
"upload_attachment",
|
||||
{
|
||||
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));
|
||||
|
||||
platformAdapter.commands("delete_attachment", {
|
||||
serverId,
|
||||
id: attachmentId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
|
||||
{uploadFiles.map((file) => {
|
||||
const { id, name, extname, size, uploaded, attachmentId } = 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;
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatMessage } from "@/components/ChatMessage";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
export const Greetings = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
@@ -12,7 +14,9 @@ export const Greetings = () => {
|
||||
_id: "greetings",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: t("assistant.chat.greetings"),
|
||||
message:
|
||||
currentAssistant?._source?.chat_settings?.greeting_message ||
|
||||
t("assistant.chat.greetings"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
258
src/components/Assistant/ServerList.tsx
Normal file
258
src/components/Assistant/ServerList.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Settings, RefreshCw, Check, Server } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import { useKeyPress } from "ahooks";
|
||||
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import ServerIcon from "@/icons/Server";
|
||||
import VisibleKey from "../Common/VisibleKey";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useAppStore, IServer } from "@/stores/appStore";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { isNil } from "lodash-es";
|
||||
|
||||
interface ServerListProps {
|
||||
isLogin: boolean;
|
||||
setIsLogin: (isLogin: boolean) => void;
|
||||
reconnect: (server?: IServer) => void;
|
||||
onCreateNewChat: () => void;
|
||||
}
|
||||
|
||||
export function ServerList({
|
||||
isLogin,
|
||||
setIsLogin,
|
||||
reconnect,
|
||||
onCreateNewChat,
|
||||
}: ServerListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const serviceList = useShortcutsStore((state) => state.serviceList);
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
|
||||
const { setMessages } = useChatStore();
|
||||
|
||||
const [serverList, setServerList] = useState<IServer[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const serverListButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const fetchServers = useCallback(
|
||||
async (resetSelection: boolean) => {
|
||||
platformAdapter
|
||||
.commands("list_coco_servers")
|
||||
.then((res: any) => {
|
||||
const enabledServers = (res as IServer[]).filter(
|
||||
(server) => server.enabled !== false
|
||||
);
|
||||
//console.log("list_coco_servers", enabledServers);
|
||||
setServerList(enabledServers);
|
||||
|
||||
if (resetSelection && enabledServers.length > 0) {
|
||||
const currentServiceExists = enabledServers.find(
|
||||
(server) => server.id === currentService?.id
|
||||
);
|
||||
|
||||
if (currentServiceExists) {
|
||||
switchServer(currentServiceExists);
|
||||
} else {
|
||||
switchServer(enabledServers[enabledServers.length - 1]);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error(err);
|
||||
});
|
||||
},
|
||||
[currentService?.id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
|
||||
fetchServers(true);
|
||||
|
||||
const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => {
|
||||
console.log("Login or Logout:", currentService, event.payload);
|
||||
if (event.payload !== isLogin) {
|
||||
setIsLogin(!!event.payload);
|
||||
}
|
||||
fetchServers(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cleanup logic if needed
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await fetchServers(false);
|
||||
setTimeout(() => setIsRefreshing(false), 1000);
|
||||
};
|
||||
|
||||
const openSettings = async () => {
|
||||
platformAdapter.emitEvent("open_settings", "connect");
|
||||
};
|
||||
|
||||
const switchServer = async (server: IServer) => {
|
||||
if (!server) return;
|
||||
try {
|
||||
// Switch UI first, then switch server connection
|
||||
setCurrentService(server);
|
||||
setEndpoint(server.endpoint);
|
||||
setMessages(""); // Clear previous messages
|
||||
onCreateNewChat();
|
||||
//
|
||||
if (!server.public && !server.profile) {
|
||||
setIsLogin(false);
|
||||
return;
|
||||
}
|
||||
setIsLogin(true);
|
||||
// The Rust backend will automatically disconnect,
|
||||
// so we don't need to handle disconnection on the frontend
|
||||
// src-tauri/src/server/websocket.rs
|
||||
reconnect && reconnect(server);
|
||||
} catch (error) {
|
||||
console.error("switchServer:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useKeyPress(["uparrow", "downarrow"], (_, key) => {
|
||||
const isClose = isNil(serverListButtonRef.current?.dataset["open"]);
|
||||
const length = serverList.length;
|
||||
|
||||
if (isClose || length <= 1) return;
|
||||
|
||||
const currentIndex = serverList.findIndex((server) => {
|
||||
return server.id === currentService?.id;
|
||||
});
|
||||
|
||||
let nextIndex = currentIndex;
|
||||
|
||||
if (key === "uparrow") {
|
||||
nextIndex = currentIndex > 0 ? currentIndex - 1 : length - 1;
|
||||
} else if (key === "downarrow") {
|
||||
nextIndex = currentIndex < serverList.length - 1 ? currentIndex + 1 : 0;
|
||||
}
|
||||
|
||||
switchServer(serverList[nextIndex]);
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<PopoverButton ref={serverListButtonRef} className="flex items-center">
|
||||
<VisibleKey
|
||||
shortcut={serviceList}
|
||||
onKeyPress={() => {
|
||||
serverListButtonRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<ServerIcon />
|
||||
</VisibleKey>
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel className="absolute right-0 z-10 mt-2 min-w-[240px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between mb-3 whitespace-nowrap">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Servers
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<VisibleKey shortcut=",">
|
||||
<Settings className="h-4 w-4 text-[#0287FF]" />
|
||||
</VisibleKey>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
|
||||
isRefreshing ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
</VisibleKey>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{serverList.length > 0 ? (
|
||||
serverList.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
onClick={() => switchServer(server)}
|
||||
className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap ${
|
||||
currentService?.id === server.id
|
||||
? "bg-gray-100 dark:bg-gray-800"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden min-w-0">
|
||||
<img
|
||||
src={server?.provider?.icon || logoImg}
|
||||
alt={server.name}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800"
|
||||
/>
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
|
||||
{server.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
|
||||
AI Assistant: {server.assistantCount || 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
server.health?.status
|
||||
? `bg-[${server.health?.status}]`
|
||||
: "bg-gray-400 dark:bg-gray-600"
|
||||
}`}
|
||||
/>
|
||||
<div className="size-4 flex justify-end">
|
||||
{currentService?.id === server.id && (
|
||||
<VisibleKey
|
||||
shortcut="↓↑"
|
||||
shortcutClassName="w-6 -translate-x-4"
|
||||
>
|
||||
<Check className="w-full h-full text-gray-500 dark:text-gray-400" />
|
||||
</VisibleKey>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<Server className="w-8 h-8 text-gray-400 dark:text-gray-600 mb-2" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("assistant.chat.noServers")}
|
||||
</p>
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="mt-2 text-xs text-[#0287FF] hover:underline"
|
||||
>
|
||||
{t("assistant.chat.addServer")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
173
src/components/Assistant/SessionFile.tsx
Normal file
173
src/components/Assistant/SessionFile.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import clsx from "clsx";
|
||||
import { filesize } from "filesize";
|
||||
import { Files, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import Checkbox from "@/components/Common/Checkbox";
|
||||
import FileIcon from "@/components/Common/Icons/FileIcon";
|
||||
import { AttachmentHit } from "@/types/commands";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
interface SessionFileProps {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const SessionFile = (props: SessionFileProps) => {
|
||||
const { sessionId } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<AttachmentHit[]>([]);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [checkList, setCheckList] = useState<string[]>([]);
|
||||
|
||||
const serverId = useMemo(() => {
|
||||
return currentService.id;
|
||||
}, [currentService]);
|
||||
|
||||
useEffect(() => {
|
||||
setUploadedFiles([]);
|
||||
|
||||
getUploadedFiles();
|
||||
}, [sessionId]);
|
||||
|
||||
const getUploadedFiles = async () => {
|
||||
if (isTauri) {
|
||||
const response: any = await platformAdapter.commands("get_attachment", {
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
setUploadedFiles(response.hits.hits);
|
||||
} else {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
let result;
|
||||
if (isTauri) {
|
||||
result = await platformAdapter.commands("delete_attachment", {
|
||||
serverId,
|
||||
id,
|
||||
});
|
||||
} else {
|
||||
}
|
||||
if (!result) return;
|
||||
|
||||
getUploadedFiles();
|
||||
};
|
||||
|
||||
const handleCheckAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setCheckList(uploadedFiles.map((item) => item._source.id));
|
||||
} else {
|
||||
setCheckList([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheck = (checked: boolean, id: string) => {
|
||||
if (checked) {
|
||||
setCheckList([...checkList, id]);
|
||||
} else {
|
||||
setCheckList(checkList.filter((item) => item !== id));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("select-none", {
|
||||
hidden: uploadedFiles.length === 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="absolute top-4 right-4 flex items-center justify-center size-8 rounded-lg bg-[#0072FF] cursor-pointer"
|
||||
onClick={() => {
|
||||
setVisible(true);
|
||||
}}
|
||||
>
|
||||
<Files className="size-5 text-white" />
|
||||
|
||||
<div className="absolute -top-2 -right-2 flex items-center justify-center min-w-4 h-4 px-1 text-white text-xs rounded-full bg-[#3DB954]">
|
||||
{uploadedFiles.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute inset-0 flex flex-col p-4 bg-white dark:bg-black",
|
||||
{
|
||||
hidden: !visible,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<X
|
||||
className="absolute top-4 right-4 size-5 text-[#999] cursor-pointer"
|
||||
onClick={() => {
|
||||
setVisible(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mb-2 text-sm text-[#333] dark:text-[#D8D8D8] font-bold">
|
||||
{t("assistant.sessionFile.title")}
|
||||
</div>
|
||||
<div className="flex items-center justify-between pr-2">
|
||||
<span className="text-sm text-[#999]">
|
||||
{t("assistant.sessionFile.description")}
|
||||
</span>
|
||||
|
||||
<Checkbox
|
||||
indeterminate
|
||||
checked={checkList.length === uploadedFiles.length}
|
||||
onChange={handleCheckAll}
|
||||
/>
|
||||
</div>
|
||||
<ul className="flex-1 overflow-auto flex flex-col gap-2 mt-6">
|
||||
{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;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MessageSquare, Plus } from "lucide-react";
|
||||
import { MessageSquare, Plus, RefreshCw } from "lucide-react";
|
||||
|
||||
import type { Chat } from "./types";
|
||||
|
||||
@@ -10,6 +11,7 @@ interface SidebarProps {
|
||||
onSelectChat: (chat: Chat) => void;
|
||||
onDeleteChat: (chatId: string) => void;
|
||||
className?: string;
|
||||
fetchChatHistory: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@@ -18,19 +20,37 @@ export function Sidebar({
|
||||
onNewChat,
|
||||
onSelectChat,
|
||||
className = "",
|
||||
fetchChatHistory,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-col ${className}`}>
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between gap-1 p-4">
|
||||
<button
|
||||
onClick={onNewChat}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all border border-[#E6E6E6] dark:border-[#272626] text-gray-700 hover:bg-gray-50/80 active:bg-gray-100/80 dark:text-white dark:hover:bg-gray-600/50 dark:active:bg-gray-500/50`}
|
||||
className={`flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all border border-[#E6E6E6] dark:border-[#272626] text-gray-700 hover:bg-gray-50/80 active:bg-gray-100/80 dark:text-white dark:hover:bg-gray-600/50 dark:active:bg-gray-500/50`}
|
||||
>
|
||||
<Plus className={`h-4 w-4 text-[#0072FF] dark:text-[#0072FF]`} />
|
||||
{t("assistant.sidebar.newChat")}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
fetchChatHistory();
|
||||
setTimeout(() => setIsRefreshing(false), 1000);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
|
||||
isRefreshing ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-3 space-y-2 custom-scrollbar">
|
||||
{chats.map((chat) => (
|
||||
|
||||
160
src/components/Assistant/Splash.tsx
Normal file
160
src/components/Assistant/Splash.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { CircleX, MoveRight } from "lucide-react";
|
||||
import { useMount } from "ahooks";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import FontIcon from "../Common/Icons/FontIcon";
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import { Get } from "@/api/axiosRequest";
|
||||
|
||||
interface StartPage {
|
||||
enabled?: boolean;
|
||||
logo?: {
|
||||
light?: string;
|
||||
dark?: string;
|
||||
};
|
||||
introduction?: string;
|
||||
display_assistants?: string[];
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
app_settings?: {
|
||||
chat?: {
|
||||
start_page?: StartPage;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const Splash = () => {
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const [settings, setSettings] = useState<StartPage>();
|
||||
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
||||
const setVisibleStartPage = useConnectStore((state) => {
|
||||
return state.setVisibleStartPage;
|
||||
});
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
const assistantList = useConnectStore((state) => state.assistantList);
|
||||
const setCurrentAssistant = useConnectStore((state) => {
|
||||
return state.setCurrentAssistant;
|
||||
});
|
||||
|
||||
useMount(async () => {
|
||||
try {
|
||||
const serverId = currentService.id;
|
||||
|
||||
let response: Response = {};
|
||||
|
||||
if (isTauri) {
|
||||
response = await platformAdapter.invokeBackend<Response>(
|
||||
"get_system_settings",
|
||||
{
|
||||
serverId,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const [err, result] = await Get("/settings");
|
||||
|
||||
if (err) {
|
||||
throw new Error(err);
|
||||
}
|
||||
|
||||
response = result as Response;
|
||||
}
|
||||
|
||||
const settings = response?.app_settings?.chat?.start_page;
|
||||
|
||||
setVisibleStartPage(Boolean(settings?.enabled));
|
||||
|
||||
setSettings(settings);
|
||||
} catch (error) {
|
||||
addError(String(error), "error");
|
||||
}
|
||||
});
|
||||
|
||||
const settingsAssistantList = useMemo(() => {
|
||||
console.log("assistantList", assistantList);
|
||||
|
||||
return assistantList.filter((item) => {
|
||||
return settings?.display_assistants?.includes(item?._source?.id);
|
||||
});
|
||||
}, [settings, assistantList]);
|
||||
|
||||
const logo = useMemo(() => {
|
||||
const { light, dark } = settings?.logo || {};
|
||||
|
||||
if (isDark) {
|
||||
return dark || light;
|
||||
}
|
||||
|
||||
return light || dark;
|
||||
}, [settings, isDark]);
|
||||
|
||||
return (
|
||||
visibleStartPage && (
|
||||
<div className="absolute inset-0 flex flex-col items-center px-6 pt-6 text-[#333] dark:text-white select-none">
|
||||
<CircleX
|
||||
className="absolute top-3 right-3 size-4 text-[#999] cursor-pointer"
|
||||
onClick={() => {
|
||||
setVisibleStartPage(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<img src={logo} className="h-8" />
|
||||
|
||||
<div className="mt-3 mb-6 text-lg font-medium">
|
||||
{settings?.introduction}
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-wrap -m-1 w-full p-0">
|
||||
{settingsAssistantList?.map((item) => {
|
||||
const { id, name, description, icon } = item._source;
|
||||
|
||||
return (
|
||||
<li key={id} className="w-1/2 p-1">
|
||||
<div
|
||||
className="group h-[74px] px-3 py-2 text-sm rounded-xl border dark:border-[#262626] bg-white dark:bg-black cursor-pointer transition hover:!border-[#0087FF]"
|
||||
onClick={() => {
|
||||
setCurrentAssistant(item);
|
||||
|
||||
setVisibleStartPage(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
{icon?.startsWith("font_") ? (
|
||||
<div className="size-4 flex items-center justify-center rounded-full bg-white">
|
||||
<FontIcon name={icon} className="w-5 h-5" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={logoImg}
|
||||
className="size-4 rounded-full"
|
||||
alt={name}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
|
||||
<MoveRight className="size-4 transition group-hover:text-[#0087FF]" />
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-xs text-[#999] line-clamp-2">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default Splash;
|
||||
@@ -15,6 +15,7 @@ export interface ISource {
|
||||
title?: string;
|
||||
question?: string;
|
||||
details?: any[];
|
||||
assistant_id?: string;
|
||||
}
|
||||
export interface Chat {
|
||||
_id: string;
|
||||
|
||||
216
src/components/AudioRecording/index.tsx
Normal file
216
src/components/AudioRecording/index.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useReactive } from "ahooks";
|
||||
import clsx from "clsx";
|
||||
import { Check, Loader, Mic, X } from "lucide-react";
|
||||
import { FC, useEffect, useRef } from "react";
|
||||
import {
|
||||
checkMicrophonePermission,
|
||||
requestMicrophonePermission,
|
||||
} from "tauri-plugin-macos-permissions-api";
|
||||
import { useWavesurfer } from "@wavesurfer/react";
|
||||
import RecordPlugin from "wavesurfer.js/dist/plugins/record.esm.js";
|
||||
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import VisibleKey from "@/components/Common/VisibleKey";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
interface AudioRecordingProps {
|
||||
onChange?: (text: string) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
audioDevices: MediaDeviceInfo[];
|
||||
isRecording: boolean;
|
||||
converting: boolean;
|
||||
countdown: number;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: State = {
|
||||
audioDevices: [],
|
||||
isRecording: false,
|
||||
converting: false,
|
||||
countdown: 30,
|
||||
};
|
||||
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
|
||||
const AudioRecording: FC<AudioRecordingProps> = (props) => {
|
||||
const { onChange } = props;
|
||||
const state = useReactive({ ...INITIAL_STATE });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const recordRef = useRef<RecordPlugin>();
|
||||
const withVisibility = useAppStore((state) => state.withVisibility);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const voiceInput = useShortcutsStore((state) => state.voiceInput);
|
||||
|
||||
const { wavesurfer } = useWavesurfer({
|
||||
container: containerRef,
|
||||
height: 20,
|
||||
waveColor: "#0072ff",
|
||||
progressColor: "#999",
|
||||
barWidth: 4,
|
||||
barRadius: 4,
|
||||
barGap: 2,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getAvailableAudioDevices();
|
||||
|
||||
return resetState;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wavesurfer) return;
|
||||
|
||||
const record = wavesurfer.registerPlugin(
|
||||
RecordPlugin.create({
|
||||
scrollingWaveform: true,
|
||||
renderRecordedAudio: false,
|
||||
})
|
||||
);
|
||||
|
||||
record.on("record-end", (blob) => {
|
||||
if (!state.converting) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onloadend = async () => {
|
||||
const base64Audio = (reader.result as string).split(",")[1];
|
||||
|
||||
const response: any = await platformAdapter.commands("transcription", {
|
||||
serverId: currentService.id,
|
||||
audioType: "mp3",
|
||||
audioContent: base64Audio,
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
|
||||
onChange?.(response.text);
|
||||
|
||||
resetState();
|
||||
};
|
||||
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
recordRef.current = record;
|
||||
}, [wavesurfer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isRecording) return;
|
||||
|
||||
interval = setInterval(() => {
|
||||
if (state.countdown <= 0) {
|
||||
handleOk();
|
||||
}
|
||||
|
||||
state.countdown--;
|
||||
}, 1000);
|
||||
}, [state.isRecording]);
|
||||
|
||||
const getAvailableAudioDevices = async () => {
|
||||
state.audioDevices = await RecordPlugin.getAvailableAudioDevices();
|
||||
};
|
||||
|
||||
const resetState = (otherState: Partial<State> = {}) => {
|
||||
clearInterval(interval);
|
||||
recordRef.current?.stopRecording();
|
||||
Object.assign(state, {
|
||||
...INITIAL_STATE,
|
||||
...otherState,
|
||||
audioDevices: state.audioDevices,
|
||||
});
|
||||
};
|
||||
|
||||
const checkPermission = async () => {
|
||||
const authorized = await checkMicrophonePermission();
|
||||
|
||||
if (authorized) return;
|
||||
|
||||
requestMicrophonePermission();
|
||||
|
||||
return new Promise(async (resolved) => {
|
||||
const timer = setInterval(async () => {
|
||||
const authorized = await checkMicrophonePermission();
|
||||
|
||||
if (!authorized) return;
|
||||
|
||||
clearInterval(timer);
|
||||
|
||||
resolved(true);
|
||||
}, 500);
|
||||
});
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
await withVisibility(checkPermission);
|
||||
state.isRecording = true;
|
||||
recordRef.current?.startRecording();
|
||||
};
|
||||
|
||||
const handleOk = () => {
|
||||
resetState({ converting: true, countdown: state.countdown });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
"p-1 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition cursor-pointer",
|
||||
{
|
||||
hidden: state.audioDevices.length === 0,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<VisibleKey shortcut={voiceInput} onKeyPress={startRecording}>
|
||||
<Mic className="size-4 text-[#999]" onClick={startRecording} />
|
||||
</VisibleKey>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute inset-0 flex items-center gap-1 px-1 rounded translate-x-full transition-all bg-[#ededed] dark:bg-[#202126]",
|
||||
{
|
||||
"!translate-x-0": state.isRecording || state.converting,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<button
|
||||
disabled={state.converting}
|
||||
className={clsx(
|
||||
"flex items-center justify-center size-6 bg-white dark:bg-black rounded-full transition cursor-pointer",
|
||||
{
|
||||
"!cursor-not-allowed opacity-50": state.converting,
|
||||
}
|
||||
)}
|
||||
onClick={() => resetState()}
|
||||
>
|
||||
<X className="size-4 text-[#0C0C0C] dark:text-[#999999]" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1 flex-1 h-6 px-2 bg-white dark:bg-black rounded-full transition">
|
||||
<div ref={containerRef} className="flex-1"></div>
|
||||
|
||||
<span className="text-xs text-[#333] dark:text-[#999]">
|
||||
{state.countdown}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={state.converting}
|
||||
className="flex items-center justify-center size-6 text-white bg-[#0072FF] rounded-full transition cursor-pointer"
|
||||
onClick={handleOk}
|
||||
>
|
||||
{state.converting ? (
|
||||
<Loader className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioRecording;
|
||||
82
src/components/ChatMessage/CallTools.tsx
Normal file
82
src/components/ChatMessage/CallTools.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Loader, Hammer, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { IChunkData } from "@/components/Assistant/types";
|
||||
import Markdown from "./Markdown";
|
||||
|
||||
interface CallToolsProps {
|
||||
Detail?: any;
|
||||
ChunkData?: IChunkData;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const CallTools = ({ Detail, ChunkData, loading }: CallToolsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
|
||||
|
||||
const [Data, setData] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!Detail?.description) return;
|
||||
setData(Detail?.description);
|
||||
}, [Detail?.description]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ChunkData?.message_chunk) return;
|
||||
setData(ChunkData?.message_chunk);
|
||||
}, [ChunkData?.message_chunk, Data]);
|
||||
|
||||
// Must be after hooks !!!
|
||||
if (!ChunkData && !Detail) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mb-3 w-full">
|
||||
<button
|
||||
onClick={() => setIsThinkingExpanded((prev) => !prev)}
|
||||
className="inline-flex items-center gap-2 px-2 py-1 rounded-xl transition-colors border border-[#E6E6E6] dark:border-[#272626]"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 animate-spin text-[#1990FF]" />
|
||||
<span className="text-xs text-[#999999] italic">
|
||||
{t(`assistant.message.steps.${ChunkData?.chunk_type}`)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Hammer className="w-4 h-4 text-[#38C200]" />
|
||||
<span className="text-xs text-[#999999]">
|
||||
{t(`assistant.message.steps.${ChunkData?.chunk_type}`)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isThinkingExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{isThinkingExpanded && (
|
||||
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
||||
<Markdown
|
||||
content={Data || ""}
|
||||
loading={loading}
|
||||
onDoubleClickCapture={() => {}}
|
||||
/>
|
||||
{/* {Data?.split("\n").map(
|
||||
(paragraph, idx) =>
|
||||
paragraph.trim() && (
|
||||
<p key={idx} className="text-sm">
|
||||
{paragraph}
|
||||
</p>
|
||||
)
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -42,7 +42,7 @@ export const DeepRead = ({
|
||||
}
|
||||
}, [ChunkData?.message_chunk]);
|
||||
|
||||
// Must be after hooks !!!
|
||||
// Must be after hooks !!!
|
||||
if (!ChunkData && !Detail) return null;
|
||||
|
||||
return (
|
||||
@@ -84,7 +84,7 @@ export const DeepRead = ({
|
||||
)}
|
||||
</button>
|
||||
{isThinkingExpanded && (
|
||||
<div className="pl-2 border-l-2 border-[e5e5e5]">
|
||||
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
||||
<div className="mb-4 space-y-3 text-xs">
|
||||
{Data?.map((item) => (
|
||||
|
||||
@@ -34,8 +34,13 @@ interface ISourceData {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const FetchSource = ({ Detail, ChunkData }: FetchSourceProps) => {
|
||||
export const FetchSource = ({
|
||||
Detail,
|
||||
ChunkData,
|
||||
loading,
|
||||
}: FetchSourceProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isSourceExpanded, setIsSourceExpanded] = useState(false);
|
||||
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -51,15 +56,18 @@ export const FetchSource = ({ Detail, ChunkData }: FetchSourceProps) => {
|
||||
useEffect(() => {
|
||||
if (!ChunkData?.message_chunk) return;
|
||||
|
||||
if (!loading) {
|
||||
try {
|
||||
const match = ChunkData.message_chunk.match(
|
||||
/\u003cPayload total=(\d+)\u003e/
|
||||
// /\u003cPayload total=(\d+)\u003e/
|
||||
/<Payload total=(\d+)>/
|
||||
);
|
||||
if (match) {
|
||||
setTotal(Number(match[1]));
|
||||
}
|
||||
|
||||
const jsonMatch = ChunkData.message_chunk.match(/\[(.*)\]/s);
|
||||
// const jsonMatch = ChunkData.message_chunk.match(/\[(.*)\]/s);
|
||||
const jsonMatch = ChunkData.message_chunk.match(/\[([\s\S]*)\]/);
|
||||
if (jsonMatch) {
|
||||
const jsonData = JSON.parse(jsonMatch[0]);
|
||||
setData(jsonData);
|
||||
@@ -67,14 +75,15 @@ export const FetchSource = ({ Detail, ChunkData }: FetchSourceProps) => {
|
||||
} catch (e) {
|
||||
console.error("Failed to parse fetch source data:", e);
|
||||
}
|
||||
}, [ChunkData?.message_chunk]);
|
||||
}
|
||||
}, [ChunkData?.message_chunk, loading]);
|
||||
|
||||
// Must be after hooks !!!
|
||||
// Must be after hooks !!!
|
||||
if (!ChunkData && !Detail) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-2 mb-2 w-[610px] ${
|
||||
className={`mt-2 mb-2 max-w-full w-full md:w-[610px] ${
|
||||
isSourceExpanded
|
||||
? "rounded-lg overflow-hidden border border-[#E6E6E6] dark:border-[#272626]"
|
||||
: ""
|
||||
@@ -120,13 +129,15 @@ export const FetchSource = ({ Detail, ChunkData }: FetchSourceProps) => {
|
||||
className="group flex items-center p-2 hover:bg-[#F7F7F7] dark:hover:bg-[#2C2C2C] border-b border-[#E6E6E6] dark:border-[#272626] last:border-b-0 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="w-full flex items-center gap-2">
|
||||
<div className="w-[75%] flex items-center gap-1">
|
||||
<div className="w-full md:w-[75%] flex items-center gap-1">
|
||||
<Globe className="w-3 h-3 flex-shrink-0" />
|
||||
<div className="text-xs text-[#333333] dark:text-[#D8D8D8] truncate font-normal group-hover:text-[#0072FF] dark:group-hover:text-[#0072FF]">
|
||||
{item.title || item.category}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[25%] flex items-center justify-end gap-2">
|
||||
<div
|
||||
className={`flex mobile:hidden w-[25%] items-center justify-end gap-2`}
|
||||
>
|
||||
<span className="text-xs text-[#999999] dark:text-[#999999] truncate">
|
||||
{item.source?.name}
|
||||
</span>
|
||||
|
||||
@@ -9,9 +9,12 @@ import RehypeHighlight from "rehype-highlight";
|
||||
import mermaid from "mermaid";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { copyToClipboard, useWindowSize } from "@/utils";
|
||||
import {
|
||||
copyToClipboard,
|
||||
// useWindowSize
|
||||
} from "@/utils";
|
||||
|
||||
import "./markdown.css";
|
||||
import "./markdown.scss";
|
||||
import "./highlight.css";
|
||||
|
||||
// 8
|
||||
@@ -67,9 +70,9 @@ function PreCode(props: { children?: any }) {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
// const previewRef = useRef<HTMLPreviewHander>(null);
|
||||
const [mermaidCode, setMermaidCode] = useState("");
|
||||
const [htmlCode, setHtmlCode] = useState("");
|
||||
const { height } = useWindowSize();
|
||||
console.log(htmlCode, height);
|
||||
// const [htmlCode, setHtmlCode] = useState("");
|
||||
// const { height } = useWindowSize();
|
||||
// console.log(htmlCode, height);
|
||||
|
||||
const renderArtifacts = useDebouncedCallback(() => {
|
||||
if (!ref.current) return;
|
||||
@@ -77,17 +80,17 @@ function PreCode(props: { children?: any }) {
|
||||
if (mermaidDom) {
|
||||
setMermaidCode((mermaidDom as HTMLElement).innerText);
|
||||
}
|
||||
const htmlDom = ref.current.querySelector("code.language-html");
|
||||
const refText = ref.current.querySelector("code")?.innerText;
|
||||
if (htmlDom) {
|
||||
setHtmlCode((htmlDom as HTMLElement).innerText);
|
||||
} else if (refText?.startsWith("<!DOCTYPE")) {
|
||||
setHtmlCode(refText);
|
||||
}
|
||||
// const htmlDom = ref.current.querySelector("code.language-html");
|
||||
// const refText = ref.current.querySelector("code")?.innerText;
|
||||
// if (htmlDom) {
|
||||
// setHtmlCode((htmlDom as HTMLElement).innerText);
|
||||
// } else if (refText?.startsWith("<!DOCTYPE")) {
|
||||
// setHtmlCode(refText);
|
||||
// }
|
||||
}, 600);
|
||||
|
||||
const enableArtifacts = true;
|
||||
console.log(enableArtifacts);
|
||||
// const enableArtifacts = true;
|
||||
// console.log(enableArtifacts);
|
||||
|
||||
//Wrap the paragraph for plain-text
|
||||
useEffect(() => {
|
||||
@@ -294,6 +297,7 @@ export default function Markdown(
|
||||
const mdRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div className="coco-chat">
|
||||
<div
|
||||
className="markdown-body"
|
||||
style={{
|
||||
@@ -307,5 +311,6 @@ export default function Markdown(
|
||||
>
|
||||
<MarkdownContent content={props.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export const PickSource = ({
|
||||
useEffect(() => {
|
||||
if (!ChunkData?.message_chunk) return;
|
||||
|
||||
if (loading) {
|
||||
if (!loading) {
|
||||
try {
|
||||
const cleanContent = ChunkData.message_chunk.replace(/^"|"$/g, "");
|
||||
const allMatches = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/g);
|
||||
@@ -44,7 +44,7 @@ export const PickSource = ({
|
||||
if (allMatches) {
|
||||
for (let i = allMatches.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const jsonString = allMatches[i].replace(/<JSON>|<\/JSON>/g, "");
|
||||
const jsonString = allMatches[i].replace(/<JSON>|<\/JSON>|<think>|<\/think>/g, "");
|
||||
const data = JSON.parse(jsonString.trim());
|
||||
|
||||
if (
|
||||
@@ -65,7 +65,7 @@ export const PickSource = ({
|
||||
}
|
||||
}, [ChunkData?.message_chunk, loading]);
|
||||
|
||||
// Must be after hooks !!!
|
||||
// Must be after hooks !!!
|
||||
if (!ChunkData && !Detail) return null;
|
||||
|
||||
return (
|
||||
@@ -103,7 +103,7 @@ export const PickSource = ({
|
||||
)}
|
||||
</button>
|
||||
{isThinkingExpanded && (
|
||||
<div className="pl-2 border-l-2 border-[e5e5e5]">
|
||||
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
||||
<div className="mb-4 space-y-3 text-xs">
|
||||
{Data?.map((item) => (
|
||||
|
||||
45
src/components/ChatMessage/PrevSuggestion.tsx
Normal file
45
src/components/ChatMessage/PrevSuggestion.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { MoveRight } from "lucide-react";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
interface PrevSuggestionProps {
|
||||
sendMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
const PrevSuggestion: FC<PrevSuggestionProps> = (props) => {
|
||||
const { sendMessage } = props;
|
||||
|
||||
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||||
|
||||
const [list, setList] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const suggested = currentAssistant?._source?.chat_settings?.suggested || {};
|
||||
if (suggested.enabled) {
|
||||
setList(suggested.questions || []);
|
||||
} else {
|
||||
setList([]);
|
||||
}
|
||||
}, [JSON.stringify(currentAssistant)]);
|
||||
|
||||
return (
|
||||
<ul className="absolute left-2 bottom-2 flex flex-col gap-2">
|
||||
{list.map((item) => {
|
||||
return (
|
||||
<li
|
||||
key={item}
|
||||
className="flex items-center self-start gap-2 px-3 py-2 leading-4 text-sm text-[#333] dark:text-[#d8d8d8] rounded-xl border border-black/15 dark:border-white/15 hover:!border-[#0072ff] hover:!text-[#0072ff] transition cursor-pointer"
|
||||
onClick={() => sendMessage(item)}
|
||||
>
|
||||
{item}
|
||||
|
||||
<MoveRight className="size-4" />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrevSuggestion;
|
||||
@@ -42,7 +42,7 @@ export const QueryIntent = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!ChunkData?.message_chunk) return;
|
||||
if (loading) {
|
||||
if (!loading) {
|
||||
const cleanContent = ChunkData.message_chunk.replace(/^"|"$/g, "");
|
||||
const allMatches = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/g);
|
||||
if (allMatches) {
|
||||
@@ -66,7 +66,7 @@ export const QueryIntent = ({
|
||||
}
|
||||
}, [ChunkData?.message_chunk]);
|
||||
|
||||
// Must be after hooks !!!
|
||||
// Must be after hooks !!!
|
||||
if (!ChunkData && !Detail) return null;
|
||||
|
||||
return (
|
||||
@@ -97,7 +97,7 @@ export const QueryIntent = ({
|
||||
)}
|
||||
</button>
|
||||
{isThinkingExpanded && (
|
||||
<div className="pl-2 border-l-2 border-[e5e5e5]">
|
||||
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
||||
<div className="mb-4 space-y-2 text-xs">
|
||||
{Data?.keyword ? (
|
||||
@@ -108,7 +108,7 @@ export const QueryIntent = ({
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Data?.keyword?.map((keyword, index) => (
|
||||
<span
|
||||
key={index}
|
||||
key={keyword + index}
|
||||
className="text-[#333333] dark:text-[#D8D8D8]"
|
||||
>
|
||||
{keyword}
|
||||
@@ -144,8 +144,8 @@ export const QueryIntent = ({
|
||||
- {t("assistant.message.steps.relatedQuestions")}:
|
||||
</span>
|
||||
<div className="flex-1 flex flex-col text-[#333333] dark:text-[#D8D8D8]">
|
||||
{Data?.query?.map((question) => (
|
||||
<span key={question}>- {question}</span>
|
||||
{Data?.query?.map((question, qIndex) => (
|
||||
<span key={question + qIndex}>- {question}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
|
||||
setData(ChunkData?.message_chunk);
|
||||
}, [ChunkData?.message_chunk, Data]);
|
||||
|
||||
// Must be after hooks !!!
|
||||
// Must be after hooks !!!
|
||||
if (!ChunkData && !Detail) return null;
|
||||
|
||||
return (
|
||||
@@ -57,7 +57,7 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
|
||||
)}
|
||||
</button>
|
||||
{isThinkingExpanded && (
|
||||
<div className="pl-2 border-l-2 border-[e5e5e5]">
|
||||
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
||||
{Data?.split("\n").map(
|
||||
(paragraph, idx) =>
|
||||
|
||||
33
src/components/ChatMessage/UserMessage.tsx
Normal file
33
src/components/ChatMessage/UserMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import type { Message, IChunkData } from "@/components/Assistant/types";
|
||||
import { QueryIntent } from "./QueryIntent";
|
||||
import { CallTools } from "./CallTools";
|
||||
import { FetchSource } from "./FetchSource";
|
||||
import { PickSource } from "./PickSource";
|
||||
import { DeepRead } from "./DeepRead";
|
||||
@@ -11,11 +13,15 @@ import { Think } from "./Think";
|
||||
import { MessageActions } from "./MessageActions";
|
||||
import Markdown from "./Markdown";
|
||||
import { SuggestionList } from "./SuggestionList";
|
||||
import { UserMessage } from "./UserMessage";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import FontIcon from "@/components/Common/Icons/FontIcon";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
isTyping?: boolean;
|
||||
query_intent?: IChunkData;
|
||||
tools?: IChunkData;
|
||||
fetch_source?: IChunkData;
|
||||
pick_source?: IChunkData;
|
||||
deep_read?: IChunkData;
|
||||
@@ -29,6 +35,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
message,
|
||||
isTyping,
|
||||
query_intent,
|
||||
tools,
|
||||
fetch_source,
|
||||
pick_source,
|
||||
deep_read,
|
||||
@@ -39,7 +46,24 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
}: ChatMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||||
const assistantList = useConnectStore((state) => state.assistantList);
|
||||
const [assistant, setAssistant] = useState<any>({});
|
||||
|
||||
const isAssistant = message?._source?.type === "assistant";
|
||||
const assistant_id = message?._source?.assistant_id;
|
||||
|
||||
useEffect(() => {
|
||||
let target = currentAssistant;
|
||||
if (isAssistant && assistant_id && Array.isArray(assistantList)) {
|
||||
const found = assistantList.find((item) => item._id === assistant_id);
|
||||
if (found) {
|
||||
target = found;
|
||||
}
|
||||
}
|
||||
setAssistant(target);
|
||||
}, [isAssistant, assistant_id, assistantList, currentAssistant]);
|
||||
|
||||
const messageContent = message?._source?.message || "";
|
||||
const details = message?._source?.details || [];
|
||||
const question = message?._source?.question || "";
|
||||
@@ -48,6 +72,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
isTyping === false && (messageContent || response?.message_chunk);
|
||||
|
||||
const [suggestion, setSuggestion] = useState<string[]>([]);
|
||||
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
||||
|
||||
const getSuggestion = (suggestion: string[]) => {
|
||||
setSuggestion(suggestion);
|
||||
@@ -55,11 +80,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
|
||||
const renderContent = () => {
|
||||
if (!isAssistant) {
|
||||
return (
|
||||
<div className="px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8]">
|
||||
{messageContent}
|
||||
</div>
|
||||
);
|
||||
return <UserMessage messageContent={messageContent} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -70,6 +91,13 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
getSuggestion={getSuggestion}
|
||||
loading={loadingStep?.query_intent}
|
||||
/>
|
||||
|
||||
<CallTools
|
||||
Detail={details.find((item) => item.type === "tools")}
|
||||
ChunkData={tools}
|
||||
loading={loadingStep?.tools}
|
||||
/>
|
||||
|
||||
<FetchSource
|
||||
Detail={details.find((item) => item.type === "fetch_source")}
|
||||
ChunkData={fetch_source}
|
||||
@@ -120,7 +148,13 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`py-8 flex ${isAssistant ? "justify-start" : "justify-end"}`}
|
||||
className={clsx(
|
||||
"py-8 flex",
|
||||
[isAssistant ? "justify-start" : "justify-end"],
|
||||
{
|
||||
hidden: visibleStartPage,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={`px-4 flex gap-4 ${
|
||||
@@ -128,20 +162,28 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`space-y-2 ${isAssistant ? "text-left" : "text-right"}`}
|
||||
className={`w-full space-y-2 ${
|
||||
isAssistant ? "text-left" : "text-right"
|
||||
}`}
|
||||
>
|
||||
<p className="flex items-center gap-1 font-semibold text-sm text-[#333] dark:text-[#d8d8d8]">
|
||||
<div className="w-full flex items-center gap-1 font-semibold text-sm text-[#333] dark:text-[#d8d8d8]">
|
||||
{isAssistant ? (
|
||||
<div className="w-6 h-6 flex justify-center items-center rounded-full bg-white border border-[#E6E6E6]">
|
||||
{assistant?._source?.icon?.startsWith("font_") ? (
|
||||
<FontIcon name={assistant._source.icon} className="w-4 h-4" />
|
||||
) : (
|
||||
<img
|
||||
src={logoImg}
|
||||
className="w-6 h-6"
|
||||
className="w-4 h-4"
|
||||
alt={t("assistant.message.logo")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{isAssistant ? t("assistant.message.aiName") : ""}
|
||||
</p>
|
||||
<div className="prose dark:prose-invert prose-sm max-w-none">
|
||||
<div className="pl-7 text-[#333] dark:text-[#d8d8d8] leading-relaxed">
|
||||
{isAssistant ? assistant?._source?.name || "Coco AI" : ""}
|
||||
</div>
|
||||
<div className="w-full prose dark:prose-invert prose-sm max-w-none">
|
||||
<div className="w-full pl-7 text-[#333] dark:text-[#d8d8d8] leading-relaxed">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1147
src/components/ChatMessage/markdown.scss
Normal file
1147
src/components/ChatMessage/markdown.scss
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ import {
|
||||
getCurrent as getCurrentDeepLinkUrls,
|
||||
onOpenUrl,
|
||||
} from "@tauri-apps/plugin-deep-link";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
@@ -28,14 +27,25 @@ import { useAppStore } from "@/stores/appStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
|
||||
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
||||
import Tooltip from "@/components/Common/Tooltip";
|
||||
import {
|
||||
list_coco_servers,
|
||||
add_coco_server,
|
||||
enable_server,
|
||||
disable_server,
|
||||
logout_coco_server,
|
||||
remove_coco_server,
|
||||
refresh_coco_server_info,
|
||||
handle_sso_callback,
|
||||
} from "@/commands";
|
||||
|
||||
export default function Cloud() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const SidebarRef = useRef<{ refreshData: () => void }>(null);
|
||||
|
||||
const error = useAppStore((state) => state.error);
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
const errors = useAppStore((state) => state.errors);
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
|
||||
const [isConnect, setIsConnect] = useState(true);
|
||||
|
||||
@@ -60,14 +70,13 @@ export default function Cloud() {
|
||||
console.log("currentService", currentService);
|
||||
setLoading(false);
|
||||
setRefreshLoading(false);
|
||||
setError("");
|
||||
setIsConnect(true);
|
||||
}, [JSON.stringify(currentService)]);
|
||||
|
||||
const fetchServers = async (resetSelection: boolean) => {
|
||||
invoke("list_coco_servers")
|
||||
list_coco_servers()
|
||||
.then((res: any) => {
|
||||
if (error) {
|
||||
if (errors.length > 0) {
|
||||
res = (res || []).map((item: any) => {
|
||||
if (item.id === currentService?.id) {
|
||||
item.health = {
|
||||
@@ -92,12 +101,11 @@ export default function Cloud() {
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
const add_coco_server = (endpointLink: string) => {
|
||||
const addServer = (endpointLink: string) => {
|
||||
if (!endpointLink) {
|
||||
throw new Error("Endpoint is required");
|
||||
}
|
||||
@@ -110,26 +118,14 @@ export default function Cloud() {
|
||||
|
||||
setRefreshLoading(true);
|
||||
|
||||
return invoke("add_coco_server", { endpoint: endpointLink })
|
||||
return add_coco_server(endpointLink)
|
||||
.then((res: any) => {
|
||||
// console.log("add_coco_server", res);
|
||||
fetchServers(false)
|
||||
.then((r) => {
|
||||
fetchServers(false).then((r) => {
|
||||
console.log("fetchServers", r);
|
||||
setCurrentService(res);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error("fetchServers failed:", err);
|
||||
setError(err);
|
||||
throw err; // Propagate error back up to outer promise chain
|
||||
});
|
||||
})
|
||||
.catch((err: any) => {
|
||||
// Handle the invoke error
|
||||
console.error("add coco server failed:", err);
|
||||
setError(err);
|
||||
throw err; // Propagate error back up
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
@@ -137,14 +133,14 @@ export default function Cloud() {
|
||||
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (code: string | null, serverId: string | null) => {
|
||||
if (!code) {
|
||||
setError("No authorization code received");
|
||||
if (!code || !serverId) {
|
||||
addError("No authorization code received");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Handling OAuth callback:", { code, serverId });
|
||||
await invoke("handle_sso_callback", {
|
||||
await handle_sso_callback({
|
||||
serverId: serverId, // Make sure 'server_id' is the correct argument
|
||||
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
|
||||
code: code,
|
||||
@@ -154,15 +150,9 @@ export default function Cloud() {
|
||||
refreshClick(serverId);
|
||||
}
|
||||
|
||||
getCurrentWindow()
|
||||
.setFocus()
|
||||
.catch((err) => {
|
||||
setError(err);
|
||||
});
|
||||
getCurrentWindow().setFocus();
|
||||
} catch (e) {
|
||||
console.error("Sign in failed:", e);
|
||||
setError("SSO login failed: " + e);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -181,7 +171,7 @@ export default function Cloud() {
|
||||
|
||||
if (reqId != ssoRequestID) {
|
||||
console.log("Request ID not matched, skip");
|
||||
setError("Request ID not matched, skip");
|
||||
addError("Request ID not matched, skip");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -189,7 +179,7 @@ export default function Cloud() {
|
||||
handleOAuthCallback(code, serverId);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse URL:", err);
|
||||
setError("Invalid URL format: " + err);
|
||||
addError("Invalid URL format: " + err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,7 +215,7 @@ export default function Cloud() {
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get initial URLs:", err);
|
||||
setError("Failed to get initial URLs: " + err);
|
||||
addError("Failed to get initial URLs: " + err);
|
||||
});
|
||||
|
||||
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
|
||||
@@ -256,7 +246,7 @@ export default function Cloud() {
|
||||
|
||||
const refreshClick = (id: string) => {
|
||||
setRefreshLoading(true);
|
||||
invoke("refresh_coco_server_info", { id })
|
||||
refresh_coco_server_info(id)
|
||||
.then((res: any) => {
|
||||
console.log("refresh_coco_server_info", id, res);
|
||||
fetchServers(false).then((r) => {
|
||||
@@ -266,10 +256,6 @@ export default function Cloud() {
|
||||
setCurrentService(res);
|
||||
emit("login_or_logout", true);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
@@ -282,49 +268,40 @@ export default function Cloud() {
|
||||
function onLogout(id: string) {
|
||||
console.log("onLogout", id);
|
||||
setRefreshLoading(true);
|
||||
invoke("logout_coco_server", { id })
|
||||
logout_coco_server(id)
|
||||
.then((res: any) => {
|
||||
console.log("logout_coco_server", id, JSON.stringify(res));
|
||||
refreshClick(id);
|
||||
emit("login_or_logout", false);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
const remove_coco_server = (id: string) => {
|
||||
invoke("remove_coco_server", { id })
|
||||
.then((res: any) => {
|
||||
const removeServer = (id: string) => {
|
||||
remove_coco_server(id).then((res: any) => {
|
||||
console.log("remove_coco_server", id, JSON.stringify(res));
|
||||
fetchServers(true).then((r) => {
|
||||
console.log("fetchServers", r);
|
||||
});
|
||||
})
|
||||
.catch((err: any) => {
|
||||
// TODO display the error message
|
||||
setError(err);
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
const enable_coco_server = useCallback(async (enabled: boolean) => {
|
||||
try {
|
||||
const command = enabled ? "enable_server" : "disable_server";
|
||||
|
||||
await invoke(command, { id: currentService?.id });
|
||||
const enable_coco_server = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
await enable_server(currentService?.id);
|
||||
} else {
|
||||
await disable_server(currentService?.id);
|
||||
}
|
||||
|
||||
setCurrentService({ ...currentService, enabled });
|
||||
|
||||
await fetchServers(false);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}, [currentService?.id]);
|
||||
},
|
||||
[currentService?.id]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex bg-gray-50 dark:bg-gray-900">
|
||||
@@ -346,9 +323,11 @@ export default function Cloud() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center text-gray-900 dark:text-white font-medium">
|
||||
<Tooltip content={currentService?.endpoint}>
|
||||
<div className="flex items-center text-gray-900 dark:text-white font-medium cursor-pointer">
|
||||
{currentService?.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsToggle
|
||||
@@ -385,7 +364,7 @@ export default function Cloud() {
|
||||
{!currentService?.builtin && (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||
onClick={() => remove_coco_server(currentService?.id)}
|
||||
onClick={() => removeServer(currentService?.id)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
|
||||
</button>
|
||||
@@ -457,8 +436,7 @@ export default function Cloud() {
|
||||
<Copy className="inline mr-2" />{" "}
|
||||
</button>
|
||||
<div className="text-justify italic text-xs">
|
||||
If the link did not open automatically, please copy
|
||||
and paste it into your browser manually.
|
||||
{t("cloud.manualCopyLink")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -484,7 +462,7 @@ export default function Cloud() {
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<Connect setIsConnect={setIsConnect} onAddServer={add_coco_server} />
|
||||
<Connect setIsConnect={setIsConnect} onAddServer={addServer} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,8 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
||||
const { t } = useTranslation();
|
||||
const [endpointLink, setEndpointLink] = useState("");
|
||||
const [refreshLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState(""); // State to store the error message
|
||||
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -36,17 +35,10 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
||||
typeof err === "string"
|
||||
? err
|
||||
: err?.message || "An unknown error occurred.";
|
||||
setErrorMessage("ERR:" + errorMessage);
|
||||
setError(errorMessage);
|
||||
console.error("Error:", errorMessage);
|
||||
addError(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to close the error message
|
||||
const closeError = () => {
|
||||
setErrorMessage("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
@@ -96,31 +88,6 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/*//TODO move to outer container, move error state to global*/}
|
||||
{errorMessage && (
|
||||
<div className="mb-8">
|
||||
<div
|
||||
style={{
|
||||
color: "red",
|
||||
marginTop: "10px",
|
||||
display: "block", // Makes sure the error message starts on a new line
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
<span>{errorMessage}</span>
|
||||
<button
|
||||
onClick={closeError}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "red",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import source_default_img from "@/assets/images/source_default.png";
|
||||
import source_default_dark_img from "@/assets/images/source_default_dark.png";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import FontIcon from "../Common/Icons/FontIcon";
|
||||
|
||||
interface Account {
|
||||
email: string;
|
||||
@@ -13,11 +14,12 @@ interface Account {
|
||||
|
||||
interface DataSourceItemProps {
|
||||
name: string;
|
||||
icon?: string;
|
||||
connector: any;
|
||||
accounts?: Account[];
|
||||
}
|
||||
|
||||
export function DataSourceItem({ name, connector }: DataSourceItemProps) {
|
||||
export function DataSourceItem({ name, icon, connector }: DataSourceItemProps) {
|
||||
// const isConnected = true;
|
||||
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
@@ -56,7 +58,12 @@ export function DataSourceItem({ name, connector }: DataSourceItemProps) {
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img src={getTypeIcon()} alt={name} className="w-6 h-6" />
|
||||
{icon?.startsWith("font_") ? (
|
||||
<FontIcon name={icon} className="size-6" />
|
||||
) : (
|
||||
<img src={getTypeIcon()} alt={name} className="size-6" />
|
||||
)}
|
||||
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{name}
|
||||
</span>
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { DataSourceItem } from "./DataSourceItem";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import {
|
||||
get_connectors_by_server,
|
||||
datasource_search,
|
||||
} from "@/commands";
|
||||
|
||||
export function DataSourcesList({ server }: { server: string }) {
|
||||
const { t } = useTranslation();
|
||||
const datasourceData = useConnectStore((state) => state.datasourceData);
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
|
||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||
const setDatasourceData = useConnectStore((state) => state.setDatasourceData);
|
||||
const setConnectorData = useConnectStore((state) => state.setConnectorData);
|
||||
|
||||
function initServerAppData({ server }: { server: string }) {
|
||||
//fetch datasource data
|
||||
invoke("get_connectors_by_server", { id: server })
|
||||
get_connectors_by_server(server)
|
||||
.then((res: any) => {
|
||||
// console.log("get_connectors_by_server", res);
|
||||
setConnectorData(res, server);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
throw err; // Propagate error back up
|
||||
})
|
||||
.finally(() => {});
|
||||
|
||||
//fetch datasource data
|
||||
invoke("get_datasources_by_server", { id: server })
|
||||
datasource_search(server)
|
||||
.then((res: any) => {
|
||||
// console.log("get_datasources_by_server", res);
|
||||
// console.log("datasource_search", res);
|
||||
setDatasourceData(res, server);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
throw err; // Propagate error back up
|
||||
})
|
||||
.finally(() => {});
|
||||
}
|
||||
|
||||
@@ -45,8 +39,6 @@ export function DataSourcesList({ server }: { server: string }) {
|
||||
setRefreshLoading(true);
|
||||
try {
|
||||
initServerAppData({ server });
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
setRefreshLoading(false);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
||||
return (
|
||||
<div
|
||||
key={item?.id}
|
||||
className={`flex cursor-pointer items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg mb-2 ${
|
||||
className={`flex cursor-pointer items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg mb-2 whitespace-nowrap ${
|
||||
currentService?.id === item?.id
|
||||
? "dark:bg-blue-900/20 dark:bg-blue-900 border border-[#0087ff]"
|
||||
: "bg-gray-50 dark:bg-gray-900 border border-[#e6e6e6] dark:border-gray-700"
|
||||
@@ -37,9 +37,9 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
||||
<img
|
||||
src={item?.provider?.icon || cocoLogoImg}
|
||||
alt="LogoImg"
|
||||
className="w-5 h-5"
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
/>
|
||||
<span className="font-medium">{item?.name}</span>
|
||||
<span className="font-medium truncate max-w-[140px]">{item?.name}</span>
|
||||
<div className="flex-1" />
|
||||
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
|
||||
{item.health?.status ? (
|
||||
|
||||
@@ -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;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useCallback } from "react";
|
||||
import { Bot, Search } from "lucide-react";
|
||||
|
||||
import { isMetaOrCtrlKey } from "@/utils/keyboardUtils";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
|
||||
interface ChatSwitchProps {
|
||||
isChatMode: boolean;
|
||||
@@ -9,19 +9,26 @@ interface ChatSwitchProps {
|
||||
}
|
||||
|
||||
const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
|
||||
const modifierKeyPressed = useShortcutsStore((state) => {
|
||||
return state.modifierKeyPressed;
|
||||
});
|
||||
const modeSwitch = useShortcutsStore((state) => {
|
||||
return state.modeSwitch;
|
||||
});
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
onChange?.(!isChatMode);
|
||||
}, [onChange, isChatMode]);
|
||||
|
||||
const handleKeydown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (isMetaOrCtrlKey(event) && event.key === "t") {
|
||||
if (modifierKeyPressed && event.key === modeSwitch.toLowerCase()) {
|
||||
event.preventDefault();
|
||||
// console.log("Switch mode triggered");
|
||||
handleToggle();
|
||||
}
|
||||
},
|
||||
[handleToggle]
|
||||
[handleToggle, modifierKeyPressed, modeSwitch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
34
src/components/Common/Checkbox/index.tsx
Normal file
34
src/components/Common/Checkbox/index.tsx
Normal 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;
|
||||
36
src/components/Common/CopyButton/index.tsx
Normal file
36
src/components/Common/CopyButton/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
src/components/Common/Copyright/index.tsx
Normal file
24
src/components/Common/Copyright/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import logoLight from "@/assets/images/logo-light.png";
|
||||
import logoDark from "@/assets/images/logo-dark.png";
|
||||
|
||||
const Copyright = () => {
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
|
||||
const renderLogo = () => {
|
||||
return (
|
||||
<a href="https://coco.rs/" target="_blank">
|
||||
<img src={isDark ? logoDark : logoLight} alt="Logo" className="h-4" />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-[6px] text-xs text-[#666] dark:text-[#999]">
|
||||
Powered by
|
||||
{renderLogo()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Copyright;
|
||||
@@ -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;
|
||||
47
src/components/Common/ErrorBoundary.tsx
Normal file
47
src/components/Common/ErrorBoundary.tsx
Normal 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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user